synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Idempotent, compensating import of safe Codex text snapshots.
|
|
2
|
+
|
|
3
|
+
The ledger intentionally lives beside Synapse session metadata rather than in
|
|
4
|
+
Codex state. It coordinates two separate SQLite databases: LangGraph
|
|
5
|
+
checkpoints and Synapse session metadata. It cannot make them atomic, so each
|
|
6
|
+
pending import carries a short lease and is reconciled by verifying the seeded
|
|
7
|
+
terminal checkpoint before marking the import complete.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
20
|
+
|
|
21
|
+
from synapse.integrations.checkpoint_seed import CheckpointSeeder, CheckpointSeedError
|
|
22
|
+
from synapse.integrations.codex_history import PARSER_VERSION, PROJECTION_KIND
|
|
23
|
+
from synapse.sessions.store import SessionInfo, SessionStore, title_from_user_message
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from synapse.integrations.codex_history import CodexTextSnapshot
|
|
27
|
+
|
|
28
|
+
_LEASE_SECONDS = 120
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CodexImportError(RuntimeError):
|
|
32
|
+
"""A Codex snapshot cannot be imported or recovered safely."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CodexImportResult:
|
|
37
|
+
"""One completed or idempotently reused imported Synapse session."""
|
|
38
|
+
|
|
39
|
+
thread_id: str
|
|
40
|
+
snapshot_digest: str
|
|
41
|
+
reused: bool
|
|
42
|
+
recovered: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _LedgerEntry:
|
|
47
|
+
source_id: str
|
|
48
|
+
snapshot_digest: str
|
|
49
|
+
thread_id: str
|
|
50
|
+
status: str
|
|
51
|
+
lease_until: str | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CodexImportLedger:
|
|
55
|
+
"""Durable source-to-thread mapping with short leases for crash recovery."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: Path | str) -> None:
|
|
58
|
+
self.path = Path(path).expanduser()
|
|
59
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
61
|
+
self._conn.row_factory = sqlite3.Row
|
|
62
|
+
self._init_db()
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
self._conn.close()
|
|
66
|
+
|
|
67
|
+
def claim(self, source_id: str, snapshot_digest: str, thread_id: str) -> Literal[
|
|
68
|
+
"new", "completed", "recover"
|
|
69
|
+
]:
|
|
70
|
+
"""Claim a source, reuse completion, or take over an expired lease."""
|
|
71
|
+
now = _utcnow()
|
|
72
|
+
lease_until = _lease_expiry()
|
|
73
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
74
|
+
try:
|
|
75
|
+
row = self._conn.execute(
|
|
76
|
+
"SELECT * FROM codex_imports WHERE source_id = ?", (source_id,)
|
|
77
|
+
).fetchone()
|
|
78
|
+
if row is None:
|
|
79
|
+
self._conn.execute(
|
|
80
|
+
"""
|
|
81
|
+
INSERT INTO codex_imports(
|
|
82
|
+
source_id, snapshot_digest, thread_id, status, lease_until,
|
|
83
|
+
created_at, updated_at
|
|
84
|
+
) VALUES (?, ?, ?, 'pending', ?, ?, ?)
|
|
85
|
+
""",
|
|
86
|
+
(source_id, snapshot_digest, thread_id, lease_until, now, now),
|
|
87
|
+
)
|
|
88
|
+
self._conn.commit()
|
|
89
|
+
return "new"
|
|
90
|
+
|
|
91
|
+
entry = _entry_from_row(row)
|
|
92
|
+
if entry.snapshot_digest != snapshot_digest:
|
|
93
|
+
raise CodexImportError("Codex source changed after its first import attempt")
|
|
94
|
+
if entry.status == "completed":
|
|
95
|
+
self._conn.commit()
|
|
96
|
+
return "completed"
|
|
97
|
+
if entry.status != "pending":
|
|
98
|
+
raise CodexImportError("Codex import ledger has an invalid state")
|
|
99
|
+
if not _lease_is_expired(entry.lease_until):
|
|
100
|
+
raise CodexImportError("Codex import is already in progress")
|
|
101
|
+
self._conn.execute(
|
|
102
|
+
"UPDATE codex_imports SET lease_until = ?, updated_at = ? WHERE source_id = ?",
|
|
103
|
+
(lease_until, now, source_id),
|
|
104
|
+
)
|
|
105
|
+
self._conn.commit()
|
|
106
|
+
return "recover"
|
|
107
|
+
except Exception:
|
|
108
|
+
self._conn.rollback()
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
def entry(self, source_id: str) -> _LedgerEntry | None:
|
|
112
|
+
row = self._conn.execute(
|
|
113
|
+
"SELECT * FROM codex_imports WHERE source_id = ?", (source_id,)
|
|
114
|
+
).fetchone()
|
|
115
|
+
return _entry_from_row(row) if row is not None else None
|
|
116
|
+
|
|
117
|
+
def complete(self, source_id: str, thread_id: str) -> None:
|
|
118
|
+
now = _utcnow()
|
|
119
|
+
updated = self._conn.execute(
|
|
120
|
+
"""
|
|
121
|
+
UPDATE codex_imports
|
|
122
|
+
SET status = 'completed', lease_until = NULL, updated_at = ?
|
|
123
|
+
WHERE source_id = ? AND thread_id = ? AND status = 'pending'
|
|
124
|
+
""",
|
|
125
|
+
(now, source_id, thread_id),
|
|
126
|
+
)
|
|
127
|
+
self._conn.commit()
|
|
128
|
+
if updated.rowcount != 1:
|
|
129
|
+
raise CodexImportError("Codex import ledger completion was lost")
|
|
130
|
+
|
|
131
|
+
def abandon(self, source_id: str, thread_id: str) -> None:
|
|
132
|
+
"""Remove an uncompleted ledger row after confirmed compensation."""
|
|
133
|
+
self._conn.execute(
|
|
134
|
+
"""
|
|
135
|
+
DELETE FROM codex_imports
|
|
136
|
+
WHERE source_id = ? AND thread_id = ? AND status = 'pending'
|
|
137
|
+
""",
|
|
138
|
+
(source_id, thread_id),
|
|
139
|
+
)
|
|
140
|
+
self._conn.commit()
|
|
141
|
+
|
|
142
|
+
def _init_db(self) -> None:
|
|
143
|
+
self._conn.execute(
|
|
144
|
+
"""
|
|
145
|
+
CREATE TABLE IF NOT EXISTS codex_imports (
|
|
146
|
+
source_id TEXT PRIMARY KEY,
|
|
147
|
+
snapshot_digest TEXT NOT NULL,
|
|
148
|
+
thread_id TEXT NOT NULL UNIQUE,
|
|
149
|
+
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
|
|
150
|
+
lease_until TEXT,
|
|
151
|
+
created_at TEXT NOT NULL,
|
|
152
|
+
updated_at TEXT NOT NULL
|
|
153
|
+
)
|
|
154
|
+
"""
|
|
155
|
+
)
|
|
156
|
+
self._conn.commit()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def import_codex_session(
|
|
160
|
+
*,
|
|
161
|
+
native_id: str,
|
|
162
|
+
settings: Any,
|
|
163
|
+
agent: Any,
|
|
164
|
+
workspace: Path | None = None,
|
|
165
|
+
codex_home: Path | None = None,
|
|
166
|
+
) -> CodexImportResult:
|
|
167
|
+
"""Discover, project, and import one Codex session through one agent graph."""
|
|
168
|
+
from synapse.integrations.codex_history import CodexHistoryProjector
|
|
169
|
+
from synapse.integrations.codex_sessions import CodexSessionScanner
|
|
170
|
+
|
|
171
|
+
target_workspace = workspace or Path(getattr(settings, "workspace", Path.cwd()))
|
|
172
|
+
session = CodexSessionScanner(codex_home).inspect(
|
|
173
|
+
native_id,
|
|
174
|
+
workspace=target_workspace,
|
|
175
|
+
include_rollout_fallback=True,
|
|
176
|
+
)
|
|
177
|
+
if session is None:
|
|
178
|
+
raise CodexImportError(f"Codex session not found: {native_id}")
|
|
179
|
+
snapshot = CodexHistoryProjector().project_path(session.rollout_path)
|
|
180
|
+
if not snapshot.importable:
|
|
181
|
+
warning_codes = ",".join(warning.code for warning in snapshot.warnings)
|
|
182
|
+
raise CodexImportError(
|
|
183
|
+
f"Codex session cannot be imported safely: {warning_codes or 'unknown'}"
|
|
184
|
+
)
|
|
185
|
+
if not snapshot.messages:
|
|
186
|
+
raise CodexImportError("Codex session cannot be imported safely: no_visible_messages")
|
|
187
|
+
|
|
188
|
+
title = next(
|
|
189
|
+
(message.text for message in snapshot.messages if message.role == "user"),
|
|
190
|
+
session.title,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
sessions_path = settings.resolved_sessions_path()
|
|
194
|
+
store = SessionStore(sessions_path)
|
|
195
|
+
ledger = CodexImportLedger(default_codex_import_ledger_path(sessions_path))
|
|
196
|
+
try:
|
|
197
|
+
service = CodexImportService(
|
|
198
|
+
seeder=CheckpointSeeder(agent),
|
|
199
|
+
sessions=store,
|
|
200
|
+
ledger=ledger,
|
|
201
|
+
)
|
|
202
|
+
return service.import_snapshot(
|
|
203
|
+
native_id=session.native_id,
|
|
204
|
+
snapshot=snapshot,
|
|
205
|
+
title=title,
|
|
206
|
+
)
|
|
207
|
+
finally:
|
|
208
|
+
ledger.close()
|
|
209
|
+
store.close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class CodexImportService:
|
|
213
|
+
"""Coordinate immutable snapshot import across checkpoint and metadata stores."""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
*,
|
|
218
|
+
seeder: CheckpointSeeder,
|
|
219
|
+
sessions: SessionStore,
|
|
220
|
+
ledger: CodexImportLedger,
|
|
221
|
+
) -> None:
|
|
222
|
+
self._seeder = seeder
|
|
223
|
+
self._sessions = sessions
|
|
224
|
+
self._ledger = ledger
|
|
225
|
+
|
|
226
|
+
def import_snapshot(
|
|
227
|
+
self,
|
|
228
|
+
*,
|
|
229
|
+
native_id: str,
|
|
230
|
+
snapshot: CodexTextSnapshot,
|
|
231
|
+
title: str,
|
|
232
|
+
) -> CodexImportResult:
|
|
233
|
+
"""Create or recover exactly one Synapse session for one Codex source."""
|
|
234
|
+
if not snapshot.messages:
|
|
235
|
+
raise CodexImportError("Codex session cannot be imported safely: no_visible_messages")
|
|
236
|
+
digest = snapshot_digest(snapshot)
|
|
237
|
+
source_id = _source_id(native_id)
|
|
238
|
+
proposed_thread_id = f"codex-{uuid.uuid4().hex[:20]}"
|
|
239
|
+
claim = self._ledger.claim(source_id, digest, proposed_thread_id)
|
|
240
|
+
entry = self._ledger.entry(source_id)
|
|
241
|
+
if entry is None:
|
|
242
|
+
raise CodexImportError("Codex import ledger claim disappeared")
|
|
243
|
+
if claim == "completed":
|
|
244
|
+
self._verify_completed(entry, snapshot)
|
|
245
|
+
return CodexImportResult(entry.thread_id, digest, reused=True, recovered=False)
|
|
246
|
+
if claim == "recover":
|
|
247
|
+
return self._recover(entry, snapshot, title, digest)
|
|
248
|
+
return self._seed_new(entry, snapshot, title, digest)
|
|
249
|
+
|
|
250
|
+
def _seed_new(
|
|
251
|
+
self,
|
|
252
|
+
entry: _LedgerEntry,
|
|
253
|
+
snapshot: CodexTextSnapshot,
|
|
254
|
+
title: str,
|
|
255
|
+
digest: str,
|
|
256
|
+
) -> CodexImportResult:
|
|
257
|
+
try:
|
|
258
|
+
self._seeder.seed_snapshot(entry.thread_id, snapshot)
|
|
259
|
+
self._ensure_session(entry.thread_id, title)
|
|
260
|
+
self._ledger.complete(entry.source_id, entry.thread_id)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
self._compensate_new(entry, exc)
|
|
263
|
+
if isinstance(exc, CodexImportError):
|
|
264
|
+
raise
|
|
265
|
+
raise CodexImportError("Codex snapshot import failed") from exc
|
|
266
|
+
return CodexImportResult(entry.thread_id, digest, reused=False, recovered=False)
|
|
267
|
+
|
|
268
|
+
def _recover(
|
|
269
|
+
self,
|
|
270
|
+
entry: _LedgerEntry,
|
|
271
|
+
snapshot: CodexTextSnapshot,
|
|
272
|
+
title: str,
|
|
273
|
+
digest: str,
|
|
274
|
+
) -> CodexImportResult:
|
|
275
|
+
try:
|
|
276
|
+
self._seeder.verify_snapshot(entry.thread_id, snapshot)
|
|
277
|
+
except CheckpointSeedError as exc:
|
|
278
|
+
if self._thread_exists(entry.thread_id):
|
|
279
|
+
raise CodexImportError(
|
|
280
|
+
"pending import checkpoint does not match its immutable snapshot"
|
|
281
|
+
) from exc
|
|
282
|
+
self._sessions.delete(entry.thread_id)
|
|
283
|
+
try:
|
|
284
|
+
self._seeder.seed_snapshot(entry.thread_id, snapshot)
|
|
285
|
+
except Exception as seed_error:
|
|
286
|
+
self._ledger.abandon(entry.source_id, entry.thread_id)
|
|
287
|
+
raise CodexImportError("pending import could not be reseeded") from seed_error
|
|
288
|
+
try:
|
|
289
|
+
self._ensure_session(entry.thread_id, title)
|
|
290
|
+
self._ledger.complete(entry.source_id, entry.thread_id)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
raise CodexImportError("pending import could not be reconciled") from exc
|
|
293
|
+
return CodexImportResult(entry.thread_id, digest, reused=False, recovered=True)
|
|
294
|
+
|
|
295
|
+
def _verify_completed(self, entry: _LedgerEntry, snapshot: CodexTextSnapshot) -> None:
|
|
296
|
+
try:
|
|
297
|
+
self._seeder.verify_snapshot(entry.thread_id, snapshot)
|
|
298
|
+
except CheckpointSeedError as exc:
|
|
299
|
+
raise CodexImportError(
|
|
300
|
+
"completed import checkpoint no longer matches its snapshot"
|
|
301
|
+
) from exc
|
|
302
|
+
if self._sessions.get(entry.thread_id) is None:
|
|
303
|
+
raise CodexImportError("completed import session metadata is missing")
|
|
304
|
+
|
|
305
|
+
def _ensure_session(self, thread_id: str, title: str) -> SessionInfo:
|
|
306
|
+
safe_title = title_from_user_message(title) or "Imported Codex session"
|
|
307
|
+
return self._sessions.ensure(thread_id, title=safe_title)
|
|
308
|
+
|
|
309
|
+
def _thread_exists(self, thread_id: str) -> bool:
|
|
310
|
+
return self._seeder.has_thread(thread_id)
|
|
311
|
+
|
|
312
|
+
def _compensate_new(self, entry: _LedgerEntry, cause: Exception) -> None:
|
|
313
|
+
cleanup_error: Exception | None = None
|
|
314
|
+
try:
|
|
315
|
+
self._sessions.delete(entry.thread_id)
|
|
316
|
+
self._seeder.delete_thread(entry.thread_id)
|
|
317
|
+
self._ledger.abandon(entry.source_id, entry.thread_id)
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
cleanup_error = exc
|
|
320
|
+
if cleanup_error is not None:
|
|
321
|
+
raise CodexImportError(
|
|
322
|
+
"Codex import failed and compensation was incomplete"
|
|
323
|
+
) from cleanup_error
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def default_codex_import_ledger_path(sessions_path: Path | str) -> Path:
|
|
327
|
+
"""Place the ledger next to project-local Synapse session metadata."""
|
|
328
|
+
return Path(sessions_path).expanduser().resolve().parent / "codex-imports.sqlite"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def snapshot_digest(snapshot: CodexTextSnapshot) -> str:
|
|
332
|
+
"""Hash the immutable supported visible-text projection, never raw rollout bytes."""
|
|
333
|
+
if (
|
|
334
|
+
not snapshot.importable
|
|
335
|
+
or snapshot.projection_kind != PROJECTION_KIND
|
|
336
|
+
or snapshot.parser_version != PARSER_VERSION
|
|
337
|
+
):
|
|
338
|
+
raise CodexImportError("Codex snapshot is not importable under the current contract")
|
|
339
|
+
payload = {
|
|
340
|
+
"projection_kind": snapshot.projection_kind,
|
|
341
|
+
"parser_version": snapshot.parser_version,
|
|
342
|
+
"messages": [
|
|
343
|
+
{
|
|
344
|
+
"source_id": message.source_id,
|
|
345
|
+
"turn_id": message.turn_id,
|
|
346
|
+
"role": message.role,
|
|
347
|
+
"text": message.text,
|
|
348
|
+
}
|
|
349
|
+
for message in snapshot.messages
|
|
350
|
+
],
|
|
351
|
+
}
|
|
352
|
+
encoded = json.dumps(
|
|
353
|
+
payload,
|
|
354
|
+
ensure_ascii=True,
|
|
355
|
+
separators=(",", ":"),
|
|
356
|
+
sort_keys=True,
|
|
357
|
+
).encode()
|
|
358
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _source_id(native_id: str) -> str:
|
|
362
|
+
value = native_id.strip()
|
|
363
|
+
if not value or len(value) > 200 or "\x00" in value:
|
|
364
|
+
raise CodexImportError("Codex native session id is invalid")
|
|
365
|
+
return f"codex:{value.casefold()}"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _entry_from_row(row: sqlite3.Row) -> _LedgerEntry:
|
|
369
|
+
return _LedgerEntry(
|
|
370
|
+
source_id=str(row["source_id"]),
|
|
371
|
+
snapshot_digest=str(row["snapshot_digest"]),
|
|
372
|
+
thread_id=str(row["thread_id"]),
|
|
373
|
+
status=str(row["status"]),
|
|
374
|
+
lease_until=str(row["lease_until"]) if row["lease_until"] is not None else None,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _utcnow() -> str:
|
|
379
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _lease_expiry() -> str:
|
|
383
|
+
expiry = datetime.now(UTC) + timedelta(seconds=_LEASE_SECONDS)
|
|
384
|
+
return expiry.replace(microsecond=0).isoformat()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _lease_is_expired(value: str | None) -> bool:
|
|
388
|
+
if value is None:
|
|
389
|
+
return True
|
|
390
|
+
try:
|
|
391
|
+
return datetime.fromisoformat(value) <= datetime.now(UTC)
|
|
392
|
+
except ValueError:
|
|
393
|
+
return False
|