Add monitoring service and database schema
- Implement monitoring service with heartbeat, lease tasks, resume tasks, and task result handling. - Create monitoring time utilities for business date calculations. - Add unit tests for date window resolution and business day handling. - Define database schema for monitoring-related tables including quotas, daily reports, and task management. - Establish migration scripts for creating and dropping monitoring tables.
This commit is contained in:
@@ -0,0 +1,729 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
JsonValue,
|
||||
MonitoringHeartbeatPlatformPayload,
|
||||
MonitoringHeartbeatResponse,
|
||||
MonitoringLeaseTask,
|
||||
MonitoringLeaseTasksPayload,
|
||||
MonitoringLeaseTasksResponse,
|
||||
MonitoringLocalPlatformState,
|
||||
MonitoringPluginPingResponse,
|
||||
MonitoringResumeTasksPayload,
|
||||
MonitoringResumeTasksResponse,
|
||||
MonitoringSkipTaskPayload,
|
||||
MonitoringSkipTaskResponse,
|
||||
MonitoringTaskResultPayload,
|
||||
MonitoringTaskResultResponse,
|
||||
} from "@geo/shared-types";
|
||||
import { browser } from "wxt/browser";
|
||||
|
||||
import { canQueryMonitoringPlatform, detectMonitoringPlatformState, queryMonitoringTask } from "./monitoring-adapters";
|
||||
import type { MonitoringAdapterTaskContext } from "./monitoring-adapters/types";
|
||||
import type { StoredMonitoringPlatformState } from "./monitoring-platforms";
|
||||
import { getExtensionState, patchExtensionState, type ExtensionStorageState, type StoredMonitoringScheduledTask } from "./storage";
|
||||
|
||||
export type MonitoringAction = "ping" | "detectPlatforms" | "syncHeartbeat" | "runCycle" | "getState";
|
||||
export type MonitoringCycleMode = "manual" | "automatic";
|
||||
|
||||
const monitoringCycleBatchLeaseLimit = 50;
|
||||
const monitoringAutomaticDelayMinMs = 5 * 60 * 1000;
|
||||
const monitoringAutomaticDelayMaxMs = 10 * 60 * 1000;
|
||||
const monitoringRemoteHeartbeatFreshnessMs = 10 * 60 * 1000;
|
||||
const monitoringBusinessTimeZone = "Asia/Shanghai";
|
||||
|
||||
export interface MonitoringHeartbeatSyncResult {
|
||||
success: boolean;
|
||||
source: "remote" | "local";
|
||||
platform_states: MonitoringLocalPlatformState[];
|
||||
heartbeat?: MonitoringHeartbeatResponse | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringCycleResult {
|
||||
success: boolean;
|
||||
leased_task_count: number;
|
||||
processed_task_count: number;
|
||||
failed_task_count: number;
|
||||
skipped_task_count: number;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringRuntimeStateSnapshot {
|
||||
installation_key: string;
|
||||
plugin_installation_id: number | null;
|
||||
api_base_url: string | null;
|
||||
monitoring_last_heartbeat_at: string | null;
|
||||
monitoring_last_heartbeat_status: string | null;
|
||||
monitoring_last_cycle_at: string | null;
|
||||
monitoring_last_cycle_status: string | null;
|
||||
monitoring_platforms: StoredMonitoringPlatformState[];
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
|
||||
function normalizeText(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatMonitoringBusinessDate(value: Date): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: monitoringBusinessTimeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(value);
|
||||
|
||||
const year = parts.find((part) => part.type === "year")?.value;
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
if (!year || !month || !day) {
|
||||
throw new Error("monitoring_business_date_format_failed");
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function currentMonitoringBusinessDate(): string {
|
||||
return formatMonitoringBusinessDate(new Date());
|
||||
}
|
||||
|
||||
function currentTimestampMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function toTimestampMs(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
function randomMonitoringDelayMs(): number {
|
||||
return monitoringAutomaticDelayMinMs + Math.floor(Math.random() * (monitoringAutomaticDelayMaxMs - monitoringAutomaticDelayMinMs + 1));
|
||||
}
|
||||
|
||||
function hasFreshSuccessfulMonitoringHeartbeat(state: ExtensionStorageState): boolean {
|
||||
if (state.monitoring_last_heartbeat_status !== "success") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastHeartbeatAt = toTimestampMs(state.monitoring_last_heartbeat_at);
|
||||
if (lastHeartbeatAt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentTimestampMs() - lastHeartbeatAt < monitoringRemoteHeartbeatFreshnessMs;
|
||||
}
|
||||
|
||||
function isImmediateMonitoringTask(task: MonitoringLeaseTask | StoredMonitoringScheduledTask): boolean {
|
||||
return task.trigger_source === "manual";
|
||||
}
|
||||
|
||||
function sortMonitoringTaskQueue(tasks: StoredMonitoringScheduledTask[]): StoredMonitoringScheduledTask[] {
|
||||
return [...tasks].sort((left, right) => {
|
||||
const leftTime = toTimestampMs(left.next_execute_at) ?? 0;
|
||||
const rightTime = toTimestampMs(right.next_execute_at) ?? 0;
|
||||
if (leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return left.task_id - right.task_id;
|
||||
});
|
||||
}
|
||||
|
||||
function isMonitoringNotLoggedIn(platform: StoredMonitoringPlatformState): boolean {
|
||||
return platform.reason_code === "not_logged_in" || (platform.access_requirement === "login_required" && !platform.connected);
|
||||
}
|
||||
|
||||
function resolveAccessStatus(platform: StoredMonitoringPlatformState): string {
|
||||
if (platform.accessible) {
|
||||
return "accessible";
|
||||
}
|
||||
if (isMonitoringNotLoggedIn(platform)) {
|
||||
return "not_logged_in";
|
||||
}
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
function toMonitoringPlatformPayload(platform: StoredMonitoringPlatformState): MonitoringHeartbeatPlatformPayload {
|
||||
return {
|
||||
ai_platform_id: platform.ai_platform_id,
|
||||
access_status: resolveAccessStatus(platform),
|
||||
connected: platform.connected,
|
||||
accessible: platform.accessible,
|
||||
detected_at: platform.detected_at ?? null,
|
||||
reason_code: platform.reason_code ?? null,
|
||||
reason_message: platform.reason_message ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function postMonitoringApi<T>(path: string, payload: unknown): Promise<T> {
|
||||
const state = await getExtensionState();
|
||||
if (!state.api_base_url) {
|
||||
throw new Error("monitoring_api_base_url_missing");
|
||||
}
|
||||
if (!state.plugin_installation_id || !state.installation_token) {
|
||||
throw new Error("monitoring_installation_credentials_missing");
|
||||
}
|
||||
|
||||
const response = await fetch(new URL(path.replace(/^\//, ""), normalizeBaseUrl(state.api_base_url)).toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Geo-Installation-Id": String(state.plugin_installation_id),
|
||||
"X-Geo-Installation-Token": state.installation_token,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const body = (await response.json()) as ApiEnvelope<T> | { message?: string };
|
||||
if (!response.ok || !("data" in body)) {
|
||||
throw new Error(body.message || "monitoring_plugin_request_failed");
|
||||
}
|
||||
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async function detectAllMonitoringPlatforms(): Promise<StoredMonitoringPlatformState[]> {
|
||||
const state = await getExtensionState();
|
||||
return await Promise.all(state.monitoring_platforms.map((platform) => detectMonitoringPlatformState(platform)));
|
||||
}
|
||||
|
||||
async function persistMonitoringPlatforms(platforms: StoredMonitoringPlatformState[]): Promise<StoredMonitoringPlatformState[]> {
|
||||
const nextState = await patchExtensionState({
|
||||
monitoring_platforms: platforms,
|
||||
});
|
||||
return nextState.monitoring_platforms;
|
||||
}
|
||||
|
||||
function projectMonitoringPlatforms(platforms: StoredMonitoringPlatformState[]): MonitoringLocalPlatformState[] {
|
||||
return platforms.map((platform) => ({
|
||||
ai_platform_id: platform.ai_platform_id,
|
||||
connected: platform.connected,
|
||||
accessible: platform.accessible,
|
||||
detected_at: platform.detected_at ?? null,
|
||||
reason_code: platform.reason_code ?? null,
|
||||
reason_message: platform.reason_message ?? null,
|
||||
message: platform.message ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function asMonitoringTaskContext(task: MonitoringLeaseTask): MonitoringAdapterTaskContext {
|
||||
return {
|
||||
task_id: task.task_id,
|
||||
brand_id: task.brand_id,
|
||||
question_id: task.question_id,
|
||||
question_hash: task.question_hash,
|
||||
question_text: task.question_text,
|
||||
ai_platform_id: task.ai_platform_id,
|
||||
platform_name: task.platform_name,
|
||||
collector_type: task.collector_type,
|
||||
run_mode: task.run_mode,
|
||||
trigger_source: task.trigger_source,
|
||||
business_date: task.business_date,
|
||||
lease_token: task.lease_token,
|
||||
lease_expires_at: task.lease_expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
function asTaskResultPayload(task: MonitoringLeaseTask, result: Awaited<ReturnType<typeof queryMonitoringTask>>): MonitoringTaskResultPayload {
|
||||
return {
|
||||
lease_token: task.lease_token,
|
||||
status: "succeeded",
|
||||
provider_model: normalizeText(result.provider_model),
|
||||
provider_request_id: normalizeText(result.provider_request_id),
|
||||
request_id: normalizeText(result.request_id),
|
||||
answer: normalizeText(result.answer),
|
||||
raw_response_json: (result.raw_response_json as Record<string, JsonValue> | undefined) ?? {},
|
||||
citations: result.citations ?? [],
|
||||
search_results: result.search_results ?? [],
|
||||
brand_mentioned: result.brand_mentioned ?? null,
|
||||
brand_mention_position: normalizeText(result.brand_mention_position),
|
||||
first_recommended: result.first_recommended ?? null,
|
||||
sentiment_label: normalizeText(result.sentiment_label),
|
||||
matched_brand_terms: result.matched_brand_terms ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function markMonitoringTaskSkipped(task: MonitoringLeaseTask, reason: string, errorMessage?: string | null): Promise<MonitoringSkipTaskResponse> {
|
||||
const payload: MonitoringSkipTaskPayload = {
|
||||
lease_token: task.lease_token,
|
||||
skip_reason: reason,
|
||||
error_message: normalizeText(errorMessage),
|
||||
completed_at: new Date().toISOString(),
|
||||
};
|
||||
return await postMonitoringApi<MonitoringSkipTaskResponse>(`/api/plugin/monitoring/tasks/${task.task_id}/skip`, payload);
|
||||
}
|
||||
|
||||
async function submitMonitoringTaskResult(task: MonitoringLeaseTask, payload: MonitoringTaskResultPayload): Promise<MonitoringTaskResultResponse> {
|
||||
return await postMonitoringApi<MonitoringTaskResultResponse>(`/api/plugin/monitoring/tasks/${task.task_id}/result`, payload);
|
||||
}
|
||||
|
||||
async function processMonitoringLeasedTask(
|
||||
platform: StoredMonitoringPlatformState,
|
||||
task: MonitoringLeaseTask,
|
||||
): Promise<"processed" | "failed" | "skipped"> {
|
||||
if (!(await canQueryMonitoringPlatform(platform))) {
|
||||
await markMonitoringTaskSkipped(task, "adapter_not_ready", `monitoring adapter is not ready for ${platform.ai_platform_id}`);
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queryMonitoringTask(platform, asMonitoringTaskContext(task));
|
||||
await submitMonitoringTaskResult(task, asTaskResultPayload(task, result));
|
||||
return "processed";
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "monitoring_query_failed";
|
||||
try {
|
||||
await markMonitoringTaskSkipped(task, "query_failed", message);
|
||||
} catch {
|
||||
// Ignore secondary skip failures so the caller can continue draining the queue.
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeMonitoringTasks(aiPlatformIDs: string[]): Promise<MonitoringLeaseTask[]> {
|
||||
const payload: MonitoringResumeTasksPayload = {
|
||||
ai_platform_ids: aiPlatformIDs,
|
||||
};
|
||||
const resumed = await postMonitoringApi<MonitoringResumeTasksResponse>("/api/plugin/monitoring/tasks/resume", payload);
|
||||
return resumed.tasks;
|
||||
}
|
||||
|
||||
async function leaseMonitoringTasks(aiPlatformIDs: string[]): Promise<MonitoringLeaseTasksResponse> {
|
||||
const payload: MonitoringLeaseTasksPayload = {
|
||||
limit: monitoringCycleBatchLeaseLimit,
|
||||
ai_platform_ids: aiPlatformIDs,
|
||||
};
|
||||
return await postMonitoringApi<MonitoringLeaseTasksResponse>("/api/plugin/monitoring/tasks/lease", payload);
|
||||
}
|
||||
|
||||
function mergeMonitoringQueueTasks(
|
||||
existingQueue: StoredMonitoringScheduledTask[],
|
||||
incomingTasks: MonitoringLeaseTask[],
|
||||
): StoredMonitoringScheduledTask[] {
|
||||
if (incomingTasks.length === 0) {
|
||||
return sortMonitoringTaskQueue(existingQueue);
|
||||
}
|
||||
|
||||
const nowMs = currentTimestampMs();
|
||||
const queueByTaskID = new Map(existingQueue.map((task) => [task.task_id, task]));
|
||||
let latestDelayedExecuteAt = existingQueue.reduce((latest, task) => {
|
||||
if (task.pacing_mode !== "delayed") {
|
||||
return latest;
|
||||
}
|
||||
const executeAt = toTimestampMs(task.next_execute_at);
|
||||
return executeAt == null ? latest : Math.max(latest, executeAt);
|
||||
}, nowMs);
|
||||
|
||||
for (const task of incomingTasks) {
|
||||
if (task.business_date !== currentMonitoringBusinessDate()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = queueByTaskID.get(task.task_id);
|
||||
if (existing) {
|
||||
queueByTaskID.set(task.task_id, {
|
||||
...existing,
|
||||
...task,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const immediate = isImmediateMonitoringTask(task);
|
||||
if (!immediate) {
|
||||
latestDelayedExecuteAt = Math.max(latestDelayedExecuteAt, nowMs) + randomMonitoringDelayMs();
|
||||
}
|
||||
|
||||
queueByTaskID.set(task.task_id, {
|
||||
...task,
|
||||
pacing_mode: immediate ? "immediate" : "delayed",
|
||||
scheduled_at: new Date(nowMs).toISOString(),
|
||||
next_execute_at: immediate ? new Date(nowMs).toISOString() : new Date(latestDelayedExecuteAt).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return sortMonitoringTaskQueue(Array.from(queueByTaskID.values()));
|
||||
}
|
||||
|
||||
async function persistMonitoringTaskQueue(queue: StoredMonitoringScheduledTask[]): Promise<StoredMonitoringScheduledTask[]> {
|
||||
const nextState = await patchExtensionState({
|
||||
monitoring_task_queue: sortMonitoringTaskQueue(queue),
|
||||
});
|
||||
return nextState.monitoring_task_queue;
|
||||
}
|
||||
|
||||
async function pruneMonitoringTaskQueue(): Promise<StoredMonitoringScheduledTask[]> {
|
||||
const state = await getExtensionState();
|
||||
const today = currentMonitoringBusinessDate();
|
||||
const retained: StoredMonitoringScheduledTask[] = [];
|
||||
|
||||
for (const task of state.monitoring_task_queue) {
|
||||
if (task.business_date === today) {
|
||||
retained.push(task);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await markMonitoringTaskSkipped(
|
||||
task,
|
||||
"stale_business_date",
|
||||
`monitoring task business_date ${task.business_date} is not the current business date`,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup for locally cached stale tasks.
|
||||
}
|
||||
}
|
||||
|
||||
return await persistMonitoringTaskQueue(retained);
|
||||
}
|
||||
|
||||
async function syncMonitoringTaskQueue(aiPlatformIDs: string[]): Promise<StoredMonitoringScheduledTask[]> {
|
||||
const localQueue = await pruneMonitoringTaskQueue();
|
||||
const resumedTasks = await resumeMonitoringTasks(aiPlatformIDs);
|
||||
const resumedTaskMap = new Map(resumedTasks.map((task) => [task.task_id, task]));
|
||||
|
||||
const matchedQueue: StoredMonitoringScheduledTask[] = [];
|
||||
for (const task of localQueue) {
|
||||
const resumedTask = resumedTaskMap.get(task.task_id);
|
||||
if (!resumedTask) {
|
||||
continue;
|
||||
}
|
||||
matchedQueue.push({
|
||||
...task,
|
||||
...resumedTask,
|
||||
});
|
||||
resumedTaskMap.delete(task.task_id);
|
||||
}
|
||||
|
||||
const mergedQueue = mergeMonitoringQueueTasks(matchedQueue, Array.from(resumedTaskMap.values()));
|
||||
return await persistMonitoringTaskQueue(mergedQueue);
|
||||
}
|
||||
|
||||
async function appendMonitoringTasksToQueue(tasks: MonitoringLeaseTask[]): Promise<StoredMonitoringScheduledTask[]> {
|
||||
const state = await getExtensionState();
|
||||
const mergedQueue = mergeMonitoringQueueTasks(state.monitoring_task_queue, tasks);
|
||||
return await persistMonitoringTaskQueue(mergedQueue);
|
||||
}
|
||||
|
||||
async function removeMonitoringTaskFromQueue(taskID: number): Promise<StoredMonitoringScheduledTask[]> {
|
||||
const state = await getExtensionState();
|
||||
return await persistMonitoringTaskQueue(state.monitoring_task_queue.filter((task) => task.task_id !== taskID));
|
||||
}
|
||||
|
||||
function isMonitoringTaskDue(task: StoredMonitoringScheduledTask, nowMs: number): boolean {
|
||||
const executeAt = toTimestampMs(task.next_execute_at);
|
||||
return executeAt == null || executeAt <= nowMs;
|
||||
}
|
||||
|
||||
async function processQueuedMonitoringTask(
|
||||
task: StoredMonitoringScheduledTask,
|
||||
platformMap: Map<string, StoredMonitoringPlatformState>,
|
||||
): Promise<"processed" | "failed" | "skipped"> {
|
||||
if (task.business_date !== currentMonitoringBusinessDate()) {
|
||||
await markMonitoringTaskSkipped(
|
||||
task,
|
||||
"stale_business_date",
|
||||
`monitoring task business_date ${task.business_date} is not the current business date`,
|
||||
);
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const platform = platformMap.get(task.ai_platform_id);
|
||||
if (!platform) {
|
||||
await markMonitoringTaskSkipped(task, "platform_not_configured", `monitoring platform ${task.ai_platform_id} is not configured locally`);
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
return await processMonitoringLeasedTask(platform, task);
|
||||
}
|
||||
|
||||
async function processImmediateMonitoringTasks(
|
||||
platformMap: Map<string, StoredMonitoringPlatformState>,
|
||||
): Promise<{ processedCount: number; failedCount: number; skippedCount: number }> {
|
||||
let processedCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (;;) {
|
||||
const state = await getExtensionState();
|
||||
const dueImmediateTask = state.monitoring_task_queue.find(
|
||||
(task) => task.pacing_mode === "immediate" && isMonitoringTaskDue(task, currentTimestampMs()),
|
||||
);
|
||||
if (!dueImmediateTask) {
|
||||
break;
|
||||
}
|
||||
|
||||
const outcome = await processQueuedMonitoringTask(dueImmediateTask, platformMap);
|
||||
await removeMonitoringTaskFromQueue(dueImmediateTask.task_id);
|
||||
|
||||
if (outcome === "processed") {
|
||||
processedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (outcome === "failed") {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
skippedCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
processedCount,
|
||||
failedCount,
|
||||
skippedCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function processNextDelayedMonitoringTask(
|
||||
platformMap: Map<string, StoredMonitoringPlatformState>,
|
||||
): Promise<{ processedCount: number; failedCount: number; skippedCount: number }> {
|
||||
const state = await getExtensionState();
|
||||
const delayedTask = state.monitoring_task_queue.find(
|
||||
(task) => task.pacing_mode === "delayed" && isMonitoringTaskDue(task, currentTimestampMs()),
|
||||
);
|
||||
if (!delayedTask) {
|
||||
return {
|
||||
processedCount: 0,
|
||||
failedCount: 0,
|
||||
skippedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await processQueuedMonitoringTask(delayedTask, platformMap);
|
||||
await removeMonitoringTaskFromQueue(delayedTask.task_id);
|
||||
|
||||
if (outcome === "processed") {
|
||||
return { processedCount: 1, failedCount: 0, skippedCount: 0 };
|
||||
}
|
||||
if (outcome === "failed") {
|
||||
return { processedCount: 0, failedCount: 1, skippedCount: 0 };
|
||||
}
|
||||
return { processedCount: 0, failedCount: 0, skippedCount: 1 };
|
||||
}
|
||||
|
||||
export async function handleMonitoringAction(
|
||||
action: MonitoringAction,
|
||||
): Promise<
|
||||
MonitoringPluginPingResponse | MonitoringLocalPlatformState[] | MonitoringHeartbeatSyncResult | MonitoringCycleResult | MonitoringRuntimeStateSnapshot
|
||||
> {
|
||||
switch (action) {
|
||||
case "ping":
|
||||
return await handleMonitoringPing();
|
||||
case "detectPlatforms":
|
||||
return await handleMonitoringDetectPlatforms();
|
||||
case "syncHeartbeat":
|
||||
return await handleMonitoringSyncHeartbeat(false);
|
||||
case "runCycle":
|
||||
return await runMonitoringCycle("manual");
|
||||
case "getState":
|
||||
return await handleMonitoringGetState();
|
||||
default:
|
||||
throw new Error("monitoring_action_not_supported");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMonitoringCycle(mode: MonitoringCycleMode): Promise<MonitoringCycleResult> {
|
||||
return await handleMonitoringRunCycle(mode);
|
||||
}
|
||||
|
||||
async function handleMonitoringPing(): Promise<MonitoringPluginPingResponse> {
|
||||
const state = await getExtensionState();
|
||||
return {
|
||||
installed: true,
|
||||
version: browser.runtime.getManifest().version,
|
||||
capabilities: ["ping", "detect", "heartbeat", "lease", "background"],
|
||||
installation_key: state.installation_key,
|
||||
plugin_installation_id: state.plugin_installation_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMonitoringDetectPlatforms(): Promise<MonitoringLocalPlatformState[]> {
|
||||
const platforms = await detectAllMonitoringPlatforms();
|
||||
const persisted = await persistMonitoringPlatforms(platforms);
|
||||
return projectMonitoringPlatforms(persisted);
|
||||
}
|
||||
|
||||
async function handleMonitoringSyncHeartbeat(allowCachedRemote: boolean): Promise<MonitoringHeartbeatSyncResult> {
|
||||
const detectedPlatforms = await detectAllMonitoringPlatforms();
|
||||
const persistedPlatforms = await persistMonitoringPlatforms(detectedPlatforms);
|
||||
const projectedPlatforms = projectMonitoringPlatforms(persistedPlatforms);
|
||||
const heartbeatAt = new Date().toISOString();
|
||||
|
||||
const state = await getExtensionState();
|
||||
if (!state.plugin_installation_id || !state.installation_token || !state.api_base_url) {
|
||||
await patchExtensionState({
|
||||
monitoring_last_heartbeat_at: heartbeatAt,
|
||||
monitoring_last_heartbeat_status: "local_only",
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
source: "local",
|
||||
platform_states: projectedPlatforms,
|
||||
heartbeat: null,
|
||||
message: "monitoring installation is not registered yet",
|
||||
};
|
||||
}
|
||||
|
||||
if (allowCachedRemote && hasFreshSuccessfulMonitoringHeartbeat(state)) {
|
||||
return {
|
||||
success: true,
|
||||
source: "local",
|
||||
platform_states: projectedPlatforms,
|
||||
heartbeat: null,
|
||||
message: "reused recent monitoring heartbeat",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const heartbeat = await postMonitoringApi<MonitoringHeartbeatResponse>("/api/plugin/monitoring/heartbeat", {
|
||||
platforms: persistedPlatforms.map(toMonitoringPlatformPayload),
|
||||
});
|
||||
|
||||
await patchExtensionState({
|
||||
monitoring_last_heartbeat_at: heartbeatAt,
|
||||
monitoring_last_heartbeat_status: "success",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
source: "remote",
|
||||
platform_states: projectedPlatforms,
|
||||
heartbeat,
|
||||
message: null,
|
||||
};
|
||||
} catch (error) {
|
||||
await patchExtensionState({
|
||||
monitoring_last_heartbeat_at: heartbeatAt,
|
||||
monitoring_last_heartbeat_status: "failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMonitoringRunCycle(mode: MonitoringCycleMode): Promise<MonitoringCycleResult> {
|
||||
let heartbeatResult: MonitoringHeartbeatSyncResult;
|
||||
try {
|
||||
heartbeatResult = await handleMonitoringSyncHeartbeat(mode === "automatic");
|
||||
} catch (error) {
|
||||
await patchExtensionState({
|
||||
monitoring_last_cycle_at: new Date().toISOString(),
|
||||
monitoring_last_cycle_status: "heartbeat_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const state = await getExtensionState();
|
||||
const accessiblePlatforms = state.monitoring_platforms.filter((platform) => platform.accessible);
|
||||
const queryReadyPlatforms = (
|
||||
await Promise.all(
|
||||
accessiblePlatforms.map(async (platform) => ({
|
||||
platform,
|
||||
canQuery: await canQueryMonitoringPlatform(platform),
|
||||
})),
|
||||
)
|
||||
)
|
||||
.filter((item) => item.canQuery)
|
||||
.map((item) => item.platform);
|
||||
|
||||
if (!state.plugin_installation_id || !state.installation_token || !state.api_base_url) {
|
||||
await patchExtensionState({
|
||||
monitoring_last_cycle_at: new Date().toISOString(),
|
||||
monitoring_last_cycle_status: "not_configured",
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
leased_task_count: 0,
|
||||
processed_task_count: 0,
|
||||
failed_task_count: 0,
|
||||
skipped_task_count: 0,
|
||||
message: "monitoring installation is not registered yet",
|
||||
};
|
||||
}
|
||||
|
||||
if (queryReadyPlatforms.length === 0) {
|
||||
await patchExtensionState({
|
||||
monitoring_last_cycle_at: new Date().toISOString(),
|
||||
monitoring_last_cycle_status: "no_query_capable_platforms",
|
||||
});
|
||||
return {
|
||||
success: heartbeatResult.success,
|
||||
leased_task_count: 0,
|
||||
processed_task_count: 0,
|
||||
failed_task_count: 0,
|
||||
skipped_task_count: 0,
|
||||
message: "monitoring adapters are not query-ready yet",
|
||||
};
|
||||
}
|
||||
|
||||
const queryReadyPlatformIDs = queryReadyPlatforms.map((platform) => platform.ai_platform_id);
|
||||
const platformMap = new Map(queryReadyPlatforms.map((platform) => [platform.ai_platform_id, platform] as const));
|
||||
|
||||
let leasedTaskCount = 0;
|
||||
let processedCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
let queue = await syncMonitoringTaskQueue(queryReadyPlatformIDs);
|
||||
if (mode === "manual" || queue.length === 0) {
|
||||
const leased = await leaseMonitoringTasks(queryReadyPlatformIDs);
|
||||
leasedTaskCount += leased.lease_count;
|
||||
if (leased.tasks.length > 0) {
|
||||
queue = await appendMonitoringTasksToQueue(leased.tasks);
|
||||
}
|
||||
}
|
||||
|
||||
const immediateResult = await processImmediateMonitoringTasks(platformMap);
|
||||
processedCount += immediateResult.processedCount;
|
||||
failedCount += immediateResult.failedCount;
|
||||
skippedCount += immediateResult.skippedCount;
|
||||
|
||||
if (mode === "automatic" && immediateResult.processedCount === 0) {
|
||||
const delayedResult = await processNextDelayedMonitoringTask(platformMap);
|
||||
processedCount += delayedResult.processedCount;
|
||||
failedCount += delayedResult.failedCount;
|
||||
skippedCount += delayedResult.skippedCount;
|
||||
}
|
||||
|
||||
const remainingQueue = (await getExtensionState()).monitoring_task_queue;
|
||||
const cycleStatus =
|
||||
failedCount > 0
|
||||
? "partial_failed"
|
||||
: processedCount > 0
|
||||
? "success"
|
||||
: skippedCount > 0
|
||||
? "skipped"
|
||||
: remainingQueue.length > 0
|
||||
? "scheduled"
|
||||
: "idle";
|
||||
await patchExtensionState({
|
||||
monitoring_last_cycle_at: new Date().toISOString(),
|
||||
monitoring_last_cycle_status: cycleStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
leased_task_count: leasedTaskCount,
|
||||
processed_task_count: processedCount,
|
||||
failed_task_count: failedCount,
|
||||
skipped_task_count: skippedCount,
|
||||
message: remainingQueue.length > 0 ? `scheduled ${remainingQueue.length} monitoring tasks` : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMonitoringGetState(): Promise<MonitoringRuntimeStateSnapshot> {
|
||||
const state = await getExtensionState();
|
||||
return {
|
||||
installation_key: state.installation_key,
|
||||
plugin_installation_id: state.plugin_installation_id,
|
||||
api_base_url: state.api_base_url,
|
||||
monitoring_last_heartbeat_at: state.monitoring_last_heartbeat_at,
|
||||
monitoring_last_heartbeat_status: state.monitoring_last_heartbeat_status,
|
||||
monitoring_last_cycle_at: state.monitoring_last_cycle_at,
|
||||
monitoring_last_cycle_status: state.monitoring_last_cycle_status,
|
||||
monitoring_platforms: state.monitoring_platforms,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user