| 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 |