puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
executor/watchdog.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Deadline watchdog for `local-process` jobs (05-executor.md §5 step 3, INV-2, INV-3).
|
|
2
|
+
|
|
3
|
+
Run as its own process (`python -m executor.watchdog ...`), detached from the engine, so
|
|
4
|
+
the deadline holds even if the engine dies. It starts the job in a new process group, and
|
|
5
|
+
at the deadline or on a kill request it sends a termination signal to the whole group,
|
|
6
|
+
waits the grace period, then force-kills the tree. It writes the outcome to a result file.
|
|
7
|
+
|
|
8
|
+
Standard library only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
POLL_S = 0.1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now() -> str:
|
|
27
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _terminate(proc: subprocess.Popen[bytes]) -> None:
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
try:
|
|
33
|
+
proc.send_signal(signal.CTRL_BREAK_EVENT)
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
pass
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
39
|
+
except ProcessLookupError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _force_kill(proc: subprocess.Popen[bytes]) -> None:
|
|
44
|
+
if sys.platform == "win32":
|
|
45
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], # noqa: S603, S607
|
|
46
|
+
capture_output=True, check=False)
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
50
|
+
except ProcessLookupError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> int:
|
|
55
|
+
p = argparse.ArgumentParser()
|
|
56
|
+
p.add_argument("--deadline", type=float, required=True)
|
|
57
|
+
p.add_argument("--grace", type=float, required=True)
|
|
58
|
+
p.add_argument("--cwd", required=True)
|
|
59
|
+
p.add_argument("--stdout", required=True)
|
|
60
|
+
p.add_argument("--stderr", required=True)
|
|
61
|
+
p.add_argument("--result", required=True)
|
|
62
|
+
p.add_argument("--killfile", required=True)
|
|
63
|
+
p.add_argument("command")
|
|
64
|
+
a = p.parse_args(argv)
|
|
65
|
+
|
|
66
|
+
started_at, t0 = _now(), time.monotonic()
|
|
67
|
+
kwargs: dict[str, object] = {}
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
70
|
+
args: list[str] | str = a.command
|
|
71
|
+
else:
|
|
72
|
+
kwargs["start_new_session"] = True
|
|
73
|
+
args = ["sh", "-c", a.command]
|
|
74
|
+
env = dict(os.environ)
|
|
75
|
+
env.pop("PYTHONPATH", None) # the watchdog's own import path is not the job's
|
|
76
|
+
with open(a.stdout, "wb") as out, open(a.stderr, "wb") as err:
|
|
77
|
+
proc = subprocess.Popen(args, cwd=a.cwd, stdout=out, stderr=err, # noqa: S603
|
|
78
|
+
stdin=subprocess.DEVNULL, shell=os.name == "nt", env=env,
|
|
79
|
+
**kwargs) # type: ignore[call-overload]
|
|
80
|
+
# For `reap` after an engine restart, if this watchdog dies (KB-020).
|
|
81
|
+
Path(a.result).with_name("job.pid").write_text(str(proc.pid), encoding="utf-8")
|
|
82
|
+
reason = "exited"
|
|
83
|
+
while proc.poll() is None:
|
|
84
|
+
if Path(a.killfile).exists():
|
|
85
|
+
reason = "killed"
|
|
86
|
+
elif time.monotonic() - t0 >= a.deadline:
|
|
87
|
+
reason = "timeout"
|
|
88
|
+
if reason != "exited":
|
|
89
|
+
_terminate(proc)
|
|
90
|
+
end = time.monotonic() + a.grace
|
|
91
|
+
while proc.poll() is None and time.monotonic() < end:
|
|
92
|
+
time.sleep(POLL_S)
|
|
93
|
+
_force_kill(proc)
|
|
94
|
+
proc.wait()
|
|
95
|
+
break
|
|
96
|
+
time.sleep(POLL_S)
|
|
97
|
+
if reason == "exited":
|
|
98
|
+
_force_kill(proc) # leftovers of the job's group, if any
|
|
99
|
+
result = {"exit_code": proc.returncode, "reason": reason, "started_at": started_at,
|
|
100
|
+
"ended_at": _now(), "duration_s": time.monotonic() - t0, "pid": proc.pid}
|
|
101
|
+
tmp = Path(a.result + ".tmp")
|
|
102
|
+
tmp.write_text(json.dumps(result), encoding="utf-8")
|
|
103
|
+
os.replace(tmp, a.result)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
sys.exit(main())
|
ledger/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""ledger: the append-only record of a run (docs/knowledge_base/04-ledger.md)."""
|
|
2
|
+
|
|
3
|
+
from .canonical import canonical_json, entry_hash
|
|
4
|
+
from .core import Ledger
|
|
5
|
+
from .errors import (
|
|
6
|
+
ArtifactCorrupt,
|
|
7
|
+
ArtifactMissing,
|
|
8
|
+
ConcurrentWrite,
|
|
9
|
+
ExportError,
|
|
10
|
+
LedgerError,
|
|
11
|
+
LedgerRejection,
|
|
12
|
+
UnknownRun,
|
|
13
|
+
)
|
|
14
|
+
from .model import (
|
|
15
|
+
ARTIFACT_KINDS,
|
|
16
|
+
CONFIRM_STAGE,
|
|
17
|
+
ENTRY_TYPES,
|
|
18
|
+
SMOKE_STAGE,
|
|
19
|
+
ChainReport,
|
|
20
|
+
Entry,
|
|
21
|
+
Rejection,
|
|
22
|
+
RunState,
|
|
23
|
+
SpendSummary,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ARTIFACT_KINDS",
|
|
28
|
+
"CONFIRM_STAGE",
|
|
29
|
+
"ENTRY_TYPES",
|
|
30
|
+
"SMOKE_STAGE",
|
|
31
|
+
"ArtifactCorrupt",
|
|
32
|
+
"ArtifactMissing",
|
|
33
|
+
"ChainReport",
|
|
34
|
+
"ConcurrentWrite",
|
|
35
|
+
"Entry",
|
|
36
|
+
"ExportError",
|
|
37
|
+
"Ledger",
|
|
38
|
+
"LedgerError",
|
|
39
|
+
"LedgerRejection",
|
|
40
|
+
"Rejection",
|
|
41
|
+
"RunState",
|
|
42
|
+
"SpendSummary",
|
|
43
|
+
"UnknownRun",
|
|
44
|
+
"canonical_json",
|
|
45
|
+
"entry_hash",
|
|
46
|
+
]
|
ledger/artifacts.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Content-addressed artifact files (04-ledger.md §4, "Artifact").
|
|
2
|
+
|
|
3
|
+
v0.1 stores artifacts on the local filesystem under `<root>/<run_id>/<hex>`. Files are
|
|
4
|
+
written once and never replaced; reads re-check the digest.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .canonical import sha256_hex
|
|
15
|
+
from .errors import ArtifactCorrupt, ArtifactMissing
|
|
16
|
+
|
|
17
|
+
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
18
|
+
_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def digest_of(data: bytes) -> str:
|
|
22
|
+
return "sha256:" + sha256_hex(data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileArtifactStore:
|
|
26
|
+
def __init__(self, root: str | Path) -> None:
|
|
27
|
+
self._root = Path(root)
|
|
28
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
def _path(self, run_id: str, digest: str) -> Path:
|
|
31
|
+
if not _RUN_ID_RE.match(run_id) or not DIGEST_RE.match(digest):
|
|
32
|
+
raise ArtifactMissing("malformed run id or digest")
|
|
33
|
+
return self._root / run_id / digest.removeprefix("sha256:")
|
|
34
|
+
|
|
35
|
+
def put(self, run_id: str, digest: str, data: bytes) -> None:
|
|
36
|
+
path = self._path(run_id, digest)
|
|
37
|
+
if path.exists():
|
|
38
|
+
return # idempotent: same digest, same content
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
|
|
41
|
+
try:
|
|
42
|
+
with os.fdopen(fd, "wb") as f:
|
|
43
|
+
f.write(data)
|
|
44
|
+
try:
|
|
45
|
+
os.link(tmp, path) # fails if another writer won the race; never overwrites
|
|
46
|
+
except FileExistsError:
|
|
47
|
+
pass
|
|
48
|
+
finally:
|
|
49
|
+
os.unlink(tmp)
|
|
50
|
+
|
|
51
|
+
def get(self, run_id: str, digest: str) -> bytes:
|
|
52
|
+
path = self._path(run_id, digest)
|
|
53
|
+
try:
|
|
54
|
+
data = path.read_bytes()
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
raise ArtifactMissing(digest) from None
|
|
57
|
+
if digest_of(data) != digest:
|
|
58
|
+
raise ArtifactCorrupt(digest)
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
def exists(self, run_id: str, digest: str) -> bool:
|
|
62
|
+
return self._path(run_id, digest).exists()
|
ledger/canonical.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Canonical JSON and hashing for ledger entries (04-ledger.md §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
GENESIS_HASH = "0" * 64
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NonCanonicalValue(ValueError):
|
|
14
|
+
"""A value that has no stable JSON form (NaN, infinity, or a non-JSON type)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check(value: Any, path: str) -> None:
|
|
18
|
+
if isinstance(value, float):
|
|
19
|
+
if math.isnan(value) or math.isinf(value):
|
|
20
|
+
raise NonCanonicalValue(f"{path}: NaN and infinity are not allowed")
|
|
21
|
+
elif isinstance(value, dict):
|
|
22
|
+
for key, item in value.items():
|
|
23
|
+
if not isinstance(key, str):
|
|
24
|
+
raise NonCanonicalValue(f"{path}: object keys must be strings")
|
|
25
|
+
_check(item, f"{path}.{key}")
|
|
26
|
+
elif isinstance(value, list | tuple):
|
|
27
|
+
for i, item in enumerate(value):
|
|
28
|
+
_check(item, f"{path}[{i}]")
|
|
29
|
+
elif value is not None and not isinstance(value, str | int | bool):
|
|
30
|
+
raise NonCanonicalValue(f"{path}: unsupported type {type(value).__name__}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_json(value: Any) -> str:
|
|
34
|
+
"""Sorted keys, no whitespace, UTF-8 text. Same output on every platform."""
|
|
35
|
+
_check(value, "$")
|
|
36
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
37
|
+
allow_nan=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sha256_hex(data: bytes) -> str:
|
|
41
|
+
return hashlib.sha256(data).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def entry_hash(fields: dict[str, Any]) -> str:
|
|
45
|
+
"""SHA-256 over the canonical JSON of every entry field except `hash`."""
|
|
46
|
+
if "hash" in fields:
|
|
47
|
+
raise ValueError("entry_hash takes the fields without `hash`")
|
|
48
|
+
return sha256_hex(canonical_json(fields).encode("utf-8"))
|
ledger/core.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""The Ledger (04-ledger.md §3): the append-only record of a run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import payloads
|
|
15
|
+
from .artifacts import DIGEST_RE, FileArtifactStore, digest_of
|
|
16
|
+
from .canonical import GENESIS_HASH, NonCanonicalValue, canonical_json, entry_hash
|
|
17
|
+
from .errors import ConcurrentWrite, ExportError, LedgerRejection, UnknownRun
|
|
18
|
+
from .model import (
|
|
19
|
+
ARTIFACT_KINDS,
|
|
20
|
+
Actor,
|
|
21
|
+
ChainReport,
|
|
22
|
+
Entry,
|
|
23
|
+
Rejection,
|
|
24
|
+
RunState,
|
|
25
|
+
SpendSummary,
|
|
26
|
+
)
|
|
27
|
+
from .rules import RunIndex, check
|
|
28
|
+
from .storage import Storage, open_storage
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _utc_now() -> str:
|
|
32
|
+
return datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _strings(value: Any) -> Iterator[str]:
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
yield value
|
|
38
|
+
elif isinstance(value, dict):
|
|
39
|
+
for k, v in value.items():
|
|
40
|
+
yield k
|
|
41
|
+
yield from _strings(v)
|
|
42
|
+
elif isinstance(value, list | tuple):
|
|
43
|
+
for item in value:
|
|
44
|
+
yield from _strings(item)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def artifact_refs(e: Entry) -> list[str]:
|
|
48
|
+
"""Digests of stored artifacts an entry points to (04-ledger.md §4).
|
|
49
|
+
|
|
50
|
+
Only these fields name artifacts. Other digests, such as the content digest of an
|
|
51
|
+
evaluation data set, describe data that lives outside the ledger.
|
|
52
|
+
"""
|
|
53
|
+
pl = e.payload
|
|
54
|
+
found: list[Any] = []
|
|
55
|
+
if e.type == "call.recorded":
|
|
56
|
+
found = [pl.get("request_artifact"), pl.get("response_artifact")]
|
|
57
|
+
elif e.type == "prereg.registered":
|
|
58
|
+
found = [pl.get("code_digest")]
|
|
59
|
+
elif e.type in ("experiment.finished", "paper.built"):
|
|
60
|
+
found = list(pl.get("artifacts", {}).values())
|
|
61
|
+
elif e.type == "selfcheck.result":
|
|
62
|
+
found = [pl.get("paper")]
|
|
63
|
+
return [d for d in found if isinstance(d, str) and DIGEST_RE.match(d)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Ledger:
|
|
67
|
+
"""One writer per run (`loop`); any number of readers.
|
|
68
|
+
|
|
69
|
+
Secret values live only in this object's memory (INV-7, KB-013). A new `Ledger` is a
|
|
70
|
+
restart: writes to a run are refused until `register_secrets` is called for it.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, storage_url: str, artifact_root: str | Path,
|
|
74
|
+
clock: Callable[[], str] = _utc_now) -> None:
|
|
75
|
+
self._storage: Storage = open_storage(storage_url)
|
|
76
|
+
self._artifacts = FileArtifactStore(artifact_root)
|
|
77
|
+
self._clock = clock
|
|
78
|
+
self._secrets: dict[str, tuple[str, ...]] = {}
|
|
79
|
+
self._index: dict[str, RunIndex] = {}
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._storage.close()
|
|
83
|
+
|
|
84
|
+
# ---- secrets -------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def register_secrets(self, run_id: str, secrets: list[str]) -> None:
|
|
87
|
+
if not self._storage.run_exists(run_id):
|
|
88
|
+
raise UnknownRun(run_id)
|
|
89
|
+
self._secrets[run_id] = self._clean_secrets(secrets)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _clean_secrets(secrets: Iterable[str]) -> tuple[str, ...]:
|
|
93
|
+
values = tuple(secrets)
|
|
94
|
+
if any(not isinstance(s, str) or s == "" for s in values):
|
|
95
|
+
raise ValueError("secret values must be non-empty strings")
|
|
96
|
+
return values
|
|
97
|
+
|
|
98
|
+
def _secret_hit(self, run_id: str, texts: Iterable[str]) -> int | None:
|
|
99
|
+
secrets = self._secrets.get(run_id, ())
|
|
100
|
+
for text in texts:
|
|
101
|
+
for i, secret in enumerate(secrets):
|
|
102
|
+
if secret in text:
|
|
103
|
+
return i
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# ---- rejections ----------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _reject(self, run_id: str, target: str, rule: str, message: str) -> LedgerRejection:
|
|
109
|
+
# The message is built by the ledger and never contains payload content (INV-7).
|
|
110
|
+
self._storage.add_rejection(Rejection(run_id, self._clock(), target, rule, message))
|
|
111
|
+
return LedgerRejection(rule, message)
|
|
112
|
+
|
|
113
|
+
def rejections(self, run_id: str) -> list[Rejection]:
|
|
114
|
+
return self._storage.rejections(run_id)
|
|
115
|
+
|
|
116
|
+
# ---- writing -------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def open_run(self, config: dict[str, Any], secrets: list[str]) -> str:
|
|
119
|
+
run_id = "run_" + uuid.uuid4().hex
|
|
120
|
+
values = self._clean_secrets(secrets)
|
|
121
|
+
payload = {"idea": config.get("idea"), "config": config}
|
|
122
|
+
self._secrets[run_id] = values
|
|
123
|
+
try:
|
|
124
|
+
self._append(run_id, "run.created", payload, [], "loop", new_run=True)
|
|
125
|
+
except LedgerRejection:
|
|
126
|
+
del self._secrets[run_id]
|
|
127
|
+
raise
|
|
128
|
+
return run_id
|
|
129
|
+
|
|
130
|
+
def append(self, run_id: str, type: str, payload: dict[str, Any],
|
|
131
|
+
refs: list[str] | None = None, *, actor: Actor = "loop") -> Entry:
|
|
132
|
+
return self._append(run_id, type, payload, list(refs or []), actor, new_run=False)
|
|
133
|
+
|
|
134
|
+
def _append(self, run_id: str, entry_type: str, payload: dict[str, Any], refs: list[str],
|
|
135
|
+
actor: Actor, *, new_run: bool) -> Entry:
|
|
136
|
+
if actor not in ("loop", "user", "system"):
|
|
137
|
+
raise ValueError("actor must be loop, user or system")
|
|
138
|
+
if not new_run and not self._storage.run_exists(run_id):
|
|
139
|
+
raise UnknownRun(run_id)
|
|
140
|
+
if run_id not in self._secrets:
|
|
141
|
+
raise self._reject(run_id, entry_type, "INV-7",
|
|
142
|
+
"secrets not registered for this run since the ledger started")
|
|
143
|
+
# 1. schema
|
|
144
|
+
try:
|
|
145
|
+
canonical_json({"payload": payload, "refs": refs})
|
|
146
|
+
model = payloads.validate_payload(entry_type, payload)
|
|
147
|
+
except NonCanonicalValue as exc:
|
|
148
|
+
raise self._reject(run_id, entry_type, "schema", str(exc)) from None
|
|
149
|
+
except LedgerRejection as exc:
|
|
150
|
+
raise self._reject(run_id, entry_type, exc.rule, exc.message) from None
|
|
151
|
+
# 2-3. refs and the type's rules
|
|
152
|
+
idx = self._refresh(run_id)
|
|
153
|
+
try:
|
|
154
|
+
check(idx, entry_type, model, refs)
|
|
155
|
+
except LedgerRejection as exc:
|
|
156
|
+
raise self._reject(run_id, entry_type, exc.rule, exc.message) from None
|
|
157
|
+
# 4. secret scan
|
|
158
|
+
hit = self._secret_hit(run_id, _strings([payload, refs]))
|
|
159
|
+
if hit is not None:
|
|
160
|
+
raise self._reject(run_id, entry_type, "INV-7",
|
|
161
|
+
f"content contains the value of registered secret #{hit}")
|
|
162
|
+
|
|
163
|
+
at = self._clock()
|
|
164
|
+
expected_seq = idx.last_seq + 1
|
|
165
|
+
|
|
166
|
+
def build(prev_hash: str | None, seq: int) -> Entry:
|
|
167
|
+
if seq != expected_seq:
|
|
168
|
+
raise ConcurrentWrite(f"expected seq {expected_seq}, storage is at {seq - 1}")
|
|
169
|
+
fields: dict[str, Any] = {
|
|
170
|
+
"run_id": run_id, "seq": seq, "entry_id": f"{run_id}:{seq}",
|
|
171
|
+
"type": entry_type, "at": at, "actor": actor, "payload": payload,
|
|
172
|
+
"refs": refs, "prev_hash": prev_hash or GENESIS_HASH,
|
|
173
|
+
}
|
|
174
|
+
return Entry(**fields, hash=entry_hash(fields))
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
entry = self._storage.append(run_id, build)
|
|
178
|
+
except ConcurrentWrite:
|
|
179
|
+
self._index.pop(run_id, None)
|
|
180
|
+
raise
|
|
181
|
+
idx.apply(entry)
|
|
182
|
+
return entry
|
|
183
|
+
|
|
184
|
+
def close_dangling(self, run_id: str) -> list[Entry]:
|
|
185
|
+
"""INV-6: on resume, close every unmatched experiment.started as `lost`."""
|
|
186
|
+
idx = self._refresh(run_id)
|
|
187
|
+
closed = []
|
|
188
|
+
for started in idx.open_experiments():
|
|
189
|
+
closed.append(self.append(
|
|
190
|
+
run_id, "experiment.finished",
|
|
191
|
+
{"experiment": started, "status": "lost", "metrics": [], "artifacts": {},
|
|
192
|
+
"duration_s": None, "cost_usd": None, "metered": False},
|
|
193
|
+
[started], actor="system"))
|
|
194
|
+
return closed
|
|
195
|
+
|
|
196
|
+
def put_artifact(self, run_id: str, data: bytes | Path, kind: str) -> str:
|
|
197
|
+
if kind not in ARTIFACT_KINDS:
|
|
198
|
+
raise ValueError(f"unknown artifact kind {kind!r}")
|
|
199
|
+
if not self._storage.run_exists(run_id):
|
|
200
|
+
raise UnknownRun(run_id)
|
|
201
|
+
if run_id not in self._secrets:
|
|
202
|
+
raise self._reject(run_id, "artifact", "INV-7",
|
|
203
|
+
"secrets not registered for this run since the ledger started")
|
|
204
|
+
content = data.read_bytes() if isinstance(data, Path) else bytes(data)
|
|
205
|
+
for i, secret in enumerate(self._secrets[run_id]):
|
|
206
|
+
if secret.encode("utf-8") in content:
|
|
207
|
+
raise self._reject(run_id, "artifact", "INV-7",
|
|
208
|
+
f"content contains the value of registered secret #{i}")
|
|
209
|
+
digest = digest_of(content)
|
|
210
|
+
self._artifacts.put(run_id, digest, content)
|
|
211
|
+
self._storage.add_artifact(run_id, digest, kind, len(content), self._clock())
|
|
212
|
+
return digest
|
|
213
|
+
|
|
214
|
+
# ---- reading -------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def _refresh(self, run_id: str) -> RunIndex:
|
|
217
|
+
idx = self._index.get(run_id)
|
|
218
|
+
if idx is None:
|
|
219
|
+
idx = self._index[run_id] = RunIndex(run_id)
|
|
220
|
+
for e in self._storage.entries(run_id, since_seq=idx.last_seq):
|
|
221
|
+
idx.apply(e)
|
|
222
|
+
return idx
|
|
223
|
+
|
|
224
|
+
def get_artifact(self, run_id: str, digest: str) -> bytes:
|
|
225
|
+
return self._artifacts.get(run_id, digest)
|
|
226
|
+
|
|
227
|
+
def entries(self, run_id: str, types: Iterable[str] | None = None,
|
|
228
|
+
refs: Iterable[str] | None = None,
|
|
229
|
+
since_seq: int | None = None) -> Iterator[Entry]:
|
|
230
|
+
type_set = set(types) if types is not None else None
|
|
231
|
+
ref_set = set(refs) if refs is not None else None
|
|
232
|
+
for e in self._storage.entries(run_id, since_seq=since_seq or 0):
|
|
233
|
+
if type_set is not None and e.type not in type_set:
|
|
234
|
+
continue
|
|
235
|
+
if ref_set is not None and not ref_set.intersection(e.refs):
|
|
236
|
+
continue
|
|
237
|
+
yield e
|
|
238
|
+
|
|
239
|
+
def state(self, run_id: str) -> RunState:
|
|
240
|
+
if not self._storage.run_exists(run_id):
|
|
241
|
+
raise UnknownRun(run_id)
|
|
242
|
+
idx = RunIndex(run_id)
|
|
243
|
+
for e in self._storage.entries(run_id):
|
|
244
|
+
idx.apply(e)
|
|
245
|
+
return idx.to_state()
|
|
246
|
+
|
|
247
|
+
def spend(self, run_id: str) -> SpendSummary:
|
|
248
|
+
if not self._storage.run_exists(run_id):
|
|
249
|
+
raise UnknownRun(run_id)
|
|
250
|
+
total = 0.0
|
|
251
|
+
by_source: dict[str, float] = {}
|
|
252
|
+
unmetered = 0
|
|
253
|
+
limit: float | None = None
|
|
254
|
+
for e in self._storage.entries(run_id):
|
|
255
|
+
pl = e.payload
|
|
256
|
+
if e.type == "run.created":
|
|
257
|
+
money = pl.get("config", {}).get("limits", {}).get("money")
|
|
258
|
+
limit = float(money) if isinstance(money, int | float) else None
|
|
259
|
+
continue
|
|
260
|
+
if e.type == "call.recorded":
|
|
261
|
+
source = pl["source"]
|
|
262
|
+
elif e.type == "experiment.finished":
|
|
263
|
+
source = "executor"
|
|
264
|
+
else:
|
|
265
|
+
continue
|
|
266
|
+
if not pl.get("metered") or pl.get("cost_usd") is None:
|
|
267
|
+
unmetered += 1
|
|
268
|
+
continue
|
|
269
|
+
cost = float(pl["cost_usd"])
|
|
270
|
+
total += cost
|
|
271
|
+
by_source[source] = by_source.get(source, 0.0) + cost
|
|
272
|
+
return SpendSummary(total_usd=total, by_source=by_source, unmetered_items=unmetered,
|
|
273
|
+
money_limit_usd=limit)
|
|
274
|
+
|
|
275
|
+
def verify(self, run_id: str) -> ChainReport:
|
|
276
|
+
entries = self._storage.entries(run_id)
|
|
277
|
+
prev = GENESIS_HASH
|
|
278
|
+
for i, e in enumerate(entries, start=1):
|
|
279
|
+
reason = None
|
|
280
|
+
if e.seq != i:
|
|
281
|
+
reason = f"expected seq {i}, found {e.seq}"
|
|
282
|
+
elif e.run_id != run_id or e.entry_id != f"{run_id}:{e.seq}":
|
|
283
|
+
reason = "entry does not belong to this run"
|
|
284
|
+
elif e.prev_hash != prev:
|
|
285
|
+
reason = "prev_hash does not match the previous entry"
|
|
286
|
+
elif entry_hash(e.hashed_fields()) != e.hash:
|
|
287
|
+
reason = "hash does not match the entry content"
|
|
288
|
+
if reason:
|
|
289
|
+
return ChainReport(run_id, len(entries), ok=False, first_break_seq=i,
|
|
290
|
+
reason=reason)
|
|
291
|
+
prev = e.hash
|
|
292
|
+
return ChainReport(run_id, len(entries), ok=True,
|
|
293
|
+
head_hash=entries[-1].hash if entries else None)
|
|
294
|
+
|
|
295
|
+
def export(self, run_id: str, dest: Path) -> Path:
|
|
296
|
+
"""INV-8: every entry and every referenced artifact, verified. All or nothing."""
|
|
297
|
+
dest = Path(dest)
|
|
298
|
+
if dest.exists() and any(dest.iterdir()):
|
|
299
|
+
raise ExportError("destination exists and is not empty")
|
|
300
|
+
if not self._storage.run_exists(run_id):
|
|
301
|
+
raise UnknownRun(run_id)
|
|
302
|
+
report = self.verify(run_id)
|
|
303
|
+
if not report.ok:
|
|
304
|
+
raise ExportError(f"chain broken at seq {report.first_break_seq}: {report.reason}")
|
|
305
|
+
entries = self._storage.entries(run_id)
|
|
306
|
+
digests = sorted({d for e in entries for d in artifact_refs(e)})
|
|
307
|
+
|
|
308
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
tmp = Path(tempfile.mkdtemp(dir=dest.parent, prefix=".export-"))
|
|
310
|
+
try:
|
|
311
|
+
(tmp / "artifacts").mkdir()
|
|
312
|
+
kinds: dict[str, str | None] = {}
|
|
313
|
+
for d in digests:
|
|
314
|
+
try:
|
|
315
|
+
data = self._artifacts.get(run_id, d)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
raise ExportError(f"referenced artifact {d} unavailable: "
|
|
318
|
+
f"{type(exc).__name__}") from None
|
|
319
|
+
(tmp / "artifacts" / d.removeprefix("sha256:")).write_bytes(data)
|
|
320
|
+
kinds[d] = self._storage.artifact_kind(run_id, d)
|
|
321
|
+
with open(tmp / "ledger.jsonl", "w", encoding="utf-8", newline="\n") as f:
|
|
322
|
+
for e in entries:
|
|
323
|
+
f.write(canonical_json(e.to_dict()) + "\n")
|
|
324
|
+
with open(tmp / "rejections.jsonl", "w", encoding="utf-8", newline="\n") as f:
|
|
325
|
+
for r in self._storage.rejections(run_id):
|
|
326
|
+
f.write(canonical_json(r.__dict__) + "\n")
|
|
327
|
+
(tmp / "artifacts" / "index.json").write_text(
|
|
328
|
+
json.dumps(kinds, indent=2, sort_keys=True), encoding="utf-8")
|
|
329
|
+
(tmp / "chain_report.json").write_text(
|
|
330
|
+
json.dumps(report.__dict__, indent=2, sort_keys=True), encoding="utf-8")
|
|
331
|
+
if dest.exists():
|
|
332
|
+
dest.rmdir()
|
|
333
|
+
tmp.rename(dest)
|
|
334
|
+
except BaseException:
|
|
335
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
336
|
+
raise
|
|
337
|
+
return dest
|
ledger/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Ledger errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LedgerError(Exception):
|
|
7
|
+
"""Base class for ledger errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LedgerRejection(LedgerError):
|
|
11
|
+
"""An append or artifact write refused by a rule. Nothing was stored (04-ledger.md §5)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, rule: str, message: str) -> None:
|
|
14
|
+
super().__init__(f"{rule}: {message}")
|
|
15
|
+
self.rule = rule
|
|
16
|
+
self.message = message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnknownRun(LedgerError):
|
|
20
|
+
"""The run id does not exist."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConcurrentWrite(LedgerError):
|
|
24
|
+
"""Another writer appended to the run between validation and storage."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ArtifactMissing(LedgerError):
|
|
28
|
+
"""No artifact with this digest exists for the run."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArtifactCorrupt(LedgerError):
|
|
32
|
+
"""A stored artifact no longer matches its digest."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExportError(LedgerError):
|
|
36
|
+
"""An export could not be completed (INV-8)."""
|