working on runtime executions

This commit is contained in:
2026-02-16 22:04:20 -06:00
parent f52320f889
commit 904ede04be
99 changed files with 6778 additions and 5929 deletions

View File

@@ -0,0 +1,776 @@
//! Pack Component Loader
//!
//! Reads runtime, action, trigger, and sensor YAML definitions from a pack directory
//! and registers them in the database. This is the Rust-native equivalent of
//! the Python `load_core_pack.py` script used during init-packs.
//!
//! Components are loaded in dependency order:
//! 1. Runtimes (no dependencies)
//! 2. Triggers (no dependencies)
//! 3. Actions (depend on runtime)
//! 4. Sensors (depend on triggers and runtime)
use std::collections::HashMap;
use std::path::Path;
use sqlx::PgPool;
use tracing::{info, warn};
use crate::error::{Error, Result};
use crate::models::Id;
use crate::repositories::action::ActionRepository;
use crate::repositories::runtime::{CreateRuntimeInput, RuntimeRepository};
use crate::repositories::trigger::{
CreateSensorInput, CreateTriggerInput, SensorRepository, TriggerRepository,
};
use crate::repositories::{Create, FindByRef};
/// Result of loading pack components into the database.
#[derive(Debug, Default)]
pub struct PackLoadResult {
/// Number of runtimes loaded
pub runtimes_loaded: usize,
/// Number of runtimes skipped (already exist)
pub runtimes_skipped: usize,
/// Number of triggers loaded
pub triggers_loaded: usize,
/// Number of triggers skipped (already exist)
pub triggers_skipped: usize,
/// Number of actions loaded
pub actions_loaded: usize,
/// Number of actions skipped (already exist)
pub actions_skipped: usize,
/// Number of sensors loaded
pub sensors_loaded: usize,
/// Number of sensors skipped (already exist)
pub sensors_skipped: usize,
/// Warnings encountered during loading
pub warnings: Vec<String>,
}
impl PackLoadResult {
pub fn total_loaded(&self) -> usize {
self.runtimes_loaded + self.triggers_loaded + self.actions_loaded + self.sensors_loaded
}
pub fn total_skipped(&self) -> usize {
self.runtimes_skipped + self.triggers_skipped + self.actions_skipped + self.sensors_skipped
}
}
/// Loads pack components (triggers, actions, sensors) from YAML files on disk
/// into the database.
pub struct PackComponentLoader<'a> {
pool: &'a PgPool,
pack_id: Id,
pack_ref: String,
}
impl<'a> PackComponentLoader<'a> {
pub fn new(pool: &'a PgPool, pack_id: Id, pack_ref: &str) -> Self {
Self {
pool,
pack_id,
pack_ref: pack_ref.to_string(),
}
}
/// Load all components from the pack directory.
///
/// Reads triggers, actions, and sensors from their respective subdirectories
/// and registers them in the database. Components that already exist (by ref)
/// are skipped.
pub async fn load_all(&self, pack_dir: &Path) -> Result<PackLoadResult> {
let mut result = PackLoadResult::default();
info!(
"Loading components for pack '{}' from {}",
self.pack_ref,
pack_dir.display()
);
// 1. Load runtimes first (no dependencies)
self.load_runtimes(pack_dir, &mut result).await?;
// 2. Load triggers (no dependencies)
let trigger_ids = self.load_triggers(pack_dir, &mut result).await?;
// 3. Load actions (depend on runtime)
self.load_actions(pack_dir, &mut result).await?;
// 4. Load sensors (depend on triggers and runtime)
self.load_sensors(pack_dir, &trigger_ids, &mut result)
.await?;
info!(
"Pack '{}' component loading complete: {} loaded, {} skipped, {} warnings",
self.pack_ref,
result.total_loaded(),
result.total_skipped(),
result.warnings.len()
);
Ok(result)
}
/// Load trigger definitions from `pack_dir/triggers/*.yaml`.
///
/// Returns a map of trigger ref -> trigger ID for use by sensor loading.
/// Load runtime definitions from `pack_dir/runtimes/*.yaml`.
///
/// Runtimes define how actions and sensors are executed (interpreter,
/// environment setup, dependency management). They are loaded first
/// since actions reference them.
async fn load_runtimes(&self, pack_dir: &Path, result: &mut PackLoadResult) -> Result<()> {
let runtimes_dir = pack_dir.join("runtimes");
if !runtimes_dir.exists() {
info!("No runtimes directory found for pack '{}'", self.pack_ref);
return Ok(());
}
let yaml_files = read_yaml_files(&runtimes_dir)?;
info!(
"Found {} runtime definition(s) for pack '{}'",
yaml_files.len(),
self.pack_ref
);
for (filename, content) in &yaml_files {
let data: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
Error::validation(format!("Failed to parse runtime YAML {}: {}", filename, e))
})?;
let runtime_ref = match data.get("ref").and_then(|v| v.as_str()) {
Some(r) => r.to_string(),
None => {
let msg = format!(
"Runtime YAML {} missing 'ref' field, skipping",
filename
);
warn!("{}", msg);
result.warnings.push(msg);
continue;
}
};
// Check if runtime already exists
if let Some(existing) =
RuntimeRepository::find_by_ref(self.pool, &runtime_ref).await?
{
info!(
"Runtime '{}' already exists (ID: {}), skipping",
runtime_ref, existing.id
);
result.runtimes_skipped += 1;
continue;
}
let name = data
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| extract_name_from_ref(&runtime_ref));
let description = data
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let distributions = data
.get("distributions")
.and_then(|v| serde_json::to_value(v).ok())
.unwrap_or_else(|| serde_json::json!({}));
let installation = data
.get("installation")
.and_then(|v| serde_json::to_value(v).ok());
let execution_config = data
.get("execution_config")
.and_then(|v| serde_json::to_value(v).ok())
.unwrap_or_else(|| serde_json::json!({}));
let input = CreateRuntimeInput {
r#ref: runtime_ref.clone(),
pack: Some(self.pack_id),
pack_ref: Some(self.pack_ref.clone()),
description,
name,
distributions,
installation,
execution_config,
};
match RuntimeRepository::create(self.pool, input).await {
Ok(rt) => {
info!(
"Created runtime '{}' (ID: {})",
runtime_ref, rt.id
);
result.runtimes_loaded += 1;
}
Err(e) => {
// Check for unique constraint violation (race condition)
if let Error::Database(ref db_err) = e {
if let sqlx::Error::Database(ref inner) = db_err {
if inner.is_unique_violation() {
info!(
"Runtime '{}' already exists (concurrent creation), skipping",
runtime_ref
);
result.runtimes_skipped += 1;
continue;
}
}
}
let msg = format!("Failed to create runtime '{}': {}", runtime_ref, e);
warn!("{}", msg);
result.warnings.push(msg);
}
}
}
Ok(())
}
async fn load_triggers(
&self,
pack_dir: &Path,
result: &mut PackLoadResult,
) -> Result<HashMap<String, Id>> {
let triggers_dir = pack_dir.join("triggers");
let mut trigger_ids = HashMap::new();
if !triggers_dir.exists() {
info!("No triggers directory found for pack '{}'", self.pack_ref);
return Ok(trigger_ids);
}
let yaml_files = read_yaml_files(&triggers_dir)?;
info!(
"Found {} trigger definition(s) for pack '{}'",
yaml_files.len(),
self.pack_ref
);
for (filename, content) in &yaml_files {
let data: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
Error::validation(format!("Failed to parse trigger YAML {}: {}", filename, e))
})?;
let trigger_ref = match data.get("ref").and_then(|v| v.as_str()) {
Some(r) => r.to_string(),
None => {
let msg = format!("Trigger YAML {} missing 'ref' field, skipping", filename);
warn!("{}", msg);
result.warnings.push(msg);
continue;
}
};
// Check if trigger already exists
if let Some(existing) = TriggerRepository::find_by_ref(self.pool, &trigger_ref).await? {
info!(
"Trigger '{}' already exists (ID: {}), skipping",
trigger_ref, existing.id
);
trigger_ids.insert(trigger_ref, existing.id);
result.triggers_skipped += 1;
continue;
}
let name = extract_name_from_ref(&trigger_ref);
let label = data
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| generate_label(&name));
let description = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let enabled = data
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let param_schema = data
.get("parameters")
.and_then(|v| serde_json::to_value(v).ok());
let out_schema = data
.get("output")
.and_then(|v| serde_json::to_value(v).ok());
let input = CreateTriggerInput {
r#ref: trigger_ref.clone(),
pack: Some(self.pack_id),
pack_ref: Some(self.pack_ref.clone()),
label,
description: Some(description),
enabled,
param_schema,
out_schema,
is_adhoc: false,
};
match TriggerRepository::create(self.pool, input).await {
Ok(trigger) => {
info!("Created trigger '{}' (ID: {})", trigger_ref, trigger.id);
trigger_ids.insert(trigger_ref, trigger.id);
result.triggers_loaded += 1;
}
Err(e) => {
let msg = format!("Failed to create trigger '{}': {}", trigger_ref, e);
warn!("{}", msg);
result.warnings.push(msg);
}
}
}
Ok(trigger_ids)
}
/// Load action definitions from `pack_dir/actions/*.yaml`.
async fn load_actions(&self, pack_dir: &Path, result: &mut PackLoadResult) -> Result<()> {
let actions_dir = pack_dir.join("actions");
if !actions_dir.exists() {
info!("No actions directory found for pack '{}'", self.pack_ref);
return Ok(());
}
let yaml_files = read_yaml_files(&actions_dir)?;
info!(
"Found {} action definition(s) for pack '{}'",
yaml_files.len(),
self.pack_ref
);
for (filename, content) in &yaml_files {
let data: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
Error::validation(format!("Failed to parse action YAML {}: {}", filename, e))
})?;
let action_ref = match data.get("ref").and_then(|v| v.as_str()) {
Some(r) => r.to_string(),
None => {
let msg = format!("Action YAML {} missing 'ref' field, skipping", filename);
warn!("{}", msg);
result.warnings.push(msg);
continue;
}
};
// Check if action already exists
if let Some(existing) = ActionRepository::find_by_ref(self.pool, &action_ref).await? {
info!(
"Action '{}' already exists (ID: {}), skipping",
action_ref, existing.id
);
result.actions_skipped += 1;
continue;
}
let name = extract_name_from_ref(&action_ref);
let label = data
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| generate_label(&name));
let description = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let entrypoint = data
.get("entry_point")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Resolve runtime ID from runner_type
let runner_type = data
.get("runner_type")
.and_then(|v| v.as_str())
.unwrap_or("shell");
let runtime_id = self.resolve_runtime_id(runner_type).await?;
let param_schema = data
.get("parameters")
.and_then(|v| serde_json::to_value(v).ok());
let out_schema = data
.get("output")
.and_then(|v| serde_json::to_value(v).ok());
// Read optional fields for parameter delivery/format and output format.
// The database has defaults (stdin, json, text), so we only set these
// in the INSERT if the YAML specifies them.
let parameter_delivery = data
.get("parameter_delivery")
.and_then(|v| v.as_str())
.unwrap_or("stdin")
.to_lowercase();
let parameter_format = data
.get("parameter_format")
.and_then(|v| v.as_str())
.unwrap_or("json")
.to_lowercase();
let output_format = data
.get("output_format")
.and_then(|v| v.as_str())
.unwrap_or("text")
.to_lowercase();
// Use raw SQL to include parameter_delivery, parameter_format,
// output_format which are not in CreateActionInput
let create_result = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO action (
ref, pack, pack_ref, label, description, entrypoint,
runtime, param_schema, out_schema, is_adhoc,
parameter_delivery, parameter_format, output_format
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id
"#,
)
.bind(&action_ref)
.bind(self.pack_id)
.bind(&self.pack_ref)
.bind(&label)
.bind(&description)
.bind(&entrypoint)
.bind(runtime_id)
.bind(&param_schema)
.bind(&out_schema)
.bind(false) // is_adhoc
.bind(&parameter_delivery)
.bind(&parameter_format)
.bind(&output_format)
.fetch_one(self.pool)
.await;
match create_result {
Ok(id) => {
info!("Created action '{}' (ID: {})", action_ref, id);
result.actions_loaded += 1;
}
Err(e) => {
// Check for unique constraint violation (already exists race condition)
if let sqlx::Error::Database(ref db_err) = e {
if db_err.is_unique_violation() {
info!(
"Action '{}' already exists (concurrent creation), skipping",
action_ref
);
result.actions_skipped += 1;
continue;
}
}
let msg = format!("Failed to create action '{}': {}", action_ref, e);
warn!("{}", msg);
result.warnings.push(msg);
}
}
}
Ok(())
}
/// Load sensor definitions from `pack_dir/sensors/*.yaml`.
async fn load_sensors(
&self,
pack_dir: &Path,
trigger_ids: &HashMap<String, Id>,
result: &mut PackLoadResult,
) -> Result<()> {
let sensors_dir = pack_dir.join("sensors");
if !sensors_dir.exists() {
info!("No sensors directory found for pack '{}'", self.pack_ref);
return Ok(());
}
let yaml_files = read_yaml_files(&sensors_dir)?;
info!(
"Found {} sensor definition(s) for pack '{}'",
yaml_files.len(),
self.pack_ref
);
// Resolve sensor runtime
let sensor_runtime_id = self.resolve_runtime_id("builtin").await?;
let sensor_runtime_ref = "core.builtin".to_string();
for (filename, content) in &yaml_files {
let data: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
Error::validation(format!("Failed to parse sensor YAML {}: {}", filename, e))
})?;
let sensor_ref = match data.get("ref").and_then(|v| v.as_str()) {
Some(r) => r.to_string(),
None => {
let msg = format!("Sensor YAML {} missing 'ref' field, skipping", filename);
warn!("{}", msg);
result.warnings.push(msg);
continue;
}
};
// Check if sensor already exists
if let Some(existing) = SensorRepository::find_by_ref(self.pool, &sensor_ref).await? {
info!(
"Sensor '{}' already exists (ID: {}), skipping",
sensor_ref, existing.id
);
result.sensors_skipped += 1;
continue;
}
let name = extract_name_from_ref(&sensor_ref);
let label = data
.get("label")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| generate_label(&name));
let description = data
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let enabled = data
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let entrypoint = data
.get("entry_point")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Resolve trigger reference
let (trigger_id, trigger_ref) = self.resolve_sensor_trigger(&data, trigger_ids).await;
let param_schema = data
.get("parameters")
.and_then(|v| serde_json::to_value(v).ok());
let config = data
.get("config")
.and_then(|v| serde_json::to_value(v).ok())
.unwrap_or_else(|| serde_json::json!({}));
let input = CreateSensorInput {
r#ref: sensor_ref.clone(),
pack: Some(self.pack_id),
pack_ref: Some(self.pack_ref.clone()),
label,
description,
entrypoint,
runtime: sensor_runtime_id.unwrap_or(0),
runtime_ref: sensor_runtime_ref.clone(),
trigger: trigger_id.unwrap_or(0),
trigger_ref: trigger_ref.unwrap_or_default(),
enabled,
param_schema,
config: Some(config),
};
match SensorRepository::create(self.pool, input).await {
Ok(sensor) => {
info!("Created sensor '{}' (ID: {})", sensor_ref, sensor.id);
result.sensors_loaded += 1;
}
Err(e) => {
let msg = format!("Failed to create sensor '{}': {}", sensor_ref, e);
warn!("{}", msg);
result.warnings.push(msg);
}
}
}
Ok(())
}
/// Resolve a runtime ID from a runner type string (e.g., "shell", "python", "builtin").
///
/// Looks up the runtime in the database by `core.{name}` ref pattern,
/// then falls back to name-based lookup (case-insensitive).
///
/// - "shell" -> "core.shell"
/// - "python" -> "core.python"
/// - "node" -> "core.nodejs"
/// - "builtin" -> "core.builtin"
async fn resolve_runtime_id(&self, runner_type: &str) -> Result<Option<Id>> {
let runner_lower = runner_type.to_lowercase();
// Runtime refs use the format `{pack_ref}.{name}` (e.g., "core.python").
let refs_to_try = match runner_lower.as_str() {
"shell" | "bash" | "sh" => vec!["core.shell"],
"python" | "python3" => vec!["core.python"],
"node" | "nodejs" | "node.js" => vec!["core.nodejs"],
"native" => vec!["core.native"],
"builtin" => vec!["core.builtin"],
other => vec![other],
};
for runtime_ref in &refs_to_try {
if let Some(runtime) = RuntimeRepository::find_by_ref(self.pool, runtime_ref).await? {
return Ok(Some(runtime.id));
}
}
// Fall back to name-based lookup (case-insensitive)
use crate::repositories::runtime::RuntimeRepository as RR;
if let Some(runtime) = RR::find_by_name(self.pool, &runner_lower).await? {
return Ok(Some(runtime.id));
}
warn!(
"Could not find runtime for runner_type '{}', action will have no runtime",
runner_type
);
Ok(None)
}
/// Resolve the trigger reference and ID for a sensor.
///
/// Handles both `trigger_type` (singular) and `trigger_types` (array) fields.
async fn resolve_sensor_trigger(
&self,
data: &serde_yaml_ng::Value,
trigger_ids: &HashMap<String, Id>,
) -> (Option<Id>, Option<String>) {
// Try trigger_types (array) first, then trigger_type (singular)
let trigger_type_str = data
.get("trigger_types")
.and_then(|v| v.as_sequence())
.and_then(|seq| seq.first())
.and_then(|v| v.as_str())
.or_else(|| data.get("trigger_type").and_then(|v| v.as_str()));
let trigger_ref = match trigger_type_str {
Some(t) => {
if t.contains('.') {
t.to_string()
} else {
format!("{}.{}", self.pack_ref, t)
}
}
None => return (None, None),
};
// Look up trigger ID from our loaded triggers map first
if let Some(&id) = trigger_ids.get(&trigger_ref) {
return (Some(id), Some(trigger_ref));
}
// Fall back to database lookup
match TriggerRepository::find_by_ref(self.pool, &trigger_ref).await {
Ok(Some(trigger)) => (Some(trigger.id), Some(trigger_ref)),
_ => {
warn!("Could not resolve trigger ref '{}' for sensor", trigger_ref);
(None, Some(trigger_ref))
}
}
}
}
/// Read all `.yaml` and `.yml` files from a directory, sorted by filename.
///
/// Returns a Vec of (filename, content) pairs.
fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
let mut files = Vec::new();
let entries = std::fs::read_dir(dir)
.map_err(|e| Error::io(format!("Failed to read directory {}: {}", dir.display(), e)))?;
let mut paths: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file()
&& matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("yaml") | Some("yml")
)
})
.collect();
// Sort by filename for deterministic ordering
paths.sort_by_key(|e| e.file_name());
for entry in paths {
let path = entry.path();
let filename = entry.file_name().to_string_lossy().to_string();
let content = std::fs::read_to_string(&path)
.map_err(|e| Error::io(format!("Failed to read file {}: {}", path.display(), e)))?;
files.push((filename, content));
}
Ok(files)
}
/// Extract the short name from a dotted ref (e.g., "core.echo" -> "echo").
fn extract_name_from_ref(r: &str) -> String {
r.rsplit('.').next().unwrap_or(r).to_string()
}
/// Generate a human-readable label from a snake_case name.
///
/// Examples:
/// - "echo" -> "Echo"
/// - "http_request" -> "Http Request"
/// - "datetime_timer" -> "Datetime Timer"
fn generate_label(name: &str) -> String {
name.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => {
let upper: String = c.to_uppercase().collect();
format!("{}{}", upper, chars.as_str())
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_name_from_ref() {
assert_eq!(extract_name_from_ref("core.echo"), "echo");
assert_eq!(extract_name_from_ref("python_example.greet"), "greet");
assert_eq!(extract_name_from_ref("simple"), "simple");
assert_eq!(extract_name_from_ref("a.b.c"), "c");
}
#[test]
fn test_generate_label() {
assert_eq!(generate_label("echo"), "Echo");
assert_eq!(generate_label("http_request"), "Http Request");
assert_eq!(generate_label("datetime_timer"), "Datetime Timer");
assert_eq!(generate_label("a_b_c"), "A B C");
}
}

View File

@@ -9,17 +9,19 @@
pub mod client;
pub mod dependency;
pub mod installer;
pub mod loader;
pub mod storage;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Re-export client, installer, storage, and dependency utilities
// Re-export client, installer, loader, storage, and dependency utilities
pub use client::RegistryClient;
pub use dependency::{
DependencyValidation, DependencyValidator, PackDepValidation, RuntimeDepValidation,
};
pub use installer::{InstalledPack, PackInstaller, PackSource};
pub use loader::{PackComponentLoader, PackLoadResult};
pub use storage::{
calculate_directory_checksum, calculate_file_checksum, verify_checksum, PackStorage,
};
@@ -245,7 +247,10 @@ impl Checksum {
pub fn parse(s: &str) -> Result<Self, String> {
let parts: Vec<&str> = s.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(format!("Invalid checksum format: {}. Expected 'algorithm:hash'", s));
return Err(format!(
"Invalid checksum format: {}. Expected 'algorithm:hash'",
s
));
}
let algorithm = parts[0].to_lowercase();
@@ -259,7 +264,10 @@ impl Checksum {
// Basic validation of hash format (hex string)
if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("Invalid hash format: {}. Must be hexadecimal", hash));
return Err(format!(
"Invalid hash format: {}. Must be hexadecimal",
hash
));
}
Ok(Self { algorithm, hash })