feat(hierarchies): declarative group create/reparent — tree shape (M2)

M2 of the remaining-hierarchies work. `pic apply --dir` now owns the org-tree
SHAPE, not just node content: a `[group]` manifest for a group that doesn't
exist is CREATED under its declared parent, and an existing group whose declared
parent changed is REPARENTED — all inside the single tree-apply transaction
(create + reparent; structural prune is deferred to M3 with the ownership layer).

group_repo: extract transaction-aware structural mutations — `create_group_tx`,
`reparent_group_tx` (the ancestor-walk cycle guard, now reading through the tx so
it sees in-tx writes), `delete_group_tx`, and `acquire_structural_lock_tx`. The
trait `create`/`reparent`/`delete` delegate to them (one SQL definition,
behavior-preserving: the coarse structural advisory lock + structure_version
bump + delete=RESTRICT all preserved).

apply_service: `TreeNode` gains `parent_slug` + `name`. `prepare_tree` classifies
group nodes into existing (resolved id, maybe reparent) vs to-create (absent →
deferred), returning a `PreparedTree { prepared, creates, reparents, token }`.
`apply_tree` adds Phase 0 (`reconcile_tree_structure_tx`): create absent groups
parent-first (topo loop with cycle/unresolved-parent detection) and reparent
existing ones, then Phase A/B reconcile content as before. A to-create group
reconciles against an empty CurrentState (all-Create) — so it needs no DB read
and sidesteps the pool-vs-tx coupling. The bound-plan token folds a
declared-absent marker, so a group created out-of-band between plan and apply
trips StateMoved.

authz (apply_api `authz_tree`): a to-create group mirrors the interactive create
gate — root-level needs `InstanceCreateGroup`, a subgroup under an existing
parent needs only `GroupAdmin(parent)`; a reparent needs `GroupAdmin` at the
group, the SOURCE parent, and the DESTINATION parent (§5.6, parity with
`reparent_group`). No 404 on a to-create node.

CLI: `[group] parent` manifest key (else the parent is inferred from the nearest
ancestor directory's group); `build_tree` emits `parent_slug` + `name` per group
node; the apply report shows a `groups +N reparented M` line.

Reviewed by subagent (core mechanism verified sound: topo termination, empty-
CurrentState reconcile, in-tx cycle guard, atomicity, StateMoved, idempotency);
fixed the two authz-parity gaps it found (missing source-parent check on
reparent; over-gated subgroup create). Tests: a new `tree_shape` journey
(create + reparent + no-op re-plan); 393 manager-core lib + the Phase-5 tree/
group/ext journeys all green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-26 22:18:27 +02:00
parent dc8c69ac6f
commit c18ce7c2c4
9 changed files with 695 additions and 146 deletions

View File

@@ -1417,6 +1417,10 @@ pub struct ApplyReportDto {
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub groups_created: u32,
#[serde(default)]
pub groups_reparented: u32,
#[serde(default)]
pub warnings: Vec<String>,
}

View File

@@ -94,6 +94,16 @@ pub async fn run(
report.extension_points_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0 || report.groups_reparented > 0 {
block.field(
"groups",
format!(
"+{} reparented {}",
report.groups_created, report.groups_reparented
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}
@@ -170,6 +180,16 @@ pub async fn run_tree(
report.extension_points_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0 || report.groups_reparented > 0 {
block.field(
"groups",
format!(
"+{} reparented {}",
report.groups_created, report.groups_reparented
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}

View File

@@ -4,7 +4,7 @@
//! the server resolves ancestry from its own group tree, so the on-disk
//! nesting is organizational, not authoritative here.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
@@ -56,20 +56,62 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
root.display()
);
}
let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
// so a group node's parent can be inferred from the enclosing directory.
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
if manifest.is_group() {
group_dirs.insert(dir.clone(), manifest.slug().to_string());
}
loaded.push((dir, manifest));
}
let mut nodes = Vec::with_capacity(loaded.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for (dir, manifest) in &loaded {
let bundle = build_bundle(manifest, dir)?;
let kind = if manifest.is_group() { "group" } else { "app" };
let slug = manifest.slug().to_string();
if !seen.insert((kind.to_string(), slug.clone())) {
bail!("project tree declares {kind} `{slug}` more than once");
}
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
// Group nodes carry their parent (explicit `[group] parent`, else the
// nearest ancestor directory's group) + display name, so the server can
// create/reparent them (M2). App nodes ignore both server-side.
if let Some(g) = &manifest.group {
let parent = g
.parent
.clone()
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
let obj = node.as_object_mut().expect("json object");
obj.insert("name".into(), json!(g.name));
if let Some(p) = parent {
obj.insert("parent_slug".into(), json!(p));
}
}
nodes.push(node);
}
let count = nodes.len();
Ok((json!({ "nodes": nodes }), count))
}
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
/// manifest — the directory-derived parent for a nested group node.
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
let mut cur = dir.parent();
while let Some(d) = cur {
if let Some(slug) = group_dirs.get(d) {
return Some(slug.clone());
}
cur = d.parent();
}
None
}

View File

@@ -209,16 +209,20 @@ pub struct ManifestApp {
pub description: Option<String>,
}
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
/// and `[vars]`. The group must already exist on the server (created with
/// `pic groups create`); the manifest reconciles its content, not the tree
/// shape. In a nested project the parent is inferred from the directory tree.
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
/// the parent is inferred from the enclosing directory's group manifest (or the
/// instance root for a top-level group).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestGroup {
pub slug: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]