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"])