remem/cursor_hook/
stop.rs1use anyhow::{anyhow, Result};
18use serde_json::Value;
19
20use super::correlation_id;
21use super::identity::{
22 field_error, required_non_empty_string, validate_identity_with_required_conversation,
23 validate_workspace_root,
24};
25use super::input::{parse_outer_object, require_event_name};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CursorStopStatus {
30 Completed,
31 Aborted,
32}
33
34impl CursorStopStatus {
35 pub fn as_str(&self) -> &'static str {
36 match self {
37 CursorStopStatus::Completed => "completed",
38 CursorStopStatus::Aborted => "aborted",
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
45pub struct CursorStopEvent {
46 pub session_id: String,
47 pub workspace_root: String,
49 pub status: CursorStopStatus,
50 pub generation_id: String,
51 pub loop_count: u64,
53 pub transcript_path: Option<String>,
57}
58
59impl CursorStopEvent {
60 pub fn canonical_stop_key(&self) -> String {
62 format!(
63 "{}:{}:{}",
64 self.session_id, self.generation_id, self.loop_count
65 )
66 }
67}
68
69pub fn parse_stop_event(bytes: &[u8]) -> Result<CursorStopEvent> {
71 let object = parse_outer_object(bytes)?;
72 require_event_name(&object, "stop")?;
73 let session_id = validate_identity_with_required_conversation(&object)?;
74 let workspace_root = validate_workspace_root(&object)?;
75 let status = validate_stop_status(&object)?;
76 let generation_id = required_non_empty_string(&object, "generation_id")?;
77 let loop_count = validate_loop_count(&object)?;
78 let transcript_path = stop_transcript_path_field(&object)?;
79 Ok(CursorStopEvent {
80 session_id,
81 workspace_root,
82 status,
83 generation_id,
84 loop_count,
85 transcript_path,
86 })
87}
88
89fn validate_stop_status(object: &serde_json::Map<String, Value>) -> Result<CursorStopStatus> {
90 let status = required_non_empty_string(object, "status")?;
91 match status.as_str() {
92 "completed" => Ok(CursorStopStatus::Completed),
93 "aborted" => Ok(CursorStopStatus::Aborted),
94 other => Err(anyhow!(
95 "cursor stop status '{other}' is outside the approved set \
96 (completed, aborted); unobserved statuses fail closed with zero \
97 writes [correlation_id={}]",
98 correlation_id()
99 )),
100 }
101}
102
103fn validate_loop_count(object: &serde_json::Map<String, Value>) -> Result<u64> {
104 match object.get("loop_count") {
105 None => Err(field_error("loop_count", "missing")),
106 Some(Value::Null) => Err(field_error("loop_count", "null")),
107 Some(Value::Number(number)) => number
108 .as_u64()
109 .ok_or_else(|| field_error("loop_count", "not a non-negative integer")),
110 Some(_) => Err(field_error("loop_count", "wrong type")),
111 }
112}
113
114fn stop_transcript_path_field(object: &serde_json::Map<String, Value>) -> Result<Option<String>> {
119 match object.get("transcript_path") {
120 None | Some(Value::Null) => Ok(None),
121 Some(Value::String(path)) => Ok(Some(path.clone())),
122 Some(_) => Err(field_error("transcript_path", "wrong type")),
123 }
124}