Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) |

Expand Down Expand Up @@ -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
Expand Down
208 changes: 208 additions & 0 deletions crates/cli/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> 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}"
);
}
}
61 changes: 61 additions & 0 deletions crates/core/src/api/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down
Loading