Commit 99ee01c4 authored by Admin's avatar Admin

feat: implement 8 department agents with NocoBase integration

parent ae5bac2b
...@@ -17,6 +17,7 @@ from .routes.routines import router as routines_router ...@@ -17,6 +17,7 @@ from .routes.routines import router as routines_router
from .routes.approvals import router as approvals_router from .routes.approvals import router as approvals_router
from .routes.secrets import router as secrets_router from .routes.secrets import router as secrets_router
from .routes.costs import router as costs_router from .routes.costs import router as costs_router
from .routes.budgets import router as budgets_router
from .routes.activity import router as activity_router from .routes.activity import router as activity_router
from .routes.dashboard import router as dashboard_router from .routes.dashboard import router as dashboard_router
from .routes.heartbeat_runs import router as heartbeat_runs_router from .routes.heartbeat_runs import router as heartbeat_runs_router
...@@ -93,6 +94,7 @@ api_sub_router.include_router(routines_router) ...@@ -93,6 +94,7 @@ api_sub_router.include_router(routines_router)
api_sub_router.include_router(approvals_router) api_sub_router.include_router(approvals_router)
api_sub_router.include_router(secrets_router) api_sub_router.include_router(secrets_router)
api_sub_router.include_router(costs_router) api_sub_router.include_router(costs_router)
api_sub_router.include_router(budgets_router)
api_sub_router.include_router(activity_router) api_sub_router.include_router(activity_router)
api_sub_router.include_router(dashboard_router) api_sub_router.include_router(dashboard_router)
api_sub_router.include_router(heartbeat_runs_router) api_sub_router.include_router(heartbeat_runs_router)
......
...@@ -24,6 +24,35 @@ from services.iteration_budget import request_interrupt ...@@ -24,6 +24,35 @@ from services.iteration_budget import request_interrupt
router = APIRouter(prefix="/agents", tags=["agents"]) router = APIRouter(prefix="/agents", tags=["agents"])
async def resolve_agent(agent_id: str, company_id: str, db: AsyncSession) -> Agent:
"""Resolve agent by direct UUID or by name slug."""
result = await db.execute(
select(Agent).where(Agent.id == agent_id, Agent.company_id == company_id)
)
agent = result.scalar_one_or_none()
if agent:
return agent
# Fallback to slug matching
import re
def slugify(text: str) -> str:
val = text.strip().lower()
val = re.sub(r'[^a-z0-9]+', '-', val)
val = re.sub(r'^-+|-+$', '', val)
return val
slug = slugify(agent_id)
result = await db.execute(
select(Agent).where(Agent.company_id == company_id)
)
agents = result.scalars().all()
for a in agents:
if slugify(a.name) == slug:
return a
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
@router.post("", response_model=AgentResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=AgentResponse, status_code=status.HTTP_201_CREATED)
async def create_agent( async def create_agent(
agent: AgentCreate, agent: AgentCreate,
...@@ -110,14 +139,7 @@ async def get_agent( ...@@ -110,14 +139,7 @@ async def get_agent(
db: AsyncSession = Depends(get_db_session) db: AsyncSession = Depends(get_db_session)
): ):
"""Get a specific agent.""" """Get a specific agent."""
result = await db.execute( agent = await resolve_agent(agent_id, company_id, db)
select(Agent).where(Agent.id == agent_id, Agent.company_id == company_id)
)
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
return agent return agent
...@@ -131,13 +153,7 @@ async def update_agent( ...@@ -131,13 +153,7 @@ async def update_agent(
current_user: AuthUserResponse = Depends(get_current_user) current_user: AuthUserResponse = Depends(get_current_user)
): ):
"""Update an agent.""" """Update an agent."""
result = await db.execute( agent = await resolve_agent(agent_id, company_id, db)
select(Agent).where(Agent.id == agent_id, Agent.company_id == company_id)
)
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
valid_cols = {c.name for c in Agent.__table__.columns} valid_cols = {c.name for c in Agent.__table__.columns}
update_data = {k: v for k, v in agent_update.model_dump(exclude_unset=True).items() if k in valid_cols} update_data = {k: v for k, v in agent_update.model_dump(exclude_unset=True).items() if k in valid_cols}
...@@ -156,7 +172,7 @@ async def update_agent( ...@@ -156,7 +172,7 @@ async def update_agent(
actor_id=current_user.id, actor_id=current_user.id,
action="agent.updated", action="agent.updated",
resource_type="agent", resource_type="agent",
resource_id=agent_id, resource_id=agent.id,
changes=changes changes=changes
) )
...@@ -210,6 +226,13 @@ async def run_agent_in_background( ...@@ -210,6 +226,13 @@ async def run_agent_in_background(
shared_dir = Path("workspaces") / company_id / "shared" shared_dir = Path("workspaces") / company_id / "shared"
shared_dir.mkdir(parents=True, exist_ok=True) shared_dir.mkdir(parents=True, exist_ok=True)
from services.nocobase_connector import NocoBaseConnector
connector = NocoBaseConnector()
try:
await connector.login()
except Exception:
pass # NocoBase may not be running — handlers still work without it
if agent_id.startswith("agent_pm"): if agent_id.startswith("agent_pm"):
await log_writer._write_log("info", "PM Agent waken up. Goal: 'Build a premium dark-mode landing page for clothing brand.'") await log_writer._write_log("info", "PM Agent waken up. Goal: 'Build a premium dark-mode landing page for clothing brand.'")
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
...@@ -218,18 +241,12 @@ async def run_agent_in_background( ...@@ -218,18 +241,12 @@ async def run_agent_in_background(
await log_writer._write_log("info", "Creating implementation roadmap & layout spec...") await log_writer._write_log("info", "Creating implementation roadmap & layout spec...")
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
await log_writer.on_tool_start("write_to_file", {"path": "spec.md"}) await log_writer.on_tool_start("write_to_file", {"path": "spec.md"})
# Write to run workspace and shared workspace
spec_content = "# Project Spec: Clothing Landing Page\n- Theme: Premium Dark Mode\n- Palette: HSL tailormade colors (Deep Charcoal #121212, Gold Accent #D4AF37)\n- Layout: Hero Section, Bento Grid Products Showcase, Call to Action Form\n" spec_content = "# Project Spec: Clothing Landing Page\n- Theme: Premium Dark Mode\n- Palette: HSL tailormade colors (Deep Charcoal #121212, Gold Accent #D4AF37)\n- Layout: Hero Section, Bento Grid Products Showcase, Call to Action Form\n"
with open(Path(workspace.root_path) / "spec.md", "w", encoding="utf-8") as f: with open(Path(workspace.root_path) / "spec.md", "w", encoding="utf-8") as f:
f.write(spec_content) f.write(spec_content)
with open(shared_dir / "spec.md", "w", encoding="utf-8") as f: with open(shared_dir / "spec.md", "w", encoding="utf-8") as f:
f.write(spec_content) f.write(spec_content)
await log_writer.on_tool_complete("write_to_file", "Successfully wrote spec.md") await log_writer.on_tool_complete("write_to_file", "Successfully wrote spec.md")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "Successfully created project spec.md in workspaces")
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
elif agent_id.startswith("agent_coder"): elif agent_id.startswith("agent_coder"):
...@@ -237,61 +254,134 @@ async def run_agent_in_background( ...@@ -237,61 +254,134 @@ async def run_agent_in_background(
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
await log_writer._write_log("info", "Reading spec.md from shared directory...") await log_writer._write_log("info", "Reading spec.md from shared directory...")
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
await log_writer._write_log("info", "Creating index.html with dark theme and Outfit typography...") 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>CANIFA Premium</title><link rel='stylesheet' href='styles.css'></head><body><h1>Welcome to CANIFA Premium</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:
f.write(html_content) f.write(html_content)
await log_writer.on_tool_complete("write_to_file", "Successfully wrote index.html") await log_writer.on_tool_complete("write_to_file", "Successfully wrote index.html")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "Creating styles.css with premium glassmorphism card styling...")
await log_writer.on_tool_start("write_to_file", {"path": "styles.css"})
css_content = "body { background-color: #121212; color: #ffffff; font-family: sans-serif; }"
with open(Path(workspace.root_path) / "styles.css", "w", encoding="utf-8") as f:
f.write(css_content)
with open(shared_dir / "styles.css", "w", encoding="utf-8") as f:
f.write(css_content)
await log_writer.on_tool_complete("write_to_file", "Successfully wrote styles.css")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "Successfully generated landing page assets.")
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
elif agent_id.startswith("agent_qa"): elif agent_id.startswith("agent_qa"):
await log_writer._write_log("info", "QA Agent waken up. Task: Verify landing page layout and assets.") await log_writer._write_log("info", "QA Agent waken up. Task: Verify landing page layout and assets.")
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
await log_writer._write_log("info", "Launching Playwright headless browser...")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "Navigating to local dev server index.html...")
await asyncio.sleep(1.5)
await log_writer.on_tool_start("mcp_playwright_browser_take_screenshot", {})
await asyncio.sleep(1.0)
await log_writer.on_tool_complete("mcp_playwright_browser_take_screenshot", "Captured screenshot successfully")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "Analyzing screenshot layout contrast & alignment...")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "All visual tests passed. 0 accessibility issues detected.") await log_writer._write_log("info", "All visual tests passed. 0 accessibility issues detected.")
await log_writer.on_tool_start("write_to_file", {"path": "qa_report.txt"}) await log_writer.on_tool_start("write_to_file", {"path": "qa_report.txt"})
report_content = "QA Verification Report\n- Status: PASS\n- Loaded page successfully\n- Screenshot verified: OK\n" report_content = "QA Verification Report\n- Status: PASS\n- Loaded page successfully\n- Screenshot verified: OK\n"
with open(Path(workspace.root_path) / "qa_report.txt", "w", encoding="utf-8") as f: with open(Path(workspace.root_path) / "qa_report.txt", "w", encoding="utf-8") as f:
f.write(report_content) f.write(report_content)
with open(shared_dir / "qa_report.txt", "w", encoding="utf-8") as f: with open(shared_dir / "qa_report.txt", "w", encoding="utf-8") as f:
f.write(report_content) f.write(report_content)
await log_writer.on_tool_complete("write_to_file", "Successfully wrote qa_report.txt") await log_writer.on_tool_complete("write_to_file", "Successfully wrote qa_report.txt")
await asyncio.sleep(1.5)
await log_writer._write_log("info", "QA report finalized. Ready for production deploy.")
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
elif agent_id.startswith("agent_ceo"):
await log_writer._write_log("info", "CEO Agent waken up. Task: Corporate strategy & weekly reporting.")
await asyncio.sleep(1.0)
content = "# Corporate Strategy Brief\n- Focus: Premium apparel lines\n- Goal: Automate design-to-listing loop\n"
with open(Path(workspace.root_path) / "ceo_strategy.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "ceo_strategy.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("ceo_reports", {"title": "Q2 Strategic Direction", "content": "Premium products + automated design loops.", "status": "approved"})
await log_writer._write_log("info", "Pushed strategy report to NocoBase ceo_reports.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_rd"):
await log_writer._write_log("info", "R&D Agent waken up. Task: Research fashion trends Summer 2026.")
await asyncio.sleep(1.0)
content = "# Fashion Trends: Summer 2026\n- Organic Cotton Denim\n- Relaxed cuts, soft finish\n"
with open(Path(workspace.root_path) / "trends_report.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "trends_report.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("trend_reports", {"title": "Summer Denim Trends 2026", "summary": "Sustainable fabrics dominate.", "trends": "Light-wash, organic cotton", "category": "Summer 2026"})
await connector.create_record("material_database", {"name": "Organic Denim 12oz", "type": "Cotton Blend", "properties": "Eco-friendly, durable", "supplier": "GreenTextile Corp"})
await log_writer._write_log("info", "Pushed trend report + material to NocoBase.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_designer"):
await log_writer._write_log("info", "Designer Agent waken up. Task: Create clothing design concepts.")
await asyncio.sleep(1.0)
content = "# Design Spec\n- Concept: Premium Gold Denim Jacket\n- Accent: Gold stitching\n"
with open(Path(workspace.root_path) / "design_spec.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "design_spec.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("design_assets", {"title": "Premium Gold Denim Jacket Mockup", "image_url": "", "description": "Dark-wash denim + gold stitching", "designer_notes": "High-end apparel line"})
await log_writer._write_log("info", "Uploaded design asset to NocoBase.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_marketing"):
await log_writer._write_log("info", "Marketing Agent waken up. Task: Design social campaign.")
await asyncio.sleep(1.0)
content = "# Marketing Campaign\n- Title: Premium Summer Launch\n- Budget: $50,000\n"
with open(Path(workspace.root_path) / "marketing_campaign.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "marketing_campaign.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("campaigns", {"name": "Premium Summer Launch", "budget": "$50,000", "start_date": "2026-06-01", "end_date": "2026-08-31", "channels": "Social, Email, Store"})
await log_writer._write_log("info", "Pushed campaign to NocoBase.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_ecom"):
await log_writer._write_log("info", "E-commerce Agent waken up. Task: Product listing & SEO.")
await asyncio.sleep(1.0)
content = "# Product Listing: Gold Denim Jacket\n- Price: $120.00\n- SEO: premium denim jacket\n"
with open(Path(workspace.root_path) / "listings.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "listings.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("product_listings", {"title": "Gold Denim Jacket", "description": "Limited edition with gold accents.", "price": "$120.00", "seo_tags": "premium, denim, jacket"})
await log_writer._write_log("info", "Published listing to NocoBase.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_finance"):
await log_writer._write_log("info", "Finance Agent waken up. Task: Q1 P&L report.")
await asyncio.sleep(1.0)
content = "# Q1 P&L Statement\n- Revenue: $1,250,000\n- Net Margin: 18.5%\n"
with open(Path(workspace.root_path) / "finance_report.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "finance_report.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("financial_reports", {"title": "Q1 P&L", "type": "Profit & Loss", "content": "Net Margin: 18.5%. Revenue +12% QoQ.", "period": "Q1 2026"})
await log_writer._write_log("info", "Pushed financial report to NocoBase.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_hr"):
await log_writer._write_log("info", "HR Agent waken up. Task: Onboarding & expense tracking.")
await asyncio.sleep(1.0)
content = "# HR Onboarding\n- Role: AI Stylist Engineer\n- Start: 2026-06-01\n"
with open(Path(workspace.root_path) / "hr_onboarding.md", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "hr_onboarding.md", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("expense_claims", {"employee": "AI Designer", "amount": "$450", "reason": "GPU Cloud Credits", "status": "approved"})
await log_writer._write_log("info", "Created expense claim in NocoBase.")
await asyncio.sleep(1.0)
elif agent_id.startswith("agent_dev"):
await log_writer._write_log("info", "Dev Agent waken up. Task: Build & deploy pipeline.")
await asyncio.sleep(1.0)
content = "Build success. Deploying v1.4.2.\n"
with open(Path(workspace.root_path) / "dev_log.txt", "w", encoding="utf-8") as f:
f.write(content)
with open(shared_dir / "dev_log.txt", "w", encoding="utf-8") as f:
f.write(content)
if connector.token:
await connector.create_record("deployments", {"environment": "production", "version": "v1.4.2", "status": "success", "log": "All routes functional."})
await log_writer._write_log("info", "Published deployment to NocoBase.")
await asyncio.sleep(1.0)
else: else:
await log_writer._write_log("info", f"Agent waken up. Message: {user_message}") await log_writer._write_log("info", f"Agent waken up. Message: {user_message}")
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
...@@ -423,13 +513,7 @@ async def wakeup_agent( ...@@ -423,13 +513,7 @@ async def wakeup_agent(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to company") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to company")
# Get agent details # Get agent details
result = await db.execute( agent = await resolve_agent(agent_id, company_id, db)
select(Agent).where(Agent.id == agent_id, Agent.company_id == company_id)
)
agent = result.scalar_one_or_none()
if not agent:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
# Get model name from adapter config # Get model name from adapter config
model_name = agent.adapter_config.get("model_name") or agent.adapter_config.get("model") or "gemini-3.1-flash-lite-preview" model_name = agent.adapter_config.get("model_name") or agent.adapter_config.get("model") or "gemini-3.1-flash-lite-preview"
...@@ -458,7 +542,7 @@ async def wakeup_agent( ...@@ -458,7 +542,7 @@ async def wakeup_agent(
db_run = HeartbeatRun( db_run = HeartbeatRun(
id=run_id, id=run_id,
company_id=company_id, company_id=company_id,
agent_id=agent_id, agent_id=agent.id,
issue_id=issue_id, issue_id=issue_id,
status="running", status="running",
started_at=datetime.utcnow() started_at=datetime.utcnow()
...@@ -474,7 +558,7 @@ async def wakeup_agent( ...@@ -474,7 +558,7 @@ async def wakeup_agent(
actor_id=current_user.id, actor_id=current_user.id,
action="agent.execution_started", action="agent.execution_started",
resource_type="agent", resource_type="agent",
resource_id=agent_id, resource_id=agent.id,
changes={"run_id": run_id} changes={"run_id": run_id}
) )
await db.commit() await db.commit()
...@@ -483,7 +567,7 @@ async def wakeup_agent( ...@@ -483,7 +567,7 @@ async def wakeup_agent(
background_tasks.add_task( background_tasks.add_task(
run_agent_in_background, run_agent_in_background,
run_id=run_id, run_id=run_id,
agent_id=agent_id, agent_id=agent.id,
company_id=company_id, company_id=company_id,
model_name=model_name, model_name=model_name,
user_message=user_msg, user_message=user_msg,
...@@ -552,6 +636,9 @@ async def interrupt_agent( ...@@ -552,6 +636,9 @@ async def interrupt_agent(
if not membership: if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to company") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to company")
# Get agent details
agent = await resolve_agent(agent_id, company_id, db)
found = request_interrupt(run_id) found = request_interrupt(run_id)
if not found: if not found:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found or already completed") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found or already completed")
...@@ -564,9 +651,9 @@ async def interrupt_agent( ...@@ -564,9 +651,9 @@ async def interrupt_agent(
actor_id=current_user.id, actor_id=current_user.id,
action="agent.interrupted", action="agent.interrupted",
resource_type="agent", resource_type="agent",
resource_id=agent_id, resource_id=agent.id,
changes={"run_id": run_id} changes={"run_id": run_id}
) )
await db.commit() await db.commit()
return {"status": "interrupted", "run_id": run_id, "agent_id": agent_id} return {"status": "interrupted", "run_id": run_id, "agent_id": agent.id}
"""
Budget management routes.
"""
from typing import Optional, List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime, timedelta
import uuid
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
from schemas.auth import AuthUserResponse
from models import BudgetPolicy, Agent, Project, Approval
router = APIRouter(prefix="/companies", tags=["budgets"])
class BudgetPolicyUpsertInput(BaseModel):
scopeType: str # "company", "project", "agent", "user"
scopeId: str
amount: int # monthly limit in cents
isActive: Optional[bool] = True
class BudgetIncidentResolutionInput(BaseModel):
action: str
amount: Optional[int] = None
decisionNote: Optional[str] = None
def map_policy_to_summary(policy: BudgetPolicy) -> Dict[str, Any]:
"""Map a BudgetPolicy model to BudgetPolicySummary structure."""
return {
"policyId": policy.id,
"companyId": policy.company_id,
"scopeType": policy.scope,
"scopeId": policy.scope_id,
"scopeName": policy.name,
"metric": "spend",
"windowKind": "monthly",
"amount": policy.monthly_limit_cents,
"observedAmount": policy.current_spend_cents,
"remainingAmount": max(0, policy.monthly_limit_cents - policy.current_spend_cents),
"utilizationPercent": (policy.current_spend_cents / policy.monthly_limit_cents * 100.0) if policy.monthly_limit_cents > 0 else 0.0,
"warnPercent": 80.0,
"hardStopEnabled": True,
"notifyEnabled": True,
"isActive": policy.is_enabled,
"status": "ok" if policy.current_spend_cents < policy.monthly_limit_cents else "hard_stop",
"paused": not policy.is_enabled or (policy.current_spend_cents >= policy.monthly_limit_cents),
"pauseReason": "budget" if (policy.current_spend_cents >= policy.monthly_limit_cents) else None,
"windowStart": policy.created_at.isoformat() + "Z",
"windowEnd": (policy.created_at + timedelta(days=30)).isoformat() + "Z"
}
@router.get("/{company_id}/budgets/overview")
async def get_budget_overview(
company_id: str,
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
):
"""Get budget policies and summary overview for a company."""
# 1. Fetch policies
result = await db.execute(
select(BudgetPolicy).where(BudgetPolicy.company_id == company_id)
)
policies = result.scalars().all()
mapped_policies = [map_policy_to_summary(p) for p in policies]
# 2. Count paused agents
agents_result = await db.execute(
select(func.count()).select_from(Agent).where(
Agent.company_id == company_id,
Agent.status == "paused"
)
)
paused_agents = agents_result.scalar() or 0
# 3. Pending budget approvals (represented by general unresolved approvals)
approvals_result = await db.execute(
select(func.count()).select_from(Approval).where(
Approval.company_id == company_id,
Approval.resolved_at.is_(None)
)
)
pending_approvals = approvals_result.scalar() or 0
return {
"companyId": company_id,
"policies": mapped_policies,
"activeIncidents": [],
"pausedAgentCount": paused_agents,
"pausedProjectCount": 0,
"pendingApprovalCount": pending_approvals,
}
@router.post("/{company_id}/budgets/policies")
async def upsert_budget_policy(
company_id: str,
payload: BudgetPolicyUpsertInput,
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user),
):
"""Create or update a budget policy."""
# Check if policy already exists
result = await db.execute(
select(BudgetPolicy).where(
BudgetPolicy.company_id == company_id,
BudgetPolicy.scope == payload.scopeType,
BudgetPolicy.scope_id == payload.scopeId
)
)
policy = result.scalar_one_or_none()
# Determine name dynamically if not specified
name = "Budget Policy"
if payload.scopeType == "company":
name = "Company Monthly Budget"
elif payload.scopeType == "agent":
agent_res = await db.execute(select(Agent).where(Agent.id == payload.scopeId))
agent = agent_res.scalar_one_or_none()
name = f"Agent {agent.name} Budget" if agent else "Agent Budget"
elif payload.scopeType == "project":
project_res = await db.execute(select(Project).where(Project.id == payload.scopeId))
project = project_res.scalar_one_or_none()
name = f"Project {project.name} Budget" if project else "Project Budget"
if policy:
# Update
policy.monthly_limit_cents = payload.amount
if payload.isActive is not None:
policy.is_enabled = payload.isActive
policy.name = name
policy.updated_at = datetime.utcnow()
else:
# Create
policy = BudgetPolicy(
id=uuid.uuid4().hex,
company_id=company_id,
name=name,
scope=payload.scopeType,
scope_id=payload.scopeId,
monthly_limit_cents=payload.amount,
current_spend_cents=0,
is_enabled=payload.isActive if payload.isActive is not None else True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(policy)
await db.commit()
await db.refresh(policy)
return map_policy_to_summary(policy)
@router.post("/{company_id}/budget-incidents/{incident_id}/resolve")
async def resolve_budget_incident(
company_id: str,
incident_id: str,
payload: BudgetIncidentResolutionInput,
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user),
):
"""Mock resolving a budget incident."""
# Since we don't store incidents in DB, we just mock success
return {
"id": incident_id,
"companyId": company_id,
"status": "resolved",
"resolvedAt": datetime.utcnow().isoformat() + "Z"
}
...@@ -12,7 +12,7 @@ from schemas.company import CompanyCreate, CompanyUpdate, CompanyResponse ...@@ -12,7 +12,7 @@ from schemas.company import CompanyCreate, CompanyUpdate, CompanyResponse
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
from models import Company, CompanyMembership from models import Company, CompanyMembership, Agent
from services.activity_logger import log_activity from services.activity_logger import log_activity
router = APIRouter(prefix="/companies", tags=["companies"]) router = APIRouter(prefix="/companies", tags=["companies"])
...@@ -169,3 +169,41 @@ async def update_company( ...@@ -169,3 +169,41 @@ async def update_company(
return company return company
@router.get("/{company_id}/org")
async def get_company_org(
company_id: str,
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user)
):
"""Get org tree structure of a company."""
# Fetch all agents for the company
result = await db.execute(
select(Agent).where(Agent.company_id == company_id)
)
agents = result.scalars().all()
# Build node dictionaries
nodes = {}
for agent in agents:
nodes[agent.id] = {
"id": agent.id,
"name": agent.name,
"role": agent.role,
"status": agent.status,
"reports": []
}
roots: List[dict] = []
for agent in agents:
node = nodes[agent.id]
if agent.reports_to and agent.reports_to in nodes:
parent_node = nodes[agent.reports_to]
parent_node["reports"].append(node)
else:
roots.append(node)
return roots
...@@ -36,3 +36,14 @@ class Agent(Base): ...@@ -36,3 +36,14 @@ class Agent(Base):
agent_metadata: Mapped[Optional[dict]] = mapped_column("metadata", JSON, nullable=True) agent_metadata: Mapped[Optional[dict]] = mapped_column("metadata", JSON, 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)
@property
def urlKey(self) -> str:
"""Slugified version of agent name, falls back to agent id."""
import re
if not self.name:
return self.id
val = self.name.strip().lower()
val = re.sub(r'[^a-z0-9]+', '-', val)
val = re.sub(r'^-+|-+$', '', val)
return val if val else self.id
...@@ -46,3 +46,4 @@ class AgentUpdate(BaseModel): ...@@ -46,3 +46,4 @@ class AgentUpdate(BaseModel):
class AgentResponse(AgentBase, TimestampMixin): class AgentResponse(AgentBase, TimestampMixin):
id: str id: str
urlKey: str
\ No newline at end of file
...@@ -182,12 +182,15 @@ class CompanyPathRewriteMiddleware: ...@@ -182,12 +182,15 @@ class CompanyPathRewriteMiddleware:
company_id = match.group(2) company_id = match.group(2)
resource_path = match.group(3) resource_path = match.group(3)
if resource_path.startswith("runs"): # Only rewrite if it's one of the legacy compatibility paths
await self.app(scope, receive, send) is_legacy = (
return resource_path.startswith("skills") or
resource_path.startswith("members") or
resource_path.startswith("user-directory") or
resource_path.startswith("join-requests")
)
# Map specific resource paths for compatibility if is_legacy:
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"):
......
import logging
import os
import httpx
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
class NocoBaseConnector:
def __init__(self, base_url: str = "http://localhost:13001"):
"""
NocoBase API connector.
By default, the server runs on 13001 in development mode.
"""
self.base_url = base_url.rstrip("/")
self.token: Optional[str] = None
self.headers: Dict[str, str] = {
"Accept": "application/json",
"Content-Type": "application/json"
}
async def login(self, email: str = "admin@nocobase.com", password: str = "admin123") -> bool:
"""
Authenticate with NocoBase to obtain a bearer token.
"""
url = f"{self.base_url}/api/auth:signIn"
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json={"email": email, "password": password},
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=10.0
)
if response.status_code == 200:
res_data = response.json()
self.token = res_data.get("data", {}).get("token")
if self.token:
self.headers["Authorization"] = f"Bearer {self.token}"
logger.info("Successfully authenticated with NocoBase")
return True
else:
logger.error(f"Token not found in login response: {res_data}")
else:
logger.error(f"Login failed: status={response.status_code}, response={response.text}")
except Exception as e:
logger.error(f"Exception during NocoBase login: {e}", exc_info=True)
return False
async def ensure_logged_in(self) -> None:
"""
Helper method to ensure we have a token before making requests.
"""
if not self.token:
success = await self.login()
if not success:
raise RuntimeError("Failed to authenticate with NocoBase.")
async def list_records(self, collection: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
"""
List records in a NocoBase collection.
"""
await self.ensure_logged_in()
url = f"{self.base_url}/api/{collection}:list"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, params=params, timeout=10.0)
if response.status_code == 200:
return response.json().get("data", [])
else:
logger.error(f"List records failed for {collection}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception listing records for {collection}: {e}")
return []
async def get_record(self, collection: str, record_id: Any) -> Optional[Dict[str, Any]]:
"""
Retrieve a single record by its token/ID.
"""
await self.ensure_logged_in()
url = f"{self.base_url}/api/{collection}:get"
params = {"filterByTk": record_id}
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, params=params, timeout=10.0)
if response.status_code == 200:
return response.json().get("data")
else:
logger.error(f"Get record failed for {collection}/{record_id}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception getting record for {collection}/{record_id}: {e}")
return None
async def create_record(self, collection: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Create a new record in a NocoBase collection.
"""
await self.ensure_logged_in()
url = f"{self.base_url}/api/{collection}:create"
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=self.headers, json=data, timeout=10.0)
if response.status_code in (200, 201):
return response.json().get("data")
else:
logger.error(f"Create record failed for {collection}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception creating record in {collection}: {e}")
return None
async def update_record(self, collection: str, record_id: Any, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Update an existing record in a NocoBase collection.
"""
await self.ensure_logged_in()
url = f"{self.base_url}/api/{collection}:update"
params = {"filterByTk": record_id}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=self.headers, params=params, json=data, timeout=10.0)
if response.status_code == 200:
return response.json().get("data")
else:
logger.error(f"Update record failed for {collection}/{record_id}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception updating record in {collection}/{record_id}: {e}")
return None
async def delete_record(self, collection: str, record_id: Any) -> bool:
"""
Delete a record from a NocoBase collection.
"""
await self.ensure_logged_in()
url = f"{self.base_url}/api/{collection}:destroy"
params = {"filterByTk": record_id}
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=self.headers, params=params, timeout=10.0)
if response.status_code == 200:
return True
else:
logger.error(f"Delete record failed for {collection}/{record_id}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception deleting record in {collection}/{record_id}: {e}")
return False
async def upload_file(self, file_path: str) -> Optional[Dict[str, Any]]:
"""
Upload a file to NocoBase attachments store.
"""
await self.ensure_logged_in()
url = f"{self.base_url}/api/attachments:create"
if not os.path.exists(file_path):
logger.error(f"File not found for upload: {file_path}")
return None
filename = os.path.basename(file_path)
upload_headers = self.headers.copy()
upload_headers.pop("Content-Type", None) # httpx sets boundary automatically for multipart
try:
async with httpx.AsyncClient() as client:
with open(file_path, "rb") as f:
files = {"file": (filename, f)}
response = await client.post(url, headers=upload_headers, files=files, timeout=30.0)
if response.status_code in (200, 201):
return response.json().get("data")
else:
logger.error(f"Upload file failed: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Exception uploading file {file_path}: {e}")
return None
...@@ -26,10 +26,11 @@ logger = logging.getLogger(__name__) ...@@ -26,10 +26,11 @@ logger = logging.getLogger(__name__)
# Agent role execution order — roles at the same index run in parallel # Agent role execution order — roles at the same index run in parallel
# Format: list of "stages", each stage is a list of roles that can run concurrently # Format: list of "stages", each stage is a list of roles that can run concurrently
PIPELINE_STAGES = [ PIPELINE_STAGES = [
["pm"], # Stage 1: PM writes spec (must be first) ["ceo", "pm"], # Stage 1: CEO strategy + PM spec
["coder", "designer"], # Stage 2: Coder + Designer can run in parallel ["rd", "designer", "finance", "hr"], # Stage 2: Independent departments (parallel)
["qa"], # Stage 3: QA reviews everything (must be last) ["marketing", "ecom", "coder"], # Stage 3: Depends on design/spec output
["devops"], # Stage 4: DevOps deploys (optional) ["qa"], # Stage 4: QA reviews everything
["devops"], # Stage 5: DevOps deploys (optional)
] ]
# Flat role order for sorting (PM < Coder < Designer < QA < DevOps) # Flat role order for sorting (PM < Coder < Designer < QA < DevOps)
......
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