use aion_core::{RunId, WorkflowId, WorkflowSummary};
use aion_mcp::tasks::resolve::{TaskProjection, TaskResolveError, TaskState};
use aion_mcp::tools::service::{ToolCall, ToolOutcome};
use serde_json::Value;
use uuid::Uuid;
use crate::{CallerIdentity, ServerState};
use super::tools::run_view::RunView;
use super::tools::start::{START_RUN, awaited_outcome, awaits_completion};
const HANDLE_PREFIX: &str = "run.v1";
const RUNNING_MESSAGE: &str = "the run is still going; cancelling this task does not stop it — \
use the `cancel` tool to stop the run";
const ENDED_MESSAGE: &str = "the run has ended; its terminal status is in the result";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TaskRef {
pub(crate) namespace: String,
pub(crate) workflow_id: WorkflowId,
pub(crate) run_id: RunId,
}
impl TaskRef {
pub(crate) fn to_handle(&self) -> String {
format!(
"{HANDLE_PREFIX}:{}:{}:{}",
self.workflow_id, self.run_id, self.namespace
)
}
pub(crate) fn parse(handle: &str) -> Result<Self, TaskResolveError> {
let malformed = |detail: String| TaskResolveError::Malformed { detail };
let mut parts = handle.splitn(4, ':');
let (Some(prefix), Some(workflow), Some(run), Some(namespace)) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(malformed(format!(
"`{handle}` is not a task id this server issued; a task id comes from an awaited \
start_run and is never constructed"
)));
};
if prefix != HANDLE_PREFIX {
return Err(malformed(format!(
"`{handle}` is not a task id this server issued (unknown handle kind `{prefix}`)"
)));
}
if namespace.is_empty() {
return Err(malformed(format!("`{handle}` names no namespace")));
}
Ok(Self {
workflow_id: Uuid::parse_str(workflow)
.map(WorkflowId::new)
.map_err(|error| {
malformed(format!("`{handle}` does not name a workflow: {error}"))
})?,
run_id: Uuid::parse_str(run)
.map(RunId::new)
.map_err(|error| malformed(format!("`{handle}` does not name a run: {error}")))?,
namespace: namespace.to_owned(),
})
}
}
pub(crate) fn task_handle(call: &ToolCall, outcome: &ToolOutcome) -> Option<String> {
if !awaits_completion(call) {
return None;
}
let field = |key: &str| outcome.structured.get(key).and_then(Value::as_str);
Some(
TaskRef {
namespace: field("namespace")?.to_owned(),
workflow_id: Uuid::parse_str(field("workflow_id")?)
.map(WorkflowId::new)
.ok()?,
run_id: Uuid::parse_str(field("run_id")?).map(RunId::new).ok()?,
}
.to_handle(),
)
}
pub(crate) async fn resolve_task(
state: &ServerState,
caller: &CallerIdentity,
task_id: &str,
) -> Result<TaskProjection, TaskResolveError> {
let reference = TaskRef::parse(task_id)?;
let view = RunView::read_parts(
state,
caller,
&reference.namespace,
&reference.workflow_id,
Some(&reference.run_id),
)
.await
.map_err(|_refusal| TaskResolveError::Unknown {
task_id: task_id.to_owned(),
})?;
let summary = WorkflowSummary::from_history(view.run_segment()).ok_or_else(|| {
TaskResolveError::Backend {
detail: format!(
"run {} of workflow {} has no recorded start event",
reference.run_id, reference.workflow_id
),
}
})?;
let ended = summary.status.is_terminal();
Ok(TaskProjection {
state: if ended {
TaskState::Finished {
tool_name: START_RUN.to_owned(),
outcome: awaited_outcome(&reference.namespace, &reference.run_id, &summary),
}
} else {
TaskState::Working
},
status_message: Some(
if ended {
ENDED_MESSAGE
} else {
RUNNING_MESSAGE
}
.to_owned(),
),
created_at: summary.started_at,
last_updated_at: summary.ended_at.unwrap_or(summary.started_at),
})
}
#[cfg(test)]
mod tests {
use aion_core::{RunId, WorkflowId};
use aion_mcp::tools::service::{ToolCall, ToolOutcome};
use serde_json::{Map, Value, json};
use uuid::Uuid;
use super::{TaskRef, task_handle};
fn outcome(structured: Value) -> ToolOutcome {
ToolOutcome {
structured,
summary: "started".to_owned(),
}
}
fn call(arguments: &Value) -> Result<ToolCall, serde_json::Error> {
Ok(ToolCall {
name: "start_run".to_owned(),
arguments: serde_json::from_value::<Map<String, Value>>(arguments.clone())?,
})
}
#[test]
fn a_handle_round_trips_through_a_namespace_containing_colons()
-> Result<(), Box<dyn std::error::Error>> {
let reference = TaskRef {
namespace: "tenant:eu:prod".to_owned(),
workflow_id: WorkflowId::new(Uuid::parse_str("5f1d2b90-1d3a-4a1e-9c2b-000000000001")?),
run_id: RunId::new(Uuid::parse_str("5f1d2b90-1d3a-4a1e-9c2b-000000000002")?),
};
let handle = reference.to_handle();
assert_eq!(TaskRef::parse(&handle)?, reference);
Ok(())
}
#[test]
fn a_string_this_server_never_issued_is_malformed_not_unknown() {
for bogus in [
"no-such-task",
"run.v2:a:b:c",
"run.v1:not-a-uuid:also-not:ns",
"run.v1:5f1d2b90-1d3a-4a1e-9c2b-000000000001:5f1d2b90-1d3a-4a1e-9c2b-000000000002:",
"",
] {
let error = TaskRef::parse(bogus).err();
assert!(
matches!(
error,
Some(aion_mcp::tasks::resolve::TaskResolveError::Malformed { .. })
),
"{bogus} must be refused as malformed, got {error:?}"
);
}
}
#[test]
fn only_an_awaited_start_mints_a_handle() -> Result<(), Box<dyn std::error::Error>> {
let structured = json!({
"namespace": "ops",
"workflow_id": "5f1d2b90-1d3a-4a1e-9c2b-000000000001",
"run_id": "5f1d2b90-1d3a-4a1e-9c2b-000000000002",
});
assert_eq!(
task_handle(&call(&json!({}))?, &outcome(structured.clone())),
None
);
let handle = task_handle(
&call(&json!({ "await_completion": true }))?,
&outcome(structured),
)
.ok_or("an awaited start must mint a handle")?;
let reference = TaskRef::parse(&handle)?;
assert_eq!(reference.namespace, "ops");
Ok(())
}
#[test]
fn an_outcome_without_run_handles_mints_nothing() -> Result<(), serde_json::Error> {
let awaited = call(&json!({ "await_completion": true }))?;
assert_eq!(
task_handle(&awaited, &outcome(json!({ "namespace": "ops" }))),
None
);
Ok(())
}
}