AkurAI Build
Menu

Public repository / 811ef9b7c8ba017fa242f008efa90faab07a0454

AkurAI-Monitor / scripts/akurai-error-forwarder.ts

Latest change 58c391dfbe412723839c809952b9554310439f0e - scripts: add akurai-error-forwarder (Monitor error/warn -> pi-bun #errors WS) by agent

#!/usr/bin/env bun
// akurai-error-forwarder — bridges AkurAI-Monitor error/warning logs into the
// pi-bun Workbench #errors channel via its token-authed /ws/ingest WebSocket.
//
// Display-only: the WS endpoint stores frames under the "Error" author, so they
// render as an error component and never trigger a channel agent reply. Runs as
// a systemd oneshot on a timer; each run forwards logs newer than the last id.
//
// Config (env, from /etc/akurai-error-forwarder.env):
//   MONITOR_URL         default https://monitor.olibuijr.com
//   MONITOR_API_TOKEN   bearer for Monitor /api/logs (PassVault entry 25)
//   PIBUN_WS_URL        default ws://192.168.1.10:4173/ws/ingest
//   WORKBENCH_TOKEN     pi-bun operator token (PassVault entry 23)
//   ERROR_CHANNEL       default "errors"
//   STATE_DIRECTORY     systemd StateDirectory (holds the last-id cursor)

const monitor = (Bun.env.MONITOR_URL ?? "https://monitor.olibuijr.com").replace(/\/$/, "");
const monitorToken = Bun.env.MONITOR_API_TOKEN ?? "";
const pibunWs = Bun.env.PIBUN_WS_URL ?? "ws://192.168.1.10:4173/ws/ingest";
const pibunToken = Bun.env.WORKBENCH_TOKEN ?? "";
const channel = Bun.env.ERROR_CHANNEL ?? "errors";
const stateFile = `${Bun.env.STATE_DIRECTORY ?? "/var/lib/akurai-error-forwarder"}/last-id`;

if (!monitorToken || !pibunToken) {
  throw new Error("akurai-error-forwarder: MONITOR_API_TOKEN and WORKBENCH_TOKEN are required");
}

type LogRow = { id: number; source: string; line: string; level: string | null };

async function fetchLevel(level: "error" | "warn"): Promise<LogRow[]> {
  const res = await fetch(`${monitor}/api/logs?level=${level}&hours=2&limit=500`, {
    headers: { Authorization: `Bearer ${monitorToken}` },
  });
  if (!res.ok) throw new Error(`monitor /api/logs ${level} -> ${res.status}`);
  const data: unknown = await res.json();
  if (!data || typeof data !== "object" || !("logs" in data) || !Array.isArray(data.logs)) return [];
  // Monitor's /api/logs rows; individual fields are read defensively below.
  const logs = data.logs as LogRow[];
  return logs;
}
const lastId = Number((await Bun.file(stateFile).text().catch(() => "0")).trim()) || 0;
const rows = [...(await fetchLevel("error")), ...(await fetchLevel("warn"))]
  .filter((row) => row.id > lastId)
  .sort((a, b) => a.id - b.id);

if (rows.length === 0) {
  console.log("akurai-error-forwarder: no new error/warn logs");
} else {
  const ws = new WebSocket(`${pibunWs}?token=${encodeURIComponent(pibunToken)}`);
  const opened = Promise.withResolvers<void>();
  ws.onopen = () => opened.resolve();
  ws.onerror = () => opened.reject(new Error("pi-bun ws connect failed"));
  const openTimer = setTimeout(() => opened.reject(new Error("pi-bun ws open timeout")), 8000);
  await opened.promise;
  clearTimeout(openTimer);

  let acked = 0;
  ws.onmessage = (message) => {
    try {
      if (JSON.parse(String(message.data)).ok) acked += 1;
    } catch {
      /* ignore malformed ack */
    }
  };

  let maxId = lastId;
  for (const row of rows) {
    ws.send(JSON.stringify({ channel, level: row.level ?? "error", source: row.source, body: row.line.slice(0, 4000) }));
    if (row.id > maxId) maxId = row.id;
  }

  await Bun.sleep(1000); // let acks arrive before closing
  ws.close();
  await Bun.write(stateFile, String(maxId));
  console.log(`akurai-error-forwarder: forwarded ${rows.length} log(s), acked ${acked}, cursor ${maxId}`);
}