lifeloop-cli 0.1.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Failure-class mapping (issue #15).
//!
//! Fills the [`FailureMapper`] seam declared in `src/router/seams.rs`
//! (issue #7) and the validation-layer companion to the
//! `receipt.emitted` guard added at emission time in
//! `src/router/receipts.rs` (issue #14).
//!
//! # Boundary
//!
//! Owns:
//! * [`LifeloopFailureMapper`] — concrete [`FailureMapper`]
//!   implementation. Pure function, no state.
//! * [`failure_class_for_route_error`] / [`failure_class_for_receipt_error`]
//!   / [`failure_class_for_transport`] — free helpers used by the
//!   mapper and exposed for callers that hold one error shape and
//!   want a [`FailureClass`] without going through the trait.
//! * [`retry_class_for`] — a thin wrapper over
//!   [`crate::FailureClass::default_retry`] kept here so the
//!   per-failure retry rule is named in one place.
//! * [`TransportError`] — a small typed enum covering the IO/transport
//!   shapes a real callback transport would surface.
//! * [`validate_receipt_eligible`] — validation-layer guard that
//!   refuses to plan a receipt for a `receipt.emitted` event,
//!   complementing the emission-time guard in
//!   [`super::receipts::ReceiptError::ReceiptEmittedNotEmittable`].
//!
//! Does **not** own:
//! * negotiation outcome → status mapping (that lives in
//!   `src/router/receipts.rs::derive_status`);
//! * adapter-specific failure semantics. Per-adapter mapping fixtures
//!   live in `tests/router_failure_mapping.rs` and translate
//!   adapter-emitted strings to the *shared* [`FailureClass`]
//!   vocabulary; they do not extend that vocabulary.
//!
//! # Mapping rationale
//!
//! Every [`super::RouteError`] variant maps to exactly one
//! [`FailureClass`]:
//!
//! | RouteError variant            | FailureClass        |
//! |-------------------------------|---------------------|
//! | `SchemaVersionMismatch`       | `InvalidRequest`    |
//! | `EmptySentinel`               | `InvalidRequest`    |
//! | `UnknownEventName`            | `InvalidRequest`    |
//! | `UnknownEnumName`             | `InvalidRequest`    |
//! | `InvalidFrameContext`         | `InvalidRequest`    |
//! | `InvalidPayloadRef`           | `InvalidRequest`    |
//! | `InvalidEventEnvelope`        | `InvalidRequest`    |
//! | `AdapterIdNotFound`           | `AdapterUnavailable`|
//! | `AdapterVersionMismatch`      | `AdapterUnavailable`|
//!
//! Every [`super::ReceiptError`] variant maps to exactly one
//! [`FailureClass`]:
//!
//! | ReceiptError variant          | FailureClass        |
//! |-------------------------------|---------------------|
//! | `ReceiptEmittedNotEmittable`  | `InvalidRequest`    |
//! | `Conflict`                    | `StateConflict`     |
//! | `Invalid`                     | `InvalidRequest`    |

use crate::{FailureClass, LifecycleEventKind, NegotiationOutcome, RetryClass};

use super::plan::RoutingPlan;
use super::receipts::ReceiptError;
use super::seams::FailureMapper;
use super::validation::RouteError;

// ===========================================================================
// TransportError
// ===========================================================================

/// Coarse shape of a callback-transport failure.
///
/// A real callback transport (HTTP, IPC, in-process bridge) surfaces
/// errors at varying granularity. The mapper consumes this enum so
/// the trait is portable across transports and so the retry-class
/// derivation has a stable input vocabulary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
    /// Network or pipe-level failure: connection refused, broken
    /// pipe, peer reset.
    Io(String),
    /// The remote did not respond within the configured deadline.
    Timeout,
    /// A non-IO crash inside the transport itself (serialization
    /// panic, internal bug). Distinct from `Io` because the retry
    /// class differs (`InternalError` -> `RetryAfterReread`).
    Internal(String),
}

impl std::fmt::Display for TransportError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(detail) => write!(f, "transport io error: {detail}"),
            Self::Timeout => f.write_str("transport timeout"),
            Self::Internal(detail) => write!(f, "transport internal error: {detail}"),
        }
    }
}

impl std::error::Error for TransportError {}

// ===========================================================================
// Free mapping helpers
// ===========================================================================

/// Map a [`RouteError`] to a [`FailureClass`].
///
/// Pure function: same variant always maps to the same class so a
/// receipt ledger replays consistently.
pub fn failure_class_for_route_error(err: &RouteError) -> FailureClass {
    match err {
        RouteError::SchemaVersionMismatch { .. }
        | RouteError::EmptySentinel { .. }
        | RouteError::UnknownEventName { .. }
        | RouteError::UnknownEnumName { .. }
        | RouteError::InvalidFrameContext { .. }
        | RouteError::InvalidPayloadRef { .. }
        | RouteError::InvalidEventEnvelope { .. } => FailureClass::InvalidRequest,
        RouteError::AdapterIdNotFound { .. } | RouteError::AdapterVersionMismatch { .. } => {
            FailureClass::AdapterUnavailable
        }
    }
}

/// Map a [`ReceiptError`] to a [`FailureClass`].
pub fn failure_class_for_receipt_error(err: &ReceiptError) -> FailureClass {
    match err {
        ReceiptError::ReceiptEmittedNotEmittable => FailureClass::InvalidRequest,
        ReceiptError::Conflict { .. } => FailureClass::StateConflict,
        ReceiptError::Invalid(_) => FailureClass::InvalidRequest,
    }
}

/// Map a [`TransportError`] to a [`FailureClass`].
pub fn failure_class_for_transport(err: &TransportError) -> FailureClass {
    match err {
        TransportError::Io(_) => FailureClass::TransportError,
        TransportError::Timeout => FailureClass::Timeout,
        TransportError::Internal(_) => FailureClass::InternalError,
    }
}

/// Map a [`NegotiationOutcome`] to a `(failure_class, retry_class)`
/// pair when the outcome blocks dispatch. Returns `None` for
/// non-blocking outcomes (`Satisfied`, `Degraded`).
///
/// `RequiresOperator` is the canonical operator-required surface:
/// it always pairs `OperatorRequired` with `RetryAfterOperator` so
/// the receipt has a deterministic retry hint.
pub fn classes_for_negotiation_outcome(
    outcome: NegotiationOutcome,
    explicit_failure_class: Option<FailureClass>,
) -> Option<(FailureClass, RetryClass)> {
    match outcome {
        NegotiationOutcome::Unsupported => {
            let fc = explicit_failure_class.unwrap_or(FailureClass::CapabilityUnsupported);
            Some((fc, fc.default_retry()))
        }
        NegotiationOutcome::RequiresOperator => {
            let fc = FailureClass::OperatorRequired;
            // OperatorRequired::default_retry() is RetryAfterOperator
            // by spec; assert the pairing explicitly so anyone
            // grepping for "operator-required surface" finds the
            // ground truth here.
            debug_assert_eq!(fc.default_retry(), RetryClass::RetryAfterOperator);
            Some((fc, RetryClass::RetryAfterOperator))
        }
        NegotiationOutcome::Satisfied | NegotiationOutcome::Degraded => None,
    }
}

/// Per-failure default retry-class hint.
///
/// Thin wrapper over [`FailureClass::default_retry`] kept here so the
/// per-class retry rule has a single discoverable name in the router
/// surface.
pub fn retry_class_for(failure_class: FailureClass) -> RetryClass {
    failure_class.default_retry()
}

// ===========================================================================
// LifeloopFailureMapper
// ===========================================================================

/// Concrete [`FailureMapper`] for issue #15.
///
/// Stateless and zero-sized — instances exist only so the type
/// participates in trait dispatch.
#[derive(Debug, Default, Clone, Copy)]
pub struct LifeloopFailureMapper;

impl LifeloopFailureMapper {
    pub fn new() -> Self {
        Self
    }

    /// Convenience: map a [`ReceiptError`] to the `(failure, retry)`
    /// pair a `failed` receipt would carry.
    pub fn map_receipt_error(&self, err: &ReceiptError) -> (FailureClass, RetryClass) {
        let fc = failure_class_for_receipt_error(err);
        (fc, retry_class_for(fc))
    }

    /// Convenience: map a [`TransportError`] to the `(failure, retry)`
    /// pair a `failed` receipt would carry.
    pub fn map_transport_error(&self, err: &TransportError) -> (FailureClass, RetryClass) {
        let fc = failure_class_for_transport(err);
        (fc, retry_class_for(fc))
    }

    /// Convenience: map a generic `dyn std::error::Error` to a
    /// `(InternalError, RetryAfterReread)` pair. Used when the only
    /// thing the caller has is a boxed error.
    pub fn map_unknown_error(&self, _err: &dyn std::error::Error) -> (FailureClass, RetryClass) {
        let fc = FailureClass::InternalError;
        (fc, retry_class_for(fc))
    }
}

impl FailureMapper for LifeloopFailureMapper {
    fn map_route_error(&self, err: &RouteError) -> (FailureClass, RetryClass) {
        let fc = failure_class_for_route_error(err);
        (fc, retry_class_for(fc))
    }
}

// ===========================================================================
// From conversions
// ===========================================================================

impl From<&RouteError> for FailureClass {
    fn from(err: &RouteError) -> Self {
        failure_class_for_route_error(err)
    }
}

impl From<&ReceiptError> for FailureClass {
    fn from(err: &ReceiptError) -> Self {
        failure_class_for_receipt_error(err)
    }
}

impl From<&TransportError> for FailureClass {
    fn from(err: &TransportError) -> Self {
        failure_class_for_transport(err)
    }
}

// ===========================================================================
// Validation-layer receipt-eligibility guard
// ===========================================================================

/// Validation-layer guard: refuse to plan receipt synthesis for a
/// `receipt.emitted` event.
///
/// Complements the emission-time guard in
/// [`super::receipts::LifeloopReceiptEmitter::synthesize_and_emit`]:
/// the emit-time guard catches the same misuse when a caller already
/// holds a [`super::NegotiatedPlan`]; this validation-layer guard
/// catches it earlier, against a [`RoutingPlan`], so a misuse can be
/// rejected before negotiation runs.
///
/// Returns [`RouteError::InvalidEventEnvelope`] on rejection so the
/// failure-class mapping is `InvalidRequest` — consistent with how
/// the same misuse is mapped when caught at deserialize time.
pub fn validate_receipt_eligible(plan: &RoutingPlan) -> Result<(), RouteError> {
    if matches!(plan.event, LifecycleEventKind::ReceiptEmitted) {
        return Err(RouteError::InvalidEventEnvelope {
            detail: "receipt.emitted is a notification event and must not produce \
                     a lifecycle receipt"
                .into(),
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn route_error_maps_to_invalid_request_or_adapter_unavailable() {
        let cases: Vec<(RouteError, FailureClass)> = vec![
            (
                RouteError::SchemaVersionMismatch {
                    expected: "a".into(),
                    found: "b".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::EmptySentinel { field: "x" },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::UnknownEventName {
                    received: "bogus".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::UnknownEnumName {
                    field: "integration_mode",
                    received: "weird".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::InvalidFrameContext {
                    detail: "missing".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::InvalidPayloadRef {
                    index: 0,
                    detail: "empty".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::InvalidEventEnvelope { detail: "x".into() },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::AdapterIdNotFound {
                    adapter_id: "ghost".into(),
                },
                FailureClass::AdapterUnavailable,
            ),
            (
                RouteError::AdapterVersionMismatch {
                    adapter_id: "codex".into(),
                    requested: "0.0.0".into(),
                    registered: "0.1.0".into(),
                },
                FailureClass::AdapterUnavailable,
            ),
        ];
        let mapper = LifeloopFailureMapper::new();
        for (err, expected) in cases {
            let (fc, rc) = mapper.map_route_error(&err);
            assert_eq!(fc, expected, "route error -> failure class: {err:?}");
            assert_eq!(rc, fc.default_retry(), "retry class follows default");
            // From impl agrees with the trait method.
            let via_from: FailureClass = (&err).into();
            assert_eq!(via_from, fc);
        }
    }

    #[test]
    fn receipt_error_mapping() {
        let mapper = LifeloopFailureMapper::new();
        assert_eq!(
            mapper.map_receipt_error(&ReceiptError::ReceiptEmittedNotEmittable),
            (FailureClass::InvalidRequest, RetryClass::DoNotRetry),
        );
        assert_eq!(
            mapper.map_receipt_error(&ReceiptError::Conflict {
                idempotency_key: "k".into()
            }),
            (FailureClass::StateConflict, RetryClass::RetryAfterReread),
        );
    }

    #[test]
    fn transport_error_mapping_distinguishes_io_timeout_internal() {
        let mapper = LifeloopFailureMapper::new();
        assert_eq!(
            mapper
                .map_transport_error(&TransportError::Io("EPIPE".into()))
                .0,
            FailureClass::TransportError,
        );
        assert_eq!(
            mapper.map_transport_error(&TransportError::Timeout).0,
            FailureClass::Timeout,
        );
        assert_eq!(
            mapper
                .map_transport_error(&TransportError::Internal("panic".into()))
                .0,
            FailureClass::InternalError,
        );
    }

    #[test]
    fn negotiation_requires_operator_uses_operator_required_pair() {
        let pair = classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None);
        assert_eq!(
            pair,
            Some((
                FailureClass::OperatorRequired,
                RetryClass::RetryAfterOperator
            ))
        );
    }

    #[test]
    fn negotiation_satisfied_and_degraded_yield_no_blocking_pair() {
        assert!(classes_for_negotiation_outcome(NegotiationOutcome::Satisfied, None).is_none());
        assert!(classes_for_negotiation_outcome(NegotiationOutcome::Degraded, None).is_none());
    }
}