cesjavi commited on
Commit
ad8049f
·
1 Parent(s): 220067b

Update with production optimizations

Browse files
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.output_quality import clean_report_text, dedupe_lines, filter_report_sections, report_text_from_output
 
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
- stripped = stripped.strip("`")
74
- if stripped.lower().startswith("json"):
75
- stripped = stripped[4:].strip()
 
 
 
 
 
 
76
  try:
77
  return json.loads(stripped)
78
  except Exception:
79
- match = re.search(r"```json\s*(.*?)\s*```", text, re.IGNORECASE | re.DOTALL)
 
 
 
 
 
 
 
80
  if match:
81
- try:
82
- return json.loads(match.group(1))
83
- except Exception:
84
- return None
 
 
 
 
 
 
 
 
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
- if isinstance(next_steps, list) and next_steps:
 
 
 
 
 
 
129
  lines.append("")
130
  lines.append("Next steps:")
131
- for step in next_steps[:5]:
132
- if isinstance(step, str) and step.strip():
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
- .eq("status", "todo")
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 = report_text_from_output(output_data)
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
- lines.extend(["## Execution Summary", ""])
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
- lines.extend([
 
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": _build_report_charts(curated_tasks)
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 start(self):
16
- logger.info("Aubm Background Worker started.")
17
- while self.running:
18
- task = await TaskQueueService.get_next_queued_task()
19
 
20
- if task:
21
- task_id = task['id']
22
- logger.info(f"Processing task: {task_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- try:
25
- # Fetch agent data for this task
26
- agent_res = supabase.table("agents").select("*").eq("id", task["assigned_agent_id"]).single().execute()
27
- if agent_res.data:
28
- await AgentRunnerService.execute_agent_logic(task, agent_res.data)
29
- logger.info(f"Task {task_id} completed successfully.")
30
- else:
31
- logger.error(f"No agent found for task {task_id}")
32
- except Exception as e:
33
- logger.error(f"Failed to process task {task_id}: {e}")
34
- else:
35
- # No tasks, sleep for a bit
36
- await asyncio.sleep(5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- loop.add_signal_handler(sig, worker.stop)
 
 
 
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
- {projects.map((project) => {
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" style={{ padding: 'var(--space-lg)', position: 'relative', overflow: 'hidden' }}>
159
- <div className="project-card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 'var(--space-md)' }}>
160
- <h3 style={{ fontSize: '1.25rem', margin: 0, flex: 1, lineHeight: 1.2 }}>{name}</h3>
161
- <div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexShrink: 0 }}>
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, padding: '4px' }}
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 style={{ marginBottom: 'var(--space-lg)' }}>
177
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: 'var(--space-xs)' }}>
178
- <span style={{ color: 'var(--text-dim)' }}>Tasks Progress</span>
179
  <span>{tasksDone}/{tasksTotal}</span>
180
  </div>
181
- <div style={{ height: '6px', width: '100%', background: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>
182
- <div style={{ height: '100%', width: `${progress}%`, background: 'var(--accent)', borderRadius: '3px', boxShadow: '0 0 10px var(--accent)' }} />
183
  </div>
184
  </div>
185
 
186
- <button className="btn btn-primary" style={{ width: '100%' }} onClick={onOpen}>
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;