3636 fetch_model_recommendation ,
3737 get_databricks_token ,
3838)
39- from ucode .ui import console , print_warning
39+ from ucode .ui import print_warning
4040
4141MANAGED_STATE_PATH = config_io .APP_DIR / "managed-state.json"
4242
43+ MANAGED_STATE_VERSION = 2
44+
4345# Opt-in switch while the feature is in bug bash: unset means launches ignore managed configs
4446# entirely and behave exactly as they did before.
4547MANAGED_CONFIG_ENV_VAR = "ENABLE_MANAGED_AGENT_CONFIG"
@@ -342,29 +344,96 @@ def _is_permission_denied(reason: str) -> bool:
342344 return "http 403" in lowered or "permission_denied" in lowered
343345
344346
345- def save_managed_state (workspace : str , config : dict ) -> None :
346- """Persist the normalized managed config to ``~/.ucode/managed-state.json`` at mode 0600.
347+ def _migrated_workspaces (data : dict ) -> dict [str , dict ]:
348+ """Return the ``workspaces`` map from a raw ``managed-state.json`` dict, migrating v1 in-memory.
349+
350+ v1 stored a single ``{workspace, config}`` slot shared by both the admin's authored draft and the
351+ launch-fetched published copy, so a legacy value's provenance can't be recovered. It is migrated
352+ into the ``published`` slot — the common case is a developer's fetched snapshot, and treating it
353+ as published keeps ``ucode export`` / ``ucode publish`` (now strictly draft-only) from mistaking a
354+ cached fetch for authored work. A one-time backup (see :func:`_backup_legacy_file_once`) makes a
355+ rare unpublished admin draft recoverable. Read-only: never writes.
356+ """
357+ if data .get ("version" ) == MANAGED_STATE_VERSION and isinstance (data .get ("workspaces" ), dict ):
358+ return {k : v for k , v in cast ("dict" , data ["workspaces" ]).items () if isinstance (v , dict )}
359+ workspaces : dict [str , dict ] = {}
360+ legacy_ws = data .get ("workspace" )
361+ if isinstance (legacy_ws , str ) and legacy_ws :
362+ legacy_cfg = data .get ("config" )
363+ workspaces [legacy_ws ] = {"published" : legacy_cfg if isinstance (legacy_cfg , dict ) else {}}
364+ return workspaces
365+
366+
367+ def _is_legacy_file (data : dict ) -> bool :
368+ """True when ``data`` is a pre-v2 ``{workspace, config}`` file (not the versioned map)."""
369+ return data .get ("version" ) != MANAGED_STATE_VERSION and "workspace" in data
370+
371+
372+ def _backup_legacy_file_once () -> None :
373+ """Copy a pre-v2 ``managed-state.json`` to ``<path>.pre-v2.bak`` before it is overwritten.
374+
375+ Best-effort and idempotent: migration maps the single legacy slot into ``published``, which can't
376+ preserve a rare unpublished admin draft, so the original bytes are kept once for recovery. Written
377+ through a temp file so an interrupted copy cannot leave a truncated backup that the ``exists()``
378+ guard would then treat as the original."""
379+ backup = MANAGED_STATE_PATH .with_suffix (MANAGED_STATE_PATH .suffix + ".pre-v2.bak" )
380+ if backup .exists () or not MANAGED_STATE_PATH .exists ():
381+ return
382+ if not _is_legacy_file (config_io .read_json_safe (MANAGED_STATE_PATH )):
383+ return
384+ tmp = backup .with_name (backup .name + ".tmp" )
385+ try :
386+ tmp .write_bytes (MANAGED_STATE_PATH .read_bytes ())
387+ _restrict_permissions (tmp )
388+ os .replace (tmp , backup )
389+ except OSError :
390+ pass
391+
392+
393+ def _save_slot (workspace : str , slot : str , config : dict ) -> None :
394+ """Write ``config`` to ``workspace``'s ``published`` or ``draft`` slot, preserving everything else.
347395
348- The file is org-authored, not developer-editable — 0600 keeps it readable/writable only by the
349- user (a light guard; hard enforcement / sudo ownership is a separate concern). No-op in dry-run.
396+ Reads the current file, migrates it to the v2 map, sets one slot for one workspace, and writes it
397+ back — so a refresh of the published slot never disturbs an admin's draft (or other workspaces).
398+ 0600 keeps the org-authored file readable/writable only by the user. No-op write in dry-run.
350399
351- An empty ``config`` records "this workspace has no managed config", which matters because the
352- file doubles as the fallback when a later read fails: without it, removing a config server-side
353- would leave the old one on disk to be reapplied after a transient outage.
400+ An empty ``config`` in the ``published`` slot records "this workspace has no managed config",
401+ which matters because that slot doubles as the fallback when a later read fails: without it,
402+ removing a config server-side would leave the old one on disk to be reapplied after an outage.
354403 """
355- payload = {"workspace" : workspace , "config" : config }
404+ workspaces = _migrated_workspaces (config_io .read_json_safe (MANAGED_STATE_PATH ))
405+ workspaces [workspace ] = {** workspaces .get (workspace , {}), slot : config }
406+ payload = {"version" : MANAGED_STATE_VERSION , "workspaces" : workspaces }
356407 if config_io .is_dry_run ():
357- # Print rather than write, matching how the agent config writers behave under --dry-run.
358- console .print (
359- f"\n [bold]\\ [dry run] { MANAGED_STATE_PATH } [/bold]\n { json .dumps (payload , indent = 2 )} \n "
360- )
408+ config_io .write_json_file (MANAGED_STATE_PATH , payload )
361409 return
362- config_io .ensure_parent_dir (MANAGED_STATE_PATH )
410+ _backup_legacy_file_once ()
411+ _write_state_atomically (payload )
412+
413+
414+ def _write_state_atomically (payload : dict ) -> None :
415+ """Write the state map through a sibling temp file and rename it into place.
416+
417+ The map holds the admin's draft, which nothing can rebuild: a torn write would take the draft
418+ with it, where before v2 an interrupted write only cost a snapshot the next launch refetches.
419+ """
420+ tmp = MANAGED_STATE_PATH .with_name (MANAGED_STATE_PATH .name + ".tmp" )
421+ config_io .write_json_file (tmp , payload )
422+ _restrict_permissions (tmp )
363423 try :
364- MANAGED_STATE_PATH . write_text ( json . dumps ( payload , indent = 2 ) + " \n " , encoding = "utf-8" )
424+ os . replace ( tmp , MANAGED_STATE_PATH )
365425 except OSError as exc :
366426 raise RuntimeError (f"Failed to write managed state file: { MANAGED_STATE_PATH } " ) from exc
367- _restrict_permissions (MANAGED_STATE_PATH )
427+
428+
429+ def save_published_config (workspace : str , config : dict ) -> None :
430+ """Persist the workspace's launch-fetched ``published`` snapshot, leaving any ``draft`` intact."""
431+ _save_slot (workspace , "published" , config )
432+
433+
434+ def save_draft_config (workspace : str , config : dict ) -> None :
435+ """Persist the admin's locally authored ``draft``, leaving the ``published`` snapshot intact."""
436+ _save_slot (workspace , "draft" , config )
368437
369438
370439def _restrict_permissions (path : Path ) -> None :
@@ -376,34 +445,55 @@ def _restrict_permissions(path: Path) -> None:
376445 pass
377446
378447
379- def load_managed_state (workspace : str | None ) -> dict | None :
380- """Load the persisted managed config for ``workspace``, or None if absent/mismatched.
381-
382- Returns the normalized config dict (the ``config`` field), only when the stored file is for the
383- same workspace — so a stale file from another workspace is ignored rather than misapplied.
384-
385- This is the single local managed config: ``ucode setup`` authors it here, ``ucode publish``
386- publishes it, and a launch refreshes it from the workspace. The admin-authored draft and the
387- pulled copy share one file because the workspace is the source of truth — to keep a draft,
388- publish it with ``ucode publish``.
389- """
448+ def _load_slot (workspace : str | None , slot : str ) -> dict | None :
449+ """Return ``workspace``'s ``published`` or ``draft`` config, or None if absent."""
390450 if not workspace :
391451 return None
392- data = config_io .read_json_safe (MANAGED_STATE_PATH )
393- if data .get ("workspace" ) != workspace :
394- return None
395- config = data .get ("config" )
452+ entry = _migrated_workspaces (config_io .read_json_safe (MANAGED_STATE_PATH )).get (workspace ) or {}
453+ config = entry .get (slot )
396454 return config if isinstance (config , dict ) else None
397455
398456
457+ def load_published_config (workspace : str | None ) -> dict | None :
458+ """Load the launch-fetched ``published`` snapshot for ``workspace``, or None if absent.
459+
460+ This is what the launch path overlays (:func:`ucode.managed_resolve.resolve_state`) and what
461+ ``ucode status`` reports — the admin-defined config a developer actually runs under. A stale
462+ snapshot from a different workspace is ignored rather than misapplied.
463+ """
464+ return _load_slot (workspace , "published" )
465+
466+
467+ def load_draft_config (workspace : str | None ) -> dict | None :
468+ """Load the admin's locally authored, unpublished ``draft`` for ``workspace``, or None.
469+
470+ Only ``ucode configure`` (admin authoring) writes this, and only ``ucode export`` / ``ucode
471+ publish`` read it — never the launch path. A developer who has only ever fetched a published
472+ snapshot has no draft, which is why export/publish are draft-only rather than falling back to the
473+ fetched copy: a cached publication is not authored work.
474+ """
475+ return _load_slot (workspace , "draft" )
476+
477+
399478def managed_state_workspace () -> str | None :
400- """The workspace the on-disk managed config was authored/pulled for , or None when there is none .
479+ """The sole workspace recorded in `` managed-state.json`` , or None when absent/ambiguous .
401480
402- Lets a caller that has no workspace in local ucode state (e.g. ``ucode setup --show`` before
403- ``ucode configure``) still find the manifest on disk and report which workspace it belongs to.
481+ Lets a caller with no workspace in local ucode state still find the config on disk. With several
482+ workspaces recorded the answer is ambiguous, so it returns None and the caller reports that a
483+ workspace must be selected first.
404484 """
405- workspace = config_io .read_json_safe (MANAGED_STATE_PATH ).get ("workspace" )
406- return workspace if isinstance (workspace , str ) and workspace else None
485+ workspaces = _migrated_workspaces (config_io .read_json_safe (MANAGED_STATE_PATH ))
486+ return next (iter (workspaces )) if len (workspaces ) == 1 else None
487+
488+
489+ def save_managed_state (workspace : str , config : dict ) -> None :
490+ """Deprecated alias for :func:`save_published_config`, kept while callers migrate to the slots."""
491+ save_published_config (workspace , config )
492+
493+
494+ def load_managed_state (workspace : str | None ) -> dict | None :
495+ """Deprecated alias for :func:`load_published_config`, kept while callers migrate to the slots."""
496+ return load_published_config (workspace )
407497
408498
409499def refresh_managed_config (state : dict ) -> tuple [dict | None , bool ]:
0 commit comments