feat(stateful-templates): materialize cron+queue templates + hooks (M5.2-M5.4)

materialize::rematerialize_stateful_templates reconciles a group cron/queue
template into one app-owned copy per descendant (materialized_from = template),
via the all-apps app_chain CTE ⋈ group-owned stateful templates. A precise
create/delete diff preserves cron last_fired_at on unchanged copies; a queue
copy is skipped-with-warning when the app already fills that queue's consumer
slot (the one-consumer invariant). Called full-live at the route-rebuild
chokepoints: apply (single + tree), app create/delete (apps_api), group
reparent (groups_api) — AppsState/GroupsState gain a pool. Pinned by
tests/stateful_templates.rs (per-app copy, idempotent, new-app materializes,
reparent de-materializes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 21:47:27 +02:00
parent 456e972336
commit ddb3589c49
7 changed files with 425 additions and 0 deletions

View File

@@ -1420,6 +1420,11 @@ impl ApplyService {
.push("route table refresh failed; it will self-heal".into());
}
}
// §4.5 M5: re-materialize stateful (cron/queue) group templates into
// per-descendant app rows. Post-commit + best-effort like the route
// rebuild; per-app skips (e.g. a queue slot already taken) surface as
// warnings.
report.warnings.extend(self.rematerialize().await);
Ok(report)
}
@@ -1559,6 +1564,8 @@ impl ApplyService {
.warnings
.push("route table refresh failed; it will self-heal".into());
}
// §4.5 M5: one post-commit materialization pass covers the whole tree.
report.warnings.extend(self.rematerialize().await);
Ok(report)
}
@@ -1851,6 +1858,19 @@ impl ApplyService {
.map_err(|e| ApplyError::Backend(e.to_string()))
}
/// §4.5 M5: reconcile materialized stateful-template copies. Best-effort —
/// a failure is logged (the copies self-heal on the next mutation) and any
/// per-app skip warnings are returned for the apply report.
async fn rematerialize(&self) -> Vec<String> {
match crate::materialize::rematerialize_stateful_templates(&self.pool).await {
Ok(warnings) => warnings,
Err(e) => {
tracing::warn!(error = %e, "apply: stateful-template materialization failed");
vec!["stateful-template materialization failed; it will self-heal".into()]
}
}
}
/// Resolve a referenced secret to plaintext, then re-seal it for an
/// email trigger's inbound-secret column. The value never appears in
/// the manifest — only the secret's name does.