feat(cli): [project] manifest block, --takeover, groups ls owner column

Makes §7 ownership usable end to end from the CLI.

- manifest: a `[project]` block (slug + optional name), independent of the
  [app]/[group] XOR; threaded onto every apply from the repo. --dir reads it
  from the tree ROOT's picloud.toml (a [project] elsewhere is ignored with a
  note).
- client: apply_node/apply_tree carry project + takeover; the 409 branch now
  surfaces the server message verbatim (covers StateMoved AND OwnershipConflict,
  both self-contained). New groups_list_with_owner captures the owner slug.
- pic apply --takeover flag; pic groups ls gains an `owner` column (the owning
  project's slug, or — when unclaimed).
- server: /api/v1/admin/groups now returns each group flattened with its owner
  slug (via list_with_owner) — the shared Group deserialize ignores the extra
  field, so pic groups tree / the dashboard are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 20:46:09 +02:00
parent 47072c481d
commit b33c87e5c4
10 changed files with 175 additions and 31 deletions

View File

@@ -158,11 +158,28 @@ pub struct PatchMemberRequest {
/// any authenticated admin sees the full tree; per-action authz still /// any authenticated admin sees the full tree; per-action authz still
/// gates every mutation and all app access. Tighten in Phase 3 when groups /// gates every mutation and all app access. Tighten in Phase 3 when groups
/// carry inheritable config. /// carry inheritable config.
/// §7: a group plus its owning project's slug (`None` = unclaimed / UI-owned).
/// The group fields are flattened, so existing consumers (dashboard, `pic
/// groups tree`) keep working; `owner` is a new, optional field.
#[derive(Serialize)]
struct GroupListItem {
#[serde(flatten)]
group: Group,
owner: Option<String>,
}
async fn list_groups( async fn list_groups(
State(s): State<GroupsState>, State(s): State<GroupsState>,
Extension(_principal): Extension<Principal>, Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<Group>>, GroupsApiError> { ) -> Result<Json<Vec<GroupListItem>>, GroupsApiError> {
Ok(Json(s.groups.list().await?)) let items = s
.groups
.list_with_owner()
.await?
.into_iter()
.map(|(group, owner)| GroupListItem { group, owner })
.collect();
Ok(Json(items))
} }
async fn create_group( async fn create_group(

View File

@@ -153,7 +153,8 @@ impl Client {
// --- Groups (Phase 2) ------------------------------------------------- // --- Groups (Phase 2) -------------------------------------------------
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree /// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
/// client-side from `parent_id`). /// client-side from `parent_id`). Ignores the §7 `owner` field the server
/// also returns (see [`Self::groups_list_with_owner`]).
pub async fn groups_list(&self) -> Result<Vec<Group>> { pub async fn groups_list(&self) -> Result<Vec<Group>> {
let resp = self let resp = self
.request(Method::GET, "/api/v1/admin/groups") .request(Method::GET, "/api/v1/admin/groups")
@@ -162,6 +163,17 @@ impl Client {
decode(resp).await decode(resp).await
} }
/// Same endpoint as [`Self::groups_list`], but captures the §7 owning
/// project's slug alongside each group — backs `pic groups ls`' owner
/// column.
pub async fn groups_list_with_owner(&self) -> Result<Vec<GroupListItem>> {
let resp = self
.request(Method::GET, "/api/v1/admin/groups")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children. /// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> { pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
let ident = seg(ident); let ident = seg(ident);
@@ -1223,12 +1235,15 @@ impl Client {
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an /// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
/// app OR group node in one transaction (Phase 5). /// app OR group node in one transaction (Phase 5).
#[allow(clippy::too_many_arguments)]
pub async fn apply_node( pub async fn apply_node(
&self, &self,
kind: NodeKind, kind: NodeKind,
slug: &str, slug: &str,
bundle: &serde_json::Value, bundle: &serde_json::Value,
prune: bool, prune: bool,
project: Option<&crate::manifest::ManifestProject>,
takeover: bool,
expected_token: Option<&str>, expected_token: Option<&str>,
) -> Result<ApplyReportDto> { ) -> Result<ApplyReportDto> {
let slug = seg(slug); let slug = seg(slug);
@@ -1236,6 +1251,8 @@ impl Client {
"bundle": bundle, "bundle": bundle,
"prune": prune, "prune": prune,
"expected_token": expected_token, "expected_token": expected_token,
"project": project,
"takeover": takeover,
}); });
let resp = self let resp = self
.request( .request(
@@ -1245,18 +1262,13 @@ impl Client {
.json(&body) .json(&body)
.send() .send()
.await?; .await?;
// The apply endpoint returns 409 only for a stale bound plan // The apply endpoint returns 409 for a stale bound plan (`StateMoved`)
// (`StateMoved`): the app changed since `pic plan` recorded its // OR a §7 ownership conflict. Both server messages are self-contained
// token. Surface an actionable next step instead of a bare // and actionable, so surface the message verbatim.
// `HTTP 409`.
if resp.status() == reqwest::StatusCode::CONFLICT { if resp.status() == reqwest::StatusCode::CONFLICT {
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body); let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!( return Err(anyhow!("{msg}"));
"{msg}\nThe live state changed since your last `pic plan`. Re-run \
`pic plan` to review the new diff, then `pic apply` — or \
`pic apply --force` to apply without re-reviewing."
));
} }
decode(resp).await decode(resp).await
} }
@@ -1277,25 +1289,27 @@ impl Client {
&self, &self,
bundle: &serde_json::Value, bundle: &serde_json::Value,
prune: bool, prune: bool,
project: Option<&crate::manifest::ManifestProject>,
takeover: bool,
expected_token: Option<&str>, expected_token: Option<&str>,
) -> Result<ApplyReportDto> { ) -> Result<ApplyReportDto> {
let body = serde_json::json!({ let body = serde_json::json!({
"bundle": bundle, "bundle": bundle,
"prune": prune, "prune": prune,
"expected_token": expected_token, "expected_token": expected_token,
"project": project,
"takeover": takeover,
}); });
let resp = self let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply") .request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body) .json(&body)
.send() .send()
.await?; .await?;
// 409 = stale bound plan OR a §7 ownership conflict; surface verbatim.
if resp.status() == reqwest::StatusCode::CONFLICT { if resp.status() == reqwest::StatusCode::CONFLICT {
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body); let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!( return Err(anyhow!("{msg}"));
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
));
} }
decode(resp).await decode(resp).await
} }
@@ -1652,6 +1666,17 @@ pub struct GroupDetailDto {
pub apps: Vec<App>, pub apps: Vec<App>,
} }
/// One row of `pic groups ls`: a group plus its §7 owning project's slug
/// (`None` = unclaimed / UI-owned). The group fields flatten in, so `.group.*`
/// reaches them.
#[derive(Debug, Deserialize)]
pub struct GroupListItem {
#[serde(flatten)]
pub group: Group,
#[serde(default)]
pub owner: Option<String>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CreateRouteBody<'a> { pub struct CreateRouteBody<'a> {
pub host_kind: HostKind, pub host_kind: HostKind,

View File

@@ -20,6 +20,7 @@ pub async fn run(
prune: bool, prune: bool,
yes: bool, yes: bool,
force: bool, force: bool,
takeover: bool,
mode: OutputMode, mode: OutputMode,
) -> Result<()> { ) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
@@ -52,7 +53,15 @@ pub async fn run(
}; };
let report = client let report = client
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref()) .apply_node(
kind,
&slug,
&bundle,
prune,
manifest.project.as_ref(),
takeover,
expected_token.as_deref(),
)
.await?; .await?;
crate::linkstate::clear_plan(base_dir, &slug); crate::linkstate::clear_plan(base_dir, &slug);
@@ -120,12 +129,13 @@ pub async fn run_tree(
prune: bool, prune: bool,
yes: bool, yes: bool,
force: bool, force: bool,
takeover: bool,
env: Option<&str>, env: Option<&str>,
mode: OutputMode, mode: OutputMode,
) -> Result<()> { ) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let (bundle, node_count) = crate::discover::build_tree(dir, env)?; let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?;
if prune && !yes { if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?; confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
@@ -141,7 +151,13 @@ pub async fn run_tree(
}; };
let report = client let report = client
.apply_tree(&bundle, prune, expected_token.as_deref()) .apply_tree(
&bundle,
prune,
project.as_ref(),
takeover,
expected_token.as_deref(),
)
.await?; .await?;
crate::linkstate::clear_plan(dir, token_key); crate::linkstate::clear_plan(dir, token_key);

View File

@@ -16,19 +16,24 @@ use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(mode: OutputMode) -> Result<()> { pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let groups = client.groups_list().await?; let groups = client.groups_list_with_owner().await?;
let mut table = Table::new(["slug", "name", "parent", "created_at"]); let mut table = Table::new(["slug", "name", "parent", "owner", "created_at"]);
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect(); let by_id: BTreeMap<_, _> = groups
.iter()
.map(|g| (g.group.id, g.group.slug.clone()))
.collect();
for g in &groups { for g in &groups {
let parent = g let parent = g
.group
.parent_id .parent_id
.and_then(|p| by_id.get(&p).cloned()) .and_then(|p| by_id.get(&p).cloned())
.unwrap_or_else(|| "-".into()); .unwrap_or_else(|| "-".into());
table.row([ table.row([
g.slug.clone(), g.group.slug.clone(),
g.name.clone(), g.group.name.clone(),
parent, parent,
g.created_at.to_rfc3339(), g.owner.clone().unwrap_or_else(|| "".into()),
g.group.created_at.to_rfc3339(),
]); ]);
} }
table.print(mode); table.print(mode);

View File

@@ -116,6 +116,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
extension_points: Vec::new(), extension_points: Vec::new(),
}), }),
group: None, group: None,
project: None,
scripts: vec![ManifestScript { scripts: vec![ManifestScript {
name: "hello".into(), name: "hello".into(),
file: "scripts/hello.rhai".into(), file: "scripts/hello.rhai".into(),

View File

@@ -49,7 +49,7 @@ 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<()> { pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let (bundle, _count) = crate::discover::build_tree(dir, env)?; let (bundle, _count, _project) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle).await?; let plan = client.plan_tree(&bundle).await?;
if !plan.state_token.is_empty() { if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) { if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {

View File

@@ -242,6 +242,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
extension_points, extension_points,
}), }),
group: None, group: None,
project: None,
scripts: manifest_scripts, scripts: manifest_scripts,
routes, routes,
triggers: manifest_triggers, triggers: manifest_triggers,

View File

@@ -11,7 +11,7 @@ use anyhow::{bail, Context, Result};
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::cmds::plan::build_bundle; use crate::cmds::plan::build_bundle;
use crate::manifest::{Manifest, MANIFEST_FILE}; use crate::manifest::{Manifest, ManifestProject, MANIFEST_FILE};
/// Find every `picloud.toml` under `root` (recursively), skipping the /// Find every `picloud.toml` under `root` (recursively), skipping the
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env /// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
@@ -46,9 +46,14 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
} }
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every /// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree /// manifest under `root`. Returns the JSON, the node count, and the owning
/// that names the same (kind, slug) twice. /// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> { /// project owns the whole tree; a `[project]` in any other node is ignored with
/// a note. Rejects a tree that names the same (kind, slug) twice.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Option<ManifestProject>)> {
let paths = find_manifests(root)?; let paths = find_manifests(root)?;
if paths.is_empty() { if paths.is_empty() {
bail!( bail!(
@@ -56,11 +61,24 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
root.display() root.display()
); );
} }
let root_manifest = root.join(MANIFEST_FILE);
let mut project: Option<ManifestProject> = None;
let mut nodes = Vec::with_capacity(paths.len()); let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new(); let mut seen: HashSet<(String, String)> = HashSet::new();
for path in &paths { for path in &paths {
let manifest = Manifest::load_with_env(path, env) let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?; .with_context(|| format!("loading {}", path.display()))?;
if let Some(p) = &manifest.project {
if path.as_path() == root_manifest {
project = Some(p.clone());
} else {
eprintln!(
"note: [project] in {} is ignored — declare it in the tree root {}",
path.display(),
root_manifest.display()
);
}
}
let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?; let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() { "group" } else { "app" }; let kind = if manifest.is_group() { "group" } else { "app" };
@@ -71,5 +89,5 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle })); nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
} }
let count = nodes.len(); let count = nodes.len();
Ok((json!({ "nodes": nodes }), count)) Ok((json!({ "nodes": nodes }), count, project))
} }

View File

@@ -246,6 +246,11 @@ struct ApplyArgs {
/// since the last `pic plan`). /// since the last `pic plan`).
#[arg(long)] #[arg(long)]
force: bool, force: bool,
/// §7 multi-repo ownership: reassign a group node owned by another
/// `[project]` to this manifest's project. Requires group-admin on the
/// node; without it an apply to an owned node is refused (409).
#[arg(long)]
takeover: bool,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on /// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before applying. /// top of the base manifest before applying.
#[arg(long)] #[arg(long)]
@@ -1421,6 +1426,7 @@ async fn main() -> ExitCode {
args.prune, args.prune,
args.yes, args.yes,
args.force, args.force,
args.takeover,
args.env.as_deref(), args.env.as_deref(),
mode, mode,
) )
@@ -1433,6 +1439,7 @@ async fn main() -> ExitCode {
args.prune, args.prune,
args.yes, args.yes,
args.force, args.force,
args.takeover,
mode, mode,
) )
.await .await

View File

@@ -37,6 +37,12 @@ pub struct Manifest {
pub app: Option<ManifestApp>, pub app: Option<ManifestApp>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<ManifestGroup>, pub group: Option<ManifestGroup>,
/// `[project]` (§7 multi-repo ownership) — the repo-root that OWNS the nodes
/// it applies. Independent of the app/group node kind; declared in the
/// repo's root manifest and threaded onto every apply from it. The first
/// apply with a new slug claims each group node it touches.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ManifestProject>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ManifestScript>, pub scripts: Vec<ManifestScript>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -263,6 +269,17 @@ pub struct ManifestGroup {
pub collections: Vec<CollectionDecl>, pub collections: Vec<CollectionDecl>,
} }
/// A `[project]` block (§7 multi-repo ownership): the identity of the repo-root
/// managing these nodes. The `slug` is committed (stable across clones); the
/// server assigns the UUID and the first apply registers it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestProject {
pub slug: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// The store kind of a shared collection (§11.6). /// The store kind of a shared collection (§11.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -596,6 +613,7 @@ mod tests {
extension_points: vec!["theme".into()], extension_points: vec!["theme".into()],
}), }),
group: None, group: None,
project: None,
scripts: vec![ scripts: vec![
ManifestScript { ManifestScript {
name: "create-post".into(), name: "create-post".into(),
@@ -759,6 +777,7 @@ mod tests {
extension_points: vec![], extension_points: vec![],
}), }),
group: None, group: None,
project: None,
scripts: vec![], scripts: vec![],
routes: vec![], routes: vec![],
triggers: ManifestTriggers::default(), triggers: ManifestTriggers::default(),
@@ -921,6 +940,41 @@ mod tests {
.expect_err("both [app] and [group]"); .expect_err("both [app] and [group]");
} }
#[test]
fn project_block_parses_independently_of_node_kind() {
// §7: [project] is orthogonal to the app/group XOR — either node kind
// may declare the owning project.
let a = Manifest::parse(
"[project]\nslug = \"platform\"\nname = \"Platform\"\n\n\
[app]\nslug = \"web\"\nname = \"Web\"\n",
)
.expect("[app] + [project] parses");
assert_eq!(a.project.as_ref().unwrap().slug, "platform");
assert_eq!(
a.project.as_ref().unwrap().name.as_deref(),
Some("Platform")
);
let g = Manifest::parse(
"[project]\nslug = \"platform\"\n\n[group]\nslug = \"acme\"\nname = \"ACME\"\n",
)
.expect("[group] + [project] parses");
assert_eq!(g.project.as_ref().unwrap().slug, "platform");
assert!(
g.project.as_ref().unwrap().name.is_none(),
"name is optional"
);
// Absent [project] → None (backward-compatible).
assert!(Manifest::parse("[app]\nslug=\"w\"\nname=\"W\"\n")
.unwrap()
.project
.is_none());
// Unknown key inside [project] is rejected (deny_unknown_fields).
Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n")
.expect_err("unknown [project] key rejected");
}
#[test] #[test]
fn overlay_vars_override_base_per_key() { fn overlay_vars_override_base_per_key() {
let mut base = sample(); let mut base = sample();