Skip to content

Commit 56a5e69

Browse files
committed
Add ucode apply: publish the authored managed config
The publish step for the manifest `ucode setup` authors. Validates, shows what would change, confirms, then writes it to the workspace via the clients added in the parent commit. Updates in place rather than replacing. When the workspace already has a config, `apply` PATCHes it using its resource name (read back from the existing-config GET, which `normalize_managed_config` preserves). Delete-then-create was the original plan — v0's Create returns ALREADY_EXISTS — but it has a window where the workspace has *no* managed config, and if the create failed there every developer would silently fall back to their own settings until someone re-ran the command. The server applies the update mask inside a single entity-store update, so a failed PATCH leaves the current config intact. It is still a whole-manifest write: every path ucode owns is sent, so a field the admin dropped on a re-run is cleared rather than left behind. Refuses to publish when it cannot tell whether a config already exists. A failed existence check used to be the one case where "just try the create" would either duplicate or silently overwrite an admin's work, so an unreadable check is a hard error naming the reason rather than a warning. `_explain_publish_failure` maps the failures an admin will actually hit. FEATURE_DISABLED is the likely first experience — the CRUD flag is off by default — so it names `codingAgentConfigCrudEnabled` instead of printing an HTTP 400. INVALID_PARAMETER_VALUE is passed through verbatim: the server names the offending field, which beats any paraphrase. `--yes` skips the confirmation for CI; `--dry-run` validates and previews without writing. The admin gate and validation both run before anything is sent, so an invalid manifest or a non-admin costs no round trip. README documents the publish step and drops the "no partial update yet" caveat, which the PATCH path makes untrue. Verified against eng-ml-inference.staging: `apply --dry-run` authenticated, verified admin, rendered the summary, detected the existing config, and chose the update path without writing. Tests: 19 cases. Mutation-verified three ways — always-create instead of PATCH, publishing despite an unreadable existence check, and publishing an invalid manifest each fail a specific test. Also covers that `typer.Exit(0)` isn't caught by the command's own RuntimeError handler, the same trap `setup` hit. Co-authored-by: Isaac
1 parent 03a3f13 commit 56a5e69

4 files changed

Lines changed: 393 additions & 4 deletions

File tree

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,23 @@ ucode setup --dry-run
194194
ucode setup --from-file ./managed-settings.json
195195
```
196196

197-
Publishing replaces the workspace's config outright — there is no partial update yet, so anything
198-
skipped in a re-run is dropped.
197+
Once the manifest looks right, publish it:
198+
199+
```bash
200+
# Validate, show what would change, and ask before publishing.
201+
ucode apply
202+
203+
# Preview without publishing.
204+
ucode apply --dry-run
205+
206+
# Publish without the confirmation prompt (for CI).
207+
ucode apply --yes
208+
```
209+
210+
`apply` updates the workspace's existing config in place rather than replacing it, so a failed
211+
publish leaves the current config intact. It is still a whole-manifest write: every field ucode
212+
authors is sent, so anything skipped in a re-run is cleared rather than carried over. Developers
213+
pick the new config up on their next ucode run.
199214

200215
---
201216

@@ -222,6 +237,8 @@ skipped in a re-run is dropped.
222237
| `ucode setup` | Author the workspace's managed coding config (workspace admins only) |
223238
| `ucode setup show` | Print the authored config and the payload `ucode apply` would publish |
224239
| `ucode setup --from-file <file>` | Load a hand-written managed config instead of running the prompts |
240+
| `ucode apply` | Publish the authored managed config to the workspace (workspace admins only) |
241+
| `ucode apply --yes` | Publish without the confirmation prompt |
225242

226243
## Managed Local Files
227244

src/ucode/cli.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
)
5858
from ucode.managed_config import managed_agent_config_enabled, managed_launch_state
5959
from ucode.managed_resolve import managed_default_model, managed_provider_service
60-
from ucode.managed_wizard import setup_command, show_command
60+
from ucode.managed_wizard import apply_command, setup_command, show_command
6161
from ucode.mcp import (
6262
MCP_CLIENTS,
6363
SKILLS_MCP_KIND,
@@ -1967,6 +1967,34 @@ def setup_show_cmd() -> None:
19671967
raise typer.Exit(code)
19681968

19691969

1970+
@app.command("apply")
1971+
def apply_cmd(
1972+
yes: Annotated[
1973+
bool,
1974+
typer.Option("--yes", "-y", help="Publish without the confirmation prompt."),
1975+
] = False,
1976+
dry_run: Annotated[
1977+
bool,
1978+
typer.Option("--dry-run", help="Validate and preview without publishing."),
1979+
] = False,
1980+
) -> None:
1981+
"""Publish this workspace's managed coding config (workspace admins only)."""
1982+
set_dry_run(dry_run)
1983+
# See the `setup` callback: `typer.Exit` subclasses RuntimeError, so it must be raised after
1984+
# the try block or the handler below would report a successful exit as an error.
1985+
try:
1986+
install_databricks_cli()
1987+
code = apply_command(yes=yes)
1988+
except RuntimeError as exc:
1989+
print_err(str(exc))
1990+
raise typer.Exit(1) from None
1991+
except KeyboardInterrupt:
1992+
print_err("Interrupted.")
1993+
raise typer.Exit(130) from None
1994+
if code:
1995+
raise typer.Exit(code)
1996+
1997+
19701998
@app.command("status")
19711999
def status_cmd() -> None:
19722000
"""Show current workspace, tool configs, and saved model selections."""

src/ucode/managed_wizard.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515

1616
import json
1717
from pathlib import Path
18+
from typing import cast
1819

1920
from ucode.agents import TOOL_SPECS, check_gateway_endpoint
21+
from ucode.config_io import is_dry_run
2022
from ucode.databricks import (
2123
ANTHROPIC_FAMILIES,
24+
create_coding_agent_config,
2225
discover_claude_models_unbucketed,
2326
ensure_databricks_auth,
2427
get_databricks_token,
@@ -27,6 +30,7 @@
2730
list_model_provider_services,
2831
list_workspace_budgets,
2932
service_usable_for_tool,
33+
update_coding_agent_config,
3034
)
3135
from ucode.managed_config import get_managed_config
3236
from ucode.managed_setup import (
@@ -876,4 +880,122 @@ def show_command() -> int:
876880
return 0
877881

878882

879-
__all__ = ["setup_command", "setup_from_file", "show_command"]
883+
# Server-side failures an admin is actually likely to hit, mapped to something they can act on. The
884+
# raw reasons are `HTTP <code> <reason>: <body>` strings from the transport, and the body carries the
885+
# API's `error_code`, so matching on that is more robust than on status codes alone.
886+
def _explain_publish_failure(reason: str) -> str:
887+
lowered = reason.lower()
888+
if "feature_disabled" in lowered:
889+
return (
890+
"Managed coding-agent configs aren't enabled on this workspace yet. Ask your Databricks "
891+
"contact to enable the `codingAgentConfigCrudEnabled` flag for it, then re-run "
892+
"`ucode apply`."
893+
)
894+
if "permission_denied" in lowered or "http 403" in lowered:
895+
return (
896+
"Publishing a managed config requires workspace admin. Your account can read the "
897+
"workspace but not author its coding config."
898+
)
899+
if "already_exists" in lowered:
900+
return (
901+
"This workspace already has a managed config, but ucode couldn't read it to update in "
902+
"place. Run `ucode apply` again — if it keeps failing, the existing config may need to "
903+
"be deleted by hand."
904+
)
905+
if "invalid_parameter_value" in lowered:
906+
# The server names the offending field; passing it through beats paraphrasing.
907+
return f"The workspace rejected the config: {reason}"
908+
return f"Could not publish the managed config: {reason}"
909+
910+
911+
def apply_command(*, yes: bool = False) -> int:
912+
"""Publish the authored manifest to the workspace.
913+
914+
Updates the existing config in place when there is one, rather than deleting and recreating it:
915+
a failed recreate would leave the workspace with no managed config at all, and every developer
916+
would silently fall back to their own settings. Returns a process exit code.
917+
"""
918+
from ucode.cli import _prompt_for_configuration
919+
920+
print_section("ucode apply")
921+
922+
state = load_state()
923+
workspace = state.get("workspace")
924+
profile = state.get("profile")
925+
if not workspace:
926+
workspace, profile = _prompt_for_configuration()
927+
928+
manifest = load_managed_settings(workspace)
929+
if manifest is None:
930+
raise RuntimeError(
931+
"No managed config has been authored for this workspace. Run `ucode setup` first "
932+
"(or `ucode setup --from-file <json>`)."
933+
)
934+
935+
errors = validate_manifest(manifest, state)
936+
if errors:
937+
print_err("The authored config is not valid, so it was not published:")
938+
for error in errors:
939+
print_note(error)
940+
print_note("Re-run `ucode setup` to fix it, or edit ~/.ucode/managed-settings.json.")
941+
return 1
942+
943+
ensure_databricks_auth(workspace, profile)
944+
token = get_databricks_token(workspace, profile)
945+
_require_admin(workspace, token)
946+
947+
payload = serialize_managed_config(manifest)
948+
_render_summary(workspace, manifest)
949+
950+
# Read before writing: the resource name tells us whether to create or update, and shows the
951+
# admin what they are about to overwrite.
952+
with spinner("Checking for an existing managed config..."):
953+
existing, reason = get_managed_config(workspace, token)
954+
if reason is not None:
955+
raise RuntimeError(
956+
f"Could not check whether {workspace} already has a managed config: {reason}. "
957+
"Refusing to publish without knowing, since that could overwrite a config silently."
958+
)
959+
960+
existing_name = (existing or {}).get("name")
961+
if existing is not None and not isinstance(existing_name, str):
962+
raise RuntimeError(
963+
"This workspace has a managed config but the API didn't return its resource name, so "
964+
"ucode can't update it in place. Delete it in the workspace and re-run `ucode apply`."
965+
)
966+
967+
console.print()
968+
if existing is None:
969+
print_note(f"This will create a new managed config on {workspace}.")
970+
else:
971+
agents = ", ".join((existing.get("enabled_agents") or {}).keys()) or "no agents"
972+
print_warning(
973+
f"This will replace the config already published on {workspace} (currently: {agents}). "
974+
"Every developer picks the new one up on their next ucode run."
975+
)
976+
if not yes and not prompt_yes_no_default("Publish this config?", default=False):
977+
print_note("Nothing was published.")
978+
return 1
979+
980+
if is_dry_run():
981+
print_success("Dry run: the config was validated but not published.")
982+
return 0
983+
984+
if existing is None:
985+
with spinner("Publishing the managed config..."):
986+
published, publish_reason = create_coding_agent_config(workspace, token, payload)
987+
else:
988+
with spinner("Updating the managed config..."):
989+
published, publish_reason = update_coding_agent_config(
990+
workspace, token, cast("str", existing_name), payload
991+
)
992+
if publish_reason is not None:
993+
raise RuntimeError(_explain_publish_failure(publish_reason))
994+
995+
name = (published or {}).get("name") or existing_name or "coding-agent-configs/?"
996+
print_success(f"Published {name} to {workspace}")
997+
print_note("Developers pick this up on their next ucode run.")
998+
return 0
999+
1000+
1001+
__all__ = ["apply_command", "setup_command", "setup_from_file", "show_command"]

0 commit comments

Comments
 (0)