feat(enabled): scripts/routes enabled column + declarative data path

Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers
already carried `enabled`; this adds it to scripts and routes and threads
it through the declarative project tool. Runtime honoring (disabled route
404 / script non-invocable / dispatcher fire-time re-check) is the next
commit — this change only stores and reconciles the flag.

- Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts +
  routes. Default true ⇒ no behavior change on migrate.
- `Script`/`Route` (shared) gain `enabled` (serde default true via the new
  `picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row
  structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it.
- Apply diff treats `enabled` as a declarative field (omitted ⇒ active):
  `script_update_reason` + `diff_routes` detect a toggle as an Update, and
  the create/update/insert paths persist it. The bound-plan `state_token`
  re-includes script/route `enabled` (removed in the earlier review fix
  precisely because the diff didn't key on it yet — now it does).
- CLI manifest model + `build_bundle` + `pull` round-trip `enabled`
  (serialized only when false; omitted ⇒ active). `pic init` scaffold and
  the interactive script/route create paths default active.

Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) +
cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 19:36:40 +02:00
parent 627996cde7
commit 55cf995eda
16 changed files with 163 additions and 16 deletions

View File

@@ -90,6 +90,10 @@ pub struct BundleScript {
pub memory_limit_mb: Option<i32>,
#[serde(default)]
pub sandbox: Option<ScriptSandbox>,
/// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not
/// leave-as-is): a script absent the key is `enabled = true`.
#[serde(default = "picloud_shared::default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
@@ -107,6 +111,9 @@ pub struct BundleRoute {
pub path: String,
#[serde(default)]
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3); omitted ⇒ active.
#[serde(default = "picloud_shared::default_true")]
pub enabled: bool,
}
/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
@@ -495,6 +502,7 @@ impl ApplyService {
timeout_seconds: bs.timeout_seconds,
memory_limit_mb: bs.memory_limit_mb,
sandbox: bs.sandbox,
enabled: bs.enabled,
imports: self.script_imports(bs)?,
};
let created = insert_script_tx(&mut tx, &new).await.map_err(map_repo)?;
@@ -515,6 +523,8 @@ impl ApplyService {
memory_limit_mb: bs.memory_limit_mb,
sandbox: bs.sandbox,
kind: Some(bs.kind),
// Declarative: always reconcile to the manifest's value.
enabled: Some(bs.enabled),
imports: Some(self.script_imports(bs)?),
};
update_script_tx(&mut tx, cur.id, &patch)
@@ -1080,6 +1090,14 @@ fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String>
if cur.kind != desired.kind {
return Some("kind changed".into());
}
// Declarative (default true): always reconcile to the manifest's value.
if cur.enabled != desired.enabled {
return Some(if desired.enabled {
"enabled".into()
} else {
"disabled".into()
});
}
// Sparse like the other optional fields: an omitted (`None`) description
// means "leave as-is", so only an explicitly-set value that differs
// forces an update. (Clearing a description is done in the dashboard.)
@@ -1161,6 +1179,7 @@ fn diff_routes(
if cur_script != Some(r.script.as_str())
|| cur.dispatch_mode != r.dispatch_mode
|| cur.host_param_name != r.host_param_name
|| cur.enabled != r.enabled
{
out.push(ResourceChange {
op: Op::Update,
@@ -1527,6 +1546,7 @@ async fn insert_bundle_route(
path: br.path.clone(),
method: br.method.clone(),
dispatch_mode: br.dispatch_mode,
enabled: br.enabled,
};
insert_route_tx(tx, &new).await.map_err(map_repo)?;
Ok(())
@@ -1622,11 +1642,16 @@ pub fn state_token(current: &CurrentState) -> String {
+ current.secret_names.len(),
);
for s in &current.scripts {
parts.push(format!("s|{}|{}", s.name.to_lowercase(), s.version));
parts.push(format!(
"s|{}|{}|{}",
s.name.to_lowercase(),
s.version,
s.enabled
));
}
for r in &current.routes {
parts.push(format!(
"r|{}|{:?}|{:?}|{}",
"r|{}|{:?}|{:?}|{}|{}",
route_key(
r.method.as_deref(),
r.host_kind,
@@ -1637,6 +1662,7 @@ pub fn state_token(current: &CurrentState) -> String {
r.script_id,
r.dispatch_mode,
r.host_param_name.as_deref().unwrap_or(""),
r.enabled,
));
}
// Mirror exactly what the trigger diff keys on: `current_trigger_identity`
@@ -1698,6 +1724,7 @@ mod tests {
timeout_seconds: 30,
sandbox: ScriptSandbox::empty(),
memory_limit_mb: 256,
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
@@ -1712,6 +1739,7 @@ mod tests {
timeout_seconds: None,
memory_limit_mb: None,
sandbox: None,
enabled: true,
}
}
@@ -1798,6 +1826,7 @@ mod tests {
path: "/p".into(),
method: Some("POST".into()),
dispatch_mode: DispatchMode::Sync,
enabled: true,
created_at: Utc::now(),
};
let current = CurrentState {
@@ -1817,6 +1846,7 @@ mod tests {
path_kind: PathKind::Exact,
path: "/p".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: vec![],
secrets: vec![],
@@ -2047,6 +2077,34 @@ mod tests {
);
}
#[test]
fn enabled_is_declarative_and_diffed() {
// A live-active script the manifest marks disabled → Update; the token
// is sensitive to the flag so an out-of-band toggle is detectable.
let cur = script("a", "let x = 1;"); // enabled: true
let base = CurrentState {
scripts: vec![cur.clone()],
..CurrentState::default()
};
let mut disable = empty_bundle();
let mut bs = bundle_script("a", "let x = 1;");
bs.enabled = false;
disable.scripts = vec![bs];
assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update);
let mut flipped = cur;
flipped.enabled = false;
let toggled = CurrentState {
scripts: vec![flipped],
..CurrentState::default()
};
assert_ne!(
state_token(&base),
state_token(&toggled),
"token must flip on an enabled change"
);
}
#[test]
fn trigger_diff_create_noop_delete() {
let s = script("h", "x");