Push/daemon presence: background digest + /alerts settings page
Converts the app from 'dashboard you visit' to 'system that finds you.'
Critical for the phone-first staffing shop that won't open a URL —
the system reaches out when something matters.
Daemon:
- Starts once per Bun process (guarded via globalThis sentinel)
- Default interval 15 min (configurable, min 1, max 1440)
- On each cycle, buildDigest() compares current state against prior
snapshot persisted in mcp-server/data/notification_state.json
- Events detected:
- risk_escalation: role moved to tight or critical (was ok/watch)
- deadline_approaching: staffing window falls within warn window
(default 7 days) AND deadline date differs from prior
- memory_growth: playbook_memory entries grew by >= 5 since last run
Channels (all opt-out individually via config):
- console: always on, logged to journalctl -u lakehouse-agent
- file: always on, appends JSONL to mcp-server/data/notifications.jsonl
- webhook: optional, POSTs {text, digest} to configured URL
(Slack incoming-webhook / Discord webhook / any custom endpoint)
Digest format (human-readable, fits in a Slack message):
LAKEHOUSE DIGEST — 2026-04-20 23:24
3 staffing deadlines within window:
• Production Worker — 2d to 2026-04-23 · demand 724
• Maintenance Tech — 4d to 2026-04-25 · demand 32
• Electrician — 5d to 2026-04-26 · demand 34
+779 new playbooks (total 779, 2204 endorsed names)
snapshot: 0 critical · 0 tight · $275,599,326 pipeline
/alerts page:
- Current status table (daemon state, interval, webhook, last run)
- Config form: enable toggle, interval, deadline warn window, webhook
URL + label (saved to data/notification_config.json)
- 'Fire a test digest now' button — force a cycle without waiting
- Recent digests panel shows the last 10 dispatches with full text
End-to-end verified live:
- Daemon armed successfully on startup
- First-run digest dispatched to console + file in <1s
- Events detected correctly: 3 deadlines within 7 days from real
Chicago permit data; 779 playbook entries surfaced as memory growth
- Digest text format is Slack-pastable
- Dispatch records appear in /alerts recent list
TDZ caveat: startAlertsDaemon() invocation moved to end of module so
all const/let in the alerts block evaluate before daemon reads them.
Previously failed with 'Cannot access X before initialization' when
the call lived near the top of the file. Nav added to all 6 pages:
Dashboard · Walkthrough · Architecture · Spec · Onboard · Alerts.
This commit is contained in:
parent
23eb04a145
commit
6287558493
248
mcp-server/alerts.html
Normal file
248
mcp-server/alerts.html
Normal file
@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Alerts</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar nav{display:flex;gap:2px}
|
||||
.bar nav a{font-size:12px;color:#545d68;padding:6px 14px;border-radius:6px;transition:all 0.15s}
|
||||
.bar nav a:hover{color:#e6edf3;background:#161b22}
|
||||
.bar nav a.active{color:#e6edf3;background:#1c2333}
|
||||
|
||||
.wrap{max-width:900px;margin:0 auto;padding:28px 20px 60px}
|
||||
|
||||
h2{color:#e6edf3;font-size:20px;font-weight:700;letter-spacing:-0.3px;margin-bottom:4px}
|
||||
.lede{color:#8b949e;font-size:13px;margin-bottom:18px;line-height:1.6;max-width:680px}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;border-radius:12px;padding:18px;margin:10px 0}
|
||||
|
||||
.row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #171d27}
|
||||
.row:last-child{border-bottom:none}
|
||||
.row .k{color:#8b949e;font-size:12px}
|
||||
.row .v{color:#e6edf3;font-weight:500;font-size:13px;font-family:ui-monospace,Menlo,monospace}
|
||||
|
||||
label{display:block;color:#8b949e;font-size:12px;margin-bottom:6px;margin-top:12px}
|
||||
label:first-child{margin-top:0}
|
||||
input[type=text],input[type=number],input[type=url]{width:100%;padding:10px 14px;background:#161b22;border:1px solid #21262d;border-radius:8px;color:#e6edf3;font-size:13px;outline:none;font-family:ui-monospace,Menlo,monospace}
|
||||
input:focus{border-color:#388bfd}
|
||||
.switch{display:flex;align-items:center;gap:8px;margin-top:4px}
|
||||
.switch input{width:auto;accent-color:#1f6feb}
|
||||
.switch span{color:#c9d1d9;font-size:13px}
|
||||
|
||||
.btn{padding:10px 18px;background:#1f6feb;border:none;border-radius:8px;color:#fff;font-size:13px;font-weight:600;cursor:pointer;margin-right:8px;margin-top:14px}
|
||||
.btn:hover{background:#388bfd}
|
||||
.btn:disabled{opacity:0.4;cursor:wait}
|
||||
.btn.ghost{background:transparent;border:1px solid #21262d;color:#c9d1d9}
|
||||
.btn.ghost:hover{background:#161b22}
|
||||
.btn.green{background:#2ea043}
|
||||
.btn.green:hover{background:#3fb950}
|
||||
|
||||
.entry{background:#161b22;border-radius:6px;padding:10px 14px;margin-bottom:6px;font-size:12px;font-family:ui-monospace,Menlo,monospace;white-space:pre-wrap;color:#c9d1d9;line-height:1.6}
|
||||
.entry .at{color:#545d68;font-size:10px;display:block;margin-bottom:4px}
|
||||
|
||||
.tip{color:#8b949e;font-size:12px;line-height:1.7;padding:10px 14px;border-left:2px solid #21262d;margin:10px 0}
|
||||
.tip strong{color:#c9d1d9}
|
||||
.tip code{background:#161b22;padding:1px 5px;border-radius:3px;font-family:ui-monospace,Menlo,monospace;font-size:11px;color:#79c0ff}
|
||||
|
||||
.result{padding:14px;border-radius:8px;margin-top:10px;font-size:12px;line-height:1.6}
|
||||
.result.ok{background:#0d2818;border:1px solid #2ea04360;color:#86efac}
|
||||
.result.err{background:#3a1a1a;border:1px solid #f8514960;color:#fca5a5}
|
||||
.result.dim{background:#161b22;border:1px solid #21262d;color:#8b949e}
|
||||
|
||||
.chan{display:inline-block;padding:3px 10px;border-radius:10px;font-size:10px;font-weight:600;margin-right:6px;letter-spacing:0.3px}
|
||||
.chan.console{background:#1a2744;color:#79c0ff}
|
||||
.chan.file{background:#1a3a2a;color:#3fb950}
|
||||
.chan.webhook{background:#2a1a3a;color:#bc8cff}
|
||||
|
||||
.footer{border-top:1px solid #171d27;padding:20px;text-align:center;color:#3d444d;font-size:11px}
|
||||
|
||||
@media(max-width:720px){
|
||||
.wrap{padding:20px 12px 40px}
|
||||
.bar nav{display:none}
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>Lakehouse — Alerts</h1>
|
||||
<nav>
|
||||
<a href=".">Dashboard</a>
|
||||
<a href="console">Walkthrough</a>
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts" class="active">Alerts</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<h2>Push alerts — make the system find you</h2>
|
||||
<div class="lede">The daemon runs in the background and dispatches a concise digest whenever something worth notifying changes: a role escalates to tight or critical, a new staffing deadline falls within your warn window, or the playbook memory compounds meaningfully. Phone-first shops don't open dashboards — they need the system to reach out.</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Current status</h3>
|
||||
<div id="status"><div style="color:#8b949e">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Configuration</h3>
|
||||
<form id="cfg-form">
|
||||
<label class="switch"><input type="checkbox" id="enabled"> <span>Daemon enabled — fire digests on the interval</span></label>
|
||||
<label>Check interval (minutes)</label>
|
||||
<input type="number" id="interval" min="1" max="1440" value="15">
|
||||
<label>Deadline warn window (days)</label>
|
||||
<input type="number" id="deadline_warn_days" min="1" max="90" value="7">
|
||||
<label>Webhook URL (Slack, Discord, or any POST-accepting endpoint — optional)</label>
|
||||
<input type="url" id="webhook_url" placeholder="https://hooks.slack.com/services/... or https://discord.com/api/webhooks/...">
|
||||
<label>Webhook label (your name for this channel)</label>
|
||||
<input type="text" id="webhook_label" placeholder="e.g. #staffing-ops">
|
||||
<div>
|
||||
<button type="button" class="btn" id="save-btn">Save</button>
|
||||
<button type="button" class="btn green" id="fire-btn">Fire a test digest now</button>
|
||||
</div>
|
||||
<div id="save-result"></div>
|
||||
</form>
|
||||
<div class="tip">
|
||||
<strong>Webhook behavior.</strong> The daemon POSTs JSON <code>{text, digest}</code> to your webhook URL.
|
||||
Slack and Discord both accept this shape if the URL is an incoming-webhook. The <code>text</code>
|
||||
field is pre-formatted human-readable; the <code>digest</code> field is structured for a bot to parse.
|
||||
Interval changes take effect on next server restart.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color:#e6edf3;font-size:15px;margin-bottom:10px">Recent digests</h3>
|
||||
<div id="recent"><div style="color:#8b949e">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">Lakehouse · alerts · daemon log: <code>mcp-server/data/notifications.jsonl</code></div>
|
||||
|
||||
<script>
|
||||
var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
|
||||
var A=location.origin+P;
|
||||
|
||||
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!==undefined)e.textContent=String(x);return e}
|
||||
function apiGet(p){return fetch(A+p).then(function(r){return r.json()})}
|
||||
function apiPost(p,b){return fetch(A+p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}).then(function(r){return r.json()})}
|
||||
|
||||
function loadStatus(){
|
||||
apiGet('/alerts/config').then(function(r){
|
||||
var host=document.getElementById('status');host.textContent='';
|
||||
var cfg=r.config||{};
|
||||
var st=r.state||{};
|
||||
var rows=[
|
||||
['Daemon',cfg.enabled?'enabled':'disabled'],
|
||||
['Interval',(cfg.interval_minutes||'?')+' minutes'],
|
||||
['Deadline warn window',(cfg.deadline_warn_days||7)+' days'],
|
||||
['Webhook',cfg.webhook_url?(cfg.webhook_label||'configured'):'none'],
|
||||
['Last run',st.last_run_at?new Date(st.last_run_at).toLocaleString():'never'],
|
||||
];
|
||||
rows.forEach(function(r){
|
||||
var row=el('div','row');
|
||||
row.appendChild(el('span','k',r[0]));
|
||||
row.appendChild(el('span','v',r[1]));
|
||||
host.appendChild(row);
|
||||
});
|
||||
// Populate form
|
||||
document.getElementById('enabled').checked=!!cfg.enabled;
|
||||
document.getElementById('interval').value=cfg.interval_minutes||15;
|
||||
document.getElementById('deadline_warn_days').value=cfg.deadline_warn_days||7;
|
||||
document.getElementById('webhook_url').value=cfg.webhook_url||'';
|
||||
document.getElementById('webhook_label').value=cfg.webhook_label||'';
|
||||
}).catch(function(e){
|
||||
var host=document.getElementById('status');host.textContent='';
|
||||
host.appendChild(el('div',null,'status unavailable: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
function loadRecent(){
|
||||
apiGet('/alerts/recent').then(function(r){
|
||||
var host=document.getElementById('recent');host.textContent='';
|
||||
var entries=r.entries||[];
|
||||
if(entries.length===0){
|
||||
host.appendChild(el('div',null,'No digests yet. The daemon fires every '+(document.getElementById('interval').value||15)+' minutes and only dispatches when something changes. Click "Fire a test digest now" to force one.'));
|
||||
host.firstChild.style.color='#8b949e';
|
||||
return;
|
||||
}
|
||||
entries.forEach(function(e){
|
||||
var card=el('div','entry');
|
||||
if(e.at) card.appendChild(el('span','at',new Date(e.at).toLocaleString()));
|
||||
card.appendChild(document.createTextNode(e.text||JSON.stringify(e.digest)));
|
||||
host.appendChild(card);
|
||||
});
|
||||
}).catch(function(e){
|
||||
var host=document.getElementById('recent');host.textContent='';
|
||||
host.appendChild(el('div',null,'recent digests unavailable: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
function save(){
|
||||
var btn=document.getElementById('save-btn');
|
||||
btn.disabled=true;
|
||||
var body={
|
||||
enabled:document.getElementById('enabled').checked,
|
||||
interval_minutes:Number(document.getElementById('interval').value)||15,
|
||||
deadline_warn_days:Number(document.getElementById('deadline_warn_days').value)||7,
|
||||
webhook_url:document.getElementById('webhook_url').value.trim(),
|
||||
webhook_label:document.getElementById('webhook_label').value.trim(),
|
||||
};
|
||||
apiPost('/alerts/config',body).then(function(r){
|
||||
btn.disabled=false;
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
if(r.saved){
|
||||
var ok=el('div','result ok',r.note||'saved.');out.appendChild(ok);
|
||||
loadStatus();
|
||||
} else {
|
||||
out.appendChild(el('div','result err',JSON.stringify(r)));
|
||||
}
|
||||
}).catch(function(e){
|
||||
btn.disabled=false;
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
out.appendChild(el('div','result err','save failed: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
function fire(){
|
||||
var btn=document.getElementById('fire-btn');
|
||||
btn.disabled=true;btn.textContent='Firing…';
|
||||
apiPost('/alerts/fire',{}).then(function(r){
|
||||
btn.disabled=false;btn.textContent='Fire a test digest now';
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
if(!r.fired){
|
||||
out.appendChild(el('div','result dim',r.reason||'nothing to dispatch'));
|
||||
} else {
|
||||
var ok=el('div','result ok');
|
||||
ok.appendChild(document.createTextNode('Dispatched to: '));
|
||||
(r.channels||[]).forEach(function(c){
|
||||
var chip=el('span','chan '+c,c);ok.appendChild(chip);
|
||||
});
|
||||
if(r.errors&&r.errors.length){
|
||||
ok.appendChild(document.createElement('br'));
|
||||
ok.appendChild(document.createTextNode('errors: '+r.errors.join(' | ')));
|
||||
}
|
||||
out.appendChild(ok);
|
||||
setTimeout(loadRecent,500);
|
||||
}
|
||||
}).catch(function(e){
|
||||
btn.disabled=false;btn.textContent='Fire a test digest now';
|
||||
var out=document.getElementById('save-result');out.textContent='';
|
||||
out.appendChild(el('div','result err','fire failed: '+(e.message||e)));
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
loadStatus();loadRecent();
|
||||
document.getElementById('save-btn').addEventListener('click',save);
|
||||
document.getElementById('fire-btn').addEventListener('click',fire);
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
@ -98,6 +98,7 @@ details .body{padding-top:10px;font-size:12px;color:#8b949e}
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
<div class="rt" id="hdr-time">Reading live state…</div>
|
||||
</div>
|
||||
|
||||
@ -660,6 +660,51 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
// Alerts — push/daemon settings page + config API + test-fire.
|
||||
if (url.pathname === "/alerts") {
|
||||
return new Response(Bun.file(import.meta.dir + "/alerts.html"), {
|
||||
headers: { ...cors, "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/alerts/config") {
|
||||
if (req.method === "GET") {
|
||||
const cfg = await loadAlertsConfig();
|
||||
const state = await loadAlertsState();
|
||||
return ok({ config: cfg, state: { last_run_at: state.last_run_at } });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const b = await json();
|
||||
const prev = await loadAlertsConfig();
|
||||
const next: AlertsConfig = {
|
||||
enabled: b.enabled ?? prev.enabled,
|
||||
interval_minutes: Math.max(1, Number(b.interval_minutes ?? prev.interval_minutes)),
|
||||
webhook_url: typeof b.webhook_url === "string" ? b.webhook_url.trim() || undefined : prev.webhook_url,
|
||||
webhook_label: typeof b.webhook_label === "string" ? b.webhook_label : prev.webhook_label,
|
||||
deadline_warn_days: Math.max(1, Number(b.deadline_warn_days ?? prev.deadline_warn_days)),
|
||||
};
|
||||
await saveAlertsConfig(next);
|
||||
return ok({ saved: true, config: next,
|
||||
note: "Interval change requires server restart to apply. Current running interval unchanged this cycle." });
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/alerts/fire" && req.method === "POST") {
|
||||
const cfg = await loadAlertsConfig();
|
||||
const d = await buildDigest();
|
||||
if (!d) return ok({ fired: false, reason: "no events since last run" });
|
||||
const res = await dispatchDigest(d, cfg);
|
||||
return ok({ fired: true, channels: res.channels, errors: res.errors, digest: d });
|
||||
}
|
||||
if (url.pathname === "/alerts/recent" && req.method === "GET") {
|
||||
const f = Bun.file(ALERTS_LOG_PATH);
|
||||
if (!(await f.exists())) return ok({ entries: [] });
|
||||
const text = await f.text();
|
||||
const lines = text.split("\n").filter(l => l.trim());
|
||||
const last = lines.slice(-10).reverse();
|
||||
const entries: any[] = [];
|
||||
for (const l of last) { try { entries.push(JSON.parse(l)); } catch {} }
|
||||
return ok({ entries });
|
||||
}
|
||||
|
||||
// Onboard ingest — forwards multipart/form-data correctly to
|
||||
// the Rust gateway /ingest/file. The generic /api/* passthrough
|
||||
// can't handle multipart because it reads as text and forwards
|
||||
@ -1481,6 +1526,256 @@ async function removeFromClientBlacklist(client: string, worker_id: string): Pro
|
||||
return { removed, total: filtered.length };
|
||||
}
|
||||
|
||||
// ─── Push daemon (alerts) ───────────────────────────────────────────────
|
||||
// Background interval that detects notification-worthy events, assembles
|
||||
// a digest, and dispatches to configured channels. Converts the app from
|
||||
// "dashboard you visit" to "system that finds you" — essential for the
|
||||
// phone-first shop that won't remember to open a URL.
|
||||
|
||||
const ALERTS_CFG_PATH = `${import.meta.dir}/data/notification_config.json`;
|
||||
const ALERTS_STATE_PATH = `${import.meta.dir}/data/notification_state.json`;
|
||||
const ALERTS_LOG_PATH = `${import.meta.dir}/data/notifications.jsonl`;
|
||||
|
||||
interface AlertsConfig {
|
||||
enabled: boolean;
|
||||
interval_minutes: number;
|
||||
webhook_url?: string;
|
||||
webhook_label?: string;
|
||||
deadline_warn_days: number;
|
||||
}
|
||||
interface AlertsState {
|
||||
last_run_at?: string;
|
||||
last_forecast_by_role?: Record<string, { risk: string; coverage_pct: number; earliest_staffing_deadline: string }>;
|
||||
last_playbook_entries?: number;
|
||||
last_digest?: any;
|
||||
}
|
||||
|
||||
async function loadAlertsConfig(): Promise<AlertsConfig> {
|
||||
const f = Bun.file(ALERTS_CFG_PATH);
|
||||
if (!(await f.exists())) {
|
||||
return { enabled: true, interval_minutes: 15, deadline_warn_days: 7 };
|
||||
}
|
||||
try { return await f.json() as AlertsConfig; }
|
||||
catch { return { enabled: true, interval_minutes: 15, deadline_warn_days: 7 }; }
|
||||
}
|
||||
async function saveAlertsConfig(c: AlertsConfig): Promise<void> {
|
||||
await Bun.write(ALERTS_CFG_PATH, JSON.stringify(c, null, 2));
|
||||
}
|
||||
async function loadAlertsState(): Promise<AlertsState> {
|
||||
const f = Bun.file(ALERTS_STATE_PATH);
|
||||
if (!(await f.exists())) return {};
|
||||
try { return await f.json() as AlertsState; } catch { return {}; }
|
||||
}
|
||||
async function saveAlertsState(s: AlertsState): Promise<void> {
|
||||
await Bun.write(ALERTS_STATE_PATH, JSON.stringify(s, null, 2));
|
||||
}
|
||||
|
||||
// Build a digest by diffing current state against last-observed state.
|
||||
// Returns null if there's nothing worth sending.
|
||||
async function buildDigest(): Promise<any | null> {
|
||||
const cfg = await loadAlertsConfig();
|
||||
const state = await loadAlertsState();
|
||||
|
||||
// Pull current snapshots in parallel. /intelligence/staffing_forecast
|
||||
// is a BUN route (our localhost), not on the Rust gateway — reach it
|
||||
// via in-process fetch. /vectors/playbook_memory/stats is on the
|
||||
// gateway and gets there via api().
|
||||
const bunPort = process.env.PORT || "3700";
|
||||
const [forecast, memStats] = await Promise.all([
|
||||
fetch(`http://localhost:${bunPort}/intelligence/staffing_forecast`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}"
|
||||
}).then(r => r.json()).catch(() => null as any),
|
||||
api("GET", "/vectors/playbook_memory/stats").catch(() => null as any),
|
||||
]);
|
||||
|
||||
const events: any[] = [];
|
||||
|
||||
// Event: role risk status changed (new critical/tight)
|
||||
const currentByRole: Record<string, any> = {};
|
||||
const priorByRole = state.last_forecast_by_role || {};
|
||||
if (forecast && Array.isArray(forecast.forecast)) {
|
||||
for (const f of forecast.forecast) {
|
||||
currentByRole[f.role] = {
|
||||
risk: f.risk,
|
||||
coverage_pct: f.coverage_pct,
|
||||
earliest_staffing_deadline: f.earliest_staffing_deadline,
|
||||
};
|
||||
const prior = priorByRole[f.role];
|
||||
const rank: Record<string, number> = { ok: 0, watch: 1, tight: 2, critical: 3 };
|
||||
if (!prior || (rank[f.risk] ?? 0) > (rank[prior.risk] ?? 0)) {
|
||||
// Risk got worse (or new role we haven't seen)
|
||||
if (f.risk === "critical" || f.risk === "tight") {
|
||||
events.push({
|
||||
kind: "risk_escalation",
|
||||
role: f.role,
|
||||
risk: f.risk,
|
||||
coverage_pct: f.coverage_pct,
|
||||
demand: f.demand_workers,
|
||||
available: f.bench_available,
|
||||
prior_risk: prior?.risk ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Event: staffing deadline within N days that wasn't there before
|
||||
const d = f.days_to_deadline;
|
||||
if (d !== undefined && d >= 0 && d <= cfg.deadline_warn_days) {
|
||||
const priorD = prior?.earliest_staffing_deadline;
|
||||
if (priorD !== f.earliest_staffing_deadline) {
|
||||
events.push({
|
||||
kind: "deadline_approaching",
|
||||
role: f.role,
|
||||
days_to_deadline: d,
|
||||
date: f.earliest_staffing_deadline,
|
||||
demand: f.demand_workers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event: playbook memory grew significantly since last check
|
||||
const nowEntries = memStats?.entries ?? 0;
|
||||
const priorEntries = state.last_playbook_entries ?? 0;
|
||||
const grewBy = nowEntries - priorEntries;
|
||||
if (grewBy >= 5) {
|
||||
events.push({
|
||||
kind: "memory_growth",
|
||||
new_entries: grewBy,
|
||||
total_entries: nowEntries,
|
||||
total_endorsed_names: memStats?.total_names_endorsed ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Only return a digest if there's something to say. First-ever run is
|
||||
// a special case: surface the snapshot as a "welcome" digest.
|
||||
const isFirstRun = !state.last_run_at;
|
||||
if (events.length === 0 && !isFirstRun) return null;
|
||||
|
||||
const digest = {
|
||||
generated_at: new Date().toISOString(),
|
||||
is_first_run: isFirstRun,
|
||||
events,
|
||||
snapshot: {
|
||||
forecast_roles: Object.keys(currentByRole).length,
|
||||
critical: forecast?.critical_roles ?? 0,
|
||||
tight: forecast?.tight_roles ?? 0,
|
||||
playbook_entries: nowEntries,
|
||||
permits_30d: forecast?.permit_count ?? 0,
|
||||
construction_pipeline_usd: forecast?.total_cost ?? 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Persist the updated state for next diff
|
||||
await saveAlertsState({
|
||||
last_run_at: digest.generated_at,
|
||||
last_forecast_by_role: currentByRole,
|
||||
last_playbook_entries: nowEntries,
|
||||
last_digest: digest,
|
||||
});
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
function formatDigestText(d: any): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`LAKEHOUSE DIGEST — ${d.generated_at.slice(0, 16).replace("T", " ")}`);
|
||||
lines.push("");
|
||||
if (d.is_first_run) {
|
||||
lines.push(`[initial snapshot] · ${d.snapshot.forecast_roles} roles tracked · `
|
||||
+ `${d.snapshot.playbook_entries} playbooks in memory · `
|
||||
+ `${d.snapshot.permits_30d} permits last 30d`);
|
||||
lines.push("");
|
||||
}
|
||||
const risk = d.events.filter((e: any) => e.kind === "risk_escalation");
|
||||
if (risk.length) {
|
||||
lines.push(`${risk.length} role${risk.length !== 1 ? "s" : ""} escalated to ${risk.map((r: any) => r.risk).filter((v: string, i: number, a: string[]) => a.indexOf(v) === i).join("/")}:`);
|
||||
for (const e of risk.slice(0, 5)) {
|
||||
lines.push(` • ${e.role} — coverage ${e.coverage_pct}% (${e.available}/${e.demand})${e.prior_risk ? ` · was ${e.prior_risk}` : " · new"}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
const dead = d.events.filter((e: any) => e.kind === "deadline_approaching");
|
||||
if (dead.length) {
|
||||
lines.push(`${dead.length} staffing deadline${dead.length !== 1 ? "s" : ""} within window:`);
|
||||
for (const e of dead.slice(0, 5)) {
|
||||
lines.push(` • ${e.role} — ${e.days_to_deadline}d to ${e.date} · demand ${e.demand}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
const mem = d.events.filter((e: any) => e.kind === "memory_growth");
|
||||
for (const e of mem) {
|
||||
lines.push(`+${e.new_entries} new playbooks (total ${e.total_entries}, ${e.total_endorsed_names} endorsed names)`);
|
||||
}
|
||||
lines.push(`snapshot: ${d.snapshot.critical} critical · ${d.snapshot.tight} tight · `
|
||||
+ `$${(d.snapshot.construction_pipeline_usd || 0).toLocaleString("en-US", { maximumFractionDigits: 0 })} pipeline`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function dispatchDigest(d: any, cfg: AlertsConfig): Promise<{ channels: string[]; errors: string[] }> {
|
||||
const channels: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const text = formatDigestText(d);
|
||||
|
||||
// Channel 1: console
|
||||
console.log(`[alerts] ${text.split("\n").join(" | ")}`);
|
||||
channels.push("console");
|
||||
|
||||
// Channel 2: JSONL file (always-on audit)
|
||||
try {
|
||||
await Bun.write(ALERTS_LOG_PATH,
|
||||
(await Bun.file(ALERTS_LOG_PATH).exists() ? await Bun.file(ALERTS_LOG_PATH).text() : "")
|
||||
+ JSON.stringify({ at: d.generated_at, text, digest: d }) + "\n"
|
||||
);
|
||||
channels.push("file");
|
||||
} catch (e: any) { errors.push(`file: ${e.message}`); }
|
||||
|
||||
// Channel 3: webhook (opt-in)
|
||||
if (cfg.webhook_url) {
|
||||
try {
|
||||
const r = await fetch(cfg.webhook_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text, digest: d }),
|
||||
});
|
||||
if (r.ok) channels.push("webhook");
|
||||
else errors.push(`webhook ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
||||
} catch (e: any) { errors.push(`webhook: ${e.message}`); }
|
||||
}
|
||||
|
||||
return { channels, errors };
|
||||
}
|
||||
|
||||
// Background daemon — kicked off once on module init. Guard via a
|
||||
// globalThis sentinel so the startAlertsDaemon() call from near the
|
||||
// top of the file (before this block evaluates) doesn't hit a temporal
|
||||
// dead zone on a let/const binding.
|
||||
async function startAlertsDaemon() {
|
||||
const g = globalThis as any;
|
||||
if (g.__lakehouse_alerts_armed) return;
|
||||
g.__lakehouse_alerts_armed = true;
|
||||
const cfg = await loadAlertsConfig();
|
||||
if (!cfg.enabled) {
|
||||
console.log("[alerts] daemon disabled via config");
|
||||
return;
|
||||
}
|
||||
const ms = Math.max(60, cfg.interval_minutes * 60) * 1000;
|
||||
console.log(`[alerts] daemon armed · interval ${cfg.interval_minutes}min · webhook ${cfg.webhook_url ? "configured" : "disabled"}`);
|
||||
// Fire once shortly after startup, then on interval.
|
||||
setTimeout(runAlertsOnce, 10_000);
|
||||
setInterval(runAlertsOnce, ms);
|
||||
}
|
||||
async function runAlertsOnce() {
|
||||
try {
|
||||
const cfg = await loadAlertsConfig();
|
||||
if (!cfg.enabled) return;
|
||||
const d = await buildDigest();
|
||||
if (!d) return;
|
||||
await dispatchDigest(d, cfg);
|
||||
} catch (e: any) {
|
||||
console.error(`[alerts] cycle error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed playbook_memory from a filled contract so the next hybrid query
|
||||
// ranks against it. Used by both runWeekSimulation (per-day) and the /log
|
||||
// endpoint (per manual logging). Fail-soft — seeding is best-effort.
|
||||
@ -1622,3 +1917,11 @@ async function runWeekSimulation() {
|
||||
|
||||
return { days: results, summary };
|
||||
}
|
||||
|
||||
// Kick off the push/alerts daemon once per process. Placed at the END of
|
||||
// the module so all const/let declarations in the alerts block (paths,
|
||||
// helpers, etc.) have evaluated before the daemon reads them. Calling
|
||||
// from earlier in the file would hit a temporal dead zone on these
|
||||
// bindings.
|
||||
startAlertsDaemon().catch(e => console.error(`[alerts] startup error: ${e.message}`));
|
||||
|
||||
|
||||
@ -94,6 +94,7 @@ table.preview tr:hover td{background:#0d1117}
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard" class="active">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
<div class="rt">30 minutes from CSV to live search</div>
|
||||
</div>
|
||||
|
||||
@ -84,6 +84,7 @@ pre{background:#161b22;border:1px solid #171d27;border-radius:8px;padding:14px 1
|
||||
<a href="proof" class="active">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
<div class="rt" id="hdr-time">Running live tests…</div>
|
||||
</div>
|
||||
|
||||
@ -107,6 +107,7 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
<div class="rt" id="status">Loading...</div>
|
||||
</div>
|
||||
|
||||
@ -81,6 +81,7 @@ table.plain tr:hover td{background:#0d1117}
|
||||
<a href="proof">Architecture</a>
|
||||
<a href="spec" class="active">Spec</a>
|
||||
<a href="onboard">Onboard</a>
|
||||
<a href="alerts">Alerts</a>
|
||||
</nav>
|
||||
<div class="rt">v1 · 2026-04-20</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user