Files
PiCloud/crates/shared/src/script.rs
MechaCat02 55cf995eda 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>
2026-06-23 19:36:40 +02:00

134 lines
4.4 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{AppId, ScriptId, ScriptSandbox};
/// Semantic role of a script (v1.1.3).
///
/// `Endpoint` scripts have an executable entry point — they bind to HTTP
/// routes and act as trigger handlers. `Module` scripts are libraries of
/// `fn`/`const` declarations imported by other scripts via Rhai's
/// `import "<name>" as <alias>;` syntax. Modules cannot be invoked
/// directly: route binding and trigger creation reject `Module` targets.
///
/// Serialized as `"endpoint"` / `"module"` so the wire shape is the
/// same string the SQL `CHECK (kind IN ('endpoint','module'))`
/// constraint enforces.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScriptKind {
#[default]
Endpoint,
Module,
}
impl ScriptKind {
/// Wire / SQL representation. Inverse of `parse_str`.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Endpoint => "endpoint",
Self::Module => "module",
}
}
/// Parse the canonical wire / SQL form. Returns `None` for any
/// other input; callers map that to a 400 / `ValidationError`.
/// Named `parse_str` (not `from_str`) to dodge the
/// `std::str::FromStr` lint without taking on the trait's
/// `Result<Self, Self::Err>` shape that this caller doesn't need.
#[must_use]
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"endpoint" => Some(Self::Endpoint),
"module" => Some(Self::Module),
_ => None,
}
}
}
#[cfg(test)]
mod kind_tests {
use super::*;
#[test]
fn default_is_endpoint() {
assert_eq!(ScriptKind::default(), ScriptKind::Endpoint);
}
#[test]
fn round_trips_through_serde_lowercase() {
assert_eq!(
serde_json::to_string(&ScriptKind::Endpoint).unwrap(),
"\"endpoint\""
);
assert_eq!(
serde_json::to_string(&ScriptKind::Module).unwrap(),
"\"module\""
);
assert_eq!(
serde_json::from_str::<ScriptKind>("\"endpoint\"").unwrap(),
ScriptKind::Endpoint
);
assert_eq!(
serde_json::from_str::<ScriptKind>("\"module\"").unwrap(),
ScriptKind::Module
);
}
#[test]
fn parse_str_round_trip() {
for k in [ScriptKind::Endpoint, ScriptKind::Module] {
assert_eq!(ScriptKind::parse_str(k.as_str()), Some(k));
}
assert_eq!(ScriptKind::parse_str("invalid"), None);
assert_eq!(ScriptKind::parse_str(""), None);
}
}
/// A user-uploaded Rhai script and its execution configuration.
///
/// This is the canonical representation that flows between manager (storage),
/// orchestrator (dispatch), and executor (run). It must stay serializable
/// because in cluster mode it crosses process boundaries on every invocation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub id: ScriptId,
/// Owning app. Set on create, immutable thereafter — a "move to
/// another app" is a copy+delete, not an in-place edit (snapshot
/// semantics — see blueprint §11.5).
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub version: i32,
pub source: String,
/// `Endpoint` (default; the only kind v1.0 through v1.1.2 supported)
/// or `Module` (v1.1.3 — imported by other scripts, never bound
/// directly to a route or trigger).
#[serde(default)]
pub kind: ScriptKind,
pub timeout_seconds: u32,
/// Per-script overrides for Rhai sandbox limits. Empty = platform
/// defaults. Values are admin-ceiling-clamped at write time.
#[serde(default)]
pub sandbox: ScriptSandbox,
/// **v1.3+ advisory only.** Real heap limits require OS-level
/// isolation (cgroups, process limits) which lands with cluster-mode
/// executor sandboxing. The field stays in the schema so we don't
/// have to add it back when that's built.
pub memory_limit_mb: u32,
/// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
/// script is not invocable (route 404s, trigger doesn't fire) but stays
/// as desired state (not pruned). Defaults `true`.
#[serde(default = "crate::default_true")]
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}