diff --git a/alembic/env.py b/alembic/env.py index 35543961..1ee27840 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d40c553c..4897d0b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic-settings", # API + "aiofiles", "fastapi", "uvicorn[standard]", "websockets", diff --git a/roboco/agents/base.py b/roboco/agents/base.py index b97259e8..362cf770 100644 --- a/roboco/agents/base.py +++ b/roboco/agents/base.py @@ -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. diff --git a/roboco/agents/board.py b/roboco/agents/board.py index c4d2c8ab..4aee30e5 100644 --- a/roboco/agents/board.py +++ b/roboco/agents/board.py @@ -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: diff --git a/roboco/agents/developer.py b/roboco/agents/developer.py index c784b5b6..80c655ac 100644 --- a/roboco/agents/developer.py +++ b/roboco/agents/developer.py @@ -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: diff --git a/roboco/agents/documenter.py b/roboco/agents/documenter.py index ce702d3d..b49edfa2 100644 --- a/roboco/agents/documenter.py +++ b/roboco/agents/documenter.py @@ -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: diff --git a/roboco/agents/orchestrator.py b/roboco/agents/orchestrator.py index a4b652ba..eeb0f92e 100644 --- a/roboco/agents/orchestrator.py +++ b/roboco/agents/orchestrator.py @@ -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 diff --git a/roboco/agents/pm.py b/roboco/agents/pm.py index f5bef57d..ee6c1958 100644 --- a/roboco/agents/pm.py +++ b/roboco/agents/pm.py @@ -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: diff --git a/roboco/agents/qa.py b/roboco/agents/qa.py index 46eb8293..711a9ea9 100644 --- a/roboco/agents/qa.py +++ b/roboco/agents/qa.py @@ -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: diff --git a/roboco/api/app.py b/roboco/api/app.py index 69210e87..4d7dc2fa 100644 --- a/roboco/api/app.py +++ b/roboco/api/app.py @@ -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 diff --git a/roboco/api/deps.py b/roboco/api/deps.py index f4fc0ec7..ba213a35 100644 --- a/roboco/api/deps.py +++ b/roboco/api/deps.py @@ -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, diff --git a/roboco/api/routes/channels.py b/roboco/api/routes/channels.py index 6a391f91..8fccb0e4 100644 --- a/roboco/api/routes/channels.py +++ b/roboco/api/routes/channels.py @@ -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: diff --git a/roboco/api/routes/dashboard.py b/roboco/api/routes/dashboard.py index 5774756a..790aadb7 100644 --- a/roboco/api/routes/dashboard.py +++ b/roboco/api/routes/dashboard.py @@ -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, diff --git a/roboco/api/routes/journals.py b/roboco/api/routes/journals.py index ae9f9881..aa417a95 100644 --- a/roboco/api/routes/journals.py +++ b/roboco/api/routes/journals.py @@ -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, diff --git a/roboco/api/routes/messages.py b/roboco/api/routes/messages.py index b922ca3e..22899703 100644 --- a/roboco/api/routes/messages.py +++ b/roboco/api/routes/messages.py @@ -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.""" diff --git a/roboco/api/routes/notifications.py b/roboco/api/routes/notifications.py index eeb313be..ebdd80ea 100644 --- a/roboco/api/routes/notifications.py +++ b/roboco/api/routes/notifications.py @@ -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() diff --git a/roboco/api/routes/optimal.py b/roboco/api/routes/optimal.py index b7456e67..42817ca8 100644 --- a/roboco/api/routes/optimal.py +++ b/roboco/api/routes/optimal.py @@ -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. diff --git a/roboco/api/routes/orchestrator.py b/roboco/api/routes/orchestrator.py index 1d760661..f49482cf 100644 --- a/roboco/api/routes/orchestrator.py +++ b/roboco/api/routes/orchestrator.py @@ -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 # ============================================================================= diff --git a/roboco/api/routes/sessions.py b/roboco/api/routes/sessions.py index fe4d5270..95100eff 100644 --- a/roboco/api/routes/sessions.py +++ b/roboco/api/routes/sessions.py @@ -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.""" diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index 70312c35..2863364d 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -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 diff --git a/roboco/api/websocket.py b/roboco/api/websocket.py index 3eec5a44..8eadf416 100644 --- a/roboco/api/websocket.py +++ b/roboco/api/websocket.py @@ -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), ) diff --git a/roboco/bootstrap.py b/roboco/bootstrap.py index 9a624642..38f26a10 100644 --- a/roboco/bootstrap.py +++ b/roboco/bootstrap.py @@ -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", diff --git a/roboco/db/base.py b/roboco/db/base.py index cdbd42fa..5ee596e8 100644 --- a/roboco/db/base.py +++ b/roboco/db/base.py @@ -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 diff --git a/roboco/enforcement/__init__.py b/roboco/enforcement/__init__.py index 53d8efe9..de8e8dad 100644 --- a/roboco/enforcement/__init__.py +++ b/roboco/enforcement/__init__.py @@ -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", diff --git a/roboco/enforcement/task_ownership.py b/roboco/enforcement/task_ownership.py index 234fc81e..96775311 100644 --- a/roboco/enforcement/task_ownership.py +++ b/roboco/enforcement/task_ownership.py @@ -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 diff --git a/roboco/events/bus.py b/roboco/events/bus.py index eb8d41c1..9967eaec 100644 --- a/roboco/events/bus.py +++ b/roboco/events/bus.py @@ -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: diff --git a/roboco/events/handlers.py b/roboco/events/handlers.py index 3daaa954..f309a0b6 100644 --- a/roboco/events/handlers.py +++ b/roboco/events/handlers.py @@ -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() diff --git a/roboco/exceptions.py b/roboco/exceptions.py index d656b548..bdb5f697 100644 --- a/roboco/exceptions.py +++ b/roboco/exceptions.py @@ -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={ diff --git a/roboco/logging.py b/roboco/logging.py index 763d222b..c5c9663c 100644 --- a/roboco/logging.py +++ b/roboco/logging.py @@ -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.""" diff --git a/roboco/mcp/journal_server.py b/roboco/mcp/journal_server.py index 7b80ad41..61ea8d51 100644 --- a/roboco/mcp/journal_server.py +++ b/roboco/mcp/journal_server.py @@ -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 ") sys.exit(1) diff --git a/roboco/mcp/message_server.py b/roboco/mcp/message_server.py index 22f22060..891b5f8b 100644 --- a/roboco/mcp/message_server.py +++ b/roboco/mcp/message_server.py @@ -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 ") sys.exit(1) diff --git a/roboco/mcp/notify_server.py b/roboco/mcp/notify_server.py index ea7023f3..d1668d25 100644 --- a/roboco/mcp/notify_server.py +++ b/roboco/mcp/notify_server.py @@ -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 ") sys.exit(1) diff --git a/roboco/mcp/task_server.py b/roboco/mcp/task_server.py index 2cebc4a3..1ac6e874 100644 --- a/roboco/mcp/task_server.py +++ b/roboco/mcp/task_server.py @@ -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 ") sys.exit(1) diff --git a/roboco/models/base.py b/roboco/models/base.py index bc10407f..fc3de2c0 100644 --- a/roboco/models/base.py +++ b/roboco/models/base.py @@ -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 diff --git a/roboco/models/channel.py b/roboco/models/channel.py index b4f9af87..188740ac 100644 --- a/roboco/models/channel.py +++ b/roboco/models/channel.py @@ -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], diff --git a/roboco/models/journal.py b/roboco/models/journal.py index f5bca026..663ce107 100644 --- a/roboco/models/journal.py +++ b/roboco/models/journal.py @@ -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 diff --git a/roboco/models/message.py b/roboco/models/message.py index 50eaff5e..ec6b76cf 100644 --- a/roboco/models/message.py +++ b/roboco/models/message.py @@ -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( diff --git a/roboco/models/notification.py b/roboco/models/notification.py index bda8f401..630a895b 100644 --- a/roboco/models/notification.py +++ b/roboco/models/notification.py @@ -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, ) diff --git a/roboco/models/session.py b/roboco/models/session.py index d1101e59..028f1f26 100644 --- a/roboco/models/session.py +++ b/roboco/models/session.py @@ -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 diff --git a/roboco/models/task.py b/roboco/models/task.py index f1e2cad0..b4920dc9 100644 --- a/roboco/models/task.py +++ b/roboco/models/task.py @@ -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( diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 74904d97..85310098 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -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, diff --git a/roboco/services/extraction.py b/roboco/services/extraction.py index a203ab7e..73cc0375 100644 --- a/roboco/services/extraction.py +++ b/roboco/services/extraction.py @@ -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) diff --git a/roboco/services/journal.py b/roboco/services/journal.py index 45265c1a..8bd5cf18 100644 --- a/roboco/services/journal.py +++ b/roboco/services/journal.py @@ -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) diff --git a/roboco/services/kanban.py b/roboco/services/kanban.py index 766941a6..9f9020d7 100644 --- a/roboco/services/kanban.py +++ b/roboco/services/kanban.py @@ -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), ) diff --git a/roboco/services/metrics.py b/roboco/services/metrics.py index e0fb067e..5b1580e1 100644 --- a/roboco/services/metrics.py +++ b/roboco/services/metrics.py @@ -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" diff --git a/roboco/services/notification.py b/roboco/services/notification.py index c834eba6..fe9d1f5d 100644 --- a/roboco/services/notification.py +++ b/roboco/services/notification.py @@ -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) diff --git a/roboco/services/optimal.py b/roboco/services/optimal.py index 06fe059b..8a24f1e6 100644 --- a/roboco/services/optimal.py +++ b/roboco/services/optimal.py @@ -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 diff --git a/roboco/services/permissions.py b/roboco/services/permissions.py index 4f4f3b94..875d414c 100644 --- a/roboco/services/permissions.py +++ b/roboco/services/permissions.py @@ -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( diff --git a/roboco/services/task.py b/roboco/services/task.py index 593917ce..fe4c2854 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -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 diff --git a/uv.lock b/uv.lock index 290c85d6..643355d5 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },