diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index ad43be6..ed51946 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -108,6 +108,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon ActivityState::Active => "active", ActivityState::Idle => "idle", ActivityState::AwaitingInput => "awaiting", + ActivityState::Error => "error", }; // Show the live foreground command alongside the spawn command when // the daemon has observed one that differs (NAP-340). @@ -176,6 +177,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index a8150bb..549d1be 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -353,6 +353,7 @@ impl SessionManager { foreground: None, activity: ActivityState::Active, last_activity_at: Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, // The watcher fills this in on its first blocked-work pass; a @@ -572,6 +573,7 @@ impl SessionManager { foreground: None, activity: ActivityState::Idle, last_activity_at: Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index bf864ed..2d88ff4 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -27,6 +27,7 @@ fn sample(id: Uuid) -> Session { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), blocked_work: None, } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 66fb569..e960175 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -80,6 +80,13 @@ pub struct Session { /// from daemons that predate the field. #[serde(default = "epoch")] pub last_activity_at: DateTime, + /// Wall-clock time at which the session entered its current + /// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or + /// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only + /// tracks the last observed PTY output. Populated by #104; defaults to the + /// Unix epoch on payloads from daemons that predate the field. + #[serde(default = "epoch")] + pub activity_since: DateTime, /// The pane's *current* working directory (`#{pane_current_path}`) — distinct /// from [`Session::cwd`], the directory it was spawned in. The activity /// watcher refreshes it as the user `cd`s around; it drives the web UI's @@ -119,6 +126,9 @@ pub enum ActivityState { Idle, /// Blocked on a `read()` from the pty — waiting on the user. AwaitingInput, + /// The agent running in the session reported a failure (#102). Never + /// inferred by the daemon; only set when an agent reports it (#105). + Error, } /// Optional worktree request attached to a `CreateSessionRequest`. @@ -537,6 +547,9 @@ mod tests { last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z") .unwrap() .with_timezone(&Utc), + activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z") + .unwrap() + .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, } @@ -588,6 +601,35 @@ mod tests { ); } + #[test] + fn activity_since_defaults_to_epoch_on_legacy_payload() { + // A daemon predating #102 omits `activity_since`; it must deserialize + // to the forward-compat default rather than failing, and a present + // value round-trips. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.activity_since, epoch()); + + let full = sample_session(); + assert_eq!(full, round_trip(&full)); + } + + // #102: nothing infers `Error`; only #105's agent-reported failure sets it. + #[test] + fn error_activity_state_serializes_pascal_case_and_idle_stays_default() { + assert_eq!( + serde_json::to_value(ActivityState::Error).unwrap(), + serde_json::json!("Error") + ); + assert_eq!(ActivityState::default(), ActivityState::Idle); + } + #[test] fn activity_state_serializes_pascal_case_and_defaults_idle() { assert_eq!( diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 881fd32..d5a6d47 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -42,6 +42,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { foreground: None, activity: Default::default(), last_activity_at: Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index dff6470..3034aa4 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1127,6 +1127,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, }], @@ -1644,6 +1645,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index d2f539b..178a559 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -194,6 +194,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 724eff0..cd17e98 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -105,6 +105,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, } @@ -228,6 +229,7 @@ mod tests { foreground: None, activity: Default::default(), last_activity_at: chrono::Utc::now(), + activity_since: chrono::Utc::now(), current_cwd: None, blocked_work: None, diff --git a/web/src/types.ts b/web/src/types.ts index 3f15719..c03aebd 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -9,8 +9,9 @@ export type IsoTimestamp = string; export type SessionStatus = "Running" | "Dead"; /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). - * Absent on payloads from daemons that predate the field. */ -export type ActivityState = "Active" | "Idle" | "AwaitingInput"; + * Absent on payloads from daemons that predate the field. `Error` is only set + * when an agent reports failure (#105); the daemon never infers it. */ +export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error"; export interface Session { id: Uuid; @@ -27,6 +28,11 @@ export interface Session { activity?: ActivityState; /** Wall-clock of the most recent observed PTY output (NAP-340). */ last_activity_at?: IsoTimestamp; + /** Wall-clock of when the session entered its current `activity` state + * (#102) — distinct from `last_activity_at`, which only tracks PTY output. + * Defaults to the Unix epoch on payloads from daemons that predate the + * field. */ + activity_since?: IsoTimestamp; /** The pane's *current* working directory, as the daemon's activity watcher * observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir). * Drives the directory-based session tree. Absent until first observed. */