stepcheck-dev 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.
- backend/__init__.py +0 -0
- backend/audit.py +351 -0
- backend/cli.py +274 -0
- backend/kernel/__init__.py +614 -0
- backend/kernel/_gates.py +299 -0
- backend/kernel/_okf.py +663 -0
- backend/kernel/_sandbox.py +135 -0
- backend/kernel/_telemetry.py +52 -0
- backend/kernel/_tokens.py +7 -0
- backend/server.py +584 -0
- stepcheck_dev-0.1.0.dist-info/METADATA +123 -0
- stepcheck_dev-0.1.0.dist-info/RECORD +17 -0
- stepcheck_dev-0.1.0.dist-info/WHEEL +5 -0
- stepcheck_dev-0.1.0.dist-info/entry_points.txt +2 -0
- stepcheck_dev-0.1.0.dist-info/licenses/LICENSE +202 -0
- stepcheck_dev-0.1.0.dist-info/licenses/NOTICE +4 -0
- stepcheck_dev-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""StateKernel — the deep module behind Seam 1 (see GLOSSARY.md).
|
|
2
|
+
|
|
3
|
+
Public interface, in full:
|
|
4
|
+
StateKernel().init_session(project_root) -> session_id
|
|
5
|
+
.checkout(session_id, node_id) -> CheckoutPayload
|
|
6
|
+
.commit(session_id, node_id, source, *, allow_deletion=False) -> CommitVerdict
|
|
7
|
+
.lock_node(session_id, node_id)
|
|
8
|
+
.get_telemetry(session_id) -> Telemetry
|
|
9
|
+
|
|
10
|
+
Everything in `_`-prefixed modules is implementation, never interface.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from . import _gates, _okf, _telemetry, _tokens
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sha256(data: str | bytes) -> str:
|
|
24
|
+
import hashlib
|
|
25
|
+
|
|
26
|
+
return hashlib.sha256(data.encode("utf-8") if isinstance(data, str) else data).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_DSSE_PAYLOAD_TYPE = "application/vnd.stepcheck.okf-commit+json"
|
|
30
|
+
_DSSE_KEYID = "stepcheck-deterministic-v1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _dsse_envelope(node_id: str, path: str, digest: str, exports: list[str], turn: int) -> dict:
|
|
34
|
+
"""Deterministic SHA-256 / DSSE-style attestation (GLOSSARY: Gate C).
|
|
35
|
+
`digest` is the SHA-256 of the exact bytes written to the tracked file."""
|
|
36
|
+
import json
|
|
37
|
+
|
|
38
|
+
payload = {
|
|
39
|
+
"node_id": node_id,
|
|
40
|
+
"path": path,
|
|
41
|
+
"sha256": digest,
|
|
42
|
+
"exports": list(exports),
|
|
43
|
+
"turn": turn,
|
|
44
|
+
}
|
|
45
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
46
|
+
return {
|
|
47
|
+
"payloadType": _DSSE_PAYLOAD_TYPE,
|
|
48
|
+
"payload": payload,
|
|
49
|
+
"signatures": [
|
|
50
|
+
{"keyid": _DSSE_KEYID, "sig": _sha256(f"DSSEv1 {_DSSE_PAYLOAD_TYPE} {canonical}")}
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"StateKernel",
|
|
56
|
+
"CheckoutPayload",
|
|
57
|
+
"CommitVerdict",
|
|
58
|
+
"Telemetry",
|
|
59
|
+
"GateError",
|
|
60
|
+
"MechanicalError",
|
|
61
|
+
"OmissionError",
|
|
62
|
+
"LogicalError",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
CHECKOUT_TOKEN_BUDGET = 4000 # hard cap on any CheckoutPayload (GLOSSARY)
|
|
66
|
+
|
|
67
|
+
ACTIVE = "ACTIVE"
|
|
68
|
+
VERIFIED = "VERIFIED"
|
|
69
|
+
LOCKED = "LOCKED"
|
|
70
|
+
DIRTY = "DIRTY_OMISSION_RISK"
|
|
71
|
+
REJECTED = "REJECTED"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GateError(Exception):
|
|
75
|
+
"""Base of every gate rejection."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MechanicalError(GateError):
|
|
79
|
+
"""Gate A: syntax, broken exported signatures, or locked-module mutation."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OmissionError(GateError):
|
|
83
|
+
"""Gate B: silent omission detected."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, message: str, subtype: str):
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
self.subtype = subtype
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LogicalError(GateError):
|
|
91
|
+
"""Gate C: a frame invariant no longer holds."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class CheckoutPayload:
|
|
96
|
+
session_id: str
|
|
97
|
+
node_id: str
|
|
98
|
+
frame_markdown: str
|
|
99
|
+
dependency_signatures: dict[str, list[str]]
|
|
100
|
+
locked_invariants: list[str]
|
|
101
|
+
token_count: int
|
|
102
|
+
dependency_skeletons: dict[str, str] = field(default_factory=dict)
|
|
103
|
+
focus: list[str] | None = None
|
|
104
|
+
auto_skeletonized: bool = False
|
|
105
|
+
|
|
106
|
+
def rendered(self) -> str:
|
|
107
|
+
return _render_payload(
|
|
108
|
+
self.frame_markdown, self.dependency_skeletons, self.locked_invariants
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _render_payload(frame: str, dep_skeletons: dict[str, str], invariants: list[str]) -> str:
|
|
113
|
+
parts = [frame, "\n## Verified dependency interfaces (AST skeletons)\n"]
|
|
114
|
+
for dep, sk in sorted(dep_skeletons.items()):
|
|
115
|
+
parts.append(f"### {dep}\n```python\n{sk}```\n")
|
|
116
|
+
parts.append("\n## Locked invariants\n")
|
|
117
|
+
parts.extend(f"- {inv}\n" for inv in invariants)
|
|
118
|
+
return "".join(parts)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class CommitVerdict:
|
|
123
|
+
accepted: bool
|
|
124
|
+
gate: str | None
|
|
125
|
+
error: GateError | None
|
|
126
|
+
attestation: dict | None
|
|
127
|
+
dirtied: list[str]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Telemetry:
|
|
132
|
+
raw_bloated_tokens: int
|
|
133
|
+
compacted_okf_tokens: int
|
|
134
|
+
tokens_saved: int
|
|
135
|
+
dollars_saved: float
|
|
136
|
+
omissions_blocked: int
|
|
137
|
+
turns: int
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class _Node:
|
|
142
|
+
info: _okf.ModuleInfo
|
|
143
|
+
status: str = ACTIVE
|
|
144
|
+
locked: bool = False
|
|
145
|
+
verified: list[dict] = field(default_factory=list)
|
|
146
|
+
disk_sha256: str = "" # SHA-256 of the tracked file as the kernel last saw it
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class _Session:
|
|
151
|
+
session_id: str
|
|
152
|
+
root: Path
|
|
153
|
+
nodes: dict[str, _Node]
|
|
154
|
+
events: list[dict] = field(default_factory=list)
|
|
155
|
+
omissions_blocked: int = 0
|
|
156
|
+
scratchpad: list[dict] = field(default_factory=list)
|
|
157
|
+
# Not persisted: sha256 of what this process last wrote, so unchanged
|
|
158
|
+
# frames / session files are not rewritten.
|
|
159
|
+
frame_shas: dict[str, str] = field(default_factory=dict)
|
|
160
|
+
persisted_sha: str = ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class StateKernel:
|
|
164
|
+
def __init__(self, *, ephemeral: bool = False) -> None:
|
|
165
|
+
"""`ephemeral=True` is verification-only (`stepcheck ci`): gates, attestations
|
|
166
|
+
and the graph work in memory, but nothing is written — no frames, session
|
|
167
|
+
files, registry entries, and no tracked-file write-back."""
|
|
168
|
+
self._sessions: dict[str, _Session] = {}
|
|
169
|
+
self._ephemeral = ephemeral
|
|
170
|
+
|
|
171
|
+
# -- Seam 1 -----------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def init_session(self, project_root: str | Path) -> str:
|
|
174
|
+
root = Path(project_root).resolve()
|
|
175
|
+
files = sorted(
|
|
176
|
+
p
|
|
177
|
+
for p in root.rglob("*.py")
|
|
178
|
+
if ".stepcheck" not in p.parts and not p.name.startswith(".")
|
|
179
|
+
)
|
|
180
|
+
loaded: dict[Path, tuple[str, bytes]] = {}
|
|
181
|
+
for p in files:
|
|
182
|
+
raw = p.read_bytes()
|
|
183
|
+
try:
|
|
184
|
+
loaded[p] = (_okf.decode_source(raw), raw)
|
|
185
|
+
except UnicodeDecodeError:
|
|
186
|
+
continue # not UTF-8: leave untracked, write-back could not round-trip it
|
|
187
|
+
# A flat stem stays the node id while it is unique in the project; stems
|
|
188
|
+
# that collide (several __init__.py, pkg/models.py + other/models.py)
|
|
189
|
+
# use their dotted relative path instead of overwriting each other.
|
|
190
|
+
stems: dict[str, int] = {}
|
|
191
|
+
for p in loaded:
|
|
192
|
+
stems[p.stem] = stems.get(p.stem, 0) + 1
|
|
193
|
+
rel = {p: str(p.relative_to(root)) for p in loaded}
|
|
194
|
+
ids = {
|
|
195
|
+
p: p.stem
|
|
196
|
+
if stems[p.stem] == 1
|
|
197
|
+
else rel[p].replace("\\", "/").removesuffix(".py").replace("/", ".")
|
|
198
|
+
for p in loaded
|
|
199
|
+
}
|
|
200
|
+
modules = _okf.module_map({ids[p]: rel[p] for p in loaded})
|
|
201
|
+
nodes: dict[str, _Node] = {}
|
|
202
|
+
for p, (text, raw) in loaded.items():
|
|
203
|
+
info = _okf.analyze(ids[p], rel[p], text, modules)
|
|
204
|
+
nodes[info.node_id] = _Node(info=info, disk_sha256=_sha256(raw))
|
|
205
|
+
session_id = f"sk-{uuid.uuid4().hex[:12]}"
|
|
206
|
+
session = _Session(session_id=session_id, root=root, nodes=nodes)
|
|
207
|
+
self._sessions[session_id] = session
|
|
208
|
+
self._register(session)
|
|
209
|
+
self._flush(session)
|
|
210
|
+
return session_id
|
|
211
|
+
|
|
212
|
+
def checkout(
|
|
213
|
+
self,
|
|
214
|
+
session_id: str,
|
|
215
|
+
node_id: str,
|
|
216
|
+
*,
|
|
217
|
+
focus: list[str] | None = None,
|
|
218
|
+
at: float | None = None,
|
|
219
|
+
) -> CheckoutPayload:
|
|
220
|
+
"""`focus=None` → full frame. `focus=[names…]` → those defs verbatim,
|
|
221
|
+
the rest of the target as an AST skeleton. `focus=[]` → pure skeleton."""
|
|
222
|
+
s, node = self._get(session_id, node_id)
|
|
223
|
+
dep_sigs = {
|
|
224
|
+
dep: list(s.nodes[dep].info.exports)
|
|
225
|
+
for dep in node.info.deps
|
|
226
|
+
if dep in s.nodes
|
|
227
|
+
}
|
|
228
|
+
dep_skeletons = {
|
|
229
|
+
dep: _okf.interface_skeleton(s.nodes[dep].info.source)
|
|
230
|
+
for dep in node.info.deps
|
|
231
|
+
if dep in s.nodes
|
|
232
|
+
}
|
|
233
|
+
invariants = list(node.info.invariants)
|
|
234
|
+
for dep in node.info.deps:
|
|
235
|
+
if dep in s.nodes and s.nodes[dep].locked:
|
|
236
|
+
invariants.extend(s.nodes[dep].info.invariants)
|
|
237
|
+
frame = _okf.frame_markdown(
|
|
238
|
+
node.info, node.status, node.locked, node.verified,
|
|
239
|
+
focus=focus, fold_history=True,
|
|
240
|
+
)
|
|
241
|
+
rendered = _render_payload(frame, dep_skeletons, invariants)
|
|
242
|
+
tokens = _tokens.count(rendered)
|
|
243
|
+
auto = False
|
|
244
|
+
if focus is None and tokens >= CHECKOUT_TOKEN_BUDGET:
|
|
245
|
+
# Unfocused frame would blow the budget: fall back to a pure AST
|
|
246
|
+
# skeleton with a symbol index so the agent can pick a focus.
|
|
247
|
+
frame = _okf.frame_markdown(
|
|
248
|
+
node.info, node.status, node.locked, node.verified,
|
|
249
|
+
focus=[], fold_history=True, symbol_index=True,
|
|
250
|
+
)
|
|
251
|
+
rendered = _render_payload(frame, dep_skeletons, invariants)
|
|
252
|
+
tokens = _tokens.count(rendered)
|
|
253
|
+
auto = True
|
|
254
|
+
s.events.append(
|
|
255
|
+
{
|
|
256
|
+
"kind": "checkout",
|
|
257
|
+
"node_id": node_id,
|
|
258
|
+
"tokens": tokens,
|
|
259
|
+
"full_tokens": _tokens.count(node.info.source),
|
|
260
|
+
"at": at,
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
self._persist(s)
|
|
264
|
+
return CheckoutPayload(
|
|
265
|
+
session_id=session_id,
|
|
266
|
+
node_id=node_id,
|
|
267
|
+
frame_markdown=frame,
|
|
268
|
+
dependency_signatures=dep_sigs,
|
|
269
|
+
locked_invariants=invariants,
|
|
270
|
+
token_count=tokens,
|
|
271
|
+
dependency_skeletons=dep_skeletons,
|
|
272
|
+
focus=focus,
|
|
273
|
+
auto_skeletonized=auto,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def commit(
|
|
277
|
+
self,
|
|
278
|
+
session_id: str,
|
|
279
|
+
node_id: str,
|
|
280
|
+
source: str | None = None,
|
|
281
|
+
*,
|
|
282
|
+
patch: list[dict] | None = None,
|
|
283
|
+
allow_deletion: bool = False,
|
|
284
|
+
at: float | None = None,
|
|
285
|
+
) -> CommitVerdict:
|
|
286
|
+
"""Stage either a full `source` or a `patch` of AST-level ops folded into
|
|
287
|
+
the node's canonical state, then run Gates A → B → C."""
|
|
288
|
+
s, node = self._get(session_id, node_id)
|
|
289
|
+
if (source is None) == (patch is None):
|
|
290
|
+
raise ValueError("commit() takes exactly one of source= or patch=")
|
|
291
|
+
if patch is not None:
|
|
292
|
+
try:
|
|
293
|
+
source = _okf.apply_patch(node.info.source, patch)
|
|
294
|
+
except ValueError as exc:
|
|
295
|
+
return self._reject(s, node, "A", MechanicalError(f"patch cannot fold: {exc}"))
|
|
296
|
+
sent_tokens = _tokens.count(json.dumps(patch, separators=(",", ":")))
|
|
297
|
+
else:
|
|
298
|
+
sent_tokens = _tokens.count(source)
|
|
299
|
+
assert source is not None
|
|
300
|
+
source = source.replace("\r\n", "\n") # canonical state is always LF
|
|
301
|
+
try:
|
|
302
|
+
_gates.gate_a(node_id, node.locked, source, s.nodes)
|
|
303
|
+
except MechanicalError as exc:
|
|
304
|
+
return self._reject(s, node, "A", exc)
|
|
305
|
+
|
|
306
|
+
staged_info = _okf.analyze(node_id, node.info.path, source, self._modules(s))
|
|
307
|
+
try:
|
|
308
|
+
_gates.gate_b(node, staged_info, node.info.source, source, allow_deletion)
|
|
309
|
+
except OmissionError as exc:
|
|
310
|
+
return self._reject(s, node, "B", exc)
|
|
311
|
+
|
|
312
|
+
# Hand-written invariants always apply; auto-mined guards are waived by a
|
|
313
|
+
# declared deletion (the agent said it meant to remove behaviour).
|
|
314
|
+
live_invariants = (
|
|
315
|
+
[inv for inv in node.info.invariants if inv not in node.info.mined_invariants]
|
|
316
|
+
if allow_deletion
|
|
317
|
+
else node.info.invariants
|
|
318
|
+
)
|
|
319
|
+
try:
|
|
320
|
+
_gates.gate_c(
|
|
321
|
+
node_id,
|
|
322
|
+
live_invariants,
|
|
323
|
+
source,
|
|
324
|
+
{nid: n.info.source for nid, n in s.nodes.items() if nid != node_id},
|
|
325
|
+
mined=node.info.mined_invariants,
|
|
326
|
+
project_paths={nid: n.info.path for nid, n in s.nodes.items()},
|
|
327
|
+
)
|
|
328
|
+
except LogicalError as exc:
|
|
329
|
+
return self._reject(s, node, "C", exc)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
digest = self._land(s, node, source)
|
|
333
|
+
except MechanicalError as exc:
|
|
334
|
+
return self._reject(s, node, "A", exc)
|
|
335
|
+
return self._accept(s, node, staged_info, digest, at=at, sent_tokens=sent_tokens)
|
|
336
|
+
|
|
337
|
+
def record_draft(self, session_id: str, node_id: str, text: str) -> None:
|
|
338
|
+
"""Door-1 support: park an intermediate (unverified) agent turn."""
|
|
339
|
+
s = self._session(session_id)
|
|
340
|
+
s.scratchpad.append(
|
|
341
|
+
{"node_id": node_id, "text": text, "tokens": _tokens.count(text)}
|
|
342
|
+
)
|
|
343
|
+
self._persist(s)
|
|
344
|
+
|
|
345
|
+
def drafts(self, session_id: str) -> list[dict]:
|
|
346
|
+
return list(self._session(session_id).scratchpad)
|
|
347
|
+
|
|
348
|
+
def graph(self, session_id: str) -> dict:
|
|
349
|
+
"""Read-only OKF graph view for Seam 2 / the UI (nodes + edges)."""
|
|
350
|
+
s = self._session(session_id)
|
|
351
|
+
nodes = [
|
|
352
|
+
{
|
|
353
|
+
"node_id": nid,
|
|
354
|
+
"path": n.info.path,
|
|
355
|
+
"status": n.status,
|
|
356
|
+
"locked": n.locked,
|
|
357
|
+
"deps": list(n.info.deps),
|
|
358
|
+
"exports": list(n.info.exports),
|
|
359
|
+
"preserved_symbols": list(n.info.preserved_symbols),
|
|
360
|
+
"invariants": list(n.info.invariants),
|
|
361
|
+
"invariants_auto_mined": list(n.info.mined_invariants),
|
|
362
|
+
"verified": len(n.verified),
|
|
363
|
+
"attestation": n.verified[-1] if n.verified else None,
|
|
364
|
+
"source": n.info.source,
|
|
365
|
+
}
|
|
366
|
+
for nid, n in sorted(s.nodes.items())
|
|
367
|
+
]
|
|
368
|
+
edges = [
|
|
369
|
+
{"source": nid, "target": dep}
|
|
370
|
+
for nid, n in sorted(s.nodes.items())
|
|
371
|
+
for dep in n.info.deps
|
|
372
|
+
]
|
|
373
|
+
return {"nodes": nodes, "edges": edges}
|
|
374
|
+
|
|
375
|
+
def get_telemetry(self, session_id: str) -> Telemetry:
|
|
376
|
+
s = self._session(session_id)
|
|
377
|
+
raw, compacted, turns = _telemetry.compute(s.events)
|
|
378
|
+
saved = raw - compacted
|
|
379
|
+
return Telemetry(
|
|
380
|
+
raw_bloated_tokens=raw,
|
|
381
|
+
compacted_okf_tokens=compacted,
|
|
382
|
+
tokens_saved=saved,
|
|
383
|
+
dollars_saved=_telemetry.dollars(saved),
|
|
384
|
+
omissions_blocked=s.omissions_blocked,
|
|
385
|
+
turns=turns,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def finalize_session(self, session_id: str) -> bool:
|
|
389
|
+
s = self._session(session_id)
|
|
390
|
+
dirty = sorted(n for n, node in s.nodes.items() if node.status == DIRTY)
|
|
391
|
+
if dirty:
|
|
392
|
+
raise OmissionError(
|
|
393
|
+
f"session cannot finalize: nodes {dirty} hold DIRTY_OMISSION_RISK "
|
|
394
|
+
f"and must be checked out and re-verified",
|
|
395
|
+
subtype="INTER_NODE_DIRTY",
|
|
396
|
+
)
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
def lock_node(self, session_id: str, node_id: str) -> None:
|
|
400
|
+
s, node = self._get(session_id, node_id)
|
|
401
|
+
node.locked = True
|
|
402
|
+
node.status = LOCKED
|
|
403
|
+
self._flush(s)
|
|
404
|
+
|
|
405
|
+
def _reject(self, s: _Session, node: _Node, gate: str, exc: GateError) -> CommitVerdict:
|
|
406
|
+
if not node.locked:
|
|
407
|
+
node.status = REJECTED
|
|
408
|
+
if gate in ("B", "C"):
|
|
409
|
+
s.omissions_blocked += 1
|
|
410
|
+
s.events.append({"kind": "reject", "node_id": node.info.node_id, "gate": gate})
|
|
411
|
+
self._flush(s)
|
|
412
|
+
return CommitVerdict(
|
|
413
|
+
accepted=False, gate=gate, error=exc, attestation=None, dirtied=[]
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _land(self, s: _Session, node: _Node, source: str) -> str:
|
|
417
|
+
"""Write gate-approved `source` to the tracked file; return the SHA-256 of
|
|
418
|
+
the bytes on disk. Refuses to overwrite edits the kernel has not seen and
|
|
419
|
+
leaves the file alone (no write, no mtime churn) when the bytes are equal.
|
|
420
|
+
An ephemeral kernel never writes: it attests the bytes that would land."""
|
|
421
|
+
path = s.root / node.info.path
|
|
422
|
+
try:
|
|
423
|
+
existing = path.read_bytes()
|
|
424
|
+
if _sha256(existing) != node.disk_sha256:
|
|
425
|
+
raise MechanicalError(
|
|
426
|
+
f"'{node.info.path}' changed on disk since the session last read it "
|
|
427
|
+
f"— open a new session to pick up those edits"
|
|
428
|
+
)
|
|
429
|
+
data = _okf.encode_like(source, existing)
|
|
430
|
+
if self._ephemeral:
|
|
431
|
+
return _sha256(data)
|
|
432
|
+
if data != existing:
|
|
433
|
+
_okf.write_atomic(path, data)
|
|
434
|
+
except (OSError, UnicodeError) as exc:
|
|
435
|
+
raise MechanicalError(f"could not write '{node.info.path}': {exc}")
|
|
436
|
+
node.disk_sha256 = _sha256(data)
|
|
437
|
+
return node.disk_sha256
|
|
438
|
+
|
|
439
|
+
def _accept(
|
|
440
|
+
self,
|
|
441
|
+
s: _Session,
|
|
442
|
+
node: _Node,
|
|
443
|
+
staged_info: _okf.ModuleInfo,
|
|
444
|
+
digest: str,
|
|
445
|
+
*,
|
|
446
|
+
at: float | None,
|
|
447
|
+
sent_tokens: int,
|
|
448
|
+
) -> CommitVerdict:
|
|
449
|
+
exports_changed = staged_info.exports != node.info.exports
|
|
450
|
+
node.info = staged_info
|
|
451
|
+
node.status = VERIFIED
|
|
452
|
+
|
|
453
|
+
dirtied: list[str] = []
|
|
454
|
+
if exports_changed:
|
|
455
|
+
for dep_id, dep in sorted(s.nodes.items()):
|
|
456
|
+
if node.info.node_id in dep.info.deps and dep_id != node.info.node_id:
|
|
457
|
+
dep.status = DIRTY
|
|
458
|
+
dirtied.append(dep_id)
|
|
459
|
+
|
|
460
|
+
commit_ordinal = 1 + sum(1 for e in s.events if e["kind"] == "commit")
|
|
461
|
+
envelope = _dsse_envelope(
|
|
462
|
+
node.info.node_id,
|
|
463
|
+
node.info.path,
|
|
464
|
+
digest,
|
|
465
|
+
staged_info.exports,
|
|
466
|
+
commit_ordinal,
|
|
467
|
+
)
|
|
468
|
+
node.verified.append(envelope)
|
|
469
|
+
# Garbage-collect this node's dead-end scratchpad history.
|
|
470
|
+
s.scratchpad = [d for d in s.scratchpad if d["node_id"] != node.info.node_id]
|
|
471
|
+
s.events.append(
|
|
472
|
+
{
|
|
473
|
+
"kind": "commit",
|
|
474
|
+
"node_id": node.info.node_id,
|
|
475
|
+
"tokens": sent_tokens, # the folded diff actually sent
|
|
476
|
+
"full_tokens": _tokens.count(staged_info.source),
|
|
477
|
+
"at": at,
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
self._flush(s)
|
|
481
|
+
return CommitVerdict(
|
|
482
|
+
accepted=True, gate=None, error=None, attestation=envelope, dirtied=dirtied
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# -- internals --------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
def _session(self, session_id: str) -> _Session:
|
|
488
|
+
"""The in-memory session, or one rehydrated from `<root>/.stepcheck/sessions/`
|
|
489
|
+
through the local registry; anything unknown is a KeyError (Seam 2: 404)."""
|
|
490
|
+
# okf-invariant: call:StateKernel().graph("nope") raises KeyError
|
|
491
|
+
s = self._sessions.get(session_id)
|
|
492
|
+
if s is not None:
|
|
493
|
+
return s
|
|
494
|
+
if not _is_session_id(session_id):
|
|
495
|
+
raise KeyError(session_id)
|
|
496
|
+
root = self._registry().get(session_id)
|
|
497
|
+
path = Path(root) / ".stepcheck" / "sessions" / f"{session_id}.json" if root else None
|
|
498
|
+
if path is None or not path.is_file():
|
|
499
|
+
raise KeyError(session_id)
|
|
500
|
+
s = _session_from_json(_read_json(path))
|
|
501
|
+
self._sessions[session_id] = s
|
|
502
|
+
return s
|
|
503
|
+
|
|
504
|
+
def _get(self, session_id: str, node_id: str) -> tuple[_Session, _Node]:
|
|
505
|
+
s = self._session(session_id)
|
|
506
|
+
return s, s.nodes[node_id]
|
|
507
|
+
|
|
508
|
+
def _modules(self, s: _Session) -> dict[str, str]:
|
|
509
|
+
return _okf.module_map({nid: n.info.path for nid, n in s.nodes.items()})
|
|
510
|
+
|
|
511
|
+
def _registry(self) -> dict[str, str]:
|
|
512
|
+
"""session_id -> project root: the only thing the per-user index holds."""
|
|
513
|
+
data = _read_json(_state_home() / "sessions.json")
|
|
514
|
+
return {sid: root for sid, root in data.items() if isinstance(root, str)}
|
|
515
|
+
|
|
516
|
+
def _register(self, s: _Session) -> None:
|
|
517
|
+
if self._ephemeral:
|
|
518
|
+
return
|
|
519
|
+
registry = {
|
|
520
|
+
sid: root
|
|
521
|
+
for sid, root in self._registry().items()
|
|
522
|
+
if (Path(root) / ".stepcheck" / "sessions" / f"{sid}.json").is_file()
|
|
523
|
+
}
|
|
524
|
+
registry[s.session_id] = str(s.root)
|
|
525
|
+
_write_json(_state_home() / "sessions.json", registry)
|
|
526
|
+
|
|
527
|
+
def _persist(self, s: _Session) -> None:
|
|
528
|
+
"""Session payload -> `<root>/.stepcheck/sessions/<id>.json`, skipped when
|
|
529
|
+
the bytes equal what this process last wrote."""
|
|
530
|
+
if self._ephemeral:
|
|
531
|
+
return
|
|
532
|
+
data = json.dumps(_session_to_json(s), ensure_ascii=False).encode("utf-8")
|
|
533
|
+
digest = _sha256(data)
|
|
534
|
+
if digest == s.persisted_sha:
|
|
535
|
+
return
|
|
536
|
+
_okf.write_atomic(s.root / ".stepcheck" / "sessions" / f"{s.session_id}.json", data)
|
|
537
|
+
s.persisted_sha = digest
|
|
538
|
+
|
|
539
|
+
def _flush(self, s: _Session) -> None:
|
|
540
|
+
if self._ephemeral:
|
|
541
|
+
return
|
|
542
|
+
s.frame_shas = _okf.write_frames(s.root / ".stepcheck" / "okf", s.nodes, s.frame_shas)
|
|
543
|
+
self._persist(s)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# -------------------------------------------------------- session persistence
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _state_home() -> Path:
|
|
550
|
+
"""Per-user registry location; STEPCHECK_HOME overrides it (tests, CI)."""
|
|
551
|
+
import os
|
|
552
|
+
|
|
553
|
+
return Path(os.environ.get("STEPCHECK_HOME") or Path.home() / ".stepcheck")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _is_session_id(session_id: object) -> bool:
|
|
557
|
+
import re
|
|
558
|
+
|
|
559
|
+
return isinstance(session_id, str) and re.fullmatch(r"sk-[0-9a-f]{12}", session_id) is not None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _read_json(path: Path) -> dict:
|
|
563
|
+
try:
|
|
564
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
565
|
+
except (OSError, ValueError):
|
|
566
|
+
return {}
|
|
567
|
+
return data if isinstance(data, dict) else {}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
571
|
+
_okf.write_atomic(path, json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _session_to_json(s: _Session) -> dict:
|
|
575
|
+
import dataclasses
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
"session_id": s.session_id,
|
|
579
|
+
"root": str(s.root),
|
|
580
|
+
"nodes": {
|
|
581
|
+
nid: {
|
|
582
|
+
"info": dataclasses.asdict(n.info),
|
|
583
|
+
"status": n.status,
|
|
584
|
+
"locked": n.locked,
|
|
585
|
+
"verified": n.verified,
|
|
586
|
+
"disk_sha256": n.disk_sha256,
|
|
587
|
+
}
|
|
588
|
+
for nid, n in s.nodes.items()
|
|
589
|
+
},
|
|
590
|
+
"events": s.events,
|
|
591
|
+
"omissions_blocked": s.omissions_blocked,
|
|
592
|
+
"scratchpad": s.scratchpad,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _session_from_json(data: dict) -> _Session:
|
|
597
|
+
nodes = {
|
|
598
|
+
nid: _Node(
|
|
599
|
+
info=_okf.ModuleInfo(**n["info"]),
|
|
600
|
+
status=n["status"],
|
|
601
|
+
locked=n["locked"],
|
|
602
|
+
verified=list(n["verified"]),
|
|
603
|
+
disk_sha256=n.get("disk_sha256", ""),
|
|
604
|
+
)
|
|
605
|
+
for nid, n in data["nodes"].items()
|
|
606
|
+
}
|
|
607
|
+
return _Session(
|
|
608
|
+
session_id=data["session_id"],
|
|
609
|
+
root=Path(data["root"]),
|
|
610
|
+
nodes=nodes,
|
|
611
|
+
events=list(data.get("events", [])),
|
|
612
|
+
omissions_blocked=int(data.get("omissions_blocked", 0)),
|
|
613
|
+
scratchpad=list(data.get("scratchpad", [])),
|
|
614
|
+
)
|