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:
root 2026-04-20 18:24:48 -05:00
parent 23eb04a145
commit 6287558493
7 changed files with 556 additions and 0 deletions

248
mcp-server/alerts.html Normal file
View 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>

View File

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

View File

@ -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}`));

View File

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

View File

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

View File

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

View File

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