feat(apply): §6 M2 — structural-divergence detection + declarative reparent
With the declared parent on the wire (M1), `apply_tree` now compares each EXISTING group node's server parent to the manifest's (directory-nesting) parent and resolves a divergence per a request `StructureMode` (default `Refuse` — a pre-M2 CLI never reshapes the tree): - `Refuse` → `ApplyError::StructuralDivergence` (422), naming the flags. - `ForceLocal` → reparent to the declared parent IN the apply tx via the extracted `group_repo::reparent_group_tx` (cycle guard + structure_version bump), §5.6-gated (`GroupAdmin` on node + source + destination) and attach-ceilinged. - `AdoptServer` → keep the server shape, reconcile content in place. M1's `create_missing_groups_tx` generalized to `reconcile_group_structure_tx` (create + reparent share the coarse-lock / attach-ceiling / RBAC path; both are recorded `structurally_changed` so the pool-based attach re-check skips their uncommitted rows). `pic plan` previews the drift (`divergence_preview` → a `structure` row naming server + manifest parents). CLI: `--force-local-structure` / `--adopt-server-structure` (mutually exclusive) → the `structure_mode` wire field; the 422 surfaces verbatim. A concurrent `pic groups reparent` between plan and apply still trips `StateMoved` (the tree token already folds structure_version). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1321,6 +1321,7 @@ impl Client {
|
||||
|
||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
||||
/// transaction (Phase 5).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
@@ -1328,6 +1329,7 @@ impl Client {
|
||||
project: Option<&crate::manifest::ManifestProject>,
|
||||
takeover: bool,
|
||||
expected_token: Option<&str>,
|
||||
structure_mode: &str,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
@@ -1335,14 +1337,20 @@ impl Client {
|
||||
"expected_token": expected_token,
|
||||
"project": project,
|
||||
"takeover": takeover,
|
||||
"structure_mode": structure_mode,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
// 409 = stale bound plan OR a §7 ownership conflict; surface verbatim.
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
// 409 = stale bound plan OR a §7 ownership conflict; 422 = a §6 M2
|
||||
// structural divergence (or an invalid bundle). Surface the server
|
||||
// message verbatim — it names the resolution flags.
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::CONFLICT
|
||||
|| status == reqwest::StatusCode::UNPROCESSABLE_ENTITY
|
||||
{
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!("{msg}"));
|
||||
@@ -1627,6 +1635,19 @@ pub struct NodePlanDto {
|
||||
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
||||
#[serde(default)]
|
||||
pub ownership: Option<OwnershipPreviewDto>,
|
||||
/// §6 M2: for a group node, its structural state (in-sync omitted; diverged
|
||||
/// names the server + manifest parents).
|
||||
#[serde(default)]
|
||||
pub structure: Option<StructurePreviewDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StructurePreviewDto {
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub server_parent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub manifest_parent: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
|
||||
@@ -124,12 +124,14 @@ pub async fn run(
|
||||
|
||||
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
||||
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_tree(
|
||||
dir: &Path,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
takeover: bool,
|
||||
structure_mode: &str,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
@@ -157,6 +159,7 @@ pub async fn run_tree(
|
||||
project.as_ref(),
|
||||
takeover,
|
||||
expected_token.as_deref(),
|
||||
structure_mode,
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(dir, token_key);
|
||||
|
||||
@@ -66,6 +66,21 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
if let Some(s) = &n.structure {
|
||||
table.row([
|
||||
node.clone(),
|
||||
"structure".to_string(),
|
||||
s.status.clone(),
|
||||
format!(
|
||||
"server={}",
|
||||
s.server_parent.clone().unwrap_or_else(|| "<root>".into())
|
||||
),
|
||||
format!(
|
||||
"manifest={}",
|
||||
s.manifest_parent.clone().unwrap_or_else(|| "<root>".into())
|
||||
),
|
||||
]);
|
||||
}
|
||||
if let Some(o) = &n.ownership {
|
||||
table.row([
|
||||
node.clone(),
|
||||
|
||||
@@ -261,6 +261,14 @@ struct ApplyArgs {
|
||||
/// top of the base manifest before applying.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// §6 M2: reparent a group whose server parent diverges from the
|
||||
/// manifest's directory nesting (default: refuse on divergence).
|
||||
#[arg(long, conflicts_with = "adopt_server_structure")]
|
||||
force_local_structure: bool,
|
||||
/// §6 M2: accept the server's group placement on a structural
|
||||
/// divergence and reconcile content in place (no reparent).
|
||||
#[arg(long)]
|
||||
adopt_server_structure: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1433,12 +1441,20 @@ async fn main() -> ExitCode {
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => match &args.dir {
|
||||
Some(dir) => {
|
||||
let structure_mode = if args.force_local_structure {
|
||||
"force-local"
|
||||
} else if args.adopt_server_structure {
|
||||
"adopt-server"
|
||||
} else {
|
||||
"refuse"
|
||||
};
|
||||
cmds::apply::run_tree(
|
||||
dir,
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
args.takeover,
|
||||
structure_mode,
|
||||
args.env.as_deref(),
|
||||
mode,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user