Skip to main content

pulse_system_types/
lib.rs

1//! Shared contract types for the pulse-null plugin ecosystem.
2//!
3//! This crate defines the types that plugin crates and pulse-null both depend on.
4//! It contains no logic — just structs, enums, and trait-adjacent types that form
5//! the contract between the orchestrator and its plugins.
6
7pub mod llm;
8pub mod monitoring;
9pub mod plugin;
10pub mod tool;
11
12use serde::{Deserialize, Serialize};
13use std::fmt;
14
15/// Plugin health status, returned by plugin `health()` methods.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "status", content = "message")]
18pub enum HealthStatus {
19    Healthy,
20    Degraded(String),
21    Down(String),
22}
23
24impl fmt::Display for HealthStatus {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Healthy => write!(f, "healthy"),
28            Self::Degraded(msg) => write!(f, "degraded: {msg}"),
29            Self::Down(msg) => write!(f, "down: {msg}"),
30        }
31    }
32}
33
34/// Plugin metadata — identity and version.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PluginMeta {
37    pub name: String,
38    pub version: String,
39    pub description: String,
40}
41
42/// A setup prompt for the init wizard.
43///
44/// Plugins return a list of these to tell pulse-null what configuration
45/// values they need during first-time setup.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SetupPrompt {
48    /// Config key (e.g. "twilio_account_sid")
49    pub key: String,
50    /// Human-readable question (e.g. "Twilio Account SID:")
51    pub question: String,
52    /// Default value if the user presses enter
53    pub default: Option<String>,
54    /// Whether a value is required
55    pub required: bool,
56    /// Whether the value is sensitive (mask input)
57    pub secret: bool,
58}
59
60/// A scheduled task definition that plugins can contribute.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ScheduledTask {
63    pub id: String,
64    pub name: String,
65    /// Cron expression (6-field: sec min hour dom month dow)
66    pub cron: String,
67    /// Trust channel for the session
68    pub channel: String,
69    /// Prompt sent to the LLM
70    pub prompt: String,
71    /// How to route the output
72    #[serde(default)]
73    pub output_routing: OutputRouting,
74    /// Whether this task is active
75    #[serde(default = "default_true")]
76    pub enabled: bool,
77    /// Who created this task
78    #[serde(default)]
79    pub created_by: TaskCreator,
80    /// Optional evaluator type — if set, the scheduler checks preconditions
81    /// before firing. Currently supported: "pipeline" (checks if pipeline
82    /// documents have changed since last fire).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub evaluator: Option<String>,
85}
86
87fn default_true() -> bool {
88    true
89}
90
91/// How to route a scheduled task's output.
92#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
93#[serde(rename_all = "lowercase")]
94pub enum OutputRouting {
95    #[default]
96    Silent,
97    Share,
98    Call,
99}
100
101/// Who created the task.
102#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
103#[serde(rename_all = "lowercase")]
104pub enum TaskCreator {
105    #[default]
106    System,
107    Entity,
108    User,
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn health_status_display() {
117        assert_eq!(HealthStatus::Healthy.to_string(), "healthy");
118        assert_eq!(
119            HealthStatus::Degraded("high latency".into()).to_string(),
120            "degraded: high latency"
121        );
122        assert_eq!(
123            HealthStatus::Down("connection refused".into()).to_string(),
124            "down: connection refused"
125        );
126    }
127
128    #[test]
129    fn health_status_serializes() {
130        let json = serde_json::to_string(&HealthStatus::Healthy).unwrap();
131        assert!(json.contains("Healthy"));
132
133        let json = serde_json::to_string(&HealthStatus::Degraded("slow".into())).unwrap();
134        assert!(json.contains("Degraded"));
135        assert!(json.contains("slow"));
136    }
137
138    #[test]
139    fn plugin_meta_roundtrip() {
140        let meta = PluginMeta {
141            name: "voice-echo".into(),
142            version: "0.7.0".into(),
143            description: "Phone calls via Twilio".into(),
144        };
145        let json = serde_json::to_string(&meta).unwrap();
146        let back: PluginMeta = serde_json::from_str(&json).unwrap();
147        assert_eq!(back.name, "voice-echo");
148        assert_eq!(back.version, "0.7.0");
149    }
150
151    #[test]
152    fn setup_prompt_with_defaults() {
153        let prompt = SetupPrompt {
154            key: "host".into(),
155            question: "Bind address:".into(),
156            default: Some("0.0.0.0".into()),
157            required: false,
158            secret: false,
159        };
160        assert_eq!(prompt.default.as_deref(), Some("0.0.0.0"));
161        assert!(!prompt.secret);
162    }
163
164    #[test]
165    fn setup_prompt_secret() {
166        let prompt = SetupPrompt {
167            key: "api_key".into(),
168            question: "API Key:".into(),
169            default: None,
170            required: true,
171            secret: true,
172        };
173        assert!(prompt.secret);
174        assert!(prompt.required);
175        assert!(prompt.default.is_none());
176    }
177
178    #[test]
179    fn scheduled_task_defaults() {
180        let json = r#"{
181            "id": "morning",
182            "name": "Morning check",
183            "cron": "0 0 8 * * *",
184            "channel": "reflection",
185            "prompt": "Good morning."
186        }"#;
187        let task: ScheduledTask = serde_json::from_str(json).unwrap();
188        assert_eq!(task.output_routing, OutputRouting::Silent);
189        assert!(task.enabled);
190        assert_eq!(task.created_by, TaskCreator::System);
191        assert!(task.evaluator.is_none());
192    }
193
194    #[test]
195    fn scheduled_task_with_evaluator() {
196        let json = r#"{
197            "id": "reflection",
198            "name": "Reflection Window",
199            "cron": "0 30 4 * * *",
200            "channel": "system",
201            "prompt": "Reflect.",
202            "evaluator": "pipeline"
203        }"#;
204        let task: ScheduledTask = serde_json::from_str(json).unwrap();
205        assert_eq!(task.evaluator.as_deref(), Some("pipeline"));
206    }
207
208    #[test]
209    fn output_routing_variants() {
210        let json = r#""share""#;
211        let routing: OutputRouting = serde_json::from_str(json).unwrap();
212        assert_eq!(routing, OutputRouting::Share);
213    }
214}