返回 CodeWhale
hooks.rs
根目录 / crates / memory / src / hooks.rs
1 //! Typed, pure hook planning. Hooks return intentions, not shell commands.
2 //! Permission checks remain with the host. Completion receipts are recorded only
3 //! AFTER the host finishes an operation; retries use that operation's own key.
4 use crate::store::Tx;
5 use crate::{Access, Capability, Error, Result, Scope, Store, policy};
6 use rusqlite::{OptionalExtension, params};
7 use serde::{Deserialize, Serialize};
8
9 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10 #[serde(rename_all = "snake_case")]
11 pub enum Boundary {
12 SessionStart,
13 TaskStart,
14 BeforePlanning,
15 BeforeDispatch,
16 BeforeCompaction,
17 AfterCompaction,
18 RepositoryChanged,
19 SubagentSpawn,
20 SubagentJoin,
21 UserRemember,
22 UserCorrection,
23 TestFinished,
24 TaskFinished,
25 SessionClose,
26 }
27 #[derive(Debug, Clone, Serialize, Deserialize)]
28 #[serde(deny_unknown_fields)]
29 pub struct HookEvent {
30 pub id: String,
31 pub boundary: Boundary,
32 pub trace_id: String,
33 pub sequence: u64,
34 pub observed_at: i64,
35 #[serde(default)]
36 pub explicit_user_request: bool,
37 #[serde(default)]
38 pub success: Option<bool>,
39 }
40 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41 #[serde(rename_all = "snake_case")]
42 pub enum Intent {
43 Recall,
44 ValidatePreparedContext,
45 SaveCheckpoint,
46 ReconcilePendingOperations,
47 RefreshRepositoryFingerprints,
48 AttenuateSubagentScope,
49 ProposeCandidate,
50 RequestCorrectionReview,
51 AttachOutcomeEvidence,
52 FlushOutbox,
53 }
54 #[derive(Debug, Clone, Serialize, Deserialize)]
55 pub struct HookPlan {
56 pub enabled: bool,
57 pub intents: Vec<Intent>,
58 pub approval_required: bool,
59 pub reason: String,
60 }
61 #[derive(Debug, Clone, Serialize, Deserialize)]
62 pub struct HookPolicy {
63 pub enabled: bool,
64 pub auto_candidates: bool,
65 pub recall_on_task: bool,
66 }
67 impl Default for HookPolicy {
68 fn default() -> Self {
69 Self {
70 enabled: false,
71 auto_candidates: false,
72 recall_on_task: true,
73 }
74 }
75 }
76
77 pub fn plan(policy: &HookPolicy, event: &HookEvent) -> HookPlan {
78 use Boundary::*;
79 use Intent::*;
80 if !policy.enabled {
81 return HookPlan {
82 enabled: false,
83 intents: vec![],
84 approval_required: false,
85 reason: "disabled_no_io".into(),
86 };
87 }
88 let mut approval = false;
89 let intents = match event.boundary {
90 SessionStart => vec![ReconcilePendingOperations],
91 TaskStart | BeforePlanning if policy.recall_on_task => vec![Recall],
92 BeforeDispatch => vec![ValidatePreparedContext],
93 BeforeCompaction => vec![SaveCheckpoint],
94 AfterCompaction => vec![ReconcilePendingOperations, Recall],
95 RepositoryChanged => vec![RefreshRepositoryFingerprints],
96 SubagentSpawn => vec![AttenuateSubagentScope, Recall],
97 SubagentJoin => vec![AttachOutcomeEvidence],
98 UserRemember => {
99 approval = true;
100 if event.explicit_user_request {
101 vec![ProposeCandidate]
102 } else {
103 vec![]
104 }
105 }
106 UserCorrection => {
107 approval = true;
108 vec![RequestCorrectionReview]
109 }
110 TestFinished | TaskFinished => {
111 let mut jobs = vec![AttachOutcomeEvidence];
112 if policy.auto_candidates && event.success == Some(true) {
113 jobs.push(ProposeCandidate);
114 approval = true;
115 }
116 jobs
117 }
118 SessionClose => vec![SaveCheckpoint, FlushOutbox],
119 _ => vec![],
120 };
121 HookPlan {
122 enabled: true,
123 intents,
124 approval_required: approval,
125 reason: "typed_host_boundary".into(),
126 }
127 }
128 impl Store {
129 /// Durable deduplication of COMPLETED host hooks. This does not execute the
130 /// plan, promise exactly-once external effects, or install an OS observer.
131 pub fn record_completed_hook(
132 &self,
133 access: &Access,
134 scope: &Scope,
135 event: &HookEvent,
136 ) -> Result<bool> {
137 access.write(scope, Capability::ContextDispatch)?;
138 policy::bounded(&event.id, "hook id", 128, true)?;
139 policy::bounded(&event.trace_id, "trace id", 128, true)?;
140 if event.observed_at < 0 || event.observed_at > self.timestamp().saturating_add(300) {
141 return Err(Error::Invalid("invalid hook observation time".into()));
142 }
143 let digest = policy::sha256(&serde_json::to_vec(event)?);
144 let tx = Tx::begin(&self.conn)?;
145 let old: Option<String> = self
146 .conn
147 .query_row(
148 "SELECT input_hash FROM memory_hook_receipts WHERE scope=?1 AND event_id=?2",
149 params![scope.key()?, event.id],
150 |r| r.get(0),
151 )
152 .optional()?;
153 if let Some(old) = old {
154 return if old == digest {
155 Ok(false)
156 } else {
157 Err(Error::IdempotencyConflict)
158 };
159 }
160 self.conn.execute("INSERT INTO memory_hook_receipts(scope,event_id,input_hash,recorded_at) VALUES(?1,?2,?3,?4)",params![scope.key()?,event.id,digest,self.timestamp()])?;
161 tx.commit()?;
162 Ok(true)
163 }
164 }
165 #[cfg(test)]
166 mod tests {
167 use super::*;
168 fn event(boundary: Boundary) -> HookEvent {
169 HookEvent {
170 id: "e".into(),
171 boundary,
172 trace_id: "r".into(),
173 sequence: 1,
174 observed_at: 1,
175 explicit_user_request: false,
176 success: None,
177 }
178 }
179 #[test]
180 fn off_is_empty() {
181 assert!(
182 plan(&HookPolicy::default(), &event(Boundary::SessionStart))
183 .intents
184 .is_empty()
185 );
186 }
187 #[test]
188 fn dispatch_never_auto_approves() {
189 let p = HookPolicy {
190 enabled: true,
191 ..Default::default()
192 };
193 assert_eq!(
194 plan(&p, &event(Boundary::BeforeDispatch)).intents,
195 vec![Intent::ValidatePreparedContext]
196 );
197 }
198 #[test]
199 fn a_success_is_not_a_lesson() {
200 let p = HookPolicy {
201 enabled: true,
202 auto_candidates: true,
203 ..Default::default()
204 };
205 let mut e = event(Boundary::TestFinished);
206 e.success = Some(true);
207 let result = plan(&p, &e);
208 assert!(result.approval_required);
209 assert!(result.intents.contains(&Intent::ProposeCandidate));
210 }
211 #[test]
212 fn restore_reconciles_before_recall() {
213 let p = HookPolicy {
214 enabled: true,
215 ..Default::default()
216 };
217 assert_eq!(
218 plan(&p, &event(Boundary::AfterCompaction)).intents,
219 vec![Intent::ReconcilePendingOperations, Intent::Recall]
220 );
221 }
222 }
223
223 lines RUST