interlockd 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.
- interlockd/__init__.py +3 -0
- interlockd/__main__.py +7 -0
- interlockd/aibom.py +136 -0
- interlockd/api.py +307 -0
- interlockd/auth.py +38 -0
- interlockd/calibration.py +260 -0
- interlockd/cli.py +1920 -0
- interlockd/compose.py +84 -0
- interlockd/config.py +80 -0
- interlockd/corpus.py +505 -0
- interlockd/data/packs/_corpus.yaml +7 -0
- interlockd/data/packs/egress.jsonl +38 -0
- interlockd/data/packs/fileops.jsonl +20 -0
- interlockd/data/packs/git.jsonl +51 -0
- interlockd/data/packs/packages.jsonl +24 -0
- interlockd/data/packs/persistence.jsonl +31 -0
- interlockd/data/packs/secrets.jsonl +22 -0
- interlockd/data/packs/shell.jsonl +75 -0
- interlockd/det/__init__.py +53 -0
- interlockd/det/describe.py +66 -0
- interlockd/det/engine.py +153 -0
- interlockd/det/flow.py +106 -0
- interlockd/det/mandate.py +108 -0
- interlockd/det/mcp.py +70 -0
- interlockd/det/memory.py +50 -0
- interlockd/det/packs.py +531 -0
- interlockd/det/shellparse.py +438 -0
- interlockd/egress.py +124 -0
- interlockd/gateway.py +350 -0
- interlockd/ids.py +25 -0
- interlockd/insights.py +316 -0
- interlockd/integrations/__init__.py +2 -0
- interlockd/integrations/client.py +85 -0
- interlockd/integrations/langchain.py +59 -0
- interlockd/integrations/openai_agents.py +72 -0
- interlockd/integrations/strands.py +69 -0
- interlockd/judges/__init__.py +107 -0
- interlockd/judges/guardrails.py +94 -0
- interlockd/judges/local_probe.py +192 -0
- interlockd/judges/projector.py +82 -0
- interlockd/judges/strands_decider.py +160 -0
- interlockd/ledger.py +62 -0
- interlockd/mcp_http.py +216 -0
- interlockd/mcp_proxy.py +315 -0
- interlockd/mcp_server.py +211 -0
- interlockd/models.py +98 -0
- interlockd/normalize/__init__.py +1 -0
- interlockd/normalize/claude_code.py +89 -0
- interlockd/normalize/cursor.py +59 -0
- interlockd/normalize/gemini_cli.py +55 -0
- interlockd/normalize/kiro.py +65 -0
- interlockd/normalize/more.py +98 -0
- interlockd/ogr.py +542 -0
- interlockd/onboard.py +300 -0
- interlockd/otel.py +139 -0
- interlockd/paths.py +63 -0
- interlockd/pipeline.py +370 -0
- interlockd/policy/__init__.py +220 -0
- interlockd/policy_lint.py +76 -0
- interlockd/render.py +142 -0
- interlockd/sandbox.py +87 -0
- interlockd/scan.py +405 -0
- interlockd/schema/verdict.schema.json +68 -0
- interlockd/selftest.py +101 -0
- interlockd/service.py +247 -0
- interlockd/shims/claude-code/interlock-hook.sh +113 -0
- interlockd/shims/kiro/interlock-hook.mjs +101 -0
- interlockd/shims/kiro/interlock-hook.sh +82 -0
- interlockd/shims/opencode/interlock.js +60 -0
- interlockd/store/__init__.py +22 -0
- interlockd/store/drainer.py +74 -0
- interlockd/store/dynamodb.py +144 -0
- interlockd/store/memory.py +28 -0
- interlockd/store/spool.py +159 -0
- interlockd/testkit.py +401 -0
- interlockd/tiers.py +95 -0
- interlockd/transcript/__init__.py +1 -0
- interlockd/transcript/claude_code.py +73 -0
- interlockd/transcript/kiro.py +102 -0
- interlockd-0.1.0.dist-info/METADATA +125 -0
- interlockd-0.1.0.dist-info/RECORD +84 -0
- interlockd-0.1.0.dist-info/WHEEL +4 -0
- interlockd-0.1.0.dist-info/entry_points.txt +2 -0
- interlockd-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Background drainer: moves undrained spool records to the repository in batches, off the decision path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import Repository
|
|
10
|
+
from .spool import Spool
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("interlockd.drainer")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Drainer:
|
|
16
|
+
def __init__(self, spool: Spool, repo: Repository, *, interval_s: float = 2.0, batch: int = 25):
|
|
17
|
+
self.spool, self.repo, self.interval, self.batch = spool, repo, interval_s, batch
|
|
18
|
+
self._stop = threading.Event()
|
|
19
|
+
self._thread: threading.Thread | None = None
|
|
20
|
+
self.drained_total = 0
|
|
21
|
+
self.last_error: str | None = None
|
|
22
|
+
|
|
23
|
+
def drain_once(self) -> int:
|
|
24
|
+
n = 0
|
|
25
|
+
pending: list[tuple[Any, int, dict[str, Any]]] = []
|
|
26
|
+
|
|
27
|
+
def flush() -> None:
|
|
28
|
+
nonlocal n
|
|
29
|
+
if not pending:
|
|
30
|
+
return
|
|
31
|
+
records = [r for _, _, r in pending]
|
|
32
|
+
if hasattr(self.repo, "put_events"):
|
|
33
|
+
self.repo.put_events(records) # type: ignore[attr-defined]
|
|
34
|
+
else:
|
|
35
|
+
for r in records:
|
|
36
|
+
self.repo.put_event(r)
|
|
37
|
+
seg, off, _ = pending[-1]
|
|
38
|
+
self.spool.mark_drained(seg, off)
|
|
39
|
+
n += len(pending)
|
|
40
|
+
pending.clear()
|
|
41
|
+
|
|
42
|
+
current_seg = None
|
|
43
|
+
for seg, off, rec in self.spool.undrained():
|
|
44
|
+
if current_seg is not None and seg != current_seg:
|
|
45
|
+
flush()
|
|
46
|
+
current_seg = seg
|
|
47
|
+
pending.append((seg, off, rec))
|
|
48
|
+
if len(pending) >= self.batch:
|
|
49
|
+
flush()
|
|
50
|
+
flush()
|
|
51
|
+
self.drained_total += n
|
|
52
|
+
return n
|
|
53
|
+
|
|
54
|
+
def _run(self) -> None:
|
|
55
|
+
backoff = self.interval
|
|
56
|
+
while not self._stop.is_set():
|
|
57
|
+
try:
|
|
58
|
+
self.drain_once()
|
|
59
|
+
self.last_error = None
|
|
60
|
+
backoff = self.interval
|
|
61
|
+
except Exception as e: # noqa: BLE001 boundary: network, credentials, throttling; keep the spool, retry
|
|
62
|
+
self.last_error = f"{type(e).__name__}: {e}"
|
|
63
|
+
log.warning("drain failed, will retry: %s", self.last_error)
|
|
64
|
+
backoff = min(backoff * 2, 60.0)
|
|
65
|
+
self._stop.wait(backoff)
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
self._thread = threading.Thread(target=self._run, name="interlock-drainer", daemon=True)
|
|
69
|
+
self._thread.start()
|
|
70
|
+
|
|
71
|
+
def stop(self) -> None:
|
|
72
|
+
self._stop.set()
|
|
73
|
+
if self._thread:
|
|
74
|
+
self._thread.join(timeout=5)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""DynamoDB single-table repository (design §7). boto3 is imported lazily so the daemon runs without it.
|
|
2
|
+
|
|
3
|
+
Items:
|
|
4
|
+
Event PK=EVT#<event_id> SK=EVT
|
|
5
|
+
Explanation PK=EVT#<event_id> SK=EXPL
|
|
6
|
+
Feedback PK=EVT#<event_id> SK=FDBK#<ulid>
|
|
7
|
+
Session summary PK=SES#<session_id> SK=META
|
|
8
|
+
Indexes:
|
|
9
|
+
GSI1-session session_id / event_id
|
|
10
|
+
GSI2-workspace-time GSI2PK (WS#<workspace>#<yyyy-mm-dd-hh>) / event_id
|
|
11
|
+
GSI3-review GSI3PK (DISP#<disposition>#<yyyy-mm-dd>) / event_id (sparse)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import UTC, datetime, timedelta
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from . import hour_bucket
|
|
21
|
+
|
|
22
|
+
REVIEW_DISPOSITIONS = {"ask", "block", "withhold", "warn"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _to_ddb(value: Any) -> Any:
|
|
26
|
+
if isinstance(value, float):
|
|
27
|
+
return Decimal(str(value))
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
return {k: _to_ddb(v) for k, v in value.items()}
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
return [_to_ddb(v) for v in value]
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _from_ddb(value: Any) -> Any:
|
|
36
|
+
if isinstance(value, Decimal):
|
|
37
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return {k: _from_ddb(v) for k, v in value.items()}
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [_from_ddb(v) for v in value]
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
TABLE_DEFINITION: dict[str, Any] = {
|
|
46
|
+
"AttributeDefinitions": [
|
|
47
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
48
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
49
|
+
{"AttributeName": "session_id", "AttributeType": "S"},
|
|
50
|
+
{"AttributeName": "event_id", "AttributeType": "S"},
|
|
51
|
+
{"AttributeName": "GSI2PK", "AttributeType": "S"},
|
|
52
|
+
{"AttributeName": "GSI3PK", "AttributeType": "S"},
|
|
53
|
+
],
|
|
54
|
+
"KeySchema": [{"AttributeName": "PK", "KeyType": "HASH"}, {"AttributeName": "SK", "KeyType": "RANGE"}],
|
|
55
|
+
"BillingMode": "PAY_PER_REQUEST",
|
|
56
|
+
"GlobalSecondaryIndexes": [
|
|
57
|
+
{"IndexName": "GSI1-session", "Projection": {"ProjectionType": "ALL"},
|
|
58
|
+
"KeySchema": [{"AttributeName": "session_id", "KeyType": "HASH"}, {"AttributeName": "event_id", "KeyType": "RANGE"}]},
|
|
59
|
+
{"IndexName": "GSI2-workspace-time", "Projection": {"ProjectionType": "ALL"},
|
|
60
|
+
"KeySchema": [{"AttributeName": "GSI2PK", "KeyType": "HASH"}, {"AttributeName": "event_id", "KeyType": "RANGE"}]},
|
|
61
|
+
{"IndexName": "GSI3-review", "Projection": {"ProjectionType": "ALL"},
|
|
62
|
+
"KeySchema": [{"AttributeName": "GSI3PK", "KeyType": "HASH"}, {"AttributeName": "event_id", "KeyType": "RANGE"}]},
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DynamoRepository:
|
|
68
|
+
def __init__(self, table_name: str, *, region: str | None = None, profile: str | None = None, retention_days: int = 90, resource=None):
|
|
69
|
+
import boto3 # lazy
|
|
70
|
+
|
|
71
|
+
self.ddb = resource or boto3.Session(profile_name=profile, region_name=region).resource("dynamodb")
|
|
72
|
+
self.table = self.ddb.Table(table_name)
|
|
73
|
+
self.table_name = table_name
|
|
74
|
+
self.retention_days = retention_days
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def ensure_table(cls, table_name: str, *, region: str | None = None, resource=None) -> DynamoRepository:
|
|
78
|
+
import boto3 # lazy
|
|
79
|
+
|
|
80
|
+
ddb = resource or boto3.resource("dynamodb", region_name=region)
|
|
81
|
+
existing = [t.name for t in ddb.tables.all()]
|
|
82
|
+
if table_name not in existing:
|
|
83
|
+
t = ddb.create_table(TableName=table_name, **TABLE_DEFINITION)
|
|
84
|
+
t.wait_until_exists()
|
|
85
|
+
ddb.meta.client.update_time_to_live(
|
|
86
|
+
TableName=table_name, TimeToLiveSpecification={"Enabled": True, "AttributeName": "expires_at"}
|
|
87
|
+
)
|
|
88
|
+
return cls(table_name, region=region, resource=ddb)
|
|
89
|
+
|
|
90
|
+
def _event_item(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
item = _to_ddb(dict(record))
|
|
92
|
+
item["PK"] = f"EVT#{record['event_id']}"
|
|
93
|
+
item["SK"] = "EVT"
|
|
94
|
+
ws = record.get("agent_workspace") or ""
|
|
95
|
+
item["GSI2PK"] = f"WS#{ws}#{hour_bucket(record['received_at'])}"
|
|
96
|
+
if record.get("disposition") in REVIEW_DISPOSITIONS:
|
|
97
|
+
item["GSI3PK"] = f"DISP#{record['disposition']}#{record['received_at'][:10]}"
|
|
98
|
+
if "expires_at" not in item:
|
|
99
|
+
exp = datetime.now(UTC) + timedelta(days=self.retention_days)
|
|
100
|
+
item["expires_at"] = int(exp.timestamp())
|
|
101
|
+
return item
|
|
102
|
+
|
|
103
|
+
def put_event(self, record: dict[str, Any]) -> None:
|
|
104
|
+
self.table.put_item(Item=self._event_item(record))
|
|
105
|
+
|
|
106
|
+
def put_events(self, records: list[dict[str, Any]]) -> None:
|
|
107
|
+
with self.table.batch_writer() as bw:
|
|
108
|
+
for r in records:
|
|
109
|
+
bw.put_item(Item=self._event_item(r))
|
|
110
|
+
|
|
111
|
+
def get_event(self, event_id: str) -> dict[str, Any] | None:
|
|
112
|
+
r = self.table.get_item(Key={"PK": f"EVT#{event_id}", "SK": "EVT"})
|
|
113
|
+
item = r.get("Item")
|
|
114
|
+
return _from_ddb(item) if item else None
|
|
115
|
+
|
|
116
|
+
def tail(self, workspace: str | None = None, limit: int = 50, *, hours_back: int = 48) -> list[dict[str, Any]]:
|
|
117
|
+
from boto3.dynamodb.conditions import Key # lazy
|
|
118
|
+
|
|
119
|
+
out: list[dict[str, Any]] = []
|
|
120
|
+
now = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)
|
|
121
|
+
ws = workspace or ""
|
|
122
|
+
for h in range(hours_back):
|
|
123
|
+
bucket = (now - timedelta(hours=h)).strftime("%Y-%m-%d-%H")
|
|
124
|
+
resp = self.table.query(
|
|
125
|
+
IndexName="GSI2-workspace-time",
|
|
126
|
+
KeyConditionExpression=Key("GSI2PK").eq(f"WS#{ws}#{bucket}"),
|
|
127
|
+
ScanIndexForward=False,
|
|
128
|
+
Limit=limit - len(out),
|
|
129
|
+
)
|
|
130
|
+
out.extend(_from_ddb(i) for i in resp.get("Items", []))
|
|
131
|
+
if len(out) >= limit:
|
|
132
|
+
break
|
|
133
|
+
return out[:limit]
|
|
134
|
+
|
|
135
|
+
def put_session(self, summary: dict[str, Any]) -> None:
|
|
136
|
+
item = _to_ddb(dict(summary))
|
|
137
|
+
item["PK"] = f"SES#{summary['session_id']}"
|
|
138
|
+
item["SK"] = "META"
|
|
139
|
+
self.table.put_item(Item=item)
|
|
140
|
+
|
|
141
|
+
def get_session(self, session_id: str) -> dict[str, Any] | None:
|
|
142
|
+
r = self.table.get_item(Key={"PK": f"SES#{session_id}", "SK": "META"})
|
|
143
|
+
item = r.get("Item")
|
|
144
|
+
return _from_ddb(item) if item else None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""In-memory repository for tests and the spool-only mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MemoryRepository:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self.events: dict[str, dict[str, Any]] = {}
|
|
11
|
+
self.sessions: dict[str, dict[str, Any]] = {}
|
|
12
|
+
|
|
13
|
+
def put_event(self, record: dict[str, Any]) -> None:
|
|
14
|
+
self.events[record["event_id"]] = record
|
|
15
|
+
|
|
16
|
+
def get_event(self, event_id: str) -> dict[str, Any] | None:
|
|
17
|
+
return self.events.get(event_id)
|
|
18
|
+
|
|
19
|
+
def tail(self, workspace: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
20
|
+
rows = [r for r in self.events.values() if workspace is None or r.get("agent_workspace") == workspace]
|
|
21
|
+
rows.sort(key=lambda r: r["event_id"], reverse=True)
|
|
22
|
+
return rows[:limit]
|
|
23
|
+
|
|
24
|
+
def put_session(self, summary: dict[str, Any]) -> None:
|
|
25
|
+
self.sessions[summary["session_id"]] = summary
|
|
26
|
+
|
|
27
|
+
def get_session(self, session_id: str) -> dict[str, Any] | None:
|
|
28
|
+
return self.sessions.get(session_id)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Append-only JSONL spool with per-segment drain offsets, hash-chained for tamper evidence.
|
|
2
|
+
|
|
3
|
+
Segments are one file per UTC day. `append` writes one line and fsyncs so the record is durable on the
|
|
4
|
+
host before the verdict is returned. A drainer reads undrained bytes and advances the offset file after
|
|
5
|
+
the remote write is confirmed.
|
|
6
|
+
|
|
7
|
+
Every record carries `prev_hash` and `hash = sha256(prev_hash + canonical JSON of the record without `hash`)`,
|
|
8
|
+
chained across segments, so editing, deleting, reordering, or inserting a record breaks `verify()`. Truncating
|
|
9
|
+
the newest records is only detectable against an anchored head (`interlock audit head`). Records written before
|
|
10
|
+
chaining existed are reported as legacy, not as breaks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Spool:
|
|
27
|
+
def __init__(self, directory: Path, *, fsync: bool = True):
|
|
28
|
+
self.dir = Path(directory)
|
|
29
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self.fsync = fsync
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
def _segment(self, received_at: str | None = None) -> Path:
|
|
34
|
+
day = (received_at or datetime.now(UTC).isoformat())[:10]
|
|
35
|
+
return self.dir / f"{day}.jsonl"
|
|
36
|
+
|
|
37
|
+
def append(self, record: dict[str, Any]) -> None:
|
|
38
|
+
seg = self._segment() # by write time, not received_at: the chain must follow file order
|
|
39
|
+
with self._lock:
|
|
40
|
+
lock_fd = os.open(self.dir / ".lock", os.O_WRONLY | os.O_CREAT, 0o600)
|
|
41
|
+
try:
|
|
42
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX) # one chain even with several writers on this spool
|
|
43
|
+
rec = {k: v for k, v in record.items() if k not in ("hash", "prev_hash")}
|
|
44
|
+
rec["prev_hash"] = self.head()
|
|
45
|
+
rec["hash"] = record_hash(rec)
|
|
46
|
+
line = json.dumps(rec, separators=(",", ":"), ensure_ascii=False) + "\n"
|
|
47
|
+
fd = os.open(seg, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
48
|
+
try:
|
|
49
|
+
os.write(fd, line.encode("utf-8"))
|
|
50
|
+
if self.fsync:
|
|
51
|
+
os.fsync(fd)
|
|
52
|
+
finally:
|
|
53
|
+
os.close(fd)
|
|
54
|
+
finally:
|
|
55
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
56
|
+
os.close(lock_fd)
|
|
57
|
+
|
|
58
|
+
def head(self) -> str:
|
|
59
|
+
"""Hash of the newest chained record ("genesis" for an empty or legacy-only spool)."""
|
|
60
|
+
for seg in reversed(self.segments()):
|
|
61
|
+
last = _last_line(seg)
|
|
62
|
+
if last:
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(last).get("hash") or GENESIS
|
|
65
|
+
except ValueError:
|
|
66
|
+
return GENESIS
|
|
67
|
+
return GENESIS
|
|
68
|
+
|
|
69
|
+
def verify(self) -> dict[str, Any]:
|
|
70
|
+
"""Walk every segment in order. Returns {ok, records, legacy, head, break: {segment, line, reason} | None}."""
|
|
71
|
+
expected, n, legacy = None, 0, 0
|
|
72
|
+
for seg in self.segments():
|
|
73
|
+
for i, raw in enumerate(seg.read_text(encoding="utf-8").splitlines(), 1):
|
|
74
|
+
if not raw.strip():
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
rec = json.loads(raw)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return _broken(seg, i, "not valid JSON", n, legacy)
|
|
80
|
+
if "hash" not in rec:
|
|
81
|
+
if expected is not None:
|
|
82
|
+
return _broken(seg, i, "unchained record after the chain started (inserted?)", n, legacy)
|
|
83
|
+
legacy += 1
|
|
84
|
+
continue
|
|
85
|
+
prev = rec.get("prev_hash")
|
|
86
|
+
if expected is None and prev != GENESIS and legacy == 0:
|
|
87
|
+
return _broken(seg, i, "first chained record does not start the chain (records removed before it?)", n, legacy)
|
|
88
|
+
if expected is not None and prev != expected:
|
|
89
|
+
return _broken(seg, i, "prev_hash does not match the previous record (removed or reordered?)", n, legacy)
|
|
90
|
+
if record_hash({k: v for k, v in rec.items() if k != "hash"}) != rec["hash"]:
|
|
91
|
+
return _broken(seg, i, "content does not match its hash (edited)", n, legacy)
|
|
92
|
+
expected, n = rec["hash"], n + 1
|
|
93
|
+
return {"ok": True, "records": n, "legacy": legacy, "head": expected or GENESIS, "break": None}
|
|
94
|
+
|
|
95
|
+
def segments(self) -> list[Path]:
|
|
96
|
+
return sorted(self.dir.glob("*.jsonl"))
|
|
97
|
+
|
|
98
|
+
def iter_records(self, *, limit: int | None = None, reverse: bool = True) -> Iterator[dict[str, Any]]:
|
|
99
|
+
n = 0
|
|
100
|
+
segs = self.segments()
|
|
101
|
+
if reverse:
|
|
102
|
+
segs = list(reversed(segs))
|
|
103
|
+
for seg in segs:
|
|
104
|
+
lines = seg.read_text(encoding="utf-8").splitlines()
|
|
105
|
+
if reverse:
|
|
106
|
+
lines = list(reversed(lines))
|
|
107
|
+
for line in lines:
|
|
108
|
+
if not line.strip():
|
|
109
|
+
continue
|
|
110
|
+
yield json.loads(line)
|
|
111
|
+
n += 1
|
|
112
|
+
if limit is not None and n >= limit:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
def _offset_path(self, seg: Path) -> Path:
|
|
116
|
+
return seg.with_suffix(".drained")
|
|
117
|
+
|
|
118
|
+
def drained_offset(self, seg: Path) -> int:
|
|
119
|
+
p = self._offset_path(seg)
|
|
120
|
+
return int(p.read_text().strip() or 0) if p.exists() else 0
|
|
121
|
+
|
|
122
|
+
def undrained(self) -> Iterator[tuple[Path, int, dict[str, Any]]]:
|
|
123
|
+
"""Yield (segment, end_offset_after_record, record) for every record not yet drained."""
|
|
124
|
+
for seg in self.segments():
|
|
125
|
+
start = self.drained_offset(seg)
|
|
126
|
+
with open(seg, "rb") as f:
|
|
127
|
+
f.seek(start)
|
|
128
|
+
pos = start
|
|
129
|
+
for raw in f:
|
|
130
|
+
pos += len(raw)
|
|
131
|
+
if raw.strip():
|
|
132
|
+
yield seg, pos, json.loads(raw.decode("utf-8"))
|
|
133
|
+
|
|
134
|
+
def mark_drained(self, seg: Path, offset: int) -> None:
|
|
135
|
+
tmp = self._offset_path(seg).with_suffix(".drained.tmp")
|
|
136
|
+
tmp.write_text(str(offset))
|
|
137
|
+
os.replace(tmp, self._offset_path(seg))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
GENESIS = "genesis"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def record_hash(rec_without_hash: dict[str, Any]) -> str:
|
|
144
|
+
body = json.dumps(rec_without_hash, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
145
|
+
return hashlib.sha256(f"{rec_without_hash.get('prev_hash', GENESIS)}\n{body}".encode()).hexdigest()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _last_line(path: Path) -> str | None:
|
|
149
|
+
with open(path, "rb") as f:
|
|
150
|
+
f.seek(0, os.SEEK_END)
|
|
151
|
+
size = f.tell()
|
|
152
|
+
block = min(size, 1 << 20)
|
|
153
|
+
f.seek(size - block)
|
|
154
|
+
lines = [ln for ln in f.read().splitlines() if ln.strip()]
|
|
155
|
+
return lines[-1].decode("utf-8") if lines else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _broken(seg: Path, line: int, reason: str, n: int, legacy: int) -> dict[str, Any]:
|
|
159
|
+
return {"ok": False, "records": n, "legacy": legacy, "head": None, "break": {"segment": seg.name, "line": line, "reason": reason}}
|