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 c06d3ce1..c16866eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,71 @@ 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" +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,18 +178,101 @@ 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", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" 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" @@ -189,8 +337,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]] @@ -239,6 +387,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" @@ -248,6 +405,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" @@ -357,6 +535,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" @@ -380,10 +568,16 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "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 +635,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 +654,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -796,6 +997,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" @@ -907,6 +1114,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" @@ -1029,7 +1247,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]] @@ -1044,7 +1271,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]] @@ -1083,6 +1310,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1218,6 +1446,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" @@ -1325,6 +1559,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" @@ -1334,6 +1579,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" @@ -1470,17 +1738,28 @@ name = "thirdshift" version = "0.12.3" dependencies = [ "anyhow", + "argon2", + "askama", "axoupdater", + "axum", "chrono", + "futures-util", "include_dir", "libc", + "rand_core 0.6.4", "reqwest", "serde", "serde_json", + "serde_urlencoded", + "sha2", "signal-hook", + "subtle", "tempfile", + "tokio", + "tokio-stream", "toml", "toml_edit", + "tower-layer", ] [[package]] @@ -1557,6 +1836,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" @@ -1691,6 +1995,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" @@ -1727,6 +2037,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 2905b0e0..cc73872f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,13 @@ 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" +argon2 = "0.5.3" +askama = "0.16.1" +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "form", "json"] } # 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 @@ -20,15 +23,25 @@ anyhow = "1.0.104" # 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"] } +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" toml_edit = "0.25.15" +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" # 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 8a07f9fa..5e65b43f 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,64 @@ 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). - [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](GLOSSARY.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..b04f33ff --- /dev/null +++ b/console/README.md @@ -0,0 +1,226 @@ +# 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. + +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`: + +```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 +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
] [--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. + +`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, codes, session tokens or page views. + +With a Resend key in `RESEND_API_KEY` or the Credentials, **Set your password** +on the sign-in page creates an account or resets its password by email code. +Only allowed addresses receive mail, from `email.from`, but every address gets +the same answer, without waiting for address-dependent delivery. Codes are +stored as argon2id hashes, last 15 minutes and allow +5 tries. An address may receive one code a minute and 10 in a rolling 24 hours; +these limits survive restarts and password changes. A correct code verifies +the address, sets its password, ends its old sessions and signs it in. +The Console log records codes sent and passwords set. Without a key the flow +is off; ask the owner to use `thirdshift console password` instead. + +The header names this machine and links to those addresses, without fetching +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. + +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. + +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 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 +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. +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` +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, `b` Lanes, `t` Stages, `f` Waterfall, `h` History, `o` Logs, `s` Security, +`c` Console log, `a` Account, 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. +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`. +`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, +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. +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 +(`thirdshift stop --requeue`), and requires the current account's CSRF token. +Both actions use the CLI's Stop, including its process identity checks and +refusal to stop a single Ticket's Run. The Activity log names the Console +and the signed-in email; localhost mode records `localhost (no sign-in)`. +The page shows “still ending” and a stopping bar until cleanup records the +outcome, then lists the branches and pull requests left on `origin`. +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 +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/package-lock.json b/console/package-lock.json new file mode 100644 index 00000000..6ed5c888 --- /dev/null +++ b/console/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "console", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "apca-w3": "0.1.9", + "playwright": "1.64.0" + } + }, + "node_modules/apca-w3": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/apca-w3/-/apca-w3-0.1.9.tgz", + "integrity": "sha512-Zrf6AeBeQjNe/fxK7U1jCo5zfdjDl6T4/kdw5Xlky3G7u+EJTZkyItjMYQGtwf9pkftsINxcYyOpuLkzKf1ITQ==", + "dev": true, + "license": "Limited W3 License", + "dependencies": { + "colorparsley": "^0.1.8" + } + }, + "node_modules/colorparsley": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/colorparsley/-/colorparsley-0.1.8.tgz", + "integrity": "sha512-rObESTTTE6G5qO5WFwFxWPggpw4KfpxnLC6Ssl8bITBnNVRhDsyCeTRAUxWQNVTx2pRknKlO2nddYTxjkdNFaw==", + "dev": true, + "license": "AGPL v3" + }, + "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..b8399e53 --- /dev/null +++ b/console/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "scripts": { + "test": "node --test tests/*.test.cjs" + }, + "devDependencies": { + "apca-w3": "0.1.9", + "playwright": "1.64.0" + } +} 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/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/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/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/reproductions/waterfall-fractional.py b/console/reproductions/waterfall-fractional.py new file mode 100644 index 00000000..c0fbab23 --- /dev/null +++ b/console/reproductions/waterfall-fractional.py @@ -0,0 +1,28 @@ +import json, os, pathlib, re, subprocess, tempfile, urllib.request + +binary = pathlib.Path(__file__).resolve().parents[2] / 'target/debug/thirdshift' +with tempfile.TemporaryDirectory(prefix='827-spec-') as temp: + home = pathlib.Path(temp) + folder = home / '.thirdshift/logs/acme/widgets/commands/issue' + folder.mkdir(parents=True) + (home / '.thirdshift/config.toml').write_text('[console]\nport = 0\n') + session = folder / '7-20261001T120000+0000-implement.jsonl' + session.write_text(json.dumps({'type':'item.completed','item':{'id':'a','type':'command_execution','command':'rg widgets','status':'completed'}})+'\n') + record = dict(version=1,kind='Run',repository='acme/widgets',name='run-7', + issue=dict(number=7,url='https://github.com/acme/widgets/issues/7',title='Widgets'), + hostname='fixture',process_id=0,process_start='gone',harness='codex', + started_at='2026-10-01T12:00:00.100+00:00',ended_at='2026-10-01T12:01:00+00:00', + stages=[dict(name='implement',began_at='2026-10-01T12:00:00.100+00:00',session_log=str(session))], + session_log=str(session),outcome='merged') + (folder / '7-fixture.command.json').write_text(json.dumps(record)) + (folder / '7-fixture.log').write_text('thirdshift: 12:00:00 starting\nthirdshift: 12:00:00 implement: $ rg widgets\nthirdshift: 12:01:00 finished\n') + server = subprocess.Popen([str(binary),'console','--no-sign-in'],env={**os.environ,'HOME':str(home)},stdout=subprocess.PIPE,text=True) + try: + origin = server.stdout.readline().strip() + html = urllib.request.urlopen(origin+'/waterfall?command=widgets%237').read().decode() + tools = re.findall(r'
  • ')[1:] + assigned = [re.search(r'

    ([^<]*)

    ',stage).group(1) for stage in stages if 'class="waterfall-tool"' in stage] + print('Stage containing implement tool:',assigned,flush=True) + assert assigned == ['implement'], 'Session tool must nest under implement; whole-second clock incorrectly puts it in previous Setup stage' + finally: + server.terminate() + server.wait(timeout=10) diff --git a/console/tests/accounts.test.cjs b/console/tests/accounts.test.cjs new file mode 100644 index 00000000..be9c3e4f --- /dev/null +++ b/console/tests/accounts.test.cjs @@ -0,0 +1,69 @@ +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"]\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({ + "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 }, 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"); + 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(); + 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"); + assert.ok(!(await context.cookies()).some(cookie => cookie.name === "__Host-thirdshift-session")); +}); diff --git a/console/tests/custom-themes.test.cjs b/console/tests/custom-themes.test.cjs new file mode 100644 index 00000000..41f8adda --- /dev/null +++ b/console/tests/custom-themes.test.cjs @@ -0,0 +1,92 @@ +const assert = require('node:assert/strict'); +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'); +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(); + 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'; + 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(); + 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(); + 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)'); + // 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(); + if (server && server.exitCode === null) server.kill(); + if (exited) await exited; + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/console/tests/inbox.test.cjs b/console/tests/inbox.test.cjs new file mode 100644 index 00000000..69c3e814 --- /dev/null +++ b/console/tests/inbox.test.cjs @@ -0,0 +1,670 @@ +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, utimes, 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" }] }] + })); + 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"] + }); + 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("Shift+T switches all built-in Themes and saves hidden sections across browsers", async t => { + const page = await pageFor(t); + const remote = []; + page.on('request', request => { if (!request.url().startsWith(origin)) remote.push(request.url()); }); + 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']) { + const name = `${family}-${mode}`; + await page.selectOption('#theme-name', name); + const saved = page.waitForResponse(response => response.url() === `${origin}/theme`); + await page.getByRole('button', { name: 'Apply Theme', exact: true }).click(); + const response = await saved; + assert.equal(response.status(), 200, await response.text()); + assert.equal((await response.json()).theme, name); + await page.waitForFunction(name => document.documentElement.dataset.theme === name, name); + assert.match(await readFile(join(home, '.thirdshift/config.toml'), 'utf8'), new RegExp(`theme = "${name}"`)); + await page.evaluate(() => document.fonts.ready); + assert.equal(await page.evaluate(() => [...document.fonts].filter(f => f.status === 'error').length), 0); + } + } + await page.getByRole('button', { name: 'Close Themes' }).click(); + await page.getByRole('button', { name: 'Sections', exact: true }).click(); + await page.getByLabel('Show Done', { exact: true }).uncheck(); + await page.getByRole('button', { name: 'Save sections' }).click(); + await page.locator('[data-group="done"][hidden]').waitFor({ state: 'attached' }); + const other = await pageFor(t); + await other.goto(origin); + await other.locator('[data-group="done"][hidden]').waitFor({ state: 'attached' }); + assert.equal(await other.locator('html').getAttribute('data-theme'), 'factory-dark'); + assert.deepEqual(remote, [], 'Themes must load fonts and assets locally'); + } finally { await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); } +}); + +test("every built-in Theme text pair meets AA in both system modes, including solid tags", async t => { + const page = await pageFor(t); + const failures = []; + const { APCAcontrast, sRGBtoY } = await import('apca-w3'); + const luminance = rgb => rgb.map(v => v / 255).map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [.2126, .7152, .0722][i], 0); + const ratio = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05); + await page.goto(origin); + const backgrounds = { focus: ['rgb(247, 247, 245)', 'rgb(18, 18, 20)'], plain: ['rgb(252, 252, 251)', 'rgb(17, 18, 20)'], press: ['rgb(232, 230, 225)', 'rgb(10, 10, 10)'], signal: ['rgb(250, 250, 250)', 'rgb(17, 19, 21)'], garden: ['rgb(246, 240, 225)', 'rgb(22, 26, 20)'], factory: ['rgb(227, 230, 232)', 'rgb(28, 29, 31)'] }; + for (const family of ['focus', 'plain', 'press', 'signal', 'garden', 'factory']) { + for (const mode of ['auto', 'light', 'dark']) { + for (const system of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: system }); + const theme = `${family}-${mode}`; + const { pairs, background } = await page.evaluate(theme => { + document.documentElement.dataset.theme = theme; + const probe = document.createElement('span'); + document.body.append(probe); + const color = token => { + probe.style.color = `var(--${token})`; + return getComputedStyle(probe).color.match(/[\d.]+/g).slice(0, 3).map(Number); + }; + const pairs = []; + for (const background of ['bg', 'panel', 'panel-2', 'sel-bg']) { + for (const text of ['text', 'head', 'dim', 'link', 'urgent-text', 'look-text', 'working-text', 'ready-text']) { + pairs.push({ name: `${text} on ${background}`, text: color(text), background: color(background) }); + } + } + for (const state of ['urgent', 'look', 'working', 'landing', 'ready', 'done']) { + pairs.push({ name: `on-${state}`, text: color(`on-${state}`), background: color(state) }); + } + probe.remove(); + const texts = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + while (texts.nextNode()) { + const node = texts.currentNode; + if (!node.textContent.trim()) continue; + const range = document.createRange(); range.selectNode(node); + if (!range.getBoundingClientRect().width || !range.getBoundingClientRect().height) continue; + const style = getComputedStyle(node.parentElement); + let parent = node.parentElement; + while (parent && getComputedStyle(parent).backgroundColor === 'rgba(0, 0, 0, 0)') parent = parent.parentElement; + if (!parent) continue; + pairs.push({ name: `rendered ${node.textContent.trim().slice(0, 30)}`, text: style.color.match(/[\d.]+/g).slice(0, 3).map(Number), background: getComputedStyle(parent).backgroundColor.match(/[\d.]+/g).slice(0, 3).map(Number) }); + } + return { pairs, background: getComputedStyle(document.body).backgroundColor }; + }, theme); + assert.equal(background, backgrounds[family][(mode === 'auto' ? system : mode) === 'dark' ? 1 : 0], `${theme} must respect the chosen mode on ${system}`); + for (const pair of pairs) { + if (ratio(pair.text, pair.background) < 4.5) failures.push(`${theme} (${system}): ${pair.name} = ${ratio(pair.text, pair.background)}`); + if (family === 'focus' && !pair.name.startsWith('on-')) { + assert.ok(Math.abs(APCAcontrast(sRGBtoY(pair.text), sRGBtoY(pair.background))) >= 45, `${theme}: ${pair.name} below APCA Lc 45`); + } + if (pair.name.startsWith('on-')) { + const best = Math.max(ratio([0, 0, 0], pair.background), ratio([255, 255, 255], pair.background)); + assert.equal(ratio(pair.text, pair.background), best, `${theme}: ${pair.name} must use the better black or white`); + } + } + } + } + } + assert.deepEqual(failures, []); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'idle Commands must not animate'); +}); + +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)"); +}); + +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"); } +}); + +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("keyboard selection and the open peek survive an Inbox push", async t => { + const page = await pageFor(t); + await page.goto(`${origin}/?page=inbox`); + await page.keyboard.press('j'); + await page.keyboard.press('j'); + const row = page.locator('.is-selected.command'); + const id = await row.getAttribute('id'); + const selector = await row.getAttribute('data-command'); + const path = selector.split('::')[0]; + const original = await readFile(path, 'utf8'); + try { + await page.keyboard.press('Space'); + await page.locator('#peek dt').first().waitFor(); + const fixture = JSON.parse(original); + fixture.issue.title = 'Keyboard-selected Command after push'; + await writeFile(path, JSON.stringify(fixture)); + await row.locator('.issue-title').filter({ hasText: fixture.issue.title }).waitFor(); + await page.locator('#peek').getByText(fixture.issue.title, { exact: true }).waitFor(); + assert.equal(await row.getAttribute('id'), id); + assert.equal(await row.getAttribute('aria-current'), 'true'); + assert.equal(await page.locator('#peek').isVisible(), true); + await page.keyboard.press('Space'); + assert.equal(await page.locator('#peek').isVisible(), false); + await page.keyboard.press('g'); + await page.keyboard.press('w'); + assert.equal(await page.locator('.is-selected').evaluate(el => el.parentElement.dataset.group), 'working'); + } finally { await writeFile(path, original); } +}); + +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'); + await page.waitForFunction(id => document.getElementById(id).querySelector('.state').getAnimations({ subtree: true }).some(a => a.playState === 'running'), id, { timeout: 2000 }); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.reload(); + assert.equal(await page.getByRole('button', { name: 'Pause motion', exact: true }).getAttribute('aria-pressed'), 'true'); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + 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); + for (const family of ['garden', 'factory']) { + await writeFile(join(home, '.thirdshift/config.toml'), `[console]\nport = 0\ntheme = "${family}-auto"\n`); + await page.waitForFunction(theme => document.documentElement.dataset.theme === theme, `${family}-auto`); + assert.notEqual(await page.locator('header').evaluate(header => getComputedStyle(header, '::after').animationName), 'none'); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + await appendFile(session, ' paused growth'); + await page.waitForTimeout(350); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + } + 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('.state').evaluate(state => getComputedStyle(state).animationIterationCount), '3'); + assert.equal(await row.locator('.heartbeat').evaluate(dot => dot.getAnimations().length), 0); + await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0); + 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); + fixture.outcome = 'merged'; fixture.ended_at = new Date().toISOString(); + await writeFile(path, JSON.stringify(fixture)); + await page.waitForFunction(() => document.documentElement.dataset.live === 'false'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + assert.equal(await page.evaluate(() => document.getAnimations().length), 0, 'an ended Command must stop the garden and conveyor'); + } finally { + await Promise.all([path, log, session].map(path => rm(path, { force: true }))); + await writeFile(join(home, '.thirdshift/config.toml'), '[console]\nport = 0\n'); + } +}); + +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); } +}); + +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 [['2', 'lanes'], ['3', 'stages'], ['4', 'waterfall'], ['5', 'history'], ['6', 'logs'], ['7', 'security'], ['1', 'inbox']]) { + await page.keyboard.press(key); + await page.waitForURL(`**/?page=${name}`); + await page.reload(); + assert.equal(await page.locator('main h1, main 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 [letter, name] of [['b', 'lanes'], ['t', 'stages'], ['f', 'waterfall'], ['h', 'history'], ['o', 'logs'], ['s', 'security'], ['i', 'inbox']]) { + await page.keyboard.press('g'); + await page.keyboard.press(letter); + await page.waitForURL(`**/?page=${name}`); + } + for (const [letter, path] of [['a', '/account'], ['c', '/console-log']]) { + await page.keyboard.press('g'); + await page.keyboard.press(letter); + await page.waitForURL(`${origin}${path}`); + await page.goto(`${origin}/?page=inbox`); + } + 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 } }); + const streams = []; + const errors = []; + page.on('request', request => { if (new URL(request.url()).pathname === '/events') streams.push(request.url()); }); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`${origin}/?page=history`); + assert.match(await page.title(), /^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/); + assert.match(await page.title(), /^Security ·/); + assert.deepEqual(streams, []); + assert.deepEqual(errors, []); + await page.locator('#peek a').filter({ hasText: 'Session log:' }).click(); + 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'); + } +}); + +test('Logs rows open the shared reader and page keys work from the reader', async t => { + const page = await pageFor(t); + await page.goto(`${origin}/?page=logs`); + await page.keyboard.press('j'); + assert.equal(await page.locator('.is-selected.log-row').count(), 1); + await page.keyboard.press('z'); + await page.keyboard.press('Enter'); + await page.waitForURL(/\/logs\/[a-f0-9]+$/); + await page.keyboard.press('5'); + await page.waitForURL('**/?page=history'); + await page.keyboard.press('g'); + await page.keyboard.press('o'); + await page.waitForURL('**/?page=logs'); +}); diff --git a/console/tests/logs.test.cjs b/console/tests/logs.test.cjs new file mode 100644 index 00000000..2f12eafe --- /dev/null +++ b/console/tests/logs.test.cjs @@ -0,0 +1,224 @@ +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(); + 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")); + 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, []); +}); + +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/stop.test.cjs b/console/tests/stop.test.cjs new file mode 100644 index 00000000..66775673 --- /dev/null +++ b/console/tests/stop.test.cjs @@ -0,0 +1,110 @@ +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, folder; +async function firstLine(child) { + const lines = createInterface({ input: child.stdout }); + try { return (await once(lines, 'line', { signal: AbortSignal.timeout(10000) }))[0]; } + finally { lines.close(); } +} +before(async () => { + home = await mkdtemp(join(tmpdir(), 'thirdshift-console-stop-')); + 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'); + 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'); + origin = await firstLine(server); + 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 }); +}); + +for (const requeue of [false, true]) { + test(`Stop confirms, cancels and streams completion (${requeue ? 'requeue' : 'triage'})`, { skip: process.platform !== 'linux' }, async t => { + // A disposable live process accepts TERM; HTTP tests separately exercise + // real Command cleanup, fake Harness and fake GitHub label changes. + const command = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); console.log('ready'); setInterval(() => {}, 1000)"], { stdio: ['ignore', 'pipe', 'inherit'] }); + const ended = once(command, 'exit'); + t.after(async () => { command.kill('SIGKILL'); await ended; }); + await firstLine(command); + const stat = await readFile(`/proc/${command.pid}/stat`, 'utf8'); + const ticks = stat.slice(stat.lastIndexOf(') ') + 2).trim().split(/\s+/)[19]; + const identity = `${(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()}:${ticks}`; + const path = join(folder, '7-fixture.command.json'); + const request = join(folder, '7-fixture.command.stop.json'); + const record = { + version: 1, kind: 'Run', name: 'acme/widgets#7', repository: 'acme/widgets', hostname: 'fixture', + issue: { number: 7, url: 'https://github.com/acme/widgets/issues/7', title: 'Work to stop' }, + process_id: command.pid, process_start: identity, harness: 'codex', + started_at: new Date().toISOString(), ended_at: null, stages: [], outcome: null + }; + await rm(request, { force: true }); + await writeFile(path, JSON.stringify(record)); + await writeFile(join(folder, '7-fixture.log'), 'Waiting for Stop\n'); + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: requeue ? 'reduce' : 'no-preference' }); + t.after(() => context.close()); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + // Each existing page can reach the same exact Command's Stop control. + for (const route of ['/', '/lanes', '/stages', '/waterfall', '/history', '/logs', '/security']) { + await page.goto(`${origin}${route}`); + if (route === '/history' || route === '/security') { + // These lists contain ended/Security Commands; use their shared peek + // route directly to verify the live Command's action is available. + await page.goto(`${origin}/?page=waterfall`); + } + if (route === '/') { + await page.locator('[data-group="working"] > summary').click(); + if (!requeue) await page.getByRole('button', { name: 'Pause motion', exact: true }).click(); + } + await page.locator('.peek-link').first().click(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + } + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + await page.getByRole('heading', { name: 'Stop this Command?' }).waitFor(); + assert.match(await page.locator('main').textContent(), /needs-triage/); + await page.getByRole('link', { name: 'Cancel', exact: true }).click(); + assert.equal(await readFile(request, 'utf8').catch(() => null), null); + await page.goto(`${origin}/stop?command=widgets%237`); + const observer = await context.newPage(); + await observer.goto(`${origin}/waterfall`); + await observer.locator('.peek-link').first().click(); + await observer.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + assert.equal((await context.request.post(`${origin}/stop`, { form: { command: 'widgets#7', confirmed: 'yes', ending: 'triage' } })).status(), 403); + await page.getByRole('button', { name: requeue ? 'Requeue instead' : 'Confirm Stop', exact: true }).click(); + await page.getByText('still ending', { exact: true }).waitFor(); + assert.equal(await page.locator('.stopping-bar').evaluate(node => getComputedStyle(node).animationName), 'none'); + assert.equal(await page.getByRole('button', { name: /Stop|Requeue/ }).count(), 0); + const asked = JSON.parse(await readFile(request, 'utf8')); + assert.equal(asked.requeue, requeue); + assert.equal(asked.from_console, true); + await page.evaluate(() => { window.sameDocument = true; }); + record.outcome = 'interrupted'; + record.ended_at = new Date().toISOString(); + record.issue_branch = 'issue-7'; + record.pull_request = 'https://github.com/acme/widgets/pull/10'; + await writeFile(path, JSON.stringify(record)); + await page.getByText('Issue branch left: issue-7', { exact: true }).waitFor(); + await page.getByText('Pull request left: https://github.com/acme/widgets/pull/10', { exact: true }).waitFor(); + await observer.getByRole('button', { name: 'Stop', exact: true }).waitFor({ state: 'hidden' }); + assert.equal(await page.evaluate(() => window.sameDocument), true); + assert.equal(await page.getByText('still ending', { exact: true }).count(), 0); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.deepEqual(errors, []); + }); +} diff --git a/console/tests/themes-regressions.test.cjs b/console/tests/themes-regressions.test.cjs new file mode 100644 index 00000000..9dcf621e --- /dev/null +++ b/console/tests/themes-regressions.test.cjs @@ -0,0 +1,68 @@ +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'); +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/console/tests/time.test.cjs b/console/tests/time.test.cjs new file mode 100644 index 00000000..b41ec6a8 --- /dev/null +++ b/console/tests/time.test.cjs @@ -0,0 +1,125 @@ +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`); + } + // 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 }); + 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(), 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(); + 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 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(); + 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/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 9761832f..a9935764 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -22,8 +22,17 @@ Start with the issue's terms in `GLOSSARY.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` | +| 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, each `--session` word matched by `src/session/kind.rs` — `selects`; `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 | +| 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` | | Architecture review, Architect run | `src/architect.rs` | `tests/architect_run.rs` | | Security audit, Security review, Security run | `src/security.rs` and `src/security/`; which Runs run a Security review, and what every child Run is handed: `src/child_run/asks.rs` — `ChildAsks` | Inline tests; `tests/security_review.rs`, `tests/security_run.rs` | diff --git a/docs/guide.md b/docs/guide.md index a678e88b..846a465b 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. @@ -171,7 +186,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. 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: @@ -363,6 +384,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 +435,11 @@ 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 + +[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] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -486,6 +513,12 @@ 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. + +`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](#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 b39085b9..47e58b5f 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -127,7 +127,16 @@

    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:

    @@ -209,7 +223,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. 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,
    @@ -364,6 +381,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 +429,11 @@
     
     [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
    +
    +[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]
     quiet_skips = false   # a skipped Pass prints nothing, leaving only its Activity log line; default false
    @@ -474,6 +497,9 @@
     

    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.

    +

    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/args.rs b/src/args.rs index 541e6608..da58d2c5 100644 --- a/src/args.rs +++ b/src/args.rs @@ -6,7 +6,7 @@ use std::iter::Peekable; use std::mem::discriminant; use std::num::NonZeroUsize; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use crate::asks::Flags; use crate::base_fix::BaseFixAsk; @@ -22,6 +22,29 @@ pub enum Command { Version, Update, Setup, + Console { + no_sign_in: bool, + }, + ConsoleInstall, + ConsolePassword(Option), + ConsoleSignOut { + email: Option, + json: bool, + }, + ConsoleLog { + json: bool, + }, + ConsoleTheme { + name: Option, + json: bool, + }, + ConsoleThemeImport(String), + ConsoleThemeExport(String), + ConsoleSections { + hide: Vec, + show: Vec, + json: bool, + }, Status { json: bool, all: bool, @@ -33,12 +56,21 @@ pub enum Command { Stop { command: String, requeue: bool, + json: bool, }, Logs { command: String, session: Option, follow: bool, }, + LogsList { + json: bool, + owner: Option, + }, + 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. @@ -236,20 +268,115 @@ 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("stop") => { + Some("console") => { + 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] | import | export " + ); + }; + return Ok(Command::ConsoleTheme { + name: (name != "list").then(|| name.clone()), + json, + }); + } + if args.get(1).is_some_and(|action| action == "sections") { + let (mut hide, mut show, mut json) = (Vec::new(), Vec::new(), false); + let mut arguments = args[2..].iter(); + while let Some(flag) = arguments.next() { + match flag.as_str() { + "--json" if !json => json = true, + "--hide" | "--show" => { + let section = arguments + .next() + .filter(|s| !s.starts_with('-')) + .context("--hide and --show need a section name")?; + let target = if flag == "--hide" { + &mut hide + } else { + &mut show + }; + target.extend(section.split(',').map(str::to_owned)); + } + _ => bail!( + "usage: thirdshift console sections [--hide
    ] [--show
    ] [--json]" + ), + } + } + return Ok(Command::ConsoleSections { hide, show, json }); + } + 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..] { - [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, - }) + [] => 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('-') => + { + Ok(Command::ConsolePassword(Some(email.clone()))) } - _ => bail!("usage: thirdshift stop [--requeue]"), + [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] | install | password [--email
    ] | sign-out [--email
    ] [--json] | log [--json]" + ), + }; + } + Some("stop") => { + 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..] { @@ -267,6 +394,49 @@ pub fn parse(args: &[String]) -> Result { }; } Some("logs") => { + match &args[1..] { + [] => { + 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(), + 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/asks.rs b/src/asks.rs index f8a91fd2..e0134ec4 100644 --- a/src/asks.rs +++ b/src/asks.rs @@ -240,10 +240,14 @@ mod tests { /// default. fn no_settings() -> UserConfig { UserConfig { + console: crate::config::ConsoleSettings::default(), merge_always: false, base_fix: false, 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), + inbox_days: 7, quiet_skips: false, email: EmailSettings::default(), spec_parallel: n(3), diff --git a/src/ci.rs b/src/ci.rs index fe7a774c..72f3ca8f 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..bd84508b 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.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 fca5be29..d8682998 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; @@ -15,10 +16,14 @@ use toml_edit::{DocumentMut, Item}; use crate::harness::{self, Harness, ModelAndEffort}; +pub mod console; +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. #[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`. @@ -30,6 +35,13 @@ 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, + /// `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, @@ -71,6 +83,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()?; @@ -86,10 +107,14 @@ 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, 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(), @@ -113,12 +138,24 @@ 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" | "base" | "launch" | "logs" + | "inbox" | "activity" | "email" | "spec" @@ -147,6 +184,21 @@ impl UserConfig { ), }, ("logs", "dir", _) => bail!("logs.dir must be a string in {file}"), + ("logs", "waiting_line_minutes", value) => { + config.waiting_line_interval = + minutes(value, "logs.waiting_line_minutes", &file)?; + } + ("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 } @@ -237,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, @@ -288,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); @@ -412,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), @@ -436,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. @@ -731,6 +840,19 @@ 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 + +[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 + +[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 [] +theme = "focus-auto" # focus/plain/press/signal/garden/factory, with -auto/-light/-dark; default focus-auto +hidden_sections = [] # needs-you/ready-for-review/landing/working/done/resolved; default [] [activity] quiet_skips = false # a skipped Pass prints nothing, leaving only its Activity log line; default false @@ -790,6 +912,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() @@ -899,6 +1029,15 @@ mod tests { "email.to", "email.from", "logs.dir", + "logs.waiting_line_minutes", + "inbox.days", + "inbox.silent_minutes", + "console.address", + "console.port", + "console.allowed_emails", + "console.other_consoles", + "console.theme", + "console.hidden_sections", "activity.quiet_skips", "spec.parallel", "pickup.limit", @@ -1096,6 +1235,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 [ @@ -1254,6 +1424,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, @@ -1408,6 +1579,7 @@ mod tests { fn notifications_to(to: &str) -> UserConfigChanges { UserConfigChanges { + console: None, harness: None, merge_always: false, base_fix: false, @@ -1503,6 +1675,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, @@ -1548,6 +1721,7 @@ mod tests { ] { let before = parse(text).unwrap(); let changes = UserConfigChanges { + console: None, merge_always: true, base_fix: true, launch_pull: true, @@ -1634,6 +1808,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 new file mode 100644 index 00000000..726c1bfa --- /dev/null +++ b/src/config/console.rs @@ -0,0 +1,198 @@ +//! Console settings and narrowly scoped, comment-preserving look writes. +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", + "ready-for-review", + "landing", + "working", + "done", + "resolved", +]; + +pub fn theme_names() -> Vec { + THEME_FAMILIES + .iter() + .flat_map(|family| ["auto", "light", "dark"].map(|mode| format!("{family}-{mode}"))) + .collect() +} + +pub fn validate_theme(theme: &str) -> Result<()> { + themes::Theme::export(theme) + .map_err(|error| anyhow::anyhow!("unknown or invalid Console Theme {theme}: {error}"))?; + Ok(()) +} + +pub fn validate_sections(sections: &[String]) -> Result<()> { + ensure!( + sections + .iter() + .all(|section| SECTIONS.contains(§ion.as_str())), + "unknown Console section; choose {}", + SECTIONS.join(", ") + ); + Ok(()) +} + +/// 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, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ConsoleSettings { + pub address: String, + pub port: u16, + pub allowed_emails: Vec, + pub other_consoles: Vec, + pub theme: String, + pub hidden_sections: Vec, +} + +impl Default for ConsoleSettings { + fn default() -> Self { + Self { + address: String::new(), + port: 8711, + allowed_emails: Vec::new(), + other_consoles: Vec::new(), + theme: "focus-auto".into(), + hidden_sections: Vec::new(), + } + } +} + +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(crate) 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), + "console.allowed_emails must contain email addresses" + ); + } + validate_theme(&self.theme)?; + validate_sections(&self.hidden_sections)?; + 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(()) + } +} + +/// The caller can change only the two look keys. Lock the read/edit/replace +/// across CLI processes and HTTP requests so independent look writes compose. +pub fn write_look( + theme: Option<&str>, + hide: &[String], + show: &[String], +) -> Result { + use std::fs::{self, OpenOptions}; + use std::io::Write; + use toml_edit::{DocumentMut, Item, Table, Value}; + + if let Some(theme) = theme { + validate_theme(theme)?; + } + validate_sections(hide)?; + validate_sections(show)?; + let (home, path) = super::home_and_path()?; + let root = path.parent().expect("User config directory"); + fs::create_dir_all(root)?; + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(root.join("config.lock"))?; + lock.lock()?; + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + let mut settings = super::UserConfig::parse(&text, &path, &home)?.console; + let mut document: DocumentMut = text.parse()?; + if let Some(theme) = theme { + settings.theme = theme.into(); + } + settings + .hidden_sections + .retain(|section| !show.contains(section)); + for section in hide { + if !settings.hidden_sections.contains(section) { + settings.hidden_sections.push(section.clone()); + } + } + if !document.contains_key("console") { + document["console"] = Item::Table(Table::new()); + } + let mut set = |key: &str, value: Value| { + let table = document["console"] + .as_table_like_mut() + .expect("validated console table"); + if table.contains_key(key) { + super::set(&mut document, "console", key, value); + } else { + table.insert(key, Item::Value(value)); + } + }; + if theme.is_some() { + set("theme", settings.theme.clone().into()); + } + if !hide.is_empty() || !show.is_empty() { + set( + "hidden_sections", + settings + .hidden_sections + .iter() + .collect::() + .into(), + ); + } + let rendered = document.to_string(); + if path.exists() { + super::replace(&path, &rendered)?; + } else { + let mut file = tempfile::NamedTempFile::new_in(root)?; + file.write_all(rendered.as_bytes())?; + file.persist(&path)?; + } + Ok(settings) +} diff --git a/src/config/console/themes.rs b/src/config/console/themes.rs new file mode 100644 index 00000000..7c45660a --- /dev/null +++ b/src/config/console/themes.rs @@ -0,0 +1,321 @@ +//! 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 CONSOLE_CSS: &str = include_str!("../../console/assets/console.css"); +const STACKS: &[&str] = &[ + "sans-serif", + "serif", + "monospace", + "system-ui", + "ui-sans-serif", + "ui-serif", + "ui-monospace", +]; + +#[derive(Debug, Serialize)] +pub struct Theme { + name: String, + base: String, + tokens: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ThemeDocument { + name: String, + #[serde(default = "default_base")] + base: String, + #[serde(default)] + 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 { + "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 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: {}", + 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 built_in_font(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") + && let Some(name) = path.file_stem().and_then(|s| s.to_str()) + && 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 new file mode 100644 index 00000000..e8039af7 --- /dev/null +++ b/src/console.rs @@ -0,0 +1,716 @@ +//! 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 install; +pub use install::install; +mod live; +mod logs; +mod look; +pub use look::{export_theme, import_theme, sections, theme}; +mod peek; +mod restart; +mod stop; +mod time; +mod waterfall; + +use std::io::Write; +use std::net::Ipv4Addr; + +use anyhow::{Context, Result, ensure}; +use askama::Template; +use axum::Router; +use axum::extract::{DefaultBodyLimit, Path, RawQuery}; +use axum::http::{Method, StatusCode, header}; +use axum::middleware; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::{get, post}; +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"); + +/// 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 { + path: &'static str, + cli: &'static [&'static str], + method: Method, + view: View, +} + +enum View { + Inbox, + Inspect, + Log, + Changes, + Logs, + CommandLogs, + Reader, + LogStream, + ConsoleLog, + Account, + SignOut, + SignInPage, + SignIn, + Look, + Theme, + ThemeImport, + ThemeExport, + Sections, + StopPage, + Stop, + SetPasswordPage, + SendCode, + SetPassword, +} + +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: &[ + "thirdshift console theme list --json", + "thirdshift console sections --json", + ], + method: Method::GET, + view: View::Look, + }, + PageRoute { + path: "/theme", + cli: &["thirdshift console theme focus-auto --json"], + method: Method::POST, + view: View::Theme, + }, + PageRoute { + path: "/sections", + cli: &["thirdshift console sections --hide done --json"], + method: Method::POST, + view: View::Sections, + }, + PageRoute { + path: "/stop", + 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, + }, + PageRoute { + path: "/stop", + 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, + }, + PageRoute { + path: "/set-password", + cli: &["thirdshift console password"], + method: Method::GET, + view: View::SetPasswordPage, + }, + PageRoute { + path: "/set-password/code", + cli: &["thirdshift console password"], + method: Method::POST, + view: View::SendCode, + }, + PageRoute { + path: "/set-password", + cli: &["thirdshift console password"], + method: Method::POST, + view: View::SetPassword, + }, + PageRoute { + path: "/waterfall", + cli: &[ + "thirdshift show widgets#7 --json", + "thirdshift logs widgets#7", + ], + method: Method::GET, + view: View::Inbox, + }, + PageRoute { + path: "/lanes", + cli: &["thirdshift status --all --json"], + method: Method::GET, + view: View::Inbox, + }, + PageRoute { + path: "/stages", + cli: &["thirdshift status --all --json"], + method: Method::GET, + view: View::Inbox, + }, + PageRoute { + path: "/", + cli: &["thirdshift status"], + method: Method::GET, + view: View::Inbox, + }, + PageRoute { + path: "/events", + cli: &["thirdshift status"], + method: Method::GET, + view: View::Changes, + }, + PageRoute { + path: "/history", + cli: &["thirdshift status --all --json"], + method: Method::GET, + view: View::Inbox, + }, + PageRoute { + path: "/security", + cli: &["thirdshift status --all --json"], + method: Method::GET, + view: View::Inbox, + }, + PageRoute { + path: "/peek", + cli: &["thirdshift show widgets#7 --json"], + method: Method::GET, + view: View::Inspect, + }, + PageRoute { + path: "/log", + cli: &["thirdshift logs widgets#7"], + method: Method::GET, + view: View::Log, + }, + PageRoute { + path: "/logs", + cli: &["thirdshift logs --json"], + method: Method::GET, + view: View::Logs, + }, + PageRoute { + path: "/logs/command/{id}", + cli: &["thirdshift logs --command id --json"], + method: Method::GET, + view: View::CommandLogs, + }, + PageRoute { + path: "/logs/{id}", + cli: &["thirdshift logs --file id"], + method: Method::GET, + view: View::Reader, + }, + PageRoute { + path: "/logs/{id}/stream/{offset}", + cli: &["thirdshift logs --file id --follow"], + method: Method::GET, + view: View::LogStream, + }, + PageRoute { + path: "/console-log", + cli: &["thirdshift console log"], + method: Method::GET, + view: View::ConsoleLog, + }, + PageRoute { + path: "/account", + cli: &["thirdshift console sign-out"], + method: Method::GET, + view: View::Account, + }, + PageRoute { + path: "/sign-out", + cli: &["thirdshift console sign-out"], + method: Method::POST, + view: View::SignOut, + }, + // 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, + view: View::SignInPage, + }, + PageRoute { + path: "/sign-in", + cli: &["thirdshift console password"], + method: Method::POST, + view: View::SignIn, + }, +]; + +pub fn serve(no_sign_in: bool) -> Result<()> { + let config = UserConfig::load()?; + ensure!( + no_sign_in || !config.console.allowed_emails.is_empty(), + "console.allowed_emails is empty; configure allowed addresses or use --no-sign-in on localhost" + ); + if !no_sign_in { + accounts::Accounts::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 watched = executable.clone(); + let (changes, observer) = live::observe(render_inbox("thirdshift status").await?); + axum::serve( + listener, + router(no_sign_in, changes).layer(axum::Extension(executable.clone())), + ) + .with_graceful_shutdown(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if watched.changed() { + break; + } + // 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:?}") + } + } + } + } + // 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>(()) + })?; + // Drop the runtime (and its sockets) before replacing the process. + drop(runtime); + executable.exec() +} + +fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver) -> Router { + let mut router = Router::new(); + for page in PAGES { + let cli = page.cli[0]; + let path = page.path; + let handler = match (&page.view, page.method.clone()) { + (View::Inbox, Method::GET) => get(move |query| inbox(cli, path, query)), + (View::Inspect, Method::GET) => get(inspect), + (View::Log, Method::GET) => get(log), + (View::Changes, Method::GET) => get(live::events), + (View::Logs, Method::GET) => get(logs::index), + (View::CommandLogs, Method::GET) => get(logs::command), + (View::Reader, Method::GET) => get(logs::reader), + (View::LogStream, Method::GET) => get(logs::stream), + (View::ConsoleLog, Method::GET) => get(auth::log), + (View::Account, Method::GET) => get(auth::account), + (View::SignOut, Method::POST) => post(auth::sign_out), + (View::SignInPage, Method::GET) => get(auth::sign_in_page), + (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), + (View::SetPasswordPage, Method::GET) => get(auth::set_password_page), + (View::SendCode, Method::POST) => post(auth::code), + (View::SetPassword, Method::POST) => post(auth::set_password), + _ => unreachable!("unknown Console page method"), + }; + router = router.route(page.path, handler); + } + router = router.route("/assets/{*path}", get(asset)); + let access = auth::Access::new(no_sign_in); + router + .with_state(changes) + .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)] +#[template(path = "console/inbox.html")] +struct InboxPage { + page: PageContext, + hidden_sections: Vec, + cli: &'static str, + inbox: Inbox, + title: &'static str, + archive: Option, + outcome: String, + repository: String, +} + +impl InboxPage { + fn hidden(&self, group: &str) -> bool { + self.hidden_sections + .iter() + .any(|section| section == &group.replace(' ', "-")) + } + 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 } + } +} + +#[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 } + } +} + +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 requested_page = query_value(&query, "page"); + if requested_page.is_empty() { + requested_page = path.trim_start_matches('/').into(); + } + if matches!(requested_page.as_str(), "logs" | "6") { + return logs::index().await; + } + if matches!(requested_page.as_str(), "waterfall" | "4") { + return waterfall::show(query).await; + } + if matches!(requested_page.as_str(), "lanes" | "2" | "stages" | "3") { + return time::show(matches!(requested_page.as_str(), "stages" | "3"), query).await; + } + let archive_page = match requested_page.as_str() { + "history" | "5" => Some(status::ArchivePage::History), + "security" | "7" => Some(status::ArchivePage::Security), + "" | "inbox" | "1" => None, + _ => return StatusCode::NOT_FOUND.into_response(), + }; + page(Ok(render_page(cli, archive_page, query).await)) +} + +async fn render_page( + cli: &'static str, + archive_page: Option, + query: Option, +) -> Result { + // The shared observations and templates stay blocking, off the async executor. + tokio::task::spawn_blocking(move || -> Result { + 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 { + page: PageContext::new(&config), + hidden_sections: config.console.hidden_sections.clone(), + cli, + inbox: status::inbox(&config)?, + title, + archive: archive_page + .map(|page| status::archive(&config, page, &outcome, &repository)) + .transpose()?, + outcome, + repository, + } + .render()?) + }) + .await? +} + +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 = crate::logs::stop::inspect(&config, &command)?; + Ok(peek::Peek::new(command, details).render()?) + }) + .await + { + Ok(Ok(html)) => Html(html).into_response(), + _ => (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 || { + let config = UserConfig::load()?; + let path = status::inspect::log_path( + &config, + &command, + (!session.is_empty()).then_some(session.as_str()), + )?; + crate::logs::catalog::load(&config, None)? + .into_iter() + .find(|log| log.path == path) + .map(|log| log.id) + .context("log is unavailable") + }) + .await + { + Ok(Ok(id)) => logs::reader(Path(id)).await, + _ => (StatusCode::NOT_FOUND, "Log is unavailable.").into_response(), + } +} + +fn page(result: std::result::Result, tokio::task::JoinError>) -> Response { + match result { + 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() + } + } +} + +async fn render_inbox(cli: &'static str) -> Result { + render_page(cli, None, None).await +} + +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(); + }; + 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("svg") => "image/svg+xml", + Some("woff2") => "font/woff2", + _ => "text/plain; charset=utf-8", + }; + ( + [ + (header::CONTENT_TYPE, content_type), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + file.contents(), + ) + .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::{ + Router, + routing::{Route, get, post}, + }; + use std::sync::{Arc, Mutex}; + use tower_layer::Layer; + + fn router() -> Router { + let (_, changes) = tokio::sync::watch::channel(0); + super::router(false, changes) + } + + // 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 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.clone()), + ); + let post_methods = registered_methods( + Router::>::new() + .route("/known", post(|| async {})) + .with_state(changes), + ); + // Each unique path has one fallback; merged methods share it. + let unique_paths = super::PAGES + .iter() + .map(|page| page.path) + .collect::>() + .len(); + let shared_fallbacks = super::PAGES.len() - unique_paths; + let expected = super::PAGES + .iter() + .map(|page| { + if page.method == axum::http::Method::GET { + get_methods + } else { + post_methods + } + }) + .sum::() + + get_methods + - shared_fallbacks; + 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.is_empty(), + "{} has no paired CLI command", + route.path + ); + for cli in route.cli { + assert!(cli.starts_with("thirdshift "), "{cli}"); + let arguments = cli + .split_whitespace() + .skip(1) + .map(str::to_owned) + .collect::>(); + assert!( + crate::args::parse(&arguments).is_ok(), + "{} is not a CLI command", + cli + ); + } + } + } + + #[test] + fn every_console_route_has_a_cli_equivalent() { + 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(router().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..b9aaf780 --- /dev/null +++ b/src/console/account.html @@ -0,0 +1,7 @@ +{% 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..90c9c3c3 --- /dev/null +++ b/src/console/account_base.html @@ -0,0 +1,16 @@ + + + + + + {% block title %}{% endblock %} · thirdshift + + + + +

    thirdshift / Console

    + {% block navigation %}{% include "console/nav.html" %}{% endblock %} +
    +
    {% block content %}{% endblock %}
    + + diff --git a/src/console/account_commands.rs b/src/console/account_commands.rs new file mode 100644 index 00000000..befa8aff --- /dev/null +++ b/src/console/account_commands.rs @@ -0,0 +1,70 @@ +//! 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>, json: bool) -> Result<()> { + let email = email(given)?; + Accounts::load()?.sign_out(&email, None)?; + if json { + println!( + "{}", + serde_json::json!({ "email": email, "signed_out": true }) + ); + } else { + println!("Signed out {email}."); + } + Ok(()) +} + +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..6a8caf9e --- /dev/null +++ b/src/console/account_log.html @@ -0,0 +1,7 @@ +{% 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 new file mode 100644 index 00000000..967c9a38 --- /dev/null +++ b/src/console/accounts.rs @@ -0,0 +1,302 @@ +//! 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}; + +mod codes; +use codes::Codes; + +pub(super) const LIFETIME: i64 = 30 * 24 * 60 * 60; + +#[derive(Default, Deserialize, Serialize)] +struct Account { + password_hash: String, + #[serde(default)] + verified: bool, + #[serde(default)] + codes: Codes, + #[serde(default)] + sessions: BTreeMap, + #[serde(default)] + failed_passwords: u32, + #[serde(default)] + next_attempt: i64, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum EventKind { + SignIn, + FailedSignIn, + PasswordSet, + CodeSent, + SignOut, + BlockedAddress, + ThemeChanged, + ThemeSaved, + SectionsChanged, +} + +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::CodeSent => "code-sent", + Self::SignOut => "sign-out", + Self::BlockedAddress => "blocked-address", + Self::ThemeChanged => "theme-changed", + Self::ThemeSaved => "theme-saved", + Self::SectionsChanged => "sections-changed", + }) + } +} + +#[derive(Deserialize, Serialize)] +pub(super) struct Event { + pub time: i64, + pub email: String, + pub event: EventKind, +} + +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) { + changed |= account.codes.clear(); + 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, EventKind::BlockedAddress)?; + } + 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 password_hash = hash_secret(password)?; + let account = self.accounts.entry(email.into()).or_default(); + account.password_hash = password_hash; + account.sessions.clear(); + account.codes.clear(); + account.failed_passwords = 0; + account.next_attempt = 0; + self.save()?; + 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, EventKind::BlockedAddress)?; + return Ok(SignIn::Refused); + } + if self + .accounts + .get(email) + .is_some_and(|account| self.now < account.next_attempt) + { + self.record(email, EventKind::FailedSignIn)?; + return Ok(SignIn::Slowdown); + } + let valid = self + .accounts + .get(email) + .is_some_and(|account| matches_secret(password, &account.password_hash)); + 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, EventKind::FailedSignIn)?; + return Ok(SignIn::Refused); + } + self.start_session(email) + } + + fn start_session(&mut self, email: &str) -> Result { + 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, EventKind::SignIn)?; + 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, EventKind::SignOut) + } + + 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(()) + } + + pub(super) 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, + }, + )?; + file.write_all(b"\n")?; + Ok(()) + } +} + +fn hash_secret(secret: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| anyhow::anyhow!("can't hash secret: {error}")) +} + +fn matches_secret(secret: &str, hash: &str) -> bool { + PasswordHash::new(hash).is_ok_and(|hash| { + Argon2::default() + .verify_password(secret.as_bytes(), &hash) + .is_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/codes.rs b/src/console/accounts/codes.rs new file mode 100644 index 00000000..77f6388b --- /dev/null +++ b/src/console/accounts/codes.rs @@ -0,0 +1,92 @@ +//! Email codes and their durable limits share the account store's lock. +use super::*; + +#[derive(Default, Deserialize, Serialize)] +pub(super) struct Codes { + pending: Option, + sent: Vec, +} + +#[derive(Deserialize, Serialize)] +struct Code { + hash: String, + sent_at: i64, + attempts: u8, +} + +impl Codes { + pub(super) fn clear(&mut self) -> bool { + self.pending.take().is_some() + } +} + +impl Accounts { + /// The sender crosses the external email seam. Reserve the limits before + /// calling it so even a failed send cannot be retried without a bound. + pub fn send_code(&mut self, email: &str, send: impl FnOnce(&str) -> Result<()>) -> Result<()> { + if !self.allowed.iter().any(|allowed| allowed == email) { + return Ok(()); + } + let account = self.accounts.entry(email.into()).or_default(); + account + .codes + .sent + .retain(|sent| self.now.saturating_sub(*sent) < 24 * 60 * 60); + if account.codes.sent.len() >= 10 + || account + .codes + .sent + .last() + .is_some_and(|sent| self.now.saturating_sub(*sent) < 60) + { + return Ok(()); + } + // Rejection sampling keeps all six-digit strings equally likely. + let number = loop { + let number = OsRng.next_u32(); + if number < u32::MAX - u32::MAX % 1_000_000 { + break number % 1_000_000; + } + }; + let code = format!("{number:06}"); + account.codes.pending = Some(Code { + hash: hash_secret(&code)?, + sent_at: self.now, + attempts: 0, + }); + account.codes.sent.push(self.now); + self.save()?; + send(&code)?; + self.record(email, EventKind::CodeSent) + } + + pub fn set_password_by_code( + &mut self, + email: &str, + code: &str, + password: &str, + ) -> Result { + if password.is_empty() || !self.allowed.iter().any(|allowed| allowed == email) { + return Ok(SignIn::Refused); + } + let Some(pending) = self + .accounts + .get_mut(email) + .and_then(|account| account.codes.pending.as_mut()) + else { + return Ok(SignIn::Refused); + }; + if self.now.saturating_sub(pending.sent_at) >= 15 * 60 || pending.attempts >= 5 { + return Ok(SignIn::Refused); + } + pending.attempts += 1; + let valid = matches_secret(code, &pending.hash); + self.save()?; + if !valid { + return Ok(SignIn::Refused); + } + self.set_password(email, password)?; + self.accounts.get_mut(email).expect("code account").verified = true; + self.start_session(email) + } +} diff --git a/src/console/accounts/tests.rs b/src/console/accounts/tests.rs new file mode 100644 index 00000000..aca4eb75 --- /dev/null +++ b/src/console/accounts/tests.rs @@ -0,0 +1,244 @@ +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 email_codes_expire_at_fifteen_minutes_and_allow_exactly_five_tries() { + let allowed = vec!["owner@example.test".into()]; + for (elapsed, wrong_tries, accepted) in [(899, 4, true), (900, 0, false), (1, 5, false)] { + let dir = tempfile::tempdir().unwrap(); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap(); + let mut code = String::new(); + accounts + .send_code("owner@example.test", |sent| { + code = sent.into(); + Ok(()) + }) + .unwrap(); + drop(accounts); + let wrong = if code == "000000" { "111111" } else { "000000" }; + for _ in 0..wrong_tries { + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + elapsed).unwrap(); + assert!(matches!( + accounts + .set_password_by_code("owner@example.test", wrong, "password") + .unwrap(), + SignIn::Refused + )); + } + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + elapsed).unwrap(); + assert_eq!( + matches!( + accounts + .set_password_by_code("owner@example.test", &code, "password") + .unwrap(), + SignIn::SignedIn(_) + ), + accepted + ); + assert!(matches!( + accounts + .set_password_by_code("owner@example.test", &code, "replay") + .unwrap(), + SignIn::Refused + )); + } +} + +#[test] +fn code_send_limits_survive_restarts_and_password_changes_and_are_per_address() { + let dir = tempfile::tempdir().unwrap(); + let allowed = vec!["owner@example.test".into(), "other@example.test".into()]; + let mut sends = 0; + let mut code = String::new(); + for (time, expected) in [(1_000, 1), (1_059, 1), (1_060, 2)] { + let mut accounts = Accounts::open(dir.path(), &allowed, time).unwrap(); + accounts + .send_code("owner@example.test", |sent| { + sends += 1; + code = sent.into(); + Ok(()) + }) + .unwrap(); + assert_eq!(sends, expected); + } + { + let mut accounts = Accounts::open(dir.path(), &allowed, 1_061).unwrap(); + assert!(matches!( + accounts + .set_password_by_code("owner@example.test", &code, "password") + .unwrap(), + SignIn::SignedIn(_) + )); + } + for index in 2..11 { + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + index * 60).unwrap(); + accounts + .send_code("owner@example.test", |_| { + sends += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(sends, (index + 1).min(10)); + } + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + 24 * 60 * 60 - 1).unwrap(); + accounts + .send_code("owner@example.test", |_| panic!("daily limit bypassed")) + .unwrap(); + accounts + .send_code("unknown@example.test", |_| panic!("unknown address sent")) + .unwrap(); + accounts + .send_code("other@example.test", |_| { + sends += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(sends, 11); + drop(accounts); + let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + 24 * 60 * 60).unwrap(); + accounts + .send_code("owner@example.test", |_| { + sends += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(sends, 12, "first send ages out of the rolling day"); +} + +#[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/README.md b/src/console/assets/README.md new file mode 100644 index 00000000..b13c5678 --- /dev/null +++ b/src/console/assets/README.md @@ -0,0 +1,49 @@ +# Embedded Console assets + +All runtime assets are compiled in with `include_dir`; the release build uses +neither Node nor npm. `themes.css` holds the six Themes' tokens, adapted from +`prototype/711-console` at `05b1b0a`, `themes.js`. It is maintained directly, +with shared layout and activity motion in `console.css`. Press's light link +and Factory's dark dim/urgent text are adjusted to pass AA on raised and +selected panels as well as the page. Focus also has an APCA Lc 45 floor. + +`console theme -` and `console sections --hide done +--show working` share the page's restricted look writer. Sections are named +`needs-you`, `ready-for-review`, `landing`, `working`, `done`, `resolved`. +Theme and hidden sections are machine settings; Pause motion is stored only +in the browser. `npm test --prefix console` checks Theme switching, contrast, +font loading, and activity motion through the served Console. + +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. + +Additional Latin-subset WOFF2 files from Google Fonts (normal style): + +- Big Shoulders Display, normal, weight 100 900: +- JetBrains Mono, normal, weight 100 800: +- Inter, normal, weight 100 900: +- Caprasimo, normal, weight 400: +- Nunito, normal, weight 200 1000: +- Barlow Condensed, normal, weight 400: +- Barlow Condensed, normal, weight 600: +- Barlow Condensed, normal, weight 700: +- Titillium Web, normal, weight 400: +- Titillium Web, normal, weight 600: +- Titillium Web, normal, weight 700: +- Atkinson Hyperlegible, normal, weight 400: +- Atkinson Hyperlegible, normal, weight 700: + +The matching SIL Open Font Licenses are vendored in `fonts/*-OFL.txt` +from `google/fonts` (`ofl//OFL.txt`). No remote URL is used at runtime. 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.css b/src/console/assets/console.css new file mode 100644 index 00000000..b3044918 --- /dev/null +++ b/src/console/assets/console.css @@ -0,0 +1,143 @@ +@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; } + +* { 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; text-transform: var(--head-case); text-shadow: var(--title-shadow); } +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; } +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; } +.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, .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; } +.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(--tag-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; } +.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; } +#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); } +.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; } +#log-content[data-live="true"]::after { content: "▌"; color: var(--working-text); } +.command[data-state="Stopping"]::after { content: ""; display: block; height: 3px; background: repeating-linear-gradient(90deg, var(--urgent) 0 12px, transparent 12px 24px); background-size: 48px 3px; } +.running-stage { background-image: repeating-linear-gradient(120deg, transparent 0 8px, var(--rule) 8px 10px); background-size: 24px 24px; } +@keyframes working { to { transform: rotate(360deg); } } +@keyframes activity-drift { to { background-position: 24px 0; } } +@keyframes alarm { 50% { outline: 2px solid var(--urgent); } } +@keyframes cursor { 50% { opacity: 0; } } +@keyframes latest { from { outline: 2px solid var(--working); } } +@media (prefers-reduced-motion: no-preference) { + :root:not([data-paused="true"]) .command[data-live="true"][data-state="live"] > .row-head > .state::before { animation: working 2s linear infinite; } + :root:not([data-paused="true"]) .command[data-live="true"][data-state="Silent"] > .row-head > .state { animation: alarm 1.8s ease-in-out 3; } + :root:not([data-paused="true"]) #log-content[data-live="true"]::after { animation: cursor 1s steps(1) infinite; } + :root:not([data-paused="true"])[data-live="true"] header::after { animation: var(--activity-motion); } + :root:not([data-paused="true"]) .command[data-live="true"][data-state="Stopping"]::after, :root:not([data-paused="true"]) .running-stage { animation: activity-drift 1s linear infinite; } + :root:not([data-paused="true"]) #log-content[data-live="true"] > :last-child { animation: latest 900ms ease-out; } +} +:root[data-paused="true"] *, :root[data-paused="true"] *::before, :root[data-paused="true"] *::after { animation: none !important; transition: none !important; } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } +@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; } +} +.log-list { padding-left: 20px; } +.stopping-bar { height: 6px; background: var(--working); transform-origin: left; animation: stopping 1.5s ease-in-out infinite alternate; } +@keyframes stopping { from { transform: scaleX(.2); } to { transform: scaleX(1); } } +@media (prefers-reduced-motion: reduce) { .stopping-bar { animation: none; } } +.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; } +.account-form { display: block; max-width: 420px; margin: 20px 0; } +.account-form label { display: block; margin: 12px 0; } +.account-form input:not([type="hidden"]), .account-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); } +.account-form button { cursor: pointer; font-weight: var(--head-weight); } +.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; } +.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; } +.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; } + .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..0ddff7d3 --- /dev/null +++ b/src/console/assets/console.js @@ -0,0 +1,300 @@ +import { activity, loadLook, motionAllowed } from './look.js'; +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; } +} +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("#inbox .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]; +} +document.addEventListener("toggle", event => { + const section = event.target; + if (!section.matches("details[data-group]")) return; + folded[section.dataset.group] = section.open; + remember(foldKey, folded); + if (section.open && selected && section.contains(selected)) selected.focus({ preventScroll: true }); +}, true); +document.addEventListener("focusin", event => { + const row = event.target.closest(".command"); + if (row) select(row, { scroll: false }); +}); +document.addEventListener("click", event => { + if (!event.target.closest('a, button, input, select, textarea')) { + 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, logLengths(row)])); +} + +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 selection = selected?.id || selected?.closest('details')?.id; + const selectedSummary = selected?.matches('summary'); + const previousGrowth = growth(); + Idiomorph.morph(current, replacement, { + callbacks: { + beforeAttributeUpdated(name, node) { + if (name === "open" && node.matches("details[data-group]")) return false; + } + } + }); + const focusedNode = document.getElementById(focused); + (wasSummary ? focusedNode?.querySelector("summary") : focusedNode)?.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. + const selectedNode = document.getElementById(selection); + select(selectedSummary ? selectedNode?.querySelector('summary') : selectedNode, { scroll: false }); + 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(); + activity(); + loadLook(); + for (const row of document.querySelectorAll('.command[data-logging="true"]')) { + const previous = previousGrowth.get(row.id) ?? [0, 0]; + const increased = logLengths(row).some((size, index) => size > previous[index]); + if (increased && row.dataset.live === 'true' && motionAllowed()) { + 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 }); + row.querySelector(':scope > .now')?.animate([ + { outline: '2px solid var(--working)' }, { outline: '2px solid transparent' } + ], { duration: 900 }); + } + } +} + +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(); +} + +async function refresh() { + if (observesCommands) { + const response = await fetch(timePage ? location.href : '/', { cache: "no-store" }); + if (!response.ok) throw new Error("Inbox unavailable"); + const html = await response.text(); + if (timePage) morphTimePage(html); + else morph(html); + } + await refreshPeek(); +} +let stopWatching; +function startUpdates() { + stopWatching ??= watchCommands(refresh); +} +const observesCommands = document.getElementById('inbox') || timePage; +if (observesCommands) startUpdates(); + +function stopPeekUpdates() { + if (observesCommands) return; + stopWatching?.(); + stopWatching = undefined; +} + +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, { scroll = true } = {}) { + selected?.classList.remove('is-selected'); + selected?.removeAttribute('aria-current'); + selected = element; + selected?.classList.add('is-selected'); + if (selected?.matches('.command')) selected.setAttribute('aria-current', 'true'); + if (scroll) selected?.scrollIntoView({ block: 'nearest' }); +} + +function closePeek() { + stopPeekUpdates(); + 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; } + peekCommand = row.dataset.command; + content.textContent = 'Loading details…'; + peek.hidden = false; + document.body.classList.add('peek-open'); + await loadPeek(row.dataset.peek); +} + +async function refreshPeek() { + if (peekCommand && !pendingPeek) { + await loadPeek(`/peek?${new URLSearchParams({ command: peekCommand })}`); + } +} + +async function loadPeek(url) { + pendingPeek?.abort(); + const request = new AbortController(); + pendingPeek = request; + try { + const response = await fetch(url, { signal: request.signal, cache: 'no-store' }); + 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)); + if (content.querySelector('[data-live="true"]')) startUpdates(); + else stopPeekUpdates(); + } catch (error) { + if (!request.signal.aborted && error.name !== 'AbortError') content.textContent = 'Details unavailable. Reload to try again.'; + } finally { + if (pendingPeek === request) pendingPeek = null; + } +} + +function items() { + return [...document.querySelectorAll('main .group > summary, main .command, main .log-row')] + .filter(element => { + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (parent.hidden || (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: () => { + const current = section.id ? document.getElementById(section.id) : section; + current.open = true; + select(current.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, main .log-row'); + 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')) { + const summary = event.target.closest('summary'); + if (summary.parentElement.open || !selected?.matches('.command') || !summary.parentElement.contains(selected)) select(summary); + } +}); + +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 (document.querySelector('#themes[open], #sections-chooser[open]')) return; + if (event.key === 'T' && event.shiftKey) { event.preventDefault(); document.querySelector('[data-open-themes]').click(); 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'); + if (!section) return; + section.open = false; + select(section.querySelector('summary')); + } else if (event.key === 'Enter' && selected) { + event.preventDefault(); + const row = selected.matches('.command, .log-row') ? selected : selected.parentElement.querySelector('.command'); + if (row?.dataset.log) location.assign(row.dataset.log); + } +}); 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/assets/fonts/atkinsonhyperlegible-400-latin.woff2 b/src/console/assets/fonts/atkinsonhyperlegible-400-latin.woff2 new file mode 100644 index 00000000..2e43c90d Binary files /dev/null and b/src/console/assets/fonts/atkinsonhyperlegible-400-latin.woff2 differ diff --git a/src/console/assets/fonts/atkinsonhyperlegible-700-latin.woff2 b/src/console/assets/fonts/atkinsonhyperlegible-700-latin.woff2 new file mode 100644 index 00000000..bc31903a Binary files /dev/null and b/src/console/assets/fonts/atkinsonhyperlegible-700-latin.woff2 differ diff --git a/src/console/assets/fonts/atkinsonhyperlegible-OFL.txt b/src/console/assets/fonts/atkinsonhyperlegible-OFL.txt new file mode 100644 index 00000000..2befc89b --- /dev/null +++ b/src/console/assets/fonts/atkinsonhyperlegible-OFL.txt @@ -0,0 +1,92 @@ +Copyright 2020 Braille Institute of America, Inc. + +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/barlowcondensed-400-latin.woff2 b/src/console/assets/fonts/barlowcondensed-400-latin.woff2 new file mode 100644 index 00000000..ebb34ad5 Binary files /dev/null and b/src/console/assets/fonts/barlowcondensed-400-latin.woff2 differ diff --git a/src/console/assets/fonts/barlowcondensed-600-latin.woff2 b/src/console/assets/fonts/barlowcondensed-600-latin.woff2 new file mode 100644 index 00000000..da60c9a9 Binary files /dev/null and b/src/console/assets/fonts/barlowcondensed-600-latin.woff2 differ diff --git a/src/console/assets/fonts/barlowcondensed-700-latin.woff2 b/src/console/assets/fonts/barlowcondensed-700-latin.woff2 new file mode 100644 index 00000000..3726e9dc Binary files /dev/null and b/src/console/assets/fonts/barlowcondensed-700-latin.woff2 differ diff --git a/src/console/assets/fonts/barlowcondensed-OFL.txt b/src/console/assets/fonts/barlowcondensed-OFL.txt new file mode 100644 index 00000000..2f22ba6c --- /dev/null +++ b/src/console/assets/fonts/barlowcondensed-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +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/bigshouldersdisplay-100-900-latin.woff2 b/src/console/assets/fonts/bigshouldersdisplay-100-900-latin.woff2 new file mode 100644 index 00000000..b1d217e0 Binary files /dev/null and b/src/console/assets/fonts/bigshouldersdisplay-100-900-latin.woff2 differ diff --git a/src/console/assets/fonts/bigshouldersdisplay-OFL.txt b/src/console/assets/fonts/bigshouldersdisplay-OFL.txt new file mode 100644 index 00000000..b5a4e0df --- /dev/null +++ b/src/console/assets/fonts/bigshouldersdisplay-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders) + +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/caprasimo-400-latin.woff2 b/src/console/assets/fonts/caprasimo-400-latin.woff2 new file mode 100644 index 00000000..a1e50c3f Binary files /dev/null and b/src/console/assets/fonts/caprasimo-400-latin.woff2 differ diff --git a/src/console/assets/fonts/caprasimo-OFL.txt b/src/console/assets/fonts/caprasimo-OFL.txt new file mode 100644 index 00000000..eec5a378 --- /dev/null +++ b/src/console/assets/fonts/caprasimo-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2023 The Caprasimo Project Authors (https://github.com/docrepair-fonts/caprasimo-fonts). + +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: +https://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-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 00000000..0804aaff Binary files /dev/null and b/src/console/assets/fonts/ibm-plex-mono-latin.woff2 differ diff --git a/src/console/assets/fonts/ibm-plex-sans-OFL.txt b/src/console/assets/fonts/ibm-plex-sans-OFL.txt new file mode 100644 index 00000000..c35c4c61 --- /dev/null +++ b/src/console/assets/fonts/ibm-plex-sans-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-sans-latin.woff2 b/src/console/assets/fonts/ibm-plex-sans-latin.woff2 new file mode 100644 index 00000000..b757bc5c Binary files /dev/null and b/src/console/assets/fonts/ibm-plex-sans-latin.woff2 differ diff --git a/src/console/assets/fonts/inter-100-900-latin.woff2 b/src/console/assets/fonts/inter-100-900-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/src/console/assets/fonts/inter-100-900-latin.woff2 differ diff --git a/src/console/assets/fonts/inter-OFL.txt b/src/console/assets/fonts/inter-OFL.txt new file mode 100644 index 00000000..21f6aff9 --- /dev/null +++ b/src/console/assets/fonts/inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +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: +https://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/jetbrainsmono-100-800-latin.woff2 b/src/console/assets/fonts/jetbrainsmono-100-800-latin.woff2 new file mode 100644 index 00000000..cd5102a4 Binary files /dev/null and b/src/console/assets/fonts/jetbrainsmono-100-800-latin.woff2 differ diff --git a/src/console/assets/fonts/jetbrainsmono-OFL.txt b/src/console/assets/fonts/jetbrainsmono-OFL.txt new file mode 100644 index 00000000..821a3dac --- /dev/null +++ b/src/console/assets/fonts/jetbrainsmono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +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: https://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/nunito-200-1000-latin.woff2 b/src/console/assets/fonts/nunito-200-1000-latin.woff2 new file mode 100644 index 00000000..3e909a16 Binary files /dev/null and b/src/console/assets/fonts/nunito-200-1000-latin.woff2 differ diff --git a/src/console/assets/fonts/nunito-OFL.txt b/src/console/assets/fonts/nunito-OFL.txt new file mode 100644 index 00000000..c8210f08 --- /dev/null +++ b/src/console/assets/fonts/nunito-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +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/titilliumweb-400-latin.woff2 b/src/console/assets/fonts/titilliumweb-400-latin.woff2 new file mode 100644 index 00000000..47501963 Binary files /dev/null and b/src/console/assets/fonts/titilliumweb-400-latin.woff2 differ diff --git a/src/console/assets/fonts/titilliumweb-600-latin.woff2 b/src/console/assets/fonts/titilliumweb-600-latin.woff2 new file mode 100644 index 00000000..ee2c4bbb Binary files /dev/null and b/src/console/assets/fonts/titilliumweb-600-latin.woff2 differ diff --git a/src/console/assets/fonts/titilliumweb-700-latin.woff2 b/src/console/assets/fonts/titilliumweb-700-latin.woff2 new file mode 100644 index 00000000..8672f69d Binary files /dev/null and b/src/console/assets/fonts/titilliumweb-700-latin.woff2 differ diff --git a/src/console/assets/fonts/titilliumweb-OFL.txt b/src/console/assets/fonts/titilliumweb-OFL.txt new file mode 100644 index 00000000..4a44b9b3 --- /dev/null +++ b/src/console/assets/fonts/titilliumweb-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2009-2011 by Accademia di Belle Arti di Urbino and students of MA course of Visual design. Some rights reserved. + +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/logs.js b/src/console/assets/logs.js new file mode 100644 index 00000000..6e2f4e35 --- /dev/null +++ b/src/console/assets/logs.js @@ -0,0 +1,85 @@ +import { activity } from './look.js'; +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); +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; + 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"; + content.dataset.live = String(!batch.done); + activity(); + 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/assets/look.js b/src/console/assets/look.js new file mode 100644 index 00000000..7d15c607 --- /dev/null +++ b/src/console/assets/look.js @@ -0,0 +1,149 @@ +// The machine owns its look; only Pause motion belongs to this browser. +const root = document.documentElement; +const themes = document.querySelector('#themes'); +const sections = document.querySelector('#sections-chooser'); +const message = document.querySelector('#look-status'); +const pause = document.querySelector('#pause-motion'); +const reduced = matchMedia('(prefers-reduced-motion: reduce)'); +let csrf; +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'); + 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; } +function motion() { + root.dataset.paused = String(paused); + pause?.setAttribute('aria-pressed', String(paused)); + if (!motionAllowed()) for (const animation of document.getAnimations()) animation.cancel(); +} +pause?.addEventListener('click', () => { + paused = !paused; + try { localStorage.setItem('thirdshift:console:pause-motion', String(paused)); } catch { /* optional storage */ } + motion(); +}); +reduced.addEventListener('change', motion); +motion(); + +export function activity() { + root.dataset.live = String(!!document.querySelector('.command[data-live="true"], #log-content[data-live="true"]')); +} + +function apply(settings) { + root.dataset.theme = settings.theme; + for (const section of document.querySelectorAll('#inbox > details[data-group]')) { + section.hidden = settings.hidden_sections.includes(section.dataset.group.replaceAll(' ', '-')); + } + if (!sections.open) for (const checkbox of document.querySelectorAll('#section-options input')) checkbox.checked = !settings.hidden_sections.includes(checkbox.value); + if (!themes.open) document.querySelector('#theme-name').value = settings.theme; + activity(); +} + +export async function loadLook() { + if (!themes || saving) return; + const requestedRevision = revision; + try { + const response = await fetch('/look', { cache: 'no-store' }); + if (!response.ok || response.redirected) throw new Error('Look unavailable'); + const settings = await response.json(); + if (requestedRevision !== revision || saving) return; + csrf = settings.csrf; + const picker = document.querySelector('#theme-name'); + const inventory = JSON.stringify(settings.themes); + 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; + } + if (!document.querySelector('#section-options input')) { + for (const name of settings.sections) { + const label = document.createElement('label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; checkbox.value = name; + label.append(checkbox, ` Show ${name.replaceAll('-', ' ').replace(/^./, c => c.toUpperCase())}`); + document.querySelector('#section-options').append(label); + } + } + apply(settings); + } catch { message.textContent = 'Could not load the Console look. Reload to try again.'; } +} + +async function save(path, values) { + revision++; + saving = true; + message.textContent = ''; + 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.'; } + finally { saving = false; } +} + +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(); }); + document.querySelector('[data-close-sections]').addEventListener('click', () => sections.close()); + document.querySelector('#theme-form').addEventListener('submit', event => { + event.preventDefault(); save('/theme', { theme: document.querySelector('#theme-name').value }); + }); + document.querySelector('#sections-form').addEventListener('submit', event => { + event.preventDefault(); + const options = [...document.querySelectorAll('#section-options input')]; + save('/sections', { hide: options.filter(c => !c.checked).map(c => c.value).join(','), show: options.filter(c => c.checked).map(c => c.value).join(',') }); + }); + loadLook(); +} +activity(); diff --git a/src/console/assets/stop.js b/src/console/assets/stop.js new file mode 100644 index 00000000..7d2027d3 --- /dev/null +++ b/src/console/assets/stop.js @@ -0,0 +1,12 @@ +import './look.js'; +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/assets/themes.css b/src/console/assets/themes.css new file mode 100644 index 00000000..a4e26ad2 --- /dev/null +++ b/src/console/assets/themes.css @@ -0,0 +1,320 @@ +@font-face { font-family: "Big Shoulders Display"; src: url("/assets/fonts/bigshouldersdisplay-100-900-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("/assets/fonts/jetbrainsmono-100-800-latin.woff2") format("woff2"); font-weight: 100 800; font-display: swap; } +@font-face { font-family: "Inter"; src: url("/assets/fonts/inter-100-900-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } +@font-face { font-family: "Caprasimo"; src: url("/assets/fonts/caprasimo-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; } +@font-face { font-family: "Nunito"; src: url("/assets/fonts/nunito-200-1000-latin.woff2") format("woff2"); font-weight: 200 1000; font-display: swap; } +@font-face { font-family: "Barlow Condensed"; src: url("/assets/fonts/barlowcondensed-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; } +@font-face { font-family: "Barlow Condensed"; src: url("/assets/fonts/barlowcondensed-600-latin.woff2") format("woff2"); font-weight: 600; font-display: swap; } +@font-face { font-family: "Barlow Condensed"; src: url("/assets/fonts/barlowcondensed-700-latin.woff2") format("woff2"); font-weight: 700; font-display: swap; } +@font-face { font-family: "Titillium Web"; src: url("/assets/fonts/titilliumweb-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; } +@font-face { font-family: "Titillium Web"; src: url("/assets/fonts/titilliumweb-600-latin.woff2") format("woff2"); font-weight: 600; font-display: swap; } +@font-face { font-family: "Titillium Web"; src: url("/assets/fonts/titilliumweb-700-latin.woff2") format("woff2"); font-weight: 700; font-display: swap; } +@font-face { font-family: "Atkinson Hyperlegible"; src: url("/assets/fonts/atkinsonhyperlegible-400-latin.woff2") format("woff2"); font-weight: 400; font-display: swap; } +@font-face { font-family: "Atkinson Hyperlegible"; src: url("/assets/fonts/atkinsonhyperlegible-700-latin.woff2") format("woff2"); font-weight: 700; font-display: swap; } + +/* Adapted from the approved #711 prototype (05b1b0a). Tokens only; layout lives in console.css. */ +:root { color-scheme: light dark; --activity-motion: none; } +:root[data-theme$="-light"] { color-scheme: light; } +:root[data-theme$="-dark"] { color-scheme: dark; } +:root[data-theme^="focus-"] { + --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: light-dark(#18181A, #E8E8EA); + --dim: light-dark(#6B6B70, #A0A0A8); + --rule: light-dark(#E3E3E0, #2C2C31); + --link: light-dark(#18181A, #E8E8EA); + --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: light-dark(#3F3F44, #C4C4CA); + --done: light-dark(#6B6B70, #9A9AA2); + --on-urgent: light-dark(#FFFFFF, #000000); + --on-look: light-dark(#000000, #000000); + --on-working: light-dark(#FFFFFF, #000000); + --on-landing: light-dark(#FFFFFF, #000000); + --on-ready: light-dark(#FFFFFF, #000000); + --on-done: light-dark(#FFFFFF, #000000); + --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; + --head-case: none; + --radius: 4px; + --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; + --title-shadow: none; +} +:root[data-theme^="plain-"] { + --bg: light-dark(#FCFCFB, #111214); + --panel: light-dark(#FFFFFF, #15171A); + --panel-2: light-dark(#F3F4F6, #1C1F23); + --sel-bg: light-dark(#EEF3FD, #17223A); + --text: light-dark(#1C1D1F, #E6E7E9); + --head: light-dark(#62676E, #9096A0); + --dim: light-dark(#62676E, #9096A0); + --rule: light-dark(#E4E6E9, #26292E); + --link: light-dark(#1C1D1F, #E6E7E9); + --urgent: light-dark(#C4262E, #FF6B6B); + --urgent-text: light-dark(#C4262E, #FF6B6B); + --look: light-dark(#9A5B00, #F0B45A); + --look-text: light-dark(#9A5B00, #F0B45A); + --working: light-dark(#1F6FEB, #6AA8FF); + --working-text: light-dark(#1A5FCC, #6AA8FF); + --landing: light-dark(#7B4FD4, #B39CFF); + --ready: light-dark(#1A7F37, #57C97A); + --ready-text: light-dark(#1A7F37, #57C97A); + --done: light-dark(#62676E, #9096A0); + --on-urgent: light-dark(#FFFFFF, #000000); + --on-look: light-dark(#FFFFFF, #000000); + --on-working: light-dark(#FFFFFF, #000000); + --on-landing: light-dark(#FFFFFF, #000000); + --on-ready: light-dark(#FFFFFF, #000000); + --on-done: light-dark(#FFFFFF, #000000); + --font-head: "Inter", sans-serif; + --font-body: "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; + --body-size: 13.5px; + --head-weight: 600; + --head-case: uppercase; + --radius: 6px; + --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; + --title-shadow: none; +} +:root[data-theme^="press-"] { + --bg: light-dark(#E8E6E1, #0A0A0A); + --panel: light-dark(#F4F2EE, #141414); + --panel-2: light-dark(#DCD9D2, #1E1E1E); + --sel-bg: light-dark(#DCD9D2, #1E1E1E); + --text: light-dark(#111111, #E8E6E1); + --head: light-dark(#111111, #E8E6E1); + --dim: light-dark(#55514A, #9A978F); + --rule: light-dark(#C6C1B6, #2A2A2A); + --link: light-dark(#005F86, #00AEEF); + --urgent: light-dark(#EC008C, #EC008C); + --urgent-text: light-dark(#A80064, #FF5CB8); + --look: light-dark(#FFF200, #FFF200); + --look-text: light-dark(#615500, #FFF200); + --working: light-dark(#00AEEF, #00AEEF); + --working-text: light-dark(#005A80, #00AEEF); + --landing: light-dark(#00AEEF, #00AEEF); + --ready: light-dark(#111111, #E8E6E1); + --ready-text: light-dark(#111111, #E8E6E1); + --done: light-dark(#68645C, #9A978F); + --on-urgent: light-dark(#000000, #000000); + --on-look: light-dark(#000000, #000000); + --on-working: light-dark(#000000, #000000); + --on-landing: light-dark(#000000, #000000); + --on-ready: light-dark(#FFFFFF, #000000); + --on-done: light-dark(#FFFFFF, #000000); + --font-head: "Big Shoulders Display", sans-serif; + --font-body: "JetBrains Mono", sans-serif; + --font-mono: "JetBrains Mono", monospace; + --body-size: 13px; + --head-weight: 600; + --head-case: none; + --radius: 0px; + --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; + --title-shadow: 1px 0 var(--urgent); +} +:root[data-theme^="signal-"] { + --bg: light-dark(#FAFAFA, #111315); + --panel: light-dark(#FFFFFF, #181B1E); + --panel-2: light-dark(#F1F2F4, #20242A); + --sel-bg: light-dark(#ECEDEF, #262B31); + --text: light-dark(#111316, #ECEEF0); + --head: light-dark(#111316, #ECEEF0); + --dim: light-dark(#4F555C, #A4AAB1); + --rule: light-dark(#DDE0E3, #2C3137); + --link: light-dark(#111316, #ECEEF0); + --urgent: light-dark(#D55E00, #FF7A33); + --urgent-text: light-dark(#B3470A, #FF8A4C); + --look: light-dark(#E69F00, #F0B429); + --look-text: light-dark(#7A4E00, #F2B53A); + --working: light-dark(#0072B2, #3FA2E0); + --working-text: light-dark(#00609A, #62B8F0); + --landing: light-dark(#9B3F76, #D97DB6); + --ready: light-dark(#009E73, #2CC79A); + --ready-text: light-dark(#00694A, #3CCB9E); + --done: light-dark(#5A6067, #9AA0A6); + --on-urgent: light-dark(#000000, #000000); + --on-look: light-dark(#000000, #000000); + --on-working: light-dark(#FFFFFF, #000000); + --on-landing: light-dark(#FFFFFF, #000000); + --on-ready: light-dark(#000000, #000000); + --on-done: light-dark(#FFFFFF, #000000); + --font-head: "Atkinson Hyperlegible", sans-serif; + --font-body: "Atkinson Hyperlegible", sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --body-size: 14.5px; + --head-weight: 700; + --head-case: none; + --radius: 4px; + --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; + --title-shadow: none; +} +:root[data-theme^="garden-"] { + --bg: light-dark(#F6F0E1, #161A14); + --panel: light-dark(#FBF7EC, #1B2018); + --panel-2: light-dark(#EFE6CF, #1E241B); + --sel-bg: light-dark(#E6EEDA, #233020); + --text: light-dark(#2E2A1F, #E8E2CF); + --head: light-dark(#2E2A1F, #E8E2CF); + --dim: light-dark(#6B6250, #A59D84); + --rule: light-dark(#D9CCAA, #36402F); + --link: light-dark(#2F6B2A, #9BD480); + --urgent: light-dark(#A23B2A, #EC7A66); + --urgent-text: light-dark(#A23B2A, #EC7A66); + --look: light-dark(#F2C14E, #F2C14E); + --look-text: light-dark(#7A5200, #F2C14E); + --working: light-dark(#4F8A3C, #6FAE58); + --working-text: light-dark(#2F6B2A, #8BC970); + --landing: light-dark(#A0773F, #C39A5A); + --ready: light-dark(#5B8FB9, #8EC1E6); + --ready-text: light-dark(#2F5F86, #8EC1E6); + --done: light-dark(#6B6250, #A59D84); + --on-urgent: light-dark(#FFFFFF, #000000); + --on-look: light-dark(#000000, #000000); + --on-working: light-dark(#000000, #000000); + --on-landing: light-dark(#000000, #000000); + --on-ready: light-dark(#000000, #000000); + --on-done: light-dark(#FFFFFF, #000000); + --font-head: "Caprasimo", sans-serif; + --font-body: "Nunito", sans-serif; + --font-mono: "JetBrains Mono", monospace; + --body-size: 14px; + --head-weight: 400; + --head-case: none; + --radius: 8px; + --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; + --panel-shadow: none; + --title-shadow: none; +} +:root[data-theme^="factory-"] { + --bg: light-dark(#E3E6E8, #1C1D1F); + --panel: light-dark(#EFF1F3, #2F3033); + --panel-2: light-dark(#E6E9EB, #3D3F42); + --sel-bg: light-dark(#F6F7F8, #3B3E43); + --text: light-dark(#1B1D1F, #E3E3E3); + --head: light-dark(#1B1D1F, #FFE6C0); + --dim: light-dark(#3E4246, #B0B3B8); + --rule: light-dark(#8E9397, #55575B); + --link: light-dark(#6A3A00, #FFA200); + --urgent: light-dark(#A8261C, #E03434); + --urgent-text: light-dark(#7E1912, #FF9288); + --look: light-dark(#E8B33A, #E8B33A); + --look-text: light-dark(#4F3B00, #F2C14E); + --working: light-dark(#2E7D32, #5EB663); + --working-text: light-dark(#164A1A, #7FD07F); + --landing: light-dark(#E08A00, #FFA200); + --ready: light-dark(#1F6FA8, #4AA8E5); + --ready-text: light-dark(#0E4A74, #5FB4EA); + --done: light-dark(#3C4044, #8F9195); + --on-urgent: light-dark(#FFFFFF, #000000); + --on-look: light-dark(#000000, #000000); + --on-working: light-dark(#FFFFFF, #000000); + --on-landing: light-dark(#000000, #000000); + --on-ready: light-dark(#FFFFFF, #000000); + --on-done: light-dark(#FFFFFF, #000000); + --font-head: "Barlow Condensed", sans-serif; + --font-body: "Titillium Web", sans-serif; + --font-mono: "JetBrains Mono", monospace; + --body-size: 14px; + --head-weight: 700; + --head-case: uppercase; + --radius: 2px; + --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; + --activity-motion: activity-drift var(--activity-speed) linear infinite; + --title-shadow: none; +} 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/auth.rs b/src/console/auth.rs new file mode 100644 index 00000000..11c530be --- /dev/null +++ b/src/console/auth.rs @@ -0,0 +1,350 @@ +//! 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}; +use crate::config::UserConfig; + +mod password; +pub(super) use password::{code, set_password, set_password_page}; + +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, + pub(super) 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 matches!(path, "/sign-in" | "/set-password" | "/set-password/code") + || 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, + theme: String, + email_enabled: bool, +} + +pub(super) async fn sign_in_page( + Extension(access): Extension, + headers: HeaderMap, +) -> Response { + anonymous_page( + access, + headers, + |AnonymousForm { + csrf, + theme, + email_enabled, + }| { + Ok(SignInPage { + csrf, + theme, + email_enabled, + } + .render()?) + }, + ) + .await +} + +struct AnonymousForm { + csrf: String, + theme: String, + email_enabled: bool, +} + +async fn anonymous_page( + access: Access, + headers: HeaderMap, + render: impl FnOnce(AnonymousForm) -> Result + Send + 'static, +) -> 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 form_token = csrf(&token); + let mut response = super::page( + tokio::task::spawn_blocking(move || -> Result { + render(AnonymousForm { + csrf: form_token, + theme: UserConfig::load()?.console.theme, + email_enabled: crate::resend_key::ResendKey::find()?.is_some(), + }) + }) + .await, + ); + if response.status().is_success() { + 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, + theme: String, +} + +pub(super) async fn account(Extension(viewer): Extension) -> Response { + 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 { + 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, + theme: String, +} + +pub(super) async fn log() -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + Ok(LogPage { + events: Accounts::load()?.events()?, + theme: UserConfig::load()?.console.theme, + } + .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/auth/password.rs b/src/console/auth/password.rs new file mode 100644 index 00000000..e956536d --- /dev/null +++ b/src/console/auth/password.rs @@ -0,0 +1,124 @@ +//! The same anonymous email-code flow creates accounts and resets passwords. +use super::*; +use crate::{email::Resend, resend_key::ResendKey}; + +const OWNER: &str = "Email password setup is off. Ask the machine's owner to run thirdshift console password --email
    ."; +const SENT: &str = "If this address is allowed, a code has been sent. It lasts 15 minutes. Enter your code and new password."; + +#[derive(Template)] +#[template(path = "console/set_password.html")] +struct PasswordPage { + csrf: String, + theme: String, + email_enabled: bool, +} + +pub(in crate::console) async fn set_password_page( + Extension(access): Extension, + headers: HeaderMap, +) -> Response { + anonymous_page( + access, + headers, + |AnonymousForm { + csrf, + theme, + email_enabled, + }| { + Ok(PasswordPage { + csrf, + theme, + email_enabled, + } + .render()?) + }, + ) + .await +} + +#[derive(Deserialize)] +pub(in crate::console) struct PasswordForm { + email: String, + #[serde(default)] + code: String, + #[serde(default)] + password: String, + #[serde(default)] + csrf: String, +} + +fn authorized(access: &Access, headers: &HeaderMap, form: &PasswordForm) -> bool { + !access.no_sign_in + && cookie(headers, SIGN_IN).is_some_and(|token| valid_csrf(&form.csrf, &csrf(&token))) +} + +pub(in crate::console) async fn code( + Extension(access): Extension, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + if !authorized(&access, &headers, &form) { + return StatusCode::FORBIDDEN.into_response(); + } + // Availability is independent of the address. Do not wait for any work + // that depends on allowlist membership, including hashing and delivery. + match tokio::task::spawn_blocking(|| ResendKey::find().map(|key| key.is_some())).await { + Ok(Ok(true)) => { + tokio::task::spawn_blocking(move || { + let result = (|| -> Result<()> { + let config = UserConfig::load()?; + Accounts::load()?.send_code(&form.email, |code| { + Resend::new(Some(form.email.clone()), &config.email)?.send( + "Set your thirdshift Console password", + &format!("Your thirdshift Console code is {code}\n\nIt lasts 15 minutes and allows 5 tries. If you did not request it, ignore this email."), + ) + }) + })(); + if let Err(error) = result { + eprintln!("thirdshift Console: can't send password code: {error:#}"); + } + }); + axum::response::Html(SENT).into_response() + } + Ok(Ok(false)) => (StatusCode::SERVICE_UNAVAILABLE, OWNER).into_response(), + error => failed(error), + } +} + +pub(in crate::console) async fn set_password( + Extension(access): Extension, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if access.no_sign_in { + return StatusCode::NOT_FOUND.into_response(); + } + if !authorized(&access, &headers, &form) { + return StatusCode::FORBIDDEN.into_response(); + } + let result = tokio::task::spawn_blocking(move || -> Result> { + if ResendKey::find()?.is_none() { + return Ok(None); + } + Ok(Some(Accounts::load()?.set_password_by_code( + &form.email, + &form.code, + &form.password, + )?)) + }) + .await; + match result { + Ok(Ok(Some(SignIn::SignedIn(token)))) => { + let mut response = Redirect::to("/").into_response(); + set_cookie(&mut response, SESSION, &token, accounts::LIFETIME); + response + }, + Ok(Ok(Some(_))) => (StatusCode::UNAUTHORIZED, axum::response::Html("Password not set. Check your code or request another.")).into_response(), + Ok(Ok(None)) => (StatusCode::SERVICE_UNAVAILABLE, OWNER).into_response(), + Ok(Err(error)) => failed(error), + Err(error) => failed(error), + } +} diff --git a/src/console/chrome.html b/src/console/chrome.html new file mode 100644 index 00000000..30ed23f0 --- /dev/null +++ b/src/console/chrome.html @@ -0,0 +1,34 @@ + +

    + +

    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.

    + + +

    +
    + +
    + +

    Sections

    thirdshift console sections --hide done --show working

    +
    Visible Inbox sections
    + +
    + + +

    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
    +

    Shift+T opens Themes. Sections and Pause motion are in the footer.

    + +
    diff --git a/src/console/conversation.rs b/src/console/conversation.rs new file mode 100644 index 00000000..3b603c6b --- /dev/null +++ b/src/console/conversation.rs @@ -0,0 +1,174 @@ +//! Normalize retained Claude and Codex events into one escaped conversation. +use crate::logs::catalog::Kind; +use serde_json::Value; + +enum Part { + Message(String, String), + Thinking(String), + Tool(String, String), + Plain(String), +} + +pub(super) fn render(lines: &str, kind: Kind) -> String { + if matches!(kind, Kind::Command) { + 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" => { + 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(), + new.lines().count(), + old.lines() + .map(|line| format!("-{line}\n")) + .collect::(), + new.lines() + .map(|line| format!("+{line}\n")) + .collect::() + ) + } else { + return Part::Tool(name.into(), pre("fallback", &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/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/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 new file mode 100644 index 00000000..c3326dda --- /dev/null +++ b/src/console/inbox.html @@ -0,0 +1,54 @@ + + + + + + + {% if title == "Inbox" %} + {{ self.needs_you() }} need you · thirdshift Console + + {% else %} + {{ title }} · {{ page.hostname }} · thirdshift + + {% endif %} + + + + + + + + {% include "console/header.html" %} + {% include "console/nav.html" %} +
    +

    {{ 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 %} +
    + {{ 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 %} + {% endmatch %} +
    + {% include "console/chrome.html" %} + + diff --git a/src/console/install.rs b/src/console/install.rs new file mode 100644 index 00000000..91c0ffd7 --- /dev/null +++ b/src/console/install.rs @@ -0,0 +1,218 @@ +//! 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. + 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() + .is_ok_and(|output| output.status.success()) + { + return Some(proxy); + } + } + CANDIDATES + .into_iter() + .find(|(_, executable, _)| available(executable)) + .map(|(_, _, proxy)| proxy) + } +} + +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 + +systemctl daemon-reload +systemctl enable thirdshift-console.service +systemctl restart thirdshift-console.service + +{site}{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/console/live.rs b/src/console/live.rs new file mode 100644 index 00000000..dd25d3e1 --- /dev/null +++ b/src/console/live.rs @@ -0,0 +1,66 @@ +//! One reader per Console; slow browsers only need the latest change notice. +use std::{convert::Infallible, time::Duration}; + +use axum::Extension; +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>, + Extension(viewer): Extension, +) -> Response { + // WatchStream emits immediately, so every reconnect also catches up. + let stream = futures_util::stream::unfold( + (WatchStream::new(changes), viewer), + |(mut changes, viewer)| async move { + loop { + let signed_in = viewer.clone(); + match tokio::task::spawn_blocking(move || signed_in.still_allowed()).await { + Ok(Ok(true)) => {} + Ok(Ok(false)) => return None, + error => { + eprintln!("thirdshift Console: can't authorize Inbox stream: {error:?}"); + return None; + } + } + match tokio::time::timeout(Duration::from_millis(250), changes.next()).await { + Ok(Some(_)) => { + let event = Event::default().event("commands").data("changed"); + return Some((Ok::<_, Infallible>(event), (changes, viewer))); + } + Ok(None) => return None, + Err(_) => {} + } + } + }, + ); + ( + [("x-accel-buffering", "no"), ("cache-control", "no-store")], + Sse::new(stream).keep_alive(KeepAlive::default()), + ) + .into_response() +} diff --git a/src/console/logs.html b/src/console/logs.html new file mode 100644 index 00000000..9ba3eff6 --- /dev/null +++ b/src/console/logs.html @@ -0,0 +1,8 @@ + + +Logs · thirdshift +{% 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 %} +
    {% include "console/chrome.html" %} diff --git a/src/console/logs.rs b/src/console/logs.rs new file mode 100644 index 00000000..b180afce --- /dev/null +++ b/src/console/logs.rs @@ -0,0 +1,245 @@ +//! 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 axum::{Extension, Json}; +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 { + page: super::PageContext, + logs: Vec, + owner: String, +} + +#[derive(Template)] +#[template(path = "console/reader.html")] +struct ReaderPage { + page: super::PageContext, + 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 logs = catalog::load(&config, owner.as_deref())?; + Ok(LogsPage { + page: super::PageContext::new(&config), + logs, + owner: owner.unwrap_or_default(), + } + .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, None)? + .into_iter() + .find(|log| log.id == id) + else { + return Ok(None); + }; + let lines = read_lines(&log, 0, usize::MAX)?; + let html = conversation::render(&String::from_utf8_lossy(&lines.bytes), log.kind); + Ok(Some( + ReaderPage { + page: super::PageContext::new(&config), + log, + html, + offset: lines.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 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)?); + 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 next < length && bytes.len() < budget { + 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(Lines { + bytes, + offset: next, + done, + }) +} + +pub(super) async fn stream( + Path((id, offset)): Path<(String, u64)>, + Extension(executable): Extension, + 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()) + .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, + executable, + viewer, + )) + }) + .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, executable, viewer)| async move { + if done { + return None; + } + loop { + 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() || !signed_in.still_allowed()? { + 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, viewer), + )); + } + 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, viewer))); + } + } + } + }, + ); + 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/look.rs b/src/console/look.rs new file mode 100644 index 00000000..85303d63 --- /dev/null +++ b/src/console/look.rs @@ -0,0 +1,199 @@ +//! The CLI and authenticated page share the same restricted look writes. +use anyhow::{Context, Result, ensure}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Form, Json}; +use serde::Deserialize; +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()?; + let settings = console::write_look(Some(name), &[], &[])?; + accounts.record(actor, EventKind::ThemeChanged)?; + Ok(settings) +} + +fn write_sections(hide: &[String], show: &[String], actor: &str) -> Result { + console::validate_sections(hide)?; + console::validate_sections(show)?; + let accounts = Accounts::load()?; + let settings = console::write_look(None, hide, show)?; + accounts.record(actor, EventKind::SectionsChanged)?; + Ok(settings) +} + +fn cli_actor() -> Result { + let user = std::process::Command::new("id") + .arg("-un") + .output() + .context("could not identify the Unix account changing the Console look")?; + ensure!( + user.status.success(), + "could not identify the Unix account changing the Console look" + ); + Ok(format!("CLI:{}", String::from_utf8(user.stdout)?.trim())) +} + +pub fn theme(name: Option<&str>, json_output: bool) -> Result { + let Some(name) = name else { + return Ok(if json_output { + serde_json::to_string(&themes::names()?)? + } else { + themes::names()?.join("\n") + }); + }; + let settings = write_theme(name, &cli_actor()?)?; + Ok(if json_output { + serde_json::to_string(&settings)? + } else { + format!("Theme: {}", settings.theme) + }) +} + +pub fn sections(hide: &[String], show: &[String], json_output: bool) -> Result { + let settings = if hide.is_empty() && show.is_empty() { + UserConfig::load()?.console + } else { + write_sections(hide, show, &cli_actor()?)? + }; + Ok(if json_output { + serde_json::to_string(&settings)? + } else { + format!("Hidden sections: {}", settings.hidden_sections.join(", ")) + }) +} + +pub(super) async fn get(Extension(viewer): Extension) -> Response { + match tokio::task::spawn_blocking(|| { + 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, revision))) => Json(json!({ + "theme": config.console.theme, + "theme_revision": revision, + "hidden_sections": config.console.hidden_sections, + "themes": names, "sections": console::SECTIONS, + "csrf": viewer.csrf, + })) + .into_response(), + error => failed(error), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ThemeForm { + theme: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SectionsForm { + #[serde(default)] + hide: String, + #[serde(default)] + show: String, +} + +pub(super) async fn set_theme( + Extension(viewer): Extension, + Form(form): Form, +) -> Response { + if let Err(error) = console::validate_theme(&form.theme) { + return (StatusCode::BAD_REQUEST, error.to_string()).into_response(); + } + written(tokio::task::spawn_blocking(move || write_theme(&form.theme, &viewer.email)).await) +} + +pub(super) async fn set_sections( + Extension(viewer): Extension, + Form(form): Form, +) -> Response { + let split = |value: &str| { + value + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect::>() + }; + let (hide, show) = (split(&form.hide), split(&form.show)); + if let Err(error) = + console::validate_sections(&hide).and_then(|()| console::validate_sections(&show)) + { + return (StatusCode::BAD_REQUEST, error.to_string()).into_response(); + } + written(tokio::task::spawn_blocking(move || write_sections(&hide, &show, &viewer.email)).await) +} + +fn written( + result: std::result::Result, tokio::task::JoinError>, +) -> Response { + match result { + Ok(Ok(settings)) => Json(settings).into_response(), + error => failed(error), + } +} + +fn failed(error: impl std::fmt::Debug) -> Response { + eprintln!("thirdshift Console: look operation failed: {error:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't read or save the Console look.", + ) + .into_response() +} diff --git a/src/console/nav.html b/src/console/nav.html new file mode 100644 index 00000000..4fa57bd3 --- /dev/null +++ b/src/console/nav.html @@ -0,0 +1 @@ + diff --git a/src/console/peek.html b/src/console/peek.html new file mode 100644 index 00000000..91083970 --- /dev/null +++ b/src/console/peek.html @@ -0,0 +1,11 @@ +
    +

    Command details

    + {% if can_stop %}
    {{ stop_cli }}
    {% endif %} + {% if stopping %}

    still ending

    {% endif %} + {% for branch in branches %}

    Issue branch left: {{ branch }}

    {% endfor %} + {% for pr in pull_requests %}

    Pull request left: {{ pr }}

    {% endfor %} + {{ 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..abf215d1 --- /dev/null +++ b/src/console/peek.rs @@ -0,0 +1,109 @@ +//! 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 can_stop: bool, + pub live: bool, + pub stopping: bool, + pub stop_cli: String, + pub branches: Vec, + pub pull_requests: 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, inspection: crate::logs::stop::Inspection) -> Self { + let value = inspection.details; + 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 { + live: value["state"] == "live", + stop_cli: format!("thirdshift stop '{}'", command.replace('\'', "'\\''")), + can_stop: inspection.can_stop, + stopping: inspection.stopping, + branches: inspection.branches, + pull_requests: inspection.pull_requests, + 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/reader.html b/src/console/reader.html new file mode 100644 index 00000000..4eb53707 --- /dev/null +++ b/src/console/reader.html @@ -0,0 +1,10 @@ + + +{{ log.kind }} · thirdshift +{% 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 %}

    +{# Only the conversation renderer's escaped HTML is trusted. #} +
    {{ html|safe }}
    +
    {% include "console/chrome.html" %} diff --git a/src/console/restart.rs b/src/console/restart.rs new file mode 100644 index 00000000..f400822c --- /dev/null +++ b/src/console/restart.rs @@ -0,0 +1,61 @@ +//! Watch the installed path, rather than /proc's possibly unlinked executable. +use std::path::PathBuf; +use std::time::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 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)); + #[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..53bd88a2 --- /dev/null +++ b/src/console/row.html @@ -0,0 +1,13 @@ +
  • +
    {% if row.live %}{% endif %}{{ 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 %} + {% 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/console/set_password.html b/src/console/set_password.html new file mode 100644 index 00000000..2ea67a46 --- /dev/null +++ b/src/console/set_password.html @@ -0,0 +1,25 @@ +{% extends "console/account_base.html" %} +{% block title %}Set your password{% endblock %} +{% block navigation %}{% endblock %} +{% block content %} +

    Set your password

    +{% if email_enabled %} +

    Create your account or reset your password with a code sent to your email.

    + +

    A code lasts 15 minutes and allows 5 tries.

    + +{% else %} +

    Email password setup is off. Ask the machine's owner to run thirdshift console password --email <address>.

    +{% endif %} +

    Sign in

    +{% endblock %} diff --git a/src/console/sign_in.html b/src/console/sign_in.html new file mode 100644 index 00000000..062837ef --- /dev/null +++ b/src/console/sign_in.html @@ -0,0 +1,17 @@ +{% extends "console/account_base.html" %} +{% block title %}Sign in{% endblock %} +{% block navigation %}{% endblock %} +{% block content %} +

    Sign in

    + +{% if email_enabled %} +

    Set your password to create an account or reset a forgotten password.

    +{% else %} +

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

    +{% endif %} +{% endblock %} diff --git a/src/console/stop.html b/src/console/stop.html new file mode 100644 index 00000000..728bc107 --- /dev/null +++ b/src/console/stop.html @@ -0,0 +1,20 @@ + + +Stop · thirdshift +{% include "console/header.html" %}{% include "console/nav.html" %} +
    +

    {{ details.command }}

    + {% if details.can_stop %} +

    Stop this Command?

    +

    Its issue will be labelled needs-triage for the Day shift. Requeue instead puts it back in line with ready-for-agent.

    +
    + + + + {{ details.stop_cli }} + {{ details.stop_cli }} --requeue + Cancel +
    + {% endif %} + {{ details|safe }} +
    diff --git a/src/console/stop.rs b/src/console/stop.rs new file mode 100644 index 00000000..83ea35bf --- /dev/null +++ b/src/console/stop.rs @@ -0,0 +1,73 @@ +//! Confirmation and observation of the shared CLI Stop. +use anyhow::Result; +use askama::Template; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::{Extension, Form, extract::RawQuery, http::StatusCode}; +use serde::Deserialize; + +use super::{auth::Viewer, peek::Peek}; +use crate::{config::UserConfig, logs::stop}; + +#[derive(Template)] +#[template(path = "console/stop.html")] +struct StopPage { + page: super::PageContext, + csrf: String, + details: Peek, +} + +pub(super) async fn show( + Extension(viewer): Extension, + RawQuery(query): RawQuery, +) -> Response { + let command = super::query_value(&query, "command"); + match tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + let inspection = stop::inspect(&config, &command)?; + Ok(StopPage { + page: super::PageContext::new(&config), + csrf: viewer.csrf, + details: Peek::new(command, inspection), + } + .render()?) + }) + .await + { + Ok(Ok(html)) => Html(html).into_response(), + _ => (StatusCode::NOT_FOUND, "Command details are unavailable.").into_response(), + } +} + +#[derive(Deserialize)] +pub(super) struct StopForm { + command: String, + #[serde(default)] + confirmed: String, + ending: String, +} + +pub(super) async fn submit( + Extension(viewer): Extension, + Form(form): Form, +) -> Response { + if form.confirmed != "yes" || !matches!(form.ending.as_str(), "triage" | "requeue") { + return StatusCode::BAD_REQUEST.into_response(); + } + let location = crate::logs::status::inspect::url("/stop", &form.command, None); + let result = tokio::task::spawn_blocking(move || { + stop::request_stop( + &form.command, + stop::Request { + who: viewer.email, + requeue: form.ending == "requeue", + from_console: true, + }, + ) + }) + .await; + match result { + Ok(Ok(_)) => Redirect::to(&location).into_response(), + Ok(Err(error)) => (StatusCode::CONFLICT, error.to_string()).into_response(), + Err(error) => super::page(Err(error)), + } +} diff --git a/src/console/time.html b/src/console/time.html new file mode 100644 index 00000000..481108eb --- /dev/null +++ b/src/console/time.html @@ -0,0 +1,54 @@ + + + + + + {{ title }} · {{ page.hostname }} · thirdshift + + + + + + + {% include "console/header.html" %} + {% 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. 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 %} + + + {% 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.text }}
    + {% 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..7400489d --- /dev/null +++ b/src/console/time.rs @@ -0,0 +1,37 @@ +//! 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 { + page: super::PageContext, + 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 { + page: super::PageContext::new(&config), + title: if stages { "Stages" } else { "Lanes" }, + hours: hours.to_string(), + views: time::load(&config, hours)?, + } + .render()?) + }) + .await, + ) +} diff --git a/src/console/waterfall.html b/src/console/waterfall.html new file mode 100644 index 00000000..334b9879 --- /dev/null +++ b/src/console/waterfall.html @@ -0,0 +1,40 @@ + + + + + + Waterfall · {{ page.hostname }} · thirdshift + + + + + + + {% include "console/header.html" %} + {% 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..ac3a5948 --- /dev/null +++ b/src/console/waterfall.rs @@ -0,0 +1,28 @@ +//! 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 { + page: super::PageContext, + view: View, +} + +pub(super) async fn show(query: Option) -> Response { + super::page( + tokio::task::spawn_blocking(move || -> Result { + let config = UserConfig::load()?; + Ok(WaterfallPage { + page: super::PageContext::new(&config), + view: waterfall::load(&config, &super::query_value(&query, "command"))?, + } + .render()?) + }) + .await, + ) +} 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.rs b/src/logs.rs index 70611642..5f8d279f 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/command_record.rs b/src/logs/command_record.rs index 3cc7b219..62225a7a 100644 --- a/src/logs/command_record.rs +++ b/src/logs/command_record.rs @@ -56,6 +56,11 @@ pub(super) struct CommandRecord { pub parent_dispatch: Option, #[serde(default)] pub tickets: Vec, + /// 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)] @@ -100,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 { @@ -139,7 +159,7 @@ impl StageName { } } - fn known(name: &str) -> bool { + pub(super) fn known(name: &str) -> bool { [ Self::Implement, Self::Resume, @@ -162,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 { @@ -180,6 +224,8 @@ 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; + self.security_findings = account.security_findings.unwrap_or_default().to_vec(); } pub fn live(&self, outside: &mut impl super::effects::Outside) -> bool { @@ -235,6 +281,8 @@ impl CommandRecord { parent_record: None, 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 ecfc7ae1..29a2e6f7 100644 --- a/src/logs/reading.rs +++ b/src/logs/reading.rs @@ -1,7 +1,10 @@ //! 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; mod selection; #[cfg(test)] mod tests; @@ -17,7 +20,9 @@ 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 inbox::{Group, InboxItem}; pub(super) use selection::{Detailed, Observation}; pub(super) struct Snapshot { @@ -27,7 +32,19 @@ 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)) + } + + 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 +74,7 @@ impl Snapshot { ); } } + let ranges = health::UsualRanges::from_records(&records); let roots: Vec<_> = records .iter() .filter(|(_, tree)| { @@ -70,6 +88,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, @@ -78,20 +101,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> { @@ -138,35 +149,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)] @@ -179,6 +161,8 @@ pub(super) struct Command { pub(super) source: &'static str, pub(super) command_log: PathBuf, pub(super) dispatched: Vec, + pub(super) health: Option, + pub(super) usual_ranges: BTreeMap, } #[derive(Serialize)] @@ -213,6 +197,8 @@ impl Command { children: Vec::new(), source: "command_record", dispatched: Vec::new(), + health: None, + usual_ranges: BTreeMap::new(), } } } @@ -308,12 +294,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/catalog.rs b/src/logs/reading/catalog.rs new file mode 100644 index 00000000..63573259 --- /dev/null +++ b/src/logs/reading/catalog.rs @@ -0,0 +1,219 @@ +//! 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(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: Kind, + pub live: bool, + pub command: String, + 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 { + path.as_os_str() + .as_encoded_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +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. + 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()); + 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, + 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)?; + } + 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 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)?)); + } + 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") { + Kind::Session + } else { + Kind::Command + }, + live, + command, + owners, + selector: if selector.is_empty() { + path.to_string_lossy().into_owned() + } else { + selector + }, + path, + }); + } + } + } + } + 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, None)? + .into_iter() + .find(|log| log.id == selected) + .context("log is not in the current inventory") +} + +pub(crate) fn show(json: bool, owner: Option<&str>) -> Result<()> { + let logs = load(&UserConfig::load()?, owner)?; + 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/reading/health.rs b/src/logs/reading/health.rs new file mode 100644 index 00000000..6625a5b4 --- /dev/null +++ b/src/logs/reading/health.rs @@ -0,0 +1,210 @@ +//! 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 timing in record.stage_times(None) { + let stage = timing.stage; + if !StageName::known(&stage.name) { + continue; + } + if let Some(seconds) = timing.seconds() { + 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); + 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 { + 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/inbox.rs b/src/logs/reading/inbox.rs new file mode 100644 index 00000000..7eabc515 --- /dev/null +++ b/src/logs/reading/inbox.rs @@ -0,0 +1,327 @@ +//! 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, Observation}; + +#[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: Observation<'a>, + pub group: Group, + pub needs_you_kind: Option, + pub landing: bool, + pub count: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + 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, + ended_at: Option<&'a str>, + outcome: Option<&'a str>, + cause: Option<&'a str>, + ready: bool, +} + +impl<'a> Observation<'a> { + fn facts(self) -> Facts<'a> { + match self { + Self::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(), + }, + Self::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> { + // 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 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 { + latest + .entry(issue) + .and_modify(|time: &mut DateTime| *time = (*time).max(*start)) + .or_insert(*start); + } + } + let mut items = Vec::new(); + 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, 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 + .outcome + .is_some_and(|outcome| outcome.contains("failed") || outcome == "review required") + { + Some(NeedsYouKind::Failed) + } else if matches!(command, Observation::Record(command) if command.record.security_findings_waiting) + { + Some(NeedsYouKind::Finding) + } else { + None + }; + let live_problem = match command { + Observation::Record(command) if facts.state == "live" => live_problem(command), + _ => None, + }; + let landing = matches!(command, Observation::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| cutoff.is_none_or(|cutoff| 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| cutoff.is_none_or(|cutoff| 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: Observation<'_>) -> 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 { + Observation::Record(command) => collect(command, &mut starts), + Observation::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/selection.rs b/src/logs/reading/selection.rs index 54b0c499..fbd30e90 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; @@ -316,7 +352,11 @@ pub(super) fn resolve<'a>(entries: &'a [Entry], input: &str) -> Result { #[serde(flatten)] 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, @@ -377,6 +419,8 @@ impl<'a> Details<'a> { Ok(Self { 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/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/recording.rs b/src/logs/recording.rs index e10e1cdb..c4dca8be 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -271,7 +271,11 @@ impl Record { if let Some(request) = self.stop_request() { self.activity_write( &root, - &format!("{subject} stopped from the CLI by {}", request.who), + &format!( + "{subject} stopped from the {} by {}", + request.source(), + request.who + ), None, ); } diff --git a/src/logs/status.rs b/src/logs/status.rs index e6ee22ff..24b90672 100644 --- a/src/logs/status.rs +++ b/src/logs/status.rs @@ -1,13 +1,18 @@ //! Format machine-wide Command observations. +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}; use anyhow::Result; use chrono::Local; use serde::Serialize; use super::command_record::CommandRecord; -use super::reading::{Command, Entry, IdlePass, Snapshot}; +use super::reading::{Command, Group, IdlePass, InboxItem, Observation, Snapshot}; use crate::config::UserConfig; use crate::issue::IssueUrl; @@ -15,14 +20,14 @@ use crate::issue::IssueUrl; struct Status<'a> { version: u32, hostname: String, - commands: Vec<&'a Entry>, + commands: Vec>, idle_passes: &'a [IdlePass], } 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,9 +36,13 @@ 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}", + "{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") @@ -65,21 +74,64 @@ 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()), - 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 { + Observation::Record(command) => print_command(command, ""), + Observation::History(command) => print_history(command), + } + if item.count > 1 { + let cause = item.cause().unwrap_or_default(); + println!(" {} Commands with this cause: {cause}", item.count); + } + } + for item in references { + if let Observation::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 { @@ -93,7 +145,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,39 +160,51 @@ 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) { 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..fd5058f1 --- /dev/null +++ b/src/logs/status/inbox.rs @@ -0,0 +1,373 @@ +//! 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, Health, Observation, 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 id: String, + pub started_ms: i64, + pub live: bool, + pub logging: bool, + pub log_growth: String, + pub selector: String, + pub logs_url: String, + 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 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( + match entry { + Entry::Record(command) => Observation::Record(command), + Entry::History(command) => Observation::History(command), + }, + now, + &config.logs_dir, + ) + }) + .collect::>()?; + Ok(Archive { + rows, + repositories, + outcomes, + }) +} + +fn entry_row(entry: Observation<'_>, now: DateTime, root: &std::path::Path) -> Result { + match entry { + 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 { + 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(), + selector: Observation::History(command).selector().unwrap_or_default(), + logs_url: command + .command_log + .as_ref() + .map(|path| logs_url(path, root)) + .unwrap_or_else(|| "/?page=logs".into()), + 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 { + 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) + .map(|group| { + let rows = items + .iter() + .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(); + } + row.count = item.count; + row.cause = item.cause().unwrap_or_default().into(); + Ok(row) + }) + .collect::>()?; + let references = items + .iter() + .filter(|item| { + group == Group::Landing && item.landing && item.group == Group::NeedsYou + }) + .filter_map(|item| { + let Observation::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(); + Ok(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, + outer_log: Option<&Path>, + root: &Path, +) -> Result { + 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()); + Ok(Row { + // 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 { .. })), + log_growth: format!( + "{}:{}", + log_length(Some(log)), + log_length(facts.session_log.as_deref()) + ), + selector: Observation::Record(command).selector().unwrap_or_default(), + logs_url: logs_url(&command.record_path, root), + 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, Some(log), root)) + .collect::>()?, + findings: facts.security_findings.clone(), + sessions: command.sessions()?, + }) +} + +fn logs_url(path: &std::path::Path, root: &std::path::Path) -> String { + path.strip_prefix(root) + .map(|path| format!("/logs/command/{}", crate::logs::catalog::id(path))) + .unwrap_or_else(|_| "/?page=logs".into()) +} + +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))) + .unwrap_or_else(|_| "unknown age".into()) +} diff --git a/src/logs/status/inspect.rs b/src/logs/status/inspect.rs index 1afd0819..48bbaf5d 100644 --- a/src/logs/status/inspect.rs +++ b/src/logs/status/inspect.rs @@ -9,6 +9,37 @@ use serde::Serialize; use crate::config::UserConfig; use crate::logs::reading::{Command, Observation, Snapshot}; +pub(crate) fn log_path( + config: &UserConfig, + input: &str, + session: Option<&str>, +) -> Result { + let snapshot = Snapshot::load(&config.logs_dir, Local::now())?; + let command = snapshot.resolve(input)?.observation; + selected_log(command, session) +} + +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, "")?, @@ -39,7 +70,12 @@ fn print_details(command: &Command, indent: &str) -> Result<()> { ); } for stage in &command.record.stages { - println!("{indent} {} · {}", stage.name, stage.began_at); + 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); } for path in command.sessions()? { println!("{indent}Session log: {}", path.display()); @@ -56,7 +92,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)] @@ -84,13 +121,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/src/logs/status/time.rs b/src/logs/status/time.rs new file mode 100644 index 00000000..c719b188 --- /dev/null +++ b/src/logs/status/time.rs @@ -0,0 +1,283 @@ +//! 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, + stage_metadata: bool, +} + +#[derive(Clone)] +pub(crate) struct Stage { + pub name: String, + pub running: bool, + 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, +} + +pub(crate) struct Cell { + pub text: String, + pub running: bool, +} + +fn timestamp(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|time| time.timestamp()) +} + +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 + .stage_times(live_until) + .enumerate() + .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" + } 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(|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(|_| 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(), + running: command.state == "live" && index + 1 == record.stages.len(), + 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), + stage_metadata: true, + } +} + +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); + } +} + +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, 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), + stage_metadata: false, + }); + } + } + 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 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 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(); + 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 stages: Vec<_> = run + .stages + .iter() + .filter(|stage| &stage.name == name) + .collect(); + let text = if !run.stage_metadata { + "Unknown · no stage times retained".into() + } else if stages.is_empty() { + "Not reached · —".into() + } else { + stages + .iter() + .map(|stage| stage.text.as_str()) + .collect::>() + .join("; ") + }; + Cell { + text, + running: stages.iter().any(|stage| stage.running), + } + }) + .collect(), + }) + .collect(); + grids.push(Grid { + repository: repository.clone(), + runs: batch, + 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/src/logs/status/waterfall.rs b/src/logs/status/waterfall.rs new file mode 100644 index 00000000..6ea52d04 --- /dev/null +++ b/src/logs/status/waterfall.rs @@ -0,0 +1,334 @@ +//! 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
") + .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"); + // 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 _open_stream = console.get("/events"); + // 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(); + 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/console/accounts.rs b/tests/console/accounts.rs new file mode 100644 index 00000000..49f5e6a3 --- /dev/null +++ b/tests/console/accounts.rs @@ -0,0 +1,638 @@ +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"; + +#[path = "password_codes.rs"] +mod password_codes; + +#[path = "stop.rs"] +mod stop; + +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 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 ["/", "/lanes", "/stages", "/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(); + 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 [ + "/", + "/?page=lanes", + "/?page=stages", + "/?page=waterfall", + "/?page=2", + "/?page=3", + "/?page=4", + "/?page=history", + "/?page=security", + "/?page=logs", + "/history", + "/security", + "/lanes", + "/stages", + "/waterfall", + "/peek?command=widgets%237", + "/log?command=widgets%237", + "/logs", + "/logs/command/id", + "/logs/id", + "/logs/id/stream/0", + "/events", + "/console-log", + "/account", + "/look", + ] { + 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); + for path in [ + "/?page=history", + "/?page=security", + "/?page=logs", + "/history", + "/security", + ] { + assert_eq!( + get(&console, path, &session).status(), + 200, + "signed-in access failed: {path}" + ); + } + 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 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(); + 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); + 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)); + 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 + ); +} + +#[test] +fn an_open_inbox_stream_closes_when_its_session_is_revoked() { + for revoke in ["sign-out", "password", "allowlist"] { + 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 response = get(&console, "/events", &session); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-type"], "text/event-stream"); + let mut stream = BufReader::new(response); + let mut first = String::new(); + loop { + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0); + if line.trim().is_empty() { + break; + } + first.push_str(&line); + } + assert!(first.contains("event: commands")); + match revoke { + "sign-out" => assert!(cli(home.path(), &["console", "sign-out"]).status.success()), + "password" => { + assert!(password(home.path(), None, PASSWORD, PASSWORD).0.success()); + } + "allowlist" => allow(home.path(), false), + _ => unreachable!(), + } + let mut rest = String::new(); + stream.read_to_string(&mut rest).unwrap(); + assert_eq!(get(&console, "/events", &session).status(), 303, "{revoke}"); + } +} + +// 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_mut(), + std::ptr::null_mut(), + ) + }, + 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 as _, 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()); +} diff --git a/tests/console/look.rs b/tests/console/look.rs new file mode 100644 index 00000000..08b2e62a --- /dev/null +++ b/tests/console/look.rs @@ -0,0 +1,352 @@ +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 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(); + 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)") + ); +} diff --git a/tests/console/password_codes.rs b/tests/console/password_codes.rs new file mode 100644 index 00000000..23a22b89 --- /dev/null +++ b/tests/console/password_codes.rs @@ -0,0 +1,489 @@ +use super::*; +use crate::support::resend::ResendStandIn; + +fn start(home: &Path, resend: &ResendStandIn, environment_key: bool) -> Console { + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command + .args(["console"]) + .env("HOME", home) + .env("THIRDSHIFT_RESEND_URL", resend.url()) + .env_remove("RESEND_API_KEY"); + if environment_key { + command.env("RESEND_API_KEY", "re_environment"); + } + Console::start_command(command) +} + +fn form(console: &Console) -> (String, String) { + let response = get(console, "/set-password", ""); + assert_eq!(response.status(), 200); + let cookie = cookie(&response); + (cookie, csrf(&response.text().unwrap())) +} + +fn post(console: &Console, path: &str, fields: &[(&str, &str)]) -> reqwest::blocking::Response { + let (cookie, csrf) = form(console); + let mut fields = fields.to_vec(); + fields.push(("csrf", &csrf)); + client() + .post(format!("{}{path}", console.address)) + .header("cookie", cookie) + .form(&fields) + .send() + .unwrap() +} + +fn code(resend: &ResendStandIn) -> String { + wait_for_email(resend); + let requests = resend.requests(); + let text = requests.last().unwrap().body["text"].as_str().unwrap(); + text.split_whitespace() + .find(|word| word.len() == 6 && word.bytes().all(|b| b.is_ascii_digit())) + .expect("six-digit code in email") + .into() +} + +fn wait_for_email(resend: &ResendStandIn) { + let deadline = Instant::now() + Duration::from_secs(10); + while resend.requests().is_empty() { + assert!(Instant::now() < deadline, "no code reached Resend"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn email_code_creates_and_resets_an_account_without_revealing_the_allowlist() { + for environment_key in [false, true] { + let home = configured(); + fs::write( + home.path().join(".thirdshift/credentials.toml"), + "[resend]\nkey = 're_credentials'\n", + ) + .unwrap(); + let mut config = fs::read_to_string(home.path().join(".thirdshift/config.toml")).unwrap(); + config.push_str("[email]\nfrom = 'console@example.test'\n"); + fs::write(home.path().join(".thirdshift/config.toml"), config).unwrap(); + let resend = ResendStandIn::replying(200, "{}"); + if environment_key { + assert!( + password(home.path(), None, "old password", "old password") + .0 + .success() + ); + } + let console = start(home.path(), &resend, environment_key); + let old_session = + environment_key.then(|| cookie(&sign_in(&console, EMAIL, "old password"))); + assert!( + get(&console, "/sign-in", "") + .text() + .unwrap() + .contains("/set-password") + ); + let unknown = post( + &console, + "/set-password/code", + &[("email", "unknown@example.test")], + ); + let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); + assert_eq!(unknown.status(), allowed.status()); + assert_eq!(unknown.text().unwrap(), allowed.text().unwrap()); + wait_for_email(&resend); + let requests = resend.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/emails"); + assert_eq!(requests[0].body["to"], EMAIL); + assert_eq!(requests[0].body["from"], "console@example.test"); + assert_eq!( + requests[0].authorization.as_deref(), + Some(if environment_key { + "Bearer re_environment" + } else { + "Bearer re_credentials" + }) + ); + let code = code(&resend); + let stored: Value = serde_json::from_slice( + &fs::read(home.path().join(".thirdshift/console-accounts.json")).unwrap(), + ) + .unwrap(); + assert!( + stored[EMAIL]["codes"]["pending"]["hash"] + .as_str() + .unwrap() + .starts_with("$argon2id$") + ); + assert!( + !serde_json::to_string(&stored) + .unwrap() + .contains(&format!("\"{code}\"")) + ); + let response = post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", PASSWORD)], + ); + assert_eq!(response.status(), 303); + let session = cookie(&response); + assert_eq!(get(&console, "/", &session).status(), 200); + if let Some(old_session) = old_session { + assert_eq!(get(&console, "/", &old_session).status(), 303); + assert_eq!(sign_in(&console, EMAIL, "old password").status(), 401); + } + assert_eq!(sign_in(&console, EMAIL, PASSWORD).status(), 303); + let stored: Value = serde_json::from_slice( + &fs::read(home.path().join(".thirdshift/console-accounts.json")).unwrap(), + ) + .unwrap(); + assert_eq!(stored[EMAIL]["verified"], true); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", "replay")] + ) + .status(), + 401 + ); + assert!( + password(home.path(), None, "new password", "new password") + .0 + .success() + ); + assert_eq!(get(&console, "/", &session).status(), 303); + let events: Vec = + serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout) + .unwrap(); + assert!( + events + .iter() + .any(|event| event["event"] == "code-sent" && event["email"] == EMAIL) + ); + assert!( + events + .iter() + .any(|event| event["event"] == "password-set" && event["email"] == EMAIL) + ); + } +} + +#[test] +fn code_flow_requires_csrf_and_enforces_five_tries_and_send_throttling_over_http() { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, true); + for path in ["/set-password/code", "/set-password"] { + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .form(&[("email", EMAIL), ("code", "123456"), ("password", PASSWORD)]) + .send() + .unwrap() + .status(), + 403 + ); + let (cookie, _) = form(&console); + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .header("cookie", cookie) + .form(&[ + ("email", EMAIL), + ("code", "123456"), + ("password", PASSWORD), + ("csrf", "wrong") + ]) + .send() + .unwrap() + .status(), + 403 + ); + } + assert!(resend.requests().is_empty()); + let first = post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(); + let code = code(&resend); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(), + first + ); + assert_eq!(resend.requests().len(), 1); + let wrong = if code == "000000" { "111111" } else { "000000" }; + for _ in 0..5 { + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", wrong), ("password", PASSWORD)] + ) + .status(), + 401 + ); + } + // The attempt budget is durable across server restarts. + drop(console); + let console = start(home.path(), &resend, true); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", PASSWORD)] + ) + .status(), + 401 + ); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(), + first + ); + assert_eq!(resend.requests().len(), 1); +} + +#[test] +fn email_flow_is_off_without_a_key_and_in_no_sign_in_mode() { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, false); + for path in ["/sign-in", "/set-password"] { + let html = get(&console, path, "").text().unwrap(); + assert!(html.contains("thirdshift console password")); + assert!(!html.contains("Send code")); + } + for path in ["/set-password/code", "/set-password"] { + let page = get(&console, "/sign-in", ""); + let cookie = cookie(&page); + let csrf = csrf(&page.text().unwrap()); + let response = client() + .post(format!("{}{path}", console.address)) + .header("cookie", cookie) + .form(&[ + ("email", EMAIL), + ("code", "123456"), + ("password", PASSWORD), + ("csrf", &csrf), + ]) + .send() + .unwrap(); + assert_eq!(response.status(), 503); + assert!( + response + .text() + .unwrap() + .contains("thirdshift console password") + ); + } + assert!(resend.requests().is_empty()); + let console = Console::start(Path::new(env!("CARGO_BIN_EXE_thirdshift")), home.path()); + assert_eq!(get(&console, "/set-password", "").status(), 404); + for path in ["/set-password/code", "/set-password"] { + assert_eq!( + client() + .post(format!("{}{path}", console.address)) + .form(&[("email", EMAIL)]) + .send() + .unwrap() + .status(), + 404 + ); + } +} + +#[test] +fn resend_refusals_do_not_reveal_the_allowlist_or_record_a_code_sent() { + let home = configured(); + let resend = ResendStandIn::replying(503, "unavailable"); + let console = start(home.path(), &resend, true); + let unknown = post( + &console, + "/set-password/code", + &[("email", "unknown@example.test")], + ); + let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); + assert_eq!(unknown.status(), allowed.status()); + assert_eq!(unknown.text().unwrap(), allowed.text().unwrap()); + wait_for_email(&resend); + assert_eq!(resend.requests().len(), 1); + let events: Vec = + serde_json::from_slice(&cli(home.path(), &["console", "log", "--json"]).stdout).unwrap(); + assert!(!events.iter().any(|event| event["event"] == "code-sent")); +} + +#[test] +fn code_response_timing_does_not_reveal_the_allowlist() { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpListener; + use std::time::{Duration, Instant}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let resend_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let mut length = 0; + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line.trim().is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + length = value.trim().parse::().unwrap(); + } + } + reader.read_exact(&mut vec![0; length]).unwrap(); + std::thread::sleep(Duration::from_secs(2)); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\ + Connection: close\r\n\r\n{}", + ) + .unwrap(); + }); + + let home = configured(); + let mut command = Command::new(env!("CARGO_BIN_EXE_thirdshift")); + command + .args(["console"]) + .env("HOME", home.path()) + .env("RESEND_API_KEY", "re_test") + .env("THIRDSHIFT_RESEND_URL", resend_url); + let console = Console::start_command(command); + + let started = Instant::now(); + let unknown = post( + &console, + "/set-password/code", + &[("email", "unknown@example.test")], + ); + let unknown_elapsed = started.elapsed(); + let unknown_status = unknown.status(); + let unknown_body = unknown.text().unwrap(); + + let started = Instant::now(); + let allowed = post(&console, "/set-password/code", &[("email", EMAIL)]); + let allowed_elapsed = started.elapsed(); + let allowed_status = allowed.status(); + let allowed_body = allowed.text().unwrap(); + server.join().unwrap(); + + assert_eq!(unknown_status, allowed_status); + assert_eq!(unknown_body, allowed_body); + assert!( + allowed_elapsed.abs_diff(unknown_elapsed) < Duration::from_secs(1), + "allowlist exposed: unknown={unknown_elapsed:?}, allowed={allowed_elapsed:?}", + ); +} + +// Prepare old durable input while the server is stopped, rather than adding +// a test-only clock to the production HTTP interface. +fn stored_codes(home: &Path, edit: impl FnOnce(&mut Value)) { + let path = home.join(".thirdshift/console-accounts.json"); + let mut accounts: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + edit(&mut accounts[EMAIL]["codes"]); + fs::write(path, serde_json::to_vec(&accounts).unwrap()).unwrap(); +} + +#[test] +fn code_expiry_is_enforced_over_http_with_a_delivered_code() { + for (age, expected_status) in [(14 * 60, 303), (15 * 60, 401)] { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, true); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]).status(), + 200 + ); + let code = code(&resend); + // The log command takes the shared lock, so the delivered send is + // finished before the fixture is closed and its timestamps aged. + assert!(cli(home.path(), &["console", "log"]).status.success()); + drop(console); + stored_codes(home.path(), |codes| { + codes["pending"]["sent_at"] = json!(chrono::Utc::now().timestamp() - age); + }); + let console = start(home.path(), &resend, true); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &code), ("password", PASSWORD)] + ) + .status(), + expected_status + ); + } +} + +#[test] +fn daily_code_limit_is_enforced_over_http_and_ages_out() { + let home = configured(); + let resend = ResendStandIn::replying(200, "{}"); + let console = start(home.path(), &resend, true); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]).status(), + 200 + ); + let _ = code(&resend); + assert!(cli(home.path(), &["console", "log"]).status.success()); + drop(console); + stored_codes(home.path(), |codes| { + let now = chrono::Utc::now().timestamp(); + codes["sent"] = json!( + (1..=10) + .rev() + .map(|minute| now - minute * 60) + .collect::>() + ); + }); + let console = start(home.path(), &resend, true); + let refused = post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(); + // Delivery is asynchronous to prevent allowlist disclosure. Observe the + // absence of a second email across a bounded delivery window. + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + assert_eq!(resend.requests().len(), 1, "eleventh code was sent"); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(cli(home.path(), &["console", "log"]).status.success()); + drop(console); + stored_codes(home.path(), |codes| { + let now = chrono::Utc::now().timestamp(); + codes["sent"] = json!([now - 24 * 60 * 60]); + }); + let console = start(home.path(), &resend, true); + assert_eq!( + post(&console, "/set-password/code", &[("email", EMAIL)]) + .text() + .unwrap(), + refused + ); + let deadline = Instant::now() + Duration::from_secs(10); + while resend.requests().len() < 2 { + assert!(Instant::now() < deadline, "code stayed blocked after a day"); + std::thread::sleep(Duration::from_millis(10)); + } + let fresh = code(&resend); + assert_eq!( + post( + &console, + "/set-password", + &[("email", EMAIL), ("code", &fresh), ("password", PASSWORD)] + ) + .status(), + 303 + ); +} diff --git a/tests/console/stop.rs b/tests/console/stop.rs new file mode 100644 index 00000000..c1c55e2d --- /dev/null +++ b/tests/console/stop.rs @@ -0,0 +1,116 @@ +use super::*; + +#[test] +fn console_stop_confirms_authenticates_and_triages_or_requeues_a_live_command() { + for requeue in [false, true] { + let scenario = support::Scenario::new(); + let home = scenario.path("home"); + scenario.user_config_is(&format!( + "[console]\nport = 0\nallowed_emails = [\"{EMAIL}\"]\n" + )); + assert!(password(&home, None, PASSWORD, PASSWORD).0.success()); + scenario.issue_labelled(7, &["bug", "ready-for-agent"]); + scenario.agent_does(&format!( + "trap 'while ! test -f {root}/release; do sleep 0.02; done; exit 143' TERM\n\ + echo unfinished > work.txt\ntouch {root}/started\n\ + while ! test -f {root}/release; do sleep 0.05; done\nexit 1\n", + root = scenario.path("").display() + )); + let _held = HeldFixture { + run: Some(scenario.run_until(&[&scenario.issue_url(7)], &[], "started")), + release: scenario.path("release"), + }; + let console = + Console::start_mode(Path::new(env!("CARGO_BIN_EXE_thirdshift")), &home, false); + let route = "/stop?command=widgets%237"; + assert_eq!(get(&console, route, "").status(), 303); + assert_eq!( + client() + .post(format!("{}/stop", console.address)) + .send() + .unwrap() + .status(), + 303 + ); + let session = cookie(&sign_in(&console, EMAIL, PASSWORD)); + let peek = get(&console, "/peek?command=widgets%237", &session) + .text() + .unwrap(); + assert!(peek.contains(">Stop"), "{peek}"); + assert!(peek.contains("thirdshift stop"), "{peek}"); + let confirm = get(&console, route, &session).text().unwrap(); + assert!(confirm.contains("needs-triage"), "{confirm}"); + assert!(confirm.contains("Requeue instead"), "{confirm}"); + let token = csrf(&confirm); + let post = |token: &str, confirmed: &str| { + client() + .post(format!("{}/stop", console.address)) + .header("cookie", &session) + .form(&[ + ("command", "widgets#7"), + ("csrf", token), + ("confirmed", confirmed), + ("ending", if requeue { "requeue" } else { "triage" }), + ]) + .send() + .unwrap() + }; + assert_eq!(post("", "yes").status(), 403); + assert_eq!(post(&token, "").status(), 400); + assert!( + !fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap() + .contains("stopped from") + ); + let response = post(&token, "yes"); + assert_eq!(response.status(), 303, "{}", response.text().unwrap()); + let ending = get(&console, route, &session).text().unwrap(); + assert!(ending.contains("still ending"), "{ending}"); + assert!(ending.contains("stopping-bar"), "{ending}"); + assert!(!ending.contains("name=\"ending\""), "{ending}"); + let stream = get(&console, "/events", &session); + let mut stream = BufReader::new(stream); + fs::write(scenario.path("release"), "").unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + assert!(Instant::now() < deadline, "Stop outcome never arrived"); + let mut line = String::new(); + assert!(stream.read_line(&mut line).unwrap() > 0); + if !line.contains("event: commands") { + continue; + } + let page = get(&console, route, &session).text().unwrap(); + if page.contains("still ending") { + continue; + } + assert!(page.contains("interrupted"), "{page}"); + assert!(page.contains("Issue branch left: issue-7"), "{page}"); + assert!(!page.contains("name=\"ending\""), "{page}"); + break; + } + assert_eq!( + scenario.issue_labels(7), + if requeue { + vec!["bug", "ready-for-agent"] + } else { + vec!["bug", "in-progress", "needs-triage"] + } + ); + assert_eq!( + scenario.origin_file("issue-7", "work.txt"), + Some("unfinished\n".into()) + ); + let activity = + fs::read_to_string(scenario.path("home/.thirdshift/logs/acme/widgets/activity.log")) + .unwrap(); + assert!( + activity.contains(&format!("Run #7 stopped from the Console by {EMAIL}")), + "{activity}" + ); + assert_eq!(post(&token, "yes").status(), 409); + let peek = get(&console, "/peek?command=widgets%237", &session) + .text() + .unwrap(); + assert!(!peek.contains(">Stop"), "{peek}"); + } +} diff --git a/tests/console_install.rs b/tests/console_install.rs new file mode 100644 index 00000000..619ea6db --- /dev/null +++ b/tests/console_install.rs @@ -0,0 +1,302 @@ +//! 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 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 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 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(); + 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) + ); + } +} 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..f093e123 --- /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"}],"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."}} 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/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/run_health.rs b/tests/run_health.rs new file mode 100644 index 00000000..de676426 --- /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"]["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"); + } + 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"]["usual_ranges"]["implement"], + 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/security_run.rs b/tests/security_run.rs index 67f54e07..4af9c9f8 100644 --- a/tests/security_run.rs +++ b/tests/security_run.rs @@ -1957,6 +1957,27 @@ 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 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"); @@ -3022,6 +3043,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/setup.rs b/tests/setup.rs index c9170f9b..83020847 100644 --- a/tests/setup.rs +++ b/tests/setup.rs @@ -48,6 +48,8 @@ fn with_no_terminal_and_no_user_config_setup_writes_the_defaults() { "[launch]", "[email]", "[logs]", + "[inbox]", + "[console]", "[activity]", "[spec]", "[pickup]", @@ -72,6 +74,16 @@ 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["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)); @@ -139,6 +151,8 @@ effort = 'high' # my Effort "[harness]", "[harness.claude]", "[harness.codex]", + "[inbox]", + "[console]", "[harness.agy]", "[harness.grok]", "[harness.muse]", @@ -214,6 +228,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); @@ -228,6 +243,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(); @@ -312,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?"; @@ -320,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(); @@ -336,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!( @@ -363,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); @@ -402,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}"))); @@ -608,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(); @@ -721,6 +820,7 @@ fn on_a_terminal_pressing_enter_throughout_writes_what_setup_with_no_terminal_wr (SECURITY_FIX, ""), (SECURITY_REVIEW, ""), (NOTIFY, ""), + (CONSOLE, ""), ], ); @@ -745,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"); 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?", ""), ], ); diff --git a/tests/status.rs b/tests/status.rs index 0fb21886..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"); @@ -234,6 +245,357 @@ 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"]); + assert!( + text.stdout + .contains("https://github.com/acme/widgets/pull/8"), + "{}", + text.stdout + ); + 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("Run acme/widgets#10 Fixture · live ·"), + "{}", + 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 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"] { @@ -290,20 +652,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 +680,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 +719,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); @@ -543,13 +905,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"); @@ -900,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") 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"] {