203 lines
7.9 KiB
TypeScript
203 lines
7.9 KiB
TypeScript
// SPEC: PJ2026-010405 CONSOLE-REQ-007; MDTODO R3.
|
|
// Responsibility: expose one authenticated Kafka retention-to-live SSE stream for the AgentRun observer.
|
|
|
|
import { createWorkbenchKafkaRefreshHandoff } from "./workbench-kafka-refresh-handoff.ts";
|
|
|
|
export async function handleAgentObserverHttp(request, response, options = {}) {
|
|
if (request.method !== "GET") {
|
|
response.writeHead(405, { allow: "GET", "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({ ok: false, error: { code: "method_not_allowed", message: "GET required." } }));
|
|
return;
|
|
}
|
|
|
|
const bridge = options.kafkaEventBridge;
|
|
if (!bridge?.started || typeof bridge.subscribeLiveHwlabEvents !== "function" || typeof bridge.queryHwlabEventRetention !== "function") {
|
|
sendJson(response, 503, observerError("agent_observer_kafka_unavailable", "Agent observer Kafka retention/live runtime is unavailable."));
|
|
return;
|
|
}
|
|
|
|
let policy;
|
|
try {
|
|
policy = agentObserverPolicy(options.env ?? process.env);
|
|
} catch (error) {
|
|
sendJson(response, 503, observerError(error?.code ?? "agent_observer_config_invalid", error instanceof Error ? error.message : String(error)));
|
|
return;
|
|
}
|
|
const requestUrl = new URL(request.url ?? "/v1/agent-observer/events", "http://agent-observer.local");
|
|
const resumeAfterId = text(requestUrl.searchParams.get("afterId") ?? request.headers?.["last-event-id"]);
|
|
const liveOnly = requestUrl.searchParams.get("mode") === "live";
|
|
|
|
try {
|
|
await (bridge.liveReady ?? bridge.ready);
|
|
} catch (error) {
|
|
sendJson(response, 503, observerError(error?.code ?? "agent_observer_kafka_start_failed", error instanceof Error ? error.message : String(error)));
|
|
return;
|
|
}
|
|
|
|
let closed = false;
|
|
const cleanup = [];
|
|
const active = () => !closed && !response.destroyed && !response.writableEnded;
|
|
const close = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
for (const dispose of cleanup.splice(0)) dispose();
|
|
};
|
|
response.once("close", close);
|
|
request.once?.("aborted", close);
|
|
request.socket?.once?.("close", close);
|
|
cleanup.push(() => request.off?.("aborted", close));
|
|
cleanup.push(() => request.socket?.off?.("close", close));
|
|
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-store, no-transform",
|
|
connection: "keep-alive",
|
|
"x-accel-buffering": "no",
|
|
"x-content-type-options": "nosniff"
|
|
});
|
|
response.flushHeaders?.();
|
|
|
|
const writeEvent = async (name, payload) => {
|
|
if (!active()) return false;
|
|
const eventId = text(payload?.eventId ?? payload?.sourceEventId);
|
|
const block = `${eventId ? `id: ${eventId}\n` : ""}event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
if (!response.write(block)) await waitForDrain(response, active);
|
|
return active();
|
|
};
|
|
let writeChain = Promise.resolve(true);
|
|
const enqueue = (name, payload) => {
|
|
writeChain = writeChain.then(() => writeEvent(name, payload));
|
|
return writeChain;
|
|
};
|
|
const fail = async (error) => {
|
|
if (!active()) return;
|
|
await enqueue("agent-observer.error", observerError(error?.code ?? "agent_observer_stream_failed", error instanceof Error ? error.message : String(error)).error);
|
|
await writeChain;
|
|
if (!response.writableEnded) response.end();
|
|
};
|
|
|
|
if (liveOnly) {
|
|
const unsubscribe = bridge.subscribeLiveHwlabEvents((envelope) => { void enqueue("hwlab.event.v1", envelope); });
|
|
cleanup.push(() => unsubscribe?.());
|
|
await enqueue("agent-observer.connected", {
|
|
type: "connected",
|
|
status: "live",
|
|
deliverySemantics: "agentrun-snapshot-then-kafka-live",
|
|
authority: "hwlab.event.v1",
|
|
replay: null,
|
|
serverSentAt: new Date().toISOString(),
|
|
valuesPrinted: false
|
|
});
|
|
} else {
|
|
const handoff = createWorkbenchKafkaRefreshHandoff({
|
|
allowUnscoped: true,
|
|
resumeAfterId,
|
|
liveBufferLimit: policy.liveBufferLimit,
|
|
identityWindowLimit: policy.identityWindowLimit,
|
|
subscribeLive: (listener) => bridge.subscribeLiveHwlabEvents(listener),
|
|
queryRetention: ({ signal }) => bridge.queryHwlabEventRetention({
|
|
limit: policy.retentionEventLimit,
|
|
scanLimit: policy.retentionScanLimit,
|
|
timeoutMs: policy.retentionTimeoutMs,
|
|
groupIdPrefix: policy.retentionGroupPrefix,
|
|
fromBeginning: true,
|
|
signal
|
|
}),
|
|
deliverEvent: (envelope) => enqueue("hwlab.event.v1", envelope),
|
|
deliverHandoff: (summary) => enqueue("agent-observer.handoff", {
|
|
type: "handoff",
|
|
status: "handoff",
|
|
deliverySemantics: "kafka-retention-then-live",
|
|
authority: "hwlab.event.v1",
|
|
replay: summary,
|
|
serverSentAt: new Date().toISOString(),
|
|
valuesPrinted: false
|
|
}),
|
|
deliverConnected: (summary) => enqueue("agent-observer.connected", {
|
|
type: "connected",
|
|
status: "live",
|
|
deliverySemantics: "kafka-retention-then-live",
|
|
authority: "hwlab.event.v1",
|
|
replay: summary,
|
|
serverSentAt: new Date().toISOString(),
|
|
valuesPrinted: false
|
|
}),
|
|
onFailure: fail
|
|
});
|
|
cleanup.push(() => handoff.stop("connection-closed"));
|
|
|
|
try {
|
|
await handoff.start();
|
|
} catch (error) {
|
|
await fail(error);
|
|
return;
|
|
}
|
|
}
|
|
if (!active()) return;
|
|
|
|
const heartbeat = setInterval(() => {
|
|
void enqueue("agent-observer.heartbeat", {
|
|
type: "heartbeat",
|
|
status: "live",
|
|
authority: "hwlab.event.v1",
|
|
serverSentAt: new Date().toISOString(),
|
|
valuesPrinted: false
|
|
});
|
|
}, policy.heartbeatMs);
|
|
heartbeat.unref?.();
|
|
cleanup.push(() => clearInterval(heartbeat));
|
|
}
|
|
|
|
function agentObserverPolicy(env) {
|
|
const policy = {
|
|
retentionEventLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT"),
|
|
retentionScanLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT, "HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT"),
|
|
retentionTimeoutMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS, "HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS"),
|
|
retentionGroupPrefix: requiredText(env.HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX, "HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX"),
|
|
liveBufferLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"),
|
|
identityWindowLimit: positiveInteger(env.HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT"),
|
|
heartbeatMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS, "HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS")
|
|
};
|
|
if (policy.identityWindowLimit < policy.retentionEventLimit + policy.liveBufferLimit) {
|
|
throw Object.assign(new Error("HWLAB_AGENT_OBSERVER_IDENTITY_WINDOW_LIMIT must cover retentionEventLimit + liveBufferLimit."), { code: "agent_observer_config_invalid" });
|
|
}
|
|
return policy;
|
|
}
|
|
|
|
function positiveInteger(value, name) {
|
|
const number = Number(value);
|
|
if (!Number.isInteger(number) || number <= 0) throw Object.assign(new Error(`${name} must be a positive integer.`), { code: "agent_observer_config_invalid" });
|
|
return number;
|
|
}
|
|
|
|
function requiredText(value, name) {
|
|
const result = text(value);
|
|
if (!result) throw Object.assign(new Error(`${name} is required.`), { code: "agent_observer_config_invalid" });
|
|
return result;
|
|
}
|
|
|
|
function text(value) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function observerError(code, message) {
|
|
return { ok: false, error: { code, message, authority: "hwlab.event.v1", valuesRedacted: true } };
|
|
}
|
|
|
|
function sendJson(response, status, payload) {
|
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
response.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function waitForDrain(response, active) {
|
|
return new Promise((resolve) => {
|
|
const finish = () => {
|
|
response.off?.("drain", finish);
|
|
response.off?.("close", finish);
|
|
resolve(active());
|
|
};
|
|
response.once("drain", finish);
|
|
response.once("close", finish);
|
|
});
|
|
}
|