diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/panes.rs | 271 | ||||
| -rw-r--r-- | src/app/api/plugins/context.rs | 3 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 157 |
4 files changed, 363 insertions, 69 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 58dbeb19..17569682 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,15 +20,14 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, - normalize_reported_agent_label, + detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, + normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, + MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; +#[cfg(test)] +use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; use super::responses::{encode_error, encode_success}; -const METADATA_SOURCE_MAX_CHARS: usize = 80; -const METADATA_TTL_MIN_MS: u64 = 1; -const METADATA_TTL_MAX_MS: u64 = 86_400_000; - impl App { pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() { @@ -1237,7 +1236,6 @@ impl App { agent_label, state: detect_state_from_api(params.state), message: params.message, - custom_status: normalize_custom_status(params.custom_status), seq: params.seq, }); @@ -1279,7 +1277,7 @@ impl App { id: String, params: PaneReportMetadataParams, ) -> String { - let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let agent_label = match params.agent.as_deref() { @@ -1295,15 +1293,21 @@ impl App { }; let raw_title_set = params.title.is_some(); let raw_display_agent_set = params.display_agent.is_some(); - let raw_custom_status_set = params.custom_status.is_some(); let raw_state_labels_set = !params.state_labels.is_empty(); + let tokens = if params.tokens.is_empty() { + None + } else { + match normalize_metadata_tokens(params.tokens) { + Ok(tokens) => Some(tokens), + Err(message) => return encode_error(id, "invalid_metadata_token", message), + } + }; let ttl = match normalize_metadata_ttl(params.ttl_ms) { Ok(ttl) => ttl, Err(message) => return encode_error(id, "invalid_metadata_ttl", message), }; let title = normalize_presentation_text(params.title); let display_agent = normalize_presentation_text(params.display_agent); - let custom_status = normalize_custom_status(params.custom_status); let applies_to_source = match params.applies_to_source { Some(applies_to_source) => match normalize_metadata_source(applies_to_source) { Ok(applies_to_source) => Some(applies_to_source), @@ -1323,7 +1327,6 @@ impl App { }; if raw_title_set && params.clear_title || raw_display_agent_set && params.clear_display_agent - || raw_custom_status_set && params.clear_custom_status || raw_state_labels_set && params.clear_state_labels { return encode_error( @@ -1334,11 +1337,10 @@ impl App { } if title.is_none() && display_agent.is_none() - && custom_status.is_none() && state_labels.is_empty() + && tokens.is_none() && !params.clear_title && !params.clear_display_agent - && !params.clear_custom_status && !params.clear_state_labels { return encode_error( @@ -1347,23 +1349,85 @@ impl App { "missing metadata field to set or clear", ); } - self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { - pane_id, - source, - agent_label, - applies_to_source, - title, - display_agent, - custom_status, - state_labels, - clear_title: params.clear_title, - clear_display_agent: params.clear_display_agent, - clear_custom_status: params.clear_custom_status, - clear_state_labels: params.clear_state_labels, - seq: params.seq, - ttl, + let presentation_requested = title.is_some() + || display_agent.is_some() + || !state_labels.is_empty() + || params.clear_title + || params.clear_display_agent + || params.clear_state_labels; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.pane_state(pane_id)) + .map(|pane| pane.attached_terminal_id.clone()) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) { + return encode_success(id, ResponseResult::Ok {}); + } + if let Some(tokens) = tokens.as_ref() { + if terminal.metadata_tokens.key_count_after_patch(tokens) + > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + { + return encode_error( + id, + "metadata_token_limit", + format!( + "pane metadata may contain at most {MAX_METADATA_TOKEN_KEYS_PER_RESOURCE} tokens" + ), + ); + } + } + match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) { + Ok(true) => {} + Ok(false) => return encode_success(id, ResponseResult::Ok {}), + Err(()) => { + return encode_error( + id, + "metadata_sequence_source_limit", + format!( + "pane metadata may track at most {} sequenced sources", + crate::metadata_tokens::MAX_SEQUENCE_SOURCES + ), + ); + } + } + let token_changed = tokens.is_some_and(|tokens| { + let changed = terminal + .metadata_tokens + .patch(tokens, ttl, std::time::Instant::now()); + if changed { + terminal.revision = terminal.revision.saturating_add(1); + } + changed }); + if presentation_requested { + self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { + pane_id, + source, + agent_label, + applies_to_source, + title, + display_agent, + state_labels, + clear_title: params.clear_title, + clear_display_agent: params.clear_display_agent, + clear_state_labels: params.clear_state_labels, + seq: None, + ttl, + }); + } + if token_changed { + self.sync_agent_metadata_deadline(); + self.emit_pane_updated(ws_idx, pane_id); + } + encode_success(id, ResponseResult::Ok {}) } @@ -1551,41 +1615,6 @@ impl App { } } -fn normalize_metadata_source(value: String) -> Result<String, &'static str> { - let value = value.trim(); - if value.is_empty() { - return Err("metadata source must not be empty"); - } - if value.chars().count() > METADATA_SOURCE_MAX_CHARS { - return Err("metadata source must be 80 characters or fewer"); - } - if !value.chars().all(metadata_source_char_is_valid) { - return Err( - "metadata source may contain only ASCII letters, digits, colon, dot, underscore, and hyphen", - ); - } - Ok(value.to_string()) -} - -fn metadata_source_char_is_valid(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, ':' | '.' | '_' | '-') -} - -fn normalize_metadata_ttl( - ttl_ms: Option<u64>, -) -> Result<Option<std::time::Duration>, &'static str> { - let Some(ttl_ms) = ttl_ms else { - return Ok(None); - }; - if ttl_ms < METADATA_TTL_MIN_MS { - return Err("metadata ttl_ms must be at least 1"); - } - if ttl_ms > METADATA_TTL_MAX_MS { - return Err("metadata ttl_ms must be 86400000 or less"); - } - Ok(Some(std::time::Duration::from_millis(ttl_ms))) -} - fn normalize_presentation_text(value: Option<String>) -> Option<String> { let trimmed = value?.trim().to_string(); let normalized: String = trimmed @@ -1850,6 +1879,7 @@ mod tests { use crate::{ api::schema::{ErrorResponse, SplitDirection, SuccessResponse}, config::Config, + detect::{Agent, AgentState}, workspace::Workspace, }; @@ -1902,13 +1932,12 @@ mod tests { source: "user:metadata.test-1".into(), agent: None, applies_to_source: None, - title: None, + title: Some("activity".into()), display_agent: None, - custom_status: Some("activity".into()), state_labels: std::collections::HashMap::new(), + tokens: std::collections::HashMap::new(), clear_title: false, clear_display_agent: false, - clear_custom_status: false, clear_state_labels: false, seq: None, ttl_ms: None, @@ -3584,6 +3613,116 @@ mod tests { } #[test] + fn pane_metadata_tokens_patch_and_clear_through_dispatcher() { + let (mut app, pane_id) = app_with_test_workspace(); + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("model".into(), Some("opus".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("model".into(), "opus".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("model".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let mut params = metadata_params(pane_id.clone()); + params.title = None; + params.tokens = tokens; + let response = app.handle_api_request(crate::api::schema::Request { + id: "set".into(), + method: crate::api::schema::Method::PaneReportMetadata(params), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "get".into(), + method: crate::api::schema::Method::PaneGet(PaneTarget { + pane_id: pane_id.clone(), + }), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info"); + }; + assert_eq!(pane.tokens, expected); + } + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id].agent_metadata.is_empty()); + } + + #[test] + fn pane_tokens_are_independent_from_presentation_guards() { + let (mut app, pane_id) = app_with_test_workspace(); + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Claude), AgentState::Working); + let mut params = metadata_params(pane_id); + params.title = None; + params.agent = Some("codex".into()); + params.tokens = + std::collections::HashMap::from([("summary".into(), Some("global".into()))]); + + let response = app.handle_pane_report_metadata("guarded".into(), params); + + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + app.state.terminals[&terminal_id].metadata_tokens.values(), + std::collections::HashMap::from([("summary".into(), "global".into())]) + ); + } + + #[test] + fn pane_metadata_uses_one_sequence_for_presentation_and_tokens() { + let (mut app, pane_id) = app_with_test_workspace(); + let mut presentation = metadata_params(pane_id.clone()); + presentation.seq = Some(10); + let response = app.handle_pane_report_metadata("presentation".into(), presentation); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let mut stale_token = metadata_params(pane_id.clone()); + stale_token.title = None; + stale_token.tokens = + std::collections::HashMap::from([("summary".into(), Some("stale".into()))]); + stale_token.seq = Some(9); + let response = app.handle_pane_report_metadata("stale".into(), stale_token); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); + let terminal_id = app.state.workspaces[0] + .pane_state(internal_pane_id) + .unwrap() + .attached_terminal_id + .clone(); + assert!(app.state.terminals[&terminal_id] + .metadata_tokens + .values() + .is_empty()); + } + + #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index ead5c8e2..16d2fdb1 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -44,6 +44,7 @@ impl App { match &event.data { EventData::WorkspaceCreated { workspace } | EventData::WorkspaceUpdated { workspace } + | EventData::WorkspaceMetadataUpdated { workspace } | EventData::WorktreeCreated { workspace, .. } | EventData::WorktreeOpened { workspace, .. } => { self.plugin_context_for_workspace_info(workspace, correlation_id) @@ -132,7 +133,7 @@ impl App { context.tab_id = Some(layout.tab_id.clone()); context }), - EventData::PaneCreated { pane } => { + EventData::PaneCreated { pane } | EventData::PaneUpdated { pane } => { self.plugin_context_for_pane_info(pane, correlation_id) } EventData::PaneMoved { pane, .. } => { diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index b77b4715..1e0ec7d8 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -2369,7 +2369,6 @@ action = "missing" agent: "codex".into(), state: crate::api::schema::PaneAgentState::Working, message: None, - custom_status: None, seq: None, agent_session_id: None, agent_session_path: None, diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 8899ec44..b7260c17 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,10 +2,11 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget, }; use crate::app::App; +use super::super::api_helpers::{normalize_metadata_source, normalize_metadata_ttl}; use super::responses::{encode_error, encode_success}; impl App { @@ -153,6 +154,76 @@ impl App { encode_success(id, ResponseResult::WorkspaceList { workspaces }) } + pub(super) fn handle_workspace_report_metadata( + &mut self, + id: String, + params: WorkspaceReportMetadataParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + let source = match normalize_metadata_source(params.source) { + Ok(source) => source, + Err(message) => return encode_error(id, "invalid_metadata_source", message), + }; + let ttl = match normalize_metadata_ttl(params.ttl_ms) { + Ok(ttl) => ttl, + Err(message) => return encode_error(id, "invalid_metadata_ttl", message), + }; + let tokens = match super::super::api_helpers::normalize_metadata_tokens(params.tokens) { + Ok(tokens) => tokens, + Err(message) => return encode_error(id, "invalid_metadata_token", message), + }; + let Some(workspace) = self.state.workspaces.get_mut(index) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + if !crate::metadata_tokens::sequence_is_fresh( + &workspace.metadata_token_sequences, + &source, + params.seq, + ) { + return encode_success(id, ResponseResult::Ok {}); + } + if workspace.metadata_tokens.key_count_after_patch(&tokens) + > super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + { + return encode_error( + id, + "metadata_token_limit", + format!( + "workspace metadata may contain at most {} tokens", + super::super::api_helpers::MAX_METADATA_TOKEN_KEYS_PER_RESOURCE + ), + ); + } + match crate::metadata_tokens::accept_sequence( + &mut workspace.metadata_token_sequences, + &source, + params.seq, + ) { + Ok(true) => {} + Ok(false) => return encode_success(id, ResponseResult::Ok {}), + Err(()) => { + return encode_error( + id, + "metadata_sequence_source_limit", + format!( + "workspace metadata may track at most {} sequenced sources", + crate::metadata_tokens::MAX_SEQUENCE_SOURCES + ), + ); + } + } + let changed = workspace + .metadata_tokens + .patch(tokens, ttl, std::time::Instant::now()); + if changed { + self.sync_agent_metadata_deadline(); + self.emit_workspace_token_updated(index); + } + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return workspace_not_found(id, &target.workspace_id); @@ -364,6 +435,90 @@ mod tests { } #[test] + fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + + for (tokens, expected) in [ + ( + std::collections::HashMap::from([ + ("summary".into(), Some("reviewing auth".into())), + ("jj_status".into(), Some("2 changes".into())), + ]), + std::collections::HashMap::from([ + ("summary".into(), "reviewing auth".into()), + ("jj_status".into(), "2 changes".into()), + ]), + ), + ( + std::collections::HashMap::from([ + ("summary".into(), Some("done".into())), + ("jj_status".into(), None), + ]), + std::collections::HashMap::from([("summary".into(), "done".into())]), + ), + ] { + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::WorkspaceReportMetadata( + WorkspaceReportMetadataParams { + workspace_id: workspace_id.clone(), + source: "user:test".into(), + tokens, + seq: None, + ttl_ms: None, + }, + ), + }); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!(app.workspace_info(0).tokens, expected); + } + + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } + if workspace.tokens.get("summary").map(String::as_str) == Some("done") + && !workspace.tokens.contains_key("jj_status") + ))); + } + + #[test] + fn workspace_token_ttl_expires_through_runtime_and_emits_update() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = vec![Workspace::test_new("one")]; + let workspace_id = app.public_workspace_id(0); + let response = app.handle_workspace_report_metadata( + "req".into(), + WorkspaceReportMetadataParams { + workspace_id, + source: "user:test".into(), + tokens: std::collections::HashMap::from([( + "summary".into(), + Some("temporary".into()), + )]), + seq: None, + ttl_ms: Some(1), + }, + ); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + let deadline = app.agent_metadata_deadline.expect("token deadline"); + + app.expire_metadata_at(deadline, deadline); + + assert!(app.workspace_info(0).tokens.is_empty()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + EventData::WorkspaceMetadataUpdated { workspace } if workspace.tokens.is_empty() + ))); + } + + #[test] fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); |
