From 404e413bba0086b17c5d8620f3892572ac214bb4 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Mon, 22 Jun 2026 15:22:11 -0700 Subject: [PATCH 1/2] epic: Add project migration tests and update documentation From d494d4176d48c85a94681dd47932e8e5541215d0 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Mon, 22 Jun 2026 15:29:47 -0700 Subject: [PATCH 2/2] feat(tests,docs): add project migration tests and update documentation Core handler tests: - Add test_list_projects_with_tenant_header_scopes_results to verify X-Tenant-ID scoping on GET /api/v1/projects (org-filtered + NULL rows vs. unscoped list returning all) Orchestrator association tests (new file): - crates/orchestrator/tests/project_associations_http.rs: 9 tests covering POST/DELETE agent and workflow associations, 404 on missing agent/workflow, and behavior when project ID does not exist in core CLI integration tests: - Add project_tests module to crates/cli/tests/integration_test.rs with mockito tests verifying CRUD routes to core URL, association routes to orchestrator URL, and delete_project pre-check behavior Documentation: - README: add agentd-core to port table (17000/7000), update description to mention project management, add project management CLI entry, add v0.15.0 migration status section with operator upgrade note - docs/storage.md: add service-to-table ownership table with note that projects moved from orchestrator to core in v0.15.0 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 12 +- crates/cli/tests/integration_test.rs | 208 +++++++++ crates/core/src/api/projects.rs | 61 +++ .../tests/project_associations_http.rs | 414 ++++++++++++++++++ docs/storage.md | 22 + 5 files changed, 715 insertions(+), 2 deletions(-) create mode 100644 crates/orchestrator/tests/project_associations_http.rs diff --git a/README.md b/README.md index 4d9336a6..26a1c388 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A modular daemon system for managing AI agents, notifications, interactive quest **agentd** is a suite of services and tools designed to orchestrate AI agents and provide intelligent, context-aware notifications and interactions. It consists of: - **agent** - Command-line interface for interacting with all services -- **agentd-core** - Core service providing user and organization management +- **agentd-core** - Core service providing user, organization, and project management - **agentd-orchestrator** - Agent lifecycle management, WebSocket SDK server, workflow scheduler, and tool policy enforcement - **agentd-notify** - Notification service with REST API and SQLite storage - **agentd-ask** - Interactive question service with tmux integration @@ -97,6 +97,7 @@ agent teardown .agentd/ # delete in reverse order - **Declarative templates** - `agent apply` / `agent teardown` for YAML-based agent and workflow management - **Agent management** - create, list, get, delete, attach, send-message, stream - **Workflow management** - create, list, get, update, delete, history, validate-template +- **Project management** - create, list, show, update, delete; add/remove agent and workflow associations - **Tool policies** - get-policy, set-policy, `--tool-policy` flag on create-agent - **Approval management** - list-approvals, approve, deny (for RequireApproval policy) - **Health monitoring** - `agent status` checks all services concurrently; per-service `health` commands @@ -362,12 +363,13 @@ For the complete configuration reference including all environment variables, da | Service | Dev Port | Prod Port | Description | |---------|----------|-----------|-------------| +| agentd-core | 17000 | 7000 | Core API (users, organizations, projects) | | agentd-ask | 17001 | 7001 | Interactive question service | | agentd-hook | 17002 | 7002 | Shell hook integration | | agentd-monitor | 17003 | 7003 | System monitoring | | agentd-notify | 17004 | 7004 | Notification service | | agentd-wrap | 17005 | 7005 | Tmux session management | -| agentd-orchestrator | 17006 | 7006 | Agent orchestration | +| agentd-orchestrator | 17006 | 7006 | Agent orchestration and project associations | | agentd-index | 17012 | 17012 | Semantic code search and indexing | | agentd-mcp | - | - | MCP server (stdio transport, no HTTP port) | @@ -419,6 +421,12 @@ For the complete configuration reference including all environment variables, da - ✅ Structured JSON logging (`AGENTD_LOG_FORMAT=json`) - ✅ GitHub Actions CI/CD pipeline +**Project Entity Migration (v0.15.0):** +- ✅ Project CRUD moved from orchestrator to core service (`agentd-core`) +- ✅ CLI, MCP tools, and UI repointed to core for project CRUD +- ✅ Orchestrator retains project association endpoints (add/remove agent and workflow) +- ⚠️ **Operators upgrading to v0.15.0** must run `agent admin backfill-projects` once after the upgrade to assign `organization_id` to existing project rows created before multi-tenancy was introduced. + **In Progress:** - 🔄 Hook service - 🔄 Monitor service diff --git a/crates/cli/tests/integration_test.rs b/crates/cli/tests/integration_test.rs index 06f6885f..f79fddfc 100644 --- a/crates/cli/tests/integration_test.rs +++ b/crates/cli/tests/integration_test.rs @@ -1701,3 +1701,211 @@ mod client_tests { mock2.assert_async().await; } } + +// --------------------------------------------------------------------------- +// Project command tests +// --------------------------------------------------------------------------- +// +// Project CRUD uses the core service URL; association operations (add/remove +// agent or workflow) use the orchestrator URL. Both URL paths are exercised +// below using two independent mockito servers. + +mod project_tests { + use mockito::Server; + use orchestrator::client::OrchestratorClient; + use serde_json::json; + use uuid::Uuid; + + fn project_json(id: &str, name: &str) -> serde_json::Value { + json!({ + "id": id, + "name": name, + "description": null, + "organization_id": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }) + } + + fn paginated(items: Vec) -> serde_json::Value { + let total = items.len(); + json!({ "items": items, "total": total, "limit": 50, "offset": 0 }) + } + + /// `list_projects` routes to the core URL (`/projects`). + #[tokio::test] + async fn test_list_projects_routes_to_core() { + let mut core_server = Server::new_async().await; + let orch_server = Server::new_async().await; // should not be called + + let id = Uuid::new_v4().to_string(); + let mock = core_server + .mock("GET", "/projects") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(paginated(vec![project_json(&id, "My Project")]).to_string()) + .create_async() + .await; + + let client = OrchestratorClient::new(orch_server.url()).with_core_url(core_server.url()); + let result = client.list_projects().await; + + assert!(result.is_ok(), "list_projects should succeed: {result:?}"); + assert_eq!(result.unwrap().items.len(), 1); + mock.assert_async().await; + } + + /// `create_project` routes to the core URL. + #[tokio::test] + async fn test_create_project_routes_to_core() { + let mut core_server = Server::new_async().await; + let orch_server = Server::new_async().await; + + let id = Uuid::new_v4().to_string(); + let mock = core_server + .mock("POST", "/projects") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(project_json(&id, "New Project").to_string()) + .create_async() + .await; + + use orchestrator::types::CreateProjectRequest; + let client = OrchestratorClient::new(orch_server.url()).with_core_url(core_server.url()); + let result = client + .create_project(&CreateProjectRequest { + name: "New Project".to_string(), + description: None, + }) + .await; + + assert!(result.is_ok(), "create_project should succeed: {result:?}"); + assert_eq!(result.unwrap().name, "New Project"); + mock.assert_async().await; + } + + /// `add_agent_to_project` routes to the orchestrator URL. + #[tokio::test] + async fn test_add_agent_to_project_routes_to_orchestrator() { + let core_server = Server::new_async().await; + let mut orch_server = Server::new_async().await; + + let project_id = Uuid::new_v4(); + let agent_id = Uuid::new_v4(); + + let mock = orch_server + .mock("POST", format!("/projects/{project_id}/agents/{agent_id}").as_str()) + .with_status(204) + .create_async() + .await; + + let client = OrchestratorClient::new(orch_server.url()).with_core_url(core_server.url()); + let result = client.associate_project_agent(&project_id, &agent_id).await; + + assert!(result.is_ok(), "associate_project_agent should succeed: {result:?}"); + mock.assert_async().await; + } + + /// `remove_agent_from_project` routes to the orchestrator URL. + #[tokio::test] + async fn test_remove_agent_from_project_routes_to_orchestrator() { + let core_server = Server::new_async().await; + let mut orch_server = Server::new_async().await; + + let project_id = Uuid::new_v4(); + let agent_id = Uuid::new_v4(); + + let mock = orch_server + .mock("DELETE", format!("/projects/{project_id}/agents/{agent_id}").as_str()) + .with_status(204) + .create_async() + .await; + + let client = OrchestratorClient::new(orch_server.url()).with_core_url(core_server.url()); + let result = client.dissociate_project_agent(&project_id, &agent_id).await; + + assert!(result.is_ok(), "dissociate_project_agent should succeed: {result:?}"); + mock.assert_async().await; + } + + /// `delete_project` pre-checks associations on the orchestrator before + /// deleting from core. When both return empty associations (total=0), + /// the delete request is forwarded to core. + #[tokio::test] + async fn test_delete_project_checks_associations_then_deletes_from_core() { + let mut core_server = Server::new_async().await; + let mut orch_server = Server::new_async().await; + + let project_id = Uuid::new_v4(); + + // Orchestrator: no agents associated + let agents_mock = orch_server + .mock("GET", format!("/projects/{project_id}/agents").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "items": [], "total": 0, "limit": 50, "offset": 0 }).to_string()) + .create_async() + .await; + + // Orchestrator: no workflows associated + let workflows_mock = orch_server + .mock("GET", format!("/projects/{project_id}/workflows").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "items": [], "total": 0, "limit": 50, "offset": 0 }).to_string()) + .create_async() + .await; + + // Core: delete succeeds + let delete_mock = core_server + .mock("DELETE", format!("/projects/{project_id}").as_str()) + .with_status(204) + .create_async() + .await; + + let client = OrchestratorClient::new(orch_server.url()).with_core_url(core_server.url()); + let result = client.delete_project(&project_id).await; + + assert!(result.is_ok(), "delete_project should succeed: {result:?}"); + agents_mock.assert_async().await; + workflows_mock.assert_async().await; + delete_mock.assert_async().await; + } + + /// `delete_project` returns an error when agents are still associated. + #[tokio::test] + async fn test_delete_project_blocked_when_agents_associated() { + let core_server = Server::new_async().await; + let mut orch_server = Server::new_async().await; + + let project_id = Uuid::new_v4(); + let agent_id = Uuid::new_v4(); + + // Orchestrator: one agent associated + let _agents_mock = orch_server + .mock("GET", format!("/projects/{project_id}/agents").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "items": [{ "id": agent_id, "name": "busy-agent", "status": "idle" }], + "total": 1, + "limit": 50, + "offset": 0 + }) + .to_string(), + ) + .create_async() + .await; + + let client = OrchestratorClient::new(orch_server.url()).with_core_url(core_server.url()); + let result = client.delete_project(&project_id).await; + + assert!(result.is_err(), "delete_project should fail when agents are associated"); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("agent") || msg.contains("associated"), + "error message should mention agents: {msg}" + ); + } +} diff --git a/crates/core/src/api/projects.rs b/crates/core/src/api/projects.rs index 925f8139..db2f186a 100644 --- a/crates/core/src/api/projects.rs +++ b/crates/core/src/api/projects.rs @@ -643,6 +643,67 @@ mod tests { assert_eq!(body["offset"], 1); } + #[tokio::test] + async fn test_list_projects_with_tenant_header_scopes_results() { + let (app, _tmp) = test_app().await; + let (token, _) = register(&app, "tenant_tester", "tenant_tester@example.com").await; + + // Project for org-x + for &(name, org) in &[ + ("Org-X Project", Some("org-x")), + ("No-Org Project", None), + ("Org-Y Project", Some("org-y")), + ] { + let payload = serde_json::json!({ "name": name }); + let mut builder = Request::builder() + .method("POST") + .uri("/api/v1/projects") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(oid) = org { + builder = builder.header("X-Tenant-ID", oid); + } + app.clone() + .oneshot(builder.body(Body::from(payload.to_string())).unwrap()) + .await + .unwrap(); + } + + // List with X-Tenant-ID: org-x — should return org-x project + NULL-org project (2) + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/projects") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header("X-Tenant-ID", "org-x") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["total"], 2, "org-x scoped list should include org-x + NULL rows"); + + // List without tenant header — should return all 3 + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/projects") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["total"], 3, "unscoped list should return all projects"); + } + // ----------------------------------------------------------------------- // Update project // ----------------------------------------------------------------------- diff --git a/crates/orchestrator/tests/project_associations_http.rs b/crates/orchestrator/tests/project_associations_http.rs new file mode 100644 index 00000000..d995a070 --- /dev/null +++ b/crates/orchestrator/tests/project_associations_http.rs @@ -0,0 +1,414 @@ +//! Integration tests for project association endpoints. +//! +//! After project CRUD was moved to the core service (#1313), the orchestrator +//! retains four association endpoints: +//! +//! - `POST /projects/{id}/agents/{agent_id}` — associate agent with project +//! - `DELETE /projects/{id}/agents/{agent_id}` — dissociate agent from project +//! - `POST /projects/{id}/workflows/{wf_id}` — associate workflow with project +//! - `DELETE /projects/{id}/workflows/{wf_id}` — dissociate workflow from project +//! +//! The orchestrator verifies that the agent / workflow exists (returns 404 if +//! not) but does **not** cross-check with the core service to verify the +//! project ID — that validation is the caller's responsibility. + +use async_trait::async_trait; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use chrono::Utc; +use communicate::client::CommunicateClient; +use orchestrator::{ + api::{create_router, ApiState}, + manager::AgentManager, + scheduler::{ + storage::SchedulerStorage, + types::{TriggerConfig, WorkflowConfig}, + Scheduler, + }, + storage::AgentStorage, + types::{Agent, AgentConfig, AgentStatus}, + websocket::ConnectionRegistry, +}; +use std::sync::Arc; +use tempfile::TempDir; +use tower::ServiceExt; +use uuid::Uuid; +use wrap::{ + backend::{SessionConfig, SessionExitInfo, SessionHealth}, + types::BackendType, + ExecutionBackend, +}; + +// --------------------------------------------------------------------------- +// No-op backend +// --------------------------------------------------------------------------- + +struct NullBackend; + +#[async_trait] +impl ExecutionBackend for NullBackend { + async fn create_session(&self, _config: &SessionConfig) -> anyhow::Result<()> { + Ok(()) + } + async fn launch_agent(&self, _config: &SessionConfig) -> anyhow::Result<()> { + Ok(()) + } + async fn session_exists(&self, _session_name: &str) -> anyhow::Result { + Ok(false) + } + async fn kill_session(&self, _session_name: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn send_command(&self, _session_name: &str, _command: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn list_sessions(&self) -> anyhow::Result> { + Ok(vec![]) + } + fn prefix(&self) -> &str { + "test" + } + async fn session_health(&self, _session_name: &str) -> anyhow::Result { + Ok(SessionHealth::Unknown) + } + async fn session_exit_info( + &self, + _session_name: &str, + ) -> anyhow::Result> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// Test app builder +// --------------------------------------------------------------------------- + +async fn build_app() -> (axum::Router, Arc, Arc, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + let storage = Arc::new(AgentStorage::with_path(&db_path).await.unwrap()); + let scheduler_storage = SchedulerStorage::new(storage.db().clone()); + let registry = ConnectionRegistry::new(); + let scheduler = Arc::new(Scheduler::new(scheduler_storage, registry.clone())); + let manager = Arc::new( + AgentManager::new( + storage.clone(), + Arc::new(NullBackend), + registry.clone(), + "ws://localhost:7006".to_string(), + ) + .with_mcp_config_dir(temp_dir.path().join("mcp")), + ); + let communicate = CommunicateClient::new("http://localhost:17010"); + + let state = ApiState { + manager, + registry, + scheduler: scheduler.clone(), + communicate, + backend_type: BackendType::Tmux, + }; + + (create_router(state), storage, scheduler, temp_dir) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Insert an agent with the given status directly into storage. +async fn insert_agent(storage: &AgentStorage, name: &str) -> Agent { + let config: AgentConfig = + serde_json::from_value(serde_json::json!({ "working_dir": "/tmp" })).unwrap(); + let mut agent = Agent::new(name.to_string(), config); + agent.status = AgentStatus::Pending; + storage.add(&agent).await.unwrap(); + agent +} + +/// Insert a workflow with a manual trigger directly into scheduler storage. +async fn insert_workflow(scheduler: &Scheduler, name: &str, agent_id: Uuid) -> WorkflowConfig { + let now = Utc::now(); + let config = WorkflowConfig { + id: Uuid::new_v4(), + name: name.to_string(), + agent_id, + trigger_config: TriggerConfig::Manual {}, + prompt_template: "Task: {{title}}".to_string(), + poll_interval_secs: 60, + enabled: true, + tool_policy: Default::default(), + created_at: now, + updated_at: now, + project_id: None, + organization_id: None, + }; + scheduler.storage().add_workflow(&config).await.unwrap(); + config +} + +// --------------------------------------------------------------------------- +// Agent association tests +// --------------------------------------------------------------------------- + +/// `POST /projects/{project_id}/agents/{agent_id}` with a valid agent returns 204. +#[tokio::test] +async fn test_associate_agent_with_project_returns_204() { + let (app, storage, _scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-assoc-agent-1").await; + let project_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/agents/{}", agent.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +/// `DELETE /projects/{project_id}/agents/{agent_id}` with a valid agent returns 204. +#[tokio::test] +async fn test_dissociate_agent_from_project_returns_204() { + let (app, storage, _scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-assoc-agent-2").await; + let project_id = Uuid::new_v4(); + + // Associate first, then dissociate. + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/agents/{}", agent.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/projects/{project_id}/agents/{}", agent.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +/// `POST /projects/{project_id}/agents/{agent_id}` with an unknown agent_id returns 404. +#[tokio::test] +async fn test_associate_agent_nonexistent_agent_returns_404() { + let (app, _storage, _scheduler, _tmp) = build_app().await; + let project_id = Uuid::new_v4(); + let missing_agent_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/agents/{missing_agent_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +/// Associating an agent with a non-existent project UUID still returns 204 +/// because the orchestrator does not validate project IDs against the core +/// service — that check is the caller's responsibility. +#[tokio::test] +async fn test_associate_agent_nonexistent_project_still_succeeds() { + let (app, storage, _scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-assoc-agent-3").await; + let nonexistent_project_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{nonexistent_project_id}/agents/{}", agent.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // 204 — orchestrator only validates that the agent exists, not the project. + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +// --------------------------------------------------------------------------- +// Workflow association tests +// --------------------------------------------------------------------------- + +/// `POST /projects/{project_id}/workflows/{wf_id}` with a valid workflow returns 204. +#[tokio::test] +async fn test_associate_workflow_with_project_returns_204() { + let (app, storage, scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-wf-assoc-agent-1").await; + let workflow = insert_workflow(&scheduler, "orch-wf-1", agent.id).await; + let project_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/workflows/{}", workflow.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +/// `DELETE /projects/{project_id}/workflows/{wf_id}` with a valid workflow returns 204. +#[tokio::test] +async fn test_dissociate_workflow_from_project_returns_204() { + let (app, storage, scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-wf-assoc-agent-2").await; + let workflow = insert_workflow(&scheduler, "orch-wf-2", agent.id).await; + let project_id = Uuid::new_v4(); + + // Associate first. + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/workflows/{}", workflow.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/projects/{project_id}/workflows/{}", workflow.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +/// `POST /projects/{project_id}/workflows/{wf_id}` with an unknown workflow returns 404. +#[tokio::test] +async fn test_associate_workflow_nonexistent_workflow_returns_404() { + let (app, _storage, _scheduler, _tmp) = build_app().await; + let project_id = Uuid::new_v4(); + let missing_wf_id = Uuid::new_v4(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/workflows/{missing_wf_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// List association tests +// --------------------------------------------------------------------------- + +/// After associating an agent, `GET /projects/{id}/agents` returns that agent. +#[tokio::test] +async fn test_list_project_agents_returns_associated_agents() { + let (app, storage, _scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-list-agent").await; + let project_id = Uuid::new_v4(); + + // Associate the agent. + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/agents/{}", agent.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/projects/{project_id}/agents")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["total"], 1); + assert_eq!(body["items"][0]["id"], agent.id.to_string()); +} + +/// After associating a workflow, `GET /projects/{id}/workflows` returns that workflow. +#[tokio::test] +async fn test_list_project_workflows_returns_associated_workflows() { + let (app, storage, scheduler, _tmp) = build_app().await; + let agent = insert_agent(&storage, "orch-list-wf-agent").await; + let workflow = insert_workflow(&scheduler, "orch-list-wf", agent.id).await; + let project_id = Uuid::new_v4(); + + // Associate the workflow. + app.clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/projects/{project_id}/workflows/{}", workflow.id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/projects/{project_id}/workflows")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["total"], 1); + assert_eq!(body["items"][0]["id"], workflow.id.to_string()); +} diff --git a/docs/storage.md b/docs/storage.md index 0f2abea3..1a516749 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -852,6 +852,28 @@ let scheduler_storage = SchedulerStorage::new(agent_storage.db().clone()); --- +## Service-to-Table Ownership + +Each agentd service owns its own SQLite database. The table below maps key +entities to their canonical service after the v0.15.0 project migration: + +| Entity | Owning service | Database | Notes | +|--------|---------------|----------|-------| +| `users` | agentd-core | `agentd-core/core.db` | | +| `organizations` | agentd-core | `agentd-core/core.db` | | +| `projects` | agentd-core | `agentd-core/core.db` | Moved from orchestrator in v0.15.0 | +| `agents` | agentd-orchestrator | `agentd/agent.db` | `project_id` FK points to core `projects.id` | +| `workflows` | agentd-orchestrator | `agentd/agent.db` | `project_id` FK points to core `projects.id` | +| `notifications` | agentd-notify | `agentd-notify/notify.db` | | +| `memories` | agentd-memory | `agentd-memory/memory.db` | | + +> **Upgrade note (v0.15.0):** The `projects` table was removed from the +> orchestrator database and added to the core database. Run +> `agent admin backfill-projects` after upgrading to assign `organization_id` +> to project rows that were created before multi-tenancy was introduced. + +--- + ## xtask Commands Three `cargo xtask` sub-commands help manage databases during development: