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/models.py ADDED
@@ -0,0 +1,618 @@
1
+ """
2
+ Typed models mirroring JSON Schemas in ``agent_trace/schemas/`` — trace records, ledgers,
3
+ commit links, git notes, remotes, and sync state.
4
+
5
+ Use :func:`from_dict` dispatchers and ``.to_dict()`` for JSONL round-trips.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+
15
+ def schemas_dir() -> Path:
16
+ """Directory containing ``*.schema.json`` shipped inside the ``agent_trace`` package."""
17
+ return Path(__file__).resolve().parent / "schemas"
18
+
19
+
20
+ # --- Trace record (traces.jsonl) ---
21
+
22
+
23
+ @dataclass
24
+ class LineHash:
25
+ line_offset: int
26
+ hash: str
27
+
28
+ @classmethod
29
+ def from_dict(cls, d: dict[str, Any]) -> LineHash:
30
+ return cls(
31
+ line_offset=int(d["line_offset"]),
32
+ hash=str(d["hash"]),
33
+ )
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "line_offset": self.line_offset,
38
+ "hash": self.hash,
39
+ }
40
+
41
+
42
+ @dataclass
43
+ class Range:
44
+ start_line: int
45
+ end_line: int
46
+ content_hash: str | None = None
47
+ line_hashes: list[LineHash] | None = None
48
+
49
+ @classmethod
50
+ def from_dict(cls, d: dict[str, Any]) -> Range:
51
+ lhs = d.get("line_hashes")
52
+ line_hashes: list[LineHash] | None = None
53
+ if isinstance(lhs, list):
54
+ line_hashes = [LineHash.from_dict(cast(dict[str, Any], x)) for x in lhs if isinstance(x, dict)]
55
+ return cls(
56
+ start_line=int(d["start_line"]),
57
+ end_line=int(d["end_line"]),
58
+ content_hash=d.get("content_hash"),
59
+ line_hashes=line_hashes,
60
+ )
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ d: dict[str, Any] = {
64
+ "start_line": self.start_line,
65
+ "end_line": self.end_line,
66
+ }
67
+ if self.content_hash is not None:
68
+ d["content_hash"] = self.content_hash
69
+ if self.line_hashes is not None:
70
+ d["line_hashes"] = [lh.to_dict() for lh in self.line_hashes]
71
+ return d
72
+
73
+
74
+ @dataclass
75
+ class Contributor:
76
+ type: str
77
+ model_id: str | None = None
78
+
79
+ @classmethod
80
+ def from_dict(cls, d: dict[str, Any]) -> Contributor:
81
+ return cls(type=str(d["type"]), model_id=d.get("model_id"))
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ out: dict[str, Any] = {"type": self.type}
85
+ if self.model_id is not None:
86
+ out["model_id"] = self.model_id
87
+ return out
88
+
89
+
90
+ @dataclass
91
+ class Conversation:
92
+ contributor: Contributor
93
+ ranges: list[Range]
94
+ id: str | None = None
95
+ content_sha256: str | None = None
96
+
97
+ @classmethod
98
+ def from_dict(cls, d: dict[str, Any]) -> Conversation:
99
+ ranges = [Range.from_dict(cast(dict[str, Any], x)) for x in d.get("ranges", []) if isinstance(x, dict)]
100
+ return cls(
101
+ contributor=Contributor.from_dict(cast(dict[str, Any], d["contributor"])),
102
+ ranges=ranges,
103
+ id=d.get("id"),
104
+ content_sha256=d.get("content_sha256"),
105
+ )
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ out: dict[str, Any] = {
109
+ "contributor": self.contributor.to_dict(),
110
+ "ranges": [r.to_dict() for r in self.ranges],
111
+ }
112
+ if self.id is not None:
113
+ out["id"] = self.id
114
+ if self.content_sha256 is not None:
115
+ out["content_sha256"] = self.content_sha256
116
+ return out
117
+
118
+
119
+ @dataclass
120
+ class FileEntry:
121
+ path: str
122
+ conversations: list[Conversation]
123
+
124
+ @classmethod
125
+ def from_dict(cls, d: dict[str, Any]) -> FileEntry:
126
+ convs = [
127
+ Conversation.from_dict(cast(dict[str, Any], x))
128
+ for x in d.get("conversations", [])
129
+ if isinstance(x, dict)
130
+ ]
131
+ return cls(path=str(d["path"]), conversations=convs)
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {"path": self.path, "conversations": [c.to_dict() for c in self.conversations]}
135
+
136
+
137
+ @dataclass
138
+ class Trace:
139
+ """One trace record (``traces.jsonl`` line)."""
140
+
141
+ version: str
142
+ id: str
143
+ timestamp: str
144
+ tool: dict[str, Any]
145
+ files: list[FileEntry]
146
+ vcs: dict[str, str] | None = None
147
+ metadata: dict[str, Any] | None = None
148
+
149
+ @classmethod
150
+ def from_dict(cls, d: dict[str, Any]) -> Trace:
151
+ file_entries = [
152
+ FileEntry.from_dict(cast(dict[str, Any], x)) for x in d.get("files", []) if isinstance(x, dict)
153
+ ]
154
+ vcs = d.get("vcs")
155
+ meta = d.get("metadata")
156
+ return cls(
157
+ version=str(d["version"]),
158
+ id=str(d["id"]),
159
+ timestamp=str(d["timestamp"]),
160
+ tool=dict(d["tool"]) if isinstance(d.get("tool"), dict) else {},
161
+ files=file_entries,
162
+ vcs=dict(vcs) if isinstance(vcs, dict) else None,
163
+ metadata=dict(meta) if isinstance(meta, dict) else None,
164
+ )
165
+
166
+ def to_dict(self) -> dict[str, Any]:
167
+ out: dict[str, Any] = {
168
+ "version": self.version,
169
+ "id": self.id,
170
+ "timestamp": self.timestamp,
171
+ "tool": self.tool,
172
+ "files": [f.to_dict() for f in self.files],
173
+ }
174
+ if self.vcs is not None:
175
+ out["vcs"] = self.vcs
176
+ if self.metadata is not None:
177
+ out["metadata"] = self.metadata
178
+ return out
179
+
180
+
181
+ # --- Ledger (ledgers.jsonl) ---
182
+
183
+
184
+ @dataclass
185
+ class LineEvidence:
186
+ """Per-line audit trail: hash and content of each AI-attributed line."""
187
+
188
+ line: int
189
+ hash: str
190
+ content: str
191
+
192
+ @classmethod
193
+ def from_dict(cls, d: dict[str, Any]) -> LineEvidence:
194
+ return cls(
195
+ line=int(d["line"]),
196
+ hash=str(d["hash"]),
197
+ content=str(d.get("content", "")),
198
+ )
199
+
200
+ def to_dict(self) -> dict[str, Any]:
201
+ return {"line": self.line, "hash": self.hash, "content": self.content}
202
+
203
+
204
+ @dataclass
205
+ class LineSegment:
206
+ """An AI-attributed run of lines. Schema 2.0: only AI segments exist;
207
+ anything not in a segment is implicitly NO_ATTRIBUTION."""
208
+
209
+ start_line: int
210
+ end_line: int
211
+ trace_id: str
212
+ type: str = "ai"
213
+ model_id: str | None = None
214
+ conversation_id: str | None = None
215
+ evidence: list[LineEvidence] | None = None
216
+
217
+ @classmethod
218
+ def from_dict(cls, d: dict[str, Any]) -> LineSegment:
219
+ ev_raw = d.get("evidence")
220
+ evidence: list[LineEvidence] | None = None
221
+ if isinstance(ev_raw, list):
222
+ evidence = [
223
+ LineEvidence.from_dict(cast(dict[str, Any], x))
224
+ for x in ev_raw
225
+ if isinstance(x, dict)
226
+ ]
227
+ return cls(
228
+ start_line=int(d["start_line"]),
229
+ end_line=int(d["end_line"]),
230
+ type=str(d.get("type", "ai")),
231
+ trace_id=str(d["trace_id"]),
232
+ model_id=d.get("model_id"),
233
+ conversation_id=d.get("conversation_id"),
234
+ evidence=evidence,
235
+ )
236
+
237
+ def to_dict(self) -> dict[str, Any]:
238
+ out: dict[str, Any] = {
239
+ "start_line": self.start_line,
240
+ "end_line": self.end_line,
241
+ "type": self.type,
242
+ "trace_id": self.trace_id,
243
+ }
244
+ if self.model_id is not None:
245
+ out["model_id"] = self.model_id
246
+ if self.conversation_id is not None:
247
+ out["conversation_id"] = self.conversation_id
248
+ if self.evidence is not None:
249
+ out["evidence"] = [e.to_dict() for e in self.evidence]
250
+ return out
251
+
252
+
253
+ @dataclass
254
+ class FileLedger:
255
+ line_attributions: list[LineSegment]
256
+
257
+ @classmethod
258
+ def from_dict(cls, d: dict[str, Any]) -> FileLedger:
259
+ segs = [
260
+ LineSegment.from_dict(cast(dict[str, Any], x))
261
+ for x in d.get("line_attributions", [])
262
+ if isinstance(x, dict)
263
+ ]
264
+ return cls(line_attributions=segs)
265
+
266
+ def to_dict(self) -> dict[str, Any]:
267
+ return {"line_attributions": [s.to_dict() for s in self.line_attributions]}
268
+
269
+
270
+ @dataclass
271
+ class Ledger:
272
+ version: str
273
+ commit_sha: str
274
+ parent_sha: str | None
275
+ committed_at: str | None
276
+ created_at: str
277
+ trace_ids: list[str]
278
+ files: dict[str, FileLedger]
279
+ parent_committed_at: str | None = None
280
+ derived_from: dict[str, Any] | None = None
281
+ used_fallback: bool = False
282
+
283
+ @classmethod
284
+ def from_dict(cls, d: dict[str, Any]) -> Ledger:
285
+ raw_files = d.get("files", {})
286
+ files: dict[str, FileLedger] = {}
287
+ if isinstance(raw_files, dict):
288
+ for path, fv in raw_files.items():
289
+ if isinstance(fv, dict):
290
+ files[str(path)] = FileLedger.from_dict(fv)
291
+ df = d.get("derived_from")
292
+ return cls(
293
+ version=str(d["version"]),
294
+ commit_sha=str(d["commit_sha"]),
295
+ parent_sha=d.get("parent_sha"),
296
+ parent_committed_at=d.get("parent_committed_at"),
297
+ committed_at=d.get("committed_at"),
298
+ created_at=str(d["created_at"]),
299
+ trace_ids=[str(x) for x in d.get("trace_ids", [])],
300
+ files=files,
301
+ derived_from=df if isinstance(df, dict) else None,
302
+ used_fallback=bool(d.get("used_fallback", False)),
303
+ )
304
+
305
+ def to_dict(self) -> dict[str, Any]:
306
+ out: dict[str, Any] = {
307
+ "version": self.version,
308
+ "commit_sha": self.commit_sha,
309
+ "parent_sha": self.parent_sha,
310
+ "committed_at": self.committed_at,
311
+ "created_at": self.created_at,
312
+ "trace_ids": list(self.trace_ids),
313
+ "files": {k: v.to_dict() for k, v in self.files.items()},
314
+ }
315
+ if self.parent_committed_at is not None:
316
+ out["parent_committed_at"] = self.parent_committed_at
317
+ if self.derived_from is not None:
318
+ out["derived_from"] = dict(self.derived_from)
319
+ if self.used_fallback:
320
+ out["used_fallback"] = True
321
+ return out
322
+
323
+
324
+ # --- Commit link (commit-links.jsonl) ---
325
+
326
+
327
+ @dataclass
328
+ class CommitLink:
329
+ commit_sha: str
330
+ parent_sha: str | None
331
+ trace_ids: list[str]
332
+ files_changed: list[str]
333
+ committed_at: str | None
334
+ created_at: str
335
+ ledger: Ledger | None = None
336
+
337
+ @classmethod
338
+ def from_dict(cls, d: dict[str, Any]) -> CommitLink:
339
+ led = d.get("ledger")
340
+ ledger_obj: Ledger | None = None
341
+ if isinstance(led, dict):
342
+ ledger_obj = Ledger.from_dict(led)
343
+ return cls(
344
+ commit_sha=str(d["commit_sha"]),
345
+ parent_sha=d.get("parent_sha"),
346
+ trace_ids=[str(x) for x in d.get("trace_ids", [])],
347
+ files_changed=[str(x) for x in d.get("files_changed", [])],
348
+ committed_at=d.get("committed_at"),
349
+ created_at=str(d["created_at"]),
350
+ ledger=ledger_obj,
351
+ )
352
+
353
+ def to_dict(self) -> dict[str, Any]:
354
+ out: dict[str, Any] = {
355
+ "commit_sha": self.commit_sha,
356
+ "parent_sha": self.parent_sha,
357
+ "trace_ids": list(self.trace_ids),
358
+ "files_changed": list(self.files_changed),
359
+ "committed_at": self.committed_at,
360
+ "created_at": self.created_at,
361
+ }
362
+ if self.ledger is not None:
363
+ out["ledger"] = self.ledger.to_dict()
364
+ return out
365
+
366
+
367
+ # --- Git note (refs/notes/agent-trace) ---
368
+
369
+
370
+ @dataclass
371
+ class GitNoteStats:
372
+ """Schema 2.0: only AI-line counts are tracked. Everything else is
373
+ implicitly NO_ATTRIBUTION. ``total_changed_lines`` is optional context."""
374
+
375
+ ai_lines: int
376
+ total_changed_lines: int | None = None
377
+
378
+ @classmethod
379
+ def from_dict(cls, d: dict[str, Any]) -> GitNoteStats:
380
+ total = d.get("total_changed_lines")
381
+ return cls(
382
+ ai_lines=int(d["ai_lines"]),
383
+ total_changed_lines=int(total) if total is not None else None,
384
+ )
385
+
386
+ def to_dict(self) -> dict[str, Any]:
387
+ out: dict[str, Any] = {"ai_lines": self.ai_lines}
388
+ if self.total_changed_lines is not None:
389
+ out["total_changed_lines"] = self.total_changed_lines
390
+ return out
391
+
392
+
393
+ @dataclass
394
+ class GitNoteLedgerSection:
395
+ files: dict[str, FileLedger]
396
+
397
+ @classmethod
398
+ def from_dict(cls, d: dict[str, Any]) -> GitNoteLedgerSection:
399
+ raw = d.get("files", {})
400
+ files: dict[str, FileLedger] = {}
401
+ if isinstance(raw, dict):
402
+ for path, fv in raw.items():
403
+ if isinstance(fv, dict):
404
+ files[str(path)] = FileLedger.from_dict(fv)
405
+ return cls(files=files)
406
+
407
+ def to_dict(self) -> dict[str, Any]:
408
+ return {"files": {k: v.to_dict() for k, v in self.files.items()}}
409
+
410
+
411
+ @dataclass
412
+ class GitNote:
413
+ version: str
414
+ trace_ids: list[str]
415
+ ledger_hash: str
416
+ stats: GitNoteStats
417
+ ledger: GitNoteLedgerSection | None = None
418
+ summary: dict[str, str] | None = None
419
+ prompts: list[str] | None = None
420
+ all_session_conversations: list[dict[str, Any]] | None = None
421
+
422
+ @classmethod
423
+ def from_dict(cls, d: dict[str, Any]) -> GitNote:
424
+ ledger_sec: GitNoteLedgerSection | None = None
425
+ if isinstance(d.get("ledger"), dict):
426
+ ledger_sec = GitNoteLedgerSection.from_dict(cast(dict[str, Any], d["ledger"]))
427
+ summary = d.get("summary")
428
+ prompts = d.get("prompts")
429
+ pr: list[str] | None = None
430
+ if isinstance(prompts, list):
431
+ pr = [str(x) for x in prompts]
432
+ asc = d.get("all_session_conversations")
433
+ asc_list: list[dict[str, Any]] | None = None
434
+ if isinstance(asc, list):
435
+ asc_list = [cast(dict[str, Any], x) for x in asc if isinstance(x, dict)]
436
+ return cls(
437
+ version=str(d["version"]),
438
+ trace_ids=[str(x) for x in d.get("trace_ids", [])],
439
+ ledger_hash=str(d["ledger_hash"]),
440
+ stats=GitNoteStats.from_dict(cast(dict[str, Any], d["stats"])),
441
+ ledger=ledger_sec,
442
+ summary=dict(summary) if isinstance(summary, dict) else None,
443
+ prompts=pr,
444
+ all_session_conversations=asc_list,
445
+ )
446
+
447
+ def to_dict(self) -> dict[str, Any]:
448
+ out: dict[str, Any] = {
449
+ "version": self.version,
450
+ "trace_ids": list(self.trace_ids),
451
+ "ledger_hash": self.ledger_hash,
452
+ "stats": self.stats.to_dict(),
453
+ }
454
+ if self.ledger is not None:
455
+ out["ledger"] = self.ledger.to_dict()
456
+ if self.summary is not None:
457
+ out["summary"] = dict(self.summary)
458
+ if self.prompts is not None:
459
+ out["prompts"] = list(self.prompts)
460
+ if self.all_session_conversations is not None:
461
+ out["all_session_conversations"] = [dict(x) for x in self.all_session_conversations]
462
+ return out
463
+
464
+
465
+ # --- Remotes (~/.agent-trace/projects/<id>/remotes.json) ---
466
+
467
+
468
+ @dataclass
469
+ class AuthConfig:
470
+ type: str
471
+ token_ref: str
472
+
473
+ @classmethod
474
+ def from_dict(cls, d: dict[str, Any]) -> AuthConfig:
475
+ return cls(type=str(d["type"]), token_ref=str(d["token_ref"]))
476
+
477
+ def to_dict(self) -> dict[str, Any]:
478
+ return {"type": self.type, "token_ref": self.token_ref}
479
+
480
+
481
+ @dataclass
482
+ class Remote:
483
+ url: str
484
+ auth: AuthConfig | None = None
485
+
486
+ @classmethod
487
+ def from_dict(cls, d: dict[str, Any]) -> Remote:
488
+ auth = d.get("auth")
489
+ auth_obj: AuthConfig | None = None
490
+ if isinstance(auth, dict):
491
+ auth_obj = AuthConfig.from_dict(auth)
492
+ return cls(url=str(d["url"]), auth=auth_obj)
493
+
494
+ def to_dict(self) -> dict[str, Any]:
495
+ out: dict[str, Any] = {"url": self.url}
496
+ if self.auth is not None:
497
+ out["auth"] = self.auth.to_dict()
498
+ return out
499
+
500
+
501
+ @dataclass
502
+ class RemotesConfig:
503
+ """Root object of ``remotes.json``: remote name → config."""
504
+
505
+ remotes: dict[str, Remote] = field(default_factory=dict)
506
+
507
+ @classmethod
508
+ def from_dict(cls, d: dict[str, Any]) -> RemotesConfig:
509
+ remotes: dict[str, Remote] = {}
510
+ for name, rv in d.items():
511
+ if isinstance(rv, dict):
512
+ remotes[str(name)] = Remote.from_dict(rv)
513
+ return cls(remotes=remotes)
514
+
515
+ def to_dict(self) -> dict[str, Any]:
516
+ return {k: v.to_dict() for k, v in self.remotes.items()}
517
+
518
+
519
+ # --- Sync state (~/.agent-trace/projects/<id>/sync-state.json) ---
520
+
521
+
522
+ @dataclass
523
+ class SyncedManifest:
524
+ trace_ids: list[str] = field(default_factory=list)
525
+ ledger_shas: list[str] = field(default_factory=list)
526
+ commit_link_shas: list[str] = field(default_factory=list)
527
+ blob_shas: list[str] = field(default_factory=list)
528
+ conversation_ids: list[str] = field(default_factory=list)
529
+ summary_keys: list[str] = field(default_factory=list)
530
+
531
+ @classmethod
532
+ def from_dict(cls, d: dict[str, Any]) -> SyncedManifest:
533
+ def _strs(key: str) -> list[str]:
534
+ v = d.get(key)
535
+ return [str(x) for x in v if isinstance(x, str)] if isinstance(v, list) else []
536
+
537
+ return cls(
538
+ trace_ids=_strs("trace_ids"),
539
+ ledger_shas=_strs("ledger_shas"),
540
+ commit_link_shas=_strs("commit_link_shas"),
541
+ blob_shas=_strs("blob_shas"),
542
+ conversation_ids=_strs("conversation_ids"),
543
+ summary_keys=_strs("summary_keys"),
544
+ )
545
+
546
+ def to_dict(self) -> dict[str, Any]:
547
+ return asdict(self)
548
+
549
+
550
+ @dataclass
551
+ class PullCursors:
552
+ traces: str | None = None
553
+ ledgers: str | None = None
554
+ commit_links: str | None = None
555
+ conversations: str | None = None
556
+ summaries: str | None = None
557
+
558
+ @classmethod
559
+ def from_dict(cls, d: dict[str, Any]) -> PullCursors:
560
+ return cls(
561
+ traces=d.get("traces"),
562
+ ledgers=d.get("ledgers"),
563
+ commit_links=d.get("commit_links"),
564
+ conversations=d.get("conversations"),
565
+ summaries=d.get("summaries"),
566
+ )
567
+
568
+ def to_dict(self) -> dict[str, Any]:
569
+ return asdict(self)
570
+
571
+
572
+ @dataclass
573
+ class RemoteSyncState:
574
+ synced: SyncedManifest = field(default_factory=SyncedManifest)
575
+ cursor: PullCursors = field(default_factory=PullCursors)
576
+
577
+ @classmethod
578
+ def from_dict(cls, d: dict[str, Any]) -> RemoteSyncState:
579
+ synced = d.get("synced")
580
+ cursor = d.get("cursor")
581
+ return cls(
582
+ synced=SyncedManifest.from_dict(cast(dict[str, Any], synced)) if isinstance(synced, dict) else SyncedManifest(),
583
+ cursor=PullCursors.from_dict(cast(dict[str, Any], cursor)) if isinstance(cursor, dict) else PullCursors(),
584
+ )
585
+
586
+ def to_dict(self) -> dict[str, Any]:
587
+ return {"synced": self.synced.to_dict(), "cursor": self.cursor.to_dict()}
588
+
589
+
590
+ @dataclass
591
+ class SyncState:
592
+ remotes: dict[str, RemoteSyncState]
593
+ version: int = 2
594
+
595
+ @classmethod
596
+ def from_dict(cls, d: dict[str, Any]) -> SyncState:
597
+ raw = d.get("remotes", {})
598
+ remotes: dict[str, RemoteSyncState] = {}
599
+ if isinstance(raw, dict):
600
+ for name, rv in raw.items():
601
+ if isinstance(rv, dict):
602
+ remotes[str(name)] = RemoteSyncState.from_dict(rv)
603
+ version = d.get("version") if isinstance(d.get("version"), int) else 2
604
+ return cls(remotes=remotes, version=version)
605
+
606
+ def to_dict(self) -> dict[str, Any]:
607
+ return {
608
+ "version": self.version,
609
+ "remotes": {k: v.to_dict() for k, v in self.remotes.items()},
610
+ }
611
+
612
+
613
+ def trace_from_dict(d: dict[str, Any]) -> Trace:
614
+ return Trace.from_dict(d)
615
+
616
+
617
+ def ledger_from_dict(d: dict[str, Any]) -> Ledger:
618
+ return Ledger.from_dict(d)