diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 55373d2..d9d7c82 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -545,10 +545,24 @@ pub struct OwnershipPreview { pub action: String, #[serde(skip_serializing_if = "Option::is_none")] pub owner: Option, + /// §7 M3.2 cross-repo blast radius: for a GROUP node, the descendant apps + /// owned by OTHER projects that a config change here fans out to, grouped by + /// project. Empty for an app node or when nothing else is affected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blast_radius: Vec, +} + +/// One entry of a group plan's blast radius: another project and how many of +/// its apps inherit from the group being changed. +#[derive(Debug, Clone, Serialize)] +pub struct ProjectImpact { + pub project: String, + pub apps: u32, } /// Pure preview policy (read-only, slug-only — no registration): compares the -/// node's current owner slug against this apply's declared project slug. +/// node's current owner slug against this apply's declared project slug. The +/// blast radius (a DB fan-out) is filled in by the caller for group nodes. fn preview_ownership( is_group: bool, current: Option<&str>, @@ -557,6 +571,7 @@ fn preview_ownership( let mk = |action: &str, owner: Option<&str>| OwnershipPreview { action: action.to_string(), owner: owner.map(str::to_string), + blast_radius: Vec::new(), }; match (current, declared) { // A group node claims when unclaimed + a project is declared. @@ -2142,11 +2157,68 @@ impl ApplyService { (false, current) } }; - Ok(Some(preview_ownership( - is_group, - current.as_deref(), - declared, - ))) + let mut preview = preview_ownership(is_group, current.as_deref(), declared); + // §7 M3.2: for a group node, surface the cross-repo blast radius — the + // descendant apps owned by OTHER projects a config change here reaches. + if let ApplyOwner::Group(g) = owner { + preview.blast_radius = self.group_blast_radius(g, declared).await?; + } + Ok(Some(preview)) + } + + /// §7 M3.2: descendant apps of `gid` grouped by their nearest-claimed- + /// ancestor project, EXCLUDING `exclude` (the planning project) — the apps + /// a change to `gid`'s inherited config fans out to across other repos. + /// Ancestor resolution is memoized per group, so the cost is one walk per + /// distinct descendant group, not per app. + async fn group_blast_radius( + &self, + gid: GroupId, + exclude: Option<&str>, + ) -> Result, ApplyError> { + // All apps in gid's subtree (gid + descendant groups). + let rows: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as( + "WITH RECURSIVE subtree AS ( + SELECT id FROM groups WHERE id = $1 + UNION ALL + SELECT g.id FROM groups g JOIN subtree s ON g.parent_id = s.id + ) + SELECT a.id, a.group_id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)", + ) + .bind(gid.into_inner()) + .fetch_all(&self.pool) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + + let mut owner_by_group: HashMap> = HashMap::new(); + let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (_app, group_id) in rows { + let ag = GroupId::from(group_id); + let owner = if let Some(o) = owner_by_group.get(&ag) { + o.clone() + } else { + let chain = self + .groups + .ancestors(ag) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let o = self + .nearest_claimed_from_chain(&chain) + .await? + .map(|(_, s)| s); + owner_by_group.insert(ag, o.clone()); + o + }; + if let Some(slug) = owner { + if Some(slug.as_str()) != exclude { + *counts.entry(slug).or_default() += 1; + } + } + } + Ok(counts + .into_iter() + .map(|(project, apps)| ProjectImpact { project, apps }) + .collect()) } async fn resolve_app(&self, ident: &str) -> Result { diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index bf37094..fb98f6e 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1548,12 +1548,21 @@ pub struct ChangeDto { } /// §7 M3 plan preview: `action` ∈ claim/owned/conflict/unclaimed; `owner` is -/// the current owner's slug (for owned/conflict). +/// the current owner's slug (for owned/conflict); `blast_radius` (M3.2) lists +/// other projects' apps a group change fans out to. #[derive(Debug, Deserialize)] pub struct OwnershipPreviewDto { pub action: String, #[serde(default)] pub owner: Option, + #[serde(default)] + pub blast_radius: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ProjectImpactDto { + pub project: String, + pub apps: u32, } /// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 61791ed..9fffc84 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -74,6 +74,15 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { o.owner.clone().unwrap_or_default(), String::new(), ]); + for b in &o.blast_radius { + table.row([ + node.clone(), + "blast_radius".to_string(), + b.project.clone(), + format!("{} app(s)", b.apps), + String::new(), + ]); + } } let groups: [(&str, &Vec); 8] = [ ("script", &n.scripts), @@ -203,6 +212,14 @@ fn render(plan: &PlanDto, mode: OutputMode) { o.owner.clone().unwrap_or_default(), String::new(), ]); + for b in &o.blast_radius { + table.row([ + "blast_radius".to_string(), + b.project.clone(), + format!("{} app(s)", b.apps), + String::new(), + ]); + } } let groups: [(&str, &Vec); 8] = [ ("script", &plan.scripts), diff --git a/crates/picloud-cli/tests/apply_ownership.rs b/crates/picloud-cli/tests/apply_ownership.rs index 93da42a..7806ceb 100644 --- a/crates/picloud-cli/tests/apply_ownership.rs +++ b/crates/picloud-cli/tests/apply_ownership.rs @@ -291,3 +291,107 @@ fn attach_point_ceiling_bounds_the_subtree() { "a sibling subtree is outside the attach point" ); } + +/// §7 M3 — `pic plan` previews the ownership outcome (claim / conflict) and the +/// cross-repo blast radius (descendant apps owned by other projects) BEFORE any +/// apply. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn plan_previews_ownership_and_blast_radius() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + // acme (root) → team_a, team_b; an app under each, each team claimed by a + // DIFFERENT project. + let acme = common::unique_slug("br-acme"); + let team_a = common::unique_slug("br-a"); + let team_b = common::unique_slug("br-b"); + let app_a = common::unique_slug("br-app-a"); + let app_b = common::unique_slug("br-app-b"); + let plat = common::unique_slug("plat"); + let teamb = common::unique_slug("teamb"); + let platform = common::unique_slug("platform"); + let _g0 = GroupGuard::new(&env.url, &env.token, &acme); + let _g1 = GroupGuard::new(&env.url, &env.token, &team_a); + let _g2 = GroupGuard::new(&env.url, &env.token, &team_b); + let _a1 = AppGuard::new(&env.url, &env.token, &app_a); + let _a2 = AppGuard::new(&env.url, &env.token, &app_b); + for (slug, parent) in [ + (&acme, None), + (&team_a, Some(&acme)), + (&team_b, Some(&acme)), + ] { + let mut c = common::pic_as(&env); + c.args(["groups", "create", slug]); + if let Some(p) = parent { + c.args(["--parent", p]); + } + c.assert().success(); + } + common::pic_as(&env) + .args(["apps", "create", &app_a, "--group", &team_a]) + .assert() + .success(); + common::pic_as(&env) + .args(["apps", "create", &app_b, "--group", &team_b]) + .assert() + .success(); + + // Claim team_a by `plat`, team_b by `teamb` (empty group applies). + let dir = manifest_dir(); + let claim = |project: &str, group: &str| { + let m = format!( + "[project]\nslug = \"{project}\"\n\n[group]\nslug = \"{group}\"\nname = \"G\"\n" + ); + fs::write(dir.path().join("picloud.toml"), m).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(dir.path().join("picloud.toml")) + .assert() + .success(); + }; + claim(&plat, &team_a); + claim(&teamb, &team_b); + + // Plan acme with project `platform`: acme is unclaimed → action `claim`; the + // blast radius lists the OTHER projects' descendant apps (plat + teamb). + fs::write( + dir.path().join("picloud.toml"), + format!( + "[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{acme}\"\nname = \"Acme\"\n" + ), + ) + .unwrap(); + let out = common::pic_as(&env) + .args(["plan", "--file"]) + .arg(dir.path().join("picloud.toml")) + .output() + .expect("plan acme"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("claim"), + "acme is unclaimed → the preview must show `claim`:\n{stdout}" + ); + assert!( + stdout.contains("blast_radius") && stdout.contains(&plat) && stdout.contains(&teamb), + "the blast radius must list the other projects' apps (plat + teamb):\n{stdout}" + ); + + // Plan team_a with `platform`: it is owned by `plat` → action `conflict`. + fs::write( + dir.path().join("picloud.toml"), + format!("[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{team_a}\"\nname = \"A\"\n"), + ) + .unwrap(); + let out = common::pic_as(&env) + .args(["plan", "--file"]) + .arg(dir.path().join("picloud.toml")) + .output() + .expect("plan team_a"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("conflict") && stdout.contains(&plat), + "planning a foreign-owned group must preview a conflict naming the owner:\n{stdout}" + ); +}