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.
- provelog/__init__.py +11 -0
- provelog/__main__.py +14 -0
- provelog/active.py +26 -0
- provelog/anchor.py +223 -0
- provelog/anchoring.py +327 -0
- provelog/assets/apple-touch-icon.png +0 -0
- provelog/assets/favicon-64.png +0 -0
- provelog/assets/favicon.ico +0 -0
- provelog/assets/favicon.svg +5 -0
- provelog/attachments.py +343 -0
- provelog/canonical.py +136 -0
- provelog/capture.py +604 -0
- provelog/capture_state.py +94 -0
- provelog/chain.py +215 -0
- provelog/chatview/__init__.py +24 -0
- provelog/chatview/agent_apps.py +132 -0
- provelog/chatview/agent_icons.py +39 -0
- provelog/chatview/app.py +778 -0
- provelog/chatview/cli.py +404 -0
- provelog/chatview/export_dir.py +118 -0
- provelog/chatview/exporter.py +720 -0
- provelog/chatview/model.py +206 -0
- provelog/chatview/page.py +4015 -0
- provelog/chatview/readers/__init__.py +170 -0
- provelog/chatview/readers/claude_code.py +340 -0
- provelog/chatview/readers/codex.py +301 -0
- provelog/chatview/readers/gemini.py +256 -0
- provelog/chatview/readers/hermes.py +306 -0
- provelog/chatview/readers/kilo.py +38 -0
- provelog/chatview/readers/kimi.py +297 -0
- provelog/chatview/readers/openclaw.py +272 -0
- provelog/chatview/readers/opencode.py +38 -0
- provelog/chatview/readers/opencode_db.py +259 -0
- provelog/chatview/readers/zcode.py +37 -0
- provelog/chatview/records.py +1666 -0
- provelog/chatview/safe.py +16 -0
- provelog/chatview/stamps.py +78 -0
- provelog/chatview/view_state.py +113 -0
- provelog/cli.py +928 -0
- provelog/codex_context.py +33 -0
- provelog/collection.py +169 -0
- provelog/dbutil.py +86 -0
- provelog/doctor.py +306 -0
- provelog/dsh_title.py +81 -0
- provelog/easy.py +201 -0
- provelog/export_flow.py +667 -0
- provelog/export_names.py +69 -0
- provelog/export_select.py +386 -0
- provelog/import_antigravity.py +248 -0
- provelog/import_claude_code.py +539 -0
- provelog/import_cline.py +365 -0
- provelog/import_codebuddy.py +420 -0
- provelog/import_codebuddy_code.py +64 -0
- provelog/import_codex.py +588 -0
- provelog/import_copilot.py +265 -0
- provelog/import_cursor.py +346 -0
- provelog/import_doubao.py +361 -0
- provelog/import_doubao_app.py +471 -0
- provelog/import_dsh.py +759 -0
- provelog/import_gemini.py +454 -0
- provelog/import_hermes.py +327 -0
- provelog/import_kimi.py +455 -0
- provelog/import_minimax.py +422 -0
- provelog/import_omp.py +37 -0
- provelog/import_openclaw.py +361 -0
- provelog/import_opencode.py +381 -0
- provelog/import_pi.py +351 -0
- provelog/import_qoder.py +476 -0
- provelog/import_vscode_agent.py +358 -0
- provelog/import_workbuddy.py +552 -0
- provelog/keys.py +98 -0
- provelog/markdown_images.py +139 -0
- provelog/merkle.py +117 -0
- provelog/netutil.py +26 -0
- provelog/onboarding.py +540 -0
- provelog/pack.py +183 -0
- provelog/pause.py +77 -0
- provelog/platform_paths.py +102 -0
- provelog/py.typed +0 -0
- provelog/sdk.py +318 -0
- provelog/server/__init__.py +0 -0
- provelog/server/__main__.py +12 -0
- provelog/server/abuse.py +105 -0
- provelog/server/app.py +506 -0
- provelog/server/dev_tools.py +87 -0
- provelog/server/pressure.py +69 -0
- provelog/server/risk.py +98 -0
- provelog/server/scheduler.py +589 -0
- provelog/server/security.py +52 -0
- provelog/server/storage.py +177 -0
- provelog/server/verify_page.py +30 -0
- provelog/session_folder.py +183 -0
- provelog/session_kind.py +39 -0
- provelog/session_store.py +1972 -0
- provelog/store.py +56 -0
- provelog/strings.py +184 -0
- provelog/textlines.py +80 -0
- provelog/trust/__init__.py +76 -0
- provelog/trust/freetsa_cacert.pem +45 -0
- provelog/trust/sectigo_tsa_cacert.pem +29 -0
- provelog/tsa_der.py +90 -0
- provelog/verifier/__init__.py +0 -0
- provelog/verifier/verify.py +1683 -0
- provelog/version.py +69 -0
- provelog/watch_runner.py +27 -0
- provelog/web/THIRD_PARTY_NOTICES.md +33 -0
- provelog/web/conformance.html +45 -0
- provelog/web/rfc3161.js +160 -0
- provelog/web/verify-offline.html +1445 -0
- provelog/web/verify.html +331 -0
- provelog/web/verify.js +947 -0
- provelog-1.2.0.dist-info/METADATA +176 -0
- provelog-1.2.0.dist-info/RECORD +119 -0
- provelog-1.2.0.dist-info/WHEEL +5 -0
- provelog-1.2.0.dist-info/entry_points.txt +2 -0
- provelog-1.2.0.dist-info/licenses/LICENSE +22 -0
- provelog-1.2.0.dist-info/licenses/LICENSE-AGPL +661 -0
- provelog-1.2.0.dist-info/licenses/NOTICE.md +7 -0
- 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
|