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.
@@ -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
+ )