feat(cli): pic pull --group round-trips a group node

Exports a group as a [group] manifest: its scripts, [[routes]]/[[triggers.*]]
TEMPLATES (with the group-only sealed/shared flags), a shared collections
entry, [vars]/[secrets], extension_points, and [suppress]. Groups own no
workflows.

The group trigger/route reports were display-only (no ops, dispatch, retry,
host_kind, cron tz, queue timeout) so a pulled manifest could not re-plan
clean. Enriched TriggerTemplateInfo with the full internally-tagged
TriggerDetails JSON + dispatch_mode + retry_max_attempts, and RouteTemplateInfo
with raw manifest-shaped fields (method/host_kind/host/host_param_name/
path_kind/dispatch_mode); pic routes ls --group now applies the ANY/host munge
client-side, consistent with the app pic routes ls. pull.rs factors the
script-writing + trigger-decoding it shares with the app path.

Pinned by a pull journey: a group with a sealed+shared kv trigger, a sealed
route, a shared collection, and a var round-trips and the exported manifest
re-applies clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 21:19:59 +02:00
parent e4a58dc140
commit 35345e62de
6 changed files with 562 additions and 165 deletions

View File

@@ -830,6 +830,19 @@ pub struct TriggerTemplateInfo {
pub sealed: bool,
/// §11.6: `true` for a shared-collection template.
pub shared: bool,
/// Full `TriggerDetails` as internally-tagged JSON (`{ "kind": …, … }`),
/// so `pic pull --group` can round-trip a template's ops / schedule /
/// timezone / queue name / visibility timeout, not just the display target.
/// `#[serde(default)]` on the read side so an older server (which omits it)
/// still deserializes; the display command reads `target`, not this.
#[serde(default)]
pub details: serde_json::Value,
/// Wire dispatch mode (`sync`/`async`) — round-trip only.
#[serde(default)]
pub dispatch_mode: String,
/// Per-trigger retry ceiling — round-trip only.
#[serde(default)]
pub retry_max_attempts: u32,
}
/// One row of the read-only §11 tail route-template report (`pic routes ls
@@ -838,12 +851,17 @@ pub struct TriggerTemplateInfo {
/// rows, so the semantic bits are shown instead.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RouteTemplateInfo {
pub method: String,
/// `None` = any method. Carried raw (not the "ANY" display munge) so
/// `pic pull --group` round-trips it; the display command applies the munge.
pub method: Option<String>,
pub host_kind: HostKind,
/// Raw host (empty for `Any`, suffix for `Wildcard`), not the display munge.
pub host: String,
pub path_kind: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub script: String,
pub dispatch: String,
pub dispatch_mode: DispatchMode,
pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
pub sealed: bool,
@@ -3651,6 +3669,9 @@ impl ApplyService {
enabled: t.enabled,
sealed: t.sealed,
shared: t.shared,
details: serde_json::to_value(&t.details).unwrap_or(serde_json::Value::Null),
dispatch_mode: t.dispatch_mode.as_str().to_string(),
retry_max_attempts: t.retry_max_attempts,
}
})
.collect())
@@ -3681,16 +3702,14 @@ impl ApplyService {
Ok(routes
.into_iter()
.map(|r| RouteTemplateInfo {
method: r.method.clone().unwrap_or_else(|| "ANY".into()),
host: match r.host_kind {
HostKind::Any => "any".to_string(),
HostKind::Strict => format!("strict:{}", r.host),
HostKind::Wildcard => format!("*.{}", r.host),
},
path_kind: path_kind_str(r.path_kind).to_string(),
method: r.method.clone(),
host_kind: r.host_kind,
host: r.host.clone(),
host_param_name: r.host_param_name.clone(),
path_kind: r.path_kind,
path: r.path.clone(),
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
dispatch: r.dispatch_mode.as_str().to_string(),
dispatch_mode: r.dispatch_mode,
enabled: r.enabled,
sealed: r.sealed,
})