Coverage for src/lilbee/app/settings.py: 100%

570 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Canonical write boundary for lilbee configuration.""" 

2 

3from __future__ import annotations 

4 

5import errno 

6import logging 

7import os 

8import threading 

9from collections.abc import Callable, Collection, Iterable, Iterator, Mapping 

10from contextlib import contextmanager 

11from dataclasses import dataclass, replace 

12from enum import StrEnum 

13from typing import TYPE_CHECKING, Any, TypeVar 

14 

15from pydantic import ValidationError 

16 

17from lilbee.app.settings_map import CHUNK_LIMIT_KEY, SETTINGS_MAP, SettingDef, SettingGroup 

18from lilbee.config_meta import ( 

19 MODEL_ROLE_FIELDS, 

20 PUBLIC_CONFIG_FIELDS, 

21 REINDEX_FIELDS, 

22 WRITABLE_CONFIG_FIELDS, 

23) 

24from lilbee.core import settings as persistent_settings 

25from lilbee.core.config import CONFIG_FILE_NAME, Config, cfg 

26from lilbee.core.config.defaults import SKIP_TOML_ENV 

27from lilbee.core.config.enums import OcrMode, SettingSource 

28from lilbee.core.config.keys import ( 

29 LOAD_AFFECTING_KEYS, 

30 PROVIDER_SWITCHING_KEYS, 

31) 

32from lilbee.core.config.login_host import refuse_unless_host 

33from lilbee.core.config.model import ( 

34 canonical_model_ref, 

35 checked_write, 

36 default_refusal, 

37 refusal_sentence, 

38 root_folders, 

39) 

40from lilbee.core.config.resolve import ( 

41 PROFILE_FIELDS, 

42 Resolved, 

43 SettingLayers, 

44 builtin_value, 

45 read_layers, 

46 resolve, 

47 resolve_all, 

48) 

49from lilbee.core.config.schema import field_type_name 

50from lilbee.core.crawl_patterns import exclude_patterns_refusal 

51from lilbee.core.project_state import dismiss_tip 

52from lilbee.providers.reload_keys import FLEET_RELOAD_KEYS 

53from lilbee.providers.roles import ( 

54 MODEL_FIELD_TO_ROLE, 

55 ROLE_GATE_FIELD_TO_ROLE, 

56) 

57from lilbee.runtime.progress import OcrBackendUsed 

58 

59if TYPE_CHECKING: 

60 from lilbee.modelhub.registry import ModelRegistry 

61 

62log = logging.getLogger(__name__) 

63 

64_MIN_CHUNK_SIZE = 64 

65 

66# Keys that decide whether scanned pages are read, and by which engine. 

67OCR_SETTING_KEYS = frozenset({"ocr", "vision_model"}) 

68SCANNED_PAGES_LABEL = "Scanned pages" 

69_SCANNED_PAGES_OFF = "skipped (ocr = off)" 

70_SCANNED_PAGES_READ = "read by {engine}" 

71_SCANNED_PAGES_ALL = "every page read by {engine} (ocr = all)" 

72_TESSERACT_ENGINE = "Tesseract ({languages})" 

73OCR_OFF_VISION_NOTICE = ( 

74 "Scanned pages stay skipped because ocr is off. Set ocr to auto to read them with {model}." 

75) 

76 

77# Path-typed writable fields whose pydantic "default" is the unresolved 

78# sentinel ``Path()`` (a literal "."). The actual default is computed by 

79# the model_validator at process start (data_root/documents, vault_base 

80# stays as None). Resetting these via the boundary would corrupt the 

81# install, so they are refused at the reset gate. 

82_NO_RESET_FIELDS: frozenset[str] = frozenset({"documents_dir"}) 

83 

84# Serializes a whole settings update (apply, save, rollback, settle) across 

85# threads. Held only inside _settings_transaction, so a plain lock (not 

86# reentrant) is enough. 

87_settings_write_lock = threading.Lock() 

88 

89 

90class _LayerChange(StrEnum): 

91 """A change to the setting layers whose resolved values are checked before it is written.""" 

92 

93 RESET = "reset" 

94 APPLY = "apply" 

95 

96 

97@dataclass(frozen=True) 

98class SettingInfo: 

99 """Externally-facing description of a single writable setting.""" 

100 

101 key: str 

102 value: Any 

103 default: Any 

104 type: str 

105 nullable: bool 

106 group: SettingGroup 

107 help_text: str 

108 choices: tuple[str, ...] | None 

109 reindex_required: bool 

110 source: SettingSource 

111 advanced: bool 

112 

113 

114@dataclass(frozen=True) 

115class SettingsUpdateResult: 

116 """Outcome of an ``apply_settings_update`` call.""" 

117 

118 updated: list[str] 

119 reindex_required: bool 

120 warnings: tuple[str, ...] = () 

121 

122 

123def scanned_pages_state() -> str: 

124 """What happens to scanned pages under the current ocr and vision_model settings.""" 

125 backend = OcrBackendUsed.chosen(cfg.ocr, cfg.vision_model) 

126 if backend is OcrBackendUsed.NONE: 

127 return _SCANNED_PAGES_OFF 

128 engine = ( 

129 cfg.vision_model 

130 if backend is OcrBackendUsed.VISION 

131 else _TESSERACT_ENGINE.format(languages="+".join(cfg.ocr_language)) 

132 ) 

133 template = _SCANNED_PAGES_ALL if cfg.ocr is OcrMode.ALL else _SCANNED_PAGES_READ 

134 return template.format(engine=engine) 

135 

136 

137def scanned_pages_line() -> str: 

138 """The one status line for scanned pages, shown on every surface.""" 

139 return f"{SCANNED_PAGES_LABEL}: {scanned_pages_state()}" 

140 

141 

142def ocr_off_vision_notice() -> tuple[str, ...]: 

143 """The notice for a vision model that ocr = off keeps unused; empty otherwise.""" 

144 if cfg.vision_model and cfg.ocr is OcrMode.OFF: 

145 return (OCR_OFF_VISION_NOTICE.format(model=cfg.vision_model),) 

146 return () 

147 

148 

149def list_setting_value(raw: str) -> list[str]: 

150 """The items of a list setting typed as text: one per line, trimmed, blank lines dropped. 

151 

152 The CLI and both Settings views use this parse. A comma stays in the item; a 

153 field validator that accepts commas splits it on write. 

154 """ 

155 return [line.strip() for line in raw.split("\n") if line.strip()] 

156 

157 

158def _update_warnings(changed_keys: set[str]) -> tuple[str, ...]: 

159 """The notice for a vision model picked while ocr = off keeps it unused.""" 

160 return ocr_off_vision_notice() if "vision_model" in changed_keys else () 

161 

162 

163def _is_write_only(key: str) -> bool: 

164 """Return True for fields persisted but never read back (API keys, hf_token).""" 

165 extra = Config.model_fields[key].json_schema_extra 

166 if isinstance(extra, dict): 

167 return bool(extra.get("write_only", False)) 

168 return False 

169 

170 

171def _secret_refusal(key: str, error: ValidationError) -> str: 

172 """What the field's validation says about a secret value, without the value itself.""" 

173 reasons = "; ".join(detail["msg"] for detail in error.errors(include_input=False)) 

174 return f"Setting '{key}' is refused: {reasons}" 

175 

176 

177def _public_writable_keys() -> list[str]: 

178 """Names of every writable config field minus write-only secrets and non-public fields.""" 

179 keys = set(WRITABLE_CONFIG_FIELDS) | set(MODEL_ROLE_FIELDS) 

180 return sorted(k for k in keys if k in PUBLIC_CONFIG_FIELDS and not _is_write_only(k)) 

181 

182 

183def _setting_help(key: str, definition: SettingDef | None) -> str: 

184 """The one documented description for *key*. 

185 

186 ``SettingDef.help_text`` wins because it is what the TUI already shows; 

187 a field with no settings-map entry falls back to its own description. 

188 """ 

189 if definition is not None and definition.help_text: 

190 return definition.help_text 

191 return Config.model_fields[key].description or "" 

192 

193 

194def setting_sources() -> dict[str, SettingSource]: 

195 """The source of every Config field's effective value, from one read of config.toml.""" 

196 return _sources(read_layers(cfg.data_root)) 

197 

198 

199def _sources(layers: SettingLayers) -> dict[str, SettingSource]: 

200 """The source of every Config field's effective value under *layers*.""" 

201 return {key: _true_source(key, entry) for key, entry in resolve_all(layers).items()} 

202 

203 

204def _true_source(key: str, entry: Resolved) -> SettingSource: 

205 """The source of *entry*; a live value that differs from the built-in is not a built-in.""" 

206 if entry.source is SettingSource.BUILT_IN and getattr(cfg, key) != entry.value: 

207 return SettingSource.AUTO 

208 return entry.source 

209 

210 

211def _reset_target(key: str, layers: SettingLayers) -> Any: 

212 """The value a reset falls back to without an env var: the profile's, else the built-in.""" 

213 return layers.profile[key] if key in layers.profile else builtin_value(key) 

214 

215 

216def profile_defaults() -> dict[str, Any]: 

217 """The value the active profile sets for each setting it names. 

218 

219 A reset falls back to it instead of the built-in, so it is the default every surface shows. 

220 """ 

221 return dict(read_layers(cfg.data_root).profile) 

222 

223 

224def _setting_info(key: str, layers: SettingLayers, source: SettingSource) -> SettingInfo: 

225 definition = SETTINGS_MAP.get(key) 

226 nullable = _is_nullable(key) 

227 group = definition.group if definition else SettingGroup.MODELS 

228 help_text = _setting_help(key, definition) 

229 choices = definition.choices if definition else None 

230 advanced = definition.advanced if definition else False 

231 return SettingInfo( 

232 key=key, 

233 value=getattr(cfg, key), 

234 default=_reset_target(key, layers), 

235 type=field_type_name(key), 

236 nullable=nullable, 

237 group=group, 

238 help_text=help_text, 

239 choices=choices, 

240 reindex_required=key in REINDEX_FIELDS, 

241 source=source, 

242 advanced=advanced, 

243 ) 

244 

245 

246def _parse_group(group: SettingGroup | str) -> SettingGroup: 

247 """Resolve a group value or label to a ``SettingGroup``. Case-insensitive on the value.""" 

248 if isinstance(group, SettingGroup): 

249 return group 

250 normalized = group.strip().lower() 

251 for candidate in SettingGroup: 

252 if candidate.value.lower() == normalized: 

253 return candidate 

254 raise ValueError( 

255 f"Unknown setting group: {group!r}. Valid groups: " 

256 f"{', '.join(g.value for g in SettingGroup)}" 

257 ) 

258 

259 

260def list_settings(group: SettingGroup | str | None = None) -> list[SettingInfo]: 

261 """List every writable non-secret setting, optionally filtered by group (case-insensitive).""" 

262 layers = read_layers(cfg.data_root) 

263 sources = _sources(layers) 

264 infos = [_setting_info(key, layers, sources[key]) for key in _public_writable_keys()] 

265 if group is not None: 

266 wanted = _parse_group(group) 

267 infos = [info for info in infos if info.group == wanted] 

268 return sorted(infos, key=lambda info: (info.group.value, info.key)) 

269 

270 

271def get_setting(key: str) -> SettingInfo: 

272 """Return the ``SettingInfo`` for one writable non-secret key.""" 

273 if not _is_settable(key): 

274 raise ValueError(f"Unknown or read-only setting: {key}") 

275 if _is_write_only(key): 

276 raise ValueError(f"Setting '{key}' is write-only and cannot be read back") 

277 layers = read_layers(cfg.data_root) 

278 return _setting_info(key, layers, _sources(layers)[key]) 

279 

280 

281def _is_settable(key: str) -> bool: 

282 return key in WRITABLE_CONFIG_FIELDS or key in MODEL_ROLE_FIELDS 

283 

284 

285def _refuse_unsettable(key: str) -> None: 

286 """Raise ValueError for a key no writer accepts.""" 

287 if not _is_settable(key): 

288 raise ValueError(f"Unknown or read-only setting: {key}") 

289 

290 

291def _is_nullable(key: str) -> bool: 

292 """Return True if ``key`` accepts ``None`` to clear the persisted entry.""" 

293 if key in WRITABLE_CONFIG_FIELDS: 

294 return WRITABLE_CONFIG_FIELDS[key] 

295 return False 

296 

297 

298def _as_int_setting(value: Any) -> int | None: 

299 """Coerce a settings value to int the way pydantic will, or None if not numeric. 

300 

301 MCP settings_set forwards raw JSON, so a numeric setting can arrive as a 

302 string (``{"chunk_overlap": "1000"}``). The cross-field guards must compare 

303 the coerced int, not skip on the string and let pydantic accept an 

304 unvalidated value downstream. ``bool`` is excluded (it is not a meaningful 

305 chunk size) and non-numeric strings fall through to pydantic's type error. 

306 """ 

307 if isinstance(value, bool): 

308 return None 

309 if isinstance(value, int): 

310 return value 

311 if isinstance(value, str): 

312 try: 

313 return int(value.strip()) 

314 except ValueError: 

315 return None 

316 return None 

317 

318 

319_CRAWL_LOGIN_KEYS = { 

320 "crawl_authorization": "authorization", 

321 "crawl_username": "username", 

322 "crawl_password": "password", 

323 "crawl_cookie": "cookie", 

324} 

325 

326 

327def _validate_crawl_login(updates: dict[str, Any]) -> None: 

328 """Reject a stored crawl login that CrawlLogin refuses once *updates* apply.""" 

329 from lilbee.crawler.models import CrawlLogin 

330 

331 host = updates.get("crawl_login_host") 

332 if isinstance(host, str): 

333 refuse_unless_host(host) 

334 # A value that is not text is left to the field's own type check, as for any string setting. 

335 if not _CRAWL_LOGIN_KEYS.keys() & updates.keys() or not all( 

336 isinstance(updates[key], str) for key in _CRAWL_LOGIN_KEYS if key in updates 

337 ): 

338 return 

339 CrawlLogin( 

340 **{ 

341 field: updates[key] if key in updates else getattr(cfg, key) 

342 for key, field in _CRAWL_LOGIN_KEYS.items() 

343 } 

344 ) 

345 

346 

347def _validate(updates: dict[str, Any]) -> None: 

348 """Reject unknown keys, null on non-nullable, and out-of-range chunk sizes.""" 

349 for key, value in updates.items(): 

350 _refuse_unsettable(key) 

351 if value is None and not _is_nullable(key): 

352 raise ValueError(f"Setting '{key}' does not accept null") 

353 refusal = exclude_patterns_refusal(updates.get("crawl_exclude_patterns")) 

354 if refusal is not None: 

355 raise ValueError(refusal) 

356 _validate_crawl_login(updates) 

357 new_ttl = _as_int_setting(updates.get("engine_idle_ttl_minutes")) 

358 if new_ttl is not None and new_ttl < 0: 

359 raise ValueError("engine_idle_ttl_minutes must be >= 0 (0 keeps weights loaded)") 

360 new_chunk_size = _as_int_setting(updates.get("chunk_size")) 

361 if new_chunk_size is not None and new_chunk_size < _MIN_CHUNK_SIZE: 

362 raise ValueError(f"chunk_size must be >= {_MIN_CHUNK_SIZE}") 

363 effective_chunk_size = new_chunk_size if new_chunk_size is not None else cfg.chunk_size 

364 new_overlap = _as_int_setting(updates.get("chunk_overlap")) 

365 # Compare the effective overlap against the effective chunk_size so that 

366 # lowering chunk_size alone (below the already-persisted overlap) is caught, 

367 # not just an explicit new overlap. 

368 effective_overlap = new_overlap if new_overlap is not None else cfg.chunk_overlap 

369 if effective_overlap >= effective_chunk_size: 

370 raise ValueError( 

371 f"chunk_overlap ({effective_overlap}) must be < chunk_size ({effective_chunk_size})" 

372 ) 

373 

374 

375def _coerce_value(key: str, value: Any) -> Any: 

376 """Canonicalize value before cfg assignment; model-role slots run task validation.""" 

377 if key in MODEL_ROLE_FIELDS and isinstance(value, str): 

378 # heavy: role_validator pulls catalog + modelhub transitively (~300 ms) 

379 from lilbee.modelhub.role_validator import validate_model_task_assignment 

380 

381 return validate_model_task_assignment(key, value) 

382 return checked_write(key, value) 

383 

384 

385_T = TypeVar("_T") 

386 

387 

388def _commit(change: Callable[[Config], _T]) -> _T: 

389 """Make *change* on a copy of cfg, then publish every field it moved onto cfg in one step. 

390 

391 Each assignment validates on the copy, so a refusal raises before cfg moves. The 

392 publish is one ``dict.update`` of keys cfg already holds: with the GIL, no other 

393 thread runs inside it. A reader that reads two fields in two lookups can still read 

394 one on each side of a commit. The whole field dict is compared, because the model 

395 validator can re-derive a field the change does not name. It is compared against 

396 cfg as it was at the copy, by value: a field another thread writes meanwhile keeps 

397 its new value, and a re-derived equal value is not published. 

398 

399 ``model_copy`` is shallow, so *change* must assign a field and never mutate a 

400 mutable value in place: an in-place change would reach cfg before the publish. 

401 """ 

402 draft = cfg.model_copy() 

403 base = dict(draft.__dict__) 

404 result = change(draft) 

405 moved = { 

406 key: value 

407 for key, value in draft.__dict__.items() 

408 if value is not base[key] and value != base[key] 

409 } 

410 cfg.__dict__.update(moved) 

411 cfg.__pydantic_fields_set__.update(moved) 

412 return result 

413 

414 

415def _set_each(config: Config, values: Mapping[str, Any]) -> None: 

416 for key in values: 

417 setattr(config, key, values[key]) 

418 

419 

420def _restore_values(values: Mapping[str, Any]) -> None: 

421 """Put *values* back on cfg in one step.""" 

422 _commit(lambda draft: _set_each(draft, values)) 

423 

424 

425def _restore_in_memory(snapshot: SettingsSnapshot) -> None: 

426 """Put the values and the embedding dimension of *snapshot* back on cfg; config.toml stays.""" 

427 _restore_values({**snapshot.values, "embedding_dim": snapshot.embedding_dim}) 

428 

429 

430def _apply_updates(config: Config, updates: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: 

431 """Set each key on *config*; raise on the first refusal. Returns (persist, delete).""" 

432 to_persist: dict[str, Any] = {} 

433 to_delete: list[str] = [] 

434 for key, raw in updates.items(): 

435 if raw is None: 

436 setattr(config, key, None) 

437 to_delete.append(key) 

438 continue 

439 try: 

440 setattr(config, key, _coerce_value(key, raw)) 

441 except ValidationError as exc: 

442 if _is_write_only(key): 

443 raise ValueError(_secret_refusal(key, exc)) from None 

444 raise ValueError(refusal_sentence(key, raw)) from None 

445 normalized = getattr(config, key) 

446 if isinstance(normalized, list): 

447 to_persist[key] = "\n".join(str(x) for x in normalized) 

448 else: 

449 # Hand the scalar over with its type intact so config.toml holds 

450 # `true` and `2560`, not `"True"` and `"2560"`. 

451 to_persist[key] = normalized 

452 return to_persist, to_delete 

453 

454 

455def _reload_changed_roles(changed_keys: set[str]) -> None: 

456 """Re-plan the fleet off-thread, so only the servers whose launch changed restart. 

457 

458 A model-role change (chat_model/embedding_model/reranker_model/vision_model) or 

459 a setting that gates a role (ocr) names its role. A role-agnostic load key 

460 (num_ctx, kv_cache_type, chunk_size) names none, and the same re-plan finds the 

461 roles it moves. The reload applies current cfg, so one write needs one reload. 

462 It runs off the caller's thread: the write never blocks on a slow respawn. 

463 """ 

464 from lilbee.app.services import peek_services 

465 

466 services = peek_services() 

467 if services is None: 

468 return 

469 changed_role_fields = changed_keys & MODEL_ROLE_FIELDS 

470 field_to_role = MODEL_FIELD_TO_ROLE | ROLE_GATE_FIELD_TO_ROLE 

471 reloaded = {field_to_role[field] for field in changed_keys & field_to_role.keys()} 

472 for role in sorted(reloaded): 

473 services.reload_role(role) 

474 if "vision_model" in changed_role_fields: 

475 # Register/unregister lilbee's xberg OCR backend on any vision-model 

476 # change (REST/MCP/TUI/CLI all funnel here), not just the REST route. 

477 from lilbee.data.extract.backends import BackendKind, sync_xberg_backend 

478 

479 sync_xberg_backend(BackendKind.OCR, services.provider) 

480 role_agnostic = (changed_keys & LOAD_AFFECTING_KEYS) - MODEL_ROLE_FIELDS 

481 if role_agnostic and not reloaded: 

482 services.provider.replan_fleet() 

483 

484 

485def requires_services_reset(updates: Iterable[str]) -> bool: 

486 """True if applying *updates* would tear down and rebuild the Services singleton. 

487 

488 A provider switch reconstructs the provider via ``create_provider``, which 

489 only runs at services init, so it forces a full ``reset_services()``. Callers 

490 on the shared HTTP daemon use this to refuse the swap rather than tear the 

491 singleton down under concurrent in-flight handlers. 

492 """ 

493 return bool(set(updates) & PROVIDER_SWITCHING_KEYS) 

494 

495 

496def provider_reset_refused_message(action: str) -> str: 

497 """Shared user-facing refusal for a provider *action* on the HTTP server. 

498 

499 *action* is the verb shown to the user, e.g. ``"Switching"`` or 

500 ``"Resetting"``. Kept in one place so the daemon entry points (MCP 

501 settings_set / settings_reset, REST config) cannot drift apart. 

502 """ 

503 return ( 

504 f"{action} the model provider is unavailable on the HTTP server: it rebuilds " 

505 "the shared engine for every connected client. Change it from the CLI." 

506 ) 

507 

508 

509def config_write_failure_message(exc: OSError) -> str: 

510 """User-facing text for a failed config write; names the fix when the file is locked.""" 

511 return _config_failure_message(exc, "write") 

512 

513 

514def config_read_failure_message(exc: OSError) -> str: 

515 """User-facing text for a failed config read; names the fix when the file is locked.""" 

516 return _config_failure_message(exc, "read") 

517 

518 

519def _config_failure_message(exc: OSError, verb: str) -> str: 

520 if isinstance(exc, persistent_settings.ReadOnlyConfigError): 

521 # The error text already names the file by its full path. 

522 return f"{exc}." 

523 detail = f"Could not {verb} {CONFIG_FILE_NAME}: {exc}." 

524 if exc.errno in (errno.EACCES, errno.EPERM): 

525 detail += f" Close the program that holds {CONFIG_FILE_NAME} open and try again." 

526 return detail 

527 

528 

529def _invalidate_caches(changed_keys: set[str]) -> None: 

530 """Drop every read-side cache whose freshness depends on a changed setting.""" 

531 if not changed_keys: 

532 return 

533 if changed_keys & MODEL_ROLE_FIELDS: 

534 # heavy: model_info reads GGUF headers with the gguf parser (~130 ms) 

535 from lilbee.modelhub.model_info import invalidate_cache as invalidate_arch_cache 

536 

537 invalidate_arch_cache() 

538 if changed_keys & FLEET_RELOAD_KEYS: 

539 # heavy: app.services pulls the provider stack + lancedb (~70 ms) 

540 _reload_changed_roles(changed_keys) 

541 if "token_sizing" in changed_keys: 

542 # Unregister lilbee's xberg tokenizer backend when token_sizing is turned 

543 # off (via any settings path); the chunker binds it on demand when on. 

544 from lilbee.app.services import peek_services 

545 from lilbee.data.extract.backends import BackendKind, sync_xberg_backend 

546 

547 services = peek_services() 

548 if services is not None: 

549 sync_xberg_backend(BackendKind.TOKENIZER, services.provider) 

550 if "mcp_tool_threads" in changed_keys: 

551 # Resize the running server's thread pool now instead of only at startup. 

552 from lilbee.server.app import reapply_thread_pool_ceiling 

553 

554 reapply_thread_pool_ceiling() 

555 if "include_uncensored" in changed_keys: 

556 # The picks memoize per process; drop them so the toggle takes 

557 # effect on the next read instead of the next restart. 

558 from lilbee.catalog.picks import reset_picks 

559 

560 reset_picks() 

561 

562 

563def _run_deferred_provider_reset(needed: bool, *, after_failure: bool = False) -> None: 

564 """Tear down the Services singleton for a provider switch, once the lock has released. 

565 

566 Blocks on the whole fleet stopping (each engine's stop-then-reap can 

567 take a few seconds), which is why ``_settings_transaction`` defers it 

568 until after ``_settings_write_lock`` releases. 

569 """ 

570 if not needed: 

571 return 

572 from lilbee.app.services import reset_services 

573 

574 if not after_failure: 

575 reset_services() 

576 return 

577 try: 

578 reset_services() 

579 except Exception: 

580 log.exception("Provider reset failed while handling another exception") 

581 

582 

583@dataclass 

584class _SettingsTransaction: 

585 """State one locked settings update carries to its exit-time provider reset.""" 

586 

587 needs_provider_reset: bool = False 

588 

589 

590@contextmanager 

591def _settings_transaction() -> Iterator[_SettingsTransaction]: 

592 """Hold ``_settings_write_lock`` for one settings update. 

593 

594 The body sets ``needs_provider_reset`` on the yielded transaction before 

595 returning. The reset then runs after the lock releases: if the body 

596 raised, a reset failure is logged and swallowed so the body's exception 

597 reaches the caller unchanged; otherwise a reset failure propagates 

598 normally. 

599 """ 

600 txn = _SettingsTransaction() 

601 try: 

602 with _settings_write_lock: 

603 yield txn 

604 except Exception: 

605 _run_deferred_provider_reset(txn.needs_provider_reset, after_failure=True) 

606 raise 

607 else: 

608 _run_deferred_provider_reset(txn.needs_provider_reset) 

609 

610 

611def apply_settings_update( 

612 updates: dict[str, Any], 

613 *, 

614 allow_model_roles: bool = True, 

615) -> SettingsUpdateResult: 

616 """Validate, apply, persist, and invalidate caches for a batch of updates. 

617 

618 Atomic on validation: the batch validates on a copy of cfg, so a 

619 rejection leaves cfg untouched and writes nothing. Atomic on disk 

620 failure: an ``OSError`` from the TOML write, or a parse error 

621 reloading a corrupt config.toml, restores the in-memory snapshot 

622 before re-raising. Cache invalidation runs only after a 

623 successful persist. 

624 

625 Pass ``allow_model_roles=False`` to reject ``chat_model`` / 

626 ``embedding_model`` / ``vision_model`` / ``reranker_model`` at the 

627 boundary; the HTTP PATCH /api/config surface uses this to route role 

628 writes through PUT /api/models/<role>. 

629 

630 Runs inside ``_settings_transaction``, which holds the lock and owns 

631 the provider-reset exception contract. 

632 """ 

633 with _settings_transaction() as txn: 

634 if not allow_model_roles: 

635 _refuse_model_roles(updates) 

636 _validate(updates) 

637 null_keys = [key for key, value in updates.items() if value is None] 

638 if null_keys: 

639 _refuse_invalid_fallbacks(_values_after_reset(null_keys), _LayerChange.APPLY) 

640 embed_in_batch = "embedding_model" in updates 

641 if embed_in_batch: 

642 # Pin the OLD ref into store meta before mutation, otherwise the 

643 # next read lazy-initializes meta from the NEW cfg and silently 

644 # hides the dimension drift. Runs even when the value is unchanged 

645 # so a legacy meta row is always canonicalized on the first swap 

646 # attempt. 

647 _pin_legacy_store_meta() 

648 before = snapshot_settings(updates) 

649 

650 def change(draft: Config) -> tuple[dict[str, Any], list[str]]: 

651 # The embedder and its dim publish together, before config.toml takes the batch. 

652 result = _apply_updates(draft, updates) 

653 _rederive_from_resolved(draft, set(updates)) 

654 return result 

655 

656 to_persist, to_delete = _commit(change) 

657 written = False 

658 try: 

659 # A write can fail part way: the update lands and the delete raises. 

660 if to_persist: 

661 persistent_settings.update_values(cfg.data_root, to_persist) 

662 written = True 

663 if to_delete: 

664 persistent_settings.delete_values(cfg.data_root, to_delete) 

665 except Exception as exc: 

666 if written or not isinstance(exc, persistent_settings.ConfigChangedWhileReadError): 

667 # No cache saw the new values yet, so none is dropped. 

668 _undo_update(before, invalidate_caches=False) 

669 else: 

670 # The read refused before any write: the file stays the editor's. 

671 _restore_in_memory(before) 

672 raise 

673 # Decide the reset now, before settle's other steps can raise. 

674 txn.needs_provider_reset = bool(set(updates) & PROVIDER_SWITCHING_KEYS) 

675 try: 

676 file_unread_values = _values_when_file_unread(updates) 

677 return _settle(set(updates), embed_in_batch=embed_in_batch, values=file_unread_values) 

678 except Exception: 

679 # A store error at the verdict comes after the write, so config.toml must go back too. 

680 _undo_update(before, invalidate_caches=True) 

681 raise 

682 

683 

684def _values_when_file_unread(updates: dict[str, Any]) -> dict[str, Any] | None: 

685 """The values to settle on under ``LILBEE_SKIP_TOML_CONFIG=1``; None when the file is read. 

686 

687 The resolver does not read config.toml under the switch, so it cannot see the value just 

688 written. cfg holds the update, except where a variable names the setting (it still wins) 

689 or the update clears it (the resolver gives the default). 

690 """ 

691 if os.environ.get(SKIP_TOML_ENV) != "1": 

692 return None 

693 resolved = resolve_all(read_layers(cfg.data_root)) 

694 return { 

695 key: resolved[key].value 

696 if raw is None or resolved[key].source is SettingSource.ENV 

697 else getattr(cfg, key) 

698 for key, raw in updates.items() 

699 } 

700 

701 

702def _undo_update(before: SettingsSnapshot, *, invalidate_caches: bool) -> None: 

703 """Put a failed update back; cfg goes back even when config.toml refuses the undo.""" 

704 try: 

705 _put_back(before, invalidate_caches=invalidate_caches) 

706 except Exception: 

707 # The caller sees the update fail, so no field may keep its new value. 

708 log.exception("Could not put the settings back after a failed update") 

709 _restore_in_memory(before) 

710 

711 

712@dataclass(frozen=True) 

713class SettingsSnapshot: 

714 """Settings as they stood: the values in cfg and the entries in config.toml.""" 

715 

716 values: dict[str, Any] 

717 saved: dict[str, Any] 

718 # Derived from the embedder, and not always from a header a restore can read again. 

719 embedding_dim: int 

720 

721 

722def snapshot_settings(keys: Collection[str]) -> SettingsSnapshot: 

723 """Record *keys* for ``restore_settings``. A key with no config.toml entry stays without one.""" 

724 user = read_layers(cfg.data_root).user 

725 return SettingsSnapshot( 

726 values={key: getattr(cfg, key) for key in keys}, 

727 saved={key: user[key] for key in keys if key in user}, 

728 embedding_dim=cfg.embedding_dim, 

729 ) 

730 

731 

732def restore_settings(snapshot: SettingsSnapshot) -> None: 

733 """Put cfg and config.toml back as *snapshot* holds them, so each source is as it was. 

734 

735 Raises ``OSError`` or ``ValueError`` when config.toml refuses the write, with cfg 

736 left alone so that it still agrees with the file. 

737 """ 

738 with _settings_transaction() as txn: 

739 txn.needs_provider_reset = bool(snapshot.values.keys() & PROVIDER_SWITCHING_KEYS) 

740 _put_back(snapshot) 

741 

742 

743def _put_back(snapshot: SettingsSnapshot, *, invalidate_caches: bool = True) -> None: 

744 """The body of ``restore_settings``. Caller holds ``_settings_write_lock``.""" 

745 if snapshot.saved: 

746 persistent_settings.update_values(cfg.data_root, snapshot.saved) 

747 persistent_settings.delete_values( 

748 cfg.data_root, [key for key in snapshot.values if key not in snapshot.saved] 

749 ) 

750 _restore_in_memory(snapshot) 

751 if invalidate_caches: 

752 _invalidate_caches(set(snapshot.values)) 

753 

754 

755def _refuse_model_roles(keys: Iterable[str]) -> None: 

756 """Refuse model-role keys, which the dedicated model route owns.""" 

757 rejected = MODEL_ROLE_FIELDS & set(keys) 

758 if rejected: 

759 offender = sorted(rejected)[0] 

760 raise ValueError( 

761 f"'{offender}' must be set through the dedicated model route " 

762 "(on the command line, lilbee model), not the general settings update." 

763 ) 

764 

765 

766def _settle( 

767 keys: set[str], 

768 *, 

769 embed_in_batch: bool, 

770 changed: set[str] | None = None, 

771 reindex_changed: set[str] | None = None, 

772 values: Mapping[str, Any] | None = None, 

773) -> SettingsUpdateResult: 

774 """Set *keys* on cfg; rederive, invalidate and report. 

775 

776 The values come from the resolver, or from *values* when the caller has read them. 

777 *changed* (default *keys*) is the keys that differ in cfg; *reindex_changed* 

778 (default *changed*) is the keys that differ in config.toml. 

779 """ 

780 effective = keys if changed is None else changed 

781 reindex_effective = effective if reindex_changed is None else reindex_changed 

782 

783 def change(draft: Config) -> None: 

784 if values is None: 

785 persistent_settings.sync_from_resolver(draft, keys) 

786 else: 

787 _set_each(draft, {key: values[key] for key in keys}) 

788 _rederive_from_resolved(draft, effective) 

789 

790 try: 

791 _commit(change) 

792 finally: 

793 _invalidate_caches(effective) 

794 reindex_required = bool((REINDEX_FIELDS - inert_reindex_keys()) & reindex_effective) 

795 if reindex_required or embed_in_batch: 

796 if not index_has_chunks(): 

797 reindex_required = False 

798 elif not reindex_required: 

799 reindex_required = _embed_reindex_required() 

800 return SettingsUpdateResult( 

801 updated=sorted(keys), 

802 reindex_required=reindex_required, 

803 warnings=_update_warnings(effective), 

804 ) 

805 

806 

807def apply_ephemeral_model_swap(field: str, ref: str) -> None: 

808 """Apply a chat/embedding model swap to cfg for this process only. 

809 

810 Performs the same embedding side effects as the persisted path (legacy 

811 store meta pinned under the OLD ref first, then embedding_dim re-derived 

812 for the new one) so the mismatch gate and table width stay correct, but 

813 never writes config.toml. 

814 """ 

815 if field == "embedding_model": 

816 _pin_legacy_store_meta() 

817 dim = _embedder_dim_from_gguf(ref) 

818 setattr(cfg, field, ref) 

819 if dim is not None: 

820 cfg.embedding_dim = dim 

821 return 

822 setattr(cfg, field, ref) 

823 

824 

825def _pin_legacy_store_meta() -> None: 

826 """Pin the current embedding ref into store meta before swapping it.""" 

827 # heavy: ~100ms (lance + store init); only paid when embedding_model is in the batch. 

828 from lilbee.app.services import get_services 

829 

830 get_services().store.initialize_meta_if_legacy() 

831 

832 

833def _embedder_dim_from_gguf(ref: str, registry: ModelRegistry | None = None) -> int | None: 

834 """The embedder's output width from its GGUF header (``<arch>.embedding_length``). 

835 

836 None when the model can't be resolved or the header lacks the field. Cheap: a 

837 cached header read, no load. *registry* is forwarded to resolve the GGUF without 

838 ``get_services()`` (callers running inside its construction). 

839 """ 

840 from lilbee.providers.base import ProviderError 

841 from lilbee.providers.engine_params import resolve_model_path 

842 from lilbee.providers.gguf_meta import read_gguf_metadata 

843 

844 try: 

845 # resolve_model_path raises ProviderError for a non-native (ollama/SDK) ref, 

846 # which has no local GGUF -- those embedders carry no width to derive here. 

847 meta = read_gguf_metadata(resolve_model_path(ref, registry)) 

848 except (ProviderError, ValueError, OSError, RuntimeError, TypeError): 

849 return None 

850 raw = meta.get("embedding_length") if meta else None 

851 if not raw: 

852 return None 

853 try: 

854 dim = int(raw) 

855 except (TypeError, ValueError): 

856 return None 

857 return dim if dim > 0 else None 

858 

859 

860def reconcile_embedding_dim( 

861 registry: ModelRegistry | None = None, config: Config | None = None 

862) -> None: 

863 """Pin the embedding dim of *config* (default cfg) to the native embedder's GGUF width; 

864 no-op for non-native embedders or an already-matching dim.""" 

865 target = cfg if config is None else config 

866 dim = _embedder_dim_from_gguf(target.embedding_model, registry) 

867 if dim is not None and dim != target.embedding_dim: 

868 target.embedding_dim = dim 

869 

870 

871def _rederive_from_resolved(config: Config, keys: set[str]) -> None: 

872 """Recompute on *config* each setting derived from one of *keys*.""" 

873 if "embedding_model" in keys: 

874 reconcile_embedding_dim(config=config) 

875 

876 

877def inert_reindex_keys() -> set[str]: 

878 """Reindex keys that change no extraction output under the effective config. 

879 

880 ``table_model`` is read only inside layout detection, and ``topic_threshold`` 

881 only inside semantic chunking, so a change to either while its gating flag is 

882 off is not worth a rebuild. 

883 """ 

884 inert: set[str] = set() 

885 if not cfg.layout_detection: 

886 inert.add("table_model") 

887 if not cfg.semantic_chunking: 

888 inert.add("topic_threshold") 

889 return inert 

890 

891 

892def reindex_values() -> dict[str, Any]: 

893 """The value in force of each setting that changes what the index holds. 

894 

895 That is each of ``REINDEX_FIELDS`` and the chunk limit, which holds files out. 

896 """ 

897 return {key: getattr(cfg, key) for key in REINDEX_FIELDS | {CHUNK_LIMIT_KEY}} 

898 

899 

900def chunk_limit_loosened(before: Mapping[str, Any]) -> bool: 

901 """Whether the chunk limit now in force admits more chunks than the one in *before*.""" 

902 old, new = before.get(CHUNK_LIMIT_KEY), cfg.max_chunks_per_file 

903 return old is not None and old != 0 and (new == 0 or new > old) 

904 

905 

906def reindex_values_differ(before: Mapping[str, Any]) -> bool: 

907 """Whether the index, built with *before*, needs a rebuild for the values now in force. 

908 

909 The same rule as a single save: a changed setting counts unless it is inert under the 

910 effective config, and an index with no chunks has nothing to rebuild. 

911 """ 

912 changed = {key for key, value in reindex_values().items() if before.get(key) != value} 

913 return bool((REINDEX_FIELDS - inert_reindex_keys()) & changed) and index_has_chunks() 

914 

915 

916def index_has_chunks() -> bool: 

917 """True when the index holds at least one chunk; a rebuild has nothing to change otherwise. 

918 

919 The store answers without opening the library when the library has no folder, 

920 and no provider is built to ask. 

921 """ 

922 from lilbee.app.services import chunks_in_store 

923 

924 return chunks_in_store() 

925 

926 

927def _embed_reindex_required() -> bool: 

928 """True when the persisted index was built with another embedder than cfg now names. 

929 

930 Runs after the swap is applied, so the store compares its meta row against 

931 the new ref and the new model's width: the same verdict search refuses on. 

932 """ 

933 from lilbee.app.services import get_services 

934 

935 store = get_services().store 

936 store.canonicalize_meta_if_legacy() 

937 return store.index_mismatch() is not None 

938 

939 

940def embed_pick_needs_rebuild(ref: str) -> bool: 

941 """True when picking *ref* as the embedder leaves the index unsearchable. 

942 

943 The verdict ``_embed_reindex_required`` reads after a swap, asked before it: 

944 the store's meta row against *ref* and its width, with cfg left alone. 

945 """ 

946 from lilbee.app.services import get_services 

947 

948 store = get_services().store 

949 store.initialize_meta_if_legacy() 

950 try: 

951 canonical = canonical_model_ref(_coerce_value("embedding_model", ref)) 

952 except ValueError: # the write refuses this ref on its own 

953 canonical = ref 

954 dim = _embedder_dim_from_gguf(canonical) or cfg.embedding_dim 

955 return store.index_mismatch(model=canonical, dim=dim) is not None 

956 

957 

958def reset_settings( 

959 keys: list[str], *, skip_unresettable: bool = False, allow_model_roles: bool = True 

960) -> SettingsUpdateResult: 

961 """Remove each key from config.toml and set cfg to the value the resolver then gives. 

962 

963 ``documents_dir`` has no default to fall back to, so it is refused; pass 

964 ``skip_unresettable=True`` for bulk gestures that skip it instead. 

965 

966 Runs inside ``_settings_transaction``, which holds the lock and owns 

967 the provider-reset exception contract. 

968 """ 

969 with _settings_transaction() as txn: 

970 if not allow_model_roles: 

971 _refuse_model_roles(keys) 

972 for key in keys: 

973 _refuse_unsettable(key) 

974 if key in _NO_RESET_FIELDS and not skip_unresettable: 

975 raise ValueError(f"'{key}' has no default to reset to; set a folder path instead.") 

976 targets = [key for key in keys if key not in _NO_RESET_FIELDS] 

977 before = _values_before_reset(targets) 

978 fallbacks = _values_after_reset(targets) 

979 coerced = _refuse_invalid_fallbacks(fallbacks, _LayerChange.RESET) 

980 _validate({key: entry.value for key, entry in fallbacks.items()}) 

981 # compared in cfg's own terms 

982 changed = {key for key in targets if getattr(cfg, key) != coerced[key]} 

983 # compared on the file's resolved value 

984 reindex_changed = {key for key in targets if before[key].value != fallbacks[key].value} 

985 if "embedding_model" in changed: 

986 _pin_legacy_store_meta() 

987 persistent_settings.delete_values(cfg.data_root, targets) 

988 # Decide the reset now, before settle's other steps can raise. 

989 txn.needs_provider_reset = bool(changed & PROVIDER_SWITCHING_KEYS) 

990 return _settle( 

991 set(targets), 

992 embed_in_batch="embedding_model" in reindex_changed, 

993 changed=changed, 

994 reindex_changed=reindex_changed, 

995 ) 

996 

997 

998def _values_before_reset(keys: list[str]) -> dict[str, Resolved]: 

999 """The value and source each of *keys* currently resolves to, saved value included.""" 

1000 layers = read_layers(cfg.data_root) 

1001 return {key: resolve(key, layers) for key in keys} 

1002 

1003 

1004def _values_after_reset(keys: list[str]) -> dict[str, Resolved]: 

1005 """The value and source each of *keys* resolves to once its user entry is gone.""" 

1006 layers = read_layers(cfg.data_root) 

1007 remaining = replace( 

1008 layers, user={key: value for key, value in layers.user.items() if key not in keys} 

1009 ) 

1010 return {key: resolve(key, remaining) for key in keys} 

1011 

1012 

1013def _refuse_invalid_fallbacks( 

1014 fallbacks: dict[str, Resolved], action: _LayerChange 

1015) -> dict[str, Any]: 

1016 """Refuse an *action* when a key would resolve to a value its field rejects. 

1017 

1018 Returns each key's value after the same coercion a cfg assignment applies 

1019 (e.g. an env var's raw string cast to int), so a caller can compare it 

1020 against cfg's current value in the same terms. 

1021 """ 

1022 trial = cfg.model_copy() 

1023 for key, entry in fallbacks.items(): 

1024 value = builtin_value(key) if default_refusal(key, entry.value) else entry.value 

1025 try: 

1026 setattr(trial, key, value) 

1027 except ValueError as exc: 

1028 raise ValueError( 

1029 f"Cannot {action.value} '{key}': its {entry.source.value} value {entry.value!r} is " 

1030 "invalid. Fix or remove that value first." 

1031 ) from exc 

1032 return {key: getattr(trial, key) for key in fallbacks} 

1033 

1034 

1035def apply_profile_layer( 

1036 name: str, 

1037 values: Mapping[str, Any], 

1038 *, 

1039 absorb: Collection[str] = (), 

1040 write_first: Callable[[], object] | None = None, 

1041) -> SettingsUpdateResult: 

1042 """Record *name* and *values* as the project's profile and set cfg from the new layers. 

1043 

1044 Every key the old or new profile holds is validated at the value it resolves to 

1045 under the new profile before anything is written; user and env values keep winning. 

1046 Each *absorb* key, which *values* must hold, leaves config.toml in the same write. 

1047 Applying a profile hides the analyze tip. 

1048 *write_first* runs once validation passes, before config.toml changes. 

1049 

1050 Runs inside ``_settings_transaction``, which holds the lock and owns 

1051 the provider-reset exception contract. 

1052 """ 

1053 with _settings_transaction() as txn: 

1054 refused = sorted(set(values) - set(PROFILE_FIELDS)) 

1055 if refused: 

1056 raise ValueError(f"Profiles cannot set {refused[0]}") 

1057 stray = sorted(set(absorb) - set(values)) 

1058 if stray: 

1059 raise ValueError(f"The profile does not hold {stray[0]}, so it cannot take it over") 

1060 layers = read_layers(cfg.data_root) 

1061 user = {key: value for key, value in layers.user.items() if key not in absorb} 

1062 after = replace(layers, user=user, profile=dict(values)) 

1063 keys = set(layers.profile) | set(values) 

1064 resolved = {key: resolve(key, after) for key in sorted(keys)} 

1065 checked = _refuse_invalid_fallbacks(resolved, _LayerChange.APPLY) 

1066 _validate({key: entry.value for key, entry in resolved.items()}) 

1067 if write_first is not None: 

1068 write_first() 

1069 persistent_settings.write_profile_table(cfg.data_root, name, values, drop=absorb) 

1070 _hide_analyze_tip() 

1071 # Decide the reset now, before settle's other steps can raise. 

1072 txn.needs_provider_reset = bool(keys & PROVIDER_SWITCHING_KEYS) 

1073 # Under LILBEE_SKIP_TOML_CONFIG=1 the resolver cannot read the profile just written, 

1074 # so settle on the checked values resolved from the layers built above. 

1075 file_unread = os.environ.get(SKIP_TOML_ENV) == "1" 

1076 settled = checked if file_unread else None 

1077 return replace( 

1078 _settle(keys, embed_in_batch=False, values=settled), 

1079 warnings=ocr_off_vision_notice(), 

1080 ) 

1081 

1082 

1083@dataclass(frozen=True) 

1084class ConfigReading: 

1085 """What config.toml set when this process last read it. 

1086 

1087 ``values`` is what the startup overlay sets from the file, by key. ``held`` is the 

1088 value a held key takes when the file stops setting it, as at startup: a folder 

1089 derived from the data root, or the model cfg held when the TUI started. 

1090 """ 

1091 

1092 stamp: tuple[int, int, int] | None 

1093 values: Mapping[str, Any] 

1094 held: Mapping[str, Any] 

1095 

1096 

1097# The source registry has its own reader, which a running sync's hold on it stops. 

1098_RELOADED_KEYS = (set(WRITABLE_CONFIG_FIELDS) | MODEL_ROLE_FIELDS) - {"linked_roots"} 

1099_ABSENT = object() 

1100 

1101 

1102def read_config_state() -> ConfigReading: 

1103 """Read config.toml under cfg's data root the way startup does, in one locked read.""" 

1104 stamp, values = persistent_settings.read_persisted(cfg.data_root) 

1105 return ConfigReading(stamp, values or {}, _held_values()) 

1106 

1107 

1108def start_config_reading() -> ConfigReading: 

1109 """The reading a follower starts from: the one the settings were loaded from. 

1110 

1111 A write that lands after the CLI loaded config.toml and before the follower starts 

1112 is then a change the follower takes. Without a load of cfg's data root, the file is 

1113 read now. 

1114 """ 

1115 loaded = persistent_settings.last_overlay(cfg.data_root) 

1116 if loaded is None: 

1117 return read_config_state() 

1118 return ConfigReading(loaded.stamp, loaded.values, _held_values()) 

1119 

1120 

1121def _held_values() -> dict[str, Any]: 

1122 """The value each held key takes when the file stops setting it, as cfg holds it now.""" 

1123 folders = root_folders(cfg.data_root) 

1124 return { 

1125 key: folders[key] if key in folders else getattr(cfg, key) 

1126 for key in persistent_settings.HELD_AT_BUILT_IN & _RELOADED_KEYS 

1127 } 

1128 

1129 

1130def _registry_refreshed() -> bool: 

1131 """Read the source registry again unless a running sync holds it; whether it changed.""" 

1132 before = cfg.linked_roots 

1133 persistent_settings.refresh_registry(cfg) 

1134 return cfg.linked_roots != before 

1135 

1136 

1137def adopt_external_changes( 

1138 last: ConfigReading, 

1139 *, 

1140 hold: frozenset[str] = frozenset(), 

1141 reset_providers: bool = True, 

1142) -> tuple[ConfigReading, SettingsUpdateResult | None]: 

1143 """Set on cfg each key another process changed in config.toml since *last*. 

1144 

1145 Costs two stat() calls when the file is unchanged. The new values come from the 

1146 same function startup uses, from one read under the lock a writer takes. A key is 

1147 taken only when its value moved since *last* and cfg does not hold it yet, so a 

1148 value this process set for itself alone (a one-off command-line flag) stays unless 

1149 the file changed that key. A held key the file stops setting goes back to its 

1150 startup value. The source registry is read by ``refresh_registry``: while a sync 

1151 holds it, it keeps its folders, and a later check takes the change. The keys go 

1152 through the same settle step as an update made here. A file that does not parse 

1153 changes nothing and keeps *last*, as at startup. A key in *hold* is never taken: a 

1154 warning says the process takes it at its next start. With *reset_providers* False a 

1155 provider switch leaves the fleet running: the caller stops it with 

1156 ``reset_for_provider_switch`` once the lock it holds around this call is free. 

1157 Returns the new reading and the update result, or None when no key changed. 

1158 """ 

1159 roots_changed = _registry_refreshed() 

1160 if persistent_settings.overlay_stamp(cfg.data_root) == last.stamp and not roots_changed: 

1161 return last, None 

1162 with _settings_transaction() as txn: 

1163 stamp, values = persistent_settings.read_persisted(cfg.data_root) 

1164 now = last if values is None else replace(last, stamp=stamp, values=values) 

1165 targets: dict[str, Any] = {} 

1166 for key in _RELOADED_KEYS: 

1167 value = now.values.get(key, _ABSENT) 

1168 if value == last.values.get(key, _ABSENT): 

1169 continue 

1170 if value is _ABSENT: 

1171 value = last.held.get(key, _ABSENT) 

1172 if value is not _ABSENT and value != getattr(cfg, key): 

1173 targets[key] = value 

1174 for key in sorted(targets.keys() & hold): 

1175 del targets[key] 

1176 log.warning("config.toml now sets %s; restart this process to use it.", key) 

1177 if not targets and not roots_changed: 

1178 return now, None 

1179 txn.needs_provider_reset = reset_providers and requires_services_reset(targets) 

1180 result = _settle(set(targets), embed_in_batch=False, values=targets) 

1181 if roots_changed: 

1182 result = replace(result, updated=sorted({*result.updated, "linked_roots"})) 

1183 return now, result 

1184 

1185 

1186def reset_for_provider_switch(result: SettingsUpdateResult | None) -> None: 

1187 """Stop the fleet when *result*, a take that left the reset to its caller, switched provider.""" 

1188 _run_deferred_provider_reset(result is not None and requires_services_reset(result.updated)) 

1189 

1190 

1191def _hide_analyze_tip() -> None: 

1192 """Hide the analyze tip once a profile is applied; a failed write must not fail the apply.""" 

1193 try: 

1194 dismiss_tip(cfg.data_root) 

1195 except OSError as exc: 

1196 log.warning("Could not record that the analyze tip is hidden: %s", exc)