Skip to main content

liminal_cli/
up.rs

1//! The laptop deployment: one configuration and one production server lifecycle.
2
3use liminal_server::server::runtime::{Deployment, run_with_deployment};
4use signal_hook::{
5    consts::signal::{SIGINT, SIGTERM},
6    iterator::Signals,
7};
8use std::io::Write as _;
9use std::net::{SocketAddr, TcpListener};
10use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
11use std::path::{Path, PathBuf};
12use std::sync::{
13    Arc,
14    atomic::{AtomicU8, Ordering},
15};
16
17pub(crate) mod assistant;
18mod console;
19
20#[cfg(test)]
21#[path = "up_tests.rs"]
22mod tests;
23
24/// Starts the configured deployment, creating the laptop configuration only when absent.
25///
26/// # Errors
27/// A configuration, listener, store or lifecycle act is refused by name.
28pub fn run(path: Option<&Path>) -> anyhow::Result<()> {
29    let explicit = path.is_some();
30    let path = match path {
31        Some(path) => path.to_path_buf(),
32        None => default_path()?,
33    };
34    if !explicit && !path.exists() {
35        let directory = path
36            .parent()
37            .ok_or_else(|| anyhow::anyhow!("configuration has no parent"))?;
38        let text = configuration(directory)?;
39        // All ports are checked together before creating even the private directory.
40        let reserved = [
41            reserve("127.0.0.1:7400".parse()?, "listen_address")?,
42            reserve("127.0.0.1:7401".parse()?, "health_listen_address")?,
43            reserve("127.0.0.1:7480".parse()?, "console.listen")?,
44        ];
45        write_configuration(&path, &text)?;
46        drop(reserved);
47    }
48    let mut table: toml::Table = toml::from_str(&std::fs::read_to_string(&path)?)?;
49    let Some(console) = console_configuration(&table)? else {
50        liminal_server::run(&path)?;
51        return Ok(());
52    };
53    let server = server_configuration(&path, &mut table)?;
54    anyhow::ensure!(
55        console.listen.ip().is_loopback() && console.listen.port() != 0,
56        "console.listen must name a non-zero loopback port"
57    );
58    anyhow::ensure!(
59        server.auth.is_none(),
60        "console: Lys sign-in is not served by liminal up; keep the configured sign-in console server"
61    );
62    if console.assistant {
63        anyhow::ensure!(
64            server.agents_config().is_some() && server.services.profile == "full",
65            "console.assistant requires [agents] and services.profile = 'full'"
66        );
67    }
68    let mut reserved = Vec::new();
69    reserved.push(reserve(server.listen_address, "listen_address")?);
70    reserved.push(reserve(
71        server.health_listen_address,
72        "health_listen_address",
73    )?);
74    for address in &server.extra_listen_addresses {
75        reserved.push(reserve(*address, "extra_listen_addresses")?);
76    }
77    if let Some(websocket) = &server.websocket {
78        reserved.push(reserve(
79            websocket.listen_address,
80            "websocket.listen_address",
81        )?);
82    }
83    if let Some(cluster) = &server.cluster {
84        reserved.push(reserve(cluster.listen_address, "cluster.listen_address")?);
85    }
86    let listener = reserve(console.listen, "console.listen")?;
87    drop(reserved);
88    let shutdown = liminal_server::server::ShutdownHandle::new();
89    let phase = Arc::new(AtomicU8::new(0));
90    let signals = StopSignals::new(shutdown.clone(), Arc::clone(&phase))?;
91    let mut deployment = ConsoleDeployment {
92        listener: Some(listener),
93        console,
94        shutdown,
95        phase: Arc::clone(&phase),
96        path: path.clone(),
97        server: server.listen_address,
98    };
99    run_with_deployment(&path, &server, &mut deployment)?;
100    phase.store(4, Ordering::Release);
101    println!("exchange stopped");
102    drop(signals);
103    Ok(())
104}
105
106/// Reads and validates an up configuration, including its optional console table.
107///
108/// # Errors
109/// The file, a configuration field or a referenced schema is refused by name.
110pub fn load_configuration(path: &Path) -> anyhow::Result<liminal_server::config::ServerConfig> {
111    let mut table: toml::Table = toml::from_str(&std::fs::read_to_string(path)?)?;
112    console_configuration(&table)?;
113    server_configuration(path, &mut table)
114}
115
116fn server_configuration(
117    path: &Path,
118    table: &mut toml::Table,
119) -> anyhow::Result<liminal_server::config::ServerConfig> {
120    table.remove("console");
121    let server: liminal_server::config::ServerConfig = std::mem::take(table).try_into()?;
122    let mut server = liminal_server::config::apply_env_overrides(server)?;
123    liminal_server::config::validate(&mut server, path.parent())?;
124    Ok(server)
125}
126
127fn console_configuration(
128    table: &toml::Table,
129) -> anyhow::Result<Option<liminal_server::config::types::ConsoleConfig>> {
130    table
131        .get("console")
132        .cloned()
133        .map(toml::Value::try_into)
134        .transpose()
135        .map_err(Into::into)
136}
137
138fn default_path() -> anyhow::Result<PathBuf> {
139    let home = std::env::var_os("HOME")
140        .ok_or_else(|| anyhow::anyhow!("HOME is absent: use liminal up --config <file>"))?;
141    anyhow::ensure!(
142        !home.is_empty(),
143        "HOME is empty: use liminal up --config <file>"
144    );
145    Ok(PathBuf::from(home).join(".liminal/liminal.toml"))
146}
147
148fn write_configuration(path: &Path, text: &str) -> anyhow::Result<()> {
149    let directory = path
150        .parent()
151        .ok_or_else(|| anyhow::anyhow!("configuration has no parent"))?;
152    if !directory.exists() {
153        std::fs::DirBuilder::new().mode(0o700).create(directory)?;
154    }
155    std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
156    let store = directory.join("store");
157    if !store.exists() {
158        std::fs::DirBuilder::new().mode(0o700).create(store)?;
159    }
160    let mut file = std::fs::OpenOptions::new()
161        .write(true)
162        .create_new(true)
163        .mode(0o600)
164        .open(path)?;
165    file.write_all(text.as_bytes())?;
166    file.sync_all()?;
167    Ok(())
168}
169
170fn reserve(address: SocketAddr, key: &str) -> anyhow::Result<TcpListener> {
171    TcpListener::bind(address).map_err(|error| {
172        let holder = std::process::Command::new("lsof")
173            .args(["-nP", &format!("-iTCP:{}", address.port()), "-sTCP:LISTEN"])
174            .output()
175            .map_or_else(
176                |_| String::new(),
177                |output| String::from_utf8_lossy(&output.stdout).trim().to_owned(),
178            );
179        let holder = if holder.is_empty() {
180            String::new()
181        } else {
182            format!("; held by {holder}")
183        };
184        anyhow::anyhow!(
185            "port {} ({address}) refused: {error}{holder}; move it with {key} in the configuration",
186            address.port()
187        )
188    })
189}
190
191fn configuration(directory: &Path) -> anyhow::Result<String> {
192    let store = directory.join("store");
193    let store = toml::Value::String(
194        store
195            .to_str()
196            .ok_or_else(|| anyhow::anyhow!("HOME is not Unicode"))?
197            .to_owned(),
198    );
199    let mut text = format!(
200        r#"# Created by liminal up. Disabled optional sections are named explicitly:
201# auth = absent (open exchange); cluster = absent; websocket = absent
202# templates = absent; open.construction = absent; channel schemas = absent
203listen_address = "127.0.0.1:7400"
204extra_listen_addresses = []
205health_listen_address = "127.0.0.1:7401"
206drain_timeout_ms = 0
207channels = []
208routing_rules = []
209persistence_path = {store}
210
211[open]
212reach = "loopback"
213
214[services]
215profile = "full"
216[services.self_streams]
217cost_rows = 10000
218events_rows = 100000
219
220[limits]
221max_connections = 256
222max_subscriptions_per_connection = 1024
223max_conversations_per_connection = 32
224max_pending_pushes_per_connection = 32
225max_pending_conversation_replies_per_connection = 32
226max_pending_replies_per_conversation = 8
227max_connection_inbox_bytes = 4194304
228max_connection_outbound_bytes = 4194304
229max_subscription_inbox_depth = 4096
230delivery_slice_budget = 32
231max_channels = 512
232reply_timeout_ms = 5000
233
234[agents]
235max_per_owner = 64
236max_per_exchange = 64
237max_field_bytes = 512
238max_event_bytes = 16384
239replay_page_rows = 64
240max_watchers = 256
241watcher_depth = 16
242watcher_bytes = 262144
243max_listened_subjects = 1024
244max_subject_bytes = 4096
245
246[participant]
247wire_frame_limit = 65536
248attach_receipt_ttl_ms = 60000
249receipt_provenance_ttl_ms = 600000
250live_receipt_server_report_threshold = 1024
251max_live_attach_receipts_per_participant = 8
252receipt_provenance_server_report_threshold = 4096
253receipt_provenance_per_conversation_report_threshold = 256
254max_receipt_provenance_per_participant = 64
255max_retired_identity_slots_server = 1024
256identity_slots = 4
257observer_recovery_max_entries = 64
258max_semantic_conversations_per_connection = 32
259max_ordinary_record_entries = 1
260max_ordinary_record_bytes = 131072
261max_generated_marker_entries = 1
262max_generated_marker_bytes = 4096
263mandatory_transaction_bound_entries = 4
264mandatory_transaction_bound_bytes = 16384
265full_recovery_claim_entries = 4
266full_recovery_claim_bytes = 16384
267retained_capacity_entries = 2048
268retained_capacity_bytes = 16777216
269max_retained_record_rows = 1024
270closure_episode_churn_limit = 1024
271[participant.pruning]
272keep_acknowledged_behind_checkpoint = 512
273
274[console]
275listen = "127.0.0.1:7480"
276assistant = true
277"#
278    );
279    text.push_str("\n[console.harness]\n");
280    text.push_str(assistant::HARNESS);
281    Ok(text)
282}
283
284struct ConsoleDeployment {
285    listener: Option<TcpListener>,
286    console: liminal_server::config::types::ConsoleConfig,
287    shutdown: liminal_server::server::ShutdownHandle,
288    phase: Arc<AtomicU8>,
289    path: PathBuf,
290    server: SocketAddr,
291}
292
293impl Deployment for ConsoleDeployment {
294    fn shutdown(&self) -> liminal_server::server::ShutdownHandle {
295        self.shutdown.clone()
296    }
297
298    fn serve(
299        &mut self,
300        supervisor: liminal_server::server::ConnectionSupervisor,
301        services: Option<
302            Arc<liminal_server::server::connection::services::LiminalConnectionServices>,
303        >,
304        shutdown: liminal_server::server::ShutdownHandle,
305    ) -> Result<(), liminal_server::ServerError> {
306        let listener =
307            self.listener
308                .take()
309                .ok_or_else(|| liminal_server::ServerError::ListenerAccept {
310                    message: "console listener already started".to_owned(),
311                })?;
312        let console = console::start(listener, supervisor)?;
313        let mut assistant = if self.console.assistant {
314            Some(
315                assistant::Assistant::start(&self.console, self.server, &self.path, services)
316                    .map_err(|error| lifecycle_error(&error))?,
317            )
318        } else {
319            None
320        };
321        let url = format!("http://{}/", self.console.listen);
322        self.phase.store(1, Ordering::Release);
323        println!("{url}");
324        open_browser(&url);
325        self.shutdown.wait();
326        self.phase.store(1, Ordering::Release);
327        let assistant_stop = assistant
328            .as_mut()
329            .map_or(Ok(()), assistant::Assistant::stop);
330        if assistant_stop.is_ok() {
331            println!("assistant stopped");
332        } else if let Err(error) = &assistant_stop {
333            eprintln!("assistant stop: {error:#}");
334        }
335        self.phase.store(2, Ordering::Release);
336        let stopped = console.shutdown();
337        if stopped.is_ok() {
338            println!("console stopped");
339        }
340        self.phase.store(3, Ordering::Release);
341        shutdown.initiate();
342        stopped.and_then(|()| assistant_stop.map_err(|error| lifecycle_error(&error)))
343    }
344}
345
346struct StopSignals {
347    handle: signal_hook::iterator::Handle,
348    worker: Option<std::thread::JoinHandle<()>>,
349}
350
351impl StopSignals {
352    fn new(
353        shutdown: liminal_server::server::ShutdownHandle,
354        phase: Arc<AtomicU8>,
355    ) -> anyhow::Result<Self> {
356        let mut signals = Signals::new([SIGINT, SIGTERM])?;
357        let handle = signals.handle();
358        let worker = std::thread::spawn(move || {
359            for _signal in signals.forever() {
360                if !shutdown.initiate() {
361                    let waiting = match phase.load(Ordering::Acquire) {
362                        0 => "startup",
363                        1 => "assistant",
364                        2 => "console listener",
365                        3 => "exchange drain and store flush",
366                        _ => "process exit",
367                    };
368                    eprintln!("still waiting for {waiting}");
369                }
370            }
371        });
372        Ok(Self {
373            handle,
374            worker: Some(worker),
375        })
376    }
377}
378
379impl Drop for StopSignals {
380    fn drop(&mut self) {
381        self.handle.close();
382        if let Some(worker) = self.worker.take() {
383            if worker.join().is_err() {
384                eprintln!("up signal worker panicked");
385            }
386        }
387    }
388}
389
390fn open_browser(url: &str) {
391    let opener = if cfg!(target_os = "macos") {
392        "open"
393    } else {
394        "xdg-open"
395    };
396    match std::process::Command::new(opener)
397        .arg(url)
398        .stdin(std::process::Stdio::null())
399        .stdout(std::process::Stdio::null())
400        .stderr(std::process::Stdio::null())
401        .spawn()
402    {
403        Ok(mut child) => {
404            let url = url.to_owned();
405            std::thread::spawn(move || match child.wait() {
406                Ok(status) if status.success() => {}
407                Ok(status) => eprintln!("browser opener {opener} exited {status}; open {url}"),
408                Err(error) => eprintln!("browser opener {opener}: {error}"),
409            });
410        }
411        Err(error) => eprintln!("browser opener {opener} unavailable: {error}; open {url}"),
412    }
413}
414
415fn lifecycle_error(error: &anyhow::Error) -> liminal_server::ServerError {
416    liminal_server::ServerError::ListenerAccept {
417        message: error.to_string(),
418    }
419}
420
421/// Serves the assistant's in-memory ticket to the ACP harness's MCP child.
422///
423/// # Errors
424/// The up configuration or its ticket tools failed.
425pub fn run_tools(path: &Path) -> anyhow::Result<bool> {
426    let mut table: toml::Table = toml::from_str(&std::fs::read_to_string(path)?)?;
427    if console_configuration(&table)?.is_none() {
428        return Ok(false);
429    }
430    let server = server_configuration(path, &mut table)?;
431    let ticket = assistant::ticket(server.listen_address);
432    let live = crate::Live::new(&ticket).map_err(anyhow::Error::msg)?;
433    let mut tools = crate::Server::configured(live, &ticket).map_err(anyhow::Error::msg)?;
434    tools.serve(&mut std::io::stdin().lock(), &mut std::io::stdout())?;
435    Ok(true)
436}