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

@@ -122,6 +122,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
timeout_seconds: None,
memory_limit_mb: None,
sandbox: None,
enabled: true,
}],
routes: vec![ManifestRoute {
script: "hello".into(),
@@ -132,6 +133,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
path_kind: PathKind::Exact,
path: "/hello".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(),

View File

@@ -61,6 +61,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
if let Some(sb) = &s.sandbox {
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
}
obj.insert("enabled".into(), json!(s.enabled));
scripts.push(Value::Object(obj));
}

View File

@@ -49,6 +49,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
path_kind: r.path_kind,
path: r.path,
dispatch_mode: r.dispatch_mode,
enabled: r.enabled,
});
}
}
@@ -90,6 +91,7 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
} else {
Some(s.sandbox)
},
enabled: s.enabled,
});
}

View File

@@ -84,6 +84,13 @@ pub struct ManifestScript {
/// Per-script sandbox overrides; omitted entirely when no knob is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
/// invocable). Omitted ⇒ active; only serialized when disabled.
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -102,6 +109,13 @@ pub struct ManifestRoute {
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
/// Omitted ⇒ active; only serialized when disabled.
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
@@ -244,6 +258,11 @@ fn is_sync(mode: &DispatchMode) -> bool {
*mode == DispatchMode::Sync
}
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
fn is_true(b: &bool) -> bool {
*b
}
fn default_timezone() -> String {
"UTC".to_string()
}
@@ -268,6 +287,7 @@ mod tests {
timeout_seconds: Some(10),
memory_limit_mb: Some(256),
sandbox: None,
enabled: true,
},
ManifestScript {
name: "lib".into(),
@@ -280,6 +300,7 @@ mod tests {
max_operations: Some(5_000_000),
..ScriptSandbox::empty()
}),
enabled: false,
},
],
routes: vec![ManifestRoute {
@@ -291,6 +312,7 @@ mod tests {
path_kind: PathKind::Exact,
path: "/posts".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
}],
triggers: ManifestTriggers {
cron: vec![CronTriggerSpec {
@@ -337,6 +359,16 @@ mod tests {
);
// Module kind IS non-default → emitted.
assert!(text.contains("kind = \"module\""), "got:\n{text}");
// `enabled` defaults true → omitted for the active script/route, but
// the disabled `lib` script emits `enabled = false`.
assert!(
text.contains("enabled = false"),
"a disabled entity must emit enabled:\n{text}"
);
assert!(
!text.contains("enabled = true"),
"active entities must omit the default:\n{text}"
);
}
#[test]