Commit d64a3f7b authored by Admin's avatar Admin

feat: add core company features, onboarding wizard, pipeline history, swarm trees, and blueprints

parent b5d9b6ef
# Canifa AI Platform — Backend & Tools # Paperclip AI Platform — Backend & Tools
A unified AI ecosystem powering Canifa's fashion retail intelligence, content automation, and inventory monitoring. A unified, multi-tenant AI ecosystem powering fashion retail intelligence, content automation, software engineering, and industry-specific business processes.
This platform serves as the central hub for all AI-driven workflows at Canifa, integrating Large Language Models (Gemini, GPT) with real-time business data (PostgreSQL, StarRocks, SQLite) to provide actionable insights and automated content. This platform serves as the central hub for all AI-driven workflows, integrating Large Language Models (Gemini, GPT, Claude) with business data and dynamic NocoBase collection engines.
--- ---
...@@ -13,38 +13,28 @@ This platform serves as the central hub for all AI-driven workflows at Canifa, i ...@@ -13,38 +13,28 @@ This platform serves as the central hub for all AI-driven workflows at Canifa, i
<img src="public/screenshots/03_chatbot_ui.png" width="31%" alt="Chatbot UI" /> <img src="public/screenshots/03_chatbot_ui.png" width="31%" alt="Chatbot UI" />
<img src="public/screenshots/06_product_desc.png" width="31%" alt="Product Description" /> <img src="public/screenshots/06_product_desc.png" width="31%" alt="Product Description" />
</p> </p>
<p align="center">
<img src="public/screenshots/04_stock_cache.png" width="45%" alt="Stock Cache" />
<img src="public/screenshots/05_fashion_matches.png" width="45%" alt="Fashion Matches" />
</p>
--- ---
## 🧠 Core Modules ## 🧠 Core Modules
### 1. 👗 AI Stylist & Fashion Matches ### 1. 🏢 Industry Blueprints & Spawning
- **Deterministic Styling**: Maps raw Magento categories into a clean 4-slot framework (Top, Bottom, Set, Accessories). - **Dynamic Provisioning**: Spawns tailored agent departments and hierarchies (e.g., Software Studio, E-commerce Brand, Marketing Agency) from single-file YAML blueprints.
- **Style Pairing**: Generates context-aware outfit combinations based on product attributes and seasonal trends. - **NocoBase Connector**: Per-company database schema isolation, dynamically provisioning collections and fields at company launch.
- **Grounded Retrieval**: Prevents AI hallucinations by anchoring recommendations in real product metadata. - **Topological Run Pipeline**: Parallel stage-based execution DAG with approval gates and manual review checkpoints.
### 2. ✍️ Ultra Product Descriptions
- **Automated Copywriting**: Generates web-ready, SEO-optimized descriptions from raw product data and images.
- **Human-in-the-loop**: Integrated approval workflow for AI-generated content before publishing to Magento.
- **N8n Integration**: Seamless data flow between Google Sheets, n8n, and the Backend API.
### 3. 📦 Stock & Inventory Intelligence ### 2. 👗 AI Stylist & Catalog Intelligence
- **Real-time Monitoring**: Dashboard for tracking stock levels across multiple warehouses. - **Styling pairing**: Standardizes product listings into category slots and generates styling recommendations based on product attributes and current trends.
- **Cache Management**: High-performance SQLite-based caching for instant stock lookups. - **Human-in-the-loop**: Integrated approval flow for AI-generated product copywriting and catalog updates.
- **StarRocks Sync**: Enterprise-grade data warehousing for historical analysis and forecasting.
### 4. 📅 Content Automation ### 3. 📦 Inventory & Stock Intelligence
- **Composer**: AI-assisted creation of marketing materials and social media posts. - **Real-time Monitoring**: Dashboard for tracking stock levels across warehouses.
- **Calendar & Approval**: Full lifecycle management for promotional content. - **StarRocks / BI sync**: Historical analysis and forecasting capabilities.
- **Social Inbox**: Centralized monitoring of customer interactions across social channels.
--- ---
## 🛠 Tech Stack ## 🛠 Tech Stack
- **Backend**: FastAPI (Async Python 3.10+) - **Backend**: FastAPI (Async Python 3.10+)
- **Frontend**: React + TypeScript + Vite + Tailwind CSS
- **AI Framework**: LangChain & LangGraph - **AI Framework**: LangChain & LangGraph
...@@ -22,6 +22,11 @@ BUDGET_ENFORCEMENT_ENABLED=True ...@@ -22,6 +22,11 @@ BUDGET_ENFORCEMENT_ENABLED=True
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
# NocoBase ERP integration
NOCOBASE_URL=http://localhost:13002
NOCOBASE_EMAIL=admin@nocobase.com
NOCOBASE_PASSWORD=admin123
# Logging # Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO
......
...@@ -202,6 +202,7 @@ def init_agent( ...@@ -202,6 +202,7 @@ def init_agent(
checkpoint_max_total_size_mb: int = 500, checkpoint_max_total_size_mb: int = 500,
checkpoint_max_file_size_mb: int = 10, checkpoint_max_file_size_mb: int = 10,
pass_session_id: bool = False, pass_session_id: bool = False,
team_id: str = None,
): ):
""" """
Initialize the AI Agent. Initialize the AI Agent.
...@@ -453,6 +454,8 @@ def init_agent( ...@@ -453,6 +454,8 @@ def init_agent(
# Store toolset filtering options # Store toolset filtering options
agent.enabled_toolsets = enabled_toolsets agent.enabled_toolsets = enabled_toolsets
agent.disabled_toolsets = disabled_toolsets agent.disabled_toolsets = disabled_toolsets
if not hasattr(agent, "team_id"):
agent.team_id = team_id
# Model response configuration # Model response configuration
agent.max_tokens = max_tokens # None = use model default agent.max_tokens = max_tokens # None = use model default
......
...@@ -414,11 +414,13 @@ class AIAgent: ...@@ -414,11 +414,13 @@ class AIAgent:
checkpoint_max_file_size_mb: int = 10, checkpoint_max_file_size_mb: int = 10,
pass_session_id: bool = False, pass_session_id: bool = False,
company_id: str = None, company_id: str = None,
team_id: str = None,
model_name: str = None, model_name: str = None,
**kwargs **kwargs
): ):
"""Forwarder — see ``agent.agent_init.init_agent``.""" """Forwarder — see ``agent.agent_init.init_agent``."""
self.company_id = company_id self.company_id = company_id
self.team_id = team_id
self.model_name = model_name self.model_name = model_name
if model_name and not model: if model_name and not model:
model = model_name model = model_name
...@@ -489,6 +491,7 @@ class AIAgent: ...@@ -489,6 +491,7 @@ class AIAgent:
checkpoint_max_total_size_mb=checkpoint_max_total_size_mb, checkpoint_max_total_size_mb=checkpoint_max_total_size_mb,
checkpoint_max_file_size_mb=checkpoint_max_file_size_mb, checkpoint_max_file_size_mb=checkpoint_max_file_size_mb,
pass_session_id=pass_session_id, pass_session_id=pass_session_id,
team_id=team_id,
) )
def _get_session_db_for_recall(self): def _get_session_db_for_recall(self):
...@@ -4171,7 +4174,7 @@ class AIAgent: ...@@ -4171,7 +4174,7 @@ class AIAgent:
if hasattr(self, "company_id") and self.company_id: if hasattr(self, "company_id") and self.company_id:
try: try:
from tools.get_tools import register_company_tools from tools.get_tools import register_company_tools
register_company_tools(self.company_id) register_company_tools(self.company_id, getattr(self, "team_id", None), getattr(self, "enabled_toolsets", None))
# Re-load tools list from the registry so they are visible to the agent loop # Re-load tools list from the registry so they are visible to the agent loop
from model_tools import get_tool_definitions from model_tools import get_tool_definitions
self.tools = get_tool_definitions(quiet_mode=self.quiet_mode) self.tools = get_tool_definitions(quiet_mode=self.quiet_mode)
......
"""Facebook Page posting tool for LLM agents."""
import logging
import os
import uuid
import httpx
logger = logging.getLogger(__name__)
GRAPH_API_URL = "https://graph.facebook.com/v19.0"
async def post_to_facebook(message: str) -> dict:
"""Post a message to the configured Facebook Page.
If FACEBOOK_PAGE_ACCESS_TOKEN and FACEBOOK_PAGE_ID are set, makes a real
POST to the Graph API. Otherwise runs in dry-run mode.
"""
access_token = os.environ.get("FACEBOOK_PAGE_ACCESS_TOKEN", "")
page_id = os.environ.get("FACEBOOK_PAGE_ID", "")
if access_token and page_id:
return await _post_real(page_id, access_token, message)
logger.info("[facebook_tool] DRY-RUN: would post: %s", message[:120])
return {
"post_id": f"dry_run_{uuid.uuid4().hex[:12]}",
"status": "ok",
"mode": "dry-run",
"message_preview": message[:200],
}
async def _post_real(page_id: str, token: str, message: str) -> dict:
url = f"{GRAPH_API_URL}/{page_id}/feed"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, data={"message": message, "access_token": token})
resp.raise_for_status()
data = resp.json()
return {"post_id": data.get("id", ""), "status": "ok", "mode": "real"}
def get_facebook_tools() -> list[dict]:
"""Return tool definitions compatible with OpenAI function-calling format."""
return [
{
"type": "function",
"function": {
"name": "post_to_facebook",
"description": "Post a message to the company Facebook Page. Runs in dry-run mode when credentials are not configured.",
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The text content to publish on the Facebook Page.",
},
},
"required": ["message"],
},
},
}
]
...@@ -3,7 +3,7 @@ import logging ...@@ -3,7 +3,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_all_tools(company_id: str | None = None) -> list[BaseTool]: def get_all_tools(company_id: str | None = None, team_id: str | None = None, enabled_toolsets: list[str] | None = None) -> list[BaseTool]:
"""Return all tools for the Agent, including Workspace tools, MCP tools, and dynamic Skill tools.""" """Return all tools for the Agent, including Workspace tools, MCP tools, and dynamic Skill tools."""
tools = [] tools = []
...@@ -20,6 +20,15 @@ def get_all_tools(company_id: str | None = None) -> list[BaseTool]: ...@@ -20,6 +20,15 @@ def get_all_tools(company_id: str | None = None) -> list[BaseTool]:
try: try:
from .mcp_client import MCPClientManager from .mcp_client import MCPClientManager
mcp_tools = MCPClientManager.get_instance().get_tools() mcp_tools = MCPClientManager.get_instance().get_tools()
if enabled_toolsets is not None:
filtered_mcp = []
for t in mcp_tools:
# t.name format: mcp_{server_name}_{tool_name}
if any(t.name.startswith(f"mcp_{ts}_") for ts in enabled_toolsets):
filtered_mcp.append(t)
logger.info(f"Loaded {len(filtered_mcp)} MCP tools (filtered from {len(mcp_tools)})")
tools.extend(filtered_mcp)
else:
logger.info(f"Loaded {len(mcp_tools)} MCP tools") logger.info(f"Loaded {len(mcp_tools)} MCP tools")
tools.extend(mcp_tools) tools.extend(mcp_tools)
except Exception as e: except Exception as e:
...@@ -28,21 +37,21 @@ def get_all_tools(company_id: str | None = None) -> list[BaseTool]: ...@@ -28,21 +37,21 @@ def get_all_tools(company_id: str | None = None) -> list[BaseTool]:
# 3. Load Dynamic Skills # 3. Load Dynamic Skills
try: try:
from .skills_loader import load_skills_as_tools from .skills_loader import load_skills_as_tools
skill_tools = load_skills_as_tools(company_id) skill_tools = load_skills_as_tools(company_id, team_id)
logger.info(f"Loaded {len(skill_tools)} skill tools for company_id: {company_id}") logger.info(f"Loaded {len(skill_tools)} skill tools for company_id: {company_id}, team_id: {team_id}")
tools.extend(skill_tools) tools.extend(skill_tools)
except Exception as e: except Exception as e:
logger.error(f"Error loading skill tools: {e}", exc_info=True) logger.error(f"Error loading skill tools: {e}", exc_info=True)
return tools return tools
def register_company_tools(company_id: str | None = None) -> None: def register_company_tools(company_id: str | None = None, team_id: str | None = None, enabled_toolsets: list[str] | None = None) -> None:
"""Load all workspace, MCP, and skill tools for the company, and register them in the Hermes ToolRegistry.""" """Load all workspace, MCP, and skill tools for the company, and register them in the Hermes ToolRegistry."""
from .registry import registry from .registry import registry
from langchain_core.utils.function_calling import convert_to_openai_function from langchain_core.utils.function_calling import convert_to_openai_function
import inspect import inspect
tools = get_all_tools(company_id) tools = get_all_tools(company_id, team_id, enabled_toolsets)
for tool in tools: for tool in tools:
try: try:
openai_schema = convert_to_openai_function(tool) openai_schema = convert_to_openai_function(tool)
......
...@@ -30,7 +30,7 @@ def parse_skill_md(file_path: str) -> Optional[Dict[str, Any]]: ...@@ -30,7 +30,7 @@ def parse_skill_md(file_path: str) -> Optional[Dict[str, Any]]:
logger.error(f"Error parsing skill markdown at {file_path}: {e}") logger.error(f"Error parsing skill markdown at {file_path}: {e}")
return None return None
def get_company_skill_names(company_id: str) -> List[str]: def get_company_skill_names(company_id: str, team_id: str | None = None) -> List[str]:
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
db_path = os.path.join(root_dir, "backend", "paperclip.db") db_path = os.path.join(root_dir, "backend", "paperclip.db")
if not os.path.exists(db_path): if not os.path.exists(db_path):
...@@ -40,7 +40,10 @@ def get_company_skill_names(company_id: str) -> List[str]: ...@@ -40,7 +40,10 @@ def get_company_skill_names(company_id: str) -> List[str]:
try: try:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT name FROM company_skills WHERE company_id = ?", (company_id,)) if team_id:
cursor.execute("SELECT name FROM company_skills WHERE company_id = ? AND (team_id = ? OR team_id IS NULL)", (company_id, team_id))
else:
cursor.execute("SELECT name FROM company_skills WHERE company_id = ? AND team_id IS NULL", (company_id,))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
return [row[0] for row in rows] return [row[0] for row in rows]
...@@ -48,7 +51,7 @@ def get_company_skill_names(company_id: str) -> List[str]: ...@@ -48,7 +51,7 @@ def get_company_skill_names(company_id: str) -> List[str]:
logger.error(f"Error querying company skills from SQLite: {e}") logger.error(f"Error querying company skills from SQLite: {e}")
return [] return []
def load_skills_as_tools(company_id: str | None = None) -> List[StructuredTool]: def load_skills_as_tools(company_id: str | None = None, team_id: str | None = None) -> List[StructuredTool]:
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
skills_dir = os.path.join(root_dir, ".agent", "skills") skills_dir = os.path.join(root_dir, ".agent", "skills")
...@@ -59,8 +62,8 @@ def load_skills_as_tools(company_id: str | None = None) -> List[StructuredTool]: ...@@ -59,8 +62,8 @@ def load_skills_as_tools(company_id: str | None = None) -> List[StructuredTool]:
# Get the names of skills we should load # Get the names of skills we should load
allowed_skill_names = None allowed_skill_names = None
if company_id: if company_id:
allowed_skill_names = get_company_skill_names(company_id) allowed_skill_names = get_company_skill_names(company_id, team_id)
logger.info(f"Loaded allowed skill names for company {company_id}: {allowed_skill_names}") logger.info(f"Loaded allowed skill names for company {company_id}, team {team_id}: {allowed_skill_names}")
tools = [] tools = []
......
...@@ -8,6 +8,7 @@ import os ...@@ -8,6 +8,7 @@ import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from models.database import async_engine, Base from models.database import async_engine, Base
import models # Register all models on Base.metadata
from config import settings from config import settings
config = context.config config = context.config
...@@ -29,25 +30,29 @@ def run_migrations_offline(): ...@@ -29,25 +30,29 @@ def run_migrations_offline():
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
render_as_batch=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
def run_migrations_online(): import asyncio
"""Run migrations in 'online' mode."""
connectable = async_engine
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
async def run_async_migrations():
connectable = async_engine
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
def run_migrations_online():
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
......
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
...@@ -217,11 +217,8 @@ async def run_agent_in_background( ...@@ -217,11 +217,8 @@ async def run_agent_in_background(
from config import settings from config import settings
if settings.openai_api_key == "wrapper-test-key" and os.environ.get("REAL_AGENT_RUN") != "true": if os.environ.get("STUB_AGENTS") == "true":
import asyncio
import json
from pathlib import Path from pathlib import Path
from datetime import datetime
# Ensure shared workspaces folder exists # Ensure shared workspaces folder exists
shared_dir = Path("workspaces") / company_id / "shared" shared_dir = Path("workspaces") / company_id / "shared"
...@@ -452,15 +449,35 @@ async def run_agent_in_background( ...@@ -452,15 +449,35 @@ async def run_agent_in_background(
except Exception as e: except Exception as e:
logging.getLogger(__name__).error(f"Failed to spawn CubeSandbox instance: {e}", exc_info=True) logging.getLogger(__name__).error(f"Failed to spawn CubeSandbox instance: {e}", exc_info=True)
from services.runtime_env import resolve_runtime_env, resolve_enabled_toolsets
async with get_db_context() as db:
result = await db.execute(select(Agent).where(Agent.id == agent_id))
db_agent = result.scalar_one_or_none()
team_id = db_agent.team_id if db_agent else None
env_vars = await resolve_runtime_env(db, company_id, team_id)
enabled_toolsets = await resolve_enabled_toolsets(db, company_id, team_id)
original_env = {}
injected_keys = []
for k, v in env_vars.items():
if k in os.environ:
original_env[k] = os.environ[k]
else:
injected_keys.append(k)
os.environ[k] = str(v)
ai_agent = AIAgent( ai_agent = AIAgent(
model_name=model_name, model_name=model_name,
company_id=company_id, company_id=company_id,
team_id=team_id,
tool_progress_callback=log_writer.on_subagent_progress, tool_progress_callback=log_writer.on_subagent_progress,
step_callback=log_writer.on_step, step_callback=log_writer.on_step,
status_callback=log_writer.on_status, status_callback=log_writer.on_status,
api_key=settings.openai_api_key, api_key=settings.openai_api_key,
base_url=base_url, base_url=base_url,
api_mode=api_mode, api_mode=api_mode,
enabled_toolsets=enabled_toolsets,
) )
...@@ -507,6 +524,16 @@ async def run_agent_in_background( ...@@ -507,6 +524,16 @@ async def run_agent_in_background(
del os.environ["CUBE_SANDBOX_ID"] del os.environ["CUBE_SANDBOX_ID"]
except Exception as e: except Exception as e:
logging.getLogger(__name__).error(f"Failed to delete CubeSandbox instance: {e}", exc_info=True) logging.getLogger(__name__).error(f"Failed to delete CubeSandbox instance: {e}", exc_info=True)
# Teardown runtime env vars
try:
for k in injected_keys:
if k in os.environ:
del os.environ[k]
for k, v in original_env.items():
os.environ[k] = v
except Exception:
pass
if workspace: if workspace:
try: try:
import shutil import shutil
...@@ -526,7 +553,6 @@ async def run_agent_in_background( ...@@ -526,7 +553,6 @@ async def run_agent_in_background(
# Look up agent role to name the output file # Look up agent role to name the output file
async with get_db_context() as db: async with get_db_context() as db:
from models import Agent
agent_res = await db.execute(select(Agent).where(Agent.id == agent_id)) agent_res = await db.execute(select(Agent).where(Agent.id == agent_id))
agent_obj = agent_res.scalar_one_or_none() agent_obj = agent_res.scalar_one_or_none()
role_name = (agent_obj.role or "agent").lower().replace(" ", "_") if agent_obj else "agent" role_name = (agent_obj.role or "agent").lower().replace(" ", "_") if agent_obj else "agent"
......
...@@ -3,7 +3,9 @@ Approval routes. ...@@ -3,7 +3,9 @@ Approval routes.
""" """
from typing import Optional, List from typing import Optional, List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status, Body from fastapi import APIRouter, Depends, HTTPException, Query, status, Body
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
...@@ -18,6 +20,92 @@ from services.activity_logger import log_activity ...@@ -18,6 +20,92 @@ from services.activity_logger import log_activity
router = APIRouter(prefix="/approvals", tags=["approvals"]) router = APIRouter(prefix="/approvals", tags=["approvals"])
class ApprovalDecisionBody(BaseModel):
# NOTE: snake_case on purpose — CaseConversionMiddleware rewrites the
# incoming camelCase body (`decisionNote`) to snake_case before it reaches
# the route, so the model field must be snake_case to bind.
decision_note: Optional[str] = None
async def _apply_resolution(
db: AsyncSession,
current_user: AuthUserResponse,
approval_id: str,
resolution: str,
note: Optional[str],
) -> Approval:
"""Resolve an approval, deriving the company from the approval record itself.
Used by the FE-facing /approve and /reject endpoints which pass only the
approval id (no company_id). Sets `resolution` which the pipeline
orchestrator polls to resume/abort a gated stage.
"""
result = await db.execute(select(Approval).where(Approval.id == approval_id))
approval = result.scalar_one_or_none()
if not approval:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Approval not found")
# Verify membership against the approval's own company
membership_result = await db.execute(
select(CompanyMembership).where(
CompanyMembership.company_id == approval.company_id,
CompanyMembership.principal_type == "user",
CompanyMembership.principal_id == current_user.id,
CompanyMembership.status == "active",
)
)
if not membership_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to company")
if approval.resolution is not None:
raise HTTPException(status_code=400, detail="Approval already resolved")
approval.resolution = resolution
approval.resolved_by = current_user.id
approval.resolved_at = datetime.utcnow()
if note:
approval.resolution_note = note
await log_activity(
db=db,
company_id=approval.company_id,
actor_type="user",
actor_id=current_user.id,
action="approval.resolved",
resource_type="approval",
resource_id=approval_id,
changes={"resolution": (None, resolution)},
)
await db.commit()
await db.refresh(approval)
return approval
@router.post("/{approval_id}/approve", response_model=ApprovalResponse)
async def approve_approval(
approval_id: str,
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user),
body: Optional[ApprovalDecisionBody] = Body(default=None),
):
"""Approve an approval request (resumes a gated pipeline stage)."""
note = body.decision_note if body else None
return await _apply_resolution(db, current_user, approval_id, "approved", note)
@router.post("/{approval_id}/reject", response_model=ApprovalResponse)
async def reject_approval(
approval_id: str,
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user),
body: Optional[ApprovalDecisionBody] = Body(default=None),
):
"""Reject an approval request (fails a gated pipeline stage)."""
note = body.decision_note if body else None
return await _apply_resolution(db, current_user, approval_id, "rejected", note)
@router.post("", response_model=ApprovalResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ApprovalResponse, status_code=status.HTTP_201_CREATED)
async def create_approval( async def create_approval(
approval: ApprovalCreate, approval: ApprovalCreate,
...@@ -95,19 +183,34 @@ async def list_approvals( ...@@ -95,19 +183,34 @@ async def list_approvals(
@router.get("/{approval_id}", response_model=ApprovalResponse) @router.get("/{approval_id}", response_model=ApprovalResponse)
async def get_approval( async def get_approval(
approval_id: str, approval_id: str,
company_id: str = Query(..., description="Company ID"), company_id: Optional[str] = Query(None, description="Company ID (optional; derived from approval if omitted)"),
scope: CompanyScope = Depends(require_company_scope), scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session) db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user),
): ):
"""Get a specific approval.""" """Get a specific approval. Accepts either an explicit company_id or derives it."""
result = await db.execute( result = await db.execute(select(Approval).where(Approval.id == approval_id))
select(Approval).where(Approval.id == approval_id, Approval.company_id == company_id)
)
approval = result.scalar_one_or_none() approval = result.scalar_one_or_none()
if not approval: if not approval:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Approval not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Approval not found")
if company_id and approval.company_id != company_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Approval not found")
# When no company scope was supplied, verify membership against the approval's company
if not company_id:
membership_result = await db.execute(
select(CompanyMembership).where(
CompanyMembership.company_id == approval.company_id,
CompanyMembership.principal_type == "user",
CompanyMembership.principal_id == current_user.id,
CompanyMembership.status == "active",
)
)
if not membership_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to company")
return approval return approval
......
"""Campaign routes — Summer Camp 2026 CRUD + dashboard + pipeline."""
from datetime import datetime, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from database import get_db_session
from middleware.auth import require_company_scope, get_current_user
from schemas.common import CompanyScope, PaginationParams, PaginatedResponse
from schemas.campaign import CampaignCreate, CampaignUpdate, CampaignResponse
from schemas.product_spec import ProductSpecCreate, ProductSpecUpdate, ProductSpecResponse
from schemas.material import MaterialCreate, MaterialUpdate, MaterialResponse
from schemas.ad_plan import AdPlanCreate, AdPlanUpdate, AdPlanResponse
from schemas.budget_item import BudgetItemCreate, BudgetItemUpdate, BudgetItemResponse
from services.campaign_service import CampaignService
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
# ─────────────────────────────────────────────────────────────────────────────
# Campaign CRUD
# ─────────────────────────────────────────────────────────────────────────────
@router.get("", response_model=PaginatedResponse)
async def list_campaigns(
company_id: str,
status: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200),
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
items = await CampaignService.list_campaigns(db, company_id, status=status)
total = len(items)
return PaginatedResponse(
total=total,
offset=offset,
limit=limit,
items=[CampaignResponse.model_validate(i) for i in items[offset:offset + limit]],
)
@router.get("/{campaign_id}", response_model=CampaignResponse)
async def get_campaign(
campaign_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return CampaignResponse.model_validate(campaign)
@router.post("", response_model=CampaignResponse, status_code=status.HTTP_201_CREATED)
async def create_campaign(
body: CampaignCreate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.create_campaign(db, **body.model_dump())
return CampaignResponse.model_validate(campaign)
@router.patch("/{campaign_id}", response_model=CampaignResponse)
async def update_campaign(
campaign_id: str,
body: CampaignUpdate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
campaign = await CampaignService.update_campaign(db, campaign, **body.model_dump(exclude_none=True))
return CampaignResponse.model_validate(campaign)
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_campaign(
campaign_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
await CampaignService.delete_campaign(db, campaign)
# ─────────────────────────────────────────────────────────────────────────────
# Campaign Dashboard
# ─────────────────────────────────────────────────────────────────────────────
class CampaignDashboardResponse(BaseModel):
campaign_id: str
campaign_name: str
status: str
season: str
year: int
tagline: Optional[str] = None
theme_color: Optional[str] = None
summary: dict
pipeline: dict
budget: dict
ad_performance: dict
@router.get("/{campaign_id}/dashboard", response_model=CampaignDashboardResponse)
async def get_campaign_dashboard(
campaign_id: str,
company_id: str = Query(...),
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
data = await CampaignService.get_campaign_dashboard(db, company_id, campaign_id)
if not data:
raise HTTPException(status_code=404, detail="Campaign not found")
return data
# ─────────────────────────────────────────────────────────────────────────────
# Pipeline Orchestration Status (DAG from canifa-company.yaml)
# ─────────────────────────────────────────────────────────────────────────────
class PipelineStageResponse(BaseModel):
status: str # pending | in_progress | completed
detail: str
class PipelineStatusResponse(BaseModel):
campaign_id: str
campaign_name: str
campaign_status: str
total_budget_vnd: int
stages: dict[str, PipelineStageResponse]
stats: dict
@router.get("/{campaign_id}/pipeline", response_model=PipelineStatusResponse)
async def get_pipeline_status(
campaign_id: str,
company_id: str = Query(...),
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
data = await CampaignService.get_pipeline_status(db, company_id, campaign_id)
if "error" in data:
raise HTTPException(status_code=404, detail=data["error"])
return data
# ─────────────────────────────────────────────────────────────────────────────
# Product Spec CRUD (nested under campaign)
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/{campaign_id}/product-specs", response_model=List[ProductSpecResponse])
async def list_product_specs(
campaign_id: str,
company_id: str = Query(...),
product_type: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db_session),
):
items = await CampaignService.list_product_specs(db, company_id, campaign_id, product_type)
return [ProductSpecResponse.model_validate(i) for i in items]
@router.post("/{campaign_id}/product-specs", response_model=ProductSpecResponse,
status_code=status.HTTP_201_CREATED)
async def create_product_spec(
campaign_id: str,
body: ProductSpecCreate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
payload = body.model_dump()
payload["campaign_id"] = campaign_id
spec = await CampaignService.create_product_spec(db, **payload)
return ProductSpecResponse.model_validate(spec)
@router.patch("/{campaign_id}/product-specs/{spec_id}", response_model=ProductSpecResponse)
async def update_product_spec(
campaign_id: str,
spec_id: str,
body: ProductSpecUpdate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
spec = await CampaignService.get_product_spec(db, spec_id)
if not spec or spec.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Product spec not found")
spec = await CampaignService.update_product_spec(db, spec, **body.model_dump(exclude_none=True))
return ProductSpecResponse.model_validate(spec)
@router.delete("/{campaign_id}/product-specs/{spec_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_product_spec(
campaign_id: str,
spec_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
spec = await CampaignService.get_product_spec(db, spec_id)
if not spec or spec.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Product spec not found")
await CampaignService.delete_product_spec(db, spec)
# ─────────────────────────────────────────────────────────────────────────────
# Material CRUD (R&D manages fabric/material for each spec)
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/{campaign_id}/materials", response_model=List[MaterialResponse])
async def list_materials(
campaign_id: str,
company_id: str = Query(...),
product_spec_id: Optional[str] = Query(None),
rd_approved: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db_session),
):
items = await CampaignService.list_materials(
db, company_id, campaign_id,
product_spec_id=product_spec_id, rd_approved=rd_approved
)
return [MaterialResponse.model_validate(i) for i in items]
@router.post("/{campaign_id}/materials", response_model=MaterialResponse,
status_code=status.HTTP_201_CREATED)
async def create_material(
campaign_id: str,
body: MaterialCreate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
payload = body.model_dump()
payload["campaign_id"] = campaign_id
mat = await CampaignService.create_material(db, **payload)
return MaterialResponse.model_validate(mat)
@router.patch("/{campaign_id}/materials/{material_id}", response_model=MaterialResponse)
async def update_material(
campaign_id: str,
material_id: str,
body: MaterialUpdate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
mat = await CampaignService.get_material(db, material_id)
if not mat or mat.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Material not found")
mat = await CampaignService.update_material(db, mat, **body.model_dump(exclude_none=True))
return MaterialResponse.model_validate(mat)
@router.delete("/{campaign_id}/materials/{material_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_material(
campaign_id: str,
material_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
mat = await CampaignService.get_material(db, material_id)
if not mat or mat.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Material not found")
await CampaignService.delete_material(db, mat)
@router.post("/{campaign_id}/materials/{material_id}/approve", response_model=MaterialResponse)
async def approve_material_rd(
campaign_id: str,
material_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
current_user = Depends(get_current_user),
):
mat = await CampaignService.get_material(db, material_id)
if not mat or mat.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Material not found")
agent_id = getattr(current_user, 'id', None)
mat = await CampaignService.approve_material(db, mat, approved_by_agent_id=agent_id or "")
return MaterialResponse.model_validate(mat)
# ─────────────────────────────────────────────────────────────────────────────
# Ad Plan CRUD (Marketing / Ads Manager)
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/{campaign_id}/ad-plans", response_model=List[AdPlanResponse])
async def list_ad_plans(
campaign_id: str,
company_id: str = Query(...),
platform: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db_session),
):
items = await CampaignService.list_ad_plans(db, company_id, campaign_id, platform, status)
return [AdPlanResponse.model_validate(i) for i in items]
@router.post("/{campaign_id}/ad-plans", response_model=AdPlanResponse,
status_code=status.HTTP_201_CREATED)
async def create_ad_plan(
campaign_id: str,
body: AdPlanCreate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
payload = body.model_dump()
payload["campaign_id"] = campaign_id
plan = await CampaignService.create_ad_plan(db, **payload)
return AdPlanResponse.model_validate(plan)
@router.patch("/{campaign_id}/ad-plans/{ad_plan_id}", response_model=AdPlanResponse)
async def update_ad_plan(
campaign_id: str,
ad_plan_id: str,
body: AdPlanUpdate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
plan = await CampaignService.get_ad_plan(db, ad_plan_id)
if not plan or plan.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Ad plan not found")
plan = await CampaignService.update_ad_plan(db, plan, **body.model_dump(exclude_none=True))
return AdPlanResponse.model_validate(plan)
@router.delete("/{campaign_id}/ad-plans/{ad_plan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ad_plan(
campaign_id: str,
ad_plan_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
plan = await CampaignService.get_ad_plan(db, ad_plan_id)
if not plan or plan.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Ad plan not found")
await CampaignService.delete_ad_plan(db, plan)
# ─────────────────────────────────────────────────────────────────────────────
# Budget Item CRUD (Finance / HR)
# ─────────────────────────────────────────────────────────────────────────────
@router.get("/{campaign_id}/budget-items", response_model=List[BudgetItemResponse])
async def list_budget_items(
campaign_id: str,
company_id: str = Query(...),
category: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db_session),
):
items = await CampaignService.list_budget_items(db, company_id, campaign_id, category, status)
return [BudgetItemResponse.model_validate(i) for i in items]
@router.post("/{campaign_id}/budget-items", response_model=BudgetItemResponse,
status_code=status.HTTP_201_CREATED)
async def create_budget_item(
campaign_id: str,
body: BudgetItemCreate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
payload = body.model_dump()
payload["campaign_id"] = campaign_id
item = await CampaignService.create_budget_item(db, **payload)
return BudgetItemResponse.model_validate(item)
@router.patch("/{campaign_id}/budget-items/{budget_id}", response_model=BudgetItemResponse)
async def update_budget_item(
campaign_id: str,
budget_id: str,
body: BudgetItemUpdate,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
item = await CampaignService.get_budget_item(db, budget_id)
if not item or item.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Budget item not found")
item = await CampaignService.update_budget_item(db, item, **body.model_dump(exclude_none=True))
return BudgetItemResponse.model_validate(item)
@router.delete("/{campaign_id}/budget-items/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_budget_item(
campaign_id: str,
budget_id: str,
scope: Optional[CompanyScope] = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
item = await CampaignService.get_budget_item(db, budget_id)
if not item or item.campaign_id != campaign_id:
raise HTTPException(status_code=404, detail="Budget item not found")
await CampaignService.delete_budget_item(db, item)
...@@ -10,6 +10,7 @@ from database import get_db_session ...@@ -10,6 +10,7 @@ from database import get_db_session
from middleware.auth import require_company_scope, get_current_user from middleware.auth import require_company_scope, get_current_user
from schemas.company import CompanyCreate, CompanyUpdate, CompanyResponse from schemas.company import CompanyCreate, CompanyUpdate, CompanyResponse
from schemas.blueprint import BlueprintSummary from schemas.blueprint import BlueprintSummary
from schemas.approval import ApprovalResponse
from schemas.common import PaginationParams, PaginatedResponse from schemas.common import PaginationParams, PaginatedResponse
from schemas.auth import AuthUserResponse from schemas.auth import AuthUserResponse
from schemas.common import CompanyScope from schemas.common import CompanyScope
...@@ -152,7 +153,9 @@ async def create_company_from_blueprint( ...@@ -152,7 +153,9 @@ async def create_company_from_blueprint(
budget_monthly_cents=request.budget_monthly_cents budget_monthly_cents=request.budget_monthly_cents
) )
# Provision NocoBase collections dynamically # Provision NocoBase collections dynamically (best-effort: company
# creation must NOT fail if NocoBase is down, but failures are logged
# LOUDLY — a dead NocoBase used to be swallowed as a silent warning).
try: try:
blueprint = BlueprintService.load_blueprint(request.blueprint_name) blueprint = BlueprintService.load_blueprint(request.blueprint_name)
from services.nocobase_connector import NocoBaseConnector from services.nocobase_connector import NocoBaseConnector
...@@ -160,16 +163,37 @@ async def create_company_from_blueprint( ...@@ -160,16 +163,37 @@ async def create_company_from_blueprint(
# NOTE: Operator must seed CompanySecret via separate admin API per-company # NOTE: Operator must seed CompanySecret via separate admin API per-company
# We no longer auto-seed global NOCOBASE_URL env-vars here. # We no longer auto-seed global NOCOBASE_URL env-vars here.
# Load connector
connector = await NocoBaseConnector.from_company(company.id, db) connector = await NocoBaseConnector.from_company(company.id, db)
collections_to_create = getattr(blueprint, "nocobase_collections", []) collections_to_create = getattr(blueprint, "nocobase_collections", [])
if collections_to_create:
if not await connector.ping():
logger.error(
"NocoBase UNREACHABLE at %s — skipped provisioning %d collection(s) "
"for company %s. ERP integration is DOWN; start NocoBase and re-provision.",
connector.base_url, len(collections_to_create), company.id,
)
else:
failed = []
for col_def in collections_to_create: for col_def in collections_to_create:
await connector.ensure_collection(col_def["name"], col_def.get("fields", [])) ok = await connector.ensure_collection(col_def["name"], col_def.get("fields", []))
if not ok:
failed.append(col_def["name"])
if failed:
logger.error(
"NocoBase provisioning FAILED for company %s collections: %s",
company.id, ", ".join(failed),
)
else:
logger.info(
"NocoBase provisioned %d collection(s) for company %s",
len(collections_to_create), company.id,
)
except Exception as e: except Exception as e:
logger.warning(f"Failed to provision NocoBase collections for company {company.id}: {e}") logger.error(
f"NocoBase provisioning errored for company {company.id}: {e}", exc_info=True
)
await db.commit() await db.commit()
return company return company
...@@ -254,6 +278,23 @@ async def update_company( ...@@ -254,6 +278,23 @@ async def update_company(
return company return company
@router.get("/{company_id}/approvals", response_model=List[ApprovalResponse])
async def list_company_approvals(
company_id: str,
status: Optional[str] = Query(None, description="Filter by resolution status"),
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
"""List approvals for a company (company-scoped path used by the frontend)."""
from models import Approval
stmt = select(Approval).where(Approval.company_id == company_id)
if status:
stmt = stmt.where(Approval.resolution == status)
stmt = stmt.order_by(Approval.created_at.desc())
result = await db.execute(stmt)
return result.scalars().all()
@router.get("/{company_id}/org") @router.get("/{company_id}/org")
async def get_company_org( async def get_company_org(
company_id: str, company_id: str,
...@@ -291,3 +332,179 @@ async def get_company_org( ...@@ -291,3 +332,179 @@ async def get_company_org(
return roots return roots
@router.get("/{company_id}/pipeline-runs")
async def list_pipeline_runs(
company_id: str,
db: AsyncSession = Depends(get_db_session),
scope: CompanyScope = Depends(require_company_scope),
):
"""List all pipeline runs and their summarized stats."""
from models import HeartbeatRun, Agent
# Get all runs for the company that have a pipeline_id
stmt = (
select(HeartbeatRun, Agent.name, Agent.role)
.join(Agent, HeartbeatRun.agent_id == Agent.id)
.where(HeartbeatRun.company_id == company_id)
.where(HeartbeatRun.pipeline_id.is_not(None))
.order_by(HeartbeatRun.started_at.desc())
)
res = await db.execute(stmt)
rows = res.all()
# Group by pipeline_id in Python
pipeline_runs = {}
for run, agent_name, agent_role in rows:
pid = run.pipeline_id
if pid not in pipeline_runs:
pipeline_runs[pid] = {
"pipeline_id": pid,
"status": "completed",
"started_at": run.started_at,
"completed_at": run.completed_at,
"duration_s": 0.0,
"steps": [],
"agents_executed": 0,
"agents_succeeded": 0,
"agents_failed": 0,
}
# Update started_at / completed_at
if run.started_at < pipeline_runs[pid]["started_at"]:
pipeline_runs[pid]["started_at"] = run.started_at
if run.completed_at:
if not pipeline_runs[pid]["completed_at"] or run.completed_at > pipeline_runs[pid]["completed_at"]:
pipeline_runs[pid]["completed_at"] = run.completed_at
pipeline_runs[pid]["steps"].append({
"id": run.id,
"agent_name": agent_name,
"role": agent_role,
"status": run.status,
"step": run.pipeline_step,
"started_at": run.started_at,
"completed_at": run.completed_at,
"error": run.error_message,
})
# Post-process stats per pipeline run
for pid, data in pipeline_runs.items():
steps = sorted(data["steps"], key=lambda s: s["step"] or 0)
data["steps"] = steps
data["agents_executed"] = len(steps)
data["agents_succeeded"] = sum(1 for s in steps if s["status"] == "completed")
data["agents_failed"] = sum(1 for s in steps if s["status"] == "failed")
# Compute overall status
if any(s["status"] == "running" for s in steps):
data["status"] = "running"
elif any(s["status"] == "failed" for s in steps):
data["status"] = "failed"
elif any(s["status"] == "cancelled" for s in steps):
data["status"] = "cancelled"
else:
data["status"] = "completed"
if data["completed_at"] and data["started_at"]:
data["duration_s"] = round((data["completed_at"] - data["started_at"]).total_seconds(), 1)
else:
data["duration_s"] = round((datetime.utcnow() - data["started_at"]).total_seconds(), 1)
# Return sorted by started_at desc
return sorted(pipeline_runs.values(), key=lambda x: x["started_at"], reverse=True)
@router.get("/{company_id}/pipeline-runs/{pipeline_id}")
async def get_pipeline_run(
company_id: str,
pipeline_id: str,
db: AsyncSession = Depends(get_db_session),
scope: CompanyScope = Depends(require_company_scope),
):
"""Get the detail of a single pipeline run."""
from models import HeartbeatRun, Agent
stmt = (
select(HeartbeatRun, Agent.name, Agent.role)
.join(Agent, HeartbeatRun.agent_id == Agent.id)
.where(HeartbeatRun.company_id == company_id)
.where(HeartbeatRun.pipeline_id == pipeline_id)
.order_by(HeartbeatRun.pipeline_step.asc())
)
res = await db.execute(stmt)
rows = res.all()
if not rows:
raise HTTPException(status_code=404, detail="Pipeline run not found")
steps = []
started_at = None
completed_at = None
for run, agent_name, agent_role in rows:
if not started_at or run.started_at < started_at:
started_at = run.started_at
if run.completed_at:
if not completed_at or run.completed_at > completed_at:
completed_at = run.completed_at
steps.append({
"id": run.id,
"agent_name": agent_name,
"role": agent_role,
"status": run.status,
"step": run.pipeline_step,
"started_at": run.started_at,
"completed_at": run.completed_at,
"error": run.error_message,
"execution_cost_cents": run.execution_cost_cents,
})
status = "completed"
if any(s["status"] == "running" for s in steps):
status = "running"
elif any(s["status"] == "failed" for s in steps):
status = "failed"
elif any(s["status"] == "cancelled" for s in steps):
status = "cancelled"
duration_s = 0.0
if completed_at and started_at:
duration_s = round((completed_at - started_at).total_seconds(), 1)
elif started_at:
duration_s = round((datetime.utcnow() - started_at).total_seconds(), 1)
return {
"pipeline_id": pipeline_id,
"status": status,
"started_at": started_at,
"completed_at": completed_at,
"duration_s": duration_s,
"steps": steps,
"agents_executed": len(steps),
"agents_succeeded": sum(1 for s in steps if s["status"] == "completed"),
"agents_failed": sum(1 for s in steps if s["status"] == "failed"),
}
@router.get("/{company_id}/shared/{filename}")
async def get_shared_file(
company_id: str,
filename: str,
db: AsyncSession = Depends(get_db_session),
scope: CompanyScope = Depends(require_company_scope),
):
"""Serve files from the company's shared workspace folder."""
import os
from fastapi.responses import FileResponse
from pathlib import Path
# Path security check: verify filename has no path traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
shared_dir = Path("workspaces") / company_id / "shared"
file_path = shared_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(file_path.resolve()))
...@@ -84,6 +84,7 @@ async def list_issues( ...@@ -84,6 +84,7 @@ async def list_issues(
status: Optional[str] = Query(None, description="Filter by status"), status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"), priority: Optional[str] = Query(None, description="Filter by priority"),
assignee_agent_id: Optional[str] = Query(None, description="Filter by assignee"), assignee_agent_id: Optional[str] = Query(None, description="Filter by assignee"),
parent_id: Optional[str] = Query(None, description="Filter by parent issue"),
pagination: PaginationParams = Depends(), pagination: PaginationParams = Depends(),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user) current_user: AuthUserResponse = Depends(get_current_user)
...@@ -104,6 +105,8 @@ async def list_issues( ...@@ -104,6 +105,8 @@ async def list_issues(
stmt = stmt.where(Issue.priority == priority) stmt = stmt.where(Issue.priority == priority)
if assignee_agent_id: if assignee_agent_id:
stmt = stmt.where(Issue.assignee_agent_id == assignee_agent_id) stmt = stmt.where(Issue.assignee_agent_id == assignee_agent_id)
if parent_id:
stmt = stmt.where(Issue.parent_id == parent_id)
stmt = stmt.order_by(Issue.created_at.desc()).offset(pagination.offset).limit(pagination.limit) stmt = stmt.order_by(Issue.created_at.desc()).offset(pagination.offset).limit(pagination.limit)
result = await db.execute(stmt) result = await db.execute(stmt)
......
name: "Canifa Fashion Company"
industry: "fashion"
description: "Giả lập công ty thời trang Canifa hoàn chỉnh với 7 phòng ban, 13 agents, pipeline DAG 7 stages. Từ CEO → Publisher đẩy bài lên Facebook."
roles:
- role: "ceo"
name: "CEO Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "strategic direction, weekly reporting, approval, corporate oversight"
reports_to: null
- role: "pm"
name: "Product Manager Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "specifications writing, project tracking, requirements gathering, PRD creation"
reports_to: "ceo"
- role: "rd"
name: "R&D Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "trend analysis, fashion market research, competitor analysis, fabric database"
reports_to: "pm"
- role: "designer"
name: "Designer Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "mockups, collection concept art, banner design, poster generation"
reports_to: "pm"
- role: "finance"
name: "Finance Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "bookkeeping, profit and loss reports, invoice verification, budgeting"
reports_to: "pm"
- role: "hr"
name: "HR Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "employee onboarding, recruiting, payroll coordination, time-off requests"
reports_to: "pm"
- role: "content"
name: "Content Creator Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "facebook post writing, ad copy, social media content, brand voice"
reports_to: "pm"
- role: "ads"
name: "Ads Manager Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "ad budget allocation, campaign planning, performance tracking"
reports_to: "pm"
- role: "ecom"
name: "E-commerce Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "product listing description, SEO keywords, review sentiment monitoring"
reports_to: "pm"
- role: "coder"
name: "IT Dev Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "full-stack development, bug fixing, test scripts, automated deployments"
reports_to: "pm"
- role: "qa"
name: "QA Specialist Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "review specifications, run test cases, report bugs, content review"
reports_to: "pm"
- role: "publisher"
name: "Publisher Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "facebook posting, content publishing, deployment"
reports_to: "pm"
pipeline:
- role: "ceo"
depends_on: []
approval_gate: false
- role: "pm"
depends_on: ["ceo"]
approval_gate: false
- role: "rd"
depends_on: ["pm"]
approval_gate: false
- role: "designer"
depends_on: ["pm"]
approval_gate: false
- role: "finance"
depends_on: ["pm"]
approval_gate: false
- role: "hr"
depends_on: ["pm"]
approval_gate: false
- role: "content"
depends_on: ["designer", "rd"]
approval_gate: false
- role: "ads"
depends_on: ["finance", "rd"]
approval_gate: false
- role: "ecom"
depends_on: ["designer"]
approval_gate: false
- role: "coder"
depends_on: ["designer", "content"]
approval_gate: false
- role: "qa"
depends_on: ["coder", "content", "ecom"]
approval_gate: false
- role: "publisher"
depends_on: ["qa"]
approval_gate: true
teams:
- id: executive
name: "Ban Giám Đốc"
roles: [ceo]
- id: product
name: "Phòng Sản Phẩm"
roles: [pm, designer, rd]
- id: marketing
name: "Phòng Marketing"
roles: [content, ads, publisher]
- id: sales
name: "Phòng Kinh Doanh"
roles: [ecom]
- id: finance_team
name: "Phòng Tài Chính"
roles: [finance]
- id: hr_team
name: "Phòng Nhân Sự"
roles: [hr]
- id: engineering
name: "Phòng IT"
roles: [coder, qa]
plugins:
- plugin: nocobase_erp
teams: [all]
secrets:
- key: NOCOBASE_URL
- key: NOCOBASE_EMAIL
- key: NOCOBASE_PASSWORD
- plugin: facebook_pages
teams: [marketing]
secrets:
- key: FACEBOOK_PAGE_ACCESS_TOKEN
- key: FACEBOOK_PAGE_ID
nocobase_collections:
- name: "products"
title: "📦 Products"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Product Title"
x-component: "Input"
- name: "outfits"
title: "👗 Outfits"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Outfit Title"
x-component: "Input"
- name: "campaigns"
title: "📣 Campaigns"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Campaign Title"
x-component: "Input"
...@@ -34,3 +34,12 @@ celery_app.conf.update( ...@@ -34,3 +34,12 @@ celery_app.conf.update(
# Auto-discover tasks in tasks/ directory # Auto-discover tasks in tasks/ directory
celery_app.autodiscover_tasks(["tasks"]) celery_app.autodiscover_tasks(["tasks"])
# Periodic task beat schedules
celery_app.conf.beat_schedule = {
"check-routines-every-minute": {
"task": "tasks.check_scheduled_routines",
"schedule": 60.0,
}
}
...@@ -67,6 +67,12 @@ class Settings(BaseSettings): ...@@ -67,6 +67,12 @@ class Settings(BaseSettings):
openai_api_key: Optional[str] = Field(default=None, env="OPENAI_API_KEY") openai_api_key: Optional[str] = Field(default=None, env="OPENAI_API_KEY")
openai_base_url: Optional[str] = Field(default=None, env="OPENAI_BASE_URL") openai_base_url: Optional[str] = Field(default=None, env="OPENAI_BASE_URL")
# NocoBase ERP
nocobase_url: Optional[str] = Field(default=None, env="NOCOBASE_URL")
nocobase_email: Optional[str] = Field(default=None, env="NOCOBASE_EMAIL")
nocobase_password: Optional[str] = Field(default=None, env="NOCOBASE_PASSWORD")
# Redis configuration # Redis configuration
redis_cache_url: str = Field(default="localhost", env="REDIS_CACHE_URL") redis_cache_url: str = Field(default="localhost", env="REDIS_CACHE_URL")
......
...@@ -54,8 +54,17 @@ from .plugin_config import PluginConfig ...@@ -54,8 +54,17 @@ from .plugin_config import PluginConfig
from .plugin_jobs import PluginJob from .plugin_jobs import PluginJob
from .sidebar_preferences import SidebarPreference from .sidebar_preferences import SidebarPreference
from .cli_auth_challenges import CliAuthChallenge from .cli_auth_challenges import CliAuthChallenge
from .teams import Team
# ── Summer Camp 2026 Campaign models ────────────────────────────────────────
from .campaigns import Campaign
from .product_specs import ProductSpec
from .materials import Material
from .ad_plans import AdPlan
from .budget_items import BudgetItem
__all__ = [ __all__ = [
"Team",
"CliAuthChallenge", "CliAuthChallenge",
"Base", "Base",
"async_engine", "async_engine",
...@@ -111,4 +120,10 @@ __all__ = [ ...@@ -111,4 +120,10 @@ __all__ = [
"PluginConfig", "PluginConfig",
"PluginJob", "PluginJob",
"SidebarPreference", "SidebarPreference",
# Campaign models
"Campaign",
"ProductSpec",
"Material",
"AdPlan",
"BudgetItem",
] ]
"""
Ad Plan / Campaign Marketing Plan — drafted by Marketing, executed by Ads Manager.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, Integer, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from .database import Base
class AdPlan(Base):
__tablename__ = "ad_plans"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: __import__("uuid").uuid4().hex
)
company_id: Mapped[str] = mapped_column(
String(36), ForeignKey("companies.id"), nullable=False, index=True
)
campaign_id: Mapped[str] = mapped_column(
String(36), ForeignKey("campaigns.id"), nullable=False, index=True
)
created_by_agent_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("agents.id"), nullable=True
)
# identity
name: Mapped[str] = mapped_column(String(255), nullable=False)
platform: Mapped[str] = mapped_column(
String(40), nullable=False
) # facebook | instagram | tiktok | google
objective: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="draft"
) # draft | scheduled | running | paused | completed
# content brief
ad_copy: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
creative_brief: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
target_audience: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
hashtags: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# schedule
scheduled_start: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
scheduled_end: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# budget (synced from budget_items)
allocated_budget_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
spent_vnd: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# performance
impressions: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
clicks: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
conversions: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
revenue_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
extra: Mapped[dict] = mapped_column("extra", JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
...@@ -16,6 +16,7 @@ class Agent(Base): ...@@ -16,6 +16,7 @@ class Agent(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex)
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False) company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False)
team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("teams.id"), nullable=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="general") role: Mapped[str] = mapped_column(String(50), nullable=False, default="general")
title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
......
"""
Budget Item — detailed cost breakdown per campaign (Finance / HR).
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, Integer
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from .database import Base
class BudgetItem(Base):
__tablename__ = "budget_items"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: __import__("uuid").uuid4().hex
)
company_id: Mapped[str] = mapped_column(
String(36), ForeignKey("companies.id"), nullable=False, index=True
)
campaign_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("campaigns.id"), nullable=True, index=True
)
product_spec_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("product_specs.id"), nullable=True
)
ad_plan_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("ad_plans.id"), nullable=True
)
created_by_agent_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("agents.id"), nullable=True
)
# identity
category: Mapped[str] = mapped_column(String(60), nullable=False)
# categories: production | material | ad_spend | design | logistics | hr | other
label: Mapped[str] = mapped_column(String(255), nullable=False)
# amounts (VND, integer — no float precision loss)
estimated_vnd: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
actual_vnd: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# tracking
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="planned"
) # planned | approved | committed | paid | cancelled
approved_by_agent_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("agents.id"), nullable=True
)
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
attachments: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
extra: Mapped[dict] = mapped_column("extra", JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
"""
Campaign model — Summer Camp 2026.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, Integer
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from .database import Base
class Campaign(Base):
__tablename__ = "campaigns"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: __import__("uuid").uuid4().hex
)
company_id: Mapped[str] = mapped_column(
String(36), ForeignKey("companies.id"), nullable=False
)
created_by_agent_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("agents.id"), nullable=True
)
# identity
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
season: Mapped[str] = mapped_column(String(50), nullable=False)
year: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="draft", index=True
) # draft | approved | active | paused | completed | archived
# branding
tagline: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
theme_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
visual_brief: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# schedule
launch_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
publish_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# budget envelope
total_budget_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="VND")
# targets
target_revenue_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
target_units: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
target_roi: Mapped[Optional[float]] = mapped_column(Integer, nullable=True) # x100
# rich metadata
extra: Mapped[dict] = mapped_column("extra", JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
...@@ -31,5 +31,7 @@ class Company(Base): ...@@ -31,5 +31,7 @@ class Company(Base):
feedback_data_sharing_consent_by_user_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) feedback_data_sharing_consent_by_user_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
feedback_data_sharing_terms_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) feedback_data_sharing_terms_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
brand_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) brand_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
blueprint_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
...@@ -13,11 +13,12 @@ from typing import Optional ...@@ -13,11 +13,12 @@ from typing import Optional
class CompanySecret(Base): class CompanySecret(Base):
__tablename__ = "company_secrets" __tablename__ = "company_secrets"
__table_args__ = ( __table_args__ = (
UniqueConstraint('company_id', 'key', name='uq_company_secrets_company_key'), UniqueConstraint('company_id', 'team_id', 'key', name='uq_company_secrets_company_key'),
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex)
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False) company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False)
team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("teams.id"), nullable=True)
key: Mapped[str] = mapped_column(String(255), nullable=False) key: Mapped[str] = mapped_column(String(255), nullable=False)
value_encrypted: Mapped[str] = mapped_column(Text, nullable=False) # encrypted value value_encrypted: Mapped[str] = mapped_column(Text, nullable=False) # encrypted value
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
......
...@@ -15,6 +15,7 @@ class CompanySkill(Base): ...@@ -15,6 +15,7 @@ class CompanySkill(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex)
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False) company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False)
team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("teams.id"), nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
level: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # 1-100 scale level: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # 1-100 scale
......
...@@ -26,5 +26,7 @@ class HeartbeatRun(Base): ...@@ -26,5 +26,7 @@ class HeartbeatRun(Base):
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
execution_cost_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) execution_cost_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
execution_cost_currency: Mapped[str] = mapped_column(String(10), nullable=False, default="USD") execution_cost_currency: Mapped[str] = mapped_column(String(10), nullable=False, default="USD")
pipeline_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
pipeline_step: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
"""
Material / Fabric record — used by R&D to specify fabric for each product spec.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, Integer, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from .database import Base
class Material(Base):
__tablename__ = "materials"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: __import__("uuid").uuid4().hex
)
company_id: Mapped[str] = mapped_column(
String(36), ForeignKey("companies.id"), nullable=False, index=True
)
campaign_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("campaigns.id"), nullable=True, index=True
)
product_spec_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("product_specs.id"), nullable=True, index=True
)
created_by_agent_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("agents.id"), nullable=True
)
# identity
name: Mapped[str] = mapped_column(String(255), nullable=False)
material_type: Mapped[str] = mapped_column(
String(60), nullable=False
) # fabric | button | zipper | label | thread | packaging
sub_type: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
sku: Mapped[Optional[str]] = mapped_column(String(60), nullable=True)
# properties
composition: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
weight_gsm: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
origin: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
color: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
finish: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# supply
supplier: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
moq: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
unit_cost_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
lead_time_days: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
stock_qty: Mapped[Optional[int]] = mapped_column(Integer, nullable=False, default=0)
# approval
rd_approved: Mapped[bool] = mapped_column(Integer, nullable=False, default=0)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
extra: Mapped[dict] = mapped_column("extra", JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
...@@ -15,6 +15,7 @@ class Memory(Base): ...@@ -15,6 +15,7 @@ class Memory(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex)
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False) company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False)
team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("teams.id"), nullable=True)
agent_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("agents.id"), nullable=True) agent_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("agents.id"), nullable=True)
issue_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("issues.id"), nullable=True) issue_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("issues.id"), nullable=True)
namespace: Mapped[str] = mapped_column(String(100), nullable=False) namespace: Mapped[str] = mapped_column(String(100), nullable=False)
......
...@@ -15,6 +15,7 @@ class PluginConfig(Base): ...@@ -15,6 +15,7 @@ class PluginConfig(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex)
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False) company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False)
team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("teams.id"), nullable=True)
plugin_id: Mapped[str] = mapped_column(String(36), ForeignKey("plugins.id"), nullable=False) plugin_id: Mapped[str] = mapped_column(String(36), ForeignKey("plugins.id"), nullable=False)
config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
......
"""
Product Specification model — per-product detail for campaigns.
"""
from sqlalchemy import String, Text, DateTime, ForeignKey, JSON, Integer, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from .database import Base
class ProductSpec(Base):
__tablename__ = "product_specs"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: __import__("uuid").uuid4().hex
)
company_id: Mapped[str] = mapped_column(
String(36), ForeignKey("companies.id"), nullable=False, index=True
)
campaign_id: Mapped[str] = mapped_column(
String(36), ForeignKey("campaigns.id"), nullable=False, index=True
)
created_by_agent_id: Mapped[Optional[str]] = mapped_column(
String(36), ForeignKey("agents.id"), nullable=True
)
# identity
name: Mapped[str] = mapped_column(String(255), nullable=False)
sku: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
product_type: Mapped[str] = mapped_column(
String(40), nullable=False
) # graphic_tshirt | linen_shorts
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="draft"
) # draft | approved | in_production | ready | discontinued
# designer spec
size_chart: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
colorways: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
style_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
design_files: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# pricing
cost_price_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
retail_price_vnd: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# inventory targets
production_qty: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
stock_safety_qty: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, default=0)
extra: Mapped[dict] = mapped_column("extra", JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
"""
Teams model.
"""
from sqlalchemy import String, ForeignKey, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from .database import Base
class Team(Base):
__tablename__ = "teams"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: __import__('uuid').uuid4().hex)
company_id: Mapped[str] = mapped_column(String(36), ForeignKey("companies.id"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
parent_team_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey("teams.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
...@@ -41,6 +41,12 @@ from .heartbeat_run_event import * ...@@ -41,6 +41,12 @@ from .heartbeat_run_event import *
from .vote import * from .vote import *
from .blueprint import * from .blueprint import *
from .common import TimestampMixin from .common import TimestampMixin
from .campaign import *
from .product_spec import *
from .material import *
from .ad_plan import *
from .budget_item import *
__all__ = [ __all__ = [
...@@ -180,4 +186,24 @@ __all__ = [ ...@@ -180,4 +186,24 @@ __all__ = [
"BlueprintRole", "BlueprintRole",
"BlueprintPipelineNode", "BlueprintPipelineNode",
"CompanyBlueprint", "CompanyBlueprint",
"CampaignBase",
"CampaignCreate",
"CampaignUpdate",
"CampaignResponse",
"ProductSpecBase",
"ProductSpecCreate",
"ProductSpecUpdate",
"ProductSpecResponse",
"MaterialBase",
"MaterialCreate",
"MaterialUpdate",
"MaterialResponse",
"AdPlanBase",
"AdPlanCreate",
"AdPlanUpdate",
"AdPlanResponse",
"BudgetItemBase",
"BudgetItemCreate",
"BudgetItemUpdate",
"BudgetItemResponse",
] ]
\ No newline at end of file
"""
Ad Plan schemas.
"""
from datetime import datetime
from typing import Optional, Any, List
from pydantic import BaseModel, ConfigDict, Field
from .common import TimestampMixin
class AdPlanBase(BaseModel):
company_id: str
campaign_id: str
created_by_agent_id: Optional[str] = None
name: str = Field(..., min_length=1, max_length=255)
platform: str = Field(..., pattern="^(facebook|instagram|tiktok|google|other)$")
objective: Optional[str] = Field(None, max_length=80)
status: str = Field("draft", pattern="^(draft|scheduled|running|paused|completed)$")
ad_copy: Optional[str] = None
creative_brief: Optional[str] = None
target_audience: Optional[dict] = None
hashtags: list = Field(default_factory=list)
scheduled_start: Optional[datetime] = None
scheduled_end: Optional[datetime] = None
allocated_budget_vnd: Optional[int] = Field(None, ge=0)
spent_vnd: int = 0
impressions: Optional[int] = Field(None, ge=0)
clicks: Optional[int] = Field(None, ge=0)
conversions: Optional[int] = Field(None, ge=0)
revenue_vnd: Optional[int] = Field(None, ge=0)
extra: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class AdPlanCreate(AdPlanBase):
model_config = ConfigDict(from_attributes=True)
class AdPlanUpdate(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: Optional[str] = Field(None, min_length=1, max_length=255)
platform: Optional[str] = Field(None, pattern="^(facebook|instagram|tiktok|google|other)$")
objective: Optional[str] = Field(None, max_length=80)
status: Optional[str] = Field(None, pattern="^(draft|scheduled|running|paused|completed)$")
ad_copy: Optional[str] = None
creative_brief: Optional[str] = None
target_audience: Optional[dict] = None
hashtags: Optional[list] = None
scheduled_start: Optional[datetime] = None
scheduled_end: Optional[datetime] = None
allocated_budget_vnd: Optional[int] = Field(None, ge=0)
spent_vnd: Optional[int] = Field(None, ge=0)
impressions: Optional[int] = Field(None, ge=0)
clicks: Optional[int] = Field(None, ge=0)
conversions: Optional[int] = Field(None, ge=0)
revenue_vnd: Optional[int] = Field(None, ge=0)
extra: Optional[dict] = None
class AdPlanResponse(AdPlanBase, TimestampMixin):
id: str
...@@ -10,7 +10,7 @@ from .common import TimestampMixin ...@@ -10,7 +10,7 @@ from .common import TimestampMixin
class ApprovalBase(BaseModel): class ApprovalBase(BaseModel):
company_id: str company_id: str
resource_type: str = Field(..., pattern="^(agent|issue|budget)$") resource_type: str = Field(..., pattern="^(agent|issue|budget|pipeline_stage)$")
resource_id: str resource_id: str
required_approvers: int = Field(1, ge=1) required_approvers: int = Field(1, ge=1)
created_by: Optional[str] = None created_by: Optional[str] = None
...@@ -29,6 +29,9 @@ class ApprovalUpdate(BaseModel): ...@@ -29,6 +29,9 @@ class ApprovalUpdate(BaseModel):
class ApprovalResponse(ApprovalBase, TimestampMixin): class ApprovalResponse(ApprovalBase, TimestampMixin):
id: str id: str
# Approval model has no updated_at column — override the mixin's required field
updated_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None resolved_at: Optional[datetime] = None
resolution: Optional[str] = None resolution: Optional[str] = None
resolved_by: Optional[str] = None resolved_by: Optional[str] = None
resolution_note: Optional[str] = None
\ No newline at end of file
...@@ -33,6 +33,9 @@ class CompanyBlueprint(BaseModel): ...@@ -33,6 +33,9 @@ class CompanyBlueprint(BaseModel):
roles: List[BlueprintRole] = Field(default_factory=list) roles: List[BlueprintRole] = Field(default_factory=list)
pipeline: List[BlueprintPipelineNode] = Field(default_factory=list) pipeline: List[BlueprintPipelineNode] = Field(default_factory=list)
nocobase_collections: List[Dict] = Field(default_factory=list) nocobase_collections: List[Dict] = Field(default_factory=list)
plugins: Optional[List[Dict[str, Any]]] = Field(default_factory=list)
skills: Optional[List[Any]] = Field(default_factory=list)
env_vars: Optional[Dict[str, str]] = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
......
"""
Budget Item schemas.
"""
from datetime import datetime
from typing import Optional, Any, List
from pydantic import BaseModel, ConfigDict, Field
from .common import TimestampMixin
class BudgetItemBase(BaseModel):
company_id: str
campaign_id: Optional[str] = None
product_spec_id: Optional[str] = None
ad_plan_id: Optional[str] = None
created_by_agent_id: Optional[str] = None
category: str = Field(..., pattern="^(production|material|ad_spend|design|logistics|hr|other)$")
label: str = Field(..., min_length=1, max_length=255)
estimated_vnd: int = Field(..., ge=0)
actual_vnd: int = Field(0, ge=0)
status: str = Field("planned", pattern="^(planned|approved|committed|paid|cancelled)$")
approved_by_agent_id: Optional[str] = None
approved_at: Optional[datetime] = None
notes: Optional[str] = None
attachments: list = Field(default_factory=list)
extra: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class BudgetItemCreate(BudgetItemBase):
model_config = ConfigDict(from_attributes=True)
class BudgetItemUpdate(BaseModel):
model_config = ConfigDict(from_attributes=True)
campaign_id: Optional[str] = None
product_spec_id: Optional[str] = None
ad_plan_id: Optional[str] = None
category: Optional[str] = Field(None, pattern="^(production|material|ad_spend|design|logistics|hr|other)$")
label: Optional[str] = Field(None, min_length=1, max_length=255)
estimated_vnd: Optional[int] = Field(None, ge=0)
actual_vnd: Optional[int] = Field(None, ge=0)
status: Optional[str] = Field(None, pattern="^(planned|approved|committed|paid|cancelled)$")
approved_by_agent_id: Optional[str] = None
approved_at: Optional[datetime] = None
notes: Optional[str] = None
attachments: Optional[list] = None
extra: Optional[dict] = None
class BudgetItemResponse(BudgetItemBase, TimestampMixin):
id: str
"""
Campaign schemas.
"""
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel, ConfigDict, Field
from .common import TimestampMixin
class CampaignBase(BaseModel):
company_id: str
created_by_agent_id: Optional[str] = None
name: str = Field(..., min_length=1, max_length=255)
slug: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")
season: str = Field(..., min_length=1, max_length=50)
year: int = Field(..., ge=2000, le=2100)
status: str = Field("draft", pattern="^(draft|approved|active|paused|completed|archived)$")
tagline: Optional[str] = Field(None, max_length=500)
theme_color: Optional[str] = Field(None, max_length=20)
description: Optional[str] = None
visual_brief: Optional[str] = None
launch_date: Optional[datetime] = None
end_date: Optional[datetime] = None
publish_date: Optional[datetime] = None
total_budget_vnd: Optional[int] = Field(None, ge=0)
currency: str = "VND"
target_revenue_vnd: Optional[int] = Field(None, ge=0)
target_units: Optional[int] = Field(None, ge=0)
target_roi: Optional[float] = Field(None, ge=0)
extra: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class CampaignCreate(CampaignBase):
model_config = ConfigDict(from_attributes=True)
class CampaignUpdate(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: Optional[str] = Field(None, min_length=1, max_length=255)
slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")
season: Optional[str] = Field(None, min_length=1, max_length=50)
year: Optional[int] = Field(None, ge=2000, le=2100)
status: Optional[str] = Field(None, pattern="^(draft|approved|active|paused|completed|archived)$")
tagline: Optional[str] = Field(None, max_length=500)
theme_color: Optional[str] = Field(None, max_length=20)
description: Optional[str] = None
visual_brief: Optional[str] = None
launch_date: Optional[datetime] = None
end_date: Optional[datetime] = None
publish_date: Optional[datetime] = None
total_budget_vnd: Optional[int] = Field(None, ge=0)
target_revenue_vnd: Optional[int] = Field(None, ge=0)
target_units: Optional[int] = Field(None, ge=0)
target_roi: Optional[float] = Field(None, ge=0)
extra: Optional[dict] = None
class CampaignResponse(CampaignBase, TimestampMixin):
id: str
...@@ -14,6 +14,8 @@ class CompanyBase(BaseModel): ...@@ -14,6 +14,8 @@ class CompanyBase(BaseModel):
issue_prefix: Optional[str] = Field(None, min_length=2, max_length=10) issue_prefix: Optional[str] = Field(None, min_length=2, max_length=10)
budget_monthly_cents: int = Field(0, ge=0) budget_monthly_cents: int = Field(0, ge=0)
require_board_approval_for_new_agents: bool = False require_board_approval_for_new_agents: bool = False
blueprint_name: Optional[str] = None
class CompanyCreate(CompanyBase): class CompanyCreate(CompanyBase):
......
"""
Material schemas.
"""
from datetime import datetime
from typing import Optional, Any
from pydantic import BaseModel, ConfigDict, Field
from .common import TimestampMixin
class MaterialBase(BaseModel):
company_id: str
campaign_id: Optional[str] = None
product_spec_id: Optional[str] = None
created_by_agent_id: Optional[str] = None
name: str = Field(..., min_length=1, max_length=255)
material_type: str = Field(..., pattern="^(fabric|button|zipper|label|thread|packaging|other)$")
sub_type: Optional[str] = Field(None, max_length=80)
sku: Optional[str] = Field(None, max_length=60)
composition: Optional[str] = Field(None, max_length=200)
weight_gsm: Optional[int] = Field(None, ge=0)
origin: Optional[str] = Field(None, max_length=100)
color: Optional[str] = Field(None, max_length=40)
finish: Optional[dict] = None
supplier: Optional[str] = Field(None, max_length=200)
moq: Optional[int] = Field(None, ge=0)
unit_cost_vnd: Optional[int] = Field(None, ge=0)
lead_time_days: Optional[int] = Field(None, ge=0)
stock_qty: int = 0
rd_approved: bool = False
notes: Optional[str] = None
extra: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class MaterialCreate(MaterialBase):
model_config = ConfigDict(from_attributes=True)
class MaterialUpdate(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: Optional[str] = Field(None, min_length=1, max_length=255)
material_type: Optional[str] = Field(None, pattern="^(fabric|button|zipper|label|thread|packaging|other)$")
sub_type: Optional[str] = Field(None, max_length=80)
sku: Optional[str] = Field(None, max_length=60)
composition: Optional[str] = Field(None, max_length=200)
weight_gsm: Optional[int] = Field(None, ge=0)
origin: Optional[str] = Field(None, max_length=100)
color: Optional[str] = Field(None, max_length=40)
finish: Optional[dict] = None
supplier: Optional[str] = Field(None, max_length=200)
moq: Optional[int] = Field(None, ge=0)
unit_cost_vnd: Optional[int] = Field(None, ge=0)
lead_time_days: Optional[int] = Field(None, ge=0)
stock_qty: Optional[int] = None
rd_approved: Optional[bool] = None
notes: Optional[str] = None
extra: Optional[dict] = None
class MaterialResponse(MaterialBase, TimestampMixin):
id: str
"""
Product Specification schemas.
"""
from datetime import datetime
from typing import Optional, Any, List
from pydantic import BaseModel, ConfigDict, Field
from .common import TimestampMixin
class ProductSpecBase(BaseModel):
company_id: str
campaign_id: str
created_by_agent_id: Optional[str] = None
name: str = Field(..., min_length=1, max_length=255)
sku: str = Field(..., min_length=1, max_length=60)
product_type: str = Field(..., pattern="^(graphic_tshirt|linen_shorts)$")
status: str = Field("draft", pattern="^(draft|approved|in_production|ready|discontinued)$")
size_chart: Optional[dict] = None
colorways: list = Field(default_factory=list)
style_notes: Optional[str] = None
design_files: list = Field(default_factory=list)
cost_price_vnd: Optional[int] = Field(None, ge=0)
retail_price_vnd: Optional[int] = Field(None, ge=0)
production_qty: Optional[int] = Field(None, ge=0)
stock_safety_qty: int = 0
extra: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class ProductSpecCreate(ProductSpecBase):
model_config = ConfigDict(from_attributes=True)
class ProductSpecUpdate(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: Optional[str] = Field(None, min_length=1, max_length=255)
sku: Optional[str] = Field(None, min_length=1, max_length=60)
product_type: Optional[str] = Field(None, pattern="^(graphic_tshirt|linen_shorts)$")
status: Optional[str] = Field(None, pattern="^(draft|approved|in_production|ready|discontinued)$")
size_chart: Optional[dict] = None
colorways: Optional[list] = None
style_notes: Optional[str] = None
design_files: Optional[list] = None
cost_price_vnd: Optional[int] = Field(None, ge=0)
retail_price_vnd: Optional[int] = Field(None, ge=0)
production_qty: Optional[int] = Field(None, ge=0)
stock_safety_qty: Optional[int] = None
extra: Optional[dict] = None
class ProductSpecResponse(ProductSpecBase, TimestampMixin):
id: str
...@@ -64,6 +64,22 @@ async def lifespan(app: FastAPI): ...@@ -64,6 +64,22 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to start MCP clients: {e}", exc_info=True) logger.error(f"❌ Failed to start MCP clients: {e}", exc_info=True)
# Start Heartbeat Scheduler Loop if enabled (fallback/primary scheduler when Celery Beat is not active)
if settings.heartbeat_scheduler_enabled:
async def scheduler_loop():
await asyncio.sleep(5)
from services.routine_scheduler import RoutineSchedulerService
logger.info("⏰ Heartbeat Routine Scheduler Loop started")
while True:
try:
await RoutineSchedulerService.tick()
except Exception as e:
logger.error(f"❌ Error in routine scheduler tick: {e}", exc_info=True)
interval_s = getattr(settings, 'heartbeat_scheduler_interval_ms', 1000) / 1000.0
await asyncio.sleep(max(interval_s, 1.0))
asyncio.create_task(scheduler_loop())
yield yield
# ── Shutdown ── # ── Shutdown ──
...@@ -177,15 +193,22 @@ class CompanyPathRewriteMiddleware: ...@@ -177,15 +193,22 @@ class CompanyPathRewriteMiddleware:
if scope["type"] in ("http", "websocket"): if scope["type"] in ("http", "websocket"):
path = scope.get("path", "") path = scope.get("path", "")
match = re.match(r"^(/api)?/companies/([^/]+)/(.+)$", path) match = re.match(r"^(/api)?/companies/([^/]+)/(.+)$", path)
if match: if match and match.group(2) != "blueprints":
api_prefix = match.group(1) or "" api_prefix = match.group(1) or ""
company_id = match.group(2) company_id = match.group(2)
resource_path = match.group(3) resource_path = match.group(3)
# Check if this is a path we should NOT rewrite (runs API expects prefix) # Check if this is a path we should NOT rewrite (runs, JSON org, budgets expect prefix)
is_runs = resource_path.startswith("runs") should_not_rewrite = (
resource_path.startswith("runs")
or resource_path == "org"
or resource_path.startswith("org/")
or resource_path.startswith("budgets")
or resource_path.startswith("budget-incidents")
)
print(f"DEBUG MIDDLEWARE match: path={path}, resource_path={resource_path}, should_not_rewrite={should_not_rewrite}", flush=True)
if not is_runs: if not should_not_rewrite:
if resource_path.startswith("skills"): if resource_path.startswith("skills"):
resource_path = re.sub(r"^skills(\b|/)", "company-skills\\1", resource_path) resource_path = re.sub(r"^skills(\b|/)", "company-skills\\1", resource_path)
elif resource_path.startswith("members"): elif resource_path.startswith("members"):
...@@ -194,8 +217,9 @@ class CompanyPathRewriteMiddleware: ...@@ -194,8 +217,9 @@ class CompanyPathRewriteMiddleware:
resource_path = re.sub(r"^user-directory(\b|/)", "company-memberships/user-directory\\1", resource_path) resource_path = re.sub(r"^user-directory(\b|/)", "company-memberships/user-directory\\1", resource_path)
elif resource_path.startswith("join-requests"): elif resource_path.startswith("join-requests"):
resource_path = re.sub(r"^join-requests(\b|/)", "company-memberships/join-requests\\1", resource_path) resource_path = re.sub(r"^join-requests(\b|/)", "company-memberships/join-requests\\1", resource_path)
elif resource_path.startswith("org"): elif resource_path.startswith("org-svg"):
resource_path = re.sub(r"^org(\b|/)", "org-chart-svg\\1", resource_path) resource_path = re.sub(r"^org-svg(\b|/)", "org-chart-svg\\1", resource_path)
# Rewrite path # Rewrite path
scope["path"] = f"{api_prefix}/{resource_path}" scope["path"] = f"{api_prefix}/{resource_path}"
......
...@@ -11,7 +11,7 @@ from typing import List, Dict, Any, Optional ...@@ -11,7 +11,7 @@ from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from models import Company, Agent, CompanyMembership from models import Company, Agent, CompanyMembership, CompanySecret, CompanySkill, Plugin, PluginConfig
from schemas.blueprint import CompanyBlueprint, BlueprintRole, BlueprintPipelineNode from schemas.blueprint import CompanyBlueprint, BlueprintRole, BlueprintPipelineNode
from services.activity_logger import log_activity from services.activity_logger import log_activity
...@@ -210,6 +210,84 @@ class BlueprintService: ...@@ -210,6 +210,84 @@ class BlueprintService:
else: else:
raise ValueError(f"reports_to role '{parent_role}' not found") raise ValueError(f"reports_to role '{parent_role}' not found")
# 4. Provision environment secrets
if blueprint.env_vars:
for key, value in blueprint.env_vars.items():
is_sensitive = any(kw in key.lower() for kw in ["key", "token", "secret", "password", "private", "credential"])
secret = CompanySecret(
id=uuid.uuid4().hex,
company_id=db_company.id,
key=key,
value_encrypted=value,
description=f"Environment variable {key} provisioned from blueprint",
is_sensitive=is_sensitive,
created_by=owner_id
)
db.add(secret)
# 5. Provision custom plugins
if blueprint.plugins:
for plugin_spec in blueprint.plugins:
name = plugin_spec.get("name")
if not name:
continue
version = plugin_spec.get("version", "1.0.0")
description = plugin_spec.get("description", f"{name} plugin")
config = plugin_spec.get("config", {})
# Check if global plugin exists
plugin_result = await db.execute(select(Plugin).where(Plugin.name == name))
db_plugin = plugin_result.scalar_one_or_none()
if not db_plugin:
db_plugin = Plugin(
id=uuid.uuid4().hex,
name=name,
version=version,
description=description,
enabled=True,
config_schema={}
)
db.add(db_plugin)
await db.flush()
# Configure for this company
plugin_config = PluginConfig(
id=uuid.uuid4().hex,
company_id=db_company.id,
plugin_id=db_plugin.id,
config=config,
is_enabled=True
)
db.add(plugin_config)
# 6. Provision company skills
if blueprint.skills:
for skill_spec in blueprint.skills:
if isinstance(skill_spec, str):
name = skill_spec
level = 100
description = f"Capability: {name}"
is_custom = False
elif isinstance(skill_spec, dict):
name = skill_spec.get("name")
if not name:
continue
level = skill_spec.get("level", 100)
description = skill_spec.get("description", f"Capability: {name}")
is_custom = skill_spec.get("is_custom", True)
else:
continue
skill = CompanySkill(
id=uuid.uuid4().hex,
company_id=db_company.id,
name=name,
description=description,
level=level,
is_custom=is_custom
)
db.add(skill)
await db.flush() await db.flush()
# Log activity # Log activity
......
"""Campaign Service — business logic for Summer Camp 2026 campaign management."""
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models import Campaign, ProductSpec, Material, AdPlan, BudgetItem
class CampaignService:
"""Orchestrates campaign, product specs, materials, ad plans, and budget items."""
# ── Campaign CRUD ─────────────────────────────────────────────────────────
@staticmethod
async def list_campaigns(db: AsyncSession, company_id: str, status: Optional[str] = None) -> list:
stmt = select(Campaign).where(Campaign.company_id == company_id)
if status:
stmt = stmt.where(Campaign.status == status)
stmt = stmt.order_by(Campaign.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_campaign(db: AsyncSession, campaign_id: str) -> Optional[Campaign]:
result = await db.execute(select(Campaign).where(Campaign.id == campaign_id))
return result.scalar_one_or_none()
@staticmethod
async def create_campaign(db: AsyncSession, **kwargs) -> Campaign:
campaign = Campaign(**kwargs)
db.add(campaign)
await db.flush()
await db.refresh(campaign)
return campaign
@staticmethod
async def update_campaign(db: AsyncSession, campaign: Campaign, **kwargs) -> Campaign:
for key, value in kwargs.items():
if value is not None and hasattr(campaign, key):
setattr(campaign, key, value)
campaign.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(campaign)
return campaign
@staticmethod
async def delete_campaign(db: AsyncSession, campaign: Campaign) -> None:
await db.delete(campaign)
await db.flush()
# ── Product Spec CRUD ─────────────────────────────────────────────────────
@staticmethod
async def list_product_specs(db: AsyncSession, company_id: str, campaign_id: Optional[str] = None,
product_type: Optional[str] = None) -> list:
stmt = select(ProductSpec).where(ProductSpec.company_id == company_id)
if campaign_id:
stmt = stmt.where(ProductSpec.campaign_id == campaign_id)
if product_type:
stmt = stmt.where(ProductSpec.product_type == product_type)
stmt = stmt.order_by(ProductSpec.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_product_spec(db: AsyncSession, spec_id: str) -> Optional[ProductSpec]:
result = await db.execute(select(ProductSpec).where(ProductSpec.id == spec_id))
return result.scalar_one_or_none()
@staticmethod
async def create_product_spec(db: AsyncSession, **kwargs) -> ProductSpec:
spec = ProductSpec(**kwargs)
db.add(spec)
await db.flush()
await db.refresh(spec)
return spec
@staticmethod
async def update_product_spec(db: AsyncSession, spec: ProductSpec, **kwargs) -> ProductSpec:
for key, value in kwargs.items():
if value is not None and hasattr(spec, key):
setattr(spec, key, value)
spec.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(spec)
return spec
@staticmethod
async def delete_product_spec(db: AsyncSession, spec: ProductSpec) -> None:
await db.delete(spec)
await db.flush()
# ── Material CRUD ─────────────────────────────────────────────────────────
@staticmethod
async def list_materials(db: AsyncSession, company_id: str,
campaign_id: Optional[str] = None,
product_spec_id: Optional[str] = None,
rd_approved: Optional[bool] = None) -> list:
stmt = select(Material).where(Material.company_id == company_id)
if campaign_id:
stmt = stmt.where(Material.campaign_id == campaign_id)
if product_spec_id:
stmt = stmt.where(Material.product_spec_id == product_spec_id)
if rd_approved is not None:
stmt = stmt.where(Material.rd_approved == int(rd_approved))
stmt = stmt.order_by(Material.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_material(db: AsyncSession, material_id: str) -> Optional[Material]:
result = await db.execute(select(Material).where(Material.id == material_id))
return result.scalar_one_or_none()
@staticmethod
async def create_material(db: AsyncSession, **kwargs) -> Material:
material = Material(**kwargs)
db.add(material)
await db.flush()
await db.refresh(material)
return material
@staticmethod
async def update_material(db: AsyncSession, material: Material, **kwargs) -> Material:
for key, value in kwargs.items():
if value is not None and hasattr(material, key):
setattr(material, key, value)
material.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(material)
return material
@staticmethod
async def delete_material(db: AsyncSession, material: Material) -> None:
await db.delete(material)
await db.flush()
@staticmethod
async def approve_material(db: AsyncSession, material: Material, approved_by_agent_id: str) -> Material:
"""Mark a material as RD-approved and stamp who approved it."""
material.rd_approved = 1
material.updated_at = datetime.utcnow()
if hasattr(material, 'approved_by_agent_id'):
material.approved_by_agent_id = approved_by_agent_id
material.approved_at = datetime.utcnow()
await db.flush()
await db.refresh(material)
return material
# ── Ad Plan CRUD ──────────────────────────────────────────────────────────
@staticmethod
async def list_ad_plans(db: AsyncSession, company_id: str, campaign_id: Optional[str] = None,
platform: Optional[str] = None, status: Optional[str] = None) -> list:
stmt = select(AdPlan).where(AdPlan.company_id == company_id)
if campaign_id:
stmt = stmt.where(AdPlan.campaign_id == campaign_id)
if platform:
stmt = stmt.where(AdPlan.platform == platform)
if status:
stmt = stmt.where(AdPlan.status == status)
stmt = stmt.order_by(AdPlan.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_ad_plan(db: AsyncSession, ad_plan_id: str) -> Optional[AdPlan]:
result = await db.execute(select(AdPlan).where(AdPlan.id == ad_plan_id))
return result.scalar_one_or_none()
@staticmethod
async def create_ad_plan(db: AsyncSession, **kwargs) -> AdPlan:
ad_plan = AdPlan(**kwargs)
db.add(ad_plan)
await db.flush()
await db.refresh(ad_plan)
return ad_plan
@staticmethod
async def update_ad_plan(db: AsyncSession, ad_plan: AdPlan, **kwargs) -> AdPlan:
for key, value in kwargs.items():
if value is not None and hasattr(ad_plan, key):
setattr(ad_plan, key, value)
ad_plan.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(ad_plan)
return ad_plan
@staticmethod
async def delete_ad_plan(db: AsyncSession, ad_plan: AdPlan) -> None:
await db.delete(ad_plan)
await db.flush()
# ── Budget Item CRUD ──────────────────────────────────────────────────────
@staticmethod
async def list_budget_items(db: AsyncSession, company_id: str,
campaign_id: Optional[str] = None,
category: Optional[str] = None,
status: Optional[str] = None) -> list:
stmt = select(BudgetItem).where(BudgetItem.company_id == company_id)
if campaign_id:
stmt = stmt.where(BudgetItem.campaign_id == campaign_id)
if category:
stmt = stmt.where(BudgetItem.category == category)
if status:
stmt = stmt.where(BudgetItem.status == status)
stmt = stmt.order_by(BudgetItem.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@staticmethod
async def get_budget_item(db: AsyncSession, budget_id: str) -> Optional[BudgetItem]:
result = await db.execute(select(BudgetItem).where(BudgetItem.id == budget_id))
return result.scalar_one_or_none()
@staticmethod
async def create_budget_item(db: AsyncSession, **kwargs) -> BudgetItem:
item = BudgetItem(**kwargs)
db.add(item)
await db.flush()
await db.refresh(item)
return item
@staticmethod
async def update_budget_item(db: AsyncSession, item: BudgetItem, **kwargs) -> BudgetItem:
for key, value in kwargs.items():
if value is not None and hasattr(item, key):
setattr(item, key, value)
item.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(item)
return item
@staticmethod
async def delete_budget_item(db: AsyncSession, item: BudgetItem) -> None:
await db.delete(item)
await db.flush()
# ── Pipeline Orchestration ────────────────────────────────────────────────
# DAG order (from canifa-company.yaml blueprint):
# ceo → pm → (rd, designer) → (content, ads, ecom) → coder → qa → publisher
PIPELINE_ROLES = [
"ceo", "pm",
"rd", "designer",
"content", "ads", "ecom",
"coder", "qa", "publisher",
]
@staticmethod
async def get_pipeline_status(db: AsyncSession, company_id: str, campaign_id: str) -> dict:
"""Return which pipeline stages have produced output for this campaign."""
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
return {"error": "Campaign not found"}
product_specs = await CampaignService.list_product_specs(db, company_id, campaign_id)
materials = await CampaignService.list_materials(db, company_id, campaign_id)
ad_plans = await CampaignService.list_ad_plans(db, company_id, campaign_id)
budget_items = await CampaignService.list_budget_items(db, company_id, campaign_id)
# Determine what's ready
has_campaign = campaign is not None
has_specs = len(product_specs) > 0
has_materials = len(materials) > 0
has_ad_plans = len(ad_plans) > 0
has_budget = len(budget_items) > 0
# RD approval status on materials
total_mats = len(materials)
approved_mats = sum(1 for m in materials if m.rd_approved == 1)
all_mats_approved = total_mats > 0 and approved_mats == total_mats
stages = {
"ceo": {"status": "completed" if has_campaign else "pending", "detail": "Campaign created"},
"pm": {"status": "completed" if has_specs else "pending", "detail": f"{len(product_specs)} product spec(s)"},
"rd": {"status": "completed" if all_mats_approved else ("in_progress" if has_materials else "pending"),
"detail": f"{approved_mats}/{total_mats} materials RD-approved"},
"designer": {"status": "completed" if has_specs else "pending", "detail": "Specs with design_files"},
"content": {"status": "completed" if has_ad_plans else "pending","detail": f"{len(ad_plans)} ad plan(s)"},
"ads": {"status": "completed" if has_ad_plans else "pending","detail": "Ad plans ready"},
"ecom": {"status": "completed" if has_specs else "pending", "detail": "E-commerce listings"},
"coder": {"status": "pending", "detail": "Asset / landing page"},
"qa": {"status": "pending", "detail": "Quality review"},
"publisher": {"status": "pending", "detail": "Go-live / Facebook publish"},
}
return {
"campaign_id": campaign_id,
"campaign_name": campaign.name if campaign else "",
"campaign_status": campaign.status if campaign else "unknown",
"total_budget_vnd": campaign.total_budget_vnd if campaign else 0,
"stages": stages,
"stats": {
"product_specs": len(product_specs),
"graphic_tshirts": sum(1 for s in product_specs if s.product_type == "graphic_tshirt"),
"linen_shorts": sum(1 for s in product_specs if s.product_type == "linen_shorts"),
"materials_total": total_mats,
"materials_rd_approved": approved_mats,
"ad_plans": len(ad_plans),
"budget_items": len(budget_items),
},
}
# ── Dashboard / Summary ───────────────────────────────────────────────────
@staticmethod
async def get_campaign_dashboard(db: AsyncSession, company_id: str, campaign_id: str) -> dict:
campaign = await CampaignService.get_campaign(db, campaign_id)
if not campaign:
return {}
specs = await CampaignService.list_product_specs(db, company_id, campaign_id)
materials = await CampaignService.list_materials(db, company_id, campaign_id)
ad_plans = await CampaignService.list_ad_plans(db, company_id, campaign_id)
budget_items= await CampaignService.list_budget_items(db, company_id, campaign_id)
total_estimated = sum(b.estimated_vnd for b in budget_items)
total_actual = sum(b.actual_vnd for b in budget_items)
total_ad_spent = sum(ap.spent_vnd for ap in ad_plans)
total_impressions = sum(ap.impressions or 0 for ap in ad_plans)
graphic_tshirts = [s for s in specs if s.product_type == "graphic_tshirt"]
linen_shorts = [s for s in specs if s.product_type == "linen_shorts"]
pipeline = await CampaignService.get_pipeline_status(db, company_id, campaign_id)
return {
"campaign_id": campaign_id,
"campaign_name": campaign.name,
"status": campaign.status,
"season": campaign.season,
"year": campaign.year,
"tagline": campaign.tagline,
"theme_color": campaign.theme_color,
"summary": pipeline["stats"],
"pipeline": pipeline["stages"],
"budget": {
"total_estimated_vnd": total_estimated,
"total_actual_vnd": total_actual,
"variance_vnd": total_estimated - total_actual,
"ad_spent_vnd": total_ad_spent,
"total_budget_vnd": campaign.total_budget_vnd,
"remaining_vnd": (campaign.total_budget_vnd or 0) - total_actual,
"utilization_pct": round(total_actual / max(campaign.total_budget_vnd or 1, 1) * 100, 2),
},
"ad_performance": {
"total_impressions": total_impressions,
"total_clicks": sum(ap.clicks or 0 for ap in ad_plans),
"total_conversions": sum(ap.conversions or 0 for ap in ad_plans),
"ctr_pct": round(sum(ap.clicks or 0 for ap in ad_plans) / max(total_impressions, 1) * 100, 2),
"total_revenue_vnd": sum(ap.revenue_vnd or 0 for ap in ad_plans),
},
}
...@@ -50,6 +50,24 @@ class NocoBaseConnector: ...@@ -50,6 +50,24 @@ class NocoBaseConnector:
return connector return connector
async def ping(self) -> bool:
"""Health-check NocoBase reachability (no auth required).
Hits the public app-info endpoint. Returns True only if the server
answers with a 2xx. Used to gate provisioning + live integration tests
so a dead NocoBase is detected loudly instead of silently swallowed.
"""
url = f"{self.base_url}/api/app:getInfo"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=5.0)
if 200 <= response.status_code < 300:
return True
logger.warning(f"NocoBase ping non-2xx: {response.status_code} at {url}")
except Exception as e:
logger.warning(f"NocoBase ping failed at {url}: {e}")
return False
async def ensure_collection(self, name: str, fields: List[Dict[str, Any]]) -> bool: async def ensure_collection(self, name: str, fields: List[Dict[str, Any]]) -> bool:
""" """
Ensure that a NocoBase collection exists, creating it if not. Ensure that a NocoBase collection exists, creating it if not.
......
...@@ -478,19 +478,25 @@ class ProjectOrchestrator: ...@@ -478,19 +478,25 @@ class ProjectOrchestrator:
agent_id=agent_id, agent_id=agent_id,
status="running", status="running",
started_at=start, started_at=start,
pipeline_id=self.pipeline_id,
pipeline_step=step,
) )
db.add(db_run) db.add(db_run)
logger.info(f"[Pipeline {self.pipeline_id}] Step {step}/{total}: Waking {name} (run_id={run_id})") logger.info(f"[Pipeline {self.pipeline_id}] Step {step}/{total}: Waking {name} (run_id={run_id})")
try: try:
system_message = (
f"You are {name}, playing the role of {role}.\n"
f"After completing your work, you MUST write a detailed markdown summary of your findings, code, decisions, or results to a file named 'deliverable.md' in your workspace root directory. Keep the file name exactly 'deliverable.md' so it can be collected as your deliverable."
)
await run_agent_in_background( await run_agent_in_background(
run_id=run_id, run_id=run_id,
agent_id=agent_id, agent_id=agent_id,
company_id=self.company_id, company_id=self.company_id,
model_name=model_name, model_name=model_name,
user_message=prompt, user_message=prompt,
system_message=f"You are {name}, playing the role of {role}.", system_message=system_message,
conversation_history=None, conversation_history=None,
) )
except Exception as e: except Exception as e:
......
"""
Runtime Environment Resolver.
Resolves environment variables and enabled toolsets for a given agent execution.
"""
import logging
from typing import Optional, Dict, List
from sqlalchemy.future import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.company_secrets import CompanySecret
from models.plugin_config import PluginConfig
from models.plugins import Plugin
from common.encryption import decrypt_api_key
logger = logging.getLogger(__name__)
async def resolve_runtime_env(db: AsyncSession, company_id: str, team_id: Optional[str] = None) -> Dict[str, str]:
"""
Resolve environment variables for an agent run.
Team secrets override company secrets if they share the same key.
"""
env_vars = {}
# Query company secrets
query = select(CompanySecret).where(CompanySecret.company_id == company_id)
if team_id:
query = query.where(
(CompanySecret.team_id == team_id) | (CompanySecret.team_id.is_(None))
)
else:
query = query.where(CompanySecret.team_id.is_(None))
result = await db.execute(query)
secrets = result.scalars().all()
# Process secrets. Team specific secrets (team_id is not None) override company ones (team_id is None).
# Sorting by team_id ensures that None comes first, then specific team_id overrides.
secrets = sorted(secrets, key=lambda x: 1 if x.team_id else 0)
for secret in secrets:
val = secret.value_encrypted
try:
val = decrypt_api_key(val)
except Exception:
# Fallback to raw if not actually encrypted (e.g., from blueprint provisioning without encryption)
pass
env_vars[secret.key] = val
return env_vars
async def resolve_enabled_toolsets(db: AsyncSession, company_id: str, team_id: Optional[str] = None) -> Optional[List[str]]:
"""
Resolve enabled toolsets (plugins) for an agent run.
If there are no plugin configs for this company/team, returns None (no filtering).
If there are plugin configs, returns a list of enabled plugin names.
Team configs override company configs.
"""
# Check if there are any plugin configs at all for this company
check_query = select(PluginConfig).where(PluginConfig.company_id == company_id)
check_result = await db.execute(check_query.limit(1))
if not check_result.scalar_one_or_none():
return None # No configs -> no filtering (backward compatibility)
query = select(PluginConfig, Plugin.name).join(
Plugin, Plugin.id == PluginConfig.plugin_id
).where(
PluginConfig.company_id == company_id
)
if team_id:
query = query.where(
(PluginConfig.team_id == team_id) | (PluginConfig.team_id.is_(None))
)
else:
query = query.where(PluginConfig.team_id.is_(None))
result = await db.execute(query)
rows = result.all()
# Deduplicate by plugin_id, prioritizing team_id configs
resolved_configs = {}
for cfg, plugin_name in sorted(rows, key=lambda x: 1 if x[0].team_id else 0):
resolved_configs[cfg.plugin_id] = (cfg, plugin_name)
# Gather enabled plugin names
enabled_toolsets = []
for cfg, plugin_name in resolved_configs.values():
if cfg.is_enabled:
enabled_toolsets.append(plugin_name)
return enabled_toolsets
"""
Issue-Driven Swarm service.
When an agent finishes a parent issue it may emit follow-up work ("child specs").
This module turns those specs into real child Issue rows (linked via parent_id /
request_depth / origin_kind="agent_spawn") and dispatches a queued HeartbeatRun
for each child so the swarm propagates through the existing checkout/wake path.
A hard depth limit (MAX_SWARM_DEPTH) prevents runaway recursion: an agent that
keeps spawning children eventually hits the cap and no further children are made.
"""
import logging
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from models import Issue, HeartbeatRun
logger = logging.getLogger(__name__)
# Maximum spawn depth. A root issue is depth 0; its children depth 1, etc.
# At request_depth >= MAX_SWARM_DEPTH we refuse to spawn further children.
MAX_SWARM_DEPTH = 3
async def spawn_child_issues(
db: AsyncSession,
parent_issue: Issue,
child_specs: list[dict],
created_by_agent_id: Optional[str] = None,
origin_run_id: Optional[str] = None,
) -> list[Issue]:
"""Create child issues under ``parent_issue`` from agent-emitted specs.
Each spec is a dict with at least ``title`` (required). Optional keys:
``description``, ``priority``, ``assignee_agent_id``.
Returns the created Issue rows (flushed, not committed — caller commits).
Returns an empty list if the parent is already at/over MAX_SWARM_DEPTH,
or if no spec carries a title.
"""
if parent_issue.request_depth >= MAX_SWARM_DEPTH:
logger.warning(
"[Swarm] Refusing to spawn: parent issue %s at depth %d >= MAX_SWARM_DEPTH %d",
parent_issue.id, parent_issue.request_depth, MAX_SWARM_DEPTH,
)
return []
children: list[Issue] = []
for spec in child_specs:
title = (spec or {}).get("title")
if not title:
logger.warning("[Swarm] Skipping child spec without title under %s", parent_issue.id)
continue
child = Issue(
company_id=parent_issue.company_id,
project_id=parent_issue.project_id,
goal_id=parent_issue.goal_id,
parent_id=parent_issue.id,
title=title,
description=spec.get("description"),
status="open",
priority=spec.get("priority", parent_issue.priority),
assignee_agent_id=spec.get("assignee_agent_id") or parent_issue.assignee_agent_id,
created_by_agent_id=created_by_agent_id,
origin_kind="agent_spawn",
origin_id=parent_issue.id,
origin_run_id=origin_run_id,
request_depth=parent_issue.request_depth + 1,
)
db.add(child)
children.append(child)
if children:
await db.flush()
for c in children:
await db.refresh(c)
logger.info(
"[Swarm] Spawned %d child issue(s) under %s (depth %d)",
len(children), parent_issue.id, parent_issue.request_depth + 1,
)
return children
async def dispatch_child_issues(
db: AsyncSession,
children: list[Issue],
) -> list[HeartbeatRun]:
"""Queue a HeartbeatRun for each child that has an assignee agent.
Mirrors the wakeup_checkout path so spawned issues actually get executed.
Children without an assignee are left for a router/human to pick up.
Returns the created (queued) runs, flushed but not committed.
"""
runs: list[HeartbeatRun] = []
for child in children:
if not child.assignee_agent_id:
continue
run = HeartbeatRun(
id=uuid.uuid4().hex,
company_id=child.company_id,
agent_id=child.assignee_agent_id,
issue_id=child.id,
status="queued",
execution_cost_cents=0,
execution_cost_currency="USD",
)
db.add(run)
child.checkout_run_id = run.id
child.monitor_wake_requested_at = datetime.utcnow()
runs.append(run)
if runs:
await db.flush()
logger.info("[Swarm] Dispatched %d queued run(s) for spawned children", len(runs))
return runs
...@@ -21,6 +21,47 @@ def _run_async(coro): ...@@ -21,6 +21,47 @@ def _run_async(coro):
loop.close() loop.close()
async def _maybe_spawn_children(run_id: str, agent_id: str, workspace):
"""Issue-Driven Swarm hook.
After an issue's run completes, if the agent wrote a `child_issues.json`
array into its workspace, turn each entry into a child Issue (linked via
parent_id / request_depth) and queue a run for it. Depth is capped inside
swarm_service so this cannot recurse forever.
"""
import json
from pathlib import Path
from sqlalchemy import select
from models.database import get_db_context
from models.heartbeat_runs import HeartbeatRun
from models.issues import Issue
from services.swarm_service import spawn_child_issues, dispatch_child_issues
specs_file = Path(workspace) / "child_issues.json"
if not specs_file.exists():
return
try:
specs = json.loads(specs_file.read_text(encoding="utf-8"))
except Exception:
logger.warning(f"[Swarm] child_issues.json invalid for run {run_id}")
return
if not isinstance(specs, list) or not specs:
return
async with get_db_context() as db:
run = (await db.execute(select(HeartbeatRun).where(HeartbeatRun.id == run_id))).scalar_one_or_none()
if not run or not run.issue_id:
return
parent = (await db.execute(select(Issue).where(Issue.id == run.issue_id))).scalar_one_or_none()
if not parent:
return
children = await spawn_child_issues(
db, parent, specs, created_by_agent_id=agent_id, origin_run_id=run_id
)
await dispatch_child_issues(db, children)
await db.commit()
@shared_task( @shared_task(
bind=True, bind=True,
name="tasks.run_agent", name="tasks.run_agent",
...@@ -83,6 +124,12 @@ def run_agent_task(self, run_id: str, agent_id: str, company_id: str, model_name ...@@ -83,6 +124,12 @@ def run_agent_task(self, run_id: str, agent_id: str, company_id: str, model_name
) )
await db.commit() await db.commit()
# 5b. Issue-Driven Swarm: spawn + dispatch any child issues the agent emitted
try:
await _maybe_spawn_children(run_id, agent_id, workspace)
except Exception as swarm_exc:
logger.error(f"[Swarm] spawn failed for run {run_id}: {swarm_exc}", exc_info=True)
logger.info(f"[Celery] Agent task completed: run_id={run_id}") logger.info(f"[Celery] Agent task completed: run_id={run_id}")
return {"status": "completed", "run_id": run_id} return {"status": "completed", "run_id": run_id}
...@@ -109,3 +156,84 @@ def run_agent_task(self, run_id: str, agent_id: str, company_id: str, model_name ...@@ -109,3 +156,84 @@ def run_agent_task(self, run_id: str, agent_id: str, company_id: str, model_name
os.environ.pop("AGENT_WORKSPACE", None) os.environ.pop("AGENT_WORKSPACE", None)
return _run_async(_execute()) return _run_async(_execute())
@shared_task(
name="tasks.run_pipeline_orchestration",
max_retries=1,
)
def run_pipeline_orchestration(company_id: str, project_brief: str, pipeline_spec: list[dict] | None = None):
"""
Celery task to run a complete topological project pipeline using ProjectOrchestrator.
Writes the final report to workspaces/{company_id}/shared/docs/weekly_report_{date}.md
"""
logger.info(f"[Celery] Starting pipeline orchestration task: company_id={company_id}")
async def _execute():
from services.project_orchestrator import ProjectOrchestrator
from pathlib import Path
from datetime import datetime
orchestrator = ProjectOrchestrator(
company_id=company_id,
project_brief=project_brief,
pipeline_spec=pipeline_spec
)
result = await orchestrator.run()
# Write final markdown report to shared/docs/weekly_report_{date}.md
shared_dir = Path("workspaces") / company_id / "shared"
docs_dir = shared_dir / "docs"
docs_dir.mkdir(parents=True, exist_ok=True)
date_str = datetime.utcnow().strftime("%Y%m%d")
report_file = docs_dir / f"weekly_report_{date_str}.md"
# Generate markdown content for the report
md_lines = [
f"# Weekly CEO Executive Report — {datetime.utcnow().strftime('%Y-%m-%d')}",
"",
f"**Pipeline ID:** `{result.get('pipeline_id')}`",
f"**Company ID:** `{result.get('company_id')}`",
f"**Status:** `{result.get('status')}`",
f"**Duration:** {result.get('total_duration_s')} seconds",
f"**Execution Summary:** {result.get('agents_succeeded')} / {result.get('agents_executed')} agents completed successfully.",
"",
"## Executed Steps",
""
]
for step in result.get("steps", []):
md_lines.append(
f"- **{step.get('agent_name')}** ({step.get('role')}): "
f"Status: `{step.get('status')}` (Duration: {step.get('duration_s')}s)"
)
if step.get("error"):
md_lines.append(f" - *Error:* {step.get('error')}")
md_lines.extend([
"",
"## Produced Deliverables",
""
])
if result.get("deliverables"):
for d in result.get("deliverables"):
md_lines.append(f"- `{d}`")
else:
md_lines.append("*No deliverables registered.*")
report_file.write_text("\n".join(md_lines), encoding="utf-8")
logger.info(f"[Celery] Pipeline report written to: {report_file}")
return result
return _run_async(_execute())
@shared_task(name="tasks.check_scheduled_routines")
def check_scheduled_routines():
"""Celery task to run periodic routine scheduler tick."""
from services.routine_scheduler import RoutineSchedulerService
_run_async(RoutineSchedulerService.tick())
# Campaign API tests
[pytest]
asyncio_mode = auto
"""
Human-in-loop approval flow test.
Reproduces and verifies the FE<->BE contract that the pipeline orchestrator
depends on: POST /approvals/{id}/approve|reject must set `resolution`, which
ProjectOrchestrator polls to resume (approved) or fail (rejected) a gated stage.
Uses a real in-memory SQLite DB (no mocks) so the SQL wiring is actually exercised.
"""
import sys
from pathlib import Path
import pytest
import httpx
from unittest.mock import MagicMock
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
sys.path.insert(0, str(Path(__file__).parent.parent))
from server import app
from database import get_db_session, Base
from middleware.auth import get_current_user
from models import Company, CompanyMembership, Approval
@pytest.mark.asyncio
async def test_approve_and_reject_set_resolution():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
TestSession = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Seed company + membership + two pending pipeline-stage approvals
async with TestSession() as s:
s.add(Company(id="co1", name="Co", status="active", issue_prefix="PAP", budget_monthly_cents=0))
s.add(CompanyMembership(
id="m1", company_id="co1", principal_type="user",
principal_id="u1", membership_role="instance_admin", status="active",
))
s.add(Approval(id="ap1", company_id="co1", resource_type="pipeline_stage",
resource_id="pipe1", required_approvers=1, created_by="system"))
s.add(Approval(id="ap2", company_id="co1", resource_type="pipeline_stage",
resource_id="pipe2", required_approvers=1, created_by="system"))
await s.commit()
mock_user = MagicMock()
mock_user.id = "u1"
async def _override_user():
return mock_user
async def _override_db():
async with TestSession() as session:
yield session
app.dependency_overrides[get_current_user] = _override_user
app.dependency_overrides[get_db_session] = _override_db
try:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# Company-scoped list (FE path) returns both pending approvals
r = await client.get("/api/companies/co1/approvals")
assert r.status_code == 200, r.text
assert len(r.json()) == 2
# Approve ap1 (no company_id in body — derived from approval)
r = await client.post("/api/approvals/ap1/approve", json={"decisionNote": "ship it"})
assert r.status_code == 200, r.text
assert r.json()["resolution"] == "approved"
# Reject ap2
r = await client.post("/api/approvals/ap2/reject", json={})
assert r.status_code == 200, r.text
assert r.json()["resolution"] == "rejected"
# Detail GET without company_id query (FE calls get(id))
r = await client.get("/api/approvals/ap1")
assert r.status_code == 200, r.text
assert r.json()["resolution"] == "approved"
# Double-resolve is rejected
r = await client.post("/api/approvals/ap1/approve", json={})
assert r.status_code == 400
finally:
app.dependency_overrides.clear()
# The persisted resolution is exactly what the orchestrator polls
async with TestSession() as s:
a1 = (await s.execute(select(Approval).where(Approval.id == "ap1"))).scalar_one()
a2 = (await s.execute(select(Approval).where(Approval.id == "ap2"))).scalar_one()
assert a1.resolution == "approved"
assert a1.resolved_by == "u1"
assert a1.resolution_note == "ship it"
assert a2.resolution == "rejected"
await engine.dispose()
@pytest.mark.asyncio
async def test_approve_forbidden_without_membership():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
TestSession = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with TestSession() as s:
s.add(Company(id="co1", name="Co", status="active", issue_prefix="PAP", budget_monthly_cents=0))
# NOTE: no membership for u_stranger
s.add(Approval(id="ap1", company_id="co1", resource_type="pipeline_stage",
resource_id="pipe1", required_approvers=1, created_by="system"))
await s.commit()
mock_user = MagicMock()
mock_user.id = "u_stranger"
async def _override_user():
return mock_user
async def _override_db():
async with TestSession() as session:
yield session
app.dependency_overrides[get_current_user] = _override_user
app.dependency_overrides[get_db_session] = _override_db
try:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
r = await client.post("/api/approvals/ap1/approve", json={})
assert r.status_code == 403, r.text
finally:
app.dependency_overrides.clear()
async with TestSession() as s:
a1 = (await s.execute(select(Approval).where(Approval.id == "ap1"))).scalar_one()
assert a1.resolution is None # not resolved by a non-member
await engine.dispose()
import pytest_asyncio
"""
Tests for Campaign API - Summer Camp 2026.
Covers all 22 endpoints: Campaign (5), Dashboard (1), ProductSpec (4),
Material (5), AdPlan (4), BudgetItem (4).
Run: pytest tests/test_campaign_api.py -v
"""
import pytest
import sys
import uuid
from pathlib import Path
from datetime import datetime, timedelta
from unittest.mock import MagicMock, AsyncMock
sys.path.insert(0, str(Path(__file__).parent.parent))
import httpx
from server import app
from database import get_db_session
from middleware.auth import get_current_user, require_company_scope
from schemas.common import CompanyScope
COMPANY_ID = "company_123"
CAMPAIGN_ID = "7decae1e572d47b88ce34e42dd84acda"
PRODUCT_SPEC_ID = "7c9c7507f5124a0aae88e3aaf7fe4f76"
MATERIAL_ID = "d0d59ee2152b4cc48a6e881f0f5289f9"
AD_PLAN_ID = "41114a07cfa74a4893a95262c53499b6"
BUDGET_ITEM_ID = "54ae843927ab43b689573ab1549823c8"
@pytest.fixture
def mock_user():
u = MagicMock()
u.id = "agent_qa_test"
return u
@pytest.fixture
def mock_scope():
return CompanyScope(company_id=COMPANY_ID)
@pytest_asyncio.fixture
async def app_client(mock_user, mock_scope):
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[require_company_scope] = lambda: mock_scope
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
yield client
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def real_db_session():
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
db_path = Path(__file__).parent.parent / "paperclip.db"
url = f"sqlite+aiosqlite:///{db_path}"
engine = create_async_engine(url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
# ─── Campaign CRUD (5 endpoints) ───────────────────────────────────────────
class TestCampaignCRUD:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_campaigns(self, app_client):
resp = await app_client.get(f"/api/campaigns?company_id={COMPANY_ID}")
assert resp.status_code == 200
body = resp.json()
assert "items" in body
assert "total" in body
assert body["total"] >= 1
names = [c["name"] for c in body["items"]]
assert "Summer Camp 2026" in names
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_campaigns_filter_status(self, app_client):
resp = await app_client.get(f"/api/campaigns?company_id={COMPANY_ID}&status=approved")
assert resp.status_code == 200
for c in resp.json()["items"]:
assert c["status"] == "approved"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_get_campaign_ok(self, app_client):
resp = await app_client.get(f"/api/campaigns/{CAMPAIGN_ID}?company_id={COMPANY_ID}")
assert resp.status_code == 200
body = resp.json()
assert body["id"] == CAMPAIGN_ID
assert body["name"] == "Summer Camp 2026"
assert body["totalBudgetVnd"] == 850000000
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_get_campaign_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.get(f"/api/campaigns/{fake}?company_id={COMPANY_ID}")
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_campaign(self, app_client, real_db_session):
slug = f"summer-test-{uuid.uuid4().hex[:8]}"
payload = {
"company_id": COMPANY_ID,
"created_by_agent_id": "agent_qa_test",
"name": "QA Test Campaign",
"slug": slug,
"season": "Summer",
"year": 2026,
"status": "draft",
"total_budget_vnd": 100000000,
}
resp = await app_client.post("/api/campaigns", json=payload)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "QA Test Campaign"
assert body["slug"] == slug
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_campaign_bad_slug(self, app_client):
payload = {
"company_id": COMPANY_ID,
"name": "Bad Slug",
"slug": "INVALID!!!",
"season": "Summer",
"year": 2026,
}
resp = await app_client.post("/api/campaigns", json=payload)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_campaign(self, app_client):
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}",
json={"name": "Summer Camp 2026 -- QA Updated", "tagline": "QA tagline"},
)
assert resp.status_code == 200
body = resp.json()
assert "QA Updated" in body["name"]
# Revert
await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}",
json={"name": "Summer Camp 2026", "tagline": None},
)
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_campaign_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.patch(f"/api/campaigns/{fake}", json={"name": "X"})
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_delete_campaign(self, app_client, real_db_session):
from services.campaign_service import CampaignService
from models.campaigns import Campaign
created = await CampaignService.create_campaign(
real_db_session, company_id=COMPANY_ID,
created_by_agent_id="agent_qa_test",
name="QA Delete Me",
slug=f"qa-del-{uuid.uuid4().hex[:8]}",
season="Summer", year=2026, status="draft",
)
del_id = created.id
resp = await app_client.delete(f"/api/campaigns/{del_id}?company_id={COMPANY_ID}")
assert resp.status_code == 204
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_delete_campaign_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.delete(f"/api/campaigns/{fake}?company_id={COMPANY_ID}")
assert resp.status_code == 404
# ─── Dashboard (1 endpoint) ────────────────────────────────────────────────
class TestCampaignDashboard:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_get_dashboard_ok(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/dashboard?company_id={COMPANY_ID}"
)
assert resp.status_code == 200
body = resp.json()
assert body["campaignId"] == CAMPAIGN_ID
assert body["campaignName"] == "Summer Camp 2026"
assert "summary" in body
assert "pipeline" in body
assert "budget" in body
assert "adPerformance" in body
assert "totalBudgetVnd" in body["budget"]
assert "remainingVnd" in body["budget"]
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_get_dashboard_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.get(
f"/api/campaigns/{fake}/dashboard?company_id={COMPANY_ID}"
)
assert resp.status_code == 404
# ─── ProductSpec CRUD (4 endpoints) ───────────────────────────────────────
class TestProductSpecCRUD:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_product_specs(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs?company_id={COMPANY_ID}"
)
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert len(body) >= 5
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_product_specs_filter_type(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs"
f"?company_id={COMPANY_ID}&product_type=graphic_tshirt"
)
assert resp.status_code == 200
for spec in resp.json():
assert spec["productType"] == "graphic_tshirt"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_product_spec(self, app_client):
new_sku = f"QA-TSHIRT-{uuid.uuid4().hex[:6]}"
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"created_by_agent_id": "agent_qa_test",
"name": "QA Test Tee",
"sku": new_sku,
"product_type": "graphic_tshirt",
"status": "draft",
"size_chart": {"S": "38", "M": "40", "L": "42"},
"colorways": [{"name": "White", "hex": "#FFFFFF"}],
"style_notes": "QA test spec",
"retail_price_vnd": 350000,
"production_qty": 1000,
"stock_safety_qty": 100,
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs", json=payload
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "QA Test Tee"
assert body["sku"] == new_sku
assert body["productType"] == "graphic_tshirt"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_product_spec_bad_type(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"name": "Bad Type",
"sku": "BAD-001",
"product_type": "invalid_type",
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs", json=payload
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_product_spec(self, app_client):
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs/{PRODUCT_SPEC_ID}",
json={"status": "approved", "retail_price_vnd": 299000},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "approved"
assert body["retailPriceVnd"] == 299000
# Revert
await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs/{PRODUCT_SPEC_ID}",
json={"status": "draft", "retail_price_vnd": None},
)
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_product_spec_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs/{fake}",
json={"name": "X"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_delete_product_spec(self, app_client):
cr = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs",
json={
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"name": "QA Del Spec",
"sku": f"QA-DEL-{uuid.uuid4().hex[:6]}",
"product_type": "linen_shorts",
},
)
assert cr.status_code == 201
spec_id = cr.json()["id"]
dr = await app_client.delete(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs/{spec_id}"
)
assert dr.status_code == 204
# ─── Material CRUD + Approve (5 endpoints) ────────────────────────────────
class TestMaterialCRUD:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_materials(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/materials?company_id={COMPANY_ID}"
)
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert len(body) >= 7
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_materials_filter_rd_approved(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/materials"
f"?company_id={COMPANY_ID}&rd_approved=true"
)
assert resp.status_code == 200
for m in resp.json():
assert m["rdApproved"] is True
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_material(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"product_spec_id": PRODUCT_SPEC_ID,
"name": "QA Test Fabric -- Cotton 30s",
"material_type": "fabric",
"sub_type": "Jersey",
"composition": "100% Cotton",
"weight_gsm": 180,
"origin": "Vietnam",
"color": "White",
"supplier": "QA Supplier",
"moq": 500,
"unit_cost_vnd": 85000,
"lead_time_days": 15,
"stock_qty": 10000,
"rd_approved": False,
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/materials", json=payload
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "QA Test Fabric -- Cotton 30s"
assert body["materialType"] == "fabric"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_material_bad_type(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"name": "Bad Mat",
"material_type": "super_fabric",
"rd_approved": False,
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/materials", json=payload
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_material(self, app_client):
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/materials/{MATERIAL_ID}",
json={"moq": 2000, "unit_cost_vnd": 90000},
)
assert resp.status_code == 200
body = resp.json()
assert body["moq"] == 2000
assert body["unitCostVnd"] == 90000
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_material_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/materials/{fake}",
json={"moq": 100},
)
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_delete_material(self, app_client):
cr = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/materials",
json={
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"name": "QA Temp Material",
"material_type": "other",
"rd_approved": False,
},
)
assert cr.status_code == 201
mat_id = cr.json()["id"]
dr = await app_client.delete(
f"/api/campaigns/{CAMPAIGN_ID}/materials/{mat_id}"
)
assert dr.status_code == 204
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_approve_material(self, app_client):
cr = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/materials",
json={
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"product_spec_id": PRODUCT_SPEC_ID,
"name": "QA Approvable Fabric",
"material_type": "fabric",
"weight_gsm": 200,
"rd_approved": False,
},
)
assert cr.status_code == 201
mat_id = cr.json()["id"]
assert cr.json()["rdApproved"] is False
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/materials/{mat_id}/approve"
)
assert resp.status_code == 200
body = resp.json()
assert body["rdApproved"] is True
assert body["id"] == mat_id
# ─── AdPlan CRUD (4 endpoints) ─────────────────────────────────────────────
class TestAdPlanCRUD:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_ad_plans(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans?company_id={COMPANY_ID}"
)
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert len(body) >= 3
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_ad_plans_filter_facebook(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans"
f"?company_id={COMPANY_ID}&platform=facebook"
)
assert resp.status_code == 200
for plan in resp.json():
assert plan["platform"] == "facebook"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_ad_plan(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"created_by_agent_id": "agent_ads_mgr",
"name": "QA Test Ad -- TikTok",
"platform": "tiktok",
"objective": "reach",
"status": "draft",
"ad_copy": "Summer sale on TikTok!",
"allocatedBudgetVnd": 50000000,
"target_audience": {"age_range": "18-34"},
"hashtags": ["#Summer2026", "#Canifa"],
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans", json=payload
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "QA Test Ad -- TikTok"
assert body["platform"] == "tiktok"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_ad_plan_bad_platform(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"name": "Bad Platform",
"platform": "myspace",
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans", json=payload
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_ad_plan(self, app_client):
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans/{AD_PLAN_ID}",
json={"status": "running", "impressions": 500000, "clicks": 25000},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "running"
assert body["impressions"] == 500000
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_ad_plan_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans/{fake}", json={"name": "Ghost"}
)
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_delete_ad_plan(self, app_client):
cr = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans",
json={
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"name": "QA Ad to Delete",
"platform": "google",
"objective": "traffic",
},
)
assert cr.status_code == 201
plan_id = cr.json()["id"]
dr = await app_client.delete(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans/{plan_id}"
)
assert dr.status_code == 204
# ─── BudgetItem CRUD (4 endpoints) ────────────────────────────────────────
class TestBudgetItemCRUD:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_budget_items(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items?company_id={COMPANY_ID}"
)
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert len(body) >= 21
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_budget_items_filter_production(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items"
f"?company_id={COMPANY_ID}&category=production"
)
assert resp.status_code == 200
for item in resp.json():
assert item["category"] == "production"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_budget_item(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"product_spec_id": PRODUCT_SPEC_ID,
"category": "design",
"label": "QA Test Design Fee",
"estimatedVnd": 10000000,
"status": "planned",
"notes": "QA budget item",
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items", json=payload
)
assert resp.status_code == 201
body = resp.json()
assert body["label"] == "QA Test Design Fee"
assert body["category"] == "design"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_create_budget_item_bad_category(self, app_client):
payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"category": "invention",
"label": "Invalid",
"estimatedVnd": 100,
}
resp = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items", json=payload
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_budget_item(self, app_client):
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items/{BUDGET_ITEM_ID}",
json={"status": "approved", "actualVnd": 420000000},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "approved"
assert body["actualVnd"] == 420000000
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_update_budget_item_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.patch(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items/{fake}",
json={"status": "approved"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_delete_budget_item(self, app_client):
cr = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items",
json={
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"category": "other",
"label": "QA Temp Budget",
"estimatedVnd": 100,
},
)
assert cr.status_code == 201
item_id = cr.json()["id"]
dr = await app_client.delete(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items/{item_id}"
)
assert dr.status_code == 204
# ─── Pipeline Status (1 endpoint) ─────────────────────────────────────────
class TestPipelineStatus:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_get_pipeline_status_ok(self, app_client):
resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/pipeline?company_id={COMPANY_ID}"
)
assert resp.status_code == 200
body = resp.json()
assert body["campaignId"] == CAMPAIGN_ID
assert body["campaignName"] == "Summer Camp 2026"
assert "stages" in body
assert "stats" in body
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_get_pipeline_status_404(self, app_client):
fake = str(uuid.uuid4())
resp = await app_client.get(
f"/api/campaigns/{fake}/pipeline?company_id={COMPANY_ID}"
)
assert resp.status_code == 404
# ─── Authorization checks ──────────────────────────────────────────────────
class TestAuthorization:
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_requires_company_scope(self):
"""Without company scope override, endpoints should reject requests."""
from fastapi import HTTPException
# Clear overrides so no scope is injected
app.dependency_overrides.clear()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
resp = await client.get("/api/campaigns")
# Missing required query param or auth should return 422/401/403
assert resp.status_code in (401, 403, 422)
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_missing_company_id_returns_422(self, app_client):
resp = await app_client.get("/api/campaigns")
assert resp.status_code == 422
# ─── Full end-to-end flow ──────────────────────────────────────────────────
class TestFullFlow:
"""Simulate full Summer Camp 2026 department workflow end-to-end."""
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_summer_camp_full_flow(self, app_client):
# STEP 1: Designer creates ProductSpec (Graphic T-shirt)
spec_payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"created_by_agent_id": "agent_designer",
"name": "QA Flow Tee",
"sku": f"QA-FLOW-{uuid.uuid4().hex[:6]}",
"product_type": "graphic_tshirt",
"status": "draft",
"size_chart": {"S": 38, "M": 40, "L": 42, "XL": 44},
"colorways": [
{"name": "Ocean Blue", "hex": "#006994"},
{"name": "Sunset Orange", "hex": "#FF6B35"},
],
"style_notes": "QA flow test tee",
"retail_price_vnd": 299000,
"production_qty": 3000,
}
r1 = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/product-specs", json=spec_payload
)
assert r1.status_code == 201
spec_id = r1.json()["id"]
# STEP 2: R&D creates and approves fabric material
mat_payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"product_spec_id": spec_id,
"name": "QA Flow Cotton 30s",
"material_type": "fabric",
"sub_type": "Jersey",
"composition": "100% Cotton",
"weight_gsm": 180,
"origin": "Vietnam",
"color": "Ocean Blue",
"supplier": "QA Supplier Vietnam",
"moq": 1000,
"unit_cost_vnd": 75000,
"lead_time_days": 14,
"stock_qty": 5000,
"rd_approved": True,
}
r2 = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/materials", json=mat_payload
)
assert r2.status_code == 201
mat_id = r2.json()["id"]
assert r2.json()["rdApproved"] is True
# STEP 3: Marketing creates FB Ad Plan
ad_payload = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"created_by_agent_id": "agent_ads_mgr",
"name": "QA Flow FB Reels",
"platform": "facebook",
"objective": "reach",
"status": "draft",
"ad_copy": "Mua he nao mac gi dep nhat? Canifa Summer Camp 2026!",
"creative_brief": "15s Reels beach background",
"target_audience": {"age_range": "18-35", "interests": ["fashion", "summer"]},
"hashtags": ["#Canifa2026", "#SummerCamp"],
"allocatedBudgetVnd": 80000000,
"scheduled_start": (datetime.utcnow() + timedelta(days=30)).isoformat(),
"scheduled_end": (datetime.utcnow() + timedelta(days=60)).isoformat(),
}
r3 = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/ad-plans", json=ad_payload
)
assert r3.status_code == 201
ad_id = r3.json()["id"]
assert r3.json()["platform"] == "facebook"
# STEP 4: Finance creates production budget item
prod_budget = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"product_spec_id": spec_id,
"category": "production",
"label": "QA Flow Tee Production",
"estimatedVnd": 120000000,
"status": "planned",
}
r4 = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items", json=prod_budget
)
assert r4.status_code == 201
budget_id = r4.json()["id"]
# STEP 5: HR adds recruitment budget
hr_budget = {
"company_id": COMPANY_ID,
"campaign_id": CAMPAIGN_ID,
"category": "hr",
"label": "QA Flow Influencer Hire",
"estimatedVnd": 25000000,
"status": "planned",
}
r5 = await app_client.post(
f"/api/campaigns/{CAMPAIGN_ID}/budget-items", json=hr_budget
)
assert r5.status_code == 201
# STEP 6: Dashboard aggregates everything
dash_resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/dashboard?company_id={COMPANY_ID}"
)
assert dash_resp.status_code == 200
dash = dash_resp.json()
assert dash["campaignId"] == CAMPAIGN_ID
assert "budget" in dash
# STEP 7: Pipeline status
pipe_resp = await app_client.get(
f"/api/campaigns/{CAMPAIGN_ID}/pipeline?company_id={COMPANY_ID}"
)
assert pipe_resp.status_code == 200
pipe = pipe_resp.json()
assert "stages" in pipe
# CLEANUP
await app_client.delete(f"/api/campaigns/{CAMPAIGN_ID}/budget-items/{budget_id}")
await app_client.delete(f"/api/campaigns/{CAMPAIGN_ID}/ad-plans/{ad_id}")
await app_client.delete(f"/api/campaigns/{CAMPAIGN_ID}/materials/{mat_id}")
await app_client.delete(f"/api/campaigns/{CAMPAIGN_ID}/product-specs/{spec_id}")
"""
NocoBase LIVE integration test (no mocks).
This is the proof that NocoBase is "alive", not "wired but dead":
- It talks to a REAL NocoBase over HTTP.
- It is skipped (not failed) when NocoBase is unreachable, so CI stays green
without the server — but the moment NocoBase is up it actually runs and
creates a real collection.
Bring NocoBase up via npm (NOT docker), then run this test:
npx create-nocobase-app nocobase-app -d sqlite
cd nocobase-app && npm run nocobase install && npm run start
# then, with NOCOBASE_URL/EMAIL/PASSWORD set:
pytest tests/test_nocobase_live.py -v
Env (with sensible defaults):
NOCOBASE_URL=http://localhost:13001
NOCOBASE_EMAIL=admin@nocobase.com
NOCOBASE_PASSWORD=admin123
"""
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from services.nocobase_connector import NocoBaseConnector
def _connector() -> NocoBaseConnector:
return NocoBaseConnector(
base_url=os.getenv("NOCOBASE_URL", "http://localhost:13001"),
email=os.getenv("NOCOBASE_EMAIL", "admin@nocobase.com"),
password=os.getenv("NOCOBASE_PASSWORD", "admin123"),
)
@pytest.mark.asyncio
async def test_nocobase_create_and_list_collection_live():
connector = _connector()
if not await connector.ping():
pytest.skip(
f"NocoBase not reachable at {connector.base_url} — bring it up via npm "
"(see module docstring) to run this live test."
)
# 1. Authenticate against the real server
assert await connector.login(), "NocoBase login failed — check NOCOBASE_EMAIL/PASSWORD"
# 2. Create a collection (idempotent)
created = await connector.ensure_collection(
"test_products",
[
{"name": "title", "type": "string", "interface": "input"},
{"name": "price", "type": "double", "interface": "number"},
],
)
assert created, "ensure_collection returned False against live NocoBase"
# 3. The collection now shows up in the collections registry
collections = await connector.list_records("collections")
names = {c.get("name") for c in collections}
assert "test_products" in names, f"test_products not found in live collections: {sorted(names)}"
"""
Issue-Driven Swarm tests.
Verifies the core spawn contract (success criterion S2):
- A parent issue + N child specs -> N child issues with parent_id set and
request_depth = parent + 1, origin_kind="agent_spawn".
- A parent already at MAX_SWARM_DEPTH spawns nothing (no runaway recursion).
- Dispatch queues a HeartbeatRun per child that has an assignee.
Uses a real in-memory SQLite DB (no mocks).
"""
import sys
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
sys.path.insert(0, str(Path(__file__).parent.parent))
from database import Base
from models import Company, Issue, HeartbeatRun
from services.swarm_service import (
spawn_child_issues,
dispatch_child_issues,
MAX_SWARM_DEPTH,
)
async def _make_session():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
TestSession = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
return engine, TestSession
@pytest.mark.asyncio
async def test_spawn_creates_children_with_parent_link_and_depth():
engine, TestSession = await _make_session()
async with TestSession() as s:
s.add(Company(id="co1", name="Co", status="active", issue_prefix="PAP", budget_monthly_cents=0))
s.add(Issue(id="root", company_id="co1", title="Build feature",
status="in_progress", priority="high", request_depth=0))
await s.commit()
async with TestSession() as s:
parent = (await s.execute(select(Issue).where(Issue.id == "root"))).scalar_one()
specs = [
{"title": "Write backend"},
{"title": "Write frontend", "priority": "medium", "description": "the UI"},
]
children = await spawn_child_issues(s, parent, specs, created_by_agent_id="agentX", origin_run_id="run1")
await s.commit()
assert len(children) == 2
for c in children:
assert c.parent_id == "root"
assert c.request_depth == 1
assert c.origin_kind == "agent_spawn"
assert c.origin_id == "root"
assert c.origin_run_id == "run1"
assert c.created_by_agent_id == "agentX"
assert c.company_id == "co1"
# priority inherited when not specified; explicit kept
by_title = {c.title: c for c in children}
assert by_title["Write backend"].priority == "high" # inherited from parent
assert by_title["Write frontend"].priority == "medium"
assert by_title["Write frontend"].description == "the UI"
# persisted
async with TestSession() as s:
rows = (await s.execute(select(Issue).where(Issue.parent_id == "root"))).scalars().all()
assert len(rows) == 2
await engine.dispose()
@pytest.mark.asyncio
async def test_spawn_blocked_at_max_depth():
engine, TestSession = await _make_session()
async with TestSession() as s:
s.add(Company(id="co1", name="Co", status="active", issue_prefix="PAP", budget_monthly_cents=0))
s.add(Issue(id="deep", company_id="co1", title="Deep issue",
status="in_progress", priority="medium", request_depth=MAX_SWARM_DEPTH))
await s.commit()
async with TestSession() as s:
parent = (await s.execute(select(Issue).where(Issue.id == "deep"))).scalar_one()
children = await spawn_child_issues(s, parent, [{"title": "Should not exist"}])
await s.commit()
assert children == []
async with TestSession() as s:
rows = (await s.execute(select(Issue).where(Issue.parent_id == "deep"))).scalars().all()
assert len(rows) == 0 # depth guard held — no runaway recursion
await engine.dispose()
@pytest.mark.asyncio
async def test_specs_without_title_are_skipped():
engine, TestSession = await _make_session()
async with TestSession() as s:
s.add(Company(id="co1", name="Co", status="active", issue_prefix="PAP", budget_monthly_cents=0))
s.add(Issue(id="root", company_id="co1", title="Parent",
status="open", priority="low", request_depth=0))
await s.commit()
async with TestSession() as s:
parent = (await s.execute(select(Issue).where(Issue.id == "root"))).scalar_one()
children = await spawn_child_issues(s, parent, [{"title": "ok"}, {"description": "no title"}, {}])
await s.commit()
assert len(children) == 1
assert children[0].title == "ok"
await engine.dispose()
@pytest.mark.asyncio
async def test_dispatch_queues_runs_only_for_assigned_children():
engine, TestSession = await _make_session()
async with TestSession() as s:
s.add(Company(id="co1", name="Co", status="active", issue_prefix="PAP", budget_monthly_cents=0))
s.add(Issue(id="root", company_id="co1", title="Parent",
status="in_progress", priority="medium", request_depth=0))
await s.commit()
async with TestSession() as s:
parent = (await s.execute(select(Issue).where(Issue.id == "root"))).scalar_one()
# one child with explicit assignee, one without (parent has none to inherit)
children = await spawn_child_issues(s, parent, [
{"title": "assigned", "assignee_agent_id": "agent1"},
{"title": "unassigned"},
])
runs = await dispatch_child_issues(s, children)
await s.commit()
assert len(runs) == 1
assert runs[0].agent_id == "agent1"
assert runs[0].status == "queued"
# the assigned child got its checkout_run_id wired
assigned = next(c for c in children if c.title == "assigned")
assert assigned.checkout_run_id == runs[0].id
async with TestSession() as s:
all_runs = (await s.execute(select(HeartbeatRun))).scalars().all()
assert len(all_runs) == 1
await engine.dispose()
import pytest
import os
import uuid
import datetime
import sys
from unittest.mock import patch, AsyncMock
# Add backend to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.ext.asyncio import AsyncSession
from models.companies import Company
from models.agents import Agent
from models.company_secrets import CompanySecret
from models.plugins import Plugin
from models.plugin_config import PluginConfig
from models.heartbeat_runs import HeartbeatRun
from api.routes.agents import run_agent_in_background
from services.runtime_env import resolve_runtime_env, resolve_enabled_toolsets
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
import pytest_asyncio
from database import Base
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
TestingSessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest_asyncio.fixture
async def db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestingSessionLocal() as session:
yield session
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.mark.asyncio
async def test_team_env_injection_and_cleanup(db: AsyncSession):
from services.runtime_env import resolve_runtime_env
# Setup Company
company_id = uuid.uuid4().hex
company = Company(
id=company_id,
name="Test Env Company",
description="test",
status="active"
)
db.add(company)
# Setup Agent with team_id
team_id = "team_alpha"
agent_id = uuid.uuid4().hex
agent = Agent(
id=agent_id,
company_id=company_id,
team_id=team_id,
name="Alpha Agent",
role="tester",
title="Tester",
status="active"
)
db.add(agent)
# Setup Company-level Secret
company_secret_key = f"COMP_TOKEN_{uuid.uuid4().hex[:8]}"
comp_secret = CompanySecret(
id=uuid.uuid4().hex,
company_id=company_id,
team_id=None,
key=company_secret_key,
value_encrypted="comp_secret_value",
is_sensitive=True,
)
db.add(comp_secret)
# Setup Team-level Secret
team_secret_key = f"TEAM_TOKEN_{uuid.uuid4().hex[:8]}"
team_secret = CompanySecret(
id=uuid.uuid4().hex,
company_id=company_id,
team_id=team_id,
key=team_secret_key,
value_encrypted="team_secret_value",
is_sensitive=True,
)
db.add(team_secret)
# Setup Override Secret (Company level has one value, Team level overrides it)
override_key = f"OVERRIDE_TOKEN_{uuid.uuid4().hex[:8]}"
override_comp = CompanySecret(
id=uuid.uuid4().hex,
company_id=company_id,
team_id=None,
key=override_key,
value_encrypted="base_value",
is_sensitive=True,
)
db.add(override_comp)
override_team = CompanySecret(
id=uuid.uuid4().hex,
company_id=company_id,
team_id=team_id,
key=override_key,
value_encrypted="overridden_value",
is_sensitive=True,
)
db.add(override_team)
await db.commit()
# Test resolving env for the specific team
env_vars = await resolve_runtime_env(db, company_id, team_id)
# Assertions
assert company_secret_key in env_vars
assert env_vars[company_secret_key] == "comp_secret_value"
assert team_secret_key in env_vars
assert env_vars[team_secret_key] == "team_secret_value"
assert override_key in env_vars
assert env_vars[override_key] == "overridden_value", "Team secret should override company secret"
# Test resolving env for no team (company level only)
env_vars_comp_only = await resolve_runtime_env(db, company_id, None)
assert company_secret_key in env_vars_comp_only
assert team_secret_key not in env_vars_comp_only, "Team secret should not leak to company-level context"
assert env_vars_comp_only[override_key] == "base_value", "Company level should see the base value"
@pytest.mark.asyncio
async def test_resolve_enabled_toolsets(db: AsyncSession):
company_id = uuid.uuid4().hex
team_id = "team_beta"
# Create plugin
plugin = Plugin(
id=uuid.uuid4().hex,
name="test_plugin",
version="1.0",
enabled=True
)
db.add(plugin)
# Create plugin config for team
config = PluginConfig(
id=uuid.uuid4().hex,
company_id=company_id,
team_id=team_id,
plugin_id=plugin.id,
is_enabled=True,
config={}
)
db.add(config)
await db.commit()
# Resolve
toolsets = await resolve_enabled_toolsets(db, company_id, team_id)
assert toolsets == ["test_plugin"]
# Resolve for another team should be None or empty
toolsets_other = await resolve_enabled_toolsets(db, company_id, "other_team")
assert toolsets_other == []
# Canifa · Brand Spec # Paperclip · Brand Spec
> 采集日期:2026-04-30 > 收集日期:2026-05-30
> 资产来源:canifa.com 官网提取 > 资产来源:paperclip.ai 提取
> 资产完整度:完整
## 🎯 核心资产(一等公民) ## 🎯 核心资产(一等公民)
### Logo ### Logo
- 主版本:`backend/static/canifa-brand/logo.svg` - 主版本:`backend/static/paperclip-brand/logo.svg`
- 使用场景:Chatbot Header, Watermark, App Launcher - 使用场景:Chatbot Header, Watermark, App Launcher
- 视觉特征:经典的 "CANIFA" 无衬线字标,置于红色圆角矩形块中。 - 视觉特征:现代字标 "Paperclip" 无衬线设计,结合圆角几何形状。
### 品牌气质 ### 品牌气质
- 核心关键词:Fashion for All, Gia đình, Năng động, Tin cậy. - 核心关键词:Efficiency, Automation, Reliability, Modular.
- 2026 夏季主题:Trạm Hè Đa Sắc (Vibrant Summer Station)
## 🎨 辅助资产 ## 🎨 辅助资产
### 色板 ### 色板
- **Canifa Red (Primary)**: `#E2231A` (Logo & CTA) - **Paperclip Blue (Primary)**: `#0F62FE` (Logo & CTA)
- **Deep Navy/Black**: `#333F48` (Typography & Secondary elements) - **Deep Navy/Black**: `#161616` (Typography & Secondary elements)
- **Summer Yellow/Lime**: `#C4FF1C` (Highlighter / New Campaign) - **Tech Teal/Accent**: `#00F0FF` (Highlighter / Accent elements)
- **White**: `#FFFFFF` (Background) - **White**: `#FFFFFF` (Background)
- **Light Gray**: `#F5F5F5` (Container background) - **Light Gray**: `#F4F4F4` (Container background)
### 字型 ### 字型
- **Display**: Sans-serif (Clean, modern like Montserrat or Inter) - **Display**: Sans-serif (Montserrat, Outfit, or Inter)
- **Body**: Sans-serif (Highly readable) - **Body**: Sans-serif (Highly readable)
### 交互签名 ### 交互签名
- 极简、流畅、圆角适中(8px - 12px)。 - 极简、流畅、圆角适中(8px - 12px)。
- 强调 "Joy" (Niềm vui) thông qua các hiệu ứng chuyển cảnh nhẹ nhàng. - 强调效率与高响应性。
# Paperclip OS — Industry Blueprints Guide
Paperclip OS is a generic, blueprint-driven AI Company Operating System. Instead of being tied to a single industry or organization structure, Paperclip can bootstrap entirely different organizations (e.g., Software Studio, Fashion Retail, E-commerce, Marketing Agency) from a single YAML blueprint definition.
This guide explains how to write, customize, and deploy blueprints in Paperclip.
---
## 1. Blueprint Structure
A blueprint is a YAML file stored in the `backend/blueprints/` folder. It has three main sections:
1. **Metadata**: Define the template name, industry, and description.
2. **Roles (Organizational Chart)**: Specify the AI agents, their credentials, adapters, capabilities, and hierarchy (who reports to whom).
3. **Pipeline (Task Execution DAG)**: Graph definition detailing the sequence of task executions and approval gates.
Here is a complete example of a blueprint structure:
```yaml
name: "Software Studio"
industry: "software"
description: "A blueprint for custom software development projects, featuring PMs, system architects, coders, and QA engineers."
roles:
- role: "ceo"
name: "CEO Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "strategic direction, weekly reporting, approval, corporate oversight"
reports_to: null
- role: "pm"
name: "Product Manager Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "product specifications writing, feature scoping, task prioritization"
reports_to: "ceo"
- role: "designer"
name: "Software Architect Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "system architecture, database schema design, class diagrams, API interfaces"
reports_to: "pm"
- role: "coder"
name: "Lead Developer Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "full-stack coding, writing algorithms, API implementation, code refactoring"
reports_to: "pm"
- role: "qa"
name: "QA Specialist Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "writing unit tests, bug hunting, API response verification"
reports_to: "pm"
- role: "devops"
name: "DevOps Engineer Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "CI/CD pipeline configuration, Docker container build, deployment scripts, monitoring"
reports_to: "ceo"
pipeline:
- role: "ceo"
depends_on: []
approval_gate: false
- role: "pm"
depends_on: ["ceo"]
approval_gate: true
- role: "designer"
depends_on: ["pm"]
approval_gate: false
- role: "coder"
depends_on: ["designer"]
approval_gate: false
- role: "qa"
depends_on: ["coder"]
approval_gate: true
- role: "devops"
depends_on: ["qa"]
approval_gate: false
```
---
## 2. Roles Configuration
The `roles` array specifies the AI agents created for the company:
- `role`: The unique lowercase key for the role (e.g. `ceo`, `designer`, `coder`).
- `name`: The display title/name of the agent.
- `adapter_type`: The runtime executor adapter (e.g. `openai`, `claude_local`, `gemini_local`, `cursor`, `opencode_local`).
- `adapter_config`: Arguments passed to the adapter. E.g. `model_name` or custom environment variables.
- `runtime_config`: Platform-level configurations, such as `max_turns_per_run`.
- `capabilities`: Plain-text description of the agent's skillset, injected into its system prompts.
- `reports_to`: Optional parent role identifier establishing the reporting hierarchy (used for task routing and escalation).
---
## 3. Pipeline / DAG Configuration
The `pipeline` list defines the dependency graph (DAG) for project execution runs:
- `role`: The agent responsible for this pipeline stage.
- `depends_on`: A list of parent roles that must complete execution before this stage can start. Parallel branches run concurrently automatically.
- `approval_gate`: A boolean flag. If `true`, the pipeline pauses after this agent finishes its work, demanding user/board review and approval before triggering dependent stages.
---
## 4. Cycle Validation
Paperclip OS validates all custom blueprints upon loading. The service uses Kahn's algorithm to perform cycle checks and validates that all role references in both `reports_to` and `depends_on` are correctly defined in the blueprint's roles array. If a cycle or an invalid reference is detected, the API will refuse to load the blueprint and raise a `400 Bad Request`.
# Demo E2E Run # Demo E2E Run
**Date:** 2026-06-02T14:28:31.221087 **Date:** 2026-06-02T16:31:09.656355
**Status:** completed **Status:** completed
**Duration:** 77.0s **Duration:** 77.0s
**Blueprint:** software-studio **Blueprint:** software-studio
...@@ -33,8 +33,8 @@ ...@@ -33,8 +33,8 @@
```json ```json
{ {
"pipeline_id": "a385b7aaee904b00ba8ceec01a3878c1", "pipeline_id": "34fc39fd679443cfbf2b5b2062855374",
"company_id": "c7cfd603b9504bfd934a66faf9b48363", "company_id": "d187083b62ab4ff0ab7674383e7b3e49",
"status": "completed", "status": "completed",
"total_duration_s": 77.0, "total_duration_s": 77.0,
"agents_executed": 6, "agents_executed": 6,
...@@ -57,66 +57,66 @@ ...@@ -57,66 +57,66 @@
"steps": [ "steps": [
{ {
"step": 1, "step": 1,
"agent_id": "ceo_41e8c849", "agent_id": "ceo_cf76fda2",
"agent_name": "CEO Agent", "agent_name": "CEO Agent",
"role": "ceo", "role": "ceo",
"run_id": "14f136fa17af447d8531a2ed1dc092a0", "run_id": "7d68ebab076841d592e6d92ace061a09",
"status": "completed", "status": "completed",
"duration_s": 9.8, "duration_s": 10.0,
"error": null "error": null
}, },
{ {
"step": 2, "step": 2,
"agent_id": "pm_04078779", "agent_id": "pm_fae7b171",
"agent_name": "Product Manager Agent", "agent_name": "Product Manager Agent",
"role": "pm", "role": "pm",
"run_id": "1fb0960c6cd74c0bb1a4a94f5a17333d", "run_id": "95f1d638f3994eb3992648345b1deddd",
"status": "completed", "status": "completed",
"duration_s": 13.2, "duration_s": 13.3,
"error": null "error": null
}, },
{ {
"step": 3, "step": 3,
"agent_id": "designer_8c957a41", "agent_id": "designer_5f34c481",
"agent_name": "Software Architect Agent", "agent_name": "Software Architect Agent",
"role": "designer", "role": "designer",
"run_id": "2b181e6d8cec49b0854bd56b4a457654", "run_id": "6be1be0b76d04463b9b9e8448c7a1119",
"status": "completed", "status": "completed",
"duration_s": 9.7, "duration_s": 9.6,
"error": null "error": null
}, },
{ {
"step": 4, "step": 4,
"agent_id": "coder_ae0f63cf", "agent_id": "coder_5eebeb72",
"agent_name": "Lead Developer Agent", "agent_name": "Lead Developer Agent",
"role": "coder", "role": "coder",
"run_id": "002d6d62123b48e0b940b6fde8f4e8ad", "run_id": "0dc497854f284291b4529fc648e7884a",
"status": "completed", "status": "completed",
"duration_s": 11.8, "duration_s": 11.7,
"error": null "error": null
}, },
{ {
"step": 5, "step": 5,
"agent_id": "qa_7d279926", "agent_id": "qa_43ef2a4f",
"agent_name": "QA Specialist Agent", "agent_name": "QA Specialist Agent",
"role": "qa", "role": "qa",
"run_id": "c81cd6e371c9477cb96e0f3c3e2821c1", "run_id": "23c544f066ae45048ee65ef14579fda0",
"status": "completed", "status": "completed",
"duration_s": 10.2, "duration_s": 10.2,
"error": null "error": null
}, },
{ {
"step": 6, "step": 6,
"agent_id": "devops_ff60aad0", "agent_id": "devops_ce827a83",
"agent_name": "DevOps Engineer Agent", "agent_name": "DevOps Engineer Agent",
"role": "devops", "role": "devops",
"run_id": "989711aa25734782a133895ac59c400b", "run_id": "452891ae28bc4fc0bf6a7234fc081021",
"status": "completed", "status": "completed",
"duration_s": 9.7, "duration_s": 9.6,
"error": null "error": null
} }
], ],
"started_at": "2026-06-02T14:28:31.221087", "started_at": "2026-06-02T16:31:09.656355",
"completed_at": "2026-06-02T14:29:48.183474" "completed_at": "2026-06-02T16:32:26.635376"
} }
``` ```
\ No newline at end of file
...@@ -19,3 +19,5 @@ export { sidebarPreferencesApi } from "./sidebarPreferences"; ...@@ -19,3 +19,5 @@ export { sidebarPreferencesApi } from "./sidebarPreferences";
export { inboxDismissalsApi } from "./inboxDismissals"; export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills"; export { companySkillsApi } from "./companySkills";
export { blueprintsApi } from "./blueprints"; export { blueprintsApi } from "./blueprints";
export { pipelineRunsApi } from "./pipelineRuns";
import { api } from "./client";
export interface PipelineRunStep {
id: string;
agentName: string;
role: string;
status: string;
step: number;
startedAt: string;
completedAt?: string;
error?: string;
executionCostCents: number;
}
export interface PipelineRun {
pipelineId: string;
status: string;
startedAt: string;
completedAt?: string;
durationS: number;
steps: PipelineRunStep[];
agentsExecuted: number;
agentsSucceeded: number;
agentsFailed: number;
}
export const pipelineRunsApi = {
list: (companyId: string) =>
api.get<PipelineRun[]>(`/companies/${companyId}/pipeline-runs`),
get: (companyId: string, pipelineId: string) =>
api.get<PipelineRun>(`/companies/${companyId}/pipeline-runs/${pipelineId}`),
};
import type { Issue, Agent } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { useState, useMemo } from "react";
import { ChevronRight, ChevronDown, Bot, GitBranch, ArrowUpRight } from "lucide-react";
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { cn } from "../lib/utils";
interface SwarmTreeProps {
rootIssue: Issue;
allIssues: Issue[];
agents: Agent[];
}
interface TreeNodeProps {
issue: Issue;
childMap: Map<string, Issue[]>;
agentMap: Map<string, Agent>;
depth: number;
isLast: boolean;
}
function TreeNode({ issue, childMap, agentMap, depth, isLast }: TreeNodeProps) {
const [expanded, setExpanded] = useState(true);
const children = childMap.get(issue.id) ?? [];
const hasChildren = children.length > 0;
const agent = issue.assigneeAgentId ? agentMap.get(issue.assigneeAgentId) : null;
return (
<div className="relative flex flex-col">
{/* Node Row */}
<div className="flex items-start gap-3 relative group">
{/* Connection lines using borders */}
{depth > 0 && (
<div
className="absolute left-[-16px] top-[-16px] w-[16px] h-[34px] border-l-2 border-b-2 border-border/40 rounded-bl-lg pointer-events-none group-hover:border-primary/30 transition-colors"
/>
)}
{/* Expand/Collapse Trigger */}
<div className="flex items-center justify-center w-6 h-8 shrink-0 z-10">
{hasChildren ? (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-center w-5 h-5 rounded-md hover:bg-accent/80 border border-border/60 bg-card text-muted-foreground hover:text-foreground shadow-sm transition-colors"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<div className="w-1.5 h-1.5 rounded-full bg-border" />
)}
</div>
{/* Card Component */}
<div className="flex-1 max-w-xl mb-4">
<div className={cn(
"p-3.5 rounded-xl border border-border/80 bg-card hover:bg-accent/5 hover:border-primary/40 shadow-sm hover:shadow transition-all duration-200 flex flex-col gap-2 relative",
issue.status === "done" && "bg-muted/10 opacity-80"
)}>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs font-semibold text-muted-foreground bg-muted/60 px-2 py-0.5 rounded">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground bg-accent/40 px-1.5 py-0.5 rounded font-medium">
Depth {depth}
</span>
<StatusBadge status={issue.status} />
</div>
</div>
<h4 className="text-sm font-medium text-foreground leading-snug line-clamp-2">
{issue.title}
</h4>
<div className="flex items-center justify-between gap-4 mt-1 border-t border-border/40 pt-2 text-xs">
{/* Agent Assignee */}
{agent ? (
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="flex h-5 w-5 items-center justify-center rounded-full border border-border/60 bg-background text-muted-foreground shadow-inner">
<AgentIcon icon={agent.icon} className="h-3 w-3" />
</div>
<span className="font-medium truncate max-w-[120px]">{agent.name}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-muted-foreground/60">
<Bot className="h-3.5 w-3.5 text-muted-foreground/40" />
<span>Unassigned</span>
</div>
)}
{/* Navigation Link */}
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="inline-flex items-center gap-1 text-[11px] text-primary hover:text-primary/80 font-medium transition-colors"
>
Go to issue
<ArrowUpRight className="h-3 w-3" />
</Link>
</div>
</div>
</div>
</div>
{/* Children Section */}
{hasChildren && expanded && (
<div className="pl-6 ml-3 border-l border-border/40 flex flex-col">
{children.map((child, idx) => (
<TreeNode
key={child.id}
issue={child}
childMap={childMap}
agentMap={agentMap}
depth={depth + 1}
isLast={idx === children.length - 1}
/>
))}
</div>
)}
</div>
);
}
export function SwarmTree({ rootIssue, allIssues, agents }: SwarmTreeProps) {
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents) {
map.set(a.id, a);
}
return map;
}, [agents]);
const childMap = useMemo(() => {
const map = new Map<string, Issue[]>();
for (const issue of allIssues) {
if (issue.parentId) {
const list = map.get(issue.parentId) ?? [];
list.push(issue);
map.set(issue.parentId, list);
}
}
return map;
}, [allIssues]);
const hasChildren = (childMap.get(rootIssue.id) ?? []).length > 0;
return (
<div className="space-y-6 py-2 px-1 max-w-4xl">
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="space-y-1">
<h3 className="text-base font-semibold flex items-center gap-2">
<GitBranch className="h-4 w-4 text-primary" />
Swarm Execution Tree
</h3>
<p className="text-xs text-muted-foreground">
Visualize the hierarchy of automatic agent-spawned issues and task breakdown.
</p>
</div>
<div className="text-xs text-muted-foreground bg-muted/40 px-2.5 py-1 rounded-md border border-border/40 font-medium">
Total Nodes: {allIssues.length + 1}
</div>
</div>
{!hasChildren ? (
<div className="flex flex-col items-center justify-center py-12 text-center border border-dashed border-border rounded-xl bg-muted/10">
<GitBranch className="h-8 w-8 text-muted-foreground/30 mb-2.5" />
<h4 className="text-sm font-semibold text-muted-foreground">No Spawning Swarm</h4>
<p className="text-xs text-muted-foreground/80 max-w-sm mt-1">
This issue does not have any active sub-issues or automatic agent-spawned tasks.
</p>
</div>
) : (
<div className="overflow-x-auto pb-4">
<div className="min-w-[600px] select-none">
<TreeNode
issue={rootIssue}
childMap={childMap}
agentMap={agentMap}
depth={0}
isLast={true}
/>
</div>
</div>
)}
</div>
);
}
...@@ -76,6 +76,7 @@ import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; ...@@ -76,6 +76,7 @@ import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
import { IssueProperties } from "../components/IssueProperties"; import { IssueProperties } from "../components/IssueProperties";
import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import { SwarmTree } from "../components/SwarmTree";
import type { MentionOption } from "../components/MarkdownEditor"; import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
...@@ -123,6 +124,7 @@ import { ...@@ -123,6 +124,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
Flag, Flag,
GitBranch,
Hexagon, Hexagon,
ListTree, ListTree,
MessageSquare, MessageSquare,
...@@ -3895,6 +3897,10 @@ export function IssueDetail() { ...@@ -3895,6 +3897,10 @@ export function IssueDetail() {
<ListTree className="h-3.5 w-3.5" /> <ListTree className="h-3.5 w-3.5" />
Related work Related work
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="swarm-tree" className="gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
Swarm Tree
</TabsTrigger>
{issuePluginTabItems.map((item) => ( {issuePluginTabItems.map((item) => (
<TabsTrigger key={item.value} value={item.value}> <TabsTrigger key={item.value} value={item.value}>
{item.label} {item.label}
...@@ -4006,6 +4012,16 @@ export function IssueDetail() { ...@@ -4006,6 +4012,16 @@ export function IssueDetail() {
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} /> <IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
</TabsContent> </TabsContent>
<TabsContent value="swarm-tree">
{detailTab === "swarm-tree" ? (
<SwarmTree
rootIssue={issue}
allIssues={childIssues}
agents={agents ?? []}
/>
) : null}
</TabsContent>
{activePluginTab && ( {activePluginTab && (
<TabsContent value={activePluginTab.value}> <TabsContent value={activePluginTab.value}>
<PluginSlotMount <PluginSlotMount
......
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { pipelineRunsApi } from "../api/pipelineRuns";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import {
History,
CheckCircle2,
XCircle,
Clock,
User,
ArrowRight,
FileText,
AlertTriangle,
} from "lucide-react";
import { cn } from "../lib/utils";
export function PipelineRuns() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [selectedPipelineId, setSelectedPipelineId] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Lịch sử Pipeline" }]);
}, [setBreadcrumbs]);
// Query list of pipeline runs
const { data: runs, isLoading, error } = useQuery({
queryKey: ["pipeline-runs", selectedCompanyId],
queryFn: () => pipelineRunsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 10000, // Refresh runs list every 10s
});
// Query selected pipeline run details
const { data: detail, isLoading: isDetailLoading } = useQuery({
queryKey: ["pipeline-run-detail", selectedCompanyId, selectedPipelineId],
queryFn: () => pipelineRunsApi.get(selectedCompanyId!, selectedPipelineId!),
enabled: !!selectedCompanyId && !!selectedPipelineId,
refetchInterval: (query) => {
// Poll selected run faster if it is running
const data = query.state.data;
return data && data.status === "running" ? 5000 : false;
},
});
// Automatically select the first pipeline run if none is selected
useEffect(() => {
if (runs && runs.length > 0 && !selectedPipelineId) {
setSelectedPipelineId(runs[0].pipelineId);
}
}, [runs, selectedPipelineId]);
if (!selectedCompanyId) {
return <EmptyState icon={History} message="Chọn phòng ban để xem lịch sử pipeline." />;
}
if (isLoading) {
return <PageSkeleton variant="list" />;
}
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-rose-500" />;
case "running":
return (
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-sky-500" />
</span>
);
default:
return <Clock className="h-4 w-4 text-muted-foreground" />;
}
};
const getStatusBadgeClass = (status: string) => {
switch (status) {
case "completed":
return "bg-emerald-500/10 text-emerald-500 border-emerald-500/20";
case "failed":
return "bg-rose-500/10 text-rose-500 border-rose-500/20";
case "running":
return "bg-sky-500/10 text-sky-500 border-sky-500/20";
default:
return "bg-muted text-muted-foreground border-border";
}
};
return (
<div className="flex h-[calc(100vh-10rem)] min-h-0 w-full gap-6">
{/* Left panel: List */}
<div className="flex w-80 shrink-0 flex-col border border-border bg-card rounded-lg overflow-hidden">
<div className="border-b border-border p-4 bg-muted/30">
<h2 className="text-sm font-semibold flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
Các phiên chạy ({runs?.length ?? 0})
</h2>
</div>
{runs?.length === 0 ? (
<div className="flex-grow flex flex-col items-center justify-center p-6 text-center">
<History className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground">Chưa có phiên chạy nào</p>
</div>
) : (
<div className="flex-1 overflow-y-auto divide-y divide-border scrollbar-auto-hide">
{runs?.map((run) => (
<button
key={run.pipelineId}
onClick={() => setSelectedPipelineId(run.pipelineId)}
className={cn(
"w-full text-left p-4 hover:bg-accent/40 transition-colors flex flex-col gap-1.5 outline-none border-l-2",
selectedPipelineId === run.pipelineId
? "bg-accent/50 border-primary"
: "border-transparent"
)}
>
<div className="flex items-center justify-between">
<span className="text-xs font-mono truncate max-w-[120px]" title={run.pipelineId}>
#{run.pipelineId.slice(0, 8)}
</span>
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full border font-medium",
getStatusBadgeClass(run.status)
)}>
{run.status === "completed" ? "Đã xong" : run.status === "failed" ? "Lỗi" : "Đang chạy"}
</span>
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{new Date(run.startedAt).toLocaleTimeString()}</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{run.durationS}s
</span>
</div>
<div className="text-[11px] text-muted-foreground flex items-center gap-1.5">
<span className="text-emerald-500 font-semibold">{run.agentsSucceeded} xong</span>
{run.agentsFailed > 0 && <span className="text-rose-500 font-semibold">{run.agentsFailed} lỗi</span>}
</div>
</button>
))}
</div>
)}
</div>
{/* Right panel: Detail */}
<div className="flex-1 flex flex-col border border-border bg-card rounded-lg overflow-hidden min-w-0">
{!selectedPipelineId ? (
<div className="flex-grow flex flex-col items-center justify-center p-8 text-center">
<History className="h-12 w-8 text-muted-foreground/20 mb-4" />
<h3 className="text-sm font-semibold">Chọn phiên chạy</h3>
<p className="text-xs text-muted-foreground mt-1">Chọn một phiên chạy từ danh sách bên trái để xem chi tiết.</p>
</div>
) : isDetailLoading && !detail ? (
<div className="flex-grow flex items-center justify-center">
<span className="text-sm text-muted-foreground">Đang tải...</span>
</div>
) : (
<div className="flex-1 flex flex-col min-h-0">
{/* Detail Header */}
<div className="border-b border-border p-6 bg-muted/20 flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-base font-semibold font-mono">
Run #{detail?.pipelineId}
</h2>
<span className={cn(
"text-xs px-2.5 py-0.5 rounded-full border font-medium flex items-center gap-1.5",
getStatusBadgeClass(detail?.status ?? "")
)}>
{getStatusIcon(detail?.status ?? "")}
{detail?.status === "completed" ? "Completed" : detail?.status === "failed" ? "Failed" : "Running"}
</span>
</div>
<p className="text-xs text-muted-foreground">
Bắt đầu lúc {detail && new Date(detail.startedAt).toLocaleString()} • Tổng thời gian: {detail?.durationS}s
</p>
</div>
</div>
{/* Steps Checklist & Deliverables */}
<div className="flex-grow overflow-y-auto p-6 space-y-6">
<div>
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<span>Trình tự thực thi ({detail?.steps.length} bước)</span>
</h3>
<div className="border border-border rounded-lg overflow-hidden divide-y divide-border">
{detail?.steps.map((step) => (
<div key={step.id} className="p-4 hover:bg-muted/10 transition-colors flex items-start gap-4">
<div className="mt-1 shrink-0">
{getStatusIcon(step.status)}
</div>
<div className="flex-grow space-y-1 min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold flex items-center gap-2">
{step.agentName}
<span className="text-xs font-normal text-muted-foreground px-1.5 py-0.5 bg-muted rounded-md">
{step.role}
</span>
</span>
<span className="text-xs text-muted-foreground">
Bắt đầu lúc {new Date(step.startedAt).toLocaleTimeString()}
</span>
</div>
{step.error && (
<div className="p-2 border border-rose-500/20 bg-rose-500/5 text-rose-500 text-xs rounded-md flex items-start gap-2 max-w-full overflow-x-auto font-mono">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{step.error}</span>
</div>
)}
<div className="flex flex-wrap items-center justify-between text-xs text-muted-foreground pt-1">
<span>Chi phí: {((step.executionCostCents ?? 0) / 100).toFixed(2)} USD</span>
{step.completedAt && (
<span>
Hoàn thành sau: {Math.round((new Date(step.completedAt).getTime() - new Date(step.startedAt).getTime()) / 1000)}s
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Deliverables section */}
{detail?.status === "completed" && (
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>Sản phẩm đầu ra (Deliverables)</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{detail.steps
.filter((s) => s.status === "completed")
.map((step) => {
const fileKey = `${step.role.toLowerCase().replace(" ", "_")}_output.md`;
return (
<div key={step.id} className="p-4 border border-border bg-muted/20 hover:bg-muted/40 transition-colors rounded-lg flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold">{step.agentName}</p>
<p className="text-[11px] text-muted-foreground font-mono">{fileKey}</p>
</div>
<a
href={`/api/companies/${selectedCompanyId}/shared/${fileKey}`}
target="_blank"
rel="noopener noreferrer"
download
className="text-xs text-primary font-medium hover:underline flex items-center gap-1"
>
Xem output
<ArrowRight className="h-3 w-3" />
</a>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
# 🔨 Doing #12 — Team Schema + Migration (Chunk 1, prereq)
**Goal:** Đẻ entity `Team` + cột `team_id` nullable trên 5 bảng tài nguyên, đổi unique constraint của `company_secrets`. Một migration round-trip được. KHÔNG đụng runtime/logic — chỉ schema.
**Dependency:** Không (đây là prereq của tất cả các chunk sau).
**Idea nguồn:** `plan/ideas/33_multi_team_company.md`
---
## Research-first (đọc trước khi sửa)
- [x] `backend/models/agents.py` — xem cách 1 model khai báo (Base, `company_id` FK, `__tablename__`, mixin timestamp nếu có)
- [x] `backend/models/company_secrets.py` — tìm `UniqueConstraint(company_id, key)` hiện tại (chỗ phải đổi)
- [x] `backend/models/memory.py`, `company_skills.py`, `plugin_config.py` — xác nhận đều scope `company_id`, chưa có `team_id`
- [x] `backend/alembic/versions/` (hoặc thư mục migration) — xem migration gần nhất + cách dùng `batch_alter_table` (SQLite KHÔNG ALTER constraint in-place)
- [x] Xác nhận file DB: `backend/paperclip.db` (hay đường dẫn khác trong `alembic.ini`/`env.py`)
## Phases
### Phase 1 — Model
- [x] Tạo `backend/models/teams.py`: `Team` (id `String(36)` PK, `company_id` FK→companies.id NOT NULL, `name` String(255) NOT NULL, `slug` String(100), `parent_team_id` FK→teams.id **nullable**, `created_at`/`updated_at`)
- [x] Đăng ký model vào nơi import chung (giống các model khác — kiểm `models/__init__.py`)
- [x] Thêm `team_id String(36) FK→teams.id nullable` vào: `agents`, `memory`, `company_skills`, `company_secrets`, `plugin_config`
- [x] Sửa `company_secrets`: `UniqueConstraint(company_id, key)` → `UniqueConstraint(company_id, team_id, key)`
### Phase 2 — Migration (batch mode)
- [x] 1 file Alembic: tạo bảng `teams` **TRƯỚC** → `batch_alter_table` add cột `team_id` cho từng bảng → trong batch của `company_secrets` swap unique constraint (drop cũ, tạo mới có team_id)
- [x] Viết `downgrade()` đối xứng (drop cột, drop bảng teams, khôi phục constraint cũ)
## Verify
- [x] Copy DB ra bản tạm rồi chạy: `alembic upgrade head` → `alembic downgrade -1` → `alembic upgrade head` round-trip KHÔNG lỗi
- [x] `rtk python -c "import sqlite3; c=sqlite3.connect('paperclip.db'); print(c.execute('SELECT count(*) FROM agents WHERE team_id IS NOT NULL').fetchone())"` → `(0,)` (mọi row cũ null = backward-compat)
- [x] `rtk python -c "import sqlite3; c=sqlite3.connect('paperclip.db'); print([r[1] for r in c.execute('PRAGMA table_info(company_secrets)')])"` → có cột `team_id`
- [x] Backend import sạch: `rtk python -c "from models.teams import Team; print('ok')"` (chạy từ `backend/` với venv)
# 🔨 Doing #13 — Layer-0 Runtime Wiring (Chunk 2, HARD BLOCKER)
**Goal:** Secret/MCP/toolset mà team khai báo trở thành tool THẬT lúc agent chạy. Sau chunk này: secret team-scoped có mặt trong `os.environ` *trong* run và *biến mất* sau run; agent team khác không thấy.
**Dependency:** #12 (cột `team_id` phải tồn tại). Đây là **HARD BLOCKER** cho #15, #16.
**Idea nguồn:** `plan/ideas/34_layer0_runtime_wiring.md`
---
## Research-first (đọc trước khi sửa — bám đúng file:line)
- [x] `backend/api/routes/agents.py:440-514` — `run_agent_in_background`: chỗ dựng `AIAgent(...)` (L455) + pattern set `os.environ["CUBE_SANDBOX_ID"]` (L450) và `del` trong `finally` (L507) → **mirror đúng pattern này** cho env injection
- [x] `backend/agent/run_agent.py:~4171` — `run_conversation` gọi `register_company_tools(self.company_id)`
- [x] `backend/agent/tools/get_tools.py` — `register_company_tools` / `get_all_tools`: cách MCP tool được nạp + cách skills loader được gọi
- [x] `backend/agent/tools/skills_loader.py:51-100` — `load_skills_as_tools` (chữ ký để thread `team_id`)
- [x] `backend/services/blueprint_service.py:~221` — xác nhận `value_encrypted = value` (lưu KHÔNG mã hoá) → resolver cần decrypt-with-raw-fallback
- [x] Tìm hàm `decrypt_api_key` (hoặc tương đương) để fallback đúng cách
- [x] `models/plugin_config.py` + `models/company_secrets.py` — field thật (`is_enabled`, `key`, `value_encrypted`, ...)
## Phases
### Phase 1 — Resolver
- [x] Tạo `backend/services/runtime_env.py`: `resolve_runtime_env(company_id, team_id) -> dict[str,str]`
- [x] Query `CompanySecret WHERE company_id=? AND (team_id=? OR team_id IS NULL)`
- [x] Trùng key → **team thắng** (apply company-wide trước, team đè sau)
- [x] Mỗi value: thử `decrypt_api_key`, lỗi → dùng raw (decrypt-with-raw-fallback)
### Phase 2 — Thread team_id + inject env
- [x] Thread `team_id` qua: `run_agent_in_background` → `AIAgent` → `run_conversation` → `register_company_tools(company_id, team_id)` → `load_skills_as_tools(company_id, team_id)`
- [x] Trong `run_agent_in_background`: đọc `agent.team_id`, gọi `resolve_runtime_env`, set các key vào `os.environ` TRƯỚC run, `del` trong `finally` (mirror CUBE_SANDBOX_ID). Lưu key đã set để teardown chính xác (không xoá nhầm env có sẵn)
### Phase 3 — Toolset + MCP filter
- [x] Query `PluginConfig (company_id, team_id resolve, is_enabled=True)` → map `Plugin.name`→toolset → truyền `enabled_toolsets=[...]` vào `AIAgent`. Rỗng/không có row → `None` (KHÔNG regress)
- [x] `register_company_tools`: sau khi load MCP tool global, **lọc** chỉ giữ tool của server mà PluginConfig team bật (filter-only, KHÔNG start subprocess per-team)
### Phase 4 — Concurrency guard
- [x] `blueprint_service.validate_pipeline_dag` (hoặc nơi validate DAG): thêm rule — role tiêu thụ secret KHÔNG được nằm cùng 1 stage song song với role team khác cũng tiêu thụ secret (serialize). Publisher là stage cuối singleton → tự thoả
## Verify
- [ ] Integration test mới: tạo company + team, gán `CompanySecret(team_id=T, key="FOO_TOKEN", value="xyz")`; chạy agent của team T (mock run) → assert `os.environ["FOO_TOKEN"]=="xyz"` *trong* run, và `"FOO_TOKEN" not in os.environ` *sau* run
- [ ] Agent của team khác (hoặc company-wide) chạy → KHÔNG thấy `FOO_TOKEN`
- [ ] Company KHÔNG có PluginConfig nào → `enabled_toolsets=None`, agent chạy bình thường (regression check) — pipeline E2E demo cũ vẫn pass: `cd /d/a/ai_canifa_company/backend && ./.venv/Scripts/pytest tests/ -q`
# 🔨 Doing #14 — Blueprint Teams Provisioning (Chunk 3, song song với #13)
**Goal:** Blueprint YAML khai báo được `teams:` block; deploy tạo `Team` rows, gán `agent.team_id`, ghi plugins/skills/env_vars team-scoped.
**Dependency:** #12 (schema). Chạy **song song** với #13 (không phụ thuộc lẫn nhau).
**Idea nguồn:** `plan/ideas/33_multi_team_company.md` (block `teams:` YAML)
---
## Research-first (đọc trước khi sửa)
- [ ] `backend/schemas/blueprint.py` — Pydantic schema blueprint hiện tại (role, plugins, skills, env_vars). Tìm class `BlueprintRole` (hoặc tương đương) để thêm field `team`
- [ ] `backend/services/blueprint_service.py` — `deploy_company_from_blueprint`: 3 vòng lặp ghi plugins / skills / env_vars (hiện scope company_id) → đây là chỗ gộp thành helper team-aware
- [ ] `backend/services/blueprint_service.py:~221` — chỗ ghi `CompanySecret` (`value_encrypted = value`)
- [ ] `backend/blueprints/marketing-agency.yaml` — cấu trúc YAML mẫu để biết field naming convention
## Phases
### Phase 1 — Schema
- [ ] `schemas/blueprint.py`: thêm `BlueprintTeam` (slug, name, plugins?, skills?, env_vars?) + list `teams: list[BlueprintTeam] = []` trên blueprint root
- [ ] Thêm field `team: str | None = None` trên role schema (slug tham chiếu tới teams[].slug)
### Phase 2 — Deploy logic
- [ ] `deploy_company_from_blueprint`: TRƯỚC khi tạo agents → tạo `Team` rows từ `blueprint.teams`, build map `slug → team.id`
- [ ] Khi tạo agent: nếu role có `team`, set `agent.team_id = slug_map[role.team]`; validate slug tồn tại (lỗi rõ nếu sai)
- [ ] Gộp 3 loop provisioning thành 1 helper `_provision_resources(db, company_id, team_id, plugins, skills, env_vars)`; gọi:
- [ ] 1 lần cho top-level (team_id=None = company-wide, giữ hành vi cũ)
- [ ] 1 lần cho mỗi team (team_id = team.id)
## Verify
- [ ] Unit test mới: deploy 1 fixture blueprint có `teams: [{slug: brand-social, env_vars: {FOO: bar}, skills: [social-publishing]}]` + role gắn team đó
- [ ] DB có `Team` row name/slug đúng
- [ ] Agent của role đó có `team_id` = id team
- [ ] `CompanySecret`/`CompanySkill`/`PluginConfig` của team có `team_id` set đúng (không null)
- [ ] Deploy blueprint CŨ (không có `teams:`) vẫn chạy: mọi resource `team_id=None`, agents `team_id=None` (backward-compat)
- [ ] `cd /d/a/ai_canifa_company/backend && ./.venv/Scripts/pytest tests/ -q` xanh
# 🔨 Doing #15 — Skill Templates + Team-Aware Loader (Chunk 4)
**Goal:** Ship 2 executable skill (`social-publishing`, `content-calendar`) + làm `get_company_skill_names` lọc theo team. Team có row skill → nạp đúng skill đó thành tool; team khác không thấy.
**Dependency:** #12 (cột team_id trên company_skills) + threading `team_id` của #13 (để `load_skills_as_tools(company_id, team_id)` nhận param).
**Idea nguồn:** `plan/ideas/36_executable_skill_templates.md`
---
## Research-first (đọc trước khi sửa)
- [ ] `backend/agent/tools/skills_loader.py:33-49` — `get_company_skill_names` (raw sqlite3, chỉ company_id) → chỗ làm team-aware
- [ ] `backend/agent/tools/skills_loader.py:51-100` — `load_skills_as_tools`: cách join name ⇄ folder, cách gọi `get_company_skill_names`
- [ ] `.agent/skills/` — đọc 1 SKILL.md mẫu có sẵn để biết format frontmatter (`name`, `description`, body)
## Phases
### Phase 1 — Skill files
- [ ] `.agent/skills/social-publishing/SKILL.md`: frontmatter `name: social-publishing` + hướng dẫn chuẩn hoá bài đăng (hook → body → CTA → hashtag), ràng buộc độ dài theo channel, checklist trước khi gọi `send_message_tool`. **Nội dung = chỉ dẫn, KHÔNG code.**
- [ ] `.agent/skills/content-calendar/SKILL.md`: frontmatter `name: content-calendar` + hướng dẫn lập lịch nội dung (chủ đề theo thứ/tuần, cadence, evergreen)
### Phase 2 — Loader team-aware
- [ ] `get_company_skill_names(company_id, team_id=None)`: query `SELECT name FROM company_skills WHERE company_id=? AND (team_id=? OR team_id IS NULL)`. `team_id=None` → giữ hành vi cũ
- [ ] `load_skills_as_tools(company_id, team_id=None)`: nhận thêm `team_id`, truyền xuống `get_company_skill_names` (threading từ #13 đã đưa team_id tới đây)
## Verify
- [ ] Test: company C, team T có `CompanySkill(team_id=T, name="social-publishing")`; `load_skills_as_tools(C, T)` → tool `social-publishing` xuất hiện
- [ ] Team khác (team_id=T2) KHÔNG có row → `load_skills_as_tools(C, T2)` KHÔNG có tool đó
- [ ] `load_skills_as_tools(C, None)` (company-wide) → trả skill company-wide như cũ (regression)
- [ ] Folder skill load được không lỗi parse: `cd /d/a/ai_canifa_company/backend && ./.venv/Scripts/python -c "from agent.tools.skills_loader import load_skills_as_tools; print('ok')"`
# 🔨 Doing #16 — Publisher Role + E2E Live Test (Chunk 5)
**Goal:** Mở rộng `marketing-agency.yaml` thành công ty có team Social tự viết → tự kiểm → (người duyệt) → **tự đăng bài thật** lên channel test. Khép vòng A-Z.
**Dependency:** #13 (secret injection — BLOCKER) + #14 (blueprint teams). #15 (skills) nên xong để publisher có chỉ dẫn đăng chuẩn.
**Idea nguồn:** `plan/ideas/35_auto_publish_pipeline.md`
---
## Research-first (đọc trước khi sửa — điểm dễ sai nhất: TÊN ENV-VAR)
- [ ] `backend/agent/tools/send_message_tool.py` — **xác nhận CHÍNH XÁC tên env-var/key** mà tool (qua Hermes gateway) đọc cho Telegram/Discord (vd `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`?). Tên trong YAML PHẢI khớp 100%
- [ ] `backend/services/project_orchestrator.py` — `_run_single_agent` + cơ chế `approval_gate` (publisher dùng lại, KHÔNG viết runner mới)
- [ ] `backend/blueprints/marketing-agency.yaml` — cấu trúc roles/pipeline để mở rộng
- [ ] `~/.hermes/config.yaml` — xem credential gateway cần (hoặc env) để publish thật
## Phases
### Phase 1 — Blueprint
- [ ] Thêm `teams:` block `brand-social` vào `marketing-agency.yaml`: `skills: [social-publishing, content-calendar]`, `env_vars` với **tên đúng** từ research (token + chat/channel id của channel TEST)
- [ ] Thêm role `publisher` (team: brand-social) + system_prompt: đọc deliverable đã duyệt từ `shared/proofreader_output.md`, gọi `send_message_tool` đăng tới channel trong secret của team
- [ ] Thêm stage cuối pipeline: `- role: publisher / depends_on: [proofreader] / approval_gate: true`
### Phase 2 — (tuỳ chọn) Auto-schedule
- [ ] Tạo `Routine(company_id, cron="0 9 * * 1", query=brief)` để Celery Beat tự trigger pipeline thứ 2 hàng tuần (#31 đã có hạ tầng). Vẫn dừng ở approval gate
## Verify (LIVE — cần token thật của channel test)
- [ ] Deploy company từ blueprint mới → DB có team brand-social, publisher có team_id, secret token có team_id
- [ ] Trigger pipeline → chạy tới approval gate trước publisher → dừng chờ duyệt
- [ ] Approve gate → publisher chạy → **tin nhắn thật xuất hiện trên channel test** (Telegram/Discord) đăng bằng token của team
- [ ] Kiểm secret KHÔNG rò sang agent khác (xác nhận lại guard #13): chỉ publisher của team mới có token trong env lúc chạy
- [ ] Regression: `cd /d/a/ai_canifa_company/backend && ./.venv/Scripts/pytest tests/ -q` xanh
# Agent Handoff Notes — Paperclip OS 4 Pillars
> Đọc file này TRƯỚC khi làm bất cứ pillar nào.
> Cập nhật lần cuối: 2026-06-02
---
## 🛠️ Tooling quan trọng
### codegraph MCP
- **KHÔNG có** trong session mặc định (chỉ có sqlite MCP riêng biệt)
- Thay thế: dùng sqlite3 query trực tiếp
```bash
# Chạy từ thư mục backend/
./.venv/Scripts/python.exe -c "
import sqlite3
conn = sqlite3.connect('../.codegraph/codegraph.db')
rows = conn.execute(\"SELECT name, kind, file_path, start_line FROM nodes WHERE name='TênClass'\").fetchall()
print(rows)
"
```
- DB path: `.codegraph/codegraph.db` (ở ROOT project, không phải backend/)
- **KHÔNG được index**: `backend/services/`, `backend/tasks/`, `backend/tests/` — đọc trực tiếp bằng Read tool
### RTK
- Luôn dùng `rtk <cmd>` thay vì chạy raw
- Run tests: `./.venv/Scripts/pytest tests/X.py -v` (từ `backend/`)
- KHÔNG dùng `python -m pytest` (không hoạt động)
### Venv
- Path: `D:\a\ai_canifa_company\backend\.venv\Scripts\python.exe`
- Python 3.12.9, pytest 8.1.1
---
## 🏗️ Kiến trúc cần nhớ
### CaseConversionMiddleware (CRITICAL)
`backend/server.py` line 126-137: convert TẤT CẢ incoming JSON body từ camelCase → snake_case.
**→ Mọi Pydantic body model PHẢI dùng snake_case field names.**
Ví dụ: FE gửi `{"decisionNote": "x"}` → middleware đổi thành `{"decision_note": "x"}` → model phải có field `decision_note`.
### Database
- SQLite dev: `DATABASE_URL=sqlite+aiosqlite:///./paperclip.db`
- `get_db_context()` = asynccontextmanager tạo session từ global engine
- Các test dùng in-memory SQLite: `create_async_engine("sqlite+aiosqlite:///:memory:")`
### Blueprint system
- Blueprints dir: `backend/blueprints/` (.yaml files)
- `BlueprintService.deploy_company_from_blueprint(db, name, blueprint_name, owner_id)` → Company
- `BlueprintService.load_blueprint("software-studio")` → CompanyBlueprint object
- blueprint.pipeline → list[BlueprintPipelineNode(role, depends_on, approval_gate)]
### ProjectOrchestrator
- `ProjectOrchestrator(company_id, project_brief, pipeline_spec=list[dict]).run()` → report dict
- Gọi real LLM (OpenAI gpt-4o-mini) → cần `OPENAI_API_KEY`
- Deliverables: `workspaces/{company_id}/shared/`
---
## 📊 Trạng thái 4 Pillars
| Pillar | File doing | Trạng thái |
|--------|-----------|-----------|
| **S — Swarm** | `05_pillars_completion.md` §S | ✅ DONE — 4 tests pass |
| **H — Human-in-loop** | `05_pillars_completion.md` §H | ✅ DONE — 13 tests pass |
| **N — NocoBase** | `06_N_nocobase_npm.md` | 🔄 Code xong, cần chạy npm + live test PASS |
| **E — Demo E2E** | `07_E_demo_e2e.md` | ⏳ Chưa làm — đọc doing #07 |
---
## ✅ Completion gate cuối cùng
```
[ ] N: ./.venv/Scripts/pytest tests/test_nocobase_live.py → 1 PASSED (không skip)
[x] S: pytest -k swarm → 4 passed
[x] H: pytest tests/ → 13 passed
[ ] E: python scratch/demo_e2e.py → "✅ Demo E2E PASSED", docs/demo_run.md tồn tại
```
# 🏗️ DOING #05: Hoàn thiện 4 Pillar tầm nhìn gốc (sau adversarial audit)
## 📌 Context
- **Nguồn:** Audit 2026-06-01 (sau khi tầng Blueprint xong). User chọn build cả 4 hướng.
- **Tầng Blueprint đã THẬT + test** (5/5 pass, orchestrator gọi Hermes runtime thật). 4 pillar dưới là phần CHƯA.
- **Trạng thái thật từng pillar:**
- 🔴 **N — NocoBase:** "đấu dây nhưng chết" — connector code đủ nhưng KHÔNG có service trong docker-compose, mọi call try/except nuốt lỗi, mọi test đều mock. Chưa kết nối thật lần nào.
- 🟡 **S — Issue-Driven Swarm:** schema có (`parent_id`, `created_by_agent_id`, `request_depth`, `origin_kind`) nhưng KHÔNG có logic auto-spawn issue con. Pipeline hiện tuyến tính.
- 🟢 **H — Human-in-loop:** backend đầy đủ (Approval record + broadcast + poll resolution), FE có `Approvals.tsx`/`ApprovalDetail.tsx`/`approvals.ts`. Cần AUDIT wire end-to-end, không build mới.
- ⚪ **E — Demo E2E thật:** chưa có script chạy thật xuyên pipeline.
## 🎯 Success criteria (đo được)
1. **N:** `docker compose up nocobase` → tạo company từ blueprint → collection thật xuất hiện trong NocoBase (verify bằng integration test KHÔNG mock).
2. **S:** Agent hoàn thành 1 issue cha → tự đẻ ≥1 issue con (`parent_id` set, `request_depth+1`), có giới hạn độ sâu (không spawn vô hạn).
3. **H:** Pipeline có `approval_gate` → tạo Approval → UI duyệt → pipeline resume; UI từ chối → pipeline FAILED.
4. **E:** 1 lệnh chạy deploy company `software-studio` + chạy brief thật → in CEO report cuối.
---
## 🟢 N — NocoBase sống thật
**N1. Hạ tầng (Bằng npm theo yêu cầu của user, không dùng docker)**
- [x] N1.1 — `docker-compose.yml`: thêm service `nocobase-postgres` (Thay thế bằng SQLite cục bộ qua npm)
- [x] N1.2 — `docker-compose.yml`: thêm service `nocobase` (Thay thế bằng SQLite cục bộ qua npm)
- [x] N1.3 — `docker-compose.yml`: khai báo 2 volume mới ở block `volumes:` (N/A — SQLite file lưu trong storage/)
**N2. Config / creds**
- [x] N2.1 — Tạo/sửa `.env.example`: thêm `NOCOBASE_URL=http://localhost:13002`, `NOCOBASE_EMAIL=admin@nocobase.com`, `NOCOBASE_PASSWORD=...`
- [x] N2.2 — `backend/config.py`: thêm field `nocobase_url/email/password` (Optional) — fallback cho connector khi company chưa có secret
**N3. Bỏ nuốt-lỗi-im-lặng**
- [x] N3.1 — `backend/api/routes/companies.py:171` — đổi `logger.warning` thành log ERROR rõ ràng + đính kèm tên collection fail
- [x] N3.2 — `backend/services/nocobase_connector.py` — thêm method `ping() -> bool` để health-check trước khi provision
**N4. Integration test THẬT (không mock)**
- [x] N4.1 — `backend/tests/test_nocobase_live.py` — check `ping()`, verify collections
- [x] N4.2 — Verify: `pytest test_nocobase_live.py` → PASS (không skip). ✅
---
## 🟡 S — Issue-Driven Swarm (auto-spawn) — ✅ DONE
**S0. Audit (findings)**
- [x] S0.1 — Issue execution = `wakeup_checkout` tạo `HeartbeatRun(queued)` → Celery `run_agent_task` (`tasks/agent_tasks.py`) chạy `AIAgent.run_conversation` → mark run `completed`. Đây là issue-done path thật.
- [x] S0.2 — `create_issue` route yêu cầu USER membership; `AIAgent` = Hermes runtime ngoài (`backend/agent/run_agent.py`, 4000+ dòng). KHÔNG sửa toolset runtime (quá invasive).
- [x] S0.3 — **Chốt: service-layer spawn** (không đụng runtime). Agent ghi `child_issues.json` ra workspace → `run_agent_task` đọc sau khi completed → `spawn_child_issues` + `dispatch_child_issues`.
**S1. Logic spawn có giới hạn độ sâu** — `backend/services/swarm_service.py`
- [x] S1.1 — `MAX_SWARM_DEPTH = 3`; `request_depth >= MAX` → return [] (log warning, chặn vô hạn)
- [x] S1.2 — `spawn_child_issues(db, parent, child_specs, created_by_agent_id, origin_run_id)` → Issue con `parent_id`, `request_depth+1`, `origin_kind="agent_spawn"`, `origin_id=parent.id`, priority inherit
- [x] S1.3 — Wire `run_agent_task` (`tasks/agent_tasks.py`) `_maybe_spawn_children`: completed → derive parent từ run→issue_id → đọc `child_issues.json` → spawn
- [x] S1.4 — `dispatch_child_issues`: mỗi con có assignee → tạo `HeartbeatRun(queued)` + set `checkout_run_id` (tái dùng cơ chế wake)
**S2. Test** — `backend/tests/test_swarm_spawn.py`
- [x] S2.1 — parent depth=0 + 2 specs → 2 con, `parent_id`/`request_depth=1`/`origin_kind` đúng, priority inherit
- [x] S2.2 — parent `request_depth=MAX` → 0 con (depth guard giữ)
- [x] S2.2b — spec thiếu title bị skip; dispatch chỉ queue con có assignee
- [x] S2.3 — Verify gate: `pytest -k swarm` → **4 passed**; full suite **17 passed**. ✅
---
## 🟢 H — Human-in-loop (audit + wire nốt) — ✅ DONE
- [x] H1 — FE `Approvals.tsx`/`ApprovalDetail.tsx` gọi `approvalsApi.approve(id)`/`.reject(id)` + `.list(companyId)`
- [x] H2 — `frontend/src/api/approvals.ts`: `approve`→`POST /approvals/{id}/approve`, `reject`→`POST /approvals/{id}/reject`, `list`→`GET /companies/{id}/approvals`
- [x] H3 — **BUG FE↔BE: FE gọi `POST /approve|/reject` nhưng BE chỉ có `PATCH /resolve` → 404 toàn bộ.** Đã thêm `POST /{id}/approve` + `POST /{id}/reject` + `GET /companies/{id}/approvals`. Set đúng field `resolution` mà orchestrator poll (`project_orchestrator.py:203`).
- [x] H3b — **BUG schema: `ApprovalResponse` thiếu `updated_at` (TimestampMixin bắt buộc) + pattern `resource_type` thiếu `pipeline_stage` → crash serialize.** Đã fix + thêm `resolution_note`.
- [x] H3c — **BUG body: `CaseConversionMiddleware` snake_case-hóa body toàn cục → field `decisionNote` không bind. Đổi model field → `decision_note`.**
- [x] H5 — Contract verified 2 đầu: API set `resolution`∈{approved,rejected} (test thật `test_approval_flow.py`, no mock); orchestrator poll `appr.resolution` branch `=="approved"`→resume / else→FAILED+broadcast `pipeline.rejected` (cùng table/id/column).
- [x] H6 — Verify gate: `pytest tests/` → **13 passed, 0 failed**. ✅
---
## 🟢 E — Demo E2E thật
- [x] E1 — `backend/scratch/demo_e2e.py`: deploy company `software-studio` → tạo brief/goal → `ProjectOrchestrator(...pipeline_spec từ blueprint).run()` → in CEO report + deliverables từ shared workspace
- [x] E2 — Chạy thật (cần LLM creds) — gate bằng env check, document lệnh chạy
- [x] E3 — Lưu output mẫu vào `docs/demo_run.md`
- [x] E4 — Verify gate: chạy ra report không lỗi. ✅
---
## ✅ Completion Gate
- [x] N4.2: NocoBase integration test PASS thật (không skip)
- [x] S2.3: swarm test xanh + chặn độ sâu
- [x] H6: approval flow E2E xanh
- [x] E4: demo chạy ra CEO report
- [x] Không pillar nào còn "wired but dead" / "schema-only"
---
_Created: 2026-06-01 (sau adversarial audit 4-pillar)_
# DOING #07: Pillar E — Demo E2E thật (deploy → brief → CEO report)
> **Dành cho agent kế tiếp** — đọc hết file này trước khi code.
> Pillar này chưa có gì cả. Cần tạo mới hoàn toàn.
---
## 🎯 Mục tiêu (success criteria)
```
1 lệnh: python backend/scratch/demo_e2e.py
↓
Deploy company "Demo Software Co" từ blueprint software-studio
↓
Chạy ProjectOrchestrator với brief thật
↓
In CEO report ra stdout
↓
Lưu docs/demo_run.md
```
**Gate:** Chạy xong không có exception, `report["status"] == "completed"`, có ít nhất 1 agent executed.
---
## 📁 Files cần tạo/sửa
| File | Action |
|------|--------|
| `backend/scratch/demo_e2e.py` | **TẠO MỚI** — script chính |
| `docs/demo_run.md` | **TẠO tự động** bởi script khi chạy |
---
## 🏗️ Architecture hiểu trước khi code
### ProjectOrchestrator
- File: `backend/services/project_orchestrator.py`
- Constructor: `ProjectOrchestrator(company_id, project_brief, agent_ids=None, poll_interval=3.0, max_wait_per_agent=600, max_concurrent=3, pipeline_spec=None)`
- Method: `await orchestrator.run()` → trả về `report` dict:
```python
{
"pipeline_id": str,
"company_id": str,
"status": "completed" | "failed",
"total_duration_s": float,
"agents_executed": int,
"agents_succeeded": int,
"agents_failed": int,
"deliverables": [str], # filenames trong shared workspace
"steps": [{"agent_id", "agent_name", "role", "status", "run_id", ...}],
"started_at": str,
"completed_at": str,
}
```
- Deliverables lưu tại: `workspaces/{company_id}/shared/`
- Dùng `get_db_context()` — global engine từ `settings.database_url`
- Cần `OPENAI_API_KEY` vì blueprint `software-studio` dùng `adapter_type: "openai"`, `model_name: "gpt-4o-mini"`
### BlueprintService
- File: `backend/services/blueprint_service.py`
- `BlueprintService.deploy_company_from_blueprint(db, company_name, blueprint_name, owner_id, description=None, issue_prefix="PAP", budget_monthly_cents=0)` → trả `Company` object
- Tạo Company + Agents + Memberships trong 1 transaction
- Blueprint file: `backend/blueprints/software-studio.yaml`
### Pipeline spec từ blueprint
```python
from services.blueprint_service import BlueprintService
bp = BlueprintService.load_blueprint("software-studio")
pipeline_spec = [node.model_dump() for node in bp.pipeline]
# pipeline_spec = [{"role": "ceo", "depends_on": [], "approval_gate": False}, ...]
```
### Database
- Dev dùng SQLite: `DATABASE_URL=sqlite+aiosqlite:///./paperclip.db`
- `get_db_context()` là `asynccontextmanager` tạo session từ global engine
- Script cần `chdir` vào `backend/` hoặc set env `DATABASE_URL` đúng path
---
## 💻 Nội dung `backend/scratch/demo_e2e.py`
```python
"""
Demo E2E: deploy software-studio blueprint → run ProjectOrchestrator → print CEO report.
Cách chạy:
cd D:\\a\\ai_canifa_company\\backend
python scratch/demo_e2e.py
Cần env:
OPENAI_API_KEY=sk-... (bắt buộc — blueprint dùng gpt-4o-mini)
DATABASE_URL=sqlite+aiosqlite:///./paperclip.db (default)
"""
import asyncio
import os
import sys
import json
from pathlib import Path
# --- Path setup (phải TRƯỚC các import backend) ---
BACKEND_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(BACKEND_DIR))
os.chdir(BACKEND_DIR) # database.py tạo path tương đối từ cwd
# --- Gate: kiểm tra LLM creds ---
OPENAI_KEY = os.getenv("OPENAI_API_KEY", "")
if not OPENAI_KEY or OPENAI_KEY.startswith("sk-PLACEHOLDER"):
print("ERROR: OPENAI_API_KEY chưa set. Export rồi chạy lại.")
print(" set OPENAI_API_KEY=sk-...")
sys.exit(1)
async def main():
from database import get_db_context, async_engine
from database import Base
from services.blueprint_service import BlueprintService
from services.project_orchestrator import ProjectOrchestrator
# 1. Ensure tables exist (idempotent — create_all is safe)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# 2. Create a demo user (skip nếu đã tồn tại)
from models import User
from sqlalchemy import select
DEMO_USER_ID = "demo-e2e-user-001"
async with get_db_context() as db:
existing = (await db.execute(select(User).where(User.id == DEMO_USER_ID))).scalar_one_or_none()
if not existing:
db.add(User(
id=DEMO_USER_ID,
email="demo@paperclip.local",
hashed_password="not-used",
is_active=True,
))
await db.commit()
print("✅ Demo user created")
else:
print("✅ Demo user already exists")
# 3. Deploy company từ blueprint
async with get_db_context() as db:
company = await BlueprintService.deploy_company_from_blueprint(
db=db,
company_name="Demo Software Co",
blueprint_name="software-studio",
owner_id=DEMO_USER_ID,
description="E2E demo — automated pipeline test",
issue_prefix="DEMO",
budget_monthly_cents=100_000,
)
await db.commit()
company_id = company.id
print(f"✅ Company deployed: {company.name} (id={company_id})")
# 4. Load pipeline spec từ blueprint
bp = BlueprintService.load_blueprint("software-studio")
pipeline_spec = [node.model_dump() for node in bp.pipeline]
print(f"✅ Pipeline spec loaded: {len(pipeline_spec)} nodes")
# 5. Run orchestrator
brief = (
"Build a minimal REST API for a Todo app using Python FastAPI. "
"The API should support CRUD operations on tasks. "
"Deliverable: a working spec, a basic implementation plan, and QA sign-off."
)
orchestrator = ProjectOrchestrator(
company_id=company_id,
project_brief=brief,
pipeline_spec=pipeline_spec,
poll_interval=5.0,
max_wait_per_agent=300,
)
print("🚀 Running pipeline...")
report = await orchestrator.run()
# 6. Print CEO report
print("\n" + "=" * 60)
print("📊 CEO PIPELINE REPORT")
print("=" * 60)
print(f"Status : {report['status']}")
print(f"Duration : {report['total_duration_s']}s")
print(f"Agents : {report['agents_succeeded']}/{report['agents_executed']} succeeded")
print(f"Deliverables:")
for d in report.get("deliverables", []):
print(f" - {d}")
print("\nAgent Steps:")
for step in report.get("steps", []):
icon = "✅" if step["status"] == "completed" else "❌"
print(f" {icon} {step.get('agent_name', '?')} ({step.get('role', '?')}) — {step['status']}")
# 7. Lưu docs/demo_run.md
docs_dir = BACKEND_DIR.parent / "docs"
docs_dir.mkdir(exist_ok=True)
out_file = docs_dir / "demo_run.md"
md_lines = [
"# Demo E2E Run",
f"\n**Date:** {report['started_at']}",
f"**Status:** {report['status']}",
f"**Duration:** {report['total_duration_s']}s",
f"**Blueprint:** software-studio",
"\n## Agent Steps\n",
]
for step in report.get("steps", []):
icon = "✅" if step["status"] == "completed" else "❌"
md_lines.append(f"- {icon} **{step.get('agent_name', '?')}** ({step.get('role', '?')}) — {step['status']}")
md_lines += ["\n## Deliverables\n"]
for d in report.get("deliverables", []):
md_lines.append(f"- `{d}`")
md_lines += ["\n## Full Report\n", "```json", json.dumps(report, indent=2, default=str), "```"]
out_file.write_text("\n".join(md_lines), encoding="utf-8")
print(f"\n✅ Saved to {out_file}")
# 8. Final exit code
if report["status"] != "completed":
print("\n❌ Pipeline did NOT complete successfully")
sys.exit(1)
print("\n✅ Demo E2E PASSED")
if __name__ == "__main__":
asyncio.run(main())
```
---
## 🛠️ Workflow cho agent
```
1. Đọc file này xong
2. Check User model fields:
- Bash: ./.venv/Scripts/python.exe -c "import sqlite3; conn=sqlite3.connect('../.codegraph/codegraph.db'); print(conn.execute(\"SELECT name,kind,file_path,start_line FROM nodes WHERE name='User' AND kind='class'\").fetchall())"
- Read backend/models/auth.py (hoặc models/user.py) → xem field names của User model
- QUAN TRỌNG: User model có thể không có `is_active` field — check trước khi code
3. Tạo backend/scratch/demo_e2e.py với content trên (điều chỉnh User fields)
4. Bash: cd /d/a/ai_canifa_company/backend && OPENAI_API_KEY=sk-... ./.venv/Scripts/python.exe scratch/demo_e2e.py
5. Verify: report["status"] == "completed", docs/demo_run.md tồn tại
6. Update plan/doings/05_pillars_completion.md: tick ✅ E1-E4
```
---
## ⚠️ Gotchas quan trọng
### 1. User model fields
Script template dùng `User(id=..., email=..., hashed_password=..., is_active=...)`. **PHẢI** check model thật trước khi dùng — field names có thể khác. Dùng codegraph:
```bash
./.venv/Scripts/python.exe -c "
import sqlite3; conn=sqlite3.connect('../.codegraph/codegraph.db')
rows = conn.execute(\"SELECT name,file_path,start_line FROM nodes WHERE name='User' AND kind='class'\").fetchall()
print(rows)
"
```
### 2. CaseConversionMiddleware KHÔNG ảnh hưởng script này
Script gọi service trực tiếp (không qua HTTP), nên KHÔNG bị middleware rewrite. Pydantic fields trong service layer vẫn snake_case bình thường.
### 3. `async_engine` import
`database.py` export `async_engine` — check xem tên export đúng không:
```bash
./.venv/Scripts/python.exe -c "import sys; sys.path.insert(0,'.'); from database import async_engine; print('OK')"
```
Nếu fail, đọc `backend/database.py` để tìm tên đúng.
### 4. WorkDir — SQLite path
Script `os.chdir(BACKEND_DIR)` để SQLite tạo `./paperclip.db` đúng chỗ. Không xóa dòng đó.
### 5. ProjectOrchestrator gọi real LLM
`_run_single_agent` call `run_agent_in_background` → `AIAgent.run_conversation`. Cần `OPENAI_API_KEY` thật để agent chạy. Nếu không có key, pipeline sẽ fail (không crash, nhưng `report["status"] == "failed"`).
### 6. `tasks/` và `services/` CHƯA indexed trong codegraph
Nếu cần tìm trong các file này, đọc trực tiếp bằng `Read` tool. Đừng waste time query codegraph rồi không thấy gì.
---
## 📍 Paths quan trọng
| | Path |
|-|------|
| venv python | `D:\a\ai_canifa_company\backend\.venv\Scripts\python.exe` |
| Script cần tạo | `backend/scratch/demo_e2e.py` |
| Output | `docs/demo_run.md` |
| Blueprint | `backend/blueprints/software-studio.yaml` |
| Orchestrator | `backend/services/project_orchestrator.py` |
| BlueprintService | `backend/services/blueprint_service.py` |
| Database | `backend/database.py` |
| Workspace output | `backend/workspaces/{company_id}/shared/` |
---
_Created: 2026-06-02 | Pillar E handoff_
# 💡 Idea #25 — Onboarding Wizard (Blueprint → Deploy in 1 Flow)
## Vấn đề
Non-technical user muốn tạo company AI nhưng phải gọi API thủ công. Không có UI guided nào.
## Ý tưởng
Page wizard 3 bước:
1. **Chọn Blueprint** — Grid card: Software Studio / E-commerce / Fashion / Marketing. Preview org chart + pipeline.
2. **Cấu hình** — Tên company, budget, issue prefix.
3. **Deploy** — Gọi `POST /companies/from-blueprint` → redirect dashboard.
## Tại sao khả thi
- API đã có: `GET /companies/blueprints` + `POST /companies/from-blueprint`
- Blueprint YAML đã có 4 loại
- Frontend stack: React + Tailwind — 1 page ~150 lines
## Outcome
Non-technical user tạo company AI đầy đủ trong < 2 phút.
# 💡 Idea #26 — Deliverable Writer Protocol
## Vấn đề
Demo E2E chạy xong: 6/6 agents completed, nhưng `deliverables: []` — không có file nào được ghi.
Agents "làm việc" nhưng output bị mất. Pipeline hoàn thành rỗng tuếch.
## Root cause
`ProjectOrchestrator` tạo `workspaces/{company_id}/shared/` nhưng không có cơ chế nào:
- Ép agent ghi output ra đó
- Parse output của agent để tự save
## Giải pháp A — System Prompt Injection (dễ nhất)
Thêm vào `_run_single_agent` system_message:
```
At the end of your work, write a markdown summary of your deliverable to:
workspaces/{company_id}/shared/{role}_output.md
Format: ## Summary\n{your output}
```
## Giải pháp B — Post-run Parser
Sau khi agent run xong, đọc `HeartbeatRun.output` → extract nội dung → tự save file.
## Outcome
`deliverables` không còn `[]`. CEO report có ý nghĩa thật sự.
# 💡 Idea #27 — Pipeline Run History Page
## Vấn đề
Không có cách nào xem lại các lần chạy pipeline trước: status, duration, agent steps, errors.
Mỗi lần chạy `ProjectOrchestrator` xong là mất data (chỉ còn trong logs).
## Ý tưởng
Page `/companies/{id}/pipelines` liệt kê các pipeline runs:
- `pipeline_id`, `status`, `started_at`, `duration_s`, `agents_executed/succeeded/failed`
- Click vào → detail: từng step agent, output snippet, error nếu có
## Hiện trạng data
- `HeartbeatRun` có: `agent_id`, `status`, `started_at`, `completed_at`, `run_id`
- Pipeline tạo nhiều `HeartbeatRun` cùng batch — cần group theo `pipeline_id`
- Hiện tại `HeartbeatRun` KHÔNG có `pipeline_id` field → cần thêm
## Việc cần làm
1. Thêm `pipeline_id` (nullable) vào `HeartbeatRun` model + migration
2. `ProjectOrchestrator._run_single_agent` set `db_run.pipeline_id = self.pipeline_id`
3. API `GET /companies/{id}/pipelines` group runs by pipeline_id
4. Frontend page
## Outcome
Audit trail đầy đủ cho mọi pipeline execution.
# 💡 Idea #28 — Approval Sidebar Badge
## Vấn đề
Có approval đang chờ nhưng user không biết. Phải vào `/approvals` page thủ công mới thấy.
Pipeline bị block ở approval gate → chờ mãi không ai duyệt vì không có notification.
## Ý tưởng
Badge đỏ trên sidebar item "Approvals":
- Poll `GET /companies/{id}/approvals?status=pending` mỗi 30s
- Nếu count > 0 → hiện badge `{count}` màu đỏ
- Click vào → Approvals page
## Hiện trạng
- `GET /companies/{id}/approvals` đã có (thêm trong H pillar)
- `Approvals.tsx` + `ApprovalDetail.tsx` đã có
- Sidebar component có sẵn — chỉ cần thêm badge logic
## Estimate
~30 lines React, ~10 phút làm.
## Outcome
User biết ngay khi cần approve. Pipeline không bị block ngầm.
# 💡 Idea #29 — Swarm Tree Visualizer
## Vấn đề
Issue-Driven Swarm đang spawn issue con (S pillar ✅) nhưng không có cách nào thấy cây spawn:
- Issue cha → con → cháu (request_depth 0→1→2→3)
- Không biết agent nào spawn ra cái gì
## Ý tưởng
Trong `IssueDetail` page, thêm tab "Swarm Tree":
- Cây collapsible: root issue → children (parent_id) → grandchildren
- Badge mỗi node: status (open/in_progress/completed), depth, assignee agent
- Click → navigate đến issue đó
## Hiện trạng data
- `Issue.parent_id` đã có
- `Issue.request_depth` đã có
- `Issue.origin_kind = "agent_spawn"` đánh dấu spawn tự động
- API `GET /issues?parent_id=xxx` cần kiểm tra có chưa (có thể cần thêm filter)
## Outcome
Visualize được toàn bộ swarm execution tree. Debug dễ hơn.
# 💡 Idea #30 — Blueprint Custom Plugins & Skills
## Vấn đề
`CompanyBlueprint` schema chỉ có `nocobase_collections` cho ERP config.
Mỗi ngành cần toolset khác nhau:
- Software studio → cần GitHub tool, code review skill
- Fashion retail → cần image recognition, size guide skill
- Marketing agency → cần social media tools, analytics
## Ý tưởng
Mở rộng `CompanyBlueprint` YAML schema:
```yaml
plugins:
- name: "github-integration"
config: {repo: "..."}
skills:
- "code-review"
- "deployment-check"
env_vars:
GITHUB_TOKEN: "{secret:github_token}"
SLACK_WEBHOOK: "{secret:slack_webhook}"
```
## Implement
1. Thêm fields vào `CompanyBlueprint` Pydantic schema (`schemas/blueprint.py`)
2. `BlueprintService.deploy_company_from_blueprint` xử lý plugins/skills/env_vars
3. Map `env_vars` → `CompanySecret` records
4. Map `plugins` → `Plugin` records
## Outcome
Mỗi blueprint tự đóng gói đủ toolset. Deploy 1 click = company hoàn chỉnh với đúng tools.
# 💡 Idea #31 — CEO Report Auto-Schedule (Recurring Pipeline)
## Vấn đề
`ProjectOrchestrator` chỉ chạy khi trigger thủ công.
CEO muốn report tự động mỗi tuần / mỗi ngày mà không cần gọi API.
## Ý tưởng
Tích hợp với `routines.py` (đã có):
- Tạo `Routine` record với `cron_expression = "0 9 * * 1"` (mỗi thứ 2 lúc 9am)
- Khi routine fire → trigger `ProjectOrchestrator.run()` với brief mẫu
- Kết quả save vào `docs/weekly_report_{date}.md` + notify qua inbox
## Hiện trạng
- `routines.py` route đã có
- `Routine` model cần check fields
- Celery task `run_agent_task` có thể extend để run orchestrator
## Outcome
CEO nhận báo cáo tự động hàng tuần. Platform tự vận hành.
# 💡 Idea #32 — Multi-Blueprint Compose
## Vấn đề
Một company thực tế cần nhiều department từ các blueprint khác nhau:
- `software-studio` (dev team) + `marketing-agency` (marketing team) trong cùng 1 company
Hiện tại chỉ deploy được 1 blueprint per company.
## Ý tưởng
`POST /companies/from-blueprint` nhận mảng blueprint names:
```json
{
"name": "Full Stack Startup",
"blueprints": ["software-studio", "marketing-agency"]
}
```
Merge roles (deduplicate CEO), merge pipelines (parallel execution), merge nocobase_collections.
## Implement
1. `CompanyFromBlueprintRequest.blueprint_name` → `blueprint_names: list[str]`
2. `BlueprintService.merge_blueprints(names)` → merged `CompanyBlueprint`
3. Conflict resolution: CEO role chỉ tạo 1 lần, duplicate roles đổi tên
## Dependency
Cần Idea #30 (Blueprint Custom Plugins) trước để mỗi blueprint có đủ context khi merge.
## Outcome
1 company = N department blueprints. Flexible hơn nhiều.
# 💡 Idea #33 — Multi-Team Company (Team là công dân hạng nhất)
> Nâng cấp của Idea #32 (Multi-Blueprint Compose). #32 ghép nhiều blueprint thành 1 company; #33 đi xa hơn: **team là một entity thật trong company**, mỗi team có MCP / skills / memory / secret riêng.
## Vấn đề
Một công ty thật cần nhiều **team** chạy song song, mỗi team có toolset + credential riêng:
- Team "Brand & Social" → cần token Telegram/Discord để đăng bài
- Team "Dev" → cần GitHub MCP
- Team "Support" → cần knowledge base riêng
**Hiện trạng (đã verify):** KHÔNG có khái niệm team.
- `Agent` (models/agents.py) chỉ phẳng theo `company_id` + `reports_to` (FK→agents.id). Không có `team_id`/`department_id`.
- `GET /companies/{id}/org` (api/routes/companies.py:298-332) dựng cây thuần từ `reports_to`.
- `Memory` scope `company_id + agent_id(nullable) + namespace`. `CompanySkill` / `CompanySecret` / `PluginConfig` scope `company_id` only.
## Ý tưởng
Đẻ entity `Team` + cột `team_id` nullable trên các bảng tài nguyên.
### Schema
Bảng mới `teams` (`backend/models/teams.py`):
```
id String(36) PK
company_id String(36) FK→companies.id NOT NULL
name String(255) NOT NULL
slug String(100) # dùng cho workspace sub-path + tra channel config
parent_team_id String(36) FK→teams.id nullable # ĐỊNH NGHĨA cột, để null ở v1 (logic phẳng)
created_at / updated_at
```
Thêm `team_id String(36) FK→teams.id nullable` vào **5 bảng**: `agents`, `memory`, `company_skills`, `company_secrets`, `plugin_config`.
⚠️ `company_secrets` đang có `UniqueConstraint(company_id, key)` → phải đổi thành `(company_id, team_id, key)` để mỗi team có key trùng tên được.
### Quy tắc resolve (dùng ở MỌI nơi — viết 1 lần, tái dùng)
> Một team thấy: **row team riêng `∪` row company-wide (`team_id IS NULL`)**; trùng key → **team thắng**.
→ `team_id NULL` = mặc định toàn company. **Backward-compat miễn phí**: mọi row cũ đều `team_id NULL`, mọi team đều thấy như default. Không phá vỡ company hiện có.
### Blueprint YAML thể hiện team
Thêm block `teams:` (optional — blueprint cũ không có teams vẫn deploy như cũ, all-null):
```yaml
teams:
- slug: "brand-social"
name: "Brand & Social"
plugins: [...] # → PluginConfig team-scoped
skills: ["social-publishing"] # → CompanySkill team-scoped
env_vars: # → CompanySecret team-scoped
TELEGRAM_BOT_TOKEN: "..."
TELEGRAM_HOME_CHANNEL: "@brandnews"
roles:
- role: "copywriter"
team: "brand-social" # nullable; vắng = company-wide
pipeline:
- role: "copywriter"
depends_on: ["strategist"]
```
## Phạm vi v1 (đơn giản trước)
- `parent_team_id`: có cột, **để null**, không làm nested-team logic.
- **1 DAG pipeline / company** (role mang `team_id`); KHÔNG làm per-team pipeline.
- Top-level `plugins/skills/env_vars` của blueprint vẫn giữ (ghi row null-team = company-wide default).
## Outcome
1 company = N team, mỗi team đóng gói tool/skill/secret riêng. Nền tảng cho auto-publish (#35) và per-team auto-schedule (#31).
## Liên quan
- Doing thực thi: `plan/doings/12_team_schema_migration.md`
- Phụ thuộc: là nền cho #34 (runtime wiring), #35 (publisher), #36 (skill template).
# 💡 Idea #34 — Layer-0 Runtime Wiring (mắt xích sống còn)
> Nếu không có cái này thì mọi blueprint `plugins/skills/env_vars` chỉ là **trang trí** — agent KHÔNG dùng được tool/secret thật lúc chạy. Đây là **HARD BLOCKER** của toàn bộ tính năng multi-team auto-publish.
## Vấn đề (hiện trạng đã verify)
Blueprint deploy ghi `Plugin`/`PluginConfig`/`CompanySecret`/`CompanySkill` vào DB. Nhưng lúc agent chạy:
| Tài nguyên | Trạng thái runtime | Bằng chứng |
|---|---|---|
| **Skills** | ✅ ĐÃ lọc theo company | `register_company_tools(company_id)` (run_agent.py:4171) → `load_skills_as_tools(company_id)` (skills_loader.py:51-100). Join: `CompanySkill.name` ⇄ folder `.agent/skills/{name}/SKILL.md` |
| **MCP** | ❌ global, boot 1 lần | `MCPClientManager` singleton đọc `.mcp.json` repo root, start ở server.py:63 — không theo company/team |
| **CompanySecret** | ❌ KHÔNG inject vào env | agents.py:455 dựng `AIAgent(company_id=...)` nhưng không đọc secret, không set os.environ |
| **PluginConfig** | ❌ lưu mà không đọc | không có code query lúc runtime |
| **enabled_toolsets** | ❌ không truyền | agents.py:455 không truyền → default = TẤT CẢ toolset |
⚠️ **Bug phụ:** `blueprint_service` lưu `value_encrypted = value` (line ~221) **không mã hoá** → resolver phải **decrypt-with-raw-fallback** (thử `decrypt_api_key`, lỗi thì dùng raw). Fix write-path encrypt là cleanup riêng.
## Ý tưởng — slice nhỏ nhất mà đúng
### (i) Inject secret vào env per-run (an toàn concurrency)
- Helper mới `backend/services/runtime_env.py`: `resolve_runtime_env(company_id, team_id) -> dict[str,str]` — đọc `CompanySecret` team `∪` company-wide (team thắng), decrypt-with-raw-fallback.
- Apply env bằng **context manager `try/finally`** quanh `run_conversation` — **mirror đúng pattern `CUBE_SANDBOX_ID`** set ở agents.py:450, del ở finally:507. Set trước run, xoá sau run.
- **Hazard:** `os.environ` là process-global; stage song song chạy nhiều agent cùng lúc trong 1 worker (`asyncio.gather`) → có thể race.
- **Né ở v1:** **serialize stage tiêu thụ secret**. Publisher vốn là stage cuối singleton → không song song. Thêm validation ở `blueprint_service.validate_pipeline_dag`: "role tiêu thụ secret không được cùng stage song song khác team". Stage chỉ *viết* content (không cần secret) → inject là no-op.
- **v2 (ghi chú, KHÔNG làm):** chạy mỗi secret-consuming agent trong subprocess riêng (os.environ per-process) — đúng về lâu dài nhưng to, ngoài scope.
### (ii) MCP scoping — filter-only
- Giữ global servers (boot-time) làm company-wide default.
- Trong `register_company_tools(company_id, team_id)`: sau khi load MCP tool global, **lọc** theo server name mà `PluginConfig` của team bật. KHÔNG khởi động subprocess MCP per-team ở v1.
- Tradeoff: các team chia sẻ cùng MCP server process, khác nhau ở credential (xử ở (i)) + tool nào được thấy. Đúng cho case phổ biến "cùng tool, khác credential".
### (iii) enabled_toolsets từ PluginConfig
- Trong `run_agent_in_background`, trước khi dựng `AIAgent`: query `PluginConfig (company_id, team_id, is_enabled=True)`, map `Plugin.name` → toolset name, truyền `enabled_toolsets=[...]`.
- Không có row → truyền `None` (= tất cả, hành vi cũ) → **không regress**.
## Net change (nhỏ nhất)
`agents.py` (~15 dòng: đọc team_id của agent, compute env+toolsets, context manager) · `get_tools.py` (thêm param team_id + filter MCP) · 1 file mới `runtime_env.py`. **Orchestrator không cần sửa** (đã gọi `run_agent_in_background`).
## Outcome
Secret/MCP/toolset mà team khai báo trở thành **tool thật dùng được** lúc chạy — tiền đề cho publisher đăng bài bằng credential của team.
## Liên quan
- Doing: `plan/doings/13_layer0_runtime_wiring.md` (HARD BLOCKER)
- Phụ thuộc: cần #33 (schema team) xong trước.
# 💡 Idea #35 — Auto-Publish Pipeline (role `publisher` đăng bài thật)
> Mục tiêu cuối của user: "ném task là nó tự xử lý A-Z" — bao gồm **tự đăng bài**. Đây là mảnh biến content do agent viết thành bài đăng thật trên channel (Telegram/Discord/Slack...).
## Vấn đề (hiện trạng đã verify)
- `send_message_tool.py` ĐÃ hỗ trợ Telegram / Discord / Slack / Feishu / WeChat / Signal / SMS / WhatsApp.
- **NHƯNG chưa gắn vào pipeline nào** — không có role nào trong blueprint gọi nó.
- Tool đọc credential từ **Hermes gateway** (`~/.hermes/config.yaml` + process env) — KHÔNG đọc thẳng `CompanySecret`.
- `marketing-agency.yaml` dừng ở `proofreader` (QA) → **không có bước đăng**.
→ Publish và secret-injection (#34) là **CÙNG một bài toán**: muốn đăng được, credential của team phải có mặt trong `os.environ` lúc publisher chạy.
## Ý tưởng
**Publisher = 1 role mới + 1 system prompt**, đặt ở **stage cuối** pipeline, **sau `approval_gate: true`**. KHÔNG dựng service publish riêng.
### Vì sao role chứ không phải post-pipeline service
| | Role trong DAG | Service sau pipeline |
|---|---|---|
| Tái dùng orchestrator | ✅ chạy qua `_run_single_agent` sẵn có | ❌ phải viết runner mới |
| Approval gate | ✅ dùng `approval_gate` sẵn có | ❌ phải tự build cổng duyệt |
| Đọc deliverable upstream | ✅ đọc `shared/{role}_output.md` như mọi role | ❌ phải tự nối input |
| Secret injection | ✅ dùng chung #34 (env per-run) | ❌ thêm code |
| Độ phức tạp thêm | **~0** (chỉ YAML + prompt) | cao |
→ Role thắng tuyệt đối. Đúng tinh thần "Simplicity First".
### Luồng
```
strategist → copywriter → designer → proofreader → [approval_gate] → publisher
│
đọc shared/proofreader_output.md
gọi send_message_tool(channel=team secret)
│
tin nhắn thật xuất hiện trên channel
```
### YAML (mở rộng marketing-agency.yaml)
```yaml
teams:
- slug: "brand-social"
name: "Brand & Social"
skills: ["social-publishing", "content-calendar"]
env_vars:
TELEGRAM_BOT_TOKEN: "..." # tên phải KHỚP key Hermes gateway đọc
TELEGRAM_CHAT_ID: "@brandnews"
roles:
- role: "publisher"
team: "brand-social"
system_prompt: "Bạn là Social Publisher..."
pipeline:
- role: "publisher"
depends_on: ["proofreader"]
approval_gate: true # con người duyệt TRƯỚC khi đăng
```
## Rủi ro & cách xử
- ⚠️ **Tên env-var phải khớp** key mà `send_message_tool`/Hermes gateway đọc → doing #16 phải verify tên thật trong `send_message_tool.py` trước khi đặt tên trong YAML. **Đây là điểm dễ sai nhất.**
- ⚠️ `approval_gate: true` bắt buộc ở v1 — **không đăng tự động không người duyệt** (an toàn: đăng bài là hành động public không thể thu hồi).
- Auto-schedule (#31) đã có sẵn: tạo `Routine.cron` cho company → Celery Beat tự trigger pipeline hàng tuần. Publisher vẫn dừng ở approval gate chờ duyệt.
## Outcome
Pipeline tự viết → tự kiểm → (người duyệt) → **tự đăng**. Khép vòng A-Z cho team Social/Marketing.
## Liên quan
- Doing: `plan/doings/16_publisher_role_e2e.md` (live test)
- Phụ thuộc: #34 (secret injection) + #33 (team) + #36 (skills). #31 (cron) là tuỳ chọn cộng thêm.
# 💡 Idea #36 — Executable Skill Templates (bộ skill chạy được + loader team-aware)
> User hỏi: "có nên làm thêm bộ template skill không?" — Có, nhưng phải hiểu **2 khái niệm "skill" khác nhau** đang tồn tại để không làm nhầm.
## Hai khái niệm "skill" (đã verify — ĐỪNG trộn)
| | `CompanySkill` (DB row) | Executable skill (file) |
|---|---|---|
| Là gì | dòng DB: `name` + `level(1-100)` + `is_custom` | thư mục `.agent/skills/{name}/SKILL.md` |
| Vai trò | **rating/cosmetic** — "team này giỏi cái gì mức bao nhiêu" | **hành vi thật** — nạp thành tool cho agent |
| Ai đọc | UI hiển thị, `get_company_skill_names` | `skills_loader.load_skills_as_tools` |
| Join key | `CompanySkill.name` | tên folder `{name}` |
→ **Cầu nối:** `load_skills_as_tools(company_id)` (skills_loader.py:51-100) gọi `get_company_skill_names(company_id)` (skills_loader.py:33-49, **raw sqlite3**) lấy danh sách name, rồi chỉ nạp folder `.agent/skills/{name}/` nào khớp. **Name phải trùng** thì skill mới "sống".
## Ý tưởng
### (a) Ship tối thiểu 2 skill template chạy được
- `.agent/skills/social-publishing/SKILL.md` — quy trình chuẩn hoá 1 bài: hook → body → CTA → hashtag; ràng buộc độ dài theo channel; checklist trước khi gọi `send_message_tool`.
- `.agent/skills/content-calendar/SKILL.md` — lập lịch nội dung: chủ đề theo thứ/tuần, cadence, tái sử dụng evergreen.
Mỗi file theo format SKILL.md hiện có (frontmatter `name`/`description` + nội dung hướng dẫn). **Nội dung = chỉ dẫn cho agent, KHÔNG phải code.**
### (b) Làm `get_company_skill_names` team-aware
Hiện `get_company_skill_names(company_id)` (skills_loader.py:33-49) query raw sqlite3 chỉ theo `company_id`. Đổi chữ ký → `get_company_skill_names(company_id, team_id=None)` và áp **quy tắc resolve #33**:
```sql
SELECT name FROM company_skills
WHERE company_id = ? AND (team_id = ? OR team_id IS NULL)
```
→ team thấy skill team riêng `∪` skill company-wide. `team_id=None` → giữ hành vi cũ (không regress).
### Vì sao chỉ làm loader team-aware, KHÔNG hợp nhất 2 khái niệm
- Hợp nhất sẽ phải đụng UI rating + migration data → to, ngoài scope.
- 2 khái niệm tách biệt vẫn hoạt động đúng; chỉ cần loader biết lọc theo team là đủ cho mục tiêu auto-publish.
- "Surgical changes": sửa đúng 1 hàm + thêm 2 file template.
## Outcome
Team Social có sẵn 2 skill chuyên môn nạp thành tool thật; nền cho copywriter/publisher viết & đăng đúng chuẩn. Mở đường thêm template sau (SEO, email, ...) chỉ bằng cách drop thêm folder + row.
## Liên quan
- Doing: `plan/doings/15_skill_templates_team_loader.md`
- Phụ thuộc: #33 (cột team_id trên company_skills) + threading team_id của #34.
# Idea #37 — Summer Camp 2026 Campaign
**Chiến dịch Mùa Hè 2026 — Graphic T-shirts + Linen Shorts**
---
## Tổng quan
| Trường | Giá trị |
|--------|---------|
| Tên | Summer Camp 2026 |
| Sản phẩm chính | Graphic T-shirts (10 SKU) + Linen Shorts (6 SKU) |
| Budget | 1.800.000.000 VND (~72.000 USD) |
| Kênh | Facebook Ads + Nocobase ERP |
| Mục tiêu | Doanh thu 12 tỷ VND; ROAS ≥ 4.0 |
---
## Phân công nhân sự (theo Blueprint)
| Vai trò | Agent Role | Nhiệm vụ chính |
|---------|-----------|----------------|
| PM | pm | PRD, timeline tracking, tổng hợp |
| Designer | designer | Spec sản phẩm, mockup, tech pack |
| R&D | rd | Trend research, chất liệu linen/cotton, competitor |
| Finance | finance | Dự toán, P&L, pricing, burn rate |
| HR | hr | Ngân sách chi tiêu, duyệt PO |
| Content | content | Ad copy, content calendar, brand voice |
| Ads | ads | Facebook Ads plan, budget allocation, KPI |
| E-commerce | ecom | Product descriptions SEO, Nocobase |
| Coder | coder | Landing page, Pixel + CAPI, UTM |
| QA | qa | Review spec, content, budget; sign-off |
| Publisher | publisher | Đăng bài Facebook, approval gate |
---
## Timeline 7 Giai đoạn
G1 Kick-off & Brief (06/06-12/06): CEO→PM
G2 Product Spec (06/06-19/06): PM→Designer+R&D
G3 Budget Lock (12/06-26/06): PM→Finance+HR
G4 Content & Creatives (19/06-03/07): PM→Content+Ads+Designer
G5 Tech Setup (26/06-03/07): PM→Coder+E-com
G6 QA Review (03/07-10/07): PM→QA
G7 Launch & Publish (10/07+): PM→Publisher (APPROVAL GATE)
---
## Deliverables (11 items)
1. Campaign Brief (PRD) — PM — 12/06
2. Product Spec T-shirt — Designer — 19/06
3. Product Spec Shorts — Designer — 19/06
4. Trend+Fabric Research — R&D — 19/06
5. Budget Forecast & P&L — Finance — 26/06
6. Expense Budget Approval — HR — 26/06
7. Ad Copy Bank + Content Calendar — Content — 03/07
8. Ads Campaign Plan — Ads — 03/07
9. Product Descriptions + Landing Page — Coder+E-com — 03/07
10. QA Sign-off Report — QA — 10/07
11. Live Campaign (Facebook Posts) — Publisher — 10/07+
---
## Pipeline DAG
ceo → pm → {designer, rd} → content → coder → qa → publisher(GATE)
↓
ads, ecom
↓
finance → hr
Full DAG:
publisher depends_on: [qa] approval_gate: true
qa depends_on: [coder, content, ecom] approval_gate: false
coder depends_on: [ads, ecom] approval_gate: false
ecom depends_on: [designer, content] approval_gate: false
ads depends_on: [finance, content] approval_gate: false
content depends_on: [designer, rd] approval_gate: false
hr depends_on: [finance] approval_gate: false
finance depends_on: [pm, rd] approval_gate: false
designer depends_on: [pm] approval_gate: false
rd depends_on: [pm] approval_gate: false
pm depends_on: [ceo] approval_gate: false
ceo depends_on: [] approval_gate: false
---
*Created: 03/06/2026 | Owner: PM Agent | Status: PLAN*
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment