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.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. 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