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