feat(worker): EOC push-agent for ops summary

- Add EOC_URL + EOC_PUSH_TOKEN config
- Add pushOpsSummaryToEoc processor
- Schedule PUSH_OPS_SUMMARY_TO_EOC every 2 minutes
- Add unit tests
This commit is contained in:
Sven (AAMOS AI)
2026-08-11 17:03:53 +07:00
parent 4eddcfbefe
commit af05f084b8
4 changed files with 140 additions and 0 deletions
+5
View File
@@ -55,6 +55,11 @@ const configSchema = z.object({
/** Strong, rotated ops token for /ops/v1/summary. Set via SSM in production. */
OPS_TOKEN: z.string().min(1).default("dev-ops-token-change-me"),
/** EOC base URL for outbound ops-summary push. Empty = push disabled. */
EOC_URL: z.string().url().optional(),
/** EOC ingest bearer token for this app. Set via SSM in production. */
EOC_PUSH_TOKEN: z.string().min(1).optional(),
APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"),
EMAIL_MODE: z.enum(["log", "smtp"]).default("log"),
SMTP_HOST: z.string().default(""),
+16
View File
@@ -22,6 +22,7 @@ import {
} from "./processors/maintenance.js";
import { processSafetyCanary } from "./processors/safety-canary.js";
import { refreshOpsSummaryCache } from "./processors/ops-summary.js";
import { pushOpsSummaryToEoc } from "./processors/eoc-push.js";
import {
DEAD_LETTER_QUEUE_NAME,
@@ -145,6 +146,12 @@ const worker = new Worker(
return;
}
case "PUSH_OPS_SUMMARY_TO_EOC": {
const result = await pushOpsSummaryToEoc(ctx.redis!);
log(`EOC push: ${result.ok ? "OK" : "FEL"}${result.error ? ` (${result.error})` : ""}`);
return;
}
// Deterministiska/planerade jobb som inte kräver egen processor ännu
case "NORMALIZE_PRODUCTS":
case "DEDUPLICATE_INVENTORY":
@@ -293,6 +300,15 @@ async function registerRepeatableJobs() {
opts: baseOpts,
},
);
await queue.upsertJobScheduler(
"scheduler-eoc-push",
{ every: 2 * 60 * 1000 }, // var 2:e minut
{
name: "PUSH_OPS_SUMMARY_TO_EOC",
data: { jobType: "PUSH_OPS_SUMMARY_TO_EOC" },
opts: baseOpts,
},
);
}
// --- Minimal healthcheck-server så att flera instanser kan övervakas ---
+71
View File
@@ -0,0 +1,71 @@
import type { Redis } from "ioredis";
import { CACHE_KEY } from "./ops-summary.js";
export interface EocPushResult {
ok: boolean;
status: number | null;
error: string | null;
}
const EOC_PUSH_TIMEOUT_MS = 15_000;
function ingestUrl(base: string): string {
const trimmed = base.trim().replace(/\/+$/, "");
return `${trimmed}/api/v1/apps/cibello/ingest`;
}
/**
* Push the cached ops summary to EOC. Never throws; failures are returned
* as `ok: false` and must be logged by the caller.
*/
export async function pushOpsSummaryToEoc(redis: Redis): Promise<EocPushResult> {
const eocUrl = process.env.EOC_URL?.trim();
const token = process.env.EOC_PUSH_TOKEN?.trim();
if (!eocUrl || !token) {
return { ok: false, status: null, error: "EOC_URL or EOC_PUSH_TOKEN not configured" };
}
const json = await redis.get(CACHE_KEY);
if (!json) {
return { ok: false, status: null, error: "ops summary cache miss" };
}
// Guard against accidentally sending non-object payloads.
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return { ok: false, status: null, error: "ops summary cache contains invalid JSON" };
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { ok: false, status: null, error: "ops summary cache is not a JSON object" };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), EOC_PUSH_TIMEOUT_MS);
try {
const res = await fetch(ingestUrl(eocUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: json,
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 204) {
return { ok: true, status: 204, error: null };
}
const text = await res.text().catch(() => "<unreadable body>");
return { ok: false, status: res.status, error: text.slice(0, 500) };
} catch (err) {
clearTimeout(timeout);
const message = err instanceof Error ? err.message : String(err);
return { ok: false, status: null, error: message };
}
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import IORedis from "ioredis";
import { pushOpsSummaryToEoc } from "../src/processors/eoc-push.js";
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: null,
});
describe("pushOpsSummaryToEoc", () => {
beforeAll(async () => {
await redis.set(
"ops:summary:cache",
JSON.stringify({ app: { app: "cibello", generated_at: new Date().toISOString() }, as_of: new Date().toISOString() }),
"EX",
60,
);
});
afterAll(async () => {
await redis.del("ops:summary:cache");
await redis.quit();
});
it("returns disabled result when EOC_URL is missing", async () => {
delete process.env.EOC_URL;
process.env.EOC_PUSH_TOKEN = "tok";
const result = await pushOpsSummaryToEoc(redis);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/EOC_URL or EOC_PUSH_TOKEN not configured/);
});
it("returns disabled result when EOC_PUSH_TOKEN is missing", async () => {
process.env.EOC_URL = "https://eoc.example.com";
delete process.env.EOC_PUSH_TOKEN;
const result = await pushOpsSummaryToEoc(redis);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/EOC_URL or EOC_PUSH_TOKEN not configured/);
});
it("returns cache-miss when cache is empty", async () => {
process.env.EOC_URL = "https://eoc.example.com";
process.env.EOC_PUSH_TOKEN = "tok";
await redis.del("ops:summary:cache");
const result = await pushOpsSummaryToEoc(redis);
expect(result.ok).toBe(false);
expect(result.error).toMatch(/cache miss/);
});
});