openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
// Windows release builds run under the `windows` subsystem so that the OS
// never allocates a console window when the binary is launched by Task
// Scheduler, a service harness, or any other non-TTY parent — the supervision
// auto-start path. Debug builds keep the default `console` subsystem so
// `cargo run` and `cargo test` output still appears in the terminal.
//
// The matching `AttachConsole(ATTACH_PARENT_PROCESS)` call in `main` restores
// stdout/stderr when the binary IS invoked from an interactive shell, so
// `openlatch status` etc. continue to print for CLI users.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]

use clap::Parser;

use openlatch_client::cli;
use openlatch_client::cli::Commands;
use openlatch_client::error::OlError;
use openlatch_client::telemetry::{self, Event};
use openlatch_client::update;

/// Outcome of the supervisor-restart-loop rollback path. Captured at
/// the very top of `main()` BEFORE telemetry / logging init, then
/// flushed out as a `update_completed` event once telemetry is alive.
/// `eprintln!` and stdlib I/O are the only outputs allowed during the
/// rollback window — `tracing::warn!` would silently no-op (no
/// subscriber yet) and `telemetry::capture_global` would no-op (handle
/// not installed yet).
enum RollbackEvent {
    /// `.bak` siblings were renamed back; sentinel cleared. Carries the
    /// `from`/`to` versions read out of the sentinel before it was
    /// cleared so the deferred telemetry event lands with real strings
    /// instead of `"(unknown)"` placeholders.
    Success { from: String, to: String },
    /// The rollback failed mid-flight. The daemon continues startup
    /// with whatever binary is on disk — typically the crash-looping
    /// new binary, which the supervisor will restart again. The reason
    /// has already been printed to stderr; nothing further to do.
    Failed,
}

/// On Windows, reattach stdio to the parent console if one exists. Called
/// before any output so interactive CLI invocations print normally under the
/// `windows` subsystem. A silent no-op when there is no parent console (the
/// Task Scheduler / launchd / systemd auto-start path).
#[cfg(windows)]
fn attach_parent_console_if_any() {
    use winapi::um::wincon::{AttachConsole, ATTACH_PARENT_PROCESS};
    // Safety: FFI call with no arguments other than a well-known constant.
    // AttachConsole returns 0 on failure (e.g. no parent console), which we
    // intentionally ignore — the daemon path has no parent to attach to.
    unsafe {
        let _ = AttachConsole(ATTACH_PARENT_PROCESS);
    }
}

#[cfg(not(windows))]
fn attach_parent_console_if_any() {}

fn main() {
    attach_parent_console_if_any();

    // Supervisor-restart-loop rollback. Runs BEFORE logging /
    // telemetry init — no subscriber, no PostHog handle yet — so output
    // goes to stderr only. The sentinel is read first so the deferred
    // telemetry event below carries real `from`/`to` versions even
    // though `rollback_from_bak` clears the sentinel as part of its
    // cleanup.
    //
    // Gated on the daemon-foreground argv. Every supervisor template
    // (launchd / systemd / Task Scheduler) and every internal spawn
    // path invokes `daemon start --foreground`; user-facing CLI
    // commands never do. Without this gate, three quick CLI calls
    // (`update`, `status`, `--version`) inside the post-swap window
    // where sentinel + `<exe>.bak` coexist would trip the threshold
    // and silently downgrade the on-disk binary.
    let is_daemon_entry = std::env::args().any(|a| a == "--foreground");
    let rollback_event = if is_daemon_entry && update::should_rollback() {
        let (from, to) = update::read_sentinel()
            .map(|s| (s.from, s.to))
            .unwrap_or_else(|| ("(unknown)".into(), "(unknown)".into()));
        match update::rollback_from_bak() {
            Ok(()) => {
                eprintln!("[openlatch] rolled back update due to supervisor restart loop");
                Some(RollbackEvent::Success { from, to })
            }
            Err(e) => {
                eprintln!("[openlatch] rollback failed: {e}");
                Some(RollbackEvent::Failed)
            }
        }
    } else {
        None
    };

    // CLI-09: Set SIGINT handler to ensure exit code 130. Flush pending crash
    // reports before the hard exit — `process::exit` skips Drop impls, so the
    // `ClientInitGuard`'s flush-on-drop never runs.
    let _ = ctrlc::set_handler(|| {
        #[cfg(feature = "crash-report")]
        openlatch_client::crash_report::flush(std::time::Duration::from_secs(2));
        std::process::exit(130);
    });

    // Crash reporting first — captures panics in everything below, including
    // runtime construction and the detached daemon child process which
    // re-enters this main() via `daemon start --foreground`. The guard is
    // bound to `main`'s stack frame so its Drop (2s flush deadline) runs on
    // normal exit. No-op when the `crash-report` feature is disabled or
    // consent resolves to Disabled.
    #[cfg(feature = "crash-report")]
    let _crash_guard = init_crash_report();

    // Telemetry: initialise the process-global handle and install a panic hook.
    // Both are no-ops when consent is disabled or no key is baked. Phase A's
    // init never spawns a network task; Task 3 will activate the POST path.
    // Note: sentry's PanicIntegration already installed its own hook during
    // init above; our hook chains through `prev(info)` so both fire.
    init_telemetry();
    install_panic_hook();

    // Deferred rollback telemetry. Now that the global handle is
    // installed, surface the rollback outcome captured above. The
    // failed-rollback path is silent — the daemon will likely keep
    // crash-looping and the operator-visible signal lands via Sentry
    // panics rather than a successful update_completed event.
    if let Some(RollbackEvent::Success { from, to }) = rollback_event {
        telemetry::capture_global(Event::update_completed(
            &from,
            &to,
            "normal",
            "in_process",
            /* success */ false,
            /* duration_ms */ None,
            /* rolled_back */ true,
        ));
    }

    let cli_args = cli::Cli::parse();
    let output = cli::build_output_config(&cli_args);

    // No subcommand → print banner + help and exit 0. Matches `--help` UX but
    // routes through our color-aware output path so `--no-color`/non-TTY gets
    // the plain banner while TTY gets the ANSI version.
    let Some(command) = cli_args.command.as_ref() else {
        use clap::CommandFactory;
        cli::header::print_full_banner(&output);
        let _ = cli::Cli::command().print_help();
        println!();
        return;
    };

    let started_at = std::time::Instant::now();
    let (command_label, subcommand_label) = command_labels(command);

    // Tag the current process as a CLI invocation. `run_daemon_foreground`
    // overwrites `process_type` to "daemon" before any daemon work runs,
    // when this main() is reached via `daemon start --foreground`.
    #[cfg(feature = "crash-report")]
    openlatch_client::crash_report::enrich_cli_scope(command_label);

    let result = dispatch(command, &output);

    // The error decides its own status (`OlError::exit_code`) instead of every
    // failure collapsing into 1. Supervisors read this number: exit 5 is how a
    // supervised `daemon start --foreground` tells systemd "another daemon
    // already holds this machine, do not restart me", and telemetry now records
    // the status the process actually returns rather than a stand-in for it.
    let exit_code = result.as_ref().err().map_or(0, OlError::exit_code);
    let duration_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
    telemetry::capture_global(Event::command_invoked(
        command_label,
        subcommand_label,
        exit_code,
        duration_ms,
    ));

    if let Err(e) = result {
        output.print_error(&e);
        std::process::exit(exit_code);
    }
}

/// Initialise Sentry crash reporting. Returns `None` when disabled by any
/// consent rule; subsequent `enrich_*` / `flush` calls are no-ops in that
/// state. The guard must be held for the lifetime of the program — its Drop
/// flushes pending events with a 2s deadline.
#[cfg(feature = "crash-report")]
fn init_crash_report() -> Option<sentry::ClientInitGuard> {
    let dir = openlatch_client::config::openlatch_dir();
    openlatch_client::crash_report::init(&dir)
}

/// Initialise the process-global telemetry handle. Reads the openlatch dir
/// for the consent file and agent id. Best-effort — errors are swallowed
/// because telemetry must never fail user commands (invariant I10-adjacent).
fn init_telemetry() {
    let dir = openlatch_client::config::openlatch_dir();
    // Pre-init builds have no agent_id yet; "agt_unknown" is the documented
    // placeholder for the very first command before `openlatch init` runs.
    let agent_id =
        openlatch_client::config::sniff_agent_id(&dir).unwrap_or_else(|| "agt_unknown".to_string());

    // Task 3: probe for a non-empty PostHog key (build-time baked or runtime
    // override). Empty key → no-op handle, preserves I1.
    let baked_key_present = openlatch_client::telemetry::network::key_is_present();
    let handle = telemetry::init(&dir, agent_id, false, baked_key_present);
    let _ = telemetry::install_global(handle);
}

/// Convert a panic hook into a `daemon_crashed` event. Captures only the
/// `file:line` of the panic location — never the message or interpolated
/// values (§5.3 of the brainstorm).
fn install_panic_hook() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let location = info
            .location()
            .map(|l| format!("{}:{}", l.file(), l.line()))
            .unwrap_or_else(|| "unknown".to_string());
        telemetry::capture_global(Event::daemon_crashed(&location, 0));
        prev(info);
    }));
}

/// Map a parsed CLI command to a stable `(command, subcommand)` pair for the
/// `command_invoked` event. Names are taken from the clap subcommand grammar
/// — never from user-supplied flag values.
fn command_labels(cmd: &Commands) -> (&'static str, Option<&'static str>) {
    match cmd {
        Commands::Init(_) => ("init", None),
        Commands::Status => ("status", None),
        Commands::Start(_) => ("start", None),
        Commands::Stop => ("stop", None),
        Commands::Restart => ("restart", None),
        Commands::Logs(_) => ("logs", None),
        Commands::Doctor(_) => ("doctor", None),
        Commands::Uninstall(_) => ("uninstall", None),
        Commands::Docs => ("docs", None),
        Commands::Hooks { cmd } => (
            "hooks",
            Some(match cmd {
                cli::HooksCommands::Install(_) => "install",
                cli::HooksCommands::Uninstall(_) => "uninstall",
                cli::HooksCommands::Status => "status",
            }),
        ),
        Commands::Daemon { cmd } => (
            "daemon",
            Some(match cmd {
                cli::DaemonCommands::Start(_) => "start",
                cli::DaemonCommands::Stop => "stop",
                cli::DaemonCommands::Restart => "restart",
            }),
        ),
        Commands::Auth { cmd } => (
            "auth",
            Some(match cmd {
                cli::AuthCommands::Login(_) => "login",
                cli::AuthCommands::Logout => "logout",
                cli::AuthCommands::Status => "status",
            }),
        ),
        Commands::Telemetry { cmd } => (
            "telemetry",
            Some(match cmd {
                cli::TelemetryCommands::Status => "status",
                cli::TelemetryCommands::Enable => "enable",
                cli::TelemetryCommands::Disable => "disable",
                cli::TelemetryCommands::Purge => "purge",
                cli::TelemetryCommands::Debug => "debug",
            }),
        ),
        Commands::Supervision { cmd } => (
            "supervision",
            Some(match cmd {
                cli::SupervisionCommands::Install => "install",
                cli::SupervisionCommands::Uninstall => "uninstall",
                cli::SupervisionCommands::Status => "status",
                cli::SupervisionCommands::Enable => "enable",
                cli::SupervisionCommands::Disable => "disable",
            }),
        ),
        Commands::Update(_) => ("update", None),
        Commands::Inventory { cmd } => (
            "inventory",
            Some(match cmd {
                cli::InventoryCommands::List(_) => "list",
                cli::InventoryCommands::Log(_) => "log",
                cli::InventoryCommands::Rescan(_) => "rescan",
                cli::InventoryCommands::Status(_) => "status",
                cli::InventoryCommands::Inspect(_) => "inspect",
                cli::InventoryCommands::Projects(_) => "projects",
                cli::InventoryCommands::Ack(_) => "ack",
            }),
        ),
        // Hidden Phase 1 spike — emit a telemetry label so we can spot
        // accidental invocations in aggregate even though the subcommand
        // is not user-facing.
        Commands::SpikeUpdate(_) => ("__spike-update", None),
        Commands::Boundary { cmd } => (
            "boundary",
            Some(match cmd {
                cli::BoundaryCommands::Status => "status",
                cli::BoundaryCommands::Explain { .. } => "explain",
            }),
        ),
        Commands::Bench { cmd } => (
            "bench",
            Some(match cmd {
                cli::BenchCommands::PanicIsolation(_) => "panic-isolation",
                cli::BenchCommands::PortStability => "port-stability",
                cli::BenchCommands::ExportFixtures => "export-fixtures",
                cli::BenchCommands::TransformEgress => "transform-egress",
                cli::BenchCommands::Replay(_) => "replay",
                cli::BenchCommands::CacheBaseline(_) => "cache-baseline",
                cli::BenchCommands::Compare(_) => "compare",
            }),
        ),
    }
}

/// Dispatch CLI commands to their handlers.
///
/// Returns `Err(OlError)` on failure; main() prints the error and exits 1.
fn dispatch(
    command: &Commands,
    output: &openlatch_client::cli::output::OutputConfig,
) -> Result<(), openlatch_client::error::OlError> {
    match command {
        Commands::Init(args) => openlatch_client::cli::commands::init::run_init(args, output),
        Commands::Status => openlatch_client::cli::commands::status::run_status(output),
        Commands::Start(args) => {
            openlatch_client::cli::commands::lifecycle::run_start(args, output)
        }
        Commands::Stop => openlatch_client::cli::commands::lifecycle::run_stop(output),
        Commands::Restart => openlatch_client::cli::commands::lifecycle::run_restart(output),
        Commands::Logs(args) => openlatch_client::cli::commands::logs::run_logs(args, output),
        Commands::Doctor(args) => openlatch_client::cli::commands::doctor::run_doctor(args, output),
        Commands::Uninstall(args) => {
            openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
        }
        Commands::Docs => openlatch_client::cli::commands::docs::run_docs(output),
        Commands::Hooks { cmd } => match cmd {
            cli::HooksCommands::Install(args) => {
                openlatch_client::cli::commands::init::run_init(args, output)
            }
            cli::HooksCommands::Uninstall(args) => {
                openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
            }
            cli::HooksCommands::Status => {
                openlatch_client::cli::commands::status::run_status(output)
            }
        },
        Commands::Daemon { cmd } => match cmd {
            cli::DaemonCommands::Start(args) => {
                openlatch_client::cli::commands::lifecycle::run_start(args, output)
            }
            cli::DaemonCommands::Stop => {
                openlatch_client::cli::commands::lifecycle::run_stop(output)
            }
            cli::DaemonCommands::Restart => {
                openlatch_client::cli::commands::lifecycle::run_restart(output)
            }
        },
        Commands::Auth { cmd } => match cmd {
            cli::AuthCommands::Login(args) => {
                openlatch_client::cli::commands::auth::run_login(args, output)
            }
            cli::AuthCommands::Logout => openlatch_client::cli::commands::auth::run_logout(output),
            cli::AuthCommands::Status => openlatch_client::cli::commands::auth::run_status(output),
        },
        Commands::Telemetry { cmd } => openlatch_client::cli::commands::telemetry::run(cmd, output),
        Commands::Supervision { cmd } => {
            openlatch_client::cli::commands::supervision::run(cmd, output)
        }
        // The update command surfaces multi-valued exit codes (5 for
        // cargo-install refusal, 6 for daemon-unreachable, 1 for
        // failure, 0 for success/idempotent) that the OL-XXXX dispatch
        // pipeline cannot express, so it `process::exit`s directly.
        Commands::Update(args) => {
            let code = openlatch_client::cli::commands::update::run(args, output);
            std::process::exit(code);
        }
        Commands::Inventory { cmd } => {
            openlatch_client::cli::commands::inventory::run_inventory(cmd, output)
        }
        // Hidden Phase 1 spike — bypasses the OL-XXXX error pipeline since
        // it is maintainer-only and has its own stderr diagnostics. The
        // process::exit means dispatch never returns past this branch.
        Commands::SpikeUpdate(args) => {
            let success = openlatch_client::cli::commands::spike_update::run(args);
            std::process::exit(if success { 0 } else { 1 });
        }
        Commands::Boundary { cmd } => openlatch_client::cli::commands::boundary::run(cmd, output),
        // Boundary verification benches build their OWN tokio runtime (this
        // dispatch is synchronous — mirrors lifecycle.rs).
        Commands::Bench { cmd } => match cmd {
            cli::BenchCommands::PanicIsolation(args) => {
                openlatch_client::boundary::bench::run_panic_isolation(&args.inject)
            }
            cli::BenchCommands::PortStability => {
                openlatch_client::boundary::bench::run_port_stability()
            }
            cli::BenchCommands::ExportFixtures => {
                openlatch_client::boundary::bench::run_export_fixtures()
            }
            cli::BenchCommands::TransformEgress => {
                openlatch_client::boundary::bench::run_transform_egress()
            }
            cli::BenchCommands::Replay(args) => {
                openlatch_client::boundary::bench::run_replay(&args.rule, args.runs)
            }
            cli::BenchCommands::CacheBaseline(args) => {
                let out = args.out.clone().or_else(|| args.out_positional.clone());
                openlatch_client::boundary::bench::run_cache_baseline(
                    args.turns,
                    args.with_mcp,
                    args.direct,
                    out.as_deref(),
                )
            }
            cli::BenchCommands::Compare(args) => {
                openlatch_client::boundary::bench::run_compare(&args.a, &args.b)
            }
        },
    }
}