diff --git a/agents/bernard/bernard/__init__.py b/agents/bernard/bernard/__init__.py
index 275d844..5c1d8d0 100644
--- a/agents/bernard/bernard/__init__.py
+++ b/agents/bernard/bernard/__init__.py
@@ -1 +1 @@
-"""Bernard dev/PR agent (scaffold)."""
+"""Bernard — GitOps PR reviewer & Temporal workflow monitor agent."""
diff --git a/agents/bernard/bernard/dashboard/__init__.py b/agents/bernard/bernard/dashboard/__init__.py
new file mode 100644
index 0000000..038fb97
--- /dev/null
+++ b/agents/bernard/bernard/dashboard/__init__.py
@@ -0,0 +1 @@
+"""Bernard dashboard package."""
diff --git a/agents/bernard/bernard/dashboard/api.py b/agents/bernard/bernard/dashboard/api.py
new file mode 100644
index 0000000..abcae48
--- /dev/null
+++ b/agents/bernard/bernard/dashboard/api.py
@@ -0,0 +1,119 @@
+"""Bernard dashboard API — FastAPI server serving alerts from Postgres."""
+from __future__ import annotations
+
+import json
+import os
+from contextlib import asynccontextmanager
+
+import psycopg
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+
+DB_DSN: str = ""
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ global DB_DSN
+ DB_DSN = os.environ["BERNARD_DB_DSN"]
+ # Ensure tables exist
+ conn = await psycopg.AsyncConnection.connect(DB_DSN)
+ try:
+ async with conn.cursor() as cur:
+ await cur.execute("""
+ CREATE TABLE IF NOT EXISTS bernard_alerts (
+ id SERIAL PRIMARY KEY,
+ alert_type TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ metadata JSONB DEFAULT '{}',
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ )
+ """)
+ await cur.execute("""
+ CREATE TABLE IF NOT EXISTS bernard_reviewed_prs (
+ id SERIAL PRIMARY KEY,
+ pr_key TEXT UNIQUE NOT NULL,
+ reviewed_at TIMESTAMPTZ DEFAULT NOW()
+ )
+ """)
+ await conn.commit()
+ finally:
+ await conn.close()
+ yield
+
+
+app = FastAPI(title="Bernard Dashboard API", lifespan=lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+@app.get("/api/alerts")
+async def get_alerts(limit: int = 50, alert_type: str | None = None):
+ conn = await psycopg.AsyncConnection.connect(DB_DSN)
+ try:
+ async with conn.cursor() as cur:
+ if alert_type:
+ await cur.execute(
+ "SELECT id, alert_type, title, body, metadata, created_at FROM bernard_alerts WHERE alert_type=%s ORDER BY created_at DESC LIMIT %s",
+ (alert_type, limit),
+ )
+ else:
+ await cur.execute(
+ "SELECT id, alert_type, title, body, metadata, created_at FROM bernard_alerts ORDER BY created_at DESC LIMIT %s",
+ (limit,),
+ )
+ rows = await cur.fetchall()
+ return [
+ {
+ "id": r[0],
+ "alert_type": r[1],
+ "title": r[2],
+ "body": r[3],
+ "metadata": r[4],
+ "created_at": r[5].isoformat() if r[5] else None,
+ }
+ for r in rows
+ ]
+ finally:
+ await conn.close()
+
+
+@app.get("/api/stats")
+async def get_stats():
+ conn = await psycopg.AsyncConnection.connect(DB_DSN)
+ try:
+ async with conn.cursor() as cur:
+ await cur.execute("SELECT COUNT(*) FROM bernard_alerts")
+ total = (await cur.fetchone())[0]
+ await cur.execute("SELECT COUNT(*) FROM bernard_alerts WHERE alert_type='pr_review'")
+ pr_reviews = (await cur.fetchone())[0]
+ await cur.execute("SELECT COUNT(*) FROM bernard_alerts WHERE alert_type='workflow_failure'")
+ wf_failures = (await cur.fetchone())[0]
+ await cur.execute("SELECT COUNT(*) FROM bernard_reviewed_prs")
+ reviewed_prs = (await cur.fetchone())[0]
+ return {
+ "total_alerts": total,
+ "pr_reviews": pr_reviews,
+ "workflow_failures": wf_failures,
+ "reviewed_prs": reviewed_prs,
+ }
+ finally:
+ await conn.close()
+
+
+@app.get("/health")
+async def health():
+ return {"status": "ok", "agent": "bernard"}
+
+
+@app.get("/{full_path:path}")
+async def serve_spa(full_path: str):
+ return FileResponse("/app/static/index.html")
diff --git a/agents/bernard/bernard/dashboard/server.py b/agents/bernard/bernard/dashboard/server.py
new file mode 100644
index 0000000..d1ec23f
--- /dev/null
+++ b/agents/bernard/bernard/dashboard/server.py
@@ -0,0 +1,12 @@
+"""Bernard dashboard server entrypoint."""
+import os
+import uvicorn
+from bernard.dashboard.api import app
+
+
+def main() -> None:
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/agents/bernard/bernard/dashboard/static/index.html b/agents/bernard/bernard/dashboard/static/index.html
new file mode 100644
index 0000000..0ee9025
--- /dev/null
+++ b/agents/bernard/bernard/dashboard/static/index.html
@@ -0,0 +1,312 @@
+
+
+
+
+
+Bernard — Agentic OS Monitor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚠️
+
Workflow Failures
+
—
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/agents/bernard/bernard/llm.py b/agents/bernard/bernard/llm.py
new file mode 100644
index 0000000..68ae796
--- /dev/null
+++ b/agents/bernard/bernard/llm.py
@@ -0,0 +1,68 @@
+"""Bernard LLM integration — calls LiteLLM for code review and failure analysis."""
+from __future__ import annotations
+
+import os
+
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import HumanMessage, SystemMessage
+
+
+def _llm() -> ChatOpenAI:
+ return ChatOpenAI(
+ model=os.environ.get("BERNARD_MODEL", "ollama-qwen"),
+ openai_api_key=os.environ["LITELLM_API_KEY"],
+ openai_api_base=os.environ.get("LITELLM_BASE_URL", "http://litellm.ai-core.svc.cluster.local:4000"),
+ temperature=0.3,
+ max_tokens=2048,
+ )
+
+
+async def review_code_diff(title: str, author: str, diff: str) -> str:
+ """Review a git diff and return a markdown-formatted code review."""
+ llm = _llm()
+ system = SystemMessage(content="""You are Bernard, an expert code reviewer for a Kubernetes GitOps platform.
+Review the provided git diff and provide structured feedback covering:
+1. **Security Issues** — Any secrets, vulnerabilities, or auth problems
+2. **Infrastructure Risks** — Misconfigurations, missing health probes, resource limits
+3. **Code Quality** — Logic errors, anti-patterns, or improvements
+4. **Summary** — Overall assessment (Approve / Request Changes / Needs Discussion)
+
+Be concise and actionable. Format your response in markdown.""")
+
+ # Truncate diff if too large
+ diff_truncated = diff[:6000] + "\n... [diff truncated]" if len(diff) > 6000 else diff
+
+ user = HumanMessage(content=f"""**PR Title:** {title}
+**Author:** {author}
+
+**Diff:**
+```diff
+{diff_truncated}
+```""")
+
+ response = await llm.ainvoke([system, user])
+ return response.content
+
+
+async def analyze_workflow_failure(workflow_id: str, workflow_type: str, error: str) -> str:
+ """Analyze a Temporal workflow failure and suggest a fix."""
+ llm = _llm()
+ system = SystemMessage(content="""You are Bernard, an expert in Temporal workflow orchestration and LangGraph agents.
+Analyze the provided workflow failure and provide:
+1. **Root Cause** — What caused the failure
+2. **Impact** — What was affected (data loss risk, user impact)
+3. **Recommended Fix** — Concrete steps to resolve the issue
+4. **Prevention** — How to prevent this in the future
+
+Be concise and technical. Format your response in markdown.""")
+
+ user = HumanMessage(content=f"""**Workflow ID:** {workflow_id}
+**Workflow Type:** {workflow_type}
+
+**Error:**
+```
+{error[:3000]}
+```""")
+
+ response = await llm.ainvoke([system, user])
+ return response.content
diff --git a/agents/bernard/bernard/temporal/__init__.py b/agents/bernard/bernard/temporal/__init__.py
new file mode 100644
index 0000000..37b2248
--- /dev/null
+++ b/agents/bernard/bernard/temporal/__init__.py
@@ -0,0 +1 @@
+"""Bernard temporal package."""
diff --git a/agents/bernard/bernard/temporal/activities.py b/agents/bernard/bernard/temporal/activities.py
new file mode 100644
index 0000000..850bf02
--- /dev/null
+++ b/agents/bernard/bernard/temporal/activities.py
@@ -0,0 +1,229 @@
+"""Bernard Temporal activities — Gitea PR review & Temporal workflow monitoring."""
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+import psycopg
+from temporalio import activity
+
+
+# ---------------------------------------------------------------------------
+# Gitea helpers
+# ---------------------------------------------------------------------------
+
+def _gitea_headers() -> dict:
+ return {
+ "Authorization": f"token {os.environ['GITEA_TOKEN']}",
+ "Accept": "application/json",
+ }
+
+
+def _gitea_base() -> str:
+ return os.environ.get("GITEA_BASE_URL", "http://gitea.gitea.svc.cluster.local:3000")
+
+
+# ---------------------------------------------------------------------------
+# PR Review activities
+# ---------------------------------------------------------------------------
+
+@activity.defn
+async def fetch_open_prs(repo_full_name: str) -> list[dict[str, Any]]:
+ """Return list of open PRs from Gitea for the given repo (owner/repo)."""
+ url = f"{_gitea_base()}/api/v1/repos/{repo_full_name}/pulls"
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.get(url, headers=_gitea_headers(), params={"state": "open", "limit": 20})
+ resp.raise_for_status()
+ return resp.json()
+
+
+@activity.defn
+async def fetch_pr_diff(payload: dict[str, Any]) -> dict[str, Any]:
+ """Fetch the unified diff for a specific PR."""
+ repo = payload["repo"]
+ pr_number = payload["pr_number"]
+ url = f"{_gitea_base()}/api/v1/repos/{repo}/pulls/{pr_number}.diff"
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.get(url, headers=_gitea_headers())
+ resp.raise_for_status()
+ pr_url = f"{_gitea_base()}/api/v1/repos/{repo}/pulls/{pr_number}"
+ pr_resp = await client.get(pr_url, headers=_gitea_headers())
+ pr_resp.raise_for_status()
+ pr_data = pr_resp.json()
+ return {
+ "repo": repo,
+ "pr_number": pr_number,
+ "title": pr_data.get("title", ""),
+ "author": pr_data.get("user", {}).get("login", ""),
+ "diff": resp.text,
+ }
+
+
+@activity.defn
+async def review_pr_with_llm(payload: dict[str, Any]) -> dict[str, Any]:
+ """Send diff to LiteLLM/Ollama and get a structured code review."""
+ from bernard.llm import review_code_diff
+ review = await review_code_diff(
+ title=payload["title"],
+ author=payload["author"],
+ diff=payload["diff"],
+ )
+ return {
+ "repo": payload["repo"],
+ "pr_number": payload["pr_number"],
+ "title": payload["title"],
+ "author": payload["author"],
+ "review": review,
+ }
+
+
+@activity.defn
+async def post_pr_comment(payload: dict[str, Any]) -> None:
+ """Post the AI review as a comment on the Gitea PR."""
+ repo = payload["repo"]
+ pr_number = payload["pr_number"]
+ review = payload["review"]
+ body = f"## 🤖 Bernard AI Code Review\n\n{review}\n\n---\n*Reviewed by Bernard — Agentic OS monitoring agent*"
+ url = f"{_gitea_base()}/api/v1/repos/{repo}/issues/{pr_number}/comments"
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.post(url, headers=_gitea_headers(), json={"body": body})
+ resp.raise_for_status()
+
+
+@activity.defn
+async def get_reviewed_prs() -> set[str]:
+ """Fetch set of already-reviewed PR keys from the bernard DB."""
+ dsn = os.environ["BERNARD_DB_DSN"]
+ conn = await psycopg.AsyncConnection.connect(dsn)
+ try:
+ async with conn.cursor() as cur:
+ await cur.execute("""
+ CREATE TABLE IF NOT EXISTS bernard_reviewed_prs (
+ id SERIAL PRIMARY KEY,
+ pr_key TEXT UNIQUE NOT NULL,
+ reviewed_at TIMESTAMPTZ DEFAULT NOW()
+ )
+ """)
+ await conn.commit()
+ await cur.execute("SELECT pr_key FROM bernard_reviewed_prs")
+ rows = await cur.fetchall()
+ return {row[0] for row in rows}
+ finally:
+ await conn.close()
+
+
+@activity.defn
+async def mark_pr_reviewed(pr_key: str) -> None:
+ """Record that a PR has been reviewed to avoid duplicate comments."""
+ dsn = os.environ["BERNARD_DB_DSN"]
+ conn = await psycopg.AsyncConnection.connect(dsn)
+ try:
+ async with conn.cursor() as cur:
+ await cur.execute(
+ "INSERT INTO bernard_reviewed_prs (pr_key) VALUES (%s) ON CONFLICT DO NOTHING",
+ (pr_key,),
+ )
+ await conn.commit()
+ finally:
+ await conn.close()
+
+
+# ---------------------------------------------------------------------------
+# Temporal monitoring activities
+# ---------------------------------------------------------------------------
+
+@activity.defn
+async def fetch_failed_workflows() -> list[dict[str, Any]]:
+ """Query Temporal for workflows that failed in the last check window."""
+ from temporalio.client import Client
+ address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233")
+ namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
+ client = await Client.connect(address, namespace=namespace)
+
+ failed = []
+ async for wf in client.list_workflows('ExecutionStatus = "Failed"'):
+ failed.append({
+ "workflow_id": wf.id,
+ "run_id": wf.run_id,
+ "workflow_type": wf.workflow_type,
+ "start_time": wf.start_time.isoformat() if wf.start_time else None,
+ "close_time": wf.close_time.isoformat() if wf.close_time else None,
+ })
+ return failed
+
+
+@activity.defn
+async def fetch_workflow_failure_details(payload: dict[str, Any]) -> dict[str, Any]:
+ """Get the detailed error and history for a failed workflow."""
+ from temporalio.client import Client
+ address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233")
+ namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
+ client = await Client.connect(address, namespace=namespace)
+
+ handle = client.get_workflow_handle(payload["workflow_id"], run_id=payload["run_id"])
+ try:
+ await handle.result()
+ error_message = "No error (workflow may have recovered)"
+ except Exception as e:
+ error_message = str(e)
+
+ return {
+ "workflow_id": payload["workflow_id"],
+ "run_id": payload["run_id"],
+ "workflow_type": payload["workflow_type"],
+ "error": error_message,
+ }
+
+
+@activity.defn
+async def analyze_failure_with_llm(payload: dict[str, Any]) -> dict[str, Any]:
+ """Use LiteLLM to analyze the failure and suggest a fix."""
+ from bernard.llm import analyze_workflow_failure
+ analysis = await analyze_workflow_failure(
+ workflow_id=payload["workflow_id"],
+ workflow_type=payload["workflow_type"],
+ error=payload["error"],
+ )
+ return {**payload, "analysis": analysis}
+
+
+# ---------------------------------------------------------------------------
+# Shared: persist alert and dashboard update
+# ---------------------------------------------------------------------------
+
+@activity.defn
+async def persist_alert(payload: dict[str, Any]) -> str:
+ """Persist an alert to the bernard DB and return the alert ID."""
+ dsn = os.environ["BERNARD_DB_DSN"]
+ conn = await psycopg.AsyncConnection.connect(dsn)
+ try:
+ async with conn.cursor() as cur:
+ await cur.execute("""
+ CREATE TABLE IF NOT EXISTS bernard_alerts (
+ id SERIAL PRIMARY KEY,
+ alert_type TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ metadata JSONB DEFAULT '{}',
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ )
+ """)
+ await conn.commit()
+ await cur.execute(
+ """INSERT INTO bernard_alerts (alert_type, title, body, metadata)
+ VALUES (%s, %s, %s, %s) RETURNING id""",
+ (
+ payload["alert_type"],
+ payload["title"],
+ payload["body"],
+ json.dumps(payload.get("metadata", {})),
+ ),
+ )
+ row = await cur.fetchone()
+ await conn.commit()
+ return str(row[0])
+ finally:
+ await conn.close()
diff --git a/agents/bernard/bernard/temporal/worker.py b/agents/bernard/bernard/temporal/worker.py
new file mode 100644
index 0000000..06ae67c
--- /dev/null
+++ b/agents/bernard/bernard/temporal/worker.py
@@ -0,0 +1,46 @@
+"""Bernard Temporal worker entrypoint."""
+from __future__ import annotations
+
+import asyncio
+import os
+
+from temporalio.client import Client
+from temporalio.worker import Worker
+
+from bernard.temporal import activities
+from bernard.temporal.workflows import PRReviewWorkflow, TemporalMonitorWorkflow
+
+
+async def _main() -> None:
+ address = os.environ.get("TEMPORAL_ADDRESS", "temporal-frontend.ai-core.svc.cluster.local:7233")
+ namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
+ task_queue = os.environ.get("BERNARD_TASK_QUEUE", "bernard")
+
+ client = await Client.connect(address, namespace=namespace)
+ worker = Worker(
+ client,
+ task_queue=task_queue,
+ workflows=[PRReviewWorkflow, TemporalMonitorWorkflow],
+ activities=[
+ activities.fetch_open_prs,
+ activities.fetch_pr_diff,
+ activities.review_pr_with_llm,
+ activities.post_pr_comment,
+ activities.get_reviewed_prs,
+ activities.mark_pr_reviewed,
+ activities.fetch_failed_workflows,
+ activities.fetch_workflow_failure_details,
+ activities.analyze_failure_with_llm,
+ activities.persist_alert,
+ ],
+ )
+ print(f"Bernard worker started on task queue '{task_queue}'")
+ await worker.run()
+
+
+def main() -> None:
+ asyncio.run(_main())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/agents/bernard/bernard/temporal/workflows.py b/agents/bernard/bernard/temporal/workflows.py
new file mode 100644
index 0000000..4752caf
--- /dev/null
+++ b/agents/bernard/bernard/temporal/workflows.py
@@ -0,0 +1,133 @@
+"""Bernard Temporal workflows — PR review & Temporal health monitor."""
+from __future__ import annotations
+
+from datetime import timedelta
+
+from temporalio import workflow
+
+with workflow.unsafe.imports_passed_through():
+ from bernard.temporal.activities import (
+ analyze_failure_with_llm,
+ fetch_failed_workflows,
+ fetch_open_prs,
+ fetch_pr_diff,
+ fetch_workflow_failure_details,
+ get_reviewed_prs,
+ mark_pr_reviewed,
+ persist_alert,
+ post_pr_comment,
+ review_pr_with_llm,
+ )
+
+_ACT_TIMEOUT = timedelta(minutes=10)
+_REPO = "deepkoluguri/agentic-os"
+
+
+# ---------------------------------------------------------------------------
+# PR Review Workflow — scans open PRs, reviews any that haven't been reviewed
+# ---------------------------------------------------------------------------
+
+@workflow.defn
+class PRReviewWorkflow:
+ """Scan all open PRs in the agentic-os repo and post AI reviews."""
+
+ @workflow.run
+ async def run(self) -> dict:
+ reviewed_keys = await workflow.execute_activity(
+ get_reviewed_prs, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ open_prs = await workflow.execute_activity(
+ fetch_open_prs, _REPO, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ results = []
+ for pr in open_prs:
+ pr_key = f"{_REPO}#{pr['number']}"
+ if pr_key in reviewed_keys:
+ continue
+
+ diff_payload = await workflow.execute_activity(
+ fetch_pr_diff,
+ {"repo": _REPO, "pr_number": pr["number"]},
+ start_to_close_timeout=_ACT_TIMEOUT,
+ )
+
+ review_payload = await workflow.execute_activity(
+ review_pr_with_llm, diff_payload, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ await workflow.execute_activity(
+ post_pr_comment, review_payload, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ alert_id = await workflow.execute_activity(
+ persist_alert,
+ {
+ "alert_type": "pr_review",
+ "title": f"PR #{pr['number']}: {pr.get('title', '')}",
+ "body": review_payload["review"],
+ "metadata": {
+ "repo": _REPO,
+ "pr_number": pr["number"],
+ "author": pr.get("user", {}).get("login", ""),
+ },
+ },
+ start_to_close_timeout=_ACT_TIMEOUT,
+ )
+
+ await workflow.execute_activity(
+ mark_pr_reviewed, pr_key, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ results.append({"pr_key": pr_key, "alert_id": alert_id})
+
+ return {"reviewed": len(results), "prs": results}
+
+
+# ---------------------------------------------------------------------------
+# Temporal Monitor Workflow — runs on cron, scans for failed workflows
+# ---------------------------------------------------------------------------
+
+@workflow.defn
+class TemporalMonitorWorkflow:
+ """Check for failed Temporal workflows and post AI root-cause analysis."""
+
+ @workflow.run
+ async def run(self) -> dict:
+ failed_workflows = await workflow.execute_activity(
+ fetch_failed_workflows, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ results = []
+ for wf in failed_workflows:
+ details = await workflow.execute_activity(
+ fetch_workflow_failure_details, wf, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ analysis = await workflow.execute_activity(
+ analyze_failure_with_llm, details, start_to_close_timeout=_ACT_TIMEOUT
+ )
+
+ alert_id = await workflow.execute_activity(
+ persist_alert,
+ {
+ "alert_type": "workflow_failure",
+ "title": f"❌ Workflow Failed: {wf['workflow_type']} ({wf['workflow_id']})",
+ "body": analysis["analysis"],
+ "metadata": {
+ "workflow_id": wf["workflow_id"],
+ "run_id": wf["run_id"],
+ "workflow_type": wf["workflow_type"],
+ "error": details["error"],
+ },
+ },
+ start_to_close_timeout=_ACT_TIMEOUT,
+ )
+
+ results.append({
+ "workflow_id": wf["workflow_id"],
+ "alert_id": alert_id,
+ })
+
+ return {"failures_analyzed": len(results), "alerts": results}
diff --git a/agents/bernard/pyproject.toml b/agents/bernard/pyproject.toml
new file mode 100644
index 0000000..5dfe84a
--- /dev/null
+++ b/agents/bernard/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "bernard"
+version = "0.1.0"
+description = "Bernard — GitOps PR reviewer and Temporal workflow monitor"
+requires-python = ">=3.11"
+
+dependencies = [
+ "temporalio>=1.7.0",
+ "langchain-openai>=0.2.0",
+ "langchain-core>=0.3.0",
+ "psycopg[binary]>=3.1.0",
+ "httpx>=0.27.0",
+ "fastapi>=0.115.0",
+ "uvicorn>=0.30.0",
+]
+
+[project.scripts]
+bernard-worker = "bernard.temporal.worker:main"
+bernard-dashboard = "bernard.dashboard.server:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
diff --git a/agents/k8s/bernard-deployment.yaml b/agents/k8s/bernard-deployment.yaml
new file mode 100644
index 0000000..4b856b8
--- /dev/null
+++ b/agents/k8s/bernard-deployment.yaml
@@ -0,0 +1,184 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: bernard-worker
+ namespace: ai-agents-bernard
+ labels:
+ app.kubernetes.io/name: bernard-worker
+ agentic-os.io/agent: bernard
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: bernard-worker
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: bernard-worker
+ agentic-os.io/agent: bernard
+ spec:
+ initContainers:
+ - name: git-clone
+ image: alpine/git:latest
+ command:
+ - sh
+ - -c
+ - |
+ git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace
+ volumeMounts:
+ - name: workspace
+ mountPath: /workspace
+ containers:
+ - name: bernard-worker
+ image: python:3.11-slim
+ command:
+ - sh
+ - -c
+ - |
+ cd /workspace/agents/bernard && pip install -e . -q && bernard-worker
+ volumeMounts:
+ - name: workspace
+ mountPath: /workspace
+ env:
+ - name: TEMPORAL_ADDRESS
+ value: "temporal-frontend.ai-core.svc.cluster.local:7233"
+ - name: TEMPORAL_NAMESPACE
+ value: "default"
+ - name: BERNARD_TASK_QUEUE
+ value: "bernard"
+ - name: GITEA_BASE_URL
+ value: "http://gitea-http.gitea.svc.cluster.local:3000"
+ - name: GITEA_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: bernard-secrets
+ key: GITEA_TOKEN
+ - name: LITELLM_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: bernard-secrets
+ key: LITELLM_API_KEY
+ - name: LITELLM_BASE_URL
+ value: "http://litellm.ai-core.svc.cluster.local:4000"
+ - name: BERNARD_DB_DSN
+ valueFrom:
+ secretKeyRef:
+ name: bernard-secrets
+ key: BERNARD_DB_DSN
+ - name: BERNARD_MODEL
+ value: "ollama-qwen"
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ volumes:
+ - name: workspace
+ emptyDir: {}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: bernard-dashboard
+ namespace: ai-agents-bernard
+ labels:
+ app.kubernetes.io/name: bernard-dashboard
+ agentic-os.io/agent: bernard
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: bernard-dashboard
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: bernard-dashboard
+ agentic-os.io/agent: bernard
+ spec:
+ initContainers:
+ - name: git-clone
+ image: alpine/git:latest
+ command:
+ - sh
+ - -c
+ - |
+ git clone http://192.168.8.248:3000/deepkoluguri/agentic-os.git /workspace
+ volumeMounts:
+ - name: workspace
+ mountPath: /workspace
+ containers:
+ - name: bernard-dashboard
+ image: python:3.11-slim
+ command:
+ - sh
+ - -c
+ - |
+ cd /workspace/agents/bernard && pip install -e . -q && bernard-dashboard
+ volumeMounts:
+ - name: workspace
+ mountPath: /workspace
+ env:
+ - name: PORT
+ value: "8080"
+ - name: BERNARD_DB_DSN
+ valueFrom:
+ secretKeyRef:
+ name: bernard-secrets
+ key: BERNARD_DB_DSN
+ ports:
+ - containerPort: 8080
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 30
+ periodSeconds: 15
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ cpu: 200m
+ memory: 256Mi
+ volumes:
+ - name: workspace
+ emptyDir: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: bernard-dashboard
+ namespace: ai-agents-bernard
+spec:
+ selector:
+ app.kubernetes.io/name: bernard-dashboard
+ ports:
+ - port: 8080
+ targetPort: 8080
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: bernard-ingress
+ namespace: ai-agents-bernard
+spec:
+ ingressClassName: apisix
+ rules:
+ - host: bernard.applaude.net
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: bernard-dashboard
+ port:
+ number: 8080
diff --git a/agents/k8s/bernard-externalsecret.yaml b/agents/k8s/bernard-externalsecret.yaml
new file mode 100644
index 0000000..9688bff
--- /dev/null
+++ b/agents/k8s/bernard-externalsecret.yaml
@@ -0,0 +1,22 @@
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+ name: bernard-secrets
+ namespace: ai-agents-bernard
+spec:
+ refreshInterval: 1h
+ secretStoreRef:
+ name: infisical
+ kind: ClusterSecretStore
+ target:
+ name: bernard-secrets
+ data:
+ - secretKey: GITEA_TOKEN
+ remoteRef:
+ key: BERNARD_GITEA_TOKEN
+ - secretKey: LITELLM_API_KEY
+ remoteRef:
+ key: LITELLM_MASTER_KEY
+ - secretKey: BERNARD_DB_DSN
+ remoteRef:
+ key: BERNARD_DB_DSN
diff --git a/agents/k8s/kustomization.yaml b/agents/k8s/kustomization.yaml
index 714841c..c1cd68d 100644
--- a/agents/k8s/kustomization.yaml
+++ b/agents/k8s/kustomization.yaml
@@ -6,3 +6,5 @@ resources:
- static-demo.yaml
- gumbo-schema-init.yaml
- gumbo-worker-deployment.yaml
+ - bernard-externalsecret.yaml
+ - bernard-deployment.yaml
diff --git a/platform/data/cnpg/databases.yaml b/platform/data/cnpg/databases.yaml
index ef5c1dc..d645b9f 100644
--- a/platform/data/cnpg/databases.yaml
+++ b/platform/data/cnpg/databases.yaml
@@ -52,3 +52,14 @@ spec:
owner: agentic_os
cluster:
name: agentic-os-pg
+---
+apiVersion: postgresql.cnpg.io/v1
+kind: Database
+metadata:
+ name: bernard
+ namespace: platform-data
+spec:
+ name: bernard
+ owner: agentic_os
+ cluster:
+ name: agentic-os-pg