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///
103/// Serialized in lowercase (`"system"`, `"pulse"`, `"user"`). The legacy
104/// `"entity"` value written before 0.7.0 still deserializes as
105/// [`TaskCreator::Pulse`].
106#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
107#[serde(rename_all = "lowercase")]
108pub enum TaskCreator {
109    /// Built into the runtime or contributed by a plugin.
110    #[default]
111    System,
112    /// Created by the pulse itself at runtime.
113    #[serde(alias = "entity")]
114    Pulse,
115    /// Created by a human operator.
116    User,
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn health_status_display() {
125        assert_eq!(HealthStatus::Healthy.to_string(), "healthy");
126        assert_eq!(
127            HealthStatus::Degraded("high latency".into()).to_string(),
128            "degraded: high latency"
129        );
130        assert_eq!(
131            HealthStatus::Down("connection refused".into()).to_string(),
132            "down: connection refused"
133        );
134    }
135
136    #[test]
137    fn health_status_serializes() {
138        let json = serde_json::to_string(&HealthStatus::Healthy).unwrap();
139        assert!(json.contains("Healthy"));
140
141        let json = serde_json::to_string(&HealthStatus::Degraded("slow".into())).unwrap();
142        assert!(json.contains("Degraded"));
143        assert!(json.contains("slow"));
144    }
145
146    #[test]
147    fn plugin_meta_roundtrip() {
148        let meta = PluginMeta {
149            name: "voice-echo".into(),
150            version: "0.7.0".into(),
151            description: "Phone calls via Twilio".into(),
152        };
153        let json = serde_json::to_string(&meta).unwrap();
154        let back: PluginMeta = serde_json::from_str(&json).unwrap();
155        assert_eq!(back.name, "voice-echo");
156        assert_eq!(back.version, "0.7.0");
157    }
158
159    #[test]
160    fn setup_prompt_with_defaults() {
161        let prompt = SetupPrompt {
162            key: "host".into(),
163            question: "Bind address:".into(),
164            default: Some("0.0.0.0".into()),
165            required: false,
166            secret: false,
167        };
168        assert_eq!(prompt.default.as_deref(), Some("0.0.0.0"));
169        assert!(!prompt.secret);
170    }
171
172    #[test]
173    fn setup_prompt_secret() {
174        let prompt = SetupPrompt {
175            key: "api_key".into(),
176            question: "API Key:".into(),
177            default: None,
178            required: true,
179            secret: true,
180        };
181        assert!(prompt.secret);
182        assert!(prompt.required);
183        assert!(prompt.default.is_none());
184    }
185
186    #[test]
187    fn scheduled_task_defaults() {
188        let json = r#"{
189            "id": "morning",
190            "name": "Morning check",
191            "cron": "0 0 8 * * *",
192            "channel": "reflection",
193            "prompt": "Good morning."
194        }"#;
195        let task: ScheduledTask = serde_json::from_str(json).unwrap();
196        assert_eq!(task.output_routing, OutputRouting::Silent);
197        assert!(task.enabled);
198        assert_eq!(task.created_by, TaskCreator::System);
199        assert!(task.evaluator.is_none());
200    }
201
202    #[test]
203    fn scheduled_task_with_evaluator() {
204        let json = r#"{
205            "id": "reflection",
206            "name": "Reflection Window",
207            "cron": "0 30 4 * * *",
208            "channel": "system",
209            "prompt": "Reflect.",
210            "evaluator": "pipeline"
211        }"#;
212        let task: ScheduledTask = serde_json::from_str(json).unwrap();
213        assert_eq!(task.evaluator.as_deref(), Some("pipeline"));
214    }
215
216    #[test]
217    fn task_creator_pulse_serializes_as_pulse() {
218        let json = serde_json::to_string(&TaskCreator::Pulse).unwrap();
219        assert_eq!(json, r#""pulse""#);
220        let back: TaskCreator = serde_json::from_str(&json).unwrap();
221        assert_eq!(back, TaskCreator::Pulse);
222    }
223
224    #[test]
225    fn task_creator_legacy_entity_deserializes_as_pulse() {
226        let creator: TaskCreator = serde_json::from_str(r#""entity""#).unwrap();
227        assert_eq!(creator, TaskCreator::Pulse);
228    }
229
230    #[test]
231    fn task_creator_rejects_unknown_value() {
232        assert!(serde_json::from_str::<TaskCreator>(r#""robot""#).is_err());
233    }
234
235    #[test]
236    fn scheduled_task_legacy_entity_creator_loads_and_rewrites_as_pulse() {
237        let legacy = r#"{
238            "id": "self-made",
239            "name": "Self-made task",
240            "cron": "0 0 9 * * *",
241            "channel": "reflection",
242            "prompt": "Check in.",
243            "created_by": "entity"
244        }"#;
245        let task: ScheduledTask = serde_json::from_str(legacy).unwrap();
246        assert_eq!(task.created_by, TaskCreator::Pulse);
247
248        let rewritten: serde_json::Value = serde_json::to_value(&task).unwrap();
249        assert_eq!(rewritten["created_by"], "pulse");
250
251        let back: ScheduledTask = serde_json::from_value(rewritten).unwrap();
252        assert_eq!(back.created_by, TaskCreator::Pulse);
253    }
254
255    #[test]
256    fn output_routing_variants() {
257        let json = r#""share""#;
258        let routing: OutputRouting = serde_json::from_str(json).unwrap();
259        assert_eq!(routing, OutputRouting::Share);
260    }
261}