From 97ea969a29931cf232cea828b6d40387e4934742 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Tue, 31 Mar 2026 15:30:33 +0300 Subject: [PATCH] feat: add agent lifecycle transitions --- TASKS.md | 14 ++- apps/admin-api/src/app.rs | 120 +++++++++++++++++++++++++- apps/admin-api/src/routes/agents.rs | 28 ++++++ apps/admin-api/src/service.rs | 40 +++++++++ apps/ui/css/pages.css | 6 +- apps/ui/html/agents.html | 19 +++- apps/ui/js/agents.js | 61 +++++++++++-- apps/ui/js/api.js | 6 ++ crates/crank-registry/src/postgres.rs | 106 +++++++++++++++++++++++ docs/admin-api.md | 2 + docs/alpine-ui-integration-plan.md | 6 +- 11 files changed, 382 insertions(+), 26 deletions(-) diff --git a/TASKS.md b/TASKS.md index d932a94..6042259 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,22 +2,20 @@ ## Current -### `feat/demo-assets` +### `feat/agent-lifecycle-polish` Status: in_progress DoD: -- project has an idempotent PostgreSQL demo seed for live UI screens -- demo seed fills workspaces, memberships, invitations, operations, agents, keys and usage -- seed is controlled by env and does not live in migrations -- docs explain how to enable and use demo data +- `feat/agent-lifecycle-polish` +- agents support explicit `publish`, `unpublish`, and `archive` lifecycle transitions +- agents page reflects real backend lifecycle states without legacy `active/inactive` mapping +- agent cards and editor can move between draft/published/archived without destructive hacks ## Next -- `feat/alpine-polish` +- `feat/platform-key-usage` ## Backlog -- `feat/current-workspace-session-model` -- `feat/agent-lifecycle-polish` - `feat/platform-key-usage` diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 97d3952..e81cce2 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -12,8 +12,8 @@ use crate::{ list_memberships, list_platform_api_keys, revoke_platform_api_key, update_membership, }, agents::{ - create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent, - save_agent_bindings, update_agent, + archive_agent, create_agent, delete_agent, get_agent, get_agent_version, list_agents, + publish_agent, save_agent_bindings, unpublish_agent, update_agent, }, auth::{change_password, get_profile, get_session, login, logout, update_profile}, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, @@ -89,6 +89,8 @@ pub fn build_app(state: AppState) -> Router { ) .route("/agents/{agent_id}/bindings", post(save_agent_bindings)) .route("/agents/{agent_id}/publish", post(publish_agent)) + .route("/agents/{agent_id}/unpublish", post(unpublish_agent)) + .route("/agents/{agent_id}/archive", post(archive_agent)) .route( "/auth-profiles", get(list_auth_profiles).post(create_auth_profile), @@ -665,6 +667,120 @@ mod tests { assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn unpublishes_and_archives_agent() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_statuses"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_create_lead_agent_status", + )) + .send() + .await + .unwrap(), + ) + .await; + let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let created = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "sales-routing", + "display_name": "Sales Routing", + "description": "Routing agent", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = created["agent_id"].as_str().unwrap().to_owned(); + + client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .json(&json!([ + { + "operation_id": operation_id, + "operation_version": 1, + "tool_name": "crm_create_lead_agent_status", + "tool_title": "Create Lead", + "tool_description_override": null, + "enabled": true + } + ])) + .send() + .await + .unwrap(); + + client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap(); + + let unpublished = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/unpublish")) + .json(&json!({})) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(unpublished["agent_id"], agent_id); + + let draft_detail = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(draft_detail["status"], "draft"); + assert_eq!(draft_detail["latest_published_version"], 1); + + let archived = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/archive")) + .json(&json!({})) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(archived["agent_id"], agent_id); + + let archived_detail = assert_success_json( + client + .get(format!("{base_url}/agents/{agent_id}")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(archived_detail["status"], "archived"); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_platform_access_resources() { diff --git a/apps/admin-api/src/routes/agents.rs b/apps/admin-api/src/routes/agents.rs index c9ac4b5..f27975c 100644 --- a/apps/admin-api/src/routes/agents.rs +++ b/apps/admin-api/src/routes/agents.rs @@ -142,3 +142,31 @@ pub async fn publish_agent( .await?; Ok(Json(json!(published))) } + +pub async fn unpublish_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let updated = state + .service + .unpublish_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn archive_agent( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let updated = state + .service + .archive_agent( + &path.workspace_id.as_str().into(), + &path.agent_id.as_str().into(), + ) + .await?; + Ok(Json(json!(updated))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index d782b03..db01df1 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1971,6 +1971,46 @@ impl AdminService { }) } + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] + pub async fn unpublish_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let updated_at = now_string()?; + self.registry + .unpublish_agent(workspace_id, agent_id, &updated_at) + .await?; + info!(agent_id = %agent_id.as_str(), "agent moved to draft"); + + Ok(AgentMutationResult { + agent_id: agent_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + updated_at, + }) + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))] + pub async fn archive_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let updated_at = now_string()?; + self.registry + .archive_agent(workspace_id, agent_id, &updated_at) + .await?; + info!(agent_id = %agent_id.as_str(), "agent archived"); + + Ok(AgentMutationResult { + agent_id: agent_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + updated_at, + }) + } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("descriptor-set.bin")))] pub async fn upload_descriptor_set( &self, diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index 1fb23f3..c404bfd 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -980,9 +980,9 @@ white-space: nowrap; flex-shrink: 0; } -.agent-status-active { background: rgba(63,185,80,0.15); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); } -.agent-status-draft { background: rgba(139,148,158,0.15); color: #8b949e; border: 1px solid rgba(139,148,158,0.3); } -.agent-status-inactive{ background: rgba(248,81,73,0.1); color: #f85149; border: 1px solid rgba(248,81,73,0.2); } +.agent-status-published { background: rgba(63,185,80,0.15); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); } +.agent-status-draft { background: rgba(139,148,158,0.15); color: #8b949e; border: 1px solid rgba(139,148,158,0.3); } +.agent-status-archived { background: rgba(210,153,34,0.15); color: #d29922; border: 1px solid rgba(210,153,34,0.3); } .agent-card-desc { font-size: 13px; diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index 00adaa0..5460717 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -116,7 +116,7 @@
across this workspace
-
Active
+
Published
@@ -200,7 +200,7 @@
- + @@ -242,6 +242,14 @@ Edit + + + diff --git a/apps/ui/js/agents.js b/apps/ui/js/agents.js index 81588e2..edc36da 100644 --- a/apps/ui/js/agents.js +++ b/apps/ui/js/agents.js @@ -1,6 +1,6 @@ function agentUiStatus(status) { - if (status === 'published') return 'active'; - if (status === 'archived') return 'inactive'; + if (status === 'published') return 'published'; + if (status === 'archived') return 'archived'; return status || 'draft'; } @@ -55,7 +55,7 @@ document.addEventListener('alpine:init', function() { display_name: '', slug: '', description: '', - status: 'active', + status: 'published', selectedOps: [], }, @@ -132,7 +132,7 @@ document.addEventListener('alpine:init', function() { get activeCount() { return this.agents.filter(function(agent) { - return agent.status === 'active'; + return agent.status === 'published'; }).length; }, @@ -158,7 +158,7 @@ document.addEventListener('alpine:init', function() { display_name: '', slug: '', description: '', - status: 'active', + status: 'published', selectedOps: [], }; this.opSearch = ''; @@ -173,7 +173,7 @@ document.addEventListener('alpine:init', function() { display_name: agent.display_name, slug: agent.slug, description: agent.description, - status: agent.status === 'active' ? 'active' : 'draft', + status: agent.raw_status || agent.status || 'draft', selectedOps: [].concat(agent.operation_ids || []), }; this.opSearch = ''; @@ -230,6 +230,10 @@ document.addEventListener('alpine:init', function() { try { var agentId = this.editingId; var currentVersion = 1; + var existingAgent = this.drawerMode === 'edit' + ? this.agents.find(function(item) { return item.id === agentId; }) + : null; + var previousStatus = existingAgent ? (existingAgent.raw_status || 'draft') : 'draft'; if (this.drawerMode === 'create') { var created = await window.CrankApi.createAgent(this.workspaceId, { @@ -273,10 +277,14 @@ document.addEventListener('alpine:init', function() { }), ); - if (this.form.status === 'active') { + if (this.form.status === 'published') { await window.CrankApi.publishAgent(this.workspaceId, agentId, { version: currentVersion, }); + } else if (this.form.status === 'archived') { + await window.CrankApi.archiveAgent(this.workspaceId, agentId); + } else if (this.drawerMode === 'edit' && previousStatus !== 'draft') { + await window.CrankApi.unpublishAgent(this.workspaceId, agentId); } await this.reload(); @@ -298,6 +306,45 @@ document.addEventListener('alpine:init', function() { } }, + async applyLifecycle(agent, action) { + if (!this.workspaceId || !window.CrankApi) { + return; + } + + try { + if (action === 'publish') { + await window.CrankApi.publishAgent(this.workspaceId, agent.id, { + version: agent.current_draft_version || 1, + }); + } else if (action === 'unpublish') { + await window.CrankApi.unpublishAgent(this.workspaceId, agent.id); + } else if (action === 'archive') { + await window.CrankApi.archiveAgent(this.workspaceId, agent.id); + } + await this.reload(); + } catch (error) { + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to update agent lifecycle', 'Lifecycle update failed'); + } + } + }, + + lifecycleAction(agent) { + if (agent.raw_status === 'published') { + return { key: 'unpublish', label: 'Unpublish' }; + } + if (agent.raw_status === 'archived') { + return { key: 'unpublish', label: 'Restore draft' }; + } + return { key: 'publish', label: 'Publish' }; + }, + + lifecycleLabel(agent) { + if (agent.raw_status === 'published') return 'Published'; + if (agent.raw_status === 'archived') return 'Archived'; + return 'Draft'; + }, + mcpEndpoint(agent) { if (agent.mcp_endpoint) return agent.mcp_endpoint; var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 8c2bbfa..85a9ab9 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -298,6 +298,12 @@ publishAgent: function(workspaceId, agentId, payload) { return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload); }, + unpublishAgent: function(workspaceId, agentId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/unpublish', {}); + }, + archiveAgent: function(workspaceId, agentId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {}); + }, listPlatformApiKeys: function(workspaceId) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys'); }, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 9fc38a3..6768d23 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -1511,6 +1511,112 @@ impl PostgresRegistry { Ok(()) } + pub async fn unpublish_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + updated_at: &str, + ) -> Result<(), RegistryError> { + let mut tx = self.pool.begin().await?; + + sqlx::query("delete from published_agents where agent_id = $1") + .bind(agent_id.as_str()) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update agent_versions + set status = $1 + where agent_id = $2 + and version = ( + select current_draft_version + from agents + where id = $2 and workspace_id = $3 + )", + ) + .bind(serialize_enum_text(&AgentStatus::Draft, "status")?) + .bind(agent_id.as_str()) + .bind(workspace_id.as_str()) + .execute(&mut *tx) + .await?; + + let result = sqlx::query( + "update agents + set status = $1, + published_at = null, + updated_at = $2::timestamptz + where id = $3 and workspace_id = $4", + ) + .bind(serialize_enum_text(&AgentStatus::Draft, "status")?) + .bind(updated_at) + .bind(agent_id.as_str()) + .bind(workspace_id.as_str()) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::AgentNotFound { + agent_id: agent_id.as_str().to_owned(), + }); + } + + tx.commit().await?; + Ok(()) + } + + pub async fn archive_agent( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + updated_at: &str, + ) -> Result<(), RegistryError> { + let mut tx = self.pool.begin().await?; + + sqlx::query("delete from published_agents where agent_id = $1") + .bind(agent_id.as_str()) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update agent_versions + set status = $1 + where agent_id = $2 + and version = ( + select current_draft_version + from agents + where id = $2 and workspace_id = $3 + )", + ) + .bind(serialize_enum_text(&AgentStatus::Archived, "status")?) + .bind(agent_id.as_str()) + .bind(workspace_id.as_str()) + .execute(&mut *tx) + .await?; + + let result = sqlx::query( + "update agents + set status = $1, + published_at = null, + updated_at = $2::timestamptz + where id = $3 and workspace_id = $4", + ) + .bind(serialize_enum_text(&AgentStatus::Archived, "status")?) + .bind(updated_at) + .bind(agent_id.as_str()) + .bind(workspace_id.as_str()) + .execute(&mut *tx) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::AgentNotFound { + agent_id: agent_id.as_str().to_owned(), + }); + } + + tx.commit().await?; + Ok(()) + } + pub async fn get_published_agent_tools_by_slug( &self, workspace_slug: &str, diff --git a/docs/admin-api.md b/docs/admin-api.md index 09d59c8..8588bd9 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -130,6 +130,8 @@ - `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions` - `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions/{version}` - `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/unpublish` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive` - `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings` - `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}` diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 61e775c..285c659 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -157,6 +157,8 @@ UI-файлы: - `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}` - `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings` - `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/unpublish` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive` - `GET /api/admin/workspaces/{workspace_id}/usage` Что еще не хватает: @@ -172,9 +174,9 @@ UI-файлы: Простой итог: -- базовый live flow уже закрыт: list/create/edit/delete/bind/publish работают через backend; +- базовый live flow уже закрыт: list/create/edit/delete/bind/publish/unpublish/archive работают через backend; - `GET /agents` уже отдает `calls_today`, `key_count`, `operation_count`, `operation_ids` и `mcp_endpoint`; -- следующий реальный разрыв здесь уже не в CRUD, а в version lifecycle и явном unpublish/archive flow для агентов. +- следующий реальный разрыв здесь уже не в CRUD, а в richer version lifecycle поверх этих состояний. ### 4.4. API Keys