fix: restore yaml import and async job result contracts
This commit is contained in:
@@ -18,3 +18,4 @@ apps/ui/vite.config.js
|
|||||||
apps/ui/vite.config.d.ts
|
apps/ui/vite.config.d.ts
|
||||||
*.log
|
*.log
|
||||||
__*.md
|
__*.md
|
||||||
|
diplom.docx
|
||||||
|
|||||||
@@ -1940,6 +1940,63 @@ mod tests {
|
|||||||
assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0);
|
assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn allows_completed_async_job_result_fetch_without_poll_delay() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let storage_root = test_storage_root("async_job_completed_result");
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
|
||||||
|
let client = authorized_client(&base_url).await;
|
||||||
|
|
||||||
|
let created = client
|
||||||
|
.post(format!("{base_url}/operations"))
|
||||||
|
.json(&test_rest_async_job_operation_payload(
|
||||||
|
&upstream_base_url,
|
||||||
|
"crm_async_job_completed_result",
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned());
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let job_id = AsyncJobId::new("job_completed_result".to_owned());
|
||||||
|
registry
|
||||||
|
.create_async_job(CreateAsyncJobRequest {
|
||||||
|
job: &AsyncJobHandle {
|
||||||
|
id: job_id.clone(),
|
||||||
|
workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||||
|
agent_id: None,
|
||||||
|
operation_id,
|
||||||
|
status: JobStatus::Completed,
|
||||||
|
progress: json!({ "pct": 100 }),
|
||||||
|
result: Some(json!({ "id": "lead_123" })),
|
||||||
|
error: None,
|
||||||
|
expires_at: Some(now + time::Duration::minutes(5)),
|
||||||
|
last_poll_at: Some(now),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
finished_at: Some(now),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{base_url}/async-jobs/{}/result", job_id.as_str()))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.json::<Value>().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status, reqwest::StatusCode::OK);
|
||||||
|
assert_eq!(body["id"], "lead_123");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn returns_structured_context_for_missing_stream_session() {
|
async fn returns_structured_context_for_missing_stream_session() {
|
||||||
|
|||||||
@@ -1719,9 +1719,12 @@ impl AdminService {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
ensure_async_job_workspace(&job, workspace_id)?;
|
ensure_async_job_workspace(&job, workspace_id)?;
|
||||||
let job = self
|
let job = if matches!(job.status, JobStatus::Completed) {
|
||||||
.touch_async_job_poll_if_ready(workspace_id, job)
|
job
|
||||||
.await?;
|
} else {
|
||||||
|
self.touch_async_job_poll_if_ready(workspace_id, job)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
match job.status {
|
match job.status {
|
||||||
JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)),
|
JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)),
|
||||||
|
|||||||
+50
-42
@@ -1416,28 +1416,32 @@ async fn handle_async_job_status_call(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
let job = if matches!(job.status, JobStatus::Completed) {
|
||||||
let poll_after_ms = streaming.poll_interval_ms.unwrap_or(1_000);
|
job
|
||||||
let remaining_delay_ms = job.remaining_poll_delay_ms(now, poll_after_ms);
|
} else {
|
||||||
if remaining_delay_ms > 0 {
|
let now = OffsetDateTime::now_utc();
|
||||||
return tool_error_response(
|
let poll_after_ms = streaming.poll_interval_ms.unwrap_or(1_000);
|
||||||
message,
|
let remaining_delay_ms = job.remaining_poll_delay_ms(now, poll_after_ms);
|
||||||
response_mode,
|
if remaining_delay_ms > 0 {
|
||||||
&session.protocol_version,
|
return tool_error_response(
|
||||||
"async_job_poll_rate_limited",
|
message,
|
||||||
format!(
|
response_mode,
|
||||||
"async job {} must wait before the next poll",
|
&session.protocol_version,
|
||||||
control.job_id
|
"async_job_poll_rate_limited",
|
||||||
),
|
format!(
|
||||||
Some(json!({
|
"async job {} must wait before the next poll",
|
||||||
"poll_after_ms": remaining_delay_ms,
|
control.job_id
|
||||||
})),
|
),
|
||||||
);
|
Some(json!({
|
||||||
}
|
"poll_after_ms": remaining_delay_ms,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let job = match state.registry.touch_async_job_poll(&job.id, &now).await {
|
match state.registry.touch_async_job_poll(&job.id, &now).await {
|
||||||
Ok(job) => job,
|
Ok(job) => job,
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
success_tool_response(
|
success_tool_response(
|
||||||
@@ -1516,28 +1520,32 @@ async fn handle_async_job_result_call(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
let job = if matches!(job.status, JobStatus::Completed) {
|
||||||
let poll_after_ms = streaming.poll_interval_ms.unwrap_or(1_000);
|
job
|
||||||
let remaining_delay_ms = job.remaining_poll_delay_ms(now, poll_after_ms);
|
} else {
|
||||||
if remaining_delay_ms > 0 {
|
let now = OffsetDateTime::now_utc();
|
||||||
return tool_error_response(
|
let poll_after_ms = streaming.poll_interval_ms.unwrap_or(1_000);
|
||||||
message,
|
let remaining_delay_ms = job.remaining_poll_delay_ms(now, poll_after_ms);
|
||||||
response_mode,
|
if remaining_delay_ms > 0 {
|
||||||
&session.protocol_version,
|
return tool_error_response(
|
||||||
"async_job_poll_rate_limited",
|
message,
|
||||||
format!(
|
response_mode,
|
||||||
"async job {} must wait before the next poll",
|
&session.protocol_version,
|
||||||
control.job_id
|
"async_job_poll_rate_limited",
|
||||||
),
|
format!(
|
||||||
Some(json!({
|
"async job {} must wait before the next poll",
|
||||||
"poll_after_ms": remaining_delay_ms,
|
control.job_id
|
||||||
})),
|
),
|
||||||
);
|
Some(json!({
|
||||||
}
|
"poll_after_ms": remaining_delay_ms,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let job = match state.registry.touch_async_job_poll(&job.id, &now).await {
|
match state.registry.touch_async_job_poll(&job.id, &now).await {
|
||||||
Ok(job) => job,
|
Ok(job) => job,
|
||||||
Err(error) => return internal_jsonrpc_error(message, error),
|
Err(error) => return internal_jsonrpc_error(message, error),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match job.status {
|
match job.status {
|
||||||
|
|||||||
+117
-31
@@ -135,15 +135,16 @@ mod tests {
|
|||||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
|
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
|
||||||
DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget,
|
AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionConfig, ExecutionMode,
|
||||||
GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey,
|
GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, JobStatus, Operation,
|
||||||
PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget,
|
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||||
StreamingConfig, Target, ToolDescription, ToolFamilyConfig, TransportBehavior, WorkspaceId,
|
PlatformApiKeyStatus, Protocol, RestTarget, StreamingConfig, Target, ToolDescription,
|
||||||
|
ToolFamilyConfig, TransportBehavior, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
|
CreateAgentRequest, CreateAsyncJobRequest, CreatePlatformApiKeyRequest,
|
||||||
PublishAgentRequest, PublishRequest,
|
ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest,
|
||||||
};
|
};
|
||||||
use crank_runtime::{
|
use crank_runtime::{
|
||||||
RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor, SecretCrypto,
|
RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor, SecretCrypto,
|
||||||
@@ -1759,6 +1760,29 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let job_id = AsyncJobId::new("job_async_rate".to_owned());
|
||||||
|
registry
|
||||||
|
.create_async_job(CreateAsyncJobRequest {
|
||||||
|
job: &AsyncJobHandle {
|
||||||
|
id: job_id.clone(),
|
||||||
|
workspace_id: test_workspace_id(),
|
||||||
|
agent_id: Some(AgentId::new("agent_sales-async-rate")),
|
||||||
|
operation_id: operation.id.clone(),
|
||||||
|
status: JobStatus::Running,
|
||||||
|
progress: json!({ "pct": 25 }),
|
||||||
|
result: None,
|
||||||
|
error: None,
|
||||||
|
expires_at: Some(now + time::Duration::minutes(5)),
|
||||||
|
last_poll_at: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
finished_at: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let base_url = spawn_mcp_server(build_test_app(
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
registry,
|
registry,
|
||||||
Duration::from_millis(0),
|
Duration::from_millis(0),
|
||||||
@@ -1769,29 +1793,6 @@ mod tests {
|
|||||||
let mcp_url = agent_mcp_url(&base_url, "sales-async-rate");
|
let mcp_url = agent_mcp_url(&base_url, "sales-async-rate");
|
||||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||||
|
|
||||||
let start_response = post_jsonrpc(
|
|
||||||
&client,
|
|
||||||
&mcp_url,
|
|
||||||
&api_key,
|
|
||||||
Some(&initialized_session),
|
|
||||||
json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"method": "tools/call",
|
|
||||||
"params": {
|
|
||||||
"name": "crm_async_rate_start",
|
|
||||||
"arguments": {
|
|
||||||
"email": "user@example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let job_id = start_response["result"]["structuredContent"]["job_id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
let first_status = post_jsonrpc(
|
let first_status = post_jsonrpc(
|
||||||
&client,
|
&client,
|
||||||
&mcp_url,
|
&mcp_url,
|
||||||
@@ -1804,7 +1805,7 @@ mod tests {
|
|||||||
"params": {
|
"params": {
|
||||||
"name": "crm_async_rate_status",
|
"name": "crm_async_rate_status",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"job_id": job_id
|
"job_id": job_id.as_str()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -1824,7 +1825,7 @@ mod tests {
|
|||||||
"params": {
|
"params": {
|
||||||
"name": "crm_async_rate_status",
|
"name": "crm_async_rate_status",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"job_id": job_id
|
"job_id": job_id.as_str()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -1842,6 +1843,91 @@ mod tests {
|
|||||||
assert!((1..=250).contains(&poll_after_ms));
|
assert!((1..=250).contains(&poll_after_ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn allows_completed_async_job_result_without_poll_delay() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let upstream_base_url = spawn_upstream_server().await;
|
||||||
|
let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_completed");
|
||||||
|
|
||||||
|
registry
|
||||||
|
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.publish_operation(PublishRequest {
|
||||||
|
workspace_id: &test_workspace_id(),
|
||||||
|
operation_id: &operation.id,
|
||||||
|
version: 1,
|
||||||
|
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
|
||||||
|
published_by: Some("alice"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
publish_agent_for_operation(®istry, &operation, "sales-async-completed").await;
|
||||||
|
let api_key = create_platform_api_key(
|
||||||
|
®istry,
|
||||||
|
"mcp-async-completed",
|
||||||
|
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let job_id = AsyncJobId::new("job_async_completed".to_owned());
|
||||||
|
registry
|
||||||
|
.create_async_job(CreateAsyncJobRequest {
|
||||||
|
job: &AsyncJobHandle {
|
||||||
|
id: job_id.clone(),
|
||||||
|
workspace_id: test_workspace_id(),
|
||||||
|
agent_id: Some(AgentId::new("agent_sales-async-completed")),
|
||||||
|
operation_id: operation.id.clone(),
|
||||||
|
status: JobStatus::Completed,
|
||||||
|
progress: json!({ "pct": 100 }),
|
||||||
|
result: Some(json!({ "id": "lead_123" })),
|
||||||
|
error: None,
|
||||||
|
expires_at: Some(now + time::Duration::minutes(5)),
|
||||||
|
last_poll_at: Some(now),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
finished_at: Some(now),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let base_url = spawn_mcp_server(build_test_app(
|
||||||
|
registry,
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://crank.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mcp_url = agent_mcp_url(&base_url, "sales-async-completed");
|
||||||
|
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||||
|
|
||||||
|
let result_response = post_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&mcp_url,
|
||||||
|
&api_key,
|
||||||
|
Some(&initialized_session),
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "crm_async_completed_result",
|
||||||
|
"arguments": {
|
||||||
|
"job_id": job_id.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
result_response["result"]["structuredContent"]["id"],
|
||||||
|
"lead_123"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_rapid_initialize_requests_with_429() {
|
async fn rejects_rapid_initialize_requests_with_429() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub struct Agent {
|
|||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub updated_at: OffsetDateTime,
|
pub updated_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339::option")]
|
#[serde(default, with = "time::serde::rfc3339::option")]
|
||||||
pub published_at: Option<OffsetDateTime>,
|
pub published_at: Option<OffsetDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ pub struct Operation<TSchema, TMapping> {
|
|||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub updated_at: OffsetDateTime,
|
pub updated_at: OffsetDateTime,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(with = "time::serde::rfc3339::option")]
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
pub published_at: Option<OffsetDateTime>,
|
pub published_at: Option<OffsetDateTime>,
|
||||||
}
|
}
|
||||||
@@ -517,4 +517,46 @@ mod tests {
|
|||||||
assert!(yaml.contains("export_mode: portable"));
|
assert!(yaml.contains("export_mode: portable"));
|
||||||
assert_eq!(restored, operation);
|
assert_eq!(restored, operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_yaml_deserializes_without_optional_published_at() {
|
||||||
|
let yaml = r#"
|
||||||
|
id: op_01
|
||||||
|
name: crm_create_lead
|
||||||
|
display_name: Create Lead
|
||||||
|
category: sales
|
||||||
|
protocol: rest
|
||||||
|
status: draft
|
||||||
|
version: 1
|
||||||
|
target:
|
||||||
|
kind: rest
|
||||||
|
base_url: https://api.example.com
|
||||||
|
method: POST
|
||||||
|
path_template: /v1/leads
|
||||||
|
static_headers: {}
|
||||||
|
input_schema:
|
||||||
|
type: object
|
||||||
|
output_schema:
|
||||||
|
type: object
|
||||||
|
input_mapping:
|
||||||
|
rules: []
|
||||||
|
output_mapping:
|
||||||
|
rules: []
|
||||||
|
execution_config:
|
||||||
|
timeout_ms: 10000
|
||||||
|
headers: {}
|
||||||
|
tool_description:
|
||||||
|
title: Create CRM lead
|
||||||
|
description: Creates a new lead.
|
||||||
|
tags: []
|
||||||
|
examples: []
|
||||||
|
created_at: 2026-03-25T08:00:00Z
|
||||||
|
updated_at: 2026-03-25T08:10:00Z
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let restored: Operation<serde_json::Value, serde_json::Value> =
|
||||||
|
serde_yaml::from_str(yaml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(restored.published_at, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user