Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ comenq bust 1a2b3c4d # move to the tail of the queue
comenq del 1a2b3c4d # remove from the queue
```

The daemon also keeps a posting-history log, recording every successful and
failed posting attempt. `hist` prints it, oldest first:

```bash
comenq hist -n 20 # the twenty most recent posting attempts
```

## Running as a user service

The daemon also runs unprivileged under `systemd --user`. Install the binaries
Expand Down
49 changes: 46 additions & 3 deletions crates/comenq/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use tokio::{
};
use tracing::warn;

use crate::output::{render_entry, render_put};
use crate::output::{render_entry, render_history, render_put};
use crate::{Args, Command};

/// Errors that can occur when interacting with the daemon.
Expand Down Expand Up @@ -102,9 +102,13 @@ async fn transact(candidates: &[PathBuf], request: &Request) -> Result<Response,
pub async fn run(args: Args) -> Result<(), ClientError> {
let request = args.command.to_request();
let response = transact(&args.socket_candidates(), &request).await?;
let (entry, entries) = match response {
let (entry, entries, history) = match response {
Response::Error { message } => return Err(ClientError::Daemon(message)),
Response::Ok { entry, entries } => (entry, entries),
Response::Ok {
entry,
entries,
history,
} => (entry, entries, history),
};
match &args.command {
Command::Put { .. } => {
Expand All @@ -124,6 +128,19 @@ pub async fn run(args: Args) -> Result<(), ClientError> {
Command::Bump { id } => println!("Moved {id} to the head of the queue."),
Command::Bust { id } => println!("Moved {id} to the tail of the queue."),
Command::Del { id } => println!("Removed {id} from the queue."),
Command::Hist { .. } => {
let history = history.ok_or(ClientError::UnexpectedResponse)?;
if history.is_empty() {
println!("No posting history recorded.");
} else {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
for record in &history {
println!("{}", render_history(record, now));
}
}
}
}
Ok(())
}
Expand Down Expand Up @@ -190,6 +207,32 @@ mod tests {
assert!(!immediate, "put must default to deferred posting");
}

#[tokio::test]
async fn run_sends_hist_request_and_accepts_reply() {
let dir = tempdir().expect("temp dir");
let socket = dir.path().join("sock");
let listener = UnixListener::bind(&socket).expect("bind socket");
let reply = Response::history(vec![comenq_lib::protocol::HistoryEntry {
id: "1a2b3c4d".into(),
posted_at: 1_000,
success: true,
error: None,
owner: "octocat".into(),
repo: "hello-world".into(),
pr_number: 1,
body: "Hi".into(),
}]);
let accept = spawn_daemon(listener, reply);

let args = Args {
socket: Some(socket),
command: Command::Hist { limit: Some(2) },
};
run(args).await.expect("run succeeds");
let request = accept.await.expect("join");
assert_eq!(request, Request::Hist { limit: Some(2) });
}

#[tokio::test]
async fn run_surfaces_daemon_errors() {
let dir = tempdir().expect("temp dir");
Expand Down
25 changes: 24 additions & 1 deletion crates/comenq/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod client;
mod output;

pub use client::{ClientError, run};
pub use output::{format_eta, one_line_summary};
pub use output::{format_age, format_eta, one_line_summary};

/// A GitHub repository slug in `owner/repo` format.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
Expand Down Expand Up @@ -155,6 +155,12 @@ pub enum Command {
/// Identifier printed by `put` and `list`.
id: String,
},
/// Show posted comments and failed attempts, oldest first.
Hist {
/// Show only the most recent LIMIT records.
#[arg(long, short = 'n', value_name = "LIMIT")]
limit: Option<usize>,
},
}

impl Command {
Expand All @@ -181,6 +187,7 @@ impl Command {
Self::Bump { id } => Request::Bump { id: id.clone() },
Self::Bust { id } => Request::Bust { id: id.clone() },
Self::Del { id } => Request::Del { id: id.clone() },
Self::Hist { limit } => Request::Hist { limit: *limit },
}
}
}
Expand Down Expand Up @@ -283,6 +290,22 @@ mod tests {
}
}

#[rstest]
#[case::bare(&["comenq", "hist"], None)]
#[case::long(&["comenq", "hist", "--limit", "5"], Some(5))]
#[case::short(&["comenq", "hist", "-n", "5"], Some(5))]
fn parses_hist_subcommand(#[case] argv: &[&str], #[case] expected: Option<usize>) {
let args = Args::try_parse_from(argv).expect("valid arguments should parse");
assert_eq!(
args.command.to_request(),
comenq_lib::protocol::Request::Hist { limit: expected }
);
let Command::Hist { limit } = args.command else {
panic!("expected hist command");
};
assert_eq!(limit, expected);
}

#[test]
fn missing_subcommand_is_rejected() {
assert!(Args::try_parse_from(["comenq"]).is_err());
Expand Down
95 changes: 89 additions & 6 deletions crates/comenq/src/output.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! Human-readable rendering of daemon replies.
//!
//! Formats estimated posting times and one-line comment summaries for the
//! `put` and `list` subcommands.
//! Formats estimated posting times, past-attempt ages, and one-line comment
//! summaries for the `put`, `list`, and `hist` subcommands.

use comenq_lib::protocol::PendingEntry;
use comenq_lib::protocol::{HistoryEntry, PendingEntry};

/// Maximum characters of comment text shown by `list`.
const SUMMARY_LIMIT: usize = 60;
Expand Down Expand Up @@ -67,6 +67,47 @@ pub fn one_line_summary(body: &str) -> String {
truncated
}

/// Render how long ago something happened as a compact human duration.
///
/// # Examples
///
/// ```rust
/// assert_eq!(comenq::format_age(0), "just now");
/// assert_eq!(comenq::format_age(45), "45s ago");
/// assert_eq!(comenq::format_age(3_720), "1h 02m ago");
/// ```
#[must_use]
pub fn format_age(seconds: u64) -> String {
if seconds == 0 {
return "just now".to_owned();
}
format!("{} ago", format_eta(seconds))
}

/// Render one `hist` line for a past posting attempt.
///
/// `now` is the current Unix time, used to show the attempt's age. Failed
/// attempts carry the failure description, collapsed to one line.
pub(crate) fn render_history(entry: &HistoryEntry, now: u64) -> String {
let age = format_age(now.saturating_sub(entry.posted_at));
let status = if entry.success { "ok" } else { "FAIL" };
let mut line = format!(
"{} {:>11} {:<4} {}/{}#{} {}",
entry.id,
age,
status,
entry.owner,
entry.repo,
entry.pr_number,
one_line_summary(&entry.body)
);
if let Some(error) = &entry.error {
line.push_str(" — ");
line.push_str(&one_line_summary(error));
}
line
}

/// Render the `put` confirmation line.
pub(crate) fn render_put(entry: &PendingEntry) -> String {
format!(
Expand Down Expand Up @@ -94,9 +135,11 @@ pub(crate) fn render_entry(entry: &PendingEntry) -> String {

#[cfg(test)]
mod tests {
//! Unit tests for ETA and summary rendering.
use super::{format_eta, one_line_summary, render_entry, render_put};
use comenq_lib::protocol::PendingEntry;
//! Unit tests for ETA, age, and summary rendering.
use super::{
format_age, format_eta, one_line_summary, render_entry, render_history, render_put,
};
use comenq_lib::protocol::{HistoryEntry, PendingEntry};
use rstest::rstest;

fn entry(body: &str, eta: u64) -> PendingEntry {
Expand Down Expand Up @@ -154,6 +197,46 @@ mod tests {
);
}

fn history(success: bool, error: Option<&str>) -> HistoryEntry {
HistoryEntry {
id: "1a2b3c4d".into(),
posted_at: 1_000,
success,
error: error.map(str::to_owned),
owner: "octocat".into(),
repo: "hello-world".into(),
pr_number: 7,
body: "Hi there".into(),
}
}

#[rstest]
#[case(0, "just now")]
#[case(45, "45s ago")]
#[case(150, "2m 30s ago")]
#[case(3_720, "1h 02m ago")]
fn formats_age(#[case] seconds: u64, #[case] expected: &str) {
assert_eq!(format_age(seconds), expected);
}

#[rstest]
fn renders_successful_history_line() {
let line = render_history(&history(true, None), 1_150);
assert_eq!(
line,
"1a2b3c4d 2m 30s ago ok octocat/hello-world#7 Hi there"
);
}

#[rstest]
fn renders_failed_history_line_with_error() {
let line = render_history(&history(false, Some("timeout\nafter 10s")), 1_045);
assert_eq!(
line,
"1a2b3c4d 45s ago FAIL octocat/hello-world#7 Hi there — timeout after 10s"
);
}

#[rstest]
fn renders_list_line_with_truncated_body() {
let body = "b".repeat(100);
Expand Down
36 changes: 32 additions & 4 deletions crates/comenqd/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use comenq_lib::protocol::{Request, Response};
use tokio::sync::{Mutex, Notify};

use crate::config::Config;
use crate::store::{PutOptions, QueueStore, Result as StoreResult, StoredEntry};
use crate::store::{HistoryRecord, PutOptions, QueueStore, Result as StoreResult, StoredEntry};

/// Current Unix time in whole seconds.
///
Expand Down Expand Up @@ -62,9 +62,24 @@ impl SharedQueue {
.next_due(self.cfg.cooldown_period_seconds, unix_now())
}

/// Remove the posted entry and record the posting time.
pub async fn complete(&self, id: &str) -> StoreResult<()> {
self.store.lock().await.complete(id, unix_now())
/// Remove the posted entry, recording the posting time and a history
/// record of the success.
pub async fn complete(&self, entry: &StoredEntry) -> StoreResult<()> {
let store = self.store.lock().await;
let now = unix_now();
store.complete(&entry.id, now)?;
store.append_history(&HistoryRecord::success(entry, now))
}

/// Record a failed posting attempt in the history log.
///
/// The entry itself stays queued; the worker retries it after a
/// cooldown.
pub async fn record_failure(&self, entry: &StoredEntry, error: &str) -> StoreResult<()> {
self.store
.lock()
.await
.append_history(&HistoryRecord::failure(entry, unix_now(), error))
}

/// Execute a protocol request and produce the reply.
Expand Down Expand Up @@ -106,6 +121,19 @@ impl SharedQueue {
Request::Bump { id } => (store.bump(&id).map(|()| Response::ok()), true),
Request::Bust { id } => (store.bust(&id).map(|()| Response::ok()), true),
Request::Del { id } => (store.del(&id).map(|()| Response::ok()), true),
Request::Hist { limit } => {
let outcome = store.history().map(|records| {
let skip = limit.map_or(0, |n| records.len().saturating_sub(n));
Response::history(
records
.into_iter()
.skip(skip)
.map(|record| record.to_entry())
.collect(),
)
});
(outcome, false)
}
};
drop(store);
match response {
Expand Down
19 changes: 19 additions & 0 deletions crates/comenqd/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//! be moved to the head (`bump`) or tail (`bust`) of the queue, or removed
//! (`del`). The Unix timestamp of the most recent successful post is kept in
//! `<queue_path>/last_post` so estimated posting times survive restarts.
//! Every posting attempt, successful or not, is appended as one JSON line to
//! `<queue_path>/history.jsonl` so past activity can be reported.
//!
//! The store itself holds no in-memory state; callers serialize mutations
//! (the daemon wraps it in a `tokio::sync::Mutex`). Files are written to a
Expand All @@ -23,6 +25,11 @@ use std::path::{Path, PathBuf};
const ENTRIES_DIR: &str = "entries";
/// File recording the Unix time of the most recent successful post.
const LAST_POST_FILE: &str = "last_post";
/// Append-only JSON Lines log of posting attempts.
const HISTORY_FILE: &str = "history.jsonl";

mod history;
pub use history::HistoryRecord;

/// A queued comment with its scheduling metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -97,6 +104,7 @@ pub struct PutOptions {
pub struct QueueStore {
entries_dir: PathBuf,
last_post_path: PathBuf,
history_path: PathBuf,
}

/// Compute the deterministic eight-character identifier for an entry.
Expand Down Expand Up @@ -134,6 +142,7 @@ impl QueueStore {
Ok(Self {
entries_dir,
last_post_path: queue_path.join(LAST_POST_FILE),
history_path: queue_path.join(HISTORY_FILE),
})
}

Expand Down Expand Up @@ -256,6 +265,16 @@ impl QueueStore {
Ok(())
}

/// Append `record` to the posting history log.
pub fn append_history(&self, record: &HistoryRecord) -> Result<()> {
history::append(&self.history_path, record)
}

/// All recorded posting attempts in chronological order.
pub fn history(&self) -> Result<Vec<HistoryRecord>> {
history::read(&self.history_path)
}

/// Pending entries paired with their estimated seconds-until-post.
///
/// The head is due one full cooldown plus its own flutter after the most
Expand Down
Loading
Loading