feat: complete Epic 1 production foundation
This commit is contained in:
@@ -5,11 +5,13 @@ use crank_core::{
|
||||
ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog,
|
||||
};
|
||||
use crank_registry::{
|
||||
AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest,
|
||||
SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery,
|
||||
AgentStateExpectation, AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest,
|
||||
PublishAgentRequest, SaveAgentCatalogConfigRequest, UpdateAgentSummaryRequest, UsageBucket,
|
||||
UsageQuery,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
@@ -17,12 +19,50 @@ use crate::{
|
||||
service::{
|
||||
AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView,
|
||||
CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id,
|
||||
agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, now_string,
|
||||
today_start_utc,
|
||||
},
|
||||
};
|
||||
|
||||
impl AdminService {
|
||||
pub fn agent_state_etag(agent: &AgentSummaryView) -> String {
|
||||
let policy =
|
||||
serde_json::to_string(&agent.tool_selection_policy).unwrap_or_else(|_| "{}".to_owned());
|
||||
let operation_ids = agent.operation_ids.join(",");
|
||||
let material = format!(
|
||||
"agent-state-v2\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{}\0{}\0{}\0{}\0{}",
|
||||
agent.workspace_id,
|
||||
agent.id,
|
||||
agent.slug,
|
||||
agent.display_name,
|
||||
agent.description,
|
||||
agent.updated_at,
|
||||
agent.current_draft_version,
|
||||
agent.status,
|
||||
agent.latest_published_version,
|
||||
agent.catalog_revision,
|
||||
agent.operation_count,
|
||||
operation_ids,
|
||||
policy,
|
||||
agent.key_count
|
||||
);
|
||||
format!("\"{:x}\"", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
|
||||
pub fn agent_state_expectation(
|
||||
agent: &AgentSummaryView,
|
||||
) -> Result<AgentStateExpectation, ApiError> {
|
||||
let updated_at = OffsetDateTime::parse(&agent.updated_at, &Rfc3339)
|
||||
.map_err(|_| ApiError::internal("invalid agent state timestamp"))?;
|
||||
Ok(AgentStateExpectation {
|
||||
status: agent.status,
|
||||
current_draft_version: agent.current_draft_version,
|
||||
latest_published_version: agent.latest_published_version,
|
||||
catalog_revision: agent.catalog_revision,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn preview_tool_search(
|
||||
&self,
|
||||
@@ -110,13 +150,16 @@ impl AdminService {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let workspace = self.get_workspace(workspace_id).await?;
|
||||
let summaries = self.registry.list_agents(workspace_id).await?;
|
||||
let usage_start = today_start_utc()?;
|
||||
let usage_end = now_string()?;
|
||||
let usage = self
|
||||
.registry
|
||||
.list_usage_by_agent(UsageQuery {
|
||||
workspace_id,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: &today_start_utc()?,
|
||||
created_after: &usage_start,
|
||||
created_before: &usage_end,
|
||||
bucket: UsageBucket::Hour,
|
||||
})
|
||||
.await?;
|
||||
@@ -189,6 +232,8 @@ impl AdminService {
|
||||
.iter()
|
||||
.map(|binding| binding.operation_id.as_str().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let usage_start = today_start_utc()?;
|
||||
let usage_end = now_string()?;
|
||||
let usage = self
|
||||
.registry
|
||||
.get_usage_for_agent(
|
||||
@@ -196,7 +241,8 @@ impl AdminService {
|
||||
workspace_id,
|
||||
period: UsagePeriod::Last24Hours,
|
||||
source: None,
|
||||
created_after: &today_start_utc()?,
|
||||
created_after: &usage_start,
|
||||
created_before: &usage_end,
|
||||
bucket: UsageBucket::Hour,
|
||||
},
|
||||
agent_id,
|
||||
@@ -317,6 +363,7 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: UpdateAgentPayload,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
@@ -328,6 +375,20 @@ impl AdminService {
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
if existing.status == AgentStatus::Published
|
||||
&& (payload.slug != existing.slug
|
||||
|| payload.display_name != existing.display_name
|
||||
|| payload.description != existing.description)
|
||||
{
|
||||
return Err(ApiError::conflict_with_context(
|
||||
"published Agent summary is immutable; unpublish before editing",
|
||||
json!({
|
||||
"agent_id": agent_id.as_str(),
|
||||
"error_code": "agent_published_summary_immutable",
|
||||
"recovery": "unpublish_edit_publish"
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if payload.slug != existing.slug
|
||||
&& self
|
||||
@@ -346,10 +407,13 @@ impl AdminService {
|
||||
.update_agent_summary(
|
||||
workspace_id,
|
||||
agent_id,
|
||||
&payload.slug,
|
||||
&payload.display_name,
|
||||
&payload.description,
|
||||
&updated_at,
|
||||
UpdateAgentSummaryRequest {
|
||||
slug: &payload.slug,
|
||||
display_name: &payload.display_name,
|
||||
description: &payload.description,
|
||||
updated_at: &updated_at,
|
||||
expected_state,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -365,6 +429,7 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
let existing = self
|
||||
.registry
|
||||
@@ -376,8 +441,20 @@ impl AdminService {
|
||||
json!({ "agent_id": agent_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
if existing.latest_published_version.is_some() {
|
||||
return Err(ApiError::conflict_with_context(
|
||||
"published Agent cannot be deleted; archive or unpublish it instead",
|
||||
json!({
|
||||
"agent_id": agent_id.as_str(),
|
||||
"error_code": "agent_delete_forbidden",
|
||||
"recovery": "archive_or_unpublish"
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
self.registry.delete_agent(workspace_id, agent_id).await?;
|
||||
self.registry
|
||||
.delete_agent(workspace_id, agent_id, expected_state)
|
||||
.await?;
|
||||
|
||||
Ok(AgentMutationResult {
|
||||
agent_id: agent_id.as_str().to_owned(),
|
||||
@@ -392,6 +469,7 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
payload: AgentCatalogPayload,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentVersionRecord, ApiError> {
|
||||
let current_version = self
|
||||
.ensure_editable_agent_version(workspace_id, agent_id)
|
||||
@@ -408,6 +486,8 @@ impl AdminService {
|
||||
enabled: binding.enabled,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.validate_exact_published_agent_bindings(workspace_id, &bindings)
|
||||
.await?;
|
||||
let tool_selection_policy = requested_policy
|
||||
.unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone());
|
||||
validate_tool_selection_policy(&tool_selection_policy, &bindings)?;
|
||||
@@ -419,6 +499,7 @@ impl AdminService {
|
||||
agent_version: current_version.version,
|
||||
bindings: &bindings,
|
||||
tool_selection_policy: &tool_selection_policy,
|
||||
expected_state,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
@@ -474,19 +555,23 @@ impl AdminService {
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
version: u32,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<PublishAgentResponse, ApiError> {
|
||||
let agent_version = self
|
||||
.get_agent_version(workspace_id, agent_id, version)
|
||||
.await?;
|
||||
let published_bindings = self
|
||||
.published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||
.await?;
|
||||
validate_tool_selection_policy(
|
||||
&agent_version.snapshot.tool_selection_policy,
|
||||
&published_bindings,
|
||||
&agent_version.bindings,
|
||||
)?;
|
||||
|
||||
if published_bindings.is_empty() {
|
||||
if agent_version
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.enabled)
|
||||
.count()
|
||||
== 0
|
||||
{
|
||||
return Err(ApiError::conflict_with_context(
|
||||
"agent cannot be published without published enabled tools",
|
||||
json!({
|
||||
@@ -497,36 +582,10 @@ impl AdminService {
|
||||
));
|
||||
}
|
||||
|
||||
self.validate_exact_published_agent_bindings(workspace_id, &agent_version.bindings)
|
||||
.await?;
|
||||
|
||||
let published_at = OffsetDateTime::now_utc();
|
||||
if published_bindings != agent_version.bindings {
|
||||
let draft_version = AgentVersion {
|
||||
agent_id: agent_id.clone(),
|
||||
version: agent_version.version + 1,
|
||||
status: AgentStatus::Draft,
|
||||
instructions: agent_version.snapshot.instructions.clone(),
|
||||
tool_selection_policy: agent_version.snapshot.tool_selection_policy.clone(),
|
||||
created_at: published_at,
|
||||
};
|
||||
|
||||
self.registry
|
||||
.create_agent_draft_version(CreateAgentDraftVersionRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
version: &draft_version,
|
||||
bindings: &agent_version.bindings,
|
||||
updated_at: &published_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.registry
|
||||
.save_agent_bindings(SaveAgentBindingsRequest {
|
||||
workspace_id,
|
||||
agent_id,
|
||||
agent_version: agent_version.version,
|
||||
bindings: &published_bindings,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
@@ -535,6 +594,7 @@ impl AdminService {
|
||||
version,
|
||||
published_at: &published_at,
|
||||
published_by: None,
|
||||
expected_state,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
@@ -552,41 +612,65 @@ impl AdminService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn published_agent_bindings(
|
||||
async fn validate_exact_published_agent_bindings(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
bindings: &[AgentOperationBinding],
|
||||
) -> Result<Vec<AgentOperationBinding>, ApiError> {
|
||||
let mut published = Vec::new();
|
||||
|
||||
) -> Result<(), ApiError> {
|
||||
for binding in bindings {
|
||||
if !binding.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(summary) = self
|
||||
.registry
|
||||
.get_operation_summary(workspace_id, &binding.operation_id)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
return Err(ApiError::not_found_with_context(
|
||||
"operation was not found for Agent binding",
|
||||
json!({
|
||||
"error_code": "agent_binding_scope_denied",
|
||||
"operation_id": binding.operation_id.as_str()
|
||||
}),
|
||||
));
|
||||
};
|
||||
if summary.status == crank_core::OperationStatus::Archived {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"archived operation cannot be added to an Agent binding",
|
||||
json!({
|
||||
"error_code": "agent_binding_archived_operation",
|
||||
"operation_id": binding.operation_id.as_str()
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let Some(operation_version) = summary.latest_published_version else {
|
||||
continue;
|
||||
let Some(version) = self
|
||||
.registry
|
||||
.get_operation_version(
|
||||
workspace_id,
|
||||
&binding.operation_id,
|
||||
binding.operation_version,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation version is not published for Agent binding",
|
||||
json!({
|
||||
"error_code": "agent_binding_not_published",
|
||||
"operation_id": binding.operation_id.as_str(),
|
||||
"operation_version": binding.operation_version
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
published.push(AgentOperationBinding {
|
||||
operation_id: summary.id,
|
||||
operation_version,
|
||||
tool_name: binding.tool_name.clone(),
|
||||
tool_title: binding.tool_title.clone(),
|
||||
tool_description_override: binding.tool_description_override.clone(),
|
||||
enabled: true,
|
||||
});
|
||||
if !version.snapshot.is_published() {
|
||||
return Err(ApiError::unprocessable_with_context(
|
||||
"operation version is not published for Agent binding",
|
||||
json!({
|
||||
"error_code": "agent_binding_not_published",
|
||||
"operation_id": binding.operation_id.as_str(),
|
||||
"operation_version": binding.operation_version
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(published)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
|
||||
@@ -594,11 +678,12 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.unpublish_agent(workspace_id, agent_id, &updated_at)
|
||||
.unpublish_agent(workspace_id, agent_id, &updated_at, expected_state)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.unpublished",
|
||||
@@ -618,11 +703,12 @@ impl AdminService {
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
agent_id: &AgentId,
|
||||
expected_state: Option<&AgentStateExpectation>,
|
||||
) -> Result<AgentMutationResult, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.archive_agent(workspace_id, agent_id, &updated_at)
|
||||
.archive_agent(workspace_id, agent_id, &updated_at, expected_state)
|
||||
.await?;
|
||||
info!(
|
||||
name: "admin.agent.archived",
|
||||
|
||||
Reference in New Issue
Block a user