autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""The configuration a REMOTE run was recorded under (#219).
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
|
|
6
|
+
The CLI has no configuration discovery. A REMOTE run started with
|
|
7
|
+
``--config`` and resumed without it runs on the built-in defaults: another
|
|
8
|
+
model and effort, other limits, and an agent worktree under
|
|
9
|
+
``<git common dir>/autoforge/worktrees`` instead of the configured
|
|
10
|
+
``execution.worktree_dir`` -- a different agent in a different, empty
|
|
11
|
+
worktree. A LOCAL run has its contract for what defines it
|
|
12
|
+
(:mod:`autoforge.run_contract`); this is the REMOTE counterpart for what
|
|
13
|
+
decides *who* works on the run and *where*.
|
|
14
|
+
|
|
15
|
+
A REMOTE run records a :class:`RecordedConfiguration` when it is created
|
|
16
|
+
(``state.config_fingerprint``), and ``resume``, ``step`` and ``unblock``
|
|
17
|
+
compare the configuration they would run on with it before any phase
|
|
18
|
+
executes (:meth:`autoforge.engine.ControllerEngine.check_run_configuration`).
|
|
19
|
+
A difference refuses, naming each changed key, unless the operator opts in
|
|
20
|
+
with ``--accept-config-change``; the change is then written to the run log
|
|
21
|
+
and the new configuration replaces the recorded one.
|
|
22
|
+
|
|
23
|
+
What it holds
|
|
24
|
+
-------------
|
|
25
|
+
|
|
26
|
+
For every profile a REMOTE run can route to (:data:`FINGERPRINT_PROFILES`):
|
|
27
|
+
the provider, the model, the effort and the limits the agent runs under
|
|
28
|
+
(the profile's own ``idle_timeout_seconds`` / ``max_runtime_seconds``, else
|
|
29
|
+
the ``execution.default_*`` value); and the resolved agent worktree root.
|
|
30
|
+
Beside them, the absolute path of the ``--config`` file it was recorded from
|
|
31
|
+
(``""`` for the built-in defaults), so a refusal can say how to restore it.
|
|
32
|
+
|
|
33
|
+
Nothing else is recorded, and nothing that could be a secret: a profile's
|
|
34
|
+
``command``, ``extra_args`` and ``options`` and the environment are left out
|
|
35
|
+
(an argument or an option is where a key would go), so the routing fields
|
|
36
|
+
are stored and printed as they are, and every text built from them crosses
|
|
37
|
+
the redaction boundary at the CLI and the run log anyway.
|
|
38
|
+
|
|
39
|
+
Which keys must match
|
|
40
|
+
---------------------
|
|
41
|
+
|
|
42
|
+
Every key recorded here. Provider, model, effort and the worktree root
|
|
43
|
+
decide which agent works and in which worktree; the limits are recorded
|
|
44
|
+
because a ``resume`` without ``--config`` changes them just as silently.
|
|
45
|
+
Raising a limit after a timeout is a legitimate reason to resume on a
|
|
46
|
+
changed configuration, and that is what the opt-in is for: it is one flag,
|
|
47
|
+
and the change is recorded.
|
|
48
|
+
|
|
49
|
+
Everything outside the fingerprint may change between steps without it:
|
|
50
|
+
loop bounds and the step budget, correction attempts, the merge gate and
|
|
51
|
+
its verification commands, the environment allow-list and the GitHub
|
|
52
|
+
client. They bound or verify what an agent does without choosing the agent
|
|
53
|
+
or its worktree, and each is read at the step that uses it.
|
|
54
|
+
|
|
55
|
+
``sandbox.mode`` is not in the fingerprint either, but it may not weaken
|
|
56
|
+
between steps (#280, ADR 0005 D15.3 as amended): the run is pinned to the
|
|
57
|
+
mode it was created under (:mod:`autoforge.sandbox_pin`), in LOCAL and
|
|
58
|
+
REMOTE runs alike, and the same comparison
|
|
59
|
+
(:meth:`~autoforge.engine.ControllerEngine.check_run_configuration`) refuses
|
|
60
|
+
a weaker one beside any change here, with the same opt-in. It is kept out
|
|
61
|
+
of this record because the record refuses any drift and any schema but its
|
|
62
|
+
own, while the pin refuses only a weaker mode and must load in a run
|
|
63
|
+
created before it.
|
|
64
|
+
|
|
65
|
+
A state file written before this field existed has none: it loads, is not
|
|
66
|
+
checked, and the first ``resume``, ``step`` or ``unblock`` records the
|
|
67
|
+
configuration it runs on (source ``upgraded``), saved with that command's
|
|
68
|
+
next save.
|
|
69
|
+
|
|
70
|
+
What can be recorded
|
|
71
|
+
--------------------
|
|
72
|
+
|
|
73
|
+
What the record's parser (:meth:`RecordedConfiguration.from_dict`) reads
|
|
74
|
+
back, and nothing else: it runs on every load of the state file, so a
|
|
75
|
+
record it refuses would make the run unloadable. The configuration bounds
|
|
76
|
+
less than the parser does (a model of any length; an ``update_epic``
|
|
77
|
+
profile, which no command validates before the run reaches it, with an
|
|
78
|
+
empty provider or a zero limit), so the parser is also run on what is
|
|
79
|
+
about to be recorded, and a value it refuses is a :class:`ConfigurationError`
|
|
80
|
+
before anything is written. ``run`` refuses a routing at config validation
|
|
81
|
+
(:func:`fingerprint_routing`; its dry run and ``doctor`` too) and a path
|
|
82
|
+
when it records the run (:meth:`RecordedConfiguration.persisted`), and
|
|
83
|
+
``resume``, ``step`` and ``unblock`` refuse to record or accept either.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import json
|
|
89
|
+
import os
|
|
90
|
+
from collections.abc import Callable
|
|
91
|
+
from dataclasses import dataclass
|
|
92
|
+
from pathlib import Path
|
|
93
|
+
from typing import TYPE_CHECKING, Any
|
|
94
|
+
|
|
95
|
+
from .errors import ConfigurationError, StateError
|
|
96
|
+
from .profiles import REQUIRED_PROFILES
|
|
97
|
+
|
|
98
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
99
|
+
from .config import AutoForgeConfig, ProfileConfig
|
|
100
|
+
|
|
101
|
+
#: Schema of the persisted record. Bumped when a recorded key is added,
|
|
102
|
+
#: removed or reinterpreted; another schema is refused on load, never
|
|
103
|
+
#: migrated (a recorded configuration rebuilt from today's is no record).
|
|
104
|
+
FINGERPRINT_SCHEMA = 1
|
|
105
|
+
|
|
106
|
+
#: Every profile a REMOTE run can route to: the ones a run requires, and
|
|
107
|
+
#: ``update_epic``, which the run reaches after a merge. A profile the
|
|
108
|
+
#: configuration does not define is recorded as ``None``.
|
|
109
|
+
FINGERPRINT_PROFILES: tuple[str, ...] = (*REQUIRED_PROFILES, "update_epic")
|
|
110
|
+
|
|
111
|
+
#: How the record came to be: ``created`` by ``run``; ``upgraded`` by the
|
|
112
|
+
#: first command that found a state file written before #219 without one;
|
|
113
|
+
#: ``accepted`` by an operator's ``--accept-config-change``.
|
|
114
|
+
SOURCE_CREATED = "created"
|
|
115
|
+
SOURCE_UPGRADED = "upgraded"
|
|
116
|
+
SOURCE_ACCEPTED = "accepted"
|
|
117
|
+
SOURCES = (SOURCE_CREATED, SOURCE_UPGRADED, SOURCE_ACCEPTED)
|
|
118
|
+
|
|
119
|
+
#: What a refusal and the run log call the worktree root.
|
|
120
|
+
WORKTREE_ROOT_KEY = "agent worktree root (execution.worktree_dir)"
|
|
121
|
+
|
|
122
|
+
_RECORD_KEYS = ("schema", "profiles", "worktree_root", "config_file", "source", "recorded_at")
|
|
123
|
+
_PROFILE_KEYS = ("provider", "model", "effort", "idle_timeout_seconds", "max_runtime_seconds")
|
|
124
|
+
# Bounds on what a hand-edited file can make the controller carry and print.
|
|
125
|
+
_MAX_NAME_CHARS = 512
|
|
126
|
+
_MAX_PATH_CHARS = 4096
|
|
127
|
+
_MAX_TIMESTAMP_CHARS = 64
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _text(value: Any, where: str, *, bound: int, empty: bool = False) -> str:
|
|
131
|
+
if not isinstance(value, str) or len(value) > bound or (not empty and not value):
|
|
132
|
+
shape = "a string" if empty else "a non-empty string"
|
|
133
|
+
raise StateError(f"{where} must be {shape} of at most {bound} characters")
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _limit(value: Any, where: str, *, optional: bool) -> int | None:
|
|
138
|
+
if value is None and optional:
|
|
139
|
+
return None
|
|
140
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
141
|
+
shape = "a positive integer or null" if optional else "a positive integer"
|
|
142
|
+
raise StateError(f"{where} must be {shape}")
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _path(value: Any, where: str, *, empty: bool = False) -> str:
|
|
147
|
+
text = _text(value, where, bound=_MAX_PATH_CHARS, empty=empty)
|
|
148
|
+
if text and not os.path.isabs(text):
|
|
149
|
+
raise StateError(f"{where} must be an absolute path")
|
|
150
|
+
return text
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _keys(data: Any, keys: tuple[str, ...], where: str) -> dict:
|
|
154
|
+
if not isinstance(data, dict) or sorted(data) != sorted(keys):
|
|
155
|
+
raise StateError(f"{where} must hold exactly {', '.join(keys)}")
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _recordable(parse: Callable[[], object]) -> None:
|
|
160
|
+
"""Refuse (ConfigurationError) what is about to be recorded unless ``parse`` reads it back.
|
|
161
|
+
|
|
162
|
+
``parse`` is the record's own parser run on it, the one definition of
|
|
163
|
+
what a state file may hold here; its refusal names the key, never the
|
|
164
|
+
value.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
parse()
|
|
168
|
+
except StateError as exc:
|
|
169
|
+
raise ConfigurationError(
|
|
170
|
+
f"{exc}: a REMOTE run records it in its state file, which could not be loaded "
|
|
171
|
+
"again otherwise (#219)"
|
|
172
|
+
) from None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class ProfileRouting:
|
|
177
|
+
"""What one profile decides about the agent it launches."""
|
|
178
|
+
|
|
179
|
+
provider: str
|
|
180
|
+
model: str
|
|
181
|
+
effort: str
|
|
182
|
+
idle_timeout_seconds: int
|
|
183
|
+
max_runtime_seconds: int | None
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_config(cls, cfg: AutoForgeConfig, profile: ProfileConfig) -> ProfileRouting:
|
|
187
|
+
"""The profile's routing, its limits resolved against the ``execution`` defaults.
|
|
188
|
+
|
|
189
|
+
Resolved here rather than through ``AutoForgeConfig.agent_limits``,
|
|
190
|
+
which also refuses a provider that cannot report activity: recording
|
|
191
|
+
is not the place to refuse a profile, and the configured values are
|
|
192
|
+
what an operator changes. The one refusal is a routing its record
|
|
193
|
+
could not be read back with (ConfigurationError, see "What can be
|
|
194
|
+
recorded" above).
|
|
195
|
+
"""
|
|
196
|
+
idle = profile.idle_timeout_seconds
|
|
197
|
+
ceiling = profile.max_runtime_seconds
|
|
198
|
+
routing = cls(
|
|
199
|
+
provider=profile.provider,
|
|
200
|
+
model=profile.model,
|
|
201
|
+
effort=profile.effort,
|
|
202
|
+
idle_timeout_seconds=(
|
|
203
|
+
idle if idle is not None else cfg.execution.default_idle_timeout_seconds
|
|
204
|
+
),
|
|
205
|
+
max_runtime_seconds=(
|
|
206
|
+
ceiling if ceiling is not None else cfg.execution.default_max_runtime_seconds
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
_recordable(lambda: cls.from_dict(routing.to_dict(), f"profiles.{profile.name}"))
|
|
210
|
+
return routing
|
|
211
|
+
|
|
212
|
+
def to_dict(self) -> dict:
|
|
213
|
+
return {key: getattr(self, key) for key in _PROFILE_KEYS}
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_dict(cls, data: Any, where: str) -> ProfileRouting:
|
|
217
|
+
data = _keys(data, _PROFILE_KEYS, where)
|
|
218
|
+
return cls(
|
|
219
|
+
provider=_text(data["provider"], f"{where}.provider", bound=_MAX_NAME_CHARS),
|
|
220
|
+
model=_text(data["model"], f"{where}.model", bound=_MAX_NAME_CHARS, empty=True),
|
|
221
|
+
effort=_text(data["effort"], f"{where}.effort", bound=_MAX_NAME_CHARS, empty=True),
|
|
222
|
+
idle_timeout_seconds=_limit( # type: ignore[arg-type]
|
|
223
|
+
data["idle_timeout_seconds"], f"{where}.idle_timeout_seconds", optional=False
|
|
224
|
+
),
|
|
225
|
+
max_runtime_seconds=_limit(
|
|
226
|
+
data["max_runtime_seconds"], f"{where}.max_runtime_seconds", optional=True
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class ConfigChange:
|
|
233
|
+
"""One recorded key whose value this invocation would change."""
|
|
234
|
+
|
|
235
|
+
key: str
|
|
236
|
+
recorded: str
|
|
237
|
+
current: str
|
|
238
|
+
|
|
239
|
+
def line(self) -> str:
|
|
240
|
+
return f"{self.key}: {self.recorded} -> {self.current}"
|
|
241
|
+
|
|
242
|
+
def to_dict(self) -> dict:
|
|
243
|
+
return {"key": self.key, "recorded": self.recorded, "current": self.current}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def fingerprint_routing(cfg: AutoForgeConfig) -> dict[str, ProfileRouting | None]:
|
|
247
|
+
"""The routing of every profile in :data:`FINGERPRINT_PROFILES`, ``None`` when not configured.
|
|
248
|
+
|
|
249
|
+
ConfigurationError when one could not be recorded. A REMOTE run's
|
|
250
|
+
``validate_config`` calls it, so ``run`` refuses such a configuration
|
|
251
|
+
before the run is created, its dry run included.
|
|
252
|
+
"""
|
|
253
|
+
return {
|
|
254
|
+
name: ProfileRouting.from_config(cfg, cfg.profiles[name]) if name in cfg.profiles else None
|
|
255
|
+
for name in FINGERPRINT_PROFILES
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _shown(value: Any) -> str:
|
|
260
|
+
"""A recorded value as a refusal prints it (JSON, so "" and null are visible)."""
|
|
261
|
+
return json.dumps(value)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _shown_profile(routing: ProfileRouting | None) -> str:
|
|
265
|
+
return "(not configured)" if routing is None else _shown(routing.to_dict())
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class ConfigFingerprint:
|
|
270
|
+
"""The compared part: per-profile routing and the agent worktree root."""
|
|
271
|
+
|
|
272
|
+
profiles: dict[str, ProfileRouting | None]
|
|
273
|
+
worktree_root: str
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def from_config(cls, cfg: AutoForgeConfig, worktree_root: str | Path) -> ConfigFingerprint:
|
|
277
|
+
"""ConfigurationError when a routing or the root could not be recorded."""
|
|
278
|
+
root = str(worktree_root)
|
|
279
|
+
profiles = fingerprint_routing(cfg)
|
|
280
|
+
_recordable(lambda: _path(root, WORKTREE_ROOT_KEY))
|
|
281
|
+
return cls(profiles=profiles, worktree_root=root)
|
|
282
|
+
|
|
283
|
+
def changes(self, current: ConfigFingerprint) -> list[ConfigChange]:
|
|
284
|
+
"""Each key ``current`` would change, in a stable order; empty when none."""
|
|
285
|
+
out: list[ConfigChange] = []
|
|
286
|
+
for name in FINGERPRINT_PROFILES:
|
|
287
|
+
was, now = self.profiles.get(name), current.profiles.get(name)
|
|
288
|
+
if was == now:
|
|
289
|
+
continue
|
|
290
|
+
if was is None or now is None:
|
|
291
|
+
out.append(
|
|
292
|
+
ConfigChange(f"profiles.{name}", _shown_profile(was), _shown_profile(now))
|
|
293
|
+
)
|
|
294
|
+
continue
|
|
295
|
+
for key in _PROFILE_KEYS:
|
|
296
|
+
before, after = getattr(was, key), getattr(now, key)
|
|
297
|
+
if before != after:
|
|
298
|
+
out.append(
|
|
299
|
+
ConfigChange(f"profiles.{name}.{key}", _shown(before), _shown(after))
|
|
300
|
+
)
|
|
301
|
+
if self.worktree_root != current.worktree_root:
|
|
302
|
+
out.append(
|
|
303
|
+
ConfigChange(
|
|
304
|
+
WORKTREE_ROOT_KEY, _shown(self.worktree_root), _shown(current.worktree_root)
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True)
|
|
311
|
+
class RecordedConfiguration:
|
|
312
|
+
"""The fingerprint as persisted, with where and how it was recorded."""
|
|
313
|
+
|
|
314
|
+
fingerprint: ConfigFingerprint
|
|
315
|
+
# The absolute path of the ``--config`` file it was recorded from; ""
|
|
316
|
+
# for the built-in defaults. Not compared: a moved file with the same
|
|
317
|
+
# content is the same configuration.
|
|
318
|
+
config_file: str
|
|
319
|
+
source: str
|
|
320
|
+
recorded_at: str
|
|
321
|
+
|
|
322
|
+
def to_dict(self) -> dict:
|
|
323
|
+
return {
|
|
324
|
+
"schema": FINGERPRINT_SCHEMA,
|
|
325
|
+
"profiles": {
|
|
326
|
+
name: (None if routing is None else routing.to_dict())
|
|
327
|
+
for name, routing in self.fingerprint.profiles.items()
|
|
328
|
+
},
|
|
329
|
+
"worktree_root": self.fingerprint.worktree_root,
|
|
330
|
+
"config_file": self.config_file,
|
|
331
|
+
"source": self.source,
|
|
332
|
+
"recorded_at": self.recorded_at,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
def persisted(self) -> dict:
|
|
336
|
+
""":meth:`to_dict`, for the state file: ConfigurationError unless :meth:`from_dict`
|
|
337
|
+
reads it back, so no record that would make the run unloadable is written."""
|
|
338
|
+
data = self.to_dict()
|
|
339
|
+
_recordable(lambda: RecordedConfiguration.from_dict(data))
|
|
340
|
+
return data
|
|
341
|
+
|
|
342
|
+
@classmethod
|
|
343
|
+
def from_dict(cls, data: Any) -> RecordedConfiguration:
|
|
344
|
+
"""Parse a persisted record strictly; StateError on any other shape."""
|
|
345
|
+
if isinstance(data, dict) and data.get("schema") != FINGERPRINT_SCHEMA:
|
|
346
|
+
raise StateError(
|
|
347
|
+
f"schema {data.get('schema')!r:.40} is not {FINGERPRINT_SCHEMA}, the one this "
|
|
348
|
+
"controller writes; a recorded configuration is never migrated"
|
|
349
|
+
)
|
|
350
|
+
data = _keys(data, _RECORD_KEYS, "the recorded configuration")
|
|
351
|
+
raw_profiles = _keys(data["profiles"], FINGERPRINT_PROFILES, "profiles")
|
|
352
|
+
profiles = {
|
|
353
|
+
name: (
|
|
354
|
+
None
|
|
355
|
+
if raw_profiles[name] is None
|
|
356
|
+
else ProfileRouting.from_dict(raw_profiles[name], f"profiles.{name}")
|
|
357
|
+
)
|
|
358
|
+
for name in FINGERPRINT_PROFILES
|
|
359
|
+
}
|
|
360
|
+
source = data["source"]
|
|
361
|
+
if source not in SOURCES:
|
|
362
|
+
raise StateError(f"source must be one of {', '.join(SOURCES)}")
|
|
363
|
+
return cls(
|
|
364
|
+
fingerprint=ConfigFingerprint(
|
|
365
|
+
profiles=profiles, worktree_root=_path(data["worktree_root"], "worktree_root")
|
|
366
|
+
),
|
|
367
|
+
config_file=_path(data["config_file"], "config_file", empty=True),
|
|
368
|
+
source=source,
|
|
369
|
+
recorded_at=_text(data["recorded_at"], "recorded_at", bound=_MAX_TIMESTAMP_CHARS),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def origin(self) -> str:
|
|
373
|
+
"""Where it was recorded from, for a message: the file, or the built-in defaults."""
|
|
374
|
+
return f"--config {self.config_file}" if self.config_file else "the built-in defaults"
|