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