From 9fe7d10fcf73570767ba7b4eabaa95f65958821b Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Wed, 25 Feb 2026 19:10:58 -0800 Subject: feat: Add history author/intent metadata and v1 record version (#3205) ## Checks - [x] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [x] I have checked that there are no existing pull requests for the same thing Adds `author` and `intent` to client history records and DB persistence, including migration/backfill and CLI/daemon propagation. Introduces V2 record-store history version `v1` while retaining read compatibility for legacy `v0` records. Adds `--author` and `--intent` flags to `atuin history start`, plus `{author}` and `{intent}` format keys for listing/history output. Updates shell-integration docs for `ATUIN_HISTORY_AUTHOR` and `ATUIN_HISTORY_INTENT`, and updates related tests/fixtures. Validated with `cargo test -p atuin-client --lib`, `cargo test -p atuin-daemon --tests`, `cargo test -p atuin search::inspector`, and `cargo fmt --check`. --- .../20260224000100_history_author_intent.sql | 2 + crates/atuin-client/src/database.rs | 23 +- crates/atuin-client/src/encryption.rs | 10 + crates/atuin-client/src/history.rs | 237 +++++++++++++++++---- crates/atuin-client/src/history/builder.rs | 22 ++ crates/atuin-client/src/history/store.rs | 21 +- crates/atuin-daemon/proto/history.proto | 2 + crates/atuin-daemon/src/client.rs | 2 + crates/atuin-daemon/src/server.rs | 8 +- crates/atuin/src/command/client/history.rs | 62 +++++- .../atuin/src/command/client/search/inspector.rs | 6 + 11 files changed, 336 insertions(+), 59 deletions(-) create mode 100644 crates/atuin-client/migrations/20260224000100_history_author_intent.sql (limited to 'crates') diff --git a/crates/atuin-client/migrations/20260224000100_history_author_intent.sql b/crates/atuin-client/migrations/20260224000100_history_author_intent.sql new file mode 100644 index 00000000..2bed17e9 --- /dev/null +++ b/crates/atuin-client/migrations/20260224000100_history_author_intent.sql @@ -0,0 +1,2 @@ +alter table history add column author text; +alter table history add column intent text; diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index 7aa095f7..5f292bec 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -200,8 +200,8 @@ impl Sqlite { async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> { sqlx::query( - "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, deleted_at) - values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at) + values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", ) .bind(h.id.0.as_str()) .bind(h.timestamp.unix_timestamp_nanos() as i64) @@ -211,6 +211,8 @@ impl Sqlite { .bind(h.cwd.as_str()) .bind(h.session.as_str()) .bind(h.hostname.as_str()) + .bind(h.author.as_str()) + .bind(h.intent.as_deref()) .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64)) .execute(&mut **tx) .await?; @@ -232,6 +234,13 @@ impl Sqlite { fn query_history(row: SqliteRow) -> History { let deleted_at: Option = row.get("deleted_at"); + let hostname: String = row.get("hostname"); + let author: Option = row.try_get("author").ok().flatten(); + let author = author + .filter(|author| !author.trim().is_empty()) + .unwrap_or_else(|| History::author_from_hostname(hostname.as_str())); + let intent: Option = row.try_get("intent").ok().flatten(); + let intent = intent.filter(|intent| !intent.trim().is_empty()); History::from_db() .id(row.get("id")) @@ -244,7 +253,9 @@ impl Sqlite { .command(row.get("command")) .cwd(row.get("cwd")) .session(row.get("session")) - .hostname(row.get("hostname")) + .hostname(hostname) + .author(author) + .intent(intent) .deleted_at( deleted_at.and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128).ok()), ) @@ -295,7 +306,7 @@ impl Database for Sqlite { sqlx::query( "update history - set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, deleted_at = ?9 + set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, author = ?9, intent = ?10, deleted_at = ?11 where id = ?1", ) .bind(h.id.0.as_str()) @@ -306,6 +317,8 @@ impl Database for Sqlite { .bind(h.cwd.as_str()) .bind(h.session.as_str()) .bind(h.hostname.as_str()) + .bind(h.author.as_str()) + .bind(h.intent.as_deref()) .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64)) .execute(&self.pool) .await?; @@ -612,6 +625,8 @@ impl Database for Sqlite { "exit", "command", "deleted_at", + "null as author", + "null as intent", "group_concat(cwd, ':') as cwd", "group_concat(session) as session", "group_concat(hostname, ',') as hostname", diff --git a/crates/atuin-client/src/encryption.rs b/crates/atuin-client/src/encryption.rs index a56dbd09..f2032482 100644 --- a/crates/atuin-client/src/encryption.rs +++ b/crates/atuin-client/src/encryption.rs @@ -253,6 +253,8 @@ fn decode(bytes: &[u8]) -> Result { cwd: cwd.to_owned(), session: session.to_owned(), hostname: hostname.to_owned(), + author: History::author_from_hostname(hostname), + intent: None, deleted_at: deleted_at .map(|t| OffsetDateTime::parse(t, &Rfc3339)) .transpose()?, @@ -287,6 +289,8 @@ mod test { .duration(1) .session("beep boop".into()) .hostname("booop".into()) + .author("booop".into()) + .intent(None) .deleted_at(None) .build() .into(); @@ -331,6 +335,8 @@ mod test { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: None, }; @@ -352,6 +358,8 @@ mod test { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: Some(datetime!(2023-05-28 18:35:40.633872 +00:00)), }; @@ -383,6 +391,8 @@ mod test { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: None, }; diff --git a/crates/atuin-client/src/history.rs b/crates/atuin-client/src/history.rs index d9d5a203..a5adc233 100644 --- a/crates/atuin-client/src/history.rs +++ b/crates/atuin-client/src/history.rs @@ -1,4 +1,5 @@ use core::fmt::Formatter; +use rmp::decode::DecodeStringError; use rmp::decode::ValueReadError; use rmp::{Marker, decode::Bytes}; use std::env; @@ -17,8 +18,14 @@ use time::OffsetDateTime; mod builder; pub mod store; -const HISTORY_VERSION: &str = "v0"; +pub(crate) const HISTORY_VERSION_V0: &str = "v0"; +pub(crate) const HISTORY_VERSION_V1: &str = "v1"; +const HISTORY_RECORD_VERSION_V0: u16 = 0; +const HISTORY_RECORD_VERSION_V1: u16 = 1; +pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1; pub const HISTORY_TAG: &str = "history"; +const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; +const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct HistoryId(pub String); @@ -46,8 +53,8 @@ impl From for HistoryId { // // ## Implementation Notes // -// New fields must should be added to `encryption::{encode, decode}` in a backwards -// compatible way. (eg sensible defaults and updating the nfields parameter) +// New fields must be added to `History::{serialize,deserialize}` in a backwards +// compatible way (sensible defaults and careful `nfields` handling). #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub struct History { /// A client-generated ID, used to identify the entry when syncing. @@ -68,6 +75,10 @@ pub struct History { pub session: String, /// The hostname of the machine the command was run on. pub hostname: String, + /// Who wrote this command (human user or automation/agent identity). + pub author: String, + /// Optional rationale for why the command was executed. + pub intent: Option, /// Timestamp, which is set when the entry is deleted, allowing a soft delete. pub deleted_at: Option, } @@ -93,6 +104,23 @@ pub struct HistoryStats { } impl History { + pub(crate) fn author_from_hostname(hostname: &str) -> String { + hostname + .split_once(':') + .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned()) + } + + fn normalize_optional_field(field: Option) -> Option { + field.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }) + } + #[allow(clippy::too_many_arguments)] fn new( timestamp: OffsetDateTime, @@ -102,12 +130,19 @@ impl History { duration: i64, session: Option, hostname: Option, + author: Option, + intent: Option, deleted_at: Option, ) -> Self { let session = session .or_else(|| env::var("ATUIN_SESSION").ok()) .unwrap_or_else(|| uuid_v7().as_simple().to_string()); let hostname = hostname.unwrap_or_else(get_host_user); + let author = Self::normalize_optional_field(author) + .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok())) + .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str())); + let intent = Self::normalize_optional_field(intent) + .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok())); Self { id: uuid_v7().as_simple().to_string().into(), @@ -118,6 +153,8 @@ impl History { duration, session, hostname, + author, + intent, deleted_at, } } @@ -131,9 +168,9 @@ impl History { let mut output = vec![]; // write the version - encode::write_u16(&mut output, 0)?; - // INFO: ensure this is updated when adding new fields - encode::write_array_len(&mut output, 9)?; + encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?; + let include_intent = self.intent.is_some(); + encode::write_array_len(&mut output, 10 + u32::from(include_intent))?; encode::write_str(&mut output, &self.id.0)?; encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?; @@ -149,9 +186,33 @@ impl History { None => encode::write_nil(&mut output)?, } + encode::write_str(&mut output, self.author.as_str())?; + if let Some(intent) = &self.intent { + encode::write_str(&mut output, intent.as_str())?; + } + Ok(DecryptedData(output)) } + fn read_optional_string(bytes: &[u8]) -> Result<(Option, &[u8])> { + use rmp::decode; + + fn error_report(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + match decode::read_str_from_slice(bytes) { + Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)), + Err(DecodeStringError::TypeMismatch(Marker::Null)) => { + let mut cursor = Bytes::new(bytes); + decode::read_nil(&mut cursor).map_err(error_report)?; + + Ok((None, cursor.remaining_slice())) + } + Err(err) => Err(error_report(err)), + } + } + fn deserialize_v0(bytes: &[u8]) -> Result { use rmp::decode; @@ -163,7 +224,7 @@ impl History { let version = decode::read_u16(&mut bytes).map_err(error_report)?; - if version != 0 { + if version != HISTORY_RECORD_VERSION_V0 { bail!("expected decoding v0 record, found v{version}"); } @@ -187,7 +248,6 @@ impl History { let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - // if we have more fields, try and get the deleted_at let mut bytes = Bytes::new(bytes); let (deleted_at, bytes) = match decode::read_u64(&mut bytes) { @@ -196,6 +256,76 @@ impl History { Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()), Err(err) => return Err(error_report(err)), }; + if !bytes.is_empty() { + bail!("trailing bytes in encoded history. malformed") + } + + Ok(History { + id: id.to_owned().into(), + timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?, + duration, + exit, + command: command.to_owned(), + cwd: cwd.to_owned(), + session: session.to_owned(), + hostname: hostname.to_owned(), + author: Self::author_from_hostname(hostname), + intent: None, + deleted_at: deleted_at + .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128)) + .transpose()?, + }) + } + + fn deserialize_v1(bytes: &[u8]) -> Result { + use rmp::decode; + + fn error_report(err: E) -> eyre::Report { + eyre!("{err:?}") + } + + let mut bytes = Bytes::new(bytes); + + let version = decode::read_u16(&mut bytes).map_err(error_report)?; + + if version != HISTORY_RECORD_VERSION_V1 { + bail!("expected decoding v1 record, found v{version}"); + } + + let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; + + if !(10..=11).contains(&nfields) { + bail!("cannot decrypt history from a different version of Atuin"); + } + + let bytes = bytes.remaining_slice(); + let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + + let mut bytes = Bytes::new(bytes); + let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?; + let duration = decode::read_int(&mut bytes).map_err(error_report)?; + let exit = decode::read_int(&mut bytes).map_err(error_report)?; + + let bytes = bytes.remaining_slice(); + let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; + + let mut bytes = Bytes::new(bytes); + + let (deleted_at, bytes) = match decode::read_u64(&mut bytes) { + Ok(unix) => (Some(unix), bytes.remaining_slice()), + // we accept null here + Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()), + Err(err) => return Err(error_report(err)), + }; + let (author, bytes) = Self::read_optional_string(bytes)?; + let (intent, bytes) = if nfields > 10 { + Self::read_optional_string(bytes)? + } else { + (None, bytes) + }; if !bytes.is_empty() { bail!("trailing bytes in encoded history. malformed") @@ -210,6 +340,8 @@ impl History { cwd: cwd.to_owned(), session: session.to_owned(), hostname: hostname.to_owned(), + author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)), + intent, deleted_at: deleted_at .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128)) .transpose()?, @@ -218,7 +350,8 @@ impl History { pub fn deserialize(bytes: &[u8], version: &str) -> Result { match version { - HISTORY_VERSION => Self::deserialize_v0(bytes), + HISTORY_VERSION_V0 => Self::deserialize_v0(bytes), + HISTORY_VERSION_V1 => Self::deserialize_v1(bytes), _ => bail!("unknown version {version:?}"), } @@ -361,6 +494,8 @@ impl History { /// .duration(100) /// .session("somesession".to_string()) /// .hostname("localhost".to_string()) + /// .author("user".to_string()) + /// .intent(None) /// .deleted_at(None) /// .build() /// .into(); @@ -469,18 +604,6 @@ mod tests { #[test] fn test_serialize_deserialize() { - let bytes = [ - 205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, - 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99, - 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, - 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, - 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, - 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, - 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, - 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, - 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192, - ]; - let history = History { id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), @@ -490,20 +613,21 @@ mod tests { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: None, }; let serialized = history.serialize().expect("failed to serialize history"); - assert_eq!(serialized.0, bytes); + assert_eq!( + &serialized.0[0..3], + [205, 0, 1], + "should encode as history v1" + ); let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION) .expect("failed to deserialize history"); assert_eq!(history, deserialized); - - // test the snapshot too - let deserialized = - History::deserialize(&bytes, HISTORY_VERSION).expect("failed to deserialize history"); - assert_eq!(history, deserialized); } #[test] @@ -517,6 +641,8 @@ mod tests { cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), session: "b97d9a306f274473a203d2eba41f9457".to_owned(), hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)), }; @@ -528,6 +654,29 @@ mod tests { assert_eq!(history, deserialized); } + #[test] + fn test_serialize_deserialize_with_author_and_intent() { + let history = History { + id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), + timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), + duration: 49206000, + exit: 0, + command: "git status".to_owned(), + cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), + session: "b97d9a306f274473a203d2eba41f9457".to_owned(), + hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "claude".to_owned(), + intent: Some("check repository status".to_owned()), + deleted_at: None, + }; + + let serialized = history.serialize().expect("failed to serialize history"); + let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION) + .expect("failed to deserialize history"); + + assert_eq!(history, deserialized); + } + #[test] fn test_serialize_deserialize_version() { // v0 @@ -543,23 +692,31 @@ mod tests { 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192, ]; - // some other version - let bytes_v1 = [ - 205, 1, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, - 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99, - 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, - 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, - 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, - 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, - 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, - 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, - 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192, - ]; + let deserialized = History::deserialize(&bytes_v0, "v0"); + assert!(deserialized.is_ok()); let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION); + assert!(deserialized.is_err()); + + let current = History { + id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), + timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), + duration: 49206000, + exit: 0, + command: "git status".to_owned(), + cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(), + session: "b97d9a306f274473a203d2eba41f9457".to_owned(), + hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(), + author: "conrad.ludgate".to_owned(), + intent: None, + deleted_at: None, + }; + + let bytes_v1 = current.serialize().expect("failed to serialize history"); + let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION); assert!(deserialized.is_ok()); - let deserialized = History::deserialize(&bytes_v1, HISTORY_VERSION); + let deserialized = History::deserialize(&bytes_v1.0, "v0"); assert!(deserialized.is_err()); } } diff --git a/crates/atuin-client/src/history/builder.rs b/crates/atuin-client/src/history/builder.rs index 2b28339f..72a505fd 100644 --- a/crates/atuin-client/src/history/builder.rs +++ b/crates/atuin-client/src/history/builder.rs @@ -20,6 +20,10 @@ pub struct HistoryImported { session: Option, #[builder(default, setter(strip_option, into))] hostname: Option, + #[builder(default, setter(strip_option, into))] + author: Option, + #[builder(default, setter(strip_option, into))] + intent: Option, } impl From for History { @@ -32,6 +36,8 @@ impl From for History { imported.duration, imported.session, imported.hostname, + imported.author, + imported.intent, None, ) } @@ -49,6 +55,10 @@ pub struct HistoryCaptured { command: String, #[builder(setter(into))] cwd: String, + #[builder(default, setter(strip_option, into))] + author: Option, + #[builder(default, setter(strip_option, into))] + intent: Option, } impl From for History { @@ -61,6 +71,8 @@ impl From for History { -1, None, None, + captured.author, + captured.intent, None, ) } @@ -79,6 +91,8 @@ pub struct HistoryFromDb { duration: i64, session: String, hostname: String, + author: String, + intent: Option, deleted_at: Option, } @@ -93,6 +107,8 @@ impl From for History { duration: from_db.duration, session: from_db.session, hostname: from_db.hostname, + author: from_db.author, + intent: from_db.intent, deleted_at: from_db.deleted_at, } } @@ -114,6 +130,10 @@ pub struct HistoryDaemonCapture { session: String, #[builder(setter(into))] hostname: String, + #[builder(default, setter(strip_option, into))] + author: Option, + #[builder(default, setter(strip_option, into))] + intent: Option, } impl From for History { @@ -126,6 +146,8 @@ impl From for History { -1, Some(captured.session), Some(captured.hostname), + captured.author, + captured.intent, None, ) } diff --git a/crates/atuin-client/src/history/store.rs b/crates/atuin-client/src/history/store.rs index 041d90ce..d166564f 100644 --- a/crates/atuin-client/src/history/store.rs +++ b/crates/atuin-client/src/history/store.rs @@ -10,7 +10,7 @@ use crate::{ }; use atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; -use super::{HISTORY_TAG, HISTORY_VERSION, History, HistoryId}; +use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId}; #[derive(Debug, Clone)] pub struct HistoryStore { @@ -196,10 +196,11 @@ impl HistoryStore { for record in records.into_iter() { let hist = match record.version.as_str() { - HISTORY_VERSION => { + HISTORY_VERSION_V0 | HISTORY_VERSION => { + let version = record.version.clone(); let decrypted = record.decrypt::(&self.encryption_key)?; - HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION) + HistoryRecord::deserialize(&decrypted.data, version.as_str()) } version => bail!("unknown history version {version:?}"), }?; @@ -257,8 +258,14 @@ impl HistoryStore { continue; } + let version = record.version.clone(); let decrypted = record.decrypt::(&self.encryption_key)?; - let record = HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION)?; + let record = match version.as_str() { + HISTORY_VERSION_V0 | HISTORY_VERSION => { + HistoryRecord::deserialize(&decrypted.data, version.as_str())? + } + version => bail!("unknown history version {version:?}"), + }; match record { HistoryRecord::Create(h) => { @@ -350,14 +357,14 @@ mod tests { #[test] fn test_serialize_deserialize_create() { let bytes = [ - 204, 0, 196, 141, 205, 0, 0, 153, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, + 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85, 115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116, 104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117, 105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55, 56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112, - 58, 101, 108, 108, 105, 101, 192, + 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101, ]; let history = History { @@ -369,6 +376,8 @@ mod tests { cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(), session: "018cd4fead897597852527a31c998059".to_owned(), hostname: "boop:ellie".to_owned(), + author: "ellie".to_owned(), + intent: None, deleted_at: None, }; diff --git a/crates/atuin-daemon/proto/history.proto b/crates/atuin-daemon/proto/history.proto index 9fbd3372..2a45b7cf 100644 --- a/crates/atuin-daemon/proto/history.proto +++ b/crates/atuin-daemon/proto/history.proto @@ -8,6 +8,8 @@ message StartHistoryRequest { string cwd = 3; string session = 4; string hostname = 5; + string author = 6; + string intent = 7; } message EndHistoryRequest { diff --git a/crates/atuin-daemon/src/client.rs b/crates/atuin-daemon/src/client.rs index 05067bda..3b76a680 100644 --- a/crates/atuin-daemon/src/client.rs +++ b/crates/atuin-daemon/src/client.rs @@ -103,6 +103,8 @@ impl HistoryClient { hostname: h.hostname, session: h.session, timestamp: h.timestamp.unix_timestamp_nanos() as u64, + author: h.author, + intent: h.intent.unwrap_or_default(), }; Ok(self.client.start_history(req).await?.into_inner()) diff --git a/crates/atuin-daemon/src/server.rs b/crates/atuin-daemon/src/server.rs index 9622d2b6..826d6191 100644 --- a/crates/atuin-daemon/src/server.rs +++ b/crates/atuin-daemon/src/server.rs @@ -69,7 +69,7 @@ impl HistorySvc for HistoryService { ) })?; - let h: History = History::daemon() + let mut h: History = History::daemon() .timestamp(timestamp) .command(req.command) .cwd(req.cwd) @@ -77,6 +77,12 @@ impl HistorySvc for HistoryService { .hostname(req.hostname) .build() .into(); + if !req.author.trim().is_empty() { + h.author = req.author; + } + if !req.intent.trim().is_empty() { + h.intent = Some(req.intent); + } // The old behaviour had us inserting half-finished history records into the database // The new behaviour no longer allows that. diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs index 00b432a6..c20f64a3 100644 --- a/crates/atuin/src/command/client/history.rs +++ b/crates/atuin/src/command/client/history.rs @@ -41,6 +41,14 @@ pub enum Cmd { #[arg(long = "command-from-env", hide = true)] cmd_env: bool, + /// Author of this command, eg `ellie`, `claude`, or `copilot` + #[arg(long)] + author: Option, + + /// Optional intent/rationale for running this command + #[arg(long)] + intent: Option, + command: Vec, }, @@ -87,7 +95,7 @@ pub enum Cmd { #[arg(long, visible_alias = "tz")] timezone: Option, - /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {exit}, {time}, {session}, and {uuid} + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid} /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, @@ -110,7 +118,7 @@ pub enum Cmd { #[arg(long, visible_alias = "tz")] timezone: Option, - /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {session}, {uuid} and {relativetime}. + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option, @@ -316,6 +324,8 @@ impl FormatKey for FmtHistory<'_> { .split_once(':') .map_or(&self.history.hostname, |(host, _)| host), )?, + "author" => f.write_str(&self.history.author)?, + "intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?, "user" => f.write_str( self.history .hostname @@ -352,18 +362,37 @@ fn parse_fmt(format: &str) -> ParsedFmt<'_> { } impl Cmd { + fn apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) { + if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) { + author.clone_into(&mut history.author); + } + + if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) { + history.intent = Some(intent.to_owned()); + } else if intent.is_some() { + history.intent = None; + } + } + #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)] - async fn handle_start(db: &impl Database, settings: &Settings, command: &str) -> Result<()> { + async fn handle_start( + db: &impl Database, + settings: &Settings, + command: &str, + author: Option<&str>, + intent: Option<&str>, + ) -> Result<()> { // It's better for atuin to silently fail here and attempt to // store whatever is ran, than to throw an error to the terminal let cwd = utils::get_current_dir(); - let h: History = History::capture() + let mut h: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) .build() .into(); + Self::apply_start_metadata(&mut h, author, intent); if !h.should_save(settings) { return Ok(()); @@ -383,17 +412,23 @@ impl Cmd { } #[cfg(feature = "daemon")] - async fn handle_daemon_start(settings: &Settings, command: &str) -> Result<()> { + async fn handle_daemon_start( + settings: &Settings, + command: &str, + author: Option<&str>, + intent: Option<&str>, + ) -> Result<()> { // It's better for atuin to silently fail here and attempt to // store whatever is ran, than to throw an error to the terminal let cwd = utils::get_current_dir(); - let h: History = History::capture() + let mut h: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) .build() .into(); + Self::apply_start_metadata(&mut h, author, intent); if !h.should_save(settings) { return Ok(()); @@ -657,7 +692,8 @@ impl Cmd { match self { Self::Start { .. } => { let command = self.get_start_command().unwrap_or_default(); - return Self::handle_daemon_start(settings, &command).await; + let (author, intent) = self.get_start_metadata().unwrap_or_default(); + return Self::handle_daemon_start(settings, &command, author, intent).await; } Self::End { id, exit, duration } => { @@ -684,7 +720,8 @@ impl Cmd { match self { Self::Start { .. } => { let command = self.get_start_command().unwrap_or_default(); - Self::handle_start(&db, settings, &command).await + let (author, intent) = self.get_start_metadata().unwrap_or_default(); + Self::handle_start(&db, settings, &command, author, intent).await } Self::End { id, exit, duration } => { Self::handle_end(&db, store, history_store, settings, &id, exit, duration).await @@ -766,6 +803,15 @@ impl Cmd { _ => None, } } + + /// Returns `(author, intent)` for the `Start` variant. + /// Returns `None` for any other variant. + fn get_start_metadata(&self) -> Option<(Option<&str>, Option<&str>)> { + match self { + Self::Start { author, intent, .. } => Some((author.as_deref(), intent.as_deref())), + _ => None, + } + } } #[cfg(test)] diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs index 4c3fece1..151e1354 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -355,6 +355,8 @@ mod tests { cwd: "/toot".to_string(), session: "sesh1".to_string(), hostname: "hostn".to_string(), + author: "hostn".to_string(), + intent: None, deleted_at: None, }; let next = History { @@ -366,6 +368,8 @@ mod tests { cwd: "/toot".to_string(), session: "sesh1".to_string(), hostname: "hostn".to_string(), + author: "hostn".to_string(), + intent: None, deleted_at: None, }; let prev = History { @@ -377,6 +381,8 @@ mod tests { cwd: "/toot".to_string(), session: "sesh1".to_string(), hostname: "hostn".to_string(), + author: "hostn".to_string(), + intent: None, deleted_at: None, }; let stats = HistoryStats { -- cgit v1.3.1