agent-trace-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/sync.py
ADDED
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Push/Pull sync protocol — content-ID manifests, no timestamp cursors as truth.
|
|
3
|
+
|
|
4
|
+
Each remote keeps a per-resource set of IDs known to be on the server in
|
|
5
|
+
``sync-state.json``. Push sends only locally-held items whose ID is not yet
|
|
6
|
+
in the manifest; pull paginates through the server, dedupes by ID against
|
|
7
|
+
the local store, and adds every received ID to the manifest. Status is
|
|
8
|
+
``local_ids - synced_ids`` — no timestamp comparisons, no false positives
|
|
9
|
+
after pull.
|
|
10
|
+
|
|
11
|
+
The per-resource ``cursor`` (server's ``max(created_at)`` from the prior
|
|
12
|
+
response) is a paging hint only. The manifest is the source of truth, so
|
|
13
|
+
losing or rebuilding the cursor only costs one extra full scan; it never
|
|
14
|
+
drops data.
|
|
15
|
+
|
|
16
|
+
No external dependencies — stdlib only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Iterable
|
|
28
|
+
|
|
29
|
+
from .conversations import (
|
|
30
|
+
ConversationBlob,
|
|
31
|
+
cache_path_for_sha,
|
|
32
|
+
enumerate_local_blobs,
|
|
33
|
+
update_conversation_index,
|
|
34
|
+
write_blob_to_cache,
|
|
35
|
+
)
|
|
36
|
+
from .remote import (
|
|
37
|
+
TokenScopeError,
|
|
38
|
+
assert_token_matches_url,
|
|
39
|
+
get_remote_base_url,
|
|
40
|
+
get_remote_org_slug,
|
|
41
|
+
get_remote_project_slug,
|
|
42
|
+
get_remote_token,
|
|
43
|
+
get_remote_url,
|
|
44
|
+
resolve_remote,
|
|
45
|
+
)
|
|
46
|
+
from .storage import (
|
|
47
|
+
ensure_project_dir,
|
|
48
|
+
get_commit_links_path,
|
|
49
|
+
get_ledgers_path,
|
|
50
|
+
get_project_dir,
|
|
51
|
+
get_traces_path,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SYNC_STATE_VERSION = 2
|
|
56
|
+
|
|
57
|
+
# Server-side page size for pull. The server caps at 1000 (app.py).
|
|
58
|
+
PULL_PAGE_LIMIT = 500
|
|
59
|
+
|
|
60
|
+
# Per-batch size for push payloads. Keeps individual POSTs bounded so a
|
|
61
|
+
# single oversized JSON body can't stall a sync.
|
|
62
|
+
PUSH_BATCH_SIZE = 500
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -------------------------------------------------------------------
|
|
66
|
+
# Sync state persistence
|
|
67
|
+
# -------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _sync_state_path(project_id: str) -> Path:
|
|
70
|
+
return get_project_dir(project_id) / "sync-state.json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _empty_remote_state() -> dict[str, Any]:
|
|
74
|
+
return {
|
|
75
|
+
"synced": {
|
|
76
|
+
"trace_ids": [],
|
|
77
|
+
"ledger_shas": [],
|
|
78
|
+
"commit_link_shas": [],
|
|
79
|
+
"blob_shas": [],
|
|
80
|
+
"conversation_ids": [],
|
|
81
|
+
"summary_keys": [],
|
|
82
|
+
},
|
|
83
|
+
"cursor": {
|
|
84
|
+
"traces": None,
|
|
85
|
+
"ledgers": None,
|
|
86
|
+
"commit_links": None,
|
|
87
|
+
"conversations": None,
|
|
88
|
+
"summaries": None,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_sync_state(project_id: str) -> dict[str, Any]:
|
|
94
|
+
"""Load sync-state.json, normalising legacy or partial files in memory.
|
|
95
|
+
|
|
96
|
+
Legacy files (v1) used ``last_push`` / ``last_pull`` timestamp cursors.
|
|
97
|
+
Those are silently discarded — the manifest will rebuild itself on the
|
|
98
|
+
next sync (server dedupes pushes; pulls dedupe by id locally).
|
|
99
|
+
"""
|
|
100
|
+
p = _sync_state_path(project_id)
|
|
101
|
+
if not p.is_file():
|
|
102
|
+
return {"version": SYNC_STATE_VERSION, "remotes": {}}
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(p.read_text())
|
|
105
|
+
except (json.JSONDecodeError, OSError):
|
|
106
|
+
return {"version": SYNC_STATE_VERSION, "remotes": {}}
|
|
107
|
+
if not isinstance(data, dict):
|
|
108
|
+
return {"version": SYNC_STATE_VERSION, "remotes": {}}
|
|
109
|
+
|
|
110
|
+
out: dict[str, Any] = {"version": SYNC_STATE_VERSION, "remotes": {}}
|
|
111
|
+
raw_remotes = data.get("remotes")
|
|
112
|
+
if isinstance(raw_remotes, dict):
|
|
113
|
+
for name, rs in raw_remotes.items():
|
|
114
|
+
if not isinstance(rs, dict):
|
|
115
|
+
continue
|
|
116
|
+
out["remotes"][str(name)] = _normalise_remote_state(rs)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalise_remote_state(rs: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
base = _empty_remote_state()
|
|
122
|
+
synced = rs.get("synced")
|
|
123
|
+
if isinstance(synced, dict):
|
|
124
|
+
for k in base["synced"]:
|
|
125
|
+
v = synced.get(k)
|
|
126
|
+
if isinstance(v, list):
|
|
127
|
+
base["synced"][k] = [str(x) for x in v if isinstance(x, str)]
|
|
128
|
+
cursor = rs.get("cursor")
|
|
129
|
+
if isinstance(cursor, dict):
|
|
130
|
+
for k in base["cursor"]:
|
|
131
|
+
v = cursor.get(k)
|
|
132
|
+
if isinstance(v, str):
|
|
133
|
+
base["cursor"][k] = v
|
|
134
|
+
return base
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _save_sync_state(project_id: str, state: dict[str, Any]) -> None:
|
|
138
|
+
ensure_project_dir(project_id)
|
|
139
|
+
p = _sync_state_path(project_id)
|
|
140
|
+
p.write_text(json.dumps(state, indent=2) + "\n")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_remote_state(state: dict[str, Any], rname: str) -> dict[str, Any]:
|
|
144
|
+
remotes = state.setdefault("remotes", {})
|
|
145
|
+
rs = remotes.get(rname)
|
|
146
|
+
if not isinstance(rs, dict):
|
|
147
|
+
rs = _empty_remote_state()
|
|
148
|
+
remotes[rname] = rs
|
|
149
|
+
return rs
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _synced_set(rs: dict[str, Any], key: str) -> set[str]:
|
|
153
|
+
return set(rs["synced"].get(key, []))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _persist_synced_set(rs: dict[str, Any], key: str, ids: set[str]) -> None:
|
|
157
|
+
rs["synced"][key] = sorted(ids)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# -------------------------------------------------------------------
|
|
161
|
+
# Local JSONL readers
|
|
162
|
+
# -------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
165
|
+
if not path.is_file():
|
|
166
|
+
return []
|
|
167
|
+
records = []
|
|
168
|
+
for line in path.read_text().splitlines():
|
|
169
|
+
line = line.strip()
|
|
170
|
+
if line:
|
|
171
|
+
try:
|
|
172
|
+
records.append(json.loads(line))
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
continue
|
|
175
|
+
return records
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _read_traces(project_id: str) -> list[dict[str, Any]]:
|
|
179
|
+
return _read_jsonl(get_traces_path(project_id))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _read_ledgers(project_id: str) -> list[dict[str, Any]]:
|
|
183
|
+
return _read_jsonl(get_ledgers_path(project_id))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_commit_links(project_id: str) -> list[dict[str, Any]]:
|
|
187
|
+
return _read_jsonl(get_commit_links_path(project_id))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -------------------------------------------------------------------
|
|
191
|
+
# Attribution filter
|
|
192
|
+
# -------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def compute_attributed_trace_ids(project_id: str) -> set[str]:
|
|
195
|
+
"""Set of trace IDs referenced by at least one local ledger."""
|
|
196
|
+
result: set[str] = set()
|
|
197
|
+
for ledger in _read_ledgers(project_id):
|
|
198
|
+
for tid in ledger.get("trace_ids", []):
|
|
199
|
+
result.add(tid)
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# -------------------------------------------------------------------
|
|
204
|
+
# HTTP helpers
|
|
205
|
+
# -------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _http_post(url: str, body: Any, token: str | None, timeout: int = 30) -> dict[str, Any]:
|
|
208
|
+
data = json.dumps(body).encode("utf-8")
|
|
209
|
+
req = urllib.request.Request(url, data=data, method="POST")
|
|
210
|
+
req.add_header("Content-Type", "application/json")
|
|
211
|
+
if token:
|
|
212
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
213
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
214
|
+
return json.loads(resp.read().decode())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _http_get(url: str, token: str | None, timeout: int = 30) -> dict[str, Any]:
|
|
218
|
+
req = urllib.request.Request(url, method="GET")
|
|
219
|
+
if token:
|
|
220
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
221
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
222
|
+
return json.loads(resp.read().decode())
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _http_get_bytes(url: str, token: str | None, timeout: int = 60) -> bytes:
|
|
226
|
+
req = urllib.request.Request(url, method="GET")
|
|
227
|
+
if token:
|
|
228
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
229
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
230
|
+
return resp.read()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _http_head_status(url: str, token: str | None, timeout: int = 15) -> int:
|
|
234
|
+
"""Return the HTTP status code of a HEAD request (or the HTTPError code)."""
|
|
235
|
+
req = urllib.request.Request(url, method="HEAD")
|
|
236
|
+
if token:
|
|
237
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
238
|
+
try:
|
|
239
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
240
|
+
return resp.status
|
|
241
|
+
except urllib.error.HTTPError as e:
|
|
242
|
+
return e.code
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _http_post_bytes(
|
|
246
|
+
url: str, body: bytes, token: str | None, *, content_type: str = "application/octet-stream", timeout: int = 60,
|
|
247
|
+
) -> int:
|
|
248
|
+
"""POST a raw byte body. Returns status code; raises HTTPError on >=400."""
|
|
249
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
250
|
+
req.add_header("Content-Type", content_type)
|
|
251
|
+
if token:
|
|
252
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
253
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
254
|
+
return resp.status
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _chunked(items: list[Any], n: int) -> Iterable[list[Any]]:
|
|
258
|
+
for i in range(0, len(items), n):
|
|
259
|
+
yield items[i : i + n]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# -------------------------------------------------------------------
|
|
263
|
+
# Push
|
|
264
|
+
# -------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@dataclass
|
|
267
|
+
class PushResult:
|
|
268
|
+
traces_pushed: int = 0
|
|
269
|
+
ledgers_pushed: int = 0
|
|
270
|
+
commit_links_pushed: int = 0
|
|
271
|
+
conversations_pushed: int = 0
|
|
272
|
+
summaries_pushed: int = 0
|
|
273
|
+
traces_held_back: int = 0
|
|
274
|
+
errors: list[str] = field(default_factory=list)
|
|
275
|
+
dry_run: bool = False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def push(
|
|
279
|
+
project_id: str,
|
|
280
|
+
remote_name: str | None = None,
|
|
281
|
+
*,
|
|
282
|
+
full: bool = False,
|
|
283
|
+
only: str | None = None,
|
|
284
|
+
since: str | None = None,
|
|
285
|
+
dry_run: bool = False,
|
|
286
|
+
) -> PushResult:
|
|
287
|
+
"""Push local items the server hasn't seen yet.
|
|
288
|
+
|
|
289
|
+
``full=True`` includes unattributed traces (those referenced by no local
|
|
290
|
+
ledger). ``only`` restricts to a single resource class. ``since`` is an
|
|
291
|
+
ISO timestamp lower-bound on the item's own timestamp field — useful for
|
|
292
|
+
one-off backfills, but unrelated to the synced manifest.
|
|
293
|
+
"""
|
|
294
|
+
result = PushResult(dry_run=dry_run)
|
|
295
|
+
|
|
296
|
+
rname, rconf = resolve_remote(project_id, remote_name)
|
|
297
|
+
base_url = get_remote_base_url(rconf).rstrip("/")
|
|
298
|
+
wire_project_id = get_remote_project_slug(rconf)
|
|
299
|
+
wire_org_slug = get_remote_org_slug(rconf)
|
|
300
|
+
token = get_remote_token(rconf)
|
|
301
|
+
|
|
302
|
+
if not base_url:
|
|
303
|
+
result.errors.append("Remote has no URL configured.")
|
|
304
|
+
return result
|
|
305
|
+
if not wire_project_id:
|
|
306
|
+
result.errors.append(
|
|
307
|
+
"Remote URL is missing the project path (<scheme>://<host>/<org>/<project>). "
|
|
308
|
+
"Run `agent-trace remote set-url` to fix it."
|
|
309
|
+
)
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
# Pre-flight scope check. The server already gates on the token's org,
|
|
313
|
+
# so a mismatched URL would silently push to the *token's* org rather
|
|
314
|
+
# than the org the user typed. We catch that here so the failure mode
|
|
315
|
+
# is loud and explicit instead of "data went somewhere else".
|
|
316
|
+
if token and wire_org_slug:
|
|
317
|
+
try:
|
|
318
|
+
assert_token_matches_url(
|
|
319
|
+
base_url, token,
|
|
320
|
+
expected_org_slug=wire_org_slug,
|
|
321
|
+
expected_project_slug=wire_project_id,
|
|
322
|
+
)
|
|
323
|
+
except TokenScopeError as e:
|
|
324
|
+
result.errors.append(f"scope check ({e.code}): {e}")
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
attributed_ids = compute_attributed_trace_ids(project_id) if not full else None
|
|
328
|
+
|
|
329
|
+
sync_state = _load_sync_state(project_id)
|
|
330
|
+
rs = _get_remote_state(sync_state, rname)
|
|
331
|
+
|
|
332
|
+
# --- Traces ---
|
|
333
|
+
if only is None or only == "traces":
|
|
334
|
+
synced_ids = _synced_set(rs, "trace_ids")
|
|
335
|
+
traces = _read_traces(project_id)
|
|
336
|
+
held_back = 0
|
|
337
|
+
to_push: list[dict[str, Any]] = []
|
|
338
|
+
for t in traces:
|
|
339
|
+
tid = t.get("id", "")
|
|
340
|
+
if not tid:
|
|
341
|
+
continue
|
|
342
|
+
if tid in synced_ids:
|
|
343
|
+
continue
|
|
344
|
+
if since and t.get("timestamp", "") < since:
|
|
345
|
+
continue
|
|
346
|
+
if attributed_ids is not None and tid not in attributed_ids:
|
|
347
|
+
held_back += 1
|
|
348
|
+
continue
|
|
349
|
+
to_push.append(t)
|
|
350
|
+
|
|
351
|
+
result.traces_held_back = held_back
|
|
352
|
+
|
|
353
|
+
if to_push and not dry_run:
|
|
354
|
+
pushed_ids = _push_batched(
|
|
355
|
+
f"{base_url}/api/v1/sync/traces",
|
|
356
|
+
wire_project_id,
|
|
357
|
+
to_push,
|
|
358
|
+
token,
|
|
359
|
+
id_of=lambda t: t.get("id", ""),
|
|
360
|
+
error_label="traces",
|
|
361
|
+
errors=result.errors,
|
|
362
|
+
)
|
|
363
|
+
synced_ids.update(pushed_ids)
|
|
364
|
+
_persist_synced_set(rs, "trace_ids", synced_ids)
|
|
365
|
+
result.traces_pushed = len(pushed_ids)
|
|
366
|
+
elif to_push:
|
|
367
|
+
result.traces_pushed = len(to_push)
|
|
368
|
+
|
|
369
|
+
# --- Ledgers ---
|
|
370
|
+
if only is None or only == "ledgers":
|
|
371
|
+
synced_shas = _synced_set(rs, "ledger_shas")
|
|
372
|
+
ledgers = _read_ledgers(project_id)
|
|
373
|
+
to_push_l: list[dict[str, Any]] = []
|
|
374
|
+
for led in ledgers:
|
|
375
|
+
sha = led.get("commit_sha", "")
|
|
376
|
+
if not sha or sha in synced_shas:
|
|
377
|
+
continue
|
|
378
|
+
if since:
|
|
379
|
+
ca = led.get("committed_at") or led.get("created_at", "")
|
|
380
|
+
if ca and ca < since:
|
|
381
|
+
continue
|
|
382
|
+
to_push_l.append(led)
|
|
383
|
+
|
|
384
|
+
if to_push_l and not dry_run:
|
|
385
|
+
pushed_shas = _push_batched(
|
|
386
|
+
f"{base_url}/api/v1/sync/ledgers",
|
|
387
|
+
wire_project_id,
|
|
388
|
+
to_push_l,
|
|
389
|
+
token,
|
|
390
|
+
id_of=lambda l: l.get("commit_sha", ""),
|
|
391
|
+
error_label="ledgers",
|
|
392
|
+
errors=result.errors,
|
|
393
|
+
)
|
|
394
|
+
synced_shas.update(pushed_shas)
|
|
395
|
+
_persist_synced_set(rs, "ledger_shas", synced_shas)
|
|
396
|
+
result.ledgers_pushed = len(pushed_shas)
|
|
397
|
+
elif to_push_l:
|
|
398
|
+
result.ledgers_pushed = len(to_push_l)
|
|
399
|
+
|
|
400
|
+
# --- Commit links ---
|
|
401
|
+
if only is None or only == "commit-links":
|
|
402
|
+
synced_shas = _synced_set(rs, "commit_link_shas")
|
|
403
|
+
links = _read_commit_links(project_id)
|
|
404
|
+
to_push_c: list[dict[str, Any]] = []
|
|
405
|
+
for cl in links:
|
|
406
|
+
sha = cl.get("commit_sha", "")
|
|
407
|
+
if not sha or sha in synced_shas:
|
|
408
|
+
continue
|
|
409
|
+
if since:
|
|
410
|
+
ca = cl.get("committed_at") or cl.get("created_at", "")
|
|
411
|
+
if ca and ca < since:
|
|
412
|
+
continue
|
|
413
|
+
to_push_c.append(cl)
|
|
414
|
+
|
|
415
|
+
if to_push_c and not dry_run:
|
|
416
|
+
pushed_shas = _push_batched(
|
|
417
|
+
f"{base_url}/api/v1/sync/commit-links",
|
|
418
|
+
wire_project_id,
|
|
419
|
+
to_push_c,
|
|
420
|
+
token,
|
|
421
|
+
id_of=lambda c: c.get("commit_sha", ""),
|
|
422
|
+
error_label="commit-links",
|
|
423
|
+
errors=result.errors,
|
|
424
|
+
)
|
|
425
|
+
synced_shas.update(pushed_shas)
|
|
426
|
+
_persist_synced_set(rs, "commit_link_shas", synced_shas)
|
|
427
|
+
result.commit_links_pushed = len(pushed_shas)
|
|
428
|
+
elif to_push_c:
|
|
429
|
+
result.commit_links_pushed = len(to_push_c)
|
|
430
|
+
|
|
431
|
+
# --- Conversations (chunked, hash-addressed) ---
|
|
432
|
+
if only is None or only == "conversations":
|
|
433
|
+
_push_conversations(
|
|
434
|
+
project_id=project_id,
|
|
435
|
+
wire_project_id=wire_project_id,
|
|
436
|
+
base_url=base_url,
|
|
437
|
+
token=token,
|
|
438
|
+
rs=rs,
|
|
439
|
+
since=since,
|
|
440
|
+
dry_run=dry_run,
|
|
441
|
+
result=result,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# --- Summaries ---
|
|
445
|
+
if only is None or only == "summaries":
|
|
446
|
+
_push_summaries(
|
|
447
|
+
project_id=project_id,
|
|
448
|
+
wire_project_id=wire_project_id,
|
|
449
|
+
base_url=base_url,
|
|
450
|
+
token=token,
|
|
451
|
+
rs=rs,
|
|
452
|
+
since=since,
|
|
453
|
+
dry_run=dry_run,
|
|
454
|
+
result=result,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if not dry_run:
|
|
458
|
+
_save_sync_state(project_id, sync_state)
|
|
459
|
+
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _push_batched(
|
|
464
|
+
url: str,
|
|
465
|
+
wire_project_id: str,
|
|
466
|
+
items: list[dict[str, Any]],
|
|
467
|
+
token: str | None,
|
|
468
|
+
*,
|
|
469
|
+
id_of,
|
|
470
|
+
error_label: str,
|
|
471
|
+
errors: list[str],
|
|
472
|
+
) -> set[str]:
|
|
473
|
+
"""POST items in PUSH_BATCH_SIZE chunks. Returns set of IDs whose batch
|
|
474
|
+
was acknowledged by the server. A failed batch leaves its IDs unsynced
|
|
475
|
+
so the next push retries them."""
|
|
476
|
+
acked: set[str] = set()
|
|
477
|
+
for batch in _chunked(items, PUSH_BATCH_SIZE):
|
|
478
|
+
try:
|
|
479
|
+
_http_post(url, {"project_id": wire_project_id, "items": batch}, token)
|
|
480
|
+
except Exception as e:
|
|
481
|
+
errors.append(f"{error_label}: {e}")
|
|
482
|
+
continue
|
|
483
|
+
for item in batch:
|
|
484
|
+
iid = id_of(item)
|
|
485
|
+
if iid:
|
|
486
|
+
acked.add(iid)
|
|
487
|
+
return acked
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _push_conversations(
|
|
491
|
+
*,
|
|
492
|
+
project_id: str,
|
|
493
|
+
wire_project_id: str,
|
|
494
|
+
base_url: str,
|
|
495
|
+
token: str | None,
|
|
496
|
+
rs: dict[str, Any],
|
|
497
|
+
since: str | None,
|
|
498
|
+
dry_run: bool,
|
|
499
|
+
result: PushResult,
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Push transcript blobs and pointers, deduping by SHA-256.
|
|
502
|
+
|
|
503
|
+
A conversation is "synced" when both its blob (sha) and its pointer
|
|
504
|
+
(conversation_id) are confirmed on the server. We track them separately:
|
|
505
|
+
|
|
506
|
+
- ``synced.blob_shas`` — bytes are present on the server.
|
|
507
|
+
- ``synced.conversation_ids`` — pointer row exists on the server.
|
|
508
|
+
|
|
509
|
+
Inline (size <= chunk threshold) conversations carry their bytes in the
|
|
510
|
+
pointer POST, so a successful pointer push implies the blob is present
|
|
511
|
+
too. Chunked conversations upload the blob first (HEAD/POST blob), then
|
|
512
|
+
send a pointer-only POST.
|
|
513
|
+
"""
|
|
514
|
+
blobs = enumerate_local_blobs(project_id)
|
|
515
|
+
synced_blob_shas = _synced_set(rs, "blob_shas")
|
|
516
|
+
synced_conv_ids = _synced_set(rs, "conversation_ids")
|
|
517
|
+
|
|
518
|
+
pending: list[ConversationBlob] = []
|
|
519
|
+
for b in blobs:
|
|
520
|
+
if b.conversation_id in synced_conv_ids and b.content_sha256 in synced_blob_shas:
|
|
521
|
+
continue
|
|
522
|
+
if since and b.mtime < since:
|
|
523
|
+
continue
|
|
524
|
+
pending.append(b)
|
|
525
|
+
|
|
526
|
+
if not pending:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
if dry_run:
|
|
530
|
+
result.conversations_pushed = len(pending)
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
items: list[dict[str, Any]] = []
|
|
534
|
+
item_conv_ids: list[str] = []
|
|
535
|
+
item_blob_shas: list[str] = []
|
|
536
|
+
chunked_supported = True
|
|
537
|
+
|
|
538
|
+
for b in pending:
|
|
539
|
+
cache_path = cache_path_for_sha(project_id, b.content_sha256)
|
|
540
|
+
item: dict[str, Any] = {
|
|
541
|
+
"conversation_id": b.conversation_id,
|
|
542
|
+
"content_sha256": b.content_sha256,
|
|
543
|
+
"size": b.size,
|
|
544
|
+
"updated_at": b.mtime,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
send_inline = not b.is_chunked()
|
|
548
|
+
|
|
549
|
+
if not send_inline and chunked_supported:
|
|
550
|
+
if b.content_sha256 in synced_blob_shas:
|
|
551
|
+
pass # Already-synced blob — pointer-only.
|
|
552
|
+
else:
|
|
553
|
+
blob_url = f"{base_url}/api/v1/blobs/{b.content_sha256}"
|
|
554
|
+
try:
|
|
555
|
+
head_status = _http_head_status(blob_url, token)
|
|
556
|
+
except Exception as e:
|
|
557
|
+
result.errors.append(f"conversations: HEAD {b.content_sha256[:12]}: {e}")
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
if head_status == 200:
|
|
561
|
+
synced_blob_shas.add(b.content_sha256)
|
|
562
|
+
else:
|
|
563
|
+
try:
|
|
564
|
+
with open(cache_path, "rb") as f:
|
|
565
|
+
raw = f.read()
|
|
566
|
+
_http_post_bytes(f"{base_url}/api/v1/blobs", raw, token)
|
|
567
|
+
synced_blob_shas.add(b.content_sha256)
|
|
568
|
+
except urllib.error.HTTPError as e:
|
|
569
|
+
if e.code in (404, 405):
|
|
570
|
+
chunked_supported = False
|
|
571
|
+
send_inline = True
|
|
572
|
+
else:
|
|
573
|
+
result.errors.append(
|
|
574
|
+
f"conversations: POST blob {b.content_sha256[:12]}: {e}"
|
|
575
|
+
)
|
|
576
|
+
continue
|
|
577
|
+
except OSError as e:
|
|
578
|
+
result.errors.append(f"conversations: read cache {b.content_sha256[:12]}: {e}")
|
|
579
|
+
continue
|
|
580
|
+
except Exception as e:
|
|
581
|
+
result.errors.append(
|
|
582
|
+
f"conversations: POST blob {b.content_sha256[:12]}: {e}"
|
|
583
|
+
)
|
|
584
|
+
continue
|
|
585
|
+
elif not send_inline and not chunked_supported:
|
|
586
|
+
send_inline = True
|
|
587
|
+
|
|
588
|
+
if send_inline:
|
|
589
|
+
try:
|
|
590
|
+
with open(cache_path, "rb") as f:
|
|
591
|
+
raw = f.read()
|
|
592
|
+
except OSError as e:
|
|
593
|
+
result.errors.append(f"conversations: read cache {b.content_sha256[:12]}: {e}")
|
|
594
|
+
continue
|
|
595
|
+
try:
|
|
596
|
+
item["content"] = raw.decode("utf-8")
|
|
597
|
+
except UnicodeDecodeError:
|
|
598
|
+
import base64
|
|
599
|
+
item["content_b64"] = base64.b64encode(raw).decode("ascii")
|
|
600
|
+
|
|
601
|
+
items.append(item)
|
|
602
|
+
item_conv_ids.append(b.conversation_id)
|
|
603
|
+
item_blob_shas.append(b.content_sha256)
|
|
604
|
+
|
|
605
|
+
if not items:
|
|
606
|
+
_persist_synced_set(rs, "blob_shas", synced_blob_shas)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
pushed_count = 0
|
|
610
|
+
for start in range(0, len(items), PUSH_BATCH_SIZE):
|
|
611
|
+
batch = items[start : start + PUSH_BATCH_SIZE]
|
|
612
|
+
batch_conv_ids = item_conv_ids[start : start + PUSH_BATCH_SIZE]
|
|
613
|
+
batch_blob_shas = item_blob_shas[start : start + PUSH_BATCH_SIZE]
|
|
614
|
+
try:
|
|
615
|
+
_http_post(
|
|
616
|
+
f"{base_url}/api/v1/sync/conversations",
|
|
617
|
+
{"project_id": wire_project_id, "items": batch},
|
|
618
|
+
token,
|
|
619
|
+
)
|
|
620
|
+
except Exception as e:
|
|
621
|
+
result.errors.append(f"conversations: {e}")
|
|
622
|
+
continue
|
|
623
|
+
synced_conv_ids.update(batch_conv_ids)
|
|
624
|
+
synced_blob_shas.update(batch_blob_shas)
|
|
625
|
+
pushed_count += len(batch)
|
|
626
|
+
|
|
627
|
+
_persist_synced_set(rs, "blob_shas", synced_blob_shas)
|
|
628
|
+
_persist_synced_set(rs, "conversation_ids", synced_conv_ids)
|
|
629
|
+
result.conversations_pushed = pushed_count
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _summary_row_key(row: dict[str, Any]) -> str:
|
|
633
|
+
cid = str(row.get("conversation_id") or "")
|
|
634
|
+
ts = str(row.get("created_at") or "")
|
|
635
|
+
return f"{cid}:{ts}"
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _push_summaries(
|
|
639
|
+
*,
|
|
640
|
+
project_id: str,
|
|
641
|
+
wire_project_id: str,
|
|
642
|
+
base_url: str,
|
|
643
|
+
token: str | None,
|
|
644
|
+
rs: dict[str, Any],
|
|
645
|
+
since: str | None,
|
|
646
|
+
dry_run: bool,
|
|
647
|
+
result: PushResult,
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Push summary rows (``session-summaries.jsonl``) the server hasn't seen yet."""
|
|
650
|
+
from .summary import iter_summary_rows
|
|
651
|
+
|
|
652
|
+
rows = iter_summary_rows(project_id)
|
|
653
|
+
if not rows:
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
synced_keys = _synced_set(rs, "summary_keys")
|
|
657
|
+
pending: list[dict[str, Any]] = []
|
|
658
|
+
for row in rows:
|
|
659
|
+
cid = str(row.get("conversation_id") or "")
|
|
660
|
+
ts = str(row.get("created_at") or "")
|
|
661
|
+
if not cid or not ts:
|
|
662
|
+
continue
|
|
663
|
+
key = f"{cid}:{ts}"
|
|
664
|
+
if key in synced_keys:
|
|
665
|
+
continue
|
|
666
|
+
if since and ts < since:
|
|
667
|
+
continue
|
|
668
|
+
pending.append(row)
|
|
669
|
+
|
|
670
|
+
if not pending:
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
if dry_run:
|
|
674
|
+
result.summaries_pushed = len(pending)
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
pushed_count = 0
|
|
678
|
+
for start in range(0, len(pending), PUSH_BATCH_SIZE):
|
|
679
|
+
batch = pending[start : start + PUSH_BATCH_SIZE]
|
|
680
|
+
try:
|
|
681
|
+
_http_post(
|
|
682
|
+
f"{base_url}/api/v1/sync/summaries",
|
|
683
|
+
{"project_id": wire_project_id, "items": batch},
|
|
684
|
+
token,
|
|
685
|
+
)
|
|
686
|
+
except urllib.error.HTTPError as e:
|
|
687
|
+
if e.code == 404:
|
|
688
|
+
# Older service without the summaries endpoint. Stop the leg
|
|
689
|
+
# cleanly so push doesn't bail on otherwise-good batches.
|
|
690
|
+
result.errors.append("summaries: endpoint not implemented (server too old)")
|
|
691
|
+
break
|
|
692
|
+
result.errors.append(f"summaries: {e}")
|
|
693
|
+
continue
|
|
694
|
+
except Exception as e:
|
|
695
|
+
result.errors.append(f"summaries: {e}")
|
|
696
|
+
continue
|
|
697
|
+
for row in batch:
|
|
698
|
+
synced_keys.add(_summary_row_key(row))
|
|
699
|
+
pushed_count += len(batch)
|
|
700
|
+
|
|
701
|
+
_persist_synced_set(rs, "summary_keys", synced_keys)
|
|
702
|
+
result.summaries_pushed = pushed_count
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# -------------------------------------------------------------------
|
|
706
|
+
# Pull
|
|
707
|
+
# -------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
@dataclass
|
|
710
|
+
class PullResult:
|
|
711
|
+
traces_pulled: int = 0
|
|
712
|
+
ledgers_pulled: int = 0
|
|
713
|
+
commit_links_pulled: int = 0
|
|
714
|
+
conversations_pulled: int = 0
|
|
715
|
+
summaries_pulled: int = 0
|
|
716
|
+
errors: list[str] = field(default_factory=list)
|
|
717
|
+
dry_run: bool = False
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _append_jsonl_dedupe(path: Path, records: list[dict], key: str = "id") -> int:
|
|
721
|
+
"""Append records to a JSONL file, skipping existing keys."""
|
|
722
|
+
existing_keys: set[str] = set()
|
|
723
|
+
if path.is_file():
|
|
724
|
+
for line in path.read_text().splitlines():
|
|
725
|
+
line = line.strip()
|
|
726
|
+
if line:
|
|
727
|
+
try:
|
|
728
|
+
k = json.loads(line).get(key, "")
|
|
729
|
+
if k:
|
|
730
|
+
existing_keys.add(k)
|
|
731
|
+
except json.JSONDecodeError:
|
|
732
|
+
continue
|
|
733
|
+
|
|
734
|
+
added = 0
|
|
735
|
+
with open(path, "a") as f:
|
|
736
|
+
for rec in records:
|
|
737
|
+
k = rec.get(key, "")
|
|
738
|
+
if k and k in existing_keys:
|
|
739
|
+
continue
|
|
740
|
+
f.write(json.dumps(rec) + "\n")
|
|
741
|
+
added += 1
|
|
742
|
+
if k:
|
|
743
|
+
existing_keys.add(k)
|
|
744
|
+
return added
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _pull_paginated(
|
|
748
|
+
*,
|
|
749
|
+
base_url: str,
|
|
750
|
+
path: str,
|
|
751
|
+
wire_project_id: str,
|
|
752
|
+
token: str | None,
|
|
753
|
+
cursor: str | None,
|
|
754
|
+
error_label: str,
|
|
755
|
+
errors: list[str],
|
|
756
|
+
) -> tuple[list[dict[str, Any]], str | None]:
|
|
757
|
+
"""Walk pages of ``GET <path>?since=<cursor>`` until a short page lands.
|
|
758
|
+
|
|
759
|
+
Returns ``(all_items, new_cursor)``. ``new_cursor`` is the last
|
|
760
|
+
``max_timestamp`` returned by the server (or the original cursor if no
|
|
761
|
+
items came back).
|
|
762
|
+
"""
|
|
763
|
+
all_items: list[dict[str, Any]] = []
|
|
764
|
+
next_cursor = cursor
|
|
765
|
+
while True:
|
|
766
|
+
params: dict[str, str] = {"project_id": wire_project_id, "limit": str(PULL_PAGE_LIMIT)}
|
|
767
|
+
if next_cursor:
|
|
768
|
+
params["since"] = next_cursor
|
|
769
|
+
qs = urllib.parse.urlencode(params)
|
|
770
|
+
try:
|
|
771
|
+
data = _http_get(f"{base_url}{path}?{qs}", token)
|
|
772
|
+
except urllib.error.HTTPError as e:
|
|
773
|
+
if e.code == 404:
|
|
774
|
+
# Endpoint not implemented (e.g., legacy server, conversations).
|
|
775
|
+
raise
|
|
776
|
+
errors.append(f"{error_label}: {e}")
|
|
777
|
+
break
|
|
778
|
+
except Exception as e:
|
|
779
|
+
errors.append(f"{error_label}: {e}")
|
|
780
|
+
break
|
|
781
|
+
|
|
782
|
+
items = data.get("items") or data.get("traces") or []
|
|
783
|
+
max_ts = data.get("max_timestamp")
|
|
784
|
+
if not items:
|
|
785
|
+
break
|
|
786
|
+
|
|
787
|
+
all_items.extend(items)
|
|
788
|
+
if isinstance(max_ts, str) and max_ts:
|
|
789
|
+
next_cursor = max_ts
|
|
790
|
+
if len(items) < PULL_PAGE_LIMIT:
|
|
791
|
+
break
|
|
792
|
+
if not isinstance(max_ts, str) or not max_ts:
|
|
793
|
+
# Server didn't advance the cursor — stop to avoid infinite loop.
|
|
794
|
+
break
|
|
795
|
+
return all_items, next_cursor
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def pull(
|
|
799
|
+
project_id: str,
|
|
800
|
+
remote_name: str | None = None,
|
|
801
|
+
*,
|
|
802
|
+
since: str | None = None,
|
|
803
|
+
dry_run: bool = False,
|
|
804
|
+
) -> PullResult:
|
|
805
|
+
"""Pull remote data into local storage.
|
|
806
|
+
|
|
807
|
+
Paginates each resource until the server returns a short page, advances
|
|
808
|
+
the per-resource cursor to the server-returned ``max_timestamp``, and
|
|
809
|
+
adds every received ID to the synced manifest so subsequent status /
|
|
810
|
+
push calls don't re-treat them as local-only.
|
|
811
|
+
|
|
812
|
+
``since`` overrides the stored cursor for this run only — useful for
|
|
813
|
+
backfilling after manifest loss.
|
|
814
|
+
"""
|
|
815
|
+
result = PullResult(dry_run=dry_run)
|
|
816
|
+
|
|
817
|
+
rname, rconf = resolve_remote(project_id, remote_name)
|
|
818
|
+
base_url = get_remote_base_url(rconf).rstrip("/")
|
|
819
|
+
wire_project_id = get_remote_project_slug(rconf)
|
|
820
|
+
wire_org_slug = get_remote_org_slug(rconf)
|
|
821
|
+
token = get_remote_token(rconf)
|
|
822
|
+
|
|
823
|
+
if not base_url:
|
|
824
|
+
result.errors.append("Remote has no URL configured.")
|
|
825
|
+
return result
|
|
826
|
+
if not wire_project_id:
|
|
827
|
+
result.errors.append(
|
|
828
|
+
"Remote URL is missing the project path (<scheme>://<host>/<org>/<project>)."
|
|
829
|
+
)
|
|
830
|
+
return result
|
|
831
|
+
|
|
832
|
+
# Same pre-flight as ``push``: refuse to pull if the bound URL's org
|
|
833
|
+
# disagrees with the token's actual org. Avoids "I pulled from /foo-org
|
|
834
|
+
# but rows came from /bar-org" surprises.
|
|
835
|
+
if token and wire_org_slug:
|
|
836
|
+
try:
|
|
837
|
+
assert_token_matches_url(
|
|
838
|
+
base_url, token,
|
|
839
|
+
expected_org_slug=wire_org_slug,
|
|
840
|
+
expected_project_slug=wire_project_id,
|
|
841
|
+
)
|
|
842
|
+
except TokenScopeError as e:
|
|
843
|
+
result.errors.append(f"scope check ({e.code}): {e}")
|
|
844
|
+
return result
|
|
845
|
+
|
|
846
|
+
sync_state = _load_sync_state(project_id)
|
|
847
|
+
rs = _get_remote_state(sync_state, rname)
|
|
848
|
+
|
|
849
|
+
ensure_project_dir(project_id)
|
|
850
|
+
|
|
851
|
+
def cursor_for(name: str) -> str | None:
|
|
852
|
+
return since if since else rs["cursor"].get(name)
|
|
853
|
+
|
|
854
|
+
# --- Traces ---
|
|
855
|
+
try:
|
|
856
|
+
items, new_cursor = _pull_paginated(
|
|
857
|
+
base_url=base_url,
|
|
858
|
+
path="/api/v1/sync/traces",
|
|
859
|
+
wire_project_id=wire_project_id,
|
|
860
|
+
token=token,
|
|
861
|
+
cursor=cursor_for("traces"),
|
|
862
|
+
error_label="traces",
|
|
863
|
+
errors=result.errors,
|
|
864
|
+
)
|
|
865
|
+
if items and not dry_run:
|
|
866
|
+
result.traces_pulled = _append_jsonl_dedupe(
|
|
867
|
+
get_traces_path(project_id), items, key="id",
|
|
868
|
+
)
|
|
869
|
+
ids = {t.get("id", "") for t in items if t.get("id")}
|
|
870
|
+
synced = _synced_set(rs, "trace_ids")
|
|
871
|
+
synced.update(ids)
|
|
872
|
+
_persist_synced_set(rs, "trace_ids", synced)
|
|
873
|
+
if new_cursor:
|
|
874
|
+
rs["cursor"]["traces"] = new_cursor
|
|
875
|
+
elif items:
|
|
876
|
+
result.traces_pulled = len(items)
|
|
877
|
+
except Exception as e:
|
|
878
|
+
result.errors.append(f"traces: {e}")
|
|
879
|
+
|
|
880
|
+
# --- Ledgers ---
|
|
881
|
+
try:
|
|
882
|
+
items, new_cursor = _pull_paginated(
|
|
883
|
+
base_url=base_url,
|
|
884
|
+
path="/api/v1/sync/ledgers",
|
|
885
|
+
wire_project_id=wire_project_id,
|
|
886
|
+
token=token,
|
|
887
|
+
cursor=cursor_for("ledgers"),
|
|
888
|
+
error_label="ledgers",
|
|
889
|
+
errors=result.errors,
|
|
890
|
+
)
|
|
891
|
+
if items and not dry_run:
|
|
892
|
+
result.ledgers_pulled = _append_jsonl_dedupe(
|
|
893
|
+
get_ledgers_path(project_id), items, key="commit_sha",
|
|
894
|
+
)
|
|
895
|
+
shas = {l.get("commit_sha", "") for l in items if l.get("commit_sha")}
|
|
896
|
+
synced = _synced_set(rs, "ledger_shas")
|
|
897
|
+
synced.update(shas)
|
|
898
|
+
_persist_synced_set(rs, "ledger_shas", synced)
|
|
899
|
+
if new_cursor:
|
|
900
|
+
rs["cursor"]["ledgers"] = new_cursor
|
|
901
|
+
elif items:
|
|
902
|
+
result.ledgers_pulled = len(items)
|
|
903
|
+
except Exception as e:
|
|
904
|
+
result.errors.append(f"ledgers: {e}")
|
|
905
|
+
|
|
906
|
+
# --- Commit links ---
|
|
907
|
+
try:
|
|
908
|
+
items, new_cursor = _pull_paginated(
|
|
909
|
+
base_url=base_url,
|
|
910
|
+
path="/api/v1/sync/commit-links",
|
|
911
|
+
wire_project_id=wire_project_id,
|
|
912
|
+
token=token,
|
|
913
|
+
cursor=cursor_for("commit_links"),
|
|
914
|
+
error_label="commit-links",
|
|
915
|
+
errors=result.errors,
|
|
916
|
+
)
|
|
917
|
+
if items and not dry_run:
|
|
918
|
+
result.commit_links_pulled = _append_jsonl_dedupe(
|
|
919
|
+
get_commit_links_path(project_id), items, key="commit_sha",
|
|
920
|
+
)
|
|
921
|
+
shas = {c.get("commit_sha", "") for c in items if c.get("commit_sha")}
|
|
922
|
+
synced = _synced_set(rs, "commit_link_shas")
|
|
923
|
+
synced.update(shas)
|
|
924
|
+
_persist_synced_set(rs, "commit_link_shas", synced)
|
|
925
|
+
if new_cursor:
|
|
926
|
+
rs["cursor"]["commit_links"] = new_cursor
|
|
927
|
+
elif items:
|
|
928
|
+
result.commit_links_pulled = len(items)
|
|
929
|
+
except Exception as e:
|
|
930
|
+
result.errors.append(f"commit-links: {e}")
|
|
931
|
+
|
|
932
|
+
# --- Conversations ---
|
|
933
|
+
try:
|
|
934
|
+
items, new_cursor = _pull_paginated(
|
|
935
|
+
base_url=base_url,
|
|
936
|
+
path="/api/v1/sync/conversations",
|
|
937
|
+
wire_project_id=wire_project_id,
|
|
938
|
+
token=token,
|
|
939
|
+
cursor=cursor_for("conversations"),
|
|
940
|
+
error_label="conversations",
|
|
941
|
+
errors=result.errors,
|
|
942
|
+
)
|
|
943
|
+
if items and not dry_run:
|
|
944
|
+
result.conversations_pulled = _materialize_conversations(
|
|
945
|
+
project_id=project_id,
|
|
946
|
+
base_url=base_url,
|
|
947
|
+
token=token,
|
|
948
|
+
items=items,
|
|
949
|
+
rs=rs,
|
|
950
|
+
errors=result.errors,
|
|
951
|
+
)
|
|
952
|
+
if new_cursor:
|
|
953
|
+
rs["cursor"]["conversations"] = new_cursor
|
|
954
|
+
elif items:
|
|
955
|
+
result.conversations_pulled = len(items)
|
|
956
|
+
except urllib.error.HTTPError as e:
|
|
957
|
+
# Older services without the conversations endpoint return 404.
|
|
958
|
+
if e.code != 404:
|
|
959
|
+
result.errors.append(f"conversations: {e}")
|
|
960
|
+
except Exception as e:
|
|
961
|
+
result.errors.append(f"conversations: {e}")
|
|
962
|
+
|
|
963
|
+
# --- Summaries ---
|
|
964
|
+
try:
|
|
965
|
+
items, new_cursor = _pull_paginated(
|
|
966
|
+
base_url=base_url,
|
|
967
|
+
path="/api/v1/sync/summaries",
|
|
968
|
+
wire_project_id=wire_project_id,
|
|
969
|
+
token=token,
|
|
970
|
+
cursor=cursor_for("summaries"),
|
|
971
|
+
error_label="summaries",
|
|
972
|
+
errors=result.errors,
|
|
973
|
+
)
|
|
974
|
+
if items and not dry_run:
|
|
975
|
+
result.summaries_pulled = _materialize_summaries(
|
|
976
|
+
project_id=project_id,
|
|
977
|
+
items=items,
|
|
978
|
+
rs=rs,
|
|
979
|
+
errors=result.errors,
|
|
980
|
+
)
|
|
981
|
+
if new_cursor:
|
|
982
|
+
rs["cursor"]["summaries"] = new_cursor
|
|
983
|
+
elif items:
|
|
984
|
+
result.summaries_pulled = len(items)
|
|
985
|
+
except urllib.error.HTTPError as e:
|
|
986
|
+
if e.code != 404:
|
|
987
|
+
result.errors.append(f"summaries: {e}")
|
|
988
|
+
except Exception as e:
|
|
989
|
+
result.errors.append(f"summaries: {e}")
|
|
990
|
+
|
|
991
|
+
if not dry_run:
|
|
992
|
+
_save_sync_state(project_id, sync_state)
|
|
993
|
+
|
|
994
|
+
return result
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _materialize_conversations(
|
|
998
|
+
*,
|
|
999
|
+
project_id: str,
|
|
1000
|
+
base_url: str,
|
|
1001
|
+
token: str | None,
|
|
1002
|
+
items: list[dict[str, Any]],
|
|
1003
|
+
rs: dict[str, Any],
|
|
1004
|
+
errors: list[str],
|
|
1005
|
+
) -> int:
|
|
1006
|
+
"""Write each pulled conversation blob into the local content-addressed
|
|
1007
|
+
cache and add its sha + conversation_id to the synced manifest. Inline
|
|
1008
|
+
content is used when present; chunked blobs are fetched from
|
|
1009
|
+
``GET /api/v1/blobs/<sha>``. Returns count of blobs newly written.
|
|
1010
|
+
"""
|
|
1011
|
+
written = 0
|
|
1012
|
+
synced_blob_shas = _synced_set(rs, "blob_shas")
|
|
1013
|
+
synced_conv_ids = _synced_set(rs, "conversation_ids")
|
|
1014
|
+
|
|
1015
|
+
for item in items:
|
|
1016
|
+
sha = item.get("content_sha256")
|
|
1017
|
+
if isinstance(sha, str) and sha:
|
|
1018
|
+
cache_path = cache_path_for_sha(project_id, sha)
|
|
1019
|
+
if not cache_path.is_file():
|
|
1020
|
+
raw: bytes | None = None
|
|
1021
|
+
if isinstance(item.get("content"), str):
|
|
1022
|
+
raw = item["content"].encode("utf-8")
|
|
1023
|
+
elif isinstance(item.get("content_b64"), str):
|
|
1024
|
+
import base64
|
|
1025
|
+
try:
|
|
1026
|
+
raw = base64.b64decode(item["content_b64"])
|
|
1027
|
+
except Exception as e:
|
|
1028
|
+
errors.append(f"conversations: bad b64 for {sha[:12]}: {e}")
|
|
1029
|
+
continue
|
|
1030
|
+
else:
|
|
1031
|
+
try:
|
|
1032
|
+
raw = _http_get_bytes(f"{base_url}/api/v1/blobs/{sha}", token)
|
|
1033
|
+
except Exception as e:
|
|
1034
|
+
errors.append(f"conversations: GET blob {sha[:12]}: {e}")
|
|
1035
|
+
continue
|
|
1036
|
+
|
|
1037
|
+
try:
|
|
1038
|
+
write_blob_to_cache(project_id, sha, raw)
|
|
1039
|
+
written += 1
|
|
1040
|
+
except ValueError as e:
|
|
1041
|
+
errors.append(f"conversations: {e}")
|
|
1042
|
+
continue
|
|
1043
|
+
except OSError as e:
|
|
1044
|
+
errors.append(f"conversations: write {sha[:12]}: {e}")
|
|
1045
|
+
continue
|
|
1046
|
+
synced_blob_shas.add(sha)
|
|
1047
|
+
|
|
1048
|
+
cid = item.get("conversation_id")
|
|
1049
|
+
if isinstance(cid, str) and cid:
|
|
1050
|
+
synced_conv_ids.add(cid)
|
|
1051
|
+
# Server delivers items in updated_at ASC order, so the last
|
|
1052
|
+
# pair we see for a given conversation_id is the freshest snapshot.
|
|
1053
|
+
if isinstance(sha, str) and sha:
|
|
1054
|
+
update_conversation_index(project_id, cid, sha)
|
|
1055
|
+
|
|
1056
|
+
_persist_synced_set(rs, "blob_shas", synced_blob_shas)
|
|
1057
|
+
_persist_synced_set(rs, "conversation_ids", synced_conv_ids)
|
|
1058
|
+
return written
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _materialize_summaries(
|
|
1062
|
+
*,
|
|
1063
|
+
project_id: str,
|
|
1064
|
+
items: list[dict[str, Any]],
|
|
1065
|
+
rs: dict[str, Any],
|
|
1066
|
+
errors: list[str],
|
|
1067
|
+
) -> int:
|
|
1068
|
+
"""Append pulled summary rows to ``session-summaries.jsonl``.
|
|
1069
|
+
|
|
1070
|
+
Dedup by synthetic ``<conversation_id>:<created_at>`` key — that pair is
|
|
1071
|
+
the natural unique identifier (the same conversation can have multiple
|
|
1072
|
+
regenerated summaries over time, each with its own timestamp).
|
|
1073
|
+
"""
|
|
1074
|
+
from .summary import append_summary, iter_summary_rows
|
|
1075
|
+
|
|
1076
|
+
synced_keys = _synced_set(rs, "summary_keys")
|
|
1077
|
+
existing_keys = {
|
|
1078
|
+
_summary_row_key(row) for row in iter_summary_rows(project_id)
|
|
1079
|
+
}
|
|
1080
|
+
written = 0
|
|
1081
|
+
for item in items:
|
|
1082
|
+
if not isinstance(item, dict):
|
|
1083
|
+
continue
|
|
1084
|
+
cid = item.get("conversation_id")
|
|
1085
|
+
summary = item.get("summary")
|
|
1086
|
+
if not isinstance(cid, str) or not isinstance(summary, str) or not cid or not summary:
|
|
1087
|
+
continue
|
|
1088
|
+
created_at = item.get("created_at") or item.get("updated_at")
|
|
1089
|
+
if not isinstance(created_at, str) or not created_at:
|
|
1090
|
+
errors.append(f"summaries: missing created_at for {cid[:12]}")
|
|
1091
|
+
continue
|
|
1092
|
+
key = f"{cid}:{created_at}"
|
|
1093
|
+
if key in existing_keys:
|
|
1094
|
+
synced_keys.add(key)
|
|
1095
|
+
continue
|
|
1096
|
+
session_id = item.get("session_id") if isinstance(item.get("session_id"), str) else None
|
|
1097
|
+
row = append_summary(
|
|
1098
|
+
project_id, cid, summary,
|
|
1099
|
+
session_id=session_id, created_at=created_at,
|
|
1100
|
+
)
|
|
1101
|
+
if row is not None:
|
|
1102
|
+
existing_keys.add(key)
|
|
1103
|
+
synced_keys.add(key)
|
|
1104
|
+
written += 1
|
|
1105
|
+
|
|
1106
|
+
_persist_synced_set(rs, "summary_keys", synced_keys)
|
|
1107
|
+
return written
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
# -------------------------------------------------------------------
|
|
1111
|
+
# Status
|
|
1112
|
+
# -------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
@dataclass
|
|
1115
|
+
class StatusReport:
|
|
1116
|
+
project_id: str
|
|
1117
|
+
remote_name: str | None = None
|
|
1118
|
+
remote_url: str | None = None
|
|
1119
|
+
total_traces: int = 0
|
|
1120
|
+
total_ledgers: int = 0
|
|
1121
|
+
total_commit_links: int = 0
|
|
1122
|
+
unpushed_traces: int = 0
|
|
1123
|
+
unpushed_ledgers: int = 0
|
|
1124
|
+
unpushed_commit_links: int = 0
|
|
1125
|
+
unattributed_traces: int = 0
|
|
1126
|
+
traces_cursor: str | None = None
|
|
1127
|
+
ledgers_cursor: str | None = None
|
|
1128
|
+
commit_links_cursor: str | None = None
|
|
1129
|
+
conversations_cursor: str | None = None
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def status(project_id: str, remote_name: str | None = None) -> StatusReport:
|
|
1133
|
+
"""Compute a git-status-like report of local vs remote sync."""
|
|
1134
|
+
report = StatusReport(project_id=project_id)
|
|
1135
|
+
|
|
1136
|
+
traces = _read_traces(project_id)
|
|
1137
|
+
ledgers = _read_ledgers(project_id)
|
|
1138
|
+
links = _read_commit_links(project_id)
|
|
1139
|
+
report.total_traces = len(traces)
|
|
1140
|
+
report.total_ledgers = len(ledgers)
|
|
1141
|
+
report.total_commit_links = len(links)
|
|
1142
|
+
|
|
1143
|
+
attributed_ids = compute_attributed_trace_ids(project_id)
|
|
1144
|
+
report.unattributed_traces = sum(
|
|
1145
|
+
1 for t in traces if t.get("id", "") not in attributed_ids
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
try:
|
|
1149
|
+
rname, rconf = resolve_remote(project_id, remote_name)
|
|
1150
|
+
report.remote_name = rname
|
|
1151
|
+
report.remote_url = get_remote_url(rconf)
|
|
1152
|
+
except ValueError:
|
|
1153
|
+
return report
|
|
1154
|
+
|
|
1155
|
+
sync_state = _load_sync_state(project_id)
|
|
1156
|
+
rs = _get_remote_state(sync_state, rname)
|
|
1157
|
+
|
|
1158
|
+
synced_trace_ids = _synced_set(rs, "trace_ids")
|
|
1159
|
+
synced_ledger_shas = _synced_set(rs, "ledger_shas")
|
|
1160
|
+
synced_link_shas = _synced_set(rs, "commit_link_shas")
|
|
1161
|
+
|
|
1162
|
+
report.unpushed_traces = sum(
|
|
1163
|
+
1 for t in traces
|
|
1164
|
+
if t.get("id", "") in attributed_ids
|
|
1165
|
+
and t.get("id", "") not in synced_trace_ids
|
|
1166
|
+
)
|
|
1167
|
+
report.unpushed_ledgers = sum(
|
|
1168
|
+
1 for l in ledgers
|
|
1169
|
+
if l.get("commit_sha", "") and l.get("commit_sha", "") not in synced_ledger_shas
|
|
1170
|
+
)
|
|
1171
|
+
report.unpushed_commit_links = sum(
|
|
1172
|
+
1 for c in links
|
|
1173
|
+
if c.get("commit_sha", "") and c.get("commit_sha", "") not in synced_link_shas
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
cursors = rs.get("cursor", {})
|
|
1177
|
+
report.traces_cursor = cursors.get("traces")
|
|
1178
|
+
report.ledgers_cursor = cursors.get("ledgers")
|
|
1179
|
+
report.commit_links_cursor = cursors.get("commit_links")
|
|
1180
|
+
report.conversations_cursor = cursors.get("conversations")
|
|
1181
|
+
|
|
1182
|
+
return report
|