feat: add Bernard monitoring agent (PR review + Temporal monitor + dashboard)

This commit is contained in:
Antigravity 2026-05-21 19:13:58 -04:00
parent b6a81d326e
commit 4b446c19b2
15 changed files with 1164 additions and 1 deletions

View File

@ -1 +1 @@
"""Bernard dev/PR agent (scaffold).""" """Bernard — GitOps PR reviewer & Temporal workflow monitor agent."""

View File

@ -0,0 +1 @@
"""Bernard dashboard package."""

View File

@ -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")

View File

@ -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()

View File

@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bernard — Agentic OS Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/11.1.1/marked.min.js"></script>
<style>
:root {
--bg: #0d0f14;
--surface: #141720;
--surface2: #1c2030;
--border: #252a3a;
--accent-blue: #4f8ef7;
--accent-green: #34d399;
--accent-red: #f87171;
--accent-yellow: #fbbf24;
--accent-purple: #a78bfa;
--text: #e2e8f0;
--text-muted: #64748b;
--glow-blue: 0 0 20px rgba(79,142,247,0.15);
--glow-green: 0 0 20px rgba(52,211,153,0.15);
--glow-red: 0 0 20px rgba(248,113,113,0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; }
/* Sidebar */
.sidebar {
width: 240px; background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 24px 0; position: fixed; height: 100vh; z-index: 10;
}
.logo { padding: 0 20px 24px; border-bottom: 1px solid var(--border); }
.logo h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
.logo span.b { color: var(--accent-blue); }
.logo p { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green); display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
nav { padding: 20px 12px; flex: 1; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px;
cursor: pointer; font-size: 14px; color: var(--text-muted); transition: all 0.2s; margin-bottom: 4px;
}
.nav-item:hover, .nav-item.active { background: var(--surface2); color: var(--text); }
.nav-item.active { border-left: 3px solid var(--accent-blue); }
.nav-icon { font-size: 16px; width: 20px; text-align: center; }
.sidebar-footer { padding: 16px 20px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); }
/* Main */
.main { margin-left: 240px; flex: 1; padding: 32px; min-height: 100vh; }
.page-header { margin-bottom: 28px; }
.page-header h2 { font-size: 24px; font-weight: 600; }
.page-header p { color: var(--text-muted); font-size: 14px; margin-top: 4px; }
.refresh-btn {
float: right; margin-top: -40px; background: var(--surface2); border: 1px solid var(--border);
color: var(--text); padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px;
transition: all 0.2s; font-family: 'Inter', sans-serif;
}
.refresh-btn:hover { border-color: var(--accent-blue); color: var(--accent-blue); }
/* Stats grid */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
.stat-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 20px; position: relative; overflow: hidden; transition: all 0.3s;
}
.stat-card:hover { border-color: var(--accent-blue); box-shadow: var(--glow-blue); transform: translateY(-1px); }
.stat-card .label { font-size: 12px; color: var(--text-muted); font-weight: 500; letter-spacing: 0.5px; text-transform: uppercase; }
.stat-card .value { font-size: 36px; font-weight: 700; margin-top: 8px; font-family: 'JetBrains Mono', monospace; }
.stat-card .value.blue { color: var(--accent-blue); }
.stat-card .value.green { color: var(--accent-green); }
.stat-card .value.red { color: var(--accent-red); }
.stat-card .value.purple { color: var(--accent-purple); }
.stat-card .icon { position: absolute; top: 16px; right: 16px; font-size: 28px; opacity: 0.2; }
/* Filter bar */
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; }
.filter-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); font-size: 13px;
cursor: pointer; transition: all 0.2s; font-family: 'Inter', sans-serif;
}
.filter-btn:hover, .filter-btn.active { border-color: var(--accent-blue); color: var(--accent-blue); background: rgba(79,142,247,0.08); }
/* Alert feed */
.alerts-list { display: flex; flex-direction: column; gap: 12px; }
.alert-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden; transition: all 0.3s; cursor: pointer;
}
.alert-card:hover { border-color: var(--accent-blue); box-shadow: var(--glow-blue); }
.alert-card.expanded .alert-body-content { display: block; }
.alert-header { padding: 16px 20px; display: flex; align-items: center; gap: 12px; }
.badge {
font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px;
white-space: nowrap; letter-spacing: 0.5px; text-transform: uppercase;
}
.badge.pr { background: rgba(52,211,153,0.15); color: var(--accent-green); border: 1px solid rgba(52,211,153,0.3); }
.badge.failure { background: rgba(248,113,113,0.15); color: var(--accent-red); border: 1px solid rgba(248,113,113,0.3); }
.alert-title { font-size: 14px; font-weight: 500; flex: 1; }
.alert-time { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; white-space: nowrap; }
.chevron { color: var(--text-muted); transition: transform 0.3s; font-size: 12px; }
.alert-card.expanded .chevron { transform: rotate(180deg); }
.alert-body-content { display: none; padding: 0 20px 20px; border-top: 1px solid var(--border); }
.alert-body-content .markdown-body { padding-top: 16px; font-size: 13.5px; line-height: 1.7; color: var(--text); }
.markdown-body h1,.markdown-body h2,.markdown-body h3 { color: var(--accent-blue); margin: 16px 0 8px; font-size: 14px; }
.markdown-body code { background: var(--surface2); padding: 2px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--accent-yellow); }
.markdown-body pre { background: var(--surface2); padding: 12px; border-radius: 8px; overflow-x: auto; margin: 10px 0; }
.markdown-body pre code { background: none; padding: 0; color: var(--text); }
.markdown-body strong { color: var(--text); font-weight: 600; }
.markdown-body ul, .markdown-body ol { padding-left: 20px; margin: 8px 0; }
.markdown-body li { margin: 4px 0; }
.markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
.alert-meta { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.alert-meta-item { font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
.alert-meta-item span { color: var(--text); }
/* Empty/loading states */
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty .icon { font-size: 48px; margin-bottom: 16px; }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-center { text-align: center; padding: 60px; }
/* Responsive */
@media (max-width: 900px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.sidebar { display: none; }
.main { margin-left: 0; }
}
</style>
</head>
<body>
<aside class="sidebar">
<div class="logo">
<h1><span class="b">B</span>ernard</h1>
<p><span class="status-dot"></span>monitoring agent</p>
</div>
<nav>
<div class="nav-item active" onclick="setFilter('all', this)">
<span class="nav-icon">📊</span> Overview
</div>
<div class="nav-item" onclick="setFilter('pr_review', this)">
<span class="nav-icon">🔍</span> PR Reviews
</div>
<div class="nav-item" onclick="setFilter('workflow_failure', this)">
<span class="nav-icon">⚠️</span> Workflow Alerts
</div>
</nav>
<div class="sidebar-footer">
Agentic OS v1.0<br>
Temporal + LangGraph
</div>
</aside>
<main class="main">
<div class="page-header">
<h2 id="page-title">Overview</h2>
<p id="page-subtitle">All monitoring alerts from Bernard</p>
<button class="refresh-btn" onclick="loadAll()">↻ Refresh</button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="icon">📋</div>
<div class="label">Total Alerts</div>
<div class="value blue" id="stat-total"></div>
</div>
<div class="stat-card">
<div class="icon">🔍</div>
<div class="label">PR Reviews</div>
<div class="value green" id="stat-pr"></div>
</div>
<div class="stat-card">
<div class="icon">⚠️</div>
<div class="label">Workflow Failures</div>
<div class="value red" id="stat-failures"></div>
</div>
<div class="stat-card">
<div class="icon"></div>
<div class="label">PRs Reviewed</div>
<div class="value purple" id="stat-reviewed"></div>
</div>
</div>
<div class="filter-bar" id="filter-bar">
<button class="filter-btn active" onclick="applyFilter('all', this)">All</button>
<button class="filter-btn" onclick="applyFilter('pr_review', this)">PR Reviews</button>
<button class="filter-btn" onclick="applyFilter('workflow_failure', this)">Workflow Failures</button>
</div>
<div id="alerts-container">
<div class="loading-center"><div class="spinner"></div></div>
</div>
</main>
<script>
const API = '';
let currentFilter = 'all';
let allAlerts = [];
function timeAgo(iso) {
if (!iso) return 'unknown';
const d = new Date(iso);
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff/60)}m ago`;
if (diff < 86400) return `${Math.floor(diff/3600)}h ago`;
return `${Math.floor(diff/86400)}d ago`;
}
async function loadStats() {
try {
const r = await fetch(`${API}/api/stats`);
const s = await r.json();
document.getElementById('stat-total').textContent = s.total_alerts;
document.getElementById('stat-pr').textContent = s.pr_reviews;
document.getElementById('stat-failures').textContent = s.workflow_failures;
document.getElementById('stat-reviewed').textContent = s.reviewed_prs;
} catch(e) { console.error(e); }
}
async function loadAlerts(filter) {
const url = filter && filter !== 'all' ? `${API}/api/alerts?alert_type=${filter}` : `${API}/api/alerts`;
const r = await fetch(url);
return await r.json();
}
function renderAlerts(alerts) {
const container = document.getElementById('alerts-container');
if (!alerts.length) {
container.innerHTML = `<div class="empty"><div class="icon">🤖</div><p>No alerts yet. Bernard is watching...</p></div>`;
return;
}
container.innerHTML = `<div class="alerts-list">${alerts.map(renderAlert).join('')}</div>`;
// Syntax highlight code blocks
document.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
}
function renderAlert(a) {
const badge = a.alert_type === 'pr_review'
? `<span class="badge pr">PR Review</span>`
: `<span class="badge failure">⚠ Failure</span>`;
const meta = a.metadata || {};
const metaItems = Object.entries(meta).map(([k,v]) =>
`<div class="alert-meta-item">${k}: <span>${v}</span></div>`
).join('');
const bodyHtml = marked.parse(a.body || '');
return `
<div class="alert-card" id="alert-${a.id}" onclick="toggleAlert(${a.id})">
<div class="alert-header">
${badge}
<div class="alert-title">${escHtml(a.title)}</div>
<div class="alert-time">${timeAgo(a.created_at)}</div>
<div class="chevron"></div>
</div>
<div class="alert-body-content">
<div class="markdown-body">${bodyHtml}</div>
${metaItems ? `<div class="alert-meta">${metaItems}</div>` : ''}
</div>
</div>`;
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function toggleAlert(id) {
document.getElementById(`alert-${id}`)?.classList.toggle('expanded');
}
function applyFilter(filter, btn) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filtered = filter === 'all' ? allAlerts : allAlerts.filter(a => a.alert_type === filter);
renderAlerts(filtered);
}
function setFilter(filter, navEl) {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
navEl.classList.add('active');
currentFilter = filter;
const filtered = filter === 'all' ? allAlerts : allAlerts.filter(a => a.alert_type === filter);
renderAlerts(filtered);
const titles = { all: 'Overview', pr_review: 'PR Reviews', workflow_failure: 'Workflow Alerts' };
document.getElementById('page-title').textContent = titles[filter] || 'Overview';
}
async function loadAll() {
document.getElementById('alerts-container').innerHTML = '<div class="loading-center"><div class="spinner"></div></div>';
await loadStats();
allAlerts = await loadAlerts('all');
const filtered = currentFilter === 'all' ? allAlerts : allAlerts.filter(a => a.alert_type === currentFilter);
renderAlerts(filtered);
}
// Auto-refresh every 30s
loadAll();
setInterval(loadAll, 30000);
</script>
</body>
</html>

View File

@ -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

View File

@ -0,0 +1 @@
"""Bernard temporal package."""

View File

@ -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()

View File

@ -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()

View File

@ -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}

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -6,3 +6,5 @@ resources:
- static-demo.yaml - static-demo.yaml
- gumbo-schema-init.yaml - gumbo-schema-init.yaml
- gumbo-worker-deployment.yaml - gumbo-worker-deployment.yaml
- bernard-externalsecret.yaml
- bernard-deployment.yaml

View File

@ -52,3 +52,14 @@ spec:
owner: agentic_os owner: agentic_os
cluster: cluster:
name: agentic-os-pg 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