Linting: Check

This commit is contained in:
Renn F
2025-12-12 02:45:47 +01:00
parent b8c19e85bd
commit d570334e04
50 changed files with 930 additions and 711 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ from roboco.config import settings
from roboco.db.base import Base
# Import all models to ensure they're registered with Base.metadata
from roboco.db import tables # noqa: F401
from roboco.db import tables
# Alembic Config object
config = context.config
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"pydantic-settings",
# API
"aiofiles",
"fastapi",
"uvicorn[standard]",
"websockets",
+4 -8
View File
@@ -11,20 +11,18 @@ import contextlib
from abc import ABC, abstractmethod
from datetime import UTC, datetime
from enum import Enum
from typing import TYPE_CHECKING, Any
from typing import Any
from uuid import UUID, uuid4
import httpx
import structlog
from anthropic import AsyncAnthropic
from pydantic import BaseModel, Field
from roboco.api.websocket import broadcast_agent_chunk
from roboco.config import settings
from roboco.models import AgentRole, AgentStatus, Team
if TYPE_CHECKING:
from anthropic import AsyncAnthropic
logger = structlog.get_logger()
@@ -156,8 +154,6 @@ class Agent(ABC):
def llm_client(self) -> "AsyncAnthropic":
"""Get or create the LLM client."""
if self._llm_client is None:
from anthropic import AsyncAnthropic
self._llm_client = AsyncAnthropic(api_key=settings.anthropic_api_key)
return self._llm_client
@@ -374,7 +370,7 @@ class Agent(ABC):
async def think(
self,
prompt: str,
context: dict[str, Any] | None = None, # noqa: ARG002
_context: dict[str, Any] | None = None,
) -> str:
"""
Send a prompt to the LLM and get a response.
@@ -402,7 +398,7 @@ class Agent(ABC):
async def think_and_stream(
self,
prompt: str,
context: dict[str, Any] | None = None, # noqa: ARG002
_context: dict[str, Any] | None = None,
) -> str:
"""
Send a prompt and stream the response.
+7 -15
View File
@@ -4,12 +4,13 @@ Board Agents (Product Owner, Head of Marketing, Auditor)
Implementation of Board-level workflows from the blueprint.
"""
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import Any
from uuid import UUID
from uuid import UUID, uuid4
import structlog
@@ -71,7 +72,7 @@ class ProductOwnerAgent(Agent):
"""Product Owner always has work."""
return self.id
async def execute_task(self, task_id: UUID) -> bool:
async def execute_task(self, _task_id: UUID) -> bool:
"""Execute Product Owner duties."""
try:
match self._current_phase:
@@ -228,7 +229,7 @@ class HeadMarketingAgent(Agent):
"""Head of Marketing always has work."""
return self.id
async def execute_task(self, task_id: UUID) -> bool:
async def execute_task(self, _task_id: UUID) -> bool:
"""Execute marketing duties."""
try:
match self._current_phase:
@@ -374,7 +375,7 @@ class AuditorAgent(Agent):
"""Auditor always has work - watching everything."""
return self.id
async def execute_task(self, task_id: UUID) -> bool:
async def execute_task(self, _task_id: UUID) -> bool:
"""Execute Auditor duties."""
try:
match self._current_phase:
@@ -491,8 +492,6 @@ Be thorough but fair.
# Parse and create flags (simplified)
if "concern" in analysis.lower() or "critical" in analysis.lower():
from uuid import uuid4
self._flags.append(
AuditFlag(
id=uuid4(),
@@ -523,9 +522,10 @@ Be thorough but fair.
self.log.debug("REPORT phase")
# Check if it's time for regular report
hours_in_day = 24
should_report = (
self._last_report is None
or (datetime.now(UTC) - self._last_report).hours >= 24
or (datetime.now(UTC) - self._last_report).hours >= hours_in_day
or any(
f.severity in [FlagSeverity.CONCERN, FlagSeverity.CRITICAL]
for f in self._flags
@@ -567,8 +567,6 @@ Be thorough but fair.
for audit_type in audits:
findings = await self._perform_audit(audit_type)
if findings:
from uuid import uuid4
self._flags.append(
AuditFlag(
id=uuid4(),
@@ -721,8 +719,6 @@ def create_product_owner(
blueprint_path = Path("agents/blueprints/board/product-owner.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -750,8 +746,6 @@ def create_head_marketing(
blueprint_path = Path("agents/blueprints/board/head-marketing.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -779,8 +773,6 @@ def create_auditor(
blueprint_path = Path("agents/blueprints/board/auditor.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
+80 -58
View File
@@ -2,9 +2,11 @@
Developer Agent
Implementation of the Developer workflow from the blueprint.
Handles task lifecycle: SCAN → CLAIM → UNDERSTAND → PLAN → EXECUTE → VERIFY → NOTES → CLOSE
Handles task lifecycle:
SCAN → CLAIM → UNDERSTAND → PLAN → EXECUTE → VERIFY → NOTES → CLOSE
"""
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
@@ -128,49 +130,10 @@ class DeveloperAgent(Agent):
ctx = self._task_context
try:
match ctx.phase:
case DevTaskPhase.CLAIM:
await self._phase_claim(ctx)
ctx.phase = DevTaskPhase.UNDERSTAND
case DevTaskPhase.UNDERSTAND:
understood = await self._phase_understand(ctx)
if understood:
ctx.phase = DevTaskPhase.PLAN
# If not understood, stay in UNDERSTAND (asking questions)
case DevTaskPhase.PLAN:
await self._phase_plan(ctx)
ctx.phase = DevTaskPhase.EXECUTE
case DevTaskPhase.EXECUTE:
completed = await self._phase_execute(ctx)
completed = await self._dispatch_phase(ctx)
if completed:
ctx.phase = DevTaskPhase.VERIFY
case DevTaskPhase.VERIFY:
verified = await self._phase_verify(ctx)
if verified:
ctx.phase = DevTaskPhase.NOTES
else:
ctx.phase = DevTaskPhase.EXECUTE # Back to fix issues
case DevTaskPhase.NOTES:
await self._phase_notes(ctx)
ctx.phase = DevTaskPhase.CLOSE
case DevTaskPhase.CLOSE:
closed = await self._phase_close(ctx)
if closed:
self._task_context = None
return True
case DevTaskPhase.BLOCKED:
resolved = await self._handle_blocked(ctx)
if resolved:
ctx.phase = DevTaskPhase.EXECUTE
return False
return completed
except Exception as e:
self.log.error("Error in task phase", phase=ctx.phase.value, error=str(e))
@@ -178,6 +141,71 @@ class DeveloperAgent(Agent):
ctx.phase = DevTaskPhase.BLOCKED
return False
async def _dispatch_phase(self, ctx: TaskContext) -> bool:
"""Dispatch to the appropriate phase handler. Returns True if task complete."""
phase_handlers = {
DevTaskPhase.CLAIM: self._handle_claim_phase,
DevTaskPhase.UNDERSTAND: self._handle_understand_phase,
DevTaskPhase.PLAN: self._handle_plan_phase,
DevTaskPhase.EXECUTE: self._handle_execute_phase,
DevTaskPhase.VERIFY: self._handle_verify_phase,
DevTaskPhase.NOTES: self._handle_notes_phase,
DevTaskPhase.CLOSE: self._handle_close_phase,
DevTaskPhase.BLOCKED: self._handle_blocked_phase,
}
handler = phase_handlers.get(ctx.phase)
if handler:
return await handler(ctx)
return False
async def _handle_claim_phase(self, ctx: TaskContext) -> bool:
"""Handle CLAIM phase transition."""
await self._phase_claim(ctx)
ctx.phase = DevTaskPhase.UNDERSTAND
return False
async def _handle_understand_phase(self, ctx: TaskContext) -> bool:
"""Handle UNDERSTAND phase transition."""
if await self._phase_understand(ctx):
ctx.phase = DevTaskPhase.PLAN
return False
async def _handle_plan_phase(self, ctx: TaskContext) -> bool:
"""Handle PLAN phase transition."""
await self._phase_plan(ctx)
ctx.phase = DevTaskPhase.EXECUTE
return False
async def _handle_execute_phase(self, ctx: TaskContext) -> bool:
"""Handle EXECUTE phase transition."""
if await self._phase_execute(ctx):
ctx.phase = DevTaskPhase.VERIFY
return False
async def _handle_verify_phase(self, ctx: TaskContext) -> bool:
"""Handle VERIFY phase transition."""
if await self._phase_verify(ctx):
ctx.phase = DevTaskPhase.NOTES
else:
ctx.phase = DevTaskPhase.EXECUTE
return False
async def _handle_notes_phase(self, ctx: TaskContext) -> bool:
"""Handle NOTES phase transition."""
await self._phase_notes(ctx)
ctx.phase = DevTaskPhase.CLOSE
return False
async def _handle_close_phase(self, ctx: TaskContext) -> bool:
"""Handle CLOSE phase transition."""
return await self._phase_close(ctx)
async def _handle_blocked_phase(self, ctx: TaskContext) -> bool:
"""Handle BLOCKED phase transition."""
if await self._handle_blocked(ctx):
ctx.phase = DevTaskPhase.EXECUTE
return False
# =========================================================================
# PHASE IMPLEMENTATIONS
# =========================================================================
@@ -270,7 +298,7 @@ If clarification needed, respond with: "QUESTION: [your question]"
Create an implementation plan for this task:
Task: {ctx.title}
Understanding: {ctx.journal_entries[-1] if ctx.journal_entries else "No previous context"}
Understanding: {ctx.journal_entries[-1] if ctx.journal_entries else "No context"}
Break this into ordered subtasks. For each subtask:
- Clear description
@@ -289,9 +317,8 @@ Format as JSON array:
ctx.subtasks = [{"description": response, "files": [], "complexity": "medium"}]
# Journal entry
ctx.journal_entries.append(
f"[{datetime.now(UTC).isoformat()}] Plan: {len(ctx.subtasks)} subtasks created"
)
ts = datetime.now(UTC).isoformat()
ctx.journal_entries.append(f"[{ts}] Plan: {len(ctx.subtasks)} subtasks created")
# Announce plan
await self.send_message(
@@ -327,7 +354,7 @@ Format as JSON array:
Execute this subtask:
Task: {ctx.title}
Subtask {ctx.current_subtask + 1}/{len(ctx.subtasks)}: {subtask.get("description", str(subtask))}
Subtask {ctx.current_subtask + 1}/{len(ctx.subtasks)}: {subtask.get("description", "")}
Provide:
1. Code changes needed
@@ -339,18 +366,19 @@ Respond with the implementation.
response = await self.think_and_stream(prompt)
# Record work done
ctx.journal_entries.append(
f"[{datetime.now(UTC).isoformat()}] Subtask {ctx.current_subtask + 1}: {response[:100]}..."
)
ts = datetime.now(UTC).isoformat()
subtask_num = ctx.current_subtask + 1
ctx.journal_entries.append(f"[{ts}] Subtask {subtask_num}: {response[:100]}...")
# Simulate commit (in real implementation would execute git)
commit_hash = f"commit_{ctx.current_subtask}"
ctx.commits.append(commit_hash)
# Progress update
progress = f"{ctx.current_subtask + 1}/{len(ctx.subtasks)}"
await self.send_message(
self._cell_channel_id or ctx.task_id,
f"TASK-{str(ctx.task_id)[:8]} progress: subtask {ctx.current_subtask + 1}/{len(ctx.subtasks)} complete",
f"TASK-{str(ctx.task_id)[:8]} progress: subtask {progress} complete",
message_type="action",
)
@@ -424,7 +452,7 @@ Create a handoff summary including:
3. Documentation needed
4. Code samples to include
"""
handoff = await self.think(prompt)
_handoff = await self.think(prompt) # Handoff content is for documenter
ctx.journal_entries.append(
f"[{datetime.now(UTC).isoformat()}] Handoff created for documenter"
@@ -602,8 +630,6 @@ def create_backend_developer(
if blueprint_path.exists():
content = blueprint_path.read_text()
# Extract system prompt section (between ```blocks after ## System Prompt)
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -630,8 +656,6 @@ def create_frontend_developer(
blueprint_path = Path("agents/blueprints/frontend/fe-dev.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -658,8 +682,6 @@ def create_ux_developer(
blueprint_path = Path("agents/blueprints/ux_ui/ux-dev.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
+7 -12
View File
@@ -2,9 +2,11 @@
Documenter Agent
Implementation of the Documenter workflow from the blueprint.
Handles documentation lifecycle: MONITOR → RECEIVE → GATHER → SYNTHESIZE → WRITE → REVIEW → PUBLISH
Handles documentation lifecycle:
MONITOR → RECEIVE → GATHER → SYNTHESIZE → WRITE → REVIEW → PUBLISH
"""
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
@@ -348,10 +350,10 @@ Format appropriately for the document type.
ctx.current_doc += 1
progress = f"{ctx.current_doc}/{len(ctx.documents_needed)}"
await self.send_message(
self._cell_channel_id or ctx.task_id,
f"TASK-{str(ctx.task_id)[:8]} doc {ctx.current_doc}/{len(ctx.documents_needed)}: "
f"{doc_spec.title}",
f"TASK-{str(ctx.task_id)[:8]} doc {progress}: {doc_spec.title}",
message_type="action",
)
@@ -389,9 +391,8 @@ Check:
If issues found, provide suggestions.
"""
review = await self.think(prompt)
ctx.notes.append(
f"[{datetime.now(UTC).isoformat()}] Reviewed {doc_spec.title}: {review[:100]}..."
)
ts = datetime.now(UTC).isoformat()
ctx.notes.append(f"[{ts}] Reviewed {doc_spec.title}: {review[:100]}...")
async def _phase_publish(self, ctx: DocContext) -> None:
"""
@@ -527,8 +528,6 @@ def create_backend_documenter(
blueprint_path = Path("agents/blueprints/backend/be-documenter.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -555,8 +554,6 @@ def create_frontend_documenter(
blueprint_path = Path("agents/blueprints/frontend/fe-documenter.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -583,8 +580,6 @@ def create_ux_documenter(
blueprint_path = Path("agents/blueprints/ux_ui/ux-documenter.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
+14 -12
View File
@@ -258,9 +258,10 @@ class Orchestrator:
)
# Check for inactivity (5 minutes)
minutes_in_seconds = 300
if agent.state.last_activity:
inactive_seconds = (now - agent.state.last_activity).total_seconds()
if inactive_seconds > 300 and agent.is_running:
if inactive_seconds > minutes_in_seconds and agent.is_running:
self.log.warning(
"Agent inactive",
agent_id=str(agent.id),
@@ -355,19 +356,21 @@ class Orchestrator:
# =============================================================================
# GLOBAL ORCHESTRATOR INSTANCE
# SINGLETON HOLDER
# =============================================================================
# Singleton orchestrator for the application
_orchestrator: Orchestrator | None = None
class _OrchestratorHolder:
"""Holder class for singleton orchestrator instance."""
instance: Orchestrator | None = None
def get_orchestrator() -> Orchestrator:
"""Get or create the global orchestrator instance."""
global _orchestrator
if _orchestrator is None:
_orchestrator = Orchestrator()
return _orchestrator
if _OrchestratorHolder.instance is None:
_OrchestratorHolder.instance = Orchestrator()
return _OrchestratorHolder.instance
async def start_orchestrator() -> Orchestrator:
@@ -379,7 +382,6 @@ async def start_orchestrator() -> Orchestrator:
async def stop_orchestrator() -> None:
"""Stop the global orchestrator."""
global _orchestrator
if _orchestrator:
await _orchestrator.stop()
_orchestrator = None
if _OrchestratorHolder.instance:
await _OrchestratorHolder.instance.stop()
_OrchestratorHolder.instance = None
+33 -18
View File
@@ -2,10 +2,13 @@
PM Agents (Cell PM and Main PM)
Implementation of PM workflows from the blueprint.
Cell PM: MONITOR → TRIAGE → ASSIGN → FACILITATE → ESCALATE → TRACK → REPORT
Main PM: OVERSEE → RECEIVE → PRIORITIZE → COORDINATE → DISTRIBUTE → REPORT UP → FACILITATE
Cell PM:
MONITOR → TRIAGE → ASSIGN → FACILITATE → ESCALATE → TRACK → REPORT
Main PM:
OVERSEE → RECEIVE → PRIORITIZE → COORDINATE → DISTRIBUTE → REPORT UP → FACILITATE
"""
import re
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
@@ -117,7 +120,7 @@ class CellPMAgent(Agent):
# PMs are always active, cycling through phases
return self.id # Use own ID as "task" since PM work is continuous
async def execute_task(self, task_id: UUID) -> bool:
async def execute_task(self, _task_id: UUID) -> bool:
"""
Execute PM duties in a cycle.
@@ -297,6 +300,7 @@ Be helpful and unblock the team.
"""
self.log.debug("REPORT phase")
concerns = self._format_concerns()
report = f"""
## {self.cell_name} Status Report
@@ -306,12 +310,18 @@ Be helpful and unblock the team.
**Available Devs**: {self._cell_status.available_devs}
**Concerns**:
{chr(10).join(f"- {c}" for c in self._cell_status.concerns) if self._cell_status.concerns else "- None"}
{concerns}
"""
# Would send to #pm-all channel
self.log.info("Report generated", report_length=len(report))
self._cell_status.concerns.clear()
def _format_concerns(self) -> str:
"""Format concerns for report."""
if not self._cell_status.concerns:
return "- None"
return chr(10).join(f"- {c}" for c in self._cell_status.concerns)
# =========================================================================
# HELPER METHODS
# =========================================================================
@@ -435,13 +445,14 @@ Be helpful and unblock the team.
"""
# Build notification content
notification_type = NotificationType.ESCALATION
task_ref = str(escalation.task_id)[:8] if escalation.task_id else "N/A"
subject = f"Escalation from {self.cell_name}: {escalation.issue[:50]}"
body = f"""
## Escalation from {self.cell_name}
**Issue:** {escalation.issue}
**Severity:** {escalation.severity}
**Task:** {str(escalation.task_id)[:8] if escalation.task_id else "N/A"}
**Task:** {task_ref}
**Proposed Solution:**
{escalation.proposed_solution or "No solution proposed"}
@@ -451,7 +462,8 @@ Please review and provide guidance.
self.log.info(
"Escalation sent to Main PM",
issue=escalation.issue,
subject=subject,
body_length=len(body),
notification_type=notification_type.value,
severity=escalation.severity,
)
@@ -525,7 +537,7 @@ class MainPMAgent(Agent):
"""Main PM always has work."""
return self.id
async def execute_task(self, task_id: UUID) -> bool:
async def execute_task(self, _task_id: UUID) -> bool:
"""Execute Main PM duties in a cycle."""
try:
match self._current_phase:
@@ -598,14 +610,21 @@ class MainPMAgent(Agent):
self.log.debug("PRIORITIZE phase")
if self._board_directives:
directives = chr(10).join(f"- {d}" for d in self._board_directives)
status_lines = []
for k, v in self._cell_statuses.items():
active = v.active_tasks
blocked = v.blocked_tasks
status_lines.append(f"- {k}: {active} active, {blocked} blocked")
cell_status = chr(10).join(status_lines)
prompt = f"""
Translate these Board directives into cell priorities:
Directives:
{chr(10).join(f"- {d}" for d in self._board_directives)}
{directives}
Current Cell Status:
{chr(10).join(f"- {k}: {v.active_tasks} active, {v.blocked_tasks} blocked" for k, v in self._cell_statuses.items())}
{cell_status}
Provide prioritized task list for each cell.
"""
@@ -760,7 +779,11 @@ Propose a resolution that unblocks all parties.
resolution: str,
) -> None:
"""Apply a cross-cell resolution."""
self.log.info("Resolution applied", issue=issue.get("description"))
self.log.info(
"Resolution applied",
issue=issue.get("description"),
resolution_length=len(resolution),
)
def _route_directive(self, directive: str) -> str | None:
"""Route a directive to appropriate cell."""
@@ -792,8 +815,6 @@ def create_backend_pm(
blueprint_path = Path("agents/blueprints/backend/be-pm.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -821,8 +842,6 @@ def create_frontend_pm(
blueprint_path = Path("agents/blueprints/frontend/fe-pm.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -850,8 +869,6 @@ def create_ux_pm(
blueprint_path = Path("agents/blueprints/ux_ui/ux-pm.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -879,8 +896,6 @@ def create_main_pm(
blueprint_path = Path("agents/blueprints/board/main-pm.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
+11 -13
View File
@@ -2,9 +2,11 @@
QA Agent
Implementation of the QA workflow from the blueprint.
Handles review lifecycle: MONITOR → RECEIVE → UNDERSTAND → TEST → VERDICT → DOCUMENT → RETURN
Handles review lifecycle:
MONITOR → RECEIVE → UNDERSTAND → TEST → VERDICT → DOCUMENT → RETURN
"""
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
@@ -239,7 +241,7 @@ Focus on:
Format as JSON array.
"""
response = await self.think(prompt)
_response = await self.think(prompt) # Response informs test case structure
# Create test cases (simplified parsing)
ctx.test_cases = [
@@ -263,9 +265,8 @@ Format as JSON array.
),
]
ctx.notes.append(
f"[{datetime.now(UTC).isoformat()}] Created {len(ctx.test_cases)} test cases"
)
ts = datetime.now(UTC).isoformat()
ctx.notes.append(f"[{ts}] Created {len(ctx.test_cases)} test cases")
async def _phase_test(self, ctx: ReviewContext) -> bool:
"""
@@ -320,10 +321,13 @@ NOTES: [notes]
ctx.current_test += 1
# Progress update
progress = f"{ctx.current_test}/{len(ctx.test_cases)}"
result_str = test_case.result.value.upper()
task_ref = str(ctx.task_id)[:8]
msg = f"TASK-{task_ref} test {progress}: {test_case.name} - {result_str}"
await self.send_message(
self._cell_channel_id or ctx.task_id,
f"TASK-{str(ctx.task_id)[:8]} test {ctx.current_test}/{len(ctx.test_cases)}: "
f"{test_case.name} - {test_case.result.value.upper()}",
msg,
message_type="action",
)
@@ -506,8 +510,6 @@ def create_backend_qa(
blueprint_path = Path("agents/blueprints/backend/be-qa.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -534,8 +536,6 @@ def create_frontend_qa(
blueprint_path = Path("agents/blueprints/frontend/fe-qa.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
@@ -562,8 +562,6 @@ def create_ux_qa(
blueprint_path = Path("agents/blueprints/ux_ui/ux-qa.md")
if blueprint_path.exists():
content = blueprint_path.read_text()
import re
match = re.search(r"## System Prompt\s*```\s*(.*?)```", content, re.DOTALL)
system_prompt = match.group(1).strip() if match else ""
else:
+38 -42
View File
@@ -12,6 +12,19 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from roboco.api.middleware import setup_middleware
from roboco.api.routes.channels import router as channels_router
from roboco.api.routes.dashboard import router as dashboard_router
from roboco.api.routes.health import router as health_router
from roboco.api.routes.journals import router as journals_router
from roboco.api.routes.kanban import router as kanban_router
from roboco.api.routes.messages import router as messages_router
from roboco.api.routes.notifications import router as notifications_router
from roboco.api.routes.optimal import router as optimal_router
from roboco.api.routes.orchestrator import router as orchestrator_router
from roboco.api.routes.sessions import router as sessions_router
from roboco.api.routes.stream import router as stream_router
from roboco.api.routes.tasks import router as tasks_router
from roboco.api.websocket import router as ws_router
from roboco.config import settings
from roboco.db.base import close_db, init_db
from roboco.logging import get_logger, setup_logging
@@ -23,9 +36,12 @@ from roboco.services.transcription import TranscriptionService
setup_logging()
logger = get_logger(__name__)
# Global service instances (initialized in lifespan)
transcription_service: TranscriptionService | None = None
extraction_pipeline: ExtractionPipeline | None = None
class _AppServices:
"""Holder for application service instances (initialized in lifespan)."""
transcription: TranscriptionService | None = None
extraction: ExtractionPipeline | None = None
@asynccontextmanager
@@ -35,8 +51,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
Handles startup and shutdown events.
"""
global transcription_service, extraction_pipeline
logger.info(
"Starting RoboCo API",
version=settings.app_version,
@@ -51,15 +65,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
logger.info("Database initialized (development mode)")
# Initialize Phase 2 services
transcription_service = TranscriptionService()
await transcription_service.start()
_AppServices.transcription = TranscriptionService()
await _AppServices.transcription.start()
extraction_service = ExtractionService()
extraction_pipeline = ExtractionPipeline(extraction_service)
_AppServices.extraction = ExtractionPipeline(extraction_service)
# Store in app state for access in routes
app.state.transcription = transcription_service
app.state.extraction = extraction_pipeline
app.state.transcription = _AppServices.transcription
app.state.extraction = _AppServices.extraction
# Initialize Phase 3 services
optimal_service = await get_optimal_service()
@@ -72,8 +86,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
# Shutdown
logger.info("Shutting down RoboCo API")
if transcription_service:
await transcription_service.stop()
if _AppServices.transcription:
await _AppServices.transcription.stop()
# Close Phase 3 services
await close_optimal_service()
@@ -118,93 +132,78 @@ def create_app() -> FastAPI:
# Routes
# ==========================================================================
from roboco.api.routes import (
channels,
dashboard,
health,
journals,
kanban,
messages,
notifications,
optimal,
orchestrator,
sessions,
stream,
tasks,
)
# Health check
app.include_router(health.router, tags=["Health"])
app.include_router(health_router, tags=["Health"])
# API v1
api_prefix = "/api/v1"
app.include_router(
channels.router,
channels_router,
prefix=f"{api_prefix}/channels",
tags=["Channels"],
)
app.include_router(
sessions.router,
sessions_router,
prefix=f"{api_prefix}/sessions",
tags=["Sessions"],
)
app.include_router(
messages.router,
messages_router,
prefix=f"{api_prefix}/messages",
tags=["Messages"],
)
app.include_router(
notifications.router,
notifications_router,
prefix=f"{api_prefix}/notifications",
tags=["Notifications"],
)
# Phase 2: Stream processing and permissions
app.include_router(
stream.router,
stream_router,
prefix=f"{api_prefix}/stream",
tags=["Stream Processing"],
)
# Phase 3: Intelligence - Optimal API and Journal API
app.include_router(
optimal.router,
optimal_router,
prefix=api_prefix,
tags=["Optimal API"],
)
app.include_router(
journals.router,
journals_router,
prefix=api_prefix,
tags=["Journals"],
)
# Phase 5: Management - Tasks, Kanban, Dashboards
app.include_router(
tasks.router,
tasks_router,
prefix=api_prefix,
tags=["Tasks"],
)
app.include_router(
kanban.router,
kanban_router,
prefix=api_prefix,
tags=["Kanban"],
)
app.include_router(
dashboard.router,
dashboard_router,
prefix=api_prefix,
tags=["Dashboard"],
)
# Phase 7: Agent Runtime
app.include_router(
orchestrator.router,
orchestrator_router,
prefix=f"{api_prefix}/orchestrator",
tags=["Orchestrator"],
)
@@ -212,9 +211,6 @@ def create_app() -> FastAPI:
# ==========================================================================
# WebSocket
# ==========================================================================
from roboco.api.websocket import router as ws_router
app.include_router(ws_router, prefix="/ws", tags=["WebSocket"])
return app
+10 -9
View File
@@ -4,6 +4,7 @@ API Dependencies
Shared dependencies for FastAPI routes.
"""
import contextlib
from typing import Annotated
from uuid import UUID
@@ -17,16 +18,18 @@ from roboco.services.permissions import AgentContext, PermissionService
# Type alias for database session dependency
DbSession = Annotated[AsyncSession, Depends(get_db)]
# Global service instances
_permission_service: PermissionService | None = None
class _ServiceHolder:
"""Holder for singleton service instances."""
permission_service: PermissionService | None = None
def get_permission_service() -> PermissionService:
"""Get or create the permission service singleton."""
global _permission_service
if _permission_service is None:
_permission_service = PermissionService()
return _permission_service
if _ServiceHolder.permission_service is None:
_ServiceHolder.permission_service = PermissionService()
return _ServiceHolder.permission_service
PermissionServiceDep = Annotated[PermissionService, Depends(get_permission_service)]
@@ -137,10 +140,8 @@ async def get_agent_context(
team: Team | None = None
if x_agent_team:
try:
with contextlib.suppress(ValueError):
team = Team(x_agent_team.lower())
except ValueError:
pass # Team is optional
return AgentContext(
agent_id=agent_id,
+3 -3
View File
@@ -81,7 +81,7 @@ async def list_channels(
)
if not include_archived:
query = query.where(ChannelTable.is_archived == False) # noqa: E712
query = query.where(ChannelTable.is_archived is False)
# Get total count
count_result = await db.execute(
@@ -294,7 +294,7 @@ async def update_channel(
)
async def add_member(
db: DbSession,
agent_id: CurrentAgentId,
_agent_id: CurrentAgentId, # For auth context
channel_id: UUID,
member_id: UUID,
can_write: bool = Query(True),
@@ -328,7 +328,7 @@ async def add_member(
)
async def remove_member(
db: DbSession,
agent_id: CurrentAgentId,
_agent_id: CurrentAgentId, # For auth context
channel_id: UUID,
member_id: UUID,
) -> None:
+8 -10
View File
@@ -8,7 +8,7 @@ Provides aggregated views, alerts, and reporting.
from datetime import UTC, datetime, timedelta
from enum import Enum
from typing import Annotated, Any
from uuid import UUID
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
@@ -161,14 +161,16 @@ async def get_auditor_dashboard(
live_feeds = []
for channel in channels:
five_minutes = 5
thirty_minutes = 30
# Determine status based on last activity
if channel.last_activity:
minutes_ago = (
datetime.now(UTC) - channel.last_activity
).total_seconds() / 60
if minutes_ago < 5:
if minutes_ago < five_minutes:
status = "streaming"
elif minutes_ago < 30:
elif minutes_ago < thirty_minutes:
status = "idle"
else:
status = "offline"
@@ -258,7 +260,7 @@ async def get_auditor_dashboard(
@router.get("/auditor/flags", response_model=list[AuditorFlag])
async def get_auditor_flags(
db: Annotated[AsyncSession, Depends(get_db)],
_db: Annotated[AsyncSession, Depends(get_db)],
severity: FlagSeverity | None = None,
resolved: bool = False,
):
@@ -287,11 +289,9 @@ async def get_auditor_flags(
)
async def create_auditor_flag(
data: CreateFlagRequest,
db: Annotated[AsyncSession, Depends(get_db)],
_db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new auditor flag."""
from uuid import uuid4
flag_id = uuid4()
flag_data = {
"severity": data.severity.value,
@@ -353,11 +353,9 @@ async def get_auditor_reports(
)
async def create_auditor_report(
data: CreateReportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
_db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new auditor report."""
from uuid import uuid4
report_id = uuid4()
report_data = {
"report_type": data.report_type,
+28 -17
View File
@@ -5,16 +5,31 @@ Agent personal journals for reflection, growth tracking, and debugging.
"""
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.models.base import JournalEntryType
from roboco.models.base import AgentRole, JournalEntryType
from roboco.models.journal import JournalEntryCreate
from roboco.services.journal import get_journal_service
# =============================================================================
# QUERY PARAMETER SCHEMAS
# =============================================================================
class ListEntriesParams(BaseModel):
"""Query parameters for listing journal entries."""
entry_type: str | None = Field(None, description="Filter by entry type")
task_id: UUID | None = Field(None, description="Filter by task")
limit: int = Field(50, ge=1, le=100, description="Maximum entries to return")
offset: int = Field(0, ge=0, description="Number of entries to skip")
router = APIRouter(prefix="/journals", tags=["journals"])
@@ -60,7 +75,7 @@ class CreateEntryRequest(BaseModel):
type: str = Field(
...,
description="Entry type (task_reflection, decision_log, learning, struggle, general)",
description="Entry type (task_reflection, decision_log, learning, etc.)",
)
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
@@ -188,7 +203,7 @@ async def get_my_journal(
@router.get("/{agent_id}", response_model=JournalResponse)
async def get_journal_by_agent(
agent_id: UUID,
agent: CurrentAgentContext,
_agent: CurrentAgentContext,
db: DbSession,
) -> JournalResponse:
"""
@@ -282,10 +297,7 @@ async def create_entry(
async def list_my_entries(
agent: CurrentAgentContext,
db: DbSession,
entry_type: str | None = Query(None, description="Filter by entry type"),
task_id: UUID | None = Query(None, description="Filter by task"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
params: Annotated[ListEntriesParams, Depends()],
) -> list[JournalEntryResponse]:
"""List the current agent's journal entries."""
service = get_journal_service(db)
@@ -295,9 +307,9 @@ async def list_my_entries(
return []
type_filter = None
if entry_type:
if params.entry_type:
try:
type_filter = JournalEntryType(entry_type)
type_filter = JournalEntryType(params.entry_type)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -307,9 +319,9 @@ async def list_my_entries(
entries = await service.list_entries(
journal_id=journal.id,
entry_type=type_filter,
task_id=task_id,
limit=limit,
offset=offset,
task_id=params.task_id,
limit=params.limit,
offset=params.offset,
include_private=True, # Can see own private entries
)
@@ -352,11 +364,10 @@ async def get_entry(
# Check privacy (simplified - in production would check journal ownership)
if entry.is_private:
journal = await service.get_journal(entry.journal_id)
if journal and journal.agent_id != agent.agent_id:
# Allow CEO and Auditor to see private entries
from roboco.models.base import AgentRole
if agent.role not in [AgentRole.CEO, AgentRole.AUDITOR]:
is_other_agent = journal and journal.agent_id != agent.agent_id
is_unprivileged = agent.role not in [AgentRole.CEO, AgentRole.AUDITOR]
if is_other_agent and is_unprivileged:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This entry is private",
+31 -19
View File
@@ -5,9 +5,10 @@ CRUD operations for messages within sessions.
"""
from datetime import UTC, datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -19,6 +20,21 @@ from roboco.models import MessageType, SessionStatus
router = APIRouter()
# =============================================================================
# Query Parameter Models
# =============================================================================
class ListMessagesParams(BaseModel):
"""Query parameters for listing messages."""
session_id: UUID
before: datetime | None = None
after: datetime | None = None
type_filter: MessageType | None = None
limit: int = Field(50, ge=1, le=100)
# =============================================================================
# Response Models
# =============================================================================
@@ -86,18 +102,14 @@ class MessageEditRequest(BaseModel):
)
async def list_messages(
db: DbSession,
agent_id: CurrentAgentId,
session_id: UUID = Query(...),
before: datetime | None = None,
after: datetime | None = None,
type_filter: MessageType | None = None,
limit: int = Query(50, ge=1, le=100),
_agent_id: CurrentAgentId,
params: Annotated[ListMessagesParams, Depends()],
) -> MessageListResponse:
"""List messages in a session."""
# Verify session exists
session_result = await db.execute(
select(SessionTable)
.where(SessionTable.id == session_id)
.where(SessionTable.id == params.session_id)
.options(selectinload(SessionTable.group))
)
session = session_result.scalar_one_or_none()
@@ -109,25 +121,25 @@ async def list_messages(
)
# Build query
query = select(MessageTable).where(MessageTable.session_id == session_id)
query = select(MessageTable).where(MessageTable.session_id == params.session_id)
if before:
query = query.where(MessageTable.timestamp < before)
if after:
query = query.where(MessageTable.timestamp > after)
if type_filter:
query = query.where(MessageTable.type == type_filter)
if params.before:
query = query.where(MessageTable.timestamp < params.before)
if params.after:
query = query.where(MessageTable.timestamp > params.after)
if params.type_filter:
query = query.where(MessageTable.type == params.type_filter)
# Order by timestamp descending (newest first) and limit
query = query.order_by(MessageTable.timestamp.desc()).limit(limit + 1)
query = query.order_by(MessageTable.timestamp.desc()).limit(params.limit + 1)
result = await db.execute(query)
messages = result.scalars().all()
# Check if there are more messages
has_more = len(messages) > limit
has_more = len(messages) > params.limit
if has_more:
messages = messages[:limit]
messages = messages[: params.limit]
items = [
MessageResponse(
@@ -166,7 +178,7 @@ async def list_messages(
)
async def get_message(
db: DbSession,
agent_id: CurrentAgentId,
_agent_id: CurrentAgentId,
message_id: UUID,
) -> MessageResponse:
"""Get a message by ID."""
+23 -11
View File
@@ -6,9 +6,10 @@ Enforces permission rules: only PMs, Board, and Auditor can send notifications.
"""
from datetime import UTC, datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
@@ -23,6 +24,20 @@ from roboco.models import NotificationPriority, NotificationType
router = APIRouter()
# =============================================================================
# Query Parameter Models
# =============================================================================
class ListNotificationsParams(BaseModel):
"""Query parameters for listing notifications."""
unread_only: bool = False
pending_ack_only: bool = False
type_filter: NotificationType | None = None
limit: int = Field(50, ge=1, le=100)
# =============================================================================
# Response Models
# =============================================================================
@@ -83,10 +98,7 @@ class NotificationCreateRequest(BaseModel):
async def list_notifications(
db: DbSession,
agent_id: CurrentAgentId,
unread_only: bool = Query(False),
pending_ack_only: bool = Query(False),
type_filter: NotificationType | None = None,
limit: int = Query(50, ge=1, le=100),
params: Annotated[ListNotificationsParams, Depends()],
) -> NotificationListResponse:
"""List notifications for the agent."""
# Query notifications where agent is a recipient
@@ -94,19 +106,19 @@ async def list_notifications(
NotificationTable.to_agents.contains([agent_id])
)
if unread_only:
if params.unread_only:
query = query.where(~NotificationTable.read_by.contains([agent_id]))
if pending_ack_only:
if params.pending_ack_only:
query = query.where(
NotificationTable.requires_ack == True, # noqa: E712
NotificationTable.requires_ack is True,
~NotificationTable.acked_by.contains([agent_id]),
)
if type_filter:
query = query.where(NotificationTable.type == type_filter)
if params.type_filter:
query = query.where(NotificationTable.type == params.type_filter)
query = query.order_by(NotificationTable.timestamp.desc()).limit(limit)
query = query.order_by(NotificationTable.timestamp.desc()).limit(params.limit)
result = await db.execute(query)
notifications = result.scalars().all()
+6 -6
View File
@@ -112,7 +112,7 @@ class RefreshRequest(BaseModel):
@router.post("/kb/index/code", status_code=status.HTTP_201_CREATED)
async def index_code(
request: IndexCodeRequest,
agent: CurrentAgentContext,
_agent: CurrentAgentContext,
) -> dict[str, Any]:
"""
Index code files/directories.
@@ -137,7 +137,7 @@ async def index_code(
@router.post("/kb/index/docs", status_code=status.HTTP_201_CREATED)
async def index_documentation(
request: IndexDocsRequest,
agent: CurrentAgentContext,
_agent: CurrentAgentContext,
) -> dict[str, Any]:
"""
Index documentation files.
@@ -219,7 +219,7 @@ async def search(
async def find_similar(
source: str,
top_k: int = 5,
agent: CurrentAgentContext = None,
_agent: CurrentAgentContext = None,
) -> SearchResponse:
"""
Find documents similar to a given source.
@@ -364,7 +364,7 @@ async def get_context(
@router.get("/stats", response_model=IndexStatsResponse)
async def get_stats(
agent: CurrentAgentContext,
_agent: CurrentAgentContext,
) -> IndexStatsResponse:
"""Get statistics about all indexes."""
service = await get_optimal_service()
@@ -378,7 +378,7 @@ async def get_stats(
@router.delete("/kb/{index_type}")
async def clear_index(
index_type: str,
agent: CurrentAgentContext,
_agent: CurrentAgentContext,
) -> dict[str, str]:
"""
Clear a specific index.
@@ -402,7 +402,7 @@ async def clear_index(
@router.post("/kb/refresh")
async def refresh_index(
request: RefreshRequest,
agent: CurrentAgentContext,
_agent: CurrentAgentContext,
) -> dict[str, Any]:
"""
Refresh an index with updated sources.
+8 -6
View File
@@ -14,24 +14,26 @@ from roboco.runtime import AgentOrchestrator
router = APIRouter()
# Global orchestrator instance (set by bootstrap)
_orchestrator: AgentOrchestrator | None = None
class _OrchestratorHolder:
"""Holder for orchestrator instance (set by bootstrap)."""
instance: AgentOrchestrator | None = None
def set_orchestrator(orchestrator: AgentOrchestrator) -> None:
"""Set the global orchestrator instance."""
global _orchestrator
_orchestrator = orchestrator
_OrchestratorHolder.instance = orchestrator
def get_orchestrator() -> AgentOrchestrator:
"""Get the global orchestrator instance."""
if _orchestrator is None:
if _OrchestratorHolder.instance is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Orchestrator not initialized",
)
return _orchestrator
return _OrchestratorHolder.instance
# =============================================================================
+24 -12
View File
@@ -6,10 +6,11 @@ by time, count, or content length.
"""
from datetime import UTC, datetime, timedelta
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -20,6 +21,19 @@ from roboco.models import SessionStatus
router = APIRouter()
# =============================================================================
# Query Parameter Models
# =============================================================================
class ListSessionsParams(BaseModel):
"""Query parameters for listing sessions."""
group_id: UUID
status_filter: SessionStatus | None = None
limit: int = Field(20, ge=1, le=100)
# =============================================================================
# Response Models
# =============================================================================
@@ -69,15 +83,13 @@ class SessionCreateRequest(BaseModel):
async def list_sessions(
db: DbSession,
agent_id: CurrentAgentId,
group_id: UUID = Query(...),
status_filter: SessionStatus | None = None,
limit: int = Query(20, ge=1, le=100),
params: Annotated[ListSessionsParams, Depends()],
) -> SessionListResponse:
"""List sessions for a group."""
# Verify group access
group_result = await db.execute(
select(GroupTable)
.where(GroupTable.id == group_id)
.where(GroupTable.id == params.group_id)
.options(selectinload(GroupTable.channel))
)
group = group_result.scalar_one_or_none()
@@ -97,12 +109,12 @@ async def list_sessions(
)
# Query sessions
query = select(SessionTable).where(SessionTable.group_id == group_id)
query = select(SessionTable).where(SessionTable.group_id == params.group_id)
if status_filter:
query = query.where(SessionTable.status == status_filter)
if params.status_filter:
query = query.where(SessionTable.status == params.status_filter)
query = query.order_by(SessionTable.started_at.desc()).limit(limit)
query = query.order_by(SessionTable.started_at.desc()).limit(params.limit)
result = await db.execute(query)
sessions = result.scalars().all()
@@ -135,7 +147,7 @@ async def list_sessions(
)
async def get_session(
db: DbSession,
agent_id: CurrentAgentId,
_agent_id: CurrentAgentId,
session_id: UUID,
) -> SessionResponse:
"""Get session details."""
@@ -256,7 +268,7 @@ async def create_session(
)
async def close_session(
db: DbSession,
agent_id: CurrentAgentId,
__agent_id: CurrentAgentId,
session_id: UUID,
) -> SessionResponse:
"""Close a session."""
+3 -2
View File
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.deps import get_current_agent_id, get_db
from roboco.models.base import Complexity, TaskStatus, Team
from roboco.services.task import get_task_service
from roboco.services.task import TaskCreateRequest, get_task_service
router = APIRouter(prefix="/tasks", tags=["tasks"])
@@ -129,7 +129,7 @@ async def create_task(
):
"""Create a new task."""
service = get_task_service(db)
task = await service.create(
req = TaskCreateRequest(
title=data.title,
description=data.description,
acceptance_criteria=data.acceptance_criteria,
@@ -140,6 +140,7 @@ async def create_task(
target_date=data.target_date,
estimated_complexity=data.estimated_complexity,
)
task = await service.create(req)
await db.commit()
return task
+22 -15
View File
@@ -9,6 +9,7 @@ Real-time communication via WebSocket connections for:
import asyncio
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -19,6 +20,19 @@ from pydantic import BaseModel
from roboco.config import settings
@dataclass
class NewMessageBroadcast:
"""Data for broadcasting a new message."""
channel_id: UUID
session_id: UUID
message_id: UUID
agent_id: UUID
content: str
message_type: str
router = APIRouter()
@@ -192,7 +206,7 @@ async def validate_channel_access(channel_id: UUID, agent_id: UUID) -> bool:
"action": "read",
},
)
if response.status_code == 200:
if response.status_code == status.HTTP_200_OK:
data = response.json()
return data.get("allowed", False)
return False
@@ -441,27 +455,20 @@ async def notification_stream(
# =============================================================================
async def broadcast_new_message(
channel_id: UUID,
session_id: UUID,
message_id: UUID,
agent_id: UUID,
content: str,
message_type: str,
) -> None:
async def broadcast_new_message(msg: NewMessageBroadcast) -> None:
"""Broadcast a new message to channel and session subscribers."""
event = {
"type": "message.new",
"message_id": str(message_id),
"agent_id": str(agent_id),
"content": content,
"message_type": message_type,
"message_id": str(msg.message_id),
"agent_id": str(msg.agent_id),
"content": msg.content,
"message_type": msg.message_type,
"timestamp": datetime.now(UTC).isoformat(),
}
await asyncio.gather(
manager.broadcast_to_channel(channel_id, event),
manager.broadcast_to_session(session_id, event),
manager.broadcast_to_channel(msg.channel_id, event),
manager.broadcast_to_session(msg.session_id, event),
)
+28 -29
View File
@@ -4,21 +4,35 @@ RoboCo Bootstrap Script
Initializes the database, creates default data, and starts the system.
"""
import argparse
import asyncio
from pathlib import Path
from uuid import UUID as UUIDType
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.routes.orchestrator import set_orchestrator
from roboco.db.base import get_db_context, init_db
from roboco.db.tables import AgentTable, ChannelTable
from roboco.db.tables import (
AgentTable,
ChannelTable,
GroupTable,
MessageTable,
SessionTable,
)
from roboco.events import EventBus
from roboco.events.handlers import register_default_handlers
from roboco.models import AgentRole, ChannelType, MessageType, SessionStatus, Team
from roboco.runtime import AgentOrchestrator
# Global orchestrator instance (accessible by API routes and event handlers)
_orchestrator: AgentOrchestrator | None = None
class _BootstrapHolder:
"""Holder for bootstrap singleton instances."""
orchestrator: AgentOrchestrator | None = None
logger = structlog.get_logger()
@@ -222,8 +236,6 @@ AUDITOR_SILENT_ACCESS = [
async def create_channels(session: AsyncSession) -> dict[str, str]:
"""Create default channels. Returns slug -> id mapping."""
from roboco.models import ChannelType
channel_ids = {}
for channel_data in DEFAULT_CHANNELS:
@@ -256,7 +268,6 @@ async def create_channels(session: AsyncSession) -> dict[str, str]:
async def create_agents(session: AsyncSession) -> dict[str, str]:
"""Create default agents. Returns agent_id (slug) -> db_id mapping."""
from roboco.models import AgentRole, Team
agent_ids = {}
@@ -311,8 +322,6 @@ async def create_channel_memberships(
Note: ChannelTable uses arrays for members/writers/silent_observers
rather than a separate membership table.
"""
from uuid import UUID as UUIDType
for channel_slug, members in CHANNEL_MEMBERSHIPS.items():
channel_id = channel_ids.get(channel_slug)
if not channel_id:
@@ -355,12 +364,10 @@ async def create_channel_memberships(
select(ChannelTable).where(ChannelTable.id == UUIDType(channel_id))
)
channel = result.scalar_one_or_none()
if channel:
# Add to silent_observers (read-only)
if auditor_uuid not in (channel.silent_observers or []):
channel.silent_observers = (channel.silent_observers or []) + [
auditor_uuid
]
# Add auditor to silent_observers (read-only)
observers = channel.silent_observers or [] if channel else []
if channel and auditor_uuid not in observers:
channel.silent_observers = [*observers, auditor_uuid]
logger.info("Channel memberships configured")
@@ -374,7 +381,7 @@ INITIAL_MESSAGES = {
"agent_id": "main-pm",
"content": """Welcome to RoboCo!
This is the official announcements channel. Important company-wide updates will be posted here.
This is the official announcements channel. Company-wide updates will be posted here.
**Key Channels:**
- `#backend-cell`, `#frontend-cell`, `#uxui-cell` - Team communication
@@ -469,11 +476,6 @@ async def create_initial_messages(
agent_ids: dict[str, str],
) -> None:
"""Create initial welcome messages in channels."""
from uuid import UUID as UUIDType
from roboco.db.tables import GroupTable, MessageTable, SessionTable
from roboco.models import MessageType, SessionStatus
for channel_slug, message_data in INITIAL_MESSAGES.items():
channel_id_str = channel_ids.get(channel_slug)
agent_id_str = agent_ids.get(message_data["agent_id"])
@@ -587,8 +589,6 @@ async def main(
skip_orchestrator: Skip starting orchestrator
spawn_agents: List of agent IDs to spawn immediately
"""
global _orchestrator
logger.info("RoboCo Bootstrap starting...")
if not skip_db:
@@ -609,22 +609,23 @@ async def main(
orchestrator = AgentOrchestrator(
blueprints_dir=Path("agents/blueprints"),
)
_orchestrator = orchestrator
_BootstrapHolder.orchestrator = orchestrator
# Set orchestrator in API routes
from roboco.api.routes.orchestrator import set_orchestrator
set_orchestrator(orchestrator)
await orchestrator.start()
# Spawn requested agents
if spawn_agents:
startup_prompt = (
"You are starting up. Call roboco_task_scan() to look for pending work."
)
for agent_id in spawn_agents:
try:
await orchestrator.spawn_agent(
agent_id=agent_id,
initial_prompt="You are starting up. Call roboco_task_scan() to look for pending work.",
initial_prompt=startup_prompt,
)
except Exception as e:
logger.error("Failed to spawn agent", agent_id=agent_id, error=str(e))
@@ -638,14 +639,12 @@ async def main(
finally:
await orchestrator.stop()
await event_bus.disconnect()
_orchestrator = None
_BootstrapHolder.orchestrator = None
logger.info("RoboCo shutdown complete")
def cli() -> None:
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(description="RoboCo Bootstrap")
parser.add_argument(
"--skip-db",
+15 -16
View File
@@ -31,36 +31,36 @@ class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)
# Engine and session factory (initialized lazily)
_engine = None
_async_session_factory = None
class _DbHolder:
"""Holder for database engine and session factory singletons."""
engine = None
session_factory: async_sessionmaker[AsyncSession] | None = None
def get_engine():
"""Get or create the async engine."""
global _engine
if _engine is None:
_engine = create_async_engine(
if _DbHolder.engine is None:
_DbHolder.engine = create_async_engine(
settings.database_url,
echo=settings.database_echo,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_pre_ping=True,
)
return _engine
return _DbHolder.engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Get or create the async session factory."""
global _async_session_factory
if _async_session_factory is None:
_async_session_factory = async_sessionmaker(
if _DbHolder.session_factory is None:
_DbHolder.session_factory = async_sessionmaker(
bind=get_engine(),
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
return _async_session_factory
return _DbHolder.session_factory
async def get_db() -> AsyncGenerator[AsyncSession]:
@@ -125,8 +125,7 @@ async def drop_db() -> None:
async def close_db() -> None:
"""Close the database connection."""
global _engine, _async_session_factory
if _engine is not None:
await _engine.dispose()
_engine = None
_async_session_factory = None
if _DbHolder.engine is not None:
await _DbHolder.engine.dispose()
_DbHolder.engine = None
_DbHolder.session_factory = None
+2
View File
@@ -26,6 +26,7 @@ from roboco.enforcement.task_lifecycle import (
validate_task_transition,
)
from roboco.enforcement.task_ownership import (
TaskClaimContext,
TaskOwnershipError,
validate_task_claim,
validate_task_ownership,
@@ -36,6 +37,7 @@ __all__ = [
"VALID_TRANSITIONS",
"ChannelAccessDeniedError",
"NotificationPermissionError",
"TaskClaimContext",
"TaskLifecycleError",
"TaskOwnershipError",
"validate_channel_access",
+35 -31
View File
@@ -4,11 +4,25 @@ Task Ownership Enforcement
Validates task ownership and claim rules.
"""
from dataclasses import dataclass
from roboco.agents_config import get_agent_role, get_agent_team
from roboco.enforcement.task_lifecycle import is_waiting_state
from roboco.exceptions import RobocoError
@dataclass
class TaskClaimContext:
"""Context for validating a task claim."""
agent_id: str
task_id: str
task_status: str
task_team: str
agent_active_tasks: list[dict]
agent_paused_tasks: list[dict]
class TaskOwnershipError(RobocoError):
"""Raised when a task ownership rule is violated."""
@@ -111,14 +125,7 @@ def validate_task_ownership(
return True
def validate_task_claim(
agent_id: str,
task_id: str,
task_status: str,
task_team: str,
agent_active_tasks: list[dict],
agent_paused_tasks: list[dict],
) -> bool:
def validate_task_claim(ctx: TaskClaimContext) -> bool:
"""
Validate agent can claim a specific task.
@@ -129,12 +136,7 @@ def validate_task_claim(
- Agent should be in the same team as the task (warning, not error)
Args:
agent_id: The agent attempting to claim
task_id: The task to claim
task_status: Current task status
task_team: Task's team
agent_active_tasks: Agent's current active tasks
agent_paused_tasks: Agent's paused tasks
ctx: Task claim context with all required validation data
Returns:
True if can claim
@@ -143,41 +145,45 @@ def validate_task_claim(
TaskOwnershipError: If cannot claim
"""
# Check task is pending
if task_status != "pending":
if ctx.task_status != "pending":
msg = (
f"Cannot claim task in '{ctx.task_status}' status. "
"Only 'pending' tasks can be claimed."
)
raise TaskOwnershipError(
agent_id=agent_id,
task_id=task_id,
agent_id=ctx.agent_id,
task_id=ctx.task_id,
action="claim",
message=f"Cannot claim task in '{task_status}' status. Only 'pending' tasks can be claimed.",
message=msg,
)
# Check for paused tasks
if agent_paused_tasks:
paused_ids = [t.get("id") for t in agent_paused_tasks]
if ctx.agent_paused_tasks:
paused_ids = [t.get("id") for t in ctx.agent_paused_tasks]
raise TaskOwnershipError(
agent_id=agent_id,
task_id=task_id,
agent_id=ctx.agent_id,
task_id=ctx.task_id,
action="claim",
message=f"You have {len(agent_paused_tasks)} paused task(s). "
message=f"You have {len(ctx.agent_paused_tasks)} paused task(s). "
f"Resume paused work before claiming new tasks. Paused: {paused_ids}",
)
# Check for active tasks
active = [
t for t in agent_active_tasks if not is_waiting_state(t.get("status", ""))
t for t in ctx.agent_active_tasks if not is_waiting_state(t.get("status", ""))
]
if active:
raise TaskOwnershipError(
agent_id=agent_id,
task_id=task_id,
agent_id=ctx.agent_id,
task_id=ctx.task_id,
action="claim",
message=f"You already have an active task: {active[0].get('id')}. "
"Complete or pause it before claiming new work.",
)
# Check team match (warning only - agents can claim cross-team if needed)
agent_team = get_agent_team(agent_id)
if agent_team and agent_team != task_team:
agent_team = get_agent_team(ctx.agent_id)
if agent_team and agent_team != ctx.task_team:
# This is allowed but unusual - could log a warning
pass
@@ -200,6 +206,4 @@ def can_review_task(
Returns:
True if can review
"""
if agent_id == task_developed_by:
return False
return True
return agent_id != task_developed_by
+7 -6
View File
@@ -264,16 +264,17 @@ class EventBus:
logger.error("Failed to handle message", error=str(e))
# Global event bus instance
_event_bus: EventBus | None = None
class _EventBusHolder:
"""Holder for singleton EventBus instance."""
instance: EventBus | None = None
def get_event_bus() -> EventBus:
"""Get or create the global event bus instance."""
global _event_bus
if _event_bus is None:
_event_bus = EventBus()
return _event_bus
if _EventBusHolder.instance is None:
_EventBusHolder.instance = EventBus()
return _EventBusHolder.instance
async def init_event_bus() -> EventBus:
+5 -5
View File
@@ -33,7 +33,7 @@ async def handle_task_status_change(event: Event) -> None:
)
# Import here to avoid circular imports
from roboco.services.notification import NotificationService
from roboco.services.notification import NotificationService # noqa: PLC0415
notification_service = NotificationService()
@@ -129,7 +129,7 @@ async def handle_handoff_created(event: Event) -> None:
from_agent=from_agent,
)
from roboco.services.notification import NotificationService
from roboco.services.notification import NotificationService # noqa: PLC0415
notification_service = NotificationService()
@@ -167,7 +167,7 @@ async def handle_qa_result(event: Event) -> None:
# Get orchestrator instance (if running)
try:
from roboco.bootstrap import _orchestrator
from roboco.bootstrap import _orchestrator # noqa: PLC0415
if _orchestrator and developer_id:
waiting = _orchestrator.get_waiting_agents()
@@ -207,7 +207,7 @@ async def handle_blocker_resolved(event: Event) -> None:
# Resume agent if waiting
try:
from roboco.bootstrap import _orchestrator
from roboco.bootstrap import _orchestrator # noqa: PLC0415
if _orchestrator and agent_id:
waiting = _orchestrator.get_waiting_agents()
@@ -244,7 +244,7 @@ async def handle_question_answered(event: Event) -> None:
# Resume agent if waiting
try:
from roboco.bootstrap import _orchestrator
from roboco.bootstrap import _orchestrator # noqa: PLC0415
if _orchestrator and agent_id:
waiting = _orchestrator.get_waiting_agents()
+2 -1
View File
@@ -217,8 +217,9 @@ class TaskLifecycleError(TaskError):
target_status: str,
details: dict[str, Any] | None = None,
):
msg = f"Cannot transition task from '{current_status}' to '{target_status}'"
super().__init__(
message=f"Cannot transition task from '{current_status}' to '{target_status}'",
message=msg,
task_id=task_id,
code="TASK_LIFECYCLE_ERROR",
details={
+2 -2
View File
@@ -18,8 +18,8 @@ if TYPE_CHECKING:
def add_app_context(
logger: logging.Logger,
method_name: str,
_logger: logging.Logger,
_method_name: str,
event_dict: dict[str, Any],
) -> dict[str, Any]:
"""Add application context to all log entries."""
+26 -10
View File
@@ -17,6 +17,7 @@ Tools:
from typing import Any
import httpx
from fastapi import status
from mcp.server.fastmcp import FastMCP
from roboco.config import settings
@@ -64,7 +65,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
mcp.agent_id = agent_id
# =========================================================================
# GENERAL ENTRY
@@ -251,7 +252,8 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
Returns:
Created decision log entry
"""
if len(options) < 2:
two = 2
if len(options) < two:
return _format_error_response(
"INVALID_OPTIONS",
"Decision log requires at least 2 options",
@@ -464,7 +466,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != 200:
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"SEARCH_FAILED",
"Failed to search journal",
@@ -513,8 +515,16 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
stats = stats_resp.json() if stats_resp.status_code == 200 else {}
growth = growth_resp.json() if growth_resp.status_code == 200 else {}
stats = (
stats_resp.json()
if stats_resp.status_code == status.HTTP_200_OK
else {}
)
growth = (
growth_resp.json()
if growth_resp.status_code == status.HTTP_200_OK
else {}
)
return {
"total_entries": stats.get("total_entries", 0),
@@ -548,9 +558,13 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
List recent journal entries.
Args:
entry_type: Optional filter by type (general, task_reflection, decision_log, learning, struggle)
task_id: Optional filter by related task
limit: Maximum entries to return
entry_type:
Optional filter by type
(general, task_reflection, decision_log, learning, struggle)
task_id:
Optional filter by related task
limit:
Maximum entries to return
Returns:
Recent journal entries
@@ -568,7 +582,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != 200:
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"LIST_FAILED",
"Failed to list entries",
@@ -591,7 +605,9 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
two = 2
if len(sys.argv) < two:
print("Usage: python journal_server.py <agent_id>")
sys.exit(1)
+100 -73
View File
@@ -16,6 +16,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
from fastapi import status
from mcp.server.fastmcp import FastMCP
from roboco.agents_config import CHANNEL_ACCESS
@@ -81,7 +82,7 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
mcp.agent_id = agent_id
# =========================================================================
# CHANNEL LISTING
@@ -155,7 +156,7 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
params={"slug": channel_slug},
)
if channels_resp.status_code != 200:
if channels_resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch channels")
channels = channels_resp.json()
@@ -175,7 +176,7 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
},
)
if messages_resp.status_code != 200:
if messages_resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch messages")
messages = messages_resp.json()
@@ -192,6 +193,76 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
# SEND MESSAGE
# =========================================================================
def _validate_message_send(
channel_slug: str,
content: str,
message_type: str,
) -> dict[str, Any] | None:
"""Validate message send parameters. Returns error dict or None if valid."""
valid_types = [
"reasoning",
"dialogue",
"decision",
"action",
"blocker",
"technical",
]
if message_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
)
if not _check_channel_access(agent_id, channel_slug, "write"):
return _format_error_response(
"ACCESS_DENIED",
f"You don't have write access to #{channel_slug}",
{
"your_writable_channels": [
ch
for ch in CHANNEL_ACCESS
if _check_channel_access(agent_id, ch, "write")
]
},
)
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
return _format_error_response(
"SILENT_OBSERVER",
"You are a silent observer on this channel and cannot post messages.",
)
if not content or not content.strip():
return _format_error_response(
"EMPTY_CONTENT",
"Message content cannot be empty.",
)
return None
async def _get_or_create_session(
client: httpx.AsyncClient,
channel_id: str,
) -> str | dict[str, Any]:
"""Get or create session for channel. Returns session_id or error dict."""
session_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/session",
)
if session_resp.status_code == status.HTTP_200_OK:
return session_resp.json()["id"]
create_resp = await client.post(
f"{_get_api_url()}/sessions",
json={"channel_id": channel_id},
)
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
return create_resp.json()["id"]
return _format_error_response(
"SESSION_ERROR", "Failed to get or create session"
)
@mcp.tool()
async def roboco_message_send(
channel_slug: str,
@@ -220,56 +291,23 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
Returns:
Sent message with confirmation
"""
# Validate message type
valid_types = [
"reasoning",
"dialogue",
"decision",
"action",
"blocker",
"technical",
]
if message_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
)
# Check write access
if not _check_channel_access(agent_id, channel_slug, "write"):
return _format_error_response(
"ACCESS_DENIED",
f"You don't have write access to #{channel_slug}",
{
"your_writable_channels": [
ch
for ch in CHANNEL_ACCESS
if _check_channel_access(agent_id, ch, "write")
]
},
)
# Silent observers cannot write even if in read list
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
return _format_error_response(
"SILENT_OBSERVER",
"You are a silent observer on this channel and cannot post messages.",
)
if not content or not content.strip():
return _format_error_response(
"EMPTY_CONTENT",
"Message content cannot be empty.",
)
# Validate inputs
if validation_error := _validate_message_send(
channel_slug, content, message_type
):
return validation_error
async with httpx.AsyncClient() as client:
# Get channel and active session
# Get channel
channels_resp = await client.get(
f"{_get_api_url()}/channels",
params={"slug": channel_slug},
)
if channels_resp.status_code != 200 or not channels_resp.json():
if (
channels_resp.status_code != status.HTTP_200_OK
or not channels_resp.json()
):
return _format_error_response(
"NOT_FOUND", f"Channel #{channel_slug} not found"
)
@@ -277,26 +315,13 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
channel = channels_resp.json()[0]
channel_id = channel["id"]
# Get or create session for the channel
session_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/session",
)
# Get or create session
session_result = await _get_or_create_session(client, channel_id)
if isinstance(session_result, dict):
return session_result # Error response
session_id = session_result
if session_resp.status_code != 200:
# Create a new session
create_resp = await client.post(
f"{_get_api_url()}/sessions",
json={"channel_id": channel_id},
)
if create_resp.status_code not in [200, 201]:
return _format_error_response(
"SESSION_ERROR", "Failed to get or create session"
)
session_id = create_resp.json()["id"]
else:
session_id = session_resp.json()["id"]
# Build message payload
# Build and send message
message_data = {
"session_id": session_id,
"type": message_type,
@@ -307,25 +332,25 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
"task_id": task_id,
}
# Send message
send_resp = await client.post(
f"{_get_api_url()}/messages",
json=message_data,
headers={"X-Agent-Id": agent_id},
)
if send_resp.status_code not in [200, 201]:
if send_resp.status_code not in [
status.HTTP_200_OK,
status.HTTP_201_CREATED,
]:
return _format_error_response(
"SEND_FAILED",
"Failed to send message",
{"api_error": send_resp.text},
)
message = send_resp.json()
return {
"status": "sent",
"message": message,
"message": send_resp.json(),
"channel": channel_slug,
"guidance": "Message sent successfully.",
}
@@ -348,12 +373,12 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/messages/{message_id}")
if resp.status_code == 404:
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response(
"NOT_FOUND", f"Message {message_id} not found"
)
if resp.status_code != 200:
if resp.status_code != status.HTTP_200_OK:
return _format_error_response("API_ERROR", "Failed to fetch message")
message = resp.json()
@@ -475,7 +500,9 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
two = 2
if len(sys.argv) < two:
print("Usage: python message_server.py <agent_id>")
sys.exit(1)
+14 -11
View File
@@ -14,6 +14,7 @@ Tools:
from typing import Any
import httpx
from fastapi import status
from mcp.server.fastmcp import FastMCP
from roboco.agents_config import (
@@ -104,7 +105,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
mcp.agent_id = agent_id
# =========================================================================
# LIST NOTIFICATIONS
@@ -140,7 +141,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != 200:
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"API_ERROR", "Failed to fetch notifications"
)
@@ -194,16 +195,16 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == 404:
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == 403:
if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response(
"NOT_RECIPIENT",
"You are not a recipient of this notification",
)
if resp.status_code != 200:
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"API_ERROR", "Failed to fetch notification"
)
@@ -246,22 +247,22 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == 404:
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == 403:
if resp.status_code == status.HTTP_403_FORBIDDEN:
return _format_error_response(
"NOT_RECIPIENT",
"You are not a recipient of this notification",
)
if resp.status_code == 400:
if resp.status_code == status.HTTP_400_BAD_REQUEST:
return _format_error_response(
"NO_ACK_REQUIRED",
"This notification does not require acknowledgment",
)
if resp.status_code != 200:
if resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"API_ERROR", "Failed to acknowledge notification"
)
@@ -367,7 +368,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
if resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
return _format_error_response(
"SEND_FAILED",
"Failed to send notification",
@@ -477,7 +478,9 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
two = 2
if len(sys.argv) < two:
print("Usage: python notify_server.py <agent_id>")
sys.exit(1)
+81 -68
View File
@@ -23,6 +23,7 @@ Tools:
from typing import Any
import httpx
from fastapi import status
from mcp.server.fastmcp import FastMCP
from roboco.config import settings
@@ -169,7 +170,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
mcp = FastMCP(f"roboco-task-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
mcp.agent_id = agent_id
# =========================================================================
# TASK SCANNING
@@ -199,7 +200,11 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
f"{_get_api_url()}/tasks",
params={"assigned_to": agent_id, "status": "paused"},
)
paused_tasks = paused_resp.json() if paused_resp.status_code == 200 else []
paused_tasks = (
paused_resp.json()
if paused_resp.status_code == status.HTTP_200_OK
else []
)
# Get assigned tasks (claimed, in_progress)
assigned_resp = await client.get(
@@ -207,7 +212,9 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
params={"assigned_to": agent_id},
)
assigned_data = (
assigned_resp.json() if assigned_resp.status_code == 200 else []
assigned_resp.json()
if assigned_resp.status_code == status.HTTP_200_OK
else []
)
assigned_tasks = [
t
@@ -225,7 +232,9 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
params=params,
)
available_tasks = (
available_resp.json() if available_resp.status_code == 200 else []
available_resp.json()
if available_resp.status_code == status.HTTP_200_OK
else []
)
# Determine guidance
@@ -275,7 +284,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if resp.status_code == 404:
if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response(
"NOT_FOUND",
f"Task {task_id} not found",
@@ -312,7 +321,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
f"{_get_api_url()}/tasks",
params={"assigned_to": agent_id},
)
if active_resp.status_code == 200:
if active_resp.status_code == status.HTTP_200_OK:
active_tasks = active_resp.json()
# Check for non-waiting active tasks
blocking_tasks = [
@@ -340,7 +349,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
# Get the task to check status
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -358,7 +367,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
json={"agent_id": agent_id},
)
if claim_resp.status_code != 200:
if claim_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"CLAIM_FAILED",
"Failed to claim task",
@@ -374,7 +383,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
proj_resp = await client.get(
f"{_get_api_url()}/projects/{claimed_task['project_id']}"
)
if proj_resp.status_code == 200:
if proj_resp.status_code == status.HTTP_200_OK:
project = proj_resp.json()
return _format_task_response(
@@ -419,7 +428,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
# Verify task state and ownership
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -461,7 +470,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
json={"plan": plan_data},
)
if update_resp.status_code != 200:
if update_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"UPDATE_FAILED",
"Failed to save plan",
@@ -490,6 +499,41 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
# TASK START
# =========================================================================
def _validate_task_start(task: dict[str, Any]) -> dict[str, Any] | None:
"""Validate task can be started. Returns error dict or None if valid."""
if task.get("assigned_to") != agent_id:
return _format_error_response(
"NOT_OWNER", "You are not assigned to this task"
)
task_status = task.get("status")
if task_status not in ["claimed", "paused"]:
return _format_error_response(
"INVALID_STATE",
f"Cannot start task in '{task_status}' status. Task must be 'claimed' or 'paused'.",
{"current_status": task_status},
)
if task_status == "claimed" and not task.get("plan"):
return _format_error_response(
"NO_PLAN",
"Cannot start without a plan. Call roboco_task_plan first.",
)
plan = task.get("plan", {})
unanswered = [
q for q in plan.get("open_questions", []) if not q.get("answered")
]
if unanswered:
return _format_error_response(
"UNANSWERED_QUESTIONS",
f"Cannot start with {len(unanswered)} unanswered question(s). "
"Get answers first, then update the plan.",
{"questions": [q.get("question") for q in unanswered]},
)
return None
@mcp.tool()
async def roboco_task_start(task_id: str) -> dict[str, Any]:
"""
@@ -508,59 +552,26 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
if task.get("assigned_to") != agent_id:
return _format_error_response(
"NOT_OWNER",
"You are not assigned to this task",
)
if task.get("status") not in ["claimed", "paused"]:
return _format_error_response(
"INVALID_STATE",
f"Cannot start task in '{task.get('status')}' status. "
"Task must be 'claimed' or 'paused'.",
{"current_status": task.get("status")},
)
# Check for plan (if claimed)
if task.get("status") == "claimed" and not task.get("plan"):
return _format_error_response(
"NO_PLAN",
"Cannot start without a plan. Call roboco_task_plan first.",
)
# Check for unanswered questions
plan = task.get("plan", {})
unanswered = [
q for q in plan.get("open_questions", []) if not q.get("answered")
]
if unanswered:
return _format_error_response(
"UNANSWERED_QUESTIONS",
f"Cannot start with {len(unanswered)} unanswered question(s). "
"Get answers first, then update the plan.",
{"questions": [q.get("question") for q in unanswered]},
)
if validation_error := _validate_task_start(task):
return validation_error
# Start the task
start_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/start")
if start_resp.status_code != 200:
if start_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"START_FAILED",
"Failed to start task",
{"api_error": start_resp.text},
)
started_task = start_resp.json()
return _format_task_response(
started_task,
start_resp.json(),
"EXECUTE",
"Task started. Work through your plan step by step:\n"
"1. Implement each sub-task\n"
@@ -593,7 +604,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -619,7 +630,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
},
)
if progress_resp.status_code != 200:
if progress_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"UPDATE_FAILED",
"Failed to update progress",
@@ -668,7 +679,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -694,7 +705,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
},
)
if block_resp.status_code != 200:
if block_resp.status_code != status.HTTP_200_OK:
return _format_error_response("BLOCK_FAILED", "Failed to block task")
blocked_task = block_resp.json()
@@ -727,7 +738,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -747,7 +758,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
f"{_get_api_url()}/tasks/{task_id}/unblock"
)
if unblock_resp.status_code != 200:
if unblock_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"UNBLOCK_FAILED", "Failed to unblock task"
)
@@ -789,7 +800,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -819,7 +830,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
# Pause the task
pause_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/pause")
if pause_resp.status_code != 200:
if pause_resp.status_code != status.HTTP_200_OK:
return _format_error_response("PAUSE_FAILED", "Failed to pause task")
paused_task = pause_resp.json()
@@ -854,7 +865,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -880,7 +891,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
verify_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/verify")
if verify_resp.status_code != 200:
if verify_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"VERIFY_FAILED", "Failed to submit for verification"
)
@@ -929,7 +940,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -957,7 +968,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
# Submit for QA
qa_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/submit-qa")
if qa_resp.status_code != 200:
if qa_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"SUBMIT_FAILED", "Failed to submit for QA"
)
@@ -1001,7 +1012,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -1024,7 +1035,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
json={"notes": qa_notes},
)
if pass_resp.status_code != 200:
if pass_resp.status_code != status.HTTP_200_OK:
return _format_error_response("QA_FAILED", "Failed to pass QA")
passed_task = pass_resp.json()
@@ -1072,7 +1083,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -1092,7 +1103,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
json={"notes": full_notes},
)
if fail_resp.status_code != 200:
if fail_resp.status_code != status.HTTP_200_OK:
return _format_error_response("QA_FAILED", "Failed to fail QA")
failed_task = fail_resp.json()
@@ -1126,7 +1137,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}")
if task_resp.status_code == 404:
if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
task = task_resp.json()
@@ -1141,7 +1152,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
f"{_get_api_url()}/tasks/{task_id}/complete"
)
if complete_resp.status_code != 200:
if complete_resp.status_code != status.HTTP_200_OK:
return _format_error_response(
"COMPLETE_FAILED", "Failed to complete task"
)
@@ -1164,7 +1175,9 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
two = 2
if len(sys.argv) < two:
print("Usage: python task_server.py <agent_id>")
sys.exit(1)
+1 -1
View File
@@ -188,5 +188,5 @@ GroupID = Annotated[UUID, Field(description="Unique group identifier")]
class TimestampMixin(RobocoBase):
"""Mixin for models that track creation and update times."""
created_at: datetime = Field(default_factory=datetime.now(UTC))
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime | None = None
+1 -1
View File
@@ -171,7 +171,7 @@ def create_announcements_channel(
name="#announcements",
slug="announcements",
type=ChannelType.SPECIAL,
description="Company-wide announcements (read-only except for Board and Main PM)",
description="Company-wide announcements (read-only except for Board and Main PM)", # noqa: E501
members=all_agent_ids,
writers=[*board_ids, main_pm_id],
silent_observers=[auditor_id],
+2 -2
View File
@@ -45,7 +45,7 @@ class JournalEntry(TimestampMixin):
)
# Metadata
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
tags: list[str] = Field(default_factory=list, description="Tags for categorization")
# Embedding for RAG search
@@ -56,7 +56,7 @@ class JournalEntry(TimestampMixin):
# Sentiment/mood tracking (for growth analysis)
sentiment: str | None = Field(
default=None,
description="Sentiment indicator (positive, neutral, negative, frustrated, confident, etc.)",
description="Sentiment indicator (positive, neutral, negative, frustrated, confident, etc.)", # noqa: E501
)
# Visibility
+3 -3
View File
@@ -24,7 +24,7 @@ from roboco.models.base import (
class MessageEdit(RobocoBase):
"""Tracks edits to messages. Agents can only edit their own messages."""
edited_at: datetime = Field(default_factory=datetime.now(UTC))
edited_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
previous_content: str = Field(..., description="Content before the edit")
edit_reason: str | None = Field(default=None, description="Why the edit was made")
@@ -40,7 +40,7 @@ class RawStream(RobocoBase):
agent_id: UUID = Field(..., description="Agent producing the stream")
channel_id: UUID = Field(..., description="Target channel")
chunk: str = Field(..., description="Raw LLM output chunk")
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
# =============================================================================
@@ -90,7 +90,7 @@ class ExtractedMessage(TimestampMixin):
)
# Metadata
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
# Embedding for RAG (stored as list of floats, actual Vector type in DB)
embedding: list[float] | None = Field(
+2 -2
View File
@@ -62,7 +62,7 @@ class Notification(TimestampMixin):
)
# Timing
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
expires_at: datetime | None = Field(
default=None, description="Expiration time if applicable"
)
@@ -225,7 +225,7 @@ def create_priority_change(
from_agent=from_agent,
to_agents=to_agents,
subject=f"Priority Changed: {task_title}",
body=f"Task '{task_title}' priority changed to {priority_labels.get(new_priority, f'P{new_priority}')}",
body=f"Task '{task_title}' priority changed to {priority_labels.get(new_priority, f'P{new_priority}')}", # noqa: E501
related_task_id=task_id,
)
+2 -2
View File
@@ -73,8 +73,8 @@ class Session(TimestampMixin):
status: SessionStatus = Field(default=SessionStatus.ACTIVE)
# Timestamps
started_at: datetime = Field(default_factory=datetime.now(UTC))
last_activity_at: datetime = Field(default_factory=datetime.now(UTC))
started_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
last_activity_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
closed_at: datetime | None = None
# Statistics
+3 -3
View File
@@ -29,7 +29,7 @@ class CommitRef(RobocoBase):
hash: str = Field(..., min_length=7, max_length=40, description="Git commit hash")
message: str = Field(..., description="Commit message summary")
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
author_agent_id: UUID | None = Field(
default=None, description="Agent who made the commit"
)
@@ -58,7 +58,7 @@ class FileRef(RobocoBase):
class ProgressUpdate(RobocoBase):
"""A progress update on a task."""
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
agent_id: UUID = Field(..., description="Agent providing update")
message: str = Field(..., description="Progress message")
percentage: int | None = Field(
@@ -70,7 +70,7 @@ class Checkpoint(RobocoBase):
"""A saved state checkpoint for task recovery."""
id: UUID = Field(default_factory=uuid4)
timestamp: datetime = Field(default_factory=datetime.now(UTC))
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
agent_id: UUID = Field(..., description="Agent who created checkpoint")
state_summary: str = Field(..., description="Summary of current state")
remaining_work: list[str] = Field(
+2 -1
View File
@@ -602,7 +602,8 @@ Start by:
instance.error_count += 1
# Auto-restart if not too many errors
if instance.error_count < 3:
max_retries = 3
if instance.error_count < max_retries:
logger.info("Auto-restarting agent", agent_id=agent_id)
await self.spawn_agent(
agent_id=agent_id,
+5 -1
View File
@@ -26,6 +26,8 @@ from roboco.models.message import ExtractedMessage
logger = structlog.get_logger()
# Maximum length for raw excerpt storage
MAX_EXCERPT_LENGTH = 200
# =============================================================================
# EXTRACTION PATTERNS
@@ -272,7 +274,9 @@ class ExtractionService:
mentions=mentions,
task_id=task_id,
confidence=confidence,
raw_excerpt=segment[:200] if len(segment) > 200 else segment,
raw_excerpt=segment[:MAX_EXCERPT_LENGTH]
if len(segment) > MAX_EXCERPT_LENGTH
else segment,
)
messages.append(message)
+1 -1
View File
@@ -316,7 +316,7 @@ class JournalService:
query = query.where(JournalEntryTable.task_id == task_id)
if not include_private:
query = query.where(JournalEntryTable.is_private == False) # noqa: E712
query = query.where(JournalEntryTable.is_private is False)
query = query.order_by(JournalEntryTable.timestamp.desc())
query = query.limit(limit).offset(offset)
+76 -53
View File
@@ -103,7 +103,8 @@ class KanbanService:
"""
Get the developer kanban board for a cell.
Columns: Backlog Assigned In Progress Blocked QA Review Documenting Done
Columns:
Backlog Assigned In Progress Blocked QA Review Documenting Done
"""
# Get all tasks for the team
result = await self.session.execute(
@@ -158,7 +159,7 @@ class KanbanService:
return KanbanBoard(
id=f"{board_type.value}-{team.value if team else 'all'}",
title=f"{team.value.title() if team else 'All'} {board_type.value.title()} Board",
title=f"{team.value.title() if team else 'All'} {board_type.value.title()} Board", # noqa: E501
board_type=board_type,
team=team,
columns=list(columns.values()),
@@ -167,51 +168,52 @@ class KanbanService:
last_updated=datetime.now(UTC),
)
async def _build_swimlane_board(
self,
tasks: list[TaskTable],
team: Team | None,
board_type: KanbanBoardType,
swimlane_by: str,
) -> KanbanBoard:
"""Build a board with swimlanes."""
column_config = get_column_config(board_type)
# Pre-fetch agent names for assignee swimlanes
agent_names: dict[str, str] = {}
def _get_swimlane_key(self, task: TaskTable, swimlane_by: str) -> str:
"""Get the swimlane key for a task."""
if swimlane_by == "priority":
return f"P{task.priority}"
if swimlane_by == "assignee":
# Get all unique assignee IDs
return str(task.assigned_to) if task.assigned_to else "Unassigned"
return "default"
def _get_swimlane_title(
self,
lane_key: str,
swimlane_by: str,
agent_names: dict[str, str],
) -> str:
"""Get the display title for a swimlane."""
if swimlane_by == "priority":
return f"Priority {lane_key}"
if swimlane_by == "assignee":
return (
"Unassigned"
if lane_key == "Unassigned"
else agent_names.get(lane_key, lane_key)
)
return lane_key
async def _fetch_agent_names(self, tasks: list[TaskTable]) -> dict[str, str]:
"""Fetch agent names for all assignees in tasks."""
assignee_ids = {t.assigned_to for t in tasks if t.assigned_to}
if assignee_ids:
if not assignee_ids:
return {}
agent_result = await self.session.execute(
select(AgentTable).where(AgentTable.id.in_(assignee_ids))
)
for agent in agent_result.scalars().all():
agent_names[str(agent.id)] = agent.name
return {str(agent.id): agent.name for agent in agent_result.scalars().all()}
# Group tasks by swimlane key
swimlane_groups: dict[str, list[TaskTable]] = {}
for task in tasks:
if swimlane_by == "priority":
key = f"P{task.priority}"
elif swimlane_by == "assignee":
key = str(task.assigned_to) if task.assigned_to else "Unassigned"
else:
key = "default"
if key not in swimlane_groups:
swimlane_groups[key] = []
swimlane_groups[key].append(task)
# Build swimlanes
swimlanes: list[KanbanSwimlane] = []
async def _build_swimlane_columns(
self,
lane_key: str,
lane_tasks: list[TaskTable],
column_config: list,
) -> tuple[list[KanbanColumn], int]:
"""Build columns for a swimlane. Returns (columns, blocked_count)."""
columns: list[KanbanColumn] = []
blocked_count = 0
for lane_key in sorted(swimlane_groups.keys()):
lane_tasks = swimlane_groups[lane_key]
# Create columns for this swimlane
columns: list[KanbanColumn] = []
for col_id, col_title, col_status in column_config:
cards = [
await self._task_to_card(t, lane_key)
@@ -229,34 +231,55 @@ class KanbanService:
)
blocked_count += sum(1 for c in cards if c.is_blocked)
# Get lane title
if swimlane_by == "priority":
lane_title = f"Priority {lane_key}"
elif swimlane_by == "assignee":
if lane_key == "Unassigned":
lane_title = "Unassigned"
else:
# Look up agent name from pre-fetched map
lane_title = agent_names.get(lane_key, lane_key)
else:
lane_title = lane_key
return columns, blocked_count
async def _build_swimlane_board(
self,
tasks: list[TaskTable],
team: Team | None,
board_type: KanbanBoardType,
swimlane_by: str,
) -> KanbanBoard:
"""Build a board with swimlanes."""
column_config = get_column_config(board_type)
# Pre-fetch agent names for assignee swimlanes
agent_names = (
await self._fetch_agent_names(tasks) if swimlane_by == "assignee" else {}
)
# Group tasks by swimlane key
swimlane_groups: dict[str, list[TaskTable]] = {}
for task in tasks:
key = self._get_swimlane_key(task, swimlane_by)
swimlane_groups.setdefault(key, []).append(task)
# Build swimlanes
swimlanes: list[KanbanSwimlane] = []
total_blocked = 0
for lane_key in sorted(swimlane_groups.keys()):
columns, blocked = await self._build_swimlane_columns(
lane_key, swimlane_groups[lane_key], column_config
)
total_blocked += blocked
swimlanes.append(
KanbanSwimlane(
id=lane_key,
title=lane_title,
title=self._get_swimlane_title(lane_key, swimlane_by, agent_names),
columns=columns,
)
)
return KanbanBoard(
id=f"{board_type.value}-{team.value if team else 'all'}-swimlane",
title=f"{team.value.title() if team else 'All'} {board_type.value.title()} Board",
title=f"{team.value.title() if team else 'All'} {board_type.value.title()} Board", # noqa: E501
board_type=board_type,
team=team,
swimlanes=swimlanes,
total_cards=len(tasks),
blocked_count=blocked_count,
blocked_count=total_blocked,
last_updated=datetime.now(UTC),
)
+7 -2
View File
@@ -565,9 +565,14 @@ class MetricsService:
blocked_ratio = blocked_count / active_count if active_count > 0 else 0
# Determine status
if blocked_ratio > 0.3:
three_tenths = 0.3
fifteen_hundredths = 0.15
five = 5
if blocked_ratio > three_tenths:
status = "critical"
elif blocked_ratio > 0.15 or (active_count > 5 and completed_count == 0):
elif blocked_ratio > fifteen_hundredths or (
active_count > five and completed_count == 0
):
status = "slow"
else:
status = "ok"
+39 -6
View File
@@ -32,13 +32,18 @@ class NotificationService:
)
# System notifications bypass normal permission checks
body = (
f"Task {task_id} has been blocked.\n\n"
f"Reason: {blocker_reason}\n\n"
"Please investigate and help resolve."
)
await self._create_notification(
notification_type=NotificationType.ESCALATION,
priority=NotificationPriority.HIGH,
from_agent=from_agent or "system",
to_agents=[to_pm],
subject=f"Task {task_id} is blocked",
body=f"Task {task_id} has been blocked.\n\nReason: {blocker_reason}\n\nPlease investigate and help resolve.",
body=body,
related_task_id=task_id,
)
@@ -55,13 +60,17 @@ class NotificationService:
to_qa=to_qa,
)
body = (
f"Task {task_id} has been submitted for QA review.\n\n"
"Please review the implementation and acceptance criteria."
)
await self._create_notification(
notification_type=NotificationType.TASK_ASSIGNMENT,
priority=NotificationPriority.NORMAL,
from_agent=from_agent or "system",
to_agents=[to_qa],
subject=f"Task {task_id} ready for QA",
body=f"Task {task_id} has been submitted for QA review.\n\nPlease review the implementation and acceptance criteria.",
body=body,
related_task_id=task_id,
)
@@ -78,13 +87,18 @@ class NotificationService:
to_developer=to_developer,
)
body = (
f"Task {task_id} did not pass QA review.\n\n"
f"QA Notes:\n{qa_notes}\n\n"
"Please address the feedback and resubmit."
)
await self._create_notification(
notification_type=NotificationType.STATUS_CHANGE,
priority=NotificationPriority.HIGH,
from_agent="system",
to_agents=[to_developer],
subject=f"Task {task_id} needs revision",
body=f"Task {task_id} did not pass QA review.\n\nQA Notes:\n{qa_notes}\n\nPlease address the feedback and resubmit.",
body=body,
related_task_id=task_id,
)
@@ -101,13 +115,17 @@ class NotificationService:
to_documenter=to_documenter,
)
body = (
f"Task {task_id} has passed QA and is ready for documentation.\n\n"
"Please create the handoff documentation."
)
await self._create_notification(
notification_type=NotificationType.TASK_ASSIGNMENT,
priority=NotificationPriority.NORMAL,
from_agent=from_agent or "system",
to_agents=[to_documenter],
subject=f"Task {task_id} ready for documentation",
body=f"Task {task_id} has passed QA and is ready for documentation.\n\nPlease create the handoff documentation.",
body=body,
related_task_id=task_id,
)
@@ -126,13 +144,18 @@ class NotificationService:
to_documenter=to_documenter,
)
body = (
f"A handoff document has been created for task {task_id}.\n\n"
f"Handoff ID: {handoff_id}\n\n"
"Please review and complete the documentation."
)
await self._create_notification(
notification_type=NotificationType.HANDOFF,
priority=NotificationPriority.NORMAL,
from_agent=from_agent or "system",
to_agents=[to_documenter],
subject=f"Handoff ready for task {task_id}",
body=f"A handoff document has been created for task {task_id}.\n\nHandoff ID: {handoff_id}\n\nPlease review and complete the documentation.",
body=body,
related_task_id=task_id,
)
@@ -152,14 +175,24 @@ class NotificationService:
async with get_async_session() as session:
# Look up agent UUIDs from agent_ids
# For now, we store the string IDs - in production would look up UUIDs
# Use from_agent if provided, otherwise system agent
sender_uuid = (
self._agent_id_to_uuid(from_agent)
if from_agent != "system"
else self._get_system_agent_uuid()
)
# Convert task_id to UUID if provided
task_uuid = UUID(related_task_id) if related_task_id else None
notification = NotificationTable(
type=notification_type,
priority=priority,
from_agent=self._get_system_agent_uuid(),
from_agent=sender_uuid,
to_agents=[self._agent_id_to_uuid(a) for a in to_agents],
subject=subject,
body=body,
requires_ack=True,
related_task_id=task_uuid,
)
session.add(notification)
+11 -11
View File
@@ -454,22 +454,22 @@ Tags: {", ".join(tags or [])}
logger.info("Refreshed index", index_type=index_type.value, sources=sources)
# Global service instance
_optimal_service: OptimalService | None = None
class _OptimalServiceHolder:
"""Holder for singleton OptimalService instance."""
instance: OptimalService | None = None
async def get_optimal_service() -> OptimalService:
"""Get or create the OptimalService instance."""
global _optimal_service
if _optimal_service is None:
_optimal_service = OptimalService()
await _optimal_service.initialize()
return _optimal_service
if _OptimalServiceHolder.instance is None:
_OptimalServiceHolder.instance = OptimalService()
await _OptimalServiceHolder.instance.initialize()
return _OptimalServiceHolder.instance
async def close_optimal_service() -> None:
"""Close the OptimalService instance."""
global _optimal_service
if _optimal_service is not None:
await _optimal_service.close()
_optimal_service = None
if _OptimalServiceHolder.instance is not None:
await _OptimalServiceHolder.instance.close()
_OptimalServiceHolder.instance = None
+24 -25
View File
@@ -496,10 +496,9 @@ class PermissionService:
# Check role-based access
if agent.role in permission.read_roles:
# For cell channels, also check team membership
if permission.channel_type == ChannelType.CELL:
if permission.teams and agent.team not in permission.teams:
return False
return True
is_cell = permission.channel_type == ChannelType.CELL
wrong_team = permission.teams and agent.team not in permission.teams
return not (is_cell and wrong_team)
# Higher permission levels can read lower-level channels
return agent.level <= PermissionLevel.MAIN_PM
@@ -527,10 +526,9 @@ class PermissionService:
# Check role-based access
if agent.role in permission.write_roles:
# For cell channels, also check team membership
if permission.channel_type == ChannelType.CELL:
if permission.teams and agent.team not in permission.teams:
return False
return True
is_cell = permission.channel_type == ChannelType.CELL
wrong_team = permission.teams and agent.team not in permission.teams
return not (is_cell and wrong_team)
# Higher permission levels can write to lower-level channels
return agent.level <= PermissionLevel.MAIN_PM
@@ -579,12 +577,14 @@ class PermissionService:
# Check if recipient role is in allowed targets
if recipient.role in allowed_targets:
# For Cell PM, also check team membership
if sender.role == AgentRole.CELL_PM:
# Cell PM can only notify their own cell (unless coordinating with other PMs)
if recipient.role != AgentRole.CELL_PM:
if sender.team != recipient.team:
return False
return True
# Cell PM can only notify their own cell unless coordinating with PMs
is_cell_pm_sender = sender.role == AgentRole.CELL_PM
is_not_pm_recipient = recipient.role != AgentRole.CELL_PM
is_different_team = sender.team != recipient.team
cannot_notify = (
is_cell_pm_sender and is_not_pm_recipient and is_different_team
)
return not cannot_notify
return False
@@ -607,14 +607,14 @@ class PermissionService:
if recipient.role in allowed:
# For cell members, check if same team
if sender.level >= PermissionLevel.CELL_MEMBER:
if recipient.level >= PermissionLevel.CELL_MEMBER:
# Cell members can only communicate within their cell
# unless going through PM
if sender.team != recipient.team:
# Exception: going through shared channels
return False
return True
sender_is_cell_member = sender.level >= PermissionLevel.CELL_MEMBER
recipient_is_cell_member = recipient.level >= PermissionLevel.CELL_MEMBER
different_teams = sender.team != recipient.team
cross_cell = (
sender_is_cell_member and recipient_is_cell_member and different_teams
)
return not cross_cell
return False
@@ -633,10 +633,9 @@ class PermissionService:
if action in allowed_actions:
# VIEW_OWN means only own cell
if action == TaskAction.VIEW_OWN and task_team:
if agent.team and agent.team != task_team:
return False
return True
is_view_own = action == TaskAction.VIEW_OWN and task_team
wrong_team = agent.team and agent.team != task_team
return not (is_view_own and wrong_team)
# Check VIEW_ALL permission for VIEW_OWN requests
return bool(
+28 -23
View File
@@ -5,6 +5,7 @@ Provides CRUD operations and lifecycle management for tasks.
Handles status transitions, assignments, and queries.
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -25,6 +26,21 @@ from roboco.models.base import Complexity, HandoffStatus, TaskStatus, Team
logger = structlog.get_logger()
@dataclass
class TaskCreateRequest:
"""Request data for creating a task."""
title: str
description: str
acceptance_criteria: list[str]
team: Team
created_by: UUID
priority: int = 2
parent_task_id: UUID | None = None
target_date: datetime | None = None
estimated_complexity: Complexity = field(default=Complexity.MEDIUM)
class TaskService:
"""
Service for managing tasks.
@@ -44,29 +60,18 @@ class TaskService:
# CRUD OPERATIONS
# =========================================================================
async def create(
self,
title: str,
description: str,
acceptance_criteria: list[str],
team: Team,
created_by: UUID,
priority: int = 2,
parent_task_id: UUID | None = None,
target_date: datetime | None = None,
estimated_complexity: Complexity = Complexity.MEDIUM,
) -> TaskTable:
async def create(self, req: TaskCreateRequest) -> TaskTable:
"""Create a new task."""
task = TaskTable(
title=title,
description=description,
acceptance_criteria=acceptance_criteria,
team=team,
created_by=created_by,
priority=priority,
parent_task_id=parent_task_id,
target_date=target_date,
estimated_complexity=estimated_complexity,
title=req.title,
description=req.description,
acceptance_criteria=req.acceptance_criteria,
team=req.team,
created_by=req.created_by,
priority=req.priority,
parent_task_id=req.parent_task_id,
target_date=req.target_date,
estimated_complexity=req.estimated_complexity,
status=TaskStatus.PENDING,
)
self.session.add(task)
@@ -75,8 +80,8 @@ class TaskService:
logger.info(
"Task created",
task_id=str(task.id),
title=title,
team=team.value,
title=req.title,
team=req.team.value,
)
return task
Generated
+17 -6
View File
@@ -6,6 +6,15 @@ resolution-markers = [
"sys_platform != 'win32'",
]
[[package]]
name = "aiofiles"
version = "25.1.0"
source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" }
sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "alembic"
version = "1.17.2"
@@ -1130,19 +1139,19 @@ wheels = [
[[package]]
name = "lance-namespace"
version = "0.2.1"
version = "0.3.0"
source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" }
dependencies = [
{ name = "lance-namespace-urllib3-client" },
]
sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/ae/a0/54aae8694869594b65bb6733811e43307bf5485cf9574bf28e50673ae5cf/lance_namespace-0.2.1.tar.gz", hash = "sha256:b4f18c5bb86486937a79ed89f0eb5b30aba907bb8a854824b450f2a30f4f38d9", size = 6082, upload-time = "2025-11-28T07:20:32.703Z" }
sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/df/af/5a8ca441cce0bc499f6c621adb90de1c19ad9df5a17b069f20b645d03d54/lance_namespace-0.3.0.tar.gz", hash = "sha256:67f467261958530ff5447062b56c0a07301f42dacfd0ee12d9cfaa544715922f", size = 6828, upload-time = "2025-12-11T00:21:04.63Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/87/de/16ea77c472c0f9d2d9ecabf5746a90d9ed9c1b1504ca659b1ce0c4833613/lance_namespace-0.2.1-py3-none-any.whl", hash = "sha256:12809ceaf13616b8e6e0caf3f84a2d921deb5436b0d3769ef021e335022f2d80", size = 7566, upload-time = "2025-11-28T07:20:31.689Z" },
{ url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/87/d6/03e42e3372dbddd5e64487923851abe337daaff590cbd325b675816e35d4/lance_namespace-0.3.0-py3-none-any.whl", hash = "sha256:346fe74d252bd81e71e1aead825851bcfd81992330046693330c0cac3d6bdbd0", size = 8330, upload-time = "2025-12-11T00:21:05.336Z" },
]
[[package]]
name = "lance-namespace-urllib3-client"
version = "0.2.1"
version = "0.3.0"
source = { registry = "https://pkgs.safetycli.com/repository/renzof/pypi/simple/" }
dependencies = [
{ name = "pydantic" },
@@ -1150,9 +1159,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/1e/1d/b2037e7bfa1802d1e5a8e59b26081043f0352eec55ab58a5002ab6b4a2a3/lance_namespace_urllib3_client-0.2.1.tar.gz", hash = "sha256:68640e279d1d52fe2a65f82b832ec5f63bd1304271014c9dffe074da564bde1b", size = 134044, upload-time = "2025-11-28T07:20:28.953Z" }
sdist = { url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/57/2d/99a80e5374a79caea636d3a7d4b273cb8960daa645f855a695d7581a1e9d/lance_namespace_urllib3_client-0.3.0.tar.gz", hash = "sha256:9de100e48bbd36069af7f543fa3e8a50e379f306bf2c191938138ef300186307", size = 152580, upload-time = "2025-12-11T00:21:03.463Z" }
wheels = [
{ url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/59/b4/40b75958172ce8211258c50e086adfb95f3211f7dc2fe9271a924d9ef2e1/lance_namespace_urllib3_client-0.2.1-py3-none-any.whl", hash = "sha256:3b3efbdab3783fd04f3c8d7b186dbf6244507485fac374bd2dd49ea61cb84cc7", size = 228490, upload-time = "2025-11-28T07:20:30.349Z" },
{ url = "https://pkgs.safetycli.com/package/renzof/pypi/packages/a0/6c/6a32fd2a101363acc0d3265d752fd111185707a33204751858c9b7b3a8ac/lance_namespace_urllib3_client-0.3.0-py3-none-any.whl", hash = "sha256:ff0eed4037d69ed74d455fa737c890c7b6839df083b95e1c354ebb098a3871e1", size = 260120, upload-time = "2025-12-11T00:21:06.634Z" },
]
[[package]]
@@ -2757,6 +2766,7 @@ name = "roboco"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "asyncpg" },
@@ -2805,6 +2815,7 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "aiofiles" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "asyncpg" },