Skip to main content

kasl/commands/
server.rs

1//! `kasl server`: connect this machine to a kasl-server, and see where it
2//! stands.
3//!
4//! The server is the team's, not this machine's: an administrator issues an
5//! agent token and hands it over, and connecting is pasting it here (ADR 0004
6//! in kasl-server - there is no open enrolment to abuse). What this command
7//! owns is everything around that: checking the server is really a
8//! kasl-server before storing anything, putting the token in the OS keyring
9//! rather than in a readable config file, and saying whose token it is out
10//! loud, while a person is watching.
11//!
12//! The pulse is the one part that is not about days: whether this person is
13//! working right now. It is turned on separately (`kasl server pulse
14//! enable`), because connecting agrees to send finished days and a live
15//! signal is a different thing to agree to. The watcher sends it; this
16//! command only switches it and reports on it.
17//!
18//! That last check is the one worth spelling out. A token is an opaque string;
19//! one pasted from the wrong chat window works perfectly and files this
20//! machine's days under a colleague's name. The server is asked who it thinks
21//! is connecting, and the answer is printed.
22
23use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, KaslServer, UploadError, normalize_url};
24use crate::db::server_outbox::ServerOutbox;
25use crate::db::server_pulse::ServerPulse;
26use crate::db::workdays::Workdays;
27use crate::libs::config::{Config, KaslServerConfig};
28use crate::libs::daemon;
29use crate::libs::day_delivery::{Delivered, deliver, record_single};
30use crate::libs::day_upload::build_day_upload;
31use crate::libs::messages::Message;
32use crate::libs::pulse::{self, format_ago};
33use crate::libs::secret::Secret;
34use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
35use anyhow::{Context, Result};
36use chrono::{Duration, Local, NaiveDate, Utc};
37use clap::{Args, Subcommand};
38use dialoguer::{Input, Password, theme::ColorfulTheme};
39use reqwest::StatusCode;
40
41/// Command-line arguments for the server command.
42#[derive(Debug, Args)]
43pub struct ServerArgs {
44    #[command(subcommand)]
45    command: ServerCommand,
46}
47
48/// Available server operations.
49#[derive(Debug, Subcommand)]
50enum ServerCommand {
51    /// Connect this machine to a kasl-server
52    #[command(about = "Connect this machine to a kasl-server")]
53    Connect(ConnectArgs),
54
55    /// Show the current connection
56    #[command(about = "Show the current connection to a kasl-server")]
57    Status,
58
59    /// Send a day to the server
60    #[command(about = "Send a day's work to the connected kasl-server")]
61    Push(PushArgs),
62
63    /// Send everything that is still owed
64    #[command(about = "Send every day still waiting to reach the server")]
65    Flush,
66
67    /// Show what is still waiting to be sent
68    #[command(about = "Show the days still waiting to reach the server")]
69    Queue,
70
71    /// Show what this server keeps about you
72    #[command(about = "Show what the connected kasl-server stores about you")]
73    Manifest,
74
75    /// Queue a stretch of past days
76    #[command(about = "Queue every recorded day in a date range and send them")]
77    Backfill(BackfillArgs),
78
79    /// Tell the server whether you are working right now
80    #[command(about = "Turn the live pulse on or off, or show how it is going")]
81    Pulse(PulseArgs),
82
83    /// Forget the connection and the stored token
84    #[command(about = "Forget the connection and the stored agent token")]
85    Disconnect,
86}
87
88/// Arguments accepted by `kasl server backfill`.
89#[derive(Debug, Args)]
90pub struct BackfillArgs {
91    /// First date of the range, YYYY-MM-DD; without it, the whole history
92    #[arg(long, value_name = "YYYY-MM-DD")]
93    from: Option<NaiveDate>,
94
95    /// Last date of the range, YYYY-MM-DD; defaults to today
96    #[arg(long, value_name = "YYYY-MM-DD")]
97    to: Option<NaiveDate>,
98}
99
100/// Arguments accepted by `kasl server pulse`.
101#[derive(Debug, Args)]
102pub struct PulseArgs {
103    #[command(subcommand)]
104    command: Option<PulseCommand>,
105}
106
107/// What `kasl server pulse` can do; without one, it shows how the pulse is
108/// going.
109#[derive(Debug, Subcommand)]
110enum PulseCommand {
111    /// Start telling the server whether you are working right now
112    #[command(about = "Start telling the server whether you are working, on a break, or not in a day")]
113    Enable,
114
115    /// Stop telling it
116    #[command(about = "Stop telling the server whether you are working right now")]
117    Disable,
118}
119
120/// Arguments accepted by `kasl server push`.
121#[derive(Debug, Args)]
122pub struct PushArgs {
123    /// Send yesterday instead of today
124    #[arg(long, short, help = "Send the last day instead of today")]
125    last: bool,
126
127    /// Send a specific date, YYYY-MM-DD
128    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "last")]
129    date: Option<NaiveDate>,
130}
131
132/// Arguments accepted by `kasl server connect`.
133#[derive(Debug, Args)]
134pub struct ConnectArgs {
135    /// Server URL, e.g. https://kasl.example.com; prompted for when omitted
136    #[arg(long, value_name = "URL")]
137    url: Option<String>,
138
139    /// PEM file with the CA that signed the server's certificate
140    #[arg(long, value_name = "PATH")]
141    ca_certificate: Option<String>,
142}
143
144/// Routes a server subcommand.
145pub async fn cmd(args: ServerArgs) -> Result<()> {
146    match args.command {
147        ServerCommand::Connect(args) => connect(args).await,
148        ServerCommand::Status => status().await,
149        ServerCommand::Push(args) => push(args).await,
150        ServerCommand::Flush => flush().await,
151        ServerCommand::Queue => queue(),
152        ServerCommand::Manifest => manifest().await,
153        ServerCommand::Backfill(args) => backfill(args).await,
154        ServerCommand::Pulse(args) => match args.command {
155            Some(PulseCommand::Enable) => pulse_enable().await,
156            Some(PulseCommand::Disable) => pulse_disable(),
157            None => pulse_show(),
158        },
159        ServerCommand::Disconnect => disconnect(),
160    }
161}
162
163/// Walks through connecting: URL, token, and two checks against the server.
164///
165/// Nothing is written until both checks pass. A half-written connection - a
166/// URL saved with a token the server never accepted - would leave the agent
167/// looking configured while every upload failed.
168async fn connect(args: ConnectArgs) -> Result<()> {
169    // The token is always typed at a prompt - never taken from an argument,
170    // where it would land in shell history - so this command cannot finish
171    // without a terminal whatever else it was given. Checked before anything
172    // else so a run that cannot succeed fails immediately, rather than after
173    // reaching the network and reporting a server it is about to walk away
174    // from.
175    crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
176
177    let mut config = Config::read().unwrap_or_default();
178
179    let url = match args.url {
180        Some(url) => normalize_url(&url),
181        None => {
182            let entered: String = Input::with_theme(&ColorfulTheme::default())
183                .with_prompt(Message::PromptKaslServerUrl.to_string())
184                .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
185                .interact_text()?;
186            normalize_url(&entered)
187        }
188    };
189
190    // A URL without a scheme reaches nothing and the failure reads like the
191    // server is down, so it is caught here where the cause is still visible.
192    if !url.starts_with("http://") && !url.starts_with("https://") {
193        return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
194    }
195
196    let candidate = KaslServerConfig {
197        url: url.clone(),
198        // A certificate named now wins; otherwise an existing one is kept, so
199        // reconnecting to the same server does not silently drop it.
200        ca_certificate: args
201            .ca_certificate
202            .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
203        pulse: keeps_pulse(config.kasl_server.as_ref(), &url),
204    };
205
206    let client = KaslServer::new(&candidate)?;
207
208    // First check: is this a kasl-server at all? Asked before the token, so a
209    // mistyped URL is not reported as a rejected token.
210    let health = client.health().await?;
211    msg_info!(Message::KaslServerReached {
212        url: url.clone(),
213        version: health.version.clone(),
214    });
215    if health.database != "ok" {
216        // Serviceable enough to answer, not enough to accept a day. Worth
217        // saying now rather than at the first upload.
218        msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
219    }
220
221    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
222    let token: String = Password::with_theme(&ColorfulTheme::default())
223        .with_prompt(Message::PromptKaslServerToken.to_string())
224        .interact()?;
225    let token = token.trim().to_string();
226    if token.is_empty() {
227        return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
228    }
229
230    // Second check: the server accepts this token, and says whose it is.
231    let identity = client.identify(&token).await?;
232
233    // Both checks passed - only now is anything persisted.
234    secret
235        .store(&token)
236        .context("the token was accepted but could not be stored in the OS keyring")?;
237    config.kasl_server = Some(candidate);
238    config.save()?;
239
240    msg_success!(Message::KaslServerConnected {
241        user_name: identity.user_name,
242        agent_name: identity.agent_name,
243    });
244    Ok(())
245}
246
247/// Reports the stored connection, and whether it still works.
248///
249/// Reaches the server rather than reading the config back: a connection that
250/// was valid when it was made and is not any more - a revoked token, a server
251/// that moved - is exactly what someone runs this to find out.
252async fn status() -> Result<()> {
253    let config = Config::read().unwrap_or_default();
254    let Some(server_config) = config.kasl_server else {
255        msg_print!(Message::KaslServerNotConnected);
256        return Ok(());
257    };
258
259    msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
260    report_connection(&server_config).await?;
261
262    // Last, and whatever the connection checks found: the pulse's record is
263    // local, and a server that cannot be reached is exactly when "the last
264    // one went 20 min ago" is worth reading.
265    report_pulse(server_config.pulse)
266}
267
268/// The connection checks of `status`: the token, the server, whose token it is.
269async fn report_connection(server_config: &KaslServerConfig) -> Result<()> {
270    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
271    let Some(token) = secret.try_get_cached() else {
272        // The config says connected and the keyring disagrees: reconnecting is
273        // the fix, and saying so beats a 401 at the next upload.
274        msg_warning!(Message::KaslServerTokenMissing);
275        return Ok(());
276    };
277
278    let client = KaslServer::new(server_config)?;
279
280    match client.health().await {
281        Ok(health) => msg_info!(Message::KaslServerReached {
282            url: server_config.url.clone(),
283            version: health.version,
284        }),
285        Err(error) => {
286            msg_warning!(Message::KaslServerUnreachable(error.to_string()));
287            return Ok(());
288        }
289    }
290
291    match client.identify(&token).await {
292        Ok(identity) => {
293            msg_success!(Message::KaslServerConnected {
294                user_name: identity.user_name,
295                agent_name: identity.agent_name,
296            });
297            // The two versions that decide whether this agent and that server
298            // understand each other, printed where someone diagnosing a
299            // refused upload is already looking. `whoami` has reported both
300            // since kasl-server 0.14.1, which is also the floor `connect`
301            // enforces, so an answer here means the pair is compatible - the
302            // only way to reach this line at all is to have connected.
303            msg_info!(Message::KaslServerCompatibility {
304                server_version: identity.server_version,
305                api_version: identity.api_version,
306            });
307        }
308        Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
309    }
310
311    Ok(())
312}
313
314/// Prints how the pulse is going, from what the watcher last recorded.
315fn report_pulse(enabled: bool) -> Result<()> {
316    let record = if enabled { ServerPulse::new()?.last()? } else { None };
317    for line in pulse::describe(enabled, record.as_ref(), Utc::now()) {
318        if line.warning {
319            msg_warning!(line.message);
320        } else {
321            msg_info!(line.message);
322        }
323    }
324    Ok(())
325}
326
327/// Whether a connection being made keeps the pulse the previous one had.
328///
329/// Only for the same server. The consent was given to one installation, and
330/// reconnecting to another - a new employer, a test instance - must not start
331/// reporting to it what the employee agreed to report somewhere else.
332fn keeps_pulse(previous: Option<&KaslServerConfig>, url: &str) -> bool {
333    previous.is_some_and(|previous| previous.pulse && previous.url == url)
334}
335
336/// How long to wait for the watcher's first pulse after turning it on.
337///
338/// A tick, a request and a margin. Long enough that a watcher that is running
339/// answers inside it; short enough that one that is not costs little.
340const FIRST_PULSE_WAIT: std::time::Duration = std::time::Duration::from_secs(12);
341
342/// Turns the pulse on, then waits to see the first one go.
343///
344/// The watcher is the only sender, so this does not send a pulse of its own:
345/// it clears the record, which is the watcher's cue to send at once, and reads
346/// back what the watcher wrote. A second sender here would put two claims about
347/// the present on the server, one of them made by a process that cannot see
348/// whether this is a pause.
349async fn pulse_enable() -> Result<()> {
350    let mut config = Config::read().unwrap_or_default();
351    let Some(server) = config.kasl_server.as_mut() else {
352        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
353    };
354    // Checked now rather than discovered by the watcher: a pulse turned on
355    // without a token would fail every five minutes where nobody is looking.
356    if Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).try_get_cached().is_none() {
357        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
358    }
359
360    ServerPulse::new()?.clear()?;
361    server.pulse = true;
362    let url = server.url.clone();
363    config.save()?;
364    msg_success!(Message::PulseEnabled(url));
365
366    if !daemon::is_running() {
367        msg_warning!(Message::PulseNoWatcher);
368        return Ok(());
369    }
370
371    let deadline = std::time::Instant::now() + FIRST_PULSE_WAIT;
372    loop {
373        if let Some(record) = ServerPulse::new()?.last()? {
374            match (record.error, record.state) {
375                (None, Some(state)) => msg_success!(Message::PulseFirstArrived(state.to_string())),
376                (Some(error), _) => msg_warning!(Message::PulseFirstFailed(error)),
377                (None, None) => msg_info!(Message::PulseFirstPending),
378            }
379            return Ok(());
380        }
381        if std::time::Instant::now() >= deadline {
382            msg_info!(Message::PulseFirstPending);
383            return Ok(());
384        }
385        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
386    }
387}
388
389/// Turns the pulse off.
390///
391/// Nothing is sent to say so: the server has no way to be told "stop", only
392/// silence, which it shows as offline once the last pulse is too old to
393/// believe. Saying that here is the honest part - the employee should not
394/// expect the dashboard to go blank.
395fn pulse_disable() -> Result<()> {
396    let mut config = Config::read().unwrap_or_default();
397    let Some(server) = config.kasl_server.as_mut() else {
398        msg_print!(Message::KaslServerNotConnected);
399        return Ok(());
400    };
401    if !server.pulse {
402        msg_print!(Message::PulseAlreadyOff);
403        return Ok(());
404    }
405
406    server.pulse = false;
407    config.save()?;
408
409    let mut store = ServerPulse::new()?;
410    let stale_after = store.last()?.and_then(|record| record.stale_after_seconds).map(format_ago);
411    store.clear()?;
412
413    msg_success!(Message::PulseDisabled(stale_after));
414    Ok(())
415}
416
417/// Shows how the pulse is going, without touching the network.
418fn pulse_show() -> Result<()> {
419    let config = Config::read().unwrap_or_default();
420    let Some(server) = config.kasl_server else {
421        msg_print!(Message::KaslServerNotConnected);
422        return Ok(());
423    };
424    report_pulse(server.pulse)
425}
426
427/// Prints what the connected server stores about this person.
428///
429/// Read from the server every time rather than described from here. The
430/// manifest is generated on the server out of the level it enforces at ingest
431/// (ADR 0011 in kasl-server), so it describes the installation this machine
432/// actually reports to; a copy kept in kasl would describe the server kasl
433/// was built against, and would be most wrong exactly when it mattered - on
434/// an installation that had narrowed what it keeps.
435///
436/// Showing is all this command does. The level belongs to the installation
437/// and an administrator sets it; there is no personal opt-out (ADR 0011), and
438/// a flag here that appeared to narrow it would be a promise kasl cannot
439/// keep.
440async fn manifest() -> Result<()> {
441    let (client, token) = connected_client()?;
442
443    let manifest = client.privacy(&token).await?;
444
445    msg_info!(Message::KaslServerPrivacyHeading(manifest.level));
446    msg_print!(Message::KaslServerPrivacySummary(manifest.summary));
447
448    msg_print!(Message::KaslServerPrivacyStoredHeading);
449    for stored in manifest.stored {
450        msg_print!(Message::KaslServerPrivacyStored {
451            what: stored.what,
452            detail: stored.detail,
453        });
454    }
455
456    msg_print!(Message::KaslServerPrivacyNeverHeading);
457    for line in manifest.never_collected {
458        msg_print!(Message::KaslServerPrivacyBullet(line));
459    }
460
461    msg_print!(Message::KaslServerPrivacyVisibleHeading);
462    for line in manifest.visible_to {
463        msg_print!(Message::KaslServerPrivacyBullet(line));
464    }
465
466    msg_print!(Message::KaslServerPrivacyRetention(manifest.retention));
467    msg_print!(Message::KaslServerPrivacyOnChange(manifest.on_change));
468
469    // Only when the server says. An absent timestamp means this server does
470    // not record when the level was set, which is not the same as a level
471    // that was never changed - printing "never" for it would invent a fact.
472    if let Some(updated_at) = manifest.updated_at {
473        msg_print!(Message::KaslServerPrivacyUpdatedAt(
474            updated_at.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string()
475        ));
476    }
477
478    msg_print!(Message::KaslServerPrivacySetByAdmin);
479
480    Ok(())
481}
482
483/// Sends one day's work to the connected server.
484///
485/// The whole day goes every time - workday bounds, pauses, tasks - because
486/// the server stores a day as a unit and the last upload wins (ADR 0004 in
487/// kasl-server). Sending the same day twice therefore changes nothing, and a
488/// day corrected here corrects itself there on the next push.
489///
490/// The day is assembled before the token is fetched: a day that cannot be
491/// built - a timestamp with no valid offset, a task without an id - is a local
492/// problem, and reporting it without first touching the keyring or the network
493/// keeps the cause visible.
494///
495/// A day that cannot be delivered is queued rather than lost, and a day that
496/// is delivered takes the rest of the backlog with it - a laptop coming back
497/// from a week offline pays the whole debt on the first push, without the user
498/// having to know a queue exists.
499async fn push(args: PushArgs) -> Result<()> {
500    let date = match args.date {
501        Some(date) => date,
502        None if args.last => (Local::now() - Duration::days(1)).date_naive(),
503        None => Local::now().date_naive(),
504    };
505
506    let config = Config::read().unwrap_or_default();
507    let Some(server_config) = config.kasl_server else {
508        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
509    };
510
511    let Some(day) = build_day_upload(date)? else {
512        msg_print!(Message::KaslServerNoDayToPush(date.to_string()));
513        return Ok(());
514    };
515
516    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
517    let Some(token) = secret.try_get_cached() else {
518        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
519    };
520
521    let client = KaslServer::new(&server_config)?;
522
523    match client.upload_day(&token, &day).await {
524        Ok(accepted) => {
525            msg_success!(Message::KaslServerDayPushed {
526                date: accepted.date.to_string(),
527                pauses: accepted.pauses,
528                tasks: accepted.tasks,
529            });
530            // Worth saying out loud rather than hiding in a debug log: this is
531            // the visible consequence of declaring the task set authoritative,
532            // and the only sign that a deletion here reached the server.
533            if accepted.deleted_tasks > 0 {
534                msg_info!(Message::KaslServerTasksDeleted(accepted.deleted_tasks));
535            }
536
537            // A day that arrives cancels its own debt. Without this a date
538            // queued by an earlier failure would be sent again by the next
539            // flush, forever.
540            ServerOutbox::new()?.remove(date)?;
541
542            // Today went, so the backlog is worth a try on the same
543            // connection: a machine that comes back online typically owes
544            // several days, and making the user run a second command to
545            // discover that would be a queue that hides itself.
546            flush_with(&client, &token).await?;
547            Ok(())
548        }
549        // Three failures, three different things to do about them: a
550        // credential to renew, a payload to fix, or a server to wait for.
551        // Telling someone whose token was revoked to fix the day and push
552        // again would send them looking at data that is not the problem.
553        // All three are errors - the day did not arrive in any of them.
554        Err(error) => {
555            // Queued before it is reported, and only if a retry could ever
556            // work: a day the server will never accept as sent would
557            // otherwise sit in the queue retrying until someone noticed.
558            let outcome = record_single(&mut ServerOutbox::new()?, date, &error)?;
559            if matches!(outcome, Delivered::Deferred { .. }) {
560                msg_info!(Message::KaslServerDayQueued(date.to_string()));
561            }
562
563            match error {
564                error @ UploadError::Rejected {
565                    status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
566                    ..
567                } => Err(msg_error_anyhow!(Message::KaslServerPushTokenRejected(error.to_string()))),
568                error @ UploadError::Rejected { .. } => Err(msg_error_anyhow!(Message::KaslServerPushRejected(error.to_string()))),
569                error => Err(msg_error_anyhow!(Message::KaslServerPushRetryable(error.to_string()))),
570            }
571        }
572    }
573}
574
575/// Sends everything the outbox still owes.
576async fn flush() -> Result<()> {
577    // What is owed is checked before the connection is, because an empty queue
578    // is nothing to do whatever the connection looks like. The other order
579    // makes an hourly `kasl server flush` on an unconnected machine fail every
580    // hour over work that does not exist - and a cron job that cries wolf is
581    // one nobody reads by the time it matters.
582    if ServerOutbox::new()?.count()? == 0 {
583        msg_print!(Message::KaslServerQueueEmpty);
584        return Ok(());
585    }
586
587    let (client, token) = connected_client()?;
588    flush_with(&client, &token).await
589}
590
591/// Drains the outbox against an already-built client.
592///
593/// Shared with `push` so a successful upload carries the backlog with it, on
594/// the connection that was just proven to work.
595async fn flush_with(client: &KaslServer, token: &str) -> Result<()> {
596    let mut outbox = ServerOutbox::new()?;
597    let dates: Vec<NaiveDate> = outbox.pending()?.into_iter().map(|owed| owed.date).collect();
598    if dates.is_empty() {
599        return Ok(());
600    }
601
602    msg_info!(Message::KaslServerQueueSending(dates.len()));
603
604    let outcomes = deliver(client, token, &mut outbox, &dates).await?;
605
606    let (mut accepted, mut refused, mut deferred) = (0, 0, 0);
607    for outcome in &outcomes {
608        match outcome {
609            Delivered::Accepted {
610                date,
611                pauses,
612                tasks,
613                deleted_tasks,
614            } => {
615                accepted += 1;
616                msg_success!(Message::KaslServerDayPushed {
617                    date: date.to_string(),
618                    pauses: *pauses,
619                    tasks: *tasks,
620                });
621                if *deleted_tasks > 0 {
622                    msg_info!(Message::KaslServerTasksDeleted(*deleted_tasks));
623                }
624            }
625            // Named rather than counted: a day dropped because the server
626            // will never take it is data that is not going to arrive, and
627            // burying that in a total would be the queue losing a day
628            // quietly.
629            Delivered::Refused { date, reason } => {
630                refused += 1;
631                msg_warning!(Message::KaslServerDayRefused {
632                    date: date.to_string(),
633                    reason: reason.clone(),
634                });
635            }
636            Delivered::Deferred { date, reason } => {
637                deferred += 1;
638                msg_warning!(Message::KaslServerDayDeferred {
639                    date: date.to_string(),
640                    reason: reason.clone(),
641                });
642            }
643        }
644    }
645
646    msg_print!(Message::KaslServerFlushSummary { accepted, refused, deferred });
647    Ok(())
648}
649
650/// Lists what is still owed, without touching the network.
651///
652/// Deliberately offline: this is the command someone runs to find out whether
653/// their work is safe, and it has to answer on a train.
654fn queue() -> Result<()> {
655    let outbox = ServerOutbox::new()?;
656    let owed = outbox.pending()?;
657
658    if owed.is_empty() {
659        msg_print!(Message::KaslServerQueueEmpty);
660        return Ok(());
661    }
662
663    msg_info!(Message::KaslServerQueueOwed(owed.len() as i64));
664    for day in &owed {
665        msg_print!(Message::KaslServerQueueEntry {
666            date: day.date.to_string(),
667            attempts: day.attempts,
668            last_error: day.last_error.clone(),
669        });
670    }
671
672    Ok(())
673}
674
675/// Queues every recorded day in a range and sends them.
676///
677/// The range is read out of the database rather than walked across the
678/// calendar: only dates that actually have a workday are queued, so a month
679/// containing weekends and leave does not fill the outbox with days that were
680/// never worked and can never be sent.
681///
682/// Without `--from` the range opens at the first day this machine ever
683/// recorded. That is the honest default for what the command is for - a
684/// machine that tracked locally before the team had a server owes everything,
685/// and asking the employee to first find out when they started using kasl in
686/// order to say so is asking the database's own question back at them.
687async fn backfill(args: BackfillArgs) -> Result<()> {
688    let to = args.to.unwrap_or_else(|| Local::now().date_naive());
689    if let Some(from) = args.from
690        && from > to
691    {
692        return Err(msg_error_anyhow!(Message::KaslServerBackfillOrderReversed));
693    }
694
695    let (client, token) = connected_client()?;
696
697    let dates = Workdays::new()?.recorded_dates(args.from, Some(to))?;
698
699    if dates.is_empty() {
700        match args.from {
701            Some(from) => msg_print!(Message::KaslServerBackfillNoDays {
702                from: from.to_string(),
703                to: to.to_string(),
704            }),
705            // A different sentence on purpose. "No workdays between the
706            // beginning and today" would read as a range that happened to be
707            // empty; what it actually means is that this machine has never
708            // recorded a day, and the fix is to start one rather than to pick
709            // other dates.
710            None => msg_print!(Message::KaslServerBackfillNothingRecorded),
711        }
712        return Ok(());
713    }
714
715    match args.from {
716        Some(from) => msg_info!(Message::KaslServerBackfillRange {
717            from: from.to_string(),
718            to: to.to_string(),
719            days: dates.len(),
720        }),
721        // The first recorded date is named rather than left as "the
722        // beginning": it is the one fact that tells the employee how much of
723        // their history is about to reach the server, which is the thing
724        // worth knowing before it does.
725        None => msg_info!(Message::KaslServerBackfillWholeHistory {
726            from: dates[0].to_string(),
727            to: to.to_string(),
728            days: dates.len(),
729        }),
730    }
731
732    // Queued before they are sent, so an interrupted backfill is not lost: a
733    // run cut off halfway leaves the rest owed rather than forgotten.
734    let mut outbox = ServerOutbox::new()?;
735    for date in &dates {
736        outbox.enqueue(*date, "queued by backfill")?;
737    }
738
739    flush_with(&client, &token).await
740}
741
742/// The client and token for the configured server, or a message saying why
743/// there is none.
744///
745/// Both failures are the same shape - nothing can be sent - and both have a
746/// single fix, `kasl server connect`.
747fn connected_client() -> Result<(KaslServer, String)> {
748    let config = Config::read().unwrap_or_default();
749    let Some(server_config) = config.kasl_server else {
750        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
751    };
752
753    let Some(token) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).try_get_cached() else {
754        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
755    };
756
757    Ok((KaslServer::new(&server_config)?, token))
758}
759
760/// Forgets the connection: the token first, then the config.
761///
762/// In that order deliberately. If removing the config succeeded and the token
763/// removal then failed, a working credential would be left behind with nothing
764/// pointing at it - the one outcome this command exists to prevent.
765fn disconnect() -> Result<()> {
766    let mut config = Config::read().unwrap_or_default();
767
768    // An unreachable keyring must not stop the address being forgotten. On a
769    // headless machine - a container, a build agent, a server with no session
770    // keyring - there is no store to hold a token and nothing to remove, and
771    // refusing to disconnect there leaves the config pointing at a server for
772    // good. The failure is still reported, because on a machine that does have
773    // a keyring it means a credential survived.
774    if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
775        msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
776    }
777
778    if config.kasl_server.take().is_some() {
779        config.save()?;
780        // The pulse's consent went with the connection; its record goes too,
781        // so a later connection does not start out reporting an old one.
782        ServerPulse::new()?.clear()?;
783        msg_success!(Message::KaslServerDisconnected);
784    } else {
785        // The token is gone either way, which is what was asked for.
786        msg_print!(Message::KaslServerNotConnected);
787    }
788
789    Ok(())
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    fn connection(url: &str, pulse: bool) -> KaslServerConfig {
797        KaslServerConfig {
798            url: url.to_string(),
799            ca_certificate: None,
800            pulse,
801        }
802    }
803
804    #[test]
805    fn reconnecting_to_the_same_server_keeps_the_pulse() {
806        assert!(keeps_pulse(Some(&connection("https://kasl.example.com", true)), "https://kasl.example.com"));
807    }
808
809    #[test]
810    fn another_server_does_not_inherit_the_consent() {
811        // Agreeing to tell one installation whether you are at your desk is
812        // not agreeing to tell the next one you connect to.
813        assert!(!keeps_pulse(Some(&connection("https://kasl.example.com", true)), "https://kasl.other.example"));
814        assert!(!keeps_pulse(Some(&connection("https://kasl.example.com", false)), "https://kasl.example.com"));
815        assert!(!keeps_pulse(None, "https://kasl.example.com"));
816    }
817}