From 823f3105eb53afa16ec6b8591167294e37f97bbc Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 17:45:41 +0000 Subject: [PATCH 01/47] Keep CI watch and Self-merge waiting lines active (#814) --- docs/guide.md | 4 ++++ src/asks.rs | 1 + src/ci.rs | 5 ++--- src/command.rs | 1 + src/config.rs | 49 +++++++++++++++++++++++++++++++++++++++++++ src/poll.rs | 45 +++++++++++++++++++++++++++++++++++++++ src/pull_request.rs | 11 +++++++++- src/setup.rs | 1 + tests/ci.rs | 43 +++++++++++++++++++++++++++++++++++++ tests/command_logs.rs | 35 +++++++++++++++++++++++++++++++ tests/setup.rs | 1 + 11 files changed, 192 insertions(+), 4 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 9f59b82e..769cc0bc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -363,6 +363,7 @@ from = "thirdshift@your-verified-domain.com" # the sender; onboarding@resend.de [logs] dir = "~/elsewhere/logs" # the root of the logs, instead of ~/.thirdshift/logs +waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines [activity] quiet_skips = true # a skipped Pass prints nothing @@ -413,6 +414,7 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi [logs] dir = "~/.thirdshift/logs" # the root of the logs, each repository's in //; default ~/.thirdshift/logs +waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -486,6 +488,8 @@ With `email.always = true`, every Run sends a [Run notification](#run-notificati `logs.dir` sets the root of the [logs](#logs), with each repository's in its `//` folder, created if missing. It must be an absolute path, `~` or a path starting with `~/`, where `~` stands for `$HOME`. A relative path stops the Run before any work, since the directory a Run is launched from is no base for a setting that holds for every Run. +`logs.waiting_line_minutes` sets how often CI watch and Self-merge repeat a waiting line even when nothing changes, in the terminal and the Command log. It defaults to 5 and must be a whole number of minutes from 1 up. CI watch still writes a line whenever the running or total check count changes; Self-merge writes waiting lines while GitHub calculates mergeability. + `[harness]` chooses the **Harness**, the agent CLI every session of a command runs on, and for each Harness the **Model** its sessions run on and their **Effort**, how hard that Model reasons. See [Harness, Model and Effort](#harness-model-and-effort). A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as `alway` for `always`, or a value of the wrong type, such as anything but `true` or `false` for `always`, or `0` for `spec.parallel` or `pickup.limit`, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. `email-test` and `setup` read it the same way. `update`, `version` and `help` never read it, so a broken User config can't block them, and they never offer Setup; nor does `email-test`. diff --git a/src/asks.rs b/src/asks.rs index ea29cfe6..fbf94091 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -244,6 +244,7 @@ mod tests { base_fix: false, launch_pull: false, logs_dir: PathBuf::from("/home/me/.thirdshift/logs"), + waiting_line_interval: std::time::Duration::from_secs(5 * 60), quiet_skips: false, email: EmailSettings::default(), spec_parallel: n(3), diff --git a/src/ci.rs b/src/ci.rs index 472553d7..9904896d 100644 --- a/src/ci.rs +++ b/src/ci.rs @@ -327,7 +327,7 @@ fn watch_to_end( rerun_checks: Option<&RerunChecks<'_>>, ) -> Result { let short = short(sha); - let mut reported_counts = None; + let mut waiting_line = poll::WaitingLine::new(); let checks = until(outside, |outside| { let checks = read_checks(outside, sha)?; ensure!( @@ -339,12 +339,11 @@ fn watch_to_end( return Ok(Some(checks)); } let counts = (pending, checks.len()); - if reported_counts != Some(counts) { + if waiting_line.should_report(counts) { outside.step(format!( "CI on {short}: {pending} still running out of {} checks listed so far", checks.len() )); - reported_counts = Some(counts); } Ok(None) })?; diff --git a/src/command.rs b/src/command.rs index acb1bf0b..d93d38bc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -106,6 +106,7 @@ pub fn start( .and_then(|()| UserConfig::load()) .map_err(|error| failure(&error))?; logs::configured(&config); + crate::poll::configure_waiting_lines(config.waiting_line_interval); // First, so no interrupt can end the command once its notification is // checked. interrupt::install().map_err(|error| failure(&error))?; diff --git a/src/config.rs b/src/config.rs index fca5be29..5f8b02f1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ use std::io::Write; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use chrono::TimeDelta; @@ -30,6 +31,9 @@ pub struct UserConfig { /// each repository's in `//`, by default /// `~/.thirdshift/logs`. pub logs_dir: PathBuf, + /// `logs.waiting_line_minutes`: time between unchanged waiting lines, + /// by default five minutes. + pub waiting_line_interval: Duration, /// `activity.quiet_skips`: a skipped Pass prints /// nothing, its Activity log line its only trace. pub quiet_skips: bool, @@ -90,6 +94,7 @@ impl UserConfig { base_fix: false, launch_pull: false, logs_dir: home.join(".thirdshift/logs"), + waiting_line_interval: Duration::from_secs(5 * 60), quiet_skips: false, email: EmailSettings::default(), spec_parallel: NonZeroUsize::new(3).unwrap(), @@ -147,6 +152,17 @@ impl UserConfig { ), }, ("logs", "dir", _) => bail!("logs.dir must be a string in {file}"), + ("logs", "waiting_line_minutes", value) => { + config.waiting_line_interval = value + .as_integer() + .filter(|minutes| *minutes > 0) + .and_then(|minutes| u64::try_from(minutes).ok()?.checked_mul(60)) + .map(Duration::from_secs) + .with_context(|| format!( + "logs.waiting_line_minutes must be a whole number of minutes from 1 up \ + within the supported range in {file}" + ))?; + } ("activity", "quiet_skips", Value::Boolean(quiet)) => { config.quiet_skips = *quiet } @@ -731,6 +747,7 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi [logs] dir = "~/.thirdshift/logs" # the root of the logs, each repository's in //; default ~/.thirdshift/logs +waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -899,6 +916,7 @@ mod tests { "email.to", "email.from", "logs.dir", + "logs.waiting_line_minutes", "activity.quiet_skips", "spec.parallel", "pickup.limit", @@ -1096,6 +1114,37 @@ mod tests { } } + #[test] + fn waiting_line_interval_defaults_to_five_minutes_and_reads_whole_minutes() { + for text in ["", "[logs]\n"] { + assert_eq!( + parse(text).unwrap().waiting_line_interval, + Duration::from_secs(300) + ); + } + let text = "[logs]\nwaiting_line_minutes = 2\n"; + assert_eq!( + parse(text).unwrap().waiting_line_interval, + Duration::from_secs(120) + ); + let path = Path::new("/home/me/.thirdshift/config.toml"); + let document = UserConfigDocument::existing(text, path, Path::new("/home/me")).unwrap(); + let rendered = document.render(None); + assert_eq!( + parse(&rendered).unwrap().waiting_line_interval, + Duration::from_secs(120) + ); + for value in ["0", "-1", "1.5", "true", "'5'", "9223372036854775807"] { + let error = parse(&format!("[logs]\nwaiting_line_minutes = {value}\n")).unwrap_err(); + assert!( + error + .to_string() + .contains("logs.waiting_line_minutes must be a whole number"), + "{error:#}" + ); + } + } + #[test] fn logs_dir_is_read_with_a_leading_tilde_expanded_and_defaults_under_home() { for (dir, expanded) in [ diff --git a/src/poll.rs b/src/poll.rs index eb23ae41..0386a50e 100644 --- a/src/poll.rs +++ b/src/poll.rs @@ -1,11 +1,56 @@ //! Shared grace periods and interruptible pauses for GitHub observations. +use std::sync::OnceLock; use std::time::{Duration, Instant}; use anyhow::{Result, bail}; use crate::interrupt; +// Each process runs one Command; child Runs load their own User config. +static WAITING_LINE_INTERVAL: OnceLock = OnceLock::new(); + +/// Capture the Command's waiting interval before any work starts. +/// `THIRDSHIFT_WAITING_MINUTE_MS` shortens a minute for binary tests, +/// preserving the User config's choice of how many minutes to wait. +pub fn configure_waiting_lines(interval: Duration) { + let interval = millis_from_env("THIRDSHIFT_WAITING_MINUTE_MS") + .and_then(|minute| minute.checked_mul(u32::try_from(interval.as_secs() / 60).ok()?)) + .unwrap_or(interval); + let _ = WAITING_LINE_INTERVAL.set(interval); +} + +/// Report a waiting state immediately, whenever it changes, and again +/// after the configured interval. Each emitted line starts a fresh interval. +pub struct WaitingLine { + reported: Option, + last_line: Instant, + interval: Duration, +} + +impl WaitingLine { + pub fn new() -> Self { + Self { + reported: None, + last_line: Instant::now(), + interval: WAITING_LINE_INTERVAL + .get() + .copied() + .unwrap_or(Duration::from_secs(5 * 60)), + } + } + + pub fn should_report(&mut self, state: T) -> bool { + if self.reported.as_ref() != Some(&state) || self.last_line.elapsed() >= self.interval { + self.reported = Some(state); + self.last_line = Instant::now(); + true + } else { + false + } + } +} + /// How long to wait for something GitHub should soon report: CI appearing /// after a push, or whether a PR is mergeable. `THIRDSHIFT_CI_GRACE_MS` /// overrides it, for tests. diff --git a/src/pull_request.rs b/src/pull_request.rs index 890ead8d..ad9628e3 100644 --- a/src/pull_request.rs +++ b/src/pull_request.rs @@ -268,6 +268,7 @@ impl PullRequest { pub fn ensure_ready_and_mergeable(&mut self) -> Result<()> { progress::step("checking the PR is open, ready and mergeable"); let deadline = std::time::Instant::now() + self.adapter.grace_period(); + let mut waiting_line = poll::WaitingLine::new(); loop { let snapshot = self.observe(false)?.context("no PR found")?; self.validate(&snapshot, true)?; @@ -280,7 +281,15 @@ impl PullRequest { snapshot.pr.url ); } - Mergeable::Unknown => self.adapter.pause()?, + Mergeable::Unknown => { + if waiting_line.should_report(()) { + progress::step(format!( + "waiting for GitHub to calculate mergeability of PR {}", + snapshot.pr.url + )); + } + self.adapter.pause()?; + } } } } diff --git a/src/setup.rs b/src/setup.rs index 2607b71d..660641c3 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1124,6 +1124,7 @@ from = \"ts@acme.dev\" [logs] dir = \"/var/log/thirdshift\" +waiting_line_minutes = 5 [activity] quiet_skips = true diff --git a/tests/ci.rs b/tests/ci.rs index 14b424b4..297cf15a 100644 --- a/tests/ci.rs +++ b/tests/ci.rs @@ -80,6 +80,49 @@ fn checks_pending_then_green_is_success() { assert_eq!(scenario.claude_calls().len(), 1); } +#[test] +fn unchanged_running_checks_keep_writing_waiting_lines_to_the_command_log() { + for merge in [false, true] { + let scenario = Scenario::new(); + scenario.user_config_is("[logs]\nwaiting_line_minutes = 1\n"); + scenario.agent_does(&format!( + "{AGENT_OPENS_PR}{}", + checks_on_head(r#"[{"name": "test", "conclusion": "success", "pending_polls": 8}]"#) + )); + let mut args = vec![scenario.issue_url(7)]; + if merge { + args.push("merge".to_string()); + } + + let result = scenario.run_with_env( + &args.iter().map(String::as_str).collect::>(), + &[("THIRDSHIFT_WAITING_MINUTE_MS", "150")], + ); + + assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); + let waiting = "1 still running out of 1 checks listed so far"; + assert!( + result.stderr.matches(waiting).count() >= 3, + "{}", + result.stderr + ); + let logs = scenario.path("home/.thirdshift/logs/acme/widgets/commands/issue"); + let log = std::fs::read_dir(logs) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.extension().is_some_and(|ext| ext == "log")) + .unwrap(); + let log = std::fs::read_to_string(log).unwrap(); + assert_eq!( + log.matches(waiting).count(), + result.stderr.matches(waiting).count() + ); + if merge { + assert_eq!(scenario.gh_state()["prs"][0]["state"], "MERGED"); + } + } +} + #[test] fn red_ci_is_handed_to_a_ci_fix_repair_whose_fix_turns_it_green() { let scenario = Scenario::new(); diff --git a/tests/command_logs.rs b/tests/command_logs.rs index 95910324..7e67e847 100644 --- a/tests/command_logs.rs +++ b/tests/command_logs.rs @@ -85,6 +85,41 @@ fn the_one_command_log(scenario: &Scenario, folder: &str, prefix: &str) -> (Path (path, format!("{stamp}~{token}")) } +#[test] +fn self_merge_keeps_writing_waiting_lines_while_github_calculates_mergeability() { + let scenario = Scenario::new(); + scenario.user_config_is("[logs]\nwaiting_line_minutes = 1\n"); + scenario.agent_does(&format!( + "{}gh fake checks \"$(git rev-parse HEAD)\" '[{{\"name\":\"test\",\"conclusion\":\"success\"}}]'\n\ + gh fake on-ci-read 1 'gh fake pr issue-7 unknown_polls 20'\n", + agent_opens_pr(7, "main") + )); + + let result = run_in_zone( + &scenario, + &["merge", &scenario.issue_url(7)], + &[ + ("THIRDSHIFT_WAITING_MINUTE_MS", "150"), + ("THIRDSHIFT_CI_GRACE_MS", "10000"), + ], + ); + + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert_eq!(scenario.gh_state()["prs"][0]["state"], "MERGED"); + let waiting = "waiting for GitHub to calculate mergeability of PR"; + assert!( + result.stderr.matches(waiting).count() >= 3, + "{}", + result.stderr + ); + let (path, _) = the_one_command_log(&scenario, "issue", "7-"); + let log = fs::read_to_string(path).unwrap(); + assert_eq!( + log.matches(waiting).count(), + result.stderr.matches(waiting).count() + ); +} + /// Assert `stamp` is a local time with [`OFFSET`], as in /// `20261003T120000-0400`. fn assert_local_stamp(stamp: &str) { diff --git a/tests/setup.rs b/tests/setup.rs index c9170f9b..a72a82b1 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -72,6 +72,7 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { ); assert!(config["email"].get("to").is_none(), "{text}"); assert_eq!(config["logs"]["dir"].as_str(), Some("~/.thirdshift/logs")); + assert_eq!(config["logs"]["waiting_line_minutes"].as_integer(), Some(5)); assert_eq!(config["activity"]["quiet_skips"].as_bool(), Some(false)); assert_eq!(config["spec"]["parallel"].as_integer(), Some(3)); assert_eq!(config["pickup"]["limit"].as_integer(), Some(3)); From 0799696ec8afc452339870e19a78cbbb21ee6e55 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 17:45:54 +0000 Subject: [PATCH 02/47] Verify Setup preserves a custom waiting interval --- tests/setup.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/setup.rs b/tests/setup.rs index a72a82b1..3916a3ca 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -215,6 +215,7 @@ fn completing_an_existing_file_replaces_it_atomically_keeps_permissions_and_then { let scenario = Scenario::new(); let original = "# my machine\n[logs]\ndir = '/var/log/ts' # custom\n\ + waiting_line_minutes = 2 # waiting\n\ [merge]\nalways = true # merge\n\ [pickup]\nwait_minutes = 60 # time to attach Tickets\n"; let path = scenario.user_config_is(original); @@ -229,6 +230,7 @@ fn completing_an_existing_file_replaces_it_atomically_keeps_permissions_and_then assert!(text.starts_with(original), "{text}"); let config: toml::Table = text.parse().unwrap(); assert_eq!(config["logs"]["dir"].as_str(), Some("/var/log/ts")); + assert_eq!(config["logs"]["waiting_line_minutes"].as_integer(), Some(2)); assert_eq!(config["pickup"]["wait_minutes"].as_integer(), Some(60)); assert_eq!(config["harness"]["default"].as_str(), Some("claude")); let metadata = fs::metadata(&path).unwrap(); From bb088787e7372e1da4426f5d9242e48bb259a6dc Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 19:08:00 +0000 Subject: [PATCH 03/47] Regenerate site manual for waiting line configuration --- site/docs/index.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/docs/index.html b/site/docs/index.html index a77976da..4624ac92 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -364,6 +364,7 @@ [logs] dir = "~/elsewhere/logs" # the root of the logs, instead of ~/.thirdshift/logs +waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines [activity] quiet_skips = true # a skipped Pass prints nothing @@ -411,6 +412,7 @@ [logs] dir = "~/.thirdshift/logs" # the root of the logs, each repository's in <owner>/<repo>/; default ~/.thirdshift/logs +waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -474,6 +476,7 @@

spec.parallel sets how many Tickets a Spec run runs at once, by default 3. It must be a whole number from 1 up; parallel <n> on the command line wins over it for one Spec run.

pickup.limit sets the Claim limit: how many open issues labelled in-progress stop a Pickup run from taking another, by default 3. It must be a whole number from 1 up. There is no command-line flag for it.

logs.dir sets the root of the logs, with each repository's in its <owner>/<repo>/ folder, created if missing. It must be an absolute path, ~ or a path starting with ~/, where ~ stands for $HOME. A relative path stops the Run before any work, since the directory a Run is launched from is no base for a setting that holds for every Run.

+

logs.waiting_line_minutes sets how often CI watch and Self-merge repeat a waiting line even when nothing changes, in the terminal and the Command log. It defaults to 5 and must be a whole number of minutes from 1 up. CI watch still writes a line whenever the running or total check count changes; Self-merge writes waiting lines while GitHub calculates mergeability.

[harness] chooses the Harness, the agent CLI every session of a command runs on, and for each Harness the Model its sessions run on and their Effort, how hard that Model reasons. See Harness, Model and Effort.

A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as alway for always, or a value of the wrong type, such as anything but true or false for always, or 0 for spec.parallel or pickup.limit, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. email-test and setup read it the same way. update, version and help never read it, so a broken User config can't block them, and they never offer Setup; nor does email-test.

From a8388efcb0e8e68be77100b005102379944dc047 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 19:58:04 +0000 Subject: [PATCH 04/47] Describe Silent and Slow Runs with repository Usual ranges (#815) --- docs/code-navigation.md | 1 + docs/guide.md | 13 +- site/docs/index.html | 9 +- src/asks.rs | 1 + src/command.rs | 2 +- src/config.rs | 39 ++++-- src/logs/command_record.rs | 5 +- src/logs/reading.rs | 18 +++ src/logs/reading/health.rs | 213 ++++++++++++++++++++++++++++++++ src/logs/reading/selection.rs | 2 + src/logs/recording.rs | 1 + src/logs/status.rs | 22 +++- src/logs/status/inspect.rs | 9 +- src/poll.rs | 3 +- src/setup.rs | 3 + tests/ci.rs | 24 ++++ tests/run_health.rs | 225 ++++++++++++++++++++++++++++++++++ tests/setup.rs | 3 + 18 files changed, 574 insertions(+), 19 deletions(-) create mode 100644 src/logs/reading/health.rs create mode 100644 tests/run_health.rs diff --git a/docs/code-navigation.md b/docs/code-navigation.md index dc0a8972..c3d91bc7 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -20,6 +20,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Command log, Activity log | `src/logs.rs`; `src/logs/recording.rs` — `Record` | `src/logs/recording/tests.rs`, `tests/command_logs.rs`, `tests/activity_log.rs` | | Command-log allocation, Session naming, child inheritance and filename decoding | `src/logs/owner.rs` — `allocate`, `Owner`, `decode`, `session_matches`; `src/session/recording.rs` — captured Model recording | `src/logs/owner/tests.rs`, `src/logs/recording/tests.rs`, `src/session/recording/tests.rs`, `tests/command_logs.rs`, `tests/status.rs`, `tests/show_logs.rs` | | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/reading.rs` — `Snapshot`, exact-parent trees and observations; `src/logs/reading/history.rs` — historical recovery; `src/logs/status.rs` — formatting; `src/logs/process_identity.rs` | `src/logs/reading/tests.rs`, `tests/status.rs`, `src/logs/recording/tests.rs` | +| Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | diff --git a/docs/guide.md b/docs/guide.md index 769cc0bc..a5588918 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -171,7 +171,13 @@ thirdshift logs 'widgets#7' --session implement thirdshift logs 'widgets#7' --session repair --follow ``` -`show` includes the Runs, stage history with start times, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as `Bash: cargo test`, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable. +`show` includes the Runs, stage history with start times and Usual ranges, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as `Bash: cargo test`, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable. + +`status` and `show` describe live work as **Silent** when neither its Session log nor its Command log has grown for `inbox.silent_minutes` (10 by default). Silence starts no earlier than the current stage. At three times the threshold, the words escalate: `silent 45 min · past 30`. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time. + +A stage's **Usual range** is its 25th–75th percentile time over the repository's last 30 Command records on this machine. Completed stages supply times, with the next stage's start or the Command's end marking completion; an unfinished stage supplies none. The range appears once there are at least three stage times. Quartiles interpolate between sorted times. A live Run past the top of the range is **Slow**: `slower than usual (12–17 min), still working`. At twice the top it also shows the multiple, such as `2× usual` or `3× usual`. Silent takes precedence over Slow. + +Both JSON views add `health` to recorded Commands: `{"state":"ok"}`, `{"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}`, or `{"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}`. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. Each stage gains `usual_range` when enough times exist, expressed as `lower_seconds` and `upper_seconds`; these observations are never written back to Command records. `show --json` is a versioned API, usable outside a repository. Version 1 wraps the selected Command in this shape: @@ -416,6 +422,9 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi dir = "~/.thirdshift/logs" # the root of the logs, each repository's in //; default ~/.thirdshift/logs waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes +[inbox] +silent_minutes = 10 # minutes without growth in either log before a Run is Silent; default 10 + [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -490,6 +499,8 @@ With `email.always = true`, every Run sends a [Run notification](#run-notificati `logs.waiting_line_minutes` sets how often CI watch and Self-merge repeat a waiting line even when nothing changes, in the terminal and the Command log. It defaults to 5 and must be a whole number of minutes from 1 up. CI watch still writes a line whenever the running or total check count changes; Self-merge writes waiting lines while GitHub calculates mergeability. +`inbox.silent_minutes` defaults to 10 and must be a whole number of minutes from 1 up. If `logs.waiting_line_minutes` is greater than or equal to it, `status` warns on stderr and waiting stages use half the silent time instead. A one-minute silent threshold therefore uses waiting lines every 30 seconds. + `[harness]` chooses the **Harness**, the agent CLI every session of a command runs on, and for each Harness the **Model** its sessions run on and their **Effort**, how hard that Model reasons. See [Harness, Model and Effort](#harness-model-and-effort). A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as `alway` for `always`, or a value of the wrong type, such as anything but `true` or `false` for `always`, or `0` for `spec.parallel` or `pickup.limit`, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. `email-test` and `setup` read it the same way. `update`, `version` and `help` never read it, so a broken User config can't block them, and they never offer Setup; nor does `email-test`. diff --git a/site/docs/index.html b/site/docs/index.html index 4624ac92..4b61ada1 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -209,7 +209,10 @@ thirdshift logs 'widgets#7' --session implement thirdshift logs 'widgets#7' --session repair --follow -

show includes the Runs, stage history with start times, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as Bash: cargo test, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable.

+

show includes the Runs, stage history with start times and Usual ranges, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as Bash: cargo test, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable.

+

status and show describe live work as Silent when neither its Session log nor its Command log has grown for inbox.silent_minutes (10 by default). Silence starts no earlier than the current stage. At three times the threshold, the words escalate: silent 45 min · past 30. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time.

+

A stage's Usual range is its 25th–75th percentile time over the repository's last 30 Command records on this machine. Completed stages supply times, with the next stage's start or the Command's end marking completion; an unfinished stage supplies none. The range appears once there are at least three stage times. Quartiles interpolate between sorted times. A live Run past the top of the range is Slow: slower than usual (12–17 min), still working. At twice the top it also shows the multiple, such as 2× usual or 3× usual. Silent takes precedence over Slow.

+

Both JSON views add health to recorded Commands: {"state":"ok"}, {"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}, or {"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. Each stage gains usual_range when enough times exist, expressed as lower_seconds and upper_seconds; these observations are never written back to Command records.

show --json is a versioned API, usable outside a repository. Version 1 wraps the selected Command in this shape:

{
   "version": 1,
@@ -414,6 +417,9 @@
 dir = "~/.thirdshift/logs"   # the root of the logs, each repository's in <owner>/<repo>/; default ~/.thirdshift/logs
 waiting_line_minutes = 5   # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes
 
+[inbox]
+silent_minutes = 10   # minutes without growth in either log before a Run is Silent; default 10
+
 [activity]
 quiet_skips = false   # a skipped Pass prints nothing, leaving only its Activity log line; default false
 
@@ -477,6 +483,7 @@
 

pickup.limit sets the Claim limit: how many open issues labelled in-progress stop a Pickup run from taking another, by default 3. It must be a whole number from 1 up. There is no command-line flag for it.

logs.dir sets the root of the logs, with each repository's in its <owner>/<repo>/ folder, created if missing. It must be an absolute path, ~ or a path starting with ~/, where ~ stands for $HOME. A relative path stops the Run before any work, since the directory a Run is launched from is no base for a setting that holds for every Run.

logs.waiting_line_minutes sets how often CI watch and Self-merge repeat a waiting line even when nothing changes, in the terminal and the Command log. It defaults to 5 and must be a whole number of minutes from 1 up. CI watch still writes a line whenever the running or total check count changes; Self-merge writes waiting lines while GitHub calculates mergeability.

+

inbox.silent_minutes defaults to 10 and must be a whole number of minutes from 1 up. If logs.waiting_line_minutes is greater than or equal to it, status warns on stderr and waiting stages use half the silent time instead. A one-minute silent threshold therefore uses waiting lines every 30 seconds.

[harness] chooses the Harness, the agent CLI every session of a command runs on, and for each Harness the Model its sessions run on and their Effort, how hard that Model reasons. See Harness, Model and Effort.

A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as alway for always, or a value of the wrong type, such as anything but true or false for always, or 0 for spec.parallel or pickup.limit, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. email-test and setup read it the same way. update, version and help never read it, so a broken User config can't block them, and they never offer Setup; nor does email-test.

diff --git a/src/asks.rs b/src/asks.rs index fbf94091..0193c5e6 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -245,6 +245,7 @@ mod tests { launch_pull: false, logs_dir: PathBuf::from("/home/me/.thirdshift/logs"), waiting_line_interval: std::time::Duration::from_secs(5 * 60), + silent_interval: std::time::Duration::from_secs(10 * 60), quiet_skips: false, email: EmailSettings::default(), spec_parallel: n(3), diff --git a/src/command.rs b/src/command.rs index d93d38bc..bd84508b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -106,7 +106,7 @@ pub fn start( .and_then(|()| UserConfig::load()) .map_err(|error| failure(&error))?; logs::configured(&config); - crate::poll::configure_waiting_lines(config.waiting_line_interval); + crate::poll::configure_waiting_lines(config.effective_waiting_line_interval()); // First, so no interrupt can end the command once its notification is // checked. interrupt::install().map_err(|error| failure(&error))?; diff --git a/src/config.rs b/src/config.rs index 5f8b02f1..6aee6e54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,8 @@ pub struct UserConfig { /// `logs.waiting_line_minutes`: time between unchanged waiting lines, /// by default five minutes. pub waiting_line_interval: Duration, + /// `inbox.silent_minutes`: how long both logs may be quiet, default ten minutes. + pub silent_interval: Duration, /// `activity.quiet_skips`: a skipped Pass prints /// nothing, its Activity log line its only trace. pub quiet_skips: bool, @@ -75,6 +77,15 @@ pub struct EmailSettings { } impl UserConfig { + /// Waiting stages must write before they could be observed as Silent. + pub fn effective_waiting_line_interval(&self) -> Duration { + if self.waiting_line_interval >= self.silent_interval { + self.silent_interval / 2 + } else { + self.waiting_line_interval + } + } + /// The User config under `$HOME`, or the defaults if there is none. pub fn load() -> Result { let (home, path) = home_and_path()?; @@ -95,6 +106,7 @@ impl UserConfig { launch_pull: false, logs_dir: home.join(".thirdshift/logs"), waiting_line_interval: Duration::from_secs(5 * 60), + silent_interval: Duration::from_secs(10 * 60), quiet_skips: false, email: EmailSettings::default(), spec_parallel: NonZeroUsize::new(3).unwrap(), @@ -124,6 +136,7 @@ impl UserConfig { | "base" | "launch" | "logs" + | "inbox" | "activity" | "email" | "spec" @@ -153,15 +166,11 @@ impl UserConfig { }, ("logs", "dir", _) => bail!("logs.dir must be a string in {file}"), ("logs", "waiting_line_minutes", value) => { - config.waiting_line_interval = value - .as_integer() - .filter(|minutes| *minutes > 0) - .and_then(|minutes| u64::try_from(minutes).ok()?.checked_mul(60)) - .map(Duration::from_secs) - .with_context(|| format!( - "logs.waiting_line_minutes must be a whole number of minutes from 1 up \ - within the supported range in {file}" - ))?; + config.waiting_line_interval = + minutes(value, "logs.waiting_line_minutes", &file)?; + } + ("inbox", "silent_minutes", value) => { + config.silent_interval = minutes(value, "inbox.silent_minutes", &file)?; } ("activity", "quiet_skips", Value::Boolean(quiet)) => { config.quiet_skips = *quiet @@ -749,6 +758,9 @@ from = "onboarding@resend.dev" # the sender; default onboarding@resend.dev, whi dir = "~/.thirdshift/logs" # the root of the logs, each repository's in //; default ~/.thirdshift/logs waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes +[inbox] +silent_minutes = 10 # minutes without growth in either log before a Run is Silent; default 10 + [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -807,6 +819,14 @@ fn with_email_to(to: Option) -> String { /// `value`, which `file` gives the setting `key`, as a whole number from 1 /// up, or an error naming both if it is anything else. +fn minutes(value: &Value, key: &str, file: &str) -> Result { + value.as_integer() + .filter(|minutes| *minutes > 0) + .and_then(|minutes| u64::try_from(minutes).ok()?.checked_mul(60)) + .map(Duration::from_secs) + .with_context(|| format!("{key} must be a whole number of minutes from 1 up within the supported range in {file}")) +} + fn whole_number_from_1(value: &Value, key: &str, file: &str) -> Result { value .as_integer() @@ -917,6 +937,7 @@ mod tests { "email.from", "logs.dir", "logs.waiting_line_minutes", + "inbox.silent_minutes", "activity.quiet_skips", "spec.parallel", "pickup.limit", diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs index 3cc7b219..ca26c91b 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -98,6 +98,9 @@ pub(super) struct Stage { pub name: String, pub began_at: String, pub session_log: Option, + /// Reader enrichment only; a writer never sets or retains this observation. + #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")] + pub usual_range: Option, } /// Internal transitions are typed; persisted names stay tolerant of new stages. @@ -139,7 +142,7 @@ impl StageName { } } - fn known(name: &str) -> bool { + pub(super) fn known(name: &str) -> bool { [ Self::Implement, Self::Resume, diff --git a/src/logs/reading.rs b/src/logs/reading.rs index ecfc7ae1..712be1f0 100644 --- a/src/logs/reading.rs +++ b/src/logs/reading.rs @@ -1,6 +1,7 @@ //! Filesystem observations of Commands, their Runs and exact ownership. //! Loading uses one sampled time; reading never prints or performs Stop effects. +mod health; mod history; mod selection; #[cfg(test)] @@ -17,6 +18,7 @@ use serde::Serialize; use super::command_record::{CommandRecord, CommandTree}; use super::effects::OnMachine; use super::{Pass, activity}; +pub(super) use health::{Health, UsualRange}; pub(super) use history::Command as HistoricalCommand; pub(super) use selection::{Detailed, Observation}; @@ -28,6 +30,14 @@ pub(super) struct Snapshot { impl Snapshot { pub(super) fn load(root: &Path, now: DateTime) -> Result { + Self::load_with_silence(root, now, std::time::Duration::from_secs(10 * 60)) + } + + pub(super) fn load_with_silence( + root: &Path, + now: DateTime, + silent: std::time::Duration, + ) -> Result { let mut records = BTreeMap::new(); let mut commands = Vec::new(); let mut idle_passes = Vec::new(); @@ -57,6 +67,7 @@ impl Snapshot { ); } } + let ranges = health::UsualRanges::from_records(&records); let roots: Vec<_> = records .iter() .filter(|(_, tree)| { @@ -70,6 +81,11 @@ impl Snapshot { .into_iter() .map(|path| Entry::Record(Box::new(tree(&path, &mut records, &now)))), ); + for entry in &mut commands { + if let Entry::Record(command) = entry { + health::observe(command, &now, silent, None, &ranges); + } + } idle_passes.sort_by(|a, b| (&a.repository, &a.kind).cmp(&(&b.repository, &b.kind))); Ok(Self { commands, @@ -179,6 +195,7 @@ pub(super) struct Command { pub(super) source: &'static str, pub(super) command_log: PathBuf, pub(super) dispatched: Vec, + pub(super) health: Option, } #[derive(Serialize)] @@ -213,6 +230,7 @@ impl Command { children: Vec::new(), source: "command_record", dispatched: Vec::new(), + health: None, } } } diff --git a/src/logs/reading/health.rs b/src/logs/reading/health.rs new file mode 100644 index 00000000..aa6976e7 --- /dev/null +++ b/src/logs/reading/health.rs @@ -0,0 +1,213 @@ +//! Health is an observation of live work, never a persisted Command fact. + +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Duration; + +use chrono::{DateTime, Local}; +use serde::Serialize; + +use super::Command; +use crate::logs::command_record::{CommandRecord, CommandTree, StageName}; + +#[derive(Clone, Copy, Serialize)] +pub(in crate::logs) struct UsualRange { + pub lower_seconds: f64, + pub upper_seconds: f64, +} + +impl UsualRange { + pub(in crate::logs) fn text(self) -> String { + format!( + "{}–{} min", + (self.lower_seconds / 60.0).round() as u64, + (self.upper_seconds / 60.0).round() as u64 + ) + } +} + +pub(super) struct UsualRanges(BTreeMap<(String, String), UsualRange>); + +impl UsualRanges { + pub(super) fn from_records(records: &BTreeMap) -> Self { + let mut repositories: BTreeMap<&str, Vec<&CommandTree>> = BTreeMap::new(); + for tree in records.values() { + repositories + .entry(&tree.command.repository) + .or_default() + .push(tree); + } + let mut samples: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for trees in repositories.values_mut() { + trees.sort_by_key(|tree| { + std::cmp::Reverse(DateTime::parse_from_rfc3339(&tree.command.started_at).ok()) + }); + for tree in trees.iter().take(30) { + for record in std::iter::once(&tree.command).chain(&tree.dispatched) { + collect_times(record, &mut samples); + } + } + } + Self( + samples + .into_iter() + .filter_map(|(key, mut times)| { + if times.len() < 3 { + return None; + } + times.sort_by(f64::total_cmp); + // Linear interpolation between adjacent ranks of the sorted sample. + let percentile = |fraction: f64| { + let rank = (times.len() - 1) as f64 * fraction; + let lower = rank.floor() as usize; + let upper = rank.ceil() as usize; + times[lower] + (times[upper] - times[lower]) * rank.fract() + }; + Some(( + key, + UsualRange { + lower_seconds: percentile(0.25), + upper_seconds: percentile(0.75), + }, + )) + }) + .collect(), + ) + } + + fn get(&self, repository: &str, stage: &str) -> Option { + self.0.get(&(repository.into(), stage.into())).copied() + } +} + +fn collect_times(record: &CommandRecord, samples: &mut BTreeMap<(String, String), Vec>) { + for (index, stage) in record.stages.iter().enumerate() { + if !StageName::known(&stage.name) { + continue; + } + let end = record + .stages + .get(index + 1) + .map(|stage| stage.began_at.as_str()) + .or_else(|| record.outcome.as_ref().and(record.ended_at.as_deref())); + if let Some((began, ended)) = DateTime::parse_from_rfc3339(&stage.began_at) + .ok() + .zip(end.and_then(|end| DateTime::parse_from_rfc3339(end).ok())) + { + let seconds = ended.signed_duration_since(began).num_milliseconds() as f64 / 1000.0; + if seconds >= 0.0 { + samples + .entry((record.repository.clone(), stage.name.clone())) + .or_default() + .push(seconds); + } + } + } +} + +#[derive(Serialize)] +#[serde(tag = "state", rename_all = "lowercase")] +pub(in crate::logs) enum Health { + Ok, + Silent { + step: u8, + seconds: u64, + threshold_seconds: u64, + }, + Slow { + step: u8, + usual_range: UsualRange, + multiple: u64, + }, +} + +impl Health { + pub(in crate::logs) fn text(&self) -> Option { + match self { + Self::Ok => None, + Self::Slow { + step, + usual_range, + multiple, + } => { + let mut text = format!("slower than usual ({}), still working", usual_range.text()); + if *step == 2 { + text.push_str(&format!(" · {multiple}× usual")); + } + Some(text) + } + Self::Silent { + step, + seconds, + threshold_seconds, + } => { + let mut text = format!("silent {} min", seconds / 60); + if *step == 2 { + text.push_str(&format!( + " · past {}", + threshold_seconds.saturating_mul(3) / 60 + )); + } + Some(text) + } + } + } +} + +pub(super) fn observe( + command: &mut Command, + now: &DateTime, + silent: Duration, + outer_log: Option<&Path>, + ranges: &UsualRanges, +) { + let log = outer_log.unwrap_or(&command.command_log); + for stage in &mut command.record.stages { + stage.usual_range = ranges.get(&command.record.repository, &stage.name); + } + if command.state == "live" { + command.health = Some(Health::Ok); + if let Some(stage) = &command.stage { + if let Some(range) = ranges.get(&command.record.repository, &stage.name) + && stage.age_seconds as f64 > range.upper_seconds + { + command.health = Some(Health::Slow { + step: if stage.age_seconds as f64 >= range.upper_seconds * 2.0 { + 2 + } else { + 1 + }, + usual_range: range, + multiple: (stage.age_seconds as f64 / range.upper_seconds.max(1.0)).floor() + as u64, + }); + } + // A newly entered stage cannot already be Silent. Missing logs use + // its start time, rather than inventing activity at read time. + let mut quiet = stage.age_seconds as u64; + for path in std::iter::once(log).chain(command.record.session_log.as_deref()) { + if let Ok(modified) = std::fs::metadata(path).and_then(|file| file.modified()) { + quiet = quiet.min( + now.signed_duration_since(DateTime::::from(modified)) + .num_seconds() + .max(0) as u64, + ); + } + } + if quiet >= silent.as_secs() { + command.health = Some(Health::Silent { + step: if quiet >= silent.as_secs().saturating_mul(3) { + 2 + } else { + 1 + }, + seconds: quiet, + threshold_seconds: silent.as_secs(), + }); + } + } + } + for child in command.children.iter_mut().chain(&mut command.dispatched) { + observe(child, now, silent, Some(log), ranges); + } +} diff --git a/src/logs/reading/selection.rs b/src/logs/reading/selection.rs index f43f242d..de837bbc 100644 --- a/src/logs/reading/selection.rs +++ b/src/logs/reading/selection.rs @@ -368,6 +368,7 @@ pub(in crate::logs) struct Details<'a> { #[serde(flatten)] record: &'a crate::logs::command_record::CommandRecord, state: &'static str, + health: &'a Option, stage: &'a Option, source: &'static str, command_log: &'a Path, @@ -383,6 +384,7 @@ impl<'a> Details<'a> { Ok(Self { record: &command.record, state: command.state, + health: &command.health, stage: &command.stage, source: command.source, command_log: &command.command_log, diff --git a/src/logs/recording.rs b/src/logs/recording.rs index e10e1cdb..ddce065f 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -310,6 +310,7 @@ impl Record { name: name.into(), began_at: self.outside.now().to_rfc3339(), session_log: facts.session_log.clone(), + usual_range: None, }); } else if let Some(stage) = facts.stages.last_mut() { stage.session_log = facts.session_log.clone(); diff --git a/src/logs/status.rs b/src/logs/status.rs index e6ee22ff..ac4da53c 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -65,7 +65,15 @@ pub(super) fn print_history(command: &super::reading::HistoricalCommand) { pub fn show(json: bool, all: bool) -> Result<()> { let config = UserConfig::load()?; - let snapshot = Snapshot::load(&config.logs_dir, Local::now())?; + let waiting = config.effective_waiting_line_interval(); + if waiting != config.waiting_line_interval { + eprintln!( + "thirdshift: warning: logs.waiting_line_minutes must be less than inbox.silent_minutes; using half the silent time ({} sec) for waiting lines", + waiting.as_secs() + ); + } + let snapshot = + Snapshot::load_with_silence(&config.logs_dir, Local::now(), config.silent_interval)?; let status = Status { version: 1, hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), @@ -93,7 +101,7 @@ pub(super) fn stage_text(command: &Command) -> String { if let Some(outcome) = &command.record.outcome { return outcome.clone(); } - command.stage.as_ref().map_or_else( + let mut text = command.stage.as_ref().map_or_else( || { if command.state == "died" { "died before first stage".into() @@ -108,7 +116,15 @@ pub(super) fn stage_text(command: &Command) -> String { format!("{} · {}", stage.name, age(stage.age_seconds)) } }, - ) + ); + if let Some(health) = command + .health + .as_ref() + .and_then(super::reading::Health::text) + { + text.push_str(&format!(" · {health}")); + } + text } pub(super) fn print_tickets(facts: &CommandRecord, indent: &str) { diff --git a/src/logs/status/inspect.rs b/src/logs/status/inspect.rs index 1afd0819..8b656f97 100644 --- a/src/logs/status/inspect.rs +++ b/src/logs/status/inspect.rs @@ -39,7 +39,11 @@ fn print_details(command: &Command, indent: &str) -> Result<()> { ); } for stage in &command.record.stages { - println!("{indent} {} · {}", stage.name, stage.began_at); + let usual = stage + .usual_range + .map(|range| format!(" · usual {}", range.text())) + .unwrap_or_default(); + println!("{indent} {} · {}{usual}", stage.name, stage.began_at); } for path in command.sessions()? { println!("{indent}Session log: {}", path.display()); @@ -56,7 +60,8 @@ fn print_details(command: &Command, indent: &str) -> Result<()> { pub fn show(input: &str, json: bool) -> Result<()> { let config = UserConfig::load()?; - let snapshot = Snapshot::load(&config.logs_dir, Local::now())?; + let snapshot = + Snapshot::load_with_silence(&config.logs_dir, Local::now(), config.silent_interval)?; let command = snapshot.resolve(input)?.observation; if json { #[derive(Serialize)] diff --git a/src/poll.rs b/src/poll.rs index 0386a50e..722d2c55 100644 --- a/src/poll.rs +++ b/src/poll.rs @@ -15,7 +15,8 @@ static WAITING_LINE_INTERVAL: OnceLock = OnceLock::new(); /// preserving the User config's choice of how many minutes to wait. pub fn configure_waiting_lines(interval: Duration) { let interval = millis_from_env("THIRDSHIFT_WAITING_MINUTE_MS") - .and_then(|minute| minute.checked_mul(u32::try_from(interval.as_secs() / 60).ok()?)) + .and_then(|minute| minute.checked_mul(u32::try_from(interval.as_secs()).ok()?)) + .map(|interval| interval / 60) .unwrap_or(interval); let _ = WAITING_LINE_INTERVAL.set(interval); } diff --git a/src/setup.rs b/src/setup.rs index 660641c3..05ed8768 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1126,6 +1126,9 @@ from = \"ts@acme.dev\" dir = \"/var/log/thirdshift\" waiting_line_minutes = 5 +[inbox] +silent_minutes = 10 + [activity] quiet_skips = true diff --git a/tests/ci.rs b/tests/ci.rs index 297cf15a..8b73ae6e 100644 --- a/tests/ci.rs +++ b/tests/ci.rs @@ -123,6 +123,30 @@ fn unchanged_running_checks_keep_writing_waiting_lines_to_the_command_log() { } } +#[test] +fn ci_watch_uses_half_the_silent_time_when_the_waiting_interval_is_too_long() { + let scenario = Scenario::new(); + scenario.user_config_is("[logs]\nwaiting_line_minutes = 5\n[inbox]\nsilent_minutes = 1\n"); + scenario.agent_does(&format!( + "{AGENT_OPENS_PR}{}", + checks_on_head(r#"[{"name": "test", "conclusion": "success", "pending_polls": 10}]"#) + )); + let result = scenario.run_with_env( + &[&scenario.issue_url(7)], + &[("THIRDSHIFT_WAITING_MINUTE_MS", "400")], + ); + assert_eq!(result.code, Some(0), "{}", result.stderr); + assert!( + result + .stderr + .matches("1 still running out of 1 checks listed so far") + .count() + >= 3, + "{}", + result.stderr + ); +} + #[test] fn red_ci_is_handed_to_a_ci_fix_repair_whose_fix_turns_it_green() { let scenario = Scenario::new(); diff --git a/tests/run_health.rs b/tests/run_health.rs new file mode 100644 index 00000000..f864bdd6 --- /dev/null +++ b/tests/run_health.rs @@ -0,0 +1,225 @@ +mod support; + +use std::fs::{self, File, FileTimes}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use serde_json::{Value, json}; +use support::{HeldRun, Scenario}; + +const LOGS: &str = "home/.thirdshift/logs/acme/widgets/commands/issue"; + +struct LiveRun { + scenario: Scenario, + held: Option, + path: PathBuf, + record: Value, +} + +impl LiveRun { + fn new() -> Self { + let scenario = Scenario::new(); + scenario.agent_does(&format!( + "touch '{}'\nwhile ! test -f '{}'; do sleep 0.02; done\nexit 1\n", + scenario.path("started").display(), + scenario.path("release").display() + )); + let held = scenario.run_until(&[&scenario.issue_url(7)], &[], "started"); + let path = fs::read_dir(scenario.path(LOGS)) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.to_string_lossy().ends_with(".command.json")) + .unwrap(); + let record = scenario.command_record(LOGS); + Self { + scenario, + held: Some(held), + path, + record, + } + } + + fn stage_age(&mut self, minutes: i64) { + let began = (chrono::Local::now() - chrono::Duration::minutes(minutes)).to_rfc3339(); + self.record["started_at"] = began.clone().into(); + self.record["stages"] = json!([{ + "name": "implement", "began_at": began, + "session_log": self.record["session_log"] + }]); + fs::write(&self.path, serde_json::to_vec(&self.record).unwrap()).unwrap(); + } + + fn log_ages(&self, command: u64, session: u64) { + for (path, minutes) in [ + ( + self.path.with_extension("log").with_file_name( + self.path + .file_name() + .unwrap() + .to_str() + .unwrap() + .replace(".command.json", ".log"), + ), + command, + ), + ( + PathBuf::from(self.record["session_log"].as_str().unwrap()), + session, + ), + ] { + File::options() + .write(true) + .open(path) + .unwrap() + .set_times( + FileTimes::new() + .set_modified(SystemTime::now() - Duration::from_secs(minutes * 60)), + ) + .unwrap(); + } + } + + fn status(&self) -> Value { + let result = self.scenario.run(&["status", "--json"]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let status: Value = serde_json::from_str(&result.stdout).unwrap(); + status["commands"] + .as_array() + .unwrap() + .iter() + .find(|command| command["state"] == "live") + .unwrap() + .clone() + } + + fn history(&self, number: u32, duration: Option, minutes_ago: i64, repo: &str) { + let mut record = self.record.clone(); + let ended = chrono::Local::now() - chrono::Duration::minutes(minutes_ago); + let began = ended - chrono::Duration::minutes(duration.unwrap_or(1)); + record["started_at"] = began.to_rfc3339().into(); + record["ended_at"] = ended.to_rfc3339().into(); + record["outcome"] = "ready for review".into(); + record["repository"] = repo.into(); + record["issue"]["number"] = number.into(); + record["session_log"] = Value::Null; + record["stages"] = duration.map_or(json!([]), |_| { + json!([{ + "name": "implement", "began_at": began.to_rfc3339(), "session_log": null + }]) + }); + let folder = self + .scenario + .path(&format!("home/.thirdshift/logs/{repo}/commands/issue")); + fs::create_dir_all(&folder).unwrap(); + fs::write( + folder.join(format!("{number}-fixture.command.json")), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + } +} + +impl Drop for LiveRun { + fn drop(&mut self) { + fs::write(self.scenario.path("release"), "").unwrap(); + self.held.take().unwrap().finish(); + } +} + +#[test] +fn silent_requires_both_logs_and_escalates_in_status_and_show() { + let mut run = LiveRun::new(); + run.stage_age(60); + for ages in [(45, 0), (0, 45), (9, 9)] { + run.log_ages(ages.0, ages.1); + assert_eq!(run.status()["health"]["state"], "ok"); + } + for (minutes, step, words) in [(14, 1, "silent 14 min"), (45, 2, "silent 45 min · past 30")] { + run.log_ages(minutes, minutes); + let status = run.status(); + assert_eq!(status["health"]["state"], "silent"); + assert_eq!(status["health"]["step"], step); + for args in [vec!["status"], vec!["show", "widgets#7"]] { + let output = run.scenario.run(&args); + assert_eq!(output.code, Some(0), "{}", output.stderr); + assert!(output.stdout.contains(words), "{}", output.stdout); + } + } + run.scenario + .user_config_is("[inbox]\nsilent_minutes = 20\n"); + run.log_ages(14, 14); + assert_eq!(run.status()["health"]["state"], "ok"); +} + +#[test] +fn slow_uses_the_repositorys_last_thirty_records_and_escalates_at_twice_the_top() { + let mut run = LiveRun::new(); + run.stage_age(20); + run.log_ages(0, 0); + for (number, duration) in [(10, 10), (11, 14)] { + run.history(number, Some(duration), 100, "acme/widgets"); + } + assert_eq!(run.status()["health"]["state"], "ok"); + let shown = run.scenario.run(&["show", "widgets#7", "--json"]); + let shown: Value = serde_json::from_str(&shown.stdout).unwrap(); + assert!(shown["command"]["stages"][0]["usual_range"].is_null()); + run.history(12, Some(20), 100, "acme/widgets"); + for number in 20..46 { + run.history(number, None, 200, "acme/widgets"); + } + run.history(90, Some(900), 2000, "acme/widgets"); + for number in 100..103 { + run.history(number, Some(900), 100, "other/gadgets"); + } + for (age, step, words) in [ + (20, 1, "slower than usual (12–17 min), still working"), + (34, 2, "2× usual"), + (51, 2, "3× usual"), + ] { + run.stage_age(age); + let status = run.status(); + assert_eq!(status["health"]["state"], "slow", "{status}"); + assert_eq!(status["health"]["step"], step); + let shown = run.scenario.run(&["show", "widgets#7", "--json"]); + assert_eq!(shown.code, Some(0), "{}", shown.stderr); + let shown: Value = serde_json::from_str(&shown.stdout).unwrap(); + assert_eq!( + shown["command"]["stages"][0]["usual_range"], + json!({"lower_seconds":720.0, "upper_seconds":1020.0}) + ); + assert_eq!(shown["command"]["health"]["state"], "slow"); + for args in [vec!["status"], vec!["show", "widgets#7"]] { + let output = run.scenario.run(&args); + assert!(output.stdout.contains(words), "{}", output.stdout); + assert!(output.stdout.contains("12–17 min"), "{}", output.stdout); + } + } + run.log_ages(45, 45); + assert_eq!(run.status()["health"]["state"], "silent"); +} + +#[test] +fn status_warns_when_waiting_lines_are_not_more_frequent_than_silence() { + let scenario = Scenario::new(); + for waiting in [1, 5] { + scenario.user_config_is(&format!( + "[logs]\nwaiting_line_minutes = {waiting}\n[inbox]\nsilent_minutes = 1\n" + )); + let output = scenario.run(&["status", "--json"]); + assert_eq!(output.code, Some(0), "{}", output.stderr); + serde_json::from_str::(&output.stdout).unwrap(); + assert!( + output.stderr.contains("logs.waiting_line_minutes"), + "{}", + output.stderr + ); + assert!( + output.stderr.contains("inbox.silent_minutes"), + "{}", + output.stderr + ); + assert!(output.stderr.contains("30 sec"), "{}", output.stderr); + } + scenario.user_config_is("[logs]\nwaiting_line_minutes = 1\n[inbox]\nsilent_minutes = 2\n"); + assert!(!scenario.run(&["status"]).stderr.contains("warning")); +} diff --git a/tests/setup.rs b/tests/setup.rs index 3916a3ca..703178de 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -48,6 +48,7 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { "[launch]", "[email]", "[logs]", + "[inbox]", "[activity]", "[spec]", "[pickup]", @@ -73,6 +74,7 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { assert!(config["email"].get("to").is_none(), "{text}"); assert_eq!(config["logs"]["dir"].as_str(), Some("~/.thirdshift/logs")); assert_eq!(config["logs"]["waiting_line_minutes"].as_integer(), Some(5)); + assert_eq!(config["inbox"]["silent_minutes"].as_integer(), Some(10)); assert_eq!(config["activity"]["quiet_skips"].as_bool(), Some(false)); assert_eq!(config["spec"]["parallel"].as_integer(), Some(3)); assert_eq!(config["pickup"]["limit"].as_integer(), Some(3)); @@ -140,6 +142,7 @@ effort = 'high' # my Effort "[harness]", "[harness.claude]", "[harness.codex]", + "[inbox]", "[harness.agy]", "[harness.grok]", "[harness.muse]", From 21cae2cd80870299e64246e79fc62761fdc68079 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 20:01:16 +0000 Subject: [PATCH 05/47] Keep Usual ranges in reader-owned stage observations --- docs/guide.md | 2 +- site/docs/index.html | 2 +- src/logs/command_record.rs | 3 --- src/logs/reading.rs | 2 ++ src/logs/reading/health.rs | 13 ++++++++++--- src/logs/reading/selection.rs | 2 ++ src/logs/recording.rs | 1 - src/logs/status/inspect.rs | 5 +++-- tests/run_health.rs | 4 ++-- 9 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index a5588918..8f4285ca 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -177,7 +177,7 @@ thirdshift logs 'widgets#7' --session repair --follow A stage's **Usual range** is its 25th–75th percentile time over the repository's last 30 Command records on this machine. Completed stages supply times, with the next stage's start or the Command's end marking completion; an unfinished stage supplies none. The range appears once there are at least three stage times. Quartiles interpolate between sorted times. A live Run past the top of the range is **Slow**: `slower than usual (12–17 min), still working`. At twice the top it also shows the multiple, such as `2× usual` or `3× usual`. Silent takes precedence over Slow. -Both JSON views add `health` to recorded Commands: `{"state":"ok"}`, `{"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}`, or `{"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}`. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. Each stage gains `usual_range` when enough times exist, expressed as `lower_seconds` and `upper_seconds`; these observations are never written back to Command records. +Both JSON views add `health` to recorded Commands: `{"state":"ok"}`, `{"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}`, or `{"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}`. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. The `usual_ranges` map gives each stage's range by name, for example `{"implement":{"lower_seconds":720.0,"upper_seconds":1020.0}}`; a stage is omitted until enough times exist. Health and ranges are reader observations, never written back to Command records. `show --json` is a versioned API, usable outside a repository. Version 1 wraps the selected Command in this shape: diff --git a/site/docs/index.html b/site/docs/index.html index 4b61ada1..a53a76e6 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -212,7 +212,7 @@

show includes the Runs, stage history with start times and Usual ranges, outcomes, and Command and Session log paths. Each live Run also gets its latest event, such as Bash: cargo test, from the last 64 KiB of its current Session log. It uses the Harness's stream interpretation, including Claude and Codex, and ignores malformed or unknown lines. Missing or unreadable Session logs leave the event unavailable.

status and show describe live work as Silent when neither its Session log nor its Command log has grown for inbox.silent_minutes (10 by default). Silence starts no earlier than the current stage. At three times the threshold, the words escalate: silent 45 min · past 30. A fresh line in either log keeps the Run from being Silent. Missing logs use the stage's start time.

A stage's Usual range is its 25th–75th percentile time over the repository's last 30 Command records on this machine. Completed stages supply times, with the next stage's start or the Command's end marking completion; an unfinished stage supplies none. The range appears once there are at least three stage times. Quartiles interpolate between sorted times. A live Run past the top of the range is Slow: slower than usual (12–17 min), still working. At twice the top it also shows the multiple, such as 2× usual or 3× usual. Silent takes precedence over Slow.

-

Both JSON views add health to recorded Commands: {"state":"ok"}, {"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}, or {"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. Each stage gains usual_range when enough times exist, expressed as lower_seconds and upper_seconds; these observations are never written back to Command records.

+

Both JSON views add health to recorded Commands: {"state":"ok"}, {"state":"slow","step":1,"usual_range":{"lower_seconds":720.0,"upper_seconds":1020.0},"multiple":1}, or {"state":"silent","step":2,"seconds":2700,"threshold_seconds":600}. Step 1 is the first threshold; step 2 is the escalation. Ended or dead Commands have null health. The usual_ranges map gives each stage's range by name, for example {"implement":{"lower_seconds":720.0,"upper_seconds":1020.0}}; a stage is omitted until enough times exist. Health and ranges are reader observations, never written back to Command records.

show --json is a versioned API, usable outside a repository. Version 1 wraps the selected Command in this shape:

{
   "version": 1,
diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs
index ca26c91b..96e17507 100644
--- a/src/logs/command_record.rs
+++ b/src/logs/command_record.rs
@@ -98,9 +98,6 @@ pub(super) struct Stage {
     pub name: String,
     pub began_at: String,
     pub session_log: Option,
-    /// Reader enrichment only; a writer never sets or retains this observation.
-    #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
-    pub usual_range: Option,
 }
 
 /// Internal transitions are typed; persisted names stay tolerant of new stages.
diff --git a/src/logs/reading.rs b/src/logs/reading.rs
index 712be1f0..afe9af8d 100644
--- a/src/logs/reading.rs
+++ b/src/logs/reading.rs
@@ -196,6 +196,7 @@ pub(super) struct Command {
     pub(super) command_log: PathBuf,
     pub(super) dispatched: Vec,
     pub(super) health: Option,
+    pub(super) usual_ranges: BTreeMap,
 }
 
 #[derive(Serialize)]
@@ -231,6 +232,7 @@ impl Command {
             source: "command_record",
             dispatched: Vec::new(),
             health: None,
+            usual_ranges: BTreeMap::new(),
         }
     }
 }
diff --git a/src/logs/reading/health.rs b/src/logs/reading/health.rs
index aa6976e7..62753fa0 100644
--- a/src/logs/reading/health.rs
+++ b/src/logs/reading/health.rs
@@ -162,9 +162,16 @@ pub(super) fn observe(
     ranges: &UsualRanges,
 ) {
     let log = outer_log.unwrap_or(&command.command_log);
-    for stage in &mut command.record.stages {
-        stage.usual_range = ranges.get(&command.record.repository, &stage.name);
-    }
+    command.usual_ranges = command
+        .record
+        .stages
+        .iter()
+        .filter_map(|stage| {
+            ranges
+                .get(&command.record.repository, &stage.name)
+                .map(|range| (stage.name.clone(), range))
+        })
+        .collect();
     if command.state == "live" {
         command.health = Some(Health::Ok);
         if let Some(stage) = &command.stage {
diff --git a/src/logs/reading/selection.rs b/src/logs/reading/selection.rs
index de837bbc..8ff0ce18 100644
--- a/src/logs/reading/selection.rs
+++ b/src/logs/reading/selection.rs
@@ -369,6 +369,7 @@ pub(in crate::logs) struct Details<'a> {
     record: &'a crate::logs::command_record::CommandRecord,
     state: &'static str,
     health: &'a Option,
+    usual_ranges: &'a std::collections::BTreeMap,
     stage: &'a Option,
     source: &'static str,
     command_log: &'a Path,
@@ -385,6 +386,7 @@ impl<'a> Details<'a> {
             record: &command.record,
             state: command.state,
             health: &command.health,
+            usual_ranges: &command.usual_ranges,
             stage: &command.stage,
             source: command.source,
             command_log: &command.command_log,
diff --git a/src/logs/recording.rs b/src/logs/recording.rs
index ddce065f..e10e1cdb 100644
--- a/src/logs/recording.rs
+++ b/src/logs/recording.rs
@@ -310,7 +310,6 @@ impl Record {
                 name: name.into(),
                 began_at: self.outside.now().to_rfc3339(),
                 session_log: facts.session_log.clone(),
-                usual_range: None,
             });
         } else if let Some(stage) = facts.stages.last_mut() {
             stage.session_log = facts.session_log.clone();
diff --git a/src/logs/status/inspect.rs b/src/logs/status/inspect.rs
index 8b656f97..d79a0221 100644
--- a/src/logs/status/inspect.rs
+++ b/src/logs/status/inspect.rs
@@ -39,8 +39,9 @@ fn print_details(command: &Command, indent: &str) -> Result<()> {
         );
     }
     for stage in &command.record.stages {
-        let usual = stage
-            .usual_range
+        let usual = command
+            .usual_ranges
+            .get(&stage.name)
             .map(|range| format!(" · usual {}", range.text()))
             .unwrap_or_default();
         println!("{indent}  {} · {}{usual}", stage.name, stage.began_at);
diff --git a/tests/run_health.rs b/tests/run_health.rs
index f864bdd6..de676426 100644
--- a/tests/run_health.rs
+++ b/tests/run_health.rs
@@ -162,7 +162,7 @@ fn slow_uses_the_repositorys_last_thirty_records_and_escalates_at_twice_the_top(
     assert_eq!(run.status()["health"]["state"], "ok");
     let shown = run.scenario.run(&["show", "widgets#7", "--json"]);
     let shown: Value = serde_json::from_str(&shown.stdout).unwrap();
-    assert!(shown["command"]["stages"][0]["usual_range"].is_null());
+    assert!(shown["command"]["usual_ranges"]["implement"].is_null());
     run.history(12, Some(20), 100, "acme/widgets");
     for number in 20..46 {
         run.history(number, None, 200, "acme/widgets");
@@ -184,7 +184,7 @@ fn slow_uses_the_repositorys_last_thirty_records_and_escalates_at_twice_the_top(
         assert_eq!(shown.code, Some(0), "{}", shown.stderr);
         let shown: Value = serde_json::from_str(&shown.stdout).unwrap();
         assert_eq!(
-            shown["command"]["stages"][0]["usual_range"],
+            shown["command"]["usual_ranges"]["implement"],
             json!({"lower_seconds":720.0, "upper_seconds":1020.0})
         );
         assert_eq!(shown["command"]["health"]["state"], "slow");

From 2821894d999eecb52e0e47dd89374d9ea02be711 Mon Sep 17 00:00:00 2001
From: Jacob Stephens 
Date: Mon, 19 Oct 2026 21:02:15 +0000
Subject: [PATCH 06/47] Group machine status into the Inbox (#816)

---
 docs/code-navigation.md    |   1 +
 docs/guide.md              |  20 +-
 site/docs/index.html       |  18 +-
 src/asks.rs                |   1 +
 src/config.rs              |  13 ++
 src/logs/command_record.rs |   5 +
 src/logs/reading.rs        |  53 +-----
 src/logs/reading/inbox.rs  | 301 ++++++++++++++++++++++++++++++
 src/logs/reading/tests.rs  |   6 +-
 src/logs/status.rs         |  61 ++++++-
 src/notification.rs        |   2 +
 src/run_ending.rs          |   9 +
 src/security.rs            |   5 +
 src/setup.rs               |   1 +
 tests/security_run.rs      |  10 +
 tests/status.rs            | 364 +++++++++++++++++++++++++++++++++++--
 16 files changed, 794 insertions(+), 76 deletions(-)
 create mode 100644 src/logs/reading/inbox.rs

diff --git a/docs/code-navigation.md b/docs/code-navigation.md
index c3d91bc7..159beb87 100644
--- a/docs/code-navigation.md
+++ b/docs/code-navigation.md
@@ -21,6 +21,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their
 | Command-log allocation, Session naming, child inheritance and filename decoding | `src/logs/owner.rs` — `allocate`, `Owner`, `decode`, `session_matches`; `src/session/recording.rs` — captured Model recording | `src/logs/owner/tests.rs`, `src/logs/recording/tests.rs`, `src/session/recording/tests.rs`, `tests/command_logs.rs`, `tests/status.rs`, `tests/show_logs.rs` |
 | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/reading.rs` — `Snapshot`, exact-parent trees and observations; `src/logs/reading/history.rs` — historical recovery; `src/logs/status.rs` — formatting; `src/logs/process_identity.rs` | `src/logs/reading/tests.rs`, `tests/status.rs`, `src/logs/recording/tests.rs` |
 | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` |
+| Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` |
 | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` |
 | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests |
 | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` |
diff --git a/docs/guide.md b/docs/guide.md
index 8f4285ca..0e3bd065 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -73,7 +73,16 @@ The designed behaviour described in this manual is implemented. Known gaps and p
 
 ## Machine status
 
-`thirdshift status` names this host and lists live Commands across every repository in `logs.dir`, with their issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as `architect-20261018T120000-0400` or `secure-20261018T120000-0400`. After the live Commands it lists those that ended in the last 24 hours, with their outcomes. `thirdshift status --all` lists every Command this machine has logs for, newest start first. A Command or child Run with no outcome whose process has gone shows as `died at ` in either view. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as `*.command.json`, with child records named for their issue as `*.issue-.child.json`. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome.
+`thirdshift status` names this host and shows its **Inbox** across every repository in `logs.dir`, in this order:
+
+- **Needs you:** live Silent Runs, then Slow Runs, then ended problems newest first: Died, Failed, Triage (a Stop without requeue), or a Security finding waiting on a decision. An ended problem leaves when a later Command on the same issue starts on this machine, or after `inbox.days` (7 by default). Nothing is read from GitHub. Failures with the same nonempty cause collapse into one item with a count; unknown causes stay separate.
+- **Ready for review:** Commands that ended ready, with a pull request.
+- **Landing:** live Runs at CI watch or Self-merge. A Command already in Needs you has a reference here instead of a second row.
+- **Working:** every other live Command.
+- **Done:** Commands that ended in the last day with nothing waiting.
+- **Resolved:** older completions with nothing waiting, back to `inbox.days`.
+
+`--all` adds **History** for everything older or without a known ending. Each row has a state word, its issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as `architect-20261018T120000-0400` or `secure-20261018T120000-0400`. A Command or child Run with no outcome whose process has gone shows as `died at `; its start time bounds how long it stays in Needs you when no ending is known. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as `*.command.json`, with child records named for their issue as `*.issue-.child.json`. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome.
 
 History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current `///commands//` layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with `--all`. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before [ADR 0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md) is ignored.
 
@@ -113,6 +122,10 @@ Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: ht
     "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log",
     "dispatched": [],
     "state": "live",
+    "group": "working",
+    "needs_you_kind": null,
+    "landing": false,
+    "count": 1,
     "stage": {"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "age_seconds": 52},
     "parent_record": null,
     "parent_dispatch": null,
@@ -129,6 +142,8 @@ Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: ht
 
 Times are RFC 3339; ages are nonnegative seconds. `model` and `effort` are null when left to the Harness. `issue` is null for a Pass with no issue; `session_log`, `issue_branch`, `pull_request`, `outcome`, `cause`, `ended_at`, `parent_record` and `parent_dispatch` are nullable. Command kinds are `Run`, `Spec run`, `Architect run`, `Pickup run`, `Security run` and `Base fix`. `name` identifies an issue or a Pass's kind and stamp. `dispatched` is an array of the Runs or Spec runs a Pass dispatched, with the same fields as its parent (and an empty `dispatched` array); it shares the Pass's process identity and Command log. It retains its own outcome, end time, cause, Issue branch and pull request. The retained Command record has the same fields, including dispatched work, without the computed `state`, `stage`, `source`, `command_log` and `children` or empty leaf `dispatched` arrays. Older records without an end time use their last modification time. `idle_passes` contains one entry per repository and kind whose latest Activity log line is a skip; it is independent of `commands`, and never creates a Command record or log for a skipped Pass.
 
+Each top-level `commands` entry adds the computed `group` (`needs you`, `ready for review`, `landing`, `working`, `done`, `resolved` or `history`), nullable `needs_you_kind` (`silent`, `slow`, `died`, `failed`, `triage`, `finding`), `landing` (also true for a Needs you item referenced from Landing), and `count`. A collapsed failure has a `collapsed` array containing the other Commands, each with its own group and kind. These fields are observations, absent from durable records and `show`; child Runs remain within their Command. The durable `security_findings_waiting` flag defaults to false for older records.
+
 `state` is `live`, `died`, `ended`, or `unknown` when historical evidence has no ending. `stage` is null before the first known stage. Readers skip unknown stage names. Known stages are `implement`, `Resume`, `Security review`, `pull request`, `CI watch`, `Repair`, `Self-merge`, `cleanup`, `Tickets`, `Spec review`, `Architecture review`, `Security audit`, `Security reproduction` and `Security fix publishing`. A Pass retains its own stages while its dispatched work records its stages separately.
 
 `children` recursively contains the immediate child Runs in separate processes, with the same fields as their parent. A Pickup's `dispatched` contains its Run or Spec run. A Spec's `tickets` is its complete roster in issue-number order; each entry has `issue` (`number`, `url`, nullable `title`), `state` (`running`, `merged`, `failed`, `waiting`, or `not started`) and `blockers` (the named open blocking issues, with the same issue fields). Match a Ticket's `issue.url` to the Run in that Spec's `children` for its stage, age, liveness and Base fix descendants. Tickets without a Run remain in the roster. Completed child Runs stay in the tree while its Command is listed. A killed child's `state: "died"` remains independent of the Spec's Ticket accounting. `parent_record` links each child to its parent's exact file; `parent_dispatch`, when present, names the dispatched issue's URL within that file. A top-level Command has null for both. Records from before these fields existed read as having no children or Tickets.
@@ -423,6 +438,7 @@ dir = "~/.thirdshift/logs"   # the root of the logs, each repository's in 
 

Machine status

-

thirdshift status names this host and lists live Commands across every repository in logs.dir, with their issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as architect-20261018T120000-0400 or secure-20261018T120000-0400. After the live Commands it lists those that ended in the last 24 hours, with their outcomes. thirdshift status --all lists every Command this machine has logs for, newest start first. A Command or child Run with no outcome whose process has gone shows as died at <stage> in either view. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as *.command.json, with child records named for their issue as *.issue-<n>.child.json. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome.

+

thirdshift status names this host and shows its Inbox across every repository in logs.dir, in this order:

+
    +
  • Needs you: live Silent Runs, then Slow Runs, then ended problems newest first: Died, Failed, Triage (a Stop without requeue), or a Security finding waiting on a decision. An ended problem leaves when a later Command on the same issue starts on this machine, or after inbox.days (7 by default). Nothing is read from GitHub. Failures with the same nonempty cause collapse into one item with a count; unknown causes stay separate.
  • +
  • Ready for review: Commands that ended ready, with a pull request.
  • +
  • Landing: live Runs at CI watch or Self-merge. A Command already in Needs you has a reference here instead of a second row.
  • +
  • Working: every other live Command.
  • +
  • Done: Commands that ended in the last day with nothing waiting.
  • +
  • Resolved: older completions with nothing waiting, back to inbox.days.
  • +
+

--all adds History for everything older or without a known ending. Each row has a state word, its issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as architect-20261018T120000-0400 or secure-20261018T120000-0400. A Command or child Run with no outcome whose process has gone shows as died at <stage>; its start time bounds how long it stays in Needs you when no ending is known. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as *.command.json, with child records named for their issue as *.issue-<n>.child.json. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome.

History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current <logs.dir>/<owner>/<repo>/commands/<kind>/ layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with --all. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before ADR 0011 is ignored.

For each repository and kind of Pass whose latest Activity log line is a skip, status also shows that Pass as idle with its reason, even when the skip is older than another kind's activity. A later start or end for that kind clears its idle entry. For example:

Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: https://github.com/acme/widgets/issues/7
@@ -160,6 +169,10 @@
     "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log",
     "dispatched": [],
     "state": "live",
+    "group": "working",
+    "needs_you_kind": null,
+    "landing": false,
+    "count": 1,
     "stage": {"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "age_seconds": 52},
     "parent_record": null,
     "parent_dispatch": null,
@@ -174,6 +187,7 @@
 }
 

Times are RFC 3339; ages are nonnegative seconds. model and effort are null when left to the Harness. issue is null for a Pass with no issue; session_log, issue_branch, pull_request, outcome, cause, ended_at, parent_record and parent_dispatch are nullable. Command kinds are Run, Spec run, Architect run, Pickup run, Security run and Base fix. name identifies an issue or a Pass's kind and stamp. dispatched is an array of the Runs or Spec runs a Pass dispatched, with the same fields as its parent (and an empty dispatched array); it shares the Pass's process identity and Command log. It retains its own outcome, end time, cause, Issue branch and pull request. The retained Command record has the same fields, including dispatched work, without the computed state, stage, source, command_log and children or empty leaf dispatched arrays. Older records without an end time use their last modification time. idle_passes contains one entry per repository and kind whose latest Activity log line is a skip; it is independent of commands, and never creates a Command record or log for a skipped Pass.

+

Each top-level commands entry adds the computed group (needs you, ready for review, landing, working, done, resolved or history), nullable needs_you_kind (silent, slow, died, failed, triage, finding), landing (also true for a Needs you item referenced from Landing), and count. A collapsed failure has a collapsed array containing the other Commands, each with its own group and kind. These fields are observations, absent from durable records and show; child Runs remain within their Command. The durable security_findings_waiting flag defaults to false for older records.

state is live, died, ended, or unknown when historical evidence has no ending. stage is null before the first known stage. Readers skip unknown stage names. Known stages are implement, Resume, Security review, pull request, CI watch, Repair, Self-merge, cleanup, Tickets, Spec review, Architecture review, Security audit, Security reproduction and Security fix publishing. A Pass retains its own stages while its dispatched work records its stages separately.

children recursively contains the immediate child Runs in separate processes, with the same fields as their parent. A Pickup's dispatched contains its Run or Spec run. A Spec's tickets is its complete roster in issue-number order; each entry has issue (number, url, nullable title), state (running, merged, failed, waiting, or not started) and blockers (the named open blocking issues, with the same issue fields). Match a Ticket's issue.url to the Run in that Spec's children for its stage, age, liveness and Base fix descendants. Tickets without a Run remain in the roster. Completed child Runs stay in the tree while its Command is listed. A killed child's state: "died" remains independent of the Spec's Ticket accounting. parent_record links each child to its parent's exact file; parent_dispatch, when present, names the dispatched issue's URL within that file. A top-level Command has null for both. Records from before these fields existed read as having no children or Tickets.

For example, this tree excerpt omits the common Run fields shown above:

@@ -418,6 +432,7 @@ waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting lines; default 5 minutes [inbox] +days = 7 # days to keep ended problems and Resolved; default 7 silent_minutes = 10 # minutes without growth in either log before a Run is Silent; default 10 [activity] @@ -483,6 +498,7 @@

pickup.limit sets the Claim limit: how many open issues labelled in-progress stop a Pickup run from taking another, by default 3. It must be a whole number from 1 up. There is no command-line flag for it.

logs.dir sets the root of the logs, with each repository's in its <owner>/<repo>/ folder, created if missing. It must be an absolute path, ~ or a path starting with ~/, where ~ stands for $HOME. A relative path stops the Run before any work, since the directory a Run is launched from is no base for a setting that holds for every Run.

logs.waiting_line_minutes sets how often CI watch and Self-merge repeat a waiting line even when nothing changes, in the terminal and the Command log. It defaults to 5 and must be a whole number of minutes from 1 up. CI watch still writes a line whenever the running or total check count changes; Self-merge writes waiting lines while GitHub calculates mergeability.

+

inbox.days defaults to 7 and must be a positive whole number of days. It bounds both ended problems in Needs you and older completions in Resolved. Done always covers one day. Ready for review stays visible while the locally recorded outcome is ready; status never checks whether its PR has since changed. Security runs retain whether findings were waiting at completion, without their private write-ups; older records lacking this fact do not infer it from the audit's outcome.

inbox.silent_minutes defaults to 10 and must be a whole number of minutes from 1 up. If logs.waiting_line_minutes is greater than or equal to it, status warns on stderr and waiting stages use half the silent time instead. A one-minute silent threshold therefore uses waiting lines every 30 seconds.

[harness] chooses the Harness, the agent CLI every session of a command runs on, and for each Harness the Model its sessions run on and their Effort, how hard that Model reasons. See Harness, Model and Effort.

A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as alway for always, or a value of the wrong type, such as anything but true or false for always, or 0 for spec.parallel or pickup.limit, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. email-test and setup read it the same way. update, version and help never read it, so a broken User config can't block them, and they never offer Setup; nor does email-test.

diff --git a/src/asks.rs b/src/asks.rs index 0193c5e6..9aa47a0a 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -246,6 +246,7 @@ mod tests { logs_dir: PathBuf::from("/home/me/.thirdshift/logs"), waiting_line_interval: std::time::Duration::from_secs(5 * 60), silent_interval: std::time::Duration::from_secs(10 * 60), + inbox_days: 7, quiet_skips: false, email: EmailSettings::default(), spec_parallel: n(3), diff --git a/src/config.rs b/src/config.rs index 6aee6e54..a535b79c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,8 @@ pub struct UserConfig { pub waiting_line_interval: Duration, /// `inbox.silent_minutes`: how long both logs may be quiet, default ten minutes. pub silent_interval: Duration, + /// `inbox.days`: retention of ended problems and Resolved, default seven days. + pub inbox_days: i64, /// `activity.quiet_skips`: a skipped Pass prints /// nothing, its Activity log line its only trace. pub quiet_skips: bool, @@ -107,6 +109,7 @@ impl UserConfig { logs_dir: home.join(".thirdshift/logs"), waiting_line_interval: Duration::from_secs(5 * 60), silent_interval: Duration::from_secs(10 * 60), + inbox_days: 7, quiet_skips: false, email: EmailSettings::default(), spec_parallel: NonZeroUsize::new(3).unwrap(), @@ -172,6 +175,14 @@ impl UserConfig { ("inbox", "silent_minutes", value) => { config.silent_interval = minutes(value, "inbox.silent_minutes", &file)?; } + ("inbox", "days", value) => { + config.inbox_days = value + .as_integer() + .filter(|days| *days >= 1 && TimeDelta::try_days(*days).is_some()) + .context(format!( + "inbox.days must be a positive whole number of days in {file}" + ))?; + } ("activity", "quiet_skips", Value::Boolean(quiet)) => { config.quiet_skips = *quiet } @@ -759,6 +770,7 @@ dir = "~/.thirdshift/logs" # the root of the logs, each repository's in , #[serde(default)] pub tickets: Vec, + /// Local knowledge at completion; status never refreshes findings from GitHub. + #[serde(default)] + pub security_findings_waiting: bool, } #[derive(Clone, Serialize, Deserialize)] @@ -180,6 +183,7 @@ impl CommandRecord { .err() .map(|cause| cause.full().into()); self.pull_request = account.pr_url.map(String::from); + self.security_findings_waiting = account.security_findings_waiting; } pub fn live(&self, outside: &mut impl super::effects::Outside) -> bool { @@ -235,6 +239,7 @@ impl CommandRecord { parent_record: None, parent_dispatch: None, tickets: Vec::new(), + security_findings_waiting: false, } } diff --git a/src/logs/reading.rs b/src/logs/reading.rs index afe9af8d..4e23478b 100644 --- a/src/logs/reading.rs +++ b/src/logs/reading.rs @@ -3,6 +3,7 @@ mod health; mod history; +mod inbox; mod selection; #[cfg(test)] mod tests; @@ -20,6 +21,7 @@ use super::effects::OnMachine; use super::{Pass, activity}; pub(super) use health::{Health, UsualRange}; pub(super) use history::Command as HistoricalCommand; +pub(super) use inbox::{Group, InboxItem}; pub(super) use selection::{Detailed, Observation}; pub(super) struct Snapshot { @@ -94,20 +96,8 @@ impl Snapshot { }) } - pub(super) fn list(&self, all: bool) -> Vec<&Entry> { - let mut commands: Vec<_> = self - .commands - .iter() - .filter(|entry| match entry { - Entry::Record(command) => { - all || command.record.outcome.is_none() - || recent(command.record.ended_at.as_deref(), self.now) - } - Entry::History(command) => all || recent(command.ended_at.as_deref(), self.now), - }) - .collect(); - commands.sort_by(|a, b| a.ordering(all).cmp(&b.ordering(all))); - commands + pub(super) fn inbox(&self, all: bool, days: i64) -> Vec> { + inbox::list(&self.commands, self.now, all, days) } pub(super) fn resolve(&self, input: &str) -> Result> { @@ -154,35 +144,6 @@ pub(super) enum Entry { History(Box), } -impl Entry { - fn ordering( - &self, - all: bool, - ) -> ( - bool, - std::cmp::Reverse>>, - &str, - ) { - let (state, started, repo) = match self { - Self::Record(command) => ( - command.state, - command.record.started_at.as_str(), - command.record.repository.as_str(), - ), - Self::History(command) => ( - command.state, - command.started_at.as_str(), - command.repository.as_str(), - ), - }; - ( - !all && state != "live", - std::cmp::Reverse(DateTime::parse_from_rfc3339(started).ok()), - repo, - ) - } -} - #[derive(Serialize)] pub(super) struct Command { #[serde(skip)] @@ -328,12 +289,6 @@ fn children( children } -fn recent(ended: Option<&str>, now: DateTime) -> bool { - ended - .and_then(|time| DateTime::parse_from_rfc3339(time).ok()) - .is_some_and(|ended| ended >= now - chrono::Duration::hours(24)) -} - fn read_idle_passes(repo: &Path) -> Result> { let path = repo.join(activity::FILE); let activity = match fs::read_to_string(&path) { diff --git a/src/logs/reading/inbox.rs b/src/logs/reading/inbox.rs new file mode 100644 index 00000000..005645d6 --- /dev/null +++ b/src/logs/reading/inbox.rs @@ -0,0 +1,301 @@ +//! The machine's Inbox, computed from local observations for every reader. + +use std::cmp::Reverse; +use std::collections::BTreeMap; + +use chrono::{DateTime, FixedOffset, Local}; +use serde::Serialize; + +use super::{Command, Entry, Health}; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "lowercase")] +pub(in crate::logs) enum Group { + #[serde(rename = "needs you")] + NeedsYou, + #[serde(rename = "ready for review")] + ReadyForReview, + Landing, + Working, + Done, + Resolved, + History, +} + +impl Group { + pub(in crate::logs) const ORDER: [Self; 7] = [ + Self::NeedsYou, + Self::ReadyForReview, + Self::Landing, + Self::Working, + Self::Done, + Self::Resolved, + Self::History, + ]; + + pub(in crate::logs) fn title(self) -> &'static str { + match self { + Self::NeedsYou => "Needs you", + Self::ReadyForReview => "Ready for review", + Self::Landing => "Landing", + Self::Working => "Working", + Self::Done => "Done", + Self::Resolved => "Resolved", + Self::History => "History", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "lowercase")] +pub(in crate::logs) enum NeedsYouKind { + Silent, + Slow, + Died, + Failed, + Triage, + Finding, +} + +impl NeedsYouKind { + pub(in crate::logs) fn title(self) -> &'static str { + match self { + Self::Silent => "Silent", + Self::Slow => "Slow", + Self::Died => "Died", + Self::Failed => "Failed", + Self::Triage => "Triage", + Self::Finding => "Finding", + } + } + + fn priority(self) -> u8 { + match self { + Self::Silent => 0, + Self::Slow => 1, + _ => 2, + } + } +} + +#[derive(Serialize)] +pub(in crate::logs) struct InboxItem<'a> { + #[serde(flatten)] + pub command: &'a Entry, + pub group: Group, + pub needs_you_kind: Option, + pub landing: bool, + pub count: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub collapsed: Vec>, +} + +struct Facts<'a> { + state: &'a str, + started_at: &'a str, + ended_at: Option<&'a str>, + outcome: Option<&'a str>, + cause: Option<&'a str>, + ready: bool, +} + +impl Entry { + fn facts(&self) -> Facts<'_> { + match self { + Entry::Record(command) => Facts { + state: command.state, + started_at: &command.record.started_at, + ended_at: command.record.ended_at.as_deref(), + outcome: command.record.outcome.as_deref(), + cause: command.record.cause.as_deref(), + ready: command.record.outcome.as_deref() == Some("ready for review") + && command.record.pull_request.is_some(), + }, + Entry::History(command) => Facts { + state: command.state, + started_at: &command.started_at, + ended_at: command.ended_at.as_deref(), + outcome: command.outcome.as_deref(), + cause: command + .outcome + .as_deref() + .and_then(|outcome| outcome.strip_prefix("failed: ")), + ready: command.outcome.as_deref().is_some_and(|outcome| { + outcome.contains("PR https://") && outcome.ends_with("is ready for review") + }), + }, + } + } +} + +pub(super) fn list( + entries: &[Entry], + now: DateTime, + all: bool, + days: i64, +) -> Vec> { + let cutoff = now - chrono::Duration::days(days); + let starts: Vec<_> = entries.iter().map(issue_starts).collect(); + let mut latest = BTreeMap::new(); + for starts in &starts { + for (issue, start) in starts { + latest + .entry(issue) + .and_modify(|time: &mut DateTime| *time = (*time).max(*start)) + .or_insert(*start); + } + } + let mut items = Vec::new(); + for (command, starts) in entries.iter().zip(&starts) { + let facts = command.facts(); + let ended = time(facts.ended_at); + let problem = if facts.state == "died" { + Some(NeedsYouKind::Died) + } else if matches!(command, Entry::Record(command) if facts.state == "ended" && super::super::stop::requested(&command.record_path).is_some_and(|request| !request.requeue)) + { + Some(NeedsYouKind::Triage) + } else if facts + .outcome + .is_some_and(|outcome| outcome.contains("failed") || outcome == "review required") + { + Some(NeedsYouKind::Failed) + } else if matches!(command, Entry::Record(command) if command.record.security_findings_waiting) + { + Some(NeedsYouKind::Finding) + } else { + None + }; + let live_problem = match command { + Entry::Record(command) if facts.state == "live" => live_problem(command), + _ => None, + }; + let landing = matches!(command, Entry::Record(command) if is_landing(command)); + let needs_you_kind = live_problem.or_else(|| { + problem.filter(|_| { + ended + .or_else(|| time(Some(facts.started_at))) + .is_some_and(|time| time >= cutoff) + && !starts.iter().any(|(issue, start)| { + latest.get(issue).is_some_and(|latest| latest > start) + }) + }) + }); + let group = if needs_you_kind.is_some() { + Group::NeedsYou + } else if facts.ready { + Group::ReadyForReview + } else if landing { + Group::Landing + } else if facts.state == "live" { + Group::Working + } else if ended.is_some_and(|time| time >= now - chrono::Duration::days(1)) { + Group::Done + } else if ended.is_some_and(|time| time >= cutoff) { + Group::Resolved + } else { + Group::History + }; + if all || group != Group::History { + items.push(InboxItem { + command, + group, + needs_you_kind, + landing, + count: 1, + collapsed: Vec::new(), + }); + } + } + items.sort_by_key(|item| { + let facts = item.command.facts(); + ( + item.group, + item.needs_you_kind.map(NeedsYouKind::priority), + Reverse(time(facts.ended_at.or(Some(facts.started_at)))), + ) + }); + let mut collapsed: Vec> = Vec::new(); + let mut failures = BTreeMap::new(); + for item in items { + if item.needs_you_kind == Some(NeedsYouKind::Failed) + && let Some(cause) = item.command.facts().cause.filter(|cause| !cause.is_empty()) + { + if let Some(&index) = failures.get(cause) { + let first: &mut InboxItem<'_> = &mut collapsed[index]; + first.count += 1; + first.collapsed.push(item); + continue; + } + failures.insert(cause, collapsed.len()); + } + collapsed.push(item); + } + collapsed +} + +fn live_problem(command: &Command) -> Option { + let own = match command.health { + Some(Health::Silent { .. }) => Some(NeedsYouKind::Silent), + Some(Health::Slow { .. }) => Some(NeedsYouKind::Slow), + _ => None, + }; + own.into_iter() + .chain( + command + .dispatched + .iter() + .chain(&command.children) + .filter_map(live_problem), + ) + .min() +} + +fn is_landing(command: &Command) -> bool { + (command.state == "live" + && matches!(command.record.kind.as_str(), "Run" | "Spec run") + && command + .stage + .as_ref() + .is_some_and(|stage| matches!(stage.name.as_str(), "CI watch" | "Self-merge"))) + || command + .dispatched + .iter() + .chain(&command.children) + .any(is_landing) +} + +fn issue_starts(entry: &Entry) -> BTreeMap<(String, u64), DateTime> { + let mut starts = BTreeMap::new(); + fn collect(command: &Command, starts: &mut BTreeMap<(String, u64), DateTime>) { + let record = &command.record; + if let Some(issue) = &record.issue + && let Some(start) = time(Some(&record.started_at)) + { + starts + .entry((record.repository.to_ascii_lowercase(), issue.number)) + .and_modify(|latest| *latest = (*latest).max(start)) + .or_insert(start); + } + for child in command.dispatched.iter().chain(&command.children) { + collect(child, starts); + } + } + match entry { + Entry::Record(command) => collect(command, &mut starts), + Entry::History(command) => { + if let Some(issue) = &command.issue + && let Some(start) = time(Some(&command.started_at)) + { + starts.insert( + (command.repository.to_ascii_lowercase(), issue.number), + start, + ); + } + } + } + starts +} + +fn time(value: Option<&str>) -> Option> { + value.and_then(|value| DateTime::parse_from_rfc3339(value).ok()) +} diff --git a/src/logs/reading/tests.rs b/src/logs/reading/tests.rs index 4305584d..3b243aeb 100644 --- a/src/logs/reading/tests.rs +++ b/src/logs/reading/tests.rs @@ -42,7 +42,7 @@ fn direct_run_selection_captures_its_owner_and_reloads_retained_work() { &record(7), ); let snapshot = Snapshot::load(dir.path(), now()).unwrap(); - assert_eq!(snapshot.list(true).len(), 1); + assert_eq!(snapshot.inbox(true, 7).len(), 1); let selected = snapshot.resolve("WIDGETS#7").unwrap(); assert_eq!(selected.owner.unwrap().record_path, path); assert!(selected.child_owner.is_none()); @@ -244,8 +244,8 @@ fn historical_selection_keeps_unknown_facts_and_has_no_process_or_stop_authority let session = folder.join("7-20261019T110000-0400~history-implement.jsonl"); fs::write(&session, "not interpreted as a live event").unwrap(); let snapshot = Snapshot::load(dir.path(), now()).unwrap(); - assert!(snapshot.list(false).is_empty()); - assert_eq!(snapshot.list(true).len(), 1); + assert!(snapshot.inbox(false, 7).is_empty()); + assert_eq!(snapshot.inbox(true, 7).len(), 1); let selected = snapshot.resolve("widgets#7").unwrap(); assert!(selected.owner.is_none()); assert!(selected.child_owner.is_none()); diff --git a/src/logs/status.rs b/src/logs/status.rs index ac4da53c..74eeb02f 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -7,7 +7,7 @@ use chrono::Local; use serde::Serialize; use super::command_record::CommandRecord; -use super::reading::{Command, Entry, IdlePass, Snapshot}; +use super::reading::{Command, Entry, Group, IdlePass, InboxItem, Snapshot}; use crate::config::UserConfig; use crate::issue::IssueUrl; @@ -15,7 +15,7 @@ use crate::issue::IssueUrl; struct Status<'a> { version: u32, hostname: String, - commands: Vec<&'a Entry>, + commands: Vec>, idle_passes: &'a [IdlePass], } @@ -32,7 +32,8 @@ fn print_command(command: &Command, indent: &str) { None => format!("{} on {}", facts.name, facts.repository), }; println!( - "{indent}{} {subject} · {} · {} · {} · {stage}", + "{indent}{} · {} {subject} · {} · {} · {} · {stage}", + command.state, facts.kind, facts.harness, facts.model.as_deref().unwrap_or("default Model"), @@ -77,17 +78,61 @@ pub fn show(json: bool, all: bool) -> Result<()> { let status = Status { version: 1, hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), - commands: snapshot.list(all), + commands: snapshot.inbox(all, config.inbox_days), idle_passes: &snapshot.idle_passes, }; if json { println!("{}", serde_json::to_string_pretty(&status)?); } else { println!("{}", status.hostname); - for entry in status.commands { - match entry { - Entry::Record(command) => print_command(command, ""), - Entry::History(command) => print_history(command), + for group in Group::ORDER { + let items: Vec<_> = status + .commands + .iter() + .filter(|item| item.group == group) + .collect(); + let references: Vec<_> = status + .commands + .iter() + .filter(|item| { + group == Group::Landing && item.landing && item.group == Group::NeedsYou + }) + .collect(); + if items.is_empty() && references.is_empty() { + continue; + } + println!("{}:", group.title()); + for item in items { + match item.command { + Entry::Record(command) => print_command(command, ""), + Entry::History(command) => print_history(command), + } + if item.count > 1 { + let cause = match item.command { + Entry::Record(command) => { + command.record.cause.as_deref().unwrap_or_default() + } + Entry::History(command) => command + .outcome + .as_deref() + .unwrap_or_default() + .trim_start_matches("failed: "), + }; + println!(" {} Commands with this cause: {cause}", item.count); + } + } + for item in references { + if let Entry::Record(command) = item.command { + let facts = &command.record; + let name = facts.issue.as_ref().map_or_else( + || facts.name.clone(), + |issue| format!("{}#{}", facts.repository, issue.number), + ); + println!( + "{name} · {} · see Needs you", + item.needs_you_kind.expect("Needs you has a kind").title() + ); + } } } for pass in status.idle_passes { diff --git a/src/notification.rs b/src/notification.rs index 5d776116..f009df21 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -377,6 +377,7 @@ mod tests { ticket_lines: &[], review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, @@ -398,6 +399,7 @@ mod tests { ticket_lines: &[], review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, diff --git a/src/run_ending.rs b/src/run_ending.rs index 147072a3..a4dba4d0 100644 --- a/src/run_ending.rs +++ b/src/run_ending.rs @@ -54,6 +54,7 @@ pub fn read(ending: &Ending) -> Result, &Skip> { ticket_lines: &[], review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, @@ -81,6 +82,7 @@ pub fn read(ending: &Ending) -> Result, &Skip> { }, failed_reproduction: audited.failed_reproduction, security_findings: Some(&audited.findings), + security_findings_waiting: audited.findings_waiting, advice: &audited.advice, ..account }) @@ -132,6 +134,7 @@ pub struct Account<'a> { pub pre_audit_failure: Option<&'static str>, /// Safe metadata from a Security run's private records, even if the audit failed. pub security_findings: Option<&'a [crate::security::RecordedFinding]>, + pub security_findings_waiting: bool, /// The URLs on stdout, a line each: the pull request's, or that of the /// issue an Architect run's review ended on. pub urls: Vec<&'a str>, @@ -202,6 +205,7 @@ impl<'a> Account<'a> { ticket_lines: &reached.ticket_lines, review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, @@ -229,6 +233,7 @@ impl<'a> Account<'a> { ticket_lines: &failed.ticket_lines, review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: failed.recovery.as_deref(), @@ -260,6 +265,7 @@ impl<'a> Account<'a> { ticket_lines: &[], review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, @@ -552,6 +558,7 @@ mod tests { ticket_lines: &[], review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, @@ -573,6 +580,7 @@ mod tests { ticket_lines: &[], review: None, security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, @@ -756,6 +764,7 @@ mod tests { dispatched: None, }), security_findings: None, + security_findings_waiting: false, failed_reproduction: None, pre_audit_failure: None, recovery: None, diff --git a/src/security.rs b/src/security.rs index 2e95496e..b2eb5f99 100644 --- a/src/security.rs +++ b/src/security.rs @@ -51,6 +51,7 @@ pub struct Ended { /// A failed reproduction's number; its audit and private recording completed. pub failed_reproduction: Option, pub findings: Vec, + pub findings_waiting: bool, pub advice: Vec, } @@ -61,6 +62,7 @@ impl From for Ended { audit_started: false, failed_reproduction: None, findings: Vec::new(), + findings_waiting: false, advice: Vec::new(), } } @@ -291,6 +293,7 @@ fn audit_and_record( ) -> Ended { let mut advice = Vec::new(); let mut findings = Vec::new(); + let mut findings_waiting = false; let mut log = None; let mut reproduction = None; let mut audit_started = false; @@ -323,6 +326,7 @@ fn audit_and_record( recorded.existing += 1; } let record = resolved.record; + findings_waiting |= record.untriaged(); let metadata = record.metadata().clone(); let url = metadata.url.clone(); findings.push(metadata); @@ -388,6 +392,7 @@ fn audit_and_record( audit_started, failed_reproduction, findings, + findings_waiting, advice, } } diff --git a/src/setup.rs b/src/setup.rs index 05ed8768..c95dd3b2 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1127,6 +1127,7 @@ dir = \"/var/log/thirdshift\" waiting_line_minutes = 5 [inbox] +days = 7 silent_minutes = 10 [activity] diff --git a/tests/security_run.rs b/tests/security_run.rs index 6bea2a02..0c050d15 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -1957,6 +1957,11 @@ fn records_a_finding_privately_as_a_draft_without_severity_or_versions() { scenario.agent_does(&audit_script(&json!([finding("input-size")]).to_string())); let result = scenario.run(&["secure"]); assert_eq!(result.code, Some(0), "{}", result.stderr); + let status = scenario.run(&["status", "--json"]); + assert_eq!(status.code, Some(0), "{}", status.stderr); + let status: Value = serde_json::from_str(&status.stdout).unwrap(); + assert_eq!(status["commands"][0]["group"], "needs you"); + assert_eq!(status["commands"][0]["needs_you_kind"], "finding"); let state = scenario.gh_state(); let advisory = &state["advisories"][0]; assert_eq!(advisory["state"], "draft"); @@ -2998,6 +3003,11 @@ fn a_completed_security_audit_sends_one_notification_when_asked() { let result = scenario.run_with_env(&["secure", "email", "me@example.com"], &resend_env(&resend)); assert_eq!(result.code, Some(0), "{}", result.stderr); + let status = scenario.run(&["status", "--json"]); + assert_eq!(status.code, Some(0), "{}", status.stderr); + let status: Value = serde_json::from_str(&status.stdout).unwrap(); + assert_eq!(status["commands"][0]["group"], "done"); + assert_eq!(status["commands"][0]["needs_you_kind"], Value::Null); let (subject, text) = the_one_notification(&resend); assert_eq!( subject, diff --git a/tests/status.rs b/tests/status.rs index 0fb21886..2444e76a 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -81,7 +81,7 @@ fn status_shows_each_idle_pass_until_its_own_kind_starts_work() { let dispatched = text .stdout .lines() - .find(|line| line.starts_with(" Run acme/widgets#7")) + .find(|line| line.starts_with(" ended · Run acme/widgets#7")) .expect(&text.stdout); assert!(dispatched.ends_with("ready for review"), "{dispatched}"); } @@ -234,6 +234,341 @@ fn write_record(scenario: &Scenario, number: u32, started: &str, ended: Option<& .unwrap(); } +fn change_record(scenario: &Scenario, number: u32, change: impl FnOnce(&mut Value)) { + let path = scenario + .path(LOGS) + .join(format!("{number}-fixture.command.json")); + let mut record = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + change(&mut record); + fs::write(path, serde_json::to_vec(&record).unwrap()).unwrap(); +} + +#[test] +fn inbox_groups_ended_commands_and_adds_only_old_history_with_all() { + let scenario = Scenario::new(); + let now = chrono::Local::now(); + for (number, days, outcome) in [ + (7, 0, "failed"), + (8, 0, "ready for review"), + (9, 0, "merged"), + (10, 2, "merged"), + (11, 9, "failed"), + ] { + let time = (now - chrono::Duration::days(days)).to_rfc3339(); + write_record(&scenario, number, &time, Some(&time)); + change_record(&scenario, number, |record| { + record["outcome"] = outcome.into(); + if number == 8 { + record["pull_request"] = "https://github.com/acme/widgets/pull/8".into(); + } + }); + } + for (flags, expected) in [ + ( + vec!["status", "--json"], + vec![ + (7, "needs you"), + (8, "ready for review"), + (9, "done"), + (10, "resolved"), + ], + ), + ( + vec!["status", "--all", "--json"], + vec![ + (7, "needs you"), + (8, "ready for review"), + (9, "done"), + (10, "resolved"), + (11, "history"), + ], + ), + ] { + let output = scenario.run(&flags); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + let rows: Vec<_> = status["commands"] + .as_array() + .unwrap() + .iter() + .map(|row| { + ( + row["issue"]["number"].as_u64().unwrap(), + row["group"].as_str().unwrap_or("missing"), + ) + }) + .collect(); + assert_eq!(rows, expected, "{status}"); + assert_eq!(status["commands"][0]["needs_you_kind"], "failed"); + } + let text = scenario.run(&["status", "--all"]); + let headings: Vec<_> = text + .stdout + .lines() + .filter(|line| line.ends_with(':')) + .collect(); + assert_eq!( + headings, + [ + "Needs you:", + "Ready for review:", + "Done:", + "Resolved:", + "History:" + ] + ); +} + +#[test] +fn inbox_clears_ended_problems_on_a_later_local_start_or_the_configured_age() { + let scenario = Scenario::new(); + let now = chrono::Local::now(); + for (number, days) in [(7, 2), (8, 1), (9, 8), (10, 6), (11, 6), (12, 0)] { + let time = (now - chrono::Duration::days(days) - chrono::Duration::hours(1)).to_rfc3339(); + write_record( + &scenario, + number, + &time, + (number != 9).then_some(time.as_str()), + ); + change_record(&scenario, number, |record| { + if [7, 10, 12].contains(&number) { + record["outcome"] = "failed".into(); + } + if number == 8 { + record["issue"] = serde_json::json!({"number": 7, "url": scenario.issue_url(7), "title": "Retry"}); + record["outcome"] = "merged".into(); + } + }); + } + let calls = scenario.gh_calls(); + let output = scenario.run(&["status", "--json"]); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + let commands = status["commands"].as_array().unwrap(); + let problems: Vec<_> = commands + .iter() + .filter(|row| row["group"] == "needs you") + .map(|row| row["issue"]["number"].as_u64().unwrap()) + .collect(); + assert_eq!(problems, [12, 10]); + assert!(!commands.iter().any(|row| row["issue"]["number"] == 9)); + assert!(commands.iter().any(|row| row["issue"]["title"] == "Fixture" + && row["issue"]["number"] == 7 + && row["group"] == "resolved")); + scenario.user_config_is("[inbox]\ndays = 1\n"); + let output = scenario.run(&["status", "--json"]); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + let commands = status["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "{status}"); + assert_eq!(commands[0]["issue"]["number"], 12); + assert_eq!(commands[0]["group"], "needs you"); + assert_eq!( + scenario.gh_calls(), + calls, + "status must use only this machine's files" + ); +} + +#[test] +fn inbox_puts_live_problems_first_and_points_to_silent_landing_without_duplicating_it() { + let scenario = Scenario::new(); + scenario.issue_titled(99, "Held fixture"); + scenario.issue_is(99, "OPEN"); + scenario.agent_does(&format!( + "touch '{}'\nwhile ! test -f '{}'; do sleep 0.02; done\nexit 1\n", + scenario.path("started").display(), + scenario.path("release").display() + )); + let held = scenario.run_until(&[&scenario.issue_url(99)], &[], "started"); + let live = scenario.command_record(LOGS); + let original = fs::read_dir(scenario.path(LOGS)) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.to_string_lossy().ends_with(".command.json")) + .unwrap(); + fs::remove_file(original).unwrap(); + let now = chrono::Local::now(); + for (number, age, stage) in [ + (7, 3600, "CI watch"), + (8, 120, "implement"), + (9, 1, "Self-merge"), + (10, 1, "implement"), + ] { + let began = (now - chrono::Duration::seconds(age)).to_rfc3339(); + write_record(&scenario, number, &began, None); + change_record(&scenario, number, |record| { + record["process_id"] = live["process_id"].clone(); + record["process_start"] = live["process_start"].clone(); + record["stages"] = + serde_json::json!([{"name": stage, "began_at": began, "session_log": null}]); + }); + } + for number in 20..23 { + let ended = (now - chrono::Duration::hours(2)).to_rfc3339(); + let began = (now - chrono::Duration::hours(2) - chrono::Duration::minutes(1)).to_rfc3339(); + write_record(&scenario, number, &began, Some(&ended)); + change_record(&scenario, number, |record| { + record["outcome"] = "merged".into(); + record["stages"] = + serde_json::json!([{"name": "implement", "began_at": began, "session_log": null}]); + }); + } + let recent = now.to_rfc3339(); + write_record(&scenario, 11, &recent, None); + write_record(&scenario, 12, &recent, Some(&recent)); + change_record(&scenario, 12, |record| record["outcome"] = "failed".into()); + let output = scenario.run(&["status", "--json"]); + let text = scenario.run(&["status"]); + fs::write(scenario.path("release"), "").unwrap(); + held.finish(); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + let commands = status["commands"].as_array().unwrap(); + let first: Vec<_> = commands + .iter() + .take(6) + .map(|row| { + ( + row["issue"]["number"].as_u64().unwrap(), + row["group"].as_str().unwrap(), + row["needs_you_kind"].as_str(), + ) + }) + .collect(); + assert_eq!( + first, + [ + (7, "needs you", Some("silent")), + (8, "needs you", Some("slow")), + (11, "needs you", Some("died")), + (12, "needs you", Some("failed")), + (9, "landing", None), + (10, "working", None), + ] + ); + assert_eq!(commands[0]["landing"], true); + assert_eq!( + commands + .iter() + .filter(|row| row["issue"]["number"] == 7) + .count(), + 1 + ); + assert!( + text.stdout + .contains("acme/widgets#7 · Silent · see Needs you"), + "{}", + text.stdout + ); + assert_eq!( + text.stdout.matches("Run acme/widgets#7 Fixture").count(), + 1, + "{}", + text.stdout + ); + assert!( + text.stdout.contains("live · Run acme/widgets#10"), + "{}", + text.stdout + ); +} + +#[test] +fn inbox_distinguishes_triage_stops_and_waiting_findings_from_completed_work() { + let scenario = Scenario::new(); + let time = chrono::Local::now().to_rfc3339(); + for number in 7..12 { + write_record(&scenario, number, &time, Some(&time)); + change_record(&scenario, number, |record| { + record["outcome"] = if number < 9 { + "interrupted" + } else { + "findings recorded" + } + .into(); + record["security_findings_waiting"] = (number == 9).into(); + if number >= 9 { + record["kind"] = "Security run".into(); + } + }); + if number < 9 { + let path = scenario + .path(LOGS) + .join(format!("{number}-fixture.command.stop.json")); + fs::write( + path, + serde_json::to_vec(&serde_json::json!({"who": "jacob", "requeue": number == 8})) + .unwrap(), + ) + .unwrap(); + } + } + let output = scenario.run(&["status", "--json"]); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + let commands = status["commands"].as_array().unwrap(); + for (number, group, kind) in [ + (7, "needs you", Some("triage")), + (8, "done", None), + (9, "needs you", Some("finding")), + (10, "done", None), + (11, "done", None), + ] { + let row = commands + .iter() + .find(|row| row["issue"]["number"] == number) + .unwrap(); + assert_eq!(row["group"], group, "{row}"); + assert_eq!(row["needs_you_kind"].as_str(), kind, "{row}"); + } +} + +#[test] +fn inbox_collapses_only_failures_with_the_same_known_cause_and_keeps_their_commands() { + let scenario = Scenario::new(); + let now = chrono::Local::now(); + for (number, cause) in [ + (7, Some("base check failed")), + (8, Some("base check failed")), + (9, Some("agent failed")), + (10, None), + (11, None), + ] { + let time = (now - chrono::Duration::minutes(i64::from(number))).to_rfc3339(); + write_record(&scenario, number, &time, Some(&time)); + change_record(&scenario, number, |record| { + record["outcome"] = "failed".into(); + record["cause"] = cause.into(); + }); + } + let output = scenario.run(&["status", "--json"]); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + let commands = status["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 4, "{status}"); + assert_eq!(commands[0]["issue"]["number"], 7); + assert_eq!(commands[0]["count"], 2); + assert_eq!(commands[0]["collapsed"][0]["issue"]["number"], 8); + assert_eq!(commands[0]["collapsed"][0]["group"], "needs you"); + for row in &commands[1..] { + assert_eq!(row["count"], 1); + } + let text = scenario.run(&["status"]); + assert!( + text.stdout + .contains("2 Commands with this cause: base check failed"), + "{}", + text.stdout + ); + assert!( + !text.stdout.contains("Run acme/widgets#8 Fixture"), + "{}", + text.stdout + ); +} + #[test] fn tokenless_sessions_require_explicit_evidence_to_belong_to_an_owned_command() { for evidence in ["none", "record", "models"] { @@ -290,20 +625,20 @@ fn tokenless_sessions_require_explicit_evidence_to_belong_to_an_owned_command() } #[test] -fn status_shows_recent_outcomes_and_all_history_newest_first() { +fn status_shows_recent_outcomes_before_resolved_history() { let scenario = Scenario::new(); let now = chrono::Local::now(); let recent = (now - chrono::Duration::hours(1)).to_rfc3339(); let old = (now - chrono::Duration::days(2)).to_rfc3339(); - // The recent completion began before the old completion: filter by end, - // and order history by start, rather than by directory or end time. + // The recent completion began before the old one: group by completion, + // so Done precedes Resolved even when it started earlier. let earliest = (now - chrono::Duration::days(3)).to_rfc3339(); write_record(&scenario, 7, &earliest, Some(&recent)); write_record(&scenario, 8, &old, Some(&old)); let result = scenario.run(&["status", "--json"]); assert_eq!(result.code, Some(0), "{}", result.stderr); let status: Value = serde_json::from_str(&result.stdout).unwrap(); - assert_eq!(status["commands"].as_array().unwrap().len(), 1); + assert_eq!(status["commands"].as_array().unwrap().len(), 2); assert_eq!(status["commands"][0]["issue"]["number"], 7); assert_eq!(status["commands"][0]["outcome"], "ready for review"); let text = scenario.run(&["status"]); @@ -318,7 +653,7 @@ fn status_shows_recent_outcomes_and_all_history_newest_first() { .iter() .map(|c| c["issue"]["number"].as_u64().unwrap()) .collect(); - assert_eq!(issues, [8, 7]); + assert_eq!(issues, [7, 8]); } } @@ -357,7 +692,7 @@ fn status_keeps_completed_ticket_trees_with_recent_and_all_history() { .unwrap(); } for (flags, expected) in [ - (vec!["status", "--json"], 1), + (vec!["status", "--json"], 2), (vec!["status", "--all", "--json"], 2), ] { let result = scenario.run(&flags); @@ -384,7 +719,7 @@ fn status_keeps_completed_ticket_trees_with_recent_and_all_history() { text.stdout ); assert!( - text.stdout.contains(" Run acme/widgets#21 Ticket"), + text.stdout.contains(" ended · Run acme/widgets#21 Ticket"), "{}", text.stdout ); @@ -543,13 +878,14 @@ fn status_reads_activity_history_and_command_logs_without_stages() { let status: Value = serde_json::from_str(&result.stdout).unwrap(); let commands = status["commands"].as_array().unwrap(); assert_eq!(commands.len(), 3); - assert_eq!(commands[0]["kind"], "Security run"); - assert_eq!(commands[0]["issue"], Value::Null); - assert_eq!(commands[0]["outcome"], "findings recorded"); - assert_eq!(commands[1]["started_at"], "2026-10-02T12:00:00-04:00"); - assert_eq!(commands[1]["ended_at"], "2026-10-02T13:00:00-04:00"); + assert_eq!(commands[0]["group"], "ready for review"); + assert_eq!(commands[1]["kind"], "Security run"); + assert_eq!(commands[1]["issue"], Value::Null); + assert_eq!(commands[1]["outcome"], "findings recorded"); + assert_eq!(commands[0]["started_at"], "2026-10-02T12:00:00-04:00"); + assert_eq!(commands[0]["ended_at"], "2026-10-02T13:00:00-04:00"); assert_eq!( - commands[1]["outcome"], + commands[0]["outcome"], "PR https://github.com/acme/widgets/pull/2 is ready for review" ); assert_eq!(commands[2]["ended_at"], "2026-10-03T12:00:00-04:00"); From b375b7ddc6ed97599e2859fc8c3d9495ead9224b Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 21:06:07 +0000 Subject: [PATCH 07/47] Address Inbox review findings and retain regressions --- src/logs/reading/inbox.rs | 14 +++++++++++--- src/logs/status.rs | 16 +++++----------- tests/status.rs | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/logs/reading/inbox.rs b/src/logs/reading/inbox.rs index 005645d6..ed9f05ef 100644 --- a/src/logs/reading/inbox.rs +++ b/src/logs/reading/inbox.rs @@ -90,6 +90,12 @@ pub(in crate::logs) struct InboxItem<'a> { pub collapsed: Vec>, } +impl InboxItem<'_> { + pub(in crate::logs) fn cause(&self) -> Option<&str> { + self.command.facts().cause + } +} + struct Facts<'a> { state: &'a str, started_at: &'a str, @@ -134,7 +140,9 @@ pub(super) fn list( all: bool, days: i64, ) -> Vec> { - let cutoff = now - chrono::Duration::days(days); + // An accepted duration can exceed chrono's calendar. In that case every + // representable timestamp is inside the requested retention period. + let cutoff = now.checked_sub_signed(chrono::Duration::days(days)); let starts: Vec<_> = entries.iter().map(issue_starts).collect(); let mut latest = BTreeMap::new(); for starts in &starts { @@ -174,7 +182,7 @@ pub(super) fn list( problem.filter(|_| { ended .or_else(|| time(Some(facts.started_at))) - .is_some_and(|time| time >= cutoff) + .is_some_and(|time| cutoff.is_none_or(|cutoff| time >= cutoff)) && !starts.iter().any(|(issue, start)| { latest.get(issue).is_some_and(|latest| latest > start) }) @@ -190,7 +198,7 @@ pub(super) fn list( Group::Working } else if ended.is_some_and(|time| time >= now - chrono::Duration::days(1)) { Group::Done - } else if ended.is_some_and(|time| time >= cutoff) { + } else if ended.is_some_and(|time| cutoff.is_none_or(|cutoff| time >= cutoff)) { Group::Resolved } else { Group::History diff --git a/src/logs/status.rs b/src/logs/status.rs index 74eeb02f..422b780a 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -22,7 +22,7 @@ struct Status<'a> { fn print_command(command: &Command, indent: &str) { let facts = &command.record; let stage = stage_text(command); - let subject = match &facts.issue { + let mut subject = match &facts.issue { Some(issue) => format!( "{}#{} {}", facts.repository, @@ -31,6 +31,9 @@ fn print_command(command: &Command, indent: &str) { ), None => format!("{} on {}", facts.name, facts.repository), }; + if let Some(pull_request) = &facts.pull_request { + subject.push_str(&format!(" · {pull_request}")); + } println!( "{indent}{} · {} {subject} · {} · {} · {} · {stage}", command.state, @@ -108,16 +111,7 @@ pub fn show(json: bool, all: bool) -> Result<()> { Entry::History(command) => print_history(command), } if item.count > 1 { - let cause = match item.command { - Entry::Record(command) => { - command.record.cause.as_deref().unwrap_or_default() - } - Entry::History(command) => command - .outcome - .as_deref() - .unwrap_or_default() - .trim_start_matches("failed: "), - }; + let cause = item.cause().unwrap_or_default(); println!(" {} Commands with this cause: {cause}", item.count); } } diff --git a/tests/status.rs b/tests/status.rs index 2444e76a..b77ed4ed 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -302,6 +302,12 @@ fn inbox_groups_ended_commands_and_adds_only_old_history_with_all() { assert_eq!(status["commands"][0]["needs_you_kind"], "failed"); } let text = scenario.run(&["status", "--all"]); + assert!( + text.stdout + .contains("https://github.com/acme/widgets/pull/8"), + "{}", + text.stdout + ); let headings: Vec<_> = text .stdout .lines() @@ -569,6 +575,16 @@ fn inbox_collapses_only_failures_with_the_same_known_cause_and_keeps_their_comma ); } +#[test] +fn inbox_accepts_a_retention_period_beyond_the_representable_calendar() { + let scenario = Scenario::new(); + scenario.user_config_is("[inbox]\ndays = 1000000000\n"); + let output = scenario.run(&["status", "--json"]); + assert_eq!(output.code, Some(0), "{}", output.stderr); + let status: Value = serde_json::from_str(&output.stdout).unwrap(); + assert_eq!(status["commands"], serde_json::json!([])); +} + #[test] fn tokenless_sessions_require_explicit_evidence_to_belong_to_an_owned_command() { for evidence in ["none", "record", "models"] { From f9f748266981380a6eeefbe6f38cb8663157767e Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 21:11:32 +0000 Subject: [PATCH 08/47] Preserve nested status row prefixes with explicit state words --- src/logs/status.rs | 4 ++-- tests/status.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/logs/status.rs b/src/logs/status.rs index 422b780a..289bfffc 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -35,9 +35,9 @@ fn print_command(command: &Command, indent: &str) { subject.push_str(&format!(" · {pull_request}")); } println!( - "{indent}{} · {} {subject} · {} · {} · {} · {stage}", - command.state, + "{indent}{} {subject} · {} · {} · {} · {} · {stage}", facts.kind, + command.state, facts.harness, facts.model.as_deref().unwrap_or("default Model"), facts.effort.as_deref().unwrap_or("default Effort") diff --git a/tests/status.rs b/tests/status.rs index b77ed4ed..9b96880d 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -81,7 +81,7 @@ fn status_shows_each_idle_pass_until_its_own_kind_starts_work() { let dispatched = text .stdout .lines() - .find(|line| line.starts_with(" ended · Run acme/widgets#7")) + .find(|line| line.starts_with(" Run acme/widgets#7")) .expect(&text.stdout); assert!(dispatched.ends_with("ready for review"), "{dispatched}"); } @@ -475,7 +475,7 @@ fn inbox_puts_live_problems_first_and_points_to_silent_landing_without_duplicati text.stdout ); assert!( - text.stdout.contains("live · Run acme/widgets#10"), + text.stdout.contains("Run acme/widgets#10 Fixture · live ·"), "{}", text.stdout ); @@ -735,7 +735,7 @@ fn status_keeps_completed_ticket_trees_with_recent_and_all_history() { text.stdout ); assert!( - text.stdout.contains(" ended · Run acme/widgets#21 Ticket"), + text.stdout.contains(" Run acme/widgets#21 Ticket"), "{}", text.stdout ); From 4fe172630f80d527a245a2a1c517804bdc838fbf Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 22:16:26 +0000 Subject: [PATCH 09/47] Serve the local Console Inbox with embedded Focus assets (#817) --- .github/workflows/ci.yml | 20 ++ .gitignore | 1 + Cargo.lock | 130 ++++++++ Cargo.toml | 5 +- README.md | 1 + askama.toml | 2 + console/README.md | 32 ++ console/package-lock.json | 41 +++ console/package.json | 5 + console/tests/inbox.test.cjs | 96 ++++++ src/args.rs | 10 + src/asks.rs | 1 + src/config.rs | 24 ++ src/config/console.rs | 41 +++ src/console.rs | 165 ++++++++++ src/console/assets/README.md | 20 ++ src/console/assets/console.css | 70 ++++ src/console/assets/console.js | 12 + .../assets/fonts/ibm-plex-mono-OFL.txt | 93 ++++++ .../assets/fonts/ibm-plex-mono-latin.woff2 | Bin 0 -> 14708 bytes .../assets/fonts/ibm-plex-sans-OFL.txt | 93 ++++++ .../assets/fonts/ibm-plex-sans-latin.woff2 | Bin 0 -> 45712 bytes src/console/assets/vendor/htmx-2.0.7.min.js | 1 + src/console/assets/vendor/htmx-LICENSE | 13 + .../assets/vendor/htmx-ext-sse-2.2.3.js | 290 +++++++++++++++++ .../assets/vendor/idiomorph-0.7.4.min.js | 1 + src/console/assets/vendor/idiomorph-LICENSE | 13 + src/console/assets/vendor/sse-LICENSE | 14 + src/console/inbox.html | 34 ++ src/console/restart.rs | 66 ++++ src/console/row.html | 9 + src/logs/status.rs | 56 ++-- src/logs/status/inbox.rs | 178 +++++++++++ src/main.rs | 8 + tests/console.rs | 299 ++++++++++++++++++ tests/packaging.rs | 10 + tests/setup.rs | 10 + 37 files changed, 1838 insertions(+), 26 deletions(-) create mode 100644 askama.toml create mode 100644 console/README.md create mode 100644 console/package-lock.json create mode 100644 console/package.json create mode 100644 console/tests/inbox.test.cjs create mode 100644 src/config/console.rs create mode 100644 src/console.rs create mode 100644 src/console/assets/README.md create mode 100644 src/console/assets/console.css create mode 100644 src/console/assets/console.js create mode 100644 src/console/assets/fonts/ibm-plex-mono-OFL.txt create mode 100644 src/console/assets/fonts/ibm-plex-mono-latin.woff2 create mode 100644 src/console/assets/fonts/ibm-plex-sans-OFL.txt create mode 100644 src/console/assets/fonts/ibm-plex-sans-latin.woff2 create mode 100644 src/console/assets/vendor/htmx-2.0.7.min.js create mode 100644 src/console/assets/vendor/htmx-LICENSE create mode 100644 src/console/assets/vendor/htmx-ext-sse-2.2.3.js create mode 100644 src/console/assets/vendor/idiomorph-0.7.4.min.js create mode 100644 src/console/assets/vendor/idiomorph-LICENSE create mode 100644 src/console/assets/vendor/sse-LICENSE create mode 100644 src/console/inbox.html create mode 100644 src/console/restart.rs create mode 100644 src/console/row.html create mode 100644 src/logs/status/inbox.rs create mode 100644 tests/console.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82e3be60..ac9ab5c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,26 @@ concurrency: # The runner images are pinned to the platforms thirdshift supports. jobs: + console-test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Install the pinned Rust toolchain + run: rustup toolchain install + - uses: Swatinem/rust-cache@v2 + - run: cargo build --locked + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: console/package-lock.json + - run: npm ci + working-directory: console + - run: npx playwright install --with-deps chromium + working-directory: console + - run: npm test + working-directory: console + site-test: runs-on: ubuntu-24.04 defaults: diff --git a/.gitignore b/.gitignore index dc284e11..ad9ef32a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target /site/node_modules/ +/console/node_modules/ .obsidian/plugins/obsidian-quiet-outline/markdown-states.json .obsidian/user/Jacob-MacBook-Pro/workspace.json .obsidian/user/Jacob-MacBook-Pro/plugins/remote-ssh/data.json diff --git a/Cargo.lock b/Cargo.lock index 95b83d2f..4333a8e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,59 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.6", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -113,12 +166,67 @@ dependencies = [ "url", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -384,6 +492,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.17.1" @@ -441,6 +555,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.1" @@ -454,6 +574,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -796,6 +917,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" @@ -1470,7 +1597,9 @@ name = "thirdshift" version = "0.12.2" dependencies = [ "anyhow", + "askama", "axoupdater", + "axum", "chrono", "include_dir", "libc", @@ -1479,6 +1608,7 @@ dependencies = [ "serde_json", "signal-hook", "tempfile", + "tokio", "toml", "toml_edit", ] diff --git a/Cargo.toml b/Cargo.toml index c082f266..7873460a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,12 @@ repository = "https://github.com/JacobStephens2/thirdshift" homepage = "https://thirdshift.app" # The crate carries the sources and the Factory skills the binary embeds; # the repository's agent tooling, docs and tests stay out of it. -include = ["/src", "/skills", "/README.md", "/LICENSE"] +include = ["/src", "/skills", "/askama.toml", "/README.md", "/LICENSE"] [dependencies] anyhow = "1.0.104" +askama = "0.16.1" +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } # axoupdater needs axoasset's `remote` feature, which always pulls in `image` # (with moxcms and pxfm) and turns on reqwest's `default-tls`, which is rustls # with aws-lc. No flag turns either off, and features unify, so switching @@ -29,6 +31,7 @@ signal-hook = "0.4.4" tempfile = "3.27.0" toml = "1.1.6" toml_edit = "0.25.15" +tokio = { version = "1.53.1", features = ["rt", "net", "time"] } # Line tables keep file and line in the crate's backtraces, and dependencies # get no debuginfo, so their frames show no file or line. Full debuginfo made diff --git a/README.md b/README.md index 778b4def..541bcc5f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The operating system schedules Architect, Pickup and Security runs. See [schedul - [Configuration](docs/guide.md#user-config), [Harness, Model and Effort](docs/guide.md#harness-model-and-effort), and [email notifications](docs/guide.md#email). - [Machine status](docs/guide.md#machine-status), [showing Commands and reading logs](docs/guide.md#show-and-read-one-command), and [stopping a Command](docs/guide.md#stopping-a-command). +- [The local Console](console/README.md): `thirdshift console --no-sign-in` serves the Inbox on localhost. - [Building and testing](docs/guide.md#building-from-source) and [releasing](docs/guide.md#releasing). - [Exact prompts](prompts/README.md) and [Factory skills](skills/), also [readable on the site](https://thirdshift.app/prompts/). - [Domain vocabulary](CONTEXT.md) and [architectural decisions](docs/adr/). diff --git a/askama.toml b/askama.toml new file mode 100644 index 00000000..2377f4c4 --- /dev/null +++ b/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["src"] diff --git a/console/README.md b/console/README.md new file mode 100644 index 00000000..8079a2a9 --- /dev/null +++ b/console/README.md @@ -0,0 +1,32 @@ +# Console browser tests + +The Console is the local page served by the binary, separate from the public +site. Build it, then run Playwright against fixture Command records: + +```sh +cargo build --locked +cd console +npm ci +npx playwright install --with-deps chromium +npm test +``` + +`THIRDSHIFT_BINARY` can name another built binary. Each run uses an isolated +home folder and a free localhost port, and closes its own server and browser. +Node and npm are test tools only; the release build embeds its assets. + +To open your machine's Inbox, run `thirdshift console --no-sign-in` and visit +the localhost address printed on stdout. Without `--no-sign-in` the command +refuses to serve until the sign-in Ticket ships. In `~/.thirdshift/config.toml`: + +```toml +[console] +port = 8711 +other_consoles = ["https://console.example.test"] +theme = "focus-auto" # also focus-light or focus-dark +``` + +The header names this machine and links to those addresses, without fetching +them. The first page pairs with `thirdshift status`. A refresh reads the current +records; server-push updates are a later Ticket. Replacing the installed binary +makes the Console re-execute itself with its original arguments. diff --git a/console/package-lock.json b/console/package-lock.json new file mode 100644 index 00000000..0e519e65 --- /dev/null +++ b/console/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "console", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "playwright": "1.64.0" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/console/package.json b/console/package.json new file mode 100644 index 00000000..79602d45 --- /dev/null +++ b/console/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "scripts": { "test": "node --test tests/*.test.cjs" }, + "devDependencies": { "playwright": "1.64.0" } +} diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs new file mode 100644 index 00000000..6632786f --- /dev/null +++ b/console/tests/inbox.test.cjs @@ -0,0 +1,96 @@ +const { test, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const { once } = require("node:events"); +const { mkdtemp, mkdir, writeFile, rm } = require("node:fs/promises"); +const { tmpdir } = require("node:os"); +const { join, resolve } = require("node:path"); +const { createInterface } = require("node:readline"); +const { chromium } = require("playwright"); + +let home, server, exited, browser, origin; +before(async () => { + home = await mkdtemp(join(tmpdir(), "thirdshift-console-")); + const configDir = join(home, ".thirdshift"); + const records = join(configDir, "logs/acme/widgets/commands/issue"); + await mkdir(records, { recursive: true }); + await writeFile(join(configDir, "config.toml"), '[console]\nport = 0\nother_consoles = ["https://other.example.test"]\n'); + for (let number = 1; number <= 10; number++) { + const now = new Date().toISOString(); + await writeFile(join(records, `${number}-fixture.command.json`), JSON.stringify({ + version: 1, kind: "Spec run", repository: "acme/widgets", hostname: "fixture", + issue: { number, url: `https://github.com/acme/widgets/issues/${number}`, title: "A long title that should wrap on a phone, even with " }, + process_id: 0, process_start: "gone", harness: "codex", model: "gpt-6", effort: "high", + started_at: now, ended_at: now, stages: [], session_log: null, + outcome: "failed", cause: `distinct fixture failure ${number}`, issue_branch: null, pull_request: null, + tickets: [{ issue: { number: 98, url: "https://github.com/acme/widgets/issues/98", title: "Build it" }, state: "waiting", blockers: [{ number: 97, url: "https://github.com/acme/widgets/issues/97", title: "First" }] }] + })); + } + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, "../../target/debug/thirdshift"), ["console", "--no-sign-in"], { + env: { ...process.env, HOME: home }, stdio: ["ignore", "pipe", "inherit"] + }); + exited = once(server, "exit"); + const lines = createInterface({ input: server.stdout }); + try { + [origin] = await once(lines, "line", { signal: AbortSignal.timeout(10000) }); + assert.match(origin, /^http:\/\/127\.0\.0\.1:\d+$/); + } finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +async function pageFor(t, options) { + const context = await browser.newContext(options); + t.after(() => context.close()); + const page = await context.newPage(); + return page; +} + +test("Inbox folds, loads only its own resources and fits a 390 px phone", async t => { + const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); + const remote = [], errors = []; + page.on("request", request => { if (!request.url().startsWith(origin)) remote.push(request.url()); }); + page.on("pageerror", error => errors.push(error.message)); + const response = await page.goto(origin); + assert.equal(response.status(), 200); + await page.evaluate(() => document.fonts.ready); + assert.equal(await page.locator('details[open]').count(), 1); + assert.equal(await page.locator('details[open]').getAttribute("data-group"), "needs you"); + assert.match(await page.locator(".plan").first().textContent(), /Ticket #98 · waiting on #97/); + assert.equal(await page.locator('.command .state').first().textContent(), "Failed"); + assert.equal(await page.locator(".command script").count(), 0); + for (const summary of await page.locator("summary").all()) { + await summary.click(); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + assert.deepEqual(remote, []); + assert.deepEqual(errors, []); + assert.equal(await page.evaluate(() => document.fonts.check('14px "IBM Plex Sans"') && document.fonts.check('12px "IBM Plex Mono"')), true); +}); + +test("all section headings fit above the fold on a 14 inch laptop with a busy Needs you", async t => { + const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); + await page.goto(origin); + await page.evaluate(() => document.fonts.ready); + assert.equal(await page.locator('[data-group="needs you"][open] .command').count(), 10); + for (const summary of await page.locator("summary").all()) { + const rect = await summary.boundingBox(); + assert.ok(rect.y >= 0 && rect.y + rect.height < 900, `${await summary.textContent()}: ${JSON.stringify(rect)}`); + } +}); + +test("the Inbox works without JavaScript and Focus auto follows system light and dark", async t => { + const page = await pageFor(t, { javaScriptEnabled: false, viewport: { width: 390, height: 844 }, colorScheme: "light" }); + await page.goto(origin); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 1); + await page.locator('[data-group="working"] > summary').click(); + assert.equal(await page.locator('[data-group="working"][open]').count(), 1); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(247, 247, 245)"); + await page.emulateMedia({ colorScheme: "dark" }); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); +}); diff --git a/src/args.rs b/src/args.rs index 541e6608..50962745 100644 --- a/src/args.rs +++ b/src/args.rs @@ -22,6 +22,9 @@ pub enum Command { Version, Update, Setup, + Console { + no_sign_in: bool, + }, Status { json: bool, all: bool, @@ -236,6 +239,13 @@ pub fn parse(args: &[String]) -> Result { Some("help" | "--help" | "-h") => return Ok(Command::Help), Some("version" | "--version" | "-V") => return Ok(Command::Version), Some("update") => return Ok(Command::Update), + Some("console") => { + return match &args[1..] { + [] => Ok(Command::Console { no_sign_in: false }), + [flag] if flag == "--no-sign-in" => Ok(Command::Console { no_sign_in: true }), + _ => bail!("usage: thirdshift console [--no-sign-in]"), + }; + } Some("stop") => { return match &args[1..] { [command] if !command.starts_with('-') => Ok(Command::Stop { diff --git a/src/asks.rs b/src/asks.rs index 9aa47a0a..10fa80c4 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -240,6 +240,7 @@ mod tests { /// default. fn no_settings() -> UserConfig { UserConfig { + console: crate::config::ConsoleSettings::default(), merge_always: false, base_fix: false, launch_pull: false, diff --git a/src/config.rs b/src/config.rs index a535b79c..c01c308b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,10 +16,14 @@ use toml_edit::{DocumentMut, Item}; use crate::harness::{self, Harness, ModelAndEffort}; +mod console; +pub use console::ConsoleSettings; + /// The settings a User config can hold. Each is what a Run does when its /// command says nothing about it. #[derive(Debug, PartialEq, Eq)] pub struct UserConfig { + pub console: ConsoleSettings, /// `merge.always`: every Run is a Merge run unless told `no-merge`. pub merge_always: bool, /// `base.fix`: every Run may start a Base fix unless told `no-base-fix`. @@ -103,6 +107,7 @@ impl UserConfig { /// What a Run does with no User config under `home`. fn defaults(home: &Path) -> Self { UserConfig { + console: ConsoleSettings::default(), merge_always: false, base_fix: false, launch_pull: false, @@ -133,6 +138,17 @@ impl UserConfig { .with_context(|| format!("can't parse {file}"))?; let mut config = UserConfig::defaults(home); for (section, value) in &table { + if section == "console" { + config.console = value + .clone() + .try_into() + .with_context(|| format!("invalid [console] in {file}"))?; + config + .console + .validate() + .with_context(|| format!("invalid [console] in {file}"))?; + continue; + } let known = matches!( section.as_str(), "merge" @@ -773,6 +789,11 @@ waiting_line_minutes = 5 # repeat unchanged CI watch and Self-merge waiting li days = 7 # days to keep ended problems and Resolved; default 7 silent_minutes = 10 # minutes without growth in either log before a Run is Silent; default 10 +[console] +port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711 +other_consoles = [] # http or https addresses linked in the header, never fetched; default [] +theme = "focus-auto" # Focus follows the system, or focus-light / focus-dark; default focus-auto + [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -951,6 +972,9 @@ mod tests { "logs.waiting_line_minutes", "inbox.days", "inbox.silent_minutes", + "console.port", + "console.other_consoles", + "console.theme", "activity.quiet_skips", "spec.parallel", "pickup.limit", diff --git a/src/config/console.rs b/src/config/console.rs new file mode 100644 index 00000000..c23204e8 --- /dev/null +++ b/src/config/console.rs @@ -0,0 +1,41 @@ +//! Console settings stay in the User config; serving never writes them. +use anyhow::{Result, ensure}; +use serde::Deserialize; + +#[derive(Debug, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ConsoleSettings { + pub port: u16, + pub other_consoles: Vec, + pub theme: String, +} + +impl Default for ConsoleSettings { + fn default() -> Self { + Self { + port: 8711, + other_consoles: Vec::new(), + theme: "focus-auto".into(), + } + } +} + +impl ConsoleSettings { + pub(super) fn validate(&self) -> Result<()> { + ensure!( + matches!( + self.theme.as_str(), + "focus-auto" | "focus-light" | "focus-dark" + ), + "console.theme must be focus-auto, focus-light or focus-dark" + ); + for address in &self.other_consoles { + let url = reqwest::Url::parse(address)?; + ensure!( + matches!(url.scheme(), "http" | "https") && url.host_str().is_some(), + "console.other_consoles must contain http or https addresses" + ); + } + Ok(()) + } +} diff --git a/src/console.rs b/src/console.rs new file mode 100644 index 00000000..e3ca424d --- /dev/null +++ b/src/console.rs @@ -0,0 +1,165 @@ +//! The machine's Console. Async execution is confined to this module. +mod restart; + +use std::io::Write; +use std::net::Ipv4Addr; + +use anyhow::{Context, Result, ensure}; +use askama::Template; +use axum::Router; +use axum::extract::Path; +use axum::http::{StatusCode, header}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use include_dir::{Dir, include_dir}; + +use crate::config::UserConfig; +use crate::logs::status::{self, Inbox, Row}; + +static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); + +// All page/action routes are registered here, beside their CLI equivalent. +// Static resources carry no view or action and have their own fallback route. +struct PageRoute { + path: &'static str, + cli: &'static str, +} + +const PAGES: &[PageRoute] = &[PageRoute { + path: "/", + cli: "thirdshift status", +}]; + +pub fn serve(no_sign_in: bool) -> Result<()> { + ensure!( + no_sign_in, + "Console sign-in is not available yet; use thirdshift console --no-sign-in on localhost" + ); + let config = UserConfig::load()?; + let executable = restart::Executable::current()?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, config.console.port)) + .await + .context("can't listen for the Console on localhost")?; + println!("http://{}", listener.local_addr()?); + std::io::stdout().flush()?; + let mut router = Router::new(); + for page in PAGES { + let cli = page.cli; + router = router.route(page.path, get(move || inbox(cli))); + } + router = router.route("/assets/{*path}", get(asset)); + let watched = executable.clone(); + axum::serve(listener, router) + .with_graceful_shutdown(async move { watched.replaced().await }) + .await + .context("Console server failed")?; + Ok::<_, anyhow::Error>(()) + })?; + // Drop the runtime (and its sockets) before replacing the process. + drop(runtime); + executable.exec() +} + +#[derive(Template)] +#[template(path = "console/inbox.html")] +struct InboxPage { + hostname: String, + consoles: Vec, + theme: String, + cli: &'static str, + inbox: Inbox, +} + +impl InboxPage { + fn row<'a>(&self, row: &'a Row) -> RowTemplate<'a> { + RowTemplate { row } + } +} + +#[derive(Template)] +#[template(path = "console/row.html")] +struct RowTemplate<'a> { + row: &'a Row, +} + +impl RowTemplate<'_> { + fn child<'a>(&self, row: &'a Row) -> RowTemplate<'a> { + RowTemplate { row } + } +} + +async fn inbox(cli: &'static str) -> Response { + // The shared observations and templates stay blocking, off the async executor. + match tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + Ok(InboxPage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), cli, + inbox: status::inbox(&config)?, + }.render()?) + }).await { + Ok(Ok(html)) => ( + [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) + ).into_response(), + error => { + eprintln!("thirdshift Console: can't read Inbox: {error:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Can't read the Inbox; check the Console's stderr.").into_response() + } + } +} + +async fn asset(Path(path): Path) -> Response { + let Some(file) = ASSETS.get_file(&path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let content_type = match std::path::Path::new(&path) + .extension() + .and_then(|ext| ext.to_str()) + { + Some("css") => "text/css; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("woff2") => "font/woff2", + _ => "text/plain; charset=utf-8", + }; + ( + [ + (header::CONTENT_TYPE, content_type), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + file.contents(), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + #[test] + fn every_console_route_has_a_cli_equivalent() { + for route in super::PAGES { + assert!( + !route.cli.trim().is_empty(), + "{} has no paired CLI command", + route.path + ); + assert!(route.cli.starts_with("thirdshift "), "{}", route.cli); + let arguments = route + .cli + .split_whitespace() + .skip(1) + .map(str::to_owned) + .collect::>(); + assert!( + crate::args::parse(&arguments).is_ok(), + "{} is not a CLI command", + route.cli + ); + } + } +} diff --git a/src/console/assets/README.md b/src/console/assets/README.md new file mode 100644 index 00000000..4a135548 --- /dev/null +++ b/src/console/assets/README.md @@ -0,0 +1,20 @@ +# Embedded Console assets + +All runtime assets are compiled in with `include_dir`; the release build uses +neither Node nor npm. Focus tokens and the font choices follow +`prototype/711-console` at `05b1b0a`, `themes.js`'s Focus light/dark definitions. + +Vendored, unmodified libraries (licenses beside them): + +- htmx 2.0.7: +- htmx-ext-sse 2.2.3: +- idiomorph 0.7.4: + +Latin-subset WOFF2 fonts, with SIL Open Font Licenses in `fonts/`: + +- IBM Plex Sans, variable, normal, weights 100–900: + +- IBM Plex Mono, normal, weight 400: + + +These URLs document provenance; the Console loads only embedded local files. diff --git a/src/console/assets/console.css b/src/console/assets/console.css new file mode 100644 index 00000000..b1f52095 --- /dev/null +++ b/src/console/assets/console.css @@ -0,0 +1,70 @@ +@font-face { font-family: "IBM Plex Sans"; src: url("/assets/fonts/ibm-plex-sans-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } +@font-face { font-family: "IBM Plex Mono"; src: url("/assets/fonts/ibm-plex-mono-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; } + +/* Focus tokens from prototype/711-console themes.js (05b1b0a), ADR 0018. */ +:root { + color-scheme: light; + --bg: #F7F7F5; --panel: #FFFFFF; --panel-2: #F0F0EE; --sel-bg: #EEEEEB; + --text: #18181A; --head: #18181A; --dim: #6B6B70; --rule: #E3E3E0; --link: #18181A; + --urgent: #D7263D; --urgent-text: #9A1424; --look: #B06800; --look-text: #7E4C00; + --working: #5E5E63; --working-text: #4A4A4F; --landing: #6B6B70; + --ready: #3F3F44; --ready-text: #3F3F44; --done: #6B6B70; + --font-head: "IBM Plex Sans", sans-serif; --font-body: "IBM Plex Sans", sans-serif; + --font-mono: "IBM Plex Mono", monospace; --body-size: 14px; --head-weight: 600; --radius: 4px; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="focus-light"]) { + color-scheme: dark; + --bg: #121214; --panel: #19191C; --panel-2: #212125; --sel-bg: #2A2A2F; + --text: #E8E8EA; --head: #E8E8EA; --dim: #A0A0A8; --rule: #2C2C31; --link: #E8E8EA; + --urgent: #FF6B78; --urgent-text: #FFB0B5; --look: #FFB020; --look-text: #FFB52E; + --working: #A8A8B0; --working-text: #B4B4BC; --landing: #9A9AA2; + --ready: #C4C4CA; --ready-text: #C4C4CA; --done: #9A9AA2; + } +} +:root[data-theme="focus-dark"] { + color-scheme: dark; + --bg: #121214; --panel: #19191C; --panel-2: #212125; --sel-bg: #2A2A2F; + --text: #E8E8EA; --head: #E8E8EA; --dim: #A0A0A8; --rule: #2C2C31; --link: #E8E8EA; + --urgent: #FF6B78; --urgent-text: #FFB0B5; --look: #FFB020; --look-text: #FFB52E; + --working: #A8A8B0; --working-text: #B4B4BC; --landing: #9A9AA2; + --ready: #C4C4CA; --ready-text: #C4C4CA; --done: #9A9AA2; +} +* { box-sizing: border-box; } +body { margin: 0 auto; max-width: 1120px; padding: 20px 28px; background: var(--bg); color: var(--text); font: var(--body-size)/1.4 var(--font-body); } +header { border-bottom: 2px solid var(--head); padding-bottom: 14px; } +.brand, .harness, .age, .empty, aside { color: var(--dim); } +.brand { margin: 0; } +h1 { margin: 4px 0; font: var(--head-weight) clamp(24px, 4vw, 40px)/1.1 var(--font-head); overflow-wrap: anywhere; } +nav { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 10px; } +a { color: var(--link); overflow-wrap: anywhere; } +.page-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 16px 0 8px; } +h2 { margin: 0; font-weight: var(--head-weight); } +code, .now, .subject, .plan { font-family: var(--font-mono); font-size: 12px; } +.group { border-bottom: 1px solid var(--rule); } +summary { padding: 10px 2px; cursor: pointer; font-size: 17px; } +summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } +.commands, .children, .plan { list-style: none; padding: 0; margin: 0; } +.commands { max-height: 300px; overflow: auto; } +.command { padding: 8px 10px; margin-bottom: 4px; border-radius: var(--radius); color: var(--dim); overflow-wrap: anywhere; } +.row-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } +.state { color: var(--text); } +.age { margin-left: auto; white-space: nowrap; } +.issue-title { flex: 1 1 160px; } +.command p { margin: 4px 0 0; } +.now { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.command[data-state="Died"], .command[data-state="Failed"], .command[data-state="Silent"], .command[data-state="Finding"], .command[data-state="Triage"] { background: var(--panel); border-left: 3px solid var(--urgent); color: var(--urgent-text); } +.command[data-state="Slow"] { border-left: 3px solid var(--look); color: var(--look-text); } +.command[data-state="Died"] .state, .command[data-state="Failed"] .state, .command[data-state="Silent"] .state, .command[data-state="Finding"] .state, .command[data-state="Triage"] .state { color: var(--urgent-text); font-weight: var(--head-weight); } +.plan { margin-top: 6px; } +.plan li::before { content: "☐ "; } +.children { border-left: 1px solid var(--rule); padding-left: 14px; margin-top: 8px; } +.empty { margin: 0 0 12px 16px; } +aside { font-size: 12px; } +:focus-visible { outline: 2px solid var(--head); outline-offset: 2px; } +@media (max-width: 500px) { + body { padding: 14px 12px; } + .issue-title { flex-basis: 100%; order: 1; } + .commands { max-height: none; } + .children { padding-left: 8px; } +} diff --git a/src/console/assets/console.js b/src/console/assets/console.js new file mode 100644 index 00000000..cc45e621 --- /dev/null +++ b/src/console/assets/console.js @@ -0,0 +1,12 @@ +// Folded sections are a per-browser convenience. A fresh visit opens Needs you. +const key = "thirdshift:console:folded"; +let folded = {}; +try { folded = JSON.parse(localStorage.getItem(key) ?? "{}"); } catch { /* storage is optional */ } +for (const section of document.querySelectorAll("details[data-group]")) { + if (typeof folded?.[section.dataset.group] === "boolean") section.open = folded[section.dataset.group]; + section.addEventListener("toggle", () => { + folded ??= {}; + folded[section.dataset.group] = section.open; + try { localStorage.setItem(key, JSON.stringify(folded)); } catch { /* storage is optional */ } + }); +} diff --git a/src/console/assets/fonts/ibm-plex-mono-OFL.txt b/src/console/assets/fonts/ibm-plex-mono-OFL.txt new file mode 100644 index 00000000..c35c4c61 --- /dev/null +++ b/src/console/assets/fonts/ibm-plex-mono-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/console/assets/fonts/ibm-plex-mono-latin.woff2 b/src/console/assets/fonts/ibm-plex-mono-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0804aaff92260b5359330f63e05b9ac25ccabeee GIT binary patch literal 14708 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

-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 literal 0 HcmV?d00001 diff --git a/src/console/assets/vendor/htmx-2.0.7.min.js b/src/console/assets/vendor/htmx-2.0.7.min.js new file mode 100644 index 00000000..d49e662e --- /dev/null +++ b/src/console/assets/vendor/htmx-2.0.7.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.7"};Q.onLoad=V;Q.process=Mt;Q.on=xe;Q.off=be;Q.trigger=ae;Q.ajax=Ln;Q.find=f;Q.findAll=x;Q.closest=g;Q.remove=_;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=ze;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=j;Q.logNone=$;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:B,findThisElement:Se,filterValues:yn,swap:ze,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:q,getExpressionVars:Tn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ue,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const T=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function y(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function q(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){R(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function D(e){return typeof e==="function"}function k(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){R(e);return null}}function B(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function U(e){const t=new URL(e,"http://x");if(t){e=t.pathname+t.search}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function V(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function j(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function $(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(te(),e)}}function b(){return window}function _(e,t){e=w(e);if(t){b().setTimeout(function(){_(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function z(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ce(w(e));if(!e){return}if(n){b().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ce(w(e));if(!r){return}if(n){b().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=w(e);e.classList.toggle(t)}function Z(e,t){e=w(e);ie(e.parentElement.children,function(e){G(e,t)});K(ce(e),t)}function g(e,t){e=ce(w(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function pe(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=w(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=pe(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ce(t),pe(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),pe(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ce(t).nextElementSibling}else if(r.indexOf("next ")===0){e=ge(t,pe(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ce(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,pe(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=y(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(y(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var ge=function(t,e,n){const r=p(y(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ue(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function w(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function ye(e,t,n,r){if(D(t)){return{target:te().body,event:J(e),listener:t,options:n}}else{return{target:w(e),event:J(t),listener:n,options:r}}}function xe(t,n,r,o){Gn(function(){const e=ye(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=D(n);return e?n:r}function be(t,n,r){Gn(function(){const e=ye(t,n,r);e.target.removeEventListener(e.event,e.listener)});return D(n)?n:r}const ve=te().createElement("output");function we(t,n){const e=ne(t,n);if(e){if(e==="this"){return[Se(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ce(q(t,function(e){return e!==t&&s(ce(e),n)}));if(i){r.push(...we(i,n))}}if(r.length===0){R('The selector "'+e+'" on '+n+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ce(q(e,function(e){return a(ce(e),t)!=null}))}function Ee(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ue(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ce(e){return Q.config.attributesToSettle.includes(e)}function Oe(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);$e(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){ie(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","

");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Ae(l,e,c){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=p(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Le(e){return function(){G(e,Q.config.addedClass);Mt(ce(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=z(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Ae(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Le(o))}}}function Ie(e,t){let n=0;while(n0}function ze(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=w(h);const r=g.contextElement?y(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){b().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(k(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,E)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,C);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,C);u.pollInterval=d(O(o,/[,\[\s]/));O(o,C);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const f={trigger:c};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,C);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,E))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,E);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,E))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,E)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,E)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ct(e,t,n){const r=oe(e);r.timeout=b().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Bt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,c,e,u,f){const a=oe(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(u)){a.lastValue.set(u,new WeakMap)}a.lastValue.get(u).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(u,l,e)){return}const t=oe(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");c(l,e);a.throttle=b().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&M(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ue(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=At(e.target);const n=Nt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ce(e),"button, input[type='submit']")}function Lt(e){return e.form||g(e,"form")}function Nt(e){const t=At(e.target);if(!t){return}const n=Lt(t);if(!n){return}return oe(n)}function It(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Pt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Dt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!B()){return null}t=U(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);ze(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){ze(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function nn(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function un(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,un(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){cn(e.name,un(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Lt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const f=ee(u,"name");ln(f,u.value,o)}const c=we(e,"hx-include");ie(c,function(e){fn(n,r,i,ce(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Pn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{R("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Pn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ue(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ue(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c in n){if(n.hasOwnProperty(c)){if(i[c]==null){i[c]=n[c]}}}}return Cn(ce(u(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Rn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Hn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Tn(e,t){return le(Rn(e,t),Hn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return t.test(e.getAllResponseHeaders())}function Ln(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:w(r)||ve,returnPromise:true})}else{let e=w(r.target);if(r.target&&!e||r.source&&!e&&!w(r.source)){e=ve}return he(t,n,w(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Nn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function In(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Pn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Dn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Dn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const F=i.handler||Vn;const M=i.select||null;if(!se(r)){re(s);return e}const c=i.targetOverride||ce(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let u=oe(r);const f=u.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const L=ee(f,"formmethod");if(L!=null){if(de.includes(L.toLowerCase())){t=L}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let X=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ce(ue(r,I))}d=(N[1]||"drop").trim();u=oe(h);if(d==="drop"&&u.xhr&&u.abortable!==true){re(s);return e}else if(d==="abort"){if(u.xhr){re(s);return e}else{X=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const P=oe(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){p=P.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=X;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=ne(r,"hx-prompt");if(B){var y=prompt(B);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,c,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Pn(i.values))}const j=Pn(Tn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Pn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let R=n;if(E){R=z;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=gn(w);if(O){R+="#"+O}}}if(!In(r,R,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),R,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D in x){if(x.hasOwnProperty(D)){const Y=x[D];qn(g,D,Y)}}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:M,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};g.onload=function(){try{const t=Nn(r);H.pathInfo.responsePath=An(g);F(r,H);if(H.keepIndicators!==true){rn(T,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},H));throw e}finally{m()}};g.onerror=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);re(l);m()};g.onabort=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);re(l);m()};g.ontimeout=function(){rn(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);re(l);m()};if(!ae(r,"htmx:beforeRequest",H)){re(s);m();return e}var T=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:wn(g,r,w);g.send(J);return e}function Fn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=ne(e,"hx-push-url");const c=ne(e,"hx-replace-url");const u=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(c){f="replace";a=c}else if(u){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Mn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Mt(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/console/assets/vendor/htmx-LICENSE b/src/console/assets/vendor/htmx-LICENSE new file mode 100644 index 00000000..d3061a37 --- /dev/null +++ b/src/console/assets/vendor/htmx-LICENSE @@ -0,0 +1,13 @@ +Zero-Clause BSD +============= + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/console/assets/vendor/htmx-ext-sse-2.2.3.js b/src/console/assets/vendor/htmx-ext-sse-2.2.3.js new file mode 100644 index 00000000..9f5af5c5 --- /dev/null +++ b/src/console/assets/vendor/htmx-ext-sse-2.2.3.js @@ -0,0 +1,290 @@ +/* +Server Sent Events Extension +============================ +This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. + +*/ + +(function() { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + htmx.defineExtension('sse', { + + /** + * Init saves the provided reference to the internal HTMX API. + * + * @param {import("../htmx").HtmxInternalApi} api + * @returns void + */ + init: function(apiRef) { + // store a reference to the internal API. + api = apiRef + + // set a function in the public API for creating new EventSource objects + if (htmx.createEventSource == undefined) { + htmx.createEventSource = createEventSource + } + }, + + getSelectors: function() { + return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + * @returns void + */ + onEvent: function(name, evt) { + var parent = evt.target || evt.detail.elt + switch (name) { + case 'htmx:beforeCleanupElement': + var internalData = api.getInternalData(parent) + // Try to remove remove an EventSource when elements are removed + var source = internalData.sseEventSource + if (source) { + api.triggerEvent(parent, 'htmx:sseClose', { + source, + type: 'nodeReplaced', + }) + internalData.sseEventSource.close() + } + + return + + // Try to create EventSources when elements are processed + case 'htmx:afterProcessNode': + ensureEventSourceOnElement(parent) + } + } + }) + + /// //////////////////////////////////////////// + // HELPER FUNCTIONS + /// //////////////////////////////////////////// + + /** + * createEventSource is the default method for creating new EventSource objects. + * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * + * @param {string} url + * @returns EventSource + */ + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }) + } + + /** + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * + * @param {HTMLElement} elt + */ + function registerSSE(elt) { + // Add message handlers for every `sse-swap` attribute + if (api.getAttributeValue(elt, 'sse-swap')) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') + var sseEventNames = sseSwapAttr.split(',') + + for (var i = 0; i < sseEventNames.length; i++) { + const sseEventName = sseEventNames[i].trim() + const listener = function(event) { + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { + return + } + + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(elt)) { + source.removeEventListener(sseEventName, listener) + return + } + + // swap the response into the DOM and trigger a notification + if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { + return + } + swap(elt, event.data) + api.triggerEvent(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(sseEventName, listener) + } + } + + // Add message handlers for every `hx-trigger="sse:*"` attribute + if (api.getAttributeValue(elt, 'hx-trigger')) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + var triggerSpecs = api.getTriggerSpecs(elt) + triggerSpecs.forEach(function(ts) { + if (ts.trigger.slice(0, 4) !== 'sse:') { + return + } + + var listener = function (event) { + if (maybeCloseSSESource(sourceElement)) { + return + } + if (!api.bodyContains(elt)) { + source.removeEventListener(ts.trigger.slice(4), listener) + } + // Trigger events to be handled by the rest of htmx + htmx.trigger(elt, ts.trigger, event) + htmx.trigger(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(ts.trigger.slice(4), listener) + }) + } + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + if (elt == null) { + return null + } + + // handle extension source creation attribute + if (api.getAttributeValue(elt, 'sse-connect')) { + var sseURL = api.getAttributeValue(elt, 'sse-connect') + if (sseURL == null) { + return + } + + ensureEventSource(elt, sseURL, retryCount) + } + + registerSSE(elt) + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url) + + source.onerror = function(err) { + // Log an error event + api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0 + retryCount = Math.max(Math.min(retryCount * 2, 128), 1) + var timeout = retryCount * 500 + window.setTimeout(function() { + ensureEventSourceOnElement(elt, retryCount) + }, timeout) + } + } + + source.onopen = function(evt) { + api.triggerEvent(elt, 'htmx:sseOpen', { source }) + + if (retryCount && retryCount > 0) { + const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]") + for (let i = 0; i < childrenToFix.length; i++) { + registerSSE(childrenToFix[i]) + } + // We want to increase the reconnection delay for consecutive failed attempts only + retryCount = 0 + } + } + + api.getInternalData(elt).sseEventSource = source + + + var closeAttribute = api.getAttributeValue(elt, "sse-close"); + if (closeAttribute) { + // close eventsource when this message is received + source.addEventListener(closeAttribute, function() { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'message', + }) + source.close() + }); + } + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource + if (source != undefined) { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'nodeMissing', + }) + source.close() + // source = null + return true + } + } + return false + } + + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + api.withExtensions(elt, function(extension) { + content = extension.transformResponse(content, null, elt) + }) + + var swapSpec = api.getSwapSpecification(elt) + var target = api.getTarget(elt) + api.swap(target, content, swapSpec) + } + + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null + } +})() diff --git a/src/console/assets/vendor/idiomorph-0.7.4.min.js b/src/console/assets/vendor/idiomorph-0.7.4.min.js new file mode 100644 index 00000000..3ec483fe --- /dev/null +++ b/src/console/assets/vendor/idiomorph-0.7.4.min.js @@ -0,0 +1 @@ +var Idiomorph=function(){"use strict";const e=()=>{};const n={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:e,afterNodeAdded:e,beforeNodeMorphed:e,afterNodeMorphed:e,beforeNodeRemoved:e,afterNodeRemoved:e,beforeAttributeUpdated:e},head:{style:"merge",shouldPreserve:e=>e.getAttribute("im-preserve")==="true",shouldReAppend:e=>e.getAttribute("im-re-append")==="true",shouldRemove:e,afterHeadMorphed:e},restoreFocus:true};function t(t,e,n={}){t=d(t);const r=f(e);const i=u(t,r,n);const o=a(i,()=>{return c(i,t,r,e=>{if(e.morphStyle==="innerHTML"){s(e,t,r);return Array.from(t.childNodes)}else{return l(e,t,r)}})});i.pantry.remove();return o}function l(e,t,n){const r=f(t);s(e,r,n,t,t.nextSibling);return Array.from(r.childNodes)}function a(e,t){if(!e.config.restoreFocus)return t();let n=document.activeElement;if(!(n instanceof HTMLInputElement||n instanceof HTMLTextAreaElement)){return t()}const{id:r,selectionStart:i,selectionEnd:o}=n;const l=t();if(r&&r!==document.activeElement?.getAttribute("id")){n=e.target.querySelector(`[id="${r}"]`);n?.focus()}if(n&&!n.selectionEnd&&o){n.setSelectionRange(i,o)}return l}const s=function(){function e(e,t,n,r=null,i=null){if(t instanceof HTMLTemplateElement&&n instanceof HTMLTemplateElement){t=t.content;n=n.content}r||=t.firstChild;for(const o of n.childNodes){if(r&&r!=i){const a=f(e,o,r,i);if(a){if(a!==r){h(e,r,a)}b(a,o,e);r=a.nextSibling;continue}}if(o instanceof Element){const s=o.getAttribute("id");if(e.persistentIds.has(s)){const c=p(t,s,r,e);b(c,o,e);r=c.nextSibling;continue}}const l=d(t,o,r,e);if(l){r=l.nextSibling}}while(r&&r!=i){const u=r;r=r.nextSibling;m(e,u)}}function d(e,t,n,r){if(r.callbacks.beforeNodeAdded(t)===false)return null;if(r.idMap.has(t)){const i=document.createElement(t.tagName);e.insertBefore(i,n);b(i,t,r);r.callbacks.afterNodeAdded(i);return i}else{const o=document.importNode(t,true);e.insertBefore(o,n);r.callbacks.afterNodeAdded(o);return o}}const f=function(){function e(e,t,n,r){let i=null;let o=t.nextSibling;let l=0;let a=n;while(a&&a!=r){if(c(a,t)){if(s(e,a,t)){return a}if(i===null){if(!e.idMap.has(a)){i=a}}}if(i===null&&o&&c(a,o)){l++;o=o.nextSibling;if(l>=2){i=undefined}}if(e.activeElementAndParents.includes(a))break;a=a.nextSibling}return i||null}function s(e,t,n){let r=e.idMap.get(t);let i=e.idMap.get(n);if(!i||!r)return false;for(const o of r){if(i.has(o)){return true}}return false}function c(e,t){const n=e;const r=t;return n.nodeType===r.nodeType&&n.tagName===r.tagName&&(!n.getAttribute?.("id")||n.getAttribute?.("id")===r.getAttribute?.("id"))}return e}();function m(e,t){if(e.idMap.has(t)){l(e.pantry,t,null)}else{if(e.callbacks.beforeNodeRemoved(t)===false)return;t.parentNode?.removeChild(t);e.callbacks.afterNodeRemoved(t)}}function h(t,e,n){let r=e;while(r&&r!==n){let e=r;r=r.nextSibling;m(t,e)}return r}function p(e,t,n,r){const i=r.target.getAttribute?.("id")===t&&r.target||r.target.querySelector(`[id="${t}"]`)||r.pantry.querySelector(`[id="${t}"]`);o(i,r);l(e,i,n);return i}function o(t,n){const r=t.getAttribute("id");while(t=t.parentNode){let e=n.idMap.get(t);if(e){e.delete(r);if(!e.size){n.idMap.delete(t)}}}}function l(t,n,r){if(t.moveBefore){try{t.moveBefore(n,r)}catch(e){t.insertBefore(n,r)}}else{t.insertBefore(n,r)}}return e}();const b=function(){function e(e,t,n){if(n.ignoreActive&&e===document.activeElement){return null}if(n.callbacks.beforeNodeMorphed(e,t)===false){return e}if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){m(e,t,n)}else{r(e,t,n);if(!f(e,n)){s(n,e,t)}}n.callbacks.afterNodeMorphed(e,t);return e}function r(e,t,n){let r=t.nodeType;if(r===1){const i=e;const o=t;const l=i.attributes;const a=o.attributes;for(const s of a){if(d(s.name,i,"update",n)){continue}if(i.getAttribute(s.name)!==s.value){i.setAttribute(s.name,s.value)}}for(let e=l.length-1;0<=e;e--){const c=l[e];if(!c)continue;if(!o.hasAttribute(c.name)){if(d(c.name,i,"remove",n)){continue}i.removeAttribute(c.name)}}if(!f(i,n)){u(i,o,n)}}if(r===8||r===3){if(e.nodeValue!==t.nodeValue){e.nodeValue=t.nodeValue}}}function u(n,r,i){if(n instanceof HTMLInputElement&&r instanceof HTMLInputElement&&r.type!=="file"){let e=r.value;let t=n.value;o(n,r,"checked",i);o(n,r,"disabled",i);if(!r.hasAttribute("value")){if(!d("value",n,"remove",i)){n.value="";n.removeAttribute("value")}}else if(t!==e){if(!d("value",n,"update",i)){n.setAttribute("value",e);n.value=e}}}else if(n instanceof HTMLOptionElement&&r instanceof HTMLOptionElement){o(n,r,"selected",i)}else if(n instanceof HTMLTextAreaElement&&r instanceof HTMLTextAreaElement){let e=r.value;let t=n.value;if(d("value",n,"update",i)){return}if(e!==t){n.value=e}if(n.firstChild&&n.firstChild.nodeValue!==e){n.firstChild.nodeValue=e}}}function o(e,t,n,r){const i=t[n],o=e[n];if(i!==o){const l=d(n,e,"update",r);if(!l){e[n]=t[n]}if(i){if(!l){e.setAttribute(n,"")}}else{if(!d(n,e,"remove",r)){e.removeAttribute(n)}}}}function d(e,t,n,r){if(e==="value"&&r.ignoreActiveValue&&t===document.activeElement){return true}return r.callbacks.beforeAttributeUpdated(e,t,n)===false}function f(e,t){return!!t.ignoreActiveValue&&e===document.activeElement&&e!==document.body}return e}();function c(t,e,n,r){if(t.head.block){const i=e.querySelector("head");const o=n.querySelector("head");if(i&&o){const l=m(i,o,t);return Promise.all(l).then(()=>{const e=Object.assign(t,{head:{block:false,ignore:true}});return r(e)})}}return r(t)}function m(e,t,r){let i=[];let o=[];let l=[];let a=[];let s=new Map;for(const n of t.children){s.set(n.outerHTML,n)}for(const u of e.children){let e=s.has(u.outerHTML);let t=r.head.shouldReAppend(u);let n=r.head.shouldPreserve(u);if(e||n){if(t){o.push(u)}else{s.delete(u.outerHTML);l.push(u)}}else{if(r.head.style==="append"){if(t){o.push(u);a.push(u)}}else{if(r.head.shouldRemove(u)!==false){o.push(u)}}}}a.push(...s.values());let c=[];for(const d of a){let n=document.createRange().createContextualFragment(d.outerHTML).firstChild;if(r.callbacks.beforeNodeAdded(n)!==false){if("href"in n&&n.href||"src"in n&&n.src){let t;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});c.push(e)}e.appendChild(n);r.callbacks.afterNodeAdded(n);i.push(n)}}for(const f of o){if(r.callbacks.beforeNodeRemoved(f)!==false){e.removeChild(f);r.callbacks.afterNodeRemoved(f)}}r.head.afterHeadMorphed(e,{added:i,kept:l,removed:o});return c}const u=function(){function e(e,t,n){const{persistentIds:r,idMap:i}=f(e,t);const o=a(n);const l=o.morphStyle||"outerHTML";if(!["innerHTML","outerHTML"].includes(l)){throw`Do not understand how to morph style ${l}`}return{target:e,newContent:t,config:o,morphStyle:l,ignoreActive:o.ignoreActive,ignoreActiveValue:o.ignoreActiveValue,restoreFocus:o.restoreFocus,idMap:i,persistentIds:r,pantry:s(),activeElementAndParents:c(e),callbacks:o.callbacks,head:o.head}}function a(e){let t=Object.assign({},n);Object.assign(t,e);t.callbacks=Object.assign({},n.callbacks,e.callbacks);t.head=Object.assign({},n.head,e.head);return t}function s(){const e=document.createElement("div");e.hidden=true;document.body.insertAdjacentElement("afterend",e);return e}function c(e){let t=[];let n=document.activeElement;if(n?.tagName!=="BODY"&&e.contains(n)){while(n){t.push(n);if(n===e)break;n=n.parentElement}}return t}function u(e){let t=Array.from(e.querySelectorAll("[id]"));if(e.getAttribute?.("id")){t.push(e)}return t}function d(n,e,r,t){for(const i of t){const o=i.getAttribute("id");if(e.has(o)){let t=i;while(t){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(o);if(t===r)break;t=t.parentElement}}}}function f(e,t){const n=u(e);const r=u(t);const i=m(n,r);let o=new Map;d(o,i,e,n);const l=t.__idiomorphRoot||t;d(o,i,l,r);return{persistentIds:i,idMap:o}}function m(e,t){let n=new Set;let r=new Map;for(const{id:o,tagName:l}of e){if(r.has(o)){n.add(o)}else{r.set(o,l)}}let i=new Set;for(const{id:o,tagName:l}of t){if(i.has(o)){n.add(o)}else if(r.get(o)===l){i.add(o)}}for(const o of n){i.delete(o)}return i}return e}();const{normalizeElement:d,normalizeParent:f}=function(){const i=new WeakSet;function e(e){if(e instanceof Document){return e.documentElement}else{return e}}function r(e){if(e==null){return document.createElement("div")}else if(typeof e==="string"){return r(l(e))}else if(i.has(e)){return e}else if(e instanceof Node){if(e.parentNode){return new o(e)}else{const t=document.createElement("div");t.append(e);return t}}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}class o{constructor(e){this.originalNode=e;this.realParentNode=e.parentNode;this.previousSibling=e.previousSibling;this.nextSibling=e.nextSibling}get childNodes(){const e=[];let t=this.previousSibling?this.previousSibling.nextSibling:this.realParentNode.firstChild;while(t&&t!=this.nextSibling){e.push(t);t=t.nextSibling}return e}querySelectorAll(r){return this.childNodes.reduce((t,e)=>{if(e instanceof Element){if(e.matches(r))t.push(e);const n=e.querySelectorAll(r);for(let e=0;e]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let t=r.parseFromString(n,"text/html");if(e.match(/<\/html>/)){i.add(t);return t}else{let e=t.firstChild;if(e){i.add(e)}return e}}else{let e=r.parseFromString("","text/html");let t=e.body.querySelector("template").content;i.add(t);return t}}return{normalizeElement:e,normalizeParent:r}}();return{morph:t,defaults:n}}(); \ No newline at end of file diff --git a/src/console/assets/vendor/idiomorph-LICENSE b/src/console/assets/vendor/idiomorph-LICENSE new file mode 100644 index 00000000..d3061a37 --- /dev/null +++ b/src/console/assets/vendor/idiomorph-LICENSE @@ -0,0 +1,13 @@ +Zero-Clause BSD +============= + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/console/assets/vendor/sse-LICENSE b/src/console/assets/vendor/sse-LICENSE new file mode 100644 index 00000000..2e176b0c --- /dev/null +++ b/src/console/assets/vendor/sse-LICENSE @@ -0,0 +1,14 @@ +BSD Zero Clause License + +Copyright (c) 2023, Alexander Petros + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/console/inbox.html b/src/console/inbox.html new file mode 100644 index 00000000..a9bfdfd7 --- /dev/null +++ b/src/console/inbox.html @@ -0,0 +1,34 @@ + + + + + + + Inbox · {{ hostname }} · thirdshift + + + + + + + + +

thirdshift / Console

{{ hostname }}

+ {% if !consoles.is_empty() %}{% endif %} +
+
+

Inbox

{{ cli }}
+ {% match inbox.warning %}{% when Some with (warning) %}

Warning: {{ warning }}

{% when None %}{% endmatch %} + {% for section in inbox.sections %} +
+ {{ section.title }}{{ section.rows.len() }} + {% if section.rows.is_empty() && section.references.is_empty() %}

Nothing here.

{% endif %} + {# Only the rendered, auto-escaped RowTemplate is trusted as HTML. #} +
    {% for item in section.rows %}{{ self.row(item)|safe }}{% endfor %}
+ {% for reference in section.references %}

{{ reference }}

{% endfor %} +
+ {% endfor %} + {% if !inbox.idle_passes.is_empty() %}{% endif %} +
+ + diff --git a/src/console/restart.rs b/src/console/restart.rs new file mode 100644 index 00000000..450e4783 --- /dev/null +++ b/src/console/restart.rs @@ -0,0 +1,66 @@ +//! Watch the installed path, rather than /proc's possibly unlinked executable. +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; + +#[derive(Clone, PartialEq)] +struct Identity { + modified: SystemTime, + size: u64, + #[cfg(unix)] + inode: (u64, u64), +} + +#[derive(Clone)] +pub(super) struct Executable { + path: PathBuf, + identity: Identity, +} + +impl Executable { + pub fn current() -> Result { + let path = std::env::current_exe().context("can't locate the Console binary")?; + let identity = identity(&path)?; + Ok(Self { path, identity }) + } + + pub async fn replaced(&self) { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + if identity(&self.path).is_ok_and(|current| current != self.identity) { + return; + } + } + } + + pub fn exec(&self) -> Result<()> { + let mut command = std::process::Command::new(&self.path); + command.args(std::env::args_os().skip(1)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + Err(command.exec()).context("can't re-execute the updated Console") + } + #[cfg(not(unix))] + { + command + .spawn() + .context("can't restart the updated Console")?; + Ok(()) + } + } +} + +fn identity(path: &std::path::Path) -> Result { + let metadata = std::fs::metadata(path)?; + Ok(Identity { + modified: metadata.modified()?, + size: metadata.len(), + #[cfg(unix)] + inode: { + use std::os::unix::fs::MetadataExt; + (metadata.dev(), metadata.ino()) + }, + }) +} diff --git a/src/console/row.html b/src/console/row.html new file mode 100644 index 00000000..31f68311 --- /dev/null +++ b/src/console/row.html @@ -0,0 +1,9 @@ +
  • +
    {{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}
    +

    {{ row.now_line }}

    + {% if !row.harness.is_empty() %}

    {{ row.harness }}

    {% endif %} + {% if !row.pull_request.is_empty() %}

    {{ row.pull_request }}

    {% endif %} + {% if row.count > 1 %}

    {{ row.count }} Commands with this cause: {{ row.cause }}

    {% else if !row.cause.is_empty() %}

    {{ row.cause }}

    {% endif %} + {% if !row.tickets.is_empty() %}
      {% for ticket in row.tickets %}
    • {{ ticket }}
    • {% endfor %}
    {% endif %} + {% if !row.children.is_empty() %}
      {% for child in row.children %}{{ self.child(child)|safe }}{% endfor %}
    {% endif %} +
  • diff --git a/src/logs/status.rs b/src/logs/status.rs index 289bfffc..5df787be 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -1,6 +1,8 @@ //! Format machine-wide Command observations. +mod inbox; pub mod inspect; +pub(crate) use inbox::{Inbox, Row, inbox}; use anyhow::Result; use chrono::Local; @@ -168,34 +170,38 @@ pub(super) fn stage_text(command: &Command) -> String { pub(super) fn print_tickets(facts: &CommandRecord, indent: &str) { for ticket in &facts.tickets { - use super::command_record::TicketState; - let state = match ticket.state { - TicketState::Running => "running".into(), - TicketState::Merged => "merged".into(), - TicketState::Failed => "failed".into(), - TicketState::NotStarted => "not started".into(), - TicketState::Waiting => format!( - "waiting on {}", - ticket - .blockers - .iter() - .map(|issue| { - if IssueUrl::parse(&issue.url).is_ok_and(|blocker| { - blocker.repo_slug().eq_ignore_ascii_case(&facts.repository) - }) { - format!("#{}", issue.number) - } else { - issue.url.clone() - } - }) - .collect::>() - .join(", ") - ), - }; - println!("{indent}Ticket #{} · {state}", ticket.issue.number); + println!("{indent}{}", ticket_text(ticket, &facts.repository)); } } +fn ticket_text(ticket: &super::command_record::TicketRecord, repository: &str) -> String { + use super::command_record::TicketState; + let state = match ticket.state { + TicketState::Running => "running".into(), + TicketState::Merged => "merged".into(), + TicketState::Failed => "failed".into(), + TicketState::NotStarted => "not started".into(), + TicketState::Waiting => format!( + "waiting on {}", + ticket + .blockers + .iter() + .map(|issue| { + if IssueUrl::parse(&issue.url) + .is_ok_and(|blocker| blocker.repo_slug().eq_ignore_ascii_case(repository)) + { + format!("#{}", issue.number) + } else { + issue.url.clone() + } + }) + .collect::>() + .join(", ") + ), + }; + format!("Ticket #{} · {state}", ticket.issue.number) +} + fn age(seconds: i64) -> String { if seconds < 60 { format!("{seconds} sec") diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs new file mode 100644 index 00000000..9901f0f7 --- /dev/null +++ b/src/logs/status/inbox.rs @@ -0,0 +1,178 @@ +//! Owned Inbox presentation for the Console, from the same observations as status. +use anyhow::Result; +use chrono::{DateTime, Local}; + +use super::{age, stage_text, ticket_text}; +use crate::config::UserConfig; +use crate::logs::command_record::Issue; +use crate::logs::reading::{Command, Entry, Group, Snapshot}; + +pub(crate) struct Inbox { + pub sections: Vec
    , + pub idle_passes: Vec, + pub warning: Option<&'static str>, +} + +pub(crate) struct Section { + pub group: &'static str, + pub title: &'static str, + pub open: bool, + pub rows: Vec, + pub references: Vec, +} + +pub(crate) struct Row { + pub subject: String, + pub title: String, + pub state: String, + pub now_line: String, + pub age: String, + pub count: usize, + pub cause: String, + pub harness: String, + pub pull_request: String, + pub tickets: Vec, + pub children: Vec, +} + +pub(crate) fn inbox(config: &UserConfig) -> Result { + let now = Local::now(); + let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?; + let items = snapshot.inbox(false, config.inbox_days); + let sections = Group::ORDER + .into_iter() + .filter(|group| *group != Group::History) + .map(|group| { + let rows = items + .iter() + .filter(|item| item.group == group) + .map(|item| { + let mut row = match item.command { + Entry::Record(command) => row(command, now), + Entry::History(command) => { + let (subject, title) = + subject(&command.repository, &command.kind, command.issue.as_ref()); + Row { + subject, + title, + state: command.state.into(), + now_line: command + .outcome + .clone() + .unwrap_or_else(|| "unknown outcome".into()), + age: elapsed( + command.ended_at.as_deref().unwrap_or(&command.started_at), + now, + ), + count: 1, + cause: String::new(), + harness: String::new(), + pull_request: String::new(), + tickets: Vec::new(), + children: Vec::new(), + } + } + }; + if let Some(kind) = item.needs_you_kind { + row.state = kind.title().into(); + } + row.count = item.count; + row.cause = item.cause().unwrap_or_default().into(); + row + }) + .collect(); + let references = items + .iter() + .filter(|item| { + group == Group::Landing && item.landing && item.group == Group::NeedsYou + }) + .filter_map(|item| { + let Entry::Record(command) = item.command else { + return None; + }; + let (subject, _) = subject( + &command.record.repository, + &command.record.name, + command.record.issue.as_ref(), + ); + Some(format!( + "{subject} · {} · see Needs you", + item.needs_you_kind.expect("Needs you has a kind").title() + )) + }) + .collect(); + Section { + group: match group { + Group::NeedsYou => "needs you", + Group::ReadyForReview => "ready for review", + Group::Landing => "landing", + Group::Working => "working", + Group::Done => "done", + Group::Resolved => "resolved", + Group::History => unreachable!(), + }, + title: group.title(), + open: group == Group::NeedsYou, + rows, + references, + } + }) + .collect(); + Ok(Inbox { + sections, + idle_passes: snapshot.idle_passes.iter().map(|pass| format!("{} idle on {}: {}", pass.kind, pass.repository, pass.reason)).collect(), + warning: (config.effective_waiting_line_interval() != config.waiting_line_interval).then_some( + "logs.waiting_line_minutes must be less than inbox.silent_minutes; using half the silent time for waiting lines"), + }) +} + +fn subject(repository: &str, name: &str, issue: Option<&Issue>) -> (String, String) { + match issue { + Some(issue) => ( + format!("{repository}#{}", issue.number), + issue + .title + .clone() + .unwrap_or_else(|| "(title unavailable)".into()), + ), + None => (format!("{name} on {repository}"), String::new()), + } +} + +fn row(command: &Command, now: DateTime) -> Row { + let facts = &command.record; + let (subject, title) = subject(&facts.repository, &facts.name, facts.issue.as_ref()); + Row { + subject, + title, + state: command.state.into(), + now_line: stage_text(command), + age: elapsed(facts.ended_at.as_deref().unwrap_or(&facts.started_at), now), + count: 1, + cause: facts.cause.clone().unwrap_or_default(), + harness: format!( + "{} · {} · {}", + facts.harness, + facts.model.as_deref().unwrap_or("default Model"), + facts.effort.as_deref().unwrap_or("default Effort") + ), + pull_request: facts.pull_request.clone().unwrap_or_default(), + tickets: facts + .tickets + .iter() + .map(|ticket| ticket_text(ticket, &facts.repository)) + .collect(), + children: command + .dispatched + .iter() + .chain(&command.children) + .map(|child| row(child, now)) + .collect(), + } +} + +fn elapsed(time: &str, now: DateTime) -> String { + DateTime::parse_from_rfc3339(time) + .map(|time| age(now.signed_duration_since(time).num_seconds().max(0))) + .unwrap_or_else(|_| "unknown age".into()) +} diff --git a/src/main.rs b/src/main.rs index 8746264d..9c889c9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod ci; mod claim; mod command; mod config; +mod console; mod delivery; mod email; mod failed_run; @@ -76,6 +77,7 @@ usage: thirdshift Run the factory on the iss thirdshift email-test [
    ] Send a test email through Resend thirdshift setup Choose defaults and write the User config thirdshift status [--all] [--json] List this machine's Commands + thirdshift console --no-sign-in Serve this machine's Inbox on localhost thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup thirdshift logs [--session ] [--follow] Read or follow a log @@ -131,6 +133,12 @@ fn main() -> ExitCode { } Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), + Ok(Command::Console { no_sign_in }) => { + return match console::serve(no_sign_in) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } Ok(Command::Stop { command, requeue }) => { return match logs::stop::stop(&command, requeue) { Ok(()) => ExitCode::SUCCESS, diff --git a/tests/console.rs b/tests/console.rs new file mode 100644 index 00000000..cdf5289a --- /dev/null +++ b/tests/console.rs @@ -0,0 +1,299 @@ +//! The Console's public seam: the binary, fixture records and HTTP. +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread::JoinHandle; +use std::time::Duration; + +use serde_json::{Value, json}; + +struct Console { + child: Child, + address: String, + lines: Receiver, + reader: Option>, +} + +impl Console { + fn start(binary: &Path, home: &Path) -> Self { + let mut child = Command::new(binary) + .args(["console", "--no-sign-in"]) + .env("HOME", home) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, lines) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender.send(line.unwrap()).is_err() { + break; + } + } + }); + let mut console = Self { + child, + address: String::new(), + lines, + reader: Some(reader), + }; + console.address = console + .lines + .recv_timeout(Duration::from_secs(10)) + .expect("Console startup"); + assert!( + console.address.starts_with("http://127.0.0.1:"), + "{}", + console.address + ); + console + } + + fn get(&self, path: &str) -> reqwest::blocking::Response { + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap() + .get(format!("{}{path}", self.address)) + .send() + .unwrap() + } +} + +impl Drop for Console { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + self.reader.take().unwrap().join().unwrap(); + } +} + +fn configure(home: &Path) { + fs::create_dir_all(home.join(".thirdshift")).unwrap(); + fs::write(home.join(".thirdshift/config.toml"), + "[console]\nport = 0\nother_consoles = [\"https://other.example.test\"]\ntheme = \"focus-auto\"\n").unwrap(); +} + +fn record(home: &Path, number: u64, outcome: Option<&str>, days: i64) -> Value { + let time = (chrono::Local::now() - chrono::Duration::days(days)).to_rfc3339(); + let record = json!({ + "version": 1, "kind": "Spec run", "repository": "acme/widgets", + "issue": {"number": number, "url": format!("https://github.com/acme/widgets/issues/{number}"), "title": "Widgets "}, + "hostname": "fixture", "process_id": 0, "process_start": "gone", + "harness": "codex", "model": "gpt-6", "effort": "high", + "started_at": time, "ended_at": outcome.map(|_| time.clone()), + "stages": [{"name": "implement", "began_at": time, "session_log": null}], "session_log": null, + "outcome": outcome, "cause": null, "issue_branch": null, + "pull_request": if outcome == Some("ready for review") { Some("https://github.com/acme/widgets/pull/10") } else { None }, + "tickets": [{"issue": {"number": 99, "url": "https://github.com/acme/widgets/issues/99", "title": "Next"}, "state": "waiting", "blockers": [{"number": 98, "url": "https://github.com/acme/widgets/issues/98", "title": "First"}]}] + }); + write_record(home, number, &record); + record +} + +fn write_record(home: &Path, number: u64, record: &Value) { + let dir = home.join(".thirdshift/logs/acme/widgets/commands/issue"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(format!("{number}-fixture.command.json")), + serde_json::to_vec(record).unwrap(), + ) + .unwrap(); +} + +#[test] +fn inbox_over_http_matches_status_and_embeds_only_local_resources() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + record(home.path(), 1, None, 0); + record(home.path(), 2, Some("ready for review"), 0); + record(home.path(), 3, Some("merged"), 0); + record(home.path(), 4, Some("merged"), 2); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start(binary, home.path()); + let response = console.get("/"); + assert_eq!(response.status(), 200); + assert!( + response.headers()["content-type"] + .to_str() + .unwrap() + .contains("text/html") + ); + let html = response.text().unwrap(); + let status = Command::new(binary) + .args(["status", "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + status.status.success(), + "{}", + String::from_utf8_lossy(&status.stderr) + ); + let status: Value = serde_json::from_slice(&status.stdout).unwrap(); + let groups: Vec<_> = html + .split("data-group=\"") + .skip(1) + .map(|rest| rest.split('"').next().unwrap()) + .collect(); + assert_eq!( + groups, + [ + "needs you", + "ready for review", + "landing", + "working", + "done", + "resolved" + ] + ); + let subjects: Vec<_> = html + .split("") + .skip(1) + .map(|rest| rest.split("").next().unwrap()) + .collect(); + let expected: Vec<_> = status["commands"] + .as_array() + .unwrap() + .iter() + .map(|item| { + format!( + "{}#{}", + item["repository"].as_str().unwrap(), + item["issue"]["number"] + ) + }) + .collect(); + assert_eq!(subjects, expected); + for item in status["commands"].as_array().unwrap() { + let number = item["issue"]["number"].as_u64().unwrap(); + let group = item["group"].as_str().unwrap(); + let section = html + .split(&format!("data-group=\"{group}\"")) + .nth(1) + .unwrap() + .split("
    ") + .next() + .unwrap(); + assert!( + section.contains(&format!("acme/widgets#{number}")), + "{section}" + ); + } + assert!(html.contains("thirdshift status")); + assert!(html.contains(status["hostname"].as_str().unwrap())); + assert!(html.contains("https://other.example.test")); + assert!(html.contains("Ticket #99 · waiting on #98")); + assert!(!html.contains("")); + assert!(html.contains("Died")); + assert!(html.contains("died at implement")); + for path in [ + "/assets/console.css", + "/assets/console.js", + "/assets/vendor/htmx-2.0.7.min.js", + "/assets/vendor/htmx-ext-sse-2.2.3.js", + "/assets/vendor/idiomorph-0.7.4.min.js", + "/assets/fonts/ibm-plex-sans-latin.woff2", + "/assets/fonts/ibm-plex-mono-latin.woff2", + ] { + let response = console.get(path); + assert_eq!(response.status(), 200, "{path}"); + assert!(!response.bytes().unwrap().is_empty()); + } + assert_eq!(console.get("/assets/../../Cargo.toml").status(), 404); + assert_eq!(console.get("/missing").status(), 404); +} + +#[test] +fn child_runs_nest_and_each_request_reads_current_records() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + record(home.path(), 7, Some("failed"), 0); + let mut child = record(home.path(), 8, Some("merged"), 0); + child["parent_record"] = json!( + home.path() + .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture.command.json") + ); + write_record(home.path(), 8, &child); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let html = console.get("/").text().unwrap(); + let nested = html.split("class=\"children\"").nth(1).expect(&html); + assert!( + nested + .split("") + .next() + .unwrap() + .contains("acme/widgets#8") + ); + let mut updated = record(home.path(), 7, Some("merged"), 0); + updated["issue"]["title"] = json!("Changed after startup"); + write_record(home.path(), 7, &updated); + assert!( + console + .get("/") + .text() + .unwrap() + .contains("Changed after startup") + ); +} + +#[test] +fn console_requires_explicit_no_sign_in_and_rejects_invalid_settings() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let refused = Command::new(binary) + .arg("console") + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(String::from_utf8_lossy(&refused.stderr).contains("--no-sign-in")); + for settings in [ + "port = -1", + "port = 65536", + "other_consoles = ['javascript:alert(1)']", + "theme = 'typo'", + "bind = '0.0.0.0'", + ] { + fs::write( + home.path().join(".thirdshift/config.toml"), + format!("[console]\n{settings}\n"), + ) + .unwrap(); + let refused = Command::new(binary) + .args(["console", "--no-sign-in"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(!refused.status.success(), "{settings}"); + assert!(String::from_utf8_lossy(&refused.stderr).contains("[console]")); + } +} + +#[cfg(unix)] +#[test] +fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { + use std::os::unix::fs::PermissionsExt; + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let binary = home.path().join("thirdshift"); + fs::copy(env!("CARGO_BIN_EXE_thirdshift"), &binary).unwrap(); + let console = Console::start(&binary, home.path()); + assert_eq!(console.get("/").status(), 200); + let replacement = home.path().join("replacement"); + fs::write(&replacement, "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HOME/reexec-args\"\nprintf 'replacement executed\\n'\n").unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o755)).unwrap(); + fs::rename(&replacement, &binary).unwrap(); + assert_eq!( + console.lines.recv_timeout(Duration::from_secs(10)).unwrap(), + "replacement executed" + ); + assert_eq!( + fs::read_to_string(home.path().join("reexec-args")).unwrap(), + "console\n--no-sign-in\n" + ); +} diff --git a/tests/packaging.rs b/tests/packaging.rs index 821cc637..378f5b07 100644 --- a/tests/packaging.rs +++ b/tests/packaging.rs @@ -93,6 +93,16 @@ fn package_carries_the_factory_skills_with_their_license_and_credits() { "skills/thirdshift-security-audit/validate-coverage-ledger.cjs", "skills/thirdshift-security-audit/validate-coverage-ledger.test.cjs", "src/main.rs", + "askama.toml", + "src/console/inbox.html", + "src/console/row.html", + "src/console/assets/console.css", + "src/console/assets/console.js", + "src/console/assets/vendor/htmx-2.0.7.min.js", + "src/console/assets/vendor/htmx-ext-sse-2.2.3.js", + "src/console/assets/vendor/idiomorph-0.7.4.min.js", + "src/console/assets/fonts/ibm-plex-sans-latin.woff2", + "src/console/assets/fonts/ibm-plex-mono-latin.woff2", "README.md", "LICENSE", ] { diff --git a/tests/setup.rs b/tests/setup.rs index 703178de..6f88f4bc 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -49,6 +49,7 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { "[email]", "[logs]", "[inbox]", + "[console]", "[activity]", "[spec]", "[pickup]", @@ -75,6 +76,14 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { assert_eq!(config["logs"]["dir"].as_str(), Some("~/.thirdshift/logs")); assert_eq!(config["logs"]["waiting_line_minutes"].as_integer(), Some(5)); assert_eq!(config["inbox"]["silent_minutes"].as_integer(), Some(10)); + assert_eq!(config["console"]["port"].as_integer(), Some(8711)); + assert_eq!(config["console"]["theme"].as_str(), Some("focus-auto")); + assert!( + config["console"]["other_consoles"] + .as_array() + .unwrap() + .is_empty() + ); assert_eq!(config["activity"]["quiet_skips"].as_bool(), Some(false)); assert_eq!(config["spec"]["parallel"].as_integer(), Some(3)); assert_eq!(config["pickup"]["limit"].as_integer(), Some(3)); @@ -143,6 +152,7 @@ effort = 'high' # my Effort "[harness.claude]", "[harness.codex]", "[inbox]", + "[console]", "[harness.agy]", "[harness.grok]", "[harness.muse]", From a5b6fcb3250aec9da1dd6e13c6213d3058507d5e Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 22:21:53 +0000 Subject: [PATCH 10/47] Enforce parity against registered routes and unify Focus tokens --- Cargo.lock | 1 + Cargo.toml | 3 ++ console/tests/inbox.test.cjs | 16 ++++++++ src/console.rs | 69 +++++++++++++++++++++++++++++----- src/console/assets/console.css | 36 ++++++------------ 5 files changed, 92 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4333a8e5..6b1ad193 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1611,6 +1611,7 @@ dependencies = [ "tokio", "toml", "toml_edit", + "tower-layer", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7873460a..9fdea940 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ toml = "1.1.6" toml_edit = "0.25.15" tokio = { version = "1.53.1", features = ["rt", "net", "time"] } +[dev-dependencies] +tower-layer = "0.3.3" + # Line tables keep file and line in the crate's backtraces, and dependencies # get no debuginfo, so their frames show no file or line. Full debuginfo made # the debug binary 108MB and target/ 1.5GB per worktree. diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 6632786f..088b8f3b 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -94,3 +94,19 @@ test("the Inbox works without JavaScript and Focus auto follows system light and await page.emulateMedia({ colorScheme: "dark" }); assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); }); + +test("explicit Focus light and dark override the system preference", async t => { + const page = await pageFor(t, { javaScriptEnabled: false }); + const config = join(home, ".thirdshift/config.toml"); + try { + for (const [theme, system, expected] of [ + ["focus-light", "dark", "rgb(247, 247, 245)"], + ["focus-dark", "light", "rgb(18, 18, 20)"] + ]) { + await writeFile(config, `[console]\nport = 0\ntheme = "${theme}"\n`); + await page.emulateMedia({ colorScheme: system }); + await page.goto(origin); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), expected); + } + } finally { await writeFile(config, "[console]\nport = 0\n"); } +}); diff --git a/src/console.rs b/src/console.rs index e3ca424d..22cd4f21 100644 --- a/src/console.rs +++ b/src/console.rs @@ -46,14 +46,8 @@ pub fn serve(no_sign_in: bool) -> Result<()> { .context("can't listen for the Console on localhost")?; println!("http://{}", listener.local_addr()?); std::io::stdout().flush()?; - let mut router = Router::new(); - for page in PAGES { - let cli = page.cli; - router = router.route(page.path, get(move || inbox(cli))); - } - router = router.route("/assets/{*path}", get(asset)); let watched = executable.clone(); - axum::serve(listener, router) + axum::serve(listener, router()) .with_graceful_shutdown(async move { watched.replaced().await }) .await .context("Console server failed")?; @@ -64,6 +58,16 @@ pub fn serve(no_sign_in: bool) -> Result<()> { executable.exec() } +fn router() -> Router { + let mut router = Router::new(); + for page in PAGES { + let cli = page.cli; + router = router.route(page.path, get(move || inbox(cli))); + } + router = router.route("/assets/{*path}", get(asset)); + router +} + #[derive(Template)] #[template(path = "console/inbox.html")] struct InboxPage { @@ -140,8 +144,42 @@ async fn asset(Path(path): Path) -> Response { #[cfg(test)] mod tests { - #[test] - fn every_console_route_has_a_cli_equivalent() { + use axum::{ + Router, + routing::{Route, get}, + }; + use std::sync::{Arc, Mutex}; + use tower_layer::Layer; + + // Router::route_layer walks every registered method service, including + // routes added directly instead of through PAGES. Nothing is dispatched. + #[derive(Clone)] + struct Routes(Arc>>); + + impl Layer for Routes { + type Service = Route; + fn layer(&self, route: Route) -> Route { + self.0.lock().unwrap().push(route.clone()); + route + } + } + + fn registered_methods(router: Router) -> usize { + let routes = Routes(Arc::default()); + let _router = router.route_layer(routes.clone()); + routes.0.lock().unwrap().len() + } + + fn assert_cli_parity(router: Router) { + // Measure the GET registration footprint through axum's public layer + // interface, so this does not depend on its implicit HEAD/fallback count. + let get_methods = registered_methods(Router::new().route("/known", get(|| async {}))); + let expected = (super::PAGES.len() + 1) * get_methods; // one static-resource route + assert_eq!( + registered_methods(router), + expected, + "registered routes must all have a CLI pair; only static resources are exempt" + ); for route in super::PAGES { assert!( !route.cli.trim().is_empty(), @@ -162,4 +200,17 @@ mod tests { ); } } + + #[test] + fn every_console_route_has_a_cli_equivalent() { + assert_cli_parity(super::router()); + } + + #[test] + #[should_panic(expected = "registered routes must all have a CLI pair")] + fn parity_rejects_a_route_registered_without_a_cli_pair() { + assert_cli_parity( + super::router().route("/unpaired", get(|| async { "page with no CLI pair" })), + ); + } } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index b1f52095..4e0522d6 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -3,33 +3,21 @@ /* Focus tokens from prototype/711-console themes.js (05b1b0a), ADR 0018. */ :root { - color-scheme: light; - --bg: #F7F7F5; --panel: #FFFFFF; --panel-2: #F0F0EE; --sel-bg: #EEEEEB; - --text: #18181A; --head: #18181A; --dim: #6B6B70; --rule: #E3E3E0; --link: #18181A; - --urgent: #D7263D; --urgent-text: #9A1424; --look: #B06800; --look-text: #7E4C00; - --working: #5E5E63; --working-text: #4A4A4F; --landing: #6B6B70; - --ready: #3F3F44; --ready-text: #3F3F44; --done: #6B6B70; + color-scheme: light dark; + --bg: light-dark(#F7F7F5, #121214); --panel: light-dark(#FFFFFF, #19191C); + --panel-2: light-dark(#F0F0EE, #212125); --sel-bg: light-dark(#EEEEEB, #2A2A2F); + --text: light-dark(#18181A, #E8E8EA); --head: var(--text); --link: var(--text); + --dim: light-dark(#6B6B70, #A0A0A8); --rule: light-dark(#E3E3E0, #2C2C31); + --urgent: light-dark(#D7263D, #FF6B78); --urgent-text: light-dark(#9A1424, #FFB0B5); + --look: light-dark(#B06800, #FFB020); --look-text: light-dark(#7E4C00, #FFB52E); + --working: light-dark(#5E5E63, #A8A8B0); --working-text: light-dark(#4A4A4F, #B4B4BC); + --landing: light-dark(#6B6B70, #9A9AA2); --ready: light-dark(#3F3F44, #C4C4CA); + --ready-text: var(--ready); --done: var(--landing); --font-head: "IBM Plex Sans", sans-serif; --font-body: "IBM Plex Sans", sans-serif; --font-mono: "IBM Plex Mono", monospace; --body-size: 14px; --head-weight: 600; --radius: 4px; } -@media (prefers-color-scheme: dark) { - :root:not([data-theme="focus-light"]) { - color-scheme: dark; - --bg: #121214; --panel: #19191C; --panel-2: #212125; --sel-bg: #2A2A2F; - --text: #E8E8EA; --head: #E8E8EA; --dim: #A0A0A8; --rule: #2C2C31; --link: #E8E8EA; - --urgent: #FF6B78; --urgent-text: #FFB0B5; --look: #FFB020; --look-text: #FFB52E; - --working: #A8A8B0; --working-text: #B4B4BC; --landing: #9A9AA2; - --ready: #C4C4CA; --ready-text: #C4C4CA; --done: #9A9AA2; - } -} -:root[data-theme="focus-dark"] { - color-scheme: dark; - --bg: #121214; --panel: #19191C; --panel-2: #212125; --sel-bg: #2A2A2F; - --text: #E8E8EA; --head: #E8E8EA; --dim: #A0A0A8; --rule: #2C2C31; --link: #E8E8EA; - --urgent: #FF6B78; --urgent-text: #FFB0B5; --look: #FFB020; --look-text: #FFB52E; - --working: #A8A8B0; --working-text: #B4B4BC; --landing: #9A9AA2; - --ready: #C4C4CA; --ready-text: #C4C4CA; --done: #9A9AA2; -} +:root[data-theme="focus-light"] { color-scheme: light; } +:root[data-theme="focus-dark"] { color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0 auto; max-width: 1120px; padding: 20px 28px; background: var(--bg); color: var(--text); font: var(--body-size)/1.4 var(--font-body); } header { border-bottom: 2px solid var(--head); padding-bottom: 14px; } From e77a6d9c65486b228763555ef85f878341c226b3 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 22:25:56 +0000 Subject: [PATCH 11/47] Complete the existing Setup fixture with Console defaults --- src/setup.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/setup.rs b/src/setup.rs index c95dd3b2..78b3b90c 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1130,6 +1130,11 @@ waiting_line_minutes = 5 days = 7 silent_minutes = 10 +[console] +port = 8711 +other_consoles = [] +theme = \"focus-auto\" + [activity] quiet_skips = true From 17b0fb5f638fd31c995a56a35b92c724a5e42c85 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 23:24:26 +0000 Subject: [PATCH 12/47] Avoid inherited writers in the Console replacement test The copied executable could fail to spawn with ETXTBSY when a concurrent test child inherited fs::copy's write handle. Copy in a separate cp process, as the existing replacement-test helper does, and wait for it to finish before launching the Console. Validated the exact failure with a forced inherited-writer reproduction before the fix and its passing result after it; cargo test, cargo fmt --check, and cargo clippy --all-targets -- -D warnings pass. --- tests/console.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/console.rs b/tests/console.rs index cdf5289a..e8455372 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -281,7 +281,14 @@ fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { let home = tempfile::tempdir().unwrap(); configure(home.path()); let binary = home.path().join("thirdshift"); - fs::copy(env!("CARGO_BIN_EXE_thirdshift"), &binary).unwrap(); + // A child forked by another test can inherit fs::copy's writer and keep + // the executable "Text file busy". Only the copying process opens it here. + let copied = Command::new("cp") + .arg(env!("CARGO_BIN_EXE_thirdshift")) + .arg(&binary) + .status() + .unwrap(); + assert!(copied.success()); let console = Console::start(&binary, home.path()); assert_eq!(console.get("/").status(), 200); let replacement = home.path().join("replacement"); From 33af751ee9cee8724e8a0f3897e100e9ce6dc770 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 23:51:23 +0000 Subject: [PATCH 13/47] Add Console conversation logs and byte-offset live readers --- Cargo.lock | 1 + Cargo.toml | 1 + console/README.md | 16 +++ console/tests/logs.test.cjs | 108 +++++++++++++++ docs/code-navigation.md | 1 + src/args.rs | 26 ++++ src/console.rs | 69 +++++++--- src/console/assets/console.css | 9 ++ src/console/assets/logs.js | 30 ++++ src/console/conversation.rs | 175 ++++++++++++++++++++++++ src/console/inbox.html | 1 + src/console/logs.html | 8 ++ src/console/logs.rs | 203 ++++++++++++++++++++++++++++ src/console/reader.html | 9 ++ src/console/row.html | 1 + src/logs.rs | 1 + src/logs/reading.rs | 1 + src/logs/reading/catalog.rs | 166 +++++++++++++++++++++++ src/logs/status/inbox.rs | 20 ++- src/main.rs | 14 ++ tests/console.rs | 186 +++++++++++++++++++++++++ tests/fixtures/console-claude.jsonl | 9 ++ tests/fixtures/console-codex.jsonl | 9 ++ 23 files changed, 1045 insertions(+), 19 deletions(-) create mode 100644 console/tests/logs.test.cjs create mode 100644 src/console/assets/logs.js create mode 100644 src/console/conversation.rs create mode 100644 src/console/logs.html create mode 100644 src/console/logs.rs create mode 100644 src/console/reader.html create mode 100644 src/logs/reading/catalog.rs create mode 100644 tests/fixtures/console-claude.jsonl create mode 100644 tests/fixtures/console-codex.jsonl diff --git a/Cargo.lock b/Cargo.lock index 6b1ad193..36097d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,7 @@ dependencies = [ "axoupdater", "axum", "chrono", + "futures-util", "include_dir", "libc", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 9fdea940..b3303421 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ axum = { version = "0.8.9", default-features = false, features = ["http1", "toki # needs an axoasset change or another updater (#174). axoupdater = { version = "0.10.2", default-features = false, features = ["github_releases", "blocking"] } chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +futures-util = { version = "0.3.34", default-features = false, features = ["std"] } include_dir = "0.7.4" libc = "0.2.189" reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "blocking", "json"] } diff --git a/console/README.md b/console/README.md index 8079a2a9..414ea90a 100644 --- a/console/README.md +++ b/console/README.md @@ -30,3 +30,19 @@ The header names this machine and links to those addresses, without fetching them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary makes the Console re-execute itself with its original arguments. + +Page 6, `/logs`, lists every Command log and Session log in the current +`commands/` layout, including Security logs, and marks the live ones. Every +Command row links to its Run and Session logs, including child Ticket Runs. +Older `sessions/` folders are not linked. The same inventory is available with +`thirdshift logs` or `thirdshift logs --json`; `thirdshift logs --file ` +reads an exact listed file and accepts `--follow`. + +Command logs are plain text. Claude and Codex Session logs are conversations +with folded tool groups, collapsed thinking, Bash, Edit, Read and Grep views, +and plain output for unfamiliar events. A live reader uses its own SSE stream +to append escaped HTML from a byte offset. Reconnecting resumes from the last +event's byte offset; incomplete live lines wait until their newline arrives. +The Follow newest line checkbox lets you read back while the Session grows. +The Playwright log test checks appends, retained DOM identity, folding across +batches, incomplete lines, reconnection and following on a phone viewport. diff --git a/console/tests/logs.test.cjs b/console/tests/logs.test.cjs new file mode 100644 index 00000000..1c3e394e --- /dev/null +++ b/console/tests/logs.test.cjs @@ -0,0 +1,108 @@ +const { test, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const { once } = require("node:events"); +const { mkdtemp, mkdir, readFile, writeFile, appendFile, rm } = require("node:fs/promises"); +const { tmpdir } = require("node:os"); +const { join, resolve } = require("node:path"); +const { createInterface } = require("node:readline"); +const { chromium } = require("playwright"); + +let home, server, exited, browser, origin, folder, log, recordPath, record; +const message = text => JSON.stringify({ type: "item.completed", item: { id: text, type: "agent_message", text } }) + "\n"; +const tool = text => JSON.stringify({ type: "item.completed", item: { id: text, type: "command_execution", command: text, aggregated_output: "output", status: "completed", exit_code: 0 } }) + "\n"; +before(async () => { + home = await mkdtemp(join(tmpdir(), "thirdshift-console-logs-")); + folder = join(home, ".thirdshift/logs/acme/widgets/commands/issue"); + await mkdir(folder, { recursive: true }); + await writeFile(join(home, ".thirdshift/config.toml"), "[console]\nport = 0\n"); + const stem = "7-20261003T120000-0400~fixture"; + log = join(folder, `${stem}-implement.jsonl`); + recordPath = join(folder, `${stem}.command.json`); + await writeFile(log, message("Initial retained line") + message("Tall initial message\n".repeat(150)) + tool("first tool")); + await writeFile(join(folder, `${stem}.log`), "Command output\n"); + // Match the native process identity used by the Linux Console test runner. + let processStart = "gone"; + if (process.platform === "linux") { + const stat = await readFile(`/proc/${process.pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(") ") + 2).trim().split(/\s+/); + processStart = `${(await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim()}:${fields[19]}`; + } + record = { + version: 1, kind: "Spec run", repository: "acme/widgets", name: "acme/widgets#7", hostname: "fixture", + issue: { number: 7, url: "https://github.com/acme/widgets/issues/7", title: "Widgets" }, + process_id: process.pid, process_start: processStart, harness: "codex", model: "gpt-6", effort: "high", + started_at: new Date().toISOString(), ended_at: null, stages: [], session_log: log, + outcome: null, cause: null, issue_branch: null, pull_request: null + }; + await writeFile(recordPath, JSON.stringify(record)); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, "../../target/debug/thirdshift"), ["console", "--no-sign-in"], { env: { ...process.env, HOME: home }, stdio: ["ignore", "pipe", "inherit"] }); + exited = once(server, "exit"); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, "line", { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test("a live Session appends without redraw, folds across batches, resumes offsets and obeys follow", { skip: process.platform !== "linux" }, async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(`${origin}/logs`); + assert.equal(await page.locator(".log-list strong").count(), 2); + await page.getByRole("link", { name: /Session log/ }).click(); + const initial = await page.locator("#log-content .message").first().elementHandle(); + await page.locator(".tool-group").evaluate(node => { node.open = true; }); + await appendFile(log, tool("second tool")); + await page.waitForFunction(() => document.querySelectorAll(".tool").length === 2); + assert.equal(await page.locator(".tool-group").count(), 1); + assert.equal(await page.locator(".tool-group").evaluate(node => node.open), true); + assert.equal(await initial.evaluate(node => node === document.querySelector("#log-content .message")), true); + await page.locator("#follow-log").uncheck(); + await page.evaluate(() => window.scrollTo(0, 0)); + const partial = message("Appended line"); + const previousOffset = await page.locator("#log-content").getAttribute("data-offset"); + await appendFile(log, partial.slice(0, -1)); + // Observe a complete polling interval; an unfinished JSON line must wait. + await page.waitForTimeout(650); + assert.equal(await page.locator("#log-content").getAttribute("data-offset"), previousOffset); + await appendFile(log, "\n"); + await page.getByText("Appended line", { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => scrollY), 0); + assert.equal(await page.locator("#log-content script").count(), 0); + assert.equal(await initial.evaluate(node => node.isConnected), true); + await page.locator("#follow-log").check(); + await appendFile(log, message("Followed newest line\n".repeat(60))); + await page.waitForFunction(() => document.querySelector("#log-content").textContent.includes("Followed newest line")); + await page.waitForFunction(() => scrollY + innerHeight >= document.documentElement.scrollHeight - 2); + const offset = Number(await page.locator("#log-content").getAttribute("data-offset")); + await appendFile(log, message("Reconnect only this line")); + // A new connection from the saved byte offset emits only subsequent lines. + const resumed = await page.evaluate(async offset => { + const base = document.querySelector("#log-content").dataset.stream.replace(/\d+$/, "0"); + const response = await fetch(base, { headers: { "Last-Event-ID": String(offset) } }); + const reader = response.body.getReader(); + const chunk = await reader.read(); + await reader.cancel(); + return new TextDecoder().decode(chunk.value); + }, offset); + assert.match(resumed, /Reconnect only this line/); + assert.doesNotMatch(resumed, /Initial retained line|Followed newest line/); + // A completed Command drains a final line even without its newline. + await appendFile(log, message("Final unterminated line").trimEnd()); + record.outcome = "ready for review"; + record.ended_at = new Date().toISOString(); + await writeFile(recordPath, JSON.stringify(record)); + await page.getByText("Final unterminated line", { exact: true }).waitFor(); + await page.waitForFunction(() => document.querySelector("#log-status").textContent === "Complete"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.deepEqual(errors, []); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 159beb87..1f496f7e 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -23,6 +23,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | +| Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | diff --git a/src/args.rs b/src/args.rs index 50962745..a974b64f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -42,6 +42,13 @@ pub enum Command { session: Option, follow: bool, }, + LogsList { + json: bool, + }, + LogFile { + id: String, + follow: bool, + }, /// Internal interface for scripts/release.sh: prompt on stdin. ReleaseSummary, /// `email-test`, with the address it was given, if any. @@ -277,6 +284,25 @@ pub fn parse(args: &[String]) -> Result { }; } Some("logs") => { + match &args[1..] { + [] => return Ok(Command::LogsList { json: false }), + [flag] if flag == "--json" => return Ok(Command::LogsList { json: true }), + [flag, id] if flag == "--file" && !id.starts_with('-') => { + return Ok(Command::LogFile { + id: id.clone(), + follow: false, + }); + } + [flag, id, follow] + if flag == "--file" && !id.starts_with('-') && follow == "--follow" => + { + return Ok(Command::LogFile { + id: id.clone(), + follow: true, + }); + } + _ => {} + } let usage = "usage: thirdshift logs [--session ] [--follow]"; let command = args .get(1) diff --git a/src/console.rs b/src/console.rs index 22cd4f21..3d48f998 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,4 +1,6 @@ //! The machine's Console. Async execution is confined to this module. +mod conversation; +mod logs; mod restart; use std::io::Write; @@ -25,10 +27,28 @@ struct PageRoute { cli: &'static str, } -const PAGES: &[PageRoute] = &[PageRoute { - path: "/", - cli: "thirdshift status", -}]; +const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/", + cli: "thirdshift status", + }, + PageRoute { + path: "/logs", + cli: "thirdshift logs --json", + }, + PageRoute { + path: "/logs/command/{id}", + cli: "thirdshift logs --json", + }, + PageRoute { + path: "/logs/{id}", + cli: "thirdshift logs --file id", + }, + PageRoute { + path: "/logs/{id}/stream/{offset}", + cli: "thirdshift logs --file id --follow", + }, +]; pub fn serve(no_sign_in: bool) -> Result<()> { ensure!( @@ -62,7 +82,15 @@ fn router() -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli; - router = router.route(page.path, get(move || inbox(cli))); + let handler = match page.path { + "/" => get(move || inbox(cli)), + "/logs" => get(logs::index), + "/logs/command/{id}" => get(logs::command), + "/logs/{id}" => get(logs::reader), + "/logs/{id}/stream/{offset}" => get(logs::stream), + _ => unreachable!("unknown Console page"), + }; + router = router.route(page.path, handler); } router = router.route("/assets/{*path}", get(asset)); router @@ -98,23 +126,32 @@ impl RowTemplate<'_> { async fn inbox(cli: &'static str) -> Response { // The shared observations and templates stay blocking, off the async executor. - match tokio::task::spawn_blocking(move || -> Result { - let config = UserConfig::load()?; - Ok(InboxPage { - hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), - consoles: config.console.other_consoles.clone(), - theme: config.console.theme.clone(), cli, - inbox: status::inbox(&config)?, - }.render()?) - }).await { + page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + Ok(InboxPage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + cli, + inbox: status::inbox(&config)?, + } + .render()?) + }) + .await, + ) +} + +fn page(result: std::result::Result, tokio::task::JoinError>) -> Response { + match result { Ok(Ok(html)) => ( [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), (header::CACHE_CONTROL, "no-store"), (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) ).into_response(), error => { - eprintln!("thirdshift Console: can't read Inbox: {error:?}"); - (StatusCode::INTERNAL_SERVER_ERROR, "Can't read the Inbox; check the Console's stderr.").into_response() + eprintln!("thirdshift Console: can't read page: {error:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Can't read this page; check the Console's stderr.").into_response() } } } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 4e0522d6..730e2af8 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -29,6 +29,7 @@ a { color: var(--link); overflow-wrap: anywhere; } .page-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 16px 0 8px; } h2 { margin: 0; font-weight: var(--head-weight); } code, .now, .subject, .plan { font-family: var(--font-mono); font-size: 12px; } +code { overflow-wrap: anywhere; } .group { border-bottom: 1px solid var(--rule); } summary { padding: 10px 2px; cursor: pointer; font-size: 17px; } summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } @@ -50,6 +51,14 @@ summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } .empty { margin: 0 0 12px 16px; } aside { font-size: 12px; } :focus-visible { outline: 2px solid var(--head); outline-offset: 2px; } +.log-list { padding-left: 20px; } +.log-list li { margin: 10px 0; overflow-wrap: anywhere; } +.log-list span { display: block; color: var(--dim); } +#log-content { margin-top: 16px; } +#log-content pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 12px; } +#log-content article, #log-content > pre, .thinking, .tool-group { padding: 8px 12px; border: 1px solid var(--rule); margin: 8px 0; border-radius: var(--radius); } +#log-content h3 { font-size: 14px; } +.diff { border-left: 3px solid var(--look); padding-left: 10px; } @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/assets/logs.js b/src/console/assets/logs.js new file mode 100644 index 00000000..ee388ee7 --- /dev/null +++ b/src/console/assets/logs.js @@ -0,0 +1,30 @@ +const content = document.querySelector("#log-content"); +const follow = document.querySelector("#follow-log"); +const status = document.querySelector("#log-status"); +const scroll = () => { if (follow.checked) window.scrollTo(0, document.documentElement.scrollHeight); }; +follow.addEventListener("change", scroll); +const stream = new EventSource(content.dataset.stream); +stream.addEventListener("lines", event => { + const batch = JSON.parse(event.data); + if (batch.offset > Number(content.dataset.offset)) { + const fragment = document.createElement("template"); + fragment.innerHTML = batch.html; + const last = content.lastElementChild; + const first = fragment.content.firstElementChild; + // A tool sequence crossing a stream batch remains one folded group. Existing + // nodes (and the reader's open/closed choices) are never replaced. + if (last?.classList.contains("tool-group") && first?.classList.contains("tool-group")) { + last.querySelector(".tools").append(...first.querySelector(".tools").childNodes); + first.remove(); + } + content.append(fragment.content); + content.dataset.offset = batch.offset; + scroll(); + } + status.textContent = batch.done ? "Complete" : "Live"; + if (batch.done) stream.close(); +}); +stream.addEventListener("log-error", event => { status.textContent = event.data; stream.close(); }); +stream.onerror = () => { status.textContent = "Connection lost; reconnecting…"; }; +window.addEventListener("pagehide", () => stream.close()); +scroll(); diff --git a/src/console/conversation.rs b/src/console/conversation.rs new file mode 100644 index 00000000..99cf3ba1 --- /dev/null +++ b/src/console/conversation.rs @@ -0,0 +1,175 @@ +//! Normalize retained Claude and Codex events into one escaped conversation. +use serde_json::Value; + +enum Part { + Message(String, String), + Thinking(String), + Tool(String, String), + Plain(String), +} + +pub(super) fn render(lines: &str, session: bool) -> String { + if !session { + return format!("
    {}
    ", escape(lines)); + } + let mut html = String::new(); + let mut tools_open = false; + for part in lines.lines().flat_map(parts) { + if matches!(part, Part::Tool(..)) { + if !tools_open { + html.push_str( + "
    Tools
    ", + ); + tools_open = true; + } + } else if tools_open { + html.push_str("
    "); + tools_open = false; + } + match part { + Part::Message(role, text) => html.push_str(&format!( + "

    {}

    {}
    ", + escape(&role), + escape(&text) + )), + Part::Thinking(text) => html.push_str(&format!( + "
    Thinking
    {}
    ", + escape(&text) + )), + Part::Tool(name, body) => html.push_str(&format!( + "

    {}

    {body}
    ", + escape(&name), + escape(&name) + )), + Part::Plain(text) => html.push_str(&format!( + "
    {}
    ", + escape(&text) + )), + } + } + if tools_open { + html.push_str(""); + } + html +} + +fn parts(line: &str) -> Vec { + let Ok(event) = serde_json::from_str::(line) else { + return vec![Part::Plain(line.into())]; + }; + match event["type"].as_str() { + Some("assistant" | "user") => { + let role = text(&event, "type"); + match &event["message"]["content"] { + Value::String(content) => vec![Part::Message(role.into(), content.clone())], + Value::Array(blocks) if !blocks.is_empty() => blocks + .iter() + .map(|block| match block["type"].as_str() { + Some("text") => Part::Message(role.into(), text(block, "text").into()), + Some("thinking") => Part::Thinking(text(block, "thinking").into()), + Some("tool_use") => tool(text(block, "name"), &block["input"]), + Some("tool_result") => Part::Tool( + "Tool result".into(), + pre("result", &pretty(&block["content"])), + ), + _ => Part::Plain(pretty(block)), + }) + .collect(), + _ => vec![Part::Plain(line.into())], + } + } + Some("item.started" | "item.updated" | "item.completed") => { + let item = &event["item"]; + vec![match item["type"].as_str() { + Some("agent_message") => { + Part::Message("assistant".into(), text(item, "text").into()) + } + Some("reasoning") => Part::Thinking(text(item, "text").into()), + Some("command_execution") => tool("Bash", item), + Some("file_change") => tool("Edit", item), + Some("mcp_tool_call") => tool(text(item, "tool"), item), + Some("web_search") => tool("Grep", item), + _ => Part::Plain(line.into()), + }] + } + _ => vec![Part::Plain(line.into())], + } +} + +fn tool(name: &str, input: &Value) -> Part { + let arguments = input.get("arguments").unwrap_or(input); + let body = match name.to_ascii_lowercase().as_str() { + "bash" | "exec_command" => format!( + "{}{}", + pre("bash", text(input, "command")), + pre("result", &pretty(input)) + ), + "edit" | "apply_patch" => { + let diff = if input["old_string"].is_string() && input["new_string"].is_string() { + let path = text(input, "file_path"); + let old = text(input, "old_string"); + let new = text(input, "new_string"); + format!( + "--- a/{path}\n+++ b/{path}\n@@ -1,{} +1,{} @@\n{}{}", + old.lines().count(), + new.lines().count(), + old.lines() + .map(|line| format!("-{line}\n")) + .collect::(), + new.lines() + .map(|line| format!("+{line}\n")) + .collect::() + ) + } else if let Some(changes) = input["changes"].as_array() { + changes + .iter() + .map(|change| { + let path = text(change, "path"); + let diff = text(change, "diff"); + if diff.is_empty() { + pretty(change) + } else { + format!("--- a/{path}\n+++ b/{path}\n{diff}") + } + }) + .collect::>() + .join("\n") + } else { + pretty(input) + }; + pre("diff", &diff) + } + "read" | "read_file" => format!( + "{}{}", + pre("read", text(arguments, "file_path")), + pre("result", &pretty(input)) + ), + "grep" | "search" => format!( + "{}{}", + pre("grep", text(arguments, "pattern")), + pre("result", &pretty(input)) + ), + _ => pre("fallback", &pretty(input)), + }; + Part::Tool(name.into(), body) +} + +fn pre(class: &str, text: &str) -> String { + format!("
    {}
    ", escape(text)) +} +fn text<'a>(value: &'a Value, key: &str) -> &'a str { + value[key].as_str().unwrap_or("") +} +fn pretty(value: &Value) -> String { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default()) +} +pub(super) fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/src/console/inbox.html b/src/console/inbox.html index a9bfdfd7..f7d24861 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -17,6 +17,7 @@ {% if !consoles.is_empty() %}{% endif %}
    +

    Inbox

    {{ cli }}
    {% match inbox.warning %}{% when Some with (warning) %}

    Warning: {{ warning }}

    {% when None %}{% endmatch %} {% for section in inbox.sections %} diff --git a/src/console/logs.html b/src/console/logs.html new file mode 100644 index 00000000..4aec87e9 --- /dev/null +++ b/src/console/logs.html @@ -0,0 +1,8 @@ + + +Logs · thirdshift +

    thirdshift / Console

    +

    Logs

    thirdshift logs --json
    + +{% if logs.is_empty() %}

    No logs found.

    {% endif %} +
    diff --git a/src/console/logs.rs b/src/console/logs.rs new file mode 100644 index 00000000..5b1f355a --- /dev/null +++ b/src/console/logs.rs @@ -0,0 +1,203 @@ +//! Log pages and their independent append-only HTML stream. +use std::convert::Infallible; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; + +use anyhow::{Result, ensure}; +use askama::Template; +use axum::extract::Path; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{ + IntoResponse, Response, Sse, + sse::{Event, KeepAlive}, +}; +use serde::Serialize; + +use super::conversation; +use crate::config::UserConfig; +use crate::logs::catalog::{self, Log}; + +#[derive(Template)] +#[template(path = "console/logs.html")] +struct LogsPage { + theme: String, + logs: Vec, +} + +#[derive(Template)] +#[template(path = "console/reader.html")] +struct ReaderPage { + theme: String, + log: Log, + html: String, + offset: u64, +} + +pub(super) async fn index() -> Response { + inventory(None).await +} +pub(super) async fn command(Path(id): Path) -> Response { + inventory(Some(id)).await +} + +async fn inventory(owner: Option) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + let mut logs = catalog::load(&config)?; + if let Some(owner) = owner { + logs.retain(|log| log.owners.contains(&owner)); + } + Ok(LogsPage { + theme: config.console.theme, + logs, + } + .render()?) + }) + .await, + ) +} + +pub(super) async fn reader(Path(id): Path) -> Response { + let result = tokio::task::spawn_blocking(move || -> Result> { + let config = UserConfig::load()?; + let Some(log) = catalog::load(&config)?.into_iter().find(|log| log.id == id) else { + return Ok(None); + }; + let mut file = BufReader::new(std::fs::File::open(&log.path)?); + let length = file.get_ref().metadata()?.len(); + let mut bytes = Vec::new(); + let mut offset = 0; + while offset < length { + let mut line = Vec::new(); + if file.read_until(b'\n', &mut line)? == 0 { + break; + } + if log.live && !line.ends_with(b"\n") { + break; + } + offset += line.len() as u64; + bytes.extend(line); + } + let html = + conversation::render(&String::from_utf8_lossy(&bytes), log.kind == "Session log"); + Ok(Some( + ReaderPage { + theme: config.console.theme, + log, + html, + offset, + } + .render()?, + )) + }) + .await; + match result { + Ok(Ok(Some(html))) => super::page(Ok(Ok(html))), + Ok(Ok(None)) => StatusCode::NOT_FOUND.into_response(), + Ok(Err(error)) => super::page(Ok(Err(error))), + Err(error) => super::page(Err(error)), + } +} + +#[derive(Debug, Serialize)] +struct Batch { + html: String, + offset: u64, + done: bool, +} + +fn appended(config: &UserConfig, id: &str, offset: u64) -> Result { + // Observe completion before the final drain, so final unterminated output + // is included even if the Command ended between polls. + let log = catalog::find(config, id)?; + let mut file = BufReader::new(std::fs::File::open(&log.path)?); + ensure!( + offset <= file.get_ref().metadata()?.len(), + "log was truncated; reopen the reader" + ); + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::new(); + let mut next = offset; + while bytes.len() < 256 * 1024 { + let mut line = Vec::new(); + if file.read_until(b'\n', &mut line)? == 0 { + break; + } + if log.live && !line.ends_with(b"\n") { + break; + } + next += line.len() as u64; + bytes.extend(line); + } + let done = !log.live && next >= file.get_ref().metadata()?.len(); + Ok(Batch { + html: conversation::render(&String::from_utf8_lossy(&bytes), log.kind == "Session log"), + offset: next, + done, + }) +} + +pub(super) async fn stream( + Path((id, offset)): Path<(String, u64)>, + headers: HeaderMap, +) -> Response { + let offset = headers + .get("last-event-id") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .unwrap_or(offset); + let checked = tokio::task::spawn_blocking(move || -> Result<_> { + let config = UserConfig::load()?; + let log = catalog::find(&config, &id)?; + ensure!( + offset <= std::fs::metadata(log.path)?.len(), + "invalid log offset" + ); + Ok((std::sync::Arc::new(config), id, offset, false)) + }) + .await; + let state = match checked { + Ok(Ok(state)) => state, + _ => return StatusCode::NOT_FOUND.into_response(), + }; + let events = futures_util::stream::unfold(state, |(config, id, mut offset, done)| async move { + if done { + return None; + } + loop { + let root = config.clone(); + let selected = id.clone(); + let result = + tokio::task::spawn_blocking(move || appended(&root, &selected, offset)).await; + match result { + Ok(Ok(batch)) if batch.offset != offset || batch.done => { + offset = batch.offset; + let done = batch.done; + let event = Event::default() + .event("lines") + .id(offset.to_string()) + .data(serde_json::to_string(&batch).expect("serializable log batch")); + return Some((Ok::<_, Infallible>(event), (config, id, offset, done))); + } + Ok(Ok(_)) => tokio::time::sleep(std::time::Duration::from_millis(250)).await, + error => { + eprintln!("thirdshift Console: log stream: {error:?}"); + let event = Event::default() + .event("log-error") + .data("Can't read log; reopen the reader. Check the Console's stderr."); + return Some((Ok(event), (config, id, offset, true))); + } + } + } + }); + let mut response = Sse::new(events) + .keep_alive(KeepAlive::default()) + .into_response(); + response + .headers_mut() + .insert("x-accel-buffering", "no".parse().unwrap()); + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + response +} diff --git a/src/console/reader.html b/src/console/reader.html new file mode 100644 index 00000000..640fe119 --- /dev/null +++ b/src/console/reader.html @@ -0,0 +1,9 @@ + + +{{ log.kind }} · thirdshift +

    thirdshift / Console

    +

    {{ log.kind }}

    {{ log.name }} {% if log.live %}live{% endif %}

    thirdshift logs --file {{ log.id }}

    +

    {% if log.live %}Live{% else %}Complete{% endif %}

    +{# Only the conversation renderer's escaped HTML is trusted. #} +
    {{ html|safe }}
    +
    diff --git a/src/console/row.html b/src/console/row.html index 31f68311..82e1a568 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,6 +1,7 @@
  • {{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}

    {{ row.now_line }}

    + Run and Session logs {% if !row.harness.is_empty() %}

    {{ row.harness }}

    {% endif %} {% if !row.pull_request.is_empty() %}

    {{ row.pull_request }}

    {% endif %} {% if row.count > 1 %}

    {{ row.count }} Commands with this cause: {{ row.cause }}

    {% else if !row.cause.is_empty() %}

    {{ row.cause }}

    {% endif %} diff --git a/src/logs.rs b/src/logs.rs index 6d88ab1a..7230eda4 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -17,6 +17,7 @@ mod effects; pub(crate) mod owner; mod process_identity; mod reading; +pub(crate) use reading::catalog; mod recording; pub mod status; pub mod stop; diff --git a/src/logs/reading.rs b/src/logs/reading.rs index 4e23478b..ac7e9934 100644 --- a/src/logs/reading.rs +++ b/src/logs/reading.rs @@ -1,6 +1,7 @@ //! Filesystem observations of Commands, their Runs and exact ownership. //! Loading uses one sampled time; reading never prints or performs Stop effects. +pub(crate) mod catalog; mod health; mod history; mod inbox; diff --git a/src/logs/reading/catalog.rs b/src/logs/reading/catalog.rs new file mode 100644 index 00000000..e3bca493 --- /dev/null +++ b/src/logs/reading/catalog.rs @@ -0,0 +1,166 @@ +//! Current-layout log inventory shared by the CLI and Console. IDs name exact +//! retained files, including older Commands with the same issue or start stamp. +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::Local; +use serde::Serialize; + +use super::{Command, Entry, Observation, Snapshot, directories, entries}; +use crate::config::UserConfig; +use crate::logs::owner; + +#[derive(Serialize)] +pub(crate) struct Log { + pub id: String, + pub name: String, + pub repository: String, + pub kind: &'static str, + pub live: bool, + pub command: String, + pub owners: Vec, + #[serde(skip)] + pub path: PathBuf, +} + +pub(crate) fn id(path: &Path) -> String { + path.as_os_str() + .as_encoded_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn load(config: &UserConfig) -> Result> { + let root = &config.logs_dir; + let snapshot = Snapshot::load(root, Local::now())?; + // A child Run keeps its own Sessions, while sharing its Command's log. + let mut observed = BTreeMap::new(); + fn collect( + command: &Command, + root: &Path, + ancestors: &[String], + observed: &mut BTreeMap)>, + ) -> Result<()> { + let mut owners = ancestors.to_vec(); + owners.push(id(command.record_path.strip_prefix(root)?)); + let subject = command + .record + .issue + .as_ref() + .map(|issue| format!("{}#{}", command.record.repository, issue.number)) + .unwrap_or_else(|| command.record.name.clone()); + observed.entry(command.command_log.clone()).or_insert(( + command.state == "live", + subject.clone(), + owners.clone(), + )); + for session in Observation::Record(command).sessions()? { + let live = + command.state == "live" && command.record.session_log.as_ref() == Some(&session); + observed.insert(session, (live, subject.clone(), owners.clone())); + } + for child in command.dispatched.iter().chain(&command.children) { + collect(child, root, &owners, observed)?; + } + Ok(()) + } + for entry in &snapshot.commands { + if let Entry::Record(command) = entry { + collect(command, root, &[], &mut observed)?; + } + } + let mut logs = Vec::new(); + for owner_dir in directories(root)? { + for repo in directories(&owner_dir)? { + let repository = format!( + "{}/{}", + owner_dir.file_name().unwrap_or_default().to_string_lossy(), + repo.file_name().unwrap_or_default().to_string_lossy() + ); + for folder in directories(&repo.join("commands"))? { + for entry in entries(&folder)? { + let path = entry.path(); + if !entry.file_type()?.is_file() + || !matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("log" | "jsonl") + ) + { + continue; + } + let relative = path.strip_prefix(root)?; + let (live, command, mut owners) = observed.remove(&path).unwrap_or_default(); + if let Some(decoded) = owner::decode(&path) { + owners.push(id(decoded.command_log(&path).strip_prefix(root)?)); + } + logs.push(Log { + id: id(relative), + name: relative.to_string_lossy().into_owned(), + repository: repository.clone(), + kind: if path.extension().is_some_and(|ext| ext == "jsonl") { + "Session log" + } else { + "Command log" + }, + live, + command, + owners, + path, + }); + } + } + } + } + logs.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(logs) +} + +pub(crate) fn find(config: &UserConfig, selected: &str) -> Result { + load(config)? + .into_iter() + .find(|log| log.id == selected) + .context("log is not in the current inventory") +} + +pub(crate) fn show(json: bool) -> Result<()> { + let logs = load(&UserConfig::load()?)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({"version": 1, "logs": logs}))? + ); + } else { + for log in logs { + println!( + "{} · {}{} · {} · {}", + log.id, + log.kind, + if log.live { " · live" } else { "" }, + log.command, + log.name + ); + } + } + Ok(()) +} + +pub(crate) fn print_file(selected: &str, follow: bool) -> Result<()> { + let config = UserConfig::load()?; + let log = find(&config, selected)?; + let mut file = std::fs::File::open(&log.path)?; + let mut stdout = std::io::stdout().lock(); + loop { + let live = follow && find(&config, selected)?.live; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + stdout.write_all(&bytes)?; + stdout.flush()?; + if !live { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs index 9901f0f7..1cee64d0 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -22,6 +22,7 @@ pub(crate) struct Section { } pub(crate) struct Row { + pub logs_url: String, pub subject: String, pub title: String, pub state: String, @@ -48,11 +49,19 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { .filter(|item| item.group == group) .map(|item| { let mut row = match item.command { - Entry::Record(command) => row(command, now), + Entry::Record(command) => row(command, now, &config.logs_dir), Entry::History(command) => { let (subject, title) = subject(&command.repository, &command.kind, command.issue.as_ref()); Row { + logs_url: command + .command_log + .as_ref() + .and_then(|path| path.strip_prefix(&config.logs_dir).ok()) + .map(|path| { + format!("/logs/command/{}", crate::logs::catalog::id(path)) + }) + .unwrap_or_else(|| "/logs".into()), subject, title, state: command.state.into(), @@ -139,10 +148,15 @@ fn subject(repository: &str, name: &str, issue: Option<&Issue>) -> (String, Stri } } -fn row(command: &Command, now: DateTime) -> Row { +fn row(command: &Command, now: DateTime, root: &std::path::Path) -> Row { let facts = &command.record; let (subject, title) = subject(&facts.repository, &facts.name, facts.issue.as_ref()); Row { + logs_url: command + .record_path + .strip_prefix(root) + .map(|path| format!("/logs/command/{}", crate::logs::catalog::id(path))) + .unwrap_or_else(|_| "/logs".into()), subject, title, state: command.state.into(), @@ -166,7 +180,7 @@ fn row(command: &Command, now: DateTime) -> Row { .dispatched .iter() .chain(&command.children) - .map(|child| row(child, now)) + .map(|child| row(child, now, root)) .collect(), } } diff --git a/src/main.rs b/src/main.rs index 9c889c9e..7db37057 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,8 @@ usage: thirdshift Run the factory on the iss thirdshift console --no-sign-in Serve this machine's Inbox on localhost thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup + thirdshift logs [--json] List this machine's logs + thirdshift logs --file [--follow] Read an exact listed log thirdshift logs [--session ] [--follow] Read or follow a log thirdshift update Update to the latest release thirdshift version Print the version @@ -161,6 +163,18 @@ fn main() -> ExitCode { Err(error) => failure(&error), }; } + Ok(Command::LogsList { json }) => { + return match logs::catalog::show(json) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } + Ok(Command::LogFile { id, follow }) => { + return match logs::catalog::print_file(&id, follow) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } Ok(Command::Status { json, all }) => { return match logs::status::show(json, all) { Ok(()) => ExitCode::SUCCESS, diff --git a/tests/console.rs b/tests/console.rs index e8455372..4c6efaf4 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -104,6 +104,174 @@ fn write_record(home: &Path, number: u64, record: &Value) { .unwrap(); } +#[test] +fn logs_inventory_matches_cli_and_reads_current_layout_only() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + fs::create_dir_all(&folder).unwrap(); + fs::write( + folder.join("7-20261003T120000-0400.log"), + "Command \n", + ) + .unwrap(); + fs::write(folder.join("7-20261003T120000-0400-implement.jsonl"), + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"Hello \"}}\n").unwrap(); + let old = home.path().join(".thirdshift/logs/acme/widgets/sessions"); + fs::create_dir_all(&old).unwrap(); + fs::write(old.join("7-20261003T120000-0400-implement.jsonl"), "old\n").unwrap(); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start(binary, home.path()); + let output = Command::new(binary) + .args(["logs", "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let inventory: Value = serde_json::from_slice(&output.stdout).unwrap(); + let logs = inventory["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 2); + let page = console.get("/logs").text().unwrap(); + assert!(page.contains("thirdshift logs")); + for log in logs { + let url = format!("/logs/{}", log["id"].as_str().unwrap()); + assert!(page.contains(&url)); + let response = console.get(&url); + assert_eq!(response.status(), 200); + let html = response.text().unwrap(); + let raw = Command::new(binary) + .args(["logs", "--file", log["id"].as_str().unwrap()]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(raw.status.success()); + if log["kind"] == "Command log" { + assert_eq!(raw.stdout, b"Command \n"); + assert!( + html.contains("Command <output>") + || html.contains("Command <output>"), + "{html}" + ); + } else { + assert!( + html.contains("Hello <reader>") || html.contains("Hello <reader>"), + "{html}" + ); + } + assert!(!html.contains("")); + } + assert_eq!(console.get("/logs/not-an-inventory-id").status(), 404); +} + +#[test] +fn log_stream_starts_at_the_byte_offset_and_sends_only_appended_html() { + use std::io::Write; + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + fs::create_dir_all(&folder).unwrap(); + let path = folder.join("7-20261003T120000-0400-implement.jsonl"); + let first = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"original message\"}}\n"; + fs::write(&path, first).unwrap(); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let inventory = console.get("/logs").text().unwrap(); + let link = inventory + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(url, _)| url.starts_with("/logs/")) + .unwrap() + .0; + let initial = console.get(link).text().unwrap(); + assert!(initial.contains(&format!("data-offset=\"{}\"", first.len()))); + let second = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"new café message\"}}\n"; + fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(second.as_bytes()) + .unwrap(); + let response = console.get(&format!("{link}/stream/{}", first.len())); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + let events = response.text().unwrap(); + assert!(!events.contains("original message"), "{events}"); + assert!(events.contains("new café message"), "{events}"); + assert!( + events.contains(&format!("id: {}", first.len() + second.len())), + "{events}" + ); + assert!(events.contains("\"done\":true")); +} + +#[test] +fn claude_and_codex_conversations_keep_tools_thinking_and_unknown_events() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + fs::create_dir_all(&folder).unwrap(); + for (kind, fixture) in [ + ("claude", include_str!("fixtures/console-claude.jsonl")), + ("codex", include_str!("fixtures/console-codex.jsonl")), + ] { + fs::write( + folder.join(format!("7-20261003T120000-0400-{kind}.jsonl")), + fixture, + ) + .unwrap(); + } + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let page = console.get("/logs").text().unwrap(); + for (kind, thinking) in [ + ("claude", "Consider the widget shape."), + ("codex", "Consider a safe edit."), + ] { + let link = page + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(_, label)| { + label + .split("") + .next() + .unwrap() + .ends_with(&format!("-{kind}.jsonl")) + }) + .unwrap() + .0; + let html = console.get(link).text().unwrap(); + assert!(html.contains(thinking)); + assert!(html.contains("
    Thinking")); + assert!(html.contains("
    Tools")); + assert!(html.contains("data-tool=\"Bash\"")); + assert!(html.contains("data-tool=\"Read\"")); + assert!(html.contains("class=\"diff\"")); + assert!(html.contains("-old widget\n+new widget"), "{html}"); + assert!(html.contains("Widgets are ready.")); + if kind == "claude" { + assert_eq!(html.matches("class=\"tool-group\"").count(), 1); + assert!(html.contains("data-tool=\"Grep\"")); + assert!(html.contains("data-tool=\"NewTool\"")); + assert!(html.contains("unknown block stays visible")); + assert!(html.contains("unknown event stays visible")); + assert!(html.contains("not JSON: retained diagnostic")); + } else { + assert!(html.contains("unknown Codex item visible")); + } + assert!(!html.contains("")); + } +} + #[test] fn inbox_over_http_matches_status_and_embeds_only_local_resources() { let home = tempfile::tempdir().unwrap(); @@ -218,8 +386,26 @@ fn child_runs_nest_and_each_request_reads_current_records() { .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture.command.json") ); write_record(home.path(), 8, &child); + let child_log = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue/8-fixture-implement.jsonl"); + fs::write(&child_log, "Ticket Session output\n").unwrap(); + child["session_log"] = json!(child_log); + write_record(home.path(), 8, &child); let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); let html = console.get("/").text().unwrap(); + let parent_link = html + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(_, label)| label.starts_with(">Run and Session logs")) + .unwrap() + .0; + let parent_logs = console.get(parent_link).text().unwrap(); + assert!( + parent_logs.contains("8-fixture-implement.jsonl"), + "{parent_logs}" + ); let nested = html.split("class=\"children\"").nth(1).expect(&html); assert!( nested diff --git a/tests/fixtures/console-claude.jsonl b/tests/fixtures/console-claude.jsonl new file mode 100644 index 00000000..5aeb3e1a --- /dev/null +++ b/tests/fixtures/console-claude.jsonl @@ -0,0 +1,9 @@ +{"type":"system","subtype":"init","session_id":"made-up-session"} +{"type":"user","message":{"role":"user","content":"Make the widgets ."}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Consider the widget shape."},{"type":"text","text":"I will inspect the widgets."}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"read_1","name":"Read","input":{"file_path":"/project/widgets.rs"}}]}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"old widget"}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"grep_1","name":"Grep","input":{"pattern":"widget","path":"/project"}},{"type":"tool_use","id":"edit_1","name":"Edit","input":{"file_path":"widgets.rs","old_string":"old widget","new_string":"new widget"}},{"type":"tool_use","id":"bash_1","name":"Bash","input":{"command":"cargo test widgets"}},{"type":"tool_use","id":"other_1","name":"NewTool","input":{"future":"visible"}}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Widgets are ready."},{"type":"future_block","detail":"unknown block stays visible"}]}} +{"type":"future_event","detail":"unknown event stays visible "} +not JSON: retained diagnostic diff --git a/tests/fixtures/console-codex.jsonl b/tests/fixtures/console-codex.jsonl new file mode 100644 index 00000000..76cfa516 --- /dev/null +++ b/tests/fixtures/console-codex.jsonl @@ -0,0 +1,9 @@ +{"type":"thread.started","thread_id":"made-up-thread"} +{"type":"item.completed","item":{"id":"r1","type":"reasoning","text":"Consider a safe edit."}} +{"type":"item.completed","item":{"id":"a1","type":"agent_message","text":"I will inspect the widgets."}} +{"type":"item.started","item":{"id":"c1","type":"command_execution","command":"cat widgets.rs","aggregated_output":"","status":"in_progress"}} +{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cat widgets.rs","aggregated_output":"widget output ","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"f1","type":"file_change","changes":[{"path":"widgets.rs","kind":"update","diff":"@@ -1 +1 @@\n-old widget\n+new widget"}],"status":"completed"}} +{"type":"item.completed","item":{"id":"m1","type":"mcp_tool_call","server":"files","tool":"Read","arguments":{"file_path":"widgets.rs"},"result":{"content":[{"type":"text","text":"new widget"}]},"status":"completed"}} +{"type":"item.completed","item":{"id":"z1","type":"future_item","detail":"unknown Codex item visible"}} +{"type":"item.completed","item":{"id":"a2","type":"agent_message","text":"Widgets are ready."}} From 2d99af034c440e397566c57549948fea38c8fb9b Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 23:55:03 +0000 Subject: [PATCH 14/47] Keep the Console Inbox current with server push (#818) --- Cargo.lock | 26 +++++ Cargo.toml | 3 +- console/README.md | 15 ++- console/tests/inbox.test.cjs | 161 +++++++++++++++++++++++++- src/console.rs | 102 +++++++++++----- src/console/assets/console.css | 2 + src/console/assets/console.js | 113 ++++++++++++++++-- src/console/assets/favicon-urgent.svg | 1 + src/console/assets/favicon.svg | 1 + src/console/inbox.html | 8 +- src/console/live.rs | 40 +++++++ src/console/row.html | 4 +- src/logs/status/inbox.rs | 53 ++++++++- tests/console.rs | 131 +++++++++++++++++++++ 14 files changed, 610 insertions(+), 50 deletions(-) create mode 100644 src/console/assets/favicon-urgent.svg create mode 100644 src/console/assets/favicon.svg create mode 100644 src/console/live.rs diff --git a/Cargo.lock b/Cargo.lock index 6b1ad193..20982ec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1609,6 +1609,7 @@ dependencies = [ "signal-hook", "tempfile", "tokio", + "tokio-stream", "toml", "toml_edit", "tower-layer", @@ -1688,6 +1689,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9fdea940..0a774b5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,8 @@ signal-hook = "0.4.4" tempfile = "3.27.0" toml = "1.1.6" toml_edit = "0.25.15" -tokio = { version = "1.53.1", features = ["rt", "net", "time"] } +tokio = { version = "1.53.1", features = ["rt", "net", "time", "sync"] } +tokio-stream = { version = "0.1.18", features = ["sync"] } [dev-dependencies] tower-layer = "0.3.3" diff --git a/console/README.md b/console/README.md index 8079a2a9..b7668084 100644 --- a/console/README.md +++ b/console/README.md @@ -27,6 +27,15 @@ theme = "focus-auto" # also focus-light or focus-dark ``` The header names this machine and links to those addresses, without fetching -them. The first page pairs with `thirdshift status`. A refresh reads the current -records; server-push updates are a later Ticket. Replacing the installed binary -makes the Console re-execute itself with its original arguments. +them. The Inbox and its `/events` stream pair with `thirdshift status`. One +server observer checks records and log lengths every second; the page morphs +after a change notice, preserving the selected row, folds and scroll. If the +stream drops or is unavailable, the page polls every five seconds until it +reconnects. The heartbeat only beats for observed log growth, stays flat while +Silent and respects reduced motion. The tab counts Needs you and its favicon +marks urgent items (Slow alone is not urgent). + +Folds and the last-look time stay in this browser. Commands started since the +previous visit are marked new, including those arriving while the page is open. +Missing or malformed storage hides nothing. Replacing the installed binary +closes open streams and re-executes the Console with its original arguments. diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 088b8f3b..b1d92e1b 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -2,7 +2,7 @@ const { test, before, after } = require("node:test"); const assert = require("node:assert/strict"); const { spawn } = require("node:child_process"); const { once } = require("node:events"); -const { mkdtemp, mkdir, writeFile, rm } = require("node:fs/promises"); +const { mkdtemp, mkdir, readFile, writeFile, appendFile, utimes, rm } = require("node:fs/promises"); const { tmpdir } = require("node:os"); const { join, resolve } = require("node:path"); const { createInterface } = require("node:readline"); @@ -110,3 +110,162 @@ test("explicit Focus light and dark override the system preference", async t => } } finally { await writeFile(config, "[console]\nport = 0\n"); } }); + +test("push morphs keep the selected row, folds and scroll without a reload", async t => { + const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(origin); + const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#1$/ }) }); + await page.locator('[data-group="done"] > summary').click(); + await row.click(); + await page.evaluate(() => { + window.visitMarker = "same document"; + window.selectedRow = document.activeElement; + document.querySelector('.commands').scrollTop = 120; + }); + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const fixture = JSON.parse(await readFile(path, 'utf8')); + fixture.issue.title = 'Advanced while you watched'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText('Advanced while you watched', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement === window.selectedRow && document.activeElement.matches('.command')), true); + assert.equal(await page.evaluate(() => window.visitMarker), "same document"); + assert.equal(await page.locator('[data-group="done"][open]').count(), 1); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 1); + assert.equal(await page.locator('[data-group="needs you"] .commands').evaluate(el => el.scrollTop), 120); + assert.deepEqual(errors, []); +}); + +test("tab title and favicon follow Needs you through pushes", async t => { + const page = await pageFor(t); + await page.goto(origin); + assert.equal(await page.title(), '10 need you · thirdshift Console'); + assert.equal(await page.locator('link[rel="icon"]').getAttribute('href'), '/assets/favicon-urgent.svg'); + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const fixtures = []; + try { + for (let number = 1; number <= 10; number++) { + const path = join(dir, `${number}-fixture.command.json`); + const original = await readFile(path, 'utf8'); + fixtures.push([path, original]); + const fixture = JSON.parse(original); + fixture.outcome = 'merged'; + await writeFile(path, JSON.stringify(fixture)); + } + await page.waitForFunction(() => document.title === '0 need you · thirdshift Console'); + assert.equal(await page.locator('link[rel="icon"]').getAttribute('href'), '/assets/favicon.svg'); + } finally { for (const [path, original] of fixtures) await writeFile(path, original); } +}); + +test("new since the last visit survives browser storage and clears after looking", async t => { + const page = await pageFor(t); + await page.goto(origin); + await page.waitForFunction(() => localStorage.getItem('thirdshift:console:last-look') !== null); + await page.goto('about:blank'); + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const path = join(dir, '201-fixture.command.json'); + try { + const fixture = JSON.parse(await readFile(join(dir, '1-fixture.command.json'), 'utf8')); + fixture.issue.number = 201; + fixture.started_at = fixture.ended_at = new Date().toISOString(); + fixture.cause = 'New problem after the last visit'; + await writeFile(path, JSON.stringify(fixture)); + await page.goto(origin); + const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#201$/ }) }); + await row.locator('.new').waitFor(); + assert.equal(await page.locator('.new:visible').count(), 1); + await page.reload(); + assert.equal(await page.locator('.new:visible').count(), 0); + } finally { await rm(path, { force: true }); } +}); + +test("cutting the open stream falls back to polling and catches up", async t => { + const page = await pageFor(t); + await page.addInitScript(() => { + const NativeSource = EventSource; + window.EventSource = class extends NativeSource { + constructor(...args) { super(...args); window.inboxStream = this; } + }; + }); + await page.goto(origin); + await page.waitForFunction(() => window.inboxStream?.readyState === EventSource.OPEN); + await page.evaluate(() => { + window.inboxStream.close(); + window.inboxStream.dispatchEvent(new Event('error')); + }); + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + try { + const fixture = JSON.parse(original); + fixture.issue.title = 'Caught up by polling'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText('Caught up by polling', { exact: true }).waitFor({ timeout: 12000 }); + assert.equal(await page.evaluate(() => window.inboxStream.readyState), 2); + } finally { await writeFile(path, original); } +}); + +for (const storage of ['blocked', 'malformed']) { + test(`live Inbox works with ${storage} browser storage`, async t => { + const page = await pageFor(t); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(mode => { + if (mode === 'blocked') Object.defineProperty(window, 'localStorage', { get() { throw new Error('Storage blocked'); } }); + else { + localStorage.setItem('thirdshift:console:folded', 'false'); + localStorage.setItem('thirdshift:console:last-look', '{broken'); + } + }, storage); + await page.goto(origin); + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + try { + const fixture = JSON.parse(original); + fixture.issue.title = `Still live with ${storage} storage`; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText(fixture.issue.title, { exact: true }).waitFor(); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 1); + assert.deepEqual(errors, []); + } finally { await writeFile(path, original); } + }); +} + +test("heartbeat beats only for server-observed log growth and is flat when Silent", { skip: process.platform !== 'linux' }, async t => { + // This fixture borrows the Console's real native process identity. HTTP + // coverage uses an actual held Run on both Linux and macOS. + const stat = await readFile(`/proc/${server.pid}/stat`, 'utf8'); + const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`; + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + const path = join(dir, '202-fixture.command.json'); + const log = join(dir, '202-fixture.log'); + const session = join(home, 'heartbeat-session.log'); + const fixture = JSON.parse(await readFile(join(dir, '1-fixture.command.json'), 'utf8')); + Object.assign(fixture, { kind: 'Run', process_id: server.pid, process_start: start, outcome: null, ended_at: null, session_log: session }); + fixture.issue.number = 202; + const began = new Date(Date.now() - 20 * 60 * 1000).toISOString(); + fixture.stages = [{ name: 'implement', began_at: began, session_log: session }]; + await writeFile(path, JSON.stringify(fixture)); + await writeFile(log, 'command'); + await writeFile(session, 'session'); + try { + const page = await pageFor(t); + await page.goto(origin); + await page.locator('[data-group="working"] > summary').click(); + const row = page.locator('.command').filter({ has: page.locator('.subject', { hasText: /^acme\/widgets#202$/ }) }); + const id = await row.getAttribute('id'); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + await appendFile(session, ' growth'); + await page.waitForFunction(id => document.getElementById(id).querySelector('.heartbeat').getAnimations().some(animation => animation.playState === 'running'), id); + await page.waitForFunction(id => document.getElementById(id).querySelector('.heartbeat').getAnimations().length === 0, id); + const old = new Date(Date.now() - 20 * 60 * 1000); + await utimes(log, old, old); + await utimes(session, old, old); + await page.waitForFunction(id => document.getElementById(id).dataset.state === 'Silent', id); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await appendFile(log, ' more'); + await page.waitForFunction(id => document.getElementById(id).dataset.logging === 'true', id); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + } finally { await Promise.all([path, log, session].map(path => rm(path, { force: true }))); } +}); diff --git a/src/console.rs b/src/console.rs index 22cd4f21..8834948e 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,4 +1,5 @@ //! The machine's Console. Async execution is confined to this module. +mod live; mod restart; use std::io::Write; @@ -23,12 +24,26 @@ static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); struct PageRoute { path: &'static str, cli: &'static str, + view: View, } -const PAGES: &[PageRoute] = &[PageRoute { - path: "/", - cli: "thirdshift status", -}]; +enum View { + Inbox, + Changes, +} + +const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/", + cli: "thirdshift status", + view: View::Inbox, + }, + PageRoute { + path: "/events", + cli: "thirdshift status", + view: View::Changes, + }, +]; pub fn serve(no_sign_in: bool) -> Result<()> { ensure!( @@ -47,8 +62,15 @@ pub fn serve(no_sign_in: bool) -> Result<()> { println!("http://{}", listener.local_addr()?); std::io::stdout().flush()?; let watched = executable.clone(); - axum::serve(listener, router()) - .with_graceful_shutdown(async move { watched.replaced().await }) + let (changes, observer) = live::observe(render_inbox("thirdshift status").await?); + axum::serve(listener, router(changes)) + .with_graceful_shutdown(async move { + watched.replaced().await; + // Dropping the sole sender closes every SSE response before + // graceful shutdown waits for the connections to finish. + observer.abort(); + let _ = observer.await; + }) .await .context("Console server failed")?; Ok::<_, anyhow::Error>(()) @@ -58,14 +80,20 @@ pub fn serve(no_sign_in: bool) -> Result<()> { executable.exec() } -fn router() -> Router { +fn router(changes: tokio::sync::watch::Receiver) -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli; - router = router.route(page.path, get(move || inbox(cli))); + router = router.route( + page.path, + match page.view { + View::Inbox => get(move || inbox(cli)), + View::Changes => get(live::events), + }, + ); } router = router.route("/assets/{*path}", get(asset)); - router + router.with_state(changes) } #[derive(Template)] @@ -79,6 +107,17 @@ struct InboxPage { } impl InboxPage { + fn needs_you(&self) -> usize { + self.inbox.sections[0].rows.len() + } + + fn urgent(&self) -> bool { + self.inbox.sections[0] + .rows + .iter() + .any(|row| row.state != "Slow") + } + fn row<'a>(&self, row: &'a Row) -> RowTemplate<'a> { RowTemplate { row } } @@ -97,28 +136,35 @@ impl RowTemplate<'_> { } async fn inbox(cli: &'static str) -> Response { - // The shared observations and templates stay blocking, off the async executor. - match tokio::task::spawn_blocking(move || -> Result { - let config = UserConfig::load()?; - Ok(InboxPage { - hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), - consoles: config.console.other_consoles.clone(), - theme: config.console.theme.clone(), cli, - inbox: status::inbox(&config)?, - }.render()?) - }).await { - Ok(Ok(html)) => ( + match render_inbox(cli).await { + Ok(html) => ( [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), (header::CACHE_CONTROL, "no-store"), (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) ).into_response(), - error => { - eprintln!("thirdshift Console: can't read Inbox: {error:?}"); + Err(error) => { + eprintln!("thirdshift Console: can't read Inbox: {error:#}"); (StatusCode::INTERNAL_SERVER_ERROR, "Can't read the Inbox; check the Console's stderr.").into_response() } } } +async fn render_inbox(cli: &'static str) -> Result { + // The shared observations and templates stay blocking, off the async executor. + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + Ok(InboxPage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + cli, + inbox: status::inbox(&config)?, + } + .render()?) + }) + .await? +} + async fn asset(Path(path): Path) -> Response { let Some(file) = ASSETS.get_file(&path) else { return StatusCode::NOT_FOUND.into_response(); @@ -129,6 +175,7 @@ async fn asset(Path(path): Path) -> Response { { Some("css") => "text/css; charset=utf-8", Some("js") => "text/javascript; charset=utf-8", + Some("svg") => "image/svg+xml", Some("woff2") => "font/woff2", _ => "text/plain; charset=utf-8", }; @@ -151,6 +198,11 @@ mod tests { use std::sync::{Arc, Mutex}; use tower_layer::Layer; + fn router() -> Router { + let (_, changes) = tokio::sync::watch::channel(0); + super::router(changes) + } + // Router::route_layer walks every registered method service, including // routes added directly instead of through PAGES. Nothing is dispatched. #[derive(Clone)] @@ -203,14 +255,12 @@ mod tests { #[test] fn every_console_route_has_a_cli_equivalent() { - assert_cli_parity(super::router()); + assert_cli_parity(router()); } #[test] #[should_panic(expected = "registered routes must all have a CLI pair")] fn parity_rejects_a_route_registered_without_a_cli_pair() { - assert_cli_parity( - super::router().route("/unpaired", get(|| async { "page with no CLI pair" })), - ); + assert_cli_parity(router().route("/unpaired", get(|| async { "page with no CLI pair" }))); } } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 4e0522d6..97b38d73 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -35,8 +35,10 @@ summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } .commands, .children, .plan { list-style: none; padding: 0; margin: 0; } .commands { max-height: 300px; overflow: auto; } .command { padding: 8px 10px; margin-bottom: 4px; border-radius: var(--radius); color: var(--dim); overflow-wrap: anywhere; } +.command:focus { outline: 2px solid var(--head); outline-offset: -2px; } .row-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } .state { color: var(--text); } +.heartbeat { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--working); opacity: .4; } .age { margin-left: auto; white-space: nowrap; } .issue-title { flex: 1 1 160px; } .command p { margin: 4px 0 0; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index cc45e621..776feca5 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -1,12 +1,107 @@ -// Folded sections are a per-browser convenience. A fresh visit opens Needs you. -const key = "thirdshift:console:folded"; -let folded = {}; -try { folded = JSON.parse(localStorage.getItem(key) ?? "{}"); } catch { /* storage is optional */ } +// Browser preferences are conveniences; malformed or unavailable storage hides nothing. +function stored(key, fallback) { + try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } +} +function remember(key, value) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage is optional */ } +} +const visitKey = "thirdshift:console:last-look"; +const visit = stored(visitKey, Date.now()); +const lastLook = typeof visit === "number" && Number.isFinite(visit) && visit >= 0 && visit <= Date.now() ? visit : Date.now(); +function markNew() { + for (const row of document.querySelectorAll(".command")) { + row.querySelector(":scope > .row-head > .new").hidden = Number(row.dataset.startedMs) <= lastLook; + } +} +markNew(); +remember(visitKey, Date.now()); +window.addEventListener("pagehide", () => remember(visitKey, Date.now())); +const foldKey = "thirdshift:console:folded"; +let folded = stored(foldKey, {}); +if (typeof folded !== "object" || Array.isArray(folded)) folded = {}; for (const section of document.querySelectorAll("details[data-group]")) { - if (typeof folded?.[section.dataset.group] === "boolean") section.open = folded[section.dataset.group]; - section.addEventListener("toggle", () => { - folded ??= {}; - folded[section.dataset.group] = section.open; - try { localStorage.setItem(key, JSON.stringify(folded)); } catch { /* storage is optional */ } + if (typeof folded[section.dataset.group] === "boolean") section.open = folded[section.dataset.group]; +} +document.addEventListener("toggle", event => { + const section = event.target; + if (!section.matches("details[data-group]")) return; + folded[section.dataset.group] = section.open; + remember(foldKey, folded); +}, true); +document.addEventListener("click", event => { + event.target.closest(".command")?.focus({ preventScroll: true }); +}); + +function growth() { + return new Map([...document.querySelectorAll(".command")].map(row => [row.id, row.dataset.logGrowth.split(":").map(Number)])); +} + +function morph(html) { + const next = new DOMParser().parseFromString(html, "text/html"); + const current = document.getElementById("inbox"); + const replacement = next.getElementById("inbox"); + if (!replacement) throw new Error("Missing Inbox"); + const active = document.activeElement; + const focused = active?.id || active?.closest("details")?.id; + const wasSummary = active?.matches("summary"); + const scroll = [...current.querySelectorAll("details")].map(section => [section.id, section.querySelector(".commands").scrollTop]); + const position = [scrollX, scrollY]; + const previousGrowth = growth(); + Idiomorph.morph(current, replacement, { + callbacks: { + beforeAttributeUpdated(name, node) { + if (name === "open" && node.matches("details[data-group]")) return false; + } + } }); + const selected = document.getElementById(focused); + (wasSummary ? selected?.querySelector("summary") : selected)?.focus({ preventScroll: true }); + for (const [id, top] of scroll) document.getElementById(id).querySelector(".commands").scrollTop = top; + window.scrollTo(...position); + document.title = next.title; + document.querySelector('link[rel="icon"]').href = next.querySelector('link[rel="icon"]').getAttribute("href"); + markNew(); + for (const row of document.querySelectorAll('.command[data-logging="true"]')) { + const previous = previousGrowth.get(row.id) ?? [0, 0]; + const increased = row.dataset.logGrowth.split(":").some((size, index) => Number(size) > previous[index]); + if (increased && !matchMedia("(prefers-reduced-motion: reduce)").matches) { + row.querySelector(":scope > .row-head > .heartbeat")?.animate([ + { transform: "scale(1)", opacity: 0.4 }, + { transform: "scale(1.8)", opacity: 1, offset: 0.3 }, + { transform: "scale(1)", opacity: 0.4 } + ], { duration: 900 }); + } + } +} + +// Coalesce notices, but fetch again if a change arrives during the request. +let refreshing = false, pending = false; +async function refresh() { + pending = true; + if (refreshing) return; + refreshing = true; + try { + while (pending) { + pending = false; + const response = await fetch("/", { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + morph(await response.text()); + } + } catch { + startPolling(); + } finally { refreshing = false; } } +let polling; +function startPolling() { + polling ??= setInterval(refresh, 5000); +} +if (typeof EventSource === "function") { + const stream = new EventSource("/events"); + stream.addEventListener("commands", refresh); + stream.addEventListener("error", startPolling); + stream.addEventListener("open", () => { + clearInterval(polling); + polling = undefined; + refresh(); + }); +} else { startPolling(); } diff --git a/src/console/assets/favicon-urgent.svg b/src/console/assets/favicon-urgent.svg new file mode 100644 index 00000000..cfea7e8e --- /dev/null +++ b/src/console/assets/favicon-urgent.svg @@ -0,0 +1 @@ + diff --git a/src/console/assets/favicon.svg b/src/console/assets/favicon.svg new file mode 100644 index 00000000..e5fa9f5b --- /dev/null +++ b/src/console/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/console/inbox.html b/src/console/inbox.html index a9bfdfd7..06d6bc48 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -4,8 +4,8 @@ - Inbox · {{ hostname }} · thirdshift - + {{ self.needs_you() }} need you · thirdshift Console + @@ -16,11 +16,11 @@

    thirdshift / Console

    {{ hostname }}

    {% if !consoles.is_empty() %}{% endif %}
    -
    +

    Inbox

    {{ cli }}
    {% match inbox.warning %}{% when Some with (warning) %}

    Warning: {{ warning }}

    {% when None %}{% endmatch %} {% for section in inbox.sections %} -
    +
    {{ section.title }}{{ section.rows.len() }} {% if section.rows.is_empty() && section.references.is_empty() %}

    Nothing here.

    {% endif %} {# Only the rendered, auto-escaped RowTemplate is trusted as HTML. #} diff --git a/src/console/live.rs b/src/console/live.rs new file mode 100644 index 00000000..618f4376 --- /dev/null +++ b/src/console/live.rs @@ -0,0 +1,40 @@ +//! One reader per Console; slow browsers only need the latest change notice. +use std::{convert::Infallible, time::Duration}; + +use axum::extract::State; +use axum::response::{ + IntoResponse, Response, Sse, + sse::{Event, KeepAlive}, +}; +use tokio::sync::watch; +use tokio_stream::{StreamExt, wrappers::WatchStream}; + +pub(super) fn observe(initial: String) -> (watch::Receiver, tokio::task::JoinHandle<()>) { + let (sender, changes) = watch::channel(0); + let observer = tokio::spawn(async move { + let mut previous = initial; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + match super::render_inbox("thirdshift status").await { + Ok(html) if html != previous => { + previous = html; + sender.send_modify(|revision| *revision += 1); + } + Ok(_) => {} + Err(error) => eprintln!("thirdshift Console: can't observe Inbox: {error:#}"), + } + } + }); + (changes, observer) +} + +pub(super) async fn events(State(changes): State>) -> Response { + // WatchStream emits immediately, so every reconnect also catches up. + let stream = WatchStream::new(changes) + .map(|_| Ok::<_, Infallible>(Event::default().event("commands").data("changed"))); + ( + [("x-accel-buffering", "no"), ("cache-control", "no-store")], + Sse::new(stream).keep_alive(KeepAlive::default()), + ) + .into_response() +} diff --git a/src/console/row.html b/src/console/row.html index 31f68311..61eb6418 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,5 +1,5 @@ -
  • -
    {{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}
    +
  • +
    {% if row.live %}{% endif %}{{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}

    {{ row.now_line }}

    {% if !row.harness.is_empty() %}

    {{ row.harness }}

    {% endif %} {% if !row.pull_request.is_empty() %}

    {{ row.pull_request }}

    {% endif %} diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs index 9901f0f7..513bfd7c 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -1,11 +1,12 @@ //! Owned Inbox presentation for the Console, from the same observations as status. use anyhow::Result; use chrono::{DateTime, Local}; +use std::path::Path; use super::{age, stage_text, ticket_text}; use crate::config::UserConfig; use crate::logs::command_record::Issue; -use crate::logs::reading::{Command, Entry, Group, Snapshot}; +use crate::logs::reading::{Command, Entry, Group, Health, Snapshot}; pub(crate) struct Inbox { pub sections: Vec
    , @@ -22,6 +23,11 @@ pub(crate) struct Section { } pub(crate) struct Row { + pub id: String, + pub started_ms: i64, + pub live: bool, + pub logging: bool, + pub log_growth: String, pub subject: String, pub title: String, pub state: String, @@ -48,11 +54,19 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { .filter(|item| item.group == group) .map(|item| { let mut row = match item.command { - Entry::Record(command) => row(command, now), + Entry::Record(command) => row(command, now, None), Entry::History(command) => { let (subject, title) = subject(&command.repository, &command.kind, command.issue.as_ref()); Row { + id: identity(&format!( + "history:{}:{}:{}", + command.repository, command.kind, command.started_at + )), + started_ms: timestamp(&command.started_at), + live: false, + logging: false, + log_growth: String::new(), subject, title, state: command.state.into(), @@ -139,10 +153,21 @@ fn subject(repository: &str, name: &str, issue: Option<&Issue>) -> (String, Stri } } -fn row(command: &Command, now: DateTime) -> Row { +fn row(command: &Command, now: DateTime, outer_log: Option<&Path>) -> Row { let facts = &command.record; + let log = outer_log.unwrap_or(&command.command_log); + let live = command.state == "live"; let (subject, title) = subject(&facts.repository, &facts.name, facts.issue.as_ref()); Row { + id: identity(&command.record_path.to_string_lossy()), + started_ms: timestamp(&facts.started_at), + live, + logging: live && !matches!(command.health, Some(Health::Silent { .. })), + log_growth: format!( + "{}:{}", + log_length(Some(log)), + log_length(facts.session_log.as_deref()) + ), subject, title, state: command.state.into(), @@ -166,11 +191,31 @@ fn row(command: &Command, now: DateTime) -> Row { .dispatched .iter() .chain(&command.children) - .map(|child| row(child, now)) + .map(|child| row(child, now, Some(log))) .collect(), } } +fn log_length(path: Option<&Path>) -> u64 { + path.and_then(|path| std::fs::metadata(path).ok()) + .map_or(0, |file| file.len()) +} + +fn timestamp(value: &str) -> i64 { + DateTime::parse_from_rfc3339(value).map_or(0, |time| time.timestamp_millis()) +} + +fn identity(value: &str) -> String { + // Encode the exact storage identity: issue numbers and timestamps alone + // can collide, and DOM IDs cannot contain whitespace. + let encoded: String = value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("command-{encoded}") +} + fn elapsed(time: &str, now: DateTime) -> String { DateTime::parse_from_rfc3339(time) .map(|time| age(now.signed_duration_since(time).num_seconds().max(0))) diff --git a/tests/console.rs b/tests/console.rs index e8455372..c1c3ead5 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -1,4 +1,6 @@ //! The Console's public seam: the binary, fixture records and HTTP. +mod support; + use std::fs; use std::io::{BufRead, BufReader}; use std::path::Path; @@ -16,6 +18,21 @@ struct Console { reader: Option>, } +struct HeldFixture { + run: Option, + release: std::path::PathBuf, +} + +impl Drop for HeldFixture { + fn drop(&mut self) { + // Release and reap the fixture even when a regression assertion fails. + let _ = fs::write(&self.release, ""); + if let Some(run) = self.run.take() { + run.finish(); + } + } +} + impl Console { fn start(binary: &Path, home: &Path) -> Self { let mut child = Command::new(binary) @@ -104,6 +121,119 @@ fn write_record(home: &Path, number: u64, record: &Value) { .unwrap(); } +#[test] +fn command_changes_push_events_over_http() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let response = console.get("/events"); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + assert_eq!(response.headers()["x-accel-buffering"], "no"); + let mut stream = BufReader::new(response); + fn changed(stream: &mut impl BufRead) { + let mut event = String::new(); + loop { + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0, "stream ended"); + if line.trim().is_empty() && event.contains("event: commands") { + assert!(event.contains("data: changed"), "{event}"); + return; + } + if line.trim().is_empty() { + event.clear(); + } else { + event.push_str(&line); + } + } + } + changed(&mut stream); // Reconnects always catch up, even if an event was missed. + let mut fixture = record(home.path(), 81, None, 0); + changed(&mut stream); + assert!(console.get("/").text().unwrap().contains("acme/widgets#81")); + fixture["stages"][0]["name"] = json!("CI watch"); + write_record(home.path(), 81, &fixture); + changed(&mut stream); + assert!( + console + .get("/") + .text() + .unwrap() + .contains("died at CI watch") + ); + fixture["outcome"] = json!("merged"); + fixture["ended_at"] = json!(chrono::Local::now().to_rfc3339()); + write_record(home.path(), 81, &fixture); + changed(&mut stream); + assert!(console.get("/").text().unwrap().contains(">merged

    ")); +} + +#[test] +fn heartbeat_observes_log_lengths_and_is_flat_for_silent_runs() { + let scenario = support::Scenario::new(); + scenario.user_config_is("[console]\nport = 0\n"); + scenario.agent_does(&format!( + "touch '{}'\nwhile ! test -f '{}'; do sleep 0.02; done\nexit 1\n", + scenario.path("started").display(), + scenario.path("release").display() + )); + let _held = HeldFixture { + run: Some(scenario.run_until(&[&scenario.issue_url(7)], &[], "started")), + release: scenario.path("release"), + }; + let dir = scenario.path("home/.thirdshift/logs/acme/widgets/commands/issue"); + let path = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.to_string_lossy().ends_with(".command.json")) + .unwrap(); + let mut fixture: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + let session = std::path::PathBuf::from(fixture["session_log"].as_str().unwrap()); + let log = path.with_file_name( + path.file_name() + .unwrap() + .to_str() + .unwrap() + .replace(".command.json", ".log"), + ); + let began = (chrono::Local::now() - chrono::Duration::minutes(20)).to_rfc3339(); + fixture["stages"] = json!([{"name": "implement", "began_at": began, "session_log": session}]); + fs::write(&path, serde_json::to_vec(&fixture).unwrap()).unwrap(); + fs::write(&log, "a").unwrap(); + fs::write(&session, "ab").unwrap(); + let console = Console::start( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + &scenario.path("home"), + ); + let html = console.get("/").text().unwrap(); + assert!(html.contains("data-log-growth=\"1:2\""), "{html}"); + assert!(html.contains("data-logging=\"true\"")); + fs::write(&session, "abcde").unwrap(); + assert!( + console + .get("/") + .text() + .unwrap() + .contains("data-log-growth=\"1:5\"") + ); + for file in [&log, &session] { + fs::File::options() + .write(true) + .open(file) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(std::time::SystemTime::now() - Duration::from_secs(20 * 60)), + ) + .unwrap(); + } + let html = console.get("/").text().unwrap(); + assert!(html.contains("data-state=\"Silent\"")); + assert!(html.contains("data-logging=\"false\"")); + assert!(html.contains("data-log-growth=\"1:5\"")); + drop(console); +} + #[test] fn inbox_over_http_matches_status_and_embeds_only_local_resources() { let home = tempfile::tempdir().unwrap(); @@ -291,6 +421,7 @@ fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { assert!(copied.success()); let console = Console::start(&binary, home.path()); assert_eq!(console.get("/").status(), 200); + let _open_stream = console.get("/events"); let replacement = home.path().join("replacement"); fs::write(&replacement, "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HOME/reexec-args\"\nprintf 'replacement executed\\n'\n").unwrap(); fs::set_permissions(&replacement, fs::Permissions::from_mode(0o755)).unwrap(); From 25b004a09f8cdac92d796ea7e3dfe6ee4e041dbe Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 23:56:35 +0000 Subject: [PATCH 15/47] Address log review findings and release streams during Console updates --- console/README.md | 9 ++ console/reproductions/codex-edit-diff.py | 44 ++++++++ src/args.rs | 29 ++++- src/console.rs | 13 ++- src/console/conversation.rs | 41 ++++--- src/console/logs.html | 2 +- src/console/logs.rs | 135 +++++++++++++---------- src/console/restart.rs | 6 +- src/logs/reading/catalog.rs | 34 ++++-- src/main.rs | 6 +- tests/console.rs | 92 ++++++++++++++- tests/fixtures/console-codex.jsonl | 2 +- 12 files changed, 311 insertions(+), 102 deletions(-) create mode 100644 console/reproductions/codex-edit-diff.py diff --git a/console/README.md b/console/README.md index 414ea90a..08d6748d 100644 --- a/console/README.md +++ b/console/README.md @@ -37,6 +37,8 @@ Command row links to its Run and Session logs, including child Ticket Runs. Older `sessions/` folders are not linked. The same inventory is available with `thirdshift logs` or `thirdshift logs --json`; `thirdshift logs --file ` reads an exact listed file and accepts `--follow`. +`thirdshift logs --command --json` selects the same Command and child Run +logs as its Console link; the inventory's `owners` field lists those exact IDs. Command logs are plain text. Claude and Codex Session logs are conversations with folded tool groups, collapsed thinking, Bash, Edit, Read and Grep views, @@ -46,3 +48,10 @@ event's byte offset; incomplete live lines wait until their newline arrives. The Follow newest line checkbox lets you read back while the Session grows. The Playwright log test checks appends, retained DOM identity, folding across batches, incomplete lines, reconnection and following on a phone viewport. + +Claude Edit events include old/new text and show unified diff excerpts. Codex +`file_change` events retain only file paths and change kinds, so the reader +shows their plain fallback and says the patch is unavailable. The Spec-review +reproduction, `python3 console/reproductions/codex-edit-diff.py` from the repo +root, preserves the unmet Codex diff expectation; it currently fails. Capturing +patch evidence for Codex requires a separate retention decision. diff --git a/console/reproductions/codex-edit-diff.py b/console/reproductions/codex-edit-diff.py new file mode 100644 index 00000000..5df7dc90 --- /dev/null +++ b/console/reproductions/codex-edit-diff.py @@ -0,0 +1,44 @@ +"""Reproduce the missing Codex edit patch, from the Spec review of #819. + +Run from the repository root: python3 console/reproductions/codex-edit-diff.py +This intentionally fails until patch evidence is available in retained logs. +The Codex FileUpdateChange schema contains only path and kind: +https://github.com/openai/codex/blob/main/sdk/typescript/src/items.ts +""" +import html +import json +import os +import pathlib +import subprocess +import tempfile +import urllib.request + +binary = str(pathlib.Path("target/debug/thirdshift").resolve()) +with tempfile.TemporaryDirectory() as home: + root = pathlib.Path(home) / ".thirdshift" + folder = root / "logs/acme/widgets/commands/issue" + folder.mkdir(parents=True) + (root / "config.toml").write_text("[console]\nport = 0\n") + event = { + "type": "item.completed", + "item": { + "id": "f1", + "type": "file_change", + "changes": [{"path": "widgets.rs", "kind": "update"}], + "status": "completed", + }, + } + (folder / "7-20261003T120000-0400-implement.jsonl").write_text(json.dumps(event) + "\n") + env = dict(os.environ, HOME=home) + listed = json.loads(subprocess.check_output([binary, "logs", "--json"], env=env)) + server = subprocess.Popen( + [binary, "console", "--no-sign-in"], env=env, stdout=subprocess.PIPE, text=True + ) + try: + origin = server.stdout.readline().strip() + with urllib.request.urlopen(origin + "/logs/" + listed["logs"][0]["id"], timeout=5) as response: + page = html.unescape(response.read().decode()) + assert "--- a/widgets.rs" in page and "+++ b/widgets.rs" in page and "@@" in page, page + finally: + server.terminate() + server.wait(timeout=5) diff --git a/src/args.rs b/src/args.rs index a974b64f..eb9d4ce2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -44,6 +44,7 @@ pub enum Command { }, LogsList { json: bool, + owner: Option, }, LogFile { id: String, @@ -285,8 +286,32 @@ pub fn parse(args: &[String]) -> Result { } Some("logs") => { match &args[1..] { - [] => return Ok(Command::LogsList { json: false }), - [flag] if flag == "--json" => return Ok(Command::LogsList { json: true }), + [] => { + return Ok(Command::LogsList { + json: false, + owner: None, + }); + } + [flag] if flag == "--json" => { + return Ok(Command::LogsList { + json: true, + owner: None, + }); + } + [flag, id] if flag == "--command" && !id.starts_with('-') => { + return Ok(Command::LogsList { + json: false, + owner: Some(id.clone()), + }); + } + [flag, id, json] + if flag == "--command" && !id.starts_with('-') && json == "--json" => + { + return Ok(Command::LogsList { + json: true, + owner: Some(id.clone()), + }); + } [flag, id] if flag == "--file" && !id.starts_with('-') => { return Ok(Command::LogFile { id: id.clone(), diff --git a/src/console.rs b/src/console.rs index 3d48f998..fafc46bd 100644 --- a/src/console.rs +++ b/src/console.rs @@ -38,7 +38,7 @@ const PAGES: &[PageRoute] = &[ }, PageRoute { path: "/logs/command/{id}", - cli: "thirdshift logs --json", + cli: "thirdshift logs --command id --json", }, PageRoute { path: "/logs/{id}", @@ -67,10 +67,13 @@ pub fn serve(no_sign_in: bool) -> Result<()> { println!("http://{}", listener.local_addr()?); std::io::stdout().flush()?; let watched = executable.clone(); - axum::serve(listener, router()) - .with_graceful_shutdown(async move { watched.replaced().await }) - .await - .context("Console server failed")?; + axum::serve( + listener, + router().layer(axum::Extension(executable.clone())), + ) + .with_graceful_shutdown(async move { watched.replaced().await }) + .await + .context("Console server failed")?; Ok::<_, anyhow::Error>(()) })?; // Drop the runtime (and its sockets) before replacing the process. diff --git a/src/console/conversation.rs b/src/console/conversation.rs index 99cf3ba1..3b603c6b 100644 --- a/src/console/conversation.rs +++ b/src/console/conversation.rs @@ -1,4 +1,5 @@ //! Normalize retained Claude and Codex events into one escaped conversation. +use crate::logs::catalog::Kind; use serde_json::Value; enum Part { @@ -8,8 +9,8 @@ enum Part { Plain(String), } -pub(super) fn render(lines: &str, session: bool) -> String { - if !session { +pub(super) fn render(lines: &str, kind: Kind) -> String { + if matches!(kind, Kind::Command) { return format!("
    {}
    ", escape(lines)); } let mut html = String::new(); @@ -105,10 +106,22 @@ fn tool(name: &str, input: &Value) -> Part { pre("result", &pretty(input)) ), "edit" | "apply_patch" => { - let diff = if input["old_string"].is_string() && input["new_string"].is_string() { - let path = text(input, "file_path"); - let old = text(input, "old_string"); - let new = text(input, "new_string"); + if input["changes"].is_array() { + // Codex exec records paths and kinds, not before/after text. + // Keep the actual event visible rather than inventing a patch. + return Part::Tool( + name.into(), + format!( + "

    Patch unavailable in this log.

    {}", + pre("fallback", &pretty(input)) + ), + ); + } + let diff = if arguments["old_string"].is_string() && arguments["new_string"].is_string() + { + let path = text(arguments, "file_path"); + let old = text(arguments, "old_string"); + let new = text(arguments, "new_string"); format!( "--- a/{path}\n+++ b/{path}\n@@ -1,{} +1,{} @@\n{}{}", old.lines().count(), @@ -120,22 +133,8 @@ fn tool(name: &str, input: &Value) -> Part { .map(|line| format!("+{line}\n")) .collect::() ) - } else if let Some(changes) = input["changes"].as_array() { - changes - .iter() - .map(|change| { - let path = text(change, "path"); - let diff = text(change, "diff"); - if diff.is_empty() { - pretty(change) - } else { - format!("--- a/{path}\n+++ b/{path}\n{diff}") - } - }) - .collect::>() - .join("\n") } else { - pretty(input) + return Part::Tool(name.into(), pre("fallback", &pretty(input))); }; pre("diff", &diff) } diff --git a/src/console/logs.html b/src/console/logs.html index 4aec87e9..336e32bd 100644 --- a/src/console/logs.html +++ b/src/console/logs.html @@ -2,7 +2,7 @@ Logs · thirdshift

    thirdshift / Console

    -

    Logs

    thirdshift logs --json
    +

    Logs

    thirdshift logs {% if !owner.is_empty() %}--command {{ owner }} {% endif %}--json
    {% if logs.is_empty() %}

    No logs found.

    {% endif %}
    diff --git a/src/console/logs.rs b/src/console/logs.rs index 5b1f355a..824cab4d 100644 --- a/src/console/logs.rs +++ b/src/console/logs.rs @@ -4,6 +4,7 @@ use std::io::{BufRead, BufReader, Seek, SeekFrom}; use anyhow::{Result, ensure}; use askama::Template; +use axum::Extension; use axum::extract::Path; use axum::http::{HeaderMap, StatusCode}; use axum::response::{ @@ -21,6 +22,7 @@ use crate::logs::catalog::{self, Log}; struct LogsPage { theme: String, logs: Vec, + owner: String, } #[derive(Template)] @@ -43,13 +45,11 @@ async fn inventory(owner: Option) -> Response { super::page( tokio::task::spawn_blocking(move || -> Result { let config = UserConfig::load()?; - let mut logs = catalog::load(&config)?; - if let Some(owner) = owner { - logs.retain(|log| log.owners.contains(&owner)); - } + let logs = catalog::load(&config, owner.as_deref())?; Ok(LogsPage { theme: config.console.theme, logs, + owner: owner.unwrap_or_default(), } .render()?) }) @@ -60,32 +60,20 @@ async fn inventory(owner: Option) -> Response { pub(super) async fn reader(Path(id): Path) -> Response { let result = tokio::task::spawn_blocking(move || -> Result> { let config = UserConfig::load()?; - let Some(log) = catalog::load(&config)?.into_iter().find(|log| log.id == id) else { + let Some(log) = catalog::load(&config, None)? + .into_iter() + .find(|log| log.id == id) + else { return Ok(None); }; - let mut file = BufReader::new(std::fs::File::open(&log.path)?); - let length = file.get_ref().metadata()?.len(); - let mut bytes = Vec::new(); - let mut offset = 0; - while offset < length { - let mut line = Vec::new(); - if file.read_until(b'\n', &mut line)? == 0 { - break; - } - if log.live && !line.ends_with(b"\n") { - break; - } - offset += line.len() as u64; - bytes.extend(line); - } - let html = - conversation::render(&String::from_utf8_lossy(&bytes), log.kind == "Session log"); + let lines = read_lines(&log, 0, usize::MAX)?; + let html = conversation::render(&String::from_utf8_lossy(&lines.bytes), log.kind); Ok(Some( ReaderPage { theme: config.console.theme, log, html, - offset, + offset: lines.offset, } .render()?, )) @@ -110,15 +98,28 @@ fn appended(config: &UserConfig, id: &str, offset: u64) -> Result { // Observe completion before the final drain, so final unterminated output // is included even if the Command ended between polls. let log = catalog::find(config, id)?; + let lines = read_lines(&log, offset, 256 * 1024)?; + Ok(Batch { + html: conversation::render(&String::from_utf8_lossy(&lines.bytes), log.kind), + offset: lines.offset, + done: lines.done, + }) +} + +struct Lines { + bytes: Vec, + offset: u64, + done: bool, +} + +fn read_lines(log: &Log, offset: u64, budget: usize) -> Result { let mut file = BufReader::new(std::fs::File::open(&log.path)?); - ensure!( - offset <= file.get_ref().metadata()?.len(), - "log was truncated; reopen the reader" - ); + let length = file.get_ref().metadata()?.len(); + ensure!(offset <= length, "log was truncated; reopen the reader"); file.seek(SeekFrom::Start(offset))?; let mut bytes = Vec::new(); let mut next = offset; - while bytes.len() < 256 * 1024 { + while next < length && bytes.len() < budget { let mut line = Vec::new(); if file.read_until(b'\n', &mut line)? == 0 { break; @@ -130,8 +131,8 @@ fn appended(config: &UserConfig, id: &str, offset: u64) -> Result { bytes.extend(line); } let done = !log.live && next >= file.get_ref().metadata()?.len(); - Ok(Batch { - html: conversation::render(&String::from_utf8_lossy(&bytes), log.kind == "Session log"), + Ok(Lines { + bytes, offset: next, done, }) @@ -139,6 +140,7 @@ fn appended(config: &UserConfig, id: &str, offset: u64) -> Result { pub(super) async fn stream( Path((id, offset)): Path<(String, u64)>, + Extension(executable): Extension, headers: HeaderMap, ) -> Response { let offset = headers @@ -153,43 +155,58 @@ pub(super) async fn stream( offset <= std::fs::metadata(log.path)?.len(), "invalid log offset" ); - Ok((std::sync::Arc::new(config), id, offset, false)) + Ok((std::sync::Arc::new(config), id, offset, false, executable)) }) .await; let state = match checked { Ok(Ok(state)) => state, _ => return StatusCode::NOT_FOUND.into_response(), }; - let events = futures_util::stream::unfold(state, |(config, id, mut offset, done)| async move { - if done { - return None; - } - loop { - let root = config.clone(); - let selected = id.clone(); - let result = - tokio::task::spawn_blocking(move || appended(&root, &selected, offset)).await; - match result { - Ok(Ok(batch)) if batch.offset != offset || batch.done => { - offset = batch.offset; - let done = batch.done; - let event = Event::default() - .event("lines") - .id(offset.to_string()) - .data(serde_json::to_string(&batch).expect("serializable log batch")); - return Some((Ok::<_, Infallible>(event), (config, id, offset, done))); - } - Ok(Ok(_)) => tokio::time::sleep(std::time::Duration::from_millis(250)).await, - error => { - eprintln!("thirdshift Console: log stream: {error:?}"); - let event = Event::default() - .event("log-error") - .data("Can't read log; reopen the reader. Check the Console's stderr."); - return Some((Ok(event), (config, id, offset, true))); + let events = futures_util::stream::unfold( + state, + |(config, id, mut offset, done, executable)| async move { + if done { + return None; + } + loop { + let root = config.clone(); + let selected = id.clone(); + let watched = executable.clone(); + let result = tokio::task::spawn_blocking(move || { + if watched.changed() { + return Ok(None); + } + appended(&root, &selected, offset).map(Some) + }) + .await; + match result { + Ok(Ok(None)) => return None, + Ok(Ok(Some(batch))) if batch.offset != offset || batch.done => { + offset = batch.offset; + let done = batch.done; + let event = Event::default() + .event("lines") + .id(offset.to_string()) + .data(serde_json::to_string(&batch).expect("serializable log batch")); + return Some(( + Ok::<_, Infallible>(event), + (config, id, offset, done, executable), + )); + } + Ok(Ok(Some(_))) => { + tokio::time::sleep(std::time::Duration::from_millis(250)).await + } + error => { + eprintln!("thirdshift Console: log stream: {error:?}"); + let event = Event::default() + .event("log-error") + .data("Can't read log; reopen the reader. Check the Console's stderr."); + return Some((Ok(event), (config, id, offset, true, executable))); + } } } - } - }); + }, + ); let mut response = Sse::new(events) .keep_alive(KeepAlive::default()) .into_response(); diff --git a/src/console/restart.rs b/src/console/restart.rs index 450e4783..20eef5ec 100644 --- a/src/console/restart.rs +++ b/src/console/restart.rs @@ -28,12 +28,16 @@ impl Executable { pub async fn replaced(&self) { loop { tokio::time::sleep(Duration::from_secs(1)).await; - if identity(&self.path).is_ok_and(|current| current != self.identity) { + if self.changed() { return; } } } + pub fn changed(&self) -> bool { + identity(&self.path).is_ok_and(|current| current != self.identity) + } + pub fn exec(&self) -> Result<()> { let mut command = std::process::Command::new(&self.path); command.args(std::env::args_os().skip(1)); diff --git a/src/logs/reading/catalog.rs b/src/logs/reading/catalog.rs index e3bca493..0e980a12 100644 --- a/src/logs/reading/catalog.rs +++ b/src/logs/reading/catalog.rs @@ -12,12 +12,29 @@ use super::{Command, Entry, Observation, Snapshot, directories, entries}; use crate::config::UserConfig; use crate::logs::owner; +#[derive(Clone, Copy, Serialize)] +pub(crate) enum Kind { + #[serde(rename = "Command log")] + Command, + #[serde(rename = "Session log")] + Session, +} + +impl std::fmt::Display for Kind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Command => "Command log", + Self::Session => "Session log", + }) + } +} + #[derive(Serialize)] pub(crate) struct Log { pub id: String, pub name: String, pub repository: String, - pub kind: &'static str, + pub kind: Kind, pub live: bool, pub command: String, pub owners: Vec, @@ -33,7 +50,7 @@ pub(crate) fn id(path: &Path) -> String { .collect() } -pub(crate) fn load(config: &UserConfig) -> Result> { +pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result> { let root = &config.logs_dir; let snapshot = Snapshot::load(root, Local::now())?; // A child Run keeps its own Sessions, while sharing its Command's log. @@ -101,9 +118,9 @@ pub(crate) fn load(config: &UserConfig) -> Result> { name: relative.to_string_lossy().into_owned(), repository: repository.clone(), kind: if path.extension().is_some_and(|ext| ext == "jsonl") { - "Session log" + Kind::Session } else { - "Command log" + Kind::Command }, live, command, @@ -114,19 +131,22 @@ pub(crate) fn load(config: &UserConfig) -> Result> { } } } + if let Some(selected) = selected_owner { + logs.retain(|log| log.owners.iter().any(|owner| owner == selected)); + } logs.sort_by(|a, b| a.name.cmp(&b.name)); Ok(logs) } pub(crate) fn find(config: &UserConfig, selected: &str) -> Result { - load(config)? + load(config, None)? .into_iter() .find(|log| log.id == selected) .context("log is not in the current inventory") } -pub(crate) fn show(json: bool) -> Result<()> { - let logs = load(&UserConfig::load()?)?; +pub(crate) fn show(json: bool, owner: Option<&str>) -> Result<()> { + let logs = load(&UserConfig::load()?, owner)?; if json { println!( "{}", diff --git a/src/main.rs b/src/main.rs index 7db37057..5445a19d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,7 @@ usage: thirdshift Run the factory on the iss thirdshift console --no-sign-in Serve this machine's Inbox on localhost thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup - thirdshift logs [--json] List this machine's logs + thirdshift logs [--command ] [--json] List this machine's logs thirdshift logs --file [--follow] Read an exact listed log thirdshift logs [--session ] [--follow] Read or follow a log thirdshift update Update to the latest release @@ -163,8 +163,8 @@ fn main() -> ExitCode { Err(error) => failure(&error), }; } - Ok(Command::LogsList { json }) => { - return match logs::catalog::show(json) { + Ok(Command::LogsList { json, owner }) => { + return match logs::catalog::show(json, owner.as_deref()) { Ok(()) => ExitCode::SUCCESS, Err(error) => failure(&error), }; diff --git a/tests/console.rs b/tests/console.rs index 4c6efaf4..75445600 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -169,6 +169,60 @@ fn logs_inventory_matches_cli_and_reads_current_layout_only() { assert_eq!(console.get("/logs/not-an-inventory-id").status(), 404); } +#[test] +fn command_logs_have_the_same_exact_owner_filter_in_cli_and_http() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + fs::create_dir_all(&folder).unwrap(); + let names = [ + "7-20261003T120000-0400~one.log", + "8-20261003T120000-0400~two.log", + ]; + for name in names { + fs::write(folder.join(name), "output\n").unwrap(); + } + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let inventory = Command::new(binary) + .args(["logs", "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + let inventory: Value = serde_json::from_slice(&inventory.stdout).unwrap(); + let owner = inventory["logs"] + .as_array() + .unwrap() + .iter() + .find(|log| log["name"].as_str().unwrap().ends_with(names[0])) + .unwrap()["id"] + .as_str() + .unwrap(); + let selected = Command::new(binary) + .args(["logs", "--command", owner, "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + selected.status.success(), + "{}", + String::from_utf8_lossy(&selected.stderr) + ); + let selected: Value = serde_json::from_slice(&selected.stdout).unwrap(); + let logs = selected["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0]["id"], owner); + let console = Console::start(binary, home.path()); + let html = console + .get(&format!("/logs/command/{owner}")) + .text() + .unwrap(); + assert!(html.contains(&format!("href=\"/logs/{owner}\""))); + assert!(!html.contains(names[1])); + assert!(html.contains(&format!("thirdshift logs --command {owner} --json"))); +} + #[test] fn log_stream_starts_at_the_byte_offset_and_sends_only_appended_html() { use std::io::Write; @@ -255,10 +309,10 @@ fn claude_and_codex_conversations_keep_tools_thinking_and_unknown_events() { assert!(html.contains("
    Tools")); assert!(html.contains("data-tool=\"Bash\"")); assert!(html.contains("data-tool=\"Read\"")); - assert!(html.contains("class=\"diff\"")); - assert!(html.contains("-old widget\n+new widget"), "{html}"); assert!(html.contains("Widgets are ready.")); if kind == "claude" { + assert!(html.contains("class=\"diff\"")); + assert!(html.contains("-old widget\n+new widget"), "{html}"); assert_eq!(html.matches("class=\"tool-group\"").count(), 1); assert!(html.contains("data-tool=\"Grep\"")); assert!(html.contains("data-tool=\"NewTool\"")); @@ -267,6 +321,9 @@ fn claude_and_codex_conversations_keep_tools_thinking_and_unknown_events() { assert!(html.contains("not JSON: retained diagnostic")); } else { assert!(html.contains("unknown Codex item visible")); + assert!(html.contains("Patch unavailable in this log.")); + assert!(html.contains("class=\"fallback\"")); + assert!(!html.contains("class=\"diff\"")); } assert!(!html.contains("")); } @@ -477,6 +534,37 @@ fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { assert!(copied.success()); let console = Console::start(&binary, home.path()); assert_eq!(console.get("/").status(), 200); + // A reader must not keep graceful shutdown waiting on an infinite stream. + #[cfg(target_os = "linux")] + let _stream = { + let mut live = record(home.path(), 7, None, 0); + let stat = fs::read_to_string(format!("/proc/{}/stat", std::process::id())).unwrap(); + let ticks = stat + .rsplit_once(") ") + .unwrap() + .1 + .split_whitespace() + .nth(19) + .unwrap(); + let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap(); + live["process_id"] = json!(std::process::id()); + live["process_start"] = json!(format!("{}:{ticks}", boot.trim())); + let log = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture-implement.jsonl"); + fs::write(&log, "initial line\n").unwrap(); + live["session_log"] = json!(log); + write_record(home.path(), 7, &live); + let inventory = console.get("/logs").text().unwrap(); + let link = inventory + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(url, _)| url.starts_with("/logs/")) + .unwrap() + .0; + console.get(&format!("{link}/stream/0")) + }; let replacement = home.path().join("replacement"); fs::write(&replacement, "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HOME/reexec-args\"\nprintf 'replacement executed\\n'\n").unwrap(); fs::set_permissions(&replacement, fs::Permissions::from_mode(0o755)).unwrap(); diff --git a/tests/fixtures/console-codex.jsonl b/tests/fixtures/console-codex.jsonl index 76cfa516..f093e123 100644 --- a/tests/fixtures/console-codex.jsonl +++ b/tests/fixtures/console-codex.jsonl @@ -3,7 +3,7 @@ {"type":"item.completed","item":{"id":"a1","type":"agent_message","text":"I will inspect the widgets."}} {"type":"item.started","item":{"id":"c1","type":"command_execution","command":"cat widgets.rs","aggregated_output":"","status":"in_progress"}} {"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"cat widgets.rs","aggregated_output":"widget output ","exit_code":0,"status":"completed"}} -{"type":"item.completed","item":{"id":"f1","type":"file_change","changes":[{"path":"widgets.rs","kind":"update","diff":"@@ -1 +1 @@\n-old widget\n+new widget"}],"status":"completed"}} +{"type":"item.completed","item":{"id":"f1","type":"file_change","changes":[{"path":"widgets.rs","kind":"update"}],"status":"completed"}} {"type":"item.completed","item":{"id":"m1","type":"mcp_tool_call","server":"files","tool":"Read","arguments":{"file_path":"widgets.rs"},"result":{"content":[{"type":"text","text":"new widget"}]},"status":"completed"}} {"type":"item.completed","item":{"id":"z1","type":"future_item","detail":"unknown Codex item visible"}} {"type":"item.completed","item":{"id":"a2","type":"agent_message","text":"Widgets are ready."}} From 81daf0d36027b2b3ff62b4fa1040c2050646db3b Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Mon, 19 Oct 2026 23:58:59 +0000 Subject: [PATCH 16/47] Add History, Security, exact peeks and Console keyboard navigation (#820) --- console/README.md | 19 +++ console/tests/inbox.test.cjs | 143 +++++++++++++++++++ src/console.rs | 150 +++++++++++++++++--- src/console/assets/console.css | 24 ++++ src/console/assets/console.js | 130 ++++++++++++++++++ src/console/fact.html | 1 + src/console/inbox.html | 35 ++++- src/console/peek.html | 7 + src/console/peek.rs | 96 +++++++++++++ src/console/row.html | 5 +- src/github/advisories.rs | 4 +- src/logs/command_record.rs | 4 + src/logs/reading.rs | 4 + src/logs/reading/selection.rs | 44 +++++- src/logs/status.rs | 1 + src/logs/status/inbox.rs | 177 +++++++++++++++++++----- src/logs/status/inspect.rs | 45 +++++- tests/console.rs | 242 +++++++++++++++++++++++++++++++++ tests/security_run.rs | 16 +++ 19 files changed, 1081 insertions(+), 66 deletions(-) create mode 100644 src/console/fact.html create mode 100644 src/console/peek.html create mode 100644 src/console/peek.rs diff --git a/console/README.md b/console/README.md index 8079a2a9..8f0908d2 100644 --- a/console/README.md +++ b/console/README.md @@ -30,3 +30,22 @@ The header names this machine and links to those addresses, without fetching them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary makes the Console re-execute itself with its original arguments. + +The available pages are Inbox (`1`), History (`5`) and Security (`7`). Links +keep the page in `?page=`. History includes every ended Command, including old +logs, and filters by outcome and repository. Security shows retained findings +and Session logs. Older records without finding metadata still show their logs. + +`j` / `k` select headings and visible rows, wrapping at either end. `Space` +folds a heading or toggles a row's peek; `Enter` opens that row's full Command +log. `z` folds the current section. `Escape` closes help, jumps or the peek, +one layer at a time, then clears selection. `?` opens the key list. `g` opens +the jump list: `i` Inbox, `h` History, `s` Security, or a section's displayed +letter. Text fields keep their normal keys. The peek starts closed and sits +beside the list on a wide screen or at the bottom of a phone. + +For an exact older Command, `show` and `logs` also accept the absolute record +or Command-log path. The Console adds `::name` to a record path to distinguish +a dispatched Run from its enclosing Command. A retained Session-log path can +select a historical Command without a record; `logs --session` accepts an exact +path from that Command's Session inventory as well as a session kind. diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 088b8f3b..65231c9a 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -25,7 +25,20 @@ before(async () => { outcome: "failed", cause: `distinct fixture failure ${number}`, issue_branch: null, pull_request: null, tickets: [{ issue: { number: 98, url: "https://github.com/acme/widgets/issues/98", title: "Build it" }, state: "waiting", blockers: [{ number: 97, url: "https://github.com/acme/widgets/issues/97", title: "First" }] }] })); + await writeFile(join(records, `${number}-fixture.log`), `Full Command output ${number}\n`); } + const security = join(configDir, 'logs/acme/widgets/commands/secure'); + await mkdir(security, { recursive: true }); + const session = join(security, 'secure-fixture-security-audit.jsonl'); + await writeFile(session, 'Security Session output\n'); + await writeFile(join(security, 'secure-fixture.log'), 'Security Command output\n'); + await writeFile(join(security, 'secure-fixture.command.json'), JSON.stringify({ + version: 1, kind: 'Security run', name: 'secure-fixture', repository: 'acme/widgets', + hostname: 'fixture', process_id: 0, process_start: 'gone', harness: 'codex', + started_at: '2026-01-01T12:00:00Z', ended_at: '2026-01-01T13:00:00Z', + stages: [], session_log: session, outcome: 'findings recorded', + security_findings: [{ title: 'Bound before storage', severity: 'high', url: 'https://github.com/acme/widgets/security/advisories/GHSA-test' }] + })); server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, "../../target/debug/thirdshift"), ["console", "--no-sign-in"], { env: { ...process.env, HOME: home }, stdio: ["ignore", "pipe", "inherit"] }); @@ -110,3 +123,133 @@ test("explicit Focus light and dark override the system preference", async t => } } finally { await writeFile(config, "[console]\nport = 0\n"); } }); + +test("every keyboard action preserves list nodes and Escape closes one layer at a time", async t => { + const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); + await page.goto(origin); + await page.evaluate(() => { + window.rowsBefore = [...document.querySelectorAll('.command')]; + window.listChanges = []; + for (const list of document.querySelectorAll('.commands')) { + new MutationObserver(records => window.listChanges.push(...records.map(r => r.type))) + .observe(list, { childList: true, subtree: true }); + } + }); + const selected = page.locator('.is-selected'); + await page.keyboard.press('j'); + assert.equal(await selected.evaluate(el => el.tagName), 'SUMMARY'); + await page.keyboard.press('k'); + assert.equal(await selected.textContent().then(s => s.trim().startsWith('Resolved')), true); + await page.keyboard.press('j'); + await page.keyboard.press('Space'); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 0); + await page.keyboard.press('Space'); + await page.keyboard.press('j'); + assert.equal(await selected.getAttribute('class').then(s => s.includes('command')), true); + await page.keyboard.press('k'); + assert.equal(await selected.evaluate(el => el.tagName), 'SUMMARY'); + await page.keyboard.press('j'); + await page.keyboard.press('Space'); + await page.locator('#peek dt').first().waitFor(); + const peekBox = await page.locator('#peek').boundingBox(); + const mainBox = await page.locator('main').boundingBox(); + assert.ok(peekBox.x >= mainBox.x + mainBox.width, 'peek is beside the list'); + await page.keyboard.press('?'); + assert.equal(await page.locator('#keys').evaluate(el => el.open), true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#keys').evaluate(el => el.open), false); + assert.equal(await page.locator('#peek').isVisible(), true); + await page.keyboard.press('g'); + assert.equal(await page.locator('#jumps').isVisible(), true); + assert.match(await page.locator('#jumps').textContent(), /History/); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#jumps').isVisible(), false); + assert.equal(await page.locator('#peek').isVisible(), true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#peek').isVisible(), false); + assert.equal(await selected.count(), 1); + await page.keyboard.press('Escape'); + assert.equal(await selected.count(), 0); + for (const [letter, group] of [['r', 'ready for review'], ['l', 'landing'], ['d', 'done'], ['v', 'resolved']]) { + await page.keyboard.press('g'); + await page.keyboard.press(letter); + assert.equal(await selected.evaluate(el => el.parentElement.dataset.group), group); + assert.equal(await selected.evaluate(el => el.parentElement.open), true); + } + await page.keyboard.press('g'); + await page.keyboard.press('w'); + assert.equal(await page.locator('[data-group="working"][open]').count(), 1); + await page.keyboard.press('g'); + await page.keyboard.press('n'); + await page.keyboard.press('j'); + await page.keyboard.press('z'); + assert.equal(await page.locator('[data-group="needs you"][open]').count(), 0); + await page.keyboard.press('Space'); + await page.keyboard.press('j'); + await page.keyboard.press('Space'); + await page.locator('#peek dt').first().waitFor(); + await page.keyboard.press('Space'); + assert.equal(await page.locator('#peek').isVisible(), false); + assert.deepEqual(await page.evaluate(() => window.listChanges), []); + assert.equal(await page.evaluate(() => window.rowsBefore.every((row, i) => row === document.querySelectorAll('.command')[i])), true); + await page.keyboard.press('Enter'); + await page.waitForURL('**/log?*'); + assert.match(await page.locator('body').textContent(), /Full Command output/); +}); + +test("page keys, jump letters and URLs survive reload and the peek is a phone bottom sheet", async t => { + const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); + await page.goto(origin); + for (const [key, name] of [['5', 'history'], ['7', 'security'], ['1', 'inbox']]) { + await page.keyboard.press(key); + await page.waitForURL(`**/?page=${name}`); + await page.reload(); + assert.equal(await page.locator('h2').first().textContent(), name[0].toUpperCase() + name.slice(1)); + assert.equal(await page.locator('#peek').isVisible(), false); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + for (const key of ['2', '3', '4', '6']) { + await page.keyboard.press(key); + assert.equal(new URL(page.url()).searchParams.get('page'), 'inbox'); + assert.equal(await page.locator(`[data-key="${key}"]`).count(), 0); + } + for (const [letter, name] of [['h', 'history'], ['s', 'security'], ['i', 'inbox']]) { + await page.keyboard.press('g'); + await page.keyboard.press(letter); + await page.waitForURL(`**/?page=${name}`); + } + await page.keyboard.press('j'); + await page.keyboard.press('j'); + await page.keyboard.press('Space'); + await page.locator('#peek dt').first().waitFor(); + const box = await page.locator('#peek').boundingBox(); + assert.ok(box.y > 0 && Math.abs(box.y + box.height - 844) < 2); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.reload(); + assert.equal(await page.locator('#peek').isVisible(), false); +}); + +test('History filters and Security findings and sessions work from a phone', async t => { + const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); + await page.goto(`${origin}/?page=history`); + assert.equal(await page.locator('.command').count(), 11); + await page.locator('select[name=outcome]').selectOption('failed'); + await page.locator('select[name=outcome]').focus(); + await page.keyboard.press('7'); + assert.equal(new URL(page.url()).searchParams.get('page'), 'history', 'select keeps normal keys'); + await page.locator('select[name=repository]').selectOption('acme/widgets'); + await page.getByRole('button', { name: 'Filter', exact: true }).click(); + await page.waitForURL('**/\?page=history&outcome=failed&repository=*'); + assert.equal(await page.locator('.command').count(), 10); + await page.goto(`${origin}/?page=security`); + assert.equal(await page.locator('.command').count(), 1); + assert.match(await page.locator('[aria-label="Security findings"]').textContent(), /Bound before storage.*high/); + assert.equal(await page.locator('.command input').count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.locator('.peek-link').click(); + await page.locator('#peek dt').first().waitFor(); + assert.match(await page.locator('#peek').textContent(), /Bound before storage/); + await page.locator('#peek a').filter({ hasText: 'Session log:' }).click(); + await page.waitForURL('**/log?*'); + assert.match(await page.locator('body').textContent(), /Security Session output/); +}); diff --git a/src/console.rs b/src/console.rs index 22cd4f21..93b48587 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,4 +1,5 @@ //! The machine's Console. Async execution is confined to this module. +mod peek; mod restart; use std::io::Write; @@ -7,7 +8,7 @@ use std::net::Ipv4Addr; use anyhow::{Context, Result, ensure}; use askama::Template; use axum::Router; -use axum::extract::Path; +use axum::extract::{Path, RawQuery}; use axum::http::{StatusCode, header}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; @@ -25,10 +26,28 @@ struct PageRoute { cli: &'static str, } -const PAGES: &[PageRoute] = &[PageRoute { - path: "/", - cli: "thirdshift status", -}]; +const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/", + cli: "thirdshift status", + }, + PageRoute { + path: "/history", + cli: "thirdshift status --all --json", + }, + PageRoute { + path: "/security", + cli: "thirdshift status --all --json", + }, + PageRoute { + path: "/peek", + cli: "thirdshift show widgets#7 --json", + }, + PageRoute { + path: "/log", + cli: "thirdshift logs widgets#7", + }, +]; pub fn serve(no_sign_in: bool) -> Result<()> { ensure!( @@ -62,7 +81,11 @@ fn router() -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli; - router = router.route(page.path, get(move || inbox(cli))); + router = match page.path { + "/peek" => router.route(page.path, get(inspect)), + "/log" => router.route(page.path, get(log)), + path => router.route(path, get(move |query| inbox(cli, path, query))), + }; } router = router.route("/assets/{*path}", get(asset)); router @@ -76,6 +99,10 @@ struct InboxPage { theme: String, cli: &'static str, inbox: Inbox, + title: &'static str, + archive: Option, + outcome: String, + repository: String, } impl InboxPage { @@ -96,29 +123,120 @@ impl RowTemplate<'_> { } } -async fn inbox(cli: &'static str) -> Response { +fn query_value(query: &Option, key: &str) -> String { + let url = reqwest::Url::parse(&format!( + "http://localhost/?{}", + query.as_deref().unwrap_or_default() + )) + .expect("fixed URL"); + url.query_pairs() + .find(|(name, _)| name == key) + .map(|(_, value)| value.into_owned()) + .unwrap_or_default() +} + +async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) -> Response { + let mut page = query_value(&query, "page"); + if page.is_empty() { + page = path.trim_start_matches('/').into(); + } + let archive_page = match page.as_str() { + "history" | "5" => Some(status::ArchivePage::History), + "security" | "7" => Some(status::ArchivePage::Security), + "" | "inbox" | "1" => None, + _ => return StatusCode::NOT_FOUND.into_response(), + }; // The shared observations and templates stay blocking, off the async executor. match tokio::task::spawn_blocking(move || -> Result { let config = UserConfig::load()?; + let outcome = query_value(&query, "outcome"); + let repository = query_value(&query, "repository"); Ok(InboxPage { hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), consoles: config.console.other_consoles.clone(), - theme: config.console.theme.clone(), cli, + theme: config.console.theme.clone(), + cli: match archive_page { + Some(status::ArchivePage::History) => "thirdshift status --all --json", + Some(status::ArchivePage::Security) => { + "thirdshift status --all --json · thirdshift show --json" + } + None => cli, + }, inbox: status::inbox(&config)?, - }.render()?) - }).await { - Ok(Ok(html)) => ( - [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), - (header::CACHE_CONTROL, "no-store"), - (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) - ).into_response(), + title: match archive_page { + Some(status::ArchivePage::History) => "History", + Some(status::ArchivePage::Security) => "Security", + None => "Inbox", + }, + archive: archive_page + .map(|page| status::archive(&config, page, &outcome, &repository)) + .transpose()?, + outcome, + repository, + } + .render()?) + }) + .await + { + Ok(Ok(html)) => html_response(html), error => { eprintln!("thirdshift Console: can't read Inbox: {error:?}"); - (StatusCode::INTERNAL_SERVER_ERROR, "Can't read the Inbox; check the Console's stderr.").into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read the Inbox; check the Console's stderr.", + ) + .into_response() } } } +async fn inspect(RawQuery(query): RawQuery) -> Response { + let command = query_value(&query, "command"); + match tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + let details = status::inspect::details(&config, &command)?; + Ok(peek::Peek::new(command, details).render()?) + }) + .await + { + Ok(Ok(html)) => html_response(html), + _ => (StatusCode::NOT_FOUND, "Command details are unavailable.").into_response(), + } +} + +async fn log(RawQuery(query): RawQuery) -> Response { + let command = query_value(&query, "command"); + let session = query_value(&query, "session"); + match tokio::task::spawn_blocking(move || { + status::inspect::read_log( + &UserConfig::load()?, + &command, + (!session.is_empty()).then_some(session.as_str()), + ) + }) + .await + { + Ok(Ok(bytes)) => ( + [ + (header::CONTENT_TYPE, "text/plain; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + bytes, + ) + .into_response(), + _ => (StatusCode::NOT_FOUND, "Log is unavailable.").into_response(), + } +} + +fn html_response(html: String) -> Response { + ( + [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) + ).into_response() +} + async fn asset(Path(path): Path) -> Response { let Some(file) = ASSETS.get_file(&path) else { return StatusCode::NOT_FOUND.into_response(); diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 4e0522d6..eed14b30 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -50,6 +50,30 @@ summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } .empty { margin: 0 0 12px 16px; } aside { font-size: 12px; } :focus-visible { outline: 2px solid var(--head); outline-offset: 2px; } +.is-selected { outline: 2px solid var(--head); outline-offset: -2px; background: var(--sel-bg); } +button, select { font: inherit; background: var(--panel); color: var(--text); border: 1px solid var(--rule); border-radius: var(--radius); padding: 5px 8px; } +form { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; } +form label { min-width: 0; } +select { max-width: 100%; } +.archive .commands { max-height: none; } +body.peek-open { max-width: 1500px; display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); column-gap: 24px; } +body.peek-open > header, body.peek-open > nav, body.peek-open > footer { grid-column: 1 / -1; } +body.peek-open > main { grid-column: 1; } +#peek { grid-column: 2; position: sticky; top: 20px; align-self: start; max-height: calc(100dvh - 40px); overflow: auto; margin-top: 16px; padding: 16px; border: 1px solid var(--rule); background: var(--panel); color: var(--text); } +[hidden] { display: none !important; } +dl { margin: 12px 0; overflow-wrap: anywhere; } +dt { color: var(--dim); text-transform: capitalize; } +dd { margin: 0 0 8px 12px; } +dd dl { border-left: 1px solid var(--rule); padding-left: 8px; } +#jumps, dialog { padding: 20px; border: 1px solid var(--rule); border-radius: var(--radius); background: var(--panel); color: var(--text); max-width: min(92vw, 520px); max-height: 85dvh; overflow: auto; } +#jumps { position: fixed; bottom: 20px; right: 20px; z-index: 20; box-shadow: 0 4px 20px #0003; } +#jumps button { margin: 3px; } +dialog::backdrop { background: #0006; } +footer { margin-top: 16px; } +@media (max-width: 760px) { + body.peek-open { display: block; } + #peek { position: fixed; inset: auto 0 0; width: 100%; max-height: 65dvh; margin: 0; z-index: 10; border-radius: 12px 12px 0 0; box-shadow: 0 -4px 20px #0003; } +} @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index cc45e621..7a895bb8 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -10,3 +10,133 @@ for (const section of document.querySelectorAll("details[data-group]")) { try { localStorage.setItem(key, JSON.stringify(folded)); } catch { /* storage is optional */ } }); } + +const peek = document.querySelector('#peek'); +const content = document.querySelector('#peek-content'); +const help = document.querySelector('#keys'); +const jumps = document.querySelector('#jumps'); +let selected = null; +let peekCommand = null; +let pendingPeek = null; + +function select(element) { + selected?.classList.remove('is-selected'); + selected = element; + selected?.classList.add('is-selected'); + selected?.scrollIntoView({ block: 'nearest' }); +} + +function closePeek() { + pendingPeek?.abort(); + pendingPeek = null; + peekCommand = null; + peek.hidden = true; + document.body.classList.remove('peek-open'); +} + +async function togglePeek(row) { + if (!row.dataset.command) return; + if (peekCommand === row.dataset.command) { closePeek(); return; } + pendingPeek?.abort(); + const request = new AbortController(); + pendingPeek = request; + peekCommand = row.dataset.command; + content.textContent = 'Loading details…'; + peek.hidden = false; + document.body.classList.add('peek-open'); + try { + const response = await fetch(row.dataset.peek, { signal: request.signal }); + if (!response.ok) throw new Error('Details unavailable'); + const html = await response.text(); + if (request.signal.aborted) return; + // Only the server's escaped show template is inserted, never a log. + content.replaceChildren(document.createRange().createContextualFragment(html)); + } catch (error) { + if (!request.signal.aborted && error.name !== 'AbortError') content.textContent = 'Details unavailable. Reload to try again.'; + } +} + +function items() { + return [...document.querySelectorAll('main .group > summary, main .command')] + .filter(element => { + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (parent.tagName === 'DETAILS' && !parent.open && element !== parent.firstElementChild) return false; + } + return true; + }); +} + +const sectionLetters = { 'needs you': 'n', 'ready for review': 'r', landing: 'l', working: 'w', done: 'd', resolved: 'v' }; +const targets = new Map(); +for (const link of document.querySelectorAll('nav[aria-label="Pages"] a')) { + targets.set(link.dataset.jump, { label: link.textContent.replace(/^\d+ /, ''), go: () => link.click() }); +} +for (const section of document.querySelectorAll('.group')) { + const letter = sectionLetters[section.dataset.group]; + if (letter) targets.set(letter, { + label: section.querySelector('summary span').textContent, + go: () => { section.open = true; select(section.querySelector('summary')); } + }); +} +for (const [letter, target] of targets) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `${letter} · ${target.label}`; + button.addEventListener('click', () => { jumps.hidden = true; target.go(); }); + jumps.append(button); +} + +document.querySelector('[data-close-peek]').addEventListener('click', closePeek); +document.querySelector('[data-close-keys]').addEventListener('click', () => help.close()); +document.querySelector('[data-open-keys]').addEventListener('click', () => help.showModal()); +document.addEventListener('click', event => { + const link = event.target.closest('.peek-link'); + const row = event.target.closest('main .command'); + if (link) { event.preventDefault(); select(row); togglePeek(row); } + else if (row && !event.target.closest('a, button, input, select, textarea')) select(row); + else if (event.target.closest('main .group > summary')) select(event.target.closest('summary')); +}); + +document.addEventListener('keydown', event => { + if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest('input, select, textarea, [contenteditable="true"]')) return; + if (event.key === 'Escape') { + event.preventDefault(); + if (help.open) help.close(); + else if (!jumps.hidden) jumps.hidden = true; + else if (!peek.hidden) closePeek(); + else select(null); + return; + } + if (help.open) return; + if (!jumps.hidden) { + event.preventDefault(); + jumps.hidden = true; + targets.get(event.key.toLowerCase())?.go(); + return; + } + const page = document.querySelector(`nav[aria-label="Pages"] a[data-key="${CSS.escape(event.key)}"]`); + if (page) { event.preventDefault(); page.click(); return; } + if (event.key === '?') { event.preventDefault(); help.showModal(); return; } + if (event.key === 'g') { event.preventDefault(); jumps.hidden = false; return; } + if (event.key === 'j' || event.key === 'k') { + event.preventDefault(); + const visible = items(); + const index = visible.indexOf(selected); + const next = index < 0 ? (event.key === 'j' ? 0 : visible.length - 1) + : (index + (event.key === 'j' ? 1 : -1) + visible.length) % visible.length; + select(visible[next] ?? null); + } else if (event.key === ' ' && selected) { + event.preventDefault(); + if (selected.tagName === 'SUMMARY') selected.parentElement.open = !selected.parentElement.open; + else togglePeek(selected); + } else if (event.key === 'z' && selected) { + event.preventDefault(); + const section = selected.closest('.group'); + section.open = false; + select(section.querySelector('summary')); + } else if (event.key === 'Enter' && selected) { + event.preventDefault(); + const row = selected.matches('.command') ? selected : selected.parentElement.querySelector('.command'); + if (row?.dataset.command) location.assign(row.dataset.log); + } +}); diff --git a/src/console/fact.html b/src/console/fact.html new file mode 100644 index 00000000..7dacff52 --- /dev/null +++ b/src/console/fact.html @@ -0,0 +1 @@ +
    {{ fact.label }}
    {{ fact.value }}{% if !fact.children.is_empty() %}
    {% for child in fact.children %}{{ self.child(child)|safe }}{% endfor %}
    {% endif %}
    diff --git a/src/console/inbox.html b/src/console/inbox.html index a9bfdfd7..bf12b231 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -4,7 +4,7 @@ - Inbox · {{ hostname }} · thirdshift + {{ title }} · {{ hostname }} · thirdshift @@ -16,8 +16,23 @@

    thirdshift / Console

    {{ hostname }}

    {% if !consoles.is_empty() %}{% endif %}
    +
    -

    Inbox

    {{ cli }}
    +

    {{ title }}

    {{ cli }}
    + {% match archive %}{% when Some with (archive) %} + {% if title == "History" %} +
    + + + + +
    + {% endif %} +
    {{ title }} {{ archive.rows.len() }} + {% if archive.rows.is_empty() %}

    Nothing here.

    {% endif %} +
      {% for item in archive.rows %}{{ self.row(item)|safe }}{% endfor %}
    +
    + {% when None %} {% match inbox.warning %}{% when Some with (warning) %}

    Warning: {{ warning }}

    {% when None %}{% endmatch %} {% for section in inbox.sections %}
    @@ -29,6 +44,22 @@
    {% endfor %} {% if !inbox.idle_passes.is_empty() %}{% endif %} + {% endmatch %}
    + +
    + + +

    Keyboard

    +
    j / k
    Next / previous row or heading, wrapping
    +
    Space
    Fold a heading or toggle a row's peek
    +
    Enter
    Open the full Command log
    +
    Esc
    Close one layer, then clear selection
    +
    1–7
    Switch to an available page
    +
    g, then a letter
    Jump to a section or page; g lists the letters
    +
    z
    Fold the current section
    +
    ?
    Show this help
    + +
    diff --git a/src/console/peek.html b/src/console/peek.html new file mode 100644 index 00000000..c192fccd --- /dev/null +++ b/src/console/peek.html @@ -0,0 +1,7 @@ +
    +

    Command details

    + {{ cli }} +

    Full Command log

    + {% for log in logs %}

    Session log: {{ log.name }}

    {% endfor %} +
    {% for fact in facts %}{{ self.fact(fact)|safe }}{% endfor %}
    +
    diff --git a/src/console/peek.rs b/src/console/peek.rs new file mode 100644 index 00000000..209eadd4 --- /dev/null +++ b/src/console/peek.rs @@ -0,0 +1,96 @@ +//! Render every show field, including nested Runs, without a second schema. +use askama::Template; +use serde_json::Value; + +#[derive(Template)] +#[template(path = "console/peek.html")] +pub(super) struct Peek { + pub command: String, + pub cli: String, + pub facts: Vec, + pub logs: Vec, +} + +pub(super) struct Log { + pub name: String, + pub url: String, +} +pub(super) struct Fact { + label: String, + value: String, + children: Vec, +} + +impl Peek { + pub fn new(command: String, value: Value) -> Self { + let mut logs = Vec::new(); + if let Some(sessions) = value["session_logs"].as_array() { + for session in sessions.iter().filter_map(Value::as_str) { + logs.push(Log { + name: session.into(), + url: crate::logs::status::inspect::url( + "/log", + &command, + Some(std::path::Path::new(session)), + ), + }); + } + } + let cli = format!( + "thirdshift show '{}' --json", + command.replace('\'', "'\\''") + ); + Self { + command, + cli, + facts: Fact::children(value), + logs, + } + } + fn fact<'a>(&self, fact: &'a Fact) -> FactTemplate<'a> { + FactTemplate { fact } + } +} + +impl Fact { + fn new(label: String, value: Value) -> Self { + let text = match &value { + Value::Null => "None".into(), + Value::String(text) => text.clone(), + Value::Array(items) if items.is_empty() => "None".into(), + Value::Object(items) if items.is_empty() => "None".into(), + Value::Array(_) | Value::Object(_) => String::new(), + other => other.to_string(), + }; + Self { + label: label.replace('_', " "), + value: text, + children: Self::children(value), + } + } + fn children(value: Value) -> Vec { + match value { + Value::Object(fields) => fields + .into_iter() + .map(|(label, value)| Self::new(label, value)) + .collect(), + Value::Array(items) => items + .into_iter() + .enumerate() + .map(|(index, value)| Self::new((index + 1).to_string(), value)) + .collect(), + _ => Vec::new(), + } + } +} + +#[derive(Template)] +#[template(path = "console/fact.html")] +struct FactTemplate<'a> { + fact: &'a Fact, +} +impl FactTemplate<'_> { + fn child<'a>(&self, fact: &'a Fact) -> FactTemplate<'a> { + FactTemplate { fact } + } +} diff --git a/src/console/row.html b/src/console/row.html index 31f68311..99b8051d 100644 --- a/src/console/row.html +++ b/src/console/row.html @@ -1,9 +1,12 @@ -
  • +
  • {{ row.state }} {{ row.subject }} {{ row.title }}{{ row.age }}

    {{ row.now_line }}

    {% if !row.harness.is_empty() %}

    {{ row.harness }}

    {% endif %} {% if !row.pull_request.is_empty() %}

    {{ row.pull_request }}

    {% endif %} {% if row.count > 1 %}

    {{ row.count }} Commands with this cause: {{ row.cause }}

    {% else if !row.cause.is_empty() %}

    {{ row.cause }}

    {% endif %} {% if !row.tickets.is_empty() %}
      {% for ticket in row.tickets %}
    • {{ ticket }}
    • {% endfor %}
    {% endif %} +

    Details · Full log

    + {% if !row.findings.is_empty() %}
      {% for finding in row.findings %}
    • {{ finding.title }} · {% match finding.severity %}{% when Some with (severity) %}{{ severity }}{% when None %}ungraded{% endmatch %} · {% if finding.url.starts_with("https://") %}{{ finding.url }}{% else %}{{ finding.url }}{% endif %}
    • {% endfor %}
    {% endif %} + {% if !row.sessions.is_empty() %}{% endif %} {% if !row.children.is_empty() %}
      {% for child in row.children %}{{ self.child(child)|safe }}{% endfor %}
    {% endif %}
  • diff --git a/src/github/advisories.rs b/src/github/advisories.rs index 92b2ddcb..555bb145 100644 --- a/src/github/advisories.rs +++ b/src/github/advisories.rs @@ -2,7 +2,7 @@ //! private repositories whose advisory endpoint is unavailable use issues. use anyhow::{Context, Result, bail}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::{GitHub, check_read}; @@ -263,7 +263,7 @@ pub struct SecurityRecord { } /// Only the metadata allowed in a Run notification; no private write-up. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordedFinding { pub severity: Option, pub title: String, diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs index b31511bf..45dea564 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -59,6 +59,8 @@ pub(super) struct CommandRecord { /// Local knowledge at completion; status never refreshes findings from GitHub. #[serde(default)] pub security_findings_waiting: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub security_findings: Vec, } #[derive(Clone, Serialize, Deserialize)] @@ -184,6 +186,7 @@ impl CommandRecord { .map(|cause| cause.full().into()); self.pull_request = account.pr_url.map(String::from); self.security_findings_waiting = account.security_findings_waiting; + self.security_findings = account.security_findings.unwrap_or_default().to_vec(); } pub fn live(&self, outside: &mut impl super::effects::Outside) -> bool { @@ -240,6 +243,7 @@ impl CommandRecord { parent_dispatch: None, tickets: Vec::new(), security_findings_waiting: false, + security_findings: Vec::new(), } } diff --git a/src/logs/reading.rs b/src/logs/reading.rs index 4e23478b..3b05ec22 100644 --- a/src/logs/reading.rs +++ b/src/logs/reading.rs @@ -31,6 +31,10 @@ pub(super) struct Snapshot { } impl Snapshot { + pub(super) fn entries(&self) -> &[Entry] { + &self.commands + } + pub(super) fn load(root: &Path, now: DateTime) -> Result { Self::load_with_silence(root, now, std::time::Duration::from_secs(10 * 60)) } diff --git a/src/logs/reading/selection.rs b/src/logs/reading/selection.rs index 8ff0ce18..fb7db063 100644 --- a/src/logs/reading/selection.rs +++ b/src/logs/reading/selection.rs @@ -13,12 +13,16 @@ use crate::logs::effects::Outside; use crate::logs::owner; enum Name { + Path(PathBuf), Issue { repository: String, number: u64 }, Pass(String), } impl Name { fn parse(name: &str) -> Result { + if Path::new(name).is_absolute() { + return Ok(Self::Path(name.into())); + } if name.starts_with("https://") { let issue = IssueUrl::parse(name)?; return Ok(Self::Issue { @@ -57,7 +61,7 @@ impl Name { .next() .is_some_and(|name| name.eq_ignore_ascii_case(repository))) } - Self::Pass(_) => true, + Self::Pass(_) | Self::Path(_) => true, } } } @@ -82,6 +86,16 @@ impl<'a> Observation<'a> { return false; } match name { + Name::Path(path) if self.selector().as_deref() == path.to_str() => true, + Name::Path(path) => match self { + Self::Record(command) => { + command.record_path == *path || command.command_log == *path + } + Self::History(command) => { + command.command_log.as_ref() == Some(path) + || command.session_logs.contains(path) + } + }, Name::Issue { number, .. } => { let issue = match self { Self::Record(command) => command.record.issue.as_ref(), @@ -125,6 +139,25 @@ impl<'a> Observation<'a> { } } + /// A durable selection, including dispatched Runs sharing an outer record. + pub(in crate::logs) fn selector(self) -> Option { + match self { + Self::Record(command) => { + let path = command.record_path.to_string_lossy(); + Some(if command.record.name.is_empty() { + path.into_owned() + } else { + format!("{path}::{}", command.record.name) + }) + } + Self::History(command) => command + .command_log + .as_ref() + .or_else(|| command.session_logs.first()) + .map(|path| path.to_string_lossy().into_owned()), + } + } + pub(in crate::logs) fn sessions(self) -> Result> { let (mut paths, issue, kind) = match self { Self::Record(command) => { @@ -186,6 +219,9 @@ impl<'a> Observation<'a> { .into_iter() .rev() .find(|path| { + if Path::new(kind).is_absolute() { + return *path == Path::new(kind); + } let Some(session) = owner::decode(path).and_then(|decoded| decoded.session_kind) else { return false; @@ -322,7 +358,11 @@ pub(super) fn resolve<'a>(entries: &'a [Entry], input: &str) -> Result, @@ -22,6 +22,7 @@ pub(crate) struct Section { } pub(crate) struct Row { + pub selector: String, pub subject: String, pub title: String, pub state: String, @@ -33,6 +34,132 @@ pub(crate) struct Row { pub pull_request: String, pub tickets: Vec, pub children: Vec, + pub findings: Vec, + pub sessions: Vec, +} + +impl Row { + pub fn peek_url(&self) -> String { + super::inspect::url("/peek", &self.selector, None) + } + pub fn log_url(&self) -> String { + super::inspect::url("/log", &self.selector, None) + } + pub fn session_url(&self, session: &std::path::Path) -> String { + super::inspect::url("/log", &self.selector, Some(session)) + } +} + +pub(crate) struct Archive { + pub rows: Vec, + pub repositories: Vec, + pub outcomes: Vec, +} + +#[derive(Clone, Copy)] +pub(crate) enum ArchivePage { + History, + Security, +} + +pub(crate) fn archive( + config: &UserConfig, + page: ArchivePage, + outcome: &str, + repository: &str, +) -> Result { + let now = Local::now(); + let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?; + let mut entries: Vec<_> = snapshot + .entries() + .iter() + .filter(|entry| match (page, entry) { + (ArchivePage::History, Entry::Record(command)) => command.state != "live", + (ArchivePage::History, Entry::History(_)) => true, + (ArchivePage::Security, Entry::Record(command)) => { + command.record.kind == "Security run" + } + (ArchivePage::Security, Entry::History(command)) => command.kind == "Security run", + }) + .collect(); + fn facts(entry: &Entry) -> (&str, &str, &str) { + match entry { + Entry::Record(command) => ( + &command.record.repository, + command.record.outcome.as_deref().unwrap_or(command.state), + command + .record + .ended_at + .as_deref() + .unwrap_or(&command.record.started_at), + ), + Entry::History(command) => ( + &command.repository, + command.outcome.as_deref().unwrap_or(command.state), + command.ended_at.as_deref().unwrap_or(&command.started_at), + ), + } + } + entries + .sort_by_key(|entry| std::cmp::Reverse(DateTime::parse_from_rfc3339(facts(entry).2).ok())); + let repositories = entries + .iter() + .map(|entry| facts(entry).0.to_owned()) + .collect::>() + .into_iter() + .collect(); + let outcomes = entries + .iter() + .map(|entry| facts(entry).1.to_owned()) + .collect::>() + .into_iter() + .collect(); + let rows = entries + .into_iter() + .filter(|entry| { + let (repo, result, _) = facts(entry); + (repository.is_empty() || repository == repo) + && (outcome.is_empty() || outcome == result) + }) + .map(|entry| entry_row(entry, now)) + .collect::>()?; + Ok(Archive { + rows, + repositories, + outcomes, + }) +} + +fn entry_row(entry: &Entry, now: DateTime) -> Result { + match entry { + Entry::Record(command) => row(command, now), + Entry::History(command) => { + let (subject, title) = + subject(&command.repository, &command.kind, command.issue.as_ref()); + Ok(Row { + selector: Observation::History(command).selector().unwrap_or_default(), + subject, + title, + state: command.state.into(), + now_line: command + .outcome + .clone() + .unwrap_or_else(|| "unknown outcome".into()), + age: elapsed( + command.ended_at.as_deref().unwrap_or(&command.started_at), + now, + ), + count: 1, + cause: String::new(), + harness: String::new(), + pull_request: String::new(), + tickets: Vec::new(), + children: Vec::new(), + findings: Vec::new(), + sessions: Observation::History(command).sessions()?, + }) + } + } } pub(crate) fn inbox(config: &UserConfig) -> Result { @@ -47,40 +174,15 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { .iter() .filter(|item| item.group == group) .map(|item| { - let mut row = match item.command { - Entry::Record(command) => row(command, now), - Entry::History(command) => { - let (subject, title) = - subject(&command.repository, &command.kind, command.issue.as_ref()); - Row { - subject, - title, - state: command.state.into(), - now_line: command - .outcome - .clone() - .unwrap_or_else(|| "unknown outcome".into()), - age: elapsed( - command.ended_at.as_deref().unwrap_or(&command.started_at), - now, - ), - count: 1, - cause: String::new(), - harness: String::new(), - pull_request: String::new(), - tickets: Vec::new(), - children: Vec::new(), - } - } - }; + let mut row = entry_row(item.command, now)?; if let Some(kind) = item.needs_you_kind { row.state = kind.title().into(); } row.count = item.count; row.cause = item.cause().unwrap_or_default().into(); - row + Ok(row) }) - .collect(); + .collect::>()?; let references = items .iter() .filter(|item| { @@ -101,7 +203,7 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { )) }) .collect(); - Section { + Ok(Section { group: match group { Group::NeedsYou => "needs you", Group::ReadyForReview => "ready for review", @@ -115,9 +217,9 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { open: group == Group::NeedsYou, rows, references, - } + }) }) - .collect(); + .collect::>()?; Ok(Inbox { sections, idle_passes: snapshot.idle_passes.iter().map(|pass| format!("{} idle on {}: {}", pass.kind, pass.repository, pass.reason)).collect(), @@ -139,10 +241,11 @@ fn subject(repository: &str, name: &str, issue: Option<&Issue>) -> (String, Stri } } -fn row(command: &Command, now: DateTime) -> Row { +fn row(command: &Command, now: DateTime) -> Result { let facts = &command.record; let (subject, title) = subject(&facts.repository, &facts.name, facts.issue.as_ref()); - Row { + Ok(Row { + selector: Observation::Record(command).selector().unwrap_or_default(), subject, title, state: command.state.into(), @@ -167,8 +270,10 @@ fn row(command: &Command, now: DateTime) -> Row { .iter() .chain(&command.children) .map(|child| row(child, now)) - .collect(), - } + .collect::>()?, + findings: facts.security_findings.clone(), + sessions: command.sessions()?, + }) } fn elapsed(time: &str, now: DateTime) -> String { diff --git a/src/logs/status/inspect.rs b/src/logs/status/inspect.rs index d79a0221..c04b5f36 100644 --- a/src/logs/status/inspect.rs +++ b/src/logs/status/inspect.rs @@ -9,6 +9,43 @@ use serde::Serialize; use crate::config::UserConfig; use crate::logs::reading::{Command, Observation, Snapshot}; +/// Exactly the detailed observations used by show, for the local Console. +pub(crate) fn details(config: &UserConfig, input: &str) -> Result { + let snapshot = + Snapshot::load_with_silence(&config.logs_dir, Local::now(), config.silent_interval)?; + Ok(serde_json::to_value( + snapshot.resolve(input)?.observation.details()?, + )?) +} + +pub(crate) fn read_log(config: &UserConfig, input: &str, session: Option<&str>) -> Result> { + let snapshot = Snapshot::load(&config.logs_dir, Local::now())?; + let command = snapshot.resolve(input)?.observation; + let path = selected_log(command, session)?; + Ok(std::fs::read(path)?) +} + +fn selected_log(command: Observation<'_>, session: Option<&str>) -> Result { + match session { + Some(session) => command.session(session), + None => command + .command_log() + .context("this Command has no Command log") + .map(std::path::Path::to_path_buf), + } +} + +pub(crate) fn url(route: &str, command: &str, session: Option<&std::path::Path>) -> String { + let mut url = reqwest::Url::parse("http://localhost").expect("fixed URL"); + url.set_path(route); + url.query_pairs_mut().append_pair("command", command); + if let Some(session) = session { + url.query_pairs_mut() + .append_pair("session", &session.to_string_lossy()); + } + format!("{}?{}", url.path(), url.query().unwrap_or_default()) +} + fn print(command: Observation<'_>) -> Result<()> { match command { Observation::Record(command) => print_details(command, "")?, @@ -90,13 +127,7 @@ pub fn logs(input: &str, session: Option<&str>, follow: bool) -> Result<()> { let snapshot = Snapshot::load(&config.logs_dir, Local::now())?; let selection = snapshot.resolve(input)?; let command = selection.observation; - let path = match session { - Some(kind) => command.session(kind)?, - None => command - .command_log() - .context("this Command has no Command log")? - .to_path_buf(), - }; + let path = selected_log(command, session)?; let mut file = std::fs::File::open(&path).with_context(|| format!("could not open {}", path.display()))?; let mut stdout = io::stdout().lock(); diff --git a/tests/console.rs b/tests/console.rs index e8455372..364df6e5 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -104,6 +104,248 @@ fn write_record(home: &Path, number: u64, record: &Value) { .unwrap(); } +#[test] +fn history_keeps_every_ended_command_in_time_order_and_filters() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + record(home.path(), 1, Some("merged"), 80); + let mut failed = record(home.path(), 2, Some("failed"), 2); + failed["cause"] = json!("same failure"); + write_record(home.path(), 2, &failed); + failed["issue"]["number"] = json!(3); + failed["started_at"] = json!(chrono::Local::now().to_rfc3339()); + failed["ended_at"] = failed["started_at"].clone(); + write_record(home.path(), 3, &failed); + record(home.path(), 4, None, 0); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let html = console.get("/?page=history").text().unwrap(); + assert!(html.contains("

    History

    ")); + assert!(html.find("acme/widgets#3").unwrap() < html.find("acme/widgets#2").unwrap()); + assert!(html.find("acme/widgets#2").unwrap() < html.find("acme/widgets#1").unwrap()); + // A missing process with no outcome is a Command that died, retained too. + assert!(html.contains("acme/widgets#4")); + let filtered = console + .get("/?page=history&outcome=merged&repository=acme%2Fwidgets") + .text() + .unwrap(); + assert!(filtered.contains("acme/widgets#1")); + assert!(!filtered.contains("acme/widgets#2")); + assert!( + !console + .get("/?page=history&repository=elsewhere") + .text() + .unwrap() + .contains("acme/widgets#1") + ); + assert_eq!(console.get("/?page=lanes").status(), 404); +} + +#[test] +fn security_lists_findings_and_sessions_from_the_cli_inventory() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + record(home.path(), 1, Some("merged"), 0); + let mut security = record(home.path(), 2, Some("findings recorded"), 50); + let session = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue/2-fixture-security-audit.jsonl"); + fs::write(&session, "Security session output").unwrap(); + security["kind"] = json!("Security run"); + security["name"] = json!("secure-fixture"); + security["issue"] = Value::Null; + security["session_log"] = json!(session); + security["security_findings"] = json!([{"title": "Bound ", "severity": "high", "url": "https://github.com/acme/widgets/security/advisories/GHSA-test"}]); + write_record(home.path(), 2, &security); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start(binary, home.path()); + let html = console.get("/?page=security").text().unwrap(); + assert!(html.contains("

    Security

    ")); + assert!(html.contains("Bound <input>")); + assert!(html.contains("high")); + assert!(html.contains("GHSA-test")); + assert!(html.contains("2-fixture-security-audit.jsonl")); + assert!(!html.contains("acme/widgets#1")); + assert!(html.contains("thirdshift status --all --json")); + assert!(html.contains("show")); + let shown = Command::new(binary) + .args(["show", "secure-fixture", "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(shown.status.success()); + let shown: Value = serde_json::from_slice(&shown.stdout).unwrap(); + assert_eq!(shown["command"]["session_logs"], json!([session])); + assert_eq!( + shown["command"]["security_findings"], + security["security_findings"] + ); +} + +#[test] +fn peek_and_full_logs_use_the_exact_selected_command_and_share_show_details() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let mut old = record(home.path(), 7, Some("failed"), 90); + old["issue"]["title"] = json!("Older Command "); + old["cause"] = json!("Old failure"); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + let selector = folder.join("7-old.command.json"); + let session = folder.join("7-old-implement.jsonl"); + fs::write(&session, "Session \n").unwrap(); + old["session_log"] = json!(session); + fs::write(&selector, serde_json::to_vec(&old).unwrap()).unwrap(); + fs::write(folder.join("7-old.log"), "Old full Command \n").unwrap(); + record(home.path(), 7, Some("merged"), 0); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let shown = Command::new(binary) + .args(["show", selector.to_str().unwrap(), "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + shown.status.success(), + "{}", + String::from_utf8_lossy(&shown.stderr) + ); + let shown: Value = serde_json::from_slice(&shown.stdout).unwrap(); + assert_eq!(shown["command"]["cause"], "Old failure"); + let console = Console::start(binary, home.path()); + let mut url = reqwest::Url::parse("http://localhost/peek").unwrap(); + url.query_pairs_mut() + .append_pair("command", selector.to_str().unwrap()); + let peek = console + .get(&format!("/peek?{}", url.query().unwrap())) + .text() + .unwrap(); + for expected in [ + "Older Command <unsafe>", + "Old failure", + "implement", + "gpt-6", + "7-old-implement.jsonl", + "tickets", + "usual ranges", + "thirdshift show", + ] { + assert!(peek.contains(expected), "missing {expected}: {peek}"); + } + let log = console + .get(&format!("/log?{}", url.query().unwrap())) + .text() + .unwrap(); + assert_eq!(log, "Old full Command \n"); + url.query_pairs_mut() + .append_pair("session", session.to_str().unwrap()); + assert_eq!( + console + .get(&format!("/log?{}", url.query().unwrap())) + .text() + .unwrap(), + "Session \n" + ); + assert_eq!(console.get("/peek?command=/etc/passwd").status(), 404); + url.query_pairs_mut() + .clear() + .append_pair("command", selector.to_str().unwrap()) + .append_pair("session", "/etc/passwd"); + assert_eq!( + console + .get(&format!("/log?{}", url.query().unwrap())) + .status(), + 404 + ); + let html = console.get("/?page=history").text().unwrap(); + assert!(html.contains("id=\"peek\" hidden")); + assert!(html.contains("7-old.command.json")); +} + +#[test] +fn dispatched_run_and_its_security_command_have_distinct_peeks() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let mut outer = record(home.path(), 1, Some("findings recorded"), 50); + outer["kind"] = json!("Security run"); + outer["name"] = json!("secure-fixture"); + outer["issue"] = Value::Null; + let mut dispatched = outer.clone(); + dispatched["kind"] = json!("Run"); + dispatched["name"] = json!("acme/widgets#42"); + dispatched["issue"] = + json!({"number":42,"url":"https://github.com/acme/widgets/issues/42","title":"Fix it"}); + dispatched["started_at"] = json!(chrono::Local::now().to_rfc3339()); + outer["dispatched"] = json!([dispatched]); + write_record(home.path(), 1, &outer); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let path = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json"); + for (selector, expected) in [ + (path.display().to_string(), "Security run"), + (format!("{}::acme/widgets#42", path.display()), "Run"), + ] { + let shown = Command::new(binary) + .args(["show", &selector, "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + shown.status.success(), + "{}", + String::from_utf8_lossy(&shown.stderr) + ); + let shown: Value = serde_json::from_slice(&shown.stdout).unwrap(); + assert_eq!(shown["command"]["kind"], expected); + } + let console = Console::start(binary, home.path()); + let html = console.get("/?page=security").text().unwrap(); + assert!(html.contains(&format!( + "data-command=\"{}::acme/widgets#42\"", + path.display() + ))); + assert!(html.contains(&format!( + "data-command=\"{}::secure-fixture\"", + path.display() + ))); +} + +#[test] +fn history_reaches_activity_and_session_logs_without_records() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let root = home.path().join(".thirdshift/logs/acme/widgets"); + let folder = root.join("commands/issue"); + fs::create_dir_all(&folder).unwrap(); + fs::write( + folder.join("8-20260101T120000+0000.log"), + "Old Command output", + ) + .unwrap(); + fs::write(root.join("activity.log"), "2026-01-01 12:00:00 Run #8 started: commands/issue/8-20260101T120000+0000.log\n2026-01-01 12:01:00 Run #8 ended: merged\n").unwrap(); + let session = folder.join("9-20260102T120000+0000-implement.jsonl"); + fs::write(&session, "Session without a Command record").unwrap(); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start(binary, home.path()); + let html = console.get("/?page=history").text().unwrap(); + assert!(html.contains("acme/widgets#8")); + assert!(html.contains("acme/widgets#9")); + assert!(html.find("acme/widgets#9").unwrap() < html.find("acme/widgets#8").unwrap()); + assert!(html.contains(&format!("data-command=\"{}\"", session.display()))); + let shown = Command::new(binary) + .args(["show", session.to_str().unwrap(), "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + shown.status.success(), + "{}", + String::from_utf8_lossy(&shown.stderr) + ); + let shown: Value = serde_json::from_slice(&shown.stdout).unwrap(); + assert_eq!(shown["command"]["session_logs"], json!([session])); +} + #[test] fn inbox_over_http_matches_status_and_embeds_only_local_resources() { let home = tempfile::tempdir().unwrap(); diff --git a/tests/security_run.rs b/tests/security_run.rs index 0c050d15..69590ff8 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -1962,6 +1962,22 @@ fn records_a_finding_privately_as_a_draft_without_severity_or_versions() { let status: Value = serde_json::from_str(&status.stdout).unwrap(); assert_eq!(status["commands"][0]["group"], "needs you"); assert_eq!(status["commands"][0]["needs_you_kind"], "finding"); + let retained = &status["commands"][0]["security_findings"]; + assert_eq!(retained[0]["title"], "Unchecked input size"); + assert!( + retained[0]["url"] + .as_str() + .unwrap() + .contains("/security/advisories/") + ); + let shown = scenario.run(&[ + "show", + status["commands"][0]["name"].as_str().unwrap(), + "--json", + ]); + assert_eq!(shown.code, Some(0), "{}", shown.stderr); + let shown: Value = serde_json::from_str(&shown.stdout).unwrap(); + assert_eq!(&shown["command"]["security_findings"], retained); let state = scenario.gh_state(); let advisory = &state["advisories"][0]; assert_eq!(advisory["state"], "draft"); From 4dde3f72e03a4ad2aa2ef2f65f3cab247c5060a2 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 00:00:31 +0000 Subject: [PATCH 17/47] Preserve dispatch and folded row selection after a push --- console/tests/inbox.test.cjs | 46 ++++++++++++++++++++++++++++++++++ src/console/assets/console.css | 2 +- src/console/assets/console.js | 23 +++++++++++++++-- src/logs/status/inbox.rs | 11 +++++++- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index b1d92e1b..4e94fa91 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -269,3 +269,49 @@ test("heartbeat beats only for server-observed log growth and is flat when Silen assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); } finally { await Promise.all([path, log, session].map(path => rm(path, { force: true }))); } }); + +test("a push keeps a Pass dispatch selected instead of its parent", async t => { + const dir = join(home, '.thirdshift/logs/acme/widgets/commands/security'); + await mkdir(dir, { recursive: true }); + const path = join(dir, '20261019T110000+0000~security.command.json'); + const now = new Date().toISOString(); + const fixture = { + version: 1, kind: 'Security run', name: 'secure-fixture', repository: 'acme/widgets', hostname: 'fixture', + issue: null, process_id: 0, process_start: 'gone', harness: 'codex', model: null, effort: null, + started_at: now, ended_at: now, stages: [], session_log: null, outcome: 'failed', cause: 'Pass failure', issue_branch: null, pull_request: null + }; + fixture.dispatched = [{ ...fixture, kind: 'Run', name: 'issue-303', issue: { number: 303, url: 'https://github.com/acme/widgets/issues/303', title: 'Dispatch before push' } }]; + await writeFile(path, JSON.stringify(fixture)); + try { + const page = await pageFor(t); + await page.goto(origin); + const child = page.locator('.command').filter({ has: page.locator(':scope > .row-head > .subject', { hasText: /^acme\/widgets#303$/ }) }); + await child.click(); + fixture.dispatched[0].issue.title = 'Dispatch after push'; + await writeFile(path, JSON.stringify(fixture)); + await page.getByText('Dispatch after push', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement.querySelector(':scope > .row-head > .subject')?.textContent), 'acme/widgets#303'); + const ids = await page.locator('.command').evaluateAll(rows => rows.map(row => row.id)); + assert.equal(new Set(ids).size, ids.length); + } finally { await rm(path, { force: true }); } +}); + +test("selection survives a move into folded Done and regains focus when opened", async t => { + const path = join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json'); + const original = await readFile(path, 'utf8'); + try { + const page = await pageFor(t); + await page.goto(origin); + const row = page.locator('.command').filter({ has: page.locator(':scope > .row-head > .subject', { hasText: /^acme\/widgets#1$/ }) }); + await row.click(); + const id = await row.getAttribute('id'); + const fixture = JSON.parse(original); + fixture.outcome = 'merged'; + await writeFile(path, JSON.stringify(fixture)); + await page.waitForFunction(id => document.getElementById(id)?.closest('details').dataset.group === 'done', id); + assert.equal(await page.evaluate(() => document.querySelector('.command[aria-current="true"]')?.id ?? document.activeElement.id), id); + assert.equal(await page.locator('[data-group="done"][open]').count(), 0); + await page.locator('[data-group="done"] > summary').click(); + await page.waitForFunction(id => document.activeElement.id === id, id); + } finally { await writeFile(path, original); } +}); diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 97b38d73..7ce2bf6c 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -35,7 +35,7 @@ summary .count { margin-left: 12px; color: var(--dim); font-size: 13px; } .commands, .children, .plan { list-style: none; padding: 0; margin: 0; } .commands { max-height: 300px; overflow: auto; } .command { padding: 8px 10px; margin-bottom: 4px; border-radius: var(--radius); color: var(--dim); overflow-wrap: anywhere; } -.command:focus { outline: 2px solid var(--head); outline-offset: -2px; } +.command:focus, .command[aria-current="true"] { outline: 2px solid var(--head); outline-offset: -2px; } .row-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } .state { color: var(--text); } .heartbeat { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--working); opacity: .4; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 776feca5..c92ab711 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -18,6 +18,12 @@ remember(visitKey, Date.now()); window.addEventListener("pagehide", () => remember(visitKey, Date.now())); const foldKey = "thirdshift:console:folded"; let folded = stored(foldKey, {}); +let selectedRowId; +function selectRow(row) { + document.querySelector('.command[aria-current="true"]')?.removeAttribute("aria-current"); + selectedRowId = row?.id; + row?.setAttribute("aria-current", "true"); +} if (typeof folded !== "object" || Array.isArray(folded)) folded = {}; for (const section of document.querySelectorAll("details[data-group]")) { if (typeof folded[section.dataset.group] === "boolean") section.open = folded[section.dataset.group]; @@ -27,13 +33,22 @@ document.addEventListener("toggle", event => { if (!section.matches("details[data-group]")) return; folded[section.dataset.group] = section.open; remember(foldKey, folded); + const selected = document.getElementById(selectedRowId); + if (section.open && selected && section.contains(selected)) selected.focus({ preventScroll: true }); }, true); +document.addEventListener("focusin", event => { + const row = event.target.closest(".command"); + if (row) selectRow(row); +}); document.addEventListener("click", event => { event.target.closest(".command")?.focus({ preventScroll: true }); }); +function logLengths(row) { + return row.dataset.logGrowth.split(":").map(Number); +} function growth() { - return new Map([...document.querySelectorAll(".command")].map(row => [row.id, row.dataset.logGrowth.split(":").map(Number)])); + return new Map([...document.querySelectorAll(".command")].map(row => [row.id, logLengths(row)])); } function morph(html) { @@ -46,6 +61,7 @@ function morph(html) { const wasSummary = active?.matches("summary"); const scroll = [...current.querySelectorAll("details")].map(section => [section.id, section.querySelector(".commands").scrollTop]); const position = [scrollX, scrollY]; + const selection = selectedRowId; const previousGrowth = growth(); Idiomorph.morph(current, replacement, { callbacks: { @@ -56,6 +72,9 @@ function morph(html) { }); const selected = document.getElementById(focused); (wasSummary ? selected?.querySelector("summary") : selected)?.focus({ preventScroll: true }); + // Focus cannot enter a folded section. Keep the logical row selected so + // it remains selected, and regains focus, when that section is reopened. + selectRow(document.getElementById(selection)); for (const [id, top] of scroll) document.getElementById(id).querySelector(".commands").scrollTop = top; window.scrollTo(...position); document.title = next.title; @@ -63,7 +82,7 @@ function morph(html) { markNew(); for (const row of document.querySelectorAll('.command[data-logging="true"]')) { const previous = previousGrowth.get(row.id) ?? [0, 0]; - const increased = row.dataset.logGrowth.split(":").some((size, index) => Number(size) > previous[index]); + const increased = logLengths(row).some((size, index) => size > previous[index]); if (increased && !matchMedia("(prefers-reduced-motion: reduce)").matches) { row.querySelector(":scope > .row-head > .heartbeat")?.animate([ { transform: "scale(1)", opacity: 0.4 }, diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs index 513bfd7c..b51d2542 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -159,7 +159,16 @@ fn row(command: &Command, now: DateTime, outer_log: Option<&Path>) -> Row let live = command.state == "live"; let (subject, title) = subject(&facts.repository, &facts.name, facts.issue.as_ref()); Row { - id: identity(&command.record_path.to_string_lossy()), + // A Pass's dispatched Runs share its storage path. Their immutable + // Command facts distinguish them without tying identity to ordering. + id: identity(&format!( + "{}\0{}\0{}\0{}\0{}", + command.record_path.to_string_lossy(), + facts.kind, + facts.name, + facts.issue.as_ref().map_or("", |issue| issue.url.as_str()), + facts.started_at + )), started_ms: timestamp(&facts.started_at), live, logging: live && !matches!(command.health, Some(Health::Silent { .. })), From 8c662ab78eb53b8d5367441003319995f67fb6f8 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 00:02:24 +0000 Subject: [PATCH 18/47] Measure Console CLI parity after attaching router state --- src/console.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/console.rs b/src/console.rs index 8834948e..85bf4ceb 100644 --- a/src/console.rs +++ b/src/console.rs @@ -224,8 +224,14 @@ mod tests { fn assert_cli_parity(router: Router) { // Measure the GET registration footprint through axum's public layer - // interface, so this does not depend on its implicit HEAD/fallback count. - let get_methods = registered_methods(Router::new().route("/known", get(|| async {}))); + // interface after attaching state, as the production router does, so + // this does not depend on its implicit HEAD/fallback count. + let (_, changes) = tokio::sync::watch::channel(0_u64); + let get_methods = registered_methods( + Router::>::new() + .route("/known", get(|| async {})) + .with_state(changes), + ); let expected = (super::PAGES.len() + 1) * get_methods; // one static-resource route assert_eq!( registered_methods(router), From 13ead25aa64212806aa472c53af8290f9fd32fe3 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 00:03:44 +0000 Subject: [PATCH 19/47] Address page metadata and focused control review findings --- console/tests/inbox.test.cjs | 27 +++++++++++++++++++++++++++ src/console.rs | 22 ++++++++++------------ src/console/assets/console.js | 2 ++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 65231c9a..2b967d39 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -253,3 +253,30 @@ test('History filters and Security findings and sessions work from a phone', asy await page.waitForURL('**/log?*'); assert.match(await page.locator('body').textContent(), /Security Session output/); }); + +test("Enter activates the focused Security session link", async t => { + const page = await pageFor(t, { viewport: { width: 1440, height: 900 } }); + await page.goto(`${origin}/?page=security`); + await page.locator('.peek-link').click(); + const sessionLink = page.locator('#peek a').filter({ hasText: 'Session log:' }); + await sessionLink.waitFor(); + await sessionLink.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL('**/log?*'); + assert.ok(new URL(page.url()).searchParams.has('session'), + `focused Session link opened the Command log instead: ${page.url()}`); + assert.match(await page.locator('body').textContent(), /Security Session output/); +}); + +test('Enter and Space activate focused peek buttons', async t => { + const page = await pageFor(t); + await page.goto(`${origin}/?page=security`); + for (const key of ['Enter', 'Space']) { + await page.locator('.peek-link').click(); + await page.locator('#peek dt').first().waitFor(); + await page.locator('[data-close-peek]').focus(); + await page.keyboard.press(key); + assert.equal(await page.locator('#peek').isVisible(), false); + assert.equal(new URL(page.url()).searchParams.get('page'), 'security'); + } +}); diff --git a/src/console.rs b/src/console.rs index 93b48587..b65a5cbe 100644 --- a/src/console.rs +++ b/src/console.rs @@ -151,23 +151,21 @@ async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) let config = UserConfig::load()?; let outcome = query_value(&query, "outcome"); let repository = query_value(&query, "repository"); + let (title, cli) = match archive_page { + Some(status::ArchivePage::History) => ("History", "thirdshift status --all --json"), + Some(status::ArchivePage::Security) => ( + "Security", + "thirdshift status --all --json · thirdshift show --json", + ), + None => ("Inbox", cli), + }; Ok(InboxPage { hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), consoles: config.console.other_consoles.clone(), theme: config.console.theme.clone(), - cli: match archive_page { - Some(status::ArchivePage::History) => "thirdshift status --all --json", - Some(status::ArchivePage::Security) => { - "thirdshift status --all --json · thirdshift show --json" - } - None => cli, - }, + cli, inbox: status::inbox(&config)?, - title: match archive_page { - Some(status::ArchivePage::History) => "History", - Some(status::ArchivePage::Security) => "Security", - None => "Inbox", - }, + title, archive: archive_page .map(|page| status::archive(&config, page, &outcome, &repository)) .transpose()?, diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 7a895bb8..27e132de 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -99,6 +99,8 @@ document.addEventListener('click', event => { document.addEventListener('keydown', event => { if (event.ctrlKey || event.metaKey || event.altKey || event.target.closest('input, select, textarea, [contenteditable="true"]')) return; + if (event.key === 'Enter' && event.target.closest('a, button')) return; + if (event.key === ' ' && event.target.closest('button')) return; if (event.key === 'Escape') { event.preventDefault(); if (help.open) help.close(); From 98a953f66b649c42dbbce8402f2c354b4b0d6b1b Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 00:50:20 +0000 Subject: [PATCH 20/47] Retain upstream child-log and streaming restart regressions --- tests/console.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/console.rs b/tests/console.rs index a193f495..82b909d1 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -689,8 +689,26 @@ fn child_runs_nest_and_each_request_reads_current_records() { .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture.command.json") ); write_record(home.path(), 8, &child); + let child_log = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue/8-fixture-implement.jsonl"); + fs::write(&child_log, "Ticket Session output\n").unwrap(); + child["session_log"] = json!(child_log); + write_record(home.path(), 8, &child); let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); let html = console.get("/").text().unwrap(); + let parent_link = html + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(_, label)| label.starts_with(">Run and Session logs")) + .unwrap() + .0; + let parent_logs = console.get(parent_link).text().unwrap(); + assert!( + parent_logs.contains("8-fixture-implement.jsonl"), + "{parent_logs}" + ); let nested = html.split("class=\"children\"").nth(1).expect(&html); assert!( nested @@ -762,6 +780,37 @@ fn replacing_the_binary_executes_the_replacement_with_the_original_arguments() { assert!(copied.success()); let console = Console::start(&binary, home.path()); assert_eq!(console.get("/").status(), 200); + // A reader must not keep graceful shutdown waiting on an infinite stream. + #[cfg(target_os = "linux")] + let _stream = { + let mut live = record(home.path(), 7, None, 0); + let stat = fs::read_to_string(format!("/proc/{}/stat", std::process::id())).unwrap(); + let ticks = stat + .rsplit_once(") ") + .unwrap() + .1 + .split_whitespace() + .nth(19) + .unwrap(); + let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap(); + live["process_id"] = json!(std::process::id()); + live["process_start"] = json!(format!("{}:{ticks}", boot.trim())); + let log = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue/7-fixture-implement.jsonl"); + fs::write(&log, "initial line\n").unwrap(); + live["session_log"] = json!(log); + write_record(home.path(), 7, &live); + let inventory = console.get("/logs").text().unwrap(); + let link = inventory + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(url, _)| url.starts_with("/logs/")) + .unwrap() + .0; + console.get(&format!("{link}/stream/0")) + }; let replacement = home.path().join("replacement"); fs::write(&replacement, "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HOME/reexec-args\"\nprintf 'replacement executed\\n'\n").unwrap(); fs::set_permissions(&replacement, fs::Permissions::from_mode(0o755)).unwrap(); From 16828d0e3fb354bf34073521864c12e1b0e8a192 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:00:29 +0000 Subject: [PATCH 21/47] Add Console accounts, sign-in and session controls (#821) --- Cargo.lock | 167 +++++++++++- Cargo.toml | 9 +- console/README.md | 33 ++- console/tests/accounts.test.cjs | 66 +++++ docs/code-navigation.md | 1 + src/args.rs | 24 +- src/config.rs | 2 + src/config/console.rs | 8 + src/console.rs | 133 +++++++-- src/console/account.html | 7 + src/console/account_commands.rs | 62 +++++ src/console/account_log.html | 7 + src/console/accounts.rs | 252 +++++++++++++++++ src/console/accounts/tests.rs | 140 ++++++++++ src/console/assets/console.css | 4 + src/console/auth.rs | 298 ++++++++++++++++++++ src/console/inbox.html | 2 +- src/console/logs.rs | 19 +- src/console/restart.rs | 11 +- src/console/sign_in.html | 11 + src/main.rs | 14 +- src/setup.rs | 85 +----- src/terminal.rs | 89 ++++++ tests/console.rs | 15 +- tests/console/accounts.rs | 469 ++++++++++++++++++++++++++++++++ 25 files changed, 1792 insertions(+), 136 deletions(-) create mode 100644 console/tests/accounts.test.cjs create mode 100644 src/console/account.html create mode 100644 src/console/account_commands.rs create mode 100644 src/console/account_log.html create mode 100644 src/console/accounts.rs create mode 100644 src/console/accounts/tests.rs create mode 100644 src/console/auth.rs create mode 100644 src/console/sign_in.html create mode 100644 src/terminal.rs create mode 100644 tests/console/accounts.rs diff --git a/Cargo.lock b/Cargo.lock index 36097d92..da3d1f6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "askama" version = "0.16.1" @@ -174,6 +186,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -187,6 +200,8 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", @@ -218,6 +233,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -233,6 +254,24 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -297,8 +336,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -347,6 +386,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -356,6 +404,27 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -465,6 +534,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -488,7 +567,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1034,6 +1113,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1156,7 +1246,16 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1171,7 +1270,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1210,6 +1309,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1345,6 +1445,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1452,6 +1558,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1461,6 +1578,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1597,6 +1737,7 @@ name = "thirdshift" version = "0.12.2" dependencies = [ "anyhow", + "argon2", "askama", "axoupdater", "axum", @@ -1604,10 +1745,14 @@ dependencies = [ "futures-util", "include_dir", "libc", + "rand_core 0.6.4", "reqwest", "serde", "serde_json", + "serde_urlencoded", + "sha2", "signal-hook", + "subtle", "tempfile", "tokio", "toml", @@ -1823,6 +1968,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -1859,6 +2010,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index b3303421..62e09e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ include = ["/src", "/skills", "/askama.toml", "/README.md", "/LICENSE"] [dependencies] anyhow = "1.0.104" +argon2 = "0.5.3" askama = "0.16.1" -axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "form"] } # axoupdater needs axoasset's `remote` feature, which always pulls in `image` # (with moxcms and pxfm) and turns on reqwest's `default-tls`, which is rustls # with aws-lc. No flag turns either off, and features unify, so switching @@ -25,9 +26,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["clock"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] } include_dir = "0.7.4" libc = "0.2.189" -reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "blocking", "json"] } +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "blocking", "json", "form"] } +rand_core = { version = "0.6.4", features = ["getrandom"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +serde_urlencoded = "0.7.1" +sha2 = "0.10.9" +subtle = "2.6.1" signal-hook = "0.4.4" tempfile = "3.27.0" toml = "1.1.6" diff --git a/console/README.md b/console/README.md index 08d6748d..0185ba1d 100644 --- a/console/README.md +++ b/console/README.md @@ -15,17 +15,44 @@ npm test home folder and a free localhost port, and closes its own server and browser. Node and npm are test tools only; the release build embeds its assets. -To open your machine's Inbox, run `thirdshift console --no-sign-in` and visit -the localhost address printed on stdout. Without `--no-sign-in` the command -refuses to serve until the sign-in Ticket ships. In `~/.thirdshift/config.toml`: +To open your machine's Inbox locally without accounts, run +`thirdshift console --no-sign-in` and visit the localhost address printed on +stdout. To require sign-in, set the allowlist in `~/.thirdshift/config.toml`: ```toml [console] port = 8711 +allowed_emails = ["you@example.com"] other_consoles = ["https://console.example.test"] theme = "focus-auto" # also focus-light or focus-dark ``` +Run `thirdshift console password --email you@example.com` in a terminal to set +the password, then `thirdshift console`. Password entry is hidden and asks +twice. With exactly one allowed address, `--email` can be omitted. An empty +allowlist refuses to serve unless `--no-sign-in` is explicit. Both modes bind +only to 127.0.0.1. Use HTTPS at the machine's reverse proxy for sign-in: cookies +are always Secure, HttpOnly and SameSite=Strict (localhost is suitable for tests). + +Accounts live in `~/.thirdshift/console-accounts.json`, beside the Credentials, +with owner-only permissions. Passwords use argon2id; only hashes of session +tokens are stored. Changes use a file lock and atomic replace, including across +CLI and server processes. Sessions survive restarts and expire 30 days after +last use. Every action, including sign-in and sign-out, requires a CSRF token. +After five wrong passwords, an address gets one attempt per minute until a +successful sign-in or a new password resets the slowdown. + +The Account page signs out the current browser. `thirdshift console sign-out +[--email
    ]` ends every session for that address; setting a new password +also ends all its sessions. Removing an address from `allowed_emails` blocks +it and revokes its sessions, while retaining the password for later re-adding. +The server checks current settings on requests and while streams remain open. + +`thirdshift console log [--json]` and the Console log page show the same +account events from `~/.thirdshift/console.log`. They name the account but +never contain passwords, session tokens or page views. Email account creation +and password reset are a later Ticket; ask the owner to use the password command. + The header names this machine and links to those addresses, without fetching them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary diff --git a/console/tests/accounts.test.cjs b/console/tests/accounts.test.cjs new file mode 100644 index 00000000..5b2a440f --- /dev/null +++ b/console/tests/accounts.test.cjs @@ -0,0 +1,66 @@ +const { test, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const { once } = require("node:events"); +const { mkdtemp, mkdir, writeFile, rm } = require("node:fs/promises"); +const { tmpdir } = require("node:os"); +const { join, resolve } = require("node:path"); +const { createInterface } = require("node:readline"); +const { chromium } = require("playwright"); + +let home, server, exited, browser, origin; +before(async () => { + home = await mkdtemp(join(tmpdir(), "thirdshift-console-accounts-")); + const root = join(home, ".thirdshift"); + await mkdir(root); + await writeFile(join(root, "config.toml"), '[console]\nport = 0\nallowed_emails = ["owner@example.test"]\n'); + // A fixture created with `console password` for "browser test password". + // The Rust HTTP tests exercise account creation through the real terminal. + await writeFile(join(root, "console-accounts.json"), JSON.stringify({ + "owner@example.test": { + password_hash: "$argon2id$v=19$m=19456,t=2,p=1$Yd89fw8Yx3I9TbhuY+Y/cQ$NhL+Lqg2T9nXrlM8NKBaQ3dpVpRWjRsyYbeNkQS8Q7s", + sessions: {} + } + }), { mode: 0o600 }); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, "../../target/debug/thirdshift"), ["console"], { + env: { ...process.env, HOME: home }, stdio: ["ignore", "pipe", "inherit"] + }); + exited = once(server, "exit"); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, "line", { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test("sign-in and sign-out work on a phone without JavaScript using secure cookies", async t => { + const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + await page.goto(origin); + assert.equal(new URL(page.url()).pathname, "/sign-in"); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.getByLabel("Email").fill("owner@example.test"); + await page.getByLabel("Password", { exact: true }).fill("browser test password"); + await Promise.all([page.waitForURL(`${origin}/`), page.getByRole("button", { name: "Sign in", exact: true }).click()]); + await page.getByRole("heading", { name: "Inbox", exact: true }).waitFor(); + const session = (await context.cookies()).find(cookie => cookie.name === "__Host-thirdshift-session"); + assert.ok(session); + assert.equal(session.httpOnly, true); + assert.equal(session.secure, true); + assert.equal(session.sameSite, "Strict"); + assert.ok(session.expires > Date.now() / 1000 + 29 * 86400); + assert.equal((await context.request.post(`${origin}/sign-out`)).status(), 403); + await page.getByRole("link", { name: "Console log", exact: true }).click(); + await page.getByText(/owner@example.test · sign-in/).waitFor(); + await page.getByRole("link", { name: "Account", exact: true }).click(); + await Promise.all([page.waitForURL(`${origin}/sign-in`), page.getByRole("button", { name: "Sign out this browser" }).click()]); + await page.goto(origin); + assert.equal(new URL(page.url()).pathname, "/sign-in"); + assert.ok(!(await context.cookies()).some(cookie => cookie.name === "__Host-thirdshift-session")); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 1f496f7e..cd9c6613 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -24,6 +24,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | +| Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | diff --git a/src/args.rs b/src/args.rs index eb9d4ce2..98a65196 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,6 +25,11 @@ pub enum Command { Console { no_sign_in: bool, }, + ConsolePassword(Option), + ConsoleSignOut(Option), + ConsoleLog { + json: bool, + }, Status { json: bool, all: bool, @@ -251,7 +256,24 @@ pub fn parse(args: &[String]) -> Result { return match &args[1..] { [] => Ok(Command::Console { no_sign_in: false }), [flag] if flag == "--no-sign-in" => Ok(Command::Console { no_sign_in: true }), - _ => bail!("usage: thirdshift console [--no-sign-in]"), + [action] if action == "password" => Ok(Command::ConsolePassword(None)), + [action] if action == "sign-out" => Ok(Command::ConsoleSignOut(None)), + [action, flag, email] if flag == "--email" && !email.starts_with('-') => { + match action.as_str() { + "password" => Ok(Command::ConsolePassword(Some(email.clone()))), + "sign-out" => Ok(Command::ConsoleSignOut(Some(email.clone()))), + _ => { + bail!("usage: thirdshift console password|sign-out [--email
    ]") + } + } + } + [action] if action == "log" => Ok(Command::ConsoleLog { json: false }), + [action, flag] if action == "log" && flag == "--json" => { + Ok(Command::ConsoleLog { json: true }) + } + _ => bail!( + "usage: thirdshift console [--no-sign-in] | password|sign-out [--email
    ] | log [--json]" + ), }; } Some("stop") => { diff --git a/src/config.rs b/src/config.rs index c01c308b..48d951f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -791,6 +791,7 @@ silent_minutes = 10 # minutes without growth in either log before a Run is Sil [console] port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711 +allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in other_consoles = [] # http or https addresses linked in the header, never fetched; default [] theme = "focus-auto" # Focus follows the system, or focus-light / focus-dark; default focus-auto @@ -973,6 +974,7 @@ mod tests { "inbox.days", "inbox.silent_minutes", "console.port", + "console.allowed_emails", "console.other_consoles", "console.theme", "activity.quiet_skips", diff --git a/src/config/console.rs b/src/config/console.rs index c23204e8..7269c2f8 100644 --- a/src/config/console.rs +++ b/src/config/console.rs @@ -6,6 +6,7 @@ use serde::Deserialize; #[serde(default, deny_unknown_fields)] pub struct ConsoleSettings { pub port: u16, + pub allowed_emails: Vec, pub other_consoles: Vec, pub theme: String, } @@ -14,6 +15,7 @@ impl Default for ConsoleSettings { fn default() -> Self { Self { port: 8711, + allowed_emails: Vec::new(), other_consoles: Vec::new(), theme: "focus-auto".into(), } @@ -22,6 +24,12 @@ impl Default for ConsoleSettings { impl ConsoleSettings { pub(super) fn validate(&self) -> Result<()> { + for email in &self.allowed_emails { + ensure!( + email.contains('@') && !email.chars().any(char::is_whitespace), + "console.allowed_emails must contain email addresses" + ); + } ensure!( matches!( self.theme.as_str(), diff --git a/src/console.rs b/src/console.rs index fafc46bd..f3919374 100644 --- a/src/console.rs +++ b/src/console.rs @@ -1,5 +1,9 @@ //! The machine's Console. Async execution is confined to this module. +mod account_commands; +mod accounts; +mod auth; mod conversation; +pub use account_commands::{password, print_log, sign_out}; mod logs; mod restart; @@ -9,10 +13,11 @@ use std::net::Ipv4Addr; use anyhow::{Context, Result, ensure}; use askama::Template; use axum::Router; -use axum::extract::Path; -use axum::http::{StatusCode, header}; +use axum::extract::{DefaultBodyLimit, Path}; +use axum::http::{Method, StatusCode, header}; +use axum::middleware; use axum::response::{Html, IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; use include_dir::{Dir, include_dir}; use crate::config::UserConfig; @@ -25,37 +30,73 @@ static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); struct PageRoute { path: &'static str, cli: &'static str, + method: Method, } const PAGES: &[PageRoute] = &[ PageRoute { path: "/", cli: "thirdshift status", + method: Method::GET, }, PageRoute { path: "/logs", cli: "thirdshift logs --json", + method: Method::GET, }, PageRoute { path: "/logs/command/{id}", cli: "thirdshift logs --command id --json", + method: Method::GET, }, PageRoute { path: "/logs/{id}", cli: "thirdshift logs --file id", + method: Method::GET, }, PageRoute { path: "/logs/{id}/stream/{offset}", cli: "thirdshift logs --file id --follow", + method: Method::GET, + }, + PageRoute { + path: "/console-log", + cli: "thirdshift console log", + method: Method::GET, + }, + PageRoute { + path: "/account", + cli: "thirdshift console sign-out", + method: Method::GET, + }, + PageRoute { + path: "/sign-out", + cli: "thirdshift console sign-out", + method: Method::POST, + }, + // CLI account access is authorized by the terminal's Unix account. HTTP + // establishes that access with the password set by this paired command. + PageRoute { + path: "/sign-in", + cli: "thirdshift console password", + method: Method::GET, + }, + PageRoute { + path: "/sign-in", + cli: "thirdshift console password", + method: Method::POST, }, ]; pub fn serve(no_sign_in: bool) -> Result<()> { + let config = UserConfig::load()?; ensure!( - no_sign_in, - "Console sign-in is not available yet; use thirdshift console --no-sign-in on localhost" + no_sign_in || !config.console.allowed_emails.is_empty(), + "console.allowed_emails is empty; configure allowed addresses or use --no-sign-in on localhost" ); - let config = UserConfig::load()?; + if !no_sign_in { + accounts::Accounts::load()?; + } let executable = restart::Executable::current()?; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -69,9 +110,27 @@ pub fn serve(no_sign_in: bool) -> Result<()> { let watched = executable.clone(); axum::serve( listener, - router().layer(axum::Extension(executable.clone())), + router(no_sign_in).layer(axum::Extension(executable.clone())), ) - .with_graceful_shutdown(async move { watched.replaced().await }) + .with_graceful_shutdown(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if watched.changed() { + return; + } + // Revoke removed accounts even when nobody makes a request, + // so re-adding an address cannot revive its old sessions. + if !no_sign_in { + match tokio::task::spawn_blocking(|| accounts::Accounts::load().map(drop)).await + { + Ok(Ok(())) => {} + error => { + eprintln!("thirdshift Console: can't reconcile allowlist: {error:?}") + } + } + } + } + }) .await .context("Console server failed")?; Ok::<_, anyhow::Error>(()) @@ -81,22 +140,32 @@ pub fn serve(no_sign_in: bool) -> Result<()> { executable.exec() } -fn router() -> Router { +fn router(no_sign_in: bool) -> Router { let mut router = Router::new(); for page in PAGES { let cli = page.cli; - let handler = match page.path { - "/" => get(move || inbox(cli)), - "/logs" => get(logs::index), - "/logs/command/{id}" => get(logs::command), - "/logs/{id}" => get(logs::reader), - "/logs/{id}/stream/{offset}" => get(logs::stream), + let handler = match (page.path, page.method.clone()) { + ("/", Method::GET) => get(move || inbox(cli)), + ("/logs", Method::GET) => get(logs::index), + ("/logs/command/{id}", Method::GET) => get(logs::command), + ("/logs/{id}", Method::GET) => get(logs::reader), + ("/logs/{id}/stream/{offset}", Method::GET) => get(logs::stream), + ("/console-log", Method::GET) => get(auth::log), + ("/account", Method::GET) => get(auth::account), + ("/sign-out", Method::POST) => post(auth::sign_out), + ("/sign-in", Method::GET) => get(auth::sign_in_page), + ("/sign-in", Method::POST) => post(auth::sign_in), _ => unreachable!("unknown Console page"), }; router = router.route(page.path, handler); } router = router.route("/assets/{*path}", get(asset)); + let access = auth::Access::new(no_sign_in); router + .layer(DefaultBodyLimit::max(16 * 1024)) + .layer(middleware::from_fn_with_state(access.clone(), auth::guard)) + .layer(axum::Extension(access)) + .layer(middleware::from_fn(auth::headers)) } #[derive(Template)] @@ -147,14 +216,14 @@ async fn inbox(cli: &'static str) -> Response { fn page(result: std::result::Result, tokio::task::JoinError>) -> Response { match result { - Ok(Ok(html)) => ( - [(header::CONTENT_SECURITY_POLICY, "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"), - (header::CACHE_CONTROL, "no-store"), - (header::X_CONTENT_TYPE_OPTIONS, "nosniff")], Html(html) - ).into_response(), + Ok(Ok(html)) => Html(html).into_response(), error => { eprintln!("thirdshift Console: can't read page: {error:?}"); - (StatusCode::INTERNAL_SERVER_ERROR, "Can't read this page; check the Console's stderr.").into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read this page; check the Console's stderr.", + ) + .into_response() } } } @@ -214,7 +283,23 @@ mod tests { // Measure the GET registration footprint through axum's public layer // interface, so this does not depend on its implicit HEAD/fallback count. let get_methods = registered_methods(Router::new().route("/known", get(|| async {}))); - let expected = (super::PAGES.len() + 1) * get_methods; // one static-resource route + let post_methods = + registered_methods(Router::new().route("/known", axum::routing::post(|| async {}))); + // Each unique path has one fallback; merging GET and POST at sign-in + // adds only the POST service, not another fallback service. + let fallback = 1; + let expected = super::PAGES + .iter() + .map(|page| { + if page.method == axum::http::Method::GET { + get_methods + } else { + post_methods + } + }) + .sum::() + + get_methods + - fallback; assert_eq!( registered_methods(router), expected, @@ -243,14 +328,14 @@ mod tests { #[test] fn every_console_route_has_a_cli_equivalent() { - assert_cli_parity(super::router()); + assert_cli_parity(super::router(false)); } #[test] #[should_panic(expected = "registered routes must all have a CLI pair")] fn parity_rejects_a_route_registered_without_a_cli_pair() { assert_cli_parity( - super::router().route("/unpaired", get(|| async { "page with no CLI pair" })), + super::router(false).route("/unpaired", get(|| async { "page with no CLI pair" })), ); } } diff --git a/src/console/account.html b/src/console/account.html new file mode 100644 index 00000000..72e9e2b9 --- /dev/null +++ b/src/console/account.html @@ -0,0 +1,7 @@ + +Account · thirdshift +

    thirdshift / Console

    +

    Account

    {{ email }}

    +{% if signed_in %}
    {% endif %} +

    End all sessions: thirdshift console sign-out --email <address>

    +
    diff --git a/src/console/account_commands.rs b/src/console/account_commands.rs new file mode 100644 index 00000000..dc339ea9 --- /dev/null +++ b/src/console/account_commands.rs @@ -0,0 +1,62 @@ +//! Console account commands use the same account store and log as HTTP. +use anyhow::{Result, bail, ensure}; +use std::io::Write; + +use super::accounts::Accounts; +use crate::config::UserConfig; + +fn email(given: Option<&str>) -> Result { + if let Some(email) = given { + return Ok(email.into()); + } + let config = UserConfig::load()?; + if let [email] = &config.console.allowed_emails[..] { + return Ok(email.clone()); + } + bail!("give --email
    ; console.allowed_emails must name exactly one address to omit it") +} + +pub fn password(given: Option<&str>) -> Result { + let email = email(given)?; + ensure!( + UserConfig::load()?.console.allowed_emails.contains(&email), + "address is not allowed in console.allowed_emails" + ); + crate::interrupt::install()?; + let (first, second) = { + // Suppress echo before either prompt, and keep it off between them. + let _echo = crate::terminal::EchoOff::new()?; + (prompt("Password: ")?, prompt("Confirm password: ")?) + }; + ensure!(first == second, "passwords do not match"); + Accounts::load()?.set_password(&email, &first)?; + Ok(format!( + "Password set for {email}; previous sessions ended." + )) +} + +fn prompt(prompt: &str) -> Result { + eprint!("{prompt}"); + std::io::stderr().flush()?; + let answer = crate::terminal::line()?.ok_or_else(|| anyhow::anyhow!("password input ended"))?; + eprintln!(); + Ok(answer) +} + +pub fn sign_out(given: Option<&str>) -> Result { + let email = email(given)?; + Accounts::load()?.sign_out(&email, None)?; + Ok(format!("Signed out {email}.")) +} + +pub fn print_log(json: bool) -> Result<()> { + let events = Accounts::load()?.events()?; + if json { + println!("{}", serde_json::to_string(&events)?); + } else { + for event in events { + println!("{} {} {}", event.time, event.email, event.event); + } + } + Ok(()) +} diff --git a/src/console/account_log.html b/src/console/account_log.html new file mode 100644 index 00000000..a80794f3 --- /dev/null +++ b/src/console/account_log.html @@ -0,0 +1,7 @@ + +Console log · thirdshift +

    thirdshift / Console

    +

    Console log

    thirdshift console log
    +
      {% for event in events %}
    • {{ event.time }} · {{ event.email }} · {{ event.event }}
    • {% endfor %}
    +{% if events.is_empty() %}

    No account events.

    {% endif %} +
    diff --git a/src/console/accounts.rs b/src/console/accounts.rs new file mode 100644 index 00000000..dde94a9c --- /dev/null +++ b/src/console/accounts.rs @@ -0,0 +1,252 @@ +//! Durable Console accounts, shared by the server and CLI under one file lock. + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub(super) const LIFETIME: i64 = 30 * 24 * 60 * 60; + +#[derive(Default, Deserialize, Serialize)] +struct Account { + password_hash: String, + #[serde(default)] + sessions: BTreeMap, + #[serde(default)] + failed_passwords: u32, + #[serde(default)] + next_attempt: i64, +} + +#[derive(Deserialize, Serialize)] +pub(super) struct Event { + pub time: i64, + pub email: String, + pub event: String, +} + +pub(super) enum SignIn { + SignedIn(String), + Refused, + Slowdown, +} + +pub(super) struct Accounts { + root: PathBuf, + // Keep a separate, stable inode locked across account-file replacements. + _lock: File, + allowed: Vec, + now: i64, + accounts: BTreeMap, +} + +impl Accounts { + pub fn load() -> Result { + let config = crate::config::UserConfig::load()?; + Self::open( + &crate::config::home()?.join(".thirdshift"), + &config.console.allowed_emails, + chrono::Utc::now().timestamp(), + ) + } + + pub fn open(root: &Path, allowed: &[String], now: i64) -> Result { + fs::create_dir_all(root)?; + let lock = private_file(&root.join("console-accounts.lock"))?; + lock.lock()?; + let path = root.join("console-accounts.json"); + let accounts = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).context("invalid Console accounts")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(error) => return Err(error.into()), + }; + let mut store = Self { + root: root.into(), + _lock: lock, + allowed: allowed.to_vec(), + now, + accounts, + }; + let mut changed = false; + let mut blocked = Vec::new(); + for (email, account) in &mut store.accounts { + let before = account.sessions.len(); + if !allowed.contains(email) { + account.sessions.clear(); + if before != 0 { + blocked.push(email.clone()); + } + } else { + account + .sessions + .retain(|_, last_use| now.saturating_sub(*last_use) < LIFETIME); + } + changed |= before != account.sessions.len(); + } + if changed { + store.save()?; + } + for email in blocked { + store.record(&email, "blocked-address")?; + } + Ok(store) + } + + pub fn set_password(&mut self, email: &str, password: &str) -> Result<()> { + ensure!( + self.allowed.iter().any(|allowed| allowed == email), + "address is not allowed in console.allowed_emails" + ); + ensure!(!password.is_empty(), "password must not be empty"); + let salt = SaltString::generate(&mut OsRng); + let password_hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|error| anyhow::anyhow!("can't hash password: {error}"))? + .to_string(); + self.accounts.insert( + email.into(), + Account { + password_hash, + ..Account::default() + }, + ); + self.save()?; + self.record(email, "password-set") + } + + pub fn sign_in(&mut self, email: &str, password: &str) -> Result { + if !self.allowed.iter().any(|allowed| allowed == email) { + self.record(email, "blocked-address")?; + return Ok(SignIn::Refused); + } + if self + .accounts + .get(email) + .is_some_and(|account| self.now < account.next_attempt) + { + self.record(email, "failed-sign-in")?; + return Ok(SignIn::Slowdown); + } + let valid = self.accounts.get(email).is_some_and(|account| { + PasswordHash::new(&account.password_hash).is_ok_and(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() + }) + }); + if !valid { + if let Some(account) = self.accounts.get_mut(email) { + account.failed_passwords = account.failed_passwords.saturating_add(1); + if account.failed_passwords >= 5 { + account.next_attempt = self.now + 60; + } + self.save()?; + } + self.record(email, "failed-sign-in")?; + return Ok(SignIn::Refused); + } + let token = random_token(); + let account = self.accounts.get_mut(email).expect("verified account"); + account.failed_passwords = 0; + account.next_attempt = 0; + account.sessions.insert(digest(&token), self.now); + self.save()?; + self.record(email, "sign-in")?; + Ok(SignIn::SignedIn(token)) + } + + pub fn session(&mut self, token: &str, touch: bool) -> Result> { + let hash = digest(token); + let email = self.accounts.iter().find_map(|(email, account)| { + account.sessions.contains_key(&hash).then(|| email.clone()) + }); + if let Some(email) = &email + && touch + { + self.accounts + .get_mut(email) + .expect("session account") + .sessions + .insert(hash, self.now); + self.save()?; + } + Ok(email) + } + + // None ends every session for the account (CLI); Some ends this browser only. + pub fn sign_out(&mut self, email: &str, token: Option<&str>) -> Result<()> { + if let Some(account) = self.accounts.get_mut(email) { + match token { + Some(token) => { + account.sessions.remove(&digest(token)); + } + None => account.sessions.clear(), + } + self.save()?; + } + self.record(email, "sign-out") + } + + pub fn events(&self) -> Result> { + let text = match fs::read_to_string(self.root.join("console.log")) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + text.lines() + .map(|line| serde_json::from_str(line).context("invalid Console log event")) + .collect() + } + + fn save(&self) -> Result<()> { + let mut file = tempfile::NamedTempFile::new_in(&self.root)?; + file.write_all(&serde_json::to_vec(&self.accounts)?)?; + file.as_file().sync_all()?; + file.persist(self.root.join("console-accounts.json"))?; + Ok(()) + } + + fn record(&self, email: &str, event: &str) -> Result<()> { + let mut file = private_file(&self.root.join("console.log"))?; + serde_json::to_writer( + &mut file, + &Event { + time: self.now, + email: email.into(), + event: event.into(), + }, + )?; + file.write_all(b"\n")?; + Ok(()) + } +} + +fn private_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + Ok(options.open(path)?) +} + +pub(super) fn random_token() -> String { + let mut bytes = [0; 32]; + OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub(super) fn digest(token: &str) -> String { + format!("{:x}", Sha256::digest(token.as_bytes())) +} + +#[cfg(test)] +mod tests; diff --git a/src/console/accounts/tests.rs b/src/console/accounts/tests.rs new file mode 100644 index 00000000..bdf85a52 --- /dev/null +++ b/src/console/accounts/tests.rs @@ -0,0 +1,140 @@ +use super::*; + +fn signed_in(accounts: &mut Accounts) -> String { + let SignIn::SignedIn(token) = accounts.sign_in("owner@example.test", "password").unwrap() + else { + panic!("sign-in refused") + }; + token +} + +#[test] +fn five_wrong_passwords_slow_the_address_to_one_attempt_per_minute() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into(), "other@example.test".into()]; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + accounts + .set_password("owner@example.test", "password") + .unwrap(); + accounts + .set_password("other@example.test", "password") + .unwrap(); + for _ in 0..5 { + assert!(matches!( + accounts.sign_in("owner@example.test", "wrong").unwrap(), + SignIn::Refused + )); + } + assert!( + !matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "sixth try must wait" + ); + assert!(matches!( + accounts.sign_in("other@example.test", "password").unwrap(), + SignIn::SignedIn(_) + )); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_059).unwrap(); + assert!(!matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + )); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_060).unwrap(); + assert!(matches!( + accounts.sign_in("owner@example.test", "wrong").unwrap(), + SignIn::Refused + )); + assert!( + !matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "each wrong try restarts the minute" + ); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_120).unwrap(); + assert!(matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + )); + assert!( + matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "success resets the slowdown" + ); +} + +#[test] +fn sessions_expire_thirty_days_after_last_use_and_allowlist_removal_revokes_them() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into()]; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + accounts + .set_password("owner@example.test", "password") + .unwrap(); + let token = signed_in(&mut accounts); + drop(accounts); + let month = 30 * 24 * 60 * 60; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month - 1).unwrap(); + assert!(accounts.session(&token, true).unwrap().is_some()); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month * 2 - 2).unwrap(); + assert!(accounts.session(&token, false).unwrap().is_some()); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month * 2 - 1).unwrap(); + assert!( + accounts.session(&token, true).unwrap().is_none(), + "expires at 30 days, even across restarts" + ); + let token = signed_in(&mut accounts); + drop(accounts); + drop(Accounts::open(dir.path(), &[], 1_000 + month * 2).unwrap()); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_001 + month * 2).unwrap(); + assert!( + accounts.session(&token, true).unwrap().is_none(), + "re-adding the address must not revive sessions" + ); + assert!( + matches!( + accounts.sign_in("owner@example.test", "password").unwrap(), + SignIn::SignedIn(_) + ), + "the password is kept" + ); +} + +#[test] +fn passwords_and_sessions_survive_reopening_and_password_changes_revoke_sessions() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into()]; + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + accounts + .set_password("owner@example.test", "first password") + .unwrap(); + assert!(matches!( + accounts.sign_in("owner@example.test", "wrong").unwrap(), + SignIn::Refused + )); + let SignIn::SignedIn(token) = accounts + .sign_in("owner@example.test", "first password") + .unwrap() + else { + panic!("sign-in refused") + }; + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_001).unwrap(); + assert_eq!( + accounts.session(&token, true).unwrap().as_deref(), + Some("owner@example.test") + ); + accounts + .set_password("owner@example.test", "new password") + .unwrap(); + assert!(accounts.session(&token, true).unwrap().is_none()); +} diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 730e2af8..1c0c892f 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -59,6 +59,10 @@ aside { font-size: 12px; } #log-content article, #log-content > pre, .thinking, .tool-group { padding: 8px 12px; border: 1px solid var(--rule); margin: 8px 0; border-radius: var(--radius); } #log-content h3 { font-size: 14px; } .diff { border-left: 3px solid var(--look); padding-left: 10px; } +form { max-width: 420px; margin: 20px 0; } +form label { display: block; margin: 12px 0; } +form input:not([type="hidden"]), form button { display: block; width: 100%; padding: 10px; margin-top: 6px; font: inherit; background: var(--panel); color: var(--text); border: 1px solid var(--rule); border-radius: var(--radius); } +form button { cursor: pointer; font-weight: var(--head-weight); } @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/auth.rs b/src/console/auth.rs new file mode 100644 index 00000000..b7db1161 --- /dev/null +++ b/src/console/auth.rs @@ -0,0 +1,298 @@ +//! HTTP sign-in, session authorization and CSRF for every Console action. +use anyhow::Result; +use askama::Template; +use axum::body::{Body, to_bytes}; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, Method, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::{Extension, Form}; +use serde::Deserialize; +use subtle::ConstantTimeEq; + +use super::accounts::{self, Accounts, SignIn}; + +const SESSION: &str = "__Host-thirdshift-session"; +const SIGN_IN: &str = "__Host-thirdshift-sign-in"; + +#[derive(Clone)] +pub(super) struct Access { + pub no_sign_in: bool, + local_csrf: String, +} + +impl Access { + pub fn new(no_sign_in: bool) -> Self { + Self { + no_sign_in, + local_csrf: accounts::random_token(), + } + } +} + +#[derive(Clone)] +pub(super) struct Viewer { + pub email: String, + token: Option, + csrf: String, +} + +impl Viewer { + // Streams recheck without extending idle lifetime on polling alone. + pub fn still_allowed(&self) -> Result { + match &self.token { + Some(token) => Ok(Accounts::load()?.session(token, false)?.is_some()), + None => Ok(true), + } + } +} + +fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|header| header.to_str().ok()) + .flat_map(|header| header.split(';')) + .find_map(|pair| { + let (key, value) = pair.trim().split_once('=')?; + (key == name && value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| value.to_owned()) + }) +} + +fn csrf(token: &str) -> String { + accounts::digest(&format!("csrf:{token}")) +} + +fn valid_csrf(given: &str, expected: &str) -> bool { + bool::from(given.as_bytes().ct_eq(expected.as_bytes())) +} + +fn set_cookie(response: &mut Response, name: &str, value: &str, age: i64) { + response.headers_mut().append( + header::SET_COOKIE, + format!("{name}={value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age={age}") + .parse() + .expect("generated cookie"), + ); +} + +pub(super) async fn guard( + State(access): State, + mut request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + if path == "/sign-in" || path.starts_with("/assets/") { + return next.run(request).await; + } + let viewer = if access.no_sign_in { + Viewer { + email: "localhost (no sign-in)".into(), + token: None, + csrf: access.local_csrf, + } + } else { + let Some(token) = cookie(request.headers(), SESSION) else { + return Redirect::to("/sign-in").into_response(); + }; + let selected = token.clone(); + let result = + tokio::task::spawn_blocking(move || Accounts::load()?.session(&selected, true)).await; + match result { + Ok(Ok(Some(email))) => Viewer { + email, + csrf: csrf(&token), + token: Some(token), + }, + Ok(Ok(None)) => return Redirect::to("/sign-in").into_response(), + error => return failed(error), + } + }; + if !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) { + let supplied = request + .headers() + .get("x-csrf-token") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let (parts, body) = request.into_parts(); + let Ok(bytes) = to_bytes(body, 16 * 1024).await else { + return StatusCode::PAYLOAD_TOO_LARGE.into_response(); + }; + let form = + serde_urlencoded::from_bytes::>(&bytes).unwrap_or_default(); + let supplied = supplied.or_else(|| { + form.into_iter() + .find(|(key, _)| key == "csrf") + .map(|(_, value)| value) + }); + if !supplied.is_some_and(|token| valid_csrf(&token, &viewer.csrf)) { + return StatusCode::FORBIDDEN.into_response(); + } + request = Request::from_parts(parts, Body::from(bytes)); + } + request.extensions_mut().insert(viewer.clone()); + let mut response = next.run(request).await; + if let Some(token) = viewer.token + && !response.headers().contains_key(header::SET_COOKIE) + { + set_cookie(&mut response, SESSION, &token, accounts::LIFETIME); + } + response +} + +pub(super) async fn headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + for (name, value) in [ + ( + header::CONTENT_SECURITY_POLICY, + "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + ), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + (header::REFERRER_POLICY, "same-origin"), + ] { + response.headers_mut().insert(name, value.parse().unwrap()); + } + response +} + +#[derive(Template)] +#[template(path = "console/sign_in.html")] +struct SignInPage { + csrf: String, +} + +pub(super) async fn sign_in_page( + Extension(access): Extension, + headers: HeaderMap, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + let token = cookie(&headers, SIGN_IN).unwrap_or_else(accounts::random_token); + let mut response = match (SignInPage { csrf: csrf(&token) }).render() { + Ok(html) => super::page(Ok(Ok(html))), + Err(error) => return failed(error), + }; + set_cookie(&mut response, SIGN_IN, &token, 15 * 60); + response +} + +#[derive(Deserialize)] +pub(super) struct SignInForm { + email: String, + password: String, + #[serde(default)] + csrf: String, +} + +pub(super) async fn sign_in( + Extension(access): Extension, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + let Some(token) = cookie(&headers, SIGN_IN) else { + return StatusCode::FORBIDDEN.into_response(); + }; + if !valid_csrf(&form.csrf, &csrf(&token)) { + return StatusCode::FORBIDDEN.into_response(); + } + let result = + tokio::task::spawn_blocking(move || Accounts::load()?.sign_in(&form.email, &form.password)) + .await; + match result { + Ok(Ok(SignIn::SignedIn(token))) => { + let mut response = Redirect::to("/").into_response(); + set_cookie(&mut response, SESSION, &token, accounts::LIFETIME); + response + } + Ok(Ok(SignIn::Refused)) => ( + StatusCode::UNAUTHORIZED, + axum::response::Html("Sign-in failed. Try again."), + ) + .into_response(), + Ok(Ok(SignIn::Slowdown)) => ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, "60")], + axum::response::Html( + "Sign-in failed. Wait one minute, then try again.", + ), + ) + .into_response(), + Ok(Err(error)) => failed(error), + Err(error) => failed(error), + } +} + +#[derive(Template)] +#[template(path = "console/account.html")] +struct AccountPage { + email: String, + csrf: String, + signed_in: bool, +} + +pub(super) async fn account(Extension(viewer): Extension) -> Response { + match (AccountPage { + email: viewer.email, + csrf: viewer.csrf, + signed_in: viewer.token.is_some(), + }) + .render() + { + Ok(html) => super::page(Ok(Ok(html))), + Err(error) => failed(error), + } +} + +pub(super) async fn sign_out(Extension(viewer): Extension) -> Response { + let Some(token) = viewer.token else { + return StatusCode::NOT_FOUND.into_response(); + }; + let result = tokio::task::spawn_blocking(move || { + Accounts::load()?.sign_out(&viewer.email, Some(&token)) + }) + .await; + match result { + Ok(Ok(())) => { + let mut response = Redirect::to("/sign-in").into_response(); + set_cookie(&mut response, SESSION, "", 0); + response + } + error => failed(error), + } +} + +#[derive(Template)] +#[template(path = "console/account_log.html")] +struct LogPage { + events: Vec, +} + +pub(super) async fn log() -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(LogPage { + events: Accounts::load()?.events()?, + } + .render()?) + }) + .await, + ) +} + +fn failed(error: impl std::fmt::Debug) -> Response { + eprintln!("thirdshift Console: account operation failed: {error:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read Console accounts; check the Console's stderr.", + ) + .into_response() +} diff --git a/src/console/inbox.html b/src/console/inbox.html index f7d24861..e7239627 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -17,7 +17,7 @@ {% if !consoles.is_empty() %}{% endif %}
    - +

    Inbox

    {{ cli }}
    {% match inbox.warning %}{% when Some with (warning) %}

    Warning: {{ warning }}

    {% when None %}{% endmatch %} {% for section in inbox.sections %} diff --git a/src/console/logs.rs b/src/console/logs.rs index 824cab4d..1f66241f 100644 --- a/src/console/logs.rs +++ b/src/console/logs.rs @@ -141,6 +141,7 @@ fn read_lines(log: &Log, offset: u64, budget: usize) -> Result { pub(super) async fn stream( Path((id, offset)): Path<(String, u64)>, Extension(executable): Extension, + Extension(viewer): Extension, headers: HeaderMap, ) -> Response { let offset = headers @@ -155,7 +156,14 @@ pub(super) async fn stream( offset <= std::fs::metadata(log.path)?.len(), "invalid log offset" ); - Ok((std::sync::Arc::new(config), id, offset, false, executable)) + Ok(( + std::sync::Arc::new(config), + id, + offset, + false, + executable, + viewer, + )) }) .await; let state = match checked { @@ -164,7 +172,7 @@ pub(super) async fn stream( }; let events = futures_util::stream::unfold( state, - |(config, id, mut offset, done, executable)| async move { + |(config, id, mut offset, done, executable, viewer)| async move { if done { return None; } @@ -172,8 +180,9 @@ pub(super) async fn stream( let root = config.clone(); let selected = id.clone(); let watched = executable.clone(); + let signed_in = viewer.clone(); let result = tokio::task::spawn_blocking(move || { - if watched.changed() { + if watched.changed() || !signed_in.still_allowed()? { return Ok(None); } appended(&root, &selected, offset).map(Some) @@ -190,7 +199,7 @@ pub(super) async fn stream( .data(serde_json::to_string(&batch).expect("serializable log batch")); return Some(( Ok::<_, Infallible>(event), - (config, id, offset, done, executable), + (config, id, offset, done, executable, viewer), )); } Ok(Ok(Some(_))) => { @@ -201,7 +210,7 @@ pub(super) async fn stream( let event = Event::default() .event("log-error") .data("Can't read log; reopen the reader. Check the Console's stderr."); - return Some((Ok(event), (config, id, offset, true, executable))); + return Some((Ok(event), (config, id, offset, true, executable, viewer))); } } } diff --git a/src/console/restart.rs b/src/console/restart.rs index 20eef5ec..f400822c 100644 --- a/src/console/restart.rs +++ b/src/console/restart.rs @@ -1,6 +1,6 @@ //! Watch the installed path, rather than /proc's possibly unlinked executable. use std::path::PathBuf; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use anyhow::{Context, Result}; @@ -25,15 +25,6 @@ impl Executable { Ok(Self { path, identity }) } - pub async fn replaced(&self) { - loop { - tokio::time::sleep(Duration::from_secs(1)).await; - if self.changed() { - return; - } - } - } - pub fn changed(&self) -> bool { identity(&self.path).is_ok_and(|current| current != self.identity) } diff --git a/src/console/sign_in.html b/src/console/sign_in.html new file mode 100644 index 00000000..5899a17c --- /dev/null +++ b/src/console/sign_in.html @@ -0,0 +1,11 @@ + +Sign in · thirdshift +

    thirdshift / Console

    Sign in

    +
    + + + + +
    +

    Need a password? Ask the machine's owner to run thirdshift console password --email <address>.

    +
    diff --git a/src/main.rs b/src/main.rs index 5445a19d..f28a6582 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod session; mod setup; mod skills; mod spec_run; +mod terminal; #[cfg(test)] mod test_support; mod update; @@ -77,7 +78,10 @@ usage: thirdshift Run the factory on the iss thirdshift email-test [
    ] Send a test email through Resend thirdshift setup Choose defaults and write the User config thirdshift status [--all] [--json] List this machine's Commands - thirdshift console --no-sign-in Serve this machine's Inbox on localhost + thirdshift console [--no-sign-in] Serve this machine's Console on localhost + thirdshift console password [--email
    ] Set a Console password privately + thirdshift console sign-out [--email
    ] End an account's sessions + thirdshift console log [--json] Read this machine's Console log thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup thirdshift logs [--command ] [--json] List this machine's logs @@ -135,6 +139,14 @@ fn main() -> ExitCode { } Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), + Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), + Ok(Command::ConsoleSignOut(email)) => return outcome(console::sign_out(email.as_deref())), + Ok(Command::ConsoleLog { json }) => { + return match console::print_log(json) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } Ok(Command::Console { no_sign_in }) => { return match console::serve(no_sign_in) { Ok(()) => ExitCode::SUCCESS, diff --git a/src/setup.rs b/src/setup.rs index 78b3b90c..89b08fd8 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -12,6 +12,7 @@ pub(crate) mod questions; +use crate::terminal::EchoOff; use std::io::{IsTerminal, Write}; use std::path::Path; @@ -372,89 +373,7 @@ impl Outside for OnMachine<'_> { /// answers available to the next question. Polling lets recorded signals end /// ordinary and hidden input even when there is no next line. fn terminal_line() -> Result> { - let mut line = Vec::new(); - loop { - interrupt::check()?; - let mut stdin = libc::pollfd { - fd: 0, - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: stdin points to one valid pollfd; polling does not own fd 0. - let ready = unsafe { libc::poll(&mut stdin, 1, 100) }; - interrupt::check()?; - if ready < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(error.into()); - } - if ready == 0 { - continue; - } - if stdin.revents & libc::POLLNVAL != 0 { - return Err(std::io::Error::from_raw_os_error(libc::EBADF).into()); - } - let mut byte = 0u8; - // SAFETY: byte is writable for the one byte requested from fd 0. - let read = unsafe { libc::read(0, (&mut byte as *mut u8).cast(), 1) }; - interrupt::check()?; - if read < 0 { - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::Interrupted { - continue; - } - return Err(error.into()); - } - if read == 0 { - if line.is_empty() { - return Ok(None); - } - break; - } - line.push(byte); - if byte == b'\n' { - break; - } - } - let line = String::from_utf8(line).context("terminal input is not UTF-8")?; - interrupt::check()?; - Ok(Some(line.trim().to_string())) -} - -/// Own the exact saved terminal settings while echo is suppressed. Every -/// return path, including recorded interruption, restores them by dropping. -struct EchoOff { - saved: libc::termios, -} - -impl EchoOff { - fn new() -> Result { - // SAFETY: an all-zero termios is a valid value for tcgetattr to fill. - let mut saved: libc::termios = unsafe { std::mem::zeroed() }; - // SAFETY: fd 0 is open, and `saved` is a valid termios to fill. - if unsafe { libc::tcgetattr(0, &mut saved) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("can't read the terminal's settings"); - } - let echo_off = EchoOff { saved }; - let mut hidden = saved; - hidden.c_lflag &= !libc::ECHO; - // SAFETY: fd 0 is open, and `hidden` is a valid termios. - if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 { - return Err(std::io::Error::last_os_error()) - .context("can't turn the terminal's echo off"); - } - Ok(echo_off) - } -} - -impl Drop for EchoOff { - fn drop(&mut self) { - // SAFETY: fd 0 is open, and `saved` is the termios read from it. - unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) }; - } + Ok(crate::terminal::line()?.map(|line| line.trim().to_owned())) } #[cfg(test)] diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 00000000..96ac63dd --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,89 @@ +//! Shared terminal input for Setup and Console passwords. +use crate::interrupt; +use anyhow::{Context, Result}; + +/// Read without read-ahead, so pasted answers remain for the next question. +/// Polling lets recorded signals end input even when no next line arrives. +pub fn line() -> Result> { + let mut line = Vec::new(); + loop { + interrupt::check()?; + let mut stdin = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: stdin points to one valid pollfd; polling does not own fd 0. + let ready = unsafe { libc::poll(&mut stdin, 1, 100) }; + interrupt::check()?; + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } + if ready == 0 { + continue; + } + if stdin.revents & libc::POLLNVAL != 0 { + return Err(std::io::Error::from_raw_os_error(libc::EBADF).into()); + } + let mut byte = 0u8; + // SAFETY: byte is writable for the one byte requested from fd 0. + let read = unsafe { libc::read(0, (&mut byte as *mut u8).cast(), 1) }; + interrupt::check()?; + if read < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } + if read == 0 { + if line.is_empty() { + return Ok(None); + } + break; + } + line.push(byte); + if byte == b'\n' { + break; + } + } + let line = String::from_utf8(line).context("terminal input is not UTF-8")?; + interrupt::check()?; + Ok(Some(line.trim_end_matches(['\r', '\n']).to_owned())) +} + +/// Restore the exact saved terminal settings on every return path. +pub struct EchoOff { + saved: libc::termios, +} + +impl EchoOff { + pub fn new() -> Result { + // SAFETY: tcgetattr fills this termios from the open stdin descriptor. + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(0, &mut saved) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("can't read the terminal's settings"); + } + let echo_off = Self { saved }; + let mut hidden = saved; + hidden.c_lflag &= !libc::ECHO; + // SAFETY: fd 0 is open, and hidden is a valid termios. + if unsafe { libc::tcsetattr(0, libc::TCSANOW, &hidden) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("can't turn the terminal's echo off"); + } + Ok(echo_off) + } +} + +impl Drop for EchoOff { + fn drop(&mut self) { + // SAFETY: fd 0 is open, and saved was read from it. + unsafe { libc::tcsetattr(0, libc::TCSANOW, &self.saved) }; + } +} diff --git a/tests/console.rs b/tests/console.rs index 75445600..54832d14 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -9,6 +9,10 @@ use std::time::Duration; use serde_json::{Value, json}; +#[cfg(unix)] +#[path = "console/accounts.rs"] +mod accounts; + struct Console { child: Child, address: String, @@ -18,8 +22,17 @@ struct Console { impl Console { fn start(binary: &Path, home: &Path) -> Self { + Self::start_mode(binary, home, true) + } + + fn start_mode(binary: &Path, home: &Path, no_sign_in: bool) -> Self { + let args: &[&str] = if no_sign_in { + &["console", "--no-sign-in"] + } else { + &["console"] + }; let mut child = Command::new(binary) - .args(["console", "--no-sign-in"]) + .args(args) .env("HOME", home) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs new file mode 100644 index 00000000..d88eecb2 --- /dev/null +++ b/tests/console/accounts.rs @@ -0,0 +1,469 @@ +use super::*; +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::process::CommandExt; +use std::process::{ExitStatus, Output}; +use std::time::Instant; + +const EMAIL: &str = "owner@example.test"; +const PASSWORD: &str = "correct horse battery staple"; + +fn configured() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + allow(home.path(), true); + home +} + +fn allow(home: &Path, allowed: bool) { + fs::write( + home.join(".thirdshift/config.toml"), + format!( + "[console]\nport = 0\nallowed_emails = [{}]\n", + if allowed { + "\"owner@example.test\"" + } else { + "" + } + ), + ) + .unwrap(); +} + +fn cli(home: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(args) + .env("HOME", home) + .output() + .unwrap() +} + +fn client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .unwrap() +} + +fn get(console: &Console, path: &str, cookie: &str) -> reqwest::blocking::Response { + client() + .get(format!("{}{path}", console.address)) + .header("cookie", cookie) + .send() + .unwrap() +} + +fn csrf(html: &str) -> String { + html.split_once("name=\"csrf\" value=\"") + .expect("CSRF field") + .1 + .split('"') + .next() + .unwrap() + .into() +} + +fn cookie(response: &reqwest::blocking::Response) -> String { + response.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .into() +} + +fn sign_in(console: &Console, email: &str, password: &str) -> reqwest::blocking::Response { + let form = get(console, "/sign-in", ""); + assert_eq!(form.status(), 200); + let cookie = cookie(&form); + let token = csrf(&form.text().unwrap()); + client() + .post(format!("{}/sign-in", console.address)) + .header("cookie", cookie) + .form(&[("email", email), ("password", password), ("csrf", &token)]) + .send() + .unwrap() +} + +#[test] +fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() { + let home = configured(); + let (status, _) = password(home.path(), None, PASSWORD, PASSWORD); + assert!(status.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + for path in [ + "/", + "/logs", + "/logs/command/id", + "/logs/id", + "/logs/id/stream/0", + "/console-log", + "/account", + ] { + assert_eq!( + get(&console, path, "").status(), + 303, + "{path} leaked without a session" + ); + } + assert_eq!(get(&console, "/assets/console.css", "").status(), 200); + assert_eq!( + client() + .post(format!("{}/sign-in", console.address)) + .form(&[("email", EMAIL), ("password", PASSWORD)]) + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!(sign_in(&console, EMAIL, "wrong").status(), 401); + assert_eq!( + sign_in(&console, "outsider@example.test", PASSWORD).status(), + 401 + ); + let response = sign_in(&console, EMAIL, PASSWORD); + assert_eq!(response.status(), 303); + let attributes = response.headers()["set-cookie"].to_str().unwrap(); + for attribute in [ + "HttpOnly", + "Secure", + "SameSite=Strict", + "Max-Age=2592000", + "Path=/", + ] { + assert!(attributes.contains(attribute), "{attributes}"); + } + let session = cookie(&response); + let accounts = + fs::read_to_string(home.path().join(".thirdshift/console-accounts.json")).unwrap(); + assert!(accounts.contains("$argon2id$")); + assert!(!accounts.contains(PASSWORD)); + assert!(!accounts.contains(session.split_once('=').unwrap().1)); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(home.path().join(".thirdshift/console-accounts.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let before = cli(home.path(), &["console", "log", "--json"]).stdout; + let page = get(&console, "/", &session); + assert_eq!(page.status(), 200); + assert!( + page.headers()["set-cookie"] + .to_str() + .unwrap() + .contains("Max-Age=2592000") + ); + let account = get(&console, "/account", &session).text().unwrap(); + let token = csrf(&account); + let log_page = get(&console, "/console-log", &session).text().unwrap(); + assert!(log_page.contains("thirdshift console log")); + let events: Vec = serde_json::from_slice(&before).unwrap(); + for event in events { + assert!(log_page.contains(event["event"].as_str().unwrap())); + } + assert_eq!( + cli(home.path(), &["console", "log", "--json"]).stdout, + before, + "page views were logged" + ); + for invalid in [None, Some("incorrect")] { + let mut request = client() + .post(format!("{}/sign-out", console.address)) + .header("cookie", &session); + if let Some(token) = invalid { + request = request.form(&[("csrf", token)]); + } + assert_eq!(request.send().unwrap().status(), 403); + assert_eq!(get(&console, "/", &session).status(), 200); + } + let response = client() + .post(format!("{}/sign-out", console.address)) + .header("cookie", &session) + .form(&[("csrf", token)]) + .send() + .unwrap(); + assert_eq!(response.status(), 303); + assert!( + response.headers()["set-cookie"] + .to_str() + .unwrap() + .contains("Max-Age=0") + ); + assert_eq!(get(&console, "/", &session).status(), 303); +} + +#[test] +fn cli_and_allowlist_changes_revoke_http_sessions_without_deleting_the_account() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start_mode(binary, home.path(), false); + let first = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let second = cookie(&sign_in(&console, EMAIL, PASSWORD)); + // A browser sign-out ends that browser, leaving the other one usable. + let token = csrf(&get(&console, "/account", &first).text().unwrap()); + assert_eq!( + client() + .post(format!("{}/sign-out", console.address)) + .header("cookie", &first) + .form(&[("csrf", token)]) + .send() + .unwrap() + .status(), + 303 + ); + assert_eq!(get(&console, "/", &first).status(), 303); + assert_eq!(get(&console, "/", &second).status(), 200); + assert!( + cli(home.path(), &["console", "sign-out", "--email", EMAIL]) + .status + .success() + ); + assert_eq!(get(&console, "/", &second).status(), 303); + let third = cookie(&sign_in(&console, EMAIL, PASSWORD)); + assert!( + password(home.path(), Some(EMAIL), "new password", "new password") + .0 + .success() + ); + assert_eq!(get(&console, "/", &third).status(), 303); + assert_eq!(sign_in(&console, EMAIL, PASSWORD).status(), 401); + let fourth = cookie(&sign_in(&console, EMAIL, "new password")); + drop(console); + let console = Console::start_mode(binary, home.path(), false); + assert_eq!( + get(&console, "/", &fourth).status(), + 200, + "sessions survive server restarts" + ); + allow(home.path(), false); + assert_eq!( + get(&console, "/", &fourth).status(), + 303, + "removal is effective on the next request" + ); + assert_eq!(sign_in(&console, EMAIL, "new password").status(), 401); + allow(home.path(), true); + assert_eq!( + get(&console, "/", &fourth).status(), + 303, + "re-adding must not revive a session" + ); + let fifth = cookie(&sign_in(&console, EMAIL, "new password")); + // Removal must also be observed while no HTTP requests arrive. + let before = fs::read_to_string(home.path().join(".thirdshift/console.log")) + .unwrap() + .len(); + allow(home.path(), false); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let text = fs::read_to_string(home.path().join(".thirdshift/console.log")).unwrap(); + if text.len() > before && text[before..].contains("blocked-address") { + break; + } + assert!( + Instant::now() < deadline, + "idle server never revoked the removed account" + ); + std::thread::sleep(Duration::from_millis(25)); + } + allow(home.path(), true); + assert_eq!(get(&console, "/", &fifth).status(), 303); + assert_eq!( + sign_in(&console, EMAIL, "new password").status(), + 303, + "account password is retained" + ); +} + +#[test] +fn http_slowdown_survives_restarts_and_allowed_accounts_still_need_a_password() { + let home = configured(); + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start_mode(binary, home.path(), false); + assert_eq!( + sign_in(&console, EMAIL, PASSWORD).status(), + 401, + "allowlisting alone grants no access" + ); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + for _ in 0..5 { + assert_eq!(sign_in(&console, EMAIL, "wrong").status(), 401); + } + let response = sign_in(&console, EMAIL, PASSWORD); + assert_eq!(response.status(), 429); + assert_eq!(response.headers()["retry-after"], "60"); + drop(console); + let console = Console::start_mode(binary, home.path(), false); + assert_eq!(sign_in(&console, EMAIL, PASSWORD).status(), 429); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_open_log_stream_closes_when_cli_sign_out_revokes_its_session() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let folder = home + .path() + .join(".thirdshift/logs/acme/widgets/commands/issue"); + let mut live = record(home.path(), 7, None, 0); + let path = folder.join("7-fixture.log"); + fs::write(&path, "first line\n").unwrap(); + live["process_id"] = json!(std::process::id()); + let stat = fs::read_to_string("/proc/self/stat").unwrap(); + let ticks = stat + .rsplit_once(") ") + .unwrap() + .1 + .split_whitespace() + .nth(19) + .unwrap(); + let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap(); + live["process_start"] = json!(format!("{}:{ticks}", boot.trim())); + write_record(home.path(), 7, &live); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let inventory = get(&console, "/logs", &session).text().unwrap(); + let link = inventory + .split("href=\"") + .skip(1) + .filter_map(|part| part.split_once('"')) + .find(|(url, _)| url.starts_with("/logs/")) + .unwrap() + .0; + let mut response = get(&console, &format!("{link}/stream/0"), &session); + assert_eq!(response.status(), 200); + let mut buffer = [0; 4096]; + let first = response.read(&mut buffer).unwrap(); + assert!(String::from_utf8_lossy(&buffer[..first]).contains("first line")); + assert!( + String::from_utf8_lossy(&buffer[..first]).contains("\"done\":false"), + "fixture must be a live stream" + ); + assert!(cli(home.path(), &["console", "sign-out"]).status.success()); + let mut rest = String::new(); + response.read_to_string(&mut rest).unwrap(); + assert!(!rest.contains("first line")); + assert_eq!( + get(&console, &format!("{link}/stream/0"), &session).status(), + 303 + ); +} + +// Exercise the real terminal interface, including controlling-TTY input and echo. +fn password(home: &Path, email: Option<&str>, first: &str, second: &str) -> (ExitStatus, String) { + let (mut master, mut slave) = (-1, -1); + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }, + 0 + ); + let mut master = unsafe { fs::File::from_raw_fd(master) }; + let slave = unsafe { fs::File::from_raw_fd(slave) }; + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command + .args(["console", "password"]) + .env("HOME", home) + .stdin(slave.try_clone().unwrap()) + .stdout(slave.try_clone().unwrap()) + .stderr(slave); + if let Some(email) = email { + command.args(["--email", email]); + } + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + let mut transcript = String::new(); + let mut prompts = 0; + loop { + if Instant::now() > deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("password command timed out: {transcript}"); + } + let mut poll = libc::pollfd { + fd: master.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + if unsafe { libc::poll(&mut poll, 1, 100) } > 0 { + let mut buffer = [0; 1024]; + if let Ok(length) = master.read(&mut buffer) { + transcript.push_str(&String::from_utf8_lossy(&buffer[..length])); + if prompts == 0 && transcript.contains("Password: ") { + writeln!(master, "{first}").unwrap(); + prompts = 1; + } + if prompts == 1 && transcript.contains("Confirm password: ") { + writeln!(master, "{second}").unwrap(); + prompts = 2; + } + } + } + if let Some(status) = child.try_wait().unwrap() { + return (status, transcript); + } + } +} + +#[test] +fn console_password_prompts_privately_and_console_log_reports_account_events() { + let home = configured(); + let (status, transcript) = password(home.path(), None, PASSWORD, PASSWORD); + assert!(status.success(), "{transcript}"); + assert!(transcript.contains("Confirm password: "), "{transcript}"); + assert!( + !transcript.contains(PASSWORD), + "terminal echoed the password" + ); + let log = cli(home.path(), &["console", "log", "--json"]); + assert!( + log.status.success(), + "{}", + String::from_utf8_lossy(&log.stderr) + ); + let events: Vec = serde_json::from_slice(&log.stdout).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["email"], EMAIL); + assert_eq!(events[0]["event"], "password-set"); + assert!(cli(home.path(), &["console", "sign-out"]).status.success()); + let text = cli(home.path(), &["console", "log"]); + assert!(String::from_utf8_lossy(&text.stdout).contains("sign-out")); + let (status, _) = password( + home.path(), + Some("outsider@example.test"), + PASSWORD, + PASSWORD, + ); + assert!(!status.success()); + let (status, _) = password(home.path(), Some(EMAIL), "different", "mismatch"); + assert!(!status.success()); +} From 23cb5a664ab165da59a113cfd149a33b8e66dbfb Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:04:30 +0000 Subject: [PATCH 22/47] Address Console Standards review findings --- console/README.md | 2 +- src/args.rs | 43 ++++++++++++++++++++++++--------- src/console/account.html | 8 +++--- src/console/account_base.html | 16 ++++++++++++ src/console/account_commands.rs | 12 +++++++-- src/console/account_log.html | 8 +++--- src/console/accounts.rs | 42 ++++++++++++++++++++++++-------- src/console/sign_in.html | 10 +++++--- src/main.rs | 9 +++++-- tests/console/accounts.rs | 12 ++++++--- 10 files changed, 120 insertions(+), 42 deletions(-) create mode 100644 src/console/account_base.html diff --git a/console/README.md b/console/README.md index 0185ba1d..1b25a2f3 100644 --- a/console/README.md +++ b/console/README.md @@ -43,7 +43,7 @@ After five wrong passwords, an address gets one attempt per minute until a successful sign-in or a new password resets the slowdown. The Account page signs out the current browser. `thirdshift console sign-out -[--email
    ]` ends every session for that address; setting a new password +[--email
    ] [--json]` ends every session for that address; setting a new password also ends all its sessions. Removing an address from `allowed_emails` blocks it and revokes its sessions, while retaining the password for later re-adding. The server checks current settings on requests and while streams remain open. diff --git a/src/args.rs b/src/args.rs index 98a65196..bcb15181 100644 --- a/src/args.rs +++ b/src/args.rs @@ -26,7 +26,10 @@ pub enum Command { no_sign_in: bool, }, ConsolePassword(Option), - ConsoleSignOut(Option), + ConsoleSignOut { + email: Option, + json: bool, + }, ConsoleLog { json: bool, }, @@ -253,26 +256,44 @@ pub fn parse(args: &[String]) -> Result { Some("version" | "--version" | "-V") => return Ok(Command::Version), Some("update") => return Ok(Command::Update), Some("console") => { + if args.get(1).is_some_and(|action| action == "sign-out") { + let mut email = None; + let mut json = false; + let mut arguments = args[2..].iter(); + while let Some(flag) = arguments.next() { + match flag.as_str() { + "--email" if email.is_none() => { + email = Some( + arguments + .next() + .filter(|email| !email.starts_with('-')) + .ok_or_else(|| anyhow::anyhow!("--email needs an address"))? + .clone(), + ); + } + "--json" if !json => json = true, + _ => { + bail!("usage: thirdshift console sign-out [--email
    ] [--json]") + } + } + } + return Ok(Command::ConsoleSignOut { email, json }); + } return match &args[1..] { [] => Ok(Command::Console { no_sign_in: false }), [flag] if flag == "--no-sign-in" => Ok(Command::Console { no_sign_in: true }), [action] if action == "password" => Ok(Command::ConsolePassword(None)), - [action] if action == "sign-out" => Ok(Command::ConsoleSignOut(None)), - [action, flag, email] if flag == "--email" && !email.starts_with('-') => { - match action.as_str() { - "password" => Ok(Command::ConsolePassword(Some(email.clone()))), - "sign-out" => Ok(Command::ConsoleSignOut(Some(email.clone()))), - _ => { - bail!("usage: thirdshift console password|sign-out [--email
    ]") - } - } + [action, flag, email] + if action == "password" && flag == "--email" && !email.starts_with('-') => + { + Ok(Command::ConsolePassword(Some(email.clone()))) } [action] if action == "log" => Ok(Command::ConsoleLog { json: false }), [action, flag] if action == "log" && flag == "--json" => { Ok(Command::ConsoleLog { json: true }) } _ => bail!( - "usage: thirdshift console [--no-sign-in] | password|sign-out [--email
    ] | log [--json]" + "usage: thirdshift console [--no-sign-in] | password [--email
    ] | sign-out [--email
    ] [--json] | log [--json]" ), }; } diff --git a/src/console/account.html b/src/console/account.html index 72e9e2b9..0c47fb69 100644 --- a/src/console/account.html +++ b/src/console/account.html @@ -1,7 +1,7 @@ - -Account · thirdshift -

    thirdshift / Console

    +{% extends "console/account_base.html" %} +{% block title %}Account{% endblock %} +{% block content %}

    Account

    {{ email }}

    {% if signed_in %}
    {% endif %}

    End all sessions: thirdshift console sign-out --email <address>

    -
    +{% endblock %} diff --git a/src/console/account_base.html b/src/console/account_base.html new file mode 100644 index 00000000..c7e6ec29 --- /dev/null +++ b/src/console/account_base.html @@ -0,0 +1,16 @@ + + + + + + {% block title %}{% endblock %} · thirdshift + + + + +

    thirdshift / Console

    + {% block navigation %}{% endblock %} +
    +
    {% block content %}{% endblock %}
    + + diff --git a/src/console/account_commands.rs b/src/console/account_commands.rs index dc339ea9..befa8aff 100644 --- a/src/console/account_commands.rs +++ b/src/console/account_commands.rs @@ -43,10 +43,18 @@ fn prompt(prompt: &str) -> Result { Ok(answer) } -pub fn sign_out(given: Option<&str>) -> Result { +pub fn sign_out(given: Option<&str>, json: bool) -> Result<()> { let email = email(given)?; Accounts::load()?.sign_out(&email, None)?; - Ok(format!("Signed out {email}.")) + if json { + println!( + "{}", + serde_json::json!({ "email": email, "signed_out": true }) + ); + } else { + println!("Signed out {email}."); + } + Ok(()) } pub fn print_log(json: bool) -> Result<()> { diff --git a/src/console/account_log.html b/src/console/account_log.html index a80794f3..6a8caf9e 100644 --- a/src/console/account_log.html +++ b/src/console/account_log.html @@ -1,7 +1,7 @@ - -Console log · thirdshift -

    thirdshift / Console

    +{% extends "console/account_base.html" %} +{% block title %}Console log{% endblock %} +{% block content %}

    Console log

    thirdshift console log
      {% for event in events %}
    • {{ event.time }} · {{ event.email }} · {{ event.event }}
    • {% endfor %}
    {% if events.is_empty() %}

    No account events.

    {% endif %} -
    +{% endblock %} diff --git a/src/console/accounts.rs b/src/console/accounts.rs index dde94a9c..489c731f 100644 --- a/src/console/accounts.rs +++ b/src/console/accounts.rs @@ -24,11 +24,33 @@ struct Account { next_attempt: i64, } +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum EventKind { + SignIn, + FailedSignIn, + PasswordSet, + SignOut, + BlockedAddress, +} + +impl std::fmt::Display for EventKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::SignIn => "sign-in", + Self::FailedSignIn => "failed-sign-in", + Self::PasswordSet => "password-set", + Self::SignOut => "sign-out", + Self::BlockedAddress => "blocked-address", + }) + } +} + #[derive(Deserialize, Serialize)] pub(super) struct Event { pub time: i64, pub email: String, - pub event: String, + pub event: EventKind, } pub(super) enum SignIn { @@ -93,7 +115,7 @@ impl Accounts { store.save()?; } for email in blocked { - store.record(&email, "blocked-address")?; + store.record(&email, EventKind::BlockedAddress)?; } Ok(store) } @@ -117,12 +139,12 @@ impl Accounts { }, ); self.save()?; - self.record(email, "password-set") + self.record(email, EventKind::PasswordSet) } pub fn sign_in(&mut self, email: &str, password: &str) -> Result { if !self.allowed.iter().any(|allowed| allowed == email) { - self.record(email, "blocked-address")?; + self.record(email, EventKind::BlockedAddress)?; return Ok(SignIn::Refused); } if self @@ -130,7 +152,7 @@ impl Accounts { .get(email) .is_some_and(|account| self.now < account.next_attempt) { - self.record(email, "failed-sign-in")?; + self.record(email, EventKind::FailedSignIn)?; return Ok(SignIn::Slowdown); } let valid = self.accounts.get(email).is_some_and(|account| { @@ -148,7 +170,7 @@ impl Accounts { } self.save()?; } - self.record(email, "failed-sign-in")?; + self.record(email, EventKind::FailedSignIn)?; return Ok(SignIn::Refused); } let token = random_token(); @@ -157,7 +179,7 @@ impl Accounts { account.next_attempt = 0; account.sessions.insert(digest(&token), self.now); self.save()?; - self.record(email, "sign-in")?; + self.record(email, EventKind::SignIn)?; Ok(SignIn::SignedIn(token)) } @@ -190,7 +212,7 @@ impl Accounts { } self.save()?; } - self.record(email, "sign-out") + self.record(email, EventKind::SignOut) } pub fn events(&self) -> Result> { @@ -212,14 +234,14 @@ impl Accounts { Ok(()) } - fn record(&self, email: &str, event: &str) -> Result<()> { + fn record(&self, email: &str, event: EventKind) -> Result<()> { let mut file = private_file(&self.root.join("console.log"))?; serde_json::to_writer( &mut file, &Event { time: self.now, email: email.into(), - event: event.into(), + event, }, )?; file.write_all(b"\n")?; diff --git a/src/console/sign_in.html b/src/console/sign_in.html index 5899a17c..17d82fda 100644 --- a/src/console/sign_in.html +++ b/src/console/sign_in.html @@ -1,6 +1,8 @@ - -Sign in · thirdshift -

    thirdshift / Console

    Sign in

    +{% extends "console/account_base.html" %} +{% block title %}Sign in{% endblock %} +{% block navigation %}{% endblock %} +{% block content %} +

    Sign in

    @@ -8,4 +10,4 @@

    Need a password? Ask the machine's owner to run thirdshift console password --email <address>.

    -
    +{% endblock %} diff --git a/src/main.rs b/src/main.rs index f28a6582..eb83ef0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,7 @@ usage: thirdshift Run the factory on the iss thirdshift status [--all] [--json] List this machine's Commands thirdshift console [--no-sign-in] Serve this machine's Console on localhost thirdshift console password [--email
    ] Set a Console password privately - thirdshift console sign-out [--email
    ] End an account's sessions + thirdshift console sign-out [--email
    ] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup @@ -140,7 +140,12 @@ fn main() -> ExitCode { Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), - Ok(Command::ConsoleSignOut(email)) => return outcome(console::sign_out(email.as_deref())), + Ok(Command::ConsoleSignOut { email, json }) => { + return match console::sign_out(email.as_deref(), json) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => failure(&error), + }; + } Ok(Command::ConsoleLog { json }) => { return match console::print_log(json) { Ok(()) => ExitCode::SUCCESS, diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index d88eecb2..742693db 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -224,10 +224,14 @@ fn cli_and_allowlist_changes_revoke_http_sessions_without_deleting_the_account() ); assert_eq!(get(&console, "/", &first).status(), 303); assert_eq!(get(&console, "/", &second).status(), 200); - assert!( - cli(home.path(), &["console", "sign-out", "--email", EMAIL]) - .status - .success() + let signed_out = cli( + home.path(), + &["console", "sign-out", "--email", EMAIL, "--json"], + ); + assert!(signed_out.status.success()); + assert_eq!( + serde_json::from_slice::(&signed_out.stdout).unwrap(), + json!({"email": EMAIL, "signed_out": true}) ); assert_eq!(get(&console, "/", &second).status(), 303); let third = cookie(&sign_in(&console, EMAIL, PASSWORD)); From 8435a71789c2f897c0e38d5a2783f8c5d7fbbde9 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:09:05 +0000 Subject: [PATCH 23/47] Complete Console config defaults and Setup fixture --- src/config.rs | 2 +- src/setup.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 48d951f9..d4818803 100644 --- a/src/config.rs +++ b/src/config.rs @@ -791,7 +791,7 @@ silent_minutes = 10 # minutes without growth in either log before a Run is Sil [console] port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711 -allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in +allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in; default [] other_consoles = [] # http or https addresses linked in the header, never fetched; default [] theme = "focus-auto" # Focus follows the system, or focus-light / focus-dark; default focus-auto diff --git a/src/setup.rs b/src/setup.rs index 89b08fd8..6099e6d0 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1051,6 +1051,7 @@ silent_minutes = 10 [console] port = 8711 +allowed_emails = [] other_consoles = [] theme = \"focus-auto\" From 997dd5a541590e1de51a49d138e4c8994500587e Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:34:52 +0000 Subject: [PATCH 24/47] Add Console Lanes and Stages pages from status observations (#826) --- console/README.md | 16 +- console/tests/inbox.test.cjs | 6 +- console/tests/time.test.cjs | 109 ++++++++++++++ docs/code-navigation.md | 1 + src/console.rs | 12 ++ src/console/assets/console.css | 20 +++ src/console/nav.html | 2 +- src/console/time.html | 55 +++++++ src/console/time.rs | 41 ++++++ src/logs/status.rs | 1 + src/logs/status/time.rs | 260 +++++++++++++++++++++++++++++++++ tests/console.rs | 62 +++++++- 12 files changed, 578 insertions(+), 7 deletions(-) create mode 100644 console/tests/time.test.cjs create mode 100644 src/console/time.html create mode 100644 src/console/time.rs create mode 100644 src/logs/status/time.rs diff --git a/console/README.md b/console/README.md index 201ef865..c372ee77 100644 --- a/console/README.md +++ b/console/README.md @@ -31,16 +31,28 @@ them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary makes the Console re-execute itself with its original arguments. -The available pages are Inbox (`1`), History (`5`), Logs (`6`) and Security (`7`). Links +The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), History (`5`), Logs (`6`) and Security (`7`). Links keep the page in `?page=`. History includes every ended Command, including old logs, and filters by outcome and repository. Security shows retained findings and Session logs. Older records without finding metadata still show their logs. +Lanes shows each Command and its child Runs on a shared time axis, over the last +3.5, 12 or 24 hours. The window is kept in `hours=`; bars clip to that window, +while their text retains the whole stage's time. Older Commands are on History. +Stages groups Runs by repository so each stage's Usual range belongs to that +repository. Live columns come first, then finished Runs newest first, continuing +in tables of three Runs. On phones each stage stacks its labelled cells. Choose +a column's Run link to open its exact peek. Repeated stages retain every visit; +missing end timestamps say “time unknown” rather than inventing a duration. +Both pages use the observations from `status --all --json`. The Rust parity +test checks their stage times and Usual ranges against the CLI; the Playwright +time tests check scale in all windows, child selection, live ordering and 390 px. + `j` / `k` select headings and visible rows, wrapping at either end. `Space` folds a heading or toggles a row's peek; `Enter` opens that row's full Command log. `z` folds the current section. `Escape` closes help, jumps or the peek, one layer at a time, then clears selection. `?` opens the key list. `g` opens -the jump list: `i` Inbox, `h` History, `o` Logs, `s` Security, or a section's displayed +the jump list: `i` Inbox, `a` Lanes, `t` Stages, `h` History, `o` Logs, `s` Security, or a section's displayed letter. Text fields keep their normal keys. The peek starts closed and sits beside the list on a wide screen or at the bottom of a phone. diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index 9d764b48..f4fb6fa2 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -200,7 +200,7 @@ test("every keyboard action preserves list nodes and Escape closes one layer at test("page keys, jump letters and URLs survive reload and the peek is a phone bottom sheet", async t => { const page = await pageFor(t, { viewport: { width: 390, height: 844 } }); await page.goto(origin); - for (const [key, name] of [['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { + for (const [key, name] of [['2', 'lanes'], ['3', 'stages'], ['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { await page.keyboard.press(key); await page.waitForURL(`**/?page=${name}`); await page.reload(); @@ -208,12 +208,12 @@ test("page keys, jump letters and URLs survive reload and the peek is a phone bo assert.equal(await page.locator('#peek').isVisible(), false); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); } - for (const key of ['2', '3', '4']) { + for (const key of ['4']) { await page.keyboard.press(key); assert.equal(new URL(page.url()).searchParams.get('page'), 'inbox'); assert.equal(await page.locator(`[data-key="${key}"]`).count(), 0); } - for (const [letter, name] of [['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { + for (const [letter, name] of [['a', 'lanes'], ['t', 'stages'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { await page.keyboard.press('g'); await page.keyboard.press(letter); await page.waitForURL(`**/?page=${name}`); diff --git a/console/tests/time.test.cjs b/console/tests/time.test.cjs new file mode 100644 index 00000000..e2dad2c2 --- /dev/null +++ b/console/tests/time.test.cjs @@ -0,0 +1,109 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn, execFileSync } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); + +let home, server, exited, browser, origin; +const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'); +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-time-')); + const folder = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + await mkdir(folder, { recursive: true }); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + let processStart = 'gone'; + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/); + processStart = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${fields[19]}`; + } + const now = Date.now(); + const time = minutes => new Date(now - minutes * 60000).toISOString(); + for (let number = 1; number <= 7; number++) { + const live = number === 1; + const record = { + version: 1, kind: 'Run', name: `run-${number}`, repository: 'acme/widgets', hostname: 'fixture', + issue: { number, url: `https://github.com/acme/widgets/issues/${number}`, title: `Run ${number} ` }, + process_id: live ? process.pid : 0, process_start: live ? processStart : 'gone', harness: 'codex', + started_at: time(number === 7 ? 1500 : 180 - number * 10), + ended_at: live ? null : time(number === 7 ? 1480 : 150 - number * 10), + outcome: live ? null : 'merged', session_log: null, + stages: [ + { name: 'implement', began_at: time(number === 7 ? 1500 : 180 - number * 10), session_log: null }, + { name: 'CI watch', began_at: time(number === 7 ? 1490 : 170 - number * 10), session_log: null } + ], + ...(number === 6 ? { parent_record: join(folder, '2-fixture.command.json') } : {}) + }; + await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify(record)); + await writeFile(join(folder, `${number}-fixture.log`), `Command ${number}\n`); + } + server = spawn(binary, ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test('Lanes scales every window, nests child Runs and fits 390 px without CSP violations', async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + for (const hours of [3.5, 12, 24]) { + await page.goto(`${origin}/?page=lanes&hours=${hours}`); + assert.equal(await page.locator('[name="hours"]').inputValue(), String(hours)); + assert.equal(await page.locator('.lane').count(), 5); + assert.equal(await page.locator('.lane').filter({ hasText: 'acme/widgets#2' }).locator('.time-run').count(), 2); + const complete = page.locator('.time-run').filter({ hasText: 'acme/widgets#3' }); + const track = await complete.locator('.timeline').boundingBox(); + const bars = await complete.locator('.timeline rect').all(); + const first = await bars[0].boundingBox(); + const second = await bars[1].boundingBox(); + // Painted bounds include the one-pixel stroke around each scaled interval. + assert.ok(Math.abs(first.width - track.width * 10 / (hours * 60)) <= 1.01, JSON.stringify({ hours, track, first, second })); + assert.ok(Math.abs(second.width - track.width * 20 / (hours * 60)) <= 1.01); + // #3 starts 150 minutes ago; its first bar is placed on the same time axis. + assert.ok(Math.abs((first.x - track.x) / track.width - (1 - 150 / (hours * 60))) < 0.002); + assert.match(await complete.textContent(), /implement: Complete · 10 min/); + assert.match(await complete.textContent(), /CI watch: Complete · 20 min/); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + } + await page.locator('[name="hours"]').selectOption('12'); + await page.getByRole('button', { name: 'Show', exact: true }).click(); + await page.waitForURL('**hours=12'); + assert.equal(await page.locator('.time-run script').count(), 0); + assert.deepEqual(errors, []); +}); + +test('Stages pins live Runs, matches CLI order and opens the selected child Run at 390 px', { skip: process.platform !== 'linux' }, async t => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + t.after(() => context.close()); + const page = await context.newPage(); + await page.goto(`${origin}/?page=stages`); + const status = JSON.parse(execFileSync(binary, ['status', '--all', '--json'], { env: { ...process.env, HOME: home } })); + const runs = status.commands.flatMap(command => [command, ...(command.children ?? [])]); + runs.sort((a, b) => (a.state !== 'live') - (b.state !== 'live') || Date.parse(b.ended_at ?? b.started_at) - Date.parse(a.ended_at ?? a.started_at)); + assert.deepEqual(await page.locator('thead .peek-link').allTextContents(), runs.map(run => `${run.repository}#${run.issue.number}`)); + assert.equal(await page.locator('thead .command').first().getAttribute('data-command'), runs[0].record_path ?? `${join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json')}::run-1`); + assert.match(await page.locator('.stage-grid').first().textContent(), /Running ·/); + assert.match(await page.locator('.stage-grid').first().textContent(), /Usual range: 10–10 min/); + const child = page.locator('thead .peek-link').filter({ hasText: 'acme/widgets#6' }); + await child.click(); + await page.locator('#peek-content h2').waitFor(); + assert.match(await page.locator('#peek-content').textContent(), /run-6/); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#peek').isVisible(), false); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 1f496f7e..63632144 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -22,6 +22,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/reading.rs` — `Snapshot`, exact-parent trees and observations; `src/logs/reading/history.rs` — historical recovery; `src/logs/status.rs` — formatting; `src/logs/process_identity.rs` | `src/logs/reading/tests.rs`, `tests/status.rs`, `src/logs/recording/tests.rs` | | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | +| Console Lanes and Stages, stage times | `src/logs/status/time.rs` — time views from complete status observations; `src/console/time.rs`, `src/console/time.html` — page rendering | `tests/console.rs`, `console/tests/time.test.cjs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | diff --git a/src/console.rs b/src/console.rs index bbb3cb89..2bcfc746 100644 --- a/src/console.rs +++ b/src/console.rs @@ -3,6 +3,7 @@ mod conversation; mod logs; mod peek; mod restart; +mod time; use std::io::Write; use std::net::Ipv4Addr; @@ -29,6 +30,14 @@ struct PageRoute { } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/lanes", + cli: "thirdshift status --all --json", + }, + PageRoute { + path: "/stages", + cli: "thirdshift status --all --json", + }, PageRoute { path: "/", cli: "thirdshift status", @@ -168,6 +177,9 @@ async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) if matches!(page.as_str(), "logs" | "6") { return logs::index().await; } + if matches!(page.as_str(), "lanes" | "2" | "stages" | "3") { + return time::show(matches!(page.as_str(), "stages" | "3"), query).await; + } let archive_page = match page.as_str() { "history" | "5" => Some(status::ArchivePage::History), "security" | "7" => Some(status::ArchivePage::Security), diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9a30b127..9ecccb29 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -83,6 +83,26 @@ footer { margin-top: 16px; } #log-content article, #log-content > pre, .thinking, .tool-group { padding: 8px 12px; border: 1px solid var(--rule); margin: 8px 0; border-radius: var(--radius); } #log-content h3 { font-size: 14px; } .diff { border-left: 3px solid var(--look); padding-left: 10px; } +.lane { border-bottom: 1px solid var(--rule); padding: 10px 0; } +.time-run h3 { font-size: 14px; margin: 4px 0; color: var(--text); } +.lane .time-run + .time-run { margin-left: 12px; border-left: 1px solid var(--rule); } +.timeline { display: block; background: var(--panel-2); margin: 8px 0; } +.timeline rect { fill: var(--panel); stroke: var(--working); } +.timeline text { fill: var(--text); font: 12px var(--font-mono); } +.stage-times { padding-left: 24px; } +.stage-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 20px 0; } +.stage-grid caption { text-align: left; font-weight: var(--head-weight); } +.stage-grid th, .stage-grid td { border: 1px solid var(--rule); padding: 8px; text-align: left; vertical-align: top; overflow-wrap: anywhere; } +.stage-grid th { font-weight: var(--head-weight); } +.usual { font-size: 12px; font-weight: normal; color: var(--dim); } +.cell-run { display: none; } +@media (max-width: 760px) { + .stage-grid, .stage-grid thead, .stage-grid tbody, .stage-grid tr, .stage-grid th, .stage-grid td { display: block; } + .stage-grid thead tr { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .stage-grid thead th:first-child { grid-column: 1 / -1; } + .stage-grid tbody th { background: var(--panel-2); } + .cell-run { display: inline; font-weight: var(--head-weight); } +} @media (max-width: 500px) { body { padding: 14px 12px; } .issue-title { flex-basis: 100%; order: 1; } diff --git a/src/console/nav.html b/src/console/nav.html index adda64ac..ab2ebd48 100644 --- a/src/console/nav.html +++ b/src/console/nav.html @@ -1 +1 @@ - + diff --git a/src/console/time.html b/src/console/time.html new file mode 100644 index 00000000..14545c50 --- /dev/null +++ b/src/console/time.html @@ -0,0 +1,55 @@ + + + + + + {{ title }} · {{ hostname }} · thirdshift + + + + + +

    thirdshift / Console

    {{ hostname }}

    + {% if !consoles.is_empty() %}{% endif %} +
    + {% include "console/nav.html" %} +
    +

    {{ title }}

    thirdshift status --all --json
    + {% if title == "Lanes" %} +
    + + +
    +

    Stages drawn to scale from {{ views.start }} to {{ views.end }}. Times below each lane include the whole stage.

    +

    Older Commands are on History.

    + {% if views.lanes.is_empty() %}

    No Commands in this window.

    {% endif %} + {% for lane in views.lanes %} +
    + {% for run in lane %} +
    +

    {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    + + {% if run.stages.is_empty() %}

    No stage times retained.

    {% endif %} +
      {% for stage in run.stages %}
    1. {{ stage.name }}: {{ stage.text }}
    2. {% endfor %}
    +
    + {% endfor %} +
    + {% endfor %} + {% else %} +

    Live Runs first, then finished Runs newest first, grouped by repository. Each table continues the preceding columns. Repeated stages list every visit.

    + {% if views.grids.is_empty() %}

    No Runs yet.

    {% endif %} + {% for grid in views.grids %} + + + {% for run in grid.runs %}{% endfor %} + {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %} +
    {{ grid.repository }}
    Stage
    Usual range
    {{ run.subject }}
    {{ run.state }}
    {{ run.title }}
    {{ row.name }}
    Usual range: {{ row.usual }}
    {{ grid.runs[loop.index0].subject }}: {{ cell }}
    + {% if grid.rows.is_empty() %}

    No stage times retained.

    {% endif %} + {% endfor %} + {% endif %} +
    + {% include "console/chrome.html" %} + + diff --git a/src/console/time.rs b/src/console/time.rs new file mode 100644 index 00000000..18addd7a --- /dev/null +++ b/src/console/time.rs @@ -0,0 +1,41 @@ +//! Lanes and Stages share the status reader's complete time observations. +use anyhow::Result; +use askama::Template; +use axum::response::Response; + +use crate::config::UserConfig; +use crate::logs::status::time::{self, Views}; + +#[derive(Template)] +#[template(path = "console/time.html")] +struct TimePage { + hostname: String, + consoles: Vec, + theme: String, + title: &'static str, + hours: String, + views: Views, +} + +pub(super) async fn show(stages: bool, query: Option) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + let hours = match super::query_value(&query, "hours").as_str() { + "12" => 12.0, + "24" => 24.0, + _ => 3.5, + }; + Ok(TimePage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + title: if stages { "Stages" } else { "Lanes" }, + hours: hours.to_string(), + views: time::load(&config, hours)?, + } + .render()?) + }) + .await, + ) +} diff --git a/src/logs/status.rs b/src/logs/status.rs index d3c2c59e..41a1704c 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -2,6 +2,7 @@ mod inbox; pub mod inspect; +pub(crate) mod time; pub(crate) use inbox::{Archive, ArchivePage, archive}; pub(crate) use inbox::{Inbox, Row, inbox}; diff --git a/src/logs/status/time.rs b/src/logs/status/time.rs new file mode 100644 index 00000000..848ab182 --- /dev/null +++ b/src/logs/status/time.rs @@ -0,0 +1,260 @@ +//! Time views over the same complete observations as status --all --json. +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use chrono::{DateTime, Local}; + +use crate::config::UserConfig; +use crate::logs::reading::{Command, Entry, Observation, Snapshot}; + +pub(crate) struct Views { + pub lanes: Vec>, + pub grids: Vec, + pub start: String, + pub end: String, +} + +#[derive(Clone)] +pub(crate) struct Run { + pub subject: String, + pub title: String, + pub state: String, + pub selector: String, + pub peek_url: String, + pub log_url: String, + pub stages: Vec, + repository: String, + started: Option, + ended: Option, +} + +#[derive(Clone)] +pub(crate) struct Stage { + pub name: String, + pub text: String, + pub seconds: String, + pub usual: String, + pub left: f64, + pub width: f64, +} + +pub(crate) struct Grid { + pub repository: String, + pub runs: Vec, + pub rows: Vec, +} + +pub(crate) struct GridRow { + pub name: String, + pub usual: String, + pub cells: Vec, +} + +fn timestamp(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|time| time.timestamp()) +} + +fn run(command: &Command, now: i64, cutoff: i64) -> Run { + let record = &command.record; + let selector = Observation::Record(command).selector().unwrap_or_default(); + let stages = record + .stages + .iter() + .enumerate() + .map(|(index, stage)| { + let began = timestamp(&stage.began_at); + let next = record.stages.get(index + 1); + let end = if let Some(next) = next { + timestamp(&next.began_at) + } else if command.state == "live" { + Some(now) + } else { + record.ended_at.as_deref().and_then(timestamp) + }; + let seconds = began + .zip(end) + .filter(|(start, end)| end >= start) + .map(|(start, end)| end - start); + let state = if next.is_some() { + "Complete" + } else if command.state == "live" { + "Running" + } else if command.state == "died" { + "Died" + } else if record.outcome.as_deref() == Some("merged") { + "Complete" + } else { + record.outcome.as_deref().unwrap_or("Unknown") + }; + let duration = seconds + .map(super::age) + .unwrap_or_else(|| "time unknown".into()); + // Clip only the drawn interval; the text retains the full stage time. + let (left, width) = began.zip(end).filter(|(start, end)| end >= start).map_or( + (0.0, 0.0), + |(start, end)| { + let start = start.clamp(cutoff, now); + let end = end.clamp(cutoff, now); + let scale = 100.0 / (now - cutoff) as f64; + ( + (start - cutoff) as f64 * scale, + (end - start).max(0) as f64 * scale, + ) + }, + ); + Stage { + name: stage.name.clone(), + text: format!("{state} · {duration}"), + seconds: seconds + .map(|seconds| seconds.to_string()) + .unwrap_or_default(), + usual: command + .usual_ranges + .get(&stage.name) + .map(|range| range.text()) + .unwrap_or_else(|| "Not enough samples".into()), + left, + width, + } + }) + .collect(); + Run { + subject: record.issue.as_ref().map_or_else( + || format!("{} on {}", record.kind, record.repository), + |issue| format!("{}#{}", record.repository, issue.number), + ), + title: record + .issue + .as_ref() + .and_then(|issue| issue.title.clone()) + .unwrap_or_else(|| record.name.clone()), + state: if command.state == "ended" { + record.outcome.clone().unwrap_or_else(|| "ended".into()) + } else { + command.state.into() + }, + peek_url: super::inspect::url("/peek", &selector, None), + log_url: super::inspect::url("/log", &selector, None), + selector, + repository: record.repository.clone(), + stages, + started: timestamp(&record.started_at), + ended: record.ended_at.as_deref().and_then(timestamp), + } +} + +fn collect(command: &Command, now: i64, cutoff: i64, runs: &mut Vec) { + runs.push(run(command, now, cutoff)); + for child in command.dispatched.iter().chain(&command.children) { + collect(child, now, cutoff, runs); + } +} + +pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { + let now = Local::now(); + let cutoff = now - chrono::Duration::seconds((hours * 3600.0) as i64); + let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?; + let mut lanes = Vec::new(); + let mut repositories: BTreeMap> = BTreeMap::new(); + for entry in snapshot.entries() { + let mut runs = Vec::new(); + match entry { + Entry::Record(command) => { + collect(command, now.timestamp(), cutoff.timestamp(), &mut runs) + } + Entry::History(command) => { + let selector = Observation::History(command).selector().unwrap_or_default(); + runs.push(Run { + subject: command.issue.as_ref().map_or_else( + || format!("{} on {}", command.kind, command.repository), + |issue| format!("{}#{}", command.repository, issue.number), + ), + title: String::new(), + state: command + .outcome + .clone() + .unwrap_or_else(|| command.state.into()), + peek_url: super::inspect::url("/peek", &selector, None), + log_url: super::inspect::url("/log", &selector, None), + selector, + repository: command.repository.clone(), + stages: Vec::new(), + started: timestamp(&command.started_at), + ended: command.ended_at.as_deref().and_then(timestamp), + }); + } + } + for run in &runs { + repositories + .entry(run.repository.clone()) + .or_default() + .push(run.clone()); + } + if runs.iter().any(|run| { + run.state == "live" + || run.started.is_some_and(|start| { + start <= now.timestamp() && run.ended.unwrap_or(start) > cutoff.timestamp() + }) + }) { + lanes.push(runs); + } + } + lanes.sort_by_key(|runs| std::cmp::Reverse(runs.first().and_then(|run| run.started))); + let mut grids = Vec::new(); + for (repository, mut runs) in repositories { + runs.sort_by_key(|run| { + ( + run.state != "live", + std::cmp::Reverse(run.ended.or(run.started)), + ) + }); + // Bound the column count while keeping every Run accessible without horizontal scrolling. + let names: BTreeSet<_> = runs + .iter() + .flat_map(|run| run.stages.iter().map(|stage| stage.name.clone())) + .collect(); + for batch in runs.chunks(3) { + let rows = names + .iter() + .map(|name| GridRow { + name: name.clone(), + usual: runs + .iter() + .flat_map(|run| &run.stages) + .find(|stage| &stage.name == name) + .map(|stage| stage.usual.clone()) + .unwrap_or_default(), + cells: batch + .iter() + .map(|run| { + let times: Vec<_> = run + .stages + .iter() + .filter(|stage| &stage.name == name) + .map(|stage| stage.text.as_str()) + .collect(); + if times.is_empty() { + "Not reached · —".into() + } else { + times.join("; ") + } + }) + .collect(), + }) + .collect(); + grids.push(Grid { + repository: repository.clone(), + runs: batch.to_vec(), + rows, + }); + } + } + Ok(Views { + lanes, + grids, + start: cutoff.format("%Y-%m-%d %H:%M %Z").to_string(), + end: now.format("%Y-%m-%d %H:%M %Z").to_string(), + }) +} diff --git a/tests/console.rs b/tests/console.rs index 82b909d1..4e57e658 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -137,7 +137,67 @@ fn history_keeps_every_ended_command_in_time_order_and_filters() { .unwrap() .contains("acme/widgets#1") ); - assert_eq!(console.get("/?page=lanes").status(), 404); + assert_eq!(console.get("/?page=waterfall").status(), 404); +} + +#[test] +fn time_pages_pair_with_all_status_stages_and_repository_usual_ranges() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let now = chrono::Local::now(); + for number in 1..=4 { + let mut value = record(home.path(), number, Some("merged"), 0); + value["started_at"] = json!((now - chrono::Duration::hours(number as i64)).to_rfc3339()); + let began = value["started_at"].as_str().unwrap().to_owned(); + let middle = (chrono::DateTime::parse_from_rfc3339(&began).unwrap() + + chrono::Duration::minutes(10)) + .to_rfc3339(); + let end = (chrono::DateTime::parse_from_rfc3339(&middle).unwrap() + + chrono::Duration::minutes(20)) + .to_rfc3339(); + value["stages"] = json!([ + {"name": "implement", "began_at": began, "session_log": null}, + {"name": "CI watch", "began_at": middle, "session_log": null} + ]); + value["ended_at"] = json!(end); + write_record(home.path(), number, &value); + } + let binary = Path::new(env!("CARGO_BIN_EXE_thirdshift")); + let console = Console::start(binary, home.path()); + let output = Command::new(binary) + .args(["status", "--all", "--json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let status: Value = serde_json::from_slice(&output.stdout).unwrap(); + let stages = console.get("/stages").text().unwrap(); + assert!(stages.contains("

    Stages

    "), "{stages}"); + assert!(stages.contains("thirdshift status --all --json")); + for command in status["commands"].as_array().unwrap() { + let number = command["issue"]["number"].as_u64().unwrap(); + assert!(stages.contains(&format!("acme/widgets#{number}"))); + assert_eq!(command["usual_ranges"]["implement"]["lower_seconds"], 600.0); + assert_eq!(command["usual_ranges"]["CI watch"]["upper_seconds"], 1200.0); + } + assert!(stages.contains("Usual range")); + assert!(stages.contains("10–10 min")); + assert!(stages.contains("20–20 min")); + assert_eq!(stages.matches("Complete · 10 min").count(), 4); + assert_eq!(stages.matches("Complete · 20 min").count(), 4); + for (window, count) in [("3.5", 3), ("12", 4), ("24", 4)] { + let html = console + .get(&format!("/lanes?hours={window}")) + .text() + .unwrap(); + assert!(html.contains("

    Lanes

    "), "{html}"); + assert_eq!(html.matches("class=\"lane\"").count(), count); + assert_eq!(html.matches("data-seconds=\"600\"").count(), count); + assert_eq!(html.matches("data-seconds=\"1200\"").count(), count); + assert!(html.contains("History")); + } + assert_eq!(console.get("/?page=2").status(), 200); + assert_eq!(console.get("/?page=3").status(), 200); } #[test] From 3a0d28443b6b092744c6a5abfb412b793247cf1f Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:39:00 +0000 Subject: [PATCH 25/47] Preserve global Run order and unknown stage history; share timing intervals --- console/README.md | 8 +- console/reproductions/time-grid.py | 64 +++++++++++ console/tests/time.test.cjs | 18 +++- src/console/time.html | 2 +- src/logs/command_record.rs | 39 +++++++ src/logs/reading/health.rs | 24 ++--- src/logs/status/time.rs | 165 +++++++++++++++-------------- 7 files changed, 220 insertions(+), 100 deletions(-) create mode 100644 console/reproductions/time-grid.py diff --git a/console/README.md b/console/README.md index c372ee77..09a459ee 100644 --- a/console/README.md +++ b/console/README.md @@ -39,9 +39,11 @@ and Session logs. Older records without finding metadata still show their logs. Lanes shows each Command and its child Runs on a shared time axis, over the last 3.5, 12 or 24 hours. The window is kept in `hours=`; bars clip to that window, while their text retains the whole stage's time. Older Commands are on History. -Stages groups Runs by repository so each stage's Usual range belongs to that -repository. Live columns come first, then finished Runs newest first, continuing -in tables of three Runs. On phones each stage stacks its labelled cells. Choose +Stages keeps live Runs first across repositories, then finished Runs newest first. +Tables continue when the repository changes or three columns are filled, so +each stage's Usual range belongs to that table's repository. On phones each +stage stacks its labelled cells. Historical Runs without stage metadata show +an unknown time, distinct from a stage that was not reached. Choose a column's Run link to open its exact peek. Repeated stages retain every visit; missing end timestamps say “time unknown” rather than inventing a duration. Both pages use the observations from `status --all --json`. The Rust parity diff --git a/console/reproductions/time-grid.py b/console/reproductions/time-grid.py new file mode 100644 index 00000000..7ca64e0f --- /dev/null +++ b/console/reproductions/time-grid.py @@ -0,0 +1,64 @@ +import datetime as dt +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import urllib.request +from html.parser import HTMLParser + +class Tables(HTMLParser): + def __init__(self): + super().__init__(); self.tables=[]; self.table=None; self.in_cell=False; self.text='' + def handle_starttag(self, tag, attrs): + if tag=='table': self.table=[] + if tag=='tr' and self.table is not None: self.row=[] + if tag in ('th','td') and self.table is not None: self.in_cell=True; self.text='' + def handle_data(self, data): + if self.in_cell: self.text+=data + def handle_endtag(self, tag): + if tag in ('th','td') and self.in_cell: self.row.append(self.text); self.in_cell=False + if tag=='tr' and self.table is not None: self.table.append(self.row) + if tag=='table': self.tables.append(self.table); self.table=None + +binary=str(Path(sys.argv[1] if len(sys.argv)>1 else 'target/debug/thirdshift').resolve()) +case=sys.argv[2] if len(sys.argv)>2 else 'ordering' +with tempfile.TemporaryDirectory(prefix='issue-826-review-') as home: + root=Path(home)/'.thirdshift'; root.mkdir(); (root/'config.toml').write_text('[console]\nport = 0\n') + now=dt.datetime.now(dt.timezone.utc) + stamp=lambda mins:(now-dt.timedelta(minutes=mins)).isoformat() + fields=Path('/proc/self/stat').read_text().rsplit(') ',1)[1].split() + identity=Path('/proc/sys/kernel/random/boot_id').read_text().strip()+':'+fields[19] + def record(repository, number, live): + folder=root/'logs'/repository/'commands/issue'; folder.mkdir(parents=True,exist_ok=True) + value=dict(version=1,kind='Run',name='run-'+str(number),repository=repository,hostname='fixture', + issue=dict(number=number,url=f'https://github.com/{repository}/issues/{number}',title='fixture'), + process_id=os.getpid() if live else 0,process_start=identity if live else 'gone',harness='codex', + started_at=stamp(30),ended_at=None if live else stamp(10),outcome=None if live else 'merged',session_log=None, + stages=[dict(name='implement',began_at=stamp(30),session_log=None)]) + (folder/f'{number}-fixture.command.json').write_text(json.dumps(value)) + record('acme/a',1,False); record('zz/z',2,True) + old=root/'logs/acme/a/activity.log' + old.write_text('2026-10-01 12:00:00 Run #7 started: commands/issue/7-20261001T120000+0000.log\n2026-10-01 12:20:00 Run #7 ended: merged\n') + env=dict(os.environ,HOME=home) + status=json.loads(subprocess.check_output([binary,'status','--all','--json'],env=env)) + server=subprocess.Popen([binary,'console','--no-sign-in'],env=env,stdout=subprocess.PIPE,text=True) + try: + origin=server.stdout.readline().strip() + html=urllib.request.urlopen(origin+'/stages',timeout=5).read().decode() + parser=Tables(); parser.feed(html) + if case=='ordering': + expected=[f'{c["repository"]}#{c["issue"]["number"]}' for c in status['commands'] if c['state']=='live'] + headers=[cell for table in parser.tables for cell in table[0][1:]] + assert headers[0].startswith(expected[0]),f'Live Runs should be pinned first; got columns {headers}; live={expected}' + elif case=='history': + history=next(c for c in status['commands'] if c['repository']=='acme/a' and c['issue']['number']==7) + assert history['stages']==[] and history['source']=='activity_log' + table=next(t for t in parser.tables if any('acme/a#7' in h for h in t[0])) + col=next(i for i,h in enumerate(table[0]) if 'acme/a#7' in h) + cell=table[1][col] + assert 'Not reached' not in cell,f'No stage metadata was retained for the merged historical Run; grid invents a state: {cell}' + else: raise ValueError(case) + finally: + server.terminate(); server.wait(timeout=5) diff --git a/console/tests/time.test.cjs b/console/tests/time.test.cjs index e2dad2c2..b41ec6a8 100644 --- a/console/tests/time.test.cjs +++ b/console/tests/time.test.cjs @@ -41,6 +41,17 @@ before(async () => { await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify(record)); await writeFile(join(folder, `${number}-fixture.log`), `Command ${number}\n`); } + // A finished Run in an alphabetically earlier repository must not precede + // this repository's live Run, or reorder the finished columns by repository. + const otherFolder = join(home, '.thirdshift/logs/aaa/first/commands/issue'); + await mkdir(otherFolder, { recursive: true }); + const other = JSON.parse(await readFile(join(folder, '3-fixture.command.json'), 'utf8')); + other.repository = 'aaa/first'; + other.issue.number = 8; + other.issue.url = 'https://github.com/aaa/first/issues/8'; + await writeFile(join(otherFolder, '8-fixture.command.json'), JSON.stringify(other)); + await writeFile(join(home, '.thirdshift/logs/acme/widgets/activity.log'), + '2026-10-01 12:00:00 Run #9 started: commands/issue/9-20261001T120000+0000.log\n2026-10-01 12:20:00 Run #9 ended: merged\n'); server = spawn(binary, ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); exited = once(server, 'exit'); const lines = createInterface({ input: server.stdout }); @@ -64,7 +75,7 @@ test('Lanes scales every window, nests child Runs and fits 390 px without CSP vi for (const hours of [3.5, 12, 24]) { await page.goto(`${origin}/?page=lanes&hours=${hours}`); assert.equal(await page.locator('[name="hours"]').inputValue(), String(hours)); - assert.equal(await page.locator('.lane').count(), 5); + assert.equal(await page.locator('.lane').count(), 6); assert.equal(await page.locator('.lane').filter({ hasText: 'acme/widgets#2' }).locator('.time-run').count(), 2); const complete = page.locator('.time-run').filter({ hasText: 'acme/widgets#3' }); const track = await complete.locator('.timeline').boundingBox(); @@ -99,6 +110,11 @@ test('Stages pins live Runs, matches CLI order and opens the selected child Run assert.equal(await page.locator('thead .command').first().getAttribute('data-command'), runs[0].record_path ?? `${join(home, '.thirdshift/logs/acme/widgets/commands/issue/1-fixture.command.json')}::run-1`); assert.match(await page.locator('.stage-grid').first().textContent(), /Running ·/); assert.match(await page.locator('.stage-grid').first().textContent(), /Usual range: 10–10 min/); + const historical = page.locator('.stage-grid').filter({ has: page.locator('thead .peek-link', { hasText: 'acme/widgets#9' }) }); + const headers = await historical.locator('thead .peek-link').allTextContents(); + const historicalCell = historical.locator('tbody tr').first().locator('td').nth(headers.indexOf('acme/widgets#9')); + assert.match(await historicalCell.textContent(), /Unknown · no stage times retained/); + assert.doesNotMatch(await historicalCell.textContent(), /Not reached/); const child = page.locator('thead .peek-link').filter({ hasText: 'acme/widgets#6' }); await child.click(); await page.locator('#peek-content h2').waitFor(); diff --git a/src/console/time.html b/src/console/time.html index 14545c50..7d8d803a 100644 --- a/src/console/time.html +++ b/src/console/time.html @@ -38,7 +38,7 @@

    {{ run.subject }} · {{ r

    {% endfor %} {% else %} -

    Live Runs first, then finished Runs newest first, grouped by repository. Each table continues the preceding columns. Repeated stages list every visit.

    +

    Live Runs first, then finished Runs newest first. Tables continue in that order, with each repository's Usual range. Repeated stages list every visit.

    {% if views.grids.is_empty() %}

    No Runs yet.

    {% endif %} {% for grid in views.grids %} diff --git a/src/logs/command_record.rs b/src/logs/command_record.rs index 45dea564..62225a7a 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -105,6 +105,21 @@ pub(super) struct Stage { pub session_log: Option, } +/// A retained stage interval, optionally extended to an observation of live work. +pub(super) struct StageTime<'a> { + pub stage: &'a Stage, + pub began: Option>, + pub ended: Option>, +} + +impl StageTime<'_> { + pub fn seconds(&self) -> Option { + let (began, ended) = self.began.zip(self.ended)?; + let milliseconds = ended.signed_duration_since(began).num_milliseconds(); + (milliseconds >= 0).then_some(milliseconds as f64 / 1000.0) + } +} + /// Internal transitions are typed; persisted names stay tolerant of new stages. #[derive(Clone, Copy)] pub enum StageName { @@ -167,6 +182,30 @@ impl StageName { } impl CommandRecord { + /// The next transition closes a stage; only a recorded outcome closes the + /// final stage. A live caller can supply its single sampled observation time. + pub fn stage_times( + &self, + live_until: Option>, + ) -> impl Iterator> { + self.stages.iter().enumerate().map(move |(index, stage)| { + let ended = if let Some(next) = self.stages.get(index + 1) { + DateTime::parse_from_rfc3339(&next.began_at).ok() + } else if self.outcome.is_some() { + self.ended_at + .as_deref() + .and_then(|end| DateTime::parse_from_rfc3339(end).ok()) + } else { + live_until + }; + StageTime { + stage, + began: DateTime::parse_from_rfc3339(&stage.began_at).ok(), + ended, + } + }) + } + /// Exact retained Session references, including earlier stages. Callers /// decide whether they need a deduplicated inventory. pub fn session_references(&self) -> impl Iterator { diff --git a/src/logs/reading/health.rs b/src/logs/reading/health.rs index 62753fa0..6625a5b4 100644 --- a/src/logs/reading/health.rs +++ b/src/logs/reading/health.rs @@ -81,26 +81,16 @@ impl UsualRanges { } fn collect_times(record: &CommandRecord, samples: &mut BTreeMap<(String, String), Vec>) { - for (index, stage) in record.stages.iter().enumerate() { + for timing in record.stage_times(None) { + let stage = timing.stage; if !StageName::known(&stage.name) { continue; } - let end = record - .stages - .get(index + 1) - .map(|stage| stage.began_at.as_str()) - .or_else(|| record.outcome.as_ref().and(record.ended_at.as_deref())); - if let Some((began, ended)) = DateTime::parse_from_rfc3339(&stage.began_at) - .ok() - .zip(end.and_then(|end| DateTime::parse_from_rfc3339(end).ok())) - { - let seconds = ended.signed_duration_since(began).num_milliseconds() as f64 / 1000.0; - if seconds >= 0.0 { - samples - .entry((record.repository.clone(), stage.name.clone())) - .or_default() - .push(seconds); - } + if let Some(seconds) = timing.seconds() { + samples + .entry((record.repository.clone(), stage.name.clone())) + .or_default() + .push(seconds); } } } diff --git a/src/logs/status/time.rs b/src/logs/status/time.rs index 848ab182..297bbb5d 100644 --- a/src/logs/status/time.rs +++ b/src/logs/status/time.rs @@ -26,6 +26,7 @@ pub(crate) struct Run { repository: String, started: Option, ended: Option, + stage_metadata: bool, } #[derive(Clone)] @@ -56,28 +57,20 @@ fn timestamp(value: &str) -> Option { .map(|time| time.timestamp()) } -fn run(command: &Command, now: i64, cutoff: i64) -> Run { +fn run(command: &Command, now: DateTime, cutoff: i64) -> Run { let record = &command.record; let selector = Observation::Record(command).selector().unwrap_or_default(); + let live_until = (command.state == "live").then_some(now.fixed_offset()); + let now = now.timestamp(); let stages = record - .stages - .iter() + .stage_times(live_until) .enumerate() - .map(|(index, stage)| { - let began = timestamp(&stage.began_at); - let next = record.stages.get(index + 1); - let end = if let Some(next) = next { - timestamp(&next.began_at) - } else if command.state == "live" { - Some(now) - } else { - record.ended_at.as_deref().and_then(timestamp) - }; - let seconds = began - .zip(end) - .filter(|(start, end)| end >= start) - .map(|(start, end)| end - start); - let state = if next.is_some() { + .map(|(index, timing)| { + let stage = timing.stage; + let began = timing.began.map(|time| time.timestamp()); + let end = timing.ended.map(|time| time.timestamp()); + let seconds = timing.seconds(); + let state = if index + 1 < record.stages.len() { "Complete" } else if command.state == "live" { "Running" @@ -89,21 +82,22 @@ fn run(command: &Command, now: i64, cutoff: i64) -> Run { record.outcome.as_deref().unwrap_or("Unknown") }; let duration = seconds - .map(super::age) + .map(|seconds| super::age(seconds as i64)) .unwrap_or_else(|| "time unknown".into()); // Clip only the drawn interval; the text retains the full stage time. - let (left, width) = began.zip(end).filter(|(start, end)| end >= start).map_or( - (0.0, 0.0), - |(start, end)| { - let start = start.clamp(cutoff, now); - let end = end.clamp(cutoff, now); - let scale = 100.0 / (now - cutoff) as f64; - ( - (start - cutoff) as f64 * scale, - (end - start).max(0) as f64 * scale, - ) - }, - ); + let (left, width) = + began + .zip(end) + .filter(|_| seconds.is_some()) + .map_or((0.0, 0.0), |(start, end)| { + let start = start.clamp(cutoff, now); + let end = end.clamp(cutoff, now); + let scale = 100.0 / (now - cutoff) as f64; + ( + (start - cutoff) as f64 * scale, + (end - start).max(0) as f64 * scale, + ) + }); Stage { name: stage.name.clone(), text: format!("{state} · {duration}"), @@ -142,10 +136,11 @@ fn run(command: &Command, now: i64, cutoff: i64) -> Run { stages, started: timestamp(&record.started_at), ended: record.ended_at.as_deref().and_then(timestamp), + stage_metadata: true, } } -fn collect(command: &Command, now: i64, cutoff: i64, runs: &mut Vec) { +fn collect(command: &Command, now: DateTime, cutoff: i64, runs: &mut Vec) { runs.push(run(command, now, cutoff)); for child in command.dispatched.iter().chain(&command.children) { collect(child, now, cutoff, runs); @@ -161,9 +156,7 @@ pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { for entry in snapshot.entries() { let mut runs = Vec::new(); match entry { - Entry::Record(command) => { - collect(command, now.timestamp(), cutoff.timestamp(), &mut runs) - } + Entry::Record(command) => collect(command, now, cutoff.timestamp(), &mut runs), Entry::History(command) => { let selector = Observation::History(command).selector().unwrap_or_default(); runs.push(Run { @@ -183,6 +176,7 @@ pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { stages: Vec::new(), started: timestamp(&command.started_at), ended: command.ended_at.as_deref().and_then(timestamp), + stage_metadata: false, }); } } @@ -202,54 +196,69 @@ pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { } } lanes.sort_by_key(|runs| std::cmp::Reverse(runs.first().and_then(|run| run.started))); + let mut ordered: Vec<_> = repositories.values().flatten().cloned().collect(); + ordered.sort_by_key(|run| { + ( + run.state != "live", + std::cmp::Reverse(run.ended.or(run.started)), + ) + }); + // Keep global ordering, continuing a repository's table whenever the next + // Run belongs elsewhere or three columns have filled the available width. + let mut batches: Vec> = Vec::new(); + for run in ordered { + if let Some(batch) = batches.last_mut() + && batch.len() < 3 + && batch[0].repository == run.repository + { + batch.push(run); + } else { + batches.push(vec![run]); + } + } let mut grids = Vec::new(); - for (repository, mut runs) in repositories { - runs.sort_by_key(|run| { - ( - run.state != "live", - std::cmp::Reverse(run.ended.or(run.started)), - ) - }); - // Bound the column count while keeping every Run accessible without horizontal scrolling. + for batch in batches { + let repository = &batch[0].repository; + let runs = &repositories[repository]; let names: BTreeSet<_> = runs .iter() .flat_map(|run| run.stages.iter().map(|stage| stage.name.clone())) .collect(); - for batch in runs.chunks(3) { - let rows = names - .iter() - .map(|name| GridRow { - name: name.clone(), - usual: runs - .iter() - .flat_map(|run| &run.stages) - .find(|stage| &stage.name == name) - .map(|stage| stage.usual.clone()) - .unwrap_or_default(), - cells: batch - .iter() - .map(|run| { - let times: Vec<_> = run - .stages - .iter() - .filter(|stage| &stage.name == name) - .map(|stage| stage.text.as_str()) - .collect(); - if times.is_empty() { - "Not reached · —".into() - } else { - times.join("; ") - } - }) - .collect(), - }) - .collect(); - grids.push(Grid { - repository: repository.clone(), - runs: batch.to_vec(), - rows, - }); - } + let rows = names + .iter() + .map(|name| GridRow { + name: name.clone(), + usual: runs + .iter() + .flat_map(|run| &run.stages) + .find(|stage| &stage.name == name) + .map(|stage| stage.usual.clone()) + .unwrap_or_default(), + cells: batch + .iter() + .map(|run| { + let times: Vec<_> = run + .stages + .iter() + .filter(|stage| &stage.name == name) + .map(|stage| stage.text.as_str()) + .collect(); + if !run.stage_metadata { + "Unknown · no stage times retained".into() + } else if times.is_empty() { + "Not reached · —".into() + } else { + times.join("; ") + } + }) + .collect(), + }) + .collect(); + grids.push(Grid { + repository: repository.clone(), + runs: batch, + rows, + }); } Ok(Views { lanes, From e043b5214e00f6e08f0a846bb27c01b76ff957d5 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 01:40:17 +0000 Subject: [PATCH 26/47] Apply Console themes and shared navigation to account pages --- console/tests/accounts.test.cjs | 7 +++-- src/console/account_base.html | 4 +-- src/console/auth.rs | 46 ++++++++++++++++++++++----------- tests/console/accounts.rs | 30 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/console/tests/accounts.test.cjs b/console/tests/accounts.test.cjs index 5b2a440f..be9c3e4f 100644 --- a/console/tests/accounts.test.cjs +++ b/console/tests/accounts.test.cjs @@ -13,7 +13,7 @@ before(async () => { home = await mkdtemp(join(tmpdir(), "thirdshift-console-accounts-")); const root = join(home, ".thirdshift"); await mkdir(root); - await writeFile(join(root, "config.toml"), '[console]\nport = 0\nallowed_emails = ["owner@example.test"]\n'); + await writeFile(join(root, "config.toml"), '[console]\nport = 0\nallowed_emails = ["owner@example.test"]\ntheme = "focus-dark"\n'); // A fixture created with `console password` for "browser test password". // The Rust HTTP tests exercise account creation through the real terminal. await writeFile(join(root, "console-accounts.json"), JSON.stringify({ @@ -39,11 +39,12 @@ after(async () => { }); test("sign-in and sign-out work on a phone without JavaScript using secure cookies", async t => { - const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 } }); + const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 }, colorScheme: "light" }); t.after(() => context.close()); const page = await context.newPage(); await page.goto(origin); assert.equal(new URL(page.url()).pathname, "/sign-in"); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); await page.getByLabel("Email").fill("owner@example.test"); await page.getByLabel("Password", { exact: true }).fill("browser test password"); @@ -58,7 +59,9 @@ test("sign-in and sign-out work on a phone without JavaScript using secure cooki assert.equal((await context.request.post(`${origin}/sign-out`)).status(), 403); await page.getByRole("link", { name: "Console log", exact: true }).click(); await page.getByText(/owner@example.test · sign-in/).waitFor(); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); await page.getByRole("link", { name: "Account", exact: true }).click(); + assert.equal(await page.evaluate(() => getComputedStyle(document.body).backgroundColor), "rgb(18, 18, 20)"); await Promise.all([page.waitForURL(`${origin}/sign-in`), page.getByRole("button", { name: "Sign out this browser" }).click()]); await page.goto(origin); assert.equal(new URL(page.url()).pathname, "/sign-in"); diff --git a/src/console/account_base.html b/src/console/account_base.html index c7e6ec29..90c9c3c3 100644 --- a/src/console/account_base.html +++ b/src/console/account_base.html @@ -1,5 +1,5 @@ - + @@ -9,7 +9,7 @@

    thirdshift / Console

    - {% block navigation %}{% endblock %} + {% block navigation %}{% include "console/nav.html" %}{% endblock %}
    {% block content %}{% endblock %}
    diff --git a/src/console/auth.rs b/src/console/auth.rs index b7db1161..8aebbc4e 100644 --- a/src/console/auth.rs +++ b/src/console/auth.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use subtle::ConstantTimeEq; use super::accounts::{self, Accounts, SignIn}; +use crate::config::UserConfig; const SESSION: &str = "__Host-thirdshift-session"; const SIGN_IN: &str = "__Host-thirdshift-sign-in"; @@ -164,6 +165,7 @@ pub(super) async fn headers(request: Request, next: Next) -> Response { #[template(path = "console/sign_in.html")] struct SignInPage { csrf: String, + theme: String, } pub(super) async fn sign_in_page( @@ -174,11 +176,20 @@ pub(super) async fn sign_in_page( return StatusCode::NOT_FOUND.into_response(); } let token = cookie(&headers, SIGN_IN).unwrap_or_else(accounts::random_token); - let mut response = match (SignInPage { csrf: csrf(&token) }).render() { - Ok(html) => super::page(Ok(Ok(html))), - Err(error) => return failed(error), - }; - set_cookie(&mut response, SIGN_IN, &token, 15 * 60); + let form_token = csrf(&token); + let mut response = super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(SignInPage { + csrf: form_token, + theme: UserConfig::load()?.console.theme, + } + .render()?) + }) + .await, + ); + if response.status().is_success() { + set_cookie(&mut response, SIGN_IN, &token, 15 * 60); + } response } @@ -237,19 +248,22 @@ struct AccountPage { email: String, csrf: String, signed_in: bool, + theme: String, } pub(super) async fn account(Extension(viewer): Extension) -> Response { - match (AccountPage { - email: viewer.email, - csrf: viewer.csrf, - signed_in: viewer.token.is_some(), - }) - .render() - { - Ok(html) => super::page(Ok(Ok(html))), - Err(error) => failed(error), - } + super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(AccountPage { + email: viewer.email, + csrf: viewer.csrf, + signed_in: viewer.token.is_some(), + theme: UserConfig::load()?.console.theme, + } + .render()?) + }) + .await, + ) } pub(super) async fn sign_out(Extension(viewer): Extension) -> Response { @@ -274,6 +288,7 @@ pub(super) async fn sign_out(Extension(viewer): Extension) -> Response { #[template(path = "console/account_log.html")] struct LogPage { events: Vec, + theme: String, } pub(super) async fn log() -> Response { @@ -281,6 +296,7 @@ pub(super) async fn log() -> Response { tokio::task::spawn_blocking(move || -> Result { Ok(LogPage { events: Accounts::load()?.events()?, + theme: UserConfig::load()?.console.theme, } .render()?) }) diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index e5cc1237..c89c200e 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -87,6 +87,36 @@ fn sign_in(console: &Console, email: &str, password: &str) -> reqwest::blocking: .unwrap() } +#[test] +fn account_pages_and_sign_in_follow_the_configured_theme() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + for theme in ["focus-dark", "focus-light"] { + fs::write( + home.path().join(".thirdshift/config.toml"), + format!("[console]\nport = 0\nallowed_emails = [\"{EMAIL}\"]\ntheme = \"{theme}\"\n"), + ) + .unwrap(); + let attribute = format!("data-theme=\"{theme}\""); + assert!( + get(&console, "/sign-in", "") + .text() + .unwrap() + .contains(&attribute) + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + for path in ["/", "/account", "/console-log"] { + let page = get(&console, path, &session).text().unwrap(); + assert!(page.contains(&attribute), "{path} ignores {theme}"); + } + } +} + #[test] fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() { let home = configured(); From ff40cffadc0b3dcab67e58401f2da133e4d5ebe1 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 02:44:51 +0000 Subject: [PATCH 27/47] Fix Console terminal test compilation on macOS The new password helper assumed Linux libc signatures. Pass mutable null pointers to openpty and infer ioctl's request type, matching the existing terminal test support. Validated the exact helper against aarch64-apple-darwin libc: reproduced both E0308 errors before the fix and passed afterward. All 19 Console integration tests, cargo fmt --check, and cargo clippy --test console -- -D warnings pass on Linux. --- tests/console/accounts.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index c89c200e..30757f82 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -427,8 +427,8 @@ fn password(home: &Path, email: Option<&str>, first: &str, second: &str) -> (Exi &mut master, &mut slave, std::ptr::null_mut(), - std::ptr::null(), - std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), ) }, 0 @@ -447,7 +447,7 @@ fn password(home: &Path, email: Option<&str>, first: &str, second: &str) -> (Exi } unsafe { command.pre_exec(|| { - if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 { + if libc::setsid() < 0 || libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) From 193c967b4dfd0ee6c31754ef35b2d62c53793c6d Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 03:09:14 +0000 Subject: [PATCH 28/47] Add the Console Waterfall with scoped tool-call timing (#827) --- console/README.md | 15 +- console/tests/waterfall.test.cjs | 103 +++++++++ docs/code-navigation.md | 1 + src/console.rs | 8 + src/console/assets/console.css | 9 + src/console/nav.html | 2 +- src/console/waterfall.html | 41 ++++ src/console/waterfall.rs | 32 +++ src/logs/status.rs | 1 + src/logs/status/waterfall.rs | 314 ++++++++++++++++++++++++++ src/logs/status/waterfall/matching.rs | 187 +++++++++++++++ tests/console.rs | 142 +++++++++++- 12 files changed, 851 insertions(+), 4 deletions(-) create mode 100644 console/tests/waterfall.test.cjs create mode 100644 src/console/waterfall.html create mode 100644 src/console/waterfall.rs create mode 100644 src/logs/status/waterfall.rs create mode 100644 src/logs/status/waterfall/matching.rs diff --git a/console/README.md b/console/README.md index 09a459ee..85507fee 100644 --- a/console/README.md +++ b/console/README.md @@ -31,7 +31,7 @@ them. The first page pairs with `thirdshift status`. A refresh reads the current records; server-push updates are a later Ticket. Replacing the installed binary makes the Console re-execute itself with its original arguments. -The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), History (`5`), Logs (`6`) and Security (`7`). Links +The available pages are Inbox (`1`), Lanes (`2`), Stages (`3`), Waterfall (`4`), History (`5`), Logs (`6`) and Security (`7`). Links keep the page in `?page=`. History includes every ended Command, including old logs, and filters by outcome and repository. Security shows retained findings and Session logs. Older records without finding metadata still show their logs. @@ -50,12 +50,23 @@ Both pages use the observations from `status --all --json`. The Rust parity test checks their stage times and Usual ranges against the CLI; the Playwright time tests check scale in all windows, child selection, live ordering and 390 px. +Waterfall chooses one retained Command with live, died and ended chips, keeping +its exact selection in `command=`. Runs, stages and tool calls share one axis, +including a Spec run's Ticket Runs. It pairs with `show --json` and `logs`: +stage intervals come from the Command record, and Claude/Codex tool summaries +match their scoped `HH:MM:SS` Command-log lines in order, across midnight. +Each tool interval extends to the next matched call in its Session or its stage +boundary. These include waiting; they are not measured tool execution times. +Unmatched calls stay visible with “Time unknown” and no bar. Session links open +their retained evidence. The Waterfall browser test checks selection, scale, +unknown times, escaping and phone layout; Rust checks matching and CLI parity. + `j` / `k` select headings and visible rows, wrapping at either end. `Space` folds a heading or toggles a row's peek; `Enter` opens that row's full Command log. `z` folds the current section. `Escape` closes help, jumps or the peek, one layer at a time, then clears selection. `?` opens the key list. `g` opens the jump list: `i` Inbox, `a` Lanes, `t` Stages, `h` History, `o` Logs, `s` Security, or a section's displayed -letter. Text fields keep their normal keys. The peek starts closed and sits +letter; `f` opens Waterfall. Text fields keep their normal keys. The peek starts closed and sits beside the list on a wide screen or at the bottom of a phone. For an exact older Command, `show` and `logs` also accept the absolute record diff --git a/console/tests/waterfall.test.cjs b/console/tests/waterfall.test.cjs new file mode 100644 index 00000000..e7587c2b --- /dev/null +++ b/console/tests/waterfall.test.cjs @@ -0,0 +1,103 @@ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); + +let home, server, exited, browser, origin; +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-waterfall-')); + const folder = join(home, '.thirdshift/logs/acme/widgets/commands/issue'); + await mkdir(folder, { recursive: true }); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + let processStart = 'gone'; + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + const fields = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/); + processStart = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${fields[19]}`; + } + const record = { + version: 1, kind: 'Spec run', repository: 'acme/widgets', name: 'spec-7', hostname: 'fixture', + issue: { number: 7, url: 'https://github.com/acme/widgets/issues/7', title: 'Widgets ' }, + process_id: 0, process_start: 'gone', harness: 'codex', started_at: '2026-10-01T12:00:00+00:00', + ended_at: '2026-10-01T12:01:00+00:00', outcome: 'merged', session_log: null, + stages: [{ name: 'Tickets', began_at: '2026-10-01T12:00:00+00:00', session_log: null }] + }; + const parent = join(folder, '7-fixture.command.json'); + await writeFile(parent, JSON.stringify(record)); + const session = join(folder, '99-20261001T120000+0000-implement.jsonl'); + const item = (id, command) => JSON.stringify({ type: 'item.completed', item: { id, type: 'command_execution', command, status: 'completed' } }) + '\n'; + await writeFile(session, item('a', 'first ') + item('b', 'unmatched') + item('c', 'last')); + await writeFile(join(folder, '7-fixture.issue-99.child.json'), JSON.stringify({ + ...record, kind: 'Run', name: 'ticket-99', issue: { ...record.issue, number: 99 }, parent_record: parent, + started_at: '2026-10-01T12:00:10+00:00', ended_at: '2026-10-01T12:00:40+00:00', session_log: session, + stages: [{ name: 'implement', began_at: '2026-10-01T12:00:10+00:00', session_log: session }] + })); + await writeFile(join(folder, '7-fixture.log'), 'thirdshift: 12:00:00 starting\nthirdshift: 12:00:15 #99: implement: $ first \nthirdshift: 12:00:30 #99: implement: $ last\nthirdshift: 12:01:00 finished\n'); + for (const number of [8, 9]) { + await writeFile(join(folder, `${number}-fixture.command.json`), JSON.stringify({ + ...record, kind: 'Run', name: `run-${number}`, issue: { ...record.issue, number }, + process_id: number === 9 ? process.pid : 0, process_start: number === 9 ? processStart : 'gone', + outcome: null, ended_at: null + })); + } + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + try { [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); } + finally { lines.close(); } + browser = await chromium.launch({ headless: true }); +}); +after(async () => { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + await exited; + if (home) await rm(home, { recursive: true, force: true }); +}); + +test('Waterfall chips, nested scale and unknown tools work at desktop and 390 px', async t => { + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + for (const width of [1200, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto(`${origin}/?page=waterfall`); + const chips = page.getByRole('navigation', { name: 'Choose a Command' }); + assert.match(await chips.textContent(), /acme\/widgets#8 · died/); + if (process.platform === 'linux') assert.match(await chips.textContent(), /acme\/widgets#9 · live/); + await chips.getByRole('link', { name: 'acme/widgets#7 · merged' }).click(); + assert.equal(await page.locator('.waterfall-run').count(), 2); + const child = page.locator('.waterfall-run[data-depth="1"]'); + assert.equal(await child.locator('.waterfall-tool').count(), 3); + assert.match(await child.locator('.waterfall-tool').nth(1).textContent(), /Time unknown/); + const track = await child.locator(':scope > .timeline').boundingBox(); + const run = await child.locator(':scope > .timeline rect').boundingBox(); + const stageTrack = await child.locator('.waterfall-stage > .timeline').boundingBox(); + const toolTrack = await child.locator('.waterfall-tool .timeline').first().boundingBox(); + const first = await child.locator('.waterfall-tool .timeline rect').first().boundingBox(); + assert.ok(Math.abs(run.width - track.width / 2) <= 1.01); + assert.ok(Math.abs(first.width - track.width / 4) <= 1.01); + assert.ok(Math.abs((first.x - track.x) / track.width - 0.25) < 0.002); + assert.equal(track.x, stageTrack.x); + assert.equal(track.x, toolTrack.x); + assert.equal(track.width, toolTrack.width); + assert.equal(await page.locator('main script, main unsafe').count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await child.locator('.peek-link').click(); + await page.locator('#peek-content h2').waitFor(); + assert.match(await page.locator('#peek-content').textContent(), /ticket-99/); + await page.keyboard.press('Escape'); + await page.keyboard.press('1'); + await page.waitForURL('**page=inbox'); + await page.keyboard.press('4'); + await page.waitForURL('**page=waterfall'); + } + assert.deepEqual(errors, []); +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 63632144..23921980 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -23,6 +23,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Silent, Slow, Usual range | `src/logs/reading/health.rs` — health observations and repository stage times; `src/config.rs` — silence threshold and safe waiting interval | `tests/run_health.rs`, `tests/ci.rs` | | Inbox, Needs you | `src/logs/reading/inbox.rs` — local grouping, ordering, clearing and failure collapse; `src/logs/status.rs` — text and JSON | `tests/status.rs`, `tests/security_run.rs` | | Console Lanes and Stages, stage times | `src/logs/status/time.rs` — time views from complete status observations; `src/console/time.rs`, `src/console/time.html` — page rendering | `tests/console.rs`, `console/tests/time.test.cjs` | +| Console Waterfall, tool-call timing | `src/logs/status/waterfall.rs` — retained Run/stage tree and scale; `src/logs/status/waterfall/matching.rs` — scoped Claude/Codex matching; `src/console/waterfall.rs` | `tests/console.rs`, `console/tests/waterfall.test.cjs` | | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | diff --git a/src/console.rs b/src/console.rs index 2bcfc746..54a419e2 100644 --- a/src/console.rs +++ b/src/console.rs @@ -4,6 +4,7 @@ mod logs; mod peek; mod restart; mod time; +mod waterfall; use std::io::Write; use std::net::Ipv4Addr; @@ -30,6 +31,10 @@ struct PageRoute { } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/waterfall", + cli: "thirdshift show widgets#7 --json", + }, PageRoute { path: "/lanes", cli: "thirdshift status --all --json", @@ -177,6 +182,9 @@ async fn inbox(cli: &'static str, path: &'static str, RawQuery(query): RawQuery) if matches!(page.as_str(), "logs" | "6") { return logs::index().await; } + if matches!(page.as_str(), "waterfall" | "4") { + return waterfall::show(query).await; + } if matches!(page.as_str(), "lanes" | "2" | "stages" | "3") { return time::show(matches!(page.as_str(), "stages" | "3"), query).await; } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 9ecccb29..b318b17b 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -90,6 +90,15 @@ footer { margin-top: 16px; } .timeline rect { fill: var(--panel); stroke: var(--working); } .timeline text { fill: var(--text); font: 12px var(--font-mono); } .stage-times { padding-left: 24px; } +.command-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; } +.command-chips a { border: 1px solid var(--rule); border-radius: var(--radius); padding: 6px; overflow-wrap: anywhere; } +.command-chips [aria-current] { outline: 2px solid var(--head); } +.waterfall-run { border-left: 2px solid var(--rule); padding-left: 8px; margin: 16px 0; overflow-wrap: anywhere; } +.waterfall-run[data-depth="1"] > .command { padding-left: 12px; } +.waterfall-run[data-depth="2"] > .command { padding-left: 24px; } +.waterfall-stages, .waterfall-stages ol { list-style: none; padding-left: 0; } +.waterfall-stage > h4, .waterfall-stage > p { margin-left: 12px; } +.waterfall-tool > a, .waterfall-tool > p { margin-left: 24px; } .stage-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 20px 0; } .stage-grid caption { text-align: left; font-weight: var(--head-weight); } .stage-grid th, .stage-grid td { border: 1px solid var(--rule); padding: 8px; text-align: left; vertical-align: top; overflow-wrap: anywhere; } diff --git a/src/console/nav.html b/src/console/nav.html index ab2ebd48..f70f4f36 100644 --- a/src/console/nav.html +++ b/src/console/nav.html @@ -1 +1 @@ - + diff --git a/src/console/waterfall.html b/src/console/waterfall.html new file mode 100644 index 00000000..38e4e83d --- /dev/null +++ b/src/console/waterfall.html @@ -0,0 +1,41 @@ + + + + + + Waterfall · {{ hostname }} · thirdshift + + + + + +

    thirdshift / Console

    {{ hostname }}

    + {% if !consoles.is_empty() %}{% endif %} +
    + {% include "console/nav.html" %} +
    +

    Waterfall

    thirdshift show <command> --json · thirdshift logs <command>
    + + {% if view.runs.is_empty() %}

    No Commands yet.

    {% else %} +

    Shared time axis: {{ view.start }} to {{ view.end }}. Tool intervals run until the next matched call in that Session or the stage boundary; they include waiting and are not measured execution times. Unknown times have no bar.

    + {% for run in view.runs %} +
    +

    {{ run.kind }} · {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    + +

    {{ run.span.start }} · {{ run.span.text }}

    + {% if run.stages.is_empty() %}

    No stage times retained.

    {% endif %} +
      {% for stage in run.stages %} +
    1. {{ stage.name }}

      {{ stage.span.start }} · {{ stage.span.text }}

      + +
        {% for tool in stage.tools %}
      1. {{ tool.label }}

        {{ tool.span.start }} · {{ tool.span.text }}

        + +
      2. {% endfor %}
      +
    2. + {% endfor %}
    +
    + {% endfor %} + {% endif %} +
    + {% include "console/chrome.html" %} + + diff --git a/src/console/waterfall.rs b/src/console/waterfall.rs new file mode 100644 index 00000000..ef9f54b4 --- /dev/null +++ b/src/console/waterfall.rs @@ -0,0 +1,32 @@ +//! One selected Command, using the same retained tree and logs as show/logs. +use anyhow::Result; +use askama::Template; +use axum::response::Response; + +use crate::config::UserConfig; +use crate::logs::status::waterfall::{self, View}; + +#[derive(Template)] +#[template(path = "console/waterfall.html")] +struct WaterfallPage { + hostname: String, + consoles: Vec, + theme: String, + view: View, +} + +pub(super) async fn show(query: Option) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + Ok(WaterfallPage { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + view: waterfall::load(&config, &super::query_value(&query, "command"))?, + } + .render()?) + }) + .await, + ) +} diff --git a/src/logs/status.rs b/src/logs/status.rs index 41a1704c..a7eed137 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -3,6 +3,7 @@ mod inbox; pub mod inspect; pub(crate) mod time; +pub(crate) mod waterfall; pub(crate) use inbox::{Archive, ArchivePage, archive}; pub(crate) use inbox::{Inbox, Row, inbox}; diff --git a/src/logs/status/waterfall.rs b/src/logs/status/waterfall.rs new file mode 100644 index 00000000..ac3a0fb4 --- /dev/null +++ b/src/logs/status/waterfall.rs @@ -0,0 +1,314 @@ +//! A Command tree on one time axis, derived from show facts and retained logs. +mod matching; + +use anyhow::Result; +use chrono::{DateTime, FixedOffset, Local}; + +use crate::config::UserConfig; +use crate::logs::reading::{Command, Entry, Observation, Snapshot}; + +type Time = DateTime; + +pub(crate) struct View { + pub choices: Vec, + pub runs: Vec, + pub start: String, + pub end: String, +} + +pub(crate) struct Choice { + pub subject: String, + pub state: String, + pub url: String, + pub selected: bool, +} + +pub(crate) struct Run { + pub subject: String, + pub kind: String, + pub title: String, + pub state: String, + pub selector: String, + pub peek_url: String, + pub log_url: String, + pub depth: usize, + pub span: Span, + pub stages: Vec, +} + +pub(crate) struct Stage { + pub name: String, + pub span: Span, + pub tools: Vec, +} + +pub(crate) struct Tool { + pub label: String, + pub span: Span, + pub url: String, +} + +pub(crate) struct Span { + pub start: String, + pub seconds: String, + pub text: String, + pub left: f64, + pub width: f64, +} + +struct Axis { + start: Time, + end: Time, +} + +impl Axis { + fn span(&self, start: Option
    - {% for run in grid.runs %}{% endfor %} - {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %} + {% for run in grid.runs %}{% endfor %} + {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %}
    {{ grid.repository }}
    Stage
    Usual range
    {{ run.subject }}
    {{ run.state }}
    {{ run.title }}
    {{ row.name }}
    Usual range: {{ row.usual }}
    {{ grid.runs[loop.index0].subject }}: {{ cell }}
    Stage
    Usual range
    {{ run.subject }}
    {{ run.state }}
    {{ run.title }}
    {{ row.name }}
    Usual range: {{ row.usual }}
    {{ grid.runs[loop.index0].subject }}: {{ cell }}
    {% if grid.rows.is_empty() %}

    No stage times retained.

    {% endif %} {% endfor %} diff --git a/src/console/waterfall.html b/src/console/waterfall.html index 38e4e83d..c08d8cb7 100644 --- a/src/console/waterfall.html +++ b/src/console/waterfall.html @@ -20,12 +20,12 @@

    Shared time axis: {{ view.start }} to {{ view.end }}. Tool intervals run until the next matched call in that Session or the stage boundary; they include waiting and are not measured execution times. Unknown times have no bar.

    {% for run in view.runs %}
    -

    {{ run.kind }} · {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    +

    {{ run.kind }} · {{ run.subject }} · {{ run.state }}

    {{ run.title }}

    {{ run.span.start }} · {{ run.span.text }}

    {% if run.stages.is_empty() %}

    No stage times retained.

    {% endif %}
      {% for stage in run.stages %} -
    1. {{ stage.name }}

      {{ stage.span.start }} · {{ stage.span.text }}

      +
    2. {{ stage.name }}

      {{ stage.span.start }} · {{ stage.span.text }}

        {% for tool in stage.tools %}
      1. {{ tool.label }}

        {{ tool.span.start }} · {{ tool.span.text }}

        diff --git a/src/main.rs b/src/main.rs index eb83ef0f..3120c616 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,8 @@ usage: thirdshift Run the factory on the iss thirdshift console password [--email
        ] Set a Console password privately thirdshift console sign-out [--email
        ] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log + thirdshift console theme [--json] Choose or list Console Themes + thirdshift console sections [--hide
        ] [--show
        ] [--json] Choose visible sections thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] Stop a live Command and wait for cleanup thirdshift logs [--command ] [--json] List this machine's logs @@ -140,6 +142,12 @@ fn main() -> ExitCode { Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), + Ok(Command::ConsoleTheme { name, json }) => { + return printed(console::theme(name.as_deref(), json)); + } + Ok(Command::ConsoleSections { hide, show, json }) => { + return printed(console::sections(&hide, &show, json)); + } Ok(Command::ConsoleSignOut { email, json }) => { return match console::sign_out(email.as_deref(), json) { Ok(()) => ExitCode::SUCCESS, @@ -362,6 +370,17 @@ fn release_summary() -> ExitCode { } } +/// A Console settings view or action, with machine-readable output on stdout. +fn printed(result: anyhow::Result) -> ExitCode { + match result { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(error) => failure(&error), + } +} + /// The end of a command other than a Run: the line that says how it went, /// or its error, on stderr. fn outcome(result: anyhow::Result) -> ExitCode { diff --git a/tests/console.rs b/tests/console.rs index 308d0341..c24aafef 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -15,6 +15,9 @@ use serde_json::{Value, json}; #[path = "console/accounts.rs"] mod accounts; +#[path = "console/look.rs"] +mod look; + struct Console { child: Child, address: String, diff --git a/tests/console/accounts.rs b/tests/console/accounts.rs index 39f4b5c5..d30dfe1c 100644 --- a/tests/console/accounts.rs +++ b/tests/console/accounts.rs @@ -152,6 +152,7 @@ fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() "/events", "/console-log", "/account", + "/look", ] { assert_eq!( get(&console, path, "").status(), @@ -262,6 +263,65 @@ fn sign_in_guards_pages_streams_and_actions_and_pairs_the_console_log_with_cli() assert_eq!(get(&console, "/", &session).status(), 303); } +#[test] +fn signed_in_look_writes_name_the_account_and_keep_access_settings() { + let home = configured(); + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + let console = Console::start_mode( + Path::new(env!("CARGO_BIN_EXE_thirdshift")), + home.path(), + false, + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let look: Value = get(&console, "/look", &session).json().unwrap(); + for (path, fields) in [ + ("/theme", vec![("theme", "signal-light")]), + ("/sections", vec![("hide", "done")]), + ] { + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .header("cookie", &session) + .form(&fields) + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .header("cookie", &session) + .header("x-csrf-token", look["csrf"].as_str().unwrap()) + .form(&fields) + .send() + .unwrap() + .status(), + 200 + ); + } + let output = cli(home.path(), &["console", "log", "--json"]); + let events: Vec = serde_json::from_slice(&output.stdout).unwrap(); + for kind in ["theme-changed", "sections-changed"] { + let changed: Vec<_> = events + .iter() + .filter(|event| event["event"] == kind) + .collect(); + assert_eq!(changed.len(), 1); + assert_eq!(changed[0]["email"], EMAIL); + } + assert_eq!(get(&console, "/", &session).status(), 200); + let config: Value = serde_json::to_value( + toml::from_str::( + &fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(config["console"]["allowed_emails"], json!([EMAIL])); + assert_eq!(config["console"]["port"], 0); +} + #[test] fn cli_and_allowlist_changes_revoke_http_sessions_without_deleting_the_account() { let home = configured(); diff --git a/tests/console/look.rs b/tests/console/look.rs new file mode 100644 index 00000000..6e10df3c --- /dev/null +++ b/tests/console/look.rs @@ -0,0 +1,119 @@ +use super::*; + +fn cli(home: &Path, arguments: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(arguments) + .env("HOME", home) + .output() + .unwrap() +} + +#[test] +fn look_cli_preserves_other_settings_and_comments() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let path = home.path().join(".thirdshift/config.toml"); + let original = "# my machine\n[console] # reader\nport = 0 # local\nallowed_emails = [\"owner@example.test\"] # access\ntheme = \"focus-auto\" # look\n\n[merge]\nalways = true # keep\n"; + fs::write(&path, original).unwrap(); + let output = cli(home.path(), &["console", "theme", "garden-dark"]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(&path).unwrap(), + original.replace("focus-auto", "garden-dark") + ); + let output = cli( + home.path(), + &[ + "console", "sections", "--hide", "done", "--hide", "resolved", + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let output = cli( + home.path(), + &["console", "sections", "--show", "done", "--json"], + ); + assert!(output.status.success()); + let settings: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(settings["hidden_sections"], json!(["resolved"])); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("allowed_emails = [\"owner@example.test\"] # access")); + assert!(text.contains("always = true # keep")); + let output = cli(home.path(), &["console", "log", "--json"]); + assert!(output.status.success()); + let events: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(events.as_array().unwrap().len(), 3); + assert!( + events + .as_array() + .unwrap() + .iter() + .all(|event| event["email"].as_str().unwrap().starts_with("CLI:")) + ); + let output = cli(home.path(), &["console", "theme", "list", "--json"]); + let themes: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(themes.as_array().unwrap().len(), 18); +} + +#[test] +fn page_can_only_write_look_keys_with_csrf() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let response = console.get("/look"); + assert_eq!(response.status(), 200); + let look: Value = response.json().unwrap(); + let client = reqwest::blocking::Client::new(); + let post = |path: &str, body: &str, csrf: &str| { + client + .post(format!("{}{path}", console.address)) + .header("x-csrf-token", csrf) + .header("content-type", "application/x-www-form-urlencoded") + .body(body.to_owned()) + .send() + .unwrap() + }; + assert_eq!(post("/theme", "theme=press-dark", "wrong").status(), 403); + let csrf = look["csrf"].as_str().unwrap(); + let before = fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(); + for (path, body) in [ + ( + "/theme", + "theme=press-dark&allowed_emails=attacker%40example.test", + ), + ("/sections", "hide=done&port=9000"), + ("/theme", "theme=unknown-dark"), + ("/sections", "hide=unknown"), + ] { + assert!( + post(path, body, csrf).status().is_client_error(), + "{path}: {body}" + ); + assert_eq!( + fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(), + before + ); + } + assert_eq!(post("/theme", "theme=press-dark", csrf).status(), 200); + assert_eq!(post("/sections", "hide=done,resolved", csrf).status(), 200); + let look: Value = console.get("/look").json().unwrap(); + assert_eq!(look["theme"], "press-dark"); + assert_eq!(look["hidden_sections"], json!(["done", "resolved"])); + let output = cli(home.path(), &["console", "log", "--json"]); + let events: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(events.as_array().unwrap().len(), 2); + assert!( + events + .as_array() + .unwrap() + .iter() + .all(|event| event["email"] == "localhost (no sign-in)") + ); +} From 84ed7a1af6e630241d23e66970360ae549328f4a Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:56:29 +0000 Subject: [PATCH 36/47] Address Console Stop review findings and retain regressions --- console/README.md | 4 ++ console/reproductions/session-stop.py | 61 +++++++++++++++++++++++++++ console/tests/logs.test.cjs | 3 ++ src/args.rs | 32 ++++++++------ src/console.rs | 4 ++ src/console/assets/changes.js | 36 ++++++++++++++++ src/console/assets/console.js | 50 ++++++---------------- src/console/assets/stop.js | 42 +++++------------- src/console/logs.html | 2 +- src/console/reader.html | 2 +- src/logs/reading/catalog.rs | 49 +++++++++++++++++---- src/logs/stop.rs | 33 ++++++++++++--- src/main.rs | 10 +++-- tests/stop.rs | 35 +++++++++++++++ 14 files changed, 263 insertions(+), 100 deletions(-) create mode 100644 console/reproductions/session-stop.py create mode 100644 src/console/assets/changes.js diff --git a/console/README.md b/console/README.md index b5db7142..14843534 100644 --- a/console/README.md +++ b/console/README.md @@ -145,6 +145,10 @@ It updates through `/events`, with polling if the stream disconnects. The stopping bar respects reduced motion. HTTP tests use real Commands with a waiting fake Harness and fake GitHub; Playwright checks confirmation, cancellation, live controls and pushed completion on a phone. +`thirdshift stop [--requeue] --json` waits quietly and returns the +outcome, `issue_branches` and `pull_requests` as one JSON object. +On Linux after a build, `python3 console/reproductions/session-stop.py` checks that +a live Session reader's Details link reaches its exact Command's Stop. Claude Edit events include old/new text and show unified diff excerpts. Codex `file_change` events retain only file paths and change kinds, so the reader diff --git a/console/reproductions/session-stop.py b/console/reproductions/session-stop.py new file mode 100644 index 00000000..0d3815cf --- /dev/null +++ b/console/reproductions/session-stop.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""A live Session reader must reach its Command's Stop confirmation.""" +import datetime +import html +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +root = Path(__file__).resolve().parents[2] +with tempfile.TemporaryDirectory(prefix="thirdshift-review-session-stop-") as temporary: + home = Path(temporary) + folder = home / ".thirdshift/logs/acme/widgets/commands/issue" + folder.mkdir(parents=True) + (home / ".thirdshift/config.toml").write_text("[console]\nport = 0\n") + command_log = folder / "7-fixture.log" + session_log = folder / "7-fixture-implement.jsonl" + command_log.write_text("Waiting for Stop\n") + session_log.write_text('{"type":"unfamiliar","text":"waiting"}\n') + stat = Path(f"/proc/{os.getpid()}/stat").read_text().rsplit(") ", 1)[1].split() + identity = Path("/proc/sys/kernel/random/boot_id").read_text().strip() + ":" + stat[19] + record = dict(version=1, kind="Run", name="acme/widgets#7", repository="acme/widgets", + hostname="fixture", issue=dict(number=7, url="https://github.com/acme/widgets/issues/7", title="Live Command"), + process_id=os.getpid(), process_start=identity, harness="codex", + started_at=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ended_at=None, outcome=None, session_log=str(session_log), stages=[]) + (folder / "7-fixture.command.json").write_text(json.dumps(record)) + server = subprocess.Popen([str(root / "target/debug/thirdshift"), "console", "--no-sign-in"], + env={**os.environ, "HOME": str(home)}, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + origin = server.stdout.readline().strip() + assert origin.startswith("http://127.0.0.1:"), origin + def get(route): + try: + with urllib.request.urlopen(origin + route, timeout=10) as response: + return response.status, response.read().decode() + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + status, command = get("/peek?" + urllib.parse.urlencode({"command": str(command_log)})) + assert status == 200 and ">Stop" in command, (status, command) + status, inventory = get("/logs") + assert status == 200 + session_row = next(row for row in re.findall(r"", inventory, re.S) + if "Session log" in row and session_log.name in row) + reader_url = html.unescape(re.search(r'href="([^"]+)"', session_row).group(1)) + status, reader = get(reader_url) + assert status == 200 + details_url = html.unescape(re.search(r'class="peek-link" href="([^"]+)"', reader).group(1)) + status, details = get(details_url) + print(f"Session reader's Command details: HTTP {status}; {details}") + assert status == 200 and ">Stop" in details, ( + "Live Session reader must expose its owning Command's Stop control", status, details) + finally: + server.terminate() + server.communicate(timeout=10) diff --git a/console/tests/logs.test.cjs b/console/tests/logs.test.cjs index 1c3e394e..e60b35dd 100644 --- a/console/tests/logs.test.cjs +++ b/console/tests/logs.test.cjs @@ -59,6 +59,9 @@ test("a live Session appends without redraw, folds across batches, resumes offse await page.goto(`${origin}/logs`); assert.equal(await page.locator(".log-list strong").count(), 2); await page.getByRole("link", { name: /Session log/ }).click(); + await page.getByRole('link', { name: 'Command details', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Close details (Esc)', exact: true }).click(); const initial = await page.locator("#log-content .message").first().elementHandle(); await page.locator(".tool-group").evaluate(node => { node.open = true; }); await appendFile(log, tool("second tool")); diff --git a/src/args.rs b/src/args.rs index bcb15181..9a8bcd14 100644 --- a/src/args.rs +++ b/src/args.rs @@ -44,6 +44,7 @@ pub enum Command { Stop { command: String, requeue: bool, + json: bool, }, Logs { command: String, @@ -298,19 +299,26 @@ pub fn parse(args: &[String]) -> Result { }; } Some("stop") => { - return match &args[1..] { - [command] if !command.starts_with('-') => Ok(Command::Stop { - command: command.clone(), - requeue: false, - }), - [command, flag] if flag == "--requeue" && !command.starts_with('-') => { - Ok(Command::Stop { - command: command.clone(), - requeue: true, - }) - } - _ => bail!("usage: thirdshift stop [--requeue]"), + let usage = "usage: thirdshift stop [--requeue] [--json]"; + let Some((command, options)) = args[1..].split_first() else { + bail!(usage) }; + if command.starts_with('-') { + bail!(usage); + } + let (mut requeue, mut json) = (false, false); + for option in options { + match option.as_str() { + "--requeue" if !requeue => requeue = true, + "--json" if !json => json = true, + _ => bail!(usage), + } + } + return Ok(Command::Stop { + command: command.clone(), + requeue, + json, + }); } Some("show") => { return match &args[1..] { diff --git a/src/console.rs b/src/console.rs index cdf52671..6a723769 100644 --- a/src/console.rs +++ b/src/console.rs @@ -63,6 +63,8 @@ const PAGES: &[PageRoute] = &[ cli: &[ "thirdshift stop widgets#7", "thirdshift stop widgets#7 --requeue", + "thirdshift stop widgets#7 --json", + "thirdshift stop widgets#7 --requeue --json", ], method: Method::GET, view: View::StopPage, @@ -72,6 +74,8 @@ const PAGES: &[PageRoute] = &[ cli: &[ "thirdshift stop widgets#7", "thirdshift stop widgets#7 --requeue", + "thirdshift stop widgets#7 --json", + "thirdshift stop widgets#7 --requeue --json", ], method: Method::POST, view: View::Stop, diff --git a/src/console/assets/changes.js b/src/console/assets/changes.js new file mode 100644 index 00000000..1f3d065f --- /dev/null +++ b/src/console/assets/changes.js @@ -0,0 +1,36 @@ +// One lifecycle for Command notices, coalesced refreshes and polling recovery. +export function watchCommands(update) { + let active = true, refreshing = false, pending = false, polling, stream; + function startPolling() { + if (active) polling ??= setInterval(refresh, 5000); + } + async function refresh() { + if (!active) return; + pending = true; + if (refreshing) return; + refreshing = true; + try { + while (active && pending) { + pending = false; + await update(); + } + } catch { startPolling(); } + finally { refreshing = false; } + } + if (typeof EventSource === 'function') { + stream = new EventSource('/events'); + stream.addEventListener('commands', refresh); + stream.addEventListener('error', startPolling); + stream.addEventListener('open', () => { + clearInterval(polling); + polling = undefined; + refresh(); + }); + } else { startPolling(); } + return () => { + active = false; + pending = false; + stream?.close(); + clearInterval(polling); + }; +} diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 4042c863..b4751d6b 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -1,3 +1,5 @@ +import { watchCommands } from './changes.js'; + // Browser preferences are conveniences; malformed or unavailable storage hides nothing. function stored(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } @@ -90,52 +92,24 @@ function morph(html) { } } -// Coalesce notices, but fetch again if a change arrives during the request. -let refreshing = false, pending = false; async function refresh() { - pending = true; - if (refreshing) return; - refreshing = true; - try { - while (pending) { - pending = false; - if (document.getElementById('inbox')) { - const response = await fetch("/", { cache: "no-store" }); - if (!response.ok) throw new Error("Inbox unavailable"); - morph(await response.text()); - } - await refreshPeek(); - } - } catch { - startPolling(); - } finally { refreshing = false; } -} -let polling; -function startPolling() { - polling ??= setInterval(refresh, 5000); + if (document.getElementById('inbox')) { + const response = await fetch("/", { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + morph(await response.text()); + } + await refreshPeek(); } -let commandStream; +let stopWatching; function startUpdates() { - if (commandStream) return; - if (typeof EventSource === "function") { - commandStream = new EventSource("/events"); - commandStream.addEventListener("commands", refresh); - commandStream.addEventListener("error", startPolling); - commandStream.addEventListener("open", () => { - clearInterval(polling); - polling = undefined; - refresh(); - }); - } else { startPolling(); } + stopWatching ??= watchCommands(refresh); } if (document.getElementById('inbox')) startUpdates(); function stopPeekUpdates() { if (document.getElementById('inbox')) return; - commandStream?.close(); - commandStream = undefined; - clearInterval(polling); - polling = undefined; + stopWatching?.(); + stopWatching = undefined; } const peek = document.querySelector('#peek'); diff --git a/src/console/assets/stop.js b/src/console/assets/stop.js index f1ca5acb..e0e8b00e 100644 --- a/src/console/assets/stop.js +++ b/src/console/assets/stop.js @@ -1,31 +1,11 @@ -// The Stop page shares the Command push stream, with polling after a disconnect. -let refreshing = false, pending = false, polling; -async function refresh() { - pending = true; - if (refreshing) return; - refreshing = true; - try { - while (pending) { - pending = false; - const response = await fetch(location.href, { cache: 'no-store' }); - if (!response.ok || response.redirected) throw new Error('Stop unavailable'); - const next = new DOMParser().parseFromString(await response.text(), 'text/html'); - const replacement = next.getElementById('stop-state'); - if (!replacement) throw new Error('Stop unavailable'); - const current = document.getElementById('stop-state'); - if (current.innerHTML !== replacement.innerHTML) current.replaceWith(replacement); - } - } catch { startPolling(); } - finally { refreshing = false; } -} -function startPolling() { polling ??= setInterval(refresh, 5000); } -if (typeof EventSource === 'function') { - const stream = new EventSource('/events'); - stream.addEventListener('commands', refresh); - stream.addEventListener('error', startPolling); - stream.addEventListener('open', () => { - clearInterval(polling); - polling = undefined; - refresh(); - }); -} else { startPolling(); } +import { watchCommands } from './changes.js'; + +watchCommands(async () => { + const response = await fetch(location.href, { cache: 'no-store' }); + if (!response.ok || response.redirected) throw new Error('Stop unavailable'); + const next = new DOMParser().parseFromString(await response.text(), 'text/html'); + const replacement = next.getElementById('stop-state'); + if (!replacement) throw new Error('Stop unavailable'); + const current = document.getElementById('stop-state'); + if (current.innerHTML !== replacement.innerHTML) current.replaceWith(replacement); +}); diff --git a/src/console/logs.html b/src/console/logs.html index 5122b648..7a3b2653 100644 --- a/src/console/logs.html +++ b/src/console/logs.html @@ -3,6 +3,6 @@ Logs · thirdshift

        thirdshift / Console

        {% include "console/nav.html" %}

        Logs

        thirdshift logs {% if !owner.is_empty() %}--command {{ owner }} {% endif %}--json
        - + {% if logs.is_empty() %}

        No logs found.

        {% endif %}
        {% include "console/chrome.html" %} diff --git a/src/console/reader.html b/src/console/reader.html index 7c2c4ce0..c7eb3ce1 100644 --- a/src/console/reader.html +++ b/src/console/reader.html @@ -3,7 +3,7 @@ {{ log.kind }} · thirdshift

        thirdshift / Console

        {% include "console/nav.html" %}

        {{ log.kind }}

        {{ log.name }} {% if log.live %}live{% endif %}

        thirdshift logs --file {{ log.id }}

        -

        Command details

        +

        Command details

        {% if log.live %}Live{% else %}Complete{% endif %}

        {# Only the conversation renderer's escaped HTML is trusted. #}
        {{ html|safe }}
        diff --git a/src/logs/reading/catalog.rs b/src/logs/reading/catalog.rs index 0e980a12..63573259 100644 --- a/src/logs/reading/catalog.rs +++ b/src/logs/reading/catalog.rs @@ -40,6 +40,16 @@ pub(crate) struct Log { pub owners: Vec, #[serde(skip)] pub path: PathBuf, + #[serde(skip)] + pub selector: String, +} + +#[derive(Default)] +struct Observed { + live: bool, + command: String, + owners: Vec, + selector: String, } pub(crate) fn id(path: &Path) -> String { @@ -59,7 +69,7 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< command: &Command, root: &Path, ancestors: &[String], - observed: &mut BTreeMap)>, + observed: &mut BTreeMap, ) -> Result<()> { let mut owners = ancestors.to_vec(); owners.push(id(command.record_path.strip_prefix(root)?)); @@ -69,15 +79,28 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< .as_ref() .map(|issue| format!("{}#{}", command.record.repository, issue.number)) .unwrap_or_else(|| command.record.name.clone()); - observed.entry(command.command_log.clone()).or_insert(( - command.state == "live", - subject.clone(), - owners.clone(), - )); + let observation = Observation::Record(command); + let selector = observation.selector().context("Command has no selector")?; + observed + .entry(command.command_log.clone()) + .or_insert(Observed { + live: command.state == "live", + command: subject.clone(), + owners: owners.clone(), + selector: selector.clone(), + }); for session in Observation::Record(command).sessions()? { let live = command.state == "live" && command.record.session_log.as_ref() == Some(&session); - observed.insert(session, (live, subject.clone(), owners.clone())); + observed.insert( + session, + Observed { + live, + command: subject.clone(), + owners: owners.clone(), + selector: selector.clone(), + }, + ); } for child in command.dispatched.iter().chain(&command.children) { collect(child, root, &owners, observed)?; @@ -109,7 +132,12 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< continue; } let relative = path.strip_prefix(root)?; - let (live, command, mut owners) = observed.remove(&path).unwrap_or_default(); + let Observed { + live, + command, + mut owners, + selector, + } = observed.remove(&path).unwrap_or_default(); if let Some(decoded) = owner::decode(&path) { owners.push(id(decoded.command_log(&path).strip_prefix(root)?)); } @@ -125,6 +153,11 @@ pub(crate) fn load(config: &UserConfig, selected_owner: Option<&str>) -> Result< live, command, owners, + selector: if selector.is_empty() { + path.to_string_lossy().into_owned() + } else { + selector + }, path, }); } diff --git a/src/logs/stop.rs b/src/logs/stop.rs index 6ed1c1a3..474fb3e4 100644 --- a/src/logs/stop.rs +++ b/src/logs/stop.rs @@ -66,7 +66,7 @@ fn require_live(record: &CommandRecord) -> Result<()> { Ok(()) } -pub fn stop(input: &str, requeue: bool) -> Result<()> { +pub fn stop(input: &str, requeue: bool, json: bool) -> Result<()> { let user = std::process::Command::new("id") .arg("-un") .output() @@ -84,6 +84,7 @@ pub fn stop(input: &str, requeue: bool) -> Result<()> { &path, &mut WallClock(Instant::now()), &mut std::io::stdout(), + json, ) } @@ -168,15 +169,15 @@ impl Clock for WallClock { } } -fn wait(path: &Path, clock: &mut impl Clock, output: &mut impl Write) -> Result<()> { +fn wait(path: &Path, clock: &mut impl Clock, output: &mut impl Write, json: bool) -> Result<()> { let mut reminder = clock.elapsed(); loop { let tree = read(path)?; if tree.command.outcome.is_some() || !tree.command.live(&mut OnMachine) { - print_ending(path, &tree, output)?; + print_ending(path, &tree, output, json)?; return Ok(()); } - if clock.elapsed().saturating_sub(reminder) >= Duration::from_secs(60) { + if !json && clock.elapsed().saturating_sub(reminder) >= Duration::from_secs(60) { writeln!(output, "{} still ending", tree.command.name)?; output.flush()?; reminder = clock.elapsed(); @@ -185,7 +186,27 @@ fn wait(path: &Path, clock: &mut impl Clock, output: &mut impl Write) -> Result< } } -fn print_ending(path: &Path, tree: &CommandTree, output: &mut impl Write) -> Result<()> { +fn print_ending( + path: &Path, + tree: &CommandTree, + output: &mut impl Write, + json: bool, +) -> Result<()> { + let work = reading::retained_work(path, chrono::Local::now())?; + if json { + writeln!( + output, + "{}", + serde_json::json!({ + "version": 1, + "name": tree.command.name, + "outcome": tree.command.outcome.as_deref().unwrap_or("died before recording an outcome"), + "issue_branches": work.branches, + "pull_requests": work.pull_requests, + }) + )?; + return Ok(()); + } writeln!( output, "{}: {}", @@ -195,7 +216,6 @@ fn print_ending(path: &Path, tree: &CommandTree, output: &mut impl Write) -> Res .as_deref() .unwrap_or("died before recording an outcome") )?; - let work = reading::retained_work(path, chrono::Local::now())?; for branch in work.branches { writeln!(output, "Issue branch left: {branch}")?; } @@ -262,6 +282,7 @@ mod tests { path: path.clone(), }, &mut output, + false, ) .unwrap(); assert_eq!( diff --git a/src/main.rs b/src/main.rs index eb83ef0f..ffe9e984 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,7 +83,7 @@ usage: thirdshift Run the factory on the iss thirdshift console sign-out [--email
        ] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log thirdshift show [--json] Show one Command in full - thirdshift stop [--requeue] Stop a live Command and wait for cleanup + thirdshift stop [--requeue] [--json] Stop a live Command and wait for cleanup thirdshift logs [--command ] [--json] List this machine's logs thirdshift logs --file [--follow] Read an exact listed log thirdshift logs [--session ] [--follow] Read or follow a log @@ -158,8 +158,12 @@ fn main() -> ExitCode { Err(error) => failure(&error), }; } - Ok(Command::Stop { command, requeue }) => { - return match logs::stop::stop(&command, requeue) { + Ok(Command::Stop { + command, + requeue, + json, + }) => { + return match logs::stop::stop(&command, requeue, json) { Ok(()) => ExitCode::SUCCESS, Err(error) => failure(&error), }; diff --git a/tests/stop.rs b/tests/stop.rs index 2216df4d..58971bf1 100644 --- a/tests/stop.rs +++ b/tests/stop.rs @@ -14,6 +14,41 @@ fn stop_and_finish(scenario: &Scenario, mut held: HeldRun, args: &[&str]) -> Run stopped } +#[test] +fn json_stop_reports_the_outcome_and_retained_work_for_both_endings() { + for requeue in [false, true] { + let scenario = Scenario::new(); + scenario.issue_labelled(7, &["ready-for-agent"]); + scenario.agent_does(&format!( + "echo unfinished > work.txt\ntouch '{}'\nwhile true; do sleep 0.05; done\n", + scenario.path("started").display() + )); + let held = scenario.run_until(&[&scenario.issue_url(7)], &[], "started"); + let args = if requeue { + vec!["stop", "widgets#7", "--requeue", "--json"] + } else { + vec!["stop", "widgets#7", "--json"] + }; + let stopped = stop_and_finish(&scenario, held, &args); + let output: serde_json::Value = serde_json::from_str(&stopped.stdout).unwrap(); + assert_eq!( + output, + serde_json::json!({ + "version": 1, "name": "acme/widgets#7", "outcome": "interrupted", + "issue_branches": ["issue-7"], "pull_requests": [] + }) + ); + assert_eq!( + scenario.issue_labels(7), + if requeue { + vec!["ready-for-agent"] + } else { + vec!["in-progress", "needs-triage"] + } + ); + } +} + #[test] fn stopping_a_pass_with_no_issue_ends_it_without_changing_labels() { for kind in ["architect", "secure"] { From ed0ecd78528b5cccd7084ebfd2a571abcee58b42 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 06:58:02 +0000 Subject: [PATCH 37/47] Fix reviewed Theme tags and time-page activity motion --- console/tests/inbox.test.cjs | 1 + console/tests/themes-regressions.test.cjs | 83 ++++++++++++++++++++++ src/console/assets/console.css | 12 ++-- src/console/assets/console.js | 30 ++++++-- src/console/assets/themes.css | 84 +++++++++++++++++++---- src/console/time.html | 7 +- src/console/waterfall.html | 3 +- src/logs/status/time.rs | 26 +++++-- 8 files changed, 214 insertions(+), 32 deletions(-) create mode 100644 console/tests/themes-regressions.test.cjs diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs index a5ba35e5..dee6795f 100644 --- a/console/tests/inbox.test.cjs +++ b/console/tests/inbox.test.cjs @@ -71,6 +71,7 @@ test("Shift+T switches all built-in Themes and saves hidden sections across brow await page.goto(origin); await page.keyboard.press('Shift+T'); await page.locator('#themes[open]').waitFor({ timeout: 2000 }); + assert.equal(await page.locator('#look-status').textContent(), ''); try { for (const family of ['focus', 'plain', 'press', 'signal', 'garden', 'factory']) { for (const mode of ['auto', 'light', 'dark']) { diff --git a/console/tests/themes-regressions.test.cjs b/console/tests/themes-regressions.test.cjs new file mode 100644 index 00000000..c53f2ed8 --- /dev/null +++ b/console/tests/themes-regressions.test.cjs @@ -0,0 +1,83 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, rm } = require('node:fs/promises'); +const { join, resolve } = require('node:path'); +const { tmpdir } = require('node:os'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); +const { test } = require('node:test'); +const fs = require('node:fs'); + +test('state tag aliases resolve at the command that supplies their state', async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const assets = resolve(__dirname, '../../src/console/assets'); + const css = fs.readFileSync(join(assets, 'themes.css'), 'utf8') + fs.readFileSync(join(assets, 'console.css'), 'utf8').replace('@import url("/assets/themes.css");', ''); + await page.setContent(`
      2. Failed
      3. `); + const actual = await page.locator('.state').evaluate(el => ({ background: getComputedStyle(el).backgroundColor, color: getComputedStyle(el).color })); + assert.equal(actual.background, 'rgb(236, 0, 140)', 'Press Failed tags should use --urgent as their solid background'); + assert.equal(actual.color, 'rgb(0, 0, 0)', 'Press Failed tags should use --on-urgent text'); + } finally { await browser.close(); } +}); +async function reproduce(mode) { + const home = await mkdtemp(join(tmpdir(), 'thirdshift824-spec-')); + let server, browser, exited; + try { + const root = join(home, '.thirdshift'); + const records = join(root, 'logs/acme/widgets/commands/issue'); + await mkdir(records, { recursive: true }); + await writeFile(join(root, 'config.toml'), `[console]\nport = 0\ntheme = "${mode === 'tags' ? 'press-light' : 'garden-light'}"\n`); + const stat = await readFile(`/proc/${process.pid}/stat`, 'utf8'); + const start = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]}`; + const began = new Date(Date.now() - 60_000).toISOString(); + const record = {version: 1, kind: 'Run', name: '1-fixture', repository: 'acme/widgets', hostname: 'fixture', issue: { number: 1, url: 'https://github.com/acme/widgets/issues/1', title: 'Fixture'}, process_id: process.pid, process_start: start, harness: 'codex', started_at: began, ended_at: mode === 'tags' ? new Date().toISOString() : null, outcome: mode === 'tags' ? 'failed' : null, session_log: null, stages: [{name: 'implement', began_at: began, session_log: null}]}; + const recordPath = join(records, '1-fixture.command.json'); + await writeFile(recordPath, JSON.stringify(record)); + await writeFile(join(records, '1-fixture.log'), 'Command output\n'); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], {env: {...process.env, HOME: home}, stdio: ['ignore', 'pipe', 'inherit']}); + exited = once(server, 'exit'); + const lines = createInterface({input: server.stdout}); + const [origin] = await once(lines, 'line', {signal: AbortSignal.timeout(10_000)}); + lines.close(); + browser = await chromium.launch({headless: true}); + if (mode === 'tags') { + const page = await browser.newPage(); + await page.goto(origin); + const tag = await page.locator('.command[data-state="Failed"] .state').first().evaluate(el => { + const style = getComputedStyle(el); + return {background: style.backgroundColor, color: style.color}; + }); + console.log({tag}); + assert.deepEqual(tag, {background: 'rgb(236, 0, 140)', color: 'rgb(0, 0, 0)'}, 'Press solid urgent tag must render the Theme token fill and its best black/white text'); + } else { + const pages = []; + for (const name of ['lanes', 'stages', 'waterfall']) { + const page = await browser.newPage(); + await page.goto(`${origin}/?page=${name}`); + await page.waitForFunction(() => document.documentElement.dataset.live === 'true'); + assert.ok(await page.evaluate(() => document.getAnimations().some(a => a.playState === 'running')), `${name} starts activity motion`); + pages.push([name, page]); + } + record.ended_at = new Date().toISOString(); record.outcome = 'merged'; + await writeFile(recordPath, JSON.stringify(record)); + const fresh = await (await fetch(`${origin}/?page=lanes`)).text(); + assert.ok(!fresh.includes('data-live="true"'), 'server now observes no live Command'); + await pages[0][1].waitForTimeout(6500); + const moving = []; + for (const [name, page] of pages) if (await page.evaluate(() => document.getAnimations().some(a => a.playState === 'running'))) moving.push(name); + console.log({moving}); + assert.deepEqual(moving, [], 'motion must stop after the only Command ends'); + } + } finally { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + if (exited) await exited; + await rm(home, {recursive: true, force: true}); + } +} + +test('time-page motion stops after the last Command ends', { skip: process.platform !== 'linux' }, () => reproduce('motion')); +test('served solid tags use their Theme fill and black or white text', { skip: process.platform !== 'linux' }, () => reproduce('tags')); + diff --git a/src/console/assets/console.css b/src/console/assets/console.css index bdd114c6..057084f7 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -58,12 +58,12 @@ dd dl { border-left: 1px solid var(--rule); padding-left: 8px; } dialog::backdrop { background: #0006; } footer { margin-top: 16px; } #section-options label { display: block; margin: 8px 0; } -.command { box-shadow: var(--panel-shadow); --state-ink: var(--done); --state-on: var(--on-done); } -.command[data-state="live"] { --state-ink: var(--working); --state-on: var(--on-working); } -.command[data-state="Landing"] { --state-ink: var(--landing); --state-on: var(--on-landing); } -.command[data-state="Ready"] { --state-ink: var(--ready); --state-on: var(--on-ready); } -.command:is([data-state="Silent"], [data-state="Died"], [data-state="Failed"], [data-state="Finding"], [data-state="Triage"]) { --state-ink: var(--urgent); --state-on: var(--on-urgent); } -.command[data-state="Slow"] { --state-ink: var(--look); --state-on: var(--on-look); } +.command { box-shadow: var(--panel-shadow); --tag-bg: var(--tag-done-bg); --tag-text: var(--tag-done-text); } +.command[data-state="live"] { --tag-bg: var(--tag-working-bg); --tag-text: var(--tag-working-text); } +.command[data-state="Landing"] { --tag-bg: var(--tag-landing-bg); --tag-text: var(--tag-landing-text); } +.command[data-state="Ready"] { --tag-bg: var(--tag-ready-bg); --tag-text: var(--tag-ready-text); } +.command:is([data-state="Silent"], [data-state="Died"], [data-state="Failed"], [data-state="Finding"], [data-state="Triage"]) { --tag-bg: var(--tag-urgent-bg); --tag-text: var(--tag-urgent-text); } +.command[data-state="Slow"] { --tag-bg: var(--tag-look-bg); --tag-text: var(--tag-look-text); } .command .state { background: var(--tag-bg); color: var(--tag-text); border-radius: var(--radius); padding: 0 3px; } .command[data-live="true"][data-state="live"] > .row-head > .state::before { content: ""; display: inline-block; width: 9px; height: 9px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; margin-right: 5px; } header::after { content: ""; display: block; height: 4px; background: var(--activity-decoration); background-size: 24px 4px; } diff --git a/src/console/assets/console.js b/src/console/assets/console.js index 6c52dad4..73a3362d 100644 --- a/src/console/assets/console.js +++ b/src/console/assets/console.js @@ -97,6 +97,25 @@ function morph(html) { } // Coalesce notices, but fetch again if a change arrives during the request. +const timePage = document.querySelector('main[data-time-page]'); +function morphTimePage(html) { + const next = new DOMParser().parseFromString(html, 'text/html'); + const replacement = next.querySelector('main[data-time-page]'); + if (!replacement) throw new Error('Missing time page'); + const selection = selected?.dataset.command; + const focused = timePage.contains(document.activeElement); + const position = [scrollX, scrollY]; + Idiomorph.morph(timePage, replacement); + if (selection) { + const row = [...timePage.querySelectorAll('.command')].find(row => row.dataset.command === selection); + select(row, { scroll: false }); + if (focused) row?.focus({ preventScroll: true }); + } + window.scrollTo(...position); + activity(); + loadLook(); +} + let refreshing = false, pending = false; async function refresh() { pending = true; @@ -105,9 +124,11 @@ async function refresh() { try { while (pending) { pending = false; - const response = await fetch("/", { cache: "no-store" }); + const response = await fetch(timePage ? location.href : '/', { cache: "no-store" }); if (!response.ok) throw new Error("Inbox unavailable"); - morph(await response.text()); + const html = await response.text(); + if (timePage) morphTimePage(html); + else morph(html); } } catch { startPolling(); @@ -117,7 +138,8 @@ let polling; function startPolling() { polling ??= setInterval(refresh, 5000); } -if (document.getElementById('inbox') && typeof EventSource === "function") { +const observesCommands = document.getElementById('inbox') || timePage; +if (observesCommands && typeof EventSource === "function") { const stream = new EventSource("/events"); stream.addEventListener("commands", refresh); stream.addEventListener("error", startPolling); @@ -126,7 +148,7 @@ if (document.getElementById('inbox') && typeof EventSource === "function") { polling = undefined; refresh(); }); -} else if (document.getElementById('inbox')) { startPolling(); } +} else if (observesCommands) { startPolling(); } const peek = document.querySelector('#peek'); const content = document.querySelector('#peek-content'); diff --git a/src/console/assets/themes.css b/src/console/assets/themes.css index 10af1e39..a4e26ad2 100644 --- a/src/console/assets/themes.css +++ b/src/console/assets/themes.css @@ -49,8 +49,18 @@ --head-weight: 600; --head-case: none; --radius: 4px; - --tag-bg: transparent; - --tag-text: var(--text); + --tag-urgent-bg: transparent; + --tag-urgent-text: var(--urgent-text); + --tag-look-bg: transparent; + --tag-look-text: var(--text); + --tag-working-bg: transparent; + --tag-working-text: var(--text); + --tag-landing-bg: transparent; + --tag-landing-text: var(--text); + --tag-ready-bg: transparent; + --tag-ready-text: var(--text); + --tag-done-bg: transparent; + --tag-done-text: var(--text); --activity-decoration: none; --activity-speed: 2s; --panel-shadow: none; @@ -89,8 +99,18 @@ --head-weight: 600; --head-case: uppercase; --radius: 6px; - --tag-bg: transparent; - --tag-text: var(--text); + --tag-urgent-bg: transparent; + --tag-urgent-text: var(--urgent-text); + --tag-look-bg: transparent; + --tag-look-text: var(--text); + --tag-working-bg: transparent; + --tag-working-text: var(--text); + --tag-landing-bg: transparent; + --tag-landing-text: var(--text); + --tag-ready-bg: transparent; + --tag-ready-text: var(--text); + --tag-done-bg: transparent; + --tag-done-text: var(--text); --activity-decoration: none; --activity-speed: 2s; --panel-shadow: none; @@ -129,8 +149,18 @@ --head-weight: 600; --head-case: none; --radius: 0px; - --tag-bg: var(--state-ink); - --tag-text: var(--state-on); + --tag-urgent-bg: var(--urgent); + --tag-urgent-text: var(--on-urgent); + --tag-look-bg: var(--look); + --tag-look-text: var(--on-look); + --tag-working-bg: var(--working); + --tag-working-text: var(--on-working); + --tag-landing-bg: var(--landing); + --tag-landing-text: var(--on-landing); + --tag-ready-bg: var(--ready); + --tag-ready-text: var(--on-ready); + --tag-done-bg: var(--done); + --tag-done-text: var(--on-done); --activity-decoration: none; --activity-speed: 2s; --panel-shadow: none; @@ -169,8 +199,18 @@ --head-weight: 700; --head-case: none; --radius: 4px; - --tag-bg: var(--state-ink); - --tag-text: var(--state-on); + --tag-urgent-bg: var(--urgent); + --tag-urgent-text: var(--on-urgent); + --tag-look-bg: var(--look); + --tag-look-text: var(--on-look); + --tag-working-bg: var(--working); + --tag-working-text: var(--on-working); + --tag-landing-bg: var(--landing); + --tag-landing-text: var(--on-landing); + --tag-ready-bg: var(--ready); + --tag-ready-text: var(--on-ready); + --tag-done-bg: var(--done); + --tag-done-text: var(--on-done); --activity-decoration: none; --activity-speed: 2s; --panel-shadow: none; @@ -209,8 +249,18 @@ --head-weight: 400; --head-case: none; --radius: 8px; - --tag-bg: var(--state-ink); - --tag-text: var(--state-on); + --tag-urgent-bg: var(--urgent); + --tag-urgent-text: var(--on-urgent); + --tag-look-bg: var(--look); + --tag-look-text: var(--on-look); + --tag-working-bg: var(--working); + --tag-working-text: var(--on-working); + --tag-landing-bg: var(--landing); + --tag-landing-text: var(--on-landing); + --tag-ready-bg: var(--ready); + --tag-ready-text: var(--on-ready); + --tag-done-bg: var(--done); + --tag-done-text: var(--on-done); --activity-decoration: repeating-linear-gradient(90deg, var(--working) 0 6px, transparent 6px 12px); --activity-speed: 5s; --activity-motion: activity-drift var(--activity-speed) linear infinite; @@ -250,8 +300,18 @@ --head-weight: 700; --head-case: uppercase; --radius: 2px; - --tag-bg: var(--state-ink); - --tag-text: var(--state-on); + --tag-urgent-bg: var(--urgent); + --tag-urgent-text: var(--on-urgent); + --tag-look-bg: var(--look); + --tag-look-text: var(--on-look); + --tag-working-bg: var(--working); + --tag-working-text: var(--on-working); + --tag-landing-bg: var(--landing); + --tag-landing-text: var(--on-landing); + --tag-ready-bg: var(--ready); + --tag-ready-text: var(--on-ready); + --tag-done-bg: var(--done); + --tag-done-text: var(--on-done); --activity-decoration: repeating-linear-gradient(90deg, var(--working) 0 6px, transparent 6px 12px); --activity-speed: 2s; --panel-shadow: inset 0 1px #ffffff33, inset 0 -1px #00000033; diff --git a/src/console/time.html b/src/console/time.html index f354d7d4..8c6e0d1f 100644 --- a/src/console/time.html +++ b/src/console/time.html @@ -6,6 +6,7 @@ {{ title }} · {{ hostname }} · thirdshift + @@ -13,7 +14,7 @@ {% if !consoles.is_empty() %}{% endif %} {% include "console/nav.html" %} -
        +

        {{ title }}

        thirdshift status --all --json
        {% if title == "Lanes" %}
        @@ -32,7 +33,7 @@

        {{ run.subject }} · {{ r {% for stage in run.stages %}{{ stage.name }}{% endfor %} {% if run.stages.is_empty() %}

        No stage times retained.

        {% endif %} -
          {% for stage in run.stages %}
        1. {{ stage.name }}: {{ stage.text }}
        2. {% endfor %}
        +
          {% for stage in run.stages %}
        1. {{ stage.name }}: {{ stage.text }}
        2. {% endfor %}
        {% endfor %}

        @@ -44,7 +45,7 @@

        {{ run.subject }} · {{ r {% for run in grid.runs %}{% endfor %} - {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %} + {% for row in grid.rows %}{% for cell in row.cells %}{% endfor %}{% endfor %}
        {{ grid.repository }}
        Stage
        Usual range
        {{ run.subject }}
        {{ run.state }}
        {{ run.title }}
        {{ row.name }}
        Usual range: {{ row.usual }}
        {{ grid.runs[loop.index0].subject }}: {{ cell }}
        {{ row.name }}
        Usual range: {{ row.usual }}
        {{ grid.runs[loop.index0].subject }}: {{ cell.text }}
        {% if grid.rows.is_empty() %}

        No stage times retained.

        {% endif %} {% endfor %} diff --git a/src/console/waterfall.html b/src/console/waterfall.html index c08d8cb7..8977c15d 100644 --- a/src/console/waterfall.html +++ b/src/console/waterfall.html @@ -6,6 +6,7 @@ Waterfall · {{ hostname }} · thirdshift + @@ -13,7 +14,7 @@ {% if !consoles.is_empty() %}{% endif %} {% include "console/nav.html" %} -
        +

        Waterfall

        thirdshift show <command> --json · thirdshift logs <command>
        {% if view.runs.is_empty() %}

        No Commands yet.

        {% else %} diff --git a/src/logs/status/time.rs b/src/logs/status/time.rs index 297bbb5d..c719b188 100644 --- a/src/logs/status/time.rs +++ b/src/logs/status/time.rs @@ -32,6 +32,7 @@ pub(crate) struct Run { #[derive(Clone)] pub(crate) struct Stage { pub name: String, + pub running: bool, pub text: String, pub seconds: String, pub usual: String, @@ -48,7 +49,12 @@ pub(crate) struct Grid { pub(crate) struct GridRow { pub name: String, pub usual: String, - pub cells: Vec, + pub cells: Vec, +} + +pub(crate) struct Cell { + pub text: String, + pub running: bool, } fn timestamp(value: &str) -> Option { @@ -100,6 +106,7 @@ fn run(command: &Command, now: DateTime, cutoff: i64) -> Run { }); Stage { name: stage.name.clone(), + running: command.state == "live" && index + 1 == record.stages.len(), text: format!("{state} · {duration}"), seconds: seconds .map(|seconds| seconds.to_string()) @@ -237,18 +244,25 @@ pub(crate) fn load(config: &UserConfig, hours: f64) -> Result { cells: batch .iter() .map(|run| { - let times: Vec<_> = run + let stages: Vec<_> = run .stages .iter() .filter(|stage| &stage.name == name) - .map(|stage| stage.text.as_str()) .collect(); - if !run.stage_metadata { + let text = if !run.stage_metadata { "Unknown · no stage times retained".into() - } else if times.is_empty() { + } else if stages.is_empty() { "Not reached · —".into() } else { - times.join("; ") + stages + .iter() + .map(|stage| stage.text.as_str()) + .collect::>() + .join("; ") + }; + Cell { + text, + running: stages.iter().any(|stage| stage.running), } }) .collect(), From 71a07b2d2b7f11cc204e70514a9b1f25a149b34f Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 07:00:15 +0000 Subject: [PATCH 38/47] Trim trailing blank line in Theme regression tests --- console/tests/themes-regressions.test.cjs | 1 - 1 file changed, 1 deletion(-) diff --git a/console/tests/themes-regressions.test.cjs b/console/tests/themes-regressions.test.cjs index c53f2ed8..c89468ba 100644 --- a/console/tests/themes-regressions.test.cjs +++ b/console/tests/themes-regressions.test.cjs @@ -80,4 +80,3 @@ async function reproduce(mode) { test('time-page motion stops after the last Command ends', { skip: process.platform !== 'linux' }, () => reproduce('motion')); test('served solid tags use their Theme fill and black or white text', { skip: process.platform !== 'linux' }, () => reproduce('tags')); - From c7062744d23db34ffc3847d3803d5c889187884a Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 07:05:11 +0000 Subject: [PATCH 39/47] Keep the complete Setup fixture current with Console look defaults --- src/setup.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/setup.rs b/src/setup.rs index 6099e6d0..780fe7c6 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1054,6 +1054,7 @@ port = 8711 allowed_emails = [] other_consoles = [] theme = \"focus-auto\" +hidden_sections = [] [activity] quiet_skips = true From 1a0953466e5ecdcec2a6e995bf27240c6420a06a Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 08:04:23 +0000 Subject: [PATCH 40/47] Print owner-run Console service and proxy installation scripts (#828) --- README.md | 53 ++++++++ console/README.md | 4 + docs/code-navigation.md | 1 + src/args.rs | 4 +- src/config.rs | 2 + src/config/console.rs | 29 +++++ src/console.rs | 2 + src/console/install.rs | 220 +++++++++++++++++++++++++++++++ src/main.rs | 10 ++ src/setup.rs | 1 + tests/console_install.rs | 274 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 src/console/install.rs create mode 100644 tests/console_install.rs diff --git a/README.md b/README.md index 541bcc5f..303f4aa1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,59 @@ Use `thirdshift help` for the command reference, or read [Run behavior](docs/gui The operating system schedules Architect, Pickup and Security runs. See [scheduling](docs/guide.md#on-a-schedule), [Weeding](docs/guide.md#weeding) and [Fencing](docs/guide.md#fencing). +## Console on a server + +Set the machine's address, fixed localhost port and sign-in allowlist in `~/.thirdshift/config.toml`: + +```toml +[console] +address = "https://console.example.com" # a DNS name, with or without https://; no port or path +port = 8711 +allowed_emails = ["you@example.com"] +``` + +Point that DNS name at the machine, then run these commands as the owner: + +```sh +thirdshift console password --email you@example.com +thirdshift console install > install-console.sh +# Read the script, then install it: +sudo sh install-console.sh +``` + +`console install` only prints a script. On Linux it prefers a running Caddy or Apache service; otherwise it checks for `caddy`, `apache2ctl` or `httpd` on PATH. The script writes `thirdshift-console.service`, running the installed binary as you with your HOME and automatic restarts. Only the script needs root. It enables the service, then restarts the proxy. + +For Caddy it writes `/etc/caddy/sites/thirdshift-console.caddy`, imports the sites folder from `/etc/caddy/Caddyfile` if needed, and disables proxy buffering with `flush_interval -1`. Caddy handles HTTPS. It **restarts**, rather than reloads, Caddy to preserve HTTP/3 on Android. For Apache it writes the port-80 site under `/etc/apache2/sites-available` or `/etc/httpd/conf.d`, preserves Host, forwards the request protocol and disables gzip on the Console's event streams. Install certbot with its Apache plugin first; the script runs `certbot --apache -d `. Debian's module/site helpers are included. On an `httpd` machine, enable `proxy`, `proxy_http`, `headers`, `ssl` and `setenvif` and permit proxy connections in the host's SELinux policy first. + +With neither proxy, the script installs a localhost-only service with `--no-sign-in` and prints an SSH tunnel command. No address or accounts are needed for that mode. Run the tunnel on your computer and open the printed `http://localhost:` address. On other operating systems, run `thirdshift console --no-sign-in` directly and use the same tunnel; only the Linux installer assumes systemd. + +### nginx + +For nginx, use the generated service but remove `--no-sign-in` from its `ExecStart` before installing it. Configure the allowlist and password above. Obtain an HTTPS certificate for your DNS name, then add a site like this, adjusting the name, certificate paths and port: + +```nginx +server { + listen 443 ssl; + server_name console.example.com; + ssl_certificate /etc/letsencrypt/live/console.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/console.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8711; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_read_timeout 1h; + } +} +``` + +Run `sudo nginx -t`, then `sudo systemctl restart nginx` on a systemd host. The Console still binds only to 127.0.0.1; nginx terminates HTTPS and passes live updates without buffering. + ## Documentation - [Configuration](docs/guide.md#user-config), [Harness, Model and Effort](docs/guide.md#harness-model-and-effort), and [email notifications](docs/guide.md#email). diff --git a/console/README.md b/console/README.md index c0421ac9..545bc526 100644 --- a/console/README.md +++ b/console/README.md @@ -21,12 +21,16 @@ stdout. To require sign-in, set the allowlist in `~/.thirdshift/config.toml`: ```toml [console] +address = "https://console.example.com" # used by console install port = 8711 allowed_emails = ["you@example.com"] other_consoles = ["https://console.example.test"] theme = "focus-auto" # also focus-light or focus-dark ``` +For a persistent service behind Caddy, Apache or nginx, see the root README's +[server installation instructions](../README.md#console-on-a-server). + Run `thirdshift console password --email you@example.com` in a terminal to set the password, then `thirdshift console`. Password entry is hidden and asks twice. With exactly one allowed address, `--email` can be omitted. An empty diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 7ae9c1d7..86c3cfcd 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -27,6 +27,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Command naming, Session selection, show, logs | `src/logs/reading/selection.rs` — resolution, captured outer owner and child-refusal target, Session inventory/selection and bounded latest events; `src/logs/status/inspect.rs` — formatting and log streaming | `src/logs/reading/tests.rs`, `tests/show_logs.rs` | | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` | +| Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | diff --git a/src/args.rs b/src/args.rs index bcb15181..1f2f772a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,6 +25,7 @@ pub enum Command { Console { no_sign_in: bool, }, + ConsoleInstall, ConsolePassword(Option), ConsoleSignOut { email: Option, @@ -282,6 +283,7 @@ pub fn parse(args: &[String]) -> Result { return match &args[1..] { [] => Ok(Command::Console { no_sign_in: false }), [flag] if flag == "--no-sign-in" => Ok(Command::Console { no_sign_in: true }), + [action] if action == "install" => Ok(Command::ConsoleInstall), [action] if action == "password" => Ok(Command::ConsolePassword(None)), [action, flag, email] if action == "password" && flag == "--email" && !email.starts_with('-') => @@ -293,7 +295,7 @@ pub fn parse(args: &[String]) -> Result { Ok(Command::ConsoleLog { json: true }) } _ => bail!( - "usage: thirdshift console [--no-sign-in] | password [--email
        ] | sign-out [--email
        ] [--json] | log [--json]" + "usage: thirdshift console [--no-sign-in] | install | password [--email
        ] | sign-out [--email
        ] [--json] | log [--json]" ), }; } diff --git a/src/config.rs b/src/config.rs index d4818803..b2843dcc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -790,6 +790,7 @@ days = 7 # days to keep ended problems and Resolved; default 7 silent_minutes = 10 # minutes without growth in either log before a Run is Silent; default 10 [console] +address = "" # HTTPS address for console install, e.g. https://console.example.com; default blank port = 8711 # localhost port for the Console; 0 chooses a free port; default 8711 allowed_emails = [] # addresses that may sign in; empty requires --no-sign-in; default [] other_consoles = [] # http or https addresses linked in the header, never fetched; default [] @@ -973,6 +974,7 @@ mod tests { "logs.waiting_line_minutes", "inbox.days", "inbox.silent_minutes", + "console.address", "console.port", "console.allowed_emails", "console.other_consoles", diff --git a/src/config/console.rs b/src/config/console.rs index 7269c2f8..85aea889 100644 --- a/src/config/console.rs +++ b/src/config/console.rs @@ -5,6 +5,7 @@ use serde::Deserialize; #[derive(Debug, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ConsoleSettings { + pub address: String, pub port: u16, pub allowed_emails: Vec, pub other_consoles: Vec, @@ -14,6 +15,7 @@ pub struct ConsoleSettings { impl Default for ConsoleSettings { fn default() -> Self { Self { + address: String::new(), port: 8711, allowed_emails: Vec::new(), other_consoles: Vec::new(), @@ -23,7 +25,34 @@ impl Default for ConsoleSettings { } impl ConsoleSettings { + /// The HTTPS site's DNS name, accepted with or without `https://`. + pub fn hostname(&self) -> Result<&str> { + let host = self + .address + .strip_prefix("https://") + .unwrap_or(&self.address); + let host = host.strip_suffix('/').unwrap_or(host); + ensure!( + !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }), + "console.address must be an HTTPS DNS name, such as https://console.example.com, without a port or path" + ); + Ok(host) + } + pub(super) fn validate(&self) -> Result<()> { + if !self.address.is_empty() { + self.hostname()?; + } for email in &self.allowed_emails { ensure!( email.contains('@') && !email.chars().any(char::is_whitespace), diff --git a/src/console.rs b/src/console.rs index 93b8f6d6..85dbe1ee 100644 --- a/src/console.rs +++ b/src/console.rs @@ -4,6 +4,8 @@ mod accounts; mod auth; mod conversation; pub use account_commands::{password, print_log, sign_out}; +mod install; +pub use install::install; mod live; mod logs; mod peek; diff --git a/src/console/install.rs b/src/console/install.rs new file mode 100644 index 00000000..62baec89 --- /dev/null +++ b/src/console/install.rs @@ -0,0 +1,220 @@ +//! Only this command knows about systemd and the machine's proxy layout. +//! It reads local settings and prints a script; it never installs anything. +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; + +use crate::config::{self, UserConfig}; + +enum Proxy { + Caddy, + Apache(ApacheLayout), +} + +enum ApacheLayout { + Debian, + RedHat, +} + +impl Proxy { + fn detect() -> Option { + // Prefer a running server when both packages are installed. On a + // machine without a running systemd manager, fall back to PATH. + for (service, proxy) in [ + ("caddy", Self::Caddy), + ("apache2", Self::Apache(ApacheLayout::Debian)), + ("httpd", Self::Apache(ApacheLayout::RedHat)), + ] { + if Command::new("systemctl") + .args(["is-active", "--quiet", service]) + .output() + .is_ok_and(|output| output.status.success()) + { + return Some(proxy); + } + } + if available("caddy") { + Some(Self::Caddy) + } else if available("apache2ctl") { + Some(Self::Apache(ApacheLayout::Debian)) + } else if available("httpd") { + Some(Self::Apache(ApacheLayout::RedHat)) + } else { + None + } + } +} + +pub fn install() -> Result { + ensure!( + cfg!(target_os = "linux"), + "console install currently supports Linux; run thirdshift console manually on this platform" + ); + ensure!( + identity("-u")? != "0", + "run thirdshift console install as the owner, without sudo; only the printed script needs sudo" + ); + let owner = identity("-un")?; + ensure!( + owner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)), + "unsupported owner name" + ); + let config = UserConfig::load()?; + ensure!( + config.console.port != 0, + "console install needs a fixed, nonzero console.port" + ); + let home = config::home()?; + ensure!(home.is_absolute(), "HOME must be an absolute path"); + let binary = std::env::current_exe().context("can't locate thirdshift")?; + let proxy = Proxy::detect(); + let mode = if proxy.is_none() { " --no-sign-in" } else { "" }; + let environment = unit_word(&format!("HOME={}", path_text(&home)?))?; + let executable = unit_word(path_text(&binary)?)?; + let (site, finish) = if let Some(proxy) = proxy { + let host = config.console.hostname()?; + ensure!( + !config.console.allowed_emails.is_empty(), + "set console.allowed_emails before installing a public Console" + ); + match proxy { + Proxy::Caddy => ( + caddy(host, config.console.port), + "systemctl restart caddy\n".into(), + ), + Proxy::Apache(layout) => apache(host, config.console.port, layout), + } + } else { + ( + String::new(), + format!( + "# No Caddy or Apache found. From your own computer, replace SERVER with this machine's SSH host:\n# ssh -N -L {port}:127.0.0.1:{port} {owner}@SERVER\n# Then open http://localhost:{port} (the service is localhost-only, without sign-in).\n", + port = config.console.port + ), + ) + }; + Ok(format!( + r#"#!/bin/sh +# Save this script, then run: sudo sh install-console.sh +set -eu + +cat > /etc/systemd/system/thirdshift-console.service <<'THIRDSHIFT_UNIT' +[Unit] +Description=thirdshift Console +After=network.target + +[Service] +User={owner} +Environment={environment} +ExecStart={executable} console{mode} +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +THIRDSHIFT_UNIT + +{site}systemctl daemon-reload +systemctl enable --now thirdshift-console.service +{finish}"# + )) +} + +fn caddy(host: &str, port: u16) -> String { + format!( + r#"mkdir -p /etc/caddy/sites +cat > /etc/caddy/sites/thirdshift-console.caddy <<'THIRDSHIFT_SITE' +{host} {{ + reverse_proxy 127.0.0.1:{port} {{ + flush_interval -1 + }} +}} +THIRDSHIFT_SITE +# Include the sites folder once, preserving existing sites. +if ! grep -Eq '^[[:space:]]*import[[:space:]]+(/etc/caddy/)?sites/\*(\.caddy)?[[:space:]]*(#.*)?$' /etc/caddy/Caddyfile; then + printf '\nimport /etc/caddy/sites/*.caddy\n' >> /etc/caddy/Caddyfile +fi +caddy validate --config /etc/caddy/Caddyfile +"# + ) +} + +fn apache(host: &str, port: u16, layout: ApacheLayout) -> (String, String) { + let (directory, enable, check, service) = match layout { + ApacheLayout::RedHat => ("/etc/httpd/conf.d", "", "httpd -t", "httpd"), + ApacheLayout::Debian => ( + "/etc/apache2/sites-available", + "a2enmod proxy proxy_http headers ssl setenvif\na2ensite thirdshift-console.conf\n", + "apache2ctl configtest", + "apache2", + ), + }; + let site = format!( + r#"mkdir -p {directory} +cat > {directory}/thirdshift-console.conf <<'THIRDSHIFT_SITE' + + ServerName {host} + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto "expr=%{{REQUEST_SCHEME}}" + ProxyPass / http://127.0.0.1:{port}/ flushpackets=on + ProxyPassReverse / http://127.0.0.1:{port}/ + SetEnvIf Request_URI "^/(events$|logs/[^/]+/stream/)" no-gzip=1 + +THIRDSHIFT_SITE +{enable}{check} +certbot --apache -d {host} +"# + ); + (site, format!("systemctl restart {service}\n")) +} + +fn available(name: &str) -> bool { + std::env::var_os("PATH").is_some_and(|paths| { + std::env::split_paths(&paths).any(|directory| { + let path = directory.join(name); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + path.metadata().is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + } + #[cfg(not(unix))] + path.is_file() + }) + }) +} + +fn identity(flag: &str) -> Result { + let output = Command::new("id") + .arg(flag) + .output() + .context("can't identify the Console owner")?; + ensure!(output.status.success(), "can't identify the Console owner"); + let value = String::from_utf8(output.stdout)?; + ensure!(!value.trim().is_empty(), "can't identify the Console owner"); + Ok(value.trim().to_owned()) +} + +fn path_text(path: &Path) -> Result<&str> { + path.to_str() + .context("console install requires UTF-8 paths") +} + +/// systemd quotes are separate from shell quotes; percent expands specifiers. +fn unit_word(value: &str) -> Result { + ensure!( + !value.chars().any(char::is_control), + "console install cannot use control characters in paths" + ); + Ok(format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + )) +} diff --git a/src/main.rs b/src/main.rs index eb83ef0f..a286a181 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,7 @@ usage: thirdshift Run the factory on the iss thirdshift setup Choose defaults and write the User config thirdshift status [--all] [--json] List this machine's Commands thirdshift console [--no-sign-in] Serve this machine's Console on localhost + thirdshift console install Print a service and proxy install script for sudo thirdshift console password [--email
        ] Set a Console password privately thirdshift console sign-out [--email
        ] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log @@ -139,6 +140,15 @@ fn main() -> ExitCode { } Ok(Command::Update) => return outcome(update::update()), Ok(Command::Setup) => return outcome(setup::setup()), + Ok(Command::ConsoleInstall) => { + return match console::install() { + Ok(script) => { + print!("{script}"); + ExitCode::SUCCESS + } + Err(error) => failure(&error), + }; + } Ok(Command::ConsolePassword(email)) => return outcome(console::password(email.as_deref())), Ok(Command::ConsoleSignOut { email, json }) => { return match console::sign_out(email.as_deref(), json) { diff --git a/src/setup.rs b/src/setup.rs index 6099e6d0..991ac553 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1050,6 +1050,7 @@ days = 7 silent_minutes = 10 [console] +address = \"\" port = 8711 allowed_emails = [] other_consoles = [] diff --git a/tests/console_install.rs b/tests/console_install.rs new file mode 100644 index 00000000..8a657df5 --- /dev/null +++ b/tests/console_install.rs @@ -0,0 +1,274 @@ +//! Installation is exercised through the binary and its printed shell script. +#![cfg(target_os = "linux")] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::{Command, Output}; + +struct Machine { + root: tempfile::TempDir, +} + +impl Machine { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("bin")).unwrap(); + fs::create_dir_all(root.path().join("home/.thirdshift")).unwrap(); + let machine = Self { root }; + // Identity is real; proxy/service commands stay inside this fixture. + std::os::unix::fs::symlink("/usr/bin/id", machine.path("bin/id")).unwrap(); + machine.configure("https://console.example.test", 9123); + machine + } + + fn path(&self, relative: &str) -> std::path::PathBuf { + self.root.path().join(relative) + } + + fn configure(&self, address: &str, port: u16) { + fs::write( + self.path("home/.thirdshift/config.toml"), + format!("[console]\naddress = {address:?}\nport = {port}\nallowed_emails = [\"owner@example.test\"]\n"), + ) + .unwrap(); + } + + fn tool(&self, name: &str, script: &str) { + let path = self.path(&format!("bin/{name}")); + fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + + fn install(&self) -> Output { + Command::new(env!("CARGO_BIN_EXE_thirdshift")) + .args(["console", "install"]) + .env("HOME", self.path("home")) + .env("PATH", self.path("bin")) + .env("USER", "not-the-owner") + .output() + .unwrap() + } + + fn script(&self) -> String { + let output = self.install(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let script = String::from_utf8(output.stdout).unwrap(); + let path = self.path("install.sh"); + fs::write(&path, &script).unwrap(); + let syntax = Command::new("/bin/sh") + .arg("-n") + .arg(path) + .output() + .unwrap(); + assert!( + syntax.status.success(), + "{}", + String::from_utf8_lossy(&syntax.stderr) + ); + script + } +} + +#[test] +fn caddy_script_installs_an_owner_service_and_streaming_site_without_running_them() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + // Detection may ask about running services, but must never install one. + machine.tool("systemctl", "[ \"$1\" = is-active ] || exit 99\nexit 1"); + let script = machine.script(); + let owner = Command::new("id").arg("-un").output().unwrap(); + let owner = String::from_utf8(owner.stdout).unwrap(); + assert!( + script.contains(&format!("User={}\n", owner.trim())), + "{script}" + ); + assert!(script.contains("Restart=always"), "{script}"); + assert!( + script.contains(&format!( + "ExecStart=\"{}\" console", + env!("CARGO_BIN_EXE_thirdshift") + )), + "{script}" + ); + assert!( + script.contains(&format!("HOME={}", machine.path("home").display())), + "{script}" + ); + assert!( + script.contains("/etc/caddy/sites/thirdshift-console.caddy"), + "{script}" + ); + assert!(script.contains("console.example.test {"), "{script}"); + assert!(script.contains("reverse_proxy 127.0.0.1:9123"), "{script}"); + assert!(script.contains("flush_interval -1"), "{script}"); + assert!( + script.contains("systemctl enable --now thirdshift-console.service"), + "{script}" + ); + assert!(script.contains("systemctl restart caddy"), "{script}"); + assert!(!script.contains("systemctl reload"), "{script}"); + assert!(!machine.path("home/.thirdshift/console").exists()); +} + +#[test] +fn apache_script_preserves_host_and_protocol_and_certifies_the_streaming_site() { + let machine = Machine::new(); + machine.tool("apache2ctl", "exit 0"); + let script = machine.script(); + for required in [ + "/etc/apache2/sites-available/thirdshift-console.conf", + "", + "ServerName console.example.test", + "ProxyPreserveHost On", + "RequestHeader set X-Forwarded-Proto \"expr=%{REQUEST_SCHEME}\"", + "ProxyPass / http://127.0.0.1:9123/ flushpackets=on", + "ProxyPassReverse / http://127.0.0.1:9123/", + "SetEnvIf Request_URI \"^/(events$|logs/[^/]+/stream/)\" no-gzip=1", + "a2enmod proxy proxy_http headers ssl setenvif", + "a2ensite thirdshift-console.conf", + "certbot --apache -d console.example.test", + "systemctl restart apache2", + ] { + assert!(script.contains(required), "missing {required}:\n{script}"); + } + let enabled = script + .find("systemctl enable --now thirdshift-console.service") + .unwrap(); + assert!(enabled < script.find("systemctl restart apache2").unwrap()); + assert!(!script.contains("/etc/caddy")); +} + +#[test] +fn without_a_proxy_the_service_and_ssh_tunnel_work_without_an_address_or_accounts() { + let machine = Machine::new(); + fs::write( + machine.path("home/.thirdshift/config.toml"), + "[console]\nport = 9123\n", + ) + .unwrap(); + let script = machine.script(); + assert!( + script.contains("/etc/systemd/system/thirdshift-console.service"), + "{script}" + ); + assert!(script.contains("console --no-sign-in\n"), "{script}"); + assert!(script.contains("ssh -N -L 9123:127.0.0.1:9123"), "{script}"); + assert!(script.contains("http://localhost:9123"), "{script}"); + assert!(!script.contains("certbot"), "{script}"); + assert!(!script.contains("/etc/caddy"), "{script}"); + assert!(!script.contains("/etc/apache2"), "{script}"); +} + +#[test] +fn a_running_apache_wins_over_an_installed_caddy() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + machine.tool("apache2ctl", "exit 0"); + machine.tool( + "systemctl", + "[ \"$1\" = is-active ] && [ \"$3\" = apache2 ]", + ); + let script = machine.script(); + assert!(script.contains("systemctl restart apache2"), "{script}"); + assert!(!script.contains("/etc/caddy"), "{script}"); +} + +#[test] +fn rocky_apache_uses_httpd_and_its_conf_directory() { + let machine = Machine::new(); + machine.tool("httpd", "exit 0"); + let script = machine.script(); + assert!( + script.contains("/etc/httpd/conf.d/thirdshift-console.conf"), + "{script}" + ); + assert!(script.contains("httpd -t"), "{script}"); + assert!( + script.contains("certbot --apache -d console.example.test"), + "{script}" + ); + assert!(script.contains("systemctl restart httpd"), "{script}"); + assert!(!script.contains("a2en"), "{script}"); +} + +#[test] +fn install_rejects_unusable_public_settings_without_printing_a_partial_script() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + for address in [ + "", + "http://console.example.test", + "https://console.example.test/path", + "bad;echo injected", + "https://console.example.test:8443", + ] { + machine.configure(address, 9123); + let output = machine.install(); + assert!(!output.status.success(), "accepted {address}"); + assert!(output.stdout.is_empty(), "partial script for {address}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.address")); + } + machine.configure("console.example.test", 0); + let output = machine.install(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.port")); + fs::write( + machine.path("home/.thirdshift/config.toml"), + "[console]\naddress = \"console.example.test\"\n", + ) + .unwrap(); + let output = machine.install(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("console.allowed_emails")); +} + +#[test] +fn unit_preserves_literal_paths_with_shell_and_systemd_metacharacters() { + let machine = Machine::new(); + let home = machine.path("home space%$\"'\\"); + fs::rename(machine.path("home"), &home).unwrap(); + let binary = machine.path("thirdshift space%$binary"); + fs::copy(env!("CARGO_BIN_EXE_thirdshift"), &binary).unwrap(); + let output = Command::new(&binary) + .args(["console", "install"]) + .env("HOME", &home) + .env("PATH", machine.path("bin")) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let script = String::from_utf8(output.stdout).unwrap(); + assert!(script.contains("home space%%$\\\"'\\\\"), "{script}"); + assert!( + script.contains("thirdshift space%%$binary\" console"), + "{script}" + ); + let unit = script + .split_once("<<'THIRDSHIFT_UNIT'\n") + .unwrap() + .1 + .split_once("THIRDSHIFT_UNIT\n") + .unwrap() + .0; + let unit_path = machine.path("thirdshift-console.service"); + fs::write(&unit_path, unit).unwrap(); + if std::path::Path::new("/usr/bin/systemd-analyze").exists() { + let output = Command::new("/usr/bin/systemd-analyze") + .args(["verify", "--man=no"]) + .arg(unit_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} From f5d071fc55d284c611a8f968eca1789a517ae0b3 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 08:07:37 +0000 Subject: [PATCH 41/47] Apply replaced Console services before exposing proxy sites --- src/console/install.rs | 32 +++++++++++++++----------------- tests/console_install.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/console/install.rs b/src/console/install.rs index 62baec89..91c0ffd7 100644 --- a/src/console/install.rs +++ b/src/console/install.rs @@ -21,11 +21,12 @@ impl Proxy { fn detect() -> Option { // Prefer a running server when both packages are installed. On a // machine without a running systemd manager, fall back to PATH. - for (service, proxy) in [ - ("caddy", Self::Caddy), - ("apache2", Self::Apache(ApacheLayout::Debian)), - ("httpd", Self::Apache(ApacheLayout::RedHat)), - ] { + const CANDIDATES: [(&str, &str, Proxy); 3] = [ + ("caddy", "caddy", Proxy::Caddy), + ("apache2", "apache2ctl", Proxy::Apache(ApacheLayout::Debian)), + ("httpd", "httpd", Proxy::Apache(ApacheLayout::RedHat)), + ]; + for (service, _, proxy) in CANDIDATES { if Command::new("systemctl") .args(["is-active", "--quiet", service]) .output() @@ -34,15 +35,10 @@ impl Proxy { return Some(proxy); } } - if available("caddy") { - Some(Self::Caddy) - } else if available("apache2ctl") { - Some(Self::Apache(ApacheLayout::Debian)) - } else if available("httpd") { - Some(Self::Apache(ApacheLayout::RedHat)) - } else { - None - } + CANDIDATES + .into_iter() + .find(|(_, executable, _)| available(executable)) + .map(|(_, _, proxy)| proxy) } } @@ -117,9 +113,11 @@ RestartSec=5 WantedBy=multi-user.target THIRDSHIFT_UNIT -{site}systemctl daemon-reload -systemctl enable --now thirdshift-console.service -{finish}"# +systemctl daemon-reload +systemctl enable thirdshift-console.service +systemctl restart thirdshift-console.service + +{site}{finish}"# )) } diff --git a/tests/console_install.rs b/tests/console_install.rs index 8a657df5..619ea6db 100644 --- a/tests/console_install.rs +++ b/tests/console_install.rs @@ -106,7 +106,7 @@ fn caddy_script_installs_an_owner_service_and_streaming_site_without_running_the assert!(script.contains("reverse_proxy 127.0.0.1:9123"), "{script}"); assert!(script.contains("flush_interval -1"), "{script}"); assert!( - script.contains("systemctl enable --now thirdshift-console.service"), + script.contains("systemctl enable thirdshift-console.service"), "{script}" ); assert!(script.contains("systemctl restart caddy"), "{script}"); @@ -136,7 +136,7 @@ fn apache_script_preserves_host_and_protocol_and_certifies_the_streaming_site() assert!(script.contains(required), "missing {required}:\n{script}"); } let enabled = script - .find("systemctl enable --now thirdshift-console.service") + .find("systemctl enable thirdshift-console.service") .unwrap(); assert!(enabled < script.find("systemctl restart apache2").unwrap()); assert!(!script.contains("/etc/caddy")); @@ -177,6 +177,34 @@ fn a_running_apache_wins_over_an_installed_caddy() { assert!(!script.contains("/etc/caddy"), "{script}"); } +#[test] +fn reinstall_restarts_the_console_before_exposing_it_through_the_proxy() { + let machine = Machine::new(); + machine.tool("caddy", "exit 0"); + let script = machine.script(); + // enable --now leaves an already running tunnel service in --no-sign-in + // mode. Replacing its unit must apply the new command before public access. + let restart = script + .find("systemctl restart thirdshift-console.service") + .expect("the replaced service must restart even if it was already running"); + assert!(script.find("systemctl daemon-reload").unwrap() < restart); + assert!(restart < script.find("systemctl restart caddy").unwrap()); +} + +#[test] +fn reinstall_restarts_console_before_certbot_can_activate_the_public_site() { + let machine = Machine::new(); + machine.tool("apache2ctl", "exit 0"); + let script = machine.script(); + assert!( + script + .find("systemctl restart thirdshift-console.service") + .unwrap() + < script.find("certbot --apache").unwrap(), + "certbot can reload Apache, so the Console must already require sign-in:\n{script}" + ); +} + #[test] fn rocky_apache_uses_httpd_and_its_conf_directory() { let machine = Machine::new(); From af6b5cb0209bb010809c6347ec50ba5971449701 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 09:26:56 +0000 Subject: [PATCH 42/47] Ask for Console access during Setup (#829) --- src/config.rs | 21 ++++- src/config/console.rs | 9 +- src/setup.rs | 164 +++++++++++++++++++++++++++++--- src/setup/console_tests.rs | 186 +++++++++++++++++++++++++++++++++++++ src/setup/questions.rs | 114 ++++++++++++++++++----- tests/setup.rs | 92 +++++++++++++++++- 6 files changed, 546 insertions(+), 40 deletions(-) create mode 100644 src/setup/console_tests.rs diff --git a/src/config.rs b/src/config.rs index b2843dcc..e412c8b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,7 @@ use toml_edit::{DocumentMut, Item}; use crate::harness::{self, Harness, ModelAndEffort}; mod console; -pub use console::ConsoleSettings; +pub use console::{ConsoleAccess, ConsoleSettings}; /// The settings a User config can hold. Each is what a Run does when its /// command says nothing about it. @@ -289,6 +289,8 @@ pub struct UserConfigDocument { /// The Setup settings to change. Other settings keep their current values. /// Credentials and the decision to send a test email stay with Setup. pub struct UserConfigChanges { + /// None retains all Console settings. + pub console: Option, pub merge_always: bool, pub base_fix: bool, pub launch_pull: bool, @@ -340,6 +342,18 @@ impl UserConfigDocument { pub fn render(self, changes: Option) -> String { let mut document = self.document; if let Some(changes) = changes { + if let Some(console) = changes.console { + set( + &mut document, + "console", + "address", + console.address.as_str(), + ); + if console.allowed_emails != self.settings.console.allowed_emails { + let emails: toml_edit::Array = console.allowed_emails.iter().collect(); + set(&mut document, "console", "allowed_emails", emails); + } + } set(&mut document, "merge", "always", changes.merge_always); set(&mut document, "base", "fix", changes.base_fix); set(&mut document, "launch", "pull", changes.launch_pull); @@ -1365,6 +1379,7 @@ mod tests { assert_eq!(current.logs_dir, home.join(".thirdshift/logs")); let text = document.render(Some(UserConfigChanges { + console: None, merge_always: true, base_fix: true, launch_pull: true, @@ -1519,6 +1534,7 @@ mod tests { fn notifications_to(to: &str) -> UserConfigChanges { UserConfigChanges { + console: None, harness: None, merge_always: false, base_fix: false, @@ -1614,6 +1630,7 @@ mod tests { let text = "# top\n[merge]\n# before\nalways = false # merge\n# after\n\n\ [harness.codex]\nmodel = 'old' # model\neffort = 'high' # effort\n"; let changes = UserConfigChanges { + console: None, merge_always: true, base_fix: false, launch_pull: false, @@ -1659,6 +1676,7 @@ mod tests { ] { let before = parse(text).unwrap(); let changes = UserConfigChanges { + console: None, merge_always: true, base_fix: true, launch_pull: true, @@ -1745,6 +1763,7 @@ mod tests { } let before = parse(&text).unwrap(); let changes = UserConfigChanges { + console: None, merge_always: true, base_fix: false, launch_pull: true, diff --git a/src/config/console.rs b/src/config/console.rs index 85aea889..7056f08a 100644 --- a/src/config/console.rs +++ b/src/config/console.rs @@ -2,6 +2,13 @@ use anyhow::{Result, ensure}; use serde::Deserialize; +/// The Console settings Setup asks about; other Console settings stay put. +#[derive(Clone)] +pub struct ConsoleAccess { + pub address: String, + pub allowed_emails: Vec, +} + #[derive(Debug, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ConsoleSettings { @@ -49,7 +56,7 @@ impl ConsoleSettings { Ok(host) } - pub(super) fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { if !self.address.is_empty() { self.hostname()?; } diff --git a/src/setup.rs b/src/setup.rs index 991ac553..90d351c6 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -12,6 +12,9 @@ pub(crate) mod questions; +#[cfg(test)] +mod console_tests; + use crate::terminal::EchoOff; use std::io::{IsTerminal, Write}; use std::path::Path; @@ -105,6 +108,7 @@ fn run_setup(outside: &mut impl Outside, home: &Path, path: &Path) -> Result Result<()> return Ok(()); }; outside.step(format!("wrote the User config {}", path.display())); + offer_console_install(outside, answers); if let Some(key) = answers.key() { match outside.save_key(key) { Ok(wrote) => outside.step(wrote), @@ -164,6 +169,14 @@ fn ask_if( outside: &mut impl Outside, document: UserConfigDocument, ) -> Result<(String, Option)> { + let settings = document.settings(); + let waiting = settings.effective_waiting_line_interval(); + if waiting != settings.waiting_line_interval { + outside.say(format!( + "warning: logs.waiting_line_minutes must be less than inbox.silent_minutes; using half the silent time ({} sec) for waiting lines", + waiting.as_secs() + )); + } let answers = if asking { let found = outside.find_key()?; Some(questions::ask(outside, document.settings(), found)?) @@ -174,6 +187,13 @@ fn ask_if( Ok((document.render(changes), answers)) } +/// Installation is an owner-run command that prints the script for sudo. +fn offer_console_install(outside: &mut impl Outside, answers: &Answers) { + if answers.console.is_some() { + outside.say("To install the Console, run `thirdshift console install`; it prints a script to run with sudo.".into()); + } +} + /// Write `text` as the User config at `path`, where there is none yet. fn write_new(outside: &mut impl Outside, path: &Path, text: &str) -> Result<()> { outside @@ -639,6 +659,7 @@ mod tests { const SECURITY_REVIEW: &str = "Runs review their change for Security findings?"; const SECURITY_FIX: &str = "Security runs may fix reproduced findings?"; const NOTIFY: &str = "Run notifications, an email"; + const CONSOLE: &str = "Run the Console?"; const TO: &str = "Send Run notifications to"; const FROM: &str = "Send them from"; const TEST_EMAIL: &str = "Send a test email now? [y/N] "; @@ -652,12 +673,13 @@ mod tests { const KEY: &str = "re_secret_123"; /// The answers that take every default and turn nothing on. - const ENTER_THROUGHOUT: [(&str, &str); 5] = [ + const ENTER_THROUGHOUT: [(&str, &str); 6] = [ (MERGE, ""), (PULL, ""), (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, ""), ]; /// The answers that turn Run notifications on, to `me@example.com` from @@ -719,6 +741,84 @@ mod tests { // What Setup writes. + #[test] + fn setup_warns_when_waiting_lines_could_read_as_silent() { + for terminal in [false, true] { + for (waiting, warns) in [(1, false), (2, true), (3, true)] { + let mut outside = Scripted { + terminal, + user_config: Some(format!( + "[logs]\nwaiting_line_minutes = {waiting}\n[inbox]\nsilent_minutes = 2\n" + )), + ..Scripted::answering(if terminal { &ENTER_THROUGHOUT } else { &[] }) + }; + setup(&mut outside).unwrap(); + let warning = outside.said().join("\n"); + assert_eq!( + warning.contains( + "logs.waiting_line_minutes must be less than inbox.silent_minutes" + ), + warns, + "{warning}" + ); + if warns { + assert!( + warning.contains("using half the silent time (60 sec)"), + "{warning}" + ); + } + assert_eq!( + table(&outside)["logs"]["waiting_line_minutes"].as_integer(), + Some(waiting) + ); + } + } + } + + #[test] + fn console_answers_are_written_with_comments_and_install_is_offered() { + let mut answers = ENTER_THROUGHOUT[..5].to_vec(); + answers.extend([ + ("Run the Console?", "y"), + ("Console's whole address", "https://thirdshift.example.com"), + ("Allowed emails", "me@example.com, other@example.net"), + ]); + let mut outside = Scripted { + user_config: Some("[console] # my Console\naddress = '' # site\nallowed_emails = [] # people\nport = 9000 # local\n".into()), + ..Scripted::answering(&answers) + }; + + setup(&mut outside).unwrap(); + + let config = table(&outside); + assert_eq!( + config["console"]["address"].as_str(), + Some("https://thirdshift.example.com") + ); + assert_eq!( + config["console"]["allowed_emails"].as_array().unwrap(), + &[ + toml::Value::from("me@example.com"), + toml::Value::from("other@example.net") + ] + ); + let text = outside.user_config.as_deref().unwrap(); + for comment in [ + "[console] # my Console", + "# site", + "# people", + "port = 9000 # local", + ] { + assert!(text.contains(comment), "{text}"); + } + assert!( + outside + .said() + .iter() + .any(|line| line.contains("thirdshift console install")) + ); + } + #[test] fn the_harness_interaction_receives_every_current_setting_once_before_other_questions() { let mine = "[harness]\ndefault = 'codex'\n[harness.claude]\nmodel = 'opus'\neffort = 'high'\n[harness.codex]\nmodel = 'GPT-6-Luna'\neffort = 'High'\n[harness.agy]\nmodel = 'gemini-3.8-flash'\neffort = 'medium'\n[harness.grok]\nmodel = 'grok-4.5'\neffort = 'low'\n[harness.muse]\nmodel = 'muse-spark-1.3'\neffort = 'high'\n[harness.opencode]\nmodel = 'provider/model'\neffort = 'high'\n"; @@ -971,6 +1071,7 @@ to = \"me@example.com\" # my inbox "Security runs may fix reproduced findings? [y/N] ", "Runs review their change for Security findings? [y/N] ", "Run notifications, an email as each Run ends? [y/N] ", + "Run the Console? [y/N] ", ] ); } @@ -989,6 +1090,7 @@ to = \"me@example.com\" # my inbox (TO, "me@example.com"), (FROM, "ts@acme.dev"), (TEST_EMAIL, ""), + (CONSOLE, "n"), ]) }; @@ -1112,6 +1214,7 @@ effort = \"\" (TO, ""), (FROM, ""), (TEST_EMAIL, ""), + (CONSOLE, "n"), ]) }; @@ -1134,6 +1237,7 @@ effort = \"\" "Send Run notifications to [mine@example.net]: ", "Send them from [ts@acme.dev]: ", "Send a test email now? [y/N] ", + "Run the Console? [y/N] ", ] ); } @@ -1148,6 +1252,7 @@ effort = \"\" (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]) }; @@ -1185,6 +1290,7 @@ always = false # quiet, please (TO, "me@example.com"), (FROM, ""), (KEY_PROMPT, ""), + (CONSOLE, "n"), ]) }; @@ -1205,8 +1311,11 @@ always = false # quiet, please #[test] fn the_credentials_are_saved_after_the_user_config_and_the_test_email_goes_last() { - let mut outside = - Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")])); + let mut outside = Scripted::answering(¬ifications_on(&[ + (KEY_PROMPT, KEY), + (TEST_EMAIL, "y"), + (CONSOLE, "n"), + ])); let done = setup(&mut outside).unwrap(); @@ -1228,8 +1337,11 @@ always = false # quiet, please #[test] fn an_entered_key_alone_ends_with_the_line_saying_where_it_was_saved() { - let mut outside = - Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "n")])); + let mut outside = Scripted::answering(¬ifications_on(&[ + (KEY_PROMPT, KEY), + (TEST_EMAIL, "n"), + (CONSOLE, "n"), + ])); let done = setup(&mut outside).unwrap(); @@ -1289,6 +1401,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]); setup(&mut outside).unwrap(); @@ -1315,6 +1428,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]); setup(&mut outside).unwrap(); @@ -1336,6 +1450,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]) }; @@ -1362,6 +1477,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]) }; @@ -1382,6 +1498,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]); setup(&mut outside).unwrap(); @@ -1402,6 +1519,7 @@ always = false # quiet, please (TO, "me@example.com"), (FROM, ""), (KEY_PROMPT, ""), + (CONSOLE, "n"), ]); setup(&mut outside).unwrap(); @@ -1430,6 +1548,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, "n"), + (CONSOLE, "n"), ]) }; @@ -1448,7 +1567,8 @@ always = false # quiet, please #[test] fn with_no_key_anywhere_skipping_it_saves_nothing_offers_no_test_email_and_says_how_to_add_one() { - let mut outside = Scripted::answering(¬ifications_on(&[(KEY_PROMPT, "")])); + let mut outside = + Scripted::answering(¬ifications_on(&[(KEY_PROMPT, ""), (CONSOLE, "n")])); let done = setup(&mut outside).unwrap(); @@ -1465,6 +1585,7 @@ always = false # quiet, please (KEY_PROMPT, "sk_not_resend"), (KEY_PROMPT, KEY), (TEST_EMAIL, ""), + (CONSOLE, "n"), ])); setup(&mut outside).unwrap(); @@ -1491,7 +1612,11 @@ always = false # quiet, please fn with_a_saved_key_pressing_enter_keeps_it() { let mut outside = Scripted { key: Ok(Some(Source::Credentials(CREDENTIALS.into()))), - ..Scripted::answering(¬ifications_on(&[(KEPT, ""), (TEST_EMAIL, "")])) + ..Scripted::answering(¬ifications_on(&[ + (KEPT, ""), + (TEST_EMAIL, ""), + (CONSOLE, "n"), + ])) }; setup(&mut outside).unwrap(); @@ -1509,7 +1634,11 @@ always = false # quiet, please fn with_a_saved_key_a_new_one_replaces_it() { let mut outside = Scripted { key: Ok(Some(Source::Credentials(CREDENTIALS.into()))), - ..Scripted::answering(¬ifications_on(&[(KEPT, KEY), (TEST_EMAIL, "")])) + ..Scripted::answering(¬ifications_on(&[ + (KEPT, KEY), + (TEST_EMAIL, ""), + (CONSOLE, "n"), + ])) }; setup(&mut outside).unwrap(); @@ -1521,7 +1650,7 @@ always = false # quiet, please fn with_resend_api_key_set_no_key_is_asked_and_its_source_is_said() { let mut outside = Scripted { key: Ok(Some(Source::Environment)), - ..Scripted::answering(¬ifications_on(&[(TEST_EMAIL, "")])) + ..Scripted::answering(¬ifications_on(&[(TEST_EMAIL, ""), (CONSOLE, "n")])) }; setup(&mut outside).unwrap(); @@ -1540,7 +1669,7 @@ always = false # quiet, please for (answer, sent) in [("y", true), ("n", false), ("", false)] { let mut outside = Scripted { key: Ok(Some(Source::Environment)), - ..Scripted::answering(¬ifications_on(&[(TEST_EMAIL, answer)])) + ..Scripted::answering(¬ifications_on(&[(TEST_EMAIL, answer), (CONSOLE, "n")])) }; let done = setup(&mut outside).unwrap(); @@ -1678,6 +1807,7 @@ always = false # quiet, please (TO, ""), (FROM, ""), (KEY_PROMPT, ""), + (CONSOLE, "n"), ]) }; @@ -1777,6 +1907,7 @@ always = false # quiet, please (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ]); offer(&mut outside).unwrap(); @@ -1801,6 +1932,7 @@ always = false # quiet, please (FROM, ""), (KEY_PROMPT, KEY), (TEST_EMAIL, "y"), + (CONSOLE, "n"), ]); offer(&mut outside).unwrap(); @@ -1824,7 +1956,11 @@ always = false # quiet, please fn an_unwritable_user_config_is_a_warning_and_the_run_carries_on_with_the_defaults() { let mut outside = Scripted { failing: vec![Fails::WriteNew], - ..Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")])) + ..Scripted::answering(¬ifications_on(&[ + (KEY_PROMPT, KEY), + (TEST_EMAIL, "y"), + (CONSOLE, "n"), + ])) }; outside.answers.push_front((OFFER, "y")); @@ -1844,7 +1980,11 @@ always = false # quiet, please fn credentials_that_cant_be_saved_or_a_test_email_that_cant_go_are_warnings() { let mut outside = Scripted { failing: vec![Fails::SaveKey, Fails::SendTest], - ..Scripted::answering(¬ifications_on(&[(KEY_PROMPT, KEY), (TEST_EMAIL, "y")])) + ..Scripted::answering(¬ifications_on(&[ + (KEY_PROMPT, KEY), + (TEST_EMAIL, "y"), + (CONSOLE, "n"), + ])) }; outside.answers.push_front((OFFER, "y")); diff --git a/src/setup/console_tests.rs b/src/setup/console_tests.rs new file mode 100644 index 00000000..ef267384 --- /dev/null +++ b/src/setup/console_tests.rs @@ -0,0 +1,186 @@ +//! Console choices through Setup's existing outside-world script. +use super::scripted::{Call, Scripted}; +use super::*; + +const HOME: &str = "/home/me"; +const PATH: &str = "/home/me/.thirdshift/config.toml"; +const NO_NOTIFICATIONS: &[(&str, &str)] = &[ + ("Merge run?", ""), + ("fast-forwards", ""), + ("Security runs may fix", ""), + ("Runs review", ""), + ("Run notifications", "n"), +]; + +fn setup(outside: &mut Scripted) { + run_setup(outside, Path::new(HOME), Path::new(PATH)).unwrap(); + assert!(outside.answers.is_empty(), "{:?}", outside.answers); +} + +fn config(outside: &Scripted) -> UserConfig { + UserConfig::parse( + outside.user_config.as_deref().unwrap(), + Path::new(PATH), + Path::new(HOME), + ) + .unwrap() +} + +#[test] +fn console_allowlist_defaults_to_email_to_even_with_notifications_off() { + let mut answers = NO_NOTIFICATIONS.to_vec(); + answers.extend([ + ("Run the Console?", "y"), + ("Console's whole address", "thirdshift.example.com"), + ("Allowed emails", ""), + ]); + let mut outside = Scripted { + user_config: Some("[email]\nto = 'owner@example.com'\n".into()), + ..Scripted::answering(&answers) + }; + setup(&mut outside); + assert_eq!( + config(&outside).console.allowed_emails, + ["owner@example.com"] + ); + assert!( + outside + .prompts() + .iter() + .any(|prompt| prompt.contains("[owner@example.com]")) + ); +} + +#[test] +fn console_allowlist_defaults_to_the_notification_address_just_chosen() { + let mut answers = NO_NOTIFICATIONS[..4].to_vec(); + answers.extend([ + ("Run notifications", "y"), + ("Send Run notifications to", "new@example.com"), + ("Send them from", ""), + ("Resend API key", ""), + ("Run the Console?", "y"), + ("Console's whole address", "thirdshift.example.com"), + ("Allowed emails", ""), + ]); + let mut outside = Scripted { + user_config: Some("[email]\nto = 'old@example.com'\n".into()), + ..Scripted::answering(&answers) + }; + setup(&mut outside); + assert_eq!(config(&outside).console.allowed_emails, ["new@example.com"]); +} + +#[test] +fn declining_console_retains_existing_access_and_offers_no_install() { + for saved in [ + "", + "[console]\naddress = 'saved.example.com' # site\nallowed_emails = ['saved@example.com'] # people\n", + ] { + let mut answers = NO_NOTIFICATIONS.to_vec(); + answers.push(("Run the Console?", "n")); + let mut outside = Scripted { + user_config: Some(saved.into()), + ..Scripted::answering(&answers) + }; + setup(&mut outside); + let before = UserConfig::parse(saved, Path::new(PATH), Path::new(HOME)).unwrap(); + assert_eq!(config(&outside).console, before.console); + assert!(outside.user_config.as_deref().unwrap().starts_with(saved)); + assert!( + !outside + .said() + .iter() + .any(|line| line.contains("console install")) + ); + assert!(!outside.prompts().iter().any(|prompt| prompt.contains("Allowed emails") || prompt.contains("whole address"))); + } +} + +#[test] +fn entering_existing_console_defaults_preserves_allowlist_layout_and_comments() { + let mut answers = NO_NOTIFICATIONS.to_vec(); + answers.extend([ + ("Run the Console? [Y/n]", ""), + ("[https://saved.example.com]", ""), + ("[saved@example.com, other@example.com]", ""), + ]); + let saved = "[console] # Console\naddress = 'https://saved.example.com' # site\nallowed_emails = [\n 'saved@example.com', # owner\n 'other@example.com', # colleague\n] # people\n"; + let mut outside = Scripted { + user_config: Some(saved.into()), + ..Scripted::answering(&answers) + }; + setup(&mut outside); + assert!(outside.user_config.as_deref().unwrap().starts_with(saved)); +} + +#[test] +fn console_reasks_invalid_addresses_and_allowlists_without_using_the_example_as_a_default() { + let mut answers = NO_NOTIFICATIONS.to_vec(); + answers.extend([ + ("Run the Console?", "y"), + ("whole address (e.g. thirdshift.example.com):", ""), + ("whole address", "http://bad.example.com"), + ("whole address", "https://good.example.com/path"), + ("whole address", "https://good.example.com"), + ("Allowed emails", ""), + ("Allowed emails", "missing-at"), + ("Allowed emails", "bad @example.com"), + ("Allowed emails", "owner@example.com,"), + ("Allowed emails", "owner@example.com"), + ]); + let mut outside = Scripted::answering(&answers); + setup(&mut outside); + assert_eq!(config(&outside).console.address, "https://good.example.com"); + assert_eq!( + config(&outside).console.allowed_emails, + ["owner@example.com"] + ); +} + +#[test] +fn eof_during_console_questions_saves_no_config_credentials_or_test_email() { + let mut answers = NO_NOTIFICATIONS[..4].to_vec(); + answers.extend([ + ("Run notifications", "y"), + ("Send Run notifications to", "owner@example.com"), + ("Send them from", ""), + ("Resend API key", "re_secret"), + ("Send a test email", "y"), + ("Run the Console?", "y"), + ("whole address", "https://good.example.com"), + ]); + for end in answers.len() - 2..=answers.len() { + let mut outside = Scripted::answering(&answers[..end]); + let error = run_setup(&mut outside, Path::new(HOME), Path::new(PATH)).unwrap_err(); + assert!(error.to_string().contains("nothing written")); + assert!(outside.user_config.is_none()); + assert!( + !outside + .calls + .iter() + .any(|call| matches!(call, Call::SaveKey(_) | Call::SendTest { .. })) + ); + } +} + +#[test] +fn first_run_setup_also_saves_console_access_and_offers_install() { + let mut answers = vec![("Set your defaults now?", "y")]; + answers.extend_from_slice(NO_NOTIFICATIONS); + answers.extend([ + ("Run the Console?", "y"), + ("whole address", "thirdshift.example.com"), + ("Allowed emails", "owner@example.com"), + ]); + let mut outside = Scripted::answering(&answers); + run_offer(&mut outside, Path::new(HOME), Path::new(PATH)).unwrap(); + assert!(outside.answers.is_empty()); + assert_eq!(config(&outside).console.address, "thirdshift.example.com"); + assert!( + outside + .said() + .iter() + .any(|line| line.contains("thirdshift console install")) + ); +} diff --git a/src/setup/questions.rs b/src/setup/questions.rs index 0fb5c21b..178c48f6 100644 --- a/src/setup/questions.rs +++ b/src/setup/questions.rs @@ -8,13 +8,17 @@ use std::path::Path; use anyhow::{Result, bail}; use super::{Outside, suggested_address}; -use crate::config::{NotificationAddresses, UserConfig, UserConfigChanges}; +use crate::config::{ + ConsoleAccess, ConsoleSettings, NotificationAddresses, UserConfig, UserConfigChanges, +}; use crate::email::DEFAULT_FROM; use crate::harness::{Harness, ModelAndEffort}; use crate::resend_key::{self, Source}; /// What the user chose. pub struct Answers { + /// None leaves the Console settings as they were. + pub console: Option, /// `harness.default`, and the Model and Effort of the Harness it names, /// each none for the Harness's own default; `None` when sessions can run /// on no Harness here, and the harness settings stay as they were. @@ -38,6 +42,7 @@ impl Answers { /// The User config changes, without Credentials or email effects. pub fn changes(&self) -> UserConfigChanges { UserConfigChanges { + console: self.console.clone(), merge_always: self.merge_always, base_fix: self.base_fix, launch_pull: self.launch_pull, @@ -133,20 +138,38 @@ pub fn ask( "Runs review their change for Security findings?", current.security_review, )?; + let notifications = ask_notifications(outside, current, found)?; + let console = ask_console( + outside, + ¤t.console, + notifications + .as_ref() + .map(|email| email.to.as_str()) + .or(current.email.to.as_deref()), + )?; + Ok(Answers { + harness, + merge_always, + base_fix, + launch_pull, + security_fix, + security_review, + notifications, + console, + }) +} + +fn ask_notifications( + outside: &mut impl Outside, + current: &UserConfig, + found: Option, +) -> Result> { if !yes_or_no( outside, "Run notifications, an email as each Run ends?", current.email.always, )? { - return Ok(Answers { - harness, - merge_always, - base_fix, - launch_pull, - security_fix, - security_review, - notifications: None, - }); + return Ok(None); } let suggested = match ¤t.email.to { Some(to) => Some(to.clone()), @@ -181,20 +204,63 @@ pub fn ask( ); false }; - Ok(Answers { - harness, - merge_always, - base_fix, - launch_pull, - security_fix, - security_review, - notifications: Some(Notifications { - to, - from, - key, - send_test, - }), - }) + Ok(Some(Notifications { + to, + from, + key, + send_test, + })) +} + +/// Keep existing access as the default, then the chosen notification address. +/// The example is a hint, never an address written by pressing Enter. +fn ask_console( + outside: &mut impl Outside, + current: &ConsoleSettings, + email_to: Option<&str>, +) -> Result> { + if !yes_or_no(outside, "Run the Console?", !current.address.is_empty())? { + return Ok(None); + } + let address = loop { + let address = answer( + outside, + "Console's whole address (e.g. thirdshift.example.com)", + (!current.address.is_empty()).then_some(current.address.as_str()), + )?; + let settings = ConsoleSettings { + address: address.clone(), + ..ConsoleSettings::default() + }; + match settings.hostname() { + Ok(_) => break address, + Err(error) => outside.say(error.to_string()), + } + }; + let saved_emails = current.allowed_emails.join(", "); + let default_emails = if saved_emails.is_empty() { + email_to + } else { + Some(saved_emails.as_str()) + }; + let allowed_emails = loop { + let emails = answer(outside, "Allowed emails (comma-separated)", default_emails)?; + let settings = ConsoleSettings { + allowed_emails: emails + .split(',') + .map(|email| email.trim().to_string()) + .collect(), + ..ConsoleSettings::default() + }; + match settings.validate() { + Ok(()) => break settings.allowed_emails, + Err(error) => outside.say(error.to_string()), + } + }; + Ok(Some(ConsoleAccess { + address, + allowed_emails, + })) } /// Ask `question` until the answer is yes, no or nothing, which is `default`. diff --git a/tests/setup.rs b/tests/setup.rs index 6f88f4bc..83020847 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -328,6 +328,7 @@ const PULL: &str = "fast-forward"; const SECURITY_REVIEW: &str = "Runs review their change for Security findings?"; const SECURITY_FIX: &str = "Security runs may fix reproduced findings?"; const NOTIFY: &str = "Run notifications, an email"; +const CONSOLE: &str = "Run the Console?"; const TO: &str = "Send Run notifications to"; const FROM: &str = "Send them from"; const TEST_EMAIL: &str = "test email now?"; @@ -336,6 +337,85 @@ const KEPT: &str = "Resend API key (input hidden, Enter keeps the saved one):"; const WROTE_CREDENTIALS: &str = "wrote the Credentials"; const KEY: &str = "re_secret_123"; +#[test] +fn setup_console_questions_use_email_to_keep_comments_and_offer_install() { + let scenario = Scenario::new(); + scenario.user_config_is("[email]\nto = 'owner@example.com'\n[logs]\nwaiting_line_minutes = 2\n[inbox]\nsilent_minutes = 2\n[console] # my Console\naddress = '' # site\nallowed_emails = [] # people\n"); + let result = setup_on_terminal( + &scenario, + &[], + &[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (SECURITY_FIX, ""), + (SECURITY_REVIEW, ""), + (NOTIFY, "n"), + (CONSOLE, "y"), + ( + "Console's whole address (e.g. thirdshift.example.com):", + "https://thirdshift.example.com", + ), + ("Allowed emails (comma-separated) [owner@example.com]", ""), + ], + ); + let text = result.user_config.unwrap(); + let config: toml::Table = text.parse().unwrap(); + assert_eq!( + config["console"]["address"].as_str(), + Some("https://thirdshift.example.com") + ); + assert_eq!( + config["console"]["allowed_emails"][0].as_str(), + Some("owner@example.com") + ); + for comment in ["[console] # my Console", "# site", "# people"] { + assert!(text.contains(comment), "{text}"); + } + assert!( + result.stderr.contains("thirdshift console install"), + "{}", + result.stderr + ); + assert!( + result + .stderr + .contains("logs.waiting_line_minutes must be less than inbox.silent_minutes"), + "{}", + result.stderr + ); +} + +#[test] +fn cancelling_the_console_questions_preserves_the_user_config() { + let scenario = Scenario::new(); + let saved = "[email]\nto = 'owner@example.com'\n"; + scenario.user_config_is(saved); + let result = scenario.run_on_terminal( + &["setup"], + &[], + &[ + (HARNESS, ""), + (MODEL, ""), + (EFFORT, ""), + (MERGE, ""), + (PULL, ""), + (SECURITY_FIX, ""), + (SECURITY_REVIEW, ""), + (NOTIFY, "n"), + (CONSOLE, "y"), + ("Console's whole address", "thirdshift.example.com"), + ("Allowed emails", CTRL_C), + ], + ); + assert_eq!(result.code, Some(1), "{}", result.stderr); + assert!(result.stderr.contains("interrupted"), "{}", result.stderr); + assert_eq!(result.user_config.as_deref(), Some(saved)); + assert!(result.terminal_restored); +} + #[test] fn setup_can_enable_security_review_and_keeps_it_as_the_next_default() { let scenario = Scenario::new(); @@ -352,6 +432,7 @@ fn setup_can_enable_security_review_and_keeps_it_as_the_next_default() { (SECURITY_FIX, ""), (SECURITY_REVIEW, answer), (NOTIFY, ""), + (CONSOLE, "n"), ], ); assert!( @@ -379,6 +460,7 @@ fn setup_asks_once_about_security_fixing_with_off_as_the_default_and_writes_yes( (SECURITY_FIX, "y"), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ], ); assert_eq!(result.stderr.matches(SECURITY_FIX).count(), 1); @@ -418,6 +500,7 @@ fn rerunning_setup_keeps_security_answers_as_defaults_and_can_turn_fixing_off() (SECURITY_FIX, answer), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, "n"), ], ); assert!(result.stderr.contains(&format!("{SECURITY_FIX} {choices}"))); @@ -624,7 +707,7 @@ fn pasted_answers_are_left_available_to_later_terminal_questions() { &[], &[( HARNESS, - " claude \n\n\n\n\n\n\ny\n café@example.com \n\n", + " claude \n\n\n\n\n\n\ny\n café@example.com \n\n\n", )], ); let config: toml::Table = result.user_config.unwrap().parse().unwrap(); @@ -737,6 +820,7 @@ fn on_a_terminal_pressing_enter_throughout_writes_what_setup_with_no_terminal_wr (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, ""), ], ); @@ -761,7 +845,11 @@ fn on_a_terminal_an_entered_key_is_written_to_the_credentials_and_never_shown() let result = setup_on_terminal( &scenario, &[], - ¬ifications_on(&[(KEY_PROMPT, &format!(" {KEY} ")), (TEST_EMAIL, "")]), + ¬ifications_on(&[ + (KEY_PROMPT, &format!(" {KEY} ")), + (TEST_EMAIL, ""), + (CONSOLE, "n"), + ]), ); let path = scenario.path("home/.thirdshift/credentials.toml"); From afb758f636e884d2816f2d3fae172655cbd4f49e Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 09:31:37 +0000 Subject: [PATCH 43/47] Preserve Console allowlist comments when editing entries --- src/config.rs | 43 +++++++++++++++++++++++++++++++ src/setup/console_tests.rs | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/config.rs b/src/config.rs index e412c8b4..2915f00a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -478,6 +478,10 @@ fn set(document: &mut DocumentMut, section: &str, key: &str, value: impl Into value.as_bool() == Some(old), (_, Some(old)) => value.as_str() == Some(old), @@ -502,6 +506,45 @@ fn set(document: &mut DocumentMut, section: &str, key: &str, value: impl Into replacement.len() { + let removed = existing.remove(replacement.len()); + removed_decor.push_str(&decor_prefix(removed.decor())); + removed_decor.push_str(&decor_suffix(removed.decor())); + } + if removed_decor.contains('#') { + removed_decor.push_str(existing.trailing().as_str().unwrap_or("")); + existing.set_trailing(removed_decor); + existing.set_trailing_comma(!existing.is_empty()); + } +} + /// Set `email.to` in `document` to `to`. With no `email.to` there yet, it /// takes the place of the commented-out one, if there is one, written as /// the defaults would write it, with its comment. diff --git a/src/setup/console_tests.rs b/src/setup/console_tests.rs index ef267384..6552d1fd 100644 --- a/src/setup/console_tests.rs +++ b/src/setup/console_tests.rs @@ -184,3 +184,55 @@ fn first_run_setup_also_saves_console_access_and_offers_install() { .any(|line| line.contains("thirdshift console install")) ); } + +#[test] +fn review_changed_console_allowlist_keeps_entry_comments() { + let mut answers = NO_NOTIFICATIONS.to_vec(); + answers.extend([ + ("Run the Console?", "y"), + ("Console's whole address", ""), + ("Allowed emails", "saved@example.com, added@example.net"), + ]); + let saved = "[console] # Console\naddress = 'https://saved.example.com' # site\nallowed_emails = [\n 'saved@example.com', # owner retained\n] # people\n"; + let mut outside = Scripted { + user_config: Some(saved.into()), + ..Scripted::answering(&answers) + }; + setup(&mut outside); + assert_eq!( + config(&outside).console.allowed_emails, + ["saved@example.com", "added@example.net"] + ); + let text = outside.user_config.as_deref().unwrap(); + for comment in ["# Console", "# site", "# owner retained", "# people"] { + assert!(text.contains(comment), "lost {comment}:\n{text}"); + } +} + +#[test] +fn replacing_or_removing_allowlist_entries_keeps_the_comments() { + for (answer, expected) in [ + ( + "new@example.com, other@example.com", + vec!["new@example.com", "other@example.com"], + ), + ("saved@example.com", vec!["saved@example.com"]), + ] { + let mut answers = NO_NOTIFICATIONS.to_vec(); + answers.extend([ + ("Run the Console?", "y"), + ("whole address", ""), + ("Allowed emails", answer), + ]); + let mut outside = Scripted { + user_config: Some("[console]\naddress = 'saved.example.com'\nallowed_emails = [\n 'saved@example.com', # owner\n 'other@example.com', # colleague\n] # people\n".into()), + ..Scripted::answering(&answers) + }; + setup(&mut outside); + assert_eq!(config(&outside).console.allowed_emails, expected); + let text = outside.user_config.as_deref().unwrap(); + for comment in ["# owner", "# colleague", "# people"] { + assert!(text.contains(comment), "lost {comment}:\n{text}"); + } + } +} From 780dcc75ce8d9321eb3caf5d600a3ecbeca97c0c Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 09:35:37 +0000 Subject: [PATCH 44/47] Answer the Console question in remaining Setup terminal scripts --- tests/muse_sessions.rs | 1 + tests/opencode_sessions.rs | 2 ++ tests/setup_offer.rs | 1 + 3 files changed, 4 insertions(+) diff --git a/tests/muse_sessions.rs b/tests/muse_sessions.rs index 2ae627ef..f4b77247 100644 --- a/tests/muse_sessions.rs +++ b/tests/muse_sessions.rs @@ -300,6 +300,7 @@ fn setup_checks_muses_proposed_model_with_the_same_flags_and_environment() { ("Security runs may fix reproduced findings?", ""), ("Runs review their change for Security findings?", ""), ("Run notifications", ""), + ("Run the Console?", ""), ], ); assert_eq!(result.code, Some(0), "{}", result.stderr); diff --git a/tests/opencode_sessions.rs b/tests/opencode_sessions.rs index e5db8990..2e616cd1 100644 --- a/tests/opencode_sessions.rs +++ b/tests/opencode_sessions.rs @@ -409,6 +409,7 @@ fn setup_proposes_no_model_and_checks_the_answer_standalone() { ("Security runs may fix reproduced findings?", ""), ("Runs review their change for Security findings?", ""), ("Run notifications", ""), + ("Run the Console?", ""), ], ); assert_eq!(result.code, Some(0), "{}", result.stderr); @@ -487,6 +488,7 @@ fn setup_retries_a_refused_model_and_effort_with_the_same_preflight_check() { ("Security runs may fix reproduced findings?", ""), ("Runs review their change for Security findings?", ""), ("Run notifications", ""), + ("Run the Console?", ""), ], ); assert_eq!(result.code, Some(0), "{}", result.stderr); diff --git a/tests/setup_offer.rs b/tests/setup_offer.rs index e9c7bacb..c5fbc686 100644 --- a/tests/setup_offer.rs +++ b/tests/setup_offer.rs @@ -87,6 +87,7 @@ fn accepting_and_choosing_merge_always_makes_that_run_a_merge_run() { (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + ("Run the Console?", ""), ], ); From a0c0c56d9c858dc0287036f3c193295baf8a7b80 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 14:24:10 +0000 Subject: [PATCH 45/47] Add custom Console Themes and local fonts (#825) --- console/README.md | 47 ++++ console/tests/custom-themes.test.cjs | 66 ++++++ docs/code-navigation.md | 1 + src/args.rs | 13 +- src/config/console.rs | 8 +- src/config/console/themes.rs | 311 +++++++++++++++++++++++++++ src/console.rs | 56 ++++- src/console/accounts.rs | 2 + src/console/assets/console.css | 2 + src/console/assets/look.js | 56 ++++- src/console/chrome.html | 8 + src/console/look.rs | 61 +++++- src/main.rs | 4 + tests/console/look.rs | 233 ++++++++++++++++++++ 14 files changed, 855 insertions(+), 13 deletions(-) create mode 100644 console/tests/custom-themes.test.cjs create mode 100644 src/config/console/themes.rs diff --git a/console/README.md b/console/README.md index df9818a2..db852779 100644 --- a/console/README.md +++ b/console/README.md @@ -15,6 +15,53 @@ npm test home folder and a free localhost port, and closes its own server and browser. Node and npm are test tools only; the release build embeds its assets. +The Themes picker offers **Make a Theme from this**, starting from any built-in +or custom Theme. Edit its JSON name and tokens, then **Save / import JSON**. +Choose the saved name in the picker to apply it. **Export JSON** gives a document +to copy into a file or paste into another Console; there is no file upload control. +Both CLI commands use exactly the same format and validation: + +```sh +thirdshift console theme export garden-dark > garden.json +thirdshift console theme import my-garden.json +thirdshift console theme list --json +thirdshift console theme my-garden +``` + +Custom Themes are `~/.thirdshift/console/themes/.json`. A minimal document: + +```json +{"name":"my-garden","base":"garden-dark","tokens":{"bg":"#142018","font-body":"mine.woff2, sans-serif"}} +``` + +`base` is a built-in name with auto/light/dark mode (default `focus-auto`). Missing +tokens get that base's defaults, including tokens added in later releases. Export +includes every token. Names use ASCII letters, digits, hyphens and underscores, +up to 100 characters; `list`, `import`, `export` and altered built-in names are +reserved. Unchanged built-in exports import successfully without replacing them. +Invalid JSON, unknown fields/tokens and invalid values are refused with a reason. +Malformed files placed in the folder by hand are omitted from the picker/list. +Saving records a `theme-saved` Console log event with its actor; selecting records +`theme-changed`. Saving does not select the Theme. + +Token values are strings. Each token accepts its values from any built-in Theme. +Colour tokens additionally accept hex colours (3/4/6/8 digits), `transparent`, +`currentColor`, or `light-dark(, )`. Body size accepts 10–32px, radius +0–32px, heading weight 100–900, heading case none/uppercase/lowercase/capitalize, +and activity speed 0.1–60s. Effects and activity motion use the built-in values, +keeping motion tied to activity. Arbitrary CSS and URLs are refused. User Themes +are their owner's responsibility to check for contrast. + +Font tokens (`font-head`, `font-body`, `font-mono`) accept a comma-separated list +of built-in families, system stacks (`system-ui`, `sans-serif`, `serif`, +`monospace`, `ui-sans-serif`, `ui-serif`, `ui-monospace`) and font filenames. +Copy `.woff2`, `.woff`, `.otf` or `.ttf` files by hand into the themes folder's +`fonts/` directory, then use e.g. `mine.woff2, serif`. Filenames use ASCII letters, +digits, dots, hyphens and underscores, with no leading dot, up to 100 characters. +Fonts come from the Console's own address; other extensions, directories and +symlinks (including a symlinked `fonts/` folder) are refused. Unknown or unavailable +families fall back to the supplied stack, then sans-serif (monospace for code). + To open your machine's Inbox locally without accounts, run `thirdshift console --no-sign-in` and visit the localhost address printed on stdout. To require sign-in, set the allowlist in `~/.thirdshift/config.toml`: diff --git a/console/tests/custom-themes.test.cjs b/console/tests/custom-themes.test.cjs new file mode 100644 index 00000000..f4c18fa8 --- /dev/null +++ b/console/tests/custom-themes.test.cjs @@ -0,0 +1,66 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { mkdtemp, mkdir, readFile, writeFile, copyFile, rm } = require('node:fs/promises'); +const { join, resolve } = require('node:path'); +const { tmpdir } = require('node:os'); +const { createInterface } = require('node:readline'); +const { chromium } = require('playwright'); +const { test } = require('node:test'); + +test('editor saves, chooses and round-trips Themes without uploading files', async () => { + const home = await mkdtemp(join(tmpdir(), 'thirdshift-custom-themes-')); + let server, exited, browser; + try { + const root = join(home, '.thirdshift'); + const fonts = join(root, 'console/themes/fonts'); + await mkdir(fonts, { recursive: true }); + await writeFile(join(root, 'config.toml'), '[console]\nport = 0\n'); + await copyFile(resolve(__dirname, '../../src/console/assets/fonts/inter-100-900-latin.woff2'), join(fonts, 'mine.woff2')); + server = spawn(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', '--no-sign-in'], { env: { ...process.env, HOME: home }, stdio: ['ignore', 'pipe', 'inherit'] }); + exited = once(server, 'exit'); + const lines = createInterface({ input: server.stdout }); + const [origin] = await once(lines, 'line', { signal: AbortSignal.timeout(10000) }); + lines.close(); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + const violations = []; + await page.exposeFunction('cspViolation', value => violations.push(value)); + await page.addInitScript(() => document.addEventListener('securitypolicyviolation', event => window.cspViolation(event.violatedDirective))); + await page.goto(origin); + await page.locator('[data-open-themes]').click(); + await page.locator('#theme-name').selectOption('garden-dark'); + await page.locator('#edit-theme').click(); + const document = JSON.parse(await page.locator('#theme-document').inputValue()); + assert.equal(document.base, 'garden-dark'); + document.name = 'my-garden'; + document.tokens.bg = '#123456'; + document.tokens['font-body'] = 'mine.woff2, serif'; + await page.locator('#theme-document').fill(JSON.stringify(document)); + await page.locator('#save-theme').click(); + await page.waitForFunction(() => document.querySelector('#theme-editor-status').textContent.includes('saved')); + assert.deepEqual(JSON.parse(await readFile(join(root, 'console/themes/my-garden.json'), 'utf8')), document); + await page.locator('#theme-name').selectOption('my-garden'); + await page.locator('#theme-form button').click(); + await page.waitForFunction(() => getComputedStyle(document.body).backgroundColor === 'rgb(18, 52, 86)'); + await page.evaluate(() => document.fonts.ready); + assert.equal(await page.evaluate(() => document.fonts.check('14px "user-mine.woff2"')), true); + assert.match(await page.locator('body').evaluate(el => getComputedStyle(el).fontFamily), /user-mine.woff2/); + await page.locator('#export-theme').click(); + const exported = await page.locator('#theme-document').inputValue(); + assert.deepEqual(JSON.parse(exported), document); + await page.locator('#save-theme').click(); + await page.waitForFunction(() => document.querySelector('#theme-editor-status').textContent.includes('saved')); + assert.deepEqual(JSON.parse(await readFile(join(root, 'console/themes/my-garden.json'), 'utf8')), document); + assert.equal(await page.locator('input[type=file]').count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await page.reload(); + await page.waitForFunction(() => getComputedStyle(document.body).backgroundColor === 'rgb(18, 52, 86)'); + assert.deepEqual(violations, []); + } finally { + await browser?.close(); + if (server && server.exitCode === null) server.kill(); + if (exited) await exited; + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 67f2aa3b..c5083e05 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -28,6 +28,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Console Logs page, conversation, log stream, exact log inventory | `src/logs/reading/catalog.rs` — current-layout inventory shared with `logs`; `src/console/logs.rs` — pages and byte-offset SSE; `src/console/conversation.rs` — Claude/Codex rendering | `tests/console.rs`, `tests/fixtures/console-*.jsonl`, `console/tests/logs.test.cjs` | | Console account, Console log, sign-in | `src/console/accounts.rs` — durable accounts, sessions, slowdown and account events; `src/console/auth.rs` — HTTP authorization and CSRF; `src/console/account_commands.rs` — CLI; `src/terminal.rs` — shared hidden terminal input | `src/console/accounts/tests.rs`, `tests/console/accounts.rs` | | Theme, hidden sections, Pause motion | `src/config/console.rs` — built-in names and restricted User config writes; `src/console/look.rs` — CLI/HTTP look actions; `src/console/assets/themes.css` — tokens; `src/console/assets/look.js` — picker and browser motion preference | `tests/console/look.rs`, `console/tests/inbox.test.cjs` | +| Custom Theme, Theme import/export, custom fonts | `src/config/console/themes.rs` — portable format, defaults, validation, folder and confined font reads; `src/console/look.rs` — shared saves and Console log; `src/console/assets/look.js` — JSON editor | `tests/console/look.rs`, `console/tests/custom-themes.test.cjs`; format in `console/README.md` | | Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — shared request, signaling, waiting and inspection; `src/console/stop.rs` — confirmation and authenticated Console action; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`, `tests/console/stop.rs`, `console/tests/stop.test.cjs`; inline Claim and Stop tests | | User config, Setup | `src/config.rs`; `src/setup.rs` | `tests/user_config.rs`, `tests/setup.rs` | diff --git a/src/args.rs b/src/args.rs index a3083534..da58d2c5 100644 --- a/src/args.rs +++ b/src/args.rs @@ -38,6 +38,8 @@ pub enum Command { name: Option, json: bool, }, + ConsoleThemeImport(String), + ConsoleThemeExport(String), ConsoleSections { hide: Vec, show: Vec, @@ -270,8 +272,17 @@ pub fn parse(args: &[String]) -> Result { if args.get(1).is_some_and(|action| action == "theme") { let json = args.last().is_some_and(|arg| arg == "--json"); let end = args.len() - usize::from(json); + if let [action, value] = &args[2..end] { + match action.as_str() { + "import" => return Ok(Command::ConsoleThemeImport(value.clone())), + "export" => return Ok(Command::ConsoleThemeExport(value.clone())), + _ => {} + } + } let [name] = &args[2..end] else { - bail!("usage: thirdshift console theme [--json]"); + bail!( + "usage: thirdshift console theme [--json] | import | export " + ); }; return Ok(Command::ConsoleTheme { name: (name != "list").then(|| name.clone()), diff --git a/src/config/console.rs b/src/config/console.rs index eae89064..726c1bfa 100644 --- a/src/config/console.rs +++ b/src/config/console.rs @@ -2,6 +2,8 @@ use anyhow::{Result, ensure}; use serde::{Deserialize, Serialize}; +pub mod themes; + pub const THEME_FAMILIES: &[&str] = &["focus", "plain", "press", "signal", "garden", "factory"]; pub const SECTIONS: &[&str] = &[ "needs-you", @@ -20,10 +22,8 @@ pub fn theme_names() -> Vec { } pub fn validate_theme(theme: &str) -> Result<()> { - ensure!( - theme_names().iter().any(|name| name == theme), - "unknown Console Theme: {theme}" - ); + themes::Theme::export(theme) + .map_err(|error| anyhow::anyhow!("unknown or invalid Console Theme {theme}: {error}"))?; Ok(()) } diff --git a/src/config/console/themes.rs b/src/config/console/themes.rs new file mode 100644 index 00000000..c6e70fa9 --- /dev/null +++ b/src/config/console/themes.rs @@ -0,0 +1,311 @@ +//! Portable Theme documents, validated before either CLI or page can save them. +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; + +const CSS: &str = include_str!("../../console/assets/themes.css"); +const FAMILIES: &[&str] = &[ + "Big Shoulders Display", + "JetBrains Mono", + "Inter", + "Caprasimo", + "Nunito", + "Barlow Condensed", + "Titillium Web", + "Atkinson Hyperlegible", + "IBM Plex Sans", + "IBM Plex Mono", +]; +const STACKS: &[&str] = &[ + "sans-serif", + "serif", + "monospace", + "system-ui", + "ui-sans-serif", + "ui-serif", + "ui-monospace", +]; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Theme { + pub name: String, + #[serde(default = "default_base")] + pub base: String, + #[serde(default)] + pub tokens: BTreeMap, +} + +fn default_base() -> String { + "focus-auto".into() +} + +pub fn directory() -> Result { + Ok(crate::config::home()?.join(".thirdshift/console/themes")) +} + +fn identifier(name: &str) -> bool { + !name.is_empty() + && name.len() <= 100 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +fn defaults(base: &str) -> Result> { + ensure!( + super::theme_names().iter().any(|name| name == base), + "unknown base Theme: {base}" + ); + let family = base.rsplit_once('-').expect("built-in mode").0; + let selector = format!(":root[data-theme^=\"{family}-\"] {{"); + let block = CSS + .split_once(&selector) + .expect("built-in tokens") + .1 + .split_once('}') + .expect("token block") + .0; + let mut tokens = BTreeMap::from([("activity-motion".into(), "none".into())]); + for declaration in block.split(';').filter_map(|s| s.trim().strip_prefix("--")) { + let (name, value) = declaration.split_once(':').expect("token declaration"); + tokens.insert(name.into(), value.trim().into()); + } + Ok(tokens) +} + +fn hex(value: &str) -> bool { + value.strip_prefix('#').is_some_and(|digits| { + matches!(digits.len(), 3 | 4 | 6 | 8) && digits.bytes().all(|b| b.is_ascii_hexdigit()) + }) +} + +fn color(value: &str) -> bool { + hex(value) + || matches!(value, "transparent" | "currentColor") + || value + .strip_prefix("light-dark(") + .and_then(|s| s.strip_suffix(')')) + .is_some_and(|pair| { + pair.split_once(',') + .is_some_and(|(a, b)| hex(a.trim()) && hex(b.trim())) + }) +} + +fn dimension(value: &str, unit: &str, min: f64, max: f64) -> bool { + value + .strip_suffix(unit) + .and_then(|s| s.parse::().ok()) + .is_some_and(|n| n >= min && n <= max) +} + +fn font_parts(value: &str) -> Result> { + let parts: Vec<_> = value + .split(',') + .map(|s| s.trim().trim_matches('"')) + .collect(); + ensure!( + parts.iter().all(|part| !part.is_empty() + && part.len() <= 100 + && part + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b' ' | b'.' | b'-' | b'_'))), + "invalid font family or stack" + ); + Ok(parts) +} + +impl Theme { + pub fn parse(text: &str) -> Result { + ensure!(text.len() <= 16 * 1024, "Theme JSON exceeds 16 KiB"); + let mut theme: Self = serde_json::from_str(text).context("invalid Theme JSON")?; + ensure!( + identifier(&theme.name) && !matches!(theme.name.as_str(), "list" | "import" | "export"), + "invalid or reserved Theme name: {}", + theme.name + ); + let mut tokens = defaults(&theme.base)?; + for (key, value) in &theme.tokens { + ensure!(tokens.contains_key(key), "unknown Theme token: {key}"); + // Existing built-in values remain valid, including their effects and + // activity-only motion. Arbitrary CSS never enters the stylesheet. + let builtin = super::THEME_FAMILIES.iter().any(|family| { + defaults(&format!("{family}-auto")) + .expect("built-in") + .get(key) + == Some(value) + }); + let valid = builtin + || match key.as_str() { + "font-head" | "font-body" | "font-mono" => font_parts(value).is_ok(), + "body-size" => dimension(value, "px", 10.0, 32.0), + "radius" => dimension(value, "px", 0.0, 32.0), + "head-weight" => value.parse::().is_ok_and(|n| (100..=900).contains(&n)), + "head-case" => matches!( + value.as_str(), + "none" | "uppercase" | "lowercase" | "capitalize" + ), + "activity-speed" => dimension(value, "s", 0.1, 60.0), + "activity-decoration" | "activity-motion" | "panel-shadow" | "title-shadow" => { + false + } + _ => color(value), + }; + ensure!(valid, "invalid Theme token {key}: {value}"); + tokens.insert(key.clone(), value.clone()); + } + theme.tokens = tokens; + if super::theme_names().contains(&theme.name) { + ensure!( + theme.base == theme.name && theme.tokens == defaults(&theme.name)?, + "built-in Theme {} cannot be replaced; choose a new name", + theme.name + ); + } + Ok(theme) + } + + pub fn export(name: &str) -> Result { + if super::theme_names().iter().any(|builtin| builtin == name) { + return Ok(Self { + name: name.into(), + base: name.into(), + tokens: defaults(name)?, + }); + } + ensure!(identifier(name), "invalid Theme name: {name}"); + let theme = Self::parse(&fs::read_to_string( + directory()?.join(format!("{name}.json")), + )?)?; + ensure!( + theme.name == name, + "Theme name does not match its filename: {name}" + ); + Ok(theme) + } + + pub fn save(&self) -> Result<()> { + // An unchanged built-in already exists on every installation. Accept + // its export without creating a file that could shadow it. + if super::theme_names().contains(&self.name) { + return Ok(()); + } + let root = directory()?; + fs::create_dir_all(&root)?; + let mut file = tempfile::NamedTempFile::new_in(&root)?; + file.write_all(serde_json::to_string_pretty(self)?.as_bytes())?; + file.persist(root.join(format!("{}.json", self.name)))?; + Ok(()) + } + + pub fn stylesheet(&self) -> String { + let mode = self.base.rsplit_once('-').expect("validated base").1; + let scheme = if mode == "auto" { "light dark" } else { mode }; + let mut faces = String::new(); + let mut declarations = format!( + ":root[data-theme=\"{}\"] {{ color-scheme: {scheme};", + self.name + ); + for (key, value) in &self.tokens { + let value = if key.starts_with("font-") { + let mut families = Vec::new(); + for family in font_parts(value).expect("validated font") { + if FAMILIES.contains(&family) { + families.push(format!("\"{family}\"")); + } else if STACKS.contains(&family) { + families.push(family.into()); + } else if font_type(family).is_some() && font(family).is_ok() { + faces.push_str(&format!("@font-face {{font-family: \"user-{family}\"; src: url(\"/assets/user-fonts/{family}\"); font-display: swap;}}\n")); + families.push(format!("\"user-{family}\"")); + } + } + families.push( + if key == "font-mono" { + "monospace" + } else { + "sans-serif" + } + .into(), + ); + families.join(", ") + } else { + value.clone() + }; + declarations.push_str(&format!("--{key}: {value};")); + } + format!("{faces}{declarations}}}\n") + } +} + +pub fn names() -> Result> { + let mut names = super::theme_names(); + let entries = match fs::read_dir(directory()?) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(names), + Err(error) => return Err(error.into()), + }; + let mut custom = Vec::new(); + for entry in entries { + let path = entry?.path(); + if path.extension().is_some_and(|s| s == "json") { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + if Theme::export(name).is_ok() { + custom.push(name.to_owned()); + } + } + } + } + custom.sort(); + names.extend(custom); + Ok(names) +} + +pub fn font_type(name: &str) -> Option<&'static str> { + if name.len() > 100 + || name.starts_with('.') + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) + { + return None; + } + match name.rsplit_once('.')?.1 { + "woff2" => Some("font/woff2"), + "woff" => Some("font/woff"), + "otf" => Some("font/otf"), + "ttf" => Some("font/ttf"), + _ => None, + } +} + +pub fn font(name: &str) -> Result> { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::OpenOptionsExt; + ensure!(font_type(name).is_some(), "unsupported font filename"); + // Pin the folder and refuse symlinks at open time, including replacement + // races. No pathname resolution can escape through a link or a slash. + let folder = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(directory()?.join("fonts"))?; + let name = std::ffi::CString::new(name)?; + let fd = unsafe { + libc::openat( + folder.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error().into()); + } + let mut file = unsafe { fs::File::from_raw_fd(fd) }; + ensure!(file.metadata()?.is_file(), "font is not a regular file"); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) +} diff --git a/src/console.rs b/src/console.rs index 79a64b39..d7942927 100644 --- a/src/console.rs +++ b/src/console.rs @@ -9,7 +9,7 @@ pub use install::install; mod live; mod logs; mod look; -pub use look::{sections, theme}; +pub use look::{export_theme, import_theme, sections, theme}; mod peek; mod restart; mod stop; @@ -59,6 +59,8 @@ enum View { SignIn, Look, Theme, + ThemeImport, + ThemeExport, Sections, StopPage, Stop, @@ -68,6 +70,18 @@ enum View { } const PAGES: &[PageRoute] = &[ + PageRoute { + path: "/themes", + cli: &["thirdshift console theme import theme.json"], + method: Method::POST, + view: View::ThemeImport, + }, + PageRoute { + path: "/themes/{name}", + cli: &["thirdshift console theme export focus-auto"], + method: Method::GET, + view: View::ThemeExport, + }, PageRoute { path: "/look", cli: &[ @@ -322,6 +336,8 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Route (View::SignIn, Method::POST) => post(auth::sign_in), (View::Look, Method::GET) => get(look::get), (View::Theme, Method::POST) => post(look::set_theme), + (View::ThemeImport, Method::POST) => post(look::import_document), + (View::ThemeExport, Method::GET) => get(look::export_document), (View::Sections, Method::POST) => post(look::set_sections), (View::StopPage, Method::GET) => get(stop::show), (View::Stop, Method::POST) => post(stop::submit), @@ -519,6 +535,12 @@ async fn render_inbox(cli: &'static str) -> Result { } async fn asset(Path(path): Path) -> Response { + if path == "user-themes.css" || path.starts_with("user-fonts/") { + return match tokio::task::spawn_blocking(move || user_asset(&path)).await { + Ok(response) => response, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + } let Some(file) = ASSETS.get_file(&path) else { return StatusCode::NOT_FOUND.into_response(); }; @@ -542,6 +564,38 @@ async fn asset(Path(path): Path) -> Response { .into_response() } +fn user_asset(path: &str) -> Response { + use crate::config::console::themes::{self, Theme}; + if let Some(name) = path.strip_prefix("user-fonts/") { + return match (themes::font_type(name), themes::font(name)) { + (Some(kind), Ok(bytes)) => ([(header::CONTENT_TYPE, kind)], bytes).into_response(), + _ => StatusCode::NOT_FOUND.into_response(), + }; + } + match themes::names() { + Ok(names) => { + let css: String = names + .iter() + .filter(|name| !crate::config::console::theme_names().contains(name)) + .filter_map(|name| Theme::export(name).ok()) + .map(|theme| theme.stylesheet()) + .collect(); + ( + [ + (header::CONTENT_TYPE, "text/css; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + ], + css, + ) + .into_response() + } + Err(error) => { + eprintln!("thirdshift Console: Theme stylesheet failed: {error:#}"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + #[cfg(test)] mod tests { use axum::{ diff --git a/src/console/accounts.rs b/src/console/accounts.rs index 09bd12aa..967c9a38 100644 --- a/src/console/accounts.rs +++ b/src/console/accounts.rs @@ -41,6 +41,7 @@ pub(super) enum EventKind { SignOut, BlockedAddress, ThemeChanged, + ThemeSaved, SectionsChanged, } @@ -54,6 +55,7 @@ impl std::fmt::Display for EventKind { Self::SignOut => "sign-out", Self::BlockedAddress => "blocked-address", Self::ThemeChanged => "theme-changed", + Self::ThemeSaved => "theme-saved", Self::SectionsChanged => "sections-changed", }) } diff --git a/src/console/assets/console.css b/src/console/assets/console.css index 751e9e7a..b3044918 100644 --- a/src/console/assets/console.css +++ b/src/console/assets/console.css @@ -1,4 +1,5 @@ @import url("/assets/themes.css"); +@import url("/assets/user-themes.css"); @font-face { font-family: "IBM Plex Sans"; src: url("/assets/fonts/ibm-plex-sans-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } @font-face { font-family: "IBM Plex Mono"; src: url("/assets/fonts/ibm-plex-mono-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; } @@ -56,6 +57,7 @@ dd dl { border-left: 1px solid var(--rule); padding-left: 8px; } #jumps { position: fixed; bottom: 20px; right: 20px; z-index: 20; box-shadow: 0 4px 20px #0003; } #jumps button { margin: 3px; } dialog::backdrop { background: #0006; } +#theme-editor textarea { display: block; width: 100%; max-width: 100%; font: 12px/1.4 var(--font-mono); color: var(--text); background: var(--bg); border: 1px solid var(--rule); } footer { margin-top: 16px; } #section-options label { display: block; margin: 8px 0; } .command { box-shadow: var(--panel-shadow); --tag-bg: var(--tag-done-bg); --tag-text: var(--tag-done-text); } diff --git a/src/console/assets/look.js b/src/console/assets/look.js index 87e02cea..0e37ac4b 100644 --- a/src/console/assets/look.js +++ b/src/console/assets/look.js @@ -9,6 +9,17 @@ let csrf; let revision = 0; let saving = false; let paused = false; +let themeInventory = ''; +async function refreshThemeStyles() { + const previous = document.querySelector('#user-theme-styles'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `/assets/user-themes.css?revision=${Date.now()}`; + const loaded = new Promise((resolve, reject) => { link.onload = resolve; link.onerror = reject; }); + document.head.append(link); + try { await loaded; previous?.remove(); link.id = 'user-theme-styles'; } + catch (error) { link.remove(); throw error; } +} try { paused = localStorage.getItem('thirdshift:console:pause-motion') === 'true'; } catch { /* optional storage */ } export function motionAllowed() { return !paused && !reduced.matches; } @@ -49,8 +60,16 @@ export async function loadLook() { if (requestedRevision !== revision || saving) return; csrf = settings.csrf; const picker = document.querySelector('#theme-name'); - if (!picker.options.length) { - for (const name of settings.themes) picker.add(new Option(name.replace('-', ' · '), name)); + const inventory = JSON.stringify(settings.themes); + if (inventory !== themeInventory || root.dataset.theme !== settings.theme) { + await refreshThemeStyles(); + if (requestedRevision !== revision || saving) return; + themeInventory = inventory; + const selected = picker.value; + picker.replaceChildren(...settings.themes.map(name => new Option(name.replace('-', ' · '), name))); + picker.value = settings.themes.includes(selected) ? selected : settings.theme; + } + if (!document.querySelector('#section-options input')) { for (const name of settings.sections) { const label = document.createElement('label'); const checkbox = document.createElement('input'); @@ -77,6 +96,39 @@ async function save(path, values) { } if (themes) { + const editor = document.querySelector('#theme-document'); + const editorStatus = document.querySelector('#theme-editor-status'); + const exportDocument = async copy => { + editorStatus.textContent = ''; + try { + const name = document.querySelector('#theme-name').value; + const response = await fetch(`/themes/${encodeURIComponent(name)}`, { cache: 'no-store' }); + if (!response.ok || response.redirected) throw new Error(await response.text()); + const theme = await response.json(); + if (copy) theme.name += '-copy'; + editor.value = JSON.stringify(theme, null, 2); + editor.focus(); + editorStatus.textContent = copy ? 'Choose a name and edit the tokens, then save.' : 'Exported JSON. Copy it to a file or paste it into another Console.'; + } catch (error) { editorStatus.textContent = `Could not export Theme: ${error.message}`; } + }; + document.querySelector('#edit-theme').addEventListener('click', () => exportDocument(true)); + document.querySelector('#export-theme').addEventListener('click', () => exportDocument(false)); + document.querySelector('#theme-editor').addEventListener('submit', async event => { + event.preventDefault(); + if (saving) return; + revision++; + saving = true; + editorStatus.textContent = ''; + try { + const response = await fetch('/themes', { method: 'POST', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf }, body: editor.value }); + if (!response.ok || response.redirected) throw new Error(await response.text()); + editor.value = JSON.stringify(await response.json(), null, 2); + await refreshThemeStyles(); + editorStatus.textContent = 'Theme saved. Choose it above to apply.'; + } catch (error) { editorStatus.textContent = `Could not save Theme: ${error.message}`; } + finally { saving = false; } + await loadLook(); + }); document.querySelector('[data-open-themes]').addEventListener('click', async () => { await loadLook(); themes.showModal(); }); document.querySelector('[data-close-themes]').addEventListener('click', () => themes.close()); document.querySelector('[data-open-sections]').addEventListener('click', async () => { await loadLook(); sections.showModal(); }); diff --git a/src/console/chrome.html b/src/console/chrome.html index efda6ca1..30ed23f0 100644 --- a/src/console/chrome.html +++ b/src/console/chrome.html @@ -3,6 +3,14 @@

        Themes

        thirdshift console theme <name>

        +

        +
        + +

        Edit the name and tokens, or paste an exported Theme. Save custom Themes to ~/.thirdshift/console/themes/. Copy fonts by hand into its fonts/ folder; name a file followed by its fallback stack, such as mine.woff2, sans-serif.

        + + +

        +
        diff --git a/src/console/look.rs b/src/console/look.rs index 6687940e..7efba0a1 100644 --- a/src/console/look.rs +++ b/src/console/look.rs @@ -8,8 +8,55 @@ use serde_json::json; use super::accounts::{Accounts, EventKind}; use super::auth::Viewer; +use crate::config::console::themes::{self, Theme}; use crate::config::{ConsoleSettings, UserConfig, console}; +fn save_theme(theme: &Theme, actor: &str) -> Result<()> { + let accounts = Accounts::load()?; + theme.save()?; + accounts.record(actor, EventKind::ThemeSaved)?; + Ok(()) +} + +pub fn import_theme(path: &str) -> Result { + let theme = Theme::parse(&std::fs::read_to_string(path)?)?; + save_theme(&theme, &cli_actor()?)?; + Ok(serde_json::to_string_pretty(&theme)?) +} + +pub fn export_theme(name: &str) -> Result { + Ok(serde_json::to_string_pretty(&Theme::export(name)?)?) +} + +pub(super) async fn export_document( + axum::extract::Path(name): axum::extract::Path, +) -> Response { + match tokio::task::spawn_blocking(move || Theme::export(&name)).await { + Ok(Ok(theme)) => Json(theme).into_response(), + Ok(Err(error)) => (StatusCode::BAD_REQUEST, error.to_string()).into_response(), + error => failed(error), + } +} + +pub(super) async fn import_document( + Extension(viewer): Extension, + text: String, +) -> Response { + let theme = match Theme::parse(&text) { + Ok(theme) => theme, + Err(error) => return (StatusCode::BAD_REQUEST, format!("{error:#}")).into_response(), + }; + match tokio::task::spawn_blocking(move || { + save_theme(&theme, &viewer.email)?; + Ok::<_, anyhow::Error>(theme) + }) + .await + { + Ok(Ok(theme)) => Json(theme).into_response(), + error => failed(error), + } +} + fn write_theme(name: &str, actor: &str) -> Result { console::validate_theme(name)?; let accounts = Accounts::load()?; @@ -42,9 +89,9 @@ fn cli_actor() -> Result { pub fn theme(name: Option<&str>, json_output: bool) -> Result { let Some(name) = name else { return Ok(if json_output { - serde_json::to_string(&console::theme_names())? + serde_json::to_string(&themes::names()?)? } else { - console::theme_names().join("\n") + themes::names()?.join("\n") }); }; let settings = write_theme(name, &cli_actor()?)?; @@ -69,11 +116,15 @@ pub fn sections(hide: &[String], show: &[String], json_output: bool) -> Result) -> Response { - match tokio::task::spawn_blocking(UserConfig::load).await { - Ok(Ok(config)) => Json(json!({ + match tokio::task::spawn_blocking(|| { + Ok::<_, anyhow::Error>((UserConfig::load()?, themes::names()?)) + }) + .await + { + Ok(Ok((config, names))) => Json(json!({ "theme": config.console.theme, "hidden_sections": config.console.hidden_sections, - "themes": console::theme_names(), "sections": console::SECTIONS, + "themes": names, "sections": console::SECTIONS, "csrf": viewer.csrf, })) .into_response(), diff --git a/src/main.rs b/src/main.rs index d64b53e7..dbf68f0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,8 @@ usage: thirdshift Run the factory on the iss thirdshift console sign-out [--email
        ] [--json] End an account's sessions thirdshift console log [--json] Read this machine's Console log thirdshift console theme [--json] Choose or list Console Themes + thirdshift console theme import Import a Theme JSON document + thirdshift console theme export Export a Theme as JSON thirdshift console sections [--hide
        ] [--show
        ] [--json] Choose visible sections thirdshift show [--json] Show one Command in full thirdshift stop [--requeue] [--json] Stop a live Command and wait for cleanup @@ -155,6 +157,8 @@ fn main() -> ExitCode { Ok(Command::ConsoleTheme { name, json }) => { return printed(console::theme(name.as_deref(), json)); } + Ok(Command::ConsoleThemeImport(path)) => return printed(console::import_theme(&path)), + Ok(Command::ConsoleThemeExport(name)) => return printed(console::export_theme(&name)), Ok(Command::ConsoleSections { hide, show, json }) => { return printed(console::sections(&hide, &show, json)); } diff --git a/tests/console/look.rs b/tests/console/look.rs index 6e10df3c..08b2e62a 100644 --- a/tests/console/look.rs +++ b/tests/console/look.rs @@ -8,6 +8,239 @@ fn cli(home: &Path, arguments: &[&str]) -> std::process::Output { .unwrap() } +#[test] +fn custom_theme_cli_round_trip_defaults_validation_and_log() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let source = home.path().join("theme.json"); + fs::write(&source, r##"{"name":"my-night","base":"press-dark","tokens":{"bg":"#123456","font-body":"my-font.woff2, sans-serif"}}"##).unwrap(); + let imported = cli( + home.path(), + &["console", "theme", "import", source.to_str().unwrap()], + ); + assert!( + imported.status.success(), + "{}", + String::from_utf8_lossy(&imported.stderr) + ); + let exported = cli(home.path(), &["console", "theme", "export", "my-night"]); + assert!( + exported.status.success(), + "{}", + String::from_utf8_lossy(&exported.stderr) + ); + let theme: Value = serde_json::from_slice(&exported.stdout).unwrap(); + assert_eq!(theme["tokens"]["bg"], "#123456"); + assert_eq!(theme["tokens"]["body-size"], "13px"); + assert_eq!(theme["tokens"]["font-body"], "my-font.woff2, sans-serif"); + assert_eq!( + serde_json::from_slice::( + &fs::read(home.path().join(".thirdshift/console/themes/my-night.json")).unwrap() + ) + .unwrap(), + theme + ); + fs::write(&source, &exported.stdout).unwrap(); + assert!( + cli( + home.path(), + &["console", "theme", "import", source.to_str().unwrap()] + ) + .status + .success() + ); + assert_eq!( + cli(home.path(), &["console", "theme", "export", "my-night"]).stdout, + exported.stdout + ); + assert!( + cli(home.path(), &["console", "theme", "my-night"]) + .status + .success() + ); + let listed = cli(home.path(), &["console", "theme", "list", "--json"]); + assert!( + serde_json::from_slice::(&listed.stdout) + .unwrap() + .as_array() + .unwrap() + .contains(&json!("my-night")) + ); + for (key, value) in [ + ("unknown", "red"), + ("bg", "url(https://example.test)"), + ("body-size", "huge"), + ("activity-motion", "spin 1s infinite"), + ] { + fs::write( + &source, + serde_json::to_vec(&json!({"name":"bad", "tokens":{key:value}})).unwrap(), + ) + .unwrap(); + let result = cli( + home.path(), + &["console", "theme", "import", source.to_str().unwrap()], + ); + assert!(!result.status.success(), "accepted {key}={value}"); + assert!(String::from_utf8_lossy(&result.stderr).contains(key)); + } + let events: Value = + serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout).unwrap(); + assert!( + events + .as_array() + .unwrap() + .iter() + .any(|event| event["event"] == "theme-saved") + ); +} + +#[test] +fn custom_theme_http_round_trip_and_fonts_are_confined() { + use std::os::unix::fs::symlink; + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + let settings: Value = console.get("/look").json().unwrap(); + let csrf = settings["csrf"].as_str().unwrap(); + let client = reqwest::blocking::Client::new(); + let post = |text: String, token: &str| { + client + .post(format!("{}/themes", console.address)) + .header("x-csrf-token", token) + .header("content-type", "application/json") + .body(text) + .send() + .unwrap() + }; + let theme = json!({"name":"http-theme","base":"garden-light","tokens":{"font-body":"custom.woff2, serif", "font-head":"Unknown Family, system-ui"}}); + assert_eq!(post(theme.to_string(), "wrong").status(), 403); + let saved = post(theme.to_string(), csrf); + assert_eq!(saved.status(), 200); + let saved: Value = saved.json().unwrap(); + assert_eq!( + post(saved.to_string(), csrf).json::().unwrap(), + saved + ); + assert_eq!( + console.get("/themes/http-theme").json::().unwrap(), + saved + ); + assert_eq!( + serde_json::from_slice::( + &cli(home.path(), &["console", "theme", "export", "http-theme"]).stdout + ) + .unwrap(), + saved + ); + let themes = home.path().join(".thirdshift/console/themes"); + assert_eq!( + serde_json::from_slice::(&fs::read(themes.join("http-theme.json")).unwrap()) + .unwrap(), + saved + ); + assert!( + console.get("/look").json::().unwrap()["themes"] + .as_array() + .unwrap() + .contains(&json!("http-theme")) + ); + for invalid in [ + json!({"name":"../escape"}), + json!({"name":"invalid", "tokens":{"bg":"red;display:none"}}), + json!({"name":"invalid", "tokens":{"font-body":"url(evil)"}}), + json!({"name":"invalid", "unknown":true}), + ] { + assert_eq!(post(invalid.to_string(), csrf).status(), 400); + } + fs::create_dir_all(themes.join("fonts")).unwrap(); + for (name, kind) in [ + ("custom.woff2", "font/woff2"), + ("other.woff", "font/woff"), + ("other.ttf", "font/ttf"), + ("other.otf", "font/otf"), + ] { + fs::write(themes.join("fonts").join(name), b"font fixture").unwrap(); + let response = console.get(&format!("/assets/user-fonts/{name}")); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], kind); + assert_eq!(response.bytes().unwrap(), b"font fixture"[..]); + } + fs::write(themes.join("fonts/not-font.txt"), "secret").unwrap(); + let outside = home.path().join("outside.woff2"); + fs::write(&outside, "secret").unwrap(); + symlink(&outside, themes.join("fonts/escape.woff2")).unwrap(); + fs::create_dir(themes.join("fonts/directory.woff2")).unwrap(); + for path in [ + "not-font.txt", + "escape.woff2", + "directory.woff2", + "%2e%2e%2foutside.woff2", + "nested/other.woff", + ] { + assert_eq!( + console.get(&format!("/assets/user-fonts/{path}")).status(), + 404, + "{path}" + ); + } + let css = console.get("/assets/user-themes.css").text().unwrap(); + assert!(css.contains("url(\"/assets/user-fonts/custom.woff2\")")); + assert!(css.contains("--font-head: system-ui, sans-serif;")); + assert!(!css.contains("Unknown Family")); + assert!(css.contains("color-scheme: light;")); + fs::rename(themes.join("fonts"), home.path().join("moved-fonts")).unwrap(); + symlink(home.path().join("moved-fonts"), themes.join("fonts")).unwrap(); + assert_eq!(console.get("/assets/user-fonts/custom.woff2").status(), 404); + let events: Value = + serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout).unwrap(); + assert!( + events + .as_array() + .unwrap() + .iter() + .any(|event| event["event"] == "theme-saved" + && event["email"] == "localhost (no sign-in)") + ); +} + +#[test] +fn built_in_theme_exports_can_be_imported_without_replacing_built_ins() { + let home = tempfile::tempdir().unwrap(); + configure(home.path()); + let source = home.path().join("builtin.json"); + for name in ["focus-auto", "garden-light", "factory-dark"] { + let exported = cli(home.path(), &["console", "theme", "export", name]); + assert!(exported.status.success()); + fs::write(&source, &exported.stdout).unwrap(); + let imported = cli( + home.path(), + &["console", "theme", "import", source.to_str().unwrap()], + ); + assert!( + imported.status.success(), + "{}", + String::from_utf8_lossy(&imported.stderr) + ); + assert_eq!(imported.stdout, exported.stdout); + let mut changed: Value = serde_json::from_slice(&exported.stdout).unwrap(); + changed["tokens"]["bg"] = json!("#123456"); + fs::write(&source, changed.to_string()).unwrap(); + assert!( + !cli( + home.path(), + &["console", "theme", "import", source.to_str().unwrap()] + ) + .status + .success() + ); + } + let listed: Value = + serde_json::from_slice(&cli(home.path(), &["console", "theme", "list", "--json"]).stdout) + .unwrap(); + assert_eq!(listed.as_array().unwrap().len(), 18); +} + #[test] fn look_cli_preserves_other_settings_and_comments() { let home = tempfile::tempdir().unwrap(); From f750e9ff5ea9d6d266d13ad577dc332aee5e5567 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 14:35:35 +0000 Subject: [PATCH 46/47] Address Theme review findings and retain refresh regressions --- console/tests/custom-themes.test.cjs | 28 ++++++++++++- src/config/console/themes.rs | 60 ++++++++++++++++------------ src/console/assets/look.js | 5 ++- src/console/look.rs | 7 +++- 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/console/tests/custom-themes.test.cjs b/console/tests/custom-themes.test.cjs index f4c18fa8..41f8adda 100644 --- a/console/tests/custom-themes.test.cjs +++ b/console/tests/custom-themes.test.cjs @@ -1,5 +1,5 @@ const assert = require('node:assert/strict'); -const { spawn } = require('node:child_process'); +const { spawn, execFileSync } = require('node:child_process'); const { once } = require('node:events'); const { mkdtemp, mkdir, readFile, writeFile, copyFile, rm } = require('node:fs/promises'); const { join, resolve } = require('node:path'); @@ -31,6 +31,7 @@ test('editor saves, chooses and round-trips Themes without uploading files', asy await page.locator('[data-open-themes]').click(); await page.locator('#theme-name').selectOption('garden-dark'); await page.locator('#edit-theme').click(); + await page.waitForFunction(() => document.querySelector('#theme-document').value.length > 0); const document = JSON.parse(await page.locator('#theme-document').inputValue()); assert.equal(document.base, 'garden-dark'); document.name = 'my-garden'; @@ -47,6 +48,7 @@ test('editor saves, chooses and round-trips Themes without uploading files', asy assert.equal(await page.evaluate(() => document.fonts.check('14px "user-mine.woff2"')), true); assert.match(await page.locator('body').evaluate(el => getComputedStyle(el).fontFamily), /user-mine.woff2/); await page.locator('#export-theme').click(); + await page.waitForFunction(() => document.querySelector('#theme-editor-status').textContent.includes('Exported JSON')); const exported = await page.locator('#theme-document').inputValue(); assert.deepEqual(JSON.parse(exported), document); await page.locator('#save-theme').click(); @@ -56,6 +58,30 @@ test('editor saves, chooses and round-trips Themes without uploading files', asy assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); await page.reload(); await page.waitForFunction(() => getComputedStyle(document.body).backgroundColor === 'rgb(18, 52, 86)'); + // Import changed contents under the selected name from another client. + document.tokens.bg = '#654321'; + const imported = join(home, 'updated.json'); + await writeFile(imported, JSON.stringify(document)); + execFileSync(process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'), ['console', 'theme', 'import', imported], { env: { ...process.env, HOME: home } }); + await page.locator('[data-open-themes]').click(); + await page.locator('#theme-name').selectOption('my-garden'); + await page.locator('#theme-form button').click(); + await page.waitForFunction(() => getComputedStyle(document.body).backgroundColor === 'rgb(101, 67, 33)'); + // Logs has no Inbox refresh. Choosing a previously unselected Theme must + // also fetch current contents when its name has not changed. + const binary = process.env.THIRDSHIFT_BINARY ?? resolve(__dirname, '../../target/debug/thirdshift'); + execFileSync(binary, ['console', 'theme', 'focus-auto'], { env: { ...process.env, HOME: home } }); + const logs = await browser.newPage(); + await logs.goto(`${origin}/logs`); + await logs.locator('[data-open-themes]').click(); + await logs.locator('[data-close-themes]').click(); + document.tokens.bg = '#abcdef'; + await writeFile(imported, JSON.stringify(document)); + execFileSync(binary, ['console', 'theme', 'import', imported], { env: { ...process.env, HOME: home } }); + await logs.locator('[data-open-themes]').click(); + await logs.locator('#theme-name').selectOption('my-garden'); + await logs.locator('#theme-form button').click(); + await logs.waitForFunction(() => getComputedStyle(document.body).backgroundColor === 'rgb(171, 205, 239)'); assert.deepEqual(violations, []); } finally { await browser?.close(); diff --git a/src/config/console/themes.rs b/src/config/console/themes.rs index c6e70fa9..7c45660a 100644 --- a/src/config/console/themes.rs +++ b/src/config/console/themes.rs @@ -8,18 +8,7 @@ use anyhow::{Context, Result, ensure}; use serde::{Deserialize, Serialize}; const CSS: &str = include_str!("../../console/assets/themes.css"); -const FAMILIES: &[&str] = &[ - "Big Shoulders Display", - "JetBrains Mono", - "Inter", - "Caprasimo", - "Nunito", - "Barlow Condensed", - "Titillium Web", - "Atkinson Hyperlegible", - "IBM Plex Sans", - "IBM Plex Mono", -]; +const CONSOLE_CSS: &str = include_str!("../../console/assets/console.css"); const STACKS: &[&str] = &[ "sans-serif", "serif", @@ -30,14 +19,31 @@ const STACKS: &[&str] = &[ "ui-monospace", ]; -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Serialize)] pub struct Theme { - pub name: String, + name: String, + base: String, + tokens: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ThemeDocument { + name: String, #[serde(default = "default_base")] - pub base: String, + base: String, #[serde(default)] - pub tokens: BTreeMap, + tokens: BTreeMap, +} + +fn built_in_font(family: &str) -> bool { + let declaration = format!("font-family: \"{family}\";"); + [CSS, CONSOLE_CSS].iter().any(|css| { + css.split("@font-face").skip(1).any(|face| { + face.split_once('}') + .is_some_and(|(block, _)| block.contains(&declaration)) + }) + }) } fn default_base() -> String { @@ -122,7 +128,12 @@ fn font_parts(value: &str) -> Result> { impl Theme { pub fn parse(text: &str) -> Result { ensure!(text.len() <= 16 * 1024, "Theme JSON exceeds 16 KiB"); - let mut theme: Self = serde_json::from_str(text).context("invalid Theme JSON")?; + let document: ThemeDocument = serde_json::from_str(text).context("invalid Theme JSON")?; + let mut theme = Self { + name: document.name, + base: document.base, + tokens: document.tokens, + }; ensure!( identifier(&theme.name) && !matches!(theme.name.as_str(), "list" | "import" | "export"), "invalid or reserved Theme name: {}", @@ -214,7 +225,7 @@ impl Theme { let value = if key.starts_with("font-") { let mut families = Vec::new(); for family in font_parts(value).expect("validated font") { - if FAMILIES.contains(&family) { + if built_in_font(family) { families.push(format!("\"{family}\"")); } else if STACKS.contains(&family) { families.push(family.into()); @@ -251,12 +262,11 @@ pub fn names() -> Result> { let mut custom = Vec::new(); for entry in entries { let path = entry?.path(); - if path.extension().is_some_and(|s| s == "json") { - if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { - if Theme::export(name).is_ok() { - custom.push(name.to_owned()); - } - } + if path.extension().is_some_and(|s| s == "json") + && let Some(name) = path.file_stem().and_then(|s| s.to_str()) + && Theme::export(name).is_ok() + { + custom.push(name.to_owned()); } } custom.sort(); diff --git a/src/console/assets/look.js b/src/console/assets/look.js index 0e37ac4b..7d15c607 100644 --- a/src/console/assets/look.js +++ b/src/console/assets/look.js @@ -10,6 +10,7 @@ let revision = 0; let saving = false; let paused = false; let themeInventory = ''; +let themeRevision = ''; async function refreshThemeStyles() { const previous = document.querySelector('#user-theme-styles'); const link = document.createElement('link'); @@ -61,10 +62,11 @@ export async function loadLook() { csrf = settings.csrf; const picker = document.querySelector('#theme-name'); const inventory = JSON.stringify(settings.themes); - if (inventory !== themeInventory || root.dataset.theme !== settings.theme) { + if (inventory !== themeInventory || root.dataset.theme !== settings.theme || themeRevision !== settings.theme_revision) { await refreshThemeStyles(); if (requestedRevision !== revision || saving) return; themeInventory = inventory; + themeRevision = settings.theme_revision; const selected = picker.value; picker.replaceChildren(...settings.themes.map(name => new Option(name.replace('-', ' · '), name))); picker.value = settings.themes.includes(selected) ? selected : settings.theme; @@ -89,6 +91,7 @@ async function save(path, values) { try { const response = await fetch(path, { method: 'POST', headers: { 'x-csrf-token': csrf }, body: new URLSearchParams(values) }); if (!response.ok || response.redirected) throw new Error('Save failed'); + if (path === '/theme') await refreshThemeStyles(); apply(await response.json()); message.textContent = 'Console look saved.'; } catch { message.textContent = 'Could not save the Console look. Reload to try again.'; } diff --git a/src/console/look.rs b/src/console/look.rs index 7efba0a1..85303d63 100644 --- a/src/console/look.rs +++ b/src/console/look.rs @@ -117,12 +117,15 @@ pub fn sections(hide: &[String], show: &[String], json_output: bool) -> Result) -> Response { match tokio::task::spawn_blocking(|| { - Ok::<_, anyhow::Error>((UserConfig::load()?, themes::names()?)) + let config = UserConfig::load()?; + let revision = super::accounts::digest(&export_theme(&config.console.theme)?); + Ok::<_, anyhow::Error>((config, themes::names()?, revision)) }) .await { - Ok(Ok((config, names))) => Json(json!({ + Ok(Ok((config, names, revision))) => Json(json!({ "theme": config.console.theme, + "theme_revision": revision, "hidden_sections": config.console.hidden_sections, "themes": names, "sections": console::SECTIONS, "csrf": viewer.csrf, From 11197833a76e42e450f6888de2cf8befec7008a9 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 20 Oct 2026 15:50:40 +0000 Subject: [PATCH 47/47] Fix whole-Spec Console review findings --- console/README.md | 13 ++- console/reproductions/header-machines.py | 21 ++++ console/reproductions/ready-dispatch.py | 32 ++++++ console/reproductions/waterfall-parity.py | 8 -- console/tests/logs.test.cjs | 113 ++++++++++++++++++++++ console/tests/themes-regressions.test.cjs | 14 --- src/console.rs | 25 +++-- src/console/assets/logs.js | 68 +++++++++++-- src/console/header.html | 3 + src/console/inbox.html | 8 +- src/console/logs.html | 4 +- src/console/logs.rs | 26 ++++- src/console/reader.html | 4 +- src/console/stop.html | 4 +- src/console/stop.rs | 4 +- src/console/time.html | 8 +- src/console/time.rs | 8 +- src/console/waterfall.html | 8 +- src/console/waterfall.rs | 8 +- src/logs/reading/inbox.rs | 48 ++++++--- src/logs/status.rs | 8 +- src/logs/status/inbox.rs | 28 +++++- tests/console.rs | 26 +++++ tests/status.rs | 25 ++++- 24 files changed, 408 insertions(+), 106 deletions(-) create mode 100644 console/reproductions/header-machines.py create mode 100644 console/reproductions/ready-dispatch.py delete mode 100644 console/reproductions/waterfall-parity.py create mode 100644 src/console/header.html diff --git a/console/README.md b/console/README.md index db852779..b04f33ff 100644 --- a/console/README.md +++ b/console/README.md @@ -157,9 +157,9 @@ boundary. These include waiting; they are not measured tool execution times. Unmatched calls stay visible with “Time unknown” and no bar. Session links open their retained evidence. The Waterfall browser test checks selection, scale, unknown times, escaping and phone layout; Rust checks matching and CLI parity. -The retained Spec-review checks are `python3 console/reproductions/waterfall-fractional.py`, -`python3 console/reproductions/waterfall-stage.py` and -`python3 console/reproductions/waterfall-parity.py` from the repo root after a build. +The retained Spec-review checks are `python3 console/reproductions/waterfall-fractional.py` +and `python3 console/reproductions/waterfall-stage.py` from the repo root after a build. +The Rust router test walks the registered routes and checks their CLI pairs. The fractional Run/stage boundaries also run in the Rust integration tests. `j` / `k` select headings and visible rows, wrapping at either end. `Space` @@ -193,6 +193,13 @@ event's byte offset; incomplete live lines wait until their newline arrives. The Follow newest line checkbox lets you read back while the Session grows. The Playwright log test checks appends, retained DOM identity, folding across batches, incomplete lines, reconnection and following on a phone viewport. +When streaming is unavailable, the reader polls finite appended-line batches +from its last byte offset without replacing the rendered conversation. + +Ready Runs dispatched by a Pass have their own Ready for review row in both +`status` and the Console. The complete Pass tree remains available in History +and `show`. Run `python3 console/reproductions/ready-dispatch.py` after a build +to check both Inbox views. A live Command's Details offers Stop beside its equivalent CLI command. The confirmation page explains `needs-triage`, offers Requeue instead diff --git a/console/reproductions/header-machines.py b/console/reproductions/header-machines.py new file mode 100644 index 00000000..e5eb36fb --- /dev/null +++ b/console/reproductions/header-machines.py @@ -0,0 +1,21 @@ +import json, os, pathlib, re, subprocess, tempfile, urllib.request +binary = str(pathlib.Path(os.environ.get('THIRDSHIFT_BINARY', 'target/debug/thirdshift')).resolve()) +other = 'https://other.example.test' +with tempfile.TemporaryDirectory(prefix='console-header-review-') as home: + root = pathlib.Path(home) / '.thirdshift' + root.mkdir() + (root / 'config.toml').write_text('[console]\nport = 0\nother_consoles = ["' + other + '"]\n') + env = dict(os.environ, HOME=home) + hostname = json.loads(subprocess.check_output([binary, 'status', '--json'], env=env))['hostname'] + server = subprocess.Popen([binary, 'console', '--no-sign-in'], env=env, stdout=subprocess.PIPE, text=True) + try: + origin = server.stdout.readline().strip() + missing = [] + for page in ['inbox', 'lanes', 'stages', 'waterfall', 'history', 'logs', 'security']: + html = urllib.request.urlopen(origin + '/?page=' + page, timeout=5).read().decode() + header = re.search(r']*>(.*?)', html, re.S).group(1) + if hostname not in header or 'href="' + other + '"' not in header: + missing.append(page) + assert not missing, 'Every Console page header should name this machine and link other Consoles; missing on: ' + ', '.join(missing) + finally: + server.terminate(); server.wait(timeout=5) diff --git a/console/reproductions/ready-dispatch.py b/console/reproductions/ready-dispatch.py new file mode 100644 index 00000000..2040bd66 --- /dev/null +++ b/console/reproductions/ready-dispatch.py @@ -0,0 +1,32 @@ +import datetime as dt +import json, os, pathlib, re, subprocess, tempfile, urllib.request +binary = str(pathlib.Path(os.environ.get('THIRDSHIFT_BINARY', 'target/debug/thirdshift')).resolve()) +with tempfile.TemporaryDirectory(prefix='console-ready-review-') as home: + root = pathlib.Path(home) / '.thirdshift' + folder = root / 'logs/acme/widgets/commands/pickup' + folder.mkdir(parents=True) + (root / 'config.toml').write_text('[console]\nport = 0\n') + now = dt.datetime.now(dt.timezone.utc).isoformat() + common = dict(version=1, repository='acme/widgets', hostname='fixture', process_id=0, + process_start='gone', harness='codex', started_at=now, ended_at=now, + session_log=None, stages=[], outcome='ready for review', cause=None, + pull_request='https://github.com/acme/widgets/pull/20') + dispatched = dict(common, kind='Run', name='acme/widgets#7', + issue=dict(number=7, url='https://github.com/acme/widgets/issues/7', title='Review me')) + outer = dict(common, kind='Pickup run', name='pickup-fixture', issue=None, + dispatched=[dispatched]) + (folder / 'pickup-fixture.command.json').write_text(json.dumps(outer)) + (folder / 'pickup-fixture.log').write_text('done\n') + env = dict(os.environ, HOME=home) + status = json.loads(subprocess.check_output([binary,'status','--json'],env=env)) + server = subprocess.Popen([binary,'console','--no-sign-in'],env=env,stdout=subprocess.PIPE,text=True) + try: + origin = server.stdout.readline().strip() + page = urllib.request.urlopen(origin,timeout=5).read().decode() + section = page.split('data-group="ready for review"',1)[1].split('

  • ',1)[0] + top = section.split('
      ',1)[0] + print('CLI top-level rows:', [(c['kind'], c.get('issue'), c['group']) for c in status['commands']]) + assert any(c['group']=='ready for review' and c['kind']=='Run' and c.get('issue',{}).get('number')==7 for c in status['commands']), 'Dispatched ready Run is absent as its own Ready for review row in status' + assert 'acme/widgets#7' in top, 'Dispatched ready Run is absent as its own Ready for review row in Console' + finally: + server.terminate(); server.wait(timeout=5) diff --git a/console/reproductions/waterfall-parity.py b/console/reproductions/waterfall-parity.py deleted file mode 100644 index 71e4ef82..00000000 --- a/console/reproductions/waterfall-parity.py +++ /dev/null @@ -1,8 +0,0 @@ -import pathlib, re - -source = (pathlib.Path(__file__).resolve().parents[2] / 'src/console.rs').read_text() -declaration = re.search(r'PageRoute\s*\{\s*path:\s*"/waterfall",(?P.*?)\n\s*\}', source, re.S) -assert declaration, 'Waterfall route must be registered in the shared parity table' -fields = declaration.group('fields') -print('Waterfall parity fields:', fields.strip(), flush=True) -assert re.search(r'thirdshift logs\b', fields), 'Waterfall parity declaration must include its logs CLI dependency alongside show --json' diff --git a/console/tests/logs.test.cjs b/console/tests/logs.test.cjs index e60b35dd..2f12eafe 100644 --- a/console/tests/logs.test.cjs +++ b/console/tests/logs.test.cjs @@ -109,3 +109,116 @@ test("a live Session appends without redraw, folds across batches, resumes offse assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); assert.deepEqual(errors, []); }); + +test("Session log polls appended lines when EventSource is unavailable", { skip: process.platform !== "linux" }, async t => { + await appendFile(log, "\n"); // Finish the preceding test's unterminated final event. + record.outcome = null; + record.ended_at = null; + await writeFile(recordPath, JSON.stringify(record)); + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + // An SSE-blocking intermediary still permits finite HTTP requests. + await page.addInitScript(() => { + window.EventSource = class extends EventTarget { + constructor() { + super(); + setTimeout(() => { + this.dispatchEvent(new Event('error')); + this.onerror?.(new Event('error')); + }, 20); + } + close() {} + }; + }); + await page.goto(`${origin}/logs`); + await page.getByRole('link', { name: /Session log/ }).click(); + const initial = await page.locator('#log-content .message').first().elementHandle(); + await appendFile(log, message('Visible through polling fallback')); + await page.getByText('Visible through polling fallback', { exact: true }).waitFor({ timeout: 12000 }); + assert.equal(await initial.evaluate(node => node === document.querySelector('#log-content .message')), true); +}); + +test("poll response cannot duplicate lines already appended by recovered SSE", { skip: process.platform !== "linux" }, async t => { + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + await page.addInitScript(() => { + window.EventSource = class extends EventTarget { + constructor(url) { super(); this.url = url; if (url.includes('/stream/')) window.readerSource = this; } + close() {} + }; + }); + let releasePoll, sawPoll, finishedPoll; + const release = new Promise(resolve => { releasePoll = resolve; }); + const captured = new Promise(resolve => { sawPoll = resolve; }); + const completed = new Promise(resolve => { finishedPoll = resolve; }); + let held = false; + await page.route('**/logs/*/stream/*', async route => { + if (held || route.request().headers().accept !== 'application/json') return route.continue(); + held = true; sawPoll(); + await release; + try { await route.fulfill({ response: await route.fetch() }); } + catch { /* Recovery may correctly abort this superseded poll. */ } + finally { finishedPoll(); } + }); + await page.goto(`${origin}/logs`); + await page.getByRole('link', { name: /Session log/ }).click(); + const path = await page.locator('#log-content').getAttribute('data-stream'); + await appendFile(log, message('First line after disconnect')); + await page.evaluate(() => window.readerSource.onerror(new Event('error'))); + await captured; + const first = await (await fetch(origin + path, { headers: { accept: 'application/json' } })).json(); + await page.evaluate(batch => { + window.readerSource.dispatchEvent(new Event('open')); + window.readerSource.dispatchEvent(new MessageEvent('lines', { data: JSON.stringify(batch) })); + }, first); + await page.getByText('First line after disconnect', { exact: true }).waitFor(); + await appendFile(log, message('Second line while old poll waits')); + releasePoll(); + await completed; + await page.waitForTimeout(250); + const secondPath = path.replace(/\d+$/, String(first.offset)); + const second = await (await fetch(origin + secondPath, { headers: { accept: 'application/json' } })).json(); + await page.evaluate(batch => window.readerSource.dispatchEvent(new MessageEvent('lines', { data: JSON.stringify(batch) })), second); + // The old poll was requested before first and contains both lines. It must + // be discarded; the recovered stream provides the second line exactly once. + await page.getByText('Second line while old poll waits', { exact: true }).waitFor({ timeout: 12000 }); + assert.equal(await page.getByText('First line after disconnect', { exact: true }).count(), 1, + 'Recovered SSE plus an older overlapping poll must append each Session line once'); +}); + +test('SSE recovery does not repeat polled lines', { skip: process.platform !== 'linux' }, async t => { + const context = await browser.newContext(); + t.after(() => context.close()); + const page = await context.newPage(); + await page.addInitScript(() => { + const NativeSource = window.EventSource; + window.EventSource = class extends EventTarget { + constructor(url) { super(); this.url = url; this.connect(); window.recoverySource = this; } + connect() { + this.native = new NativeSource(this.url); + this.native.addEventListener('open', () => { this.opened = true; this.dispatchEvent(new Event('open')); }); + this.native.addEventListener('lines', event => this.dispatchEvent(new MessageEvent('lines', { data: event.data, lastEventId: event.lastEventId }))); + this.native.addEventListener('log-error', event => this.dispatchEvent(new MessageEvent('log-error', { data: event.data }))); + } + close() { this.native.close(); } + pause() { this.native.close(); } + fail() { const event = new Event('error'); this.dispatchEvent(event); this.onerror?.(event); } + resume() { this.connect(); } + }; + }); + await page.goto(`${origin}/logs`); + await page.getByRole('link', { name: /Session log/ }).click(); + await page.waitForFunction(() => window.recoverySource?.opened); + await page.evaluate(() => window.recoverySource.pause()); + await appendFile(log, message('Delivered by polling')); + await page.evaluate(() => window.recoverySource.fail()); + await page.getByText('Delivered by polling', { exact: true }).waitFor({ timeout: 12000 }); + await appendFile(log, message('Delivered after SSE recovery')); + await page.evaluate(() => window.recoverySource.resume()); + await page.getByText('Delivered after SSE recovery', { exact: true }).waitFor(); + const visible = await page.locator('#log-content .message pre').allTextContents(); + assert.equal(visible.filter(text => text === 'Delivered by polling').length, 1, + `SSE replayed a line already appended by polling: ${JSON.stringify(visible)}`); +}); diff --git a/console/tests/themes-regressions.test.cjs b/console/tests/themes-regressions.test.cjs index c89468ba..9dcf621e 100644 --- a/console/tests/themes-regressions.test.cjs +++ b/console/tests/themes-regressions.test.cjs @@ -7,20 +7,6 @@ const { tmpdir } = require('node:os'); const { createInterface } = require('node:readline'); const { chromium } = require('playwright'); const { test } = require('node:test'); -const fs = require('node:fs'); - -test('state tag aliases resolve at the command that supplies their state', async () => { - const browser = await chromium.launch({ headless: true }); - try { - const page = await browser.newPage(); - const assets = resolve(__dirname, '../../src/console/assets'); - const css = fs.readFileSync(join(assets, 'themes.css'), 'utf8') + fs.readFileSync(join(assets, 'console.css'), 'utf8').replace('@import url("/assets/themes.css");', ''); - await page.setContent(`
    • Failed
    • `); - const actual = await page.locator('.state').evaluate(el => ({ background: getComputedStyle(el).backgroundColor, color: getComputedStyle(el).color })); - assert.equal(actual.background, 'rgb(236, 0, 140)', 'Press Failed tags should use --urgent as their solid background'); - assert.equal(actual.color, 'rgb(0, 0, 0)', 'Press Failed tags should use --on-urgent text'); - } finally { await browser.close(); } -}); async function reproduce(mode) { const home = await mkdtemp(join(tmpdir(), 'thirdshift824-spec-')); let server, browser, exited; diff --git a/src/console.rs b/src/console.rs index d7942927..e8039af7 100644 --- a/src/console.rs +++ b/src/console.rs @@ -34,6 +34,23 @@ use crate::logs::status::{self, Inbox, Row}; static ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/console/assets"); +/// The machine identity and look shared by the Console's reader pages. +struct PageContext { + hostname: String, + consoles: Vec, + theme: String, +} + +impl PageContext { + fn new(config: &UserConfig) -> Self { + Self { + hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), + consoles: config.console.other_consoles.clone(), + theme: config.console.theme.clone(), + } + } +} + // All page/action routes are registered here, beside their CLI equivalent. // Static resources carry no view or action and have their own fallback route. struct PageRoute { @@ -361,9 +378,7 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Route #[derive(Template)] #[template(path = "console/inbox.html")] struct InboxPage { - hostname: String, - consoles: Vec, - theme: String, + page: PageContext, hidden_sections: Vec, cli: &'static str, inbox: Inbox, @@ -461,9 +476,7 @@ async fn render_page( None => ("Inbox", cli), }; Ok(InboxPage { - hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), - consoles: config.console.other_consoles.clone(), - theme: config.console.theme.clone(), + page: PageContext::new(&config), hidden_sections: config.console.hidden_sections.clone(), cli, inbox: status::inbox(&config)?, diff --git a/src/console/assets/logs.js b/src/console/assets/logs.js index e7dde82c..6e2f4e35 100644 --- a/src/console/assets/logs.js +++ b/src/console/assets/logs.js @@ -4,9 +4,16 @@ const follow = document.querySelector("#follow-log"); const status = document.querySelector("#log-status"); const scroll = () => { if (follow.checked) window.scrollTo(0, document.documentElement.scrollHeight); }; follow.addEventListener("change", scroll); -const stream = new EventSource(content.dataset.stream); -stream.addEventListener("lines", event => { - const batch = JSON.parse(event.data); +let stream, polling, pollingRequest; +let stopped = false; +function stop() { + stopped = true; + stream?.close(); + clearInterval(polling); + pollingRequest?.abort(); +} +function append(batch) { + if (stopped) return; if (batch.offset > Number(content.dataset.offset)) { const fragment = document.createElement("template"); fragment.innerHTML = batch.html; @@ -25,9 +32,54 @@ stream.addEventListener("lines", event => { status.textContent = batch.done ? "Complete" : "Live"; content.dataset.live = String(!batch.done); activity(); - if (batch.done) stream.close(); -}); -stream.addEventListener("log-error", event => { status.textContent = event.data; stream.close(); }); -stream.onerror = () => { status.textContent = "Connection lost; reconnecting…"; }; -window.addEventListener("pagehide", () => stream.close()); + if (batch.done) stop(); +} +async function poll() { + if (stopped || pollingRequest) return; + pollingRequest = new AbortController(); + try { + const offset = content.dataset.offset; + const path = content.dataset.stream.replace(/\d+$/, offset); + const response = await fetch(path, { headers: { accept: 'application/json' }, cache: 'no-store', signal: pollingRequest.signal }); + if (!response.ok || response.redirected) throw new Error('Log unavailable'); + const batch = await response.json(); + // A recovered stream may already have appended part of this batch. + if (offset === content.dataset.offset) append(batch); + } catch (error) { + if (!stopped && error.name !== 'AbortError') status.textContent = 'Connection lost; retrying…'; + } finally { pollingRequest = undefined; } +} +function startPolling() { + if (stopped) return; + status.textContent = 'Connection lost; checking for new lines…'; + polling ??= setInterval(poll, 5000); + poll(); +} +function connect() { + const offset = content.dataset.offset; + const source = new EventSource(content.dataset.stream.replace(/\d+$/, offset)); + stream = source; + source.addEventListener('lines', event => { + if (source === stream) append(JSON.parse(event.data)); + }); + source.addEventListener('open', () => { + if (stopped || source !== stream) return; + clearInterval(polling); + polling = undefined; + pollingRequest?.abort(); + // Polling may have advanced beyond this connection's starting offset. + // Resume with a fresh stream so replayed batches cannot overlap the DOM. + if (offset !== content.dataset.offset) { + source.close(); + connect(); + } + }); + source.addEventListener('log-error', event => { + if (source === stream) { status.textContent = event.data; stop(); } + }); + source.onerror = () => { if (source === stream) startPolling(); }; +} +if (typeof EventSource === 'function') connect(); +else startPolling(); +window.addEventListener('pagehide', stop); scroll(); diff --git a/src/console/header.html b/src/console/header.html new file mode 100644 index 00000000..e98a6eab --- /dev/null +++ b/src/console/header.html @@ -0,0 +1,3 @@ +

      thirdshift / Console

      {{ page.hostname }}

      + {% if !page.consoles.is_empty() %}{% endif %} +
      diff --git a/src/console/inbox.html b/src/console/inbox.html index 9193c260..c3326dda 100644 --- a/src/console/inbox.html +++ b/src/console/inbox.html @@ -1,5 +1,5 @@ - + @@ -8,7 +8,7 @@ {{ self.needs_you() }} need you · thirdshift Console {% else %} - {{ title }} · {{ hostname }} · thirdshift + {{ title }} · {{ page.hostname }} · thirdshift {% endif %} @@ -18,9 +18,7 @@ -

      thirdshift / Console

      {{ hostname }}

      - {% if !consoles.is_empty() %}{% endif %} -
      + {% include "console/header.html" %} {% include "console/nav.html" %}

      {{ title }}

      {{ cli }}
      diff --git a/src/console/logs.html b/src/console/logs.html index 7a3b2653..9ba3eff6 100644 --- a/src/console/logs.html +++ b/src/console/logs.html @@ -1,7 +1,7 @@ - + Logs · thirdshift -

      thirdshift / Console

      {% include "console/nav.html" %} +{% include "console/header.html" %}{% include "console/nav.html" %}

      Logs

      thirdshift logs {% if !owner.is_empty() %}--command {{ owner }} {% endif %}--json
      {% if logs.is_empty() %}

      No logs found.

      {% endif %} diff --git a/src/console/logs.rs b/src/console/logs.rs index 1f66241f..b180afce 100644 --- a/src/console/logs.rs +++ b/src/console/logs.rs @@ -4,13 +4,13 @@ use std::io::{BufRead, BufReader, Seek, SeekFrom}; use anyhow::{Result, ensure}; use askama::Template; -use axum::Extension; use axum::extract::Path; use axum::http::{HeaderMap, StatusCode}; use axum::response::{ IntoResponse, Response, Sse, sse::{Event, KeepAlive}, }; +use axum::{Extension, Json}; use serde::Serialize; use super::conversation; @@ -20,7 +20,7 @@ use crate::logs::catalog::{self, Log}; #[derive(Template)] #[template(path = "console/logs.html")] struct LogsPage { - theme: String, + page: super::PageContext, logs: Vec, owner: String, } @@ -28,7 +28,7 @@ struct LogsPage { #[derive(Template)] #[template(path = "console/reader.html")] struct ReaderPage { - theme: String, + page: super::PageContext, log: Log, html: String, offset: u64, @@ -47,7 +47,7 @@ async fn inventory(owner: Option) -> Response { let config = UserConfig::load()?; let logs = catalog::load(&config, owner.as_deref())?; Ok(LogsPage { - theme: config.console.theme, + page: super::PageContext::new(&config), logs, owner: owner.unwrap_or_default(), } @@ -70,7 +70,7 @@ pub(super) async fn reader(Path(id): Path) -> Response { let html = conversation::render(&String::from_utf8_lossy(&lines.bytes), log.kind); Ok(Some( ReaderPage { - theme: config.console.theme, + page: super::PageContext::new(&config), log, html, offset: lines.offset, @@ -144,6 +144,22 @@ pub(super) async fn stream( Extension(viewer): Extension, headers: HeaderMap, ) -> Response { + // A finite request uses the same reader and offset as SSE when a proxy + // cannot keep streams open. It still crosses the normal account guard. + if headers + .get("accept") + .is_some_and(|value| value == "application/json") + { + return match tokio::task::spawn_blocking(move || { + appended(&UserConfig::load()?, &id, offset) + }) + .await + { + Ok(Ok(batch)) => Json(batch).into_response(), + Ok(Err(error)) => super::page(Ok(Err(error))), + Err(error) => super::page(Err(error)), + }; + } let offset = headers .get("last-event-id") .and_then(|value| value.to_str().ok()) diff --git a/src/console/reader.html b/src/console/reader.html index bc80d019..4eb53707 100644 --- a/src/console/reader.html +++ b/src/console/reader.html @@ -1,7 +1,7 @@ - + {{ log.kind }} · thirdshift -

      thirdshift / Console

      {% include "console/nav.html" %} +{% include "console/header.html" %}{% include "console/nav.html" %}

      {{ log.kind }}

      {{ log.name }} {% if log.live %}live{% endif %}

      thirdshift logs --file {{ log.id }}

      Command details

      {% if log.live %}Live{% else %}Complete{% endif %}

      diff --git a/src/console/stop.html b/src/console/stop.html index 04e14b73..728bc107 100644 --- a/src/console/stop.html +++ b/src/console/stop.html @@ -1,7 +1,7 @@ - + Stop · thirdshift -

      thirdshift / Console

      {% include "console/nav.html" %} +{% include "console/header.html" %}{% include "console/nav.html" %}

      {{ details.command }}

      {% if details.can_stop %} diff --git a/src/console/stop.rs b/src/console/stop.rs index 568db0d3..83ea35bf 100644 --- a/src/console/stop.rs +++ b/src/console/stop.rs @@ -11,7 +11,7 @@ use crate::{config::UserConfig, logs::stop}; #[derive(Template)] #[template(path = "console/stop.html")] struct StopPage { - theme: String, + page: super::PageContext, csrf: String, details: Peek, } @@ -25,7 +25,7 @@ pub(super) async fn show( let config = UserConfig::load()?; let inspection = stop::inspect(&config, &command)?; Ok(StopPage { - theme: config.console.theme, + page: super::PageContext::new(&config), csrf: viewer.csrf, details: Peek::new(command, inspection), } diff --git a/src/console/time.html b/src/console/time.html index 8c6e0d1f..481108eb 100644 --- a/src/console/time.html +++ b/src/console/time.html @@ -1,18 +1,16 @@ - + - {{ title }} · {{ hostname }} · thirdshift + {{ title }} · {{ page.hostname }} · thirdshift -

      thirdshift / Console

      {{ hostname }}

      - {% if !consoles.is_empty() %}{% endif %} -
      + {% include "console/header.html" %} {% include "console/nav.html" %}

      {{ title }}

      thirdshift status --all --json
      diff --git a/src/console/time.rs b/src/console/time.rs index 18addd7a..7400489d 100644 --- a/src/console/time.rs +++ b/src/console/time.rs @@ -9,9 +9,7 @@ use crate::logs::status::time::{self, Views}; #[derive(Template)] #[template(path = "console/time.html")] struct TimePage { - hostname: String, - consoles: Vec, - theme: String, + page: super::PageContext, title: &'static str, hours: String, views: Views, @@ -27,9 +25,7 @@ pub(super) async fn show(stages: bool, query: Option) -> Response { _ => 3.5, }; Ok(TimePage { - hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), - consoles: config.console.other_consoles.clone(), - theme: config.console.theme.clone(), + page: super::PageContext::new(&config), title: if stages { "Stages" } else { "Lanes" }, hours: hours.to_string(), views: time::load(&config, hours)?, diff --git a/src/console/waterfall.html b/src/console/waterfall.html index 8977c15d..334b9879 100644 --- a/src/console/waterfall.html +++ b/src/console/waterfall.html @@ -1,18 +1,16 @@ - + - Waterfall · {{ hostname }} · thirdshift + Waterfall · {{ page.hostname }} · thirdshift -

      thirdshift / Console

      {{ hostname }}

      - {% if !consoles.is_empty() %}{% endif %} -
      + {% include "console/header.html" %} {% include "console/nav.html" %}

      Waterfall

      thirdshift show <command> --json · thirdshift logs <command>
      diff --git a/src/console/waterfall.rs b/src/console/waterfall.rs index ef9f54b4..ac3a5948 100644 --- a/src/console/waterfall.rs +++ b/src/console/waterfall.rs @@ -9,9 +9,7 @@ use crate::logs::status::waterfall::{self, View}; #[derive(Template)] #[template(path = "console/waterfall.html")] struct WaterfallPage { - hostname: String, - consoles: Vec, - theme: String, + page: super::PageContext, view: View, } @@ -20,9 +18,7 @@ pub(super) async fn show(query: Option) -> Response { tokio::task::spawn_blocking(move || -> Result { let config = UserConfig::load()?; Ok(WaterfallPage { - hostname: crate::host::name().unwrap_or_else(|| "unknown".into()), - consoles: config.console.other_consoles.clone(), - theme: config.console.theme.clone(), + page: super::PageContext::new(&config), view: waterfall::load(&config, &super::query_value(&query, "command"))?, } .render()?) diff --git a/src/logs/reading/inbox.rs b/src/logs/reading/inbox.rs index ed9f05ef..7eabc515 100644 --- a/src/logs/reading/inbox.rs +++ b/src/logs/reading/inbox.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use chrono::{DateTime, FixedOffset, Local}; use serde::Serialize; -use super::{Command, Entry, Health}; +use super::{Command, Entry, Health, Observation}; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "lowercase")] @@ -81,7 +81,7 @@ impl NeedsYouKind { #[derive(Serialize)] pub(in crate::logs) struct InboxItem<'a> { #[serde(flatten)] - pub command: &'a Entry, + pub command: Observation<'a>, pub group: Group, pub needs_you_kind: Option, pub landing: bool, @@ -105,10 +105,10 @@ struct Facts<'a> { ready: bool, } -impl Entry { - fn facts(&self) -> Facts<'_> { +impl<'a> Observation<'a> { + fn facts(self) -> Facts<'a> { match self { - Entry::Record(command) => Facts { + Self::Record(command) => Facts { state: command.state, started_at: &command.record.started_at, ended_at: command.record.ended_at.as_deref(), @@ -117,7 +117,7 @@ impl Entry { ready: command.record.outcome.as_deref() == Some("ready for review") && command.record.pull_request.is_some(), }, - Entry::History(command) => Facts { + Self::History(command) => Facts { state: command.state, started_at: &command.started_at, ended_at: command.ended_at.as_deref(), @@ -143,7 +143,25 @@ pub(super) fn list( // An accepted duration can exceed chrono's calendar. In that case every // representable timestamp is inside the requested retention period. let cutoff = now.checked_sub_signed(chrono::Duration::days(days)); - let starts: Vec<_> = entries.iter().map(issue_starts).collect(); + let observations: Vec<_> = entries + .iter() + .flat_map(|entry| { + let (outer, ready): (_, Vec<_>) = match entry { + Entry::Record(command) => ( + Observation::Record(command), + command + .dispatched + .iter() + .filter(|run| Observation::Record(run).facts().ready) + .map(Observation::Record) + .collect(), + ), + Entry::History(command) => (Observation::History(command), Vec::new()), + }; + std::iter::once(outer).chain(ready) + }) + .collect(); + let starts: Vec<_> = observations.iter().copied().map(issue_starts).collect(); let mut latest = BTreeMap::new(); for starts in &starts { for (issue, start) in starts { @@ -154,12 +172,12 @@ pub(super) fn list( } } let mut items = Vec::new(); - for (command, starts) in entries.iter().zip(&starts) { + for (command, starts) in observations.into_iter().zip(&starts) { let facts = command.facts(); let ended = time(facts.ended_at); let problem = if facts.state == "died" { Some(NeedsYouKind::Died) - } else if matches!(command, Entry::Record(command) if facts.state == "ended" && super::super::stop::requested(&command.record_path).is_some_and(|request| !request.requeue)) + } else if matches!(command, Observation::Record(command) if facts.state == "ended" && !facts.ready && super::super::stop::requested(&command.record_path).is_some_and(|request| !request.requeue)) { Some(NeedsYouKind::Triage) } else if facts @@ -167,17 +185,17 @@ pub(super) fn list( .is_some_and(|outcome| outcome.contains("failed") || outcome == "review required") { Some(NeedsYouKind::Failed) - } else if matches!(command, Entry::Record(command) if command.record.security_findings_waiting) + } else if matches!(command, Observation::Record(command) if command.record.security_findings_waiting) { Some(NeedsYouKind::Finding) } else { None }; let live_problem = match command { - Entry::Record(command) if facts.state == "live" => live_problem(command), + Observation::Record(command) if facts.state == "live" => live_problem(command), _ => None, }; - let landing = matches!(command, Entry::Record(command) if is_landing(command)); + let landing = matches!(command, Observation::Record(command) if is_landing(command)); let needs_you_kind = live_problem.or_else(|| { problem.filter(|_| { ended @@ -272,7 +290,7 @@ fn is_landing(command: &Command) -> bool { .any(is_landing) } -fn issue_starts(entry: &Entry) -> BTreeMap<(String, u64), DateTime> { +fn issue_starts(entry: Observation<'_>) -> BTreeMap<(String, u64), DateTime> { let mut starts = BTreeMap::new(); fn collect(command: &Command, starts: &mut BTreeMap<(String, u64), DateTime>) { let record = &command.record; @@ -289,8 +307,8 @@ fn issue_starts(entry: &Entry) -> BTreeMap<(String, u64), DateTime> } } match entry { - Entry::Record(command) => collect(command, &mut starts), - Entry::History(command) => { + Observation::Record(command) => collect(command, &mut starts), + Observation::History(command) => { if let Some(issue) = &command.issue && let Some(start) = time(Some(&command.started_at)) { diff --git a/src/logs/status.rs b/src/logs/status.rs index a7eed137..24b90672 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -12,7 +12,7 @@ use chrono::Local; use serde::Serialize; use super::command_record::CommandRecord; -use super::reading::{Command, Entry, Group, IdlePass, InboxItem, Snapshot}; +use super::reading::{Command, Group, IdlePass, InboxItem, Observation, Snapshot}; use crate::config::UserConfig; use crate::issue::IssueUrl; @@ -112,8 +112,8 @@ pub fn show(json: bool, all: bool) -> Result<()> { println!("{}:", group.title()); for item in items { match item.command { - Entry::Record(command) => print_command(command, ""), - Entry::History(command) => print_history(command), + Observation::Record(command) => print_command(command, ""), + Observation::History(command) => print_history(command), } if item.count > 1 { let cause = item.cause().unwrap_or_default(); @@ -121,7 +121,7 @@ pub fn show(json: bool, all: bool) -> Result<()> { } } for item in references { - if let Entry::Record(command) = item.command { + if let Observation::Record(command) = item.command { let facts = &command.record; let name = facts.issue.as_ref().map_or_else( || facts.name.clone(), diff --git a/src/logs/status/inbox.rs b/src/logs/status/inbox.rs index fe51fd58..fd5058f1 100644 --- a/src/logs/status/inbox.rs +++ b/src/logs/status/inbox.rs @@ -128,7 +128,16 @@ pub(crate) fn archive( (repository.is_empty() || repository == repo) && (outcome.is_empty() || outcome == result) }) - .map(|entry| entry_row(entry, now, &config.logs_dir)) + .map(|entry| { + entry_row( + match entry { + Entry::Record(command) => Observation::Record(command), + Entry::History(command) => Observation::History(command), + }, + now, + &config.logs_dir, + ) + }) .collect::>()?; Ok(Archive { rows, @@ -137,10 +146,10 @@ pub(crate) fn archive( }) } -fn entry_row(entry: &Entry, now: DateTime, root: &std::path::Path) -> Result { +fn entry_row(entry: Observation<'_>, now: DateTime, root: &std::path::Path) -> Result { match entry { - Entry::Record(command) => row(command, now, None, root), - Entry::History(command) => { + Observation::Record(command) => row(command, now, None, root), + Observation::History(command) => { let (subject, title) = subject(&command.repository, &command.kind, command.issue.as_ref()); Ok(Row { @@ -186,6 +195,11 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { let now = Local::now(); let snapshot = Snapshot::load_with_silence(&config.logs_dir, now, config.silent_interval)?; let items = snapshot.inbox(false, config.inbox_days); + let ready_selectors: std::collections::BTreeSet<_> = items + .iter() + .filter(|item| item.group == Group::ReadyForReview) + .filter_map(|item| item.command.selector()) + .collect(); let sections = Group::ORDER .into_iter() .filter(|group| *group != Group::History) @@ -195,6 +209,10 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { .filter(|item| item.group == group) .map(|item| { let mut row = entry_row(item.command, now, &config.logs_dir)?; + // Ready dispatched Runs have their own Inbox row. Preserve + // the full tree in history/show without duplicating DOM IDs. + row.children + .retain(|child| !ready_selectors.contains(&child.selector)); if let Some(kind) = item.needs_you_kind { row.state = kind.title().into(); } @@ -209,7 +227,7 @@ pub(crate) fn inbox(config: &UserConfig) -> Result { group == Group::Landing && item.landing && item.group == Group::NeedsYou }) .filter_map(|item| { - let Entry::Record(command) = item.command else { + let Observation::Record(command) = item.command else { return None; }; let (subject, _) = subject( diff --git a/tests/console.rs b/tests/console.rs index fa263fcc..90713040 100644 --- a/tests/console.rs +++ b/tests/console.rs @@ -1018,6 +1018,32 @@ fn inbox_over_http_matches_status_and_embeds_only_local_resources() { assert!(html.contains("thirdshift status")); assert!(html.contains(status["hostname"].as_str().unwrap())); assert!(html.contains("https://other.example.test")); + for page in [ + "inbox", + "lanes", + "stages", + "waterfall", + "history", + "logs", + "security", + ] { + let html = console.get(&format!("/?page={page}")).text().unwrap(); + let header = html + .split("
      ") + .nth(1) + .unwrap() + .split("
      ") + .next() + .unwrap(); + assert!( + header.contains(status["hostname"].as_str().unwrap()), + "{page}: {header}" + ); + assert!( + header.contains("href=\"https://other.example.test\""), + "{page}: {header}" + ); + } assert!(html.contains("Ticket #99 · waiting on #98")); assert!(!html.contains("")); assert!(html.contains("Died")); diff --git a/tests/status.rs b/tests/status.rs index 9b96880d..a6e672ca 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -62,12 +62,23 @@ fn status_shows_each_idle_pass_until_its_own_kind_starts_work() { let result = scenario.run(flags); assert_eq!(result.code, Some(0), "{}", result.stderr); let status: Value = serde_json::from_str(&result.stdout).unwrap(); - assert_eq!(status["commands"].as_array().unwrap().len(), 1, "{status}"); - let pass = &status["commands"][0]; + let commands = status["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 2, "{status}"); + let pass = commands + .iter() + .find(|command| command["kind"] == "Pickup run") + .unwrap(); assert_eq!(pass["kind"], "Pickup run"); let dispatched = &pass["dispatched"][0]; assert_eq!(dispatched["issue"]["number"], 7); assert_eq!(dispatched["command_log"], pass["command_log"]); + let ready = commands + .iter() + .find(|command| command["kind"] == "Run") + .unwrap(); + assert_eq!(ready["group"], "ready for review"); + assert_eq!(ready["issue"]["number"], 7); + assert_eq!(ready["command_log"], dispatched["command_log"]); for command in [pass, dispatched] { assert_eq!(command["source"], "command_record"); assert_eq!(command["state"], "ended"); @@ -1252,7 +1263,15 @@ fn status_keeps_children_under_their_own_security_dispatch() { assert_eq!(result.code, Some(0), "{}", result.stderr); let status: Value = serde_json::from_str(&result.stdout).unwrap(); let commands = status["commands"].as_array().unwrap(); - assert_eq!(commands.len(), 2); + assert_eq!(commands.len(), 3); + let ready = commands + .iter() + .find(|command| command["kind"] == "Run" && command["issue"]["number"] == 20) + .unwrap(); + assert_eq!(ready["group"], "ready for review"); + assert_eq!(ready["children"].as_array().unwrap().len(), 1); + assert_eq!(ready["children"][0]["issue"]["number"], 42); + assert_eq!(ready["children"][0]["state"], "ended"); let pass = commands .iter() .find(|command| command["kind"] == "Security run")