feat(rails): GET /api/transitions + /api/escalations for SIEM dashboard

This commit is contained in:
2026-04-10 18:22:58 +09:00
parent 9aeef223c6
commit 579137e4bf
3 changed files with 96 additions and 1 deletions

View File

@@ -68,7 +68,9 @@ const server = createServer(async (req, res) => {
});
}
const req2 = { ...parsed.data, agentName: parsed.data.agentName || AGENT_NAME };
// Force agentName to this sister's identity (env), not whatever rails sent.
// The stage info is preserved separately in parsed.data.stage.
const req2 = { ...parsed.data, agentName: AGENT_NAME };
const railsClient = new RailsClient(req2.railsApiUrl);
log("info", "invoke.start", {

View File

@@ -172,6 +172,71 @@ function mergeContextIntoSnapshot(
return snapshot;
}
export async function listTransitions(opts?: {
pipelineId?: string;
eventType?: string;
limit?: number;
}): Promise<
Array<{
id: number;
pipelineId: string;
fromState: string;
toState: string;
eventType: string;
timestamp: Date;
}>
> {
const prisma = getPrisma();
const where: { pipelineId?: string; eventType?: string } = {};
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
if (opts?.eventType) where.eventType = opts.eventType;
return prisma.stateTransition.findMany({
where,
orderBy: { timestamp: "desc" },
take: opts?.limit ?? 100,
select: {
id: true,
pipelineId: true,
fromState: true,
toState: true,
eventType: true,
timestamp: true,
},
});
}
export async function listEscalations(opts?: {
pipelineId?: string;
resolved?: boolean;
limit?: number;
}): Promise<
Array<{
id: string;
pipelineId: string;
reason: string;
errorCategory: string;
stage: string;
attempts: number;
contextSnapshot: string;
resolvedAt: Date | null;
resolution: string | null;
createdAt: Date;
}>
> {
const prisma = getPrisma();
const where: { pipelineId?: string; resolvedAt?: null | { not: null } } = {};
if (opts?.pipelineId) where.pipelineId = opts.pipelineId;
if (opts?.resolved === false) where.resolvedAt = null;
if (opts?.resolved === true) where.resolvedAt = { not: null };
return prisma.escalation.findMany({
where,
orderBy: { createdAt: "desc" },
take: opts?.limit ?? 50,
});
}
export async function listPipelines(opts?: {
state?: PipelineState;
limit?: number;

View File

@@ -4,6 +4,8 @@ import {
createPipeline,
getPipelineState,
listPipelines,
listTransitions,
listEscalations,
sendEvent,
} from "../orchestrator/persist.js";
import { runPipeline } from "../orchestrator/runner.js";
@@ -201,6 +203,32 @@ export async function startHttpServer(opts: ServerOpts): Promise<{
return sendJson(res, 200, detail);
}
// ── State transitions (SIEM-style log) ──
if (method === "GET" && path === "/api/transitions") {
const limit = parseInt(url.searchParams.get("limit") ?? "100", 10);
const pid = url.searchParams.get("pipelineId") ?? undefined;
const eventType = url.searchParams.get("eventType") ?? undefined;
const transitions = await listTransitions({
...(pid !== undefined && { pipelineId: pid }),
...(eventType !== undefined && { eventType }),
limit,
});
return sendJson(res, 200, { transitions });
}
// ── Escalations ──
if (method === "GET" && path === "/api/escalations") {
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
const pid = url.searchParams.get("pipelineId") ?? undefined;
const resolvedQ = url.searchParams.get("resolved");
const opts: { pipelineId?: string; resolved?: boolean; limit: number } = { limit };
if (pid !== undefined) opts.pipelineId = pid;
if (resolvedQ === "true") opts.resolved = true;
else if (resolvedQ === "false") opts.resolved = false;
const escalations = await listEscalations(opts);
return sendJson(res, 200, { escalations });
}
return sendJson(res, 404, { error: "not_found", path });
} catch (err) {
log.error(