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

@@ -0,0 +1,8 @@
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
-- this adds the same toggle to scripts and routes. A disabled script is not
-- invocable; a disabled route 404s (indistinguishable from absent). Default
-- TRUE so every existing row stays active — no behavior change on migrate.
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -246,6 +246,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
} else {
Some(input.sandbox)
},
// Scripts are created active; toggling is a dedicated path.
enabled: true,
imports: validated.imports,
})
.await?;
@@ -345,6 +347,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
memory_limit_mb: input.memory_limit_mb,
sandbox: input.sandbox,
kind: input.kind,
enabled: None,
imports: imports_for_patch,
},
)
@@ -476,10 +479,9 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_)
| Self::BadRequest(_)
| Self::Invalid(_)
| Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {

View File

@@ -68,6 +68,7 @@ async fn seed_into(
timeout_seconds: Some(5),
memory_limit_mb: None,
sandbox: None,
enabled: true,
imports: Vec::new(),
})
.await?;
@@ -85,6 +86,7 @@ async fn seed_into(
// `curl -d '{"name":"X"}' /hello` work out of the box.
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
})
.await?;

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");

View File

@@ -313,6 +313,7 @@ mod tests {
timeout_seconds: 30,
memory_limit_mb: 64,
sandbox: ScriptSandbox::default(),
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}

View File

@@ -154,6 +154,8 @@ pub struct NewScript {
/// Sandbox overrides; `None` means store an empty object (use
/// platform defaults at exec time).
pub sandbox: Option<ScriptSandbox>,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// v1.1.3: literal-path `import "<name>"` declarations extracted
/// from the source. The repo writes these into `script_imports`
/// transactionally with the script row. Empty when validation
@@ -176,6 +178,8 @@ pub struct ScriptPatch {
/// rejects unsafe transitions (e.g. endpoint→module when routes
/// or triggers reference the script).
pub kind: Option<ScriptKind>,
/// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it.
pub enabled: Option<bool>,
/// v1.1.3: when `source` is also `Some`, the repo replaces the
/// `script_imports` edges for this script with these names.
/// `None` keeps the existing edges untouched (a name/description
@@ -203,7 +207,8 @@ impl PostgresScriptRepository {
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
/// one query.
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
timeout_seconds, memory_limit_mb, sandbox, enabled, \
created_at, updated_at";
#[async_trait]
impl ScriptRepository for PostgresScriptRepository {
@@ -393,8 +398,8 @@ pub(crate) async fn insert_script_tx(
let res = sqlx::query_as::<_, ScriptRow>(&format!(
"INSERT INTO scripts ( \
app_id, name, description, source, kind, \
timeout_seconds, memory_limit_mb, sandbox \
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
timeout_seconds, memory_limit_mb, sandbox, enabled \
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
RETURNING {SCRIPT_SELECT_COLS}"
))
.bind(input.app_id.into_inner())
@@ -405,6 +410,7 @@ pub(crate) async fn insert_script_tx(
.bind(input.timeout_seconds)
.bind(input.memory_limit_mb)
.bind(sandbox_json)
.bind(input.enabled)
.fetch_one(&mut **tx)
.await;
let script: Script = match res {
@@ -441,6 +447,7 @@ pub(crate) async fn update_script_tx(
memory_limit_mb = COALESCE($7, memory_limit_mb), \
sandbox = COALESCE($8, sandbox), \
kind = COALESCE($9, kind), \
enabled = COALESCE($10, enabled), \
version = version + 1, \
updated_at = NOW() \
WHERE id = $1 \
@@ -455,6 +462,7 @@ pub(crate) async fn update_script_tx(
.bind(patch.memory_limit_mb)
.bind(sandbox_json)
.bind(patch.kind.map(ScriptKind::as_str))
.bind(patch.enabled)
.fetch_optional(&mut **tx)
.await;
let script: Script = match res {
@@ -505,6 +513,7 @@ struct ScriptRow {
timeout_seconds: i32,
memory_limit_mb: i32,
sandbox: serde_json::Value,
enabled: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -531,6 +540,7 @@ impl From<ScriptRow> for Script {
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
sandbox,
enabled: r.enabled,
created_at: r.created_at,
updated_at: r.updated_at,
}

View File

@@ -229,6 +229,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
path: normalized_path,
method: input.method,
dispatch_mode: input.dispatch_mode,
// Routes are created active; toggling is a dedicated path.
enabled: true,
})
.await?;
refresh_table(&state).await?;
@@ -625,6 +627,7 @@ mod tests {
path: path.to_string(),
method: None,
dispatch_mode: DispatchMode::default(),
enabled: true,
created_at: chrono::Utc::now(),
}
}

View File

@@ -21,6 +21,8 @@ pub struct NewRoute {
pub path: String,
pub method: Option<String>,
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
}
#[async_trait]
@@ -63,7 +65,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes ORDER BY created_at",
)
.fetch_all(&self.pool)
@@ -74,7 +76,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
let row = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE id = $1",
)
.bind(route_id)
@@ -86,7 +88,7 @@ impl RouteRepository for PostgresRouteRepository {
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE app_id = $1 ORDER BY created_at",
)
.bind(app_id.into_inner())
@@ -101,7 +103,7 @@ impl RouteRepository for PostgresRouteRepository {
) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at \
path_kind, path, method, dispatch_mode, enabled, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at",
)
.bind(script_id.into_inner())
@@ -173,10 +175,10 @@ pub(crate) async fn insert_route_tx(
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
path_kind, path, method, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, dispatch_mode, created_at",
path_kind, path, method, dispatch_mode, enabled, created_at",
)
.bind(input.app_id.into_inner())
.bind(input.script_id.into_inner())
@@ -187,6 +189,7 @@ pub(crate) async fn insert_route_tx(
.bind(&input.path)
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.fetch_one(&mut **tx)
.await;
match res {
@@ -231,6 +234,7 @@ struct RouteRow {
path: String,
method: Option<String>,
dispatch_mode: String,
enabled: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@@ -255,6 +259,7 @@ impl From<RouteRow> for Route {
path: r.path,
method: r.method,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
created_at: r.created_at,
}
}

View File

@@ -1347,6 +1347,7 @@ mod tests {
timeout_seconds: 30,
sandbox: picloud_shared::ScriptSandbox::default(),
memory_limit_mb: 256,
enabled: true,
created_at: now,
updated_at: now,
},