1use crate::{
8 domain::{AgentError, AgentErrorKind, AgentId, DestinationRef, RunId, SourceRef},
9 error::{CausalIds, RetryClassification},
10 hook_ports::HookExecutorRegistry,
11 hook_records::{HookMutationJournalPlan, HookRecord, HookResponseDecision},
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 session_id: Option<crate::domain::SessionId>,
31 pub turn_id: Option<crate::domain::TurnId>,
33 pub attempt_id: Option<crate::domain::AttemptId>,
36 pub source: SourceRef,
39 pub destination: Option<DestinationRef>,
42 pub package_fingerprint: RuntimePackageFingerprint,
45 pub cancellation: HookCancellationToken,
47}
48
49impl HookLifecycleContext {
50 pub fn new(
54 run_id: RunId,
55 agent_id: AgentId,
56 source: SourceRef,
57 package_fingerprint: RuntimePackageFingerprint,
58 ) -> Self {
59 Self {
60 run_id,
61 agent_id,
62 session_id: None,
63 turn_id: None,
64 attempt_id: None,
65 source,
66 destination: None,
67 package_fingerprint,
68 cancellation: HookCancellationToken::default(),
69 }
70 }
71}
72
73pub struct HookLifecycleCoordinator<'a, R, J>
76where
77 R: HookExecutorRegistry + ?Sized,
78 J: RunJournal + ?Sized,
79{
80 registry: &'a R,
81 journal: &'a J,
82 next_journal_seq: u64,
83 sequence_allocator: Option<Box<dyn FnMut(u64) -> u64 + 'a>>,
84}
85
86impl<'a, R, J> HookLifecycleCoordinator<'a, R, J>
87where
88 R: HookExecutorRegistry + ?Sized,
89 J: RunJournal + ?Sized,
90{
91 pub fn new(registry: &'a R, journal: &'a J, next_journal_seq: u64) -> Self {
95 Self {
96 registry,
97 journal,
98 next_journal_seq,
99 sequence_allocator: None,
100 }
101 }
102
103 pub fn new_with_sequence_allocator<F>(
106 registry: &'a R,
107 journal: &'a J,
108 next_journal_seq: u64,
109 sequence_allocator: F,
110 ) -> Self
111 where
112 F: FnMut(u64) -> u64 + 'a,
113 {
114 Self {
115 registry,
116 journal,
117 next_journal_seq,
118 sequence_allocator: Some(Box::new(sequence_allocator)),
119 }
120 }
121
122 pub fn validate_package_hooks(&self, specs: &[HookSpec]) -> Result<(), AgentError> {
126 validate_package_hooks(specs, self.registry)
127 }
128
129 pub fn next_journal_seq(&self) -> u64 {
133 self.next_journal_seq
134 }
135
136 pub fn invoke_point(
140 &mut self,
141 specs: &[HookSpec],
142 point: HookPoint,
143 context: HookLifecycleContext,
144 view: HookView,
145 ) -> Result<Vec<HookInvocationOutcome>, AgentError> {
146 self.invoke_point_guarded(specs, point, context, view, |_, _| Ok(true))
147 }
148
149 pub fn invoke_point_guarded<F>(
153 &mut self,
154 specs: &[HookSpec],
155 point: HookPoint,
156 context: HookLifecycleContext,
157 view: HookView,
158 mut acceptance_guard: F,
159 ) -> Result<Vec<HookInvocationOutcome>, AgentError>
160 where
161 F: FnMut(&HookSpec, &HookResponse) -> Result<bool, AgentError>,
162 {
163 let hooks = ordered_hooks_for_point(specs, point);
164 let mut outcomes = Vec::with_capacity(hooks.len());
165 for spec in hooks {
166 outcomes.push(self.invoke_one(&spec, &context, view.clone(), &mut acceptance_guard)?);
167 }
168 Ok(outcomes)
169 }
170
171 fn invoke_one<F>(
172 &mut self,
173 spec: &HookSpec,
174 context: &HookLifecycleContext,
175 view: HookView,
176 acceptance_guard: &mut F,
177 ) -> Result<HookInvocationOutcome, AgentError>
178 where
179 F: FnMut(&HookSpec, &HookResponse) -> Result<bool, AgentError>,
180 {
181 spec.validate()?;
182 let invocation_id = format!("hook.invocation.{}", self.next_journal_seq);
183 if context.cancellation.cancelled {
184 return Ok(HookInvocationOutcome::from_record(
185 spec,
186 HookInvocationStatus::Cancelled,
187 HookRecord::cancelled(spec, invocation_id),
188 ));
189 }
190
191 let executor = self.registry.resolve(&spec.executor_ref).ok_or_else(|| {
192 fail_closed_error(
193 spec,
194 context,
195 AgentErrorKind::HostConfigurationNeeded,
196 "missing hook executor ref",
197 )
198 })?;
199 let input = HookInput {
200 hook_id: spec.hook_id.clone(),
201 point: spec.point.clone(),
202 run_id: context.run_id.clone(),
203 agent_id: context.agent_id.clone(),
204 turn_id: context.turn_id.clone(),
205 attempt_id: context.attempt_id.clone(),
206 source: SourceRef::with_kind(crate::domain::SourceKind::Hook, spec.hook_id.as_str()),
207 destination: context.destination.clone(),
208 package_fingerprint: context.package_fingerprint.clone(),
209 view,
210 policy_refs: vec![spec.policy_ref.clone()],
211 cancellation: context.cancellation.clone(),
212 };
213
214 let hook_result = executor.invoke(input);
215 let execution = match hook_result {
216 Ok(execution) => execution,
217 Err(error) if !spec.is_security_relevant() && !spec.failure.fails_closed() => {
218 return Ok(HookInvocationOutcome::from_record(
219 spec,
220 HookInvocationStatus::FailedOpen,
221 HookRecord::failed(spec, invocation_id, error.context().message),
222 ));
223 }
224 Err(error) => {
225 return Err(fail_closed_error(
226 spec,
227 context,
228 error.kind(),
229 error.context().message,
230 ));
231 }
232 };
233
234 if execution.elapsed_ms > spec.timeout.timeout_ms {
235 return self.handle_timeout(spec, context, invocation_id, execution.elapsed_ms);
236 }
237
238 self.handle_response(
239 spec,
240 context,
241 invocation_id,
242 execution.response,
243 acceptance_guard,
244 )
245 }
246
247 fn handle_timeout(
248 &self,
249 spec: &HookSpec,
250 context: &HookLifecycleContext,
251 invocation_id: String,
252 elapsed_ms: u64,
253 ) -> Result<HookInvocationOutcome, AgentError> {
254 if !spec.is_security_relevant() && !spec.failure.fails_closed() {
255 return Ok(HookInvocationOutcome::from_record(
256 spec,
257 HookInvocationStatus::TimedOutFailOpen,
258 HookRecord::timeout(spec, invocation_id, elapsed_ms),
259 ));
260 }
261 Err(fail_closed_error(
262 spec,
263 context,
264 AgentErrorKind::Timeout,
265 "hook timed out before guarded lifecycle transition",
266 ))
267 }
268
269 fn handle_response<F>(
270 &mut self,
271 spec: &HookSpec,
272 context: &HookLifecycleContext,
273 invocation_id: String,
274 response: HookResponse,
275 acceptance_guard: &mut F,
276 ) -> Result<HookInvocationOutcome, AgentError>
277 where
278 F: FnMut(&HookSpec, &HookResponse) -> Result<bool, AgentError>,
279 {
280 let response_class = response.response_class();
281 if !spec
282 .point
283 .allowed_response_classes()
284 .contains(&response_class)
285 {
286 return Ok(HookInvocationOutcome::rejected(
287 spec,
288 invocation_id,
289 response_class,
290 HookInvocationStatus::RejectedPointMatrix,
291 ));
292 }
293 if !spec
294 .mutation_rights
295 .allows_response_class(response_class.clone())
296 {
297 return Ok(HookInvocationOutcome::rejected(
298 spec,
299 invocation_id,
300 response_class,
301 HookInvocationStatus::RejectedMutationRight,
302 ));
303 }
304
305 if !response.changes_behavior() {
306 return Ok(HookInvocationOutcome::from_record(
307 spec,
308 HookInvocationStatus::Completed,
309 HookRecord::completed(spec, invocation_id, 0),
310 ));
311 }
312 match acceptance_guard(spec, &response) {
313 Ok(true) => {}
314 Ok(false) => {
315 return self.append_rejected_response_decision(
316 spec,
317 context,
318 invocation_id,
319 response_class,
320 HookInvocationStatus::RejectedPolicy,
321 );
322 }
323 Err(error) => {
324 self.append_rejected_response_decision(
325 spec,
326 context,
327 invocation_id,
328 response_class,
329 HookInvocationStatus::RejectedPolicy,
330 )?;
331 return Err(error);
332 }
333 }
334
335 let journal_seq = self.next_seq_block(3);
336 let record_id = format!("journal.hook.{}.{}", spec.hook_id.as_str(), journal_seq);
337 let plan = HookMutationJournalPlan::accepted_response(
338 journal_seq,
339 record_id,
340 context.run_id.clone(),
341 context.agent_id.clone(),
342 context.session_id.clone(),
343 context.turn_id.clone(),
344 context.attempt_id.clone(),
345 context.source.clone(),
346 spec,
347 invocation_id.clone(),
348 response_class.clone(),
349 context.package_fingerprint.as_str(),
350 );
351 self.journal
352 .append(plan.hook_journal_record.clone())
353 .map_err(|error| {
354 fail_closed_error(
355 spec,
356 context,
357 error.kind(),
358 "hook response journal append failed before apply",
359 )
360 })?;
361 let _intent_cursor = self
362 .journal
363 .append(plan.intent_journal_record.clone())
364 .map_err(|error| {
365 fail_closed_error(
366 spec,
367 context,
368 error.kind(),
369 "hook mutation journal append failed before apply",
370 )
371 })?;
372 let terminal_cursor = self
373 .journal
374 .append(plan.result_journal_record.clone())
375 .map_err(|error| {
376 fail_closed_error(
377 spec,
378 context,
379 error.kind(),
380 "hook mutation terminal result append failed before apply",
381 )
382 })?;
383 Ok(HookInvocationOutcome {
384 hook_id: spec.hook_id.clone(),
385 status: HookInvocationStatus::AppliedJournaledMutation,
386 response_class: Some(response_class),
387 accepted_response: Some(response),
388 journal_cursor: Some(terminal_cursor),
389 journaled_before_apply: true,
390 record: plan.hook_record,
391 })
392 }
393
394 fn append_rejected_response_decision(
395 &mut self,
396 spec: &HookSpec,
397 context: &HookLifecycleContext,
398 invocation_id: String,
399 response_class: HookResponseClass,
400 status: HookInvocationStatus,
401 ) -> Result<HookInvocationOutcome, AgentError> {
402 let journal_seq = self.next_seq_block(1);
403 let record_id = format!("journal.hook.{}.{}", spec.hook_id.as_str(), journal_seq);
404 let (record, journal_record) = HookRecord::rejected_response_journal_record(
405 journal_seq,
406 record_id,
407 context.run_id.clone(),
408 context.agent_id.clone(),
409 context.session_id.clone(),
410 context.turn_id.clone(),
411 context.attempt_id.clone(),
412 context.source.clone(),
413 spec,
414 invocation_id,
415 HookResponseDecision::RejectedPolicy,
416 response_class.clone(),
417 context.package_fingerprint.as_str(),
418 );
419 let cursor = self.journal.append(journal_record).map_err(|error| {
420 fail_closed_error(
421 spec,
422 context,
423 error.kind(),
424 "hook rejected response journal append failed before returning policy rejection",
425 )
426 })?;
427 Ok(HookInvocationOutcome {
428 hook_id: spec.hook_id.clone(),
429 status,
430 response_class: Some(response_class),
431 accepted_response: None,
432 journal_cursor: Some(cursor),
433 journaled_before_apply: false,
434 record,
435 })
436 }
437
438 fn next_seq_block(&mut self, width: u64) -> u64 {
439 let seq = if let Some(sequence_allocator) = &mut self.sequence_allocator {
440 sequence_allocator(width)
441 } else {
442 self.next_journal_seq
443 };
444 self.next_journal_seq = seq.saturating_add(width);
445 seq
446 }
447}
448
449pub fn validate_package_hooks<R>(specs: &[HookSpec], registry: &R) -> Result<(), AgentError>
453where
454 R: HookExecutorRegistry + ?Sized,
455{
456 validate_hook_specs(specs)?;
457 for spec in specs {
458 if !registry.contains(&spec.executor_ref) {
459 return Err(AgentError::new(
460 AgentErrorKind::InvalidPackage,
461 RetryClassification::HostConfigurationNeeded,
462 format!(
463 "hook executor {} is not resolved before start_run",
464 spec.executor_ref.as_str()
465 ),
466 ));
467 }
468 }
469 Ok(())
470}
471
472#[derive(Clone, Debug, Eq, PartialEq)]
473pub struct HookInvocationOutcome {
476 pub hook_id: crate::package_hooks::HookId,
478 pub status: HookInvocationStatus,
480 pub response_class: Option<HookResponseClass>,
483 pub accepted_response: Option<HookResponse>,
487 pub journal_cursor: Option<JournalCursor>,
490 pub journaled_before_apply: bool,
493 pub record: HookRecord,
495}
496
497impl HookInvocationOutcome {
498 fn from_record(spec: &HookSpec, status: HookInvocationStatus, record: HookRecord) -> Self {
499 Self {
500 hook_id: spec.hook_id.clone(),
501 status,
502 response_class: None,
503 accepted_response: None,
504 journal_cursor: None,
505 journaled_before_apply: false,
506 record,
507 }
508 }
509
510 fn rejected(
511 spec: &HookSpec,
512 invocation_id: String,
513 response_class: HookResponseClass,
514 status: HookInvocationStatus,
515 ) -> Self {
516 let decision = match status {
517 HookInvocationStatus::RejectedMutationRight => {
518 crate::hook_records::HookResponseDecision::RejectedMutationRight
519 }
520 HookInvocationStatus::RejectedPointMatrix => {
521 crate::hook_records::HookResponseDecision::RejectedPointMatrix
522 }
523 HookInvocationStatus::RejectedPolicy => {
524 crate::hook_records::HookResponseDecision::RejectedPolicy
525 }
526 _ => crate::hook_records::HookResponseDecision::RejectedPolicy,
527 };
528 Self {
529 hook_id: spec.hook_id.clone(),
530 status,
531 response_class: Some(response_class.clone()),
532 accepted_response: None,
533 journal_cursor: None,
534 journaled_before_apply: false,
535 record: HookRecord::response_decision(
536 spec,
537 invocation_id,
538 decision,
539 response_class,
540 Vec::new(),
541 ),
542 }
543 }
544}
545
546#[derive(Clone, Debug, Eq, PartialEq)]
547pub enum HookInvocationStatus {
550 Completed,
552 AppliedJournaledMutation,
554 RejectedMutationRight,
556 RejectedPointMatrix,
558 RejectedPolicy,
560 TimedOutFailOpen,
562 FailedOpen,
564 Cancelled,
566}
567
568fn fail_closed_error(
569 spec: &HookSpec,
570 context: &HookLifecycleContext,
571 kind: AgentErrorKind,
572 message: impl Into<String>,
573) -> AgentError {
574 let kind = match spec.failure {
575 crate::package_hooks::HookFailurePolicy::Deny => AgentErrorKind::PolicyDenial,
576 crate::package_hooks::HookFailurePolicy::InterruptRun
577 | crate::package_hooks::HookFailurePolicy::FailRun => AgentErrorKind::HookFailure,
578 crate::package_hooks::HookFailurePolicy::FailOpenObserveOnly => kind,
579 };
580 AgentError::new(kind, RetryClassification::RepairNeeded, message).with_causal_ids(CausalIds {
581 run_id: Some(context.run_id.clone()),
582 ..CausalIds::default()
583 })
584}