qi-runtime-postgres 2.0.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.
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
"""PostgreSQL 上的 Session、Submission、Outbox 与 Lease 统一事实存储。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any, LiteralString
|
|
13
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
14
|
+
|
|
15
|
+
import psycopg
|
|
16
|
+
from psycopg import sql as pg_sql
|
|
17
|
+
from psycopg.rows import dict_row
|
|
18
|
+
from psycopg_pool import ConnectionPool
|
|
19
|
+
|
|
20
|
+
from qi_protocol import (
|
|
21
|
+
CommittedSessionEvent,
|
|
22
|
+
Submission,
|
|
23
|
+
SubmissionReceipt,
|
|
24
|
+
SubmissionStatus,
|
|
25
|
+
)
|
|
26
|
+
from qi_runtime_plane import ClaimedSubmission, SessionLease
|
|
27
|
+
from qi_session import Session, SessionEvent, SessionHeader, SessionProjection
|
|
28
|
+
from qi_session.store_errors import FencingTokenRejected, RuntimeStoreError, SequenceConflict
|
|
29
|
+
|
|
30
|
+
RUNTIME_STORE_SCHEMA = 1
|
|
31
|
+
|
|
32
|
+
_SCHEMA = """
|
|
33
|
+
CREATE TABLE IF NOT EXISTS runtime_schema (
|
|
34
|
+
version BIGINT PRIMARY KEY,
|
|
35
|
+
applied_at_ms BIGINT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
38
|
+
session_id TEXT PRIMARY KEY,
|
|
39
|
+
header_json TEXT NOT NULL,
|
|
40
|
+
last_sequence BIGINT NOT NULL DEFAULT -1,
|
|
41
|
+
created_at_ms BIGINT NOT NULL,
|
|
42
|
+
updated_at_ms BIGINT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
45
|
+
session_id TEXT NOT NULL,
|
|
46
|
+
sequence BIGINT NOT NULL,
|
|
47
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
48
|
+
event_type TEXT NOT NULL,
|
|
49
|
+
payload_json TEXT NOT NULL,
|
|
50
|
+
envelope_json TEXT NOT NULL,
|
|
51
|
+
recorded_at_ms BIGINT NOT NULL,
|
|
52
|
+
PRIMARY KEY(session_id, sequence),
|
|
53
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_session_events_type
|
|
56
|
+
ON session_events(session_id, event_type, sequence);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS submissions (
|
|
58
|
+
operation_id TEXT PRIMARY KEY,
|
|
59
|
+
submission_id TEXT NOT NULL UNIQUE,
|
|
60
|
+
session_id TEXT NOT NULL,
|
|
61
|
+
kind TEXT NOT NULL,
|
|
62
|
+
payload_json TEXT NOT NULL,
|
|
63
|
+
source_json TEXT NOT NULL,
|
|
64
|
+
expected_revision BIGINT,
|
|
65
|
+
status TEXT NOT NULL,
|
|
66
|
+
accepted_sequence BIGINT NOT NULL,
|
|
67
|
+
accepted_cursor BIGINT NOT NULL DEFAULT 0,
|
|
68
|
+
claimed_by TEXT,
|
|
69
|
+
claim_expires_at_ms BIGINT,
|
|
70
|
+
attempts BIGINT NOT NULL DEFAULT 0,
|
|
71
|
+
last_error TEXT,
|
|
72
|
+
created_at_ms BIGINT NOT NULL,
|
|
73
|
+
updated_at_ms BIGINT NOT NULL,
|
|
74
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_submissions_claim
|
|
77
|
+
ON submissions(status, claim_expires_at_ms, created_at_ms, operation_id);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_submissions_session
|
|
79
|
+
ON submissions(session_id, status, accepted_sequence);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS session_leases (
|
|
81
|
+
session_id TEXT PRIMARY KEY,
|
|
82
|
+
owner_id TEXT NOT NULL,
|
|
83
|
+
generation BIGINT NOT NULL,
|
|
84
|
+
expires_at_ms BIGINT NOT NULL,
|
|
85
|
+
updated_at_ms BIGINT NOT NULL,
|
|
86
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
87
|
+
);
|
|
88
|
+
CREATE TABLE IF NOT EXISTS event_outbox (
|
|
89
|
+
cursor BIGSERIAL PRIMARY KEY,
|
|
90
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
91
|
+
session_id TEXT NOT NULL,
|
|
92
|
+
sequence BIGINT NOT NULL,
|
|
93
|
+
event_type TEXT NOT NULL,
|
|
94
|
+
payload_json TEXT NOT NULL,
|
|
95
|
+
recorded_at_ms BIGINT NOT NULL,
|
|
96
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_event_outbox_session
|
|
99
|
+
ON event_outbox(session_id, cursor);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS approvals (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
session_id TEXT NOT NULL,
|
|
103
|
+
turn_id TEXT NOT NULL DEFAULT '',
|
|
104
|
+
step_id TEXT NOT NULL DEFAULT '',
|
|
105
|
+
tool_call_id TEXT NOT NULL DEFAULT '',
|
|
106
|
+
operation_id TEXT NOT NULL DEFAULT '',
|
|
107
|
+
tool_name TEXT NOT NULL,
|
|
108
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
109
|
+
revision BIGINT NOT NULL DEFAULT 0,
|
|
110
|
+
arguments_json TEXT NOT NULL DEFAULT '{}',
|
|
111
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
112
|
+
actor_json TEXT,
|
|
113
|
+
resolution_reason TEXT,
|
|
114
|
+
expires_at TEXT,
|
|
115
|
+
created_at TEXT NOT NULL,
|
|
116
|
+
resolved_at TEXT,
|
|
117
|
+
requested_sequence BIGINT NOT NULL DEFAULT -1,
|
|
118
|
+
resolved_sequence BIGINT,
|
|
119
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
120
|
+
);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_approvals_pending
|
|
122
|
+
ON approvals(status, expires_at, session_id);
|
|
123
|
+
CREATE TABLE IF NOT EXISTS session_snapshots (
|
|
124
|
+
session_id TEXT NOT NULL,
|
|
125
|
+
sequence BIGINT NOT NULL,
|
|
126
|
+
snapshot_json TEXT NOT NULL,
|
|
127
|
+
created_at_ms BIGINT NOT NULL,
|
|
128
|
+
PRIMARY KEY(session_id, sequence),
|
|
129
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
130
|
+
);
|
|
131
|
+
CREATE TABLE IF NOT EXISTS projection_offsets (
|
|
132
|
+
projector TEXT PRIMARY KEY,
|
|
133
|
+
cursor BIGINT NOT NULL,
|
|
134
|
+
updated_at_ms BIGINT NOT NULL
|
|
135
|
+
);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS session_projection (
|
|
137
|
+
session_id TEXT NOT NULL,
|
|
138
|
+
projection_key TEXT NOT NULL,
|
|
139
|
+
sequence BIGINT NOT NULL,
|
|
140
|
+
payload_json TEXT NOT NULL,
|
|
141
|
+
PRIMARY KEY(session_id, projection_key),
|
|
142
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
143
|
+
);
|
|
144
|
+
CREATE TABLE IF NOT EXISTS context_window_projection (
|
|
145
|
+
session_id TEXT NOT NULL,
|
|
146
|
+
window_id TEXT NOT NULL,
|
|
147
|
+
previous_window_id TEXT,
|
|
148
|
+
started_sequence BIGINT NOT NULL,
|
|
149
|
+
trigger TEXT NOT NULL,
|
|
150
|
+
PRIMARY KEY(session_id, window_id),
|
|
151
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
152
|
+
);
|
|
153
|
+
CREATE TABLE IF NOT EXISTS context_note_projection (
|
|
154
|
+
session_id TEXT NOT NULL,
|
|
155
|
+
path TEXT NOT NULL,
|
|
156
|
+
content TEXT NOT NULL,
|
|
157
|
+
updated_sequence BIGINT NOT NULL,
|
|
158
|
+
PRIMARY KEY(session_id, path),
|
|
159
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
160
|
+
);
|
|
161
|
+
CREATE TABLE IF NOT EXISTS context_history_projection (
|
|
162
|
+
session_id TEXT NOT NULL,
|
|
163
|
+
item_id TEXT NOT NULL,
|
|
164
|
+
window_id TEXT NOT NULL,
|
|
165
|
+
sequence BIGINT NOT NULL,
|
|
166
|
+
role TEXT NOT NULL,
|
|
167
|
+
tool_name TEXT,
|
|
168
|
+
content TEXT NOT NULL,
|
|
169
|
+
PRIMARY KEY(session_id, item_id),
|
|
170
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
171
|
+
);
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_context_history_window
|
|
173
|
+
ON context_history_projection(session_id, window_id, sequence);
|
|
174
|
+
CREATE TABLE IF NOT EXISTS user_input_requests (
|
|
175
|
+
request_id TEXT PRIMARY KEY,
|
|
176
|
+
session_id TEXT NOT NULL,
|
|
177
|
+
turn_id TEXT NOT NULL DEFAULT '',
|
|
178
|
+
prompt TEXT NOT NULL,
|
|
179
|
+
input_schema_json TEXT NOT NULL DEFAULT '{}',
|
|
180
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
181
|
+
outcome TEXT,
|
|
182
|
+
value_json TEXT,
|
|
183
|
+
actor_json TEXT,
|
|
184
|
+
resolution_reason TEXT,
|
|
185
|
+
revision BIGINT NOT NULL DEFAULT 0,
|
|
186
|
+
expires_at_ms BIGINT NOT NULL,
|
|
187
|
+
requested_sequence BIGINT NOT NULL,
|
|
188
|
+
resolved_sequence BIGINT,
|
|
189
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
190
|
+
);
|
|
191
|
+
CREATE INDEX IF NOT EXISTS idx_user_input_pending
|
|
192
|
+
ON user_input_requests(status, expires_at_ms, session_id);
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class PostgresRuntimeStore:
|
|
197
|
+
"""将事件、队列投影和 Outbox 放在同一 PostgreSQL 提交边界内。
|
|
198
|
+
|
|
199
|
+
Session sequence 是会话内顺序, Outbox cursor 是跨会话的发布位置。
|
|
200
|
+
两者不能互换; 业务投影可重建, 不能绕过事件直接修改业务终态。
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self, dsn: str, *, min_size: int = 1, max_size: int = 10, timeout: float = 30
|
|
205
|
+
) -> None:
|
|
206
|
+
self._closed = False
|
|
207
|
+
self._pool_lock = threading.Lock()
|
|
208
|
+
self._opened = False
|
|
209
|
+
self._pool: ConnectionPool[psycopg.Connection[dict[str, Any]]] = ConnectionPool(
|
|
210
|
+
dsn,
|
|
211
|
+
min_size=min_size,
|
|
212
|
+
max_size=max_size,
|
|
213
|
+
timeout=timeout,
|
|
214
|
+
kwargs={"row_factory": dict_row},
|
|
215
|
+
open=False,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def migrate(self) -> None:
|
|
219
|
+
await asyncio.to_thread(self._migrate_sync)
|
|
220
|
+
|
|
221
|
+
async def create(self, header: SessionHeader) -> None:
|
|
222
|
+
await asyncio.to_thread(self._create_sync, header)
|
|
223
|
+
|
|
224
|
+
async def append(self, session_id: str, events: Sequence[SessionEvent]) -> None:
|
|
225
|
+
if events:
|
|
226
|
+
await asyncio.to_thread(self._append_sync, session_id, tuple(events), None, None)
|
|
227
|
+
|
|
228
|
+
async def append_fenced(
|
|
229
|
+
self,
|
|
230
|
+
session_id: str,
|
|
231
|
+
events: Sequence[SessionEvent],
|
|
232
|
+
*,
|
|
233
|
+
expected_sequence: int,
|
|
234
|
+
fencing_token: int,
|
|
235
|
+
) -> tuple[CommittedSessionEvent, ...]:
|
|
236
|
+
return await asyncio.to_thread(
|
|
237
|
+
self._append_sync,
|
|
238
|
+
session_id,
|
|
239
|
+
tuple(events),
|
|
240
|
+
expected_sequence,
|
|
241
|
+
fencing_token,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def flush(self, session_id: str) -> None:
|
|
245
|
+
# append 返回前已 COMMIT; 此处没有待刷缓冲, 仍保留 Event Store 端口约定。
|
|
246
|
+
del session_id
|
|
247
|
+
|
|
248
|
+
async def load(self, session_id: str) -> tuple[SessionHeader, tuple[SessionEvent, ...]]:
|
|
249
|
+
return await asyncio.to_thread(self._load_sync, session_id)
|
|
250
|
+
|
|
251
|
+
async def inspect(self, session_id: str) -> tuple[SessionHeader, tuple[SessionEvent, ...]]:
|
|
252
|
+
return await self.load(session_id)
|
|
253
|
+
|
|
254
|
+
async def read_from(self, session_id: str, from_sequence: int) -> tuple[SessionEvent, ...]:
|
|
255
|
+
if from_sequence < 0:
|
|
256
|
+
raise ValueError("from_sequence must be non-negative")
|
|
257
|
+
return await asyncio.to_thread(self._read_range_sync, session_id, from_sequence, None, None)
|
|
258
|
+
|
|
259
|
+
async def read_before(
|
|
260
|
+
self,
|
|
261
|
+
session_id: str,
|
|
262
|
+
before_sequence: int,
|
|
263
|
+
limit: int,
|
|
264
|
+
) -> tuple[SessionEvent, ...]:
|
|
265
|
+
if before_sequence < 0 or limit < 1:
|
|
266
|
+
raise ValueError("before_sequence must be non-negative and limit positive")
|
|
267
|
+
return await asyncio.to_thread(
|
|
268
|
+
self._read_range_sync,
|
|
269
|
+
session_id,
|
|
270
|
+
None,
|
|
271
|
+
before_sequence,
|
|
272
|
+
limit,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def read_after(
|
|
276
|
+
self,
|
|
277
|
+
session_id: str,
|
|
278
|
+
after_sequence: int,
|
|
279
|
+
limit: int,
|
|
280
|
+
) -> tuple[SessionEvent, ...]:
|
|
281
|
+
if after_sequence < -1 or limit < 1:
|
|
282
|
+
raise ValueError("after_sequence must be at least -1 and limit positive")
|
|
283
|
+
return await asyncio.to_thread(
|
|
284
|
+
self._read_range_sync,
|
|
285
|
+
session_id,
|
|
286
|
+
after_sequence + 1,
|
|
287
|
+
None,
|
|
288
|
+
limit,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def read_raw(self, session_id: str) -> bytes:
|
|
292
|
+
header, events = await self.load(session_id)
|
|
293
|
+
rows = [
|
|
294
|
+
{"type": "session", **header.model_dump(mode="json")},
|
|
295
|
+
*(event.wire_dump() for event in events),
|
|
296
|
+
]
|
|
297
|
+
return b"".join(
|
|
298
|
+
json.dumps(row, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
299
|
+
for row in rows
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
async def list_headers(self) -> tuple[SessionHeader, ...]:
|
|
303
|
+
return await asyncio.to_thread(self._list_headers_sync)
|
|
304
|
+
|
|
305
|
+
async def list_projections(self, key: str) -> tuple[SessionProjection, ...]:
|
|
306
|
+
return await asyncio.to_thread(self._list_projections_sync, key)
|
|
307
|
+
|
|
308
|
+
async def search_context_history(
|
|
309
|
+
self,
|
|
310
|
+
session_id: str,
|
|
311
|
+
query: str,
|
|
312
|
+
limit: int,
|
|
313
|
+
) -> tuple[int, ...]:
|
|
314
|
+
return await asyncio.to_thread(self._search_context_history_sync, session_id, query, limit)
|
|
315
|
+
|
|
316
|
+
async def get_user_input(self, request_id: str) -> Mapping[str, Any] | None:
|
|
317
|
+
return await asyncio.to_thread(self._get_user_input_sync, request_id)
|
|
318
|
+
|
|
319
|
+
async def get_approval(self, approval_id: str) -> Mapping[str, Any] | None:
|
|
320
|
+
return await asyncio.to_thread(self._get_approval_sync, approval_id)
|
|
321
|
+
|
|
322
|
+
async def list_pending_approvals(
|
|
323
|
+
self,
|
|
324
|
+
*,
|
|
325
|
+
session_ids: Sequence[str] | None = None,
|
|
326
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
327
|
+
return await asyncio.to_thread(self._list_pending_approvals_sync, session_ids)
|
|
328
|
+
|
|
329
|
+
async def list_pending_user_inputs(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
session_ids: Sequence[str] | None = None,
|
|
333
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
334
|
+
return await asyncio.to_thread(self._list_pending_user_inputs_sync, session_ids)
|
|
335
|
+
|
|
336
|
+
async def rebuild_projections(self) -> None:
|
|
337
|
+
"""仅从事实事件重建所有本地查询投影。"""
|
|
338
|
+
|
|
339
|
+
await asyncio.to_thread(self._rebuild_projections_sync)
|
|
340
|
+
|
|
341
|
+
async def read_snapshot(self, session_id: str) -> Mapping[str, Any] | None:
|
|
342
|
+
return await asyncio.to_thread(self._read_snapshot_sync, session_id)
|
|
343
|
+
|
|
344
|
+
async def close(self) -> None:
|
|
345
|
+
self._closed = True
|
|
346
|
+
await asyncio.to_thread(self._pool.close)
|
|
347
|
+
|
|
348
|
+
async def get_receipt(
|
|
349
|
+
self,
|
|
350
|
+
operation_id: str,
|
|
351
|
+
*,
|
|
352
|
+
duplicate: bool = False,
|
|
353
|
+
) -> SubmissionReceipt | None:
|
|
354
|
+
return await asyncio.to_thread(self._get_receipt_sync, operation_id, duplicate)
|
|
355
|
+
|
|
356
|
+
async def get_submission(self, operation_id: str) -> Submission | None:
|
|
357
|
+
return await asyncio.to_thread(self._get_submission_sync, operation_id)
|
|
358
|
+
|
|
359
|
+
async def claim_next(
|
|
360
|
+
self,
|
|
361
|
+
worker_id: str,
|
|
362
|
+
*,
|
|
363
|
+
lease_seconds: float = 30,
|
|
364
|
+
) -> ClaimedSubmission | None:
|
|
365
|
+
"""按会话接纳顺序领取命令, 过期 claim 可以再次领取。
|
|
366
|
+
|
|
367
|
+
Claim 不是一次性执行保证, 也不是 Session 写权限; 后者由独立 Lease 校验。
|
|
368
|
+
重试仍需依赖 operation_id 和执行账本避免重复副作用。
|
|
369
|
+
"""
|
|
370
|
+
return await asyncio.to_thread(self._claim_next_sync, worker_id, lease_seconds)
|
|
371
|
+
|
|
372
|
+
async def release(self, operation_id: str, *, error: str | None = None) -> None:
|
|
373
|
+
await asyncio.to_thread(self._release_submission_sync, operation_id, error)
|
|
374
|
+
|
|
375
|
+
async def pending(self, session_id: str | None = None) -> tuple[Submission, ...]:
|
|
376
|
+
return await asyncio.to_thread(self._pending_sync, session_id)
|
|
377
|
+
|
|
378
|
+
async def unfinished_counts(self, session_id: str) -> tuple[int, int]:
|
|
379
|
+
return await asyncio.to_thread(self._unfinished_counts_sync, session_id)
|
|
380
|
+
|
|
381
|
+
async def acquire(
|
|
382
|
+
self,
|
|
383
|
+
session_id: str,
|
|
384
|
+
owner_id: str,
|
|
385
|
+
*,
|
|
386
|
+
ttl_seconds: float = 30,
|
|
387
|
+
) -> SessionLease | None:
|
|
388
|
+
return await asyncio.to_thread(self._acquire_sync, session_id, owner_id, ttl_seconds)
|
|
389
|
+
|
|
390
|
+
async def renew(self, lease: SessionLease, *, ttl_seconds: float = 30) -> SessionLease:
|
|
391
|
+
return await asyncio.to_thread(self._renew_sync, lease, ttl_seconds)
|
|
392
|
+
|
|
393
|
+
async def release_lease(self, lease: SessionLease) -> None:
|
|
394
|
+
await asyncio.to_thread(self._release_lease_sync, lease)
|
|
395
|
+
|
|
396
|
+
async def read_outbox(
|
|
397
|
+
self,
|
|
398
|
+
after_cursor: int,
|
|
399
|
+
*,
|
|
400
|
+
limit: int = 100,
|
|
401
|
+
) -> tuple[CommittedSessionEvent, ...]:
|
|
402
|
+
return await asyncio.to_thread(self._read_outbox_sync, after_cursor, limit)
|
|
403
|
+
|
|
404
|
+
async def latest_outbox_cursor(self) -> int:
|
|
405
|
+
return await asyncio.to_thread(self._latest_outbox_cursor_sync)
|
|
406
|
+
|
|
407
|
+
@contextmanager
|
|
408
|
+
def _connection(self) -> Iterator[psycopg.Connection[dict[str, Any]]]:
|
|
409
|
+
with self._pool_lock:
|
|
410
|
+
if self._closed:
|
|
411
|
+
raise RuntimeStoreError("runtime store is closed")
|
|
412
|
+
if not self._opened:
|
|
413
|
+
self._pool.open(wait=True)
|
|
414
|
+
self._opened = True
|
|
415
|
+
with self._pool.connection() as connection:
|
|
416
|
+
yield connection
|
|
417
|
+
|
|
418
|
+
def _migrate_sync(self) -> None:
|
|
419
|
+
with self._connection() as connection:
|
|
420
|
+
# Serialize setup across processes, including first creation of schema metadata.
|
|
421
|
+
connection.execute("SELECT pg_advisory_xact_lock(716901, 1)")
|
|
422
|
+
for statement in _SCHEMA.split(";"):
|
|
423
|
+
if statement.strip():
|
|
424
|
+
connection.execute(pg_sql.SQL(statement))
|
|
425
|
+
versions = tuple(
|
|
426
|
+
int(row["version"])
|
|
427
|
+
for row in connection.execute(
|
|
428
|
+
"SELECT version FROM runtime_schema ORDER BY version"
|
|
429
|
+
).fetchall()
|
|
430
|
+
)
|
|
431
|
+
if not versions:
|
|
432
|
+
connection.execute(
|
|
433
|
+
"INSERT INTO runtime_schema(version, applied_at_ms) VALUES (%s, %s)",
|
|
434
|
+
(RUNTIME_STORE_SCHEMA, _now_ms()),
|
|
435
|
+
)
|
|
436
|
+
elif versions != (RUNTIME_STORE_SCHEMA,):
|
|
437
|
+
raise RuntimeStoreError(f"unsupported Runtime Store schema versions: {versions}")
|
|
438
|
+
|
|
439
|
+
def _create_sync(self, header: SessionHeader) -> None:
|
|
440
|
+
now = _now_ms()
|
|
441
|
+
payload = json.dumps(header.model_dump(mode="json"), ensure_ascii=False)
|
|
442
|
+
with self._connection() as connection:
|
|
443
|
+
try:
|
|
444
|
+
connection.execute(
|
|
445
|
+
"""INSERT INTO sessions(
|
|
446
|
+
session_id, header_json, last_sequence, created_at_ms, updated_at_ms)
|
|
447
|
+
VALUES (%s, %s, -1, %s, %s)""",
|
|
448
|
+
(header.id, payload, now, now),
|
|
449
|
+
)
|
|
450
|
+
connection.commit()
|
|
451
|
+
except psycopg.IntegrityError as exc:
|
|
452
|
+
raise RuntimeStoreError(f"session already exists: {header.id}") from exc
|
|
453
|
+
|
|
454
|
+
def _append_sync(
|
|
455
|
+
self,
|
|
456
|
+
session_id: str,
|
|
457
|
+
events: tuple[SessionEvent, ...],
|
|
458
|
+
expected_sequence: int | None,
|
|
459
|
+
fencing_token: int | None,
|
|
460
|
+
) -> tuple[CommittedSessionEvent, ...]:
|
|
461
|
+
with self._connection() as connection:
|
|
462
|
+
try:
|
|
463
|
+
connection.execute("SELECT pg_advisory_xact_lock(716901, 2)")
|
|
464
|
+
row = connection.execute(
|
|
465
|
+
"SELECT last_sequence FROM sessions WHERE session_id=%s FOR UPDATE",
|
|
466
|
+
(session_id,),
|
|
467
|
+
).fetchone()
|
|
468
|
+
if row is None:
|
|
469
|
+
raise KeyError(f"unknown session: {session_id}")
|
|
470
|
+
current = int(row["last_sequence"])
|
|
471
|
+
if expected_sequence is not None and expected_sequence != current + 1:
|
|
472
|
+
raise SequenceConflict(
|
|
473
|
+
f"session {session_id!r} expected sequence {expected_sequence}, "
|
|
474
|
+
f"actual {current + 1}"
|
|
475
|
+
)
|
|
476
|
+
if fencing_token is not None:
|
|
477
|
+
self._check_fencing(connection, session_id, fencing_token)
|
|
478
|
+
committed: list[CommittedSessionEvent] = []
|
|
479
|
+
for offset, event in enumerate(events):
|
|
480
|
+
sequence = current + 1 + offset
|
|
481
|
+
if event.sequence != sequence:
|
|
482
|
+
raise SequenceConflict(
|
|
483
|
+
f"event sequence {event.sequence} does not follow durable {current}"
|
|
484
|
+
)
|
|
485
|
+
committed.append(self._insert_event(connection, session_id, event))
|
|
486
|
+
if events:
|
|
487
|
+
connection.execute(
|
|
488
|
+
"UPDATE sessions SET last_sequence=%s, updated_at_ms=%s WHERE "
|
|
489
|
+
"session_id=%s",
|
|
490
|
+
(events[-1].sequence, _now_ms(), session_id),
|
|
491
|
+
)
|
|
492
|
+
connection.commit()
|
|
493
|
+
return tuple(committed)
|
|
494
|
+
except BaseException:
|
|
495
|
+
connection.rollback()
|
|
496
|
+
raise
|
|
497
|
+
|
|
498
|
+
def _insert_event(
|
|
499
|
+
self,
|
|
500
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
501
|
+
session_id: str,
|
|
502
|
+
event: SessionEvent,
|
|
503
|
+
) -> CommittedSessionEvent:
|
|
504
|
+
event_id = _event_id(session_id, event.sequence)
|
|
505
|
+
envelope = event.wire_dump()
|
|
506
|
+
payload_json = json.dumps(dict(event.data), ensure_ascii=False, separators=(",", ":"))
|
|
507
|
+
connection.execute(
|
|
508
|
+
"""INSERT INTO session_events(
|
|
509
|
+
session_id, sequence, event_id, event_type, payload_json,
|
|
510
|
+
envelope_json, recorded_at_ms)
|
|
511
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
|
512
|
+
(
|
|
513
|
+
session_id,
|
|
514
|
+
event.sequence,
|
|
515
|
+
event_id,
|
|
516
|
+
event.type,
|
|
517
|
+
payload_json,
|
|
518
|
+
json.dumps(envelope, ensure_ascii=False, separators=(",", ":")),
|
|
519
|
+
event.time,
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
cursor = connection.execute(
|
|
523
|
+
"""INSERT INTO event_outbox(
|
|
524
|
+
event_id, session_id, sequence, event_type, payload_json, recorded_at_ms)
|
|
525
|
+
VALUES (%s, %s, %s, %s, %s, %s) RETURNING cursor""",
|
|
526
|
+
(event_id, session_id, event.sequence, event.type, payload_json, event.time),
|
|
527
|
+
).fetchone()
|
|
528
|
+
assert cursor is not None
|
|
529
|
+
committed = CommittedSessionEvent(
|
|
530
|
+
eventId=event_id,
|
|
531
|
+
sessionId=session_id,
|
|
532
|
+
sequence=event.sequence,
|
|
533
|
+
eventType=event.type,
|
|
534
|
+
payload=dict(event.data),
|
|
535
|
+
recordedAtMs=event.time,
|
|
536
|
+
durableCursor=int(cursor["cursor"]),
|
|
537
|
+
)
|
|
538
|
+
self._project_event(
|
|
539
|
+
connection,
|
|
540
|
+
session_id,
|
|
541
|
+
event,
|
|
542
|
+
durable_cursor=committed.durable_cursor,
|
|
543
|
+
)
|
|
544
|
+
return committed
|
|
545
|
+
|
|
546
|
+
def _project_submission(
|
|
547
|
+
self,
|
|
548
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
549
|
+
session_id: str,
|
|
550
|
+
event: SessionEvent,
|
|
551
|
+
durable_cursor: int,
|
|
552
|
+
) -> None:
|
|
553
|
+
"""在 Event 与 Outbox 相同事务内维护可重建 Submission 投影。"""
|
|
554
|
+
|
|
555
|
+
data = event.data
|
|
556
|
+
operation_id = str(data["operation_id"])
|
|
557
|
+
submission_id = str(data["submission_id"])
|
|
558
|
+
source = data.get("source", {})
|
|
559
|
+
payload = data.get("payload", {})
|
|
560
|
+
connection.execute(
|
|
561
|
+
"""INSERT INTO submissions(
|
|
562
|
+
operation_id, submission_id, session_id, kind, payload_json,
|
|
563
|
+
source_json, expected_revision, status, accepted_sequence,
|
|
564
|
+
accepted_cursor, created_at_ms, updated_at_ms)
|
|
565
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s)""",
|
|
566
|
+
(
|
|
567
|
+
operation_id,
|
|
568
|
+
submission_id,
|
|
569
|
+
session_id,
|
|
570
|
+
str(data["kind"]),
|
|
571
|
+
json.dumps(payload, ensure_ascii=False),
|
|
572
|
+
json.dumps(source, ensure_ascii=False),
|
|
573
|
+
data.get("expected_revision"),
|
|
574
|
+
event.sequence,
|
|
575
|
+
durable_cursor,
|
|
576
|
+
event.time,
|
|
577
|
+
event.time,
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def _load_sync(self, session_id: str) -> tuple[SessionHeader, tuple[SessionEvent, ...]]:
|
|
582
|
+
with self._connection() as connection:
|
|
583
|
+
row = connection.execute(
|
|
584
|
+
"SELECT header_json FROM sessions WHERE session_id=%s",
|
|
585
|
+
(session_id,),
|
|
586
|
+
).fetchone()
|
|
587
|
+
if row is None:
|
|
588
|
+
raise KeyError(f"unknown session: {session_id}")
|
|
589
|
+
header = SessionHeader.model_validate_json(str(row["header_json"]))
|
|
590
|
+
events = tuple(
|
|
591
|
+
_event_from_json(session_id, str(item["envelope_json"]))
|
|
592
|
+
for item in connection.execute(
|
|
593
|
+
"""SELECT envelope_json FROM session_events
|
|
594
|
+
WHERE session_id=%s ORDER BY sequence""",
|
|
595
|
+
(session_id,),
|
|
596
|
+
).fetchall()
|
|
597
|
+
)
|
|
598
|
+
validated = Session(header, events)
|
|
599
|
+
return header, validated.events
|
|
600
|
+
|
|
601
|
+
def _read_range_sync(
|
|
602
|
+
self,
|
|
603
|
+
session_id: str,
|
|
604
|
+
from_sequence: int | None,
|
|
605
|
+
before_sequence: int | None,
|
|
606
|
+
limit: int | None,
|
|
607
|
+
) -> tuple[SessionEvent, ...]:
|
|
608
|
+
clauses: list[LiteralString] = ["session_id=%s"]
|
|
609
|
+
values: list[Any] = [session_id]
|
|
610
|
+
if from_sequence is not None:
|
|
611
|
+
clauses.append("sequence>=%s")
|
|
612
|
+
values.append(from_sequence)
|
|
613
|
+
if before_sequence is not None:
|
|
614
|
+
clauses.append("sequence<%s")
|
|
615
|
+
values.append(before_sequence)
|
|
616
|
+
order = "DESC" if before_sequence is not None else "ASC"
|
|
617
|
+
sql = "SELECT envelope_json FROM session_events WHERE " + " AND ".join(clauses)
|
|
618
|
+
sql += f" ORDER BY sequence {order}"
|
|
619
|
+
if limit is not None:
|
|
620
|
+
sql += " LIMIT %s"
|
|
621
|
+
values.append(limit)
|
|
622
|
+
with self._connection() as connection:
|
|
623
|
+
rows = connection.execute(pg_sql.SQL(sql), tuple(values)).fetchall()
|
|
624
|
+
events = tuple(_event_from_json(session_id, str(row["envelope_json"])) for row in rows)
|
|
625
|
+
return tuple(reversed(events)) if before_sequence is not None else events
|
|
626
|
+
|
|
627
|
+
def _list_headers_sync(self) -> tuple[SessionHeader, ...]:
|
|
628
|
+
with self._connection() as connection:
|
|
629
|
+
rows = connection.execute(
|
|
630
|
+
"SELECT header_json FROM sessions ORDER BY created_at_ms DESC, session_id"
|
|
631
|
+
).fetchall()
|
|
632
|
+
return tuple(SessionHeader.model_validate_json(str(row["header_json"])) for row in rows)
|
|
633
|
+
|
|
634
|
+
def _search_context_history_sync(
|
|
635
|
+
self,
|
|
636
|
+
session_id: str,
|
|
637
|
+
query: str,
|
|
638
|
+
limit: int,
|
|
639
|
+
) -> tuple[int, ...]:
|
|
640
|
+
with self._connection() as connection:
|
|
641
|
+
rows = connection.execute(
|
|
642
|
+
"""SELECT sequence FROM context_history_projection
|
|
643
|
+
WHERE session_id=%s AND strpos(content, %s) > 0
|
|
644
|
+
ORDER BY sequence DESC LIMIT %s""",
|
|
645
|
+
(session_id, query, max(1, limit)),
|
|
646
|
+
).fetchall()
|
|
647
|
+
return tuple(int(row["sequence"]) for row in rows)
|
|
648
|
+
|
|
649
|
+
def _get_user_input_sync(self, request_id: str) -> Mapping[str, Any] | None:
|
|
650
|
+
with self._connection() as connection:
|
|
651
|
+
row = connection.execute(
|
|
652
|
+
"SELECT * FROM user_input_requests WHERE request_id=%s",
|
|
653
|
+
(request_id,),
|
|
654
|
+
).fetchone()
|
|
655
|
+
return None if row is None else _public_user_input(dict(row))
|
|
656
|
+
|
|
657
|
+
def _get_approval_sync(self, approval_id: str) -> Mapping[str, Any] | None:
|
|
658
|
+
with self._connection() as connection:
|
|
659
|
+
row = connection.execute(
|
|
660
|
+
"SELECT * FROM approvals WHERE id=%s",
|
|
661
|
+
(approval_id,),
|
|
662
|
+
).fetchone()
|
|
663
|
+
return None if row is None else _public_approval(dict(row))
|
|
664
|
+
|
|
665
|
+
def _list_pending_approvals_sync(
|
|
666
|
+
self,
|
|
667
|
+
session_ids: Sequence[str] | None,
|
|
668
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
669
|
+
sql = "SELECT * FROM approvals WHERE status='pending'"
|
|
670
|
+
parameters: tuple[Any, ...] = ()
|
|
671
|
+
if session_ids is not None:
|
|
672
|
+
if not session_ids:
|
|
673
|
+
return ()
|
|
674
|
+
placeholders = ",".join("%s" for _ in session_ids)
|
|
675
|
+
sql += f" AND session_id IN ({placeholders})"
|
|
676
|
+
parameters = tuple(session_ids)
|
|
677
|
+
sql += " ORDER BY requested_sequence, id"
|
|
678
|
+
with self._connection() as connection:
|
|
679
|
+
rows = connection.execute(pg_sql.SQL(sql), parameters).fetchall()
|
|
680
|
+
return tuple(_public_approval(dict(row)) for row in rows)
|
|
681
|
+
|
|
682
|
+
def _list_pending_user_inputs_sync(
|
|
683
|
+
self,
|
|
684
|
+
session_ids: Sequence[str] | None,
|
|
685
|
+
) -> tuple[Mapping[str, Any], ...]:
|
|
686
|
+
sql = "SELECT * FROM user_input_requests WHERE status='pending'"
|
|
687
|
+
parameters: tuple[Any, ...] = ()
|
|
688
|
+
if session_ids is not None:
|
|
689
|
+
if not session_ids:
|
|
690
|
+
return ()
|
|
691
|
+
placeholders = ",".join("%s" for _ in session_ids)
|
|
692
|
+
sql += f" AND session_id IN ({placeholders})"
|
|
693
|
+
parameters = tuple(session_ids)
|
|
694
|
+
sql += " ORDER BY requested_sequence, request_id"
|
|
695
|
+
with self._connection() as connection:
|
|
696
|
+
rows = connection.execute(pg_sql.SQL(sql), parameters).fetchall()
|
|
697
|
+
return tuple(_public_user_input(dict(row)) for row in rows)
|
|
698
|
+
|
|
699
|
+
def _list_projections_sync(self, key: str) -> tuple[SessionProjection, ...]:
|
|
700
|
+
with self._connection() as connection:
|
|
701
|
+
rows = connection.execute(
|
|
702
|
+
"""SELECT session_id, sequence, payload_json
|
|
703
|
+
FROM session_projection
|
|
704
|
+
WHERE projection_key=%s ORDER BY session_id""",
|
|
705
|
+
(key,),
|
|
706
|
+
).fetchall()
|
|
707
|
+
return tuple(
|
|
708
|
+
SessionProjection(
|
|
709
|
+
session_id=str(row["session_id"]),
|
|
710
|
+
key=key,
|
|
711
|
+
sequence=int(row["sequence"]),
|
|
712
|
+
value=json.loads(str(row["payload_json"])),
|
|
713
|
+
)
|
|
714
|
+
for row in rows
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def _read_snapshot_sync(self, session_id: str) -> Mapping[str, Any] | None:
|
|
718
|
+
with self._connection() as connection:
|
|
719
|
+
row = connection.execute(
|
|
720
|
+
"""SELECT snapshot_json FROM session_snapshots
|
|
721
|
+
WHERE session_id=%s ORDER BY sequence DESC LIMIT 1""",
|
|
722
|
+
(session_id,),
|
|
723
|
+
).fetchone()
|
|
724
|
+
return None if row is None else json.loads(str(row["snapshot_json"]))
|
|
725
|
+
|
|
726
|
+
def _project_event(
|
|
727
|
+
self,
|
|
728
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
729
|
+
session_id: str,
|
|
730
|
+
event: SessionEvent,
|
|
731
|
+
*,
|
|
732
|
+
durable_cursor: int,
|
|
733
|
+
) -> None:
|
|
734
|
+
"""在事实事件事务内更新可删除投影, 避免出现先发布后投影的窗口。"""
|
|
735
|
+
|
|
736
|
+
self._project_submission_event(connection, session_id, event, durable_cursor)
|
|
737
|
+
self._project_subagent(connection, session_id, event)
|
|
738
|
+
self._project_channel(connection, session_id, event)
|
|
739
|
+
self._project_context(connection, session_id, event)
|
|
740
|
+
self._project_user_input(connection, session_id, event)
|
|
741
|
+
self._project_approval(connection, session_id, event)
|
|
742
|
+
self._project_snapshot(connection, session_id, event, durable_cursor)
|
|
743
|
+
connection.execute(
|
|
744
|
+
"""INSERT INTO projection_offsets(projector, cursor, updated_at_ms)
|
|
745
|
+
VALUES ('runtime-inline', %s, %s)
|
|
746
|
+
ON CONFLICT(projector) DO UPDATE SET
|
|
747
|
+
cursor=excluded.cursor,
|
|
748
|
+
updated_at_ms=excluded.updated_at_ms""",
|
|
749
|
+
(durable_cursor, _now_ms()),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
def _project_submission_event(
|
|
753
|
+
self,
|
|
754
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
755
|
+
session_id: str,
|
|
756
|
+
event: SessionEvent,
|
|
757
|
+
durable_cursor: int,
|
|
758
|
+
) -> None:
|
|
759
|
+
if event.type == "submission/accepted":
|
|
760
|
+
self._project_submission(connection, session_id, event, durable_cursor)
|
|
761
|
+
return
|
|
762
|
+
if event.type not in {"submission/completed", "submission/failed"}:
|
|
763
|
+
return
|
|
764
|
+
operation_id = str(event.data.get("operation_id", ""))
|
|
765
|
+
status = "completed" if event.type == "submission/completed" else "failed"
|
|
766
|
+
changed = connection.execute(
|
|
767
|
+
"""UPDATE submissions SET status=%s, claimed_by=NULL,
|
|
768
|
+
claim_expires_at_ms=NULL, last_error=%s, updated_at_ms=%s
|
|
769
|
+
WHERE operation_id=%s AND session_id=%s""",
|
|
770
|
+
(
|
|
771
|
+
status,
|
|
772
|
+
event.data.get("error") if status == "failed" else None,
|
|
773
|
+
event.time,
|
|
774
|
+
operation_id,
|
|
775
|
+
session_id,
|
|
776
|
+
),
|
|
777
|
+
).rowcount
|
|
778
|
+
if changed != 1:
|
|
779
|
+
raise RuntimeStoreError(
|
|
780
|
+
f"submission terminal event references unknown operation {operation_id!r}"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def _project_user_input(
|
|
784
|
+
self,
|
|
785
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
786
|
+
session_id: str,
|
|
787
|
+
event: SessionEvent,
|
|
788
|
+
) -> None:
|
|
789
|
+
data = dict(event.data)
|
|
790
|
+
if event.type == "user-input/requested":
|
|
791
|
+
connection.execute(
|
|
792
|
+
"""INSERT INTO user_input_requests(
|
|
793
|
+
request_id, session_id, turn_id, prompt, input_schema_json,
|
|
794
|
+
expires_at_ms, requested_sequence)
|
|
795
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING""",
|
|
796
|
+
(
|
|
797
|
+
str(data["request_id"]),
|
|
798
|
+
session_id,
|
|
799
|
+
str(data.get("turn_id") or event.turn_id or ""),
|
|
800
|
+
str(data.get("prompt", "")),
|
|
801
|
+
json.dumps(data.get("input_schema", {}), ensure_ascii=False),
|
|
802
|
+
int(data["expires_at_ms"]),
|
|
803
|
+
event.sequence,
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
return
|
|
807
|
+
if event.type != "user-input/resolved":
|
|
808
|
+
return
|
|
809
|
+
request_id = str(data["request_id"])
|
|
810
|
+
expected_revision = int(data.get("expected_revision", 0))
|
|
811
|
+
changed = connection.execute(
|
|
812
|
+
"""UPDATE user_input_requests
|
|
813
|
+
SET status='resolved', outcome=%s, value_json=%s, actor_json=%s,
|
|
814
|
+
resolution_reason=%s, revision=revision+1, resolved_sequence=%s
|
|
815
|
+
WHERE request_id=%s AND session_id=%s AND status='pending' AND revision=%s""",
|
|
816
|
+
(
|
|
817
|
+
str(data["outcome"]),
|
|
818
|
+
json.dumps(data.get("value"), ensure_ascii=False),
|
|
819
|
+
json.dumps(data.get("actor", {}), ensure_ascii=False),
|
|
820
|
+
data.get("reason"),
|
|
821
|
+
event.sequence,
|
|
822
|
+
request_id,
|
|
823
|
+
session_id,
|
|
824
|
+
expected_revision,
|
|
825
|
+
),
|
|
826
|
+
).rowcount
|
|
827
|
+
if changed != 1:
|
|
828
|
+
raise RuntimeStoreError(
|
|
829
|
+
f"user input {request_id!r} was resolved concurrently or is missing"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
def _project_approval(
|
|
833
|
+
self,
|
|
834
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
835
|
+
session_id: str,
|
|
836
|
+
event: SessionEvent,
|
|
837
|
+
) -> None:
|
|
838
|
+
data = dict(event.data)
|
|
839
|
+
if event.type == "approval/requested":
|
|
840
|
+
timeout_seconds = max(0.0, float(data.get("timeout_seconds", 300)))
|
|
841
|
+
expires_at = _iso_time(event.time + int(timeout_seconds * 1000))
|
|
842
|
+
connection.execute(
|
|
843
|
+
"""INSERT INTO approvals(
|
|
844
|
+
id, session_id, turn_id, step_id, tool_call_id, operation_id,
|
|
845
|
+
tool_name, status, revision, arguments_json, reason, expires_at,
|
|
846
|
+
created_at, requested_sequence)
|
|
847
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', 0, %s, %s, %s, %s, %s) ON
|
|
848
|
+
CONFLICT DO NOTHING""",
|
|
849
|
+
(
|
|
850
|
+
str(data["approval_id"]),
|
|
851
|
+
session_id,
|
|
852
|
+
str(data.get("turn_id") or event.turn_id or ""),
|
|
853
|
+
str(data.get("step_id") or event.step_id or ""),
|
|
854
|
+
str(data.get("tool_call_id", "")),
|
|
855
|
+
str(data.get("operation_id", "")),
|
|
856
|
+
str(data.get("tool_name", "")),
|
|
857
|
+
json.dumps(data.get("arguments_summary", {}), ensure_ascii=False),
|
|
858
|
+
str(data.get("reason") or "approval required"),
|
|
859
|
+
expires_at,
|
|
860
|
+
_iso_time(event.time),
|
|
861
|
+
event.sequence,
|
|
862
|
+
),
|
|
863
|
+
)
|
|
864
|
+
return
|
|
865
|
+
if event.type != "approval/resolved":
|
|
866
|
+
return
|
|
867
|
+
approval_id = str(data["approval_id"])
|
|
868
|
+
expected_revision = int(data.get("expected_revision", 0))
|
|
869
|
+
changed = connection.execute(
|
|
870
|
+
"""UPDATE approvals
|
|
871
|
+
SET status=%s, revision=revision+1, actor_json=%s, resolution_reason=%s,
|
|
872
|
+
resolved_at=%s, resolved_sequence=%s
|
|
873
|
+
WHERE id=%s AND session_id=%s AND status='pending' AND revision=%s""",
|
|
874
|
+
(
|
|
875
|
+
str(data.get("resolution", "rejected")),
|
|
876
|
+
json.dumps(data.get("actor", {}), ensure_ascii=False),
|
|
877
|
+
data.get("reason"),
|
|
878
|
+
_iso_time(event.time),
|
|
879
|
+
event.sequence,
|
|
880
|
+
approval_id,
|
|
881
|
+
session_id,
|
|
882
|
+
expected_revision,
|
|
883
|
+
),
|
|
884
|
+
).rowcount
|
|
885
|
+
if changed != 1:
|
|
886
|
+
raise RuntimeStoreError(
|
|
887
|
+
f"approval {approval_id!r} was resolved concurrently or is missing"
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
def _project_snapshot(
|
|
891
|
+
self,
|
|
892
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
893
|
+
session_id: str,
|
|
894
|
+
event: SessionEvent,
|
|
895
|
+
durable_cursor: int,
|
|
896
|
+
) -> None:
|
|
897
|
+
"""维护不含消息正文的最新运行态快照。
|
|
898
|
+
|
|
899
|
+
快照只用于冷启动候选过滤和诊断。恢复命中后仍会重放完整 Event Log,
|
|
900
|
+
因此投影被删除或损坏不会改变 Session 的业务语义。
|
|
901
|
+
"""
|
|
902
|
+
|
|
903
|
+
row = connection.execute(
|
|
904
|
+
"""SELECT snapshot_json FROM session_snapshots
|
|
905
|
+
WHERE session_id=%s ORDER BY sequence DESC LIMIT 1""",
|
|
906
|
+
(session_id,),
|
|
907
|
+
).fetchone()
|
|
908
|
+
snapshot: dict[str, Any] = (
|
|
909
|
+
json.loads(str(row["snapshot_json"]))
|
|
910
|
+
if row is not None
|
|
911
|
+
else {
|
|
912
|
+
"schemaVersion": 1,
|
|
913
|
+
"activeTurnId": None,
|
|
914
|
+
"activeStepId": None,
|
|
915
|
+
"driverState": "idle",
|
|
916
|
+
"inbox": {"nextTurn": [], "nextStep": []},
|
|
917
|
+
"hasInboxSplices": False,
|
|
918
|
+
"pendingApprovalIds": [],
|
|
919
|
+
"pendingUserInputIds": [],
|
|
920
|
+
"contextWindowId": None,
|
|
921
|
+
}
|
|
922
|
+
)
|
|
923
|
+
data = dict(event.data)
|
|
924
|
+
event_type = event.type
|
|
925
|
+
|
|
926
|
+
inbox = snapshot.setdefault("inbox", {"nextTurn": [], "nextStep": []})
|
|
927
|
+
if not isinstance(inbox, dict):
|
|
928
|
+
inbox = {"nextTurn": [], "nextStep": []}
|
|
929
|
+
snapshot["inbox"] = inbox
|
|
930
|
+
if event_type == "agent/inbox/spliced":
|
|
931
|
+
target = str(data.get("target", ""))
|
|
932
|
+
key = "nextTurn" if target == "next-turn" else "nextStep"
|
|
933
|
+
queue = inbox.setdefault(key, [])
|
|
934
|
+
if not isinstance(queue, list):
|
|
935
|
+
queue = []
|
|
936
|
+
inbox[key] = queue
|
|
937
|
+
index = int(data.get("index", len(queue)))
|
|
938
|
+
delete_count = int(data.get("delete_count", 0))
|
|
939
|
+
inserted = data.get("inserted", [])
|
|
940
|
+
values = (
|
|
941
|
+
[
|
|
942
|
+
{
|
|
943
|
+
"inputId": str(item.get("input_id", "")),
|
|
944
|
+
"wakeup": bool(item.get("wakeup", True)),
|
|
945
|
+
}
|
|
946
|
+
for item in inserted
|
|
947
|
+
if isinstance(item, Mapping) and item.get("input_id")
|
|
948
|
+
]
|
|
949
|
+
if isinstance(inserted, list)
|
|
950
|
+
else []
|
|
951
|
+
)
|
|
952
|
+
if 0 <= index <= len(queue) and 0 <= delete_count <= len(queue) - index:
|
|
953
|
+
queue[index : index + delete_count] = values
|
|
954
|
+
snapshot["hasInboxSplices"] = True
|
|
955
|
+
elif not bool(snapshot.get("hasInboxSplices")):
|
|
956
|
+
if event_type == "input/enqueued":
|
|
957
|
+
target = str(data.get("target", "next-step"))
|
|
958
|
+
key = "nextTurn" if target == "next-turn" else "nextStep"
|
|
959
|
+
queue = inbox.setdefault(key, [])
|
|
960
|
+
if isinstance(queue, list):
|
|
961
|
+
queue.append(
|
|
962
|
+
{
|
|
963
|
+
"inputId": str(data.get("input_id", "")),
|
|
964
|
+
"wakeup": bool(data.get("wakeup", True)),
|
|
965
|
+
}
|
|
966
|
+
)
|
|
967
|
+
elif event_type in {"input/entered", "input/discarded"}:
|
|
968
|
+
input_id = str(data.get("input_id", ""))
|
|
969
|
+
for key in ("nextTurn", "nextStep"):
|
|
970
|
+
queue = inbox.get(key)
|
|
971
|
+
if isinstance(queue, list):
|
|
972
|
+
inbox[key] = [
|
|
973
|
+
item
|
|
974
|
+
for item in queue
|
|
975
|
+
if not isinstance(item, Mapping) or item.get("inputId") != input_id
|
|
976
|
+
]
|
|
977
|
+
|
|
978
|
+
if event_type == "turn/start":
|
|
979
|
+
snapshot["activeTurnId"] = event.turn_id
|
|
980
|
+
snapshot["driverState"] = "turn_started"
|
|
981
|
+
elif event_type == "step/start":
|
|
982
|
+
snapshot["activeStepId"] = event.step_id
|
|
983
|
+
snapshot["driverState"] = "step_preparing"
|
|
984
|
+
elif event_type == "request/context":
|
|
985
|
+
snapshot["driverState"] = "model_streaming"
|
|
986
|
+
elif event_type == "tool/call":
|
|
987
|
+
snapshot["driverState"] = "tool_running"
|
|
988
|
+
elif event_type == "approval/requested":
|
|
989
|
+
approval_id = str(data.get("approval_id", ""))
|
|
990
|
+
pending = snapshot.setdefault("pendingApprovalIds", [])
|
|
991
|
+
if approval_id and isinstance(pending, list) and approval_id not in pending:
|
|
992
|
+
pending.append(approval_id)
|
|
993
|
+
snapshot["driverState"] = "approval_waiting"
|
|
994
|
+
elif event_type == "approval/resolved":
|
|
995
|
+
approval_id = str(data.get("approval_id", ""))
|
|
996
|
+
pending = snapshot.get("pendingApprovalIds")
|
|
997
|
+
if isinstance(pending, list):
|
|
998
|
+
snapshot["pendingApprovalIds"] = [item for item in pending if item != approval_id]
|
|
999
|
+
snapshot["driverState"] = "tool_running"
|
|
1000
|
+
elif event_type == "user-input/requested":
|
|
1001
|
+
request_id = str(data.get("request_id", ""))
|
|
1002
|
+
pending = snapshot.setdefault("pendingUserInputIds", [])
|
|
1003
|
+
if request_id and isinstance(pending, list) and request_id not in pending:
|
|
1004
|
+
pending.append(request_id)
|
|
1005
|
+
elif event_type == "user-input/resolved":
|
|
1006
|
+
request_id = str(data.get("request_id", ""))
|
|
1007
|
+
pending = snapshot.get("pendingUserInputIds")
|
|
1008
|
+
if isinstance(pending, list):
|
|
1009
|
+
snapshot["pendingUserInputIds"] = [item for item in pending if item != request_id]
|
|
1010
|
+
elif event_type == "step/end":
|
|
1011
|
+
snapshot["activeStepId"] = None
|
|
1012
|
+
snapshot["driverState"] = "step_completed"
|
|
1013
|
+
elif event_type == "turn/end":
|
|
1014
|
+
if snapshot.get("activeTurnId") == event.turn_id:
|
|
1015
|
+
snapshot["activeTurnId"] = None
|
|
1016
|
+
snapshot["activeStepId"] = None
|
|
1017
|
+
snapshot["driverState"] = "turn_completed"
|
|
1018
|
+
elif event_type == "execution/recovery" and data.get("attention_required"):
|
|
1019
|
+
snapshot["driverState"] = "attention_required"
|
|
1020
|
+
elif event_type == "context/window-started":
|
|
1021
|
+
snapshot["contextWindowId"] = data.get("windowId")
|
|
1022
|
+
|
|
1023
|
+
snapshot["sequence"] = event.sequence
|
|
1024
|
+
snapshot["durableCursor"] = durable_cursor
|
|
1025
|
+
snapshot["lastEventType"] = event_type
|
|
1026
|
+
connection.execute("DELETE FROM session_snapshots WHERE session_id=%s", (session_id,))
|
|
1027
|
+
connection.execute(
|
|
1028
|
+
"""INSERT INTO session_snapshots(session_id, sequence, snapshot_json, created_at_ms)
|
|
1029
|
+
VALUES (%s, %s, %s, %s)""",
|
|
1030
|
+
(
|
|
1031
|
+
session_id,
|
|
1032
|
+
event.sequence,
|
|
1033
|
+
json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")),
|
|
1034
|
+
event.time,
|
|
1035
|
+
),
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
def _project_subagent(
|
|
1039
|
+
self,
|
|
1040
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
1041
|
+
session_id: str,
|
|
1042
|
+
event: SessionEvent,
|
|
1043
|
+
) -> None:
|
|
1044
|
+
if event.type == "subagent/descriptor":
|
|
1045
|
+
header_row = connection.execute(
|
|
1046
|
+
"SELECT header_json FROM sessions WHERE session_id=%s",
|
|
1047
|
+
(session_id,),
|
|
1048
|
+
).fetchone()
|
|
1049
|
+
header = json.loads(str(header_row["header_json"])) if header_row is not None else {}
|
|
1050
|
+
payload = {
|
|
1051
|
+
**dict(event.data),
|
|
1052
|
+
"session_id": session_id,
|
|
1053
|
+
"parent_session_id": header.get("parent_session_id"),
|
|
1054
|
+
"status": "ready",
|
|
1055
|
+
}
|
|
1056
|
+
elif event.type in {
|
|
1057
|
+
"subagent/activation-started",
|
|
1058
|
+
"subagent/activation-ended",
|
|
1059
|
+
"subagent/settled",
|
|
1060
|
+
"subagent/ended",
|
|
1061
|
+
"subagent/failed",
|
|
1062
|
+
}:
|
|
1063
|
+
existing = self._projection_payload(connection, session_id, "subagentIdentity")
|
|
1064
|
+
if existing is None:
|
|
1065
|
+
return
|
|
1066
|
+
payload = dict(existing)
|
|
1067
|
+
payload["status"] = str(
|
|
1068
|
+
event.data.get("activity")
|
|
1069
|
+
or event.data.get("status")
|
|
1070
|
+
or event.type.removeprefix("subagent/")
|
|
1071
|
+
)
|
|
1072
|
+
else:
|
|
1073
|
+
return
|
|
1074
|
+
self._upsert_projection(connection, session_id, "subagentIdentity", event.sequence, payload)
|
|
1075
|
+
|
|
1076
|
+
def _project_channel(
|
|
1077
|
+
self,
|
|
1078
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
1079
|
+
session_id: str,
|
|
1080
|
+
event: SessionEvent,
|
|
1081
|
+
) -> None:
|
|
1082
|
+
if not event.type.startswith("channel/") and event.type != "input/enqueued":
|
|
1083
|
+
return
|
|
1084
|
+
payload = self._projection_payload(connection, session_id, "channelState") or {
|
|
1085
|
+
"bindings": {},
|
|
1086
|
+
"inbounds": {},
|
|
1087
|
+
"turn_routes": {},
|
|
1088
|
+
"pending_deliveries": {},
|
|
1089
|
+
"completed_deliveries": [],
|
|
1090
|
+
}
|
|
1091
|
+
data = dict(event.data)
|
|
1092
|
+
changed = False
|
|
1093
|
+
if event.type in {"channel/bound", "channel/unbound"}:
|
|
1094
|
+
address = _channel_address_key(data)
|
|
1095
|
+
if address is not None:
|
|
1096
|
+
if event.type == "channel/bound":
|
|
1097
|
+
payload["bindings"][address] = data
|
|
1098
|
+
else:
|
|
1099
|
+
payload["bindings"].pop(address, None)
|
|
1100
|
+
changed = True
|
|
1101
|
+
elif event.type == "channel/inbound":
|
|
1102
|
+
inbound = _channel_inbound_key(data)
|
|
1103
|
+
if inbound is not None:
|
|
1104
|
+
payload["inbounds"][inbound] = data
|
|
1105
|
+
changed = True
|
|
1106
|
+
elif event.type == "input/enqueued":
|
|
1107
|
+
source = data.get("message_source")
|
|
1108
|
+
if event.turn_id is not None and isinstance(source, Mapping):
|
|
1109
|
+
address = _channel_address_key(source)
|
|
1110
|
+
if address is not None:
|
|
1111
|
+
payload["turn_routes"][event.turn_id] = dict(source)
|
|
1112
|
+
changed = True
|
|
1113
|
+
elif event.type == "channel/delivery/queued":
|
|
1114
|
+
delivery_id = str(data.get("delivery_id", ""))
|
|
1115
|
+
if delivery_id:
|
|
1116
|
+
payload["pending_deliveries"][delivery_id] = data
|
|
1117
|
+
changed = True
|
|
1118
|
+
elif event.type in {"channel/delivery/succeeded", "channel/delivery/failed"}:
|
|
1119
|
+
delivery_id = str(data.get("delivery_id", ""))
|
|
1120
|
+
if delivery_id:
|
|
1121
|
+
payload["pending_deliveries"].pop(delivery_id, None)
|
|
1122
|
+
completed = payload["completed_deliveries"]
|
|
1123
|
+
if delivery_id not in completed:
|
|
1124
|
+
completed.append(delivery_id)
|
|
1125
|
+
changed = True
|
|
1126
|
+
if changed:
|
|
1127
|
+
self._upsert_projection(connection, session_id, "channelState", event.sequence, payload)
|
|
1128
|
+
|
|
1129
|
+
def _project_context(
|
|
1130
|
+
self,
|
|
1131
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
1132
|
+
session_id: str,
|
|
1133
|
+
event: SessionEvent,
|
|
1134
|
+
) -> None:
|
|
1135
|
+
row = connection.execute(
|
|
1136
|
+
"""SELECT window_id FROM context_window_projection
|
|
1137
|
+
WHERE session_id=%s ORDER BY started_sequence DESC LIMIT 1""",
|
|
1138
|
+
(session_id,),
|
|
1139
|
+
).fetchone()
|
|
1140
|
+
current_window = str(row["window_id"]) if row is not None else f"legacy-{session_id}"
|
|
1141
|
+
data = dict(event.data)
|
|
1142
|
+
if event.type == "context/window-started":
|
|
1143
|
+
current_window = str(data.get("windowId", current_window))
|
|
1144
|
+
connection.execute(
|
|
1145
|
+
"""INSERT INTO context_window_projection(
|
|
1146
|
+
session_id, window_id, previous_window_id, started_sequence, trigger)
|
|
1147
|
+
VALUES (%s, %s, %s, %s, %s) ON CONFLICT (session_id, window_id) DO UPDATE SET
|
|
1148
|
+
previous_window_id=excluded.previous_window_id,
|
|
1149
|
+
started_sequence=excluded.started_sequence, trigger=excluded.trigger""",
|
|
1150
|
+
(
|
|
1151
|
+
session_id,
|
|
1152
|
+
current_window,
|
|
1153
|
+
data.get("previousWindowId"),
|
|
1154
|
+
int(data.get("startedSequence", event.sequence)),
|
|
1155
|
+
str(data.get("trigger", "initial")),
|
|
1156
|
+
),
|
|
1157
|
+
)
|
|
1158
|
+
connection.execute(
|
|
1159
|
+
"""UPDATE context_history_projection SET window_id=%s
|
|
1160
|
+
WHERE session_id=%s AND window_id=%s""",
|
|
1161
|
+
(current_window, session_id, f"legacy-{session_id}"),
|
|
1162
|
+
)
|
|
1163
|
+
return
|
|
1164
|
+
if event.type == "compaction/checkpoint":
|
|
1165
|
+
next_window = data.get("windowId")
|
|
1166
|
+
carried = data.get("carriedEventSequences")
|
|
1167
|
+
if isinstance(next_window, str) and isinstance(carried, list):
|
|
1168
|
+
for sequence in carried:
|
|
1169
|
+
if isinstance(sequence, int) and not isinstance(sequence, bool):
|
|
1170
|
+
connection.execute(
|
|
1171
|
+
"""UPDATE context_history_projection SET window_id=%s
|
|
1172
|
+
WHERE session_id=%s AND sequence=%s""",
|
|
1173
|
+
(next_window, session_id, sequence),
|
|
1174
|
+
)
|
|
1175
|
+
return
|
|
1176
|
+
if event.type in {"context/note-written", "context/note-appended"}:
|
|
1177
|
+
path = str(data.get("path", ""))
|
|
1178
|
+
text = str(data.get("text", ""))
|
|
1179
|
+
existing = connection.execute(
|
|
1180
|
+
"SELECT content FROM context_note_projection WHERE session_id=%s AND path=%s",
|
|
1181
|
+
(session_id, path),
|
|
1182
|
+
).fetchone()
|
|
1183
|
+
content = (
|
|
1184
|
+
(str(existing["content"]) if existing is not None else "") + text
|
|
1185
|
+
if event.type == "context/note-appended"
|
|
1186
|
+
else text
|
|
1187
|
+
)
|
|
1188
|
+
connection.execute(
|
|
1189
|
+
"""INSERT INTO context_note_projection(
|
|
1190
|
+
session_id, path, content, updated_sequence) VALUES (%s, %s, %s, %s) ON
|
|
1191
|
+
CONFLICT (session_id, path) DO UPDATE SET content=excluded.content,
|
|
1192
|
+
updated_sequence=excluded.updated_sequence""",
|
|
1193
|
+
(session_id, path, content, event.sequence),
|
|
1194
|
+
)
|
|
1195
|
+
return
|
|
1196
|
+
history = _context_history_value(event)
|
|
1197
|
+
if history is None:
|
|
1198
|
+
return
|
|
1199
|
+
role, tool_name, content = history
|
|
1200
|
+
connection.execute(
|
|
1201
|
+
"""INSERT INTO context_history_projection(
|
|
1202
|
+
session_id, item_id, window_id, sequence, role, tool_name, content)
|
|
1203
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (session_id, item_id) DO UPDATE SET
|
|
1204
|
+
window_id=excluded.window_id, sequence=excluded.sequence, role=excluded.role,
|
|
1205
|
+
tool_name=excluded.tool_name, content=excluded.content""",
|
|
1206
|
+
(
|
|
1207
|
+
session_id,
|
|
1208
|
+
f"item-{event.sequence}",
|
|
1209
|
+
current_window,
|
|
1210
|
+
event.sequence,
|
|
1211
|
+
role,
|
|
1212
|
+
tool_name,
|
|
1213
|
+
content,
|
|
1214
|
+
),
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
@staticmethod
|
|
1218
|
+
def _projection_payload(
|
|
1219
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
1220
|
+
session_id: str,
|
|
1221
|
+
key: str,
|
|
1222
|
+
) -> dict[str, Any] | None:
|
|
1223
|
+
row = connection.execute(
|
|
1224
|
+
"""SELECT payload_json FROM session_projection
|
|
1225
|
+
WHERE session_id=%s AND projection_key=%s""",
|
|
1226
|
+
(session_id, key),
|
|
1227
|
+
).fetchone()
|
|
1228
|
+
return json.loads(str(row["payload_json"])) if row is not None else None
|
|
1229
|
+
|
|
1230
|
+
@staticmethod
|
|
1231
|
+
def _upsert_projection(
|
|
1232
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
1233
|
+
session_id: str,
|
|
1234
|
+
key: str,
|
|
1235
|
+
sequence: int,
|
|
1236
|
+
payload: Mapping[str, Any],
|
|
1237
|
+
) -> None:
|
|
1238
|
+
connection.execute(
|
|
1239
|
+
"""INSERT INTO session_projection(
|
|
1240
|
+
session_id, projection_key, sequence, payload_json) VALUES (%s, %s, %s, %s) ON
|
|
1241
|
+
CONFLICT (session_id, projection_key) DO UPDATE SET sequence=excluded.sequence,
|
|
1242
|
+
payload_json=excluded.payload_json""",
|
|
1243
|
+
(session_id, key, sequence, json.dumps(payload, ensure_ascii=False)),
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
def _rebuild_projections_sync(self) -> None:
|
|
1247
|
+
with self._connection() as connection:
|
|
1248
|
+
connection.execute("SELECT pg_advisory_xact_lock(716901, 2)")
|
|
1249
|
+
# 正常 Submission 由 submission/accepted 事实重建。离线迁移生成的
|
|
1250
|
+
# recovery Submission 没有改写旧 Session Log, 因而保留其专用投影行。
|
|
1251
|
+
connection.execute(
|
|
1252
|
+
"""DELETE FROM submissions
|
|
1253
|
+
WHERE operation_id IN (
|
|
1254
|
+
SELECT payload_json::jsonb->>'operation_id'
|
|
1255
|
+
FROM session_events WHERE event_type='submission/accepted'
|
|
1256
|
+
)"""
|
|
1257
|
+
)
|
|
1258
|
+
for table in (
|
|
1259
|
+
"session_projection",
|
|
1260
|
+
"context_window_projection",
|
|
1261
|
+
"context_note_projection",
|
|
1262
|
+
"context_history_projection",
|
|
1263
|
+
"user_input_requests",
|
|
1264
|
+
"approvals",
|
|
1265
|
+
"session_snapshots",
|
|
1266
|
+
"projection_offsets",
|
|
1267
|
+
):
|
|
1268
|
+
connection.execute(pg_sql.SQL("DELETE FROM {}").format(pg_sql.Identifier(table)))
|
|
1269
|
+
rows = connection.execute(
|
|
1270
|
+
"""SELECT events.session_id, events.envelope_json, outbox.cursor
|
|
1271
|
+
FROM session_events AS events
|
|
1272
|
+
JOIN event_outbox AS outbox ON outbox.event_id=events.event_id
|
|
1273
|
+
ORDER BY outbox.cursor"""
|
|
1274
|
+
).fetchall()
|
|
1275
|
+
for row in rows:
|
|
1276
|
+
self._project_event(
|
|
1277
|
+
connection,
|
|
1278
|
+
str(row["session_id"]),
|
|
1279
|
+
SessionEvent.model_validate_json(str(row["envelope_json"])),
|
|
1280
|
+
durable_cursor=int(row["cursor"]),
|
|
1281
|
+
)
|
|
1282
|
+
connection.commit()
|
|
1283
|
+
|
|
1284
|
+
def _get_submission_sync(self, operation_id: str) -> Submission | None:
|
|
1285
|
+
with self._connection() as connection:
|
|
1286
|
+
row = connection.execute(
|
|
1287
|
+
"SELECT * FROM submissions WHERE operation_id=%s", (operation_id,)
|
|
1288
|
+
).fetchone()
|
|
1289
|
+
return _op_from_row(dict(row)) if row is not None else None
|
|
1290
|
+
|
|
1291
|
+
def _get_receipt_sync(
|
|
1292
|
+
self,
|
|
1293
|
+
operation_id: str,
|
|
1294
|
+
duplicate: bool,
|
|
1295
|
+
) -> SubmissionReceipt | None:
|
|
1296
|
+
with self._connection() as connection:
|
|
1297
|
+
row = connection.execute(
|
|
1298
|
+
"""SELECT submission_id, session_id, accepted_cursor
|
|
1299
|
+
FROM submissions WHERE operation_id=%s""",
|
|
1300
|
+
(operation_id,),
|
|
1301
|
+
).fetchone()
|
|
1302
|
+
if row is None:
|
|
1303
|
+
return None
|
|
1304
|
+
return SubmissionReceipt(
|
|
1305
|
+
operationId=operation_id,
|
|
1306
|
+
sessionId=str(row["session_id"]),
|
|
1307
|
+
status=SubmissionStatus.DUPLICATE if duplicate else SubmissionStatus.ACCEPTED,
|
|
1308
|
+
durableCursor=int(row["accepted_cursor"]),
|
|
1309
|
+
submissionId=str(row["submission_id"]),
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
def _claim_next_sync(
|
|
1313
|
+
self,
|
|
1314
|
+
worker_id: str,
|
|
1315
|
+
lease_seconds: float,
|
|
1316
|
+
) -> ClaimedSubmission | None:
|
|
1317
|
+
with self._connection() as connection:
|
|
1318
|
+
now = _database_now(connection)
|
|
1319
|
+
expires = now + max(1, int(lease_seconds * 1000))
|
|
1320
|
+
row = connection.execute(
|
|
1321
|
+
"""SELECT candidate.* FROM submissions AS candidate
|
|
1322
|
+
WHERE (
|
|
1323
|
+
candidate.status='pending'
|
|
1324
|
+
OR (
|
|
1325
|
+
candidate.status='claimed'
|
|
1326
|
+
AND candidate.claim_expires_at_ms<=%s
|
|
1327
|
+
)
|
|
1328
|
+
)
|
|
1329
|
+
AND NOT EXISTS (
|
|
1330
|
+
SELECT 1 FROM submissions AS earlier
|
|
1331
|
+
WHERE earlier.session_id=candidate.session_id
|
|
1332
|
+
AND earlier.status IN ('pending', 'claimed')
|
|
1333
|
+
AND earlier.accepted_sequence<candidate.accepted_sequence
|
|
1334
|
+
)
|
|
1335
|
+
ORDER BY candidate.created_at_ms,
|
|
1336
|
+
candidate.accepted_sequence,
|
|
1337
|
+
candidate.operation_id
|
|
1338
|
+
LIMIT 1 FOR UPDATE OF candidate SKIP LOCKED""",
|
|
1339
|
+
(now,),
|
|
1340
|
+
).fetchone()
|
|
1341
|
+
if row is None:
|
|
1342
|
+
connection.commit()
|
|
1343
|
+
return None
|
|
1344
|
+
connection.execute(
|
|
1345
|
+
"""UPDATE submissions
|
|
1346
|
+
SET status='claimed', claimed_by=%s, claim_expires_at_ms=%s,
|
|
1347
|
+
attempts=attempts+1, updated_at_ms=%s
|
|
1348
|
+
WHERE operation_id=%s""",
|
|
1349
|
+
(worker_id, expires, now, row["operation_id"]),
|
|
1350
|
+
)
|
|
1351
|
+
connection.commit()
|
|
1352
|
+
op = _op_from_row(dict(row))
|
|
1353
|
+
return ClaimedSubmission(op, str(row["submission_id"]), int(row["attempts"]) + 1)
|
|
1354
|
+
|
|
1355
|
+
def _release_submission_sync(
|
|
1356
|
+
self,
|
|
1357
|
+
operation_id: str,
|
|
1358
|
+
error: str | None,
|
|
1359
|
+
) -> None:
|
|
1360
|
+
with self._connection() as connection:
|
|
1361
|
+
changed = connection.execute(
|
|
1362
|
+
"""UPDATE submissions SET status='pending', claimed_by=NULL,
|
|
1363
|
+
claim_expires_at_ms=NULL, last_error=%s, updated_at_ms=%s
|
|
1364
|
+
WHERE operation_id=%s AND status='claimed'""",
|
|
1365
|
+
(error, _now_ms(), operation_id),
|
|
1366
|
+
).rowcount
|
|
1367
|
+
connection.commit()
|
|
1368
|
+
if not changed:
|
|
1369
|
+
raise KeyError(f"unknown submission: {operation_id}")
|
|
1370
|
+
|
|
1371
|
+
def _pending_sync(self, session_id: str | None) -> tuple[Submission, ...]:
|
|
1372
|
+
sql = "SELECT * FROM submissions WHERE status IN ('pending', 'claimed')"
|
|
1373
|
+
values: tuple[Any, ...] = ()
|
|
1374
|
+
if session_id is not None:
|
|
1375
|
+
sql += " AND session_id=%s"
|
|
1376
|
+
values = (session_id,)
|
|
1377
|
+
sql += " ORDER BY created_at_ms, accepted_sequence, operation_id"
|
|
1378
|
+
with self._connection() as connection:
|
|
1379
|
+
rows = connection.execute(pg_sql.SQL(sql), values).fetchall()
|
|
1380
|
+
return tuple(_op_from_row(dict(row)) for row in rows)
|
|
1381
|
+
|
|
1382
|
+
def _unfinished_counts_sync(self, session_id: str) -> tuple[int, int]:
|
|
1383
|
+
with self._connection() as connection:
|
|
1384
|
+
row = connection.execute(
|
|
1385
|
+
"""SELECT
|
|
1386
|
+
COUNT(*) AS op_count,
|
|
1387
|
+
SUM(CASE WHEN kind='user-input' THEN 1 ELSE 0 END) AS turn_count
|
|
1388
|
+
FROM submissions
|
|
1389
|
+
WHERE session_id=%s AND status IN ('pending', 'claimed')""",
|
|
1390
|
+
(session_id,),
|
|
1391
|
+
).fetchone()
|
|
1392
|
+
assert row is not None
|
|
1393
|
+
return int(row["op_count"]), int(row["turn_count"] or 0)
|
|
1394
|
+
|
|
1395
|
+
def _acquire_sync(
|
|
1396
|
+
self,
|
|
1397
|
+
session_id: str,
|
|
1398
|
+
owner_id: str,
|
|
1399
|
+
ttl_seconds: float,
|
|
1400
|
+
) -> SessionLease | None:
|
|
1401
|
+
with self._connection() as connection:
|
|
1402
|
+
parent = connection.execute(
|
|
1403
|
+
"SELECT session_id FROM sessions WHERE session_id=%s FOR UPDATE", (session_id,)
|
|
1404
|
+
).fetchone()
|
|
1405
|
+
if parent is None:
|
|
1406
|
+
raise KeyError(f"unknown session: {session_id}")
|
|
1407
|
+
now = _database_now(connection)
|
|
1408
|
+
expires = now + max(1, int(ttl_seconds * 1000))
|
|
1409
|
+
row = connection.execute(
|
|
1410
|
+
"SELECT owner_id, generation, expires_at_ms FROM session_leases WHERE "
|
|
1411
|
+
"session_id=%s FOR UPDATE",
|
|
1412
|
+
(session_id,),
|
|
1413
|
+
).fetchone()
|
|
1414
|
+
if row is not None and int(row["expires_at_ms"]) > now and row["owner_id"] != owner_id:
|
|
1415
|
+
connection.commit()
|
|
1416
|
+
return None
|
|
1417
|
+
# 同一 Worker 在 Lease 尚有效时重复 acquire 只是续期,不能提升
|
|
1418
|
+
# generation;否则它会让自己仍在运行的 Active Turn 立刻变成旧写入方。
|
|
1419
|
+
same_live_owner = (
|
|
1420
|
+
row is not None
|
|
1421
|
+
and str(row["owner_id"]) == owner_id
|
|
1422
|
+
and int(row["expires_at_ms"]) > now
|
|
1423
|
+
)
|
|
1424
|
+
if same_live_owner:
|
|
1425
|
+
assert row is not None
|
|
1426
|
+
generation = int(row["generation"])
|
|
1427
|
+
else:
|
|
1428
|
+
generation = (int(row["generation"]) if row is not None else 0) + 1
|
|
1429
|
+
connection.execute(
|
|
1430
|
+
"""INSERT INTO session_leases(
|
|
1431
|
+
session_id, owner_id, generation, expires_at_ms, updated_at_ms)
|
|
1432
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
1433
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
1434
|
+
owner_id=excluded.owner_id,
|
|
1435
|
+
generation=excluded.generation,
|
|
1436
|
+
expires_at_ms=excluded.expires_at_ms,
|
|
1437
|
+
updated_at_ms=excluded.updated_at_ms""",
|
|
1438
|
+
(session_id, owner_id, generation, expires, now),
|
|
1439
|
+
)
|
|
1440
|
+
connection.commit()
|
|
1441
|
+
return SessionLease(session_id, owner_id, generation, expires)
|
|
1442
|
+
|
|
1443
|
+
def _renew_sync(self, lease: SessionLease, ttl_seconds: float) -> SessionLease:
|
|
1444
|
+
with self._connection() as connection:
|
|
1445
|
+
connection.execute(
|
|
1446
|
+
"SELECT generation FROM session_leases WHERE session_id=%s FOR UPDATE",
|
|
1447
|
+
(lease.session_id,),
|
|
1448
|
+
)
|
|
1449
|
+
now = _database_now(connection)
|
|
1450
|
+
expires = now + max(1, int(ttl_seconds * 1000))
|
|
1451
|
+
changed = connection.execute(
|
|
1452
|
+
"""UPDATE session_leases SET expires_at_ms=%s, updated_at_ms=%s
|
|
1453
|
+
WHERE session_id=%s AND owner_id=%s AND generation=%s AND expires_at_ms>%s""",
|
|
1454
|
+
(
|
|
1455
|
+
expires,
|
|
1456
|
+
now,
|
|
1457
|
+
lease.session_id,
|
|
1458
|
+
lease.owner_id,
|
|
1459
|
+
lease.generation,
|
|
1460
|
+
now,
|
|
1461
|
+
),
|
|
1462
|
+
).rowcount
|
|
1463
|
+
connection.commit()
|
|
1464
|
+
if not changed:
|
|
1465
|
+
raise FencingTokenRejected(f"lease {lease.session_id!r} is no longer current")
|
|
1466
|
+
return SessionLease(lease.session_id, lease.owner_id, lease.generation, expires)
|
|
1467
|
+
|
|
1468
|
+
def _release_lease_sync(self, lease: SessionLease) -> None:
|
|
1469
|
+
with self._connection() as connection:
|
|
1470
|
+
connection.execute(
|
|
1471
|
+
"""UPDATE session_leases SET expires_at_ms=0
|
|
1472
|
+
WHERE session_id=%s AND owner_id=%s AND generation=%s""",
|
|
1473
|
+
(lease.session_id, lease.owner_id, lease.generation),
|
|
1474
|
+
)
|
|
1475
|
+
connection.commit()
|
|
1476
|
+
|
|
1477
|
+
def _check_fencing(
|
|
1478
|
+
self,
|
|
1479
|
+
connection: psycopg.Connection[dict[str, Any]],
|
|
1480
|
+
session_id: str,
|
|
1481
|
+
token: int,
|
|
1482
|
+
) -> None:
|
|
1483
|
+
row = connection.execute(
|
|
1484
|
+
"SELECT generation, expires_at_ms FROM session_leases WHERE session_id=%s FOR UPDATE",
|
|
1485
|
+
(session_id,),
|
|
1486
|
+
).fetchone()
|
|
1487
|
+
if (
|
|
1488
|
+
row is None
|
|
1489
|
+
or int(row["generation"]) != token
|
|
1490
|
+
or int(row["expires_at_ms"]) <= _database_now(connection)
|
|
1491
|
+
):
|
|
1492
|
+
raise FencingTokenRejected(f"fencing token {token} is stale for {session_id!r}")
|
|
1493
|
+
|
|
1494
|
+
def _read_outbox_sync(
|
|
1495
|
+
self,
|
|
1496
|
+
after_cursor: int,
|
|
1497
|
+
limit: int,
|
|
1498
|
+
) -> tuple[CommittedSessionEvent, ...]:
|
|
1499
|
+
with self._connection() as connection:
|
|
1500
|
+
rows = connection.execute(
|
|
1501
|
+
"""SELECT * FROM event_outbox WHERE cursor>%s
|
|
1502
|
+
ORDER BY cursor LIMIT %s""",
|
|
1503
|
+
(after_cursor, limit),
|
|
1504
|
+
).fetchall()
|
|
1505
|
+
return tuple(
|
|
1506
|
+
CommittedSessionEvent(
|
|
1507
|
+
eventId=str(row["event_id"]),
|
|
1508
|
+
sessionId=str(row["session_id"]),
|
|
1509
|
+
sequence=int(row["sequence"]),
|
|
1510
|
+
eventType=str(row["event_type"]),
|
|
1511
|
+
payload=json.loads(str(row["payload_json"])),
|
|
1512
|
+
recordedAtMs=int(row["recorded_at_ms"]),
|
|
1513
|
+
durableCursor=int(row["cursor"]),
|
|
1514
|
+
)
|
|
1515
|
+
for row in rows
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
def _latest_outbox_cursor_sync(self) -> int:
|
|
1519
|
+
with self._connection() as connection:
|
|
1520
|
+
row = connection.execute(
|
|
1521
|
+
"SELECT COALESCE(MAX(cursor), 0) AS cursor FROM event_outbox"
|
|
1522
|
+
).fetchone()
|
|
1523
|
+
return int(row["cursor"]) if row is not None else 0
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _op_from_row(row: Mapping[str, Any]) -> Submission:
|
|
1527
|
+
return Submission.model_validate(
|
|
1528
|
+
{
|
|
1529
|
+
"operationId": row["operation_id"],
|
|
1530
|
+
"sessionId": row["session_id"],
|
|
1531
|
+
"kind": row["kind"],
|
|
1532
|
+
"payload": json.loads(str(row["payload_json"])),
|
|
1533
|
+
"expectedRevision": row["expected_revision"],
|
|
1534
|
+
"source": json.loads(str(row["source_json"])),
|
|
1535
|
+
}
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _event_from_json(session_id: str, value: str) -> SessionEvent:
|
|
1540
|
+
"""恢复线协议事件, 并重新附加不参与持久化的 Session 所有权。"""
|
|
1541
|
+
|
|
1542
|
+
return SessionEvent.model_validate_json(value).model_copy(update={"session_id": session_id})
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def _public_user_input(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
1546
|
+
raw_value = row.get("value_json")
|
|
1547
|
+
raw_actor = row.get("actor_json")
|
|
1548
|
+
return {
|
|
1549
|
+
"requestId": str(row["request_id"]),
|
|
1550
|
+
"sessionId": str(row["session_id"]),
|
|
1551
|
+
"turnId": str(row.get("turn_id") or ""),
|
|
1552
|
+
"prompt": str(row.get("prompt") or ""),
|
|
1553
|
+
"inputSchema": json.loads(str(row.get("input_schema_json") or "{}")),
|
|
1554
|
+
"status": str(row.get("status") or "pending"),
|
|
1555
|
+
"outcome": str(row["outcome"]) if row.get("outcome") is not None else None,
|
|
1556
|
+
"value": json.loads(str(raw_value)) if raw_value is not None else None,
|
|
1557
|
+
"actor": json.loads(str(raw_actor)) if raw_actor is not None else {},
|
|
1558
|
+
"reason": row.get("resolution_reason"),
|
|
1559
|
+
"revision": int(row.get("revision") or 0),
|
|
1560
|
+
"expiresAtMs": int(row.get("expires_at_ms") or 0),
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _public_approval(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
1565
|
+
"""把内部审批投影转换为 Runtime Plane 的稳定公共字段。"""
|
|
1566
|
+
|
|
1567
|
+
raw_actor = row.get("actor_json")
|
|
1568
|
+
try:
|
|
1569
|
+
actor = json.loads(str(raw_actor or "{}"))
|
|
1570
|
+
except json.JSONDecodeError:
|
|
1571
|
+
actor = {}
|
|
1572
|
+
return {
|
|
1573
|
+
"approvalId": str(row["id"]),
|
|
1574
|
+
"sessionId": str(row.get("session_id") or ""),
|
|
1575
|
+
"turnId": str(row.get("turn_id") or ""),
|
|
1576
|
+
"stepId": str(row.get("step_id") or ""),
|
|
1577
|
+
"callId": str(row.get("tool_call_id") or ""),
|
|
1578
|
+
"operationId": str(row.get("operation_id") or ""),
|
|
1579
|
+
"toolName": str(row.get("tool_name") or ""),
|
|
1580
|
+
"argumentsSummary": json.loads(str(row.get("arguments_json") or "{}")),
|
|
1581
|
+
"reason": str(row.get("reason") or ""),
|
|
1582
|
+
"outcome": str(row.get("status") or "pending"),
|
|
1583
|
+
"revision": int(row.get("revision") or 0),
|
|
1584
|
+
"expiresAt": row.get("expires_at"),
|
|
1585
|
+
"actor": actor,
|
|
1586
|
+
"resolutionReason": row.get("resolution_reason"),
|
|
1587
|
+
"createdAt": row.get("created_at"),
|
|
1588
|
+
"resolvedAt": row.get("resolved_at"),
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _iso_time(time_ms: int) -> str:
|
|
1593
|
+
return datetime.fromtimestamp(time_ms / 1000, UTC).isoformat()
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def _event_id(session_id: str, sequence: int) -> str:
|
|
1597
|
+
return uuid5(NAMESPACE_URL, f"qi-pi:{session_id}:{sequence}").hex
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def _now_ms() -> int:
|
|
1601
|
+
return time.time_ns() // 1_000_000
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _channel_address_key(value: Mapping[str, Any]) -> str | None:
|
|
1605
|
+
channel_id = value.get("channel_id", value.get("channelId"))
|
|
1606
|
+
account_id = value.get("account_id", value.get("accountId"))
|
|
1607
|
+
conversation_id = value.get("conversation_id", value.get("conversationId"))
|
|
1608
|
+
if not channel_id or not account_id or not conversation_id:
|
|
1609
|
+
return None
|
|
1610
|
+
return json.dumps(
|
|
1611
|
+
[str(channel_id), str(account_id), str(conversation_id)],
|
|
1612
|
+
ensure_ascii=False,
|
|
1613
|
+
separators=(",", ":"),
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _channel_inbound_key(value: Mapping[str, Any]) -> str | None:
|
|
1618
|
+
address = _channel_address_key(value)
|
|
1619
|
+
message_id = value.get("message_id", value.get("messageId"))
|
|
1620
|
+
if address is None or not message_id:
|
|
1621
|
+
return None
|
|
1622
|
+
return f"{address}:{json.dumps(str(message_id), ensure_ascii=False)}"
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _context_history_value(event: SessionEvent) -> tuple[str, str | None, str] | None:
|
|
1626
|
+
if event.type == "user/message":
|
|
1627
|
+
return "user", None, _json_text(event.data)
|
|
1628
|
+
if event.type == "assistant/message":
|
|
1629
|
+
return "assistant", None, _json_text(event.data.get("message"))
|
|
1630
|
+
if event.type == "tool/call":
|
|
1631
|
+
return "tool", str(event.data.get("name", "")) or None, str(event.data.get("arguments", ""))
|
|
1632
|
+
if event.type == "tool/result":
|
|
1633
|
+
return "tool", None, _json_text(event.data.get("message"))
|
|
1634
|
+
return None
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _json_text(value: Any) -> str:
|
|
1638
|
+
if isinstance(value, str):
|
|
1639
|
+
return value
|
|
1640
|
+
if isinstance(value, Mapping):
|
|
1641
|
+
return "\n".join(
|
|
1642
|
+
_json_text(item)
|
|
1643
|
+
for key, item in value.items()
|
|
1644
|
+
if key in {"content", "text", "name", "arguments", "message"}
|
|
1645
|
+
)
|
|
1646
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
1647
|
+
return "\n".join(_json_text(item) for item in value)
|
|
1648
|
+
return ""
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
__all__ = [
|
|
1652
|
+
"RUNTIME_STORE_SCHEMA",
|
|
1653
|
+
"FencingTokenRejected",
|
|
1654
|
+
"PostgresRuntimeStore",
|
|
1655
|
+
"RuntimeStoreError",
|
|
1656
|
+
"SequenceConflict",
|
|
1657
|
+
]
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def _database_now(connection: psycopg.Connection[dict[str, Any]]) -> int:
|
|
1661
|
+
row = connection.execute(
|
|
1662
|
+
"SELECT (extract(epoch FROM clock_timestamp()) * 1000)::bigint AS now_ms"
|
|
1663
|
+
).fetchone()
|
|
1664
|
+
assert row is not None
|
|
1665
|
+
return int(row["now_ms"])
|