provelog 1.2.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 (119) hide show
  1. provelog/__init__.py +11 -0
  2. provelog/__main__.py +14 -0
  3. provelog/active.py +26 -0
  4. provelog/anchor.py +223 -0
  5. provelog/anchoring.py +327 -0
  6. provelog/assets/apple-touch-icon.png +0 -0
  7. provelog/assets/favicon-64.png +0 -0
  8. provelog/assets/favicon.ico +0 -0
  9. provelog/assets/favicon.svg +5 -0
  10. provelog/attachments.py +343 -0
  11. provelog/canonical.py +136 -0
  12. provelog/capture.py +604 -0
  13. provelog/capture_state.py +94 -0
  14. provelog/chain.py +215 -0
  15. provelog/chatview/__init__.py +24 -0
  16. provelog/chatview/agent_apps.py +132 -0
  17. provelog/chatview/agent_icons.py +39 -0
  18. provelog/chatview/app.py +778 -0
  19. provelog/chatview/cli.py +404 -0
  20. provelog/chatview/export_dir.py +118 -0
  21. provelog/chatview/exporter.py +720 -0
  22. provelog/chatview/model.py +206 -0
  23. provelog/chatview/page.py +4015 -0
  24. provelog/chatview/readers/__init__.py +170 -0
  25. provelog/chatview/readers/claude_code.py +340 -0
  26. provelog/chatview/readers/codex.py +301 -0
  27. provelog/chatview/readers/gemini.py +256 -0
  28. provelog/chatview/readers/hermes.py +306 -0
  29. provelog/chatview/readers/kilo.py +38 -0
  30. provelog/chatview/readers/kimi.py +297 -0
  31. provelog/chatview/readers/openclaw.py +272 -0
  32. provelog/chatview/readers/opencode.py +38 -0
  33. provelog/chatview/readers/opencode_db.py +259 -0
  34. provelog/chatview/readers/zcode.py +37 -0
  35. provelog/chatview/records.py +1666 -0
  36. provelog/chatview/safe.py +16 -0
  37. provelog/chatview/stamps.py +78 -0
  38. provelog/chatview/view_state.py +113 -0
  39. provelog/cli.py +928 -0
  40. provelog/codex_context.py +33 -0
  41. provelog/collection.py +169 -0
  42. provelog/dbutil.py +86 -0
  43. provelog/doctor.py +306 -0
  44. provelog/dsh_title.py +81 -0
  45. provelog/easy.py +201 -0
  46. provelog/export_flow.py +667 -0
  47. provelog/export_names.py +69 -0
  48. provelog/export_select.py +386 -0
  49. provelog/import_antigravity.py +248 -0
  50. provelog/import_claude_code.py +539 -0
  51. provelog/import_cline.py +365 -0
  52. provelog/import_codebuddy.py +420 -0
  53. provelog/import_codebuddy_code.py +64 -0
  54. provelog/import_codex.py +588 -0
  55. provelog/import_copilot.py +265 -0
  56. provelog/import_cursor.py +346 -0
  57. provelog/import_doubao.py +361 -0
  58. provelog/import_doubao_app.py +471 -0
  59. provelog/import_dsh.py +759 -0
  60. provelog/import_gemini.py +454 -0
  61. provelog/import_hermes.py +327 -0
  62. provelog/import_kimi.py +455 -0
  63. provelog/import_minimax.py +422 -0
  64. provelog/import_omp.py +37 -0
  65. provelog/import_openclaw.py +361 -0
  66. provelog/import_opencode.py +381 -0
  67. provelog/import_pi.py +351 -0
  68. provelog/import_qoder.py +476 -0
  69. provelog/import_vscode_agent.py +358 -0
  70. provelog/import_workbuddy.py +552 -0
  71. provelog/keys.py +98 -0
  72. provelog/markdown_images.py +139 -0
  73. provelog/merkle.py +117 -0
  74. provelog/netutil.py +26 -0
  75. provelog/onboarding.py +540 -0
  76. provelog/pack.py +183 -0
  77. provelog/pause.py +77 -0
  78. provelog/platform_paths.py +102 -0
  79. provelog/py.typed +0 -0
  80. provelog/sdk.py +318 -0
  81. provelog/server/__init__.py +0 -0
  82. provelog/server/__main__.py +12 -0
  83. provelog/server/abuse.py +105 -0
  84. provelog/server/app.py +506 -0
  85. provelog/server/dev_tools.py +87 -0
  86. provelog/server/pressure.py +69 -0
  87. provelog/server/risk.py +98 -0
  88. provelog/server/scheduler.py +589 -0
  89. provelog/server/security.py +52 -0
  90. provelog/server/storage.py +177 -0
  91. provelog/server/verify_page.py +30 -0
  92. provelog/session_folder.py +183 -0
  93. provelog/session_kind.py +39 -0
  94. provelog/session_store.py +1972 -0
  95. provelog/store.py +56 -0
  96. provelog/strings.py +184 -0
  97. provelog/textlines.py +80 -0
  98. provelog/trust/__init__.py +76 -0
  99. provelog/trust/freetsa_cacert.pem +45 -0
  100. provelog/trust/sectigo_tsa_cacert.pem +29 -0
  101. provelog/tsa_der.py +90 -0
  102. provelog/verifier/__init__.py +0 -0
  103. provelog/verifier/verify.py +1683 -0
  104. provelog/version.py +69 -0
  105. provelog/watch_runner.py +27 -0
  106. provelog/web/THIRD_PARTY_NOTICES.md +33 -0
  107. provelog/web/conformance.html +45 -0
  108. provelog/web/rfc3161.js +160 -0
  109. provelog/web/verify-offline.html +1445 -0
  110. provelog/web/verify.html +331 -0
  111. provelog/web/verify.js +947 -0
  112. provelog-1.2.0.dist-info/METADATA +176 -0
  113. provelog-1.2.0.dist-info/RECORD +119 -0
  114. provelog-1.2.0.dist-info/WHEEL +5 -0
  115. provelog-1.2.0.dist-info/entry_points.txt +2 -0
  116. provelog-1.2.0.dist-info/licenses/LICENSE +22 -0
  117. provelog-1.2.0.dist-info/licenses/LICENSE-AGPL +661 -0
  118. provelog-1.2.0.dist-info/licenses/NOTICE.md +7 -0
  119. provelog-1.2.0.dist-info/top_level.txt +1 -0
provelog/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """ProveLog: local, tamper-evident evidence for AI-agent work.
2
+
3
+ The public product is the CLI (`provelog`). There is no public Python SDK;
4
+ `provelog.sdk.Provelog` is an internal recording engine used by the collector,
5
+ importers.
6
+ """
7
+
8
+
9
+ __version__ = "1.2.0"
10
+
11
+ GENESIS_PREV_HASH = "0" * 64
provelog/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """`python -m provelog`: the CLI through the interpreter, immune to PATH order.
2
+
3
+ Every subcommand works identically to the `provelog` console script:
4
+ python -m provelog verify bundle.json
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from .cli import main
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main())
provelog/active.py ADDED
@@ -0,0 +1,26 @@
1
+ """The process-local active-recorder slot, shared by the SDK and the easy front.
2
+
3
+ A ContextVar lives here rather than in either module so instrumentation can
4
+ prefer the innermost OPEN session regardless of whether it was opened by
5
+ the internal `easy.record(...)` helper or by a bare `Provelog.session()` - both set it. Without the
6
+ SDK setting it, an explicitly-instrumented client recorded into whatever
7
+ recorder the wrapper was last bound to, silently crossing sessions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from contextvars import ContextVar
13
+
14
+ _active: ContextVar = ContextVar("provelog_active_recorder", default=None)
15
+
16
+
17
+ def current():
18
+ return _active.get()
19
+
20
+
21
+ def set_active(recorder):
22
+ return _active.set(recorder)
23
+
24
+
25
+ def reset(token):
26
+ _active.reset(token)
provelog/anchor.py ADDED
@@ -0,0 +1,223 @@
1
+ """External anchoring of the server receipt-chain head.
2
+
3
+ Anchoring bounds the tamper window: anything anchored cannot be rewritten without
4
+ the anchor failing to verify. We anchor a Merkle root over a batch of receipt hashes.
5
+
6
+ Two backends:
7
+ - RFC3161Anchor: real trusted timestamp from a TSA. The server default is
8
+ Sectigo primary with FreeTSA as the failure fallback. The TSA genTime is
9
+ authoritative wall-clock; the verifier flags client ts_utc that trails it.
10
+ - LocalAnchor: offline/dev anchor signed by a separate "anchor key". Carries NO
11
+ third-party trust; used for tests and air-gapped runs. Clearly labelled in output.
12
+
13
+ Merkle construction follows RFC 6962 domain separation (0x00 leaf / 0x01 node) so the
14
+ inclusion proofs are interoperable with standard transparency tooling.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import logging
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from typing import Protocol
24
+
25
+ _log = logging.getLogger("provelog.anchor")
26
+
27
+
28
+ def _leaf(h: bytes) -> bytes:
29
+ return hashlib.sha256(b"\x00" + h).digest()
30
+
31
+
32
+ def _node(l: bytes, r: bytes) -> bytes:
33
+ return hashlib.sha256(b"\x01" + l + r).digest()
34
+
35
+
36
+ def merkle_root(leaves_hex: list[str]) -> str:
37
+ if not leaves_hex:
38
+ return "0" * 64
39
+ level = [_leaf(bytes.fromhex(h)) for h in leaves_hex]
40
+ while len(level) > 1:
41
+ nxt = []
42
+ for i in range(0, len(level), 2):
43
+ if i + 1 < len(level):
44
+ nxt.append(_node(level[i], level[i + 1]))
45
+ else:
46
+ nxt.append(level[i])
47
+ level = nxt
48
+ return level[0].hex()
49
+
50
+
51
+ def _checked_root(root_hex: str) -> str:
52
+ """A root arrives over the wire in the anchor-only service, so it is untrusted input. It must
53
+ be exactly 32 hex-encoded bytes: anything else is a client bug, and timestamping it would mint
54
+ a receipt that can never match any bundle."""
55
+ root = (root_hex or "").strip().lower()
56
+ if len(root) != 64:
57
+ raise ValueError(f"a Merkle root is 32 bytes, so 64 hex characters, got {len(root)}")
58
+ try:
59
+ bytes.fromhex(root)
60
+ except ValueError:
61
+ raise ValueError("a Merkle root must be hex") from None
62
+ return root
63
+
64
+
65
+ @dataclass
66
+ class AnchorReceipt:
67
+ kind: str
68
+ merkle_root: str
69
+ gen_time: str
70
+ token_b64: str | None = None
71
+ signature: str | None = None
72
+ anchor_pubkey: str | None = None
73
+ tsa_url: str | None = None
74
+
75
+
76
+ class Anchor(Protocol):
77
+ def anchor(self, leaves_hex: list[str]) -> AnchorReceipt: ...
78
+ def anchor_root(self, root_hex: str) -> AnchorReceipt: ...
79
+
80
+
81
+ class LocalAnchor:
82
+ """Offline anchor signed by a dedicated key held outside the agent process."""
83
+
84
+ def __init__(self, signing_key=None):
85
+ from .keys import SigningKey
86
+ self._key = signing_key or SigningKey.generate()
87
+
88
+ def anchor(self, leaves_hex: list[str]) -> AnchorReceipt:
89
+ return self.anchor_root(merkle_root(leaves_hex))
90
+
91
+ def anchor_root(self, root_hex: str) -> AnchorReceipt:
92
+ """Timestamp a root someone else computed. The anchor-only service never sees the
93
+ leaves, so it cannot derive the root; it can only vouch for the one it was handed."""
94
+ root = _checked_root(root_hex)
95
+ gen_time = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
96
+ sig = self._key.sign((root + "|" + gen_time).encode("utf-8"))
97
+ return AnchorReceipt(
98
+ kind="local",
99
+ merkle_root=root,
100
+ gen_time=gen_time,
101
+ signature=sig,
102
+ anchor_pubkey=self._key.public_key_hex(),
103
+ )
104
+
105
+
106
+ class RFC3161Anchor:
107
+ """Trusted timestamp anchor. Defaults to Sectigo's public TSA over HTTPS
108
+ (probed working 2026-09-22; the HTTP endpoint remains valid); any RFC 3161
109
+ TSA URL works. FreeTSA remains supported and in the failover list for
110
+ operators who prefer it. Every round-trip is timed and its HTTP status
111
+ counted in `stats` - the operator's health check shows which TSA is
112
+ slow or rate-limiting."""
113
+
114
+ def __init__(self, tsa_url: str = "https://timestamp.sectigo.com/rfc3161",
115
+ timeout: float = 10.0):
116
+ self.tsa_url = tsa_url
117
+ self.timeout = timeout
118
+ self.stats = {"calls": 0, "ok": 0, "http_429": 0, "http_5xx": 0,
119
+ "http_other_error": 0, "total_latency_s": 0.0,
120
+ "last_latency_s": 0.0, "last_error": ""}
121
+
122
+ def anchor(self, leaves_hex: list[str]) -> AnchorReceipt:
123
+ return self.anchor_root(merkle_root(leaves_hex))
124
+
125
+ def anchor_root(self, root_hex: str) -> AnchorReceipt:
126
+ import base64
127
+ import time as _time
128
+
129
+ import httpx
130
+ from rfc3161_client import TimestampRequestBuilder
131
+
132
+ from .tsa_der import decode_timestamp_response
133
+
134
+ root = _checked_root(root_hex)
135
+ req = TimestampRequestBuilder().data(bytes.fromhex(root)).build()
136
+ self.stats["calls"] += 1
137
+ t0 = _time.monotonic()
138
+ try:
139
+ resp = httpx.post(
140
+ self.tsa_url,
141
+ content=req.as_bytes(),
142
+ headers={"Content-Type": "application/timestamp-query"},
143
+ timeout=self.timeout,
144
+ )
145
+ latency = _time.monotonic() - t0
146
+ self.stats["total_latency_s"] += latency
147
+ self.stats["last_latency_s"] = latency
148
+ if resp.status_code == 429:
149
+ self.stats["http_429"] += 1
150
+ elif resp.status_code >= 500:
151
+ self.stats["http_5xx"] += 1
152
+ elif resp.status_code >= 400:
153
+ self.stats["http_other_error"] += 1
154
+ resp.raise_for_status()
155
+ tst = decode_timestamp_response(resp.content)
156
+ gen_time = tst.tst_info.gen_time.astimezone(UTC).strftime(
157
+ "%Y-%m-%dT%H:%M:%S.%fZ"
158
+ )
159
+ self.stats["ok"] += 1
160
+ self.stats["last_error"] = ""
161
+ return AnchorReceipt(
162
+ kind="rfc3161",
163
+ merkle_root=root,
164
+ gen_time=gen_time,
165
+ token_b64=base64.b64encode(resp.content).decode("ascii"),
166
+ tsa_url=self.tsa_url,
167
+ )
168
+ except Exception as e:
169
+ if not self.stats["last_error"]:
170
+ self.stats["last_error"] = f"{type(e).__name__}: {e}"[:300]
171
+ raise
172
+
173
+
174
+
175
+
176
+
177
+ SECTIGO_TSA_URL = "https://timestamp.sectigo.com/rfc3161"
178
+ FREETSA_TSA_URL = "https://freetsa.org/tsr"
179
+
180
+
181
+ class FailoverTSAAnchor:
182
+ """Primary/fallback TSA chain, in a fixed order.
183
+
184
+ It always tries the first TSA first; only a failure hands THIS anchoring
185
+ call to the next. The default chain is Sectigo -> FreeTSA. Both are
186
+ independent RFC 3161 TSAs and produce equally valid receipts; the order
187
+ is preference/availability, not a trust level. FreeTSA permits low volume
188
+ at 1 request/second/IP, so it is the fallback.
189
+ If every member fails, the last error is raised and the caller keeps the
190
+ batch pending; no local stamp is produced.
191
+ """
192
+
193
+ def __init__(self, tsa_urls: list[str] | None = None, timeout: float = 10.0,
194
+ _factory=None):
195
+ urls = ([SECTIGO_TSA_URL, FREETSA_TSA_URL] if tsa_urls is None
196
+ else list(tsa_urls))
197
+ if not urls:
198
+ raise ValueError("FailoverTSAAnchor needs at least one TSA URL")
199
+ self._factory = _factory or (lambda u: RFC3161Anchor(u, timeout))
200
+ self.members = [self._factory(u) for u in urls]
201
+
202
+ @property
203
+ def primary_url(self) -> str:
204
+ return getattr(self.members[0], "tsa_url", "")
205
+
206
+ def anchor(self, leaves_hex: list[str]) -> AnchorReceipt:
207
+ return self.anchor_root(merkle_root(leaves_hex))
208
+
209
+ def anchor_root(self, root_hex: str) -> AnchorReceipt:
210
+ errors: list[str] = []
211
+ for member in self.members:
212
+ try:
213
+ return member.anchor_root(root_hex)
214
+ except Exception as e:
215
+ errors.append(f"{getattr(member, 'tsa_url', '?')}: {e}")
216
+ raise RuntimeError(
217
+ f"all {len(self.members)} TSAs failed to timestamp; "
218
+ f"last errors: {'; '.join(errors[-2:])}")
219
+
220
+ def stats(self) -> dict:
221
+ return {"tsa": {m.tsa_url: dict(m.stats) for m in self.members}}
222
+
223
+
provelog/anchoring.py ADDED
@@ -0,0 +1,327 @@
1
+ """Client-side anchor submission for the log collector process.
2
+
3
+ A claim is tiny: one Merkle root over a stream's receipt hashes, a count, and a
4
+ device signature. The service folds every claim it receives into a single
5
+ RFC 3161 stamp per 60 s epoch, so the number of TSA calls grows with time, not
6
+ with the number of conversations or users - which is what makes stamping
7
+ free to offer.
8
+
9
+ Rules, all of them deliberate:
10
+
11
+ * **One claim per stream per minute.** A newer claim supersedes an older one
12
+ (``covers_up_to`` only grows), so more often than that buys nothing and
13
+ spends the service's per-IP budget, of which a busy machine with subagents
14
+ already has several streams.
15
+ * **Only when the stream grew.** A quiet conversation is not re-pushed.
16
+ * **Never break collection.** Every failure here is silent: the records are
17
+ already on disk and signed, and the next pass retries. Stamping is the only
18
+ network dependency in the product, so it must never be able to stop it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import time
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from .netutil import client_for
30
+
31
+
32
+
33
+ ANCHOR_EVERY_S = 60.0
34
+ ANCHOR_TIMEOUT_S = 4.0
35
+
36
+
37
+ ANCHOR_BACKOFF_S = 300.0
38
+
39
+
40
+
41
+
42
+ PENDING_TTL_S = 600.0
43
+
44
+
45
+
46
+ _last_attempt: dict[str, float] = {}
47
+ _down_until: float = 0.0
48
+ _last_429: float = 0.0
49
+ _upgrade_required: dict[str, Any] | None = None
50
+ _upgrade_noticed = False
51
+
52
+
53
+
54
+
55
+ _pickup_at: dict[str, float] = {}
56
+
57
+
58
+ def upgrade_info() -> dict[str, Any] | None:
59
+ """The relay's last 426 response, if this process received one."""
60
+ return _upgrade_required
61
+
62
+
63
+ def _journal_path(store: Any) -> Path | None:
64
+ root = getattr(store, "root", None)
65
+ return None if root is None else Path(root) / ".internal" / "pending_anchors.json"
66
+
67
+
68
+ def _journal(store: Any) -> dict[str, str]:
69
+ """{stream_id: anchor_id} for claims whose receipt is still queued.
70
+
71
+ A file, not a scan: with thousands of conversations, "ask every stream whether
72
+ it has a pending receipt" is O(n^2) per pass (98 ms per call at 3,000 folders,
73
+ measured), while this is one small read.
74
+ """
75
+ path = _journal_path(store)
76
+ if path is None or not path.is_file():
77
+ return {}
78
+ try:
79
+ data = json.loads(path.read_text(encoding="utf-8"))
80
+ return {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
81
+ except (OSError, ValueError):
82
+ return {}
83
+
84
+
85
+ def _journal_set(store: Any, stream_id: str, anchor_id: str | None) -> None:
86
+ path = _journal_path(store)
87
+ if path is None:
88
+ return
89
+ rows = _journal(store)
90
+ if anchor_id:
91
+ rows[stream_id] = anchor_id
92
+ else:
93
+ rows.pop(stream_id, None)
94
+ try:
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
97
+ except OSError:
98
+ pass
99
+
100
+
101
+ def pickup_pending(store: Any, *, url: str | None = None,
102
+ limit: int = 50, timeout: float = ANCHOR_TIMEOUT_S,
103
+ now: float | None = None) -> int:
104
+ """Fetch receipts for claims this machine knows are queued. Bounded per pass.
105
+
106
+ A 404 means the epoch has not minted the receipt yet (normal right after a
107
+ claim): the journal pointer is KEPT and re-polled at most once per epoch, so
108
+ a quiet session still receives its receipt promptly. The pointer is dropped
109
+ only when the receipt is delivered, the relay says it is gone (410), or the
110
+ claim outlives the TTL - the TTL path in ``maybe_anchor`` then fails and
111
+ re-claims it.
112
+ """
113
+ rows = _journal(store)
114
+ if not rows:
115
+ return 0
116
+ base = (url or endpoint()).rstrip("/")
117
+ resolved = 0
118
+ fetched = 0
119
+ stamp = now if now is not None else time.time()
120
+ for stream_id, anchor_id in list(rows.items()):
121
+ if fetched >= limit:
122
+ break
123
+ if _pickup_at.get(stream_id, 0.0) > stamp:
124
+ continue
125
+ try:
126
+ with client_for(base, timeout=timeout) as client:
127
+ resp = client.get(f"{base}/v1/anchors/{anchor_id}")
128
+ except Exception:
129
+ return resolved
130
+ fetched += 1
131
+ if resp.status_code == 200:
132
+ try:
133
+ store.resolve_pending_anchor(stream_id, resp.json())
134
+ except Exception:
135
+ continue
136
+ _journal_set(store, stream_id, None)
137
+ _pickup_at.pop(stream_id, None)
138
+ resolved += 1
139
+ elif resp.status_code == 410:
140
+
141
+
142
+
143
+ _journal_set(store, stream_id, None)
144
+ _pickup_at.pop(stream_id, None)
145
+ else:
146
+
147
+
148
+
149
+ pending = (store.pending_anchor(stream_id)
150
+ if hasattr(store, "pending_anchor") else None)
151
+ if pending and _age_seconds(pending.get("created_at")) >= PENDING_TTL_S:
152
+ _journal_set(store, stream_id, None)
153
+ _pickup_at.pop(stream_id, None)
154
+ else:
155
+ _pickup_at[stream_id] = stamp + ANCHOR_EVERY_S
156
+ return resolved
157
+
158
+
159
+ def endpoint(config: dict[str, Any] | None = None) -> str:
160
+ """Where claims go: the configured endpoint, else the hosted default."""
161
+ if config and config.get("anchor_endpoint"):
162
+ return str(config["anchor_endpoint"]).rstrip("/")
163
+ from .easy import _resolve
164
+ return str(_resolve().get("anchor_endpoint") or "https://provelog.ai").rstrip("/")
165
+
166
+
167
+ def rate_limited_recently(now: float | None = None, window: float = 180.0) -> bool:
168
+ """Did the service push back in the last few minutes?"""
169
+ stamp = now if now is not None else time.time()
170
+ return bool(_last_429) and (stamp - _last_429) < window
171
+
172
+
173
+ def _age_seconds(stamp: Any) -> float:
174
+ try:
175
+ parsed = datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
176
+ except (TypeError, ValueError):
177
+ return float("inf")
178
+ return (datetime.now(UTC) - parsed.astimezone(UTC)).total_seconds()
179
+
180
+
181
+ def pickup(store: Any, stream_id: str, *, url: str | None = None,
182
+ timeout: float = ANCHOR_TIMEOUT_S) -> bool:
183
+ """Fetch a receipt the service has finished minting. Silent on failure."""
184
+ if not hasattr(store, "pending_anchor"):
185
+ return False
186
+ pending = store.pending_anchor(stream_id)
187
+ if not pending or not pending.get("anchor_id"):
188
+ return False
189
+ import httpx
190
+
191
+ target = url or endpoint()
192
+ try:
193
+ with client_for(target, timeout=timeout) as client:
194
+ resp = client.get(f"{target}/v1/anchors/{pending['anchor_id']}")
195
+ except httpx.HTTPError:
196
+ return False
197
+ if resp.status_code != 200:
198
+
199
+
200
+
201
+ return False
202
+ try:
203
+ body = resp.json()
204
+ except ValueError:
205
+ return False
206
+ store.resolve_pending_anchor(stream_id, body)
207
+ return True
208
+
209
+
210
+ def maybe_anchor(store: Any, stream_id: str, *, url: str | None = None,
211
+ key: Any | None = None, now: float | None = None,
212
+ timeout: float = ANCHOR_TIMEOUT_S,
213
+ force: bool = False, lane: str = "live") -> dict[str, Any] | None:
214
+ """Push one claim for this stream if it grew; pick up a finished receipt first."""
215
+ if not hasattr(store, "anchor_leaves"):
216
+ return None
217
+ global _down_until, _upgrade_required, _upgrade_noticed
218
+
219
+ stamp_now = now if now is not None else time.time()
220
+ if _upgrade_required is not None:
221
+ return None
222
+ if stamp_now < _down_until:
223
+ return None
224
+ url = (url or endpoint()).rstrip("/")
225
+
226
+ pickup(store, stream_id, url=url, timeout=timeout)
227
+
228
+ leaves = store.anchor_leaves(stream_id)
229
+ if not leaves:
230
+ return None
231
+ anchors = store.anchors_for_stream(stream_id) if hasattr(store, "anchors_for_stream") else []
232
+
233
+
234
+ delivered = [a for a in anchors if not a.get("pending") and not a.get("failed")]
235
+ covered = max((int(a.get("covers_up_to") or 0) for a in delivered), default=0)
236
+ if len(leaves) <= covered:
237
+ return None
238
+
239
+
240
+
241
+
242
+ pending = store.pending_anchor(stream_id) if hasattr(store, "pending_anchor") else None
243
+ if pending:
244
+ stale = _age_seconds(pending.get("created_at")) >= PENDING_TTL_S
245
+ if stale:
246
+ if hasattr(store, "fail_pending_anchor"):
247
+ store.fail_pending_anchor(stream_id, pending["anchor_id"],
248
+ "no receipt within TTL; re-claiming")
249
+ elif len(leaves) <= int(pending.get("covers_up_to") or 0):
250
+ return None
251
+ if not force:
252
+ last = _last_attempt.get(stream_id, 0.0)
253
+ if stamp_now - last < ANCHOR_EVERY_S:
254
+ return None
255
+ if delivered and _age_seconds(delivered[-1].get("created_at")) < ANCHOR_EVERY_S:
256
+ return None
257
+ _last_attempt[stream_id] = stamp_now
258
+
259
+ import httpx
260
+
261
+ from .anchor import merkle_root
262
+ from .easy import _device_key
263
+
264
+ root = merkle_root(leaves)
265
+ signing_key = key or _device_key()
266
+ covers = len(leaves)
267
+ payload = {
268
+ "stream_id": stream_id,
269
+ "merkle_root": root,
270
+ "covers_up_to": covers,
271
+ "device_pubkey": signing_key.public_key_hex(),
272
+ "device_sig": signing_key.sign(
273
+ f"{stream_id}||{root}||{covers}||{lane}".encode()),
274
+ "lane": lane,
275
+ }
276
+ from . import __version__
277
+
278
+ try:
279
+ with client_for(url, timeout=timeout) as client:
280
+ resp = client.post(f"{url}/v1/anchors", json=payload,
281
+ headers={"X-ProveLog-Version": __version__})
282
+ except httpx.HTTPError:
283
+ _down_until = stamp_now + ANCHOR_BACKOFF_S
284
+ return None
285
+ if resp.status_code == 426:
286
+
287
+
288
+ try:
289
+ body = resp.json()
290
+ except ValueError:
291
+ body = {}
292
+ _upgrade_required = body if isinstance(body, dict) else {}
293
+ if not _upgrade_noticed:
294
+ from .version import DOWNLOAD_URL
295
+ url_hint = str(_upgrade_required.get("url") or DOWNLOAD_URL)
296
+ print("ProveLog: this version is no longer supported by the stamping "
297
+ f"service. Update: {url_hint}", file=__import__("sys").stderr)
298
+ _upgrade_noticed = True
299
+ return None
300
+ if resp.status_code == 429:
301
+
302
+
303
+ global _last_429
304
+ _last_429 = stamp_now
305
+ return None
306
+ if resp.status_code >= 500:
307
+ _down_until = stamp_now + ANCHOR_BACKOFF_S
308
+ return None
309
+ if resp.status_code != 200:
310
+ return None
311
+ try:
312
+ out = resp.json()
313
+ except ValueError:
314
+ return None
315
+ if not isinstance(out, dict):
316
+ return None
317
+ if out.get("status") == "queued":
318
+
319
+
320
+ store.add_anchor(stream_id, out, covers_up_to=covers,
321
+ anchor_id=out.get("anchor_id"), pending=True)
322
+ _journal_set(store, stream_id, out.get("anchor_id"))
323
+ _pickup_at.pop(stream_id, None)
324
+ return out
325
+ store.add_anchor(stream_id, out, covers_up_to=covers,
326
+ anchor_id=out.get("anchor_id"))
327
+ return out
Binary file
Binary file
Binary file
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#0E8F7E"/>
3
+ <path d="M23.7 19.2 L41 32 L23.7 44.8" fill="none" stroke="#FFFFFF"
4
+ stroke-width="6.7" stroke-linecap="round" stroke-linejoin="round"/>
5
+ </svg>