1use crate::{
8 domain::{AgentError, AgentErrorKind, AgentId, DestinationRef, RunId, SourceRef},
9 error::{CausalIds, RetryClassification},
10 hook_ports::HookExecutorRegistry,
11 hook_records::{HookMutationJournalPlan, HookRecord},
12 journal::JournalCursor,
13 journal_ports::RunJournal,
14 package::RuntimePackageFingerprint,
15 package_hooks::{
16 HookCancellationToken, HookInput, HookPoint, HookResponse, HookResponseClass, HookSpec,
17 HookView, ordered_hooks_for_point, validate_hook_specs,
18 },
19};
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct HookLifecycleContext {
25 pub run_id: RunId,
27 pub agent_id: AgentId,
29 pub turn_id: Option<crate::domain::TurnId>,
31 pub attempt_id: Option<crate::domain::AttemptId>,
34 pub source: SourceRef,
37 pub destination: Option<DestinationRef>,
40 pub package_fingerprint: RuntimePackageFingerprint,
43 pub cancellation: HookCancellationToken,
45}
46
47impl HookLifecycleContext {
48 pub fn new(
52 run_id: RunId,
53 agent_id: AgentId,
54 source: SourceRef,
55 package_fingerprint: RuntimePackageFingerprint,
56 ) -> Self {
57 Self {
58 run_id,
59 agent_id,
60 turn_id: None,
61 attempt_id: None,
62 source,
63 destination: None,
64 package_fingerprint,
65 cancellation: HookCancellationToken::default(),
66 }
67 }
68}
69
70pub struct HookLifecycleCoordinator<'a, R, J>
73where
74 R: HookExecutorRegistry,
75 J: RunJournal,
76{
77 registry: &'a R,
78 journal: &'a J,
79 next_journal_seq: u64,
80}
81
82impl<'a, R, J> HookLifecycleCoordinator<'a, R, J>
83where
84 R: HookExecutorRegistry,
85 J: RunJournal,
86{
87 pub fn new(registry: &'a R, journal: &'a J, next_journal_seq: u64) -> Self {
91 Self {
92 registry,
93 journal,
94 next_journal_seq,
95 }
96 }
97
98 pub fn validate_package_hooks(&self, specs: &[HookSpec]) -> Result<(), AgentError> {
102 validate_package_hooks(specs, self.registry)
103 }
104
105 pub fn invoke_point(
109 &mut self,
110 specs: &[HookSpec],
111 point: HookPoint,
112 context: HookLifecycleContext,
113 view: HookView,
114 ) -> Result<Vec<HookInvocationOutcome>, AgentError> {
115 let hooks = ordered_hooks_for_point(specs, point);
116 let mut outcomes = Vec::with_capacity(hooks.len());
117 for spec in hooks {
118 outcomes.push(self.invoke_one(&spec, &context, view.clone())?);
119 }
120 Ok(outcomes)
121 }
122
123 fn invoke_one(
124 &mut self,
125 spec: &HookSpec,
126 context: &HookLifecycleContext,
127 view: HookView,
128 ) -> Result<HookInvocationOutcome, AgentError> {
129 spec.validate()?;
130 let invocation_id = format!("hook.invocation.{}", self.next_journal_seq);
131 if context.cancellation.cancelled {
132 return Ok(HookInvocationOutcome::from_record(
133 spec,
134 HookInvocationStatus::Cancelled,
135 HookRecord::cancelled(spec, invocation_id),
136 ));
137 }
138
139 let executor = self.registry.resolve(&spec.executor_ref).ok_or_else(|| {
140 fail_closed_error(
141 spec,
142 context,
143 AgentErrorKind::HostConfigurationNeeded,
144 "missing hook executor ref",
145 )
146 })?;
147 let input = HookInput {
148 hook_id: spec.hook_id.clone(),
149 point: spec.point.clone(),
150 run_id: context.run_id.clone(),
151 agent_id: context.agent_id.clone(),
152 turn_id: context.turn_id.clone(),
153 attempt_id: context.attempt_id.clone(),
154 source: SourceRef::with_kind(crate::domain::SourceKind::Hook, spec.hook_id.as_str()),
155 destination: context.destination.clone(),
156 package_fingerprint: context.package_fingerprint.clone(),
157 view,
158 policy_refs: vec![spec.policy_ref.clone()],
159 cancellation: context.cancellation.clone(),
160 };
161
162 let hook_result = executor.invoke(input);
163 let execution = match hook_result {
164 Ok(execution) => execution,
165 Err(error) if !spec.is_security_relevant() && !spec.failure.fails_closed() => {
166 return Ok(HookInvocationOutcome::from_record(
167 spec,
168 HookInvocationStatus::FailedOpen,
169 HookRecord::failed(spec, invocation_id, error.context().message),
170 ));
171 }
172 Err(error) => {
173 return Err(fail_closed_error(
174 spec,
175 context,
176 error.kind(),
177 error.context().message,
178 ));
179 }
180 };
181
182 if execution.elapsed_ms > spec.timeout.timeout_ms {
183 return self.handle_timeout(spec, context, invocation_id, execution.elapsed_ms);
184 }
185
186 self.handle_response(spec, context, invocation_id, execution.response)
187 }
188
189 fn handle_timeout(
190 &self,
191 spec: &HookSpec,
192 context: &HookLifecycleContext,
193 invocation_id: String,
194 elapsed_ms: u64,
195 ) -> Result<HookInvocationOutcome, AgentError> {
196 if !spec.is_security_relevant() && !spec.failure.fails_closed() {
197 return Ok(HookInvocationOutcome::from_record(
198 spec,
199 HookInvocationStatus::TimedOutFailOpen,
200 HookRecord::timeout(spec, invocation_id, elapsed_ms),
201 ));
202 }
203 Err(fail_closed_error(
204 spec,
205 context,
206 AgentErrorKind::Timeout,
207 "hook timed out before guarded lifecycle transition",
208 ))
209 }
210
211 fn handle_response(
212 &mut self,
213 spec: &HookSpec,
214 context: &HookLifecycleContext,
215 invocation_id: String,
216 response: HookResponse,
217 ) -> Result<HookInvocationOutcome, AgentError> {
218 let response_class = response.response_class();
219 if !spec
220 .point
221 .allowed_response_classes()
222 .contains(&response_class)
223 {
224 return Ok(HookInvocationOutcome::rejected(
225 spec,
226 invocation_id,
227 response_class,
228 HookInvocationStatus::RejectedPointMatrix,
229 ));
230 }
231 if !spec
232 .mutation_rights
233 .allows_response_class(response_class.clone())
234 {
235 return Ok(HookInvocationOutcome::rejected(
236 spec,
237 invocation_id,
238 response_class,
239 HookInvocationStatus::RejectedMutationRight,
240 ));
241 }
242
243 if !response.changes_behavior() {
244 return Ok(HookInvocationOutcome::from_record(
245 spec,
246 HookInvocationStatus::Completed,
247 HookRecord::completed(spec, invocation_id, 0),
248 ));
249 }
250
251 let journal_seq = self.next_seq_block(3);
252 let record_id = format!("journal.hook.{}.{}", spec.hook_id.as_str(), journal_seq);
253 let plan = HookMutationJournalPlan::accepted_response(
254 journal_seq,
255 record_id,
256 context.run_id.clone(),
257 context.agent_id.clone(),
258 context.source.clone(),
259 spec,
260 invocation_id.clone(),
261 response_class.clone(),
262 context.package_fingerprint.as_str(),
263 );
264 self.journal
265 .append(plan.hook_journal_record.clone())
266 .map_err(|error| {
267 fail_closed_error(
268 spec,
269 context,
270 error.kind(),
271 "hook response journal append failed before apply",
272 )
273 })?;
274 let _intent_cursor = self
275 .journal
276 .append(plan.intent_journal_record.clone())
277 .map_err(|error| {
278 fail_closed_error(
279 spec,
280 context,
281 error.kind(),
282 "hook mutation journal append failed before apply",
283 )
284 })?;
285 let terminal_cursor = self
286 .journal
287 .append(plan.result_journal_record.clone())
288 .map_err(|error| {
289 fail_closed_error(
290 spec,
291 context,
292 error.kind(),
293 "hook mutation terminal result append failed before apply",
294 )
295 })?;
296 Ok(HookInvocationOutcome {
297 hook_id: spec.hook_id.clone(),
298 status: HookInvocationStatus::AppliedJournaledMutation,
299 response_class: Some(response_class),
300 journal_cursor: Some(terminal_cursor),
301 journaled_before_apply: true,
302 record: plan.hook_record,
303 })
304 }
305
306 fn next_seq_block(&mut self, width: u64) -> u64 {
307 let seq = self.next_journal_seq;
308 self.next_journal_seq += width;
309 seq
310 }
311}
312
313pub fn validate_package_hooks<R>(specs: &[HookSpec], registry: &R) -> Result<(), AgentError>
317where
318 R: HookExecutorRegistry,
319{
320 validate_hook_specs(specs)?;
321 for spec in specs {
322 if !registry.contains(&spec.executor_ref) {
323 return Err(AgentError::new(
324 AgentErrorKind::InvalidPackage,
325 RetryClassification::HostConfigurationNeeded,
326 format!(
327 "hook executor {} is not resolved before start_run",
328 spec.executor_ref.as_str()
329 ),
330 ));
331 }
332 }
333 Ok(())
334}
335
336#[derive(Clone, Debug, Eq, PartialEq)]
337pub struct HookInvocationOutcome {
340 pub hook_id: crate::package_hooks::HookId,
342 pub status: HookInvocationStatus,
344 pub response_class: Option<HookResponseClass>,
347 pub journal_cursor: Option<JournalCursor>,
350 pub journaled_before_apply: bool,
353 pub record: HookRecord,
355}
356
357impl HookInvocationOutcome {
358 fn from_record(spec: &HookSpec, status: HookInvocationStatus, record: HookRecord) -> Self {
359 Self {
360 hook_id: spec.hook_id.clone(),
361 status,
362 response_class: None,
363 journal_cursor: None,
364 journaled_before_apply: false,
365 record,
366 }
367 }
368
369 fn rejected(
370 spec: &HookSpec,
371 invocation_id: String,
372 response_class: HookResponseClass,
373 status: HookInvocationStatus,
374 ) -> Self {
375 let decision = match status {
376 HookInvocationStatus::RejectedMutationRight => {
377 crate::hook_records::HookResponseDecision::RejectedMutationRight
378 }
379 HookInvocationStatus::RejectedPointMatrix => {
380 crate::hook_records::HookResponseDecision::RejectedPointMatrix
381 }
382 _ => crate::hook_records::HookResponseDecision::RejectedPolicy,
383 };
384 Self {
385 hook_id: spec.hook_id.clone(),
386 status,
387 response_class: Some(response_class.clone()),
388 journal_cursor: None,
389 journaled_before_apply: false,
390 record: HookRecord::response_decision(
391 spec,
392 invocation_id,
393 decision,
394 response_class,
395 Vec::new(),
396 ),
397 }
398 }
399}
400
401#[derive(Clone, Debug, Eq, PartialEq)]
402pub enum HookInvocationStatus {
405 Completed,
407 AppliedJournaledMutation,
409 RejectedMutationRight,
411 RejectedPointMatrix,
413 TimedOutFailOpen,
415 FailedOpen,
417 Cancelled,
419}
420
421fn fail_closed_error(
422 spec: &HookSpec,
423 context: &HookLifecycleContext,
424 kind: AgentErrorKind,
425 message: impl Into<String>,
426) -> AgentError {
427 let kind = match spec.failure {
428 crate::package_hooks::HookFailurePolicy::Deny => AgentErrorKind::PolicyDenial,
429 crate::package_hooks::HookFailurePolicy::InterruptRun
430 | crate::package_hooks::HookFailurePolicy::FailRun => AgentErrorKind::HookFailure,
431 crate::package_hooks::HookFailurePolicy::FailOpenObserveOnly => kind,
432 };
433 AgentError::new(kind, RetryClassification::RepairNeeded, message).with_causal_ids(CausalIds {
434 run_id: Some(context.run_id.clone()),
435 ..CausalIds::default()
436 })
437}