20 KiB
Core Pack Integration Guide
Last Updated: 2026-01-20
Status: Implementation Guide
⚠️ Note: This document was written during early planning. Some code examples reference the now-removed
runtime_typefield and old 3-part runtime ref format (core.action.shell). The current architecture uses unified runtimes with 2-part refs (core.shell) and determines executability by the presence ofexecution_config. Seedocs/QUICKREF-unified-runtime-detection.mdfor the current model.
Overview
This document outlines the steps required to integrate the filesystem-based core pack with the Attune platform. The core pack has been implemented in packs/core/ and needs to be loaded into the system during startup or installation.
Current State
✅ Completed
- Pack Structure: Complete filesystem-based pack in
packs/core/ - Actions: 4 actions implemented (echo, sleep, noop, http_request)
- Triggers: 3 trigger type definitions (intervaltimer, crontimer, datetimetimer)
- Sensors: 1 sensor implementation (interval_timer_sensor)
- Documentation: Comprehensive README and pack structure docs
- Testing: Manual validation of action execution
⏳ Pending Integration
- Pack Loader: Service to parse and register pack components
- Database Registration: Insert pack metadata and components into PostgreSQL
- Worker Integration: Execute actions from pack directory
- Sensor Integration: Load and run sensors from pack directory
- Startup Process: Automatic pack loading on service startup
Integration Architecture
┌─────────────────────────────────────────────────────────────┐
│ Attune Services │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Pack Loader Service │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Parse │ │ Validate │ │ Register │ │ │
│ │ │ pack.yaml│→ │ Schemas │→ │ in DB │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ PostgreSQL Database │ │
│ │ ┌──────┐ ┌────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Pack │ │ Action │ │ Trigger │ │ Sensor │ │ │
│ │ │ Meta │ │ Meta │ │ Meta │ │ Meta │ │ │
│ │ └──────┘ └────────┘ └─────────┘ └─────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Worker Service │ │ Sensor Service │ │
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │
│ │ │ Execute │ │ │ │ Run │ │ │
│ │ │ Actions │ │ │ │ Sensors │ │ │
│ │ │ from Pack │ │ │ │ from Pack │ │ │
│ │ └────────────┘ │ │ └────────────┘ │ │
│ └──────────────────┘ └──────────────────┘ │
│ ↑ ↑ │
└───────────┼──────────────────────────────────┼──────────────┘
│ │
└──────────────┬───────────────────┘
↓
┌─────────────────┐
│ Filesystem │
│ packs/core/ │
│ - actions/ │
│ - sensors/ │
│ - triggers/ │
└─────────────────┘
Implementation Steps
Phase 1: Pack Loader Service
Create a pack loader service in crates/common/src/pack_loader.rs (or as a separate crate).
1.1 Pack Parser
// Parse pack.yaml manifest
pub struct PackLoader {
pack_dir: PathBuf,
}
pub struct PackManifest {
pub ref_: String,
pub label: String,
pub description: String,
pub version: String,
pub author: Option<String>,
pub email: Option<String>,
pub system: bool,
pub enabled: bool,
pub conf_schema: Option<serde_json::Value>,
pub config: Option<serde_json::Value>,
pub meta: Option<serde_json::Value>,
pub tags: Vec<String>,
pub runtime_deps: Vec<String>,
}
impl PackLoader {
pub fn load_manifest(&self) -> Result<PackManifest> {
// Parse packs/{pack_name}/pack.yaml
}
}
1.2 Component Parsers
pub struct ActionMetadata {
pub name: String,
pub ref_: String,
pub description: String,
pub runner_type: String,
pub entry_point: String,
pub enabled: bool,
pub parameters: Option<serde_json::Value>,
pub output_schema: Option<serde_json::Value>,
pub tags: Vec<String>,
}
pub struct TriggerMetadata {
pub name: String,
pub ref_: String,
pub description: String,
pub type_: String,
pub enabled: bool,
pub parameters_schema: Option<serde_json::Value>,
pub payload_schema: Option<serde_json::Value>,
pub tags: Vec<String>,
}
pub struct SensorMetadata {
pub name: String,
pub ref_: String,
pub description: String,
pub runner_type: String,
pub entry_point: String,
pub trigger_types: Vec<String>,
pub enabled: bool,
pub parameters: Option<serde_json::Value>,
pub poll_interval: Option<i32>,
pub tags: Vec<String>,
}
impl PackLoader {
pub fn load_actions(&self) -> Result<Vec<ActionMetadata>> {
// Parse actions/*.yaml files
}
pub fn load_triggers(&self) -> Result<Vec<TriggerMetadata>> {
// Parse triggers/*.yaml files
}
pub fn load_sensors(&self) -> Result<Vec<SensorMetadata>> {
// Parse sensors/*.yaml files
}
}
1.3 Database Registration
impl PackLoader {
pub async fn register_pack(
&self,
pool: &PgPool,
manifest: &PackManifest,
) -> Result<i64> {
// Insert into attune.pack table
// Returns pack ID
}
pub async fn register_actions(
&self,
pool: &PgPool,
pack_id: i64,
actions: &[ActionMetadata],
) -> Result<()> {
// Insert into attune.action table
}
pub async fn register_triggers(
&self,
pool: &PgPool,
pack_id: i64,
triggers: &[TriggerMetadata],
) -> Result<()> {
// Insert into attune.trigger table
}
pub async fn register_sensors(
&self,
pool: &PgPool,
pack_id: i64,
sensors: &[SensorMetadata],
) -> Result<()> {
// Insert into attune.sensor table
}
}
1.4 Pack Loading Function
pub async fn load_pack(
pack_dir: PathBuf,
pool: &PgPool,
) -> Result<()> {
let loader = PackLoader::new(pack_dir);
// Parse pack manifest
let manifest = loader.load_manifest()?;
// Register pack
let pack_id = loader.register_pack(pool, &manifest).await?;
// Load and register components
let actions = loader.load_actions()?;
loader.register_actions(pool, pack_id, &actions).await?;
let triggers = loader.load_triggers()?;
loader.register_triggers(pool, pack_id, &triggers).await?;
let sensors = loader.load_sensors()?;
loader.register_sensors(pool, pack_id, &sensors).await?;
info!("Pack '{}' loaded successfully", manifest.ref_);
Ok(())
}
Phase 2: Worker Service Integration
Update the worker service to execute actions from the filesystem.
2.1 Action Execution Path Resolution
pub struct ActionExecutor {
packs_dir: PathBuf,
}
impl ActionExecutor {
pub fn resolve_action_path(
&self,
pack_ref: &str,
entry_point: &str,
) -> Result<PathBuf> {
// packs/{pack_ref}/actions/{entry_point}
let path = self.packs_dir
.join(pack_ref)
.join("actions")
.join(entry_point);
if !path.exists() {
return Err(Error::ActionNotFound(entry_point.to_string()));
}
Ok(path)
}
}
2.2 Environment Variable Setup
pub fn prepare_action_env(
params: &HashMap<String, serde_json::Value>,
) -> HashMap<String, String> {
let mut env = HashMap::new();
for (key, value) in params {
let env_key = format!("ATTUNE_ACTION_{}", key.to_uppercase());
let env_value = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => serde_json::to_string(value).unwrap(),
};
env.insert(env_key, env_value);
}
env
}
2.3 Action Execution
pub async fn execute_action(
&self,
action: &Action,
params: HashMap<String, serde_json::Value>,
) -> Result<ExecutionResult> {
// Resolve action script path
let script_path = self.resolve_action_path(
&action.pack_ref,
&action.entrypoint,
)?;
// Prepare environment variables
let env = prepare_action_env(¶ms);
// Execute based on runtime name (resolved from runtime.name, lowercased)
let output = match runtime_name.as_str() {
"shell" => self.execute_shell_action(script_path, env).await?,
"python" => self.execute_python_action(script_path, env).await?,
_ => return Err(Error::UnsupportedRuntime(runtime_name.clone())),
};
Ok(output)
}
Phase 3: Sensor Service Integration
Update the sensor service to load and run sensors from the filesystem.
3.1 Sensor Path Resolution
pub struct SensorManager {
packs_dir: PathBuf,
}
impl SensorManager {
pub fn resolve_sensor_path(
&self,
pack_ref: &str,
entry_point: &str,
) -> Result<PathBuf> {
// packs/{pack_ref}/sensors/{entry_point}
let path = self.packs_dir
.join(pack_ref)
.join("sensors")
.join(entry_point);
if !path.exists() {
return Err(Error::SensorNotFound(entry_point.to_string()));
}
Ok(path)
}
}
3.2 Sensor Environment Setup
pub fn prepare_sensor_env(
sensor: &Sensor,
trigger_instances: &[TriggerInstance],
) -> HashMap<String, String> {
let mut env = HashMap::new();
// Add sensor config
for (key, value) in &sensor.config {
let env_key = format!("ATTUNE_SENSOR_{}", key.to_uppercase());
env.insert(env_key, value.to_string());
}
// Add trigger instances as JSON array
let triggers_json = serde_json::to_string(trigger_instances).unwrap();
env.insert("ATTUNE_SENSOR_TRIGGERS".to_string(), triggers_json);
env
}
3.3 Sensor Execution
pub async fn run_sensor(
&self,
sensor: &Sensor,
trigger_instances: Vec<TriggerInstance>,
) -> Result<()> {
// Resolve sensor script path
let script_path = self.resolve_sensor_path(
&sensor.pack_ref,
&sensor.entrypoint,
)?;
// Prepare environment
let env = prepare_sensor_env(sensor, &trigger_instances);
// Start sensor process
let mut child = Command::new(&script_path)
.envs(env)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Read stdout line by line (JSON events)
let stdout = child.stdout.take().unwrap();
let reader = BufReader::new(stdout);
for line in reader.lines() {
let event_json = line?;
let event: SensorEvent = serde_json::from_str(&event_json)?;
// Create event in database
self.create_event_from_sensor(sensor, event).await?;
}
Ok(())
}
Phase 4: Service Startup Integration
Add pack loading to service initialization.
4.1 API Service Startup
// In crates/api/src/main.rs
#[tokio::main]
async fn main() -> Result<()> {
// ... existing initialization ...
// Load core pack
let packs_dir = PathBuf::from("packs");
let core_pack_dir = packs_dir.join("core");
if core_pack_dir.exists() {
info!("Loading core pack...");
pack_loader::load_pack(core_pack_dir, &pool).await?;
info!("Core pack loaded successfully");
}
// ... continue with server startup ...
}
4.2 Worker Service Startup
// In crates/worker/src/main.rs
#[tokio::main]
async fn main() -> Result<()> {
// ... existing initialization ...
// Set packs directory for action execution
let packs_dir = PathBuf::from("packs");
let executor = ActionExecutor::new(packs_dir);
// ... continue with worker initialization ...
}
4.3 Sensor Service Startup
// In crates/sensor/src/main.rs
#[tokio::main]
async fn main() -> Result<()> {
// ... existing initialization ...
// Set packs directory for sensor execution
let packs_dir = PathBuf::from("packs");
let sensor_manager = SensorManager::new(packs_dir);
// Load and start sensors
let sensors = load_enabled_sensors(&pool).await?;
for sensor in sensors {
sensor_manager.start_sensor(sensor).await?;
}
// ... continue with sensor service ...
}
Configuration
Add pack-related configuration to config.yaml:
packs:
# Directory containing packs
directory: "./packs"
# Auto-load packs on startup
auto_load:
- core
# Pack-specific configuration
core:
max_action_timeout: 300
enable_debug_logging: false
Database Schema Updates
The existing database schema already supports packs. Ensure these tables are used:
attune.pack- Pack metadataattune.action- Action definitionsattune.trigger- Trigger type definitionsattune.sensor- Sensor definitionsattune.runtime- Runtime definitions
Note: The current scripts/seed_core_pack.sql inserts data directly. This should be replaced or complemented by the filesystem-based loader.
Migration Strategy
Option 1: Replace SQL Seed Script
Remove scripts/seed_core_pack.sql and load from filesystem exclusively.
Pros: Single source of truth (filesystem)
Cons: Requires pack loader to be implemented first
Option 2: Dual Approach (Recommended)
Keep SQL seed script for initial setup, add filesystem loader for development/updates.
Pros: Works immediately, smooth migration path
Cons: Need to maintain both during transition
Implementation:
- Keep existing SQL seed script for now
- Implement pack loader service
- Add CLI command:
attune pack reload core - Eventually replace SQL seed with filesystem loading
Testing Plan
Unit Tests
- Pack manifest parsing
- Component metadata parsing
- Path resolution
- Environment variable preparation
Integration Tests
-
Pack Loading
- Load core pack from filesystem
- Verify database registration
- Validate component metadata
-
Action Execution
- Execute
core.echowith parameters - Execute
core.http_requestwith mock server - Verify environment variable passing
- Capture stdout/stderr correctly
- Execute
-
Sensor Execution
- Run
core.interval_timer_sensor - Verify event emission
- Check trigger firing logic
- Run
End-to-End Tests
- Create rule with
core.intervaltimertrigger - Verify rule fires and executes
core.echoaction - Check execution logs and results
Dependencies
Rust Crates
[dependencies]
serde_yaml = "0.9" # Parse YAML files
walkdir = "2.4" # Traverse pack directories
tokio = { version = "1", features = ["process"] } # Async process execution
System Dependencies
- Shell (bash/sh) for shell actions
- Python 3.8+ for Python actions
- Python packages:
requests>=2.28.0,croniter>=1.4.0
Rollout Plan
Week 1: Pack Loader Implementation
- Create
pack_loadermodule inattune_common - Implement manifest and component parsers
- Add database registration functions
- Write unit tests
Week 2: Worker Integration
- Add action path resolution
- Implement environment variable preparation
- Update action execution to use filesystem
- Add integration tests
Week 3: Sensor Integration
- Add sensor path resolution
- Implement sensor process management
- Update event creation from sensor output
- Add integration tests
Week 4: Testing & Documentation
- End-to-end testing
- CLI commands for pack management
- Update deployment documentation
- Performance testing
Success Criteria
- ✅ Core pack loaded from filesystem on startup
- ✅ Actions execute successfully from pack directory
- ✅ Sensors run and emit events correctly
- ✅ Environment variables passed properly to actions/sensors
- ✅ Database contains correct metadata for all components
- ✅ No regression in existing functionality
- ✅ Integration tests pass
- ✅ Documentation updated
Related Documentation
packs/core/README.md- Core pack usage guidedocs/pack-structure.md- Pack structure referencedocs/pack-management-architecture.md- Architecture overviewdocs/worker-service.md- Worker service documentationdocs/sensor-service.md- Sensor service documentation
Open Questions
- Runtime Registration: Should we create runtime entries in the database for each runner type (shell, python)?
- Pack Versioning: How to handle pack updates? Replace existing entries or keep version history?
- Pack Dependencies: How to handle dependencies between packs?
- Pack Registry: Future external pack registry integration?
- Hot Reload: Should packs be hot-reloadable without service restart?
Conclusion
Integrating the filesystem-based core pack requires:
- Pack loader service to parse and register components
- Worker service updates to execute actions from filesystem
- Sensor service updates to run sensors from filesystem
- Startup integration to load packs automatically
The implementation can be phased, starting with the pack loader, then worker integration, then sensor integration. The existing SQL seed script can remain as a fallback during the transition.