feat(apply): project on the plan wire + ownership preview annotation

§7 M3 (part 1): `pic plan` now surfaces the ownership outcome BEFORE apply.

- plan / plan_owner / plan_tree take the declaring `[project]`; each result
  carries an `ownership` preview (pure `preview_ownership`, unit-tested):
  claim (unclaimed + project), owned (already this project), conflict (owned by
  X — named), or unclaimed. A group node previews its OWN owner; an app node its
  nearest claimed ancestor (read-only, on the pool).
- the attach-point ceiling (M2) is now previewed at plan too, so `pic plan`
  outside the subtree fails early — consistent with apply.
- wire: PlanRequest / TreePlanRequest wrap { bundle, project } (project
  `#[serde(default)]` → a pre-M3 CLI still plans); the plan DTOs + `pic plan`
  gain an `ownership` row (mode-agnostic: shows in tsv + json).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:09:55 +02:00
parent 5a820d7262
commit b4eb226d20
4 changed files with 241 additions and 21 deletions

View File

@@ -1220,14 +1220,16 @@ impl Client {
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
project: Option<&crate::manifest::ManifestProject>,
) -> Result<PlanDto> {
let slug = seg(slug);
let body = serde_json::json!({ "bundle": bundle, "project": project });
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
)
.json(bundle)
.json(&body)
.send()
.await?;
decode(resp).await
@@ -1274,10 +1276,15 @@ impl Client {
}
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
pub async fn plan_tree(
&self,
bundle: &serde_json::Value,
project: Option<&crate::manifest::ManifestProject>,
) -> Result<TreePlanDto> {
let body = serde_json::json!({ "bundle": bundle, "project": project });
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(bundle)
.json(&body)
.send()
.await?;
decode(resp).await
@@ -1527,6 +1534,9 @@ pub struct PlanDto {
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)]
pub state_token: String,
/// §7 M3: the ownership outcome this apply would produce.
#[serde(default)]
pub ownership: Option<OwnershipPreviewDto>,
}
#[derive(Debug, Deserialize)]
@@ -1537,6 +1547,15 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// §7 M3 plan preview: `action` ∈ claim/owned/conflict/unclaimed; `owner` is
/// the current owner's slug (for owned/conflict).
#[derive(Debug, Deserialize)]
pub struct OwnershipPreviewDto {
pub action: String,
#[serde(default)]
pub owner: Option<String>,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
/// bound-plan token for the whole subtree.
#[derive(Debug, Deserialize)]
@@ -1567,6 +1586,9 @@ pub struct NodePlanDto {
pub collections: Vec<ChangeDto>,
#[serde(default)]
pub suppressions: Vec<ChangeDto>,
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default)]
pub ownership: Option<OwnershipPreviewDto>,
}
/// Response of `POST .../apply`: counts of what changed.

View File

@@ -31,7 +31,9 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
NodeKind::App
};
let plan = client.plan_node(kind, manifest.slug(), &bundle).await?;
let plan = client
.plan_node(kind, manifest.slug(), &bundle, manifest.project.as_ref())
.await?;
// Record the bound-plan token so a subsequent `pic apply` can detect the
// node changing underneath the reviewed plan (best-effort — a read-only
// plan still succeeds if the project dir isn't writable).
@@ -49,8 +51,8 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, _count, _project) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle).await?;
let (bundle, _count, project) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle, project.as_ref()).await?;
if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
@@ -64,6 +66,15 @@ 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(o) = &n.ownership {
table.row([
node.clone(),
"ownership".to_string(),
o.action.clone(),
o.owner.clone().unwrap_or_default(),
String::new(),
]);
}
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &n.scripts),
("route", &n.routes),
@@ -185,6 +196,14 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]);
if let Some(o) = &plan.ownership {
table.row([
"ownership".to_string(),
o.action.clone(),
o.owner.clone().unwrap_or_default(),
String::new(),
]);
}
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &plan.scripts),
("route", &plan.routes),