1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::{Actor, Pattern, Scope};
15
16pub const SIGNAL_SCHEMA_VERSION: u32 = 1;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Signal {
39 pub id: Uuid,
40 pub emitted_at: DateTime<Utc>,
41 pub actor: Actor,
42 pub target: SignalTarget,
43 pub kind: SignalKind,
44 pub scope: Scope,
45 #[serde(default = "default_confidence")]
48 pub confidence: f64,
49 #[serde(default = "current_schema_version")]
53 pub schema_version: u32,
54}
55
56fn default_confidence() -> f64 {
57 1.0
58}
59fn current_schema_version() -> u32 {
60 SIGNAL_SCHEMA_VERSION
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SignalBatch {
70 pub batch_id: Uuid,
71 #[serde(default = "current_schema_version")]
73 pub schema_version: u32,
74 pub signals: Vec<Signal>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SignalBatchResponse {
80 pub accepted: usize,
81 pub deduplicated: usize,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "snake_case")]
87pub enum SignalTarget {
88 Pattern { name: String, scope: Scope },
90 NewDraftPattern { payload: Box<Pattern> },
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(tag = "type", rename_all = "snake_case")]
98pub enum SignalKind {
99 ExecutionSuccess,
101 ExecutionFailure { error: String },
103 UserOverrideAtBreakpoint { reason: Option<String> },
105 AutoFixApplied { step: String },
107 NewPatternProposal { origin_context: String },
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::ActorSource;
115
116 fn sample_actor() -> Actor {
117 Actor {
118 source: ActorSource::CommanderDaemon,
119 native_id: "svc-1".into(),
120 display_name: None,
121 resolved_user_id: None,
122 }
123 }
124
125 fn sample_signal() -> Signal {
126 Signal {
127 id: Uuid::new_v4(),
128 emitted_at: Utc::now(),
129 actor: sample_actor(),
130 target: SignalTarget::Pattern {
131 name: "rust-err-handling".into(),
132 scope: Scope::Personal,
133 },
134 kind: SignalKind::ExecutionSuccess,
135 scope: Scope::Personal,
136 confidence: 0.9,
137 schema_version: SIGNAL_SCHEMA_VERSION,
138 }
139 }
140
141 #[test]
142 fn signal_roundtrip_execution_success() {
143 let s = sample_signal();
144 let y = serde_yaml::to_string(&s).unwrap();
145 let back: Signal = serde_yaml::from_str(&y).unwrap();
146 assert_eq!(back.id, s.id);
147 assert!(matches!(back.kind, SignalKind::ExecutionSuccess));
148 assert!((back.confidence - 0.9).abs() < 1e-9);
149 }
150
151 #[test]
152 fn signal_confidence_defaults_to_one() {
153 let y = r#"
154id: 00000000-0000-0000-0000-000000000001
155emitted_at: 2026-04-18T10:00:00Z
156actor: { source: commander_daemon, native_id: x }
157target: { kind: pattern, name: foo, scope: { kind: personal } }
158kind: { type: execution_success }
159scope: { kind: personal }
160"#;
161 let s: Signal = serde_yaml::from_str(y).unwrap();
162 assert!((s.confidence - 1.0).abs() < 1e-9);
163 assert_eq!(s.schema_version, 1);
164 }
165
166 #[test]
167 fn signal_kind_execution_failure_carries_error() {
168 let s = Signal {
169 kind: SignalKind::ExecutionFailure {
170 error: "db timeout".into(),
171 },
172 ..sample_signal()
173 };
174 let y = serde_yaml::to_string(&s).unwrap();
175 let back: Signal = serde_yaml::from_str(&y).unwrap();
176 match back.kind {
177 SignalKind::ExecutionFailure { error } => assert_eq!(error, "db timeout"),
178 _ => panic!("wrong variant"),
179 }
180 }
181
182 #[test]
183 fn signal_kind_override_with_reason() {
184 let y = r#"
185id: 00000000-0000-0000-0000-000000000002
186emitted_at: 2026-04-18T10:00:00Z
187actor: { source: slack, native_id: U999 }
188target: { kind: pattern, name: x, scope: { kind: personal } }
189kind: { type: user_override_at_breakpoint, reason: "wrong step" }
190scope: { kind: personal }
191"#;
192 let s: Signal = serde_yaml::from_str(y).unwrap();
193 match s.kind {
194 SignalKind::UserOverrideAtBreakpoint { reason } => {
195 assert_eq!(reason.as_deref(), Some("wrong step"));
196 }
197 _ => panic!("wrong variant"),
198 }
199 }
200
201 #[test]
202 fn signal_kind_override_without_reason() {
203 let y = r#"
204id: 00000000-0000-0000-0000-000000000003
205emitted_at: 2026-04-18T10:00:00Z
206actor: { source: slack, native_id: U999 }
207target: { kind: pattern, name: x, scope: { kind: personal } }
208kind: { type: user_override_at_breakpoint }
209scope: { kind: personal }
210"#;
211 let s: Signal = serde_yaml::from_str(y).unwrap();
212 assert!(matches!(
213 s.kind,
214 SignalKind::UserOverrideAtBreakpoint { reason: None }
215 ));
216 }
217
218 #[test]
219 fn signal_kind_autofix() {
220 let s = Signal {
221 kind: SignalKind::AutoFixApplied {
222 step: "run-tests".into(),
223 },
224 ..sample_signal()
225 };
226 let y = serde_yaml::to_string(&s).unwrap();
227 let back: Signal = serde_yaml::from_str(&y).unwrap();
228 match back.kind {
229 SignalKind::AutoFixApplied { step } => assert_eq!(step, "run-tests"),
230 _ => panic!("wrong variant"),
231 }
232 }
233
234 #[test]
235 fn signal_kind_new_pattern_proposal() {
236 let s = Signal {
237 kind: SignalKind::NewPatternProposal {
238 origin_context: "slack DM from alice: use pnpm".into(),
239 },
240 ..sample_signal()
241 };
242 let y = serde_yaml::to_string(&s).unwrap();
243 let back: Signal = serde_yaml::from_str(&y).unwrap();
244 match back.kind {
245 SignalKind::NewPatternProposal { origin_context } => {
246 assert!(origin_context.contains("alice"));
247 }
248 _ => panic!("wrong variant"),
249 }
250 }
251
252 #[test]
253 fn signal_target_pattern_roundtrip() {
254 let p = SignalTarget::Pattern {
255 name: "foo".into(),
256 scope: Scope::Team {
257 team_id: "ops".into(),
258 },
259 };
260 let y = serde_yaml::to_string(&p).unwrap();
261 assert!(y.contains("kind: pattern"));
262 let back: SignalTarget = serde_yaml::from_str(&y).unwrap();
263 assert!(matches!(back, SignalTarget::Pattern { .. }));
264 }
265
266 #[test]
267 fn signal_with_new_draft_pattern_roundtrip() {
268 use crate::knowledge::KnowledgeBase;
269 use crate::pattern::{Content, Tier};
270
271 let kb = KnowledgeBase {
273 name: "draft-pat".into(),
274 description: "chat-extracted draft".into(),
275 content: Content::Plain("use pnpm not npm".into()),
276 tier: Tier::Session,
277 ..Default::default()
278 };
279 let pat = Pattern {
280 base: kb,
281 kind: None,
282 origin: None,
283 attachments: Vec::new(),
284 };
285
286 let sig = Signal {
287 id: Uuid::new_v4(),
288 emitted_at: Utc::now(),
289 actor: sample_actor(),
290 target: SignalTarget::NewDraftPattern {
291 payload: Box::new(pat.clone()),
292 },
293 kind: SignalKind::NewPatternProposal {
294 origin_context: "slack DM".into(),
295 },
296 scope: Scope::Personal,
297 confidence: 0.75,
298 schema_version: SIGNAL_SCHEMA_VERSION,
299 };
300 let y = serde_yaml::to_string(&sig).unwrap();
301 assert!(y.contains("kind: new_draft_pattern"));
302 let back: Signal = serde_yaml::from_str(&y).unwrap();
303 match back.target {
304 SignalTarget::NewDraftPattern { payload } => {
305 assert_eq!(payload.name, "draft-pat");
306 }
307 _ => panic!("expected NewDraftPattern variant"),
308 }
309 }
310
311 #[test]
312 fn schema_version_constant() {
313 assert_eq!(SIGNAL_SCHEMA_VERSION, 1);
314 }
315}