feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+155 -69
View File
@@ -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",