gatewayd: main, serve --config with its start checks
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -1,4 +1,93 @@
|
|||||||
fn main() {
|
//! `gatewayd serve --config <path>`: the Mattermost channel. It loads its configuration and its
|
||||||
eprintln!("gatewayd: not implemented until M4");
|
//! token, prepares its directory, then serves until it must stop (exit 1).
|
||||||
std::process::exit(2);
|
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
use gatewayd::config::{Config, MATTERMOST_TOKEN};
|
||||||
|
use gatewayd::secrets;
|
||||||
|
use gatewayd::serve::{START_FAILED, Tuning, run};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
// `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
|
||||||
|
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||||
|
let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
|
||||||
|
match (words.as_slice(), args.get(2)) {
|
||||||
|
([Some("serve"), Some("--config"), _], Some(path)) => serve(Path::new(path)),
|
||||||
|
_ => {
|
||||||
|
eprintln!("usage: gatewayd serve --config <path>");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve(path: &Path) -> ExitCode {
|
||||||
|
// Each failure prints its message with `eprintln!` and returns `ExitCode::from(1)`.
|
||||||
|
// 1. `let config = match Config::load(path) { ... }`: an Err(e) prints
|
||||||
|
// "gatewayd: {e}\n{START_FAILED}".
|
||||||
|
// 2. `let source = match config.token_source() { ... }`: an Err(why) prints
|
||||||
|
// "gatewayd: {}: {why}\n{START_FAILED}" with `path.display()`.
|
||||||
|
// 3. `let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`:
|
||||||
|
// an Err(e) prints "gatewayd: {e}" (the error carries its own pointer).
|
||||||
|
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
|
||||||
|
// 4. The state file's directory:
|
||||||
|
// `let dir = config.state_path().parent().map(Path::to_path_buf).unwrap_or_default();`
|
||||||
|
// `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)`: an Err(e) prints
|
||||||
|
// "gatewayd: cannot prepare {}: {e}\n{START_FAILED}" with `dir.display()`.
|
||||||
|
// 5. `let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));`
|
||||||
|
// `let stop = run(config, loaded.secret, Tuning::default(), log, &AtomicBool::new(false));`
|
||||||
|
// `eprintln!("{stop}");` and `ExitCode::from(1)`.
|
||||||
|
let config = match Config::load(path) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("gatewayd: {e}\n{START_FAILED}");
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let source = match config.token_source() {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(why) => {
|
||||||
|
eprintln!("gatewayd: {}: {why}\n{START_FAILED}", path.display());
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k)) {
|
||||||
|
Ok(loaded) => loaded,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("gatewayd: {e}");
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(warning) = &loaded.warning {
|
||||||
|
eprintln!("{warning}");
|
||||||
|
}
|
||||||
|
let dir = config
|
||||||
|
.state_path()
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Err(e) = std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&dir)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"gatewayd: cannot prepare {}: {e}\n{START_FAILED}",
|
||||||
|
dir.display()
|
||||||
|
);
|
||||||
|
return ExitCode::from(1);
|
||||||
|
}
|
||||||
|
let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));
|
||||||
|
let stop = run(
|
||||||
|
config,
|
||||||
|
loaded.secret,
|
||||||
|
Tuning::default(),
|
||||||
|
log,
|
||||||
|
&AtomicBool::new(false),
|
||||||
|
);
|
||||||
|
eprintln!("{stop}");
|
||||||
|
ExitCode::from(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! The `gatewayd` program: its usage, what stops it at start, the warning for a secret in a file,
|
||||||
|
//! and that the token never reaches its output (M4a spec, sections 3, 4 and 10). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const TOKEN: &str = "tok-3f9a1c7e5b2d4f6a8c0e";
|
||||||
|
|
||||||
|
fn gatewayd() -> Command {
|
||||||
|
let mut c = Command::new(env!("CARGO_BIN_EXE_gatewayd"));
|
||||||
|
c.env_remove("GW_TEST_TOKEN")
|
||||||
|
.env_remove("CREDENTIALS_DIRECTORY");
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closed_url() -> String {
|
||||||
|
let port = std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.unwrap()
|
||||||
|
.local_addr()
|
||||||
|
.unwrap()
|
||||||
|
.port();
|
||||||
|
format!("http://127.0.0.1:{port}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config(dir: &TempDir, secret: &str) -> std::path::PathBuf {
|
||||||
|
let text = format!(
|
||||||
|
"[mattermost]\nurl = \"{}\"\n[secrets.mattermost_token]\n{secret}\n[allow]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n",
|
||||||
|
closed_url(),
|
||||||
|
dir.path().join("home").display()
|
||||||
|
);
|
||||||
|
dir.write("gatewayd.toml", &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run until `want` appears on standard error or 5 s pass, then kill it; all it printed.
|
||||||
|
fn stderr_until(mut cmd: Command, want: &str) -> String {
|
||||||
|
let mut child = cmd
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let mut reader = BufReader::new(child.stderr.take().unwrap());
|
||||||
|
let until = Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut all = String::new();
|
||||||
|
while Instant::now() < until && !all.contains(want) {
|
||||||
|
let mut line = String::new();
|
||||||
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
all.push_str(&line);
|
||||||
|
}
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage() {
|
||||||
|
for args in [
|
||||||
|
&[][..],
|
||||||
|
&["serve"][..],
|
||||||
|
&["serve", "--config"][..],
|
||||||
|
&["run", "--config", "x"][..],
|
||||||
|
] {
|
||||||
|
let out = gatewayd().args(args).output().unwrap();
|
||||||
|
assert_eq!(out.status.code(), Some(2), "{args:?}");
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
"usage: gatewayd serve --config <path>\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_config_stops_at_start() {
|
||||||
|
let dir = TempDir::new("main-config");
|
||||||
|
let missing = dir.path().join("nope.toml");
|
||||||
|
let bad = dir.write("bad.toml", "[mattermost]\nurl = \"ftp://x\"\n");
|
||||||
|
for path in [missing, bad] {
|
||||||
|
let out = gatewayd()
|
||||||
|
.arg("serve")
|
||||||
|
.arg("--config")
|
||||||
|
.arg(&path)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert_eq!(out.status.code(), Some(1), "{err}");
|
||||||
|
assert!(
|
||||||
|
err.starts_with(&format!("gatewayd: {}: ", path.display())),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.ends_with("\nsee docs/runbook.md#gatewayd-start-failed\n"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_secret_stops_at_start() {
|
||||||
|
let dir = TempDir::new("main-secret");
|
||||||
|
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
|
||||||
|
let out = gatewayd()
|
||||||
|
.arg("serve")
|
||||||
|
.arg("--config")
|
||||||
|
.arg(&config)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert_eq!(out.status.code(), Some(1), "{err}");
|
||||||
|
assert!(
|
||||||
|
err.starts_with("gatewayd: secret mattermost_token: "),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.ends_with("\nsee docs/runbook.md#secret-unavailable\n"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dir.path().join("home").exists(),
|
||||||
|
"nothing is made before the secret is read"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_in_a_file_warns_and_the_token_is_never_printed() {
|
||||||
|
let dir = TempDir::new("main-file");
|
||||||
|
let secret = dir.write("token", &format!("{TOKEN}\n"));
|
||||||
|
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
let config = write_config(&dir, &format!("file = \"{}\"", secret.display()));
|
||||||
|
let mut cmd = gatewayd();
|
||||||
|
cmd.arg("serve").arg("--config").arg(&config);
|
||||||
|
let err = stderr_until(cmd, "trying again");
|
||||||
|
let warning = format!(
|
||||||
|
"gatewayd: warning: secret mattermost_token is read in plaintext from {}; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)\n",
|
||||||
|
secret.display()
|
||||||
|
);
|
||||||
|
assert!(err.starts_with(&warning), "{err}");
|
||||||
|
assert!(
|
||||||
|
err.contains("gatewayd: cannot reach http://127.0.0.1:"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
assert!(!err.contains(TOKEN), "{err}");
|
||||||
|
let mode = std::fs::metadata(dir.path().join("home/gateway"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777;
|
||||||
|
assert_eq!(mode, 0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secret_from_the_environment_has_no_warning() {
|
||||||
|
let dir = TempDir::new("main-env");
|
||||||
|
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
|
||||||
|
let mut cmd = gatewayd();
|
||||||
|
cmd.arg("serve")
|
||||||
|
.arg("--config")
|
||||||
|
.arg(&config)
|
||||||
|
.env("GW_TEST_TOKEN", TOKEN);
|
||||||
|
let err = stderr_until(cmd, "trying again");
|
||||||
|
assert!(err.starts_with("gatewayd: cannot reach "), "{err}");
|
||||||
|
assert!(!err.contains("warning") && !err.contains(TOKEN), "{err}");
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M4a/15-gatewayd-main | 2026-09-24 | done | 1 | pass | none | Replaced the placeholder `src/main.rs` with the skeleton and copied `tests/main.rs`. `main` parses `args_os` like `loopd`'s; `serve` runs the five start checks in order — `Config::load` (`gatewayd: {e}\n{START_FAILED}`), `token_source` (`gatewayd: {}: {why}\n{START_FAILED}` with the path), `secrets::load` (`gatewayd: {e}`, its own `#secret-unavailable` pointer, then the file warning if any), then the state dir via `DirBuilder` recursive + `mode(0o700)` (`gatewayd: cannot prepare {}: {e}\n{START_FAILED}`), each returning exit 1 before anything is written. Then `run` with a `Log` that echoes to stderr and the returned `Stop` printed, exit 1. The todo comment matches the function and every test; the token never reaches output (it is only ever `expose`d inside `serve`, never here). `cargo test -p gatewayd` every suite passes; `make gate` prints `gate: ok` first run. | ? |
|
||||||
| M4a/14-gatewayd-serve | 2026-09-24 | done | 1 | pass | none | Copied `tests/serve.rs`, `tests/serve_restart.rs`, `tests/support/fake_mm.rs`, `tests/support/gateway.rs` and the `src/serve/mod.rs`/`handle.rs` skeletons, added `pub mod serve;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `From<StateError> for Stop` → `Stop::State(e)`; `backoff` (the last backoff repeats past its end); `sleep_unless` (interruptible 20 ms steps until the deadline); `Gateway::connected` (log "connected to <url> as <username>", route as this user from `users/me`, and on the first connection only, answer each in-flight turn with INTERRUPTED so a later reconnect does not). Filled `handle.rs`: `now_ms` (millis with an i64 clamp); `post` (a failing post logs, never throws); `tracked` ("D" or an allowed channel); `handle_post` (skip untracked/seen, record `handled` before routing so a crash never answers twice, then route with the state answering `knows_thread` — an `NotAllowed` stranger is logged by post id and user id only, never the text, a reply posts, a queued message joins the thread then pushes with Start/Waiting/Full); `start` (record the in-flight turn, spawn `deliver` on its own thread, and on a spawn error post LOOP_DOWN and un-busy the session); `finished` (drain done, end each turn, start the next batch); `typing` (a `user_typing` per running thread, seq incremented); `catch_up_channel` (no mark → mark "now" and stop, history not answered; else `posts_since`, log when `full`, replay each post). `SessionId` has no `Display`, so the start-error line formats `session.as_str()`. `serve` 6 and `serve_restart` 7 pass five runs in ~1.3 s; `make gate` prints `gate: ok` first run. | ? |
|
| M4a/14-gatewayd-serve | 2026-09-24 | done | 1 | pass | none | Copied `tests/serve.rs`, `tests/serve_restart.rs`, `tests/support/fake_mm.rs`, `tests/support/gateway.rs` and the `src/serve/mod.rs`/`handle.rs` skeletons, added `pub mod serve;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `From<StateError> for Stop` → `Stop::State(e)`; `backoff` (the last backoff repeats past its end); `sleep_unless` (interruptible 20 ms steps until the deadline); `Gateway::connected` (log "connected to <url> as <username>", route as this user from `users/me`, and on the first connection only, answer each in-flight turn with INTERRUPTED so a later reconnect does not). Filled `handle.rs`: `now_ms` (millis with an i64 clamp); `post` (a failing post logs, never throws); `tracked` ("D" or an allowed channel); `handle_post` (skip untracked/seen, record `handled` before routing so a crash never answers twice, then route with the state answering `knows_thread` — an `NotAllowed` stranger is logged by post id and user id only, never the text, a reply posts, a queued message joins the thread then pushes with Start/Waiting/Full); `start` (record the in-flight turn, spawn `deliver` on its own thread, and on a spawn error post LOOP_DOWN and un-busy the session); `finished` (drain done, end each turn, start the next batch); `typing` (a `user_typing` per running thread, seq incremented); `catch_up_channel` (no mark → mark "now" and stop, history not answered; else `posts_since`, log when `full`, replay each post). `SessionId` has no `Display`, so the start-error line formats `session.as_str()`. `serve` 6 and `serve_restart` 7 pass five runs in ~1.3 s; `make gate` prints `gate: ok` first run. | ? |
|
||||||
| M4a/13-gatewayd-deliver | 2026-09-23 | done | 1 | pass | none | Copied `tests/deliver.rs`, `tests/support/fake_loop.rs` and the `src/deliver.rs` skeleton, added `pub mod deliver;` to `lib.rs` (before `http`, alphabetical). Filled `error_text` (the snake_case name serde gives the ErrorCode via `serde_json::to_value`, falling back to `<unknown>` without ever formatting `ErrorCode` with `{}` since it has no Display); `split_answer` (blank/whitespace-only → [EMPTY_ANSWER]; else while the rest is longer than `MAX_POST` *characters*, cut at the last newline within the first `MAX_POST` chars past position 0 — dropping that newline — else at the byte index of the `MAX_POST`-th char via `char_indices().nth(MAX_POST)`, never inside one char); `one_turn` (connect fails → LoopDown("cannot connect to <path>: <e>"), write one id-1 final Turn envelope (write error → LoopDown), then read: (1, not final, TurnEvent)→on_event, (1, final, TurnDone)→Answer(content), (1, final, Error)→Refused, a read error → LoopDown("the turn ended early: <e>"), anything else → LoopDown("an unexpected frame")); `run_turn` (one_turn with `batch.resume`, and when Refused(NoSuchSession) with `batch.resume` true, one more with resume false to create the session, as `bxctl chat --session`); `deliver` (post in the thread — ApprovalPending posts `approval_text` at once, then Answer→every split part in order, Refused→error_text, LoopDown→log "gatewayd: <session>: <why>" and post LOOP_DOWN; a failing post logs "gatewayd: cannot post in <channel> (thread <root>): <e>" through a small `post` helper). All 9 tests pass; `make gate` prints `gate: ok` first run. | ? |
|
| M4a/13-gatewayd-deliver | 2026-09-23 | done | 1 | pass | none | Copied `tests/deliver.rs`, `tests/support/fake_loop.rs` and the `src/deliver.rs` skeleton, added `pub mod deliver;` to `lib.rs` (before `http`, alphabetical). Filled `error_text` (the snake_case name serde gives the ErrorCode via `serde_json::to_value`, falling back to `<unknown>` without ever formatting `ErrorCode` with `{}` since it has no Display); `split_answer` (blank/whitespace-only → [EMPTY_ANSWER]; else while the rest is longer than `MAX_POST` *characters*, cut at the last newline within the first `MAX_POST` chars past position 0 — dropping that newline — else at the byte index of the `MAX_POST`-th char via `char_indices().nth(MAX_POST)`, never inside one char); `one_turn` (connect fails → LoopDown("cannot connect to <path>: <e>"), write one id-1 final Turn envelope (write error → LoopDown), then read: (1, not final, TurnEvent)→on_event, (1, final, TurnDone)→Answer(content), (1, final, Error)→Refused, a read error → LoopDown("the turn ended early: <e>"), anything else → LoopDown("an unexpected frame")); `run_turn` (one_turn with `batch.resume`, and when Refused(NoSuchSession) with `batch.resume` true, one more with resume false to create the session, as `bxctl chat --session`); `deliver` (post in the thread — ApprovalPending posts `approval_text` at once, then Answer→every split part in order, Refused→error_text, LoopDown→log "gatewayd: <session>: <why>" and post LOOP_DOWN; a failing post logs "gatewayd: cannot post in <channel> (thread <root>): <e>" through a small `post` helper). All 9 tests pass; `make gate` prints `gate: ok` first run. | ? |
|
||||||
| M4a/12-gatewayd-state | 2026-09-23 | done | 1 | pass | none | Copied `tests/state.rs` and the `src/state.rs` skeleton, added `pub mod state;` to `lib.rs`. Filled `problem` (every `channels` key, `recent` and `threads` entry must be `valid_id` → "not a Mattermost id: <id with {:?}>"; each `in_flight` entry, session `mm-`+valid id with channel and root valid → "a turn in flight is not valid: <session with {:?}>"); `load` (NotFound → empty StateFile, any other read error, serde parse error or `problem()` → Read(path, why)); `save`/`persist` (the six atomic steps of brokerd's persist, io error mapped to Write, old file left on failure); `handled` (records unseen ids keeping the newest RECENT_KEPT, moves the channel mark to the max), `mark` (sets only a channel without a mark), `join_thread` (keeps the newest THREADS_KEPT), `start_turn`/`end_turn`/`take_in_flight` (removing by session, save only when in_flight was non-empty). Every mutating method saves before returning. All 4 tests pass in ~0.04 s; `make gate` prints `gate: ok` first run. | ? |
|
| M4a/12-gatewayd-state | 2026-09-23 | done | 1 | pass | none | Copied `tests/state.rs` and the `src/state.rs` skeleton, added `pub mod state;` to `lib.rs`. Filled `problem` (every `channels` key, `recent` and `threads` entry must be `valid_id` → "not a Mattermost id: <id with {:?}>"; each `in_flight` entry, session `mm-`+valid id with channel and root valid → "a turn in flight is not valid: <session with {:?}>"); `load` (NotFound → empty StateFile, any other read error, serde parse error or `problem()` → Read(path, why)); `save`/`persist` (the six atomic steps of brokerd's persist, io error mapped to Write, old file left on failure); `handled` (records unseen ids keeping the newest RECENT_KEPT, moves the channel mark to the max), `mark` (sets only a channel without a mark), `join_thread` (keeps the newest THREADS_KEPT), `start_turn`/`end_turn`/`take_in_flight` (removing by session, save only when in_flight was non-empty). Every mutating method saves before returning. All 4 tests pass in ~0.04 s; `make gate` prints `gate: ok` first run. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user