feat(cli): pic projects ls + §7 M3/track-complete docs

§7 M3 (part 3) — the ownership track becomes inspectable, completing M1–M3.

- server: GET /api/v1/admin/projects (projects_api) lists every project + its
  owned-group count (projects repo list_with_counts, one GROUP BY); behind the
  /admin middleware, like the groups list.
- CLI: pic projects ls (cmds/projects.rs + client projects_list); the
  apply_ownership plan-preview journey now also asserts projects ls (plat owns
  exactly 1 group; teamb listed).
- docs: design §7 + CLAUDE.md record M3 shipped and the whole §7 multi-repo
  ownership track (M1 claim · M2 attach ceiling · M3 preview + projects ls)
  COMPLETE; deferred = structural-divergence + declarative tree shape + per-env
  approval gating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:26:10 +02:00
parent 30fe160f16
commit f673922d89
11 changed files with 244 additions and 29 deletions

File diff suppressed because one or more lines are too long

View File

@@ -80,6 +80,7 @@ pub mod outbox_event_emitter;
pub mod outbox_repo; pub mod outbox_repo;
pub mod principal_resolver; pub mod principal_resolver;
pub mod project_repo; pub mod project_repo;
pub mod projects_api;
pub mod pubsub_repo; pub mod pubsub_repo;
pub mod pubsub_service; pub mod pubsub_service;
pub mod queue_repo; pub mod queue_repo;
@@ -225,6 +226,7 @@ pub use outbox_repo::{
}; };
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError}; pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
pub use project_repo::{PostgresProjectRepository, ProjectRepository, ProjectRepositoryError}; pub use project_repo::{PostgresProjectRepository, ProjectRepository, ProjectRepositoryError};
pub use projects_api::{projects_router, ProjectsApiError, ProjectsState};
pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError}; pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig}; pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig};
pub use realtime_authority::RealtimeAuthorityImpl; pub use realtime_authority::RealtimeAuthorityImpl;

View File

@@ -21,6 +21,9 @@ pub enum ProjectRepositoryError {
pub trait ProjectRepository: Send + Sync { pub trait ProjectRepository: Send + Sync {
/// Every project on the instance, ordered by slug. /// Every project on the instance, ordered by slug.
async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError>; async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError>;
/// Every project paired with how many group nodes it owns — backs
/// `pic projects ls`. One GROUP BY instead of N per-project counts.
async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError>;
async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError>; async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError>; async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError>;
} }
@@ -40,6 +43,21 @@ const PROJECT_COLS: &str = "id, slug, name, created_by, created_at";
#[async_trait] #[async_trait]
impl ProjectRepository for PostgresProjectRepository { impl ProjectRepository for PostgresProjectRepository {
async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError> {
let rows = sqlx::query_as::<_, ProjectCountRow>(
"SELECT p.id, p.slug, p.name, p.created_by, p.created_at, \
COUNT(g.id) AS owned_groups \
FROM projects p LEFT JOIN groups g ON g.owner_project = p.id \
GROUP BY p.id ORDER BY p.slug",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.project.into(), r.owned_groups))
.collect())
}
async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError> { async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError> {
let rows = sqlx::query_as::<_, ProjectRow>(&format!( let rows = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects ORDER BY slug" "SELECT {PROJECT_COLS} FROM projects ORDER BY slug"
@@ -79,6 +97,14 @@ struct ProjectRow {
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
/// `list_with_counts` row: a project flattened with its owned-group count.
#[derive(sqlx::FromRow)]
struct ProjectCountRow {
#[sqlx(flatten)]
project: ProjectRow,
owned_groups: i64,
}
impl From<ProjectRow> for Project { impl From<ProjectRow> for Project {
fn from(r: ProjectRow) -> Self { fn from(r: ProjectRow) -> Self {
Self { Self {

View File

@@ -0,0 +1,81 @@
//! §7 M3 read-only projects surface: `GET /api/v1/admin/projects` — every
//! project (repo-root) with the count of group nodes it owns. Backs
//! `pic projects ls`. Behind the `/admin` middleware, so any authenticated
//! admin can list them (a project reveals only org structure, like the groups
//! list); ownership WRITES stay in the apply path.
use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::Principal;
use serde::Serialize;
use serde_json::json;
use crate::project_repo::{ProjectRepository, ProjectRepositoryError};
#[derive(Clone)]
pub struct ProjectsState {
pub projects: Arc<dyn ProjectRepository>,
}
/// Mounted under `/api/v1/admin`.
pub fn projects_router(state: ProjectsState) -> Router {
Router::new()
.route("/projects", get(list_projects))
.with_state(state)
}
#[derive(Serialize)]
struct ProjectListItem {
slug: String,
name: String,
owned_groups: i64,
created_at: DateTime<Utc>,
}
async fn list_projects(
State(s): State<ProjectsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<ProjectListItem>>, ProjectsApiError> {
let items = s
.projects
.list_with_counts()
.await?
.into_iter()
.map(|(p, owned_groups)| ProjectListItem {
slug: p.slug,
name: p.name,
owned_groups,
created_at: p.created_at,
})
.collect();
Ok(Json(items))
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectsApiError {
#[error("backend: {0}")]
Backend(String),
}
impl From<ProjectRepositoryError> for ProjectsApiError {
fn from(e: ProjectRepositoryError) -> Self {
Self::Backend(e.to_string())
}
}
impl IntoResponse for ProjectsApiError {
fn into_response(self) -> Response {
tracing::error!(error = %self, "projects api error");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal error" })),
)
.into_response()
}
}

View File

@@ -174,6 +174,16 @@ impl Client {
decode(resp).await decode(resp).await
} }
/// `GET /api/v1/admin/projects` — every §7 project + its owned-group count.
/// Backs `pic projects ls`.
pub async fn projects_list(&self) -> Result<Vec<ProjectDto>> {
let resp = self
.request(Method::GET, "/api/v1/admin/projects")
.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);
@@ -1708,6 +1718,17 @@ pub struct GroupListItem {
pub owner: Option<String>, pub owner: Option<String>,
} }
/// One row of `pic projects ls` (§7 M3): a project + how many group nodes it
/// owns.
#[derive(Debug, Deserialize)]
pub struct ProjectDto {
pub slug: String,
pub name: String,
#[serde(default)]
pub owned_groups: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CreateRouteBody<'a> { pub struct CreateRouteBody<'a> {
pub host_kind: HostKind, pub host_kind: HostKind,

View File

@@ -16,6 +16,7 @@ pub mod logout;
pub mod logs; pub mod logs;
pub mod members; pub mod members;
pub mod plan; pub mod plan;
pub mod projects;
pub mod pull; pub mod pull;
pub mod queues; pub mod queues;
pub mod routes; pub mod routes;

View File

@@ -0,0 +1,29 @@
//! `pic projects` — read-only view of §7 multi-repo ownership projects.
//!
//! A project is the repo-root that declaratively owns a slice of the group
//! tree. Ownership is CLAIMED by `pic apply` (first apply with a `[project]`
//! slug registers it); this command only lists what exists.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
/// `pic projects ls` — every project + how many group nodes it owns.
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let projects = client.projects_list().await?;
let mut table = Table::new(["slug", "name", "owned_groups", "created_at"]);
for p in &projects {
table.row([
p.slug.clone(),
p.name.clone(),
p.owned_groups.to_string(),
p.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}

View File

@@ -58,6 +58,12 @@ enum Cmd {
cmd: GroupsCmd, cmd: GroupsCmd,
}, },
/// §7 multi-repo ownership projects (read-only).
Projects {
#[command(subcommand)]
cmd: ProjectsCmd,
},
/// Script management. /// Script management.
Scripts { Scripts {
#[command(subcommand)] #[command(subcommand)]
@@ -490,6 +496,12 @@ enum AppsCmd {
}, },
} }
#[derive(Subcommand)]
enum ProjectsCmd {
/// List all projects + how many group nodes each owns.
Ls,
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum GroupsCmd { enum GroupsCmd {
/// List all groups (flat). /// List all groups (flat).
@@ -1509,6 +1521,9 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id }, cmd: DomainsCmd::Rm { app, domain_id },
}, },
} => cmds::apps_domains::rm(&app, &domain_id).await, } => cmds::apps_domains::rm(&app, &domain_id).await,
Cmd::Projects {
cmd: ProjectsCmd::Ls,
} => cmds::projects::ls(mode).await,
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await, Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
Cmd::Groups { Cmd::Groups {
cmd: GroupsCmd::Tree, cmd: GroupsCmd::Tree,

View File

@@ -354,6 +354,31 @@ fn plan_previews_ownership_and_blast_radius() {
claim(&plat, &team_a); claim(&plat, &team_a);
claim(&teamb, &team_b); claim(&teamb, &team_b);
// `pic projects ls` lists both registered projects with their owned-group
// counts (plat owns exactly team_a).
let projects = String::from_utf8(
common::pic_as(&env)
.args(["projects", "ls"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let plat_row = projects
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&plat.as_str()))
.unwrap_or_else(|| panic!("plat not in projects ls:\n{projects}"));
assert_eq!(
plat_row.get(2).copied(),
Some("1"),
"plat owns exactly one group:\n{projects}"
);
assert!(
projects.contains(teamb.as_str()),
"teamb must be listed:\n{projects}"
);
// Plan acme with project `platform`: acme is unclaimed → action `claim`; the // Plan acme with project `platform`: acme is unclaimed → action `claim`; the
// blast radius lists the OTHER projects' descendant apps (plat + teamb). // blast radius lists the OTHER projects' descendant apps (plat + teamb).
fs::write( fs::write(

View File

@@ -13,31 +13,31 @@ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router, apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router,
email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations, email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations,
rebuild_route_table, require_authenticated, route_admin_router, secrets_router, topics_router, projects_router, rebuild_route_table, require_authenticated, route_admin_router,
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState,
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl,
FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo,
GroupKvServiceImpl, GroupMembersRepository, GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, GroupMembersRepository,
GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, GroupsState, HttpConfig,
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresGroupCollectionResolver, PostgresGroupDocsRepo, PostgresGroupKvRepo, PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo,
PostgresGroupMembersRepository, PostgresGroupQueueRepo, PostgresGroupRepository, PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupQueueRepo,
PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository, PostgresPubsubRepo, PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository,
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl, PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, ProjectRepository,
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, SandboxCeiling, ScriptRepository, ProjectsState, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState,
TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
VarsServiceImpl, UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
}; };
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -117,6 +117,8 @@ pub async fn build_app(
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone())); let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let groups_repo: Arc<dyn GroupRepository> = let groups_repo: Arc<dyn GroupRepository> =
Arc::new(PostgresGroupRepository::new(pool.clone())); Arc::new(PostgresGroupRepository::new(pool.clone()));
let projects_repo: Arc<dyn ProjectRepository> =
Arc::new(PostgresProjectRepository::new(pool.clone()));
let group_members_repo: Arc<dyn GroupMembersRepository> = let group_members_repo: Arc<dyn GroupMembersRepository> =
Arc::new(PostgresGroupMembersRepository::new(pool.clone())); Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> = let domains_repo: Arc<dyn AppDomainRepository> =
@@ -538,7 +540,7 @@ pub async fn build_app(
vars: Arc::new(PostgresVarsRepo::new(pool.clone())), vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
apps: apps_repo.clone(), apps: apps_repo.clone(),
groups: groups_repo.clone(), groups: groups_repo.clone(),
projects: Arc::new(PostgresProjectRepository::new(pool.clone())), projects: projects_repo.clone(),
modules: modules.clone(), modules: modules.clone(),
domains: domains_repo.clone(), domains: domains_repo.clone(),
authz: authz.clone(), authz: authz.clone(),
@@ -696,6 +698,9 @@ pub async fn build_app(
.merge(api_keys_router(api_keys_state)) .merge(api_keys_router(api_keys_state))
.merge(triggers_router(triggers_state)) .merge(triggers_router(triggers_state))
.merge(apply_router(apply_service)) .merge(apply_router(apply_service))
.merge(projects_router(ProjectsState {
projects: projects_repo.clone(),
}))
.merge(picloud_manager_core::queues_api::queues_router( .merge(picloud_manager_core::queues_api::queues_router(
picloud_manager_core::queues_api::QueuesState { picloud_manager_core::queues_api::QueuesState {
queues: queue_repo.clone(), queues: queue_repo.clone(),

View File

@@ -783,8 +783,18 @@ within that subtree. `check_within_attach` requires the attach group be a **prop
(so you can't apply the attach point itself, only its descendants) or an ancestor (inclusive) of an app node's (so you can't apply the attach point itself, only its descendants) or an ancestor (inclusive) of an app node's
group — resolved via `groups.ancestors`, enforced read-only before the claim in both `apply_owner` and group — resolved via `groups.ancestors`, enforced read-only before the claim in both `apply_owner` and
`apply_tree`. Absent = instance root = no ceiling (default). Pinned by the `apply_ownership` journey's `apply_tree`. Absent = instance root = no ceiling (default). Pinned by the `apply_ownership` journey's
attach-ceiling case. **Deferred:** the plan-time cross-repo blast-radius preview + `pic projects ls` (M3); the attach-ceiling case.
**structural-divergence** detection of §6; declarative group create/reparent (lifting "groups pre-exist").
**Status — M3 shipped (plan preview + `pic projects ls`).** `pic plan` now carries the declaring `[project]`
and returns an `ownership` preview per node — `claim` / `owned` / `conflict` (owner named) / `unclaimed`
(pure `preview_ownership`, unit-tested) — plus, for a group node, the cross-repo **blast radius**: descendant
apps owned by OTHER projects the change fans out to (`group_blast_radius`, a subtree CTE with per-group
memoized nearest-claimed resolution). The attach ceiling is previewed at plan too (consistent with apply).
Read-only `pic projects ls` lists every project + its owned-group count (`GET /api/v1/admin/projects`,
`list_with_counts`). Pinned by the `apply_ownership` journey's plan-preview case. **With M3, the multi-repo
ownership track (§7 M1M3) is complete. Deferred (separate later work):** §6 structural-divergence detection
(`--adopt-server-structure` / `--force-local-structure`); declarative group create/reparent (lifting "groups
pre-exist"); per-env approval gating (`[project.environments]`).
--- ---