From 8f1ba39a8adbac87fa1300662f601d68900b9569 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 21:54:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(routes):=20author=20group=20route=20templa?= =?UTF-8?q?tes=20in=20manifest=20+=20reconcile=20(=C2=A711=20tail=20R4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open the declarative path so a [group] can declare route TEMPLATES. - manifest.rs: drop the blanket "[group] cannot declare [[routes]]" rejection (routes join the event trigger templates a group may own). - apply_service: - validate_bundle_for: remove the group routes rejection. A group route's `script` is validated as binding a group-owned ENDPOINT via `resolve_inherited_targets_for(Group)`, now extended to scan bundle.routes as well as bundle.triggers — so a template binding the group's own (declared or pre-existing) endpoint resolves, and one pointing at a module or a missing name is a 422. - insert_bundle_route takes a ScriptOwner (was app-only): a group node writes a group_id route TEMPLATE, an app node an app-owned route. - load_current(Group) loads the group's own routes via list_for_group so re-apply is a NoOp and the template is prune-able. Host-claim validation stays App-only (already guarded) — a template has no single app; a descendant serves it on whatever host it claims (so templates use host_kind = any in practice). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_service.rs | 49 ++++++++++++------------ crates/picloud-cli/src/manifest.rs | 12 ++---- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index ac852df..fcc825b 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -580,9 +580,9 @@ impl ApplyService { ) -> Result, ApplyError> { match owner { ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await, - // §11 tail: a group trigger TEMPLATE binds the group's OWN endpoint - // scripts (declared here or pre-existing). Surface the ones a - // template references but the bundle doesn't declare, so + // §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN + // endpoint scripts (declared here or pre-existing). Surface the ones + // a template references but the bundle doesn't declare, so // `validate_bundle` accepts them (the reconcile's name_to_id already // includes the group's current scripts). ApplyOwner::Group(group_id) => { @@ -595,6 +595,7 @@ impl ApplyService { .triggers .iter() .map(|t| t.script().to_lowercase()) + .chain(bundle.routes.iter().map(|r| r.script.to_lowercase())) .collect(); let by_name: HashMap = self .scripts @@ -629,16 +630,13 @@ impl ApplyService { inherited_endpoints: &HashSet, ) -> Result<(), ApplyError> { if matches!(owner, ApplyOwner::Group(_)) { - if !bundle.routes.is_empty() { - return Err(ApplyError::Invalid( - "a group manifest cannot declare routes — routes bind to an app".into(), - )); - } - // §11 tail: a group MAY declare trigger TEMPLATES, but only for the - // stateless EVENT kinds (kv/docs/files/pubsub) — those resolve live - // via the chain union at dispatch. cron/queue/email carry per-app - // state (last_fired_at, the one-consumer lock, a sealed secret) and - // would need materialization; reject them on a group, deferred. + // §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding + // a group-owned endpoint by `validate_bundle` via the inherited + // targets) and TRIGGER TEMPLATES — but trigger templates are limited + // to the stateless EVENT kinds (kv/docs/files/pubsub), which resolve + // live via the chain union at dispatch. cron/queue/email carry + // per-app state (last_fired_at, the one-consumer lock, a sealed + // secret) and would need materialization; reject them, deferred. for t in &bundle.triggers { if !matches!( t, @@ -834,10 +832,10 @@ impl ApplyService { }; let br = bundle_routes[&ch.key]; let sid = resolve_script(&name_to_id, &br.script)?; - // Routes only exist on app nodes (a group bundle has none, so this - // loop is empty for a group — validated above). - let app_id = owner.app_id().expect("routes only exist on app nodes"); - insert_bundle_route(&mut *tx, app_id, sid, br).await?; + // §11 tail: an app node writes an app-owned route; a group node + // writes a route TEMPLATE (group_id owner) that expands into every + // descendant app's slice at rebuild. + insert_bundle_route(&mut *tx, owner.as_script_owner(), sid, br).await?; if is_update { report.routes_updated += 1; } else { @@ -2175,8 +2173,8 @@ impl ApplyService { } async fn load_current(&self, owner: ApplyOwner) -> Result { - // A group node has only scripts + vars; routes / triggers / secrets are - // app-only and stay empty (the bundle for a group declares none). + // A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail); + // secrets stay empty (app-only — the bundle for a group declares none). let (scripts, routes, triggers, secret_names, var_owner) = match owner { ApplyOwner::App(app_id) => ( self.scripts @@ -2199,9 +2197,12 @@ impl ApplyService { .list_for_group(group_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))?, - Vec::new(), - // §11 tail: a group owns trigger TEMPLATES; load them so the - // diff sees existing markers (NoOp on re-apply, prune-able). + // §11 tail: a group owns route + trigger TEMPLATES; load them so + // the diff sees existing markers (NoOp on re-apply, prune-able). + self.routes + .list_for_group(group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?, self.triggers .list_for_group(group_id) .await @@ -3208,12 +3209,12 @@ fn resolve_script( async fn insert_bundle_route( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - app_id: AppId, + owner: ScriptOwner, script_id: ScriptId, br: &BundleRoute, ) -> Result<(), ApplyError> { let new = NewRoute { - owner: ScriptOwner::App(app_id), + owner, script_id, host_kind: br.host_kind, host: br.host.clone(), diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index c9a9a92..e46ac0e 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -68,15 +68,11 @@ impl Manifest { } } // A group node owns scripts + vars (+ secret names) and, as of §11 tail, - // EVENT trigger TEMPLATES (kv/docs/files/pubsub) that fan out live to - // descendant apps. Routes stay app-only; so do the stateful trigger - // kinds (cron/queue/email need per-app state/secrets → materialization). + // ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that + // fan out live to descendant apps. The stateful trigger kinds + // (cron/queue/email) stay app-only — they need per-app state/secrets → + // materialization. if m.group.is_some() { - if !m.routes.is_empty() { - anyhow::bail!( - "a [group] manifest cannot declare [[routes]] — routes bind to an app" - ); - } let t = &m.triggers; if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() { anyhow::bail!(