2026-04-12 09:56:18 +08:00
|
|
|
import type {
|
|
|
|
|
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> {
|
2026-04-19 14:18:20 +08:00
|
|
|
void path;
|
|
|
|
|
void payload;
|
|
|
|
|
throw new Error("browser_extension_monitoring_removed_use_desktop_client");
|
2026-04-12 09:56:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
};
|
2026-04-19 14:18:20 +08:00
|
|
|
return await postMonitoringApi<MonitoringSkipTaskResponse>("skip_task", payload);
|
2026-04-12 09:56:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitMonitoringTaskResult(task: MonitoringLeaseTask, payload: MonitoringTaskResultPayload): Promise<MonitoringTaskResultResponse> {
|
2026-04-19 14:18:20 +08:00
|
|
|
void task;
|
|
|
|
|
return await postMonitoringApi<MonitoringTaskResultResponse>("submit_result", payload);
|
2026-04-12 09:56:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
2026-04-19 14:18:20 +08:00
|
|
|
const resumed = await postMonitoringApi<MonitoringResumeTasksResponse>("resume_tasks", payload);
|
2026-04-12 09:56:18 +08:00
|
|
|
return resumed.tasks;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function leaseMonitoringTasks(aiPlatformIDs: string[]): Promise<MonitoringLeaseTasksResponse> {
|
|
|
|
|
const payload: MonitoringLeaseTasksPayload = {
|
|
|
|
|
limit: monitoringCycleBatchLeaseLimit,
|
|
|
|
|
ai_platform_ids: aiPlatformIDs,
|
|
|
|
|
};
|
2026-04-19 14:18:20 +08:00
|
|
|
return await postMonitoringApi<MonitoringLeaseTasksResponse>("lease_tasks", payload);
|
2026-04-12 09:56:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-19 14:18:20 +08:00
|
|
|
const heartbeat = await postMonitoringApi<MonitoringHeartbeatResponse>("heartbeat", {
|
2026-04-12 09:56:18 +08:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|