Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
A
ai company
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
ai company
Commits
99ee01c4
Commit
99ee01c4
authored
May 29, 2026
by
Admin
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement 8 department agents with NocoBase integration
parent
ae5bac2b
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
597 additions
and
102 deletions
+597
-102
main_router.py
backend/api/main_router.py
+2
-0
agents.py
backend/api/routes/agents.py
+158
-71
budgets.py
backend/api/routes/budgets.py
+181
-0
companies.py
backend/api/routes/companies.py
+39
-1
agents.py
backend/models/agents.py
+11
-0
agent.py
backend/schemas/agent.py
+2
-1
server.py
backend/server.py
+28
-25
nocobase_connector.py
backend/services/nocobase_connector.py
+171
-0
project_orchestrator.py
backend/services/project_orchestrator.py
+5
-4
No files found.
backend/api/main_router.py
View file @
99ee01c4
...
...
@@ -17,6 +17,7 @@ from .routes.routines import router as routines_router
from
.routes.approvals
import
router
as
approvals_router
from
.routes.secrets
import
router
as
secrets_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.dashboard
import
router
as
dashboard_router
from
.routes.heartbeat_runs
import
router
as
heartbeat_runs_router
...
...
@@ -93,6 +94,7 @@ api_sub_router.include_router(routines_router)
api_sub_router
.
include_router
(
approvals_router
)
api_sub_router
.
include_router
(
secrets_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
(
dashboard_router
)
api_sub_router
.
include_router
(
heartbeat_runs_router
)
...
...
backend/api/routes/agents.py
View file @
99ee01c4
...
...
@@ -24,6 +24,35 @@ from services.iteration_budget import request_interrupt
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
)
async
def
create_agent
(
agent
:
AgentCreate
,
...
...
@@ -110,14 +139,7 @@ async def get_agent(
db
:
AsyncSession
=
Depends
(
get_db_session
)
):
"""Get a specific agent."""
result
=
await
db
.
execute
(
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"
)
agent
=
await
resolve_agent
(
agent_id
,
company_id
,
db
)
return
agent
...
...
@@ -131,13 +153,7 @@ async def update_agent(
current_user
:
AuthUserResponse
=
Depends
(
get_current_user
)
):
"""Update an agent."""
result
=
await
db
.
execute
(
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"
)
agent
=
await
resolve_agent
(
agent_id
,
company_id
,
db
)
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
}
...
...
@@ -156,7 +172,7 @@ async def update_agent(
actor_id
=
current_user
.
id
,
action
=
"agent.updated"
,
resource_type
=
"agent"
,
resource_id
=
agent
_
id
,
resource_id
=
agent
.
id
,
changes
=
changes
)
...
...
@@ -210,6 +226,13 @@ async def run_agent_in_background(
shared_dir
=
Path
(
"workspaces"
)
/
company_id
/
"shared"
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"
):
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
)
...
...
@@ -218,80 +241,147 @@ async def run_agent_in_background(
await
log_writer
.
_write_log
(
"info"
,
"Creating implementation roadmap & layout spec..."
)
await
asyncio
.
sleep
(
1.5
)
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
"
with
open
(
Path
(
workspace
.
root_path
)
/
"spec.md"
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
spec_content
)
with
open
(
shared_dir
/
"spec.md"
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
spec_content
)
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
)
elif
agent_id
.
startswith
(
"agent_coder"
):
await
log_writer
.
_write_log
(
"info"
,
"Coder Agent waken up. Goal: Implement the spec.md layout."
)
await
asyncio
.
sleep
(
1.5
)
await
log_writer
.
_write_log
(
"info"
,
"Reading spec.md from shared directory..."
)
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"
})
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>"
html_content
=
"<!DOCTYPE html><html><head><title>CANIFA Premium</title></head><body><h1>CANIFA Premium</h1></body></html>"
with
open
(
Path
(
workspace
.
root_path
)
/
"index.html"
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
html_content
)
with
open
(
shared_dir
/
"index.html"
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
html_content
)
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
)
elif
agent_id
.
startswith
(
"agent_qa"
):
await
log_writer
.
_write_log
(
"info"
,
"QA Agent waken up. Task: Verify landing page layout and assets."
)
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
.
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
"
with
open
(
Path
(
workspace
.
root_path
)
/
"qa_report.txt"
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
report_content
)
with
open
(
shared_dir
/
"qa_report.txt"
,
"w"
,
encoding
=
"utf-8"
)
as
f
:
f
.
write
(
report_content
)
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
)
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
:
await
log_writer
.
_write_log
(
"info"
,
f
"Agent waken up. Message: {user_message}"
)
await
asyncio
.
sleep
(
1.5
)
...
...
@@ -423,13 +513,7 @@ async def wakeup_agent(
raise
HTTPException
(
status_code
=
status
.
HTTP_403_FORBIDDEN
,
detail
=
"No access to company"
)
# Get agent details
result
=
await
db
.
execute
(
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"
)
agent
=
await
resolve_agent
(
agent_id
,
company_id
,
db
)
# 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"
...
...
@@ -458,7 +542,7 @@ async def wakeup_agent(
db_run
=
HeartbeatRun
(
id
=
run_id
,
company_id
=
company_id
,
agent_id
=
agent
_
id
,
agent_id
=
agent
.
id
,
issue_id
=
issue_id
,
status
=
"running"
,
started_at
=
datetime
.
utcnow
()
...
...
@@ -474,7 +558,7 @@ async def wakeup_agent(
actor_id
=
current_user
.
id
,
action
=
"agent.execution_started"
,
resource_type
=
"agent"
,
resource_id
=
agent
_
id
,
resource_id
=
agent
.
id
,
changes
=
{
"run_id"
:
run_id
}
)
await
db
.
commit
()
...
...
@@ -483,7 +567,7 @@ async def wakeup_agent(
background_tasks
.
add_task
(
run_agent_in_background
,
run_id
=
run_id
,
agent_id
=
agent
_
id
,
agent_id
=
agent
.
id
,
company_id
=
company_id
,
model_name
=
model_name
,
user_message
=
user_msg
,
...
...
@@ -552,6 +636,9 @@ async def interrupt_agent(
if
not
membership
:
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
)
if
not
found
:
raise
HTTPException
(
status_code
=
status
.
HTTP_404_NOT_FOUND
,
detail
=
"Run not found or already completed"
)
...
...
@@ -564,9 +651,9 @@ async def interrupt_agent(
actor_id
=
current_user
.
id
,
action
=
"agent.interrupted"
,
resource_type
=
"agent"
,
resource_id
=
agent
_
id
,
resource_id
=
agent
.
id
,
changes
=
{
"run_id"
:
run_id
}
)
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
}
backend/api/routes/budgets.py
0 → 100644
View file @
99ee01c4
"""
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"
}
backend/api/routes/companies.py
View file @
99ee01c4
...
...
@@ -12,7 +12,7 @@ from schemas.company import CompanyCreate, CompanyUpdate, CompanyResponse
from
schemas.common
import
PaginationParams
,
PaginatedResponse
from
schemas.auth
import
AuthUserResponse
from
schemas.common
import
CompanyScope
from
models
import
Company
,
CompanyMembership
from
models
import
Company
,
CompanyMembership
,
Agent
from
services.activity_logger
import
log_activity
router
=
APIRouter
(
prefix
=
"/companies"
,
tags
=
[
"companies"
])
...
...
@@ -169,3 +169,41 @@ async def update_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
backend/models/agents.py
View file @
99ee01c4
...
...
@@ -36,3 +36,14 @@ class Agent(Base):
agent_metadata
:
Mapped
[
Optional
[
dict
]]
=
mapped_column
(
"metadata"
,
JSON
,
nullable
=
True
)
created_at
:
Mapped
[
datetime
]
=
mapped_column
(
DateTime
,
nullable
=
False
,
default
=
datetime
.
utcnow
)
updated_at
:
Mapped
[
datetime
]
=
mapped_column
(
DateTime
,
nullable
=
False
,
default
=
datetime
.
utcnow
)
@
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
backend/schemas/agent.py
View file @
99ee01c4
...
...
@@ -45,4 +45,5 @@ class AgentUpdate(BaseModel):
class
AgentResponse
(
AgentBase
,
TimestampMixin
):
id
:
str
\ No newline at end of file
id
:
str
urlKey
:
str
\ No newline at end of file
backend/server.py
View file @
99ee01c4
...
...
@@ -182,32 +182,35 @@ class CompanyPathRewriteMiddleware:
company_id
=
match
.
group
(
2
)
resource_path
=
match
.
group
(
3
)
if
resource_path
.
startswith
(
"runs"
):
await
self
.
app
(
scope
,
receive
,
send
)
return
# Map specific resource paths for compatibility
if
resource_path
.
startswith
(
"skills"
):
resource_path
=
re
.
sub
(
r"^skills(\b|/)"
,
"company-skills
\\
1"
,
resource_path
)
elif
resource_path
.
startswith
(
"members"
):
resource_path
=
re
.
sub
(
r"^members(\b|/)"
,
"company-memberships/members
\\
1"
,
resource_path
)
elif
resource_path
.
startswith
(
"user-directory"
):
resource_path
=
re
.
sub
(
r"^user-directory(\b|/)"
,
"company-memberships/user-directory
\\
1"
,
resource_path
)
elif
resource_path
.
startswith
(
"join-requests"
):
resource_path
=
re
.
sub
(
r"^join-requests(\b|/)"
,
"company-memberships/join-requests
\\
1"
,
resource_path
)
# Rewrite path
scope
[
"path"
]
=
f
"{api_prefix}/{resource_path}"
# Only rewrite if it's one of the legacy compatibility paths
is_legacy
=
(
resource_path
.
startswith
(
"skills"
)
or
resource_path
.
startswith
(
"members"
)
or
resource_path
.
startswith
(
"user-directory"
)
or
resource_path
.
startswith
(
"join-requests"
)
)
# Rewrite query string
query_string
=
scope
.
get
(
"query_string"
,
b
""
)
.
decode
(
"utf-8"
)
addition
=
f
"company_id={company_id}"
if
query_string
:
query_string
=
f
"{query_string}&{addition}"
else
:
query_string
=
addition
scope
[
"query_string"
]
=
query_string
.
encode
(
"utf-8"
)
if
is_legacy
:
if
resource_path
.
startswith
(
"skills"
):
resource_path
=
re
.
sub
(
r"^skills(\b|/)"
,
"company-skills
\\
1"
,
resource_path
)
elif
resource_path
.
startswith
(
"members"
):
resource_path
=
re
.
sub
(
r"^members(\b|/)"
,
"company-memberships/members
\\
1"
,
resource_path
)
elif
resource_path
.
startswith
(
"user-directory"
):
resource_path
=
re
.
sub
(
r"^user-directory(\b|/)"
,
"company-memberships/user-directory
\\
1"
,
resource_path
)
elif
resource_path
.
startswith
(
"join-requests"
):
resource_path
=
re
.
sub
(
r"^join-requests(\b|/)"
,
"company-memberships/join-requests
\\
1"
,
resource_path
)
# Rewrite path
scope
[
"path"
]
=
f
"{api_prefix}/{resource_path}"
# Rewrite query string
query_string
=
scope
.
get
(
"query_string"
,
b
""
)
.
decode
(
"utf-8"
)
addition
=
f
"company_id={company_id}"
if
query_string
:
query_string
=
f
"{query_string}&{addition}"
else
:
query_string
=
addition
scope
[
"query_string"
]
=
query_string
.
encode
(
"utf-8"
)
await
self
.
app
(
scope
,
receive
,
send
)
...
...
backend/services/nocobase_connector.py
0 → 100644
View file @
99ee01c4
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
backend/services/project_orchestrator.py
View file @
99ee01c4
...
...
@@ -26,10 +26,11 @@ logger = logging.getLogger(__name__)
# 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
PIPELINE_STAGES
=
[
[
"pm"
],
# Stage 1: PM writes spec (must be first)
[
"coder"
,
"designer"
],
# Stage 2: Coder + Designer can run in parallel
[
"qa"
],
# Stage 3: QA reviews everything (must be last)
[
"devops"
],
# Stage 4: DevOps deploys (optional)
[
"ceo"
,
"pm"
],
# Stage 1: CEO strategy + PM spec
[
"rd"
,
"designer"
,
"finance"
,
"hr"
],
# Stage 2: Independent departments (parallel)
[
"marketing"
,
"ecom"
,
"coder"
],
# Stage 3: Depends on design/spec output
[
"qa"
],
# Stage 4: QA reviews everything
[
"devops"
],
# Stage 5: DevOps deploys (optional)
]
# Flat role order for sorting (PM < Coder < Designer < QA < DevOps)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment