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:
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user