Commit 406a39cf authored by Admin's avatar Admin

feat: platform generalization — Company Blueprint system (Paperclip OS)

Transforms Canifa-specific codebase into a generic AI Company platform
that can instantiate any business type from a YAML Company Blueprint.

Core additions:
- backend/schemas/blueprint.py: CompanyBlueprint, BlueprintRole,
  BlueprintPipelineNode, BlueprintSummary Pydantic models with
  reports_to cross-validation via @model_validator(mode='after')
- backend/services/blueprint_service.py: load/list/validate blueprints,
  deploy_company_from_blueprint() with 2-pass agent seeding for FK safety
- backend/blueprints/: 4 YAML blueprints (fashion-retail, software-studio,
  ecommerce-brand, marketing-agency) with roles, pipeline DAGs, nocobase_collections
- backend/tests/test_blueprint_e2e.py: 5 E2E tests — all pass

API changes:
- GET /companies/blueprints → List[BlueprintSummary] (with role_count)
- GET /companies/blueprints/{name} → full blueprint detail
- POST /companies/from-blueprint → deploy company from blueprint
- POST /pipelines/run accepts blueprint_id to drive orchestrator DAG

Orchestrator:
- ProjectOrchestrator now accepts pipeline_spec: list[dict] | None
- topological_sort_stages() (Kahn's BFS) derives parallel stages from blueprint DAG
- Falls back to PIPELINE_STAGES if no spec provided

De-branded:
- Removed all "Canifa" strings from models/services/api/tasks source files
- run_logs.py demo data → generic "Demo Brand/Server/Landing Page"
- agents.py demo HTML → "Demo Company"
- seed_db.py → uses deploy_company_from_blueprint("fashion-retail")

NocoBase:
- NocoBaseConnector: params-based init + from_company() classmethod
- company_secrets: UniqueConstraint('company_id', 'key') instead of global unique

Frontend:
- OnboardingWizard: blueprint picker with org-chart preview + DAG layer render
- blueprints.ts API client
- Companies.tsx: blueprint badge on company cards
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent f24d1b6f
...@@ -227,7 +227,8 @@ async def run_agent_in_background( ...@@ -227,7 +227,8 @@ async def run_agent_in_background(
shared_dir.mkdir(parents=True, exist_ok=True) shared_dir.mkdir(parents=True, exist_ok=True)
from services.nocobase_connector import NocoBaseConnector from services.nocobase_connector import NocoBaseConnector
connector = NocoBaseConnector() async with get_db_context() as db:
connector = await NocoBaseConnector.from_company(company_id, db)
try: try:
await connector.login() await connector.login()
except Exception: except Exception:
...@@ -256,7 +257,7 @@ async def run_agent_in_background( ...@@ -256,7 +257,7 @@ async def run_agent_in_background(
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
await log_writer._write_log("info", "Creating index.html with dark theme...") await log_writer._write_log("info", "Creating index.html with dark theme...")
await log_writer.on_tool_start("write_to_file", {"path": "index.html"}) await log_writer.on_tool_start("write_to_file", {"path": "index.html"})
html_content = "<!DOCTYPE html><html><head><title>CANIFA Premium</title></head><body><h1>CANIFA Premium</h1></body></html>" html_content = "<!DOCTYPE html><html><head><title>Demo Company</title></head><body><h1>Demo Company</h1></body></html>"
with open(Path(workspace.root_path) / "index.html", "w", encoding="utf-8") as f: with open(Path(workspace.root_path) / "index.html", "w", encoding="utf-8") as f:
f.write(html_content) f.write(html_content)
with open(shared_dir / "index.html", "w", encoding="utf-8") as f: with open(shared_dir / "index.html", "w", encoding="utf-8") as f:
......
...@@ -4,11 +4,12 @@ from sqlalchemy.ext.asyncio import AsyncSession ...@@ -4,11 +4,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
import uuid import uuid
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import get_db_session 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.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
...@@ -95,6 +96,89 @@ async def list_companies( ...@@ -95,6 +96,89 @@ async def list_companies(
return companies return companies
import os
import logging
from services.blueprint_service import BlueprintService
logger = logging.getLogger(__name__)
class CompanyFromBlueprintRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
blueprint_name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
issue_prefix: Optional[str] = "PAP"
budget_monthly_cents: int = Field(0, ge=0)
@router.get("/blueprints", response_model=List[BlueprintSummary])
async def list_blueprints(
current_user: AuthUserResponse = Depends(get_current_user)
):
"""List all available company blueprints."""
return BlueprintService.list_blueprints()
@router.get("/blueprints/{blueprint_name}")
async def get_blueprint(
blueprint_name: str,
current_user: AuthUserResponse = Depends(get_current_user)
):
"""Get details of a specific blueprint."""
try:
blueprint = BlueprintService.load_blueprint(blueprint_name)
return blueprint.model_dump()
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/from-blueprint", response_model=CompanyResponse, status_code=status.HTTP_201_CREATED)
async def create_company_from_blueprint(
request: CompanyFromBlueprintRequest,
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user)
):
"""Create a new company from an industry blueprint template."""
try:
company = await BlueprintService.deploy_company_from_blueprint(
db=db,
company_name=request.name,
blueprint_name=request.blueprint_name,
owner_id=current_user.id,
description=request.description,
issue_prefix=request.issue_prefix,
budget_monthly_cents=request.budget_monthly_cents
)
# Provision NocoBase collections dynamically
try:
blueprint = BlueprintService.load_blueprint(request.blueprint_name)
from services.nocobase_connector import NocoBaseConnector
# NOTE: Operator must seed CompanySecret via separate admin API per-company
# We no longer auto-seed global NOCOBASE_URL env-vars here.
# Load connector
connector = await NocoBaseConnector.from_company(company.id, db)
collections_to_create = getattr(blueprint, "nocobase_collections", [])
for col_def in collections_to_create:
await connector.ensure_collection(col_def["name"], col_def.get("fields", []))
except Exception as e:
logger.warning(f"Failed to provision NocoBase collections for company {company.id}: {e}")
await db.commit()
return company
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{company_id}", response_model=CompanyResponse) @router.get("/{company_id}", response_model=CompanyResponse)
async def get_company( async def get_company(
company_id: str, company_id: str,
......
...@@ -16,6 +16,7 @@ from database import get_db_session ...@@ -16,6 +16,7 @@ from database import get_db_session
from middleware.auth import get_current_user from middleware.auth import get_current_user
from schemas.auth import AuthUserResponse from schemas.auth import AuthUserResponse
from models import Agent, CompanyMembership from models import Agent, CompanyMembership
from services.blueprint_service import BlueprintService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -29,6 +30,7 @@ class PipelineRunRequest(BaseModel): ...@@ -29,6 +30,7 @@ class PipelineRunRequest(BaseModel):
company_id: str company_id: str
project_brief: str project_brief: str
template_id: Optional[str] = None # Optional: deploy template first template_id: Optional[str] = None # Optional: deploy template first
blueprint_id: Optional[str] = None # Optional: load blueprint pipeline spec
agent_ids: Optional[List[str]] = None # Optional: specific agents to use agent_ids: Optional[List[str]] = None # Optional: specific agents to use
...@@ -109,10 +111,17 @@ async def run_pipeline( ...@@ -109,10 +111,17 @@ async def run_pipeline(
# Create orchestrator and run in background # Create orchestrator and run in background
from services.project_orchestrator import ProjectOrchestrator from services.project_orchestrator import ProjectOrchestrator
pipeline_spec = None
if payload.blueprint_id:
blueprint = await BlueprintService.load_blueprint(payload.blueprint_id)
if blueprint and blueprint.pipeline:
pipeline_spec = [n.model_dump() for n in blueprint.pipeline]
orchestrator = ProjectOrchestrator( orchestrator = ProjectOrchestrator(
company_id=payload.company_id, company_id=payload.company_id,
project_brief=payload.project_brief, project_brief=payload.project_brief,
agent_ids=payload.agent_ids, agent_ids=payload.agent_ids,
pipeline_spec=pipeline_spec,
) )
pipeline_id = orchestrator.pipeline_id pipeline_id = orchestrator.pipeline_id
......
...@@ -435,7 +435,7 @@ async def get_ceo_report( ...@@ -435,7 +435,7 @@ async def get_ceo_report(
status_label = "Hoàn thành" if r.status == "completed" else "Đang chạy" if r.status == "running" else "Lỗi/Dừng" status_label = "Hoàn thành" if r.status == "completed" else "Đang chạy" if r.status == "running" else "Lỗi/Dừng"
action_desc = "" action_desc = ""
if r.agent_id == "agent_pm": if r.agent_id == "agent_pm":
action_desc = "Phân tích yêu cầu thương hiệu thời trang Canifa, thiết kế tài liệu spec.md phác thảo layout và thiết kế bento grid." action_desc = "Phân tích yêu cầu dự án, thiết kế tài liệu spec.md."
elif r.agent_id == "agent_coder": elif r.agent_id == "agent_coder":
action_desc = "Đọc spec.md, sinh mã nguồn giao diện HTML (index.html) và tệp CSS (styles.css) với hiệu ứng kính mờ (glassmorphic cards) và Outfit font." action_desc = "Đọc spec.md, sinh mã nguồn giao diện HTML (index.html) và tệp CSS (styles.css) với hiệu ứng kính mờ (glassmorphic cards) và Outfit font."
elif r.agent_id == "agent_qa": elif r.agent_id == "agent_qa":
...@@ -548,9 +548,9 @@ async def get_ceo_report( ...@@ -548,9 +548,9 @@ async def get_ceo_report(
# Build report payload # Build report payload
return { return {
"runId": run_id, "runId": run_id,
"title": "Báo cáo CEO: Kết quả xây dựng Landing Page Canifa Premium", "title": "Báo cáo CEO: Kết quả xây dựng Landing Page",
"project": "Canifa Premium Landing Page", "project": "Demo Landing Page",
"summary": "AI Agent Company đã hoàn thành việc xây dựng, thiết kế và kiểm thử chất lượng trang Landing Page Canifa Premium. Giao diện sử dụng phong cách Dark Mode sang trọng, kết hợp font chữ hiện đại và thiết kế dạng bento grid tối ưu. Trình kiểm thử QA tự động đã xác nhận giao diện tương thích tốt, không lỗi hiển thị.", "summary": "AI Agent Company đã hoàn thành việc xây dựng, thiết kế và kiểm thử chất lượng trang Demo Company. Giao diện sử dụng phong cách Dark Mode sang trọng, kết hợp font chữ hiện đại và thiết kế dạng bento grid tối ưu. Trình kiểm thử QA tự động đã xác nhận giao diện tương thích tốt, không lỗi hiển thị.",
"status": "waiting_approval" if run.agent_id == "agent_qa" and run.status == "completed" else "in_progress", "status": "waiting_approval" if run.agent_id == "agent_qa" and run.status == "completed" else "in_progress",
"timeline": timeline, "timeline": timeline,
"artifacts": artifacts, "artifacts": artifacts,
...@@ -568,7 +568,7 @@ async def get_ceo_report( ...@@ -568,7 +568,7 @@ async def get_ceo_report(
"action": "approve_deploy", "action": "approve_deploy",
"label": "Phê duyệt & Deploy lên Magento", "label": "Phê duyệt & Deploy lên Magento",
"type": "primary", "type": "primary",
"description": "Xuất bản trang web trực tiếp tới cửa hàng thời trang Canifa." "description": "Xuất bản trang web trực tiếp lên server production."
}, },
{ {
"action": "request_revision", "action": "request_revision",
...@@ -625,7 +625,7 @@ async def handle_ceo_action( ...@@ -625,7 +625,7 @@ async def handle_ceo_action(
id=uuid.uuid4().hex, id=uuid.uuid4().hex,
run_id=run_id, run_id=run_id,
log_type="info", log_type="info",
message="[Magento Deploy] Khởi chạy SSH sync sang server Canifa Magento 2...", message="[Magento Deploy] Khởi chạy SSH sync sang server production...",
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
updated_at=datetime.utcnow() updated_at=datetime.utcnow()
) )
......
name: "E-Commerce Brand"
industry: "ecommerce"
description: "A blueprint for digital retail brands, automating sourcing, marketing campaigns, and P&L reviews."
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: "catalog specifications, pricing strategy, inventory targets"
reports_to: "ceo"
- role: "designer"
name: "Merchandiser Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "product mockups, product sourcing lists, supplier communication drafts"
reports_to: "pm"
- role: "marketing"
name: "Marketing Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "promotional copywriting, social media schedules, influencer outreach plans"
reports_to: "ceo"
- role: "ecom"
name: "E-commerce Manager Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "digital storefront setup, SEO tag optimization, customer review synthesis"
reports_to: "pm"
- role: "finance"
name: "CFO Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "gross margin analysis, advertising cost oversight, monthly P&L drafts"
reports_to: "ceo"
- role: "qa"
name: "Listing Auditor Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "storefront link audits, checkout flow testing, price sanity checking"
reports_to: "pm"
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: "ecom"
depends_on: ["designer"]
approval_gate: false
- role: "marketing"
depends_on: ["ecom"]
approval_gate: false
- role: "finance"
depends_on: ["marketing"]
approval_gate: false
- role: "qa"
depends_on: ["ecom"]
approval_gate: true
nocobase_collections:
- name: "products"
title: "??? Products"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Product Title"
x-component: "Input"
- name: "orders"
title: "?? Orders"
fields:
- name: "order_id"
type: "string"
uiSchema:
title: "Order ID"
x-component: "Input"
- name: "campaigns"
title: "?? Campaigns"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Campaign Title"
x-component: "Input"
name: "Fashion Retail"
industry: "fashion"
description: "The legacy Canifa-inspired model for fashion design, production, and e-commerce distribution."
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"
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: "ceo"
- 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: "ceo"
- 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: "ceo"
- 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: "ceo"
- role: "marketing"
name: "Marketing Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "ad copywriting, social media campaign, promotional calendar"
reports_to: "ceo"
- 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: "ceo"
- 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: "ceo"
- 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"
reports_to: "pm"
pipeline:
- role: "ceo"
depends_on: []
approval_gate: false
- role: "pm"
depends_on: ["ceo"]
approval_gate: true
- 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: "marketing"
depends_on: ["designer", "rd"]
approval_gate: false
- role: "ecom"
depends_on: ["designer"]
approval_gate: false
- role: "coder"
depends_on: ["designer"]
approval_gate: false
- role: "qa"
depends_on: ["coder"]
approval_gate: true
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"
name: "Marketing Agency"
industry: "marketing"
description: "A blueprint for digital marketing agencies, automating content calendars, graphic design, and media buying campaigns."
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: "Campaign Strategist Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "target audience definition, key message formulation, channel strategy"
reports_to: "ceo"
- role: "designer"
name: "Copywriter Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "ad copy generation, social captions, email newsletter content, landing page copy"
reports_to: "pm"
- role: "coder"
name: "Creative Designer Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "image asset selection, logo design specs, banner graphic layouts"
reports_to: "pm"
- role: "marketing"
name: "Media Buyer Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "Facebook/Google Ads budget allocation, targeting criteria setup, campaign setup drafts"
reports_to: "pm"
- role: "qa"
name: "Proofreader Agent"
adapter_type: "openai"
adapter_config:
model_name: "gpt-4o-mini"
runtime_config:
max_turns_per_run: 10
capabilities: "spelling and grammar checking, link verification, compliance review"
reports_to: "pm"
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: "marketing"
depends_on: ["designer", "coder"]
approval_gate: false
- role: "qa"
depends_on: ["marketing"]
approval_gate: true
nocobase_collections:
- name: "clients"
title: "?? Clients"
fields:
- name: "name"
type: "string"
uiSchema:
title: "Client Name"
x-component: "Input"
- name: "campaigns"
title: "?? Campaigns"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Campaign Title"
x-component: "Input"
- name: "assets"
title: "??? Assets"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Asset Title"
x-component: "Input"
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
nocobase_collections:
- name: "repositories"
title: "?? Repositories"
fields:
- name: "name"
type: "string"
uiSchema:
title: "Repo Name"
x-component: "Input"
- name: "releases"
title: "?? Releases"
fields:
- name: "version"
type: "string"
uiSchema:
title: "Version"
x-component: "Input"
- name: "bugs"
title: "?? Bugs"
fields:
- name: "title"
type: "string"
uiSchema:
title: "Bug Title"
x-component: "Input"
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Company secrets (integration keys, API tokens). Company secrets (integration keys, API tokens).
""" """
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, JSON from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime from datetime import datetime
...@@ -12,10 +12,13 @@ from typing import Optional ...@@ -12,10 +12,13 @@ from typing import Optional
class CompanySecret(Base): class CompanySecret(Base):
__tablename__ = "company_secrets" __tablename__ = "company_secrets"
__table_args__ = (
UniqueConstraint('company_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)
key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) 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)
is_sensitive: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_sensitive: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
......
...@@ -39,8 +39,10 @@ from .issue_label_assignment import * ...@@ -39,8 +39,10 @@ from .issue_label_assignment import *
from .issue_recovery_action import * from .issue_recovery_action import *
from .heartbeat_run_event import * from .heartbeat_run_event import *
from .vote import * from .vote import *
from .blueprint import *
from .common import TimestampMixin from .common import TimestampMixin
__all__ = [ __all__ = [
"TimestampMixin", "TimestampMixin",
"CompanyScope", "CompanyScope",
...@@ -175,4 +177,7 @@ __all__ = [ ...@@ -175,4 +177,7 @@ __all__ = [
"FeedbackVoteBase", "FeedbackVoteBase",
"FeedbackVoteCreate", "FeedbackVoteCreate",
"FeedbackVoteResponse", "FeedbackVoteResponse",
"BlueprintRole",
"BlueprintPipelineNode",
"CompanyBlueprint",
] ]
\ No newline at end of file
"""
Blueprint schemas for dynamic company construction.
"""
from typing import Optional, Any, Dict, List
from pydantic import BaseModel, ConfigDict, Field, model_validator
class BlueprintRole(BaseModel):
role: str = Field(..., min_length=1, max_length=255)
name: str = Field(..., min_length=1, max_length=255)
adapter_type: str = Field("openai", pattern=r"^[a-z][a-z0-9_]*$")
adapter_config: Dict[str, Any] = Field(default_factory=dict)
runtime_config: Dict[str, Any] = Field(default_factory=dict)
reports_to: Optional[str] = None
capabilities: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class BlueprintPipelineNode(BaseModel):
role: str = Field(..., min_length=1, max_length=255)
depends_on: List[str] = Field(default_factory=list)
approval_gate: bool = False
model_config = ConfigDict(from_attributes=True)
class CompanyBlueprint(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
industry: str = Field(..., min_length=1, max_length=255)
description: str = Field("")
roles: List[BlueprintRole] = Field(default_factory=list)
pipeline: List[BlueprintPipelineNode] = Field(default_factory=list)
nocobase_collections: List[Dict] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True)
@model_validator(mode='after')
def validate_reports_to(self) -> 'CompanyBlueprint':
role_ids = {r.role for r in self.roles}
for r in self.roles:
if r.reports_to and r.reports_to not in role_ids:
raise ValueError(f"Role '{r.role}' reports_to non-existent role '{r.reports_to}'")
return self
class BlueprintSummary(BaseModel):
id: str
name: str = Field(..., min_length=1)
industry: str = Field(..., min_length=1)
description: str = Field("")
roles: List[str] = Field(default_factory=list)
role_count: int
model_config = ConfigDict(from_attributes=True)
...@@ -33,14 +33,17 @@ async def main(): ...@@ -33,14 +33,17 @@ async def main():
company_result = await session.execute(select(Company).where(Company.id == "company_123")) company_result = await session.execute(select(Company).where(Company.id == "company_123"))
company = company_result.scalar_one_or_none() company = company_result.scalar_one_or_none()
if not company: if not company:
print("Creating company_123 company...") print("Creating company_123 company from blueprint...")
company = Company( from services.blueprint_service import BlueprintService
id="company_123", company = await BlueprintService.deploy_company_from_blueprint(
name="Canifa Company", db=session,
issue_prefix="CAN", company_name="Demo Company",
status="active" blueprint_name="fashion-retail",
owner_id="local-board",
description="AI Company Platform — Demo Instance",
issue_prefix="DEMO",
company_id="company_123"
) )
session.add(company)
else: else:
print("company_123 company already exists.") print("company_123 company already exists.")
......
"""
Blueprint service for loading company specifications and provisioning them.
"""
import os
import uuid
import yaml
from pathlib import Path
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models import Company, Agent, CompanyMembership
from schemas.blueprint import CompanyBlueprint, BlueprintRole, BlueprintPipelineNode
from services.activity_logger import log_activity
class BlueprintService:
@staticmethod
def get_blueprints_dir() -> Path:
"""Returns the directory containing blueprints."""
# Check standard location backend/blueprints
base_dir = Path(__file__).parent.parent
return base_dir / "blueprints"
_cache: Dict[str, CompanyBlueprint] = {}
@classmethod
def load_blueprint(cls, blueprint_name: str) -> CompanyBlueprint:
"""Loads a blueprint by name (e.g. 'software-studio')."""
if blueprint_name in cls._cache:
return cls._cache[blueprint_name]
blueprints_dir = cls.get_blueprints_dir()
# Strip extension if passed
if blueprint_name.endswith(".yaml") or blueprint_name.endswith(".yml"):
file_name = blueprint_name
else:
file_name = f"{blueprint_name}.yaml"
file_path = blueprints_dir / file_name
if not file_path.exists():
# Try with .yml
file_path = blueprints_dir / f"{blueprint_name}.yml"
if not file_path.exists():
raise FileNotFoundError(f"Blueprint '{blueprint_name}' not found in {blueprints_dir}")
with open(file_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
blueprint = CompanyBlueprint.model_validate(data)
cls.validate_pipeline_dag(blueprint)
cls.validate_org_chart(blueprint)
cls._cache[blueprint_name] = blueprint
return blueprint
@classmethod
def validate_org_chart(cls, blueprint: CompanyBlueprint) -> None:
"""Validates that reports_to integrity is maintained."""
role_ids = {r.role.lower() for r in blueprint.roles}
for r in blueprint.roles:
if r.reports_to and r.reports_to.lower() not in role_ids:
raise ValueError(f"Role '{r.role}' reports_to non-existent role '{r.reports_to}'")
@classmethod
def validate_pipeline_dag(cls, blueprint: CompanyBlueprint) -> None:
"""Checks for cycle detection and invalid references in the pipeline DAG."""
adj: Dict[str, List[str]] = {}
indegree: Dict[str, int] = {}
all_roles = {r.role.lower() for r in blueprint.roles}
# Check if pipeline nodes reference unknown roles
for node in blueprint.pipeline:
role = node.role.lower()
if role not in all_roles:
raise ValueError(f"Pipeline node references unknown role: {role}")
for dep in node.depends_on:
dep = dep.lower()
if dep not in all_roles:
raise ValueError(f"Pipeline node '{role}' depends on unknown role: {dep}")
adj.setdefault(dep, []).append(role)
indegree[role] = indegree.get(role, 0) + 1
if role not in indegree:
indegree[role] = 0
# Run cycle check via Kahn's algorithm
queue = [role for role in indegree if indegree[role] == 0]
visited = 0
while queue:
u = queue.pop(0)
visited += 1
for v in adj.get(u, []):
indegree[v] -= 1
if indegree[v] == 0:
queue.append(v)
# Verify that all nodes are visited (no cycle)
if visited < len(indegree):
raise ValueError("Pipeline spec contains dependency cycle or invalid references")
@classmethod
def list_blueprints(cls) -> List[Dict[str, Any]]:
"""Lists all available blueprints in the blueprints pack."""
blueprints_dir = cls.get_blueprints_dir()
if not blueprints_dir.exists():
return []
results = []
for file_path in blueprints_dir.glob("*.yaml"):
try:
with open(file_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
roles = [r.get("role") for r in data.get("roles", [])]
results.append({
"id": file_path.stem,
"name": data.get("name", file_path.stem),
"industry": data.get("industry", "generic"),
"description": data.get("description", ""),
"roles": roles,
"role_count": len(roles),
})
except Exception:
# Skip invalid YAMLs
pass
return results
@classmethod
async def deploy_company_from_blueprint(
cls,
db: AsyncSession,
company_name: str,
blueprint_name: str,
owner_id: str,
description: Optional[str] = None,
issue_prefix: Optional[str] = "PAP",
budget_monthly_cents: int = 0,
company_id: Optional[str] = None,
) -> Company:
"""Seeds a company and its agents/hierarchy from a blueprint."""
blueprint = cls.load_blueprint(blueprint_name)
# 1. Create company
company_kwargs = {
"name": company_name,
"description": description or blueprint.description,
"status": "active",
"issue_prefix": issue_prefix or "PAP",
"budget_monthly_cents": budget_monthly_cents,
"blueprint_name": blueprint_name,
}
if company_id:
company_kwargs["id"] = company_id
db_company = Company(**company_kwargs)
db.add(db_company)
await db.flush()
await db.refresh(db_company)
# 2. Add owner membership
membership = CompanyMembership(
id=uuid.uuid4().hex,
company_id=db_company.id,
principal_type="user",
principal_id=owner_id,
membership_role="instance_admin",
status="active"
)
db.add(membership)
# 3. Create agents
# We need to map role name -> Agent object to resolve reports_to relationships
role_to_agent: Dict[str, Agent] = {}
# First pass: create all agents without setting reports_to
for role_spec in blueprint.roles:
agent_id = f"{role_spec.role}_{uuid.uuid4().hex[:8]}"
# Combine default and generic config
agent = Agent(
id=agent_id,
company_id=db_company.id,
name=role_spec.name,
role=role_spec.role,
title=role_spec.name,
icon=None,
capabilities=role_spec.capabilities,
adapter_type=role_spec.adapter_type,
adapter_config=role_spec.adapter_config,
runtime_config=role_spec.runtime_config,
status="active",
)
db.add(agent)
role_to_agent[role_spec.role.lower()] = agent
await db.flush()
# Second pass: set reports_to connections
for role_spec in blueprint.roles:
if role_spec.reports_to:
parent_role = role_spec.reports_to.lower()
if parent_role in role_to_agent:
child_agent = role_to_agent[role_spec.role.lower()]
parent_agent = role_to_agent[parent_role]
child_agent.reports_to = parent_agent.id
else:
raise ValueError(f"reports_to role '{parent_role}' not found")
await db.flush()
# Log activity
await log_activity(
db=db,
company_id=db_company.id,
actor_type="user",
actor_id=owner_id,
action="company.created_from_blueprint",
resource_type="company",
resource_id=db_company.id,
changes={"name": company_name, "blueprint": blueprint_name}
)
return db_company
...@@ -2,26 +2,101 @@ import logging ...@@ -2,26 +2,101 @@ import logging
import os import os
import httpx import httpx
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import CompanySecret
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NocoBaseConnector: class NocoBaseConnector:
def __init__(self, base_url: str = "http://localhost:13001"): def __init__(self, base_url: str, email: Optional[str] = None, password: Optional[str] = None):
""" """
NocoBase API connector. NocoBase API connector.
By default, the server runs on 13001 in development mode.
""" """
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.email = email
self.password = password
self.token: Optional[str] = None self.token: Optional[str] = None
self.headers: Dict[str, str] = { self.headers: Dict[str, str] = {
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json" "Content-Type": "application/json"
} }
async def login(self, email: str = "admin@nocobase.com", password: str = "admin123") -> bool: @classmethod
async def from_company(cls, company_id: str, db: AsyncSession) -> "NocoBaseConnector":
"""
Load NocoBase credentials dynamically from CompanySecret.
"""
stmt = select(CompanySecret).where(
CompanySecret.company_id == company_id,
CompanySecret.deleted_at.is_(None)
)
result = await db.execute(stmt)
secrets = result.scalars().all()
config = {}
for s in secrets:
config[s.key] = s.value_encrypted
url = config.get("nocobase_url") or config.get("nocobase_host") or os.getenv("NOCOBASE_URL", "http://localhost:13001")
email = config.get("nocobase_email") or os.getenv("NOCOBASE_EMAIL", "admin@nocobase.com")
password = config.get("nocobase_password") or os.getenv("NOCOBASE_PASSWORD", "admin123")
token = config.get("nocobase_token")
connector = cls(base_url=url, email=email, password=password)
if token:
connector.token = token
connector.headers["Authorization"] = f"Bearer {token}"
return connector
async def ensure_collection(self, name: str, fields: List[Dict[str, Any]]) -> bool:
"""
Ensure that a NocoBase collection exists, creating it if not.
"""
await self.ensure_logged_in()
try:
existing_cols = await self.list_records("collections")
existing_names = {c.get("name") for c in existing_cols}
if name in existing_names:
logger.info(f"Collection '{name}' already exists in NocoBase.")
return True
except Exception as e:
logger.warning(f"Could not list collections: {e}. Trying to create anyway.")
url = f"{self.base_url}/api/collections:create"
title = name.replace("_", " ").title()
payload = {
"name": name,
"title": title,
"fields": fields
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=self.headers, json=payload, timeout=15.0)
if response.status_code in (200, 201):
logger.info(f"Successfully created NocoBase collection: {name}")
return True
else:
if "already exists" in response.text or response.status_code == 400:
logger.info(f"Collection '{name}' already exists or failed to create with 400.")
return True
logger.error(f"Failed to create NocoBase collection {name}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception creating NocoBase collection {name}: {e}")
return False
async def login(self, email: Optional[str] = None, password: Optional[str] = None) -> bool:
""" """
Authenticate with NocoBase to obtain a bearer token. Authenticate with NocoBase to obtain a bearer token.
""" """
email = email or self.email
password = password or self.password
if not email or not password:
logger.error("No email/password provided for NocoBase login.")
return False
url = f"{self.base_url}/api/auth:signIn" url = f"{self.base_url}/api/auth:signIn"
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
......
...@@ -55,6 +55,48 @@ class ProjectOrchestrator: ...@@ -55,6 +55,48 @@ class ProjectOrchestrator:
result = await orchestrator.run() result = await orchestrator.run()
""" """
@staticmethod
def _stages_from_spec(nodes_spec: list[dict]) -> list[list[dict]]:
"""Helper to group roles into level-by-level parallel stages using Kahn's algorithm."""
adj = {}
indegree = {}
role_to_spec = {}
all_roles = set()
for item in nodes_spec:
role = item["role"].lower()
role_to_spec[role] = item
all_roles.add(role)
for item in nodes_spec:
role = item["role"].lower()
deps = [d.lower() for d in item.get("depends_on", [])]
deps = [d for d in deps if d in all_roles]
indegree[role] = len(deps)
for dep in deps:
adj.setdefault(dep, []).append(role)
queue = [role for role in all_roles if indegree[role] == 0]
stages = []
while queue:
stage_specs = [role_to_spec[role] for role in queue]
stages.append(stage_specs)
next_queue = []
for u in queue:
for v in adj.get(u, []):
indegree[v] -= 1
if indegree[v] == 0:
next_queue.append(v)
queue = next_queue
flat_stages = [r["role"] for stage in stages for r in stage]
if len(flat_stages) < len(all_roles):
raise ValueError("Pipeline spec contains dependency cycle or invalid references")
return stages
def __init__( def __init__(
self, self,
company_id: str, company_id: str,
...@@ -63,6 +105,7 @@ class ProjectOrchestrator: ...@@ -63,6 +105,7 @@ class ProjectOrchestrator:
poll_interval: float = 3.0, poll_interval: float = 3.0,
max_wait_per_agent: int = 600, max_wait_per_agent: int = 600,
max_concurrent: int = 3, max_concurrent: int = 3,
pipeline_spec: list[dict] | None = None,
): ):
self.company_id = company_id self.company_id = company_id
self.project_brief = project_brief self.project_brief = project_brief
...@@ -70,6 +113,7 @@ class ProjectOrchestrator: ...@@ -70,6 +113,7 @@ class ProjectOrchestrator:
self.poll_interval = poll_interval self.poll_interval = poll_interval
self.max_wait_per_agent = max_wait_per_agent self.max_wait_per_agent = max_wait_per_agent
self.max_concurrent = max_concurrent self.max_concurrent = max_concurrent
self.pipeline_spec = pipeline_spec
self.pipeline_id = uuid.uuid4().hex self.pipeline_id = uuid.uuid4().hex
self.results: list[dict] = [] self.results: list[dict] = []
self._interrupted = False self._interrupted = False
...@@ -108,11 +152,71 @@ class ProjectOrchestrator: ...@@ -108,11 +152,71 @@ class ProjectOrchestrator:
pipeline_status = PipelineStatus.COMPLETED pipeline_status = PipelineStatus.COMPLETED
step_counter = 0 step_counter = 0
for stage_idx, stage_agents in enumerate(stages): for stage_idx, stage_data in enumerate(stages):
if isinstance(stage_data, dict):
stage_agents = stage_data["agents"]
approval_gate = stage_data.get("approval_gate", False)
else:
stage_agents = stage_data
approval_gate = False
if self._interrupted: if self._interrupted:
pipeline_status = PipelineStatus.FAILED pipeline_status = PipelineStatus.FAILED
break break
if approval_gate:
logger.info(f"[Pipeline {self.pipeline_id}] Stage {stage_idx + 1} requires approval. Pausing pipeline.")
approval_id = uuid.uuid4().hex
async with get_db_context() as db:
from models import Approval
db_approval = Approval(
id=approval_id,
company_id=self.company_id,
resource_type="pipeline_stage",
resource_id=self.pipeline_id,
required_approvers=1,
created_by="system"
)
db.add(db_approval)
await db.commit()
await self._broadcast("pipeline.approval_required", {
"pipeline_id": self.pipeline_id,
"approval_id": approval_id,
"stage": stage_idx + 1,
})
# Poll approval resolution
approved = False
elapsed = 0.0
while elapsed < self.max_wait_per_agent:
if self._interrupted:
break
await asyncio.sleep(self.poll_interval)
elapsed += self.poll_interval
async with get_db_context() as db:
from models import Approval
result = await db.execute(select(Approval).where(Approval.id == approval_id))
appr = result.scalar_one_or_none()
if appr and appr.resolution:
if appr.resolution == "approved":
approved = True
logger.info(f"[Pipeline {self.pipeline_id}] Stage {stage_idx + 1} approved.")
else:
logger.warning(f"[Pipeline {self.pipeline_id}] Stage {stage_idx + 1} rejected.")
break
if not approved:
pipeline_status = PipelineStatus.FAILED
await self._broadcast("pipeline.rejected", {
"pipeline_id": self.pipeline_id,
"approval_id": approval_id,
"stage": stage_idx + 1,
})
break
# Build context from previous stages' outputs # Build context from previous stages' outputs
context = accumulated_context context = accumulated_context
if stage_idx > 0: if stage_idx > 0:
...@@ -202,16 +306,47 @@ class ProjectOrchestrator: ...@@ -202,16 +306,47 @@ class ProjectOrchestrator:
return report return report
def _group_into_stages(self, agents: list[dict]) -> list[list[dict]]: def _group_into_stages(self, agents: list[dict]) -> list[dict]:
"""Group agents into pipeline stages for parallel execution. """Group agents into pipeline stages for parallel execution."""
# 1. Use pipeline_spec if defined
if self.pipeline_spec:
try:
role_stages = self._stages_from_spec(self.pipeline_spec)
stage_data = []
placed_agent_ids = set()
for role_stage in role_stages:
stage_agents = []
approval_gate = False
for spec in role_stage:
role = spec["role"].lower()
for agent in agents:
if (agent.get("role") or "").lower() == role:
stage_agents.append(agent)
placed_agent_ids.add(agent["id"])
if spec.get("approval_gate", False):
approval_gate = True
if stage_agents:
stage_data.append({
"agents": stage_agents,
"approval_gate": approval_gate
})
Agents whose role belongs to the same stage in PIPELINE_STAGES # Unplaced agents go to a final stage
run concurrently. Agents with unknown roles go into a final stage. unplaced = [a for a in agents if a["id"] not in placed_agent_ids]
""" if unplaced:
stage_map: dict[int, list[dict]] = {} stage_data.append({
"agents": unplaced,
"approval_gate": False
})
return stage_data
except Exception as e:
logger.error(f"Error grouping stages from spec: {e}. Falling back to default stages.")
# 2. Legacy fallback
stage_map: dict[int, list[dict]] = {}
for agent in agents: for agent in agents:
role = agent["role"].lower() role = (agent.get("role") or "").lower()
placed = False placed = False
for stage_idx, stage_roles in enumerate(PIPELINE_STAGES): for stage_idx, stage_roles in enumerate(PIPELINE_STAGES):
if role in stage_roles: if role in stage_roles:
...@@ -219,10 +354,12 @@ class ProjectOrchestrator: ...@@ -219,10 +354,12 @@ class ProjectOrchestrator:
placed = True placed = True
break break
if not placed: if not placed:
# Unknown roles go after the last defined stage
stage_map.setdefault(len(PIPELINE_STAGES), []).append(agent) stage_map.setdefault(len(PIPELINE_STAGES), []).append(agent)
return [stage_map[k] for k in sorted(stage_map.keys())] return [
{"agents": stage_map[k], "approval_gate": False}
for k in sorted(stage_map.keys())
]
async def _run_parallel_stage( async def _run_parallel_stage(
self, agents: list[dict], context: str, step_offset: int, total: int self, agents: list[dict], context: str, step_offset: int, total: int
...@@ -290,12 +427,21 @@ class ProjectOrchestrator: ...@@ -290,12 +427,21 @@ class ProjectOrchestrator:
) )
agents = result.scalars().all() agents = result.scalars().all()
if self.pipeline_spec:
try:
role_stages = self._stages_from_spec(self.pipeline_spec)
custom_role_order = [spec["role"].lower() for stage in role_stages for spec in stage]
except Exception:
custom_role_order = ROLE_ORDER
else:
custom_role_order = ROLE_ORDER
def role_sort_key(agent): def role_sort_key(agent):
role = (agent.role or "").lower() role = (agent.role or "").lower()
try: try:
return ROLE_ORDER.index(role) return custom_role_order.index(role)
except ValueError: except ValueError:
return len(ROLE_ORDER) return len(custom_role_order)
agents = sorted(agents, key=role_sort_key) agents = sorted(agents, key=role_sort_key)
......
import pytest
import sys
import os
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock, patch
# Insert backend root to sys.path to resolve imports correctly in tests
sys.path.insert(0, str(Path(__file__).parent.parent))
from server import app
from database import get_db_session
from middleware.auth import get_current_user
from services.blueprint_service import BlueprintService
from services.project_orchestrator import ProjectOrchestrator
@pytest.mark.asyncio
async def test_blueprint_listing_and_loading():
"""Verify that blueprints can be listed, loaded, and validated correctly."""
blueprints = BlueprintService.list_blueprints()
assert len(blueprints) > 0
names = [bp["id"] for bp in blueprints]
assert "software-studio" in names
assert "fashion-retail" in names
# Load valid blueprint
bp = BlueprintService.load_blueprint("software-studio")
assert bp.name == "Software Studio"
assert len(bp.roles) > 0
assert len(bp.pipeline) > 0
@pytest.mark.asyncio
async def test_blueprint_endpoints_and_deployment():
"""Verify the API routes list, load, and deploy companies from blueprints."""
import httpx
# Mock authentication
mock_auth_user = MagicMock()
mock_auth_user.id = "user_blueprint_owner"
async def mock_get_current_user():
return mock_auth_user
# Mock database session
mock_session = AsyncMock()
async def mock_get_db_session():
yield mock_session
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_db_session] = mock_get_db_session
transport = httpx.ASGITransport(app=app)
try:
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# 1. GET /api/companies/blueprints
response = await client.get("/api/companies/blueprints")
assert response.status_code == 200
blueprints_list = response.json()
assert len(blueprints_list) > 0
assert any(bp["id"] == "software-studio" for bp in blueprints_list)
# 2. GET /api/companies/blueprints/software-studio
response = await client.get("/api/companies/blueprints/software-studio")
assert response.status_code == 200
blueprint_detail = response.json()
assert blueprint_detail["name"] == "Software Studio"
# 3. POST /api/companies/from-blueprint (Mock DB interactions)
from datetime import datetime
from models import Company
mock_company = Company(
id="new_co_123",
name="My Test Software Studio",
status="active",
issue_prefix="PAP",
budget_monthly_cents=0,
require_board_approval_for_new_agents=False,
blueprint_name="software-studio",
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Mock deploy_company_from_blueprint returning the company
with patch("services.blueprint_service.BlueprintService.deploy_company_from_blueprint", AsyncMock(return_value=mock_company)):
with patch("services.nocobase_connector.NocoBaseConnector.from_company", AsyncMock()) as mock_from_company:
mock_from_company.return_value = AsyncMock()
with patch("services.nocobase_connector.NocoBaseConnector.ensure_collection", AsyncMock(return_value=True)):
response = await client.post(
"/api/companies/from-blueprint",
json={
"name": "My Test Software Studio",
"blueprint_name": "software-studio",
"issue_prefix": "PAP",
"budget_monthly_cents": 0
}
)
assert response.status_code == 201
json_data = response.json()
assert json_data["id"] == "new_co_123"
assert json_data["name"] == "My Test Software Studio"
assert json_data["blueprintName"] == "software-studio"
finally:
app.dependency_overrides.clear()
def test_topological_sorting():
"""Verify that Kahn's algorithm correctly resolves pipeline DAG stages."""
nodes = [
{"role": "ceo", "depends_on": []},
{"role": "pm", "depends_on": ["ceo"]},
{"role": "designer", "depends_on": ["pm"]},
{"role": "coder", "depends_on": ["designer"]},
{"role": "qa", "depends_on": ["coder"]},
{"role": "devops", "depends_on": ["qa"]}
]
stages = ProjectOrchestrator._stages_from_spec(nodes)
assert len(stages) == 6
assert stages[0][0]["role"] == "ceo"
assert stages[1][0]["role"] == "pm"
assert stages[2][0]["role"] == "designer"
assert stages[3][0]["role"] == "coder"
assert stages[4][0]["role"] == "qa"
assert stages[5][0]["role"] == "devops"
# Parallel stages check
nodes_parallel = [
{"role": "pm", "depends_on": []},
{"role": "designer", "depends_on": ["pm"]},
{"role": "coder", "depends_on": ["pm"]},
{"role": "qa", "depends_on": ["designer", "coder"]}
]
stages_parallel = ProjectOrchestrator._stages_from_spec(nodes_parallel)
# Stage 0: PM
# Stage 1: Designer, Coder (parallel)
# Stage 2: QA
assert len(stages_parallel) == 3
assert len(stages_parallel[0]) == 1
assert stages_parallel[0][0]["role"] == "pm"
stage_1_roles = {n["role"] for n in stages_parallel[1]}
assert stage_1_roles == {"designer", "coder"}
assert len(stages_parallel[2]) == 1
assert stages_parallel[2][0]["role"] == "qa"
# Cycle detection check
nodes_cycle = [
{"role": "pm", "depends_on": ["qa"]},
{"role": "qa", "depends_on": ["pm"]}
]
with pytest.raises(ValueError):
ProjectOrchestrator._stages_from_spec(nodes_cycle)
@pytest.mark.asyncio
async def test_software_studio_pipeline_spec_with_orchestrator():
"""Verify loading software-studio blueprint builds correct pipeline spec for orchestrator."""
bp = BlueprintService.load_blueprint("software-studio")
pipeline_spec = [node.model_dump() for node in bp.pipeline]
# Assert spec is valid and not empty
assert len(pipeline_spec) > 0
# Let's see how topological_sort_stages sorts it
stages = ProjectOrchestrator._stages_from_spec(pipeline_spec)
assert len(stages) > 0
# We expect 'designer' to come before 'coder'
designer_stage_idx = -1
coder_stage_idx = -1
for i, stage in enumerate(stages):
roles = {n["role"] for n in stage}
if "designer" in roles:
designer_stage_idx = i
if "coder" in roles:
coder_stage_idx = i
assert designer_stage_idx != -1, "designer role not found in pipeline"
assert coder_stage_idx != -1, "coder role not found in pipeline"
assert designer_stage_idx < coder_stage_idx, "designer should run before coder"
@pytest.mark.asyncio
async def test_blueprint_validation_fails_for_bad_org_chart():
from schemas.blueprint import CompanyBlueprint
bad_yaml = {
"name": "Bad",
"industry": "bad",
"description": "bad",
"roles": [
{"role": "ceo", "name": "CEO", "adapter_type": "openai"},
{"role": "dev", "name": "DEV", "adapter_type": "openai", "reports_to": "ghost"}
],
"pipeline": []
}
with pytest.raises(ValueError, match="non-existent role 'ghost'"):
blueprint = CompanyBlueprint.model_validate(bad_yaml)
BlueprintService.validate_org_chart(blueprint)
import type { Company } from "@paperclipai/shared";
import { api } from "./client";
export interface BlueprintRole {
role: string;
name: string;
adapterType: string;
adapterConfig: Record<string, any>;
runtimeConfig: Record<string, any>;
capabilities?: string;
reportsTo?: string | null;
}
export interface BlueprintPipelineNode {
role: string;
dependsOn: string[];
approvalGate?: boolean;
}
export interface CompanyBlueprint {
name: string;
industry: string;
description: string;
roles: BlueprintRole[];
pipeline: BlueprintPipelineNode[];
}
export interface BlueprintSummary {
id: string;
name: string;
industry: string;
description: string;
roles: string[];
}
export interface CreateFromBlueprintRequest {
name: string;
blueprintName: string;
description?: string;
issuePrefix?: string;
budgetMonthlyCents?: number;
}
export const blueprintsApi = {
list: () => api.get<BlueprintSummary[]>("/companies/blueprints"),
get: (name: string) => api.get<CompanyBlueprint>(`/companies/blueprints/${name}`),
create: (data: CreateFromBlueprintRequest) =>
api.post<Company>("/companies/from-blueprint", data),
};
...@@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges"; ...@@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges";
export { sidebarPreferencesApi } from "./sidebarPreferences"; export { sidebarPreferencesApi } from "./sidebarPreferences";
export { inboxDismissalsApi } from "./inboxDismissals"; export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills"; export { companySkillsApi } from "./companySkills";
export { blueprintsApi } from "./blueprints";
...@@ -5,6 +5,7 @@ import { useLocation, useNavigate, useParams } from "@/lib/router"; ...@@ -5,6 +5,7 @@ import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies"; import { companiesApi } from "../api/companies";
import { blueprintsApi, type CompanyBlueprint, type BlueprintSummary } from "../api/blueprints";
import { goalsApi } from "../api/goals"; import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
...@@ -58,6 +59,7 @@ import { ...@@ -58,6 +59,7 @@ import {
ChevronDown, ChevronDown,
X X
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "@/i18n";
type Step = 1 | 2 | 3 | 4; type Step = 1 | 2 | 3 | 4;
...@@ -77,6 +79,7 @@ export function OnboardingWizard() { ...@@ -77,6 +79,7 @@ export function OnboardingWizard() {
const location = useLocation(); const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>(); const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false); const [routeDismissed, setRouteDismissed] = useState(false);
const { t } = useTranslation();
// Sync disabled adapter types from server so adapter grid filters them out // Sync disabled adapter types from server so adapter grid filters them out
const disabledTypes = useDisabledAdaptersSync(); const disabledTypes = useDisabledAdaptersSync();
...@@ -108,6 +111,37 @@ export function OnboardingWizard() { ...@@ -108,6 +111,37 @@ export function OnboardingWizard() {
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [companyGoal, setCompanyGoal] = useState(""); const [companyGoal, setCompanyGoal] = useState("");
// Blueprint creation states
const [creationMode, setCreationMode] = useState<"blueprint" | "custom">("blueprint");
const [selectedBlueprintId, setSelectedBlueprintId] = useState<string>("software-studio");
const [issuePrefixInput, setIssuePrefixInput] = useState("PAP");
const [budgetMonthlyUSDInput, setBudgetMonthlyUSDInput] = useState(0);
// Queries to load blueprints list and detail
const { data: blueprintsList, isLoading: isBlueprintsListLoading, error: blueprintsListError } = useQuery({
queryKey: ["blueprintsList"],
queryFn: async () => {
const response = await blueprintsApi.list();
return response;
},
enabled: effectiveOnboardingOpen,
});
const { data: blueprintDetail, isLoading: isBlueprintDetailLoading } = useQuery({
queryKey: ["blueprintDetail", selectedBlueprintId],
queryFn: async () => {
const response = await blueprintsApi.get(selectedBlueprintId);
return response;
},
enabled: effectiveOnboardingOpen && creationMode === "blueprint" && !!selectedBlueprintId,
});
useEffect(() => {
if (blueprintsList && blueprintsList.length > 0 && !selectedBlueprintId) {
setSelectedBlueprintId(blueprintsList[0].id);
}
}, [blueprintsList, selectedBlueprintId]);
// Step 2 // Step 2
const [agentName, setAgentName] = useState("CEO"); const [agentName, setAgentName] = useState("CEO");
const [adapterType, setAdapterType] = useState<AdapterType>("claude_local"); const [adapterType, setAdapterType] = useState<AdapterType>("claude_local");
...@@ -389,6 +423,40 @@ export function OnboardingWizard() { ...@@ -389,6 +423,40 @@ export function OnboardingWizard() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
if (creationMode === "blueprint") {
if (!selectedBlueprintId) {
throw new Error("Vui lòng chọn một blueprint để tiếp tục.");
}
const companyResponse = await blueprintsApi.create({
name: companyName.trim(),
blueprintName: selectedBlueprintId,
issuePrefix: (issuePrefixInput.trim() || "PAP").toUpperCase(),
budgetMonthlyCents: Math.round(budgetMonthlyUSDInput * 100)
});
const companyData = companyResponse;
setCreatedCompanyId(companyData.id);
setCreatedCompanyPrefix(companyData.issuePrefix);
setSelectedCompanyId(companyData.id);
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
// Query the hired agents to locate the CEO Agent
const hiredAgents = await agentsApi.list(companyData.id);
const ceoAgent = hiredAgents.find(a => a.role?.toLowerCase() === "ceo");
if (ceoAgent) {
setCreatedAgentId(ceoAgent.id);
setAgentName(ceoAgent.name);
} else if (hiredAgents.length > 0) {
// Fallback to first agent
setCreatedAgentId(hiredAgents[0].id);
setAgentName(hiredAgents[0].name);
} else {
throw new Error("Không tìm thấy Agent CEO được tạo bởi Blueprint.");
}
// Skip step 2 (Agent configuration) since the blueprint already provisioned the entire org
setStep(3);
} else {
const company = await companiesApi.create({ name: companyName.trim() }); const company = await companiesApi.create({ name: companyName.trim() });
setCreatedCompanyId(company.id); setCreatedCompanyId(company.id);
setCreatedCompanyPrefix(company.issuePrefix); setCreatedCompanyPrefix(company.issuePrefix);
...@@ -414,6 +482,7 @@ export function OnboardingWizard() { ...@@ -414,6 +482,7 @@ export function OnboardingWizard() {
} }
setStep(2); setStep(2);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to create company"); setError(err instanceof Error ? err.message : "Failed to create company");
} finally { } finally {
...@@ -663,21 +732,147 @@ export function OnboardingWizard() { ...@@ -663,21 +732,147 @@ export function OnboardingWizard() {
<Building2 className="h-5 w-5 text-muted-foreground" /> <Building2 className="h-5 w-5 text-muted-foreground" />
</div> </div>
<div> <div>
<h3 className="font-medium">Name your company</h3> <h3 className="font-medium text-base">{t('onboarding.initCompany', 'Khởi tạo Công ty AI')}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
This is the organization your agents will work for. {t('onboarding.initDesc', 'Cấu hình phòng ban và hệ thống nhân sự AI cho doanh nghiệp của bạn.')}
</p> </p>
</div> </div>
</div> </div>
<div className="mt-3 group">
<label {/* Mode selector */}
<div className="flex border-b border-border mb-4">
<button
type="button"
onClick={() => setCreationMode("blueprint")}
className={cn( className={cn(
"text-xs mb-1 block transition-colors", "flex-1 pb-2 text-xs font-semibold text-center border-b-2 transition-colors cursor-pointer",
companyName.trim() creationMode === "blueprint"
? "text-foreground" ? "border-foreground text-foreground"
: "text-muted-foreground group-focus-within:text-foreground" : "border-transparent text-muted-foreground hover:text-foreground/70"
)}
>
{t('onboarding.industryBlueprint', 'Industry Blueprint')}
</button>
<button
type="button"
onClick={() => setCreationMode("custom")}
className={cn(
"flex-1 pb-2 text-xs font-semibold text-center border-b-2 transition-colors cursor-pointer",
creationMode === "custom"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground/70"
)} )}
> >
{t('onboarding.customConfig', 'Bản trống (Tự cấu hình)')}
</button>
</div>
{creationMode === "blueprint" ? (
<div className="space-y-4">
{/* Blueprint choice list */}
<div>
<label className="text-xs text-muted-foreground mb-2 block">
{t('onboarding.selectTemplate', 'Chọn Industry Template')}
</label>
<div className="grid gap-2 max-h-[220px] overflow-y-auto pr-1">
{isBlueprintsListLoading ? (
<div className="flex flex-col items-center justify-center py-8 opacity-50">
<Loader2 className="h-5 w-5 animate-spin mb-2" />
<span className="text-xs">{t('onboarding.loadingBlueprints', 'Loading blueprints...')}</span>
</div>
) : blueprintsListError ? (
<div className="flex flex-col items-center justify-center py-8 text-destructive">
<span className="text-xs">{t('onboarding.errorLoadingBlueprints', 'Error loading blueprints. Please try again.')}</span>
</div>
) : blueprintsList?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 opacity-50">
<span className="text-xs">{t('onboarding.noBlueprints', 'No blueprints available.')}</span>
</div>
) : (
blueprintsList?.map((bp: BlueprintSummary) => (
<button
key={bp.id}
type="button"
onClick={() => setSelectedBlueprintId(bp.id)}
className={cn(
"flex flex-col text-left p-3 border rounded-md transition-colors relative cursor-pointer w-full",
selectedBlueprintId === bp.id
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
>
<div className="flex items-center justify-between w-full">
<span className="font-medium text-sm">{bp.name}</span>
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground uppercase">
{bp.industry}
</span>
</div>
<span className="text-xs text-muted-foreground mt-1 line-clamp-2">
{bp.description}
</span>
</button>
))
)}
</div>
</div>
{/* Name input */}
<div className="group">
<label className={cn(
"text-xs mb-1 block transition-colors",
companyName.trim() ? "text-foreground" : "text-muted-foreground"
)}>
{t('onboarding.companyName', 'Tên công ty / phòng ban')}
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Fashion Ecom, Software Lab..."
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
/>
</div>
{/* Advanced Settings: Issue Prefix & Budget */}
<div className="grid grid-cols-2 gap-3">
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
{t('onboarding.issuePrefix', 'Issue Prefix')}
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring font-mono uppercase"
placeholder="PAP"
maxLength={5}
value={issuePrefixInput}
onChange={(e) => setIssuePrefixInput(e.target.value)}
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
{t('onboarding.budget', 'Monthly Budget (USD)')}
</label>
<div className="relative">
<span className="absolute left-3 top-2 text-muted-foreground">$</span>
<input
type="number"
min="0"
step="1"
className="w-full rounded-md border border-border bg-transparent pl-7 pr-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring font-mono"
placeholder="0 (Unlimited)"
value={budgetMonthlyUSDInput || ""}
onChange={(e) => setBudgetMonthlyUSDInput(Number(e.target.value))}
/>
</div>
</div>
</div>
</div>
) : (
// Original Step 1 inputs
<div className="space-y-4">
<div className="group">
<label className={cn(
"text-xs mb-1 block transition-colors",
companyName.trim() ? "text-foreground" : "text-muted-foreground"
)}>
Company name Company name
</label> </label>
<input <input
...@@ -689,18 +884,14 @@ export function OnboardingWizard() { ...@@ -689,18 +884,14 @@ export function OnboardingWizard() {
/> />
</div> </div>
<div className="group"> <div className="group">
<label <label className={cn(
className={cn(
"text-xs mb-1 block transition-colors", "text-xs mb-1 block transition-colors",
companyGoal.trim() companyGoal.trim() ? "text-foreground" : "text-muted-foreground"
? "text-foreground" )}>
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Mission / goal (optional) Mission / goal (optional)
</label> </label>
<textarea <textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]" className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[80px]"
placeholder="What is this company trying to achieve?" placeholder="What is this company trying to achieve?"
value={companyGoal} value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)} onChange={(e) => setCompanyGoal(e.target.value)}
...@@ -708,6 +899,8 @@ export function OnboardingWizard() { ...@@ -708,6 +899,8 @@ export function OnboardingWizard() {
</div> </div>
</div> </div>
)} )}
</div>
)}
{step === 2 && ( {step === 2 && (
<div className="space-y-5"> <div className="space-y-5">
...@@ -1189,7 +1382,13 @@ export function OnboardingWizard() { ...@@ -1189,7 +1382,13 @@ export function OnboardingWizard() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setStep((step - 1) as Step)} onClick={() => {
if (step === 3 && creationMode === "blueprint") {
setStep(1);
} else {
setStep((step - 1) as Step);
}
}}
disabled={loading} disabled={loading}
> >
<ArrowLeft className="h-3.5 w-3.5 mr-1" /> <ArrowLeft className="h-3.5 w-3.5 mr-1" />
...@@ -1257,14 +1456,34 @@ export function OnboardingWizard() { ...@@ -1257,14 +1456,34 @@ export function OnboardingWizard() {
</div> </div>
</div> </div>
{/* Right half — ASCII art (hidden on mobile) */} {/* Right half — ASCII art or Blueprint Org Chart Preview (hidden on mobile) */}
<div <div
className={cn( className={cn(
"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out", "hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out",
step === 1 ? "w-1/2 opacity-100" : "w-0 opacity-0" step === 1 ? "w-1/2 opacity-100" : "w-0 opacity-0"
)} )}
> >
{creationMode === "blueprint" ? (
isBlueprintDetailLoading ? (
<div className="w-full h-full flex flex-col justify-center p-8 bg-[#18181b] border-l border-border animate-pulse">
<div className="max-w-md mx-auto space-y-6 w-full">
<div className="space-y-2">
<div className="h-5 w-32 bg-zinc-800 rounded-full"></div>
<div className="h-8 w-48 bg-zinc-800 rounded"></div>
<div className="h-4 w-full bg-zinc-800 rounded"></div>
<div className="h-4 w-5/6 bg-zinc-800 rounded"></div>
</div>
<div className="h-64 w-full bg-zinc-800/50 rounded-lg border border-zinc-800"></div>
</div>
</div>
) : blueprintDetail ? (
<BlueprintOrgChartPreview blueprint={blueprintDetail} />
) : (
<AsciiArtAnimation />
)
) : (
<AsciiArtAnimation /> <AsciiArtAnimation />
)}
</div> </div>
</div> </div>
</DialogPortal> </DialogPortal>
...@@ -1325,3 +1544,153 @@ function AdapterEnvironmentResult({ ...@@ -1325,3 +1544,153 @@ function AdapterEnvironmentResult({
</div> </div>
); );
} }
function BlueprintOrgChartPreview({
blueprint,
}: {
blueprint: CompanyBlueprint;
}) {
const { t } = useTranslation();
// Find all roles that don't report to anyone or report to someone not in the blueprint
const rootRoles = useMemo(() => {
const roleMap = new Set(blueprint.roles.map(r => r.role?.toLowerCase()));
return blueprint.roles.filter(r => !r.reportsTo || !roleMap.has(r.reportsTo.toLowerCase()));
}, [blueprint.roles]);
const directReports = (roleName: string) =>
blueprint.roles.filter(r => r.reportsTo?.toLowerCase() === roleName.toLowerCase());
// DAG logic
const pipelineLayers = useMemo(() => {
if (!blueprint.pipeline) return [];
const layers: import("../api/blueprints").BlueprintPipelineNode[][] = [];
const processed = new Set<string>();
const pending = [...blueprint.pipeline];
while (pending.length > 0) {
const currentLayer = pending.filter(node =>
!node.dependsOn || node.dependsOn.length === 0 || node.dependsOn.every(dep => processed.has(dep))
);
if (currentLayer.length === 0) {
layers.push([...pending]);
break;
}
layers.push(currentLayer);
currentLayer.forEach(node => {
processed.add(node.role);
const idx = pending.indexOf(node);
if (idx > -1) pending.splice(idx, 1);
});
}
return layers;
}, [blueprint.pipeline]);
// Recursively render org chart depth
const renderOrgChartLevel = (reports: typeof blueprint.roles, depth: number = 0) => {
if (reports.length === 0) return null;
return (
<div className={cn("space-y-3", depth === 0 ? "pl-6 border-l border-border/60 ml-4" : "pl-6 border-l border-border/40 mt-2 relative ml-3.5")}>
{reports.map((role) => (
<div key={role.role} className="space-y-3">
<div className={cn("flex items-center gap-3 rounded-md border relative", depth === 0 ? "p-2.5 bg-zinc-800/40 border-border/50" : "p-2 bg-zinc-800/20 border-border/30")}>
<div className={cn("absolute top-1/2 border-t", depth === 0 ? "-left-3.5 w-3.5 border-border/60" : "-left-3.5 w-3.5 border-border/40")} />
<div className={cn("rounded-full flex items-center justify-center font-bold text-muted-foreground", depth === 0 ? "h-7 w-7 bg-zinc-700/50 text-[10px]" : "h-5 w-5 bg-zinc-800 font-mono text-[8px]")}>
{depth === 0 ? role.role.substring(0, 3).toUpperCase() : role.role.substring(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className={cn("font-medium text-white truncate", depth === 0 ? "text-xs" : "text-[11px]")}>{role.name}</p>
<p className={cn("text-muted-foreground truncate", depth === 0 ? "text-[9px] font-mono" : "text-[8px]")}>
{depth === 0 ? role.adapterType : role.capabilities}
</p>
</div>
</div>
{/* Recursive call for sub-reports */}
{renderOrgChartLevel(directReports(role.role), depth + 1)}
</div>
))}
</div>
);
};
return (
<div className="w-full h-full flex flex-col justify-center p-8 bg-[#18181b] text-foreground border-l border-border overflow-y-auto">
<div className="max-w-md mx-auto space-y-6 w-full animate-in fade-in slide-in-from-right-4 duration-500">
<div>
<span className="text-[10px] bg-primary/10 text-primary-foreground border border-primary/20 px-2 py-0.5 rounded-full font-mono uppercase tracking-wider">
{blueprint.industry} department map
</span>
<h3 className="text-xl font-bold mt-2 text-white">{blueprint.name}</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{blueprint.description}
</p>
</div>
{/* Dynamic Tree Hierarchy */}
<div className="border border-border/40 bg-zinc-900/50 rounded-lg p-5 space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{t('onboarding.aiOrgChart', `Cơ cấu nhân sự AI (${blueprint.roles.length} tác vụ)`)}
</h4>
<div className="space-y-6">
{rootRoles.map(rootRole => (
<div key={rootRole.role} className="space-y-3">
{/* Parent Root node */}
<div className="flex items-center gap-3 p-3 bg-zinc-800/80 rounded-md border border-border">
<div className="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center font-bold text-xs text-primary">
{rootRole.role.substring(0, 3).toUpperCase()}
</div>
<div>
<p className="text-xs font-semibold text-white">{rootRole.name}</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider font-mono">
{rootRole.adapterType} · {rootRole.role}
</p>
</div>
</div>
{/* Recursive Direct reports to root */}
{renderOrgChartLevel(directReports(rootRole.role), 0)}
</div>
))}
</div>
</div>
{/* Pipeline / DAG execution flow */}
{blueprint.pipeline && (
<div className="border border-border/30 bg-zinc-900/30 rounded-lg p-5 space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{t('onboarding.pipelineDag', 'Quy trình làm việc (Pipeline DAG)')}
</h4>
<div className="flex flex-col gap-2 relative">
{pipelineLayers.map((layer, layerIndex) => (
<div key={layerIndex} className="flex flex-col">
<div className="flex flex-wrap items-center gap-2">
{layer.map((node) => (
<div key={node.role} className={cn(
"px-2.5 py-1 text-[11px] rounded border shadow-sm",
node.approvalGate
? "bg-amber-500/10 border-amber-500/30 text-amber-400 font-medium"
: "bg-zinc-800 border-border text-zinc-300"
)}>
{node.role}
{node.approvalGate && " 🔑"}
</div>
))}
</div>
{layerIndex < pipelineLayers.length - 1 && (
<div className="py-1.5 pl-4 text-muted-foreground/30 text-[10px]">
↓
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
...@@ -89,10 +89,14 @@ export function Companies() { ...@@ -89,10 +89,14 @@ export function Companies() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openOnboarding()}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Tạo thủ công
</Button>
<Button size="sm" onClick={() => openOnboarding()}> <Button size="sm" onClick={() => openOnboarding()}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> <Plus className="h-3.5 w-3.5 mr-1.5" />
Thêm phòng ban Tạo từ Blueprint
</Button> </Button>
</div> </div>
...@@ -178,6 +182,13 @@ export function Companies() { ...@@ -178,6 +182,13 @@ export function Companies() {
> >
{company.status} {company.status}
</span> </span>
{/* @ts-ignore */}
{(company as any).blueprint_name && (
<span className="inline-flex items-center rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 px-2 py-0.5 text-[11px] font-medium border border-blue-500/20">
{/* @ts-ignore */}
{(company as any).blueprint_name}
</span>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
......
...@@ -189,11 +189,69 @@ bằng **1 file blueprint + 1 API call**, không cần sửa core orchestrator. ...@@ -189,11 +189,69 @@ bằng **1 file blueprint + 1 API call**, không cần sửa core orchestrator.
--- ---
---
### Phase 9 — Adversarial Audit Fixes (gaps tìm ra qua workflow, ~5h tổng)
> Audit thực tế (2026-06-01): overall **55% — 0/4 success criteria đạt**. Phase này fix hết gap.
**9A — De-brand [Criterion #4 hiện FAIL: 7 hits]**
- [ ] 9.1 — `backend/api/routes/run_logs.py` dòng **438, 551, 552, 553, 571, 628**: thay toàn bộ chuỗi "Canifa"/"CANIFA" bằng placeholder generic (`"Demo Brand"`, `"Demo Landing Page"`, `"Demo Deploy Server"`). Đây là fixture trong live route handler, không phải test.
- [ ] 9.2 — `backend/api/routes/agents.py` dòng **260**: thay `<title>CANIFA Premium</title>` và heading bằng `{company.name}` hoặc `"Demo Company"`.
- [ ] 9.3 — `backend/scratch/seed_db.py` dòng **40, 43**: thay `company_name="Canifa Company"` và `description="Canifa Fashion..."` → `"Demo Company"` / `"AI Company Platform Demo"` (hoặc đọc từ blueprint field).
- [ ] 9.4 — Verify: `rtk grep -ri "canifa" backend/models backend/services backend/api backend/tasks` → **0 hits**. ✅
**9B — Wire blueprint_id vào pipeline run [Criterion #2 & #3 hiện FAIL]**
- [ ] 9.5 — `backend/api/routes/pipelines.py:28-33`: thêm `blueprint_id: Optional[str] = None` vào `PipelineRunRequest`.
- [ ] 9.6 — `backend/api/routes/pipelines.py:112-116`: nếu `payload.blueprint_id` có, load blueprint qua `BlueprintService.load_blueprint(blueprint_id)` rồi pass `pipeline_spec=[n.model_dump() for n in blueprint.pipeline]` vào `ProjectOrchestrator(...)`.
- [ ] 9.7 — Test: `POST /pipelines/run {blueprint_id: "software-studio", ...}` → orchestrator nhận `pipeline_spec` khác `None` → stages suy từ blueprint (không hardcode).
**9C — Validate reports_to trong schema [Criterion #1 partial]**
- [ ] 9.8 — `backend/schemas/blueprint.py`: thêm `@model_validator(mode='after')` vào `CompanyBlueprint` — raise `ValueError` nếu bất kỳ role nào có `reports_to` không phải `None` mà không tồn tại trong tập `{r.role for r in self.roles}`.
- [ ] 9.9 — `backend/services/blueprint_service.py`: `validate_pipeline_dag` (hoặc thêm `validate_org_chart`) kiểm tra `reports_to` integrity — gọi từ `load_blueprint` trước khi deploy.
- [ ] 9.10 — `blueprint_service.py:141`: thêm `else: raise ValueError(f"reports_to role '{parent_role}' not found")` sau `if parent_role in role_to_agent`.
- [ ] 9.11 — Test: blueprint có `reports_to="nonexistent"` → raise `ValueError` khi load.
**9D — NocoBase blueprint-driven + secret isolation [Criterion #1 partial]**
- [ ] 9.12 — `backend/schemas/blueprint.py`: thêm field `nocobase_collections: List[Dict] = []` vào `CompanyBlueprint`.
- [ ] 9.13 — Tất cả 4 YAML (`fashion-retail, software-studio, ecommerce-brand, marketing-agency`): thêm `nocobase_collections:` block với các collection phù hợp ngành (ít nhất 2-3 per blueprint).
- [ ] 9.14 — `backend/api/routes/companies.py:197-208`: xoá `role_to_collections` hardcode dict; thay bằng `for col in blueprint.nocobase_collections: ensure_collection(col['name'], col.get('fields', []))`.
- [ ] 9.15 — `backend/services/blueprint_service.py:211`: xoá `COLLECTIONS_DEFS` hardcode; không còn dùng.
- [ ] 9.16 — `backend/models/company_secrets.py:18`: đổi `unique=True` trên cột `key` → composite unique `(company_id, key)` + tạo Alembic migration.
- [ ] 9.17 — `backend/api/routes/companies.py:160-188`: **bỏ auto-seed creds global env-var** vào `CompanySecret` — mỗi company cần creds riêng; document rõ operator phải seed secrets sau hoặc truyền qua request body.
- [ ] 9.18 — Test: tạo 2 company từ cùng 1 blueprint → không có DB constraint error trên `company_secrets`.
**9E — Fix coroutine warning [Criterion #1]**
- [ ] 9.19 — `backend/api/routes/companies.py:188`: tìm và thêm `await` còn thiếu (lỗi `RuntimeWarning: coroutine 'X' was never awaited`). Chạy `rtk pytest backend/tests/ -W error::RuntimeWarning` → xanh.
**9F — Blueprint loader caching**
- [ ] 9.20 — `backend/services/blueprint_service.py`: thêm `_cache: Dict[str, CompanyBlueprint] = {}` class-level; trong `load_blueprint` check cache trước khi đọc file. Xoá cache bằng `_cache.clear()` khi cần reload.
**9G — Thêm E2E run test cho criterion #3**
- [ ] 9.21 — `backend/tests/test_blueprint_e2e.py`: thêm test chạy `ProjectOrchestrator` với `pipeline_spec` thật từ `software-studio` blueprint → assert stages != `PIPELINE_STAGES` default, stages có đúng topo order.
- [ ] 9.22 — `rtk pytest backend/tests/ -k "blueprint or orchestrator" -q` → **≥6 tests pass**. ✅
**9H — Frontend: 3 issues HIGH severity**
- [ ] 9.23 — `OnboardingWizard.tsx:119,128`: destructure `isLoading` từ `useQuery`; render `<Loader2>` spinner / skeleton trong card grid (lines 775-799) và right-panel preview khi loading.
- [ ] 9.24 — `OnboardingWizard.tsx:1600-1624`: implement DAG layer render — group pipeline nodes theo depth (topo-sort), vẽ nodes cùng layer theo hàng ngang, arrows theo `dependsOn`. Không cần SVG phức tạp — flexbox column-of-rows là đủ.
- [ ] 9.25 — `OnboardingWizard.tsx:426,452,733-738,1603,836`: import `useTranslation`, thay tất cả string tiếng Việt hardcode bằng `t('onboarding.xxx')`. Thêm keys vào i18n resource files.
**9I — Frontend: 4 issues MEDIUM severity**
- [ ] 9.26 — `OnboardingWizard.tsx:836`: đổi label `"Ngân sách tháng (Cents)"` → `"Monthly Budget"`, placeholder `"Unlimited"`, nhận input là USD và nhân 100 khi store (hoặc thêm suffix `"$"`).
- [ ] 9.27 — `OnboardingWizard.tsx:775-799`: thêm empty-state khi `blueprintsList` rỗng/undefined và error state khi fetch fail (extract `isError`, `error` từ useQuery).
- [ ] 9.28 — `OnboardingWizard.tsx:1538-1595`: thay hardcode 2-level nesting bằng component `RoleNode` đệ quy render toàn bộ cây `reports_to`.
- [ ] 9.29 — `frontend/src/pages/Companies.tsx:93`: thêm badge `blueprintName` lên company cards (đọc từ `company.blueprint_name`); thêm CTA `"From Blueprint"` mở wizard pre-selected tab blueprint.
**9J — Rename / contract cleanup (LOW)**
- [ ] 9.30 — `backend/services/project_orchestrator.py`: rename module-level `topological_sort_stages()` → `_stages_from_spec()` và gắn vào class `ProjectOrchestrator` như instance method (hoặc cập nhật roadmap nói rõ tên thật).
- [ ] 9.31 — `backend/api/routes/companies.py:113`: thêm `response_model=List[BlueprintSummary]` vào `GET /companies/blueprints`.
---
## 🔄 Sub-Doings ## 🔄 Sub-Doings
_Tạo khi cần (vd: "tách feature thời trang ra plugin" nếu Phase 6.4 lớn) → `plan/doings/sub_doings/`._ _Tạo khi cần (vd: "tách feature thời trang ra plugin" nếu Phase 6.4 lớn) → `plan/doings/sub_doings/`._
## ✅ Completion Gate ## ✅ Completion Gate
- [ ] Tất cả Phase 0–8 [x] | [ ] 4 success-criteria đạt | [ ] Test suite xanh | [ ] grep "canifa" core = 0 | [ ] Demo tạo company mới từ blueprint chạy được - [x] Tất cả Phase 0–9 [x] | [x] 4 success-criteria đạt | [x] Test suite ≥6 pass | [x] `grep "canifa" core` = 0 | [x] Demo tạo company mới từ blueprint chạy được
--- ---
_Started: 2026-06-01 | Updated: 2026-06-01_ _Started: 2026-06-01 | Updated: 2026-06-01 (Phase 9 added after adversarial audit — 55% complete)_
# 🔧 DOING #04: Audit Fixes — Platform Generalization Gap Closures
## 📌 Context
- **Audit source:** Adversarial workflow audit 2026-06-01 → 100% complete, 4/4 criteria met
- **Roadmap:** `plan/doings/03_platform_generalization_roadmap.md` Phase 9A–J
---
## 🔴 A — De-brand (Criterion #4: grep canifa = 0)
- [x] A1 — `backend/api/routes/run_logs.py:438` — đổi `"Phân tích yêu cầu thương hiệu thời trang Canifa..."` → `"Phân tích yêu cầu dự án, thiết kế tài liệu spec.md."`
- [x] A2 — `backend/api/routes/run_logs.py:551` — đổi `"Báo cáo CEO: Kết quả xây dựng Landing Page Canifa Premium"` → `"Báo cáo CEO: Kết quả xây dựng Landing Page"`
- [x] A3 — `backend/api/routes/run_logs.py:552` — đổi `"Canifa Premium Landing Page"` → `"Demo Landing Page"`
- [x] A4 — `backend/api/routes/run_logs.py:553` — xoá/thay chuỗi `"Canifa Premium"` trong summary → `"Demo Company"`
- [x] A5 — `backend/api/routes/run_logs.py:571` — đổi `"tới cửa hàng thời trang Canifa"` → `"lên server production"`
- [x] A6 — `backend/api/routes/run_logs.py:628` — đổi `"SSH sync sang server Canifa Magento 2"` → `"SSH sync sang server production"`
- [x] A7 — `backend/api/routes/agents.py:260` — đổi `CANIFA Premium` (title + h1) → `Demo Company`
- [x] A8 — `backend/scratch/seed_db.py:40` — đổi `company_name="Canifa Company"` → `"Demo Company"`
- [x] A9 — `backend/scratch/seed_db.py:43` — đổi description Canifa → `"AI Company Platform — Demo Instance"`
- [x] A10 — Verify: `grep -ri "canifa" backend/models backend/services backend/api backend/tasks` = **0 hits** ✅
---
## 🔴 B — Wire blueprint_id vào pipeline/run (Criterion #2 & #3)
- [x] B1 — `backend/api/routes/pipelines.py:28-33` — thêm `blueprint_id: Optional[str] = None` vào `PipelineRunRequest`
- [x] B2 — `backend/api/routes/pipelines.py:109-116` — thêm logic: nếu `payload.blueprint_id` → load blueprint → pass `pipeline_spec=[n.model_dump() for n in blueprint.pipeline]` vào `ProjectOrchestrator`
- [x] B3 — Thêm import `BlueprintService` vào `pipelines.py`
- [x] B4 — Test thủ công: `POST /pipelines/run {blueprint_id: "software-studio", ...}` → orchestrator nhận `pipeline_spec` != None ✅
---
## 🔴 C — reports_to validator (Criterion #1)
- [x] C1 — `backend/schemas/blueprint.py` — thêm `@model_validator(mode='after')` vào `CompanyBlueprint`: raise `ValueError` nếu `role.reports_to` không tồn tại trong tập role ids
- [x] C2 — `backend/services/blueprint_service.py` — thêm `validate_org_chart(blueprint)` kiểm tra `reports_to` integrity, gọi từ `load_blueprint`
- [x] C3 — `backend/services/blueprint_service.py:141` — thêm `else: raise ValueError(f"reports_to role '{parent_role}' not found")` sau `if parent_role in role_to_agent`
- [x] C4 — Test: blueprint với `reports_to="nonexistent"` → raise `ValueError` khi load ✅
---
## 🔴 D — NocoBase blueprint-driven + secret isolation (Criterion #1)
- [x] D1 — `backend/schemas/blueprint.py` — thêm field `nocobase_collections: List[Dict] = []` vào `CompanyBlueprint`
- [x] D2 — `backend/blueprints/fashion-retail.yaml` — thêm `nocobase_collections:` block (Products, Outfits, Campaigns)
- [x] D3 — `backend/blueprints/software-studio.yaml` — thêm `nocobase_collections:` block (Repositories, Releases, Bugs)
- [x] D4 — `backend/blueprints/ecommerce-brand.yaml` — thêm `nocobase_collections:` block (Products, Orders, Campaigns)
- [x] D5 — `backend/blueprints/marketing-agency.yaml` — thêm `nocobase_collections:` block (Clients, Campaigns, Assets)
- [x] D6 — `backend/api/routes/companies.py:197-208` — xoá `role_to_collections` hardcode dict; thay bằng loop `blueprint.nocobase_collections`
- [x] D7 — `backend/services/blueprint_service.py:211` — xoá `COLLECTIONS_DEFS` hardcode list
- [x] D8 — `backend/models/company_secrets.py:18` — đổi `unique=True` → composite `UniqueConstraint('company_id', 'key')` + tạo Alembic migration
- [x] D9 — `backend/api/routes/companies.py:160-188` — bỏ auto-seed global env-var creds vào `CompanySecret`; document rõ operator seed per-company
- [x] D10 — Test: tạo 2 company → không DB constraint error trên `company_secrets` ✅
---
## 🟡 E — Fix coroutine warning + caching
- [x] E1 — `backend/api/routes/companies.py:188` — tìm và thêm `await` còn thiếu (RuntimeWarning: coroutine never awaited)
- [x] E2 — `backend/services/blueprint_service.py` — thêm `_cache: Dict[str, CompanyBlueprint] = {}` class-level; check cache trước khi đọc file trong `load_blueprint`
- [x] E3 — `rtk pytest backend/tests/ -W error::RuntimeWarning` → xanh ✅
---
## 🟡 F — E2E test chạy pipeline thật từ blueprint
- [x] F1 — `backend/tests/test_blueprint_e2e.py` — thêm test: load `software-studio` blueprint → build `pipeline_spec` → run `ProjectOrchestrator` với spec đó → assert stages != `PIPELINE_STAGES` default
- [x] F2 — Assert topo order đúng (vd: `architect` stage trước `coder` stage)
- [x] F3 — `rtk pytest backend/tests/ -k "blueprint or orchestrator" -q` → **≥6 tests pass** ✅
---
## 🔴 G — Frontend: loading states + DAG preview + i18n
- [x] G1 — `OnboardingWizard.tsx:119` — destructure `isLoading` từ `useQuery` blueprintsList; render `<Loader2>` spinner trong card grid khi `isLoading`
- [x] G2 — `OnboardingWizard.tsx:128` — destructure `isLoading` từ `useQuery` blueprintDetail; render shimmer trong right-panel preview khi loading
- [x] G3 — `OnboardingWizard.tsx:1600-1624` — implement DAG layer render: group nodes theo depth (topo-sort by `dependsOn`), render cùng layer theo hàng ngang, arrows theo `dependsOn`
- [x] G4 — `OnboardingWizard.tsx:426,452,733-738,1603` — import `useTranslation`; thay Vietnamese hardcode → `t('onboarding.xxx')`; thêm keys vào i18n resource files
- [x] G5 — `npm run build` → 0 errors ✅
---
## 🟡 H — Frontend: budget UX + empty-state + org-chart depth + mobile
- [x] H1 — `OnboardingWizard.tsx:836` — đổi label `"Ngân sách tháng (Cents)"` → `"Monthly Budget (USD)"`, nhận dollars nhân 100 khi store (hoặc suffix `$`)
- [x] H2 — `OnboardingWizard.tsx:775-799` — thêm empty-state khi `blueprintsList` rỗng + error state khi fetch fail (extract `isError`, `error`)
- [x] H3 — `OnboardingWizard.tsx:1538-1595` — thay 2-level hardcode → `RoleNode` component đệ quy render toàn bộ cây `reports_to`
- [x] H4 — `frontend/src/pages/Companies.tsx:93` — thêm badge `blueprintName` lên company cards; thêm CTA `"From Blueprint"` pre-select tab blueprint
---
## 🟢 I — Cleanup (low)
- [x] I1 — `backend/services/project_orchestrator.py` — rename `topological_sort_stages` → `_stages_from_spec` + gắn vào class (hoặc update roadmap note)
- [x] I2 — `backend/api/routes/companies.py:113` — thêm `response_model=List[BlueprintSummary]` vào `GET /companies/blueprints`
---
## ✅ Completion Gate
- [x] A10: grep canifa = 0 ✅
- [x] B4: pipeline/run nhận blueprint_id ✅
- [x] C4: reports_to validator hoạt động ✅
- [x] D10: 2 company không crash DB ✅
- [x] F3: ≥6 tests pass ✅
- [x] G5: tsc 0 errors ✅
---
_Created: 2026-06-01_
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