diff --git a/Cargo.lock b/Cargo.lock index 7043d355..4ffba5c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2763,7 +2763,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchestrator-cli" -version = "0.6.21" +version = "0.6.22" dependencies = [ "animus-actor", "animus-control-protocol", diff --git a/crates/orchestrator-cli/Cargo.toml b/crates/orchestrator-cli/Cargo.toml index fabdd0c4..f8add0a7 100644 --- a/crates/orchestrator-cli/Cargo.toml +++ b/crates/orchestrator-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orchestrator-cli" -version = "0.6.21" +version = "0.6.22" edition = "2021" license = "Elastic-2.0" default-run = "animus" diff --git a/crates/orchestrator-cli/src/cli_types/subject_types.rs b/crates/orchestrator-cli/src/cli_types/subject_types.rs index 97096171..34af5f28 100644 --- a/crates/orchestrator-cli/src/cli_types/subject_types.rs +++ b/crates/orchestrator-cli/src/cli_types/subject_types.rs @@ -92,6 +92,12 @@ pub(crate) struct SubjectListArgs { /// Opaque cursor from a prior page's `next_cursor`, to fetch the next page. #[arg(long, value_name = "CURSOR")] pub cursor: Option, + + /// Case-insensitive substring filter on the subject TITLE. Looks a subject + /// up by name without paging the whole set: the full set is fetched and + /// filtered by title, then `--limit` is applied to the matches. + #[arg(long, value_name = "TEXT")] + pub query: Option, } #[derive(Debug, Args)] diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_command_args.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_command_args.rs index 3a1e4c35..285b1afe 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_command_args.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_command_args.rs @@ -11,6 +11,7 @@ pub(super) fn build_subject_list_args(input: &SubjectListInput) -> Vec { args.push(limit.to_string()); } push_opt(&mut args, "--cursor", input.cursor.clone()); + push_opt(&mut args, "--query", input.query.clone()); args } diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_inputs.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_inputs.rs index 499e300c..b14e3d3e 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_inputs.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/subject_inputs.rs @@ -12,6 +12,11 @@ pub(super) struct SubjectListInput { /// Opaque cursor from a prior result's `next_cursor`, to fetch the next page. #[serde(default)] pub(super) cursor: Option, + /// Case-insensitive substring filter on the subject title — look a subject up + /// by name (e.g. a repo's owner/repo) without paging. The full set is fetched + /// and filtered, so the match is found even on a large board. + #[serde(default)] + pub(super) query: Option, #[serde(default)] pub(super) project_root: Option, } diff --git a/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs b/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs index d7614daf..efb91370 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_mcp/tests.rs @@ -1581,6 +1581,7 @@ fn mcp_subject_list_routes_via_control() { status: Some("ready".to_string()), limit: Some(25), cursor: Some("page2".to_string()), + query: Some("flaky".to_string()), project_root: None, }; let args = build_subject_list_args(&input); @@ -1597,6 +1598,8 @@ fn mcp_subject_list_routes_via_control() { "25".to_string(), "--cursor".to_string(), "page2".to_string(), + "--query".to_string(), + "flaky".to_string(), ] ); } diff --git a/crates/orchestrator-cli/src/services/operations/ops_subject.rs b/crates/orchestrator-cli/src/services/operations/ops_subject.rs index 0a1aeef7..8850ad93 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_subject.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_subject.rs @@ -54,6 +54,7 @@ const DEFAULT_SUBJECT_LIST_LIMIT: u32 = 50; async fn handle_subject_list(args: SubjectListArgs, project_root: &str, json: bool) -> Result<()> { let kind = resolve_kind(args.kind.as_deref(), project_root, json)?; + let query = args.query.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string); let mut filter = serde_json::Map::new(); filter.insert("kind".to_string(), json!([kind])); if let Some(status) = args.status.as_deref() { @@ -64,16 +65,67 @@ async fn handle_subject_list(args: SubjectListArgs, project_root: &str, json: bo // that honor `limit` also return `total` + `next_cursor` for paging; backends // that ignore it simply return everything (no behavior change for them). let limit = args.limit.unwrap_or(DEFAULT_SUBJECT_LIST_LIMIT); - if limit > 0 { - filter.insert("limit".to_string(), json!(limit)); + // A --query lookup must see the WHOLE set (the backend has no title filter, + // and the daemon drops unknown filter fields), so fetch unbounded and apply + // both the title filter and the page limit client-side below. + let fetch_limit = if query.is_some() { 0 } else { limit }; + if fetch_limit > 0 { + filter.insert("limit".to_string(), json!(fetch_limit)); } if let Some(cursor) = args.cursor.as_deref() { filter.insert("cursor".to_string(), json!(cursor)); } let params = Some(Value::Object(filter)); + if let Some(q) = query { + return dispatch_list_filtered(&kind, params, &q, limit, project_root, json).await; + } dispatch(&kind, "list", params, project_root, json).await } +/// `subject list --query`: fetch the full set, filter by a case-insensitive +/// substring of the subject TITLE, then apply the page `limit` to the matches. +/// Lets agents/UI resolve a subject by name without paging — and without the +/// agent-facing MCP result truncating a huge unfiltered list. Mirrors +/// `dispatch`'s output (json envelope vs human table); the result is the +/// filtered subset with an exact `total` and a null `next_cursor`. +async fn dispatch_list_filtered( + kind: &str, + params: Option, + query: &str, + limit: u32, + project_root: &str, + json: bool, +) -> Result<()> { + let resolution = resolve_subject_dispatch(Path::new(project_root)).await?; + let method = format!("{kind}/list"); + let raw = route_or_not_found(&resolution.selected, &method, params).await?; + let needle = query.to_lowercase(); + let mut matches: Vec = extract_subjects(&raw) + .into_iter() + .filter(|s| s.get("title").and_then(Value::as_str).map(|t| t.to_lowercase().contains(&needle)).unwrap_or(false)) + .cloned() + .collect(); + let total = matches.len() as u64; + if limit > 0 { + matches.truncate(limit as usize); + } + let result = json!({ "subjects": matches, "next_cursor": Value::Null, "total": total }); + if json { + return print_value( + SubjectCallResponse { + kind: kind.to_string(), + verb: "list", + method, + plugin_count: resolution.selected.plugin_count(), + result, + }, + true, + ); + } + render_subject_human("list", kind, &result); + Ok(()) +} + async fn handle_subject_get(args: SubjectGetArgs, project_root: &str, json: bool) -> Result<()> { let kind = resolve_kind(args.kind.as_deref(), project_root, json)?; if args.id.trim().is_empty() {