feat(xos): Phase 4 — real-time Socket.IO, SLA scheduler, business hours, campaigns

- server/socket-io.ts: singleton SocketServer shared across all modules
- server/routes.ts: wire initSocketIO before chat/community sockets; start XOS scheduler
- server/xos/scheduler.ts: SLA breach checker (every 5min) + supervisor stats broadcaster (every 30s) via broadcastXos
- server/xos/routes.ts: campaign segmentation (POST /campaigns/segment), CRUD for xos_campaigns
- server/whatsapp/service.ts: XOS queue-based business hours enforcement (xosQueueId in AutoReplyConfig), checkXosQueueIsOpen() helper
- client/src/hooks/use-xos-socket.ts: useXosSocket hook — subscribes to xos:supervisor.stats and xos:sla.breach Socket.IO events
- client/src/pages/XosSupervisor.tsx: integrate useXosSocket for real-time KPI cards; show connection mode (tempo real vs polling)
- shared/schema.ts: fix missing xosDevPipelines pgTable header

https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
Claude 2026-03-17 00:56:14 +00:00 committed by root
parent 5aaf94df0e
commit 07fa5e5871
8 changed files with 459 additions and 9 deletions

View File

@ -0,0 +1,103 @@
/**
* useXosSocket subscribes to XOS real-time events from Socket.IO.
* Usage:
* const { supervisorStats, lastSlaBreachEvent } = useXosSocket();
*/
import { useEffect, useRef, useState, useCallback } from "react";
import { io, Socket } from "socket.io-client";
export interface SupervisorStats {
openConversations: number;
resolvedToday: number;
urgentTickets: number;
agentsOnline: number;
timestamp: string;
}
export interface SlaBreachEvent {
protocolId: number;
protocolNumber: string;
tenantId: number;
queueId: number | null;
assignedTo: number | null;
breachedAt: string;
}
interface XosSocketState {
connected: boolean;
supervisorStats: SupervisorStats | null;
lastSlaBreachEvent: SlaBreachEvent | null;
}
let _socket: Socket | null = null;
let _refCount = 0;
function getSocket(): Socket {
if (!_socket) {
_socket = io(window.location.origin, {
path: "/socket.io",
transports: ["websocket", "polling"],
reconnectionAttempts: 10,
reconnectionDelay: 2000,
});
}
return _socket;
}
export function useXosSocket() {
const [state, setState] = useState<XosSocketState>({
connected: false,
supervisorStats: null,
lastSlaBreachEvent: null,
});
const socketRef = useRef<Socket | null>(null);
const handleStats = useCallback((data: SupervisorStats) => {
setState((s) => ({ ...s, supervisorStats: data }));
}, []);
const handleSlaBreach = useCallback((data: SlaBreachEvent) => {
setState((s) => ({ ...s, lastSlaBreachEvent: data }));
}, []);
const handleConnect = useCallback(() => {
setState((s) => ({ ...s, connected: true }));
}, []);
const handleDisconnect = useCallback(() => {
setState((s) => ({ ...s, connected: false }));
}, []);
useEffect(() => {
const socket = getSocket();
socketRef.current = socket;
_refCount++;
socket.on("connect", handleConnect);
socket.on("disconnect", handleDisconnect);
socket.on("xos:supervisor.stats", handleStats);
socket.on("xos:sla.breach", handleSlaBreach);
// Sync initial connection state
if (socket.connected) {
setState((s) => ({ ...s, connected: true }));
}
return () => {
socket.off("connect", handleConnect);
socket.off("disconnect", handleDisconnect);
socket.off("xos:supervisor.stats", handleStats);
socket.off("xos:sla.breach", handleSlaBreach);
_refCount--;
// Keep socket alive as long as any component uses it
if (_refCount <= 0 && _socket) {
_socket.disconnect();
_socket = null;
_refCount = 0;
}
};
}, [handleConnect, handleDisconnect, handleStats, handleSlaBreach]);
return state;
}

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useXosSocket } from "@/hooks/use-xos-socket";
import { Link } from "wouter"; import { Link } from "wouter";
import { BrowserFrame } from "@/components/Browser/BrowserFrame"; import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { import {
@ -71,6 +72,7 @@ export default function XosSupervisor() {
const [activeTab, setActiveTab] = useState("overview"); const [activeTab, setActiveTab] = useState("overview");
const [queueFilter, setQueueFilter] = useState("all"); const [queueFilter, setQueueFilter] = useState("all");
const [autoRefresh] = useState(true); const [autoRefresh] = useState(true);
const { connected: socketConnected, supervisorStats: liveStats } = useXosSocket();
const refetchInterval = autoRefresh ? 30000 : false; const refetchInterval = autoRefresh ? 30000 : false;
@ -143,9 +145,13 @@ export default function XosSupervisor() {
); );
} }
// Prefer real-time socket stats over polling data when available
const conv = overview?.conversations; const conv = overview?.conversations;
const tick = overview?.tickets; const tick = overview?.tickets;
const totalActive = (conv?.open || 0) + (conv?.pending || 0); const totalActive = liveStats?.openConversations ?? ((conv?.open || 0) + (conv?.pending || 0));
const resolvedToday = liveStats?.resolvedToday ?? conv?.resolved_today ?? 0;
const urgentTickets = liveStats?.urgentTickets ?? tick?.urgent_open ?? 0;
const agentsOnline = liveStats?.agentsOnline ?? overview?.agents_active?.length ?? 0;
return ( return (
<BrowserFrame> <BrowserFrame>
@ -167,7 +173,10 @@ export default function XosSupervisor() {
<h1 className="text-xl font-bold text-slate-800">Monitor de Supervisor</h1> <h1 className="text-xl font-bold text-slate-800">Monitor de Supervisor</h1>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Atualizado às {overview ? new Date(overview.generated_at).toLocaleTimeString("pt-BR") : "—"} Atualizado às {overview ? new Date(overview.generated_at).toLocaleTimeString("pt-BR") : "—"}
{autoRefresh && <span className="ml-2 text-emerald-600"> ao vivo</span>} {socketConnected
? <span className="ml-2 text-emerald-600"> tempo real</span>
: autoRefresh && <span className="ml-2 text-yellow-600"> polling 30s</span>
}
</p> </p>
</div> </div>
</div> </div>
@ -205,6 +214,7 @@ export default function XosSupervisor() {
</div> </div>
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100"> <div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
<span>{conv?.unassigned || 0} sem agente</span> <span>{conv?.unassigned || 0} sem agente</span>
<span></span> <span></span>
<span>{conv?.pending || 0} pendentes</span> <span>{conv?.pending || 0} pendentes</span>
</div> </div>
@ -216,7 +226,7 @@ export default function XosSupervisor() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-emerald-100 text-sm">Resolvidos Hoje</p> <p className="text-emerald-100 text-sm">Resolvidos Hoje</p>
<p className="text-3xl font-bold">{conv?.resolved_today || 0}</p> <p className="text-3xl font-bold">{resolvedToday}</p>
</div> </div>
<CheckCircle2 className="h-10 w-10 text-emerald-200" /> <CheckCircle2 className="h-10 w-10 text-emerald-200" />
</div> </div>
@ -231,7 +241,7 @@ export default function XosSupervisor() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-orange-100 text-sm">Tickets Urgentes</p> <p className="text-orange-100 text-sm">Tickets Urgentes</p>
<p className="text-3xl font-bold">{tick?.urgent_open || 0}</p> <p className="text-3xl font-bold">{urgentTickets}</p>
</div> </div>
<Ticket className="h-10 w-10 text-orange-200" /> <Ticket className="h-10 w-10 text-orange-200" />
</div> </div>
@ -252,7 +262,7 @@ export default function XosSupervisor() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-violet-100 text-sm">Agentes Ativos</p> <p className="text-violet-100 text-sm">Agentes Ativos</p>
<p className="text-3xl font-bold">{overview?.agents_active?.length || 0}</p> <p className="text-3xl font-bold">{agentsOnline}</p>
</div> </div>
<Users className="h-10 w-10 text-violet-200" /> <Users className="h-10 w-10 text-violet-200" />
</div> </div>

View File

@ -61,6 +61,8 @@ import pipelineRoutes from "./blackboard/pipelineRoutes";
import { startAllAgents } from "./blackboard/agents"; import { startAllAgents } from "./blackboard/agents";
import { loadModuleRoutes } from "./modules/loader"; import { loadModuleRoutes } from "./modules/loader";
import graphRoutes from "./graph/routes"; import graphRoutes from "./graph/routes";
import { initSocketIO } from "./socket-io";
import { startXosScheduler } from "./xos/scheduler";
export async function registerRoutes( export async function registerRoutes(
httpServer: Server, httpServer: Server,
@ -80,6 +82,8 @@ export async function registerRoutes(
registerChatRoutes(app); registerChatRoutes(app);
registerSoeRoutes(app); registerSoeRoutes(app);
registerInternalChatRoutes(app); registerInternalChatRoutes(app);
// Initialize shared Socket.IO singleton (must be before other socket setups)
initSocketIO(httpServer);
setupChatSocket(httpServer); setupChatSocket(httpServer);
setupCommunitySocket(httpServer); setupCommunitySocket(httpServer);
registerWhatsappRoutes(app); registerWhatsappRoutes(app);
@ -134,6 +138,9 @@ export async function registerRoutes(
// Iniciar os 6 agentes do Blackboard // Iniciar os 6 agentes do Blackboard
startAllAgents(); startAllAgents();
// XOS Scheduler: SLA breach checker + supervisor stats broadcaster
startXosScheduler();
// Central de Protocolos (MCP, A2A, AP2, UCP) // Central de Protocolos (MCP, A2A, AP2, UCP)
app.use("/api", protocolsRoutes); app.use("/api", protocolsRoutes);
registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json) registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json)

32
server/socket-io.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* Singleton Socket.IO instância compartilhada entre todos os módulos.
* Inicializado em routes.ts via initSocketIO(httpServer).
* Qualquer módulo pode importar getIO() para emitir eventos.
*/
import { Server as HttpServer } from "http";
import { Server as SocketServer, Socket } from "socket.io";
let _io: SocketServer | null = null;
export function initSocketIO(httpServer: HttpServer): SocketServer {
if (_io) return _io;
_io = new SocketServer(httpServer, {
path: "/socket.io",
cors: { origin: "*", methods: ["GET", "POST"] },
});
return _io;
}
export function getIO(): SocketServer {
if (!_io) throw new Error("[socket-io] Not initialized. Call initSocketIO first.");
return _io;
}
/** Emite um evento XOS para todos os clientes conectados (não bloqueia). */
export function broadcastXos(event: string, data: unknown) {
try {
getIO().emit(`xos:${event}`, data);
} catch {
// Socket ainda não iniciado ou sem clientes — ignorar silenciosamente
}
}

View File

@ -26,6 +26,8 @@ interface AutoReplyConfig {
outsideHoursMessage: string; outsideHoursMessage: string;
aiEnabled: boolean; aiEnabled: boolean;
maxAutoRepliesPerContact: number; maxAutoRepliesPerContact: number;
/** Optional: link to an XOS queue for schedule/out-of-hours config */
xosQueueId?: number;
} }
interface WhatsAppSession { interface WhatsAppSession {
@ -130,6 +132,31 @@ Nome do cliente: ${contactName}`;
} }
} }
/** Check XOS queue schedule. Returns { isOpen, outOfHoursMessage }. */
private async checkXosQueueIsOpen(queueId: number): Promise<{ isOpen: boolean; outOfHoursMessage: string | null }> {
try {
const result = await db.execute(sql`
SELECT schedules, out_of_hours_message FROM xos_queues WHERE id = ${queueId}
`);
const queue = ((result as any).rows ?? [])[0];
if (!queue) return { isOpen: true, outOfHoursMessage: null };
const schedules: Array<{ dayOfWeek: number; startTime: string; endTime: string; enabled: boolean }> = queue.schedules || [];
if (!schedules.length) return { isOpen: true, outOfHoursMessage: null };
const now = new Date();
const dayOfWeek = now.getDay(); // 0=Sun, 6=Sat
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
const todaySchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.enabled !== false);
if (!todaySchedule) return { isOpen: false, outOfHoursMessage: queue.out_of_hours_message };
const isOpen = currentTime >= todaySchedule.startTime && currentTime < todaySchedule.endTime;
return { isOpen, outOfHoursMessage: isOpen ? null : queue.out_of_hours_message };
} catch {
return { isOpen: true, outOfHoursMessage: null }; // fail-open: don't block messages if DB query fails
}
}
private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> { private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> {
try { try {
const config = this.getAutoReplyConfig(msg.userId); const config = this.getAutoReplyConfig(msg.userId);
@ -142,13 +169,24 @@ Nome do cliente: ${contactName}`;
return; return;
} }
// Check business hours — prefer XOS queue schedule when configured
let isBusinessHours: boolean;
let outsideHoursReply: string;
if (config.xosQueueId) {
const { isOpen, outOfHoursMessage } = await this.checkXosQueueIsOpen(config.xosQueueId);
isBusinessHours = isOpen;
outsideHoursReply = outOfHoursMessage || config.outsideHoursMessage;
} else {
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
const isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end; isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
outsideHoursReply = config.outsideHoursMessage;
}
let replyText: string; let replyText: string;
if (!isBusinessHours) { if (!isBusinessHours) {
replyText = config.outsideHoursMessage; replyText = outsideHoursReply;
} else if (currentCount === 0) { } else if (currentCount === 0) {
replyText = config.welcomeMessage; replyText = config.welcomeMessage;
} else if (config.aiEnabled) { } else if (config.aiEnabled) {

View File

@ -1646,4 +1646,143 @@ export async function fireCrmAutomations(eventType: string, payload: Record<stri
} }
} }
// ─── Campaign Segmentation ────────────────────────────────────────────────────
/**
* POST /api/xos/campaigns/segment
* Returns XOS contacts matching a segmentation query for campaign targeting.
* Body: { tenantId?, filters: { type?, leadStatus?, source?, tags?, city?, state?,
* hasPhone?, hasWhatsapp?, hasEmail?, leadScoreMin?, leadScoreMax?, assignedTo? },
* limit?, offset? }
*/
router.post("/campaigns/segment", async (req: Request, res: Response) => {
try {
const { tenantId, filters = {}, limit = 500, offset = 0 } = req.body;
const safeLimit = Math.min(Number(limit) || 500, 5000);
const safeOffset = Math.max(Number(offset) || 0, 0);
// Build SQL fragments dynamically
const clauses: ReturnType<typeof sql>[] = [sql`1=1`];
if (tenantId) clauses.push(sql`c.tenant_id = ${tenantId}`);
if (filters.type) clauses.push(sql`c.type = ${filters.type}`);
if (filters.city) clauses.push(sql`c.city ILIKE ${"%" + filters.city + "%"}`);
if (filters.state) clauses.push(sql`c.state ILIKE ${"%" + filters.state + "%"}`);
if (filters.assignedTo) clauses.push(sql`c.assigned_to = ${filters.assignedTo}`);
if (filters.hasPhone === true) clauses.push(sql`(c.phone IS NOT NULL AND c.phone != '')`);
if (filters.hasPhone === false) clauses.push(sql`(c.phone IS NULL OR c.phone = '')`);
if (filters.hasWhatsapp === true) clauses.push(sql`(c.whatsapp IS NOT NULL AND c.whatsapp != '')`);
if (filters.hasWhatsapp === false) clauses.push(sql`(c.whatsapp IS NULL OR c.whatsapp = '')`);
if (filters.hasEmail === true) clauses.push(sql`(c.email IS NOT NULL AND c.email != '')`);
if (filters.hasEmail === false) clauses.push(sql`(c.email IS NULL OR c.email = '')`);
if (typeof filters.leadScoreMin === "number") clauses.push(sql`c.lead_score >= ${filters.leadScoreMin}`);
if (typeof filters.leadScoreMax === "number") clauses.push(sql`c.lead_score <= ${filters.leadScoreMax}`);
if (Array.isArray(filters.leadStatus) && filters.leadStatus.length > 0) {
clauses.push(sql`c.lead_status = ANY(${filters.leadStatus})`);
}
if (Array.isArray(filters.source) && filters.source.length > 0) {
clauses.push(sql`c.source = ANY(${filters.source})`);
}
if (Array.isArray(filters.tags) && filters.tags.length > 0) {
clauses.push(sql`c.tags @> ${filters.tags}::text[]`);
}
const whereSql = sql.join(clauses, sql` AND `);
const countResult = await db.execute(sql`SELECT COUNT(*) AS total FROM xos_contacts c WHERE ${whereSql}`);
const total = Number(((countResult as any).rows?.[0])?.total ?? 0);
const dataResult = await db.execute(sql`
SELECT c.id, c.name, c.email, c.phone, c.whatsapp, c.type, c.lead_status,
c.lead_score, c.source, c.tags, c.city, c.state, c.company,
c.assigned_to, c.last_contact_at
FROM xos_contacts c
WHERE ${whereSql}
ORDER BY c.name ASC
LIMIT ${safeLimit} OFFSET ${safeOffset}
`);
const contacts = (dataResult as any).rows ?? [];
res.json({ total, limit: safeLimit, offset: safeOffset, count: contacts.length, contacts });
} catch (error: any) {
console.error("Campaign segment error:", error);
res.status(500).json({ error: "Failed to query campaign segment" });
}
});
/**
* GET /api/xos/campaigns list campaigns for tenant
*/
router.get("/campaigns", async (req: Request, res: Response) => {
try {
const tenantId = (req as any).user?.tenantId;
const result = await db.execute(sql`
SELECT id, name, description, type, status, segment_query, subject,
scheduled_at, started_at, completed_at, stats, created_at, updated_at
FROM xos_campaigns
${tenantId ? sql`WHERE tenant_id = ${tenantId}` : sql``}
ORDER BY created_at DESC
LIMIT 100
`);
res.json((result as any).rows ?? []);
} catch (error: any) {
console.error("List campaigns error:", error);
res.status(500).json({ error: "Failed to list campaigns" });
}
});
/**
* POST /api/xos/campaigns create campaign
*/
router.post("/campaigns", async (req: Request, res: Response) => {
try {
const tenantId = (req as any).user?.tenantId;
const userId = (req as any).user?.id;
const { name, description, type, segmentQuery, content, subject, scheduledAt } = req.body;
if (!name || !type) return res.status(400).json({ error: "name and type are required" });
const result = await db.execute(sql`
INSERT INTO xos_campaigns (tenant_id, name, description, type, status, segment_query, content, subject, scheduled_at, created_by)
VALUES (
${tenantId || null}, ${name}, ${description || null}, ${type}, 'draft',
${segmentQuery ? JSON.stringify(segmentQuery) : null},
${content || null}, ${subject || null}, ${scheduledAt || null}, ${userId || null}
)
RETURNING *
`);
res.status(201).json(((result as any).rows ?? [])[0]);
} catch (error: any) {
console.error("Create campaign error:", error);
res.status(500).json({ error: "Failed to create campaign" });
}
});
/**
* PATCH /api/xos/campaigns/:id update campaign
*/
router.patch("/campaigns/:id", async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const { status, name, description, content, subject, scheduledAt, segmentQuery } = req.body;
await db.execute(sql`
UPDATE xos_campaigns SET
status = COALESCE(${status || null}, status),
name = COALESCE(${name || null}, name),
description = COALESCE(${description || null}, description),
content = COALESCE(${content || null}, content),
subject = COALESCE(${subject || null}, subject),
scheduled_at = COALESCE(${scheduledAt || null}, scheduled_at),
segment_query = COALESCE(${segmentQuery ? JSON.stringify(segmentQuery) : null}::jsonb, segment_query),
started_at = CASE WHEN ${status || null} = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END,
completed_at = CASE WHEN ${status || null} IN ('completed','cancelled') AND completed_at IS NULL THEN NOW() ELSE completed_at END,
updated_at = NOW()
WHERE id = ${id}
`);
res.json({ ok: true });
} catch (error: any) {
console.error("Update campaign error:", error);
res.status(500).json({ error: "Failed to update campaign" });
}
});
export default router; export default router;

119
server/xos/scheduler.ts Normal file
View File

@ -0,0 +1,119 @@
/**
* XOS Scheduler
* - Every 5 min: check SLA breaches and emit events
* - Every 30 sec: broadcast live supervisor stats via Socket.IO
*/
import { db } from "../db";
import { sql } from "drizzle-orm";
import { broadcastXos } from "../socket-io";
const SLA_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const STATS_BROADCAST_INTERVAL_MS = 30 * 1000; // 30 seconds
/** Mark overdue protocols as SLA-breached and emit event per tenant */
async function checkSlaBreaches() {
try {
// Mark protocols where sla_deadline has passed and still open/unbreached
const breached = await db.execute(sql`
UPDATE xos_protocols
SET sla_breach = true, updated_at = NOW()
WHERE status = 'open'
AND sla_deadline IS NOT NULL
AND sla_deadline < NOW()
AND sla_breach = false
RETURNING id, tenant_id, protocol_number, contact_id, queue_id, assigned_to
`);
const rows = (breached as any).rows ?? [];
for (const row of rows) {
broadcastXos("sla.breach", {
protocolId: row.id,
tenantId: row.tenant_id,
protocolNumber: row.protocol_number,
contactId: row.contact_id,
queueId: row.queue_id,
assignedTo: row.assigned_to,
breachedAt: new Date().toISOString(),
});
// Forward to automation engine (non-blocking)
try {
const engineHost = process.env.AUTOMATION_ENGINE_HOST || "localhost";
const enginePort = process.env.AUTOMATION_ENGINE_PORT || "8005";
await fetch(`http://${engineHost}:${enginePort}/xos/trigger`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "crm.sla.breached",
payload: { protocol_id: row.id, protocol_number: row.protocol_number },
tenant_id: row.tenant_id,
}),
signal: AbortSignal.timeout(5000),
});
} catch {
// Non-blocking — engine may be offline
}
}
if (rows.length > 0) {
console.log(`[xos:scheduler] SLA check: ${rows.length} breach(es) detected`);
}
} catch (err) {
console.error("[xos:scheduler] SLA check error:", err);
}
}
/** Broadcast aggregate supervisor stats to all connected clients */
async function broadcastSupervisorStats() {
try {
const [convsResult, ticketsResult, agentsResult] = await Promise.all([
db.execute(sql`
SELECT
COUNT(*) FILTER (WHERE status = 'open') AS open_conversations,
COUNT(*) FILTER (WHERE status = 'resolved' AND updated_at > NOW() - INTERVAL '24h') AS resolved_today
FROM xos_conversations
`),
db.execute(sql`
SELECT
COUNT(*) FILTER (WHERE status IN ('open','in_progress') AND priority = 'urgent') AS urgent_open
FROM xos_tickets
`),
db.execute(sql`
SELECT COUNT(*) FILTER (WHERE is_online = true) AS agents_online
FROM xos_agents
`),
]);
const c = ((convsResult as any).rows ?? [])[0] ?? {};
const t = ((ticketsResult as any).rows ?? [])[0] ?? {};
const a = ((agentsResult as any).rows ?? [])[0] ?? {};
broadcastXos("supervisor.stats", {
openConversations: Number(c.open_conversations ?? 0),
resolvedToday: Number(c.resolved_today ?? 0),
urgentTickets: Number(t.urgent_open ?? 0),
agentsOnline: Number(a.agents_online ?? 0),
timestamp: new Date().toISOString(),
});
} catch (err) {
// Suppress — tables may not exist in dev
if (process.env.NODE_ENV !== "production") {
console.debug("[xos:scheduler] stats broadcast skipped:", (err as any)?.message);
}
}
}
export function startXosScheduler() {
// Stagger initial runs slightly to avoid startup congestion
setTimeout(() => {
checkSlaBreaches();
setInterval(checkSlaBreaches, SLA_CHECK_INTERVAL_MS);
}, 15_000); // first SLA check 15s after boot
setTimeout(() => {
broadcastSupervisorStats();
setInterval(broadcastSupervisorStats, STATS_BROADCAST_INTERVAL_MS);
}, 5_000); // first broadcast 5s after boot
console.log("[xos:scheduler] Started — SLA checks every 5min, stats every 30s");
}

View File

@ -7145,6 +7145,8 @@ export const xosSlaPolicies = pgTable("xos_sla_policies", {
export const insertXosSlaPolicySchema = createInsertSchema(xosSlaPolicies).omit({ id: true, createdAt: true, updatedAt: true }); export const insertXosSlaPolicySchema = createInsertSchema(xosSlaPolicies).omit({ id: true, createdAt: true, updatedAt: true });
export type XosSlaPolicy = typeof xosSlaPolicies.$inferSelect; export type XosSlaPolicy = typeof xosSlaPolicies.$inferSelect;
export type InsertXosSlaPolicy = z.infer<typeof insertXosSlaPolicySchema>; export type InsertXosSlaPolicy = z.infer<typeof insertXosSlaPolicySchema>;
export const xosDevPipelines = pgTable("xos_dev_pipelines", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`), correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`),
prompt: text("prompt").notNull(), prompt: text("prompt").notNull(),