Skip to main content

ignition_core/actions/
logs.rs

1//! Log actions (02-04, HLTH-03/04): the poll-based TAIL loop — serde
2//! models and a sink OUT, no printing (ARCHITECTURE.md layering: the
3//! Phase-6 TUI rides this same layer).
4//!
5//! There is NO server push for gateway logs (02-RESEARCH Don't-Hand-Roll
6//! table): `GET /logs?startTime=<epoch-ms>` IS the tail primitive. The
7//! loop polls through the shared [`crate::poll`] engine (×1.5 adaptive
8//! backoff, Network/GatewayRestarting retried, Auth never) — the same
9//! engine 02-05's `wait` reuses.
10//!
11//! Cursor semantics (plan key_link): start at `since` (or 0 = the
12//! whole buffer); every page advances the cursor to the max timestamp
13//! seen; the next query sends `startTime = cursor + 1` — no overlap,
14//! no gaps. Entries are sorted client-side so the stream order is
15//! timestamp order regardless of the server's page ordering.
16//!
17//! `deadline: None` = run until Ctrl-C (the process default kill —
18//! research: keep Ctrl-C simple, README-documented); `Some(d)` ends
19//! GRACEFULLY: the poll's deadline expiry maps to `Ok` (exit 0 — the
20//! entries already streamed through the sink).
21
22use std::path::Path;
23use std::sync::Mutex;
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26use serde::Serialize;
27
28use crate::client::logs::{LogDownload, LogEntry, LogQuery, LoggerInfo};
29// Public re-export: the list action's return type is the wire-faithful
30// page — callers (and ActionOutput) name it via the action module.
31use crate::client::GatewayApi;
32pub use crate::client::logs::LogPage;
33use crate::client::query::ListEnvelope;
34use crate::error::CoreError;
35use crate::poll::{self, PollConfig, PollState};
36
37/// `ign logs download` output model.
38#[derive(Debug, Serialize)]
39pub struct DownloadResult {
40    /// Path of the file written.
41    pub file: String,
42    /// Bytes written.
43    pub bytes: usize,
44    /// Response content type (`application/x-sqlite3` — verified).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub content_type: Option<String>,
47}
48
49/// `ign logs loggers set` output model.
50#[derive(Debug, Serialize)]
51pub struct SetLevelResult {
52    /// The logger that was changed.
53    pub logger: String,
54    /// The level it now carries (uppercase wire form).
55    pub level: String,
56}
57
58/// `ign logs loggers reset` output model.
59#[derive(Debug, Serialize)]
60pub struct ResetResult {
61    /// Always `true` — the reset reset every custom level.
62    pub reset: bool,
63}
64
65/// The logger registry page (`ign logs loggers`).
66pub type LoggersEnvelope = ListEnvelope<LoggerInfo>;
67
68/// `ign logs` (no `--follow`): newest entries first. The query always
69/// sorts `desc(timestamp)` and carries an explicit `limit` — together
70/// they make "the recent log entries" (must-have truth #1) without a
71/// `--since` window guess: `--limit 200` = the NEWEST 200, not the
72/// oldest.
73pub async fn list_logs(
74    api: &dyn GatewayApi,
75    logger: Option<&str>,
76    min_level: Option<&str>,
77    since_ms: Option<i64>,
78    limit: i64,
79) -> Result<LogPage, CoreError> {
80    let query = LogQuery {
81        start_time: since_ms,
82        logger: logger.map(str::to_string),
83        min_level: min_level.map(str::to_string),
84        limit,
85        sort_by: Some("desc(timestamp)".to_string()),
86        ..LogQuery::default()
87    };
88    api.logs(&query).await
89}
90
91/// `ign logs loggers`: the logger registry, explicit `limit` (must-have
92/// truth #5 — even the registry never rides the unlimited default),
93/// optional substring `search`.
94pub async fn loggers(
95    api: &dyn GatewayApi,
96    search: Option<&str>,
97) -> Result<ListEnvelope<LoggerInfo>, CoreError> {
98    let query = crate::client::query::ListQuery {
99        limit: crate::client::logs::DEFAULT_LOG_LIMIT,
100        search: search.map(str::to_string),
101        ..Default::default()
102    };
103    api.loggers(&query).await
104}
105
106/// `ign logs loggers set <name> <LEVEL>`. Confirmation guarding belongs
107/// to the CALLER (the CLI refuses without `--yes` before any API
108/// construction) — the action is the obedient arm.
109pub async fn set_logger_level(
110    api: &dyn GatewayApi,
111    logger: &str,
112    level: &str,
113) -> Result<SetLevelResult, CoreError> {
114    api.set_logger_level(logger, level).await?;
115    Ok(SetLevelResult {
116        logger: logger.to_string(),
117        level: level.to_string(),
118    })
119}
120
121/// `ign logs loggers reset` — same guard contract as set.
122pub async fn reset_logger_levels(api: &dyn GatewayApi) -> Result<ResetResult, CoreError> {
123    api.reset_logger_levels().await?;
124    Ok(ResetResult { reset: true })
125}
126
127/// The download filename: `-o FILE` wins, then the gateway's
128/// `Content-Disposition` name, then `<stem>-logs-<unix_ts>.idb` — NEVER
129/// `.zip` (Pitfall 7: the archive is SQLite).
130fn download_filename(
131    output: Option<&Path>,
132    download: &LogDownload,
133    stem: &str,
134    now_secs: i64,
135) -> String {
136    if let Some(output) = output {
137        return output.display().to_string();
138    }
139    if let Some(filename) = download.filename.as_deref().filter(|name| !name.is_empty()) {
140        return filename.to_string();
141    }
142    format!("{stem}-logs-{now_secs}.idb")
143}
144
145/// `ign logs download` — fetch the `.idb` archive and write it EXACTLY
146/// as received (no transformation, no extraction). `stem` names the
147/// gateway for the fallback filename (the CLI passes the profile name).
148pub async fn download(
149    api: &dyn GatewayApi,
150    output: Option<&Path>,
151    fallback_stem: &str,
152) -> Result<DownloadResult, CoreError> {
153    let fetched = api.logs_download().await?;
154    let now_secs = SystemTime::now()
155        .duration_since(UNIX_EPOCH)
156        .map(|since| since.as_secs() as i64)
157        .unwrap_or_default();
158    let file = download_filename(output, &fetched, fallback_stem, now_secs);
159    std::fs::write(&file, &fetched.bytes)
160        .map_err(|err| CoreError::Internal(format!("cannot write log archive {file}: {err}")))?;
161    Ok(DownloadResult {
162        bytes: fetched.bytes.len(),
163        content_type: fetched.content_type,
164        file,
165    })
166}
167
168/// Parse `--since`: an absolute `EPOCH_MS` value or a relative span
169/// `Nms` / `Ns` / `Nmin` / `Nh` (resolved against `now_ms`). Returned
170/// as a plain `String` error so clap can surface it as a usage-class
171/// parse failure (exit 2) — validation happens at arg-parse time, not
172/// deep in dispatch.
173pub fn parse_since(spec: &str, now_ms: i64) -> Result<i64, String> {
174    let spec = spec.trim();
175    // Order matters: "ms" before bare "s"; "min" between them.
176    for (suffix, unit_ms) in [
177        ("ms", 1_i64),
178        ("min", 60_000),
179        ("h", 3_600_000),
180        ("s", 1_000),
181    ] {
182        if let Some(digits) = spec.strip_suffix(suffix)
183            && let Ok(count) = digits.parse::<i64>()
184            && count >= 0
185        {
186            return Ok(now_ms - count * unit_ms);
187        }
188    }
189    // Absolute epoch-ms (also covers "0" = the whole buffer).
190    match spec.parse::<i64>() {
191        Ok(epoch_ms) if epoch_ms >= 0 => Ok(epoch_ms),
192        _ => Err(format!(
193            "invalid --since {spec:?}: expected EPOCH-MS or a relative span like 500ms, 30s, 5min, 2h"
194        )),
195    }
196}
197
198/// `ign logs --follow` result — how much streamed before the tail
199/// ended. (Ctrl-C never reaches here: the process default kill emits
200/// no envelope at all, README-documented.)
201#[derive(Debug, Default, Serialize)]
202pub struct TailResult {
203    /// Entries delivered to the sink.
204    pub streamed: usize,
205}
206
207/// The tail loop's probe scratch: the cursor (epoch ms) and the sink.
208/// Owned by [`poll`], lent fresh to every probe call.
209struct TailState<'a> {
210    /// Max timestamp delivered so far (-1 before the first page).
211    cursor: i64,
212    /// Receives each entry as it arrives (the action stays
213    /// printer-free — the dispatch owns stdout). `+ Send` so the tail
214    /// future can cross `tokio::spawn` on the multi-thread runtime
215    /// (06-01: the TUI's logs worker; the rig.rs sinks set this
216    /// convention first).
217    sink: &'a mut (dyn FnMut(&LogEntry) + Send),
218}
219
220/// Stream new log entries to `sink` as they arrive. The action is
221/// printer-free — the dispatch owns stdout (human lines or NDJSON).
222///
223/// Every query carries an explicit limit ([`LogQuery::default`] —
224/// Pitfall 9); a page larger than the limit still advances the cursor
225/// correctly (cursor = max timestamp seen, so the next poll resumes
226/// exactly past it).
227pub async fn tail(
228    api: &dyn GatewayApi,
229    logger: Option<&str>,
230    min_level: Option<&str>,
231    since_ms: Option<i64>,
232    interval: Duration,
233    deadline: Option<Duration>,
234    sink: &mut (dyn FnMut(&LogEntry) + Send),
235) -> Result<TailResult, CoreError> {
236    // -1 so the FIRST query's start_time = cursor + 1 = since exactly
237    // (or 0 when no --since — the whole buffer).
238    let state = TailState {
239        cursor: since_ms.unwrap_or(0) - 1,
240        sink,
241    };
242    // The stream count lives OUTSIDE the poll call (the probe bumps it
243    // through a shared borrow; poll consumes the state). A `Mutex` (not
244    // `Cell`) so the probe future is Send — the 06-02 TUI spawns tails.
245    let streamed = Mutex::new(0usize);
246
247    let cfg = PollConfig {
248        subject: "log tail (GET /data/api/v1/logs)".to_string(),
249        interval,
250        deadline: deadline.unwrap_or(Duration::MAX),
251        ..PollConfig::default()
252    };
253
254    let outcome = poll::poll(cfg, state, |state| {
255        Box::pin(async {
256            let query = LogQuery {
257                start_time: Some(state.cursor + 1),
258                logger: logger.map(str::to_string),
259                min_level: min_level.map(str::to_string),
260                ..LogQuery::default()
261            };
262            let page = api.logs(&query).await?;
263            let mut entries = page.items;
264            // Timestamp order regardless of server page ordering.
265            entries.sort_by_key(|entry| entry.timestamp);
266            let observation = entries
267                .last()
268                .map(|last| format!("{} entries, latest at {}", entries.len(), last.timestamp));
269            for entry in &entries {
270                (state.sink)(entry);
271            }
272            if let Some(last) = entries.last() {
273                state.cursor = last.timestamp;
274            }
275            *streamed.lock().expect("streamed count") += entries.len();
276            Ok(PollState::<()>::Pending(observation))
277        })
278    })
279    .await;
280
281    match outcome {
282        // The probe never reports Done (T = ()) — the only Ok is
283        // unreachable; kept for match totality.
284        Ok(()) => Ok(TailResult {
285            streamed: *streamed.lock().expect("streamed count"),
286        }),
287        // Deadline expiry = GRACEFUL end (exit 0): poll retries genuine
288        // Network errors until the deadline, so a None-source Network
289        // error IS the timeout. The entries already streamed.
290        Err(CoreError::Network { source: None, .. }) => Ok(TailResult {
291            streamed: *streamed.lock().expect("streamed count"),
292        }),
293        Err(err) => Err(err),
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use std::sync::Mutex;
300    use std::time::Duration;
301
302    use super::{download_filename, parse_since, tail};
303    use crate::client::GatewayApi;
304    use crate::client::logs::{LogDownload, LogEntry, LogQuery};
305    use crate::client::query::{ListEnvelope, ListMetadata};
306    use crate::error::CoreError;
307
308    /// `--since` accepts EPOCH-MS and every relative suffix, parsed
309    /// against a fixed now; junk is a usage-class String error.
310    #[test]
311    fn parse_since_accepts_epoch_and_relative_spans() {
312        const NOW: i64 = 1_787_346_747_022;
313        assert_eq!(parse_since("1787346747022", NOW), Ok(1787346747022));
314        assert_eq!(parse_since("0", NOW), Ok(0));
315        assert_eq!(parse_since("500ms", NOW), Ok(NOW - 500));
316        assert_eq!(parse_since("30s", NOW), Ok(NOW - 30_000));
317        assert_eq!(parse_since("5min", NOW), Ok(NOW - 300_000));
318        assert_eq!(parse_since("2h", NOW), Ok(NOW - 7_200_000));
319        // suffix order matters: "ms" before bare "s"
320        assert_eq!(parse_since("1s", NOW), Ok(NOW - 1_000));
321        assert!(parse_since("banana", NOW).is_err());
322        assert!(
323            parse_since("-5s", NOW).is_err(),
324            "negative spans are invalid"
325        );
326        assert!(parse_since("", NOW).is_err());
327    }
328
329    /// Filename precedence: `-o FILE` > Content-Disposition > the
330    /// `<stem>-logs-<ts>.idb` fallback — and NEVER a `.zip` (Pitfall 7).
331    #[test]
332    fn download_filename_precedence() {
333        let fetched = LogDownload {
334            bytes: Vec::new(),
335            filename: Some("GW_Ignition_logs_20260822-0307.idb".into()),
336            content_type: Some("application/x-sqlite3".into()),
337        };
338        assert_eq!(
339            download_filename(
340                Some(std::path::Path::new("/tmp/out.idb")),
341                &fetched,
342                "dev",
343                1000
344            ),
345            "/tmp/out.idb",
346            "-o wins"
347        );
348        assert_eq!(
349            download_filename(None, &fetched, "dev", 1000),
350            "GW_Ignition_logs_20260822-0307.idb",
351            "Content-Disposition name second"
352        );
353        let anonymous = LogDownload {
354            bytes: Vec::new(),
355            filename: None,
356            content_type: None,
357        };
358        assert_eq!(
359            download_filename(None, &anonymous, "dev", 1_787_346_747),
360            "dev-logs-1787346747.idb",
361            "fallback = <stem>-logs-<unix_ts>.idb — never .zip"
362        );
363    }
364
365    /// A scripted double: serves `pages` in order (then empty pages
366    /// forever) and records every query it saw.
367    #[derive(Default)]
368    struct TailRig {
369        pages: Mutex<std::collections::VecDeque<Vec<LogEntry>>>,
370        queries: Mutex<Vec<LogQuery>>,
371    }
372
373    fn entry(timestamp: i64, message: &str) -> LogEntry {
374        LogEntry {
375            timestamp,
376            logger_name: "GatewayManager".into(),
377            level: "INFO".into(),
378            message: message.into(),
379            stack: Vec::new(),
380            mdc: Default::default(),
381            extra: Default::default(),
382        }
383    }
384
385    #[async_trait::async_trait]
386    impl GatewayApi for TailRig {
387        async fn thread_dump(
388            &self,
389        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
390            unreachable!("not part of this action")
391        }
392        async fn thread_deadlocks(
393            &self,
394        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
395            unreachable!("not part of this action")
396        }
397        async fn uninstall_module(
398            &self,
399            _gateway_module_id: &str,
400            _module_id: &str,
401        ) -> Result<(), CoreError> {
402            unreachable!("not part of this action")
403        }
404        async fn bundle_generate(
405            &self,
406        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
407            unreachable!("not part of this action")
408        }
409        async fn bundle_status(
410            &self,
411        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
412            unreachable!("not part of this action")
413        }
414        async fn bundle_download(
415            &self,
416            _out: &std::path::Path,
417        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
418            unreachable!("not part of this action")
419        }
420        async fn tag_provider_list(
421            &self,
422            _query: &crate::client::query::ListQuery,
423        ) -> Result<
424            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
425            CoreError,
426        > {
427            unreachable!("not part of this action")
428        }
429        async fn tag_provider_find(
430            &self,
431            _name: &str,
432        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
433            unreachable!("not part of this action")
434        }
435        async fn tag_provider_create(
436            &self,
437            _body: &[crate::client::tags::TagProviderCreate],
438        ) -> Result<(), CoreError> {
439            unreachable!("not part of this action")
440        }
441        async fn tag_provider_delete(
442            &self,
443            _name: &str,
444            _signature: &str,
445        ) -> Result<(), CoreError> {
446            unreachable!("not part of this action")
447        }
448        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
449            unreachable!("not part of this action")
450        }
451        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
452            unreachable!("not part of this action")
453        }
454        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
455            unreachable!("not part of this action")
456        }
457        async fn backup_download(
458            &self,
459            _out: &std::path::Path,
460            _backup_type: crate::client::backup::BackupType,
461        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
462            unreachable!("not part of this action")
463        }
464        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
465            unreachable!("not part of this action")
466        }
467        async fn eam_task_history(
468            &self,
469            _limit: Option<u32>,
470            _search: Option<&str>,
471        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
472        {
473            unreachable!("not part of this action")
474        }
475        async fn eam_task_definitions(
476            &self,
477        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
478        {
479            unreachable!("not part of this action")
480        }
481        async fn eam_task_find(
482            &self,
483            _name: &str,
484        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
485            unreachable!("not part of this action")
486        }
487        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
488            unreachable!("not part of this action")
489        }
490        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
491            unreachable!("not part of this action")
492        }
493        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
494            unreachable!("not part of this action")
495        }
496        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
497            unreachable!("not part of this action")
498        }
499        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
500            unreachable!("not part of this action")
501        }
502        async fn eam_tasks_scheduled(
503            &self,
504            _running: bool,
505        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
506            unreachable!("not part of this action")
507        }
508        async fn eam_task_modify(
509            &self,
510            _definition: &serde_json::Value,
511        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
512            unreachable!("not part of this action")
513        }
514        async fn eam_task_delete(
515            &self,
516            _name: &str,
517            _signature: &str,
518            _confirm: bool,
519        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
520            unreachable!("not part of this action")
521        }
522        async fn api_call(
523            &self,
524            _call: &crate::client::apicall::ApiCallRequest,
525        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
526            unreachable!("not part of this action")
527        }
528        async fn license_status(
529            &self,
530        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
531            unreachable!("not part of this action")
532        }
533        async fn redundancy_status(
534            &self,
535        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
536            unreachable!("not part of this action")
537        }
538        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
539            unreachable!("not part of this action")
540        }
541        async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
542            self.queries.lock().unwrap().push(filter.clone());
543            let items = self.pages.lock().unwrap().pop_front().unwrap_or_default();
544            Ok(ListEnvelope {
545                metadata: ListMetadata {
546                    total: items.len() as i64,
547                    matching: items.len() as i64,
548                    limit: 200,
549                    offset: 0,
550                },
551                items,
552            })
553        }
554        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
555            unreachable!("not part of this action")
556        }
557        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
558            unreachable!("not part of this action")
559        }
560        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
561            unreachable!("not part of this action")
562        }
563        async fn modules(
564            &self,
565            _quarantined: bool,
566            _query: &crate::client::query::ListQuery,
567        ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
568            unreachable!("not part of this action")
569        }
570        async fn metrics_current(
571            &self,
572        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
573            unreachable!("not part of this action")
574        }
575        async fn metrics_historic(
576            &self,
577        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
578            unreachable!("not part of this action")
579        }
580        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
581            unreachable!("not part of this action")
582        }
583        async fn designers(
584            &self,
585            _query: &crate::client::query::ListQuery,
586        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
587            unreachable!("not part of this action")
588        }
589        async fn perspective_sessions(
590            &self,
591            _query: &crate::client::query::ListQuery,
592        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
593            unreachable!("not part of this action")
594        }
595        async fn vision_clients(
596            &self,
597            _query: &crate::client::query::ListQuery,
598        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
599            unreachable!("not part of this action")
600        }
601        async fn terminate_perspective_session(
602            &self,
603            _id: &str,
604            _message: Option<&str>,
605        ) -> Result<(), CoreError> {
606            unreachable!("not part of this action")
607        }
608        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
609            unreachable!("not part of this action")
610        }
611        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
612            unreachable!("not part of this action")
613        }
614        async fn database_connections(
615            &self,
616        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
617        {
618            unreachable!("not part of this action")
619        }
620        async fn opc_connections(
621            &self,
622        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
623        {
624            unreachable!("not part of this action")
625        }
626        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
627            unreachable!("not part of this action")
628        }
629        async fn loggers(
630            &self,
631            _query: &crate::client::query::ListQuery,
632        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
633            unreachable!("not part of this action")
634        }
635        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
636            unreachable!("not part of this action")
637        }
638        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
639            unreachable!("not part of this action")
640        }
641        async fn restart(&self) -> Result<(), CoreError> {
642            unreachable!("not part of this action")
643        }
644        async fn scan_projects(&self) -> Result<(), CoreError> {
645            unreachable!("not part of this action")
646        }
647        async fn security_properties(
648            &self,
649        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
650            unreachable!("not part of this action")
651        }
652        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
653            unreachable!("not part of this action")
654        }
655        async fn webdev_route_call(
656            &self,
657            _project: &str,
658            _route: &str,
659            _body: &serde_json::Value,
660            _extra_headers: &[(&str, &str)],
661        ) -> Result<serde_json::Value, CoreError> {
662            unreachable!("not part of this action")
663        }
664        async fn webdev_route_probe(
665            &self,
666            _project: &str,
667            _route: &str,
668            _extra_headers: &[(&str, &str)],
669        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
670            unreachable!("not part of this action")
671        }
672        async fn projects(
673            &self,
674            _query: &crate::client::query::ListQuery,
675        ) -> Result<
676            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
677            CoreError,
678        > {
679            unreachable!("not part of this action")
680        }
681        async fn project_find(
682            &self,
683            _name: &str,
684        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
685            unreachable!("not part of this action")
686        }
687        async fn project_create(
688            &self,
689            _body: &crate::client::projects::ProjectCreate,
690        ) -> Result<(), CoreError> {
691            unreachable!("not part of this action")
692        }
693        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
694            unreachable!("not part of this action")
695        }
696        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
697            unreachable!("not part of this action")
698        }
699        async fn project_modify(
700            &self,
701            _name: &str,
702            _body: &crate::client::projects::ProjectModify,
703        ) -> Result<(), CoreError> {
704            unreachable!("not part of this action")
705        }
706        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
707            unreachable!("not part of this action")
708        }
709        async fn project_export_to_file(
710            &self,
711            _name: &str,
712            _out: &std::path::Path,
713        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
714            unreachable!("not part of this action")
715        }
716        async fn project_import(
717            &self,
718            _name: &str,
719            _zip: Vec<u8>,
720            _overwrite: bool,
721        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
722            unreachable!("not part of this action")
723        }
724    }
725
726    /// Two pages then silence under a short deadline: entries arrive in
727    /// TIMESTAMP order through the sink, the cursor advances past each
728    /// page's max (next query's startTime = max + 1), and the deadline
729    /// expiry ends the tail CLEANLY (Ok, exit 0 semantics).
730    #[tokio::test]
731    async fn tail_streams_pages_in_order_and_ends_cleanly_on_deadline() {
732        let rig = TailRig {
733            pages: Mutex::new(
734                vec![
735                    // Deliberately out of order WITHIN the page: client-side
736                    // sort must fix the stream order.
737                    vec![entry(1010, "second"), entry(1005, "first")],
738                    vec![entry(1022, "third"), entry(1018, "wait, also")],
739                ]
740                .into(),
741            ),
742            queries: Mutex::new(Vec::new()),
743        };
744
745        let mut received: Vec<(i64, String)> = Vec::new();
746        let sink: &mut (dyn FnMut(&LogEntry) + Send) = &mut |entry: &LogEntry| {
747            received.push((entry.timestamp, entry.message.clone()));
748        };
749
750        let result = tail(
751            &rig,
752            None,
753            None,
754            Some(1000), // since → first query startTime = 1000
755            Duration::from_millis(5),
756            // Deadline budget generous on purpose: 40ms starved the
757            // second page under a full parallel workspace run (the
758            // rig serves pages from memory — only scheduler latency
759            // competes). 400ms gives ~10x headroom while keeping the
760            // isolated test under half a second.
761            Some(Duration::from_millis(400)),
762            sink,
763        )
764        .await
765        .expect("deadline expiry ends the tail cleanly");
766
767        // Entries delivered in timestamp order across BOTH pages.
768        assert_eq!(
769            received,
770            vec![
771                (1005, "first".into()),
772                (1010, "second".into()),
773                (1018, "wait, also".into()),
774                (1022, "third".into()),
775            ],
776            "stream order is timestamp order (client-side sort)"
777        );
778        assert_eq!(result.streamed, 4);
779
780        // Cursor discipline: first query starts at `since` exactly;
781        // after page 1 (max 1010) the next startTime = 1011; after
782        // page 2 (max 1022) the next startTime = 1023 (then silence).
783        let queries = rig.queries.lock().unwrap();
784        assert_eq!(queries[0].start_time, Some(1000), "first = since");
785        assert!(
786            queries.len() >= 3,
787            "polled again after each page: {}",
788            queries.len()
789        );
790        assert_eq!(queries[1].start_time, Some(1011), "cursor = max + 1");
791        assert!(
792            queries[2].start_time == Some(1023),
793            "cursor advanced past page 2: {:?}",
794            queries[2].start_time
795        );
796        // Every query carries the explicit limit (Pitfall 9).
797        assert!(queries.iter().all(|query| query.limit == 200));
798    }
799
800    /// Auth failures surface immediately — the tail never retries a
801    /// rejected token (the poll engine's never-retry rule, proven at
802    /// the action seam).
803    #[tokio::test]
804    async fn tail_fails_fast_on_auth() {
805        struct AuthRig;
806        #[async_trait::async_trait]
807        impl GatewayApi for AuthRig {
808            async fn thread_dump(
809                &self,
810            ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
811                unreachable!("not part of this action")
812            }
813            async fn thread_deadlocks(
814                &self,
815            ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
816                unreachable!("not part of this action")
817            }
818            async fn uninstall_module(
819                &self,
820                _gateway_module_id: &str,
821                _module_id: &str,
822            ) -> Result<(), CoreError> {
823                unreachable!("not part of this action")
824            }
825            async fn bundle_generate(
826                &self,
827            ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
828                unreachable!("not part of this action")
829            }
830            async fn bundle_status(
831                &self,
832            ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
833                unreachable!("not part of this action")
834            }
835            async fn bundle_download(
836                &self,
837                _out: &std::path::Path,
838            ) -> Result<crate::client::projects::ExportMeta, CoreError> {
839                unreachable!("not part of this action")
840            }
841            async fn tag_provider_list(
842                &self,
843                _query: &crate::client::query::ListQuery,
844            ) -> Result<
845                crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
846                CoreError,
847            > {
848                unreachable!("not part of this action")
849            }
850            async fn tag_provider_find(
851                &self,
852                _name: &str,
853            ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
854                unreachable!("not part of this action")
855            }
856            async fn tag_provider_create(
857                &self,
858                _body: &[crate::client::tags::TagProviderCreate],
859            ) -> Result<(), CoreError> {
860                unreachable!("not part of this action")
861            }
862            async fn tag_provider_delete(
863                &self,
864                _name: &str,
865                _signature: &str,
866            ) -> Result<(), CoreError> {
867                unreachable!("not part of this action")
868            }
869            async fn trial_status_wire(
870                &self,
871            ) -> Result<crate::client::trial::TrialWire, CoreError> {
872                unreachable!("not part of this action")
873            }
874            async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
875                unreachable!("not part of this action")
876            }
877            async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
878                unreachable!("not part of this action")
879            }
880            async fn backup_download(
881                &self,
882                _out: &std::path::Path,
883                _backup_type: crate::client::backup::BackupType,
884            ) -> Result<crate::client::projects::ExportMeta, CoreError> {
885                unreachable!("not part of this action")
886            }
887            async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
888                unreachable!("not part of this action")
889            }
890            async fn eam_task_history(
891                &self,
892                _limit: Option<u32>,
893                _search: Option<&str>,
894            ) -> Result<
895                crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>,
896                CoreError,
897            > {
898                unreachable!("not part of this action")
899            }
900            async fn eam_task_definitions(
901                &self,
902            ) -> Result<
903                crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>,
904                CoreError,
905            > {
906                unreachable!("not part of this action")
907            }
908            async fn eam_task_find(
909                &self,
910                _name: &str,
911            ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
912                unreachable!("not part of this action")
913            }
914            async fn eam_task_create(
915                &self,
916                _definition: &serde_json::Value,
917            ) -> Result<(), CoreError> {
918                unreachable!("not part of this action")
919            }
920            async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
921                unreachable!("not part of this action")
922            }
923            async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
924                unreachable!("not part of this action")
925            }
926            async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
927                unreachable!("not part of this action")
928            }
929            async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
930                unreachable!("not part of this action")
931            }
932            async fn eam_tasks_scheduled(
933                &self,
934                _running: bool,
935            ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
936                unreachable!("not part of this action")
937            }
938            async fn eam_task_modify(
939                &self,
940                _definition: &serde_json::Value,
941            ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
942                unreachable!("not part of this action")
943            }
944            async fn eam_task_delete(
945                &self,
946                _name: &str,
947                _signature: &str,
948                _confirm: bool,
949            ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
950                unreachable!("not part of this action")
951            }
952            async fn api_call(
953                &self,
954                _call: &crate::client::apicall::ApiCallRequest,
955            ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
956                unreachable!("not part of this action")
957            }
958            async fn license_status(
959                &self,
960            ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
961                unreachable!("not part of this action")
962            }
963            async fn redundancy_status(
964                &self,
965            ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
966                unreachable!("not part of this action")
967            }
968            async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
969                unreachable!("not part of this action")
970            }
971            async fn logs(&self, _filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
972                Err(CoreError::Auth {
973                    status: 401,
974                    endpoint: Some("http://gw/data/api/v1/logs".into()),
975                })
976            }
977            async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
978                unreachable!("not part of this action")
979            }
980            async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
981                unreachable!("not part of this action")
982            }
983            async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
984                unreachable!("not part of this action")
985            }
986            async fn modules(
987                &self,
988                _quarantined: bool,
989                _query: &crate::client::query::ListQuery,
990            ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
991                unreachable!("not part of this action")
992            }
993            async fn metrics_current(
994                &self,
995            ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
996                unreachable!("not part of this action")
997            }
998            async fn metrics_historic(
999                &self,
1000            ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
1001                unreachable!("not part of this action")
1002            }
1003            async fn metrics_threads(
1004                &self,
1005            ) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
1006                unreachable!("not part of this action")
1007            }
1008            async fn designers(
1009                &self,
1010                _query: &crate::client::query::ListQuery,
1011            ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError>
1012            {
1013                unreachable!("not part of this action")
1014            }
1015            async fn perspective_sessions(
1016                &self,
1017                _query: &crate::client::query::ListQuery,
1018            ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError>
1019            {
1020                unreachable!("not part of this action")
1021            }
1022            async fn vision_clients(
1023                &self,
1024                _query: &crate::client::query::ListQuery,
1025            ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError>
1026            {
1027                unreachable!("not part of this action")
1028            }
1029            async fn terminate_perspective_session(
1030                &self,
1031                _id: &str,
1032                _message: Option<&str>,
1033            ) -> Result<(), CoreError> {
1034                unreachable!("not part of this action")
1035            }
1036            async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
1037                unreachable!("not part of this action")
1038            }
1039            async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
1040                unreachable!("not part of this action")
1041            }
1042            async fn database_connections(
1043                &self,
1044            ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1045            {
1046                unreachable!("not part of this action")
1047            }
1048            async fn opc_connections(
1049                &self,
1050            ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
1051            {
1052                unreachable!("not part of this action")
1053            }
1054            async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
1055                unreachable!("not part of this action")
1056            }
1057            async fn loggers(
1058                &self,
1059                _query: &crate::client::query::ListQuery,
1060            ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
1061                unreachable!("not part of this action")
1062            }
1063            async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
1064                unreachable!("not part of this action")
1065            }
1066            async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1067                unreachable!("not part of this action")
1068            }
1069            async fn restart(&self) -> Result<(), CoreError> {
1070                unreachable!("not part of this action")
1071            }
1072            async fn scan_projects(&self) -> Result<(), CoreError> {
1073                unreachable!("not part of this action")
1074            }
1075            async fn security_properties(
1076                &self,
1077            ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
1078                unreachable!("not part of this action")
1079            }
1080            async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
1081                unreachable!("not part of this action")
1082            }
1083            async fn webdev_route_call(
1084                &self,
1085                _project: &str,
1086                _route: &str,
1087                _body: &serde_json::Value,
1088                _extra_headers: &[(&str, &str)],
1089            ) -> Result<serde_json::Value, CoreError> {
1090                unreachable!("not part of this action")
1091            }
1092            async fn webdev_route_probe(
1093                &self,
1094                _project: &str,
1095                _route: &str,
1096                _extra_headers: &[(&str, &str)],
1097            ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
1098                unreachable!("not part of this action")
1099            }
1100            async fn projects(
1101                &self,
1102                _query: &crate::client::query::ListQuery,
1103            ) -> Result<
1104                crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
1105                CoreError,
1106            > {
1107                unreachable!("not part of this action")
1108            }
1109            async fn project_find(
1110                &self,
1111                _name: &str,
1112            ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
1113                unreachable!("not part of this action")
1114            }
1115            async fn project_create(
1116                &self,
1117                _body: &crate::client::projects::ProjectCreate,
1118            ) -> Result<(), CoreError> {
1119                unreachable!("not part of this action")
1120            }
1121            async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
1122                unreachable!("not part of this action")
1123            }
1124            async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
1125                unreachable!("not part of this action")
1126            }
1127            async fn project_modify(
1128                &self,
1129                _name: &str,
1130                _body: &crate::client::projects::ProjectModify,
1131            ) -> Result<(), CoreError> {
1132                unreachable!("not part of this action")
1133            }
1134            async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
1135                unreachable!("not part of this action")
1136            }
1137            async fn project_export_to_file(
1138                &self,
1139                _name: &str,
1140                _out: &std::path::Path,
1141            ) -> Result<crate::client::projects::ExportMeta, CoreError> {
1142                unreachable!("not part of this action")
1143            }
1144            async fn project_import(
1145                &self,
1146                _name: &str,
1147                _zip: Vec<u8>,
1148                _overwrite: bool,
1149            ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
1150                unreachable!("not part of this action")
1151            }
1152        }
1153
1154        let sink: &mut (dyn FnMut(&LogEntry) + Send) = &mut |_| {};
1155        let err = tail(
1156            &AuthRig,
1157            None,
1158            None,
1159            None,
1160            Duration::from_millis(5),
1161            Some(Duration::from_secs(5)),
1162            sink,
1163        )
1164        .await
1165        .expect_err("auth must fail fast");
1166        assert!(matches!(err, CoreError::Auth { status: 401, .. }));
1167        assert_eq!(err.exit_code(), 5);
1168    }
1169}