feat: add Bernard monitoring agent (PR review + Temporal monitor + dashboard)
This commit is contained in:
parent
b6a81d326e
commit
4b446c19b2
|
|
@ -1 +1 @@
|
||||||
"""Bernard dev/PR agent (scaffold)."""
|
"""Bernard — GitOps PR reviewer & Temporal workflow monitor agent."""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Bernard dashboard package."""
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Bernard temporal package."""
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue