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