Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,14 @@ pub const fn http_application_owner_kind(
| ApplicationSurfaceOperation::Analytics
| ApplicationSurfaceOperation::Search
| ApplicationSurfaceOperation::RunAffectedTests
| ApplicationSurfaceOperation::Dashboard => return None,
| ApplicationSurfaceOperation::Dashboard
| ApplicationSurfaceOperation::Status
| ApplicationSurfaceOperation::ActiveProject
| ApplicationSurfaceOperation::RemoteStatus
| ApplicationSurfaceOperation::Runtime
| ApplicationSurfaceOperation::ProjectList
| ApplicationSurfaceOperation::ProjectSearch
| ApplicationSurfaceOperation::ProjectContext => return None,
ApplicationSurfaceOperation::FactStoreCurate
| ApplicationSurfaceOperation::FactStoreAdd
| ApplicationSurfaceOperation::FactStoreSearch
Expand Down
88 changes: 76 additions & 12 deletions crates/tracedecay-cli/src/tool_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,6 @@ const FIRST_TOUCH_STORE_TOOLS: &[&str] = &[
"tracedecay_lcm_expand_query",
];

/// Tools that read the profile's project registry. They need no mounted
/// project: a project, when one is connected, only marks the active listing
/// entry. An explicit `--project` that is not an initialised project therefore
/// routes projectless instead of being refused for a project the read never
/// depended on.
const PROFILE_REGISTRY_TOOLS: &[&str] = &[
"tracedecay_project_list",
"tracedecay_project_search",
"tracedecay_project_context",
];

fn tool_deadline_range_error() -> TraceDecayError {
TraceDecayError::Config {
message: format!(
Expand Down Expand Up @@ -227,6 +216,19 @@ fn run_inner(
)
.await;
}
if operation.is_profile_registry_read() {
let mut tool_args = tool_args;
let dispatch = DaemonToolDispatch::for_tool(
profile,
explicit_project,
tool_name,
&mut tool_args,
);
return dispatch_cli_profile_registry(
profile, operation, tool_args, dispatch, raw_json, deadline,
)
.await;
}
if operation.is_graph_tool() {
let project_path =
DaemonToolDispatch::project_scoped(profile, explicit_project, tool_name)
Expand Down Expand Up @@ -344,6 +346,16 @@ fn run_inner(
)
.await;
}
if let Some(operation) = ApplicationSurfaceOperation::from_tool_name(&def.name)
&& operation.is_profile_registry_read()
{
let dispatch =
DaemonToolDispatch::for_tool(profile, explicit_project, &def.name, &mut tool_args);
return dispatch_cli_profile_registry(
profile, operation, tool_args, dispatch, raw_json, deadline,
)
.await;
}
if let Some(operation) = ApplicationSurfaceOperation::from_tool_name(&def.name)
&& operation.is_graph_tool()
{
Expand Down Expand Up @@ -847,6 +859,51 @@ async fn dispatch_cli_graph_tool(
tool_result_process_outcome(&result.value, tool_name)
}

/// Run one profile registry read through the daemon's profile owner and print
/// the same tool result its MCP call returns. The handshake's project, when
/// the dispatch names one, only marks that project active.
#[hotpath::measure(label = "cli.tool.profile_registry", future = true)]
async fn dispatch_cli_profile_registry(
profile: &ProfileRoot,
operation: ApplicationSurfaceOperation,
tool_args: Value,
dispatch: DaemonToolDispatch,
raw_json: bool,
deadline: Instant,
) -> Result<()> {
let tool_name = operation.mcp_tool_name();
let request_id =
mint_global_request_id(GlobalRequestSurface::Cli).map_err(|_| TraceDecayError::Config {
message: "could not allocate an application surface request id".to_owned(),
})?;
let client =
tracedecay_daemon_identity::invocation_client_for_current(dispatch.handshake(profile)?)?;
let (request_deadline, cancellation) = cli_request_controls(&request_id, deadline)?;
let outcome = tracedecay::mcp::tools::execute_graph_tool_surface(
tracedecay_tool_catalog::BindingSurface::Cli,
operation,
tool_args.clone(),
Some(&client),
Some(request_id),
Some(request_deadline),
Some(cancellation),
)
.await?;
let response_handle_root = cli_response_handle_root(profile, dispatch.project_path.as_deref())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reopening project layout for registry CLI reads

When a profile-registry CLI read names an initialized project whose repository identity marker is malformed or unreadable, the daemon profile owner can successfully answer from the registry, but this new post-response call re-reads that project marker through resolve_persisted_layout and replaces the valid result with a local error. Registry list/search/context are profile-owned and previously rendered projectlessly without a response-handle root—listing rendering still explicitly ignores the root—so these calls should render with no project layout lookup rather than reacquiring an unrelated project authority.

Useful? React with 👍 / 👎.

let mut result = match outcome {
Ok(completion) => tracedecay_mcp::handlers::graph_tool::render_graph_tool(
response_handle_root.as_deref(),
&tool_args,
completion,
)?,
Err(refusal) => refusal.render(response_handle_root.as_deref(), &tool_args)?,
};
account_tool_result(dispatch.project_path.as_deref(), &mut result);
tracedecay_mcp::tool_errors::mark_semantic_tool_error(&mut result);
print_tool_output(&result.value, raw_json);
tool_result_process_outcome(&result.value, tool_name)
}

/// Enrolled project's handle root, or none when that path has no store.
fn cli_response_handle_root(
profile: &ProfileRoot,
Expand Down Expand Up @@ -921,7 +978,14 @@ impl DaemonToolDispatch {
allow_init: false,
};
}
if PROFILE_REGISTRY_TOOLS.contains(&tool_name) {
// Registry reads need no mounted project: a project, when one is
// connected, only marks the active listing entry. An explicit
// `--project` that is not an initialised project therefore routes
// projectless instead of being refused for a project the read never
// depended on.
if ApplicationSurfaceOperation::from_tool_name(tool_name)
.is_some_and(ApplicationSurfaceOperation::is_profile_registry_read)
{
return Self::registry_scoped(profile, explicit_project, tool_name, tool_args);
}
Self::project_scoped(profile, explicit_project, tool_name)
Expand Down
44 changes: 44 additions & 0 deletions crates/tracedecay-cli/src/tool_command/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,3 +1580,47 @@ fn application_surface_rejects_invalid_output_formats() {
));
}
}

/// A `status` wait that ends `timed_out` is the retryable readiness refusal
/// the process exits 75 on, naming the last state; one that cannot reach its
/// state is the non-retryable refusal naming the reason; one that reached
/// exits 0.
#[test]
fn tool_status_exit_follows_the_wait_outcome() {
let status = |wait: Value| {
json!({
"content": [{"type": "text", "text": "## Project Status"}],
"structuredContent": {"wait": wait},
})
};
let refusal = |wait: Value| {
let error = tool_result_process_outcome(&status(wait), "tracedecay_status")
.expect_err("an unreached wait exits non-zero");
error
.project_route_context()
.map(|(code, retryable, detail)| (code.to_owned(), retryable, detail.to_owned()))
.unwrap_or_else(|| panic!("the wait refusal is a typed route error: {error}"))
};

assert_eq!(
refusal(json!({"outcome": "timed_out", "last_state": "warming"})),
(
CODE_INDEX_READINESS_WAIT_TIMED_OUT.to_owned(),
true,
"tracedecay_status wait_for timed out before the index reached the requested \
state; last state: warming"
.to_owned(),
)
);
assert_eq!(
refusal(json!({"outcome": "unavailable", "reason": "scheduler_not_mounted"})),
(
CODE_INDEX_READINESS_WAIT_UNAVAILABLE.to_owned(),
false,
"tracedecay_status wait_for cannot reach the requested state: scheduler_not_mounted"
.to_owned(),
)
);
tool_result_process_outcome(&status(json!({"outcome": "reached"})), "tracedecay_status")
.expect("a reached wait exits 0");
}
Loading
Loading