aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/panes.rs271
-rw-r--r--src/app/api/plugins/context.rs3
-rw-r--r--src/app/api/plugins/mod.rs1
-rw-r--r--src/app/api/workspaces.rs157
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(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.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, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.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(&params.workspace_id) else {
+ return workspace_not_found(id, &params.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, &params.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();