Files
buzz/desktop/src-tauri/src/migration/materialize.rs
Will Pfleger f86dfc5883 feat(desktop): surface config diff in restart-required badge (#3637)
The "Restart required" badge reports that an agent's running config has
drifted from its spawn-time config, but never says what changed. This
ships the full feature: a typed Rust diff engine and a TS/UI layer that
renders it at every badge site.

## Rust core (spawn-snapshot diff engine)

Replaces the lossy `u64` `spawn_config_hash` with a typed
`SpawnConfigSnapshot`. The snapshot is stamped from the already-resolved
command/env/config values immediately before `spawn()`, closing the race
window where a mid-spawn config edit would suppress the badge.

`SpawnConfigSnapshot::canonical()` is the single JSON projection shared
by the badge and the diff. Drift is `to_value(stamped) !=
to_value(current)`; the diff is a generic leaf walk over those same two
values, so badge-on and diff-non-empty are structurally guaranteed.
Adding a snapshot field reaches the UI with no code change to the diff
engine — `mutation_table_covers_every_serialized_field` fails CI if a
new field arrives without a mutation row.

`eligible_restart_diff(persona_orphaned, Option<TrackedSpawnState>)`
returns the final vector — snapshot walk entries plus a synthetic
`adapter_availability` entry. It returns empty for an orphaned instance
(spawning one would fail) and for agents with no tracked spawn state
(never stamped, can never have drifted). `needs_restart =
!restart_diff.is_empty()` derives from that vector and nothing else.

Redaction policy (`policy_for(path)`) is shared by the wire diff and the
snapshot's manual `Debug` via `is_safe_to_reveal()` from
`managed_agents::env_vars` as the single authority for env-key masking:

| Policy | Paths | Rendering |
|---|---|---|
| `Text` | `system_prompt`, `team_instructions` | character counts only
|
| `MaskedBare` | `args`, `relay_url` | `••••`, no suffix |
| `MaskedSuffix` | non-allowlisted `env.*` | `••••` + last 4 chars when
longer than 8 |
| `Plain` | allowlisted `env.*` (`BUZZ_AGENT_THINKING_EFFORT`,
`BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL`, `DATABRICKS_HOST/MODEL`) and
everything else | verbatim |

Default-deny: every env key not in the explicit allowlist stays masked.
`is_safe_to_reveal()` is the single allowlist authority for both the
baked-env display and the diff.

`restart_diff` is omitted from the wire when empty
(`skip_serializing_if`).

## TypeScript / UI layer

New `restartDiff.ts` module defines `RestartDiffEntry`, `RestartChange`,
`JsonValue`; `tauri.ts` and `types.ts` re-export and add `restart_diff`
/ `restartDiff` fields (Rust omission → `restartDiff: []`).

**`RestartDiffBadge`** — hover tooltip capped at 6 entries + "and N
more", `asChild` span trigger (never inside a `<button>`), auto-restart
blurb below the diff list (on/off variant from `autoRestartEnabled`
prop; same `AUTO_RESTART_ON_BLURB` / `AUTO_RESTART_OFF_BLURB` constants
shared with the Runtime-tab banner). **`RestartDiffList`** renders the
full uncapped list for the Runtime-tab banner with `tooltip`/`inline`
presentation variants for correct foreground in both surfaces.

**`ManagedAgentRow` B4 fix** — badge moved to a sibling `div` of the row
expansion button; tooltip trigger has no `button` ancestor.

**`UnifiedAgentsSection`** — both badge sites render
`<RestartDiffBadge>` instead of a raw `<Badge>`, with
`autoRestartEnabled` threaded from `agent.autoRestartOnConfigChange`.

**Side-panel fix** — `RestartDiffBadge` rendered tab-independently in
the `ProfileSummaryView` hero area (was Runtime-tab only — root cause of
the ~50% inconsistency Will reported). Hero badge is `self-center` in
the flex column. `ProfileRuntimeTabContent` early-return checks
`needsRestart` so the banner is never dropped when all other content is
empty. Auto-restart blurb in the Runtime-tab banner uses the shared
constants.

## Wire shape

```jsonc
"restart_diff": [
  { "field": "model",              "change": { "kind": "value",  "before": "gpt-5", "after": "claude-4" } },
  { "field": "system_prompt",      "change": { "kind": "text",   "before_chars": 1234, "after_chars": 1410 } },
  { "field": "env.OPENAI_API_KEY", "change": { "kind": "masked", "before": "••••bc12", "after": "••••xyz9" } },
  { "field": "env.BUZZ_AGENT_THINKING_EFFORT", "change": { "kind": "value", "before": "medium", "after": "high" } }
]
```

`added`/`removed` occur only for dynamic-map keys; nullable struct
fields always serialize as `null`; arrays are atomic leaves (`args`,
never `args.0`).

## Tests

**Rust** — 1902 passing: snapshot mutation coverage, diff entry
serialization, allowlist-aware env masking
(`allowlisted_env_key_shows_plain_value`,
`allowlisted_env_key_is_case_insensitive`,
`non_allowlisted_env_key_stays_masked`),
`unstamped_agent_yields_no_badge_and_no_entries` (both orphan values),
`summary_without_drift_omits_restart_diff_from_the_wire`,
`unstamped_availability_is_not_drift`. Clippy clean, fmt clean.

**TypeScript** — `needs-restart-screenshots.spec.ts`: 11 E2E cases
registered in the smoke project — all three badge sites, tooltip +
keyboard focus, DOM no-button-ancestor assertion, 6+1 truncation,
uncapped Runtime list, unknown field humanisation, side-panel badge on
default Info tab, inactive/friendly-error Runtime opening path.

Consolidates [#3652](https://github.com/block/buzz/pull/3652)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-04 12:21:59 -04:00

141 lines
5.2 KiB
Rust

//! Phase 1A (unified agent model): boot-time materialization of each
//! persona-linked agent record's `runtime` onto the record itself.
//!
//! Child module of `migration` so it reuses the parent's private JSON-patch
//! helpers (`patch_json_records`, `load_persona_runtimes`,
//! `canonical_dev_data_dir`).
use std::path::Path;
use tauri::Manager as _;
use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records};
/// Materialize each persona-linked agent record's `runtime` from its linked
/// persona (unified agent model, Phase 1A). After this, spawn resolution reads
/// the record's own runtime (`record_agent_command` step 2) instead of the
/// live persona — same effective command by construction, so the spawn-config
/// snapshot is unchanged and no running agent shows a spurious restart badge
/// (see `spawn_snapshot::tests::materializing_runtime_keeps_snapshot_stable`).
///
/// Idempotent: records that already carry `runtime` are untouched, as are
/// records with no linked persona or a persona without a runtime (both keep
/// resolving through the legacy fallback path unchanged).
pub fn materialize_agent_runtimes(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let mut dirs = vec![current_dir.clone()];
if let Some(canonical) = canonical_dev_data_dir(&current_dir) {
if canonical.exists() && canonical != current_dir {
dirs.push(canonical);
}
}
for dir in dirs {
let path = dir.join("agents/managed-agents.json");
if path.exists() {
materialize_runtimes_in_file(&path);
}
}
}
fn materialize_runtimes_in_file(path: &Path) {
let persona_runtimes = load_persona_runtimes(path);
if persona_runtimes.is_empty() {
return;
}
patch_json_records(path, |obj| {
if obj.contains_key("runtime") {
return false;
}
let Some(runtime) = obj
.get("persona_id")
.and_then(|v| v.as_str())
.and_then(|pid| persona_runtimes.get(pid))
else {
return false;
};
obj.insert(
"runtime".to_string(),
serde_json::Value::String(runtime.clone()),
);
true
});
}
#[cfg(test)]
mod tests {
use super::materialize_runtimes_in_file;
use crate::migration::test_support::{
read_agents_json, write_agents_json, write_personas_json,
};
#[test]
fn materialize_runtimes_copies_persona_runtime_onto_record() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]),
);
write_agents_json(
dir.path(),
&serde_json::json!([{ "name": "Fizz", "persona_id": "persona-1" }]),
);
materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json"));
let records = read_agents_json(dir.path());
assert_eq!(records[0]["runtime"], "goose");
}
#[test]
fn materialize_runtimes_is_idempotent_and_preserves_existing() {
// A record that already carries runtime (even one diverging from its
// persona) is never overwritten, and the second run rewrites nothing.
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
{ "name": "Fizz", "persona_id": "persona-1", "runtime": "claude" },
{ "name": "Buzz", "persona_id": "persona-1" }
]),
);
let agents_path = dir.path().join("agents/managed-agents.json");
materialize_runtimes_in_file(&agents_path);
let records = read_agents_json(dir.path());
assert_eq!(
records[0]["runtime"], "claude",
"existing runtime preserved"
);
assert_eq!(records[1]["runtime"], "goose");
let before = std::fs::read_to_string(&agents_path).unwrap();
materialize_runtimes_in_file(&agents_path);
let after = std::fs::read_to_string(&agents_path).unwrap();
assert_eq!(before, after, "second run must be a no-op");
}
#[test]
fn materialize_runtimes_skips_unlinked_and_unknown_personas() {
let dir = tempfile::tempdir().unwrap();
write_personas_json(
dir.path(),
&serde_json::json!([{ "id": "persona-1", "displayName": "Alice", "runtime": "goose" }]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
{ "name": "NoPersona" },
{ "name": "GonePersona", "persona_id": "deleted" }
]),
);
let agents_path = dir.path().join("agents/managed-agents.json");
let before = std::fs::read_to_string(&agents_path).unwrap();
materialize_runtimes_in_file(&agents_path);
let after = std::fs::read_to_string(&agents_path).unwrap();
assert_eq!(before, after, "no linked runtime → untouched file");
}
}