Skip to main content

bijux_cli/kernel/
pipeline.rs

1//! Execution kernel and lifecycle pipeline for Rust bijux-cli.
2
3use std::collections::BTreeMap;
4use std::future::Future;
5use std::panic::{catch_unwind, AssertUnwindSafe};
6use std::pin::Pin;
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::Arc;
9use std::sync::Mutex;
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use crate::contracts::diagnostics::{DiagnosticRecord, InvocationEvent, InvocationTrace};
13use crate::contracts::{
14    CommandPath, ErrorEnvelopeV1, ErrorPayloadV1, ExecutionPolicy, ExitCode, GlobalFlags,
15    Namespace, OutputEnvelopeMetaV1, OutputEnvelopeV1,
16};
17use crate::shared::telemetry::{truncate_chars, MAX_COMMAND_FIELD_CHARS, MAX_TEXT_FIELD_CHARS};
18use serde_json::{json, Value};
19
20static INVOCATION_COUNTER: AtomicU64 = AtomicU64::new(1);
21
22/// Lifecycle stages executed by the kernel.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[allow(dead_code)]
25pub enum LifecycleStage {
26    /// Process bootstrap and runtime setup.
27    Bootstrap,
28    /// Command intent construction.
29    BuildIntent,
30    /// Policy resolution.
31    ResolvePolicy,
32    /// Context assembly.
33    AssembleContext,
34    /// Dispatch and execute handler.
35    Dispatch,
36    /// Emission stage.
37    Emit,
38    /// Exit mapping stage.
39    ExitMap,
40}
41
42/// Execution intent built from argv.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExecutionIntent {
45    /// Command path segments.
46    pub command_path: Vec<String>,
47    /// Parsed global flags.
48    pub global_flags: GlobalFlags,
49    /// Raw args after command path.
50    pub args: Vec<String>,
51}
52
53/// Execution context for handlers and stages.
54#[derive(Debug, Clone)]
55pub struct ExecutionContext {
56    /// Intent for the current invocation.
57    pub intent: ExecutionIntent,
58    /// Effective policy after precedence.
59    pub policy: ExecutionPolicy,
60    /// Timeout budget for handler execution.
61    pub timeout: Option<Duration>,
62    /// Cancellation token.
63    pub cancelled: Arc<AtomicBool>,
64    /// Structured route/policy/emission trace mode.
65    pub trace_mode: bool,
66}
67
68/// Emission stream target.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum OutputStream {
71    /// Standard output stream.
72    Stdout,
73    /// Standard error stream.
74    Stderr,
75}
76
77/// Emission produced by handler pipeline.
78#[derive(Debug, Clone, PartialEq)]
79pub struct Emission {
80    /// Output stream target.
81    pub stream: OutputStream,
82    /// Structured payload.
83    pub payload: Value,
84}
85
86/// Handler outcome before emission mapping.
87#[derive(Debug, Clone, PartialEq)]
88pub enum HandlerOutcome {
89    /// Successful payload.
90    Success(Value),
91    /// Error envelope payload.
92    Error(ErrorEnvelopeV1),
93}
94
95/// Final execution result.
96#[derive(Debug, Clone, PartialEq)]
97pub struct ExecutionResult {
98    /// Exit code.
99    pub exit_code: ExitCode,
100    /// Emission if any.
101    pub emission: Option<Emission>,
102    /// Optional trace payload.
103    pub trace: Option<InvocationTrace>,
104}
105
106/// Diagnostic hook for structured internal telemetry.
107pub trait DiagnosticsHook: Send + Sync {
108    /// Observe a single record emitted by kernel stages.
109    fn record(&self, record: DiagnosticRecord);
110}
111
112/// Lifecycle hook for plugin/repl boundaries.
113pub trait LifecycleHook: Send + Sync {
114    /// Invoked when plugin loading starts.
115    fn on_plugin_load(&self) {}
116    /// Invoked when REPL starts.
117    fn on_repl_start(&self) {}
118    /// Invoked when REPL shutdown starts.
119    fn on_repl_shutdown(&self) {}
120}
121
122/// Sync handler abstraction.
123pub trait SyncHandler: Send + Sync {
124    /// Execute synchronously.
125    fn execute(&self, ctx: &ExecutionContext) -> Result<Value, ErrorEnvelopeV1>;
126}
127
128/// Async handler abstraction.
129pub trait AsyncHandler: Send + Sync {
130    /// Execute asynchronously.
131    fn execute_async(
132        &self,
133        ctx: &ExecutionContext,
134    ) -> Pin<Box<dyn Future<Output = Result<Value, ErrorEnvelopeV1>> + Send + '_>>;
135}
136
137/// Unified handler variant supporting sync and async.
138#[cfg_attr(not(test), allow(dead_code))]
139pub enum Handler {
140    /// Sync handler variant.
141    Sync(Box<dyn SyncHandler>),
142    /// Async handler variant.
143    Async(Box<dyn AsyncHandler>),
144}
145
146/// Kernel-level execution failures.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum KernelError {
149    /// Invocation was cancelled.
150    Cancelled,
151    /// Invocation exceeded configured timeout.
152    Timeout,
153}
154
155/// Build intent from argv (simple kernel parser).
156#[must_use]
157pub fn build_intent_from_argv(argv: &[String]) -> ExecutionIntent {
158    // PARITY-PARTIAL: This lightweight argv parser exists only for kernel-local tests.
159    // Final parity paths should use routing parser intent output.
160    let mut command_path = Vec::new();
161    let mut args = Vec::new();
162
163    let mut i = 1;
164    while i < argv.len() {
165        let token = &argv[i];
166        if token.starts_with('-') {
167            i += 1;
168            continue;
169        }
170
171        command_path.push(token.clone());
172        i += 1;
173        break;
174    }
175
176    while i < argv.len() {
177        args.push(argv[i].clone());
178        i += 1;
179    }
180
181    let flags = GlobalFlags {
182        output_format: None,
183        pretty_mode: None,
184        color_mode: None,
185        log_level: None,
186        quiet: argv.iter().any(|v| v == "--quiet" || v == "-q"),
187        include_runtime: argv.iter().any(|v| v == "--trace"),
188    };
189
190    ExecutionIntent { command_path, global_flags: flags, args }
191}
192
193/// Assemble execution context.
194#[must_use]
195#[allow(dead_code)]
196pub(crate) fn assemble_context(
197    intent: ExecutionIntent,
198    policy: ExecutionPolicy,
199    timeout: Option<Duration>,
200    cancelled: Arc<AtomicBool>,
201    trace_mode: bool,
202) -> ExecutionContext {
203    ExecutionContext { intent, policy, timeout, cancelled, trace_mode }
204}
205
206#[allow(dead_code)]
207fn success_meta(ctx: &ExecutionContext) -> OutputEnvelopeMetaV1 {
208    OutputEnvelopeMetaV1 {
209        version: "v1".to_string(),
210        command: CommandPath {
211            segments: ctx.intent.command_path.iter().map(|v| Namespace(v.clone())).collect(),
212        },
213        timestamp: event_timestamp(),
214    }
215}
216
217#[allow(dead_code)]
218fn map_outcome_to_emission(outcome: HandlerOutcome, quiet: bool) -> Option<Emission> {
219    if quiet {
220        return None;
221    }
222
223    match outcome {
224        HandlerOutcome::Success(payload) => {
225            Some(Emission { stream: OutputStream::Stdout, payload })
226        }
227        HandlerOutcome::Error(err) => Some(Emission {
228            stream: OutputStream::Stderr,
229            payload: serde_json::to_value(err).expect("error envelope must serialize"),
230        }),
231    }
232}
233
234#[allow(dead_code)]
235fn map_outcome_to_exit(outcome: &HandlerOutcome) -> ExitCode {
236    match outcome {
237        HandlerOutcome::Success(_) => ExitCode::Success,
238        HandlerOutcome::Error(err) => map_error_category_to_exit(&err.error.category),
239    }
240}
241
242#[allow(dead_code)]
243fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
244    if let Some(message) = payload.downcast_ref::<&str>() {
245        return (*message).to_string();
246    }
247    if let Some(message) = payload.downcast_ref::<String>() {
248        return message.clone();
249    }
250    "unknown panic payload".to_string()
251}
252
253#[allow(dead_code)]
254fn internal_kernel_error(ctx: &ExecutionContext, message: String) -> HandlerOutcome {
255    HandlerOutcome::Error(ErrorEnvelopeV1::failure(
256        ErrorPayloadV1 {
257            code: "KERNEL_HANDLER_PANIC".to_string(),
258            message: format!("kernel handler panicked: {message}"),
259            category: "internal".to_string(),
260            details: None,
261        },
262        success_meta(ctx),
263    ))
264}
265
266#[allow(dead_code)]
267fn catch_unwind_silent<T>(f: impl FnOnce() -> T) -> std::thread::Result<T> {
268    static PANIC_HOOK_LOCK: Mutex<()> = Mutex::new(());
269    let _guard = PANIC_HOOK_LOCK.lock().expect("panic hook lock poisoned");
270    let previous_hook = std::panic::take_hook();
271    std::panic::set_hook(Box::new(|_| {}));
272    let result = catch_unwind(AssertUnwindSafe(f));
273    std::panic::set_hook(previous_hook);
274    result
275}
276
277/// Map stable error category to stable exit code contract.
278#[must_use]
279pub fn map_error_category_to_exit(category: &str) -> ExitCode {
280    match category {
281        "usage" | "validation" => ExitCode::Usage,
282        "plugin" | "internal" => ExitCode::Error,
283        _ => ExitCode::Error,
284    }
285}
286
287fn unix_timestamp_millis() -> u128 {
288    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()
289}
290
291fn event_timestamp() -> String {
292    unix_timestamp_millis().to_string()
293}
294
295fn exit_code_kind(exit_code: ExitCode) -> &'static str {
296    match exit_code {
297        ExitCode::Success => "success",
298        ExitCode::Usage => "usage",
299        ExitCode::Encoding => "encoding",
300        ExitCode::Aborted => "aborted",
301        ExitCode::Error => "error",
302    }
303}
304
305fn next_invocation_id() -> String {
306    let seq = INVOCATION_COUNTER.fetch_add(1, Ordering::Relaxed);
307    format!("kernel-{}-{}-{seq}", std::process::id(), unix_timestamp_millis())
308}
309
310fn bounded_command(command: &str) -> (String, bool) {
311    truncate_chars(command, MAX_COMMAND_FIELD_CHARS)
312}
313
314fn bounded_message(message: &str) -> (String, bool) {
315    truncate_chars(message, MAX_TEXT_FIELD_CHARS)
316}
317
318fn bounded_optional_message(value: Option<&str>) -> (Option<String>, bool) {
319    match value {
320        Some(text) => {
321            let (bounded, truncated) = bounded_message(text);
322            (Some(bounded), truncated)
323        }
324        None => (None, false),
325    }
326}
327
328fn bounded_trace_command(path: &[String]) -> (String, bool) {
329    bounded_command(&path.join(" "))
330}
331
332#[allow(dead_code)]
333fn is_fast_path(intent: &ExecutionIntent) -> bool {
334    matches!(
335        intent.command_path.as_slice(),
336        [one] if one == "help" || one == "version" || one == "completion"
337    )
338}
339
340/// Execute unified sync/async handler pipeline with lifecycle and diagnostics hooks.
341#[allow(dead_code)]
342pub(crate) fn execute_pipeline(
343    ctx: &ExecutionContext,
344    handler: &Handler,
345    diagnostics: &[Arc<dyn DiagnosticsHook>],
346    lifecycle: &[Arc<dyn LifecycleHook>],
347) -> Result<ExecutionResult, KernelError> {
348    let started_at = Instant::now();
349
350    let mut trace_events = Vec::<InvocationEvent>::new();
351
352    for hook in diagnostics {
353        hook.record(DiagnosticRecord {
354            id: "bootstrap".to_string(),
355            severity: "info".to_string(),
356            message: format!("stage={:?}", LifecycleStage::Bootstrap),
357            fields: BTreeMap::new(),
358        });
359    }
360
361    if ctx.cancelled.load(Ordering::SeqCst) {
362        let command_joined = ctx.intent.command_path.join(" ");
363        let (command, command_truncated) = bounded_command(&command_joined);
364        for hook in diagnostics {
365            hook.record(DiagnosticRecord {
366                id: "kernel_cancelled_before_dispatch".to_string(),
367                severity: "warning".to_string(),
368                message: "execution cancelled before dispatch".to_string(),
369                fields: BTreeMap::from([
370                    ("command".to_string(), json!(command)),
371                    ("command_truncated".to_string(), json!(command_truncated)),
372                ]),
373            });
374        }
375        return Err(KernelError::Cancelled);
376    }
377
378    let (trace_command, trace_command_truncated) = bounded_trace_command(&ctx.intent.command_path);
379    trace_events.push(InvocationEvent {
380        // Trace remains human-readable; diagnostics carry truncated metadata separately.
381        timestamp: event_timestamp(),
382        name: "bootstrap".to_string(),
383        payload: BTreeMap::from([
384            ("command".to_string(), json!(trace_command)),
385            ("command_truncated".to_string(), json!(trace_command_truncated)),
386        ]),
387    });
388
389    if is_fast_path(&ctx.intent) {
390        // PARITY-PARTIAL: fast-path currently emits a generic payload; full parity requires
391        // command-specific payloads for help/version/completion.
392        let payload = OutputEnvelopeV1 {
393            status: "ok".to_string(),
394            data: json!({"fast_path": true}),
395            meta: success_meta(ctx),
396        };
397        let outcome = HandlerOutcome::Success(
398            serde_json::to_value(payload).expect("success envelope must serialize"),
399        );
400        let exit_code = map_outcome_to_exit(&outcome);
401        let emission = map_outcome_to_emission(outcome, ctx.policy.quiet);
402        let emission_stream = emission.as_ref().map(|item| match item.stream {
403            OutputStream::Stdout => "stdout",
404            OutputStream::Stderr => "stderr",
405        });
406        for hook in diagnostics {
407            let command_joined = ctx.intent.command_path.join(" ");
408            let (command, command_truncated) = bounded_command(&command_joined);
409            hook.record(DiagnosticRecord {
410                id: "kernel_dispatch_fast_path_completed".to_string(),
411                severity: "info".to_string(),
412                message: "fast-path command completed".to_string(),
413                fields: BTreeMap::from([
414                    ("command".to_string(), json!(command)),
415                    ("command_truncated".to_string(), json!(command_truncated)),
416                    ("exit_code".to_string(), json!(exit_code as i32)),
417                    ("exit_kind".to_string(), json!(exit_code_kind(exit_code))),
418                ]),
419            });
420        }
421        return Ok(ExecutionResult {
422            exit_code,
423            emission,
424            trace: if ctx.trace_mode {
425                Some(InvocationTrace {
426                    invocation_id: next_invocation_id(),
427                    command: CommandPath {
428                        segments: ctx
429                            .intent
430                            .command_path
431                            .iter()
432                            .map(|v| Namespace(v.clone()))
433                            .collect(),
434                    },
435                    policy: ctx.policy.clone(),
436                    events: vec![
437                        InvocationEvent {
438                            timestamp: event_timestamp(),
439                            name: "dispatch.start".to_string(),
440                            payload: BTreeMap::from([
441                                ("command".to_string(), json!(trace_command.clone())),
442                                ("command_truncated".to_string(), json!(trace_command_truncated)),
443                                ("mode".to_string(), json!("fast-path")),
444                            ]),
445                        },
446                        InvocationEvent {
447                            timestamp: event_timestamp(),
448                            name: "dispatch.finish".to_string(),
449                            payload: BTreeMap::from([
450                                ("command".to_string(), json!(trace_command)),
451                                ("command_truncated".to_string(), json!(trace_command_truncated)),
452                                ("mode".to_string(), json!("fast-path")),
453                                ("exit_code".to_string(), json!(exit_code as i32)),
454                                ("exit_kind".to_string(), json!(exit_code_kind(exit_code))),
455                                ("quiet".to_string(), json!(ctx.policy.quiet)),
456                                ("emission".to_string(), json!(emission_stream)),
457                                (
458                                    "duration_ms".to_string(),
459                                    json!(started_at.elapsed().as_millis()),
460                                ),
461                            ]),
462                        },
463                    ],
464                })
465            } else {
466                None
467            },
468        });
469    }
470
471    if ctx.intent.command_path.first().is_some_and(|v| v == "plugins") {
472        let (trace_command, trace_command_truncated) =
473            bounded_trace_command(&ctx.intent.command_path);
474        trace_events.push(InvocationEvent {
475            timestamp: event_timestamp(),
476            name: "lifecycle.plugin.load".to_string(),
477            payload: BTreeMap::from([
478                ("command".to_string(), json!(trace_command)),
479                ("command_truncated".to_string(), json!(trace_command_truncated)),
480            ]),
481        });
482        for hook in lifecycle {
483            hook.on_plugin_load();
484        }
485    }
486    let is_repl_command = ctx.intent.command_path.first().is_some_and(|v| v == "repl");
487    if is_repl_command {
488        let (trace_command, trace_command_truncated) =
489            bounded_trace_command(&ctx.intent.command_path);
490        trace_events.push(InvocationEvent {
491            timestamp: event_timestamp(),
492            name: "lifecycle.repl.start".to_string(),
493            payload: BTreeMap::from([
494                ("command".to_string(), json!(trace_command)),
495                ("command_truncated".to_string(), json!(trace_command_truncated)),
496            ]),
497        });
498        for hook in lifecycle {
499            hook.on_repl_start();
500        }
501    }
502
503    let (trace_command, trace_command_truncated) = bounded_trace_command(&ctx.intent.command_path);
504    trace_events.push(InvocationEvent {
505        timestamp: event_timestamp(),
506        name: "dispatch.start".to_string(),
507        payload: BTreeMap::from([
508            ("command".to_string(), json!(trace_command.clone())),
509            ("command_truncated".to_string(), json!(trace_command_truncated)),
510            ("mode".to_string(), json!("standard")),
511        ]),
512    });
513
514    let outcome = match handler {
515        Handler::Sync(sync_handler) => match catch_unwind_silent(|| sync_handler.execute(ctx)) {
516            Ok(Ok(payload)) => HandlerOutcome::Success(payload),
517            Ok(Err(err)) => HandlerOutcome::Error(err),
518            Err(payload) => {
519                let panic_text = panic_message(payload);
520                let command_joined = ctx.intent.command_path.join(" ");
521                let (command, command_truncated) = bounded_command(&command_joined);
522                let (panic_message_text, panic_message_truncated) = bounded_message(&panic_text);
523                for hook in diagnostics {
524                    hook.record(DiagnosticRecord {
525                        id: "kernel_handler_panic".to_string(),
526                        severity: "error".to_string(),
527                        message: "sync handler panicked during dispatch".to_string(),
528                        fields: BTreeMap::from([
529                            ("command".to_string(), json!(command)),
530                            ("command_truncated".to_string(), json!(command_truncated)),
531                            ("panic_message".to_string(), json!(panic_message_text)),
532                            ("panic_message_truncated".to_string(), json!(panic_message_truncated)),
533                        ]),
534                    });
535                }
536                internal_kernel_error(ctx, panic_text)
537            }
538        },
539        Handler::Async(async_handler) => {
540            match catch_unwind_silent(|| {
541                futures::executor::block_on(async_handler.execute_async(ctx))
542            }) {
543                Ok(Ok(payload)) => HandlerOutcome::Success(payload),
544                Ok(Err(err)) => HandlerOutcome::Error(err),
545                Err(payload) => {
546                    let panic_text = panic_message(payload);
547                    let command_joined = ctx.intent.command_path.join(" ");
548                    let (command, command_truncated) = bounded_command(&command_joined);
549                    let (panic_message_text, panic_message_truncated) =
550                        bounded_message(&panic_text);
551                    for hook in diagnostics {
552                        hook.record(DiagnosticRecord {
553                            id: "kernel_handler_panic".to_string(),
554                            severity: "error".to_string(),
555                            message: "async handler panicked during dispatch".to_string(),
556                            fields: BTreeMap::from([
557                                ("command".to_string(), json!(command)),
558                                ("command_truncated".to_string(), json!(command_truncated)),
559                                ("panic_message".to_string(), json!(panic_message_text)),
560                                (
561                                    "panic_message_truncated".to_string(),
562                                    json!(panic_message_truncated),
563                                ),
564                            ]),
565                        });
566                    }
567                    internal_kernel_error(ctx, panic_text)
568                }
569            }
570        }
571    };
572
573    if is_repl_command {
574        let (trace_command, trace_command_truncated) =
575            bounded_trace_command(&ctx.intent.command_path);
576        trace_events.push(InvocationEvent {
577            timestamp: event_timestamp(),
578            name: "lifecycle.repl.shutdown".to_string(),
579            payload: BTreeMap::from([
580                ("command".to_string(), json!(trace_command)),
581                ("command_truncated".to_string(), json!(trace_command_truncated)),
582            ]),
583        });
584        for hook in lifecycle {
585            hook.on_repl_shutdown();
586        }
587    }
588
589    if let Some(limit) = ctx.timeout {
590        if started_at.elapsed() > limit {
591            let command_joined = ctx.intent.command_path.join(" ");
592            let (command, command_truncated) = bounded_command(&command_joined);
593            for hook in diagnostics {
594                hook.record(DiagnosticRecord {
595                    id: "kernel_timeout_after_dispatch".to_string(),
596                    severity: "error".to_string(),
597                    message: format!(
598                        "execution exceeded timeout budget of {}ms",
599                        limit.as_millis()
600                    ),
601                    fields: BTreeMap::from([
602                        ("command".to_string(), json!(command)),
603                        ("command_truncated".to_string(), json!(command_truncated)),
604                        ("timeout_ms".to_string(), json!(limit.as_millis())),
605                        ("elapsed_ms".to_string(), json!(started_at.elapsed().as_millis())),
606                    ]),
607                });
608            }
609            return Err(KernelError::Timeout);
610        }
611    }
612
613    if ctx.cancelled.load(Ordering::SeqCst) {
614        let command_joined = ctx.intent.command_path.join(" ");
615        let (command, command_truncated) = bounded_command(&command_joined);
616        for hook in diagnostics {
617            hook.record(DiagnosticRecord {
618                id: "kernel_cancelled_after_dispatch".to_string(),
619                severity: "warning".to_string(),
620                message: "execution cancelled after dispatch".to_string(),
621                fields: BTreeMap::from([
622                    ("command".to_string(), json!(command)),
623                    ("command_truncated".to_string(), json!(command_truncated)),
624                ]),
625            });
626        }
627        return Err(KernelError::Cancelled);
628    }
629
630    match &outcome {
631        HandlerOutcome::Success(_) => {
632            let command_joined = ctx.intent.command_path.join(" ");
633            let (command, command_truncated) = bounded_command(&command_joined);
634            for hook in diagnostics {
635                hook.record(DiagnosticRecord {
636                    id: "kernel_handler_outcome_success".to_string(),
637                    severity: "info".to_string(),
638                    message: "handler returned success payload".to_string(),
639                    fields: BTreeMap::from([
640                        ("command".to_string(), json!(command)),
641                        ("command_truncated".to_string(), json!(command_truncated)),
642                    ]),
643                });
644            }
645        }
646        HandlerOutcome::Error(err) => {
647            let command_joined = ctx.intent.command_path.join(" ");
648            let (command, command_truncated) = bounded_command(&command_joined);
649            let (error_code, error_code_truncated) = bounded_message(&err.error.code);
650            let (error_category, error_category_truncated) = bounded_message(&err.error.category);
651            for hook in diagnostics {
652                hook.record(DiagnosticRecord {
653                    id: "kernel_handler_outcome_error".to_string(),
654                    severity: "warning".to_string(),
655                    message: "handler returned error payload".to_string(),
656                    fields: BTreeMap::from([
657                        ("command".to_string(), json!(command)),
658                        ("command_truncated".to_string(), json!(command_truncated)),
659                        ("error_category".to_string(), json!(error_category)),
660                        ("error_category_truncated".to_string(), json!(error_category_truncated)),
661                        ("error_code".to_string(), json!(error_code)),
662                        ("error_code_truncated".to_string(), json!(error_code_truncated)),
663                    ]),
664                });
665            }
666        }
667    }
668
669    let exit_code = map_outcome_to_exit(&outcome);
670    let (outcome_error_category, outcome_error_category_truncated) =
671        bounded_optional_message(match &outcome {
672            HandlerOutcome::Success(_) => None,
673            HandlerOutcome::Error(err) => Some(err.error.category.as_str()),
674        });
675    let (outcome_error_code, outcome_error_code_truncated) =
676        bounded_optional_message(match &outcome {
677            HandlerOutcome::Success(_) => None,
678            HandlerOutcome::Error(err) => Some(err.error.code.as_str()),
679        });
680    let emission = map_outcome_to_emission(outcome, ctx.policy.quiet);
681    let emission_stream = emission.as_ref().map(|item| match item.stream {
682        OutputStream::Stdout => "stdout",
683        OutputStream::Stderr => "stderr",
684    });
685
686    trace_events.push(InvocationEvent {
687        timestamp: event_timestamp(),
688        name: "dispatch.finish".to_string(),
689        payload: BTreeMap::from([
690            ("command".to_string(), json!(trace_command)),
691            ("command_truncated".to_string(), json!(trace_command_truncated)),
692            ("mode".to_string(), json!("standard")),
693            ("exit_code".to_string(), json!(exit_code as i32)),
694            ("exit_kind".to_string(), json!(exit_code_kind(exit_code))),
695            ("quiet".to_string(), json!(ctx.policy.quiet)),
696            ("error_category".to_string(), json!(outcome_error_category)),
697            ("error_category_truncated".to_string(), json!(outcome_error_category_truncated)),
698            ("error_code".to_string(), json!(outcome_error_code)),
699            ("error_code_truncated".to_string(), json!(outcome_error_code_truncated)),
700            ("emission".to_string(), json!(emission_stream)),
701            ("duration_ms".to_string(), json!(started_at.elapsed().as_millis())),
702        ]),
703    });
704
705    for hook in diagnostics {
706        let command_joined = ctx.intent.command_path.join(" ");
707        let (command, command_truncated) = bounded_command(&command_joined);
708        hook.record(DiagnosticRecord {
709            id: "kernel_dispatch_completed".to_string(),
710            severity: "info".to_string(),
711            message: "kernel dispatch finished".to_string(),
712            fields: BTreeMap::from([
713                ("command".to_string(), json!(command)),
714                ("command_truncated".to_string(), json!(command_truncated)),
715                ("exit_code".to_string(), json!(exit_code as i32)),
716                ("exit_kind".to_string(), json!(exit_code_kind(exit_code))),
717                ("duration_ms".to_string(), json!(started_at.elapsed().as_millis())),
718            ]),
719        });
720    }
721
722    Ok(ExecutionResult {
723        exit_code,
724        emission,
725        trace: if ctx.trace_mode {
726            Some(InvocationTrace {
727                invocation_id: next_invocation_id(),
728                command: CommandPath {
729                    segments: ctx
730                        .intent
731                        .command_path
732                        .iter()
733                        .map(|v| Namespace(v.clone()))
734                        .collect(),
735                },
736                policy: ctx.policy.clone(),
737                events: trace_events,
738            })
739        } else {
740            None
741        },
742    })
743}