tenseleyflow/documentlanguagemodel / 57eef3f

Browse files

Hoist pending-plan I/O into dlm._pending shared base

Audit 12 F12.4: synth/pending.py and preference/pending.py were 90%+ identical — same JSON schema, same load/save/clear shape, same Section payload encoding (load tolerates the small per-domain field-set asymmetry). Move I/O to dlm._pending; each domain module shrinks to a thin typed shim that supplies (subdir, plan class, error class). Public API preserved (test imports still resolve including the private helpers); 470 tests pass. Net wins: one place to fix bugs in the I/O path; adding a third pending domain (e.g. harvest) is now a 30-line shim.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
57eef3f8cd41c7d240c168d9ad7f5a46383a55df
Parents
46a1078
Tree
a21bb9f

3 changed files

StatusFile+-
C src/dlm/_pending.py 0 0
M src/dlm/preference/pending.py 62 154
M src/dlm/synth/pending.py 62 161
src/dlm/synth/pending.py → src/dlm/_pending.pycopied (68% similarity)
@@ -1,4 +1,21 @@
1
-"""Persist staged auto-synth instruction sections between CLI steps."""
1
+"""Shared substrate for staged "pending plan" payloads under a store.
2
+
3
+Both `dlm preference mine`/`apply` and `dlm synth instructions`/`apply`
4
+need to stage a list of `Section` payloads on disk between two CLI
5
+invocations, then read them back in the apply step. Each domain stores
6
+the payload under a different store subdirectory and wraps validation
7
+errors in its own typed exception, but the I/O shape is identical.
8
+
9
+This module owns the I/O. The two domain modules
10
+(`dlm.preference.pending`, `dlm.synth.pending`) supply their own
11
+`PendingPlan` dataclass and error class via the small set of
12
+parameterized functions below.
13
+
14
+The on-disk format records the full optional-field surface of
15
+``Section`` so a domain that grows new optional fields tomorrow does
16
+not need to bump ``schema_version``: load-time defaults absorb the
17
+addition.
18
+"""
2
 
19
 
3
 from __future__ import annotations
20
 from __future__ import annotations
4
 
21
 
@@ -10,7 +27,6 @@ from typing import TYPE_CHECKING, Any
10
 
27
 
11
 from dlm.doc.sections import Section, SectionType
28
 from dlm.doc.sections import Section, SectionType
12
 from dlm.io.atomic import write_text as atomic_write_text
29
 from dlm.io.atomic import write_text as atomic_write_text
13
-from dlm.synth.errors import SynthError
14
 
30
 
15
 if TYPE_CHECKING:
31
 if TYPE_CHECKING:
16
     from collections.abc import Sequence
32
     from collections.abc import Sequence
@@ -18,22 +34,21 @@ if TYPE_CHECKING:
18
     from dlm.store.paths import StorePath
34
     from dlm.store.paths import StorePath
19
 
35
 
20
 
36
 
21
-class PendingSynthPlanError(SynthError):
37
+_SCHEMA_VERSION = 1
22
-    """Raised when the staged synth plan cannot be read or validated."""
23
 
38
 
24
 
39
 
25
 @dataclass(frozen=True)
40
 @dataclass(frozen=True)
26
-class PendingSynthPlan:
41
+class PendingSectionPlan:
27
-    """One staged synth plan for a store."""
42
+    """Generic staged plan payload — domain modules subclass this for typing."""
28
 
43
 
29
     source_path: Path
44
     source_path: Path
30
     created_at: str
45
     created_at: str
31
     sections: tuple[Section, ...]
46
     sections: tuple[Section, ...]
32
 
47
 
33
 
48
 
34
-def pending_plan_path(store: StorePath) -> Path:
49
+def pending_plan_path(store: StorePath, *, subdir: str) -> Path:
35
-    """Path to the staged synth payload for `store`."""
50
+    """Path to the staged payload for `store` under the given subdir."""
36
-    return store.root / "synth" / "pending.json"
51
+    return store.root / subdir / "pending.json"
37
 
52
 
38
 
53
 
39
 def save_pending_plan(
54
 def save_pending_plan(
@@ -41,17 +56,18 @@ def save_pending_plan(
41
     *,
56
     *,
42
     source_path: Path,
57
     source_path: Path,
43
     sections: Sequence[Section],
58
     sections: Sequence[Section],
44
-) -> PendingSynthPlan:
59
+    subdir: str,
45
-    """Persist `sections` as the staged synth plan for `store`."""
60
+    plan_cls: type[PendingSectionPlan],
46
-    plan = PendingSynthPlan(
61
+) -> PendingSectionPlan:
62
+    plan = plan_cls(
47
         source_path=source_path.resolve(),
63
         source_path=source_path.resolve(),
48
         created_at=_utcnow(),
64
         created_at=_utcnow(),
49
         sections=tuple(sections),
65
         sections=tuple(sections),
50
     )
66
     )
51
-    path = pending_plan_path(store)
67
+    path = pending_plan_path(store, subdir=subdir)
52
     path.parent.mkdir(parents=True, exist_ok=True)
68
     path.parent.mkdir(parents=True, exist_ok=True)
53
     payload = {
69
     payload = {
54
-        "schema_version": 1,
70
+        "schema_version": _SCHEMA_VERSION,
55
         "source_path": str(plan.source_path),
71
         "source_path": str(plan.source_path),
56
         "created_at": plan.created_at,
72
         "created_at": plan.created_at,
57
         "sections": [_section_to_payload(section) for section in plan.sections],
73
         "sections": [_section_to_payload(section) for section in plan.sections],
@@ -60,52 +76,60 @@ def save_pending_plan(
60
     return plan
76
     return plan
61
 
77
 
62
 
78
 
63
-def load_pending_plan(store: StorePath) -> PendingSynthPlan | None:
79
+def load_pending_plan(
64
-    """Return the staged synth plan for `store`, or None when absent."""
80
+    store: StorePath,
65
-    path = pending_plan_path(store)
81
+    *,
82
+    subdir: str,
83
+    plan_cls: type[PendingSectionPlan],
84
+    error_cls: type[Exception],
85
+    label: str,
86
+) -> PendingSectionPlan | None:
87
+    """Return the staged plan, or None when absent. Raises `error_cls` on corruption.
88
+
89
+    `label` names the domain in error messages ("preference plan", "synth plan").
90
+    """
91
+    path = pending_plan_path(store, subdir=subdir)
66
     if not path.exists():
92
     if not path.exists():
67
         return None
93
         return None
68
     try:
94
     try:
69
         raw = json.loads(path.read_text(encoding="utf-8"))
95
         raw = json.loads(path.read_text(encoding="utf-8"))
70
     except OSError as exc:
96
     except OSError as exc:
71
-        raise PendingSynthPlanError(f"could not read staged synth plan: {exc}") from exc
97
+        raise error_cls(f"could not read staged {label}: {exc}") from exc
72
     except json.JSONDecodeError as exc:
98
     except json.JSONDecodeError as exc:
73
-        raise PendingSynthPlanError(f"staged synth plan is not valid JSON: {exc}") from exc
99
+        raise error_cls(f"staged {label} is not valid JSON: {exc}") from exc
74
 
100
 
75
     if not isinstance(raw, dict):
101
     if not isinstance(raw, dict):
76
-        raise PendingSynthPlanError("staged synth plan must be a JSON object")
102
+        raise error_cls(f"staged {label} must be a JSON object")
77
-    if raw.get("schema_version") != 1:
103
+    if raw.get("schema_version") != _SCHEMA_VERSION:
78
-        raise PendingSynthPlanError(
104
+        raise error_cls(f"unsupported staged {label} schema_version={raw.get('schema_version')!r}")
79
-            f"unsupported staged synth plan schema_version={raw.get('schema_version')!r}"
80
-        )
81
 
105
 
82
     source_path = raw.get("source_path")
106
     source_path = raw.get("source_path")
83
     created_at = raw.get("created_at")
107
     created_at = raw.get("created_at")
84
     sections_raw = raw.get("sections")
108
     sections_raw = raw.get("sections")
85
     if not isinstance(source_path, str) or not source_path:
109
     if not isinstance(source_path, str) or not source_path:
86
-        raise PendingSynthPlanError("staged synth plan is missing source_path")
110
+        raise error_cls(f"staged {label} is missing source_path")
87
     if not isinstance(created_at, str) or not created_at:
111
     if not isinstance(created_at, str) or not created_at:
88
-        raise PendingSynthPlanError("staged synth plan is missing created_at")
112
+        raise error_cls(f"staged {label} is missing created_at")
89
     if not isinstance(sections_raw, list):
113
     if not isinstance(sections_raw, list):
90
-        raise PendingSynthPlanError("staged synth plan is missing sections")
114
+        raise error_cls(f"staged {label} is missing sections")
91
 
115
 
92
     sections: list[Section] = []
116
     sections: list[Section] = []
93
     for idx, entry in enumerate(sections_raw):
117
     for idx, entry in enumerate(sections_raw):
94
         try:
118
         try:
95
             sections.append(_section_from_payload(entry))
119
             sections.append(_section_from_payload(entry))
96
         except (TypeError, ValueError, KeyError) as exc:
120
         except (TypeError, ValueError, KeyError) as exc:
97
-            raise PendingSynthPlanError(f"invalid section payload at index {idx}: {exc}") from exc
121
+            raise error_cls(f"invalid section payload at index {idx}: {exc}") from exc
98
 
122
 
99
-    return PendingSynthPlan(
123
+    return plan_cls(
100
         source_path=Path(source_path),
124
         source_path=Path(source_path),
101
         created_at=created_at,
125
         created_at=created_at,
102
         sections=tuple(sections),
126
         sections=tuple(sections),
103
     )
127
     )
104
 
128
 
105
 
129
 
106
-def clear_pending_plan(store: StorePath) -> bool:
130
+def clear_pending_plan(store: StorePath, *, subdir: str) -> bool:
107
-    """Delete the staged synth plan for `store`. Returns True iff it existed."""
131
+    """Delete the staged plan; True iff it existed."""
108
-    path = pending_plan_path(store)
132
+    path = pending_plan_path(store, subdir=subdir)
109
     if not path.exists():
133
     if not path.exists():
110
         return False
134
         return False
111
     path.unlink()
135
     path.unlink()
src/dlm/preference/pending.pymodified
@@ -5,42 +5,61 @@ but the follow-up `dlm preference apply` still needs a stable plan to
5
 write. This module stores the mined `Section` payloads under the store
5
 write. This module stores the mined `Section` payloads under the store
6
 root so the reviewed plan can be applied later without re-running
6
 root so the reviewed plan can be applied later without re-running
7
 sampling or judging.
7
 sampling or judging.
8
+
9
+I/O is shared with `dlm.synth.pending` via `dlm._pending`.
8
 """
10
 """
9
 
11
 
10
 from __future__ import annotations
12
 from __future__ import annotations
11
 
13
 
12
-import json
13
 from dataclasses import dataclass
14
 from dataclasses import dataclass
14
-from datetime import UTC, datetime
15
+from typing import TYPE_CHECKING
15
-from pathlib import Path
16
+
16
-from typing import TYPE_CHECKING, Any
17
+from dlm._pending import (
17
-
18
+    PendingSectionPlan,
18
-from dlm.doc.sections import Section, SectionType
19
+    _optional_float,
19
-from dlm.io.atomic import write_text as atomic_write_text
20
+    _optional_int,
21
+    _optional_str,
22
+    _section_from_payload,
23
+    _section_to_payload,
24
+)
25
+from dlm._pending import (
26
+    clear_pending_plan as _clear,
27
+)
28
+from dlm._pending import (
29
+    load_pending_plan as _load,
30
+)
31
+from dlm._pending import (
32
+    pending_plan_path as _path,
33
+)
34
+from dlm._pending import (
35
+    save_pending_plan as _save,
36
+)
20
 from dlm.preference.errors import PreferenceMiningError
37
 from dlm.preference.errors import PreferenceMiningError
21
 
38
 
22
 if TYPE_CHECKING:
39
 if TYPE_CHECKING:
23
     from collections.abc import Sequence
40
     from collections.abc import Sequence
41
+    from pathlib import Path
24
 
42
 
43
+    from dlm.doc.sections import Section
25
     from dlm.store.paths import StorePath
44
     from dlm.store.paths import StorePath
26
 
45
 
27
 
46
 
47
+_SUBDIR = "preference"
48
+_LABEL = "preference plan"
49
+
50
+
28
 class PendingPreferencePlanError(PreferenceMiningError):
51
 class PendingPreferencePlanError(PreferenceMiningError):
29
     """Raised when the staged preference plan cannot be read or validated."""
52
     """Raised when the staged preference plan cannot be read or validated."""
30
 
53
 
31
 
54
 
32
 @dataclass(frozen=True)
55
 @dataclass(frozen=True)
33
-class PendingPreferencePlan:
56
+class PendingPreferencePlan(PendingSectionPlan):
34
     """One staged preference-mine plan for a store."""
57
     """One staged preference-mine plan for a store."""
35
 
58
 
36
-    source_path: Path
37
-    created_at: str
38
-    sections: tuple[Section, ...]
39
-
40
 
59
 
41
 def pending_plan_path(store: StorePath) -> Path:
60
 def pending_plan_path(store: StorePath) -> Path:
42
     """Path to the staged preference-mine payload for `store`."""
61
     """Path to the staged preference-mine payload for `store`."""
43
-    return store.root / "preference" / "pending.json"
62
+    return _path(store, subdir=_SUBDIR)
44
 
63
 
45
 
64
 
46
 def save_pending_plan(
65
 def save_pending_plan(
@@ -50,154 +69,43 @@ def save_pending_plan(
50
     sections: Sequence[Section],
69
     sections: Sequence[Section],
51
 ) -> PendingPreferencePlan:
70
 ) -> PendingPreferencePlan:
52
     """Persist `sections` as the staged plan for `store`."""
71
     """Persist `sections` as the staged plan for `store`."""
53
-    plan = PendingPreferencePlan(
72
+    return _save(  # type: ignore[return-value]
54
-        source_path=source_path.resolve(),
73
+        store,
55
-        created_at=_utcnow(),
74
+        source_path=source_path,
56
-        sections=tuple(sections),
75
+        sections=sections,
76
+        subdir=_SUBDIR,
77
+        plan_cls=PendingPreferencePlan,
57
     )
78
     )
58
-    path = pending_plan_path(store)
59
-    path.parent.mkdir(parents=True, exist_ok=True)
60
-    payload = {
61
-        "schema_version": 1,
62
-        "source_path": str(plan.source_path),
63
-        "created_at": plan.created_at,
64
-        "sections": [_section_to_payload(section) for section in plan.sections],
65
-    }
66
-    atomic_write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
67
-    return plan
68
 
79
 
69
 
80
 
70
 def load_pending_plan(store: StorePath) -> PendingPreferencePlan | None:
81
 def load_pending_plan(store: StorePath) -> PendingPreferencePlan | None:
71
     """Return the staged plan for `store`, or None when absent."""
82
     """Return the staged plan for `store`, or None when absent."""
72
-    path = pending_plan_path(store)
83
+    return _load(  # type: ignore[return-value]
73
-    if not path.exists():
84
+        store,
74
-        return None
85
+        subdir=_SUBDIR,
75
-    try:
86
+        plan_cls=PendingPreferencePlan,
76
-        raw = json.loads(path.read_text(encoding="utf-8"))
87
+        error_cls=PendingPreferencePlanError,
77
-    except OSError as exc:
88
+        label=_LABEL,
78
-        raise PendingPreferencePlanError(f"could not read staged preference plan: {exc}") from exc
79
-    except json.JSONDecodeError as exc:
80
-        raise PendingPreferencePlanError(
81
-            f"staged preference plan is not valid JSON: {exc}"
82
-        ) from exc
83
-
84
-    if not isinstance(raw, dict):
85
-        raise PendingPreferencePlanError("staged preference plan must be a JSON object")
86
-    if raw.get("schema_version") != 1:
87
-        raise PendingPreferencePlanError(
88
-            f"unsupported staged preference plan schema_version={raw.get('schema_version')!r}"
89
-        )
90
-
91
-    source_path = raw.get("source_path")
92
-    created_at = raw.get("created_at")
93
-    sections_raw = raw.get("sections")
94
-    if not isinstance(source_path, str) or not source_path:
95
-        raise PendingPreferencePlanError("staged preference plan is missing source_path")
96
-    if not isinstance(created_at, str) or not created_at:
97
-        raise PendingPreferencePlanError("staged preference plan is missing created_at")
98
-    if not isinstance(sections_raw, list):
99
-        raise PendingPreferencePlanError("staged preference plan is missing sections")
100
-
101
-    sections: list[Section] = []
102
-    for idx, entry in enumerate(sections_raw):
103
-        try:
104
-            sections.append(_section_from_payload(entry))
105
-        except (TypeError, ValueError, KeyError) as exc:
106
-            raise PendingPreferencePlanError(
107
-                f"invalid section payload at index {idx}: {exc}"
108
-            ) from exc
109
-
110
-    return PendingPreferencePlan(
111
-        source_path=Path(source_path),
112
-        created_at=created_at,
113
-        sections=tuple(sections),
114
     )
89
     )
115
 
90
 
116
 
91
 
117
 def clear_pending_plan(store: StorePath) -> bool:
92
 def clear_pending_plan(store: StorePath) -> bool:
118
     """Delete the staged plan for `store`. Returns True iff it existed."""
93
     """Delete the staged plan for `store`. Returns True iff it existed."""
119
-    path = pending_plan_path(store)
94
+    return _clear(store, subdir=_SUBDIR)
120
-    if not path.exists():
95
+
121
-        return False
96
+
122
-    path.unlink()
97
+# Re-export private I/O helpers so test modules that reached into the
123
-    return True
98
+# original implementation continue to work without import churn.
124
-
99
+__all__ = [
125
-
100
+    "PendingPreferencePlan",
126
-def _utcnow() -> str:
101
+    "PendingPreferencePlanError",
127
-    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
102
+    "_optional_float",
128
-
103
+    "_optional_int",
129
-
104
+    "_optional_str",
130
-def _section_to_payload(section: Section) -> dict[str, Any]:
105
+    "_section_from_payload",
131
-    return {
106
+    "_section_to_payload",
132
-        "type": section.type.value,
107
+    "clear_pending_plan",
133
-        "content": section.content,
108
+    "load_pending_plan",
134
-        "start_line": section.start_line,
109
+    "pending_plan_path",
135
-        "adapter": section.adapter,
110
+    "save_pending_plan",
136
-        "tags": dict(section.tags),
111
+]
137
-        "auto_harvest": section.auto_harvest,
138
-        "harvest_source": section.harvest_source,
139
-        "auto_mined": section.auto_mined,
140
-        "judge_name": section.judge_name,
141
-        "judge_score_chosen": section.judge_score_chosen,
142
-        "judge_score_rejected": section.judge_score_rejected,
143
-        "mined_at": section.mined_at,
144
-        "mined_run_id": section.mined_run_id,
145
-        "media_path": section.media_path,
146
-        "media_alt": section.media_alt,
147
-        "media_blob_sha": section.media_blob_sha,
148
-        "media_transcript": section.media_transcript,
149
-    }
150
-
151
-
152
-def _section_from_payload(raw: object) -> Section:
153
-    if not isinstance(raw, dict):
154
-        raise TypeError(f"expected object, got {type(raw).__name__}")
155
-    section_type = SectionType(str(raw["type"]))
156
-    tags = raw.get("tags", {})
157
-    if not isinstance(tags, dict):
158
-        raise TypeError("tags must be an object")
159
-    if not all(isinstance(k, str) and isinstance(v, str) for k, v in tags.items()):
160
-        raise TypeError("tags keys and values must be strings")
161
-    return Section(
162
-        type=section_type,
163
-        content=str(raw["content"]),
164
-        start_line=int(raw.get("start_line", 0)),
165
-        adapter=_optional_str(raw.get("adapter")),
166
-        tags=dict(tags),
167
-        auto_harvest=bool(raw.get("auto_harvest", False)),
168
-        harvest_source=_optional_str(raw.get("harvest_source")),
169
-        auto_mined=bool(raw.get("auto_mined", False)),
170
-        judge_name=_optional_str(raw.get("judge_name")),
171
-        judge_score_chosen=_optional_float(raw.get("judge_score_chosen")),
172
-        judge_score_rejected=_optional_float(raw.get("judge_score_rejected")),
173
-        mined_at=_optional_str(raw.get("mined_at")),
174
-        mined_run_id=_optional_int(raw.get("mined_run_id")),
175
-        media_path=_optional_str(raw.get("media_path")),
176
-        media_alt=_optional_str(raw.get("media_alt")),
177
-        media_blob_sha=_optional_str(raw.get("media_blob_sha")),
178
-        media_transcript=_optional_str(raw.get("media_transcript")),
179
-    )
180
-
181
-
182
-def _optional_str(value: object) -> str | None:
183
-    if value is None:
184
-        return None
185
-    if not isinstance(value, str):
186
-        raise TypeError(f"expected string or null, got {type(value).__name__}")
187
-    return value
188
-
189
-
190
-def _optional_float(value: object) -> float | None:
191
-    if value is None:
192
-        return None
193
-    if isinstance(value, bool) or not isinstance(value, int | float):
194
-        raise TypeError(f"expected float or null, got {type(value).__name__}")
195
-    return float(value)
196
-
197
-
198
-def _optional_int(value: object) -> int | None:
199
-    if value is None:
200
-        return None
201
-    if isinstance(value, bool) or not isinstance(value, int):
202
-        raise TypeError(f"expected int or null, got {type(value).__name__}")
203
-    return value
src/dlm/synth/pending.pymodified
@@ -1,39 +1,59 @@
1
-"""Persist staged auto-synth instruction sections between CLI steps."""
1
+"""Persist staged auto-synth instruction sections between CLI steps.
2
+
3
+I/O is shared with `dlm.preference.pending` via `dlm._pending`.
4
+"""
2
 
5
 
3
 from __future__ import annotations
6
 from __future__ import annotations
4
 
7
 
5
-import json
6
 from dataclasses import dataclass
8
 from dataclasses import dataclass
7
-from datetime import UTC, datetime
9
+from typing import TYPE_CHECKING
8
-from pathlib import Path
10
+
9
-from typing import TYPE_CHECKING, Any
11
+from dlm._pending import (
10
-
12
+    PendingSectionPlan,
11
-from dlm.doc.sections import Section, SectionType
13
+    _optional_float,
12
-from dlm.io.atomic import write_text as atomic_write_text
14
+    _optional_int,
15
+    _optional_str,
16
+    _section_from_payload,
17
+    _section_to_payload,
18
+)
19
+from dlm._pending import (
20
+    clear_pending_plan as _clear,
21
+)
22
+from dlm._pending import (
23
+    load_pending_plan as _load,
24
+)
25
+from dlm._pending import (
26
+    pending_plan_path as _path,
27
+)
28
+from dlm._pending import (
29
+    save_pending_plan as _save,
30
+)
13
 from dlm.synth.errors import SynthError
31
 from dlm.synth.errors import SynthError
14
 
32
 
15
 if TYPE_CHECKING:
33
 if TYPE_CHECKING:
16
     from collections.abc import Sequence
34
     from collections.abc import Sequence
35
+    from pathlib import Path
17
 
36
 
37
+    from dlm.doc.sections import Section
18
     from dlm.store.paths import StorePath
38
     from dlm.store.paths import StorePath
19
 
39
 
20
 
40
 
41
+_SUBDIR = "synth"
42
+_LABEL = "synth plan"
43
+
44
+
21
 class PendingSynthPlanError(SynthError):
45
 class PendingSynthPlanError(SynthError):
22
     """Raised when the staged synth plan cannot be read or validated."""
46
     """Raised when the staged synth plan cannot be read or validated."""
23
 
47
 
24
 
48
 
25
 @dataclass(frozen=True)
49
 @dataclass(frozen=True)
26
-class PendingSynthPlan:
50
+class PendingSynthPlan(PendingSectionPlan):
27
     """One staged synth plan for a store."""
51
     """One staged synth plan for a store."""
28
 
52
 
29
-    source_path: Path
30
-    created_at: str
31
-    sections: tuple[Section, ...]
32
-
33
 
53
 
34
 def pending_plan_path(store: StorePath) -> Path:
54
 def pending_plan_path(store: StorePath) -> Path:
35
     """Path to the staged synth payload for `store`."""
55
     """Path to the staged synth payload for `store`."""
36
-    return store.root / "synth" / "pending.json"
56
+    return _path(store, subdir=_SUBDIR)
37
 
57
 
38
 
58
 
39
 def save_pending_plan(
59
 def save_pending_plan(
@@ -43,160 +63,41 @@ def save_pending_plan(
43
     sections: Sequence[Section],
63
     sections: Sequence[Section],
44
 ) -> PendingSynthPlan:
64
 ) -> PendingSynthPlan:
45
     """Persist `sections` as the staged synth plan for `store`."""
65
     """Persist `sections` as the staged synth plan for `store`."""
46
-    plan = PendingSynthPlan(
66
+    return _save(  # type: ignore[return-value]
47
-        source_path=source_path.resolve(),
67
+        store,
48
-        created_at=_utcnow(),
68
+        source_path=source_path,
49
-        sections=tuple(sections),
69
+        sections=sections,
70
+        subdir=_SUBDIR,
71
+        plan_cls=PendingSynthPlan,
50
     )
72
     )
51
-    path = pending_plan_path(store)
52
-    path.parent.mkdir(parents=True, exist_ok=True)
53
-    payload = {
54
-        "schema_version": 1,
55
-        "source_path": str(plan.source_path),
56
-        "created_at": plan.created_at,
57
-        "sections": [_section_to_payload(section) for section in plan.sections],
58
-    }
59
-    atomic_write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
60
-    return plan
61
 
73
 
62
 
74
 
63
 def load_pending_plan(store: StorePath) -> PendingSynthPlan | None:
75
 def load_pending_plan(store: StorePath) -> PendingSynthPlan | None:
64
     """Return the staged synth plan for `store`, or None when absent."""
76
     """Return the staged synth plan for `store`, or None when absent."""
65
-    path = pending_plan_path(store)
77
+    return _load(  # type: ignore[return-value]
66
-    if not path.exists():
78
+        store,
67
-        return None
79
+        subdir=_SUBDIR,
68
-    try:
80
+        plan_cls=PendingSynthPlan,
69
-        raw = json.loads(path.read_text(encoding="utf-8"))
81
+        error_cls=PendingSynthPlanError,
70
-    except OSError as exc:
82
+        label=_LABEL,
71
-        raise PendingSynthPlanError(f"could not read staged synth plan: {exc}") from exc
72
-    except json.JSONDecodeError as exc:
73
-        raise PendingSynthPlanError(f"staged synth plan is not valid JSON: {exc}") from exc
74
-
75
-    if not isinstance(raw, dict):
76
-        raise PendingSynthPlanError("staged synth plan must be a JSON object")
77
-    if raw.get("schema_version") != 1:
78
-        raise PendingSynthPlanError(
79
-            f"unsupported staged synth plan schema_version={raw.get('schema_version')!r}"
80
-        )
81
-
82
-    source_path = raw.get("source_path")
83
-    created_at = raw.get("created_at")
84
-    sections_raw = raw.get("sections")
85
-    if not isinstance(source_path, str) or not source_path:
86
-        raise PendingSynthPlanError("staged synth plan is missing source_path")
87
-    if not isinstance(created_at, str) or not created_at:
88
-        raise PendingSynthPlanError("staged synth plan is missing created_at")
89
-    if not isinstance(sections_raw, list):
90
-        raise PendingSynthPlanError("staged synth plan is missing sections")
91
-
92
-    sections: list[Section] = []
93
-    for idx, entry in enumerate(sections_raw):
94
-        try:
95
-            sections.append(_section_from_payload(entry))
96
-        except (TypeError, ValueError, KeyError) as exc:
97
-            raise PendingSynthPlanError(f"invalid section payload at index {idx}: {exc}") from exc
98
-
99
-    return PendingSynthPlan(
100
-        source_path=Path(source_path),
101
-        created_at=created_at,
102
-        sections=tuple(sections),
103
     )
83
     )
104
 
84
 
105
 
85
 
106
 def clear_pending_plan(store: StorePath) -> bool:
86
 def clear_pending_plan(store: StorePath) -> bool:
107
     """Delete the staged synth plan for `store`. Returns True iff it existed."""
87
     """Delete the staged synth plan for `store`. Returns True iff it existed."""
108
-    path = pending_plan_path(store)
88
+    return _clear(store, subdir=_SUBDIR)
109
-    if not path.exists():
89
+
110
-        return False
90
+
111
-    path.unlink()
91
+__all__ = [
112
-    return True
92
+    "PendingSynthPlan",
113
-
93
+    "PendingSynthPlanError",
114
-
94
+    "_optional_float",
115
-def _utcnow() -> str:
95
+    "_optional_int",
116
-    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
96
+    "_optional_str",
117
-
97
+    "_section_from_payload",
118
-
98
+    "_section_to_payload",
119
-def _section_to_payload(section: Section) -> dict[str, Any]:
99
+    "clear_pending_plan",
120
-    return {
100
+    "load_pending_plan",
121
-        "type": section.type.value,
101
+    "pending_plan_path",
122
-        "content": section.content,
102
+    "save_pending_plan",
123
-        "start_line": section.start_line,
103
+]
124
-        "adapter": section.adapter,
125
-        "tags": dict(section.tags),
126
-        "auto_harvest": section.auto_harvest,
127
-        "harvest_source": section.harvest_source,
128
-        "auto_mined": section.auto_mined,
129
-        "judge_name": section.judge_name,
130
-        "judge_score_chosen": section.judge_score_chosen,
131
-        "judge_score_rejected": section.judge_score_rejected,
132
-        "mined_at": section.mined_at,
133
-        "mined_run_id": section.mined_run_id,
134
-        "auto_synth": section.auto_synth,
135
-        "synth_teacher": section.synth_teacher,
136
-        "synth_strategy": section.synth_strategy,
137
-        "synth_at": section.synth_at,
138
-        "source_section_id": section.source_section_id,
139
-        "media_path": section.media_path,
140
-        "media_alt": section.media_alt,
141
-        "media_blob_sha": section.media_blob_sha,
142
-        "media_transcript": section.media_transcript,
143
-    }
144
-
145
-
146
-def _section_from_payload(raw: object) -> Section:
147
-    if not isinstance(raw, dict):
148
-        raise TypeError(f"expected object, got {type(raw).__name__}")
149
-    section_type = SectionType(str(raw["type"]))
150
-    tags = raw.get("tags", {})
151
-    if not isinstance(tags, dict):
152
-        raise TypeError("tags must be an object")
153
-    if not all(isinstance(k, str) and isinstance(v, str) for k, v in tags.items()):
154
-        raise TypeError("tags keys and values must be strings")
155
-    return Section(
156
-        type=section_type,
157
-        content=str(raw["content"]),
158
-        start_line=int(raw.get("start_line", 0)),
159
-        adapter=_optional_str(raw.get("adapter")),
160
-        tags=dict(tags),
161
-        auto_harvest=bool(raw.get("auto_harvest", False)),
162
-        harvest_source=_optional_str(raw.get("harvest_source")),
163
-        auto_mined=bool(raw.get("auto_mined", False)),
164
-        judge_name=_optional_str(raw.get("judge_name")),
165
-        judge_score_chosen=_optional_float(raw.get("judge_score_chosen")),
166
-        judge_score_rejected=_optional_float(raw.get("judge_score_rejected")),
167
-        mined_at=_optional_str(raw.get("mined_at")),
168
-        mined_run_id=_optional_int(raw.get("mined_run_id")),
169
-        auto_synth=bool(raw.get("auto_synth", False)),
170
-        synth_teacher=_optional_str(raw.get("synth_teacher")),
171
-        synth_strategy=_optional_str(raw.get("synth_strategy")),
172
-        synth_at=_optional_str(raw.get("synth_at")),
173
-        source_section_id=_optional_str(raw.get("source_section_id")),
174
-        media_path=_optional_str(raw.get("media_path")),
175
-        media_alt=_optional_str(raw.get("media_alt")),
176
-        media_blob_sha=_optional_str(raw.get("media_blob_sha")),
177
-        media_transcript=_optional_str(raw.get("media_transcript")),
178
-    )
179
-
180
-
181
-def _optional_str(value: object) -> str | None:
182
-    if value is None:
183
-        return None
184
-    if not isinstance(value, str):
185
-        raise TypeError(f"expected string or null, got {type(value).__name__}")
186
-    return value
187
-
188
-
189
-def _optional_float(value: object) -> float | None:
190
-    if value is None:
191
-        return None
192
-    if isinstance(value, bool) or not isinstance(value, int | float):
193
-        raise TypeError(f"expected float or null, got {type(value).__name__}")
194
-    return float(value)
195
-
196
-
197
-def _optional_int(value: object) -> int | None:
198
-    if value is None:
199
-        return None
200
-    if isinstance(value, bool) or not isinstance(value, int):
201
-        raise TypeError(f"expected int or null, got {type(value).__name__}")
202
-    return value