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:
@@ -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 ¤t.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 ¤t.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");
|
||||
|
||||
Reference in New Issue
Block a user