Update with production optimizations
Browse files- backend/services/config.py +2 -0
- backend/services/orchestrator_service.py +224 -25
- backend/worker.py +95 -21
- frontend/src/components/Dashboard.tsx +122 -33
- frontend/src/components/Login.tsx +4 -7
- frontend/src/components/common/StatusBadge.tsx +61 -0
backend/services/config.py
CHANGED
|
@@ -18,6 +18,8 @@ class Settings(BaseSettings):
|
|
| 18 |
|
| 19 |
# App Config
|
| 20 |
TASK_QUEUE_EMBEDDED_WORKER: bool = True
|
|
|
|
|
|
|
| 21 |
OUTPUT_LANGUAGE: str = "en"
|
| 22 |
PORT: int = 8000
|
| 23 |
SENTRY_DSN: Optional[str] = None
|
|
|
|
| 18 |
|
| 19 |
# App Config
|
| 20 |
TASK_QUEUE_EMBEDDED_WORKER: bool = True
|
| 21 |
+
TASK_QUEUE_HEARTBEAT_ENABLED: bool = True
|
| 22 |
+
TASK_EXECUTION_MODE: str = "queue" # direct | queue
|
| 23 |
OUTPUT_LANGUAGE: str = "en"
|
| 24 |
PORT: int = 8000
|
| 25 |
SENTRY_DSN: Optional[str] = None
|
backend/services/orchestrator_service.py
CHANGED
|
@@ -5,7 +5,8 @@ import logging
|
|
| 5 |
import re
|
| 6 |
from services.config import settings
|
| 7 |
from services.agent_runner_service import AgentRunnerService
|
| 8 |
-
from services.
|
|
|
|
| 9 |
|
| 10 |
logger = logging.getLogger("uvicorn")
|
| 11 |
|
|
@@ -68,20 +69,46 @@ def _format_value_for_report(value, level: int = 0) -> list[str]:
|
|
| 68 |
|
| 69 |
|
| 70 |
def _extract_json_payload(text: str):
|
|
|
|
|
|
|
|
|
|
| 71 |
stripped = text.strip()
|
|
|
|
|
|
|
| 72 |
if stripped.startswith("```"):
|
| 73 |
-
|
| 74 |
-
if
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
try:
|
| 77 |
return json.loads(stripped)
|
| 78 |
except Exception:
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if match:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
return None
|
| 86 |
|
| 87 |
def _format_output_for_report(output_data) -> str:
|
|
@@ -125,12 +152,17 @@ def _format_conclusion_payload(data: dict) -> str:
|
|
| 125 |
if isinstance(conclusion, str) and conclusion.strip():
|
| 126 |
lines.append(conclusion.strip())
|
| 127 |
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
lines.append("")
|
| 130 |
lines.append("Next steps:")
|
| 131 |
-
for step in
|
| 132 |
-
|
| 133 |
-
lines.append(f"- {step.strip()}")
|
| 134 |
|
| 135 |
return "\n".join(lines).strip() or "\n".join(_format_value_for_report(data))
|
| 136 |
|
|
@@ -171,7 +203,7 @@ def _build_report_charts(tasks: list[dict]) -> dict:
|
|
| 171 |
risk_mentions = 0
|
| 172 |
|
| 173 |
for task in tasks:
|
| 174 |
-
text = f"{task.get('title', '')} {task.get('description', '')} {_output_text(task.get('output_data'))}"
|
| 175 |
risk_mentions += sum(text.count(term) for term in categories["Risk"])
|
| 176 |
for category, terms in categories.items():
|
| 177 |
if any(term in text for term in terms):
|
|
@@ -202,6 +234,27 @@ def _build_report_charts(tasks: list[dict]) -> dict:
|
|
| 202 |
]
|
| 203 |
}
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
REPORT_VARIANTS = {
|
| 206 |
"full": {
|
| 207 |
"title": "Final Report",
|
|
@@ -251,6 +304,13 @@ class OrchestratorService:
|
|
| 251 |
|
| 252 |
# Update status to in_progress
|
| 253 |
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
# 2. Agent A generates initial response
|
| 256 |
initial_res, _ = await AgentRunnerService.run_agent_task(
|
|
@@ -307,6 +367,13 @@ class OrchestratorService:
|
|
| 307 |
"status": "awaiting_approval",
|
| 308 |
"output_data": consolidated_output
|
| 309 |
}).eq("id", task_id).execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
logger.info(f"Debate completed for task {task_id}")
|
| 312 |
|
|
@@ -316,6 +383,13 @@ class OrchestratorService:
|
|
| 316 |
"status": "failed",
|
| 317 |
"output_data": {"error": str(e)}
|
| 318 |
}).eq("id", task_id).execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
# LOG ERROR TO AGENT CONSOLE
|
| 321 |
supabase.table("agent_logs").insert({
|
|
@@ -338,7 +412,7 @@ class OrchestratorService:
|
|
| 338 |
supabase.table("tasks")
|
| 339 |
.select("*")
|
| 340 |
.eq("project_id", project_id)
|
| 341 |
-
.
|
| 342 |
.order("priority", desc=True)
|
| 343 |
.order("created_at", desc=False)
|
| 344 |
.execute()
|
|
@@ -405,6 +479,109 @@ class OrchestratorService:
|
|
| 405 |
"failed": failed,
|
| 406 |
}
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
def _select_report_agent(self, project: dict, variant: str):
|
| 409 |
config = REPORT_VARIANTS.get(variant, REPORT_VARIANTS["full"])
|
| 410 |
terms = config["agent_terms"]
|
|
@@ -495,7 +672,7 @@ class OrchestratorService:
|
|
| 495 |
|
| 496 |
return None
|
| 497 |
|
| 498 |
-
def _quality_approved_tasks(self, tasks: list[dict]) -> tuple[list[dict], list[dict]]:
|
| 499 |
approved: list[dict] = []
|
| 500 |
excluded: list[dict] = []
|
| 501 |
for task in tasks:
|
|
@@ -506,7 +683,10 @@ class OrchestratorService:
|
|
| 506 |
"reasons": ["Task has no usable approved output."]
|
| 507 |
})
|
| 508 |
continue
|
|
|
|
| 509 |
quality_review = output_data.get("quality_review") if isinstance(output_data, dict) else None
|
|
|
|
|
|
|
| 510 |
if quality_review and not quality_review.get("approved", False):
|
| 511 |
excluded.append({
|
| 512 |
"title": task.get("title", "Untitled task"),
|
|
@@ -517,7 +697,7 @@ class OrchestratorService:
|
|
| 517 |
return approved, excluded
|
| 518 |
|
| 519 |
def _curate_task_output(self, output_data) -> tuple[str, list[str]]:
|
| 520 |
-
text =
|
| 521 |
text = clean_report_text(dedupe_lines(text))
|
| 522 |
text, excluded_lines = filter_report_sections(text)
|
| 523 |
return text or "No approved output was saved for this task.", excluded_lines
|
|
@@ -546,7 +726,7 @@ class OrchestratorService:
|
|
| 546 |
if incomplete:
|
| 547 |
raise ValueError(f"Final report is available after all tasks are approved. Pending tasks: {len(incomplete)}")
|
| 548 |
|
| 549 |
-
curated_tasks, excluded_tasks = self._quality_approved_tasks(tasks)
|
| 550 |
if not curated_tasks:
|
| 551 |
raise ValueError("No approved task outputs passed quality validation for final reporting.")
|
| 552 |
|
|
@@ -564,13 +744,10 @@ class OrchestratorService:
|
|
| 564 |
if project.get("context"):
|
| 565 |
lines.extend(["## Context", project["context"], ""])
|
| 566 |
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
# We will add the tabular summary later in the UI or via charts,
|
| 570 |
-
# but for the text report, we include the approved work summary.
|
| 571 |
-
lines.extend(["## Approved Work Summary", ""])
|
| 572 |
|
| 573 |
report_exclusions: list[str] = []
|
|
|
|
| 574 |
kept_task_count = 0
|
| 575 |
for task in curated_tasks:
|
| 576 |
curated_text, excluded_lines = self._curate_task_output(task.get("output_data"))
|
|
@@ -582,7 +759,8 @@ class OrchestratorService:
|
|
| 582 |
})
|
| 583 |
continue
|
| 584 |
kept_task_count += 1
|
| 585 |
-
|
|
|
|
| 586 |
f"### {kept_task_count}. {task['title']}",
|
| 587 |
task.get("description") or "No task description provided.",
|
| 588 |
"",
|
|
@@ -590,6 +768,11 @@ class OrchestratorService:
|
|
| 590 |
""
|
| 591 |
])
|
| 592 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
if excluded_tasks or report_exclusions:
|
| 594 |
lines.extend(["## Excluded Content", ""])
|
| 595 |
for excluded in excluded_tasks:
|
|
@@ -648,13 +831,24 @@ class OrchestratorService:
|
|
| 648 |
logger.warning(f"Report variant generation failed: {exc}")
|
| 649 |
report = self._build_fallback_variant(project, tasks, variant) or report
|
| 650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
return {
|
| 652 |
"project_id": project_id,
|
| 653 |
"project_name": project["name"],
|
| 654 |
"task_count": kept_task_count,
|
| 655 |
"variant": variant,
|
| 656 |
"report": clean_report_text(dedupe_lines(report)),
|
| 657 |
-
"charts":
|
| 658 |
}
|
| 659 |
|
| 660 |
async def decompose_project(self, project_id: str):
|
|
@@ -747,6 +941,11 @@ Do not use placeholder names or generic filler tasks. Every task title must be c
|
|
| 747 |
# Insert tasks
|
| 748 |
from .project_service import project_service
|
| 749 |
await project_service.add_tasks_to_project(project_id, valid_tasks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
logger.info(f"Auto-decomposed project {project_id} into {len(valid_tasks)} tasks.")
|
| 751 |
except Exception as e:
|
| 752 |
logger.error(f"Project decomposition failed: {e}")
|
|
|
|
| 5 |
import re
|
| 6 |
from services.config import settings
|
| 7 |
from services.agent_runner_service import AgentRunnerService
|
| 8 |
+
from services.audit_service import audit_service
|
| 9 |
+
from services.output_quality import clean_report_text, dedupe_lines, filter_report_sections, validate_output
|
| 10 |
|
| 11 |
logger = logging.getLogger("uvicorn")
|
| 12 |
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def _extract_json_payload(text: str):
|
| 72 |
+
if not text:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
stripped = text.strip()
|
| 76 |
+
|
| 77 |
+
# 1. Try standard block extraction
|
| 78 |
if stripped.startswith("```"):
|
| 79 |
+
cleaned = stripped.strip("`")
|
| 80 |
+
if cleaned.lower().startswith("json"):
|
| 81 |
+
cleaned = cleaned[4:].strip()
|
| 82 |
+
try:
|
| 83 |
+
return json.loads(cleaned)
|
| 84 |
+
except Exception:
|
| 85 |
+
pass # Fallback to regex
|
| 86 |
+
|
| 87 |
+
# 2. Try direct parsing
|
| 88 |
try:
|
| 89 |
return json.loads(stripped)
|
| 90 |
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
# 3. Robust Regex Search (find content between first { and last })
|
| 94 |
+
# This is the "Repair Layer" for noisy LLM outputs
|
| 95 |
+
try:
|
| 96 |
+
# Search for anything starting with { and ending with }
|
| 97 |
+
# across multiple lines
|
| 98 |
+
match = re.search(r'(\{.*\})', stripped, re.DOTALL)
|
| 99 |
if match:
|
| 100 |
+
return json.loads(match.group(1))
|
| 101 |
+
except Exception:
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
# 4. Specific Markdown Block Search
|
| 105 |
+
match = re.search(r"```json\s*(.*?)\s*```", text, re.IGNORECASE | re.DOTALL)
|
| 106 |
+
if match:
|
| 107 |
+
try:
|
| 108 |
+
return json.loads(match.group(1))
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
return None
|
| 113 |
|
| 114 |
def _format_output_for_report(output_data) -> str:
|
|
|
|
| 152 |
if isinstance(conclusion, str) and conclusion.strip():
|
| 153 |
lines.append(conclusion.strip())
|
| 154 |
|
| 155 |
+
usable_steps = [
|
| 156 |
+
step.strip()
|
| 157 |
+
for step in next_steps
|
| 158 |
+
if isinstance(step, str) and step.strip()
|
| 159 |
+
] if isinstance(next_steps, list) else []
|
| 160 |
+
|
| 161 |
+
if usable_steps:
|
| 162 |
lines.append("")
|
| 163 |
lines.append("Next steps:")
|
| 164 |
+
for step in usable_steps[:5]:
|
| 165 |
+
lines.append(f"- {step}")
|
|
|
|
| 166 |
|
| 167 |
return "\n".join(lines).strip() or "\n".join(_format_value_for_report(data))
|
| 168 |
|
|
|
|
| 203 |
risk_mentions = 0
|
| 204 |
|
| 205 |
for task in tasks:
|
| 206 |
+
text = f"{task.get('title', '')} {task.get('description', '')} {_output_text(task.get('output_data'))}".lower()
|
| 207 |
risk_mentions += sum(text.count(term) for term in categories["Risk"])
|
| 208 |
for category, terms in categories.items():
|
| 209 |
if any(term in text for term in terms):
|
|
|
|
| 234 |
]
|
| 235 |
}
|
| 236 |
|
| 237 |
+
def _format_chart_rows(title: str, rows: list[dict]) -> list[str]:
|
| 238 |
+
if not rows:
|
| 239 |
+
return [f"### {title}", "No data available.", ""]
|
| 240 |
+
|
| 241 |
+
lines = [f"### {title}"]
|
| 242 |
+
lines.extend(f"- {row['label']}: {row['value']}" for row in rows)
|
| 243 |
+
lines.append("")
|
| 244 |
+
return lines
|
| 245 |
+
|
| 246 |
+
def _format_execution_summary(charts: dict, total_tasks: int, kept_task_count: int, excluded_count: int) -> list[str]:
|
| 247 |
+
lines = [
|
| 248 |
+
f"- Total tasks: {total_tasks}",
|
| 249 |
+
f"- Included outputs: {kept_task_count}",
|
| 250 |
+
f"- Excluded outputs: {excluded_count}",
|
| 251 |
+
"",
|
| 252 |
+
]
|
| 253 |
+
lines.extend(_format_chart_rows("Scores", charts.get("scores", [])))
|
| 254 |
+
lines.extend(_format_chart_rows("Task Categories", charts.get("categories", [])))
|
| 255 |
+
lines.extend(_format_chart_rows("Priorities", charts.get("priorities", [])))
|
| 256 |
+
return lines
|
| 257 |
+
|
| 258 |
REPORT_VARIANTS = {
|
| 259 |
"full": {
|
| 260 |
"title": "Final Report",
|
|
|
|
| 304 |
|
| 305 |
# Update status to in_progress
|
| 306 |
supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute()
|
| 307 |
+
await audit_service.log_action(
|
| 308 |
+
user_id=None,
|
| 309 |
+
action="debate_started",
|
| 310 |
+
agent_id=agent_a_id,
|
| 311 |
+
task_id=task_id,
|
| 312 |
+
metadata={"agent_b_id": agent_b_id, "project_id": task.get("project_id")},
|
| 313 |
+
)
|
| 314 |
|
| 315 |
# 2. Agent A generates initial response
|
| 316 |
initial_res, _ = await AgentRunnerService.run_agent_task(
|
|
|
|
| 367 |
"status": "awaiting_approval",
|
| 368 |
"output_data": consolidated_output
|
| 369 |
}).eq("id", task_id).execute()
|
| 370 |
+
await audit_service.log_action(
|
| 371 |
+
user_id=None,
|
| 372 |
+
action="debate_completed",
|
| 373 |
+
agent_id=agent_a_id,
|
| 374 |
+
task_id=task_id,
|
| 375 |
+
metadata={"agent_b_id": agent_b_id, "project_id": task.get("project_id")},
|
| 376 |
+
)
|
| 377 |
|
| 378 |
logger.info(f"Debate completed for task {task_id}")
|
| 379 |
|
|
|
|
| 383 |
"status": "failed",
|
| 384 |
"output_data": {"error": str(e)}
|
| 385 |
}).eq("id", task_id).execute()
|
| 386 |
+
await audit_service.log_action(
|
| 387 |
+
user_id=None,
|
| 388 |
+
action="debate_failed",
|
| 389 |
+
agent_id=agent_a_id,
|
| 390 |
+
task_id=task_id,
|
| 391 |
+
metadata={"agent_b_id": agent_b_id, "error": str(e)},
|
| 392 |
+
)
|
| 393 |
|
| 394 |
# LOG ERROR TO AGENT CONSOLE
|
| 395 |
supabase.table("agent_logs").insert({
|
|
|
|
| 412 |
supabase.table("tasks")
|
| 413 |
.select("*")
|
| 414 |
.eq("project_id", project_id)
|
| 415 |
+
.in_("status", ["todo", "failed"])
|
| 416 |
.order("priority", desc=True)
|
| 417 |
.order("created_at", desc=False)
|
| 418 |
.execute()
|
|
|
|
| 479 |
"failed": failed,
|
| 480 |
}
|
| 481 |
|
| 482 |
+
async def queue_project(self, project_id: str):
|
| 483 |
+
"""
|
| 484 |
+
Assigns available agents and queues runnable project tasks for worker execution.
|
| 485 |
+
"""
|
| 486 |
+
from services.task_queue import TaskQueueService
|
| 487 |
+
|
| 488 |
+
project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data
|
| 489 |
+
if not project:
|
| 490 |
+
raise ValueError(f"Project not found: {project_id}")
|
| 491 |
+
if project.get("status") == "completed":
|
| 492 |
+
raise ValueError("Completed projects are locked and cannot be modified.")
|
| 493 |
+
|
| 494 |
+
owner_id = project.get("owner_id")
|
| 495 |
+
tasks = (
|
| 496 |
+
supabase.table("tasks")
|
| 497 |
+
.select("*")
|
| 498 |
+
.eq("project_id", project_id)
|
| 499 |
+
.in_("status", ["todo", "failed"])
|
| 500 |
+
.order("priority", desc=True)
|
| 501 |
+
.order("created_at", desc=False)
|
| 502 |
+
.execute()
|
| 503 |
+
.data
|
| 504 |
+
or []
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
all_tasks_res = supabase.table("tasks").select("id", count="exact").eq("project_id", project_id).limit(1).execute()
|
| 508 |
+
has_any_tasks = all_tasks_res.count > 0 if all_tasks_res.count is not None else len(all_tasks_res.data) > 0
|
| 509 |
+
|
| 510 |
+
if not has_any_tasks:
|
| 511 |
+
logger.info(f"No tasks found for project {project_id}. Triggering auto-decomposition before queueing.")
|
| 512 |
+
await self.decompose_project(project_id)
|
| 513 |
+
tasks = (
|
| 514 |
+
supabase.table("tasks")
|
| 515 |
+
.select("*")
|
| 516 |
+
.eq("project_id", project_id)
|
| 517 |
+
.in_("status", ["todo", "failed"])
|
| 518 |
+
.order("priority", desc=True)
|
| 519 |
+
.order("created_at", desc=False)
|
| 520 |
+
.execute()
|
| 521 |
+
.data
|
| 522 |
+
or []
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
agents = supabase.table("agents").select("*").execute().data or []
|
| 526 |
+
available_agents = [
|
| 527 |
+
agent for agent in agents
|
| 528 |
+
if agent.get("user_id") in (None, owner_id)
|
| 529 |
+
]
|
| 530 |
+
|
| 531 |
+
queued = 0
|
| 532 |
+
failed = 0
|
| 533 |
+
skipped = 0
|
| 534 |
+
|
| 535 |
+
for task in tasks:
|
| 536 |
+
try:
|
| 537 |
+
agent_data = self._resolve_agent(task, available_agents)
|
| 538 |
+
if not agent_data:
|
| 539 |
+
raise ValueError("No available agent for task")
|
| 540 |
+
|
| 541 |
+
if not task.get("assigned_agent_id"):
|
| 542 |
+
supabase.table("tasks").update({
|
| 543 |
+
"assigned_agent_id": agent_data["id"]
|
| 544 |
+
}).eq("id", task["id"]).execute()
|
| 545 |
+
|
| 546 |
+
result = await TaskQueueService.queue_task(task["id"])
|
| 547 |
+
if result and result.data:
|
| 548 |
+
queued += 1
|
| 549 |
+
else:
|
| 550 |
+
skipped += 1
|
| 551 |
+
except Exception as exc:
|
| 552 |
+
failed += 1
|
| 553 |
+
logger.error(f"Project queueing task failed: {str(exc)}")
|
| 554 |
+
supabase.table("tasks").update({
|
| 555 |
+
"status": "failed",
|
| 556 |
+
"last_error": str(exc),
|
| 557 |
+
"output_data": {"error": str(exc)}
|
| 558 |
+
}).eq("id", task["id"]).execute()
|
| 559 |
+
await audit_service.log_action(
|
| 560 |
+
user_id=owner_id,
|
| 561 |
+
action="task_queue_failed",
|
| 562 |
+
task_id=task.get("id"),
|
| 563 |
+
metadata={"project_id": project_id, "error": str(exc)},
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
await audit_service.log_action(
|
| 567 |
+
user_id=owner_id,
|
| 568 |
+
action="project_queued",
|
| 569 |
+
metadata={
|
| 570 |
+
"project_id": project_id,
|
| 571 |
+
"queued_tasks": queued,
|
| 572 |
+
"failed": failed,
|
| 573 |
+
"skipped": skipped,
|
| 574 |
+
},
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
return {
|
| 578 |
+
"project_id": project_id,
|
| 579 |
+
"queued_tasks": queued,
|
| 580 |
+
"failed": failed,
|
| 581 |
+
"skipped": skipped,
|
| 582 |
+
"mode": "queue",
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
def _select_report_agent(self, project: dict, variant: str):
|
| 586 |
config = REPORT_VARIANTS.get(variant, REPORT_VARIANTS["full"])
|
| 587 |
terms = config["agent_terms"]
|
|
|
|
| 672 |
|
| 673 |
return None
|
| 674 |
|
| 675 |
+
def _quality_approved_tasks(self, tasks: list[dict], project: dict) -> tuple[list[dict], list[dict]]:
|
| 676 |
approved: list[dict] = []
|
| 677 |
excluded: list[dict] = []
|
| 678 |
for task in tasks:
|
|
|
|
| 683 |
"reasons": ["Task has no usable approved output."]
|
| 684 |
})
|
| 685 |
continue
|
| 686 |
+
task_with_project = {**task, "project": project}
|
| 687 |
quality_review = output_data.get("quality_review") if isinstance(output_data, dict) else None
|
| 688 |
+
if not quality_review and isinstance(output_data, dict):
|
| 689 |
+
quality_review = validate_output(task_with_project, output_data)
|
| 690 |
if quality_review and not quality_review.get("approved", False):
|
| 691 |
excluded.append({
|
| 692 |
"title": task.get("title", "Untitled task"),
|
|
|
|
| 697 |
return approved, excluded
|
| 698 |
|
| 699 |
def _curate_task_output(self, output_data) -> tuple[str, list[str]]:
|
| 700 |
+
text = _format_output_for_report(output_data)
|
| 701 |
text = clean_report_text(dedupe_lines(text))
|
| 702 |
text, excluded_lines = filter_report_sections(text)
|
| 703 |
return text or "No approved output was saved for this task.", excluded_lines
|
|
|
|
| 726 |
if incomplete:
|
| 727 |
raise ValueError(f"Final report is available after all tasks are approved. Pending tasks: {len(incomplete)}")
|
| 728 |
|
| 729 |
+
curated_tasks, excluded_tasks = self._quality_approved_tasks(tasks, project)
|
| 730 |
if not curated_tasks:
|
| 731 |
raise ValueError("No approved task outputs passed quality validation for final reporting.")
|
| 732 |
|
|
|
|
| 744 |
if project.get("context"):
|
| 745 |
lines.extend(["## Context", project["context"], ""])
|
| 746 |
|
| 747 |
+
approved_work_lines = ["## Approved Work Summary", ""]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
|
| 749 |
report_exclusions: list[str] = []
|
| 750 |
+
included_tasks: list[dict] = []
|
| 751 |
kept_task_count = 0
|
| 752 |
for task in curated_tasks:
|
| 753 |
curated_text, excluded_lines = self._curate_task_output(task.get("output_data"))
|
|
|
|
| 759 |
})
|
| 760 |
continue
|
| 761 |
kept_task_count += 1
|
| 762 |
+
included_tasks.append(task)
|
| 763 |
+
approved_work_lines.extend([
|
| 764 |
f"### {kept_task_count}. {task['title']}",
|
| 765 |
task.get("description") or "No task description provided.",
|
| 766 |
"",
|
|
|
|
| 768 |
""
|
| 769 |
])
|
| 770 |
|
| 771 |
+
charts = _build_report_charts(included_tasks)
|
| 772 |
+
lines.extend(["## Execution Summary", ""])
|
| 773 |
+
lines.extend(_format_execution_summary(charts, len(tasks), kept_task_count, len(excluded_tasks)))
|
| 774 |
+
lines.extend(approved_work_lines)
|
| 775 |
+
|
| 776 |
if excluded_tasks or report_exclusions:
|
| 777 |
lines.extend(["## Excluded Content", ""])
|
| 778 |
for excluded in excluded_tasks:
|
|
|
|
| 831 |
logger.warning(f"Report variant generation failed: {exc}")
|
| 832 |
report = self._build_fallback_variant(project, tasks, variant) or report
|
| 833 |
|
| 834 |
+
await audit_service.log_action(
|
| 835 |
+
user_id=project.get("owner_id"),
|
| 836 |
+
action="final_report_generated",
|
| 837 |
+
metadata={
|
| 838 |
+
"project_id": project_id,
|
| 839 |
+
"variant": variant,
|
| 840 |
+
"task_count": kept_task_count,
|
| 841 |
+
"excluded_task_count": len(excluded_tasks),
|
| 842 |
+
},
|
| 843 |
+
)
|
| 844 |
+
|
| 845 |
return {
|
| 846 |
"project_id": project_id,
|
| 847 |
"project_name": project["name"],
|
| 848 |
"task_count": kept_task_count,
|
| 849 |
"variant": variant,
|
| 850 |
"report": clean_report_text(dedupe_lines(report)),
|
| 851 |
+
"charts": charts
|
| 852 |
}
|
| 853 |
|
| 854 |
async def decompose_project(self, project_id: str):
|
|
|
|
| 941 |
# Insert tasks
|
| 942 |
from .project_service import project_service
|
| 943 |
await project_service.add_tasks_to_project(project_id, valid_tasks)
|
| 944 |
+
await audit_service.log_action(
|
| 945 |
+
user_id=owner_id,
|
| 946 |
+
action="project_decomposed",
|
| 947 |
+
metadata={"project_id": project_id, "task_count": len(valid_tasks)},
|
| 948 |
+
)
|
| 949 |
logger.info(f"Auto-decomposed project {project_id} into {len(valid_tasks)} tasks.")
|
| 950 |
except Exception as e:
|
| 951 |
logger.error(f"Project decomposition failed: {e}")
|
backend/worker.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
import asyncio
|
| 2 |
import logging
|
|
|
|
| 3 |
import signal
|
|
|
|
|
|
|
| 4 |
from services.task_queue import TaskQueueService
|
| 5 |
from services.supabase_service import supabase
|
| 6 |
from services.agent_runner_service import AgentRunnerService
|
|
|
|
| 7 |
|
| 8 |
logging.basicConfig(level=logging.INFO)
|
| 9 |
logger = logging.getLogger("worker")
|
|
@@ -11,29 +15,95 @@ logger = logging.getLogger("worker")
|
|
| 11 |
class AubmWorker:
|
| 12 |
def __init__(self):
|
| 13 |
self.running = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
async def
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
task = await TaskQueueService.get_next_queued_task()
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
def stop(self):
|
| 39 |
logger.info("Stopping worker...")
|
|
@@ -45,9 +115,13 @@ async def main():
|
|
| 45 |
# Handle shutdown signals
|
| 46 |
loop = asyncio.get_running_loop()
|
| 47 |
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
await worker.start()
|
|
|
|
| 51 |
|
| 52 |
if __name__ == "__main__":
|
| 53 |
asyncio.run(main())
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import logging
|
| 3 |
+
import os
|
| 4 |
import signal
|
| 5 |
+
import socket
|
| 6 |
+
import uuid
|
| 7 |
from services.task_queue import TaskQueueService
|
| 8 |
from services.supabase_service import supabase
|
| 9 |
from services.agent_runner_service import AgentRunnerService
|
| 10 |
+
from services.config import settings
|
| 11 |
|
| 12 |
logging.basicConfig(level=logging.INFO)
|
| 13 |
logger = logging.getLogger("worker")
|
|
|
|
| 15 |
class AubmWorker:
|
| 16 |
def __init__(self):
|
| 17 |
self.running = True
|
| 18 |
+
suffix = uuid.uuid4().hex[:8]
|
| 19 |
+
self.worker_id = os.getenv("AUBM_WORKER_ID") or f"{socket.gethostname()}-{suffix}"
|
| 20 |
+
self.lease_seconds = int(os.getenv("AUBM_WORKER_LEASE_SECONDS", "300"))
|
| 21 |
+
self.max_attempts = int(os.getenv("AUBM_WORKER_MAX_ATTEMPTS", "3"))
|
| 22 |
+
self.retry_delay_seconds = int(os.getenv("AUBM_WORKER_RETRY_DELAY_SECONDS", "30"))
|
| 23 |
+
self.processed_count = 0
|
| 24 |
+
self.failed_count = 0
|
| 25 |
|
| 26 |
+
async def heartbeat(self, status: str, current_task_id: str | None = None):
|
| 27 |
+
if not settings.TASK_QUEUE_HEARTBEAT_ENABLED:
|
| 28 |
+
return
|
|
|
|
| 29 |
|
| 30 |
+
await TaskQueueService.heartbeat(
|
| 31 |
+
self.worker_id,
|
| 32 |
+
status=status,
|
| 33 |
+
current_task_id=current_task_id,
|
| 34 |
+
processed_count=self.processed_count,
|
| 35 |
+
failed_count=self.failed_count,
|
| 36 |
+
metadata={
|
| 37 |
+
"lease_seconds": self.lease_seconds,
|
| 38 |
+
"max_attempts": self.max_attempts,
|
| 39 |
+
"retry_delay_seconds": self.retry_delay_seconds,
|
| 40 |
+
},
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
async def _heartbeat_loop(self):
|
| 44 |
+
"""Separate loop to send heartbeat at a fixed interval."""
|
| 45 |
+
while self.running:
|
| 46 |
+
try:
|
| 47 |
+
# We use a longer interval for regular heartbeats
|
| 48 |
+
await self.heartbeat("idle")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.warning("Background heartbeat failed: %s", e)
|
| 51 |
+
await asyncio.sleep(30) # Regular heartbeat every 30 seconds
|
| 52 |
+
|
| 53 |
+
async def start(self):
|
| 54 |
+
mode_suffix = "" if settings.TASK_QUEUE_HEARTBEAT_ENABLED else " (HEARTBEAT DISABLED)"
|
| 55 |
+
logger.info(f"Aubm Background Worker started{mode_suffix}: {self.worker_id}")
|
| 56 |
+
|
| 57 |
+
# Start the background heartbeat task if enabled
|
| 58 |
+
heartbeat_task = None
|
| 59 |
+
if settings.TASK_QUEUE_HEARTBEAT_ENABLED:
|
| 60 |
+
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
while self.running:
|
| 64 |
+
task = await TaskQueueService.claim_next_queued_task(
|
| 65 |
+
self.worker_id,
|
| 66 |
+
lease_seconds=self.lease_seconds,
|
| 67 |
+
max_attempts=self.max_attempts,
|
| 68 |
+
)
|
| 69 |
|
| 70 |
+
if task:
|
| 71 |
+
task_id = task['id']
|
| 72 |
+
logger.info("Processing task: %s", task_id)
|
| 73 |
+
await self.heartbeat("processing", task_id)
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
# Fetch agent data for this task
|
| 77 |
+
agent_id = task.get("assigned_agent_id")
|
| 78 |
+
if not agent_id:
|
| 79 |
+
raise RuntimeError("No agent assigned to queued task")
|
| 80 |
+
|
| 81 |
+
agent_res = supabase.table("agents").select("*").eq("id", agent_id).single().execute()
|
| 82 |
+
if agent_res.data:
|
| 83 |
+
await AgentRunnerService.execute_agent_logic(task, agent_res.data)
|
| 84 |
+
await TaskQueueService.clear_lease(task_id)
|
| 85 |
+
self.processed_count += 1
|
| 86 |
+
await self.heartbeat("idle")
|
| 87 |
+
logger.info("Task %s completed successfully.", task_id)
|
| 88 |
+
else:
|
| 89 |
+
raise RuntimeError(f"Assigned agent not found: {agent_id}")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error("Failed to process task %s: %s", task_id, e)
|
| 92 |
+
self.failed_count += 1
|
| 93 |
+
await TaskQueueService.mark_attempt_failed(
|
| 94 |
+
task,
|
| 95 |
+
str(e),
|
| 96 |
+
self.max_attempts,
|
| 97 |
+
self.retry_delay_seconds,
|
| 98 |
+
)
|
| 99 |
+
await self.heartbeat("error")
|
| 100 |
+
else:
|
| 101 |
+
# No tasks, sleep for a bit (10s)
|
| 102 |
+
await asyncio.sleep(10)
|
| 103 |
+
finally:
|
| 104 |
+
if heartbeat_task:
|
| 105 |
+
heartbeat_task.cancel()
|
| 106 |
+
await self.heartbeat("stopping")
|
| 107 |
|
| 108 |
def stop(self):
|
| 109 |
logger.info("Stopping worker...")
|
|
|
|
| 115 |
# Handle shutdown signals
|
| 116 |
loop = asyncio.get_running_loop()
|
| 117 |
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 118 |
+
try:
|
| 119 |
+
loop.add_signal_handler(sig, worker.stop)
|
| 120 |
+
except NotImplementedError:
|
| 121 |
+
signal.signal(sig, lambda *_: worker.stop())
|
| 122 |
|
| 123 |
await worker.start()
|
| 124 |
+
await worker.heartbeat("stopping")
|
| 125 |
|
| 126 |
if __name__ == "__main__":
|
| 127 |
asyncio.run(main())
|
frontend/src/components/Dashboard.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
-
import { FolderOpen, Play, RefreshCw, Trash2 } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
|
|
|
| 6 |
|
| 7 |
interface Project {
|
| 8 |
id: string;
|
|
@@ -29,6 +30,10 @@ const Dashboard: React.FC<DashboardProps> = ({ onNewProject, onOpenProject }) =>
|
|
| 29 |
const [tasks, setTasks] = useState<Task[]>([]);
|
| 30 |
const [loading, setLoading] = useState(false);
|
| 31 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
const loadDashboard = useCallback(async () => {
|
| 34 |
if (!user) return;
|
|
@@ -92,6 +97,52 @@ const Dashboard: React.FC<DashboardProps> = ({ onNewProject, onOpenProject }) =>
|
|
| 92 |
}, {});
|
| 93 |
}, [tasks]);
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
return (
|
| 96 |
<>
|
| 97 |
<div className="page-heading dashboard-heading">
|
|
@@ -113,6 +164,53 @@ const Dashboard: React.FC<DashboardProps> = ({ onNewProject, onOpenProject }) =>
|
|
| 113 |
|
| 114 |
{error && <div className="inline-status">{error}</div>}
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
{!loading && projects.length === 0 && (
|
| 117 |
<div className="glass-panel empty-state">
|
| 118 |
<FolderOpen size={32} color="var(--accent)" />
|
|
@@ -124,8 +222,19 @@ const Dashboard: React.FC<DashboardProps> = ({ onNewProject, onOpenProject }) =>
|
|
| 124 |
</div>
|
| 125 |
)}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
<div className="dashboard-grid">
|
| 128 |
-
{
|
| 129 |
const counts = taskCounts[project.id] ?? { done: 0, total: 0 };
|
| 130 |
return (
|
| 131 |
<ProjectCard
|
|
@@ -155,15 +264,15 @@ const ProjectCard: React.FC<{ name: string; status: string; tasksDone: number; t
|
|
| 155 |
const progress = tasksTotal > 0 ? (tasksDone / tasksTotal) * 100 : 0;
|
| 156 |
|
| 157 |
return (
|
| 158 |
-
<motion.div whileHover={{ y: -5 }} className="glass-panel project-card"
|
| 159 |
-
<div className="project-card-header"
|
| 160 |
-
<h3
|
| 161 |
-
<div
|
| 162 |
<StatusBadge status={status} />
|
| 163 |
<button
|
| 164 |
className="btn btn-icon"
|
| 165 |
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
| 166 |
-
style={{ color: 'var(--danger)', opacity: 0.6
|
| 167 |
title="Delete Project"
|
| 168 |
>
|
| 169 |
<Trash2 size={16} />
|
|
@@ -173,17 +282,17 @@ const ProjectCard: React.FC<{ name: string; status: string; tasksDone: number; t
|
|
| 173 |
|
| 174 |
{/* Description removed as requested for a cleaner layout */}
|
| 175 |
|
| 176 |
-
<div
|
| 177 |
-
<div
|
| 178 |
-
<span
|
| 179 |
<span>{tasksDone}/{tasksTotal}</span>
|
| 180 |
</div>
|
| 181 |
-
<div
|
| 182 |
-
<div style={{
|
| 183 |
</div>
|
| 184 |
</div>
|
| 185 |
|
| 186 |
-
<button className="btn btn-primary"
|
| 187 |
<Play size={16} fill="white" />
|
| 188 |
Open Project
|
| 189 |
</button>
|
|
@@ -191,25 +300,5 @@ const ProjectCard: React.FC<{ name: string; status: string; tasksDone: number; t
|
|
| 191 |
);
|
| 192 |
};
|
| 193 |
|
| 194 |
-
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
| 195 |
-
const normalized = status.replace('_', ' ');
|
| 196 |
-
const color = status === 'active' ? 'var(--success)' : status === 'completed' ? 'var(--info)' : 'var(--text-muted)';
|
| 197 |
-
|
| 198 |
-
return (
|
| 199 |
-
<span style={{
|
| 200 |
-
fontSize: '0.7rem',
|
| 201 |
-
padding: '0.2rem 0.6rem',
|
| 202 |
-
borderRadius: 'var(--radius-full)',
|
| 203 |
-
background: 'rgba(255,255,255,0.05)',
|
| 204 |
-
border: `1px solid ${color}`,
|
| 205 |
-
color,
|
| 206 |
-
textTransform: 'uppercase',
|
| 207 |
-
fontWeight: 700,
|
| 208 |
-
letterSpacing: '0.05em'
|
| 209 |
-
}}>
|
| 210 |
-
{normalized}
|
| 211 |
-
</span>
|
| 212 |
-
);
|
| 213 |
-
};
|
| 214 |
|
| 215 |
export default Dashboard;
|
|
|
|
| 1 |
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
+
import { FolderOpen, Play, RefreshCw, Search, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
| 6 |
+
import StatusBadge from './common/StatusBadge';
|
| 7 |
|
| 8 |
interface Project {
|
| 9 |
id: string;
|
|
|
|
| 30 |
const [tasks, setTasks] = useState<Task[]>([]);
|
| 31 |
const [loading, setLoading] = useState(false);
|
| 32 |
const [error, setError] = useState<string | null>(null);
|
| 33 |
+
const [searchTerm, setSearchTerm] = useState('');
|
| 34 |
+
const [statusFilter, setStatusFilter] = useState('all');
|
| 35 |
+
const [progressFilter, setProgressFilter] = useState('all');
|
| 36 |
+
const [sortBy, setSortBy] = useState('newest');
|
| 37 |
|
| 38 |
const loadDashboard = useCallback(async () => {
|
| 39 |
if (!user) return;
|
|
|
|
| 97 |
}, {});
|
| 98 |
}, [tasks]);
|
| 99 |
|
| 100 |
+
const filteredProjects = useMemo(() => {
|
| 101 |
+
const normalizedSearch = searchTerm.trim().toLowerCase();
|
| 102 |
+
|
| 103 |
+
return projects
|
| 104 |
+
.filter((project) => {
|
| 105 |
+
if (statusFilter !== 'all' && project.status !== statusFilter) return false;
|
| 106 |
+
|
| 107 |
+
if (normalizedSearch) {
|
| 108 |
+
const searchableText = `${project.name} ${project.description ?? ''}`.toLowerCase();
|
| 109 |
+
if (!searchableText.includes(normalizedSearch)) return false;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const counts = taskCounts[project.id] ?? { done: 0, total: 0 };
|
| 113 |
+
const progress = counts.total > 0 ? counts.done / counts.total : 0;
|
| 114 |
+
|
| 115 |
+
if (progressFilter === 'not_started') return counts.done === 0;
|
| 116 |
+
if (progressFilter === 'in_progress') return progress > 0 && progress < 1;
|
| 117 |
+
if (progressFilter === 'completed') return counts.total > 0 && progress === 1;
|
| 118 |
+
if (progressFilter === 'no_tasks') return counts.total === 0;
|
| 119 |
+
|
| 120 |
+
return true;
|
| 121 |
+
})
|
| 122 |
+
.sort((a, b) => {
|
| 123 |
+
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
| 124 |
+
if (sortBy === 'oldest') return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
| 125 |
+
if (sortBy === 'progress') {
|
| 126 |
+
const aCounts = taskCounts[a.id] ?? { done: 0, total: 0 };
|
| 127 |
+
const bCounts = taskCounts[b.id] ?? { done: 0, total: 0 };
|
| 128 |
+
const aProgress = aCounts.total > 0 ? aCounts.done / aCounts.total : 0;
|
| 129 |
+
const bProgress = bCounts.total > 0 ? bCounts.done / bCounts.total : 0;
|
| 130 |
+
return bProgress - aProgress;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
| 134 |
+
});
|
| 135 |
+
}, [progressFilter, projects, searchTerm, sortBy, statusFilter, taskCounts]);
|
| 136 |
+
|
| 137 |
+
const hasActiveFilters = Boolean(searchTerm.trim()) || statusFilter !== 'all' || progressFilter !== 'all' || sortBy !== 'newest';
|
| 138 |
+
|
| 139 |
+
const clearFilters = () => {
|
| 140 |
+
setSearchTerm('');
|
| 141 |
+
setStatusFilter('all');
|
| 142 |
+
setProgressFilter('all');
|
| 143 |
+
setSortBy('newest');
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
return (
|
| 147 |
<>
|
| 148 |
<div className="page-heading dashboard-heading">
|
|
|
|
| 164 |
|
| 165 |
{error && <div className="inline-status">{error}</div>}
|
| 166 |
|
| 167 |
+
{projects.length > 0 && (
|
| 168 |
+
<div className="dashboard-controls glass-panel">
|
| 169 |
+
<div className="dashboard-search">
|
| 170 |
+
<Search size={17} />
|
| 171 |
+
<input
|
| 172 |
+
value={searchTerm}
|
| 173 |
+
onChange={(event) => setSearchTerm(event.target.value)}
|
| 174 |
+
placeholder="Search projects..."
|
| 175 |
+
aria-label="Search projects"
|
| 176 |
+
/>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
<div className="dashboard-filter-group">
|
| 180 |
+
<SlidersHorizontal size={17} />
|
| 181 |
+
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)} aria-label="Filter by status">
|
| 182 |
+
<option value="all">All statuses</option>
|
| 183 |
+
<option value="active">Active</option>
|
| 184 |
+
<option value="completed">Completed</option>
|
| 185 |
+
<option value="archived">Archived</option>
|
| 186 |
+
</select>
|
| 187 |
+
<select value={progressFilter} onChange={(event) => setProgressFilter(event.target.value)} aria-label="Filter by progress">
|
| 188 |
+
<option value="all">All progress</option>
|
| 189 |
+
<option value="not_started">Not started</option>
|
| 190 |
+
<option value="in_progress">In progress</option>
|
| 191 |
+
<option value="completed">Completed tasks</option>
|
| 192 |
+
<option value="no_tasks">No tasks</option>
|
| 193 |
+
</select>
|
| 194 |
+
<select value={sortBy} onChange={(event) => setSortBy(event.target.value)} aria-label="Sort projects">
|
| 195 |
+
<option value="newest">Newest first</option>
|
| 196 |
+
<option value="oldest">Oldest first</option>
|
| 197 |
+
<option value="name">Name A-Z</option>
|
| 198 |
+
<option value="progress">Most progress</option>
|
| 199 |
+
</select>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div className="dashboard-results">
|
| 203 |
+
<span>{filteredProjects.length}/{projects.length} shown</span>
|
| 204 |
+
{hasActiveFilters && (
|
| 205 |
+
<button className="btn btn-glass btn-sm" type="button" onClick={clearFilters}>
|
| 206 |
+
<X size={14} />
|
| 207 |
+
Clear
|
| 208 |
+
</button>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
|
| 214 |
{!loading && projects.length === 0 && (
|
| 215 |
<div className="glass-panel empty-state">
|
| 216 |
<FolderOpen size={32} color="var(--accent)" />
|
|
|
|
| 222 |
</div>
|
| 223 |
)}
|
| 224 |
|
| 225 |
+
{!loading && projects.length > 0 && filteredProjects.length === 0 && (
|
| 226 |
+
<div className="glass-panel empty-state">
|
| 227 |
+
<Search size={32} color="var(--accent)" />
|
| 228 |
+
<h3>No matching projects</h3>
|
| 229 |
+
<p>Adjust the search or filters to show more projects.</p>
|
| 230 |
+
<button className="btn btn-glass" onClick={clearFilters}>
|
| 231 |
+
Clear Filters
|
| 232 |
+
</button>
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
<div className="dashboard-grid">
|
| 237 |
+
{filteredProjects.map((project) => {
|
| 238 |
const counts = taskCounts[project.id] ?? { done: 0, total: 0 };
|
| 239 |
return (
|
| 240 |
<ProjectCard
|
|
|
|
| 264 |
const progress = tasksTotal > 0 ? (tasksDone / tasksTotal) * 100 : 0;
|
| 265 |
|
| 266 |
return (
|
| 267 |
+
<motion.div whileHover={{ y: -5 }} className="glass-panel project-card">
|
| 268 |
+
<div className="project-card-header">
|
| 269 |
+
<h3>{name}</h3>
|
| 270 |
+
<div className="project-card-actions">
|
| 271 |
<StatusBadge status={status} />
|
| 272 |
<button
|
| 273 |
className="btn btn-icon"
|
| 274 |
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
| 275 |
+
style={{ color: 'var(--danger)', opacity: 0.6 }}
|
| 276 |
title="Delete Project"
|
| 277 |
>
|
| 278 |
<Trash2 size={16} />
|
|
|
|
| 282 |
|
| 283 |
{/* Description removed as requested for a cleaner layout */}
|
| 284 |
|
| 285 |
+
<div className="project-card-progress">
|
| 286 |
+
<div className="project-card-progress-label">
|
| 287 |
+
<span>Tasks Progress</span>
|
| 288 |
<span>{tasksDone}/{tasksTotal}</span>
|
| 289 |
</div>
|
| 290 |
+
<div className="project-card-progress-track">
|
| 291 |
+
<div className="project-card-progress-fill" style={{ width: `${progress}%` }} />
|
| 292 |
</div>
|
| 293 |
</div>
|
| 294 |
|
| 295 |
+
<button className="btn btn-primary project-card-open" onClick={onOpen}>
|
| 296 |
<Play size={16} fill="white" />
|
| 297 |
Open Project
|
| 298 |
</button>
|
|
|
|
| 300 |
);
|
| 301 |
};
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
export default Dashboard;
|
frontend/src/components/Login.tsx
CHANGED
|
@@ -19,11 +19,6 @@ const Login: React.FC = () => {
|
|
| 19 |
setLoading(false);
|
| 20 |
};
|
| 21 |
|
| 22 |
-
const handleSSOLogin = async (provider: 'google' | 'github') => {
|
| 23 |
-
const { error } = await supabase.auth.signInWithOAuth({ provider });
|
| 24 |
-
if (error) setError(error.message);
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
return (
|
| 28 |
<div className="login-screen">
|
| 29 |
<motion.div
|
|
@@ -95,6 +90,7 @@ const Login: React.FC = () => {
|
|
| 95 |
</button>
|
| 96 |
</form>
|
| 97 |
|
|
|
|
| 98 |
<div style={{ margin: 'var(--space-lg) 0', display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
| 99 |
<div style={{ flex: 1, height: '1px', background: 'var(--glass-border)' }}></div>
|
| 100 |
<span style={{ fontSize: '0.8rem', color: 'var(--text-dim)' }}>OR CONTINUE WITH</span>
|
|
@@ -102,15 +98,16 @@ const Login: React.FC = () => {
|
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<div className="auth-provider-grid">
|
| 105 |
-
<button className="btn btn-glass" onClick={() => handleSSOLogin('google')}>
|
| 106 |
<Globe size={18} />
|
| 107 |
Google
|
| 108 |
</button>
|
| 109 |
-
<button className="btn btn-glass" onClick={() => handleSSOLogin('github')}>
|
| 110 |
<GitBranch size={18} />
|
| 111 |
GitHub
|
| 112 |
</button>
|
| 113 |
</div>
|
|
|
|
| 114 |
|
| 115 |
<div style={{ marginTop: 'var(--space-lg)', fontSize: '0.85rem', color: 'var(--text-dim)' }}>
|
| 116 |
Enterprise authentication enabled.
|
|
|
|
| 19 |
setLoading(false);
|
| 20 |
};
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
return (
|
| 23 |
<div className="login-screen">
|
| 24 |
<motion.div
|
|
|
|
| 90 |
</button>
|
| 91 |
</form>
|
| 92 |
|
| 93 |
+
{/* Social Login - Hidden for now but code preserved
|
| 94 |
<div style={{ margin: 'var(--space-lg) 0', display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
| 95 |
<div style={{ flex: 1, height: '1px', background: 'var(--glass-border)' }}></div>
|
| 96 |
<span style={{ fontSize: '0.8rem', color: 'var(--text-dim)' }}>OR CONTINUE WITH</span>
|
|
|
|
| 98 |
</div>
|
| 99 |
|
| 100 |
<div className="auth-provider-grid">
|
| 101 |
+
<button className="btn btn-glass" onClick={() => (window as any).handleSSOLogin?.('google')}>
|
| 102 |
<Globe size={18} />
|
| 103 |
Google
|
| 104 |
</button>
|
| 105 |
+
<button className="btn btn-glass" onClick={() => (window as any).handleSSOLogin?.('github')}>
|
| 106 |
<GitBranch size={18} />
|
| 107 |
GitHub
|
| 108 |
</button>
|
| 109 |
</div>
|
| 110 |
+
*/}
|
| 111 |
|
| 112 |
<div style={{ marginTop: 'var(--space-lg)', fontSize: '0.85rem', color: 'var(--text-dim)' }}>
|
| 113 |
Enterprise authentication enabled.
|
frontend/src/components/common/StatusBadge.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface StatusBadgeProps {
|
| 4 |
+
status: string;
|
| 5 |
+
style?: React.CSSProperties;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, style }) => {
|
| 9 |
+
const getStatusColor = (s: string) => {
|
| 10 |
+
switch (s?.toLowerCase()) {
|
| 11 |
+
case 'done':
|
| 12 |
+
case 'completed':
|
| 13 |
+
case 'approved':
|
| 14 |
+
return 'var(--success)';
|
| 15 |
+
case 'in_progress':
|
| 16 |
+
case 'running':
|
| 17 |
+
return 'var(--accent)';
|
| 18 |
+
case 'todo':
|
| 19 |
+
case 'queued':
|
| 20 |
+
return 'var(--text-dim)';
|
| 21 |
+
case 'failed':
|
| 22 |
+
case 'error':
|
| 23 |
+
return 'var(--danger)';
|
| 24 |
+
case 'awaiting_approval':
|
| 25 |
+
return 'var(--warning)';
|
| 26 |
+
default:
|
| 27 |
+
return 'var(--text-dim)';
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const formatStatus = (s: string) => {
|
| 32 |
+
return (s || 'Unknown').replace(/_/g, ' ').toUpperCase();
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<span style={{
|
| 37 |
+
padding: '4px 8px',
|
| 38 |
+
borderRadius: '4px',
|
| 39 |
+
fontSize: '0.7rem',
|
| 40 |
+
fontWeight: 600,
|
| 41 |
+
background: 'rgba(255,255,255,0.05)',
|
| 42 |
+
border: `1px solid ${getStatusColor(status)}33`,
|
| 43 |
+
color: getStatusColor(status),
|
| 44 |
+
display: 'inline-flex',
|
| 45 |
+
alignItems: 'center',
|
| 46 |
+
gap: '4px',
|
| 47 |
+
...style
|
| 48 |
+
}}>
|
| 49 |
+
<span style={{
|
| 50 |
+
width: '6px',
|
| 51 |
+
height: '6px',
|
| 52 |
+
borderRadius: '50%',
|
| 53 |
+
background: getStatusColor(status),
|
| 54 |
+
boxShadow: `0 0 8px ${getStatusColor(status)}`
|
| 55 |
+
}} />
|
| 56 |
+
{formatStatus(status)}
|
| 57 |
+
</span>
|
| 58 |
+
);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
export default StatusBadge;
|