tanglebrain 0.16.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.
tanglebrain/roster.py ADDED
@@ -0,0 +1,415 @@
1
+ """Roster config loader.
2
+
3
+ The roster is a simple, editable YAML list of routable backends — *not* a registry subsystem.
4
+ Adding, removing, or reorganizing a backend is an entry edit, never a code change; this
5
+ modifiability is a first-class requirement.
6
+
7
+ This module parses that YAML into typed objects. It parses *every* entry regardless of which
8
+ adapters are built, so the full roster is always inspectable; whether a given entry is
9
+ *invocable* depends on which adapters exist (``openai-compat`` + ``cli``) and, for ``tier: api``
10
+ entries, on the global ``api_billing_enabled`` gate plus the entry's own ``enabled`` flag — a
11
+ paid entry parses here but stays inert until both are on (see :mod:`tanglebrain.settings`).
12
+
13
+ Each entry::
14
+
15
+ - id: local-model
16
+ tier: local
17
+ invoke: { kind: openai-compat, base_url: "http://.../v1", model: "local-model" }
18
+ cost: free
19
+ good_at: [grunt, code, tools]
20
+
21
+ ``invoke.kind`` is one of ``openai-compat`` | ``cli`` | ``api``. ``scrub_env`` enforces the
22
+ session-vs-key safety rule per adapter. ``can_orchestrate`` flags an entry as eligible for the
23
+ orchestrator rotation; ``can_delegate`` flags it as an eligible delegate target an orchestrator
24
+ may offload a sub-task to. ``key_ref`` references a credential without embedding it:
25
+ ``file:PATH`` | ``env:NAME`` | ``none``.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+ import yaml
34
+
35
+ VALID_KINDS = ("openai-compat", "cli", "api")
36
+ VALID_TIERS = ("local", "sub", "api")
37
+
38
+ # Env var pointing at an explicit roster file (highest-precedence default, ~ expanded).
39
+ ROSTER_ENV_VAR = "TANGLEBRAIN_ROSTER"
40
+ # The operator's own roster, kept OUTSIDE the repo (XDG config). Auto-discovered if present.
41
+ USER_ROSTER_SUBPATH = ("tanglebrain", "roster.yaml")
42
+
43
+
44
+ class RosterError(ValueError):
45
+ """Raised when the roster YAML is missing, malformed, or semantically invalid.
46
+
47
+ A subclass of ``ValueError`` so callers can catch it specifically while still treating
48
+ it as the bad-input error it is.
49
+ """
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Invoke:
54
+ """How to invoke one roster entry — the transport-specific call details.
55
+
56
+ Attributes:
57
+ kind: One of ``openai-compat`` | ``cli`` | ``api``.
58
+ base_url: OpenAI-compat base URL (e.g. the LiteLLM ``/v1`` endpoint). Required for
59
+ ``openai-compat``; ``None`` otherwise.
60
+ model: Model id/alias to request. Required for ``openai-compat``; ``None`` otherwise.
61
+ cmd: Argv for a subprocess CLI invocation. Required for ``cli``; ``None`` otherwise.
62
+ A literal ``{prompt}`` token in the argv is replaced with the prompt by the CLI
63
+ adapter; if no token is present the prompt is appended as the final argument.
64
+ scrub_env: Env var names to remove from a subprocess's environment before launch
65
+ (e.g. ``ANTHROPIC_API_KEY``, so ``claude -p`` rides the flat sub, not a billed key).
66
+ parse: Name of the output parser the ``cli`` adapter uses to extract the final text
67
+ from the subprocess stdout (e.g. ``claude-json``, ``gemini-json``, ``plain``).
68
+ ``None`` lets the adapter pick its default. Informational for non-``cli`` kinds.
69
+ delegate_args: Extra argv appended to ``cmd`` when the router invokes this entry as an
70
+ orchestrator with local delegation enabled — the per-CLI flags that register the
71
+ local-delegate tool and allow it. A ``{delegate_mcp_json}`` token is substituted
72
+ with the delegate's MCP-server JSON at runtime. Empty = the CLI cannot be handed the
73
+ delegate per-invocation (or doesn't need it).
74
+ key_ref: Credential reference — ``file:PATH`` | ``env:NAME`` | ``none`` — never a raw
75
+ secret. ``None`` means no credential is configured for this entry.
76
+ """
77
+
78
+ kind: str
79
+ base_url: str | None = None
80
+ model: str | None = None
81
+ cmd: list[str] | None = None
82
+ scrub_env: list[str] = field(default_factory=list)
83
+ parse: str | None = None
84
+ delegate_args: list[str] = field(default_factory=list)
85
+ key_ref: str | None = None
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class RosterEntry:
90
+ """One routable backend in the roster.
91
+
92
+ Attributes:
93
+ id: Unique identifier for the entry (e.g. ``gpt-oss-120b``, ``claude``).
94
+ tier: Cost tier — ``local`` | ``sub`` | ``api``.
95
+ invoke: How to call this entry (see :class:`Invoke`).
96
+ cost: Free-form cost annotation (e.g. ``free``, ``paid``); informational.
97
+ good_at: Tags describing what the entry is good at (drives task-fit routing).
98
+ can_orchestrate: Whether this entry joins the orchestrator rotation.
99
+ can_delegate: Whether this entry is an eligible **delegate target** — a backend an
100
+ orchestrator may hand a sub-task to via the generalized ``delegate`` tool. Explicit
101
+ opt-in (default ``False``), mirroring ``can_orchestrate``; nothing is delegable unless
102
+ declared. The free local tier remains the default target regardless of this flag (the
103
+ ``delegate_local`` tool / ``target=None`` path resolves it directly).
104
+ enabled: Per-entry kill-switch. ``True`` by default. Currently enforced only for
105
+ ``tier: api`` entries (a disabled paid key is never routable, even with the global
106
+ ``api_billing_enabled`` gate on); informational for other tiers.
107
+ budget_usd_month: Optional monthly USD budget annotation for a paid key, which must be a
108
+ number ``> 0`` when present. This is **displayed only** — the hard cap is enforced
109
+ gateway-side on the key, not by TangleBrain. ``None`` (the default)
110
+ means no budget is recorded.
111
+ """
112
+
113
+ id: str
114
+ tier: str
115
+ invoke: Invoke
116
+ cost: str | None = None
117
+ good_at: list[str] = field(default_factory=list)
118
+ can_orchestrate: bool = False
119
+ can_delegate: bool = False
120
+ enabled: bool = True
121
+ budget_usd_month: float | None = None
122
+
123
+
124
+ class Roster:
125
+ """An ordered collection of :class:`RosterEntry`, with lookup/filter helpers.
126
+
127
+ Order is preserved from the YAML file because it is meaningful — e.g. the local-first
128
+ selector walks entries in declared order.
129
+ """
130
+
131
+ def __init__(self, entries: list[RosterEntry]) -> None:
132
+ """Store the entries and build an id index, rejecting duplicate ids.
133
+
134
+ Args:
135
+ entries: The parsed roster entries, in file order.
136
+
137
+ Raises:
138
+ RosterError: If two entries share the same ``id``.
139
+ """
140
+ self._entries = list(entries)
141
+ self._by_id: dict[str, RosterEntry] = {}
142
+ for entry in self._entries:
143
+ if entry.id in self._by_id:
144
+ raise RosterError(f"duplicate roster entry id: {entry.id!r}")
145
+ self._by_id[entry.id] = entry
146
+
147
+ def __iter__(self):
148
+ """Iterate entries in declared (file) order."""
149
+ return iter(self._entries)
150
+
151
+ def __len__(self) -> int:
152
+ """Return the number of entries in the roster."""
153
+ return len(self._entries)
154
+
155
+ @property
156
+ def entries(self) -> list[RosterEntry]:
157
+ """Return a copy of the entries list, in declared order."""
158
+ return list(self._entries)
159
+
160
+ def by_id(self, entry_id: str) -> RosterEntry:
161
+ """Return the entry with the given id.
162
+
163
+ Args:
164
+ entry_id: The id to look up.
165
+
166
+ Returns:
167
+ The matching :class:`RosterEntry`.
168
+
169
+ Raises:
170
+ KeyError: If no entry has that id.
171
+ """
172
+ return self._by_id[entry_id]
173
+
174
+ def in_tier(self, tier: str) -> list[RosterEntry]:
175
+ """Return all entries in the given tier, in declared order.
176
+
177
+ Args:
178
+ tier: The tier to filter by (e.g. ``local``).
179
+
180
+ Returns:
181
+ The matching entries (possibly empty).
182
+ """
183
+ return [e for e in self._entries if e.tier == tier]
184
+
185
+ def orchestrators(self) -> list[RosterEntry]:
186
+ """Return the entries flagged ``can_orchestrate`` (the rotation set).
187
+
188
+ Returns:
189
+ The orchestrator-capable entries, in declared order.
190
+ """
191
+ return [e for e in self._entries if e.can_orchestrate]
192
+
193
+ def delegate_targets(self) -> list[RosterEntry]:
194
+ """Return the entries flagged ``can_delegate`` (the delegate-target menu).
195
+
196
+ These are the backends an orchestrator may hand a sub-task to via the generalized
197
+ ``delegate`` tool — the configured, opt-in menu. The free local default target is resolved
198
+ separately (see :func:`tanglebrain.selector.select_local`) and need not be flagged.
199
+
200
+ Returns:
201
+ The delegate-eligible entries, in declared order.
202
+ """
203
+ return [e for e in self._entries if e.can_delegate]
204
+
205
+
206
+ def _parse_invoke(raw: object, entry_id: str) -> Invoke:
207
+ """Validate and build an :class:`Invoke` from one entry's ``invoke`` block.
208
+
209
+ Args:
210
+ raw: The raw ``invoke`` value from YAML (expected to be a mapping).
211
+ entry_id: The owning entry's id, for error messages.
212
+
213
+ Returns:
214
+ A validated :class:`Invoke`.
215
+
216
+ Raises:
217
+ RosterError: If the block is not a mapping, the kind is missing/unknown, or the
218
+ fields required for that kind are absent.
219
+ """
220
+ if not isinstance(raw, dict):
221
+ raise RosterError(f"entry {entry_id!r}: 'invoke' must be a mapping")
222
+
223
+ kind = raw.get("kind")
224
+ if kind not in VALID_KINDS:
225
+ raise RosterError(
226
+ f"entry {entry_id!r}: invoke.kind must be one of {VALID_KINDS}, got {kind!r}"
227
+ )
228
+
229
+ base_url = raw.get("base_url")
230
+ model = raw.get("model")
231
+ cmd = raw.get("cmd")
232
+ scrub_env = raw.get("scrub_env", []) or []
233
+ parse = raw.get("parse")
234
+ delegate_args = raw.get("delegate_args", []) or []
235
+ key_ref = raw.get("key_ref")
236
+
237
+ if kind == "openai-compat":
238
+ if not base_url or not model:
239
+ raise RosterError(
240
+ f"entry {entry_id!r}: openai-compat invoke requires 'base_url' and 'model'"
241
+ )
242
+ elif kind == "cli":
243
+ if not cmd or not isinstance(cmd, list):
244
+ raise RosterError(f"entry {entry_id!r}: cli invoke requires a non-empty 'cmd' list")
245
+ elif kind == "api":
246
+ # Paid API is gateway-fronted: base_url is the OpenAI-compatible gateway endpoint, model the
247
+ # alias it exposes, and key_ref a *scoped key* — never a raw provider key. All three are
248
+ # required so a paid entry can never be half-configured.
249
+ if not base_url or not model:
250
+ raise RosterError(
251
+ f"entry {entry_id!r}: api invoke requires 'base_url' and 'model' "
252
+ "(the LiteLLM-fronted endpoint + model alias)"
253
+ )
254
+ if not key_ref:
255
+ raise RosterError(
256
+ f"entry {entry_id!r}: api invoke requires 'key_ref' "
257
+ "(a scoped LiteLLM virtual key reference — never a raw provider key)"
258
+ )
259
+
260
+ if not isinstance(scrub_env, list):
261
+ raise RosterError(f"entry {entry_id!r}: invoke.scrub_env must be a list")
262
+
263
+ if parse is not None and not isinstance(parse, str):
264
+ raise RosterError(f"entry {entry_id!r}: invoke.parse must be a string")
265
+
266
+ if not isinstance(delegate_args, list):
267
+ raise RosterError(f"entry {entry_id!r}: invoke.delegate_args must be a list")
268
+
269
+ return Invoke(
270
+ kind=kind,
271
+ base_url=base_url,
272
+ model=model,
273
+ cmd=list(cmd) if cmd else None,
274
+ scrub_env=list(scrub_env),
275
+ parse=parse,
276
+ delegate_args=list(delegate_args),
277
+ key_ref=key_ref,
278
+ )
279
+
280
+
281
+ def _parse_entry(raw: object) -> RosterEntry:
282
+ """Validate and build one :class:`RosterEntry` from a YAML mapping.
283
+
284
+ Args:
285
+ raw: The raw entry value from YAML (expected to be a mapping).
286
+
287
+ Returns:
288
+ A validated :class:`RosterEntry`.
289
+
290
+ Raises:
291
+ RosterError: If the entry is not a mapping, is missing ``id`` / ``tier`` / ``invoke``,
292
+ or ``tier`` is not one of ``VALID_TIERS``.
293
+ """
294
+ if not isinstance(raw, dict):
295
+ raise RosterError(f"each roster entry must be a mapping, got {type(raw).__name__}")
296
+
297
+ entry_id = raw.get("id")
298
+ if not entry_id:
299
+ raise RosterError("roster entry is missing required field 'id'")
300
+
301
+ tier = raw.get("tier")
302
+ if not tier:
303
+ raise RosterError(f"entry {entry_id!r}: missing required field 'tier'")
304
+ if tier not in VALID_TIERS:
305
+ raise RosterError(
306
+ f"entry {entry_id!r}: tier must be one of {VALID_TIERS}, got {tier!r}"
307
+ )
308
+
309
+ if "invoke" not in raw:
310
+ raise RosterError(f"entry {entry_id!r}: missing required field 'invoke'")
311
+
312
+ good_at = raw.get("good_at", []) or []
313
+ if not isinstance(good_at, list):
314
+ raise RosterError(f"entry {entry_id!r}: 'good_at' must be a list")
315
+
316
+ enabled = raw.get("enabled", True)
317
+ if not isinstance(enabled, bool):
318
+ raise RosterError(
319
+ f"entry {entry_id!r}: 'enabled' must be a boolean (true/false), got {enabled!r}"
320
+ )
321
+
322
+ budget = raw.get("budget_usd_month")
323
+ if budget is not None:
324
+ # Reject bool explicitly (bool is an int subclass) — a budget of `true` is a config error.
325
+ if isinstance(budget, bool) or not isinstance(budget, (int, float)):
326
+ raise RosterError(
327
+ f"entry {entry_id!r}: 'budget_usd_month' must be a number, got {budget!r}"
328
+ )
329
+ if budget <= 0:
330
+ raise RosterError(
331
+ f"entry {entry_id!r}: 'budget_usd_month' must be > 0, got {budget!r}"
332
+ )
333
+
334
+ return RosterEntry(
335
+ id=entry_id,
336
+ tier=tier,
337
+ invoke=_parse_invoke(raw["invoke"], entry_id),
338
+ cost=raw.get("cost"),
339
+ good_at=list(good_at),
340
+ can_orchestrate=bool(raw.get("can_orchestrate", False)),
341
+ can_delegate=bool(raw.get("can_delegate", False)),
342
+ enabled=enabled,
343
+ budget_usd_month=float(budget) if budget is not None else None,
344
+ )
345
+
346
+
347
+ def packaged_roster_path() -> Path:
348
+ """Return the path to the **generic example** roster shipped with the package.
349
+
350
+ Returns:
351
+ The absolute path to ``tanglebrain/config/roster.yaml`` (the fallback default).
352
+ """
353
+ return Path(__file__).resolve().parent / "config" / "roster.yaml"
354
+
355
+
356
+ def _user_roster_path() -> Path:
357
+ """The operator's own roster path under XDG config (``~/.config/tanglebrain/roster.yaml``)."""
358
+ xdg = os.environ.get("XDG_CONFIG_HOME")
359
+ root = Path(xdg).expanduser() if xdg else Path.home() / ".config"
360
+ return root.joinpath(*USER_ROSTER_SUBPATH)
361
+
362
+
363
+ def default_roster_path() -> Path:
364
+ """Resolve the roster path used when no explicit path is given.
365
+
366
+ The shipped ``config/roster.yaml`` is only a **generic example** — an operator keeps their real
367
+ roster outside the repo. Resolution order (first hit wins):
368
+
369
+ 1. ``TANGLEBRAIN_ROSTER`` env var (``~`` expanded) — an explicit override.
370
+ 2. ``$XDG_CONFIG_HOME/tanglebrain/roster.yaml`` (or ``~/.config/tanglebrain/roster.yaml``) **if it
371
+ exists** — the operator's own roster, auto-discovered.
372
+ 3. The packaged generic example (:func:`packaged_roster_path`).
373
+
374
+ Returns:
375
+ The resolved roster path (existence is checked by :func:`load_roster`, so a bad
376
+ ``TANGLEBRAIN_ROSTER`` surfaces a clear "not found" error rather than silently falling back).
377
+ """
378
+ env = os.environ.get(ROSTER_ENV_VAR)
379
+ if env:
380
+ return Path(env).expanduser()
381
+ user = _user_roster_path()
382
+ if user.exists():
383
+ return user
384
+ return packaged_roster_path()
385
+
386
+
387
+ def load_roster(path: str | os.PathLike[str] | None = None) -> Roster:
388
+ """Load and validate the roster YAML into a :class:`Roster`.
389
+
390
+ Args:
391
+ path: Path to the roster YAML. When ``None``, the path is resolved by
392
+ :func:`default_roster_path` (``TANGLEBRAIN_ROSTER`` env → ``~/.config/tanglebrain/
393
+ roster.yaml`` → the packaged generic example).
394
+
395
+ Returns:
396
+ The parsed :class:`Roster`.
397
+
398
+ Raises:
399
+ RosterError: If the file is missing, is not a YAML list, or any entry is invalid.
400
+ """
401
+ roster_path = Path(path) if path is not None else default_roster_path()
402
+ if not roster_path.exists():
403
+ raise RosterError(f"roster file not found: {roster_path}")
404
+
405
+ try:
406
+ raw = yaml.safe_load(roster_path.read_text())
407
+ except yaml.YAMLError as exc:
408
+ raise RosterError(f"roster file is not valid YAML: {roster_path}: {exc}") from exc
409
+
410
+ if not isinstance(raw, list):
411
+ raise RosterError(
412
+ f"roster file must be a YAML list of entries, got {type(raw).__name__}: {roster_path}"
413
+ )
414
+
415
+ return Roster([_parse_entry(item) for item in raw])
@@ -0,0 +1,201 @@
1
+ """Surgical, comment-preserving roster editor (the knob-panel roster slice).
2
+
3
+ The knob panel lets the operator tune config from the browser. Roster editing needs care because
4
+ ``roster.yaml`` carries dense inline comments (per-CLI invocation notes, the paid-tier example) that
5
+ a naive YAML dump would destroy. This module edits a **focused** set of simple per-entry fields *in
6
+ place* — touching only the specific value on the specific line — so every comment, blank line, and
7
+ the nested ``invoke`` block survive byte-for-byte. No new dependency (the GUI's standing
8
+ zero-new-runtime-dep stance).
9
+
10
+ It deliberately does **not** add, remove, or reorder entries, nor edit the ``invoke`` block — those
11
+ still need a hand-edit (they'd require a full comment-preserving YAML round-trip). Editable fields
12
+ are entry-level scalars only:
13
+
14
+ - ``enabled`` / ``can_orchestrate`` — booleans,
15
+ - ``budget_usd_month`` — a positive number, or cleared (``None``) → the line is removed,
16
+ - ``good_at`` — a flow-style list of simple tags.
17
+
18
+ **Safety**: every save re-parses the candidate text with the real :func:`tanglebrain.roster.load_roster`
19
+ *before* it is written (a surgical mistake can never land a malformed roster), then backs up the
20
+ existing file and writes atomically — mirroring the pricing save.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import os
26
+ import re
27
+ import shutil
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ from tanglebrain.measurement import _atomic_write, _backup_dir
32
+ from tanglebrain.roster import RosterError, default_roster_path, load_roster
33
+
34
+ # The only fields this editor will touch (entry-level scalars). Everything else — id, tier, the
35
+ # invoke block, cost — stays hand-edited.
36
+ EDITABLE_FIELDS = ("enabled", "can_orchestrate", "budget_usd_month", "good_at")
37
+
38
+ _GOOD_AT_TAG_RE = re.compile(r"^[\w.\-]+$") # flow-safe tags: no spaces/commas/brackets/quotes
39
+
40
+
41
+ class RosterEditError(ValueError):
42
+ """Raised when a requested roster edit is invalid or cannot be applied safely."""
43
+
44
+
45
+ def render_field(field: str, value: object) -> str | None:
46
+ """Validate ``value`` for ``field`` and render it as the YAML scalar to write.
47
+
48
+ Args:
49
+ field: One of :data:`EDITABLE_FIELDS`.
50
+ value: The new value (from JSON: ``bool`` / number / ``None`` / list of str).
51
+
52
+ Returns:
53
+ The rendered YAML value (e.g. ``"true"``, ``"25"``, ``"[a, b]"``), or ``None`` to signal the
54
+ field's line should be **removed** (only ``budget_usd_month`` cleared to ``None``).
55
+
56
+ Raises:
57
+ RosterEditError: If ``field`` is not editable or ``value`` is the wrong type/shape.
58
+ """
59
+ if field not in EDITABLE_FIELDS:
60
+ raise RosterEditError(f"field {field!r} is not editable (allowed: {', '.join(EDITABLE_FIELDS)})")
61
+
62
+ if field in ("enabled", "can_orchestrate"):
63
+ if not isinstance(value, bool):
64
+ raise RosterEditError(f"{field} must be a boolean, got {value!r}")
65
+ return "true" if value else "false"
66
+
67
+ if field == "budget_usd_month":
68
+ if value is None or value == "":
69
+ return None # cleared → remove the line (absent == no budget)
70
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
71
+ raise RosterEditError(f"budget_usd_month must be a finite number, got {value!r}")
72
+ if value <= 0:
73
+ raise RosterEditError(f"budget_usd_month must be > 0, got {value!r}")
74
+ # Render an integer-valued number without a trailing .0, else the float repr.
75
+ return str(int(value)) if float(value).is_integer() else repr(float(value))
76
+
77
+ # good_at — a list of simple, flow-safe tags.
78
+ if not isinstance(value, list) or any(not isinstance(t, str) for t in value):
79
+ raise RosterEditError(f"good_at must be a list of strings, got {value!r}")
80
+ tags = [t.strip() for t in value]
81
+ for t in tags:
82
+ if not t or not _GOOD_AT_TAG_RE.match(t):
83
+ raise RosterEditError(
84
+ f"good_at tag {t!r} is not a simple tag (allowed: letters, digits, _ . -)"
85
+ )
86
+ return "[" + ", ".join(tags) + "]"
87
+
88
+
89
+ def _entry_id_of(line: str) -> str | None:
90
+ """Return the entry id declared by a ``- id: <id>`` list-item line, or ``None``."""
91
+ m = re.match(r"^-\s+id:\s*(.*)$", line)
92
+ if not m:
93
+ return None
94
+ raw = re.split(r"\s+#", m.group(1), maxsplit=1)[0].strip()
95
+ return raw.strip("\"'")
96
+
97
+
98
+ def _find_entry_block(lines: list[str], entry_id: str) -> tuple[int, int]:
99
+ """Return the ``[start, end)`` line range of ``entry_id``'s block (its indented field lines).
100
+
101
+ The block is the ``- id:`` line plus every following *indented* line, stopping at the first
102
+ column-0 line (a blank line, the next ``- `` item, a ``#`` comment, or EOF).
103
+
104
+ Raises:
105
+ RosterEditError: If no entry with that id is found.
106
+ """
107
+ start = next((i for i, ln in enumerate(lines) if _entry_id_of(ln) == entry_id), None)
108
+ if start is None:
109
+ raise RosterEditError(f"no roster entry with id {entry_id!r}")
110
+ end = start + 1
111
+ while end < len(lines) and lines[end][:1] in (" ", "\t"):
112
+ end += 1
113
+ return start, end
114
+
115
+
116
+ def _apply_field(lines: list[str], entry_id: str, field: str, rendered: str | None) -> list[str]:
117
+ """Return a new ``lines`` list with ``field`` set to ``rendered`` on ``entry_id``'s block.
118
+
119
+ Updates the value in place (preserving any trailing inline comment); when the field is absent it
120
+ is appended at the **end of the entry's block** (a fresh 2-space-indent line after all the entry's
121
+ existing lines), which can never splice between a block-style key and its continuation, nor land
122
+ inside the nested ``invoke`` map. Removes the line when ``rendered`` is ``None``.
123
+ """
124
+ start, end = _find_entry_block(lines, entry_id)
125
+ # The field line is matched only at exactly 2-space indent, so a same-named key nested deeper in
126
+ # the invoke block (4-space) is never mistaken for the top-level field.
127
+ field_re = re.compile(r"^( {2}" + re.escape(field) + r":\s*)(.*?)(\s+#.*)?$")
128
+
129
+ for i in range(start, end):
130
+ m = field_re.match(lines[i])
131
+ if not m:
132
+ continue
133
+ if rendered is None:
134
+ return lines[:i] + lines[i + 1:] # remove the line
135
+ return lines[:i] + [m.group(1) + rendered + (m.group(3) or "")] + lines[i + 1:]
136
+
137
+ # Field absent.
138
+ if rendered is None:
139
+ return lines # nothing to remove
140
+ return lines[:end] + [f" {field}: {rendered}"] + lines[end:]
141
+
142
+
143
+ def save_roster_edits(
144
+ entry_id: str,
145
+ fields: dict,
146
+ path: str | os.PathLike[str] | None = None,
147
+ ) -> None:
148
+ """Apply edits to one entry's editable fields, validate, back up, and write atomically.
149
+
150
+ Args:
151
+ entry_id: The id of the (existing) entry to edit.
152
+ fields: ``{field: value}`` for one or more :data:`EDITABLE_FIELDS`.
153
+ path: Target roster YAML. Defaults to the packaged ``config/roster.yaml``.
154
+
155
+ Raises:
156
+ RosterEditError: If the entry is unknown, a field/value is invalid, or the resulting YAML
157
+ fails to re-parse as a valid roster (in which case nothing is written).
158
+ """
159
+ if not fields:
160
+ raise RosterEditError("no fields to edit")
161
+
162
+ target = Path(path) if path is not None else default_roster_path()
163
+ if not target.exists():
164
+ raise RosterEditError(f"roster file not found: {target}")
165
+
166
+ text = target.read_text(encoding="utf-8")
167
+ lines = text.split("\n")
168
+
169
+ _find_entry_block(lines, entry_id) # fail fast on unknown id before rendering anything
170
+ for field, value in fields.items():
171
+ lines = _apply_field(lines, entry_id, field, render_field(field, value))
172
+ candidate = "\n".join(lines)
173
+
174
+ # Validate by re-parsing with the real loader before any write — a surgical slip can never land
175
+ # a malformed roster. Use a sibling temp file so the parse sees exactly what we'd write.
176
+ check = target.with_name(target.name + ".check.tmp")
177
+ try:
178
+ check.write_text(candidate, encoding="utf-8")
179
+ try:
180
+ roster = load_roster(check)
181
+ except RosterError as exc:
182
+ raise RosterEditError(f"edit would produce an invalid roster: {exc}") from exc
183
+ finally:
184
+ check.unlink(missing_ok=True)
185
+
186
+ # Defensive: confirm each edit actually took on the re-parsed entry.
187
+ entry = roster.by_id(entry_id)
188
+ for field, value in fields.items():
189
+ got = getattr(entry, field)
190
+ want = (None if (value is None or value == "") and field == "budget_usd_month"
191
+ else float(value) if field == "budget_usd_month"
192
+ else [t.strip() for t in value] if field == "good_at"
193
+ else value)
194
+ if got != want:
195
+ raise RosterEditError(f"edit to {field!r} did not apply as expected (got {got!r})")
196
+
197
+ backup_dir = _backup_dir()
198
+ backup_dir.mkdir(parents=True, exist_ok=True)
199
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S_%fZ")
200
+ shutil.copy2(target, backup_dir / f"roster-{stamp}.yaml")
201
+ _atomic_write(target, candidate)