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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user