feat(monitoring): dispatch monitoring tasks to desktop via AMQP outbox
Replace SSE /desktop/events with priority AMQP dispatch for monitoring
runs, and add phase1/phase2 infrastructure so desktop workers can lease,
resume, report, skip, and cancel monitoring tasks over the existing
dispatch WebSocket.
- Add monitoring collect outbox worker and phase2 desktop task fields
- Add /desktop/monitoring/tasks/{lease,resume,result,skip,cancel} routes
- Introduce execution-devtools and network-observer on desktop runtime
- Refactor runtime-controller, LoginView, and doubao adapter for the new flow
- Add channelName column to tracking views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { Session, WebContents } from "electron/main";
|
||||
|
||||
import type {
|
||||
DesktopObservedRequest,
|
||||
DesktopObservedRequestKind,
|
||||
DesktopObservedRequestSnapshot,
|
||||
DesktopObservedRequestSource,
|
||||
} from "../shared/network-debug";
|
||||
|
||||
interface ActiveObservedRequest {
|
||||
id: string;
|
||||
startedAt: number;
|
||||
kind: DesktopObservedRequestKind;
|
||||
source: DesktopObservedRequestSource;
|
||||
label: string;
|
||||
method: string;
|
||||
url: string;
|
||||
partition: string | null;
|
||||
resourceType: string | null;
|
||||
}
|
||||
|
||||
interface StartObservedRequestOptions {
|
||||
kind?: DesktopObservedRequestKind;
|
||||
source?: DesktopObservedRequestSource;
|
||||
label: string;
|
||||
method?: string;
|
||||
url: string;
|
||||
partition?: string | null;
|
||||
resourceType?: string | null;
|
||||
}
|
||||
|
||||
const observedSessions = new WeakSet<Session>();
|
||||
const activeRequests = new Map<string, ActiveObservedRequest>();
|
||||
const observedRecords: DesktopObservedRequest[] = [];
|
||||
const maxObservedRecords = 240;
|
||||
const requestFilter = {
|
||||
urls: ["http://*/*", "https://*/*", "ws://*/*", "wss://*/*"],
|
||||
};
|
||||
const redactedQueryKeys = /token|signature|sig|auth|key|password|session/i;
|
||||
|
||||
let rendererTarget: WebContents | null = null;
|
||||
let originalFetch: typeof globalThis.fetch | null = null;
|
||||
|
||||
export function installObservedGlobalFetch(): void {
|
||||
if (originalFetch || typeof globalThis.fetch !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
originalFetch = globalThis.fetch.bind(globalThis);
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = requestURL(input);
|
||||
if (!url || !isInspectableURL(url) || !originalFetch) {
|
||||
return originalFetch!(input, init);
|
||||
}
|
||||
|
||||
const method = requestMethod(input, init);
|
||||
const requestId = startObservedRequest({
|
||||
kind: inferKindFromHeaders(input, init),
|
||||
source: "main",
|
||||
label: "main.fetch",
|
||||
method,
|
||||
url,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await originalFetch(input, init);
|
||||
resolveObservedRequest(requestId, {
|
||||
phase: "response",
|
||||
status: response.status,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
failObservedRequest(requestId, error);
|
||||
throw error;
|
||||
}
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
export function registerObservedRequestRendererTarget(webContents: WebContents): void {
|
||||
rendererTarget = webContents;
|
||||
webContents.once("destroyed", () => {
|
||||
if (rendererTarget === webContents) {
|
||||
rendererTarget = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function observeSessionRequests(
|
||||
target: Session,
|
||||
options: { label: string; partition?: string | null },
|
||||
): void {
|
||||
if (observedSessions.has(target)) {
|
||||
return;
|
||||
}
|
||||
observedSessions.add(target);
|
||||
|
||||
target.webRequest.onBeforeRequest(requestFilter, (details, callback) => {
|
||||
if (!isInspectableURL(details.url)) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = sessionRequestId(details.id, options.partition ?? null);
|
||||
activeRequests.set(requestId, {
|
||||
id: requestId,
|
||||
startedAt: Date.now(),
|
||||
kind: details.resourceType === "webSocket" ? "ws" : "http",
|
||||
source: "session",
|
||||
label: options.label,
|
||||
method: normalizeMethod(details.method),
|
||||
url: sanitizeURL(details.url),
|
||||
partition: options.partition ?? null,
|
||||
resourceType: normalizeResourceType(details.resourceType),
|
||||
});
|
||||
|
||||
pushRecord(buildRecord(activeRequests.get(requestId)!, {
|
||||
at: Date.now(),
|
||||
phase: "request",
|
||||
}));
|
||||
|
||||
callback({});
|
||||
});
|
||||
|
||||
target.webRequest.onCompleted(requestFilter, (details) => {
|
||||
if (!isInspectableURL(details.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = sessionRequestId(details.id, options.partition ?? null);
|
||||
const active = activeRequests.get(requestId)
|
||||
?? createFallbackActiveRequest(requestId, {
|
||||
source: "session",
|
||||
label: options.label,
|
||||
method: normalizeMethod(details.method),
|
||||
url: details.url,
|
||||
partition: options.partition ?? null,
|
||||
resourceType: normalizeResourceType(details.resourceType),
|
||||
});
|
||||
|
||||
pushRecord(buildRecord(active, {
|
||||
at: Date.now(),
|
||||
phase: "response",
|
||||
status: details.statusCode,
|
||||
durationMs: Date.now() - active.startedAt,
|
||||
}));
|
||||
activeRequests.delete(requestId);
|
||||
});
|
||||
|
||||
target.webRequest.onErrorOccurred(requestFilter, (details) => {
|
||||
if (!isInspectableURL(details.url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = sessionRequestId(details.id, options.partition ?? null);
|
||||
const active = activeRequests.get(requestId)
|
||||
?? createFallbackActiveRequest(requestId, {
|
||||
source: "session",
|
||||
label: options.label,
|
||||
method: normalizeMethod(details.method),
|
||||
url: details.url,
|
||||
partition: options.partition ?? null,
|
||||
resourceType: normalizeResourceType(details.resourceType),
|
||||
});
|
||||
|
||||
pushRecord(buildRecord(active, {
|
||||
at: Date.now(),
|
||||
phase: "error",
|
||||
durationMs: Date.now() - active.startedAt,
|
||||
error: normalizeErrorMessage(details.error),
|
||||
}));
|
||||
activeRequests.delete(requestId);
|
||||
});
|
||||
}
|
||||
|
||||
export function startObservedRequest(options: StartObservedRequestOptions): string {
|
||||
const id = `main:${randomUUID()}`;
|
||||
const active: ActiveObservedRequest = {
|
||||
id,
|
||||
startedAt: Date.now(),
|
||||
kind: options.kind ?? "http",
|
||||
source: options.source ?? "main",
|
||||
label: options.label,
|
||||
method: normalizeMethod(options.method),
|
||||
url: sanitizeURL(options.url),
|
||||
partition: options.partition ?? null,
|
||||
resourceType: options.resourceType ?? null,
|
||||
};
|
||||
|
||||
activeRequests.set(id, active);
|
||||
pushRecord(buildRecord(active, {
|
||||
at: active.startedAt,
|
||||
phase: "request",
|
||||
}));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function resolveObservedRequest(
|
||||
id: string,
|
||||
options: {
|
||||
phase?: "response" | "close";
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
keepActive?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
const active = activeRequests.get(id);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushRecord(buildRecord(active, {
|
||||
at: Date.now(),
|
||||
phase: options.phase ?? "response",
|
||||
status: options.status ?? null,
|
||||
durationMs: Date.now() - active.startedAt,
|
||||
error: options.error ?? null,
|
||||
}));
|
||||
|
||||
if (!options.keepActive) {
|
||||
activeRequests.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function failObservedRequest(id: string, error: unknown): void {
|
||||
const active = activeRequests.get(id);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushRecord(buildRecord(active, {
|
||||
at: Date.now(),
|
||||
phase: "error",
|
||||
durationMs: Date.now() - active.startedAt,
|
||||
error: normalizeErrorMessage(error),
|
||||
}));
|
||||
activeRequests.delete(id);
|
||||
}
|
||||
|
||||
export function getObservedRequestSnapshot(): DesktopObservedRequestSnapshot {
|
||||
return {
|
||||
rendererAttached: Boolean(rendererTarget && !rendererTarget.isDestroyed()),
|
||||
activeCount: activeRequests.size,
|
||||
recent: observedRecords.slice(-80),
|
||||
};
|
||||
}
|
||||
|
||||
function requestURL(input: RequestInfo | URL): string | null {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
return typeof input.url === "string" ? input.url : null;
|
||||
}
|
||||
|
||||
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
||||
if (init?.method) {
|
||||
return normalizeMethod(init.method);
|
||||
}
|
||||
if (typeof Request !== "undefined" && input instanceof Request) {
|
||||
return normalizeMethod(input.method);
|
||||
}
|
||||
return "GET";
|
||||
}
|
||||
|
||||
function inferKindFromHeaders(input: RequestInfo | URL, init?: RequestInit): DesktopObservedRequestKind {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("accept") && typeof Request !== "undefined" && input instanceof Request) {
|
||||
const requestAccept = input.headers.get("accept");
|
||||
if (requestAccept) {
|
||||
headers.set("accept", requestAccept);
|
||||
}
|
||||
}
|
||||
|
||||
const accept = headers.get("accept")?.toLowerCase() ?? "";
|
||||
if (accept.includes("text/event-stream")) {
|
||||
return "sse";
|
||||
}
|
||||
return "http";
|
||||
}
|
||||
|
||||
function isInspectableURL(url: string): boolean {
|
||||
try {
|
||||
const protocol = new URL(url).protocol;
|
||||
return protocol === "http:" || protocol === "https:" || protocol === "ws:" || protocol === "wss:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeURL(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
for (const key of parsed.searchParams.keys()) {
|
||||
if (redactedQueryKeys.test(key)) {
|
||||
parsed.searchParams.set(key, "[REDACTED]");
|
||||
}
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMethod(value: string | undefined | null): string {
|
||||
return typeof value === "string" && value.trim() ? value.trim().toUpperCase() : "GET";
|
||||
}
|
||||
|
||||
function normalizeResourceType(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "request_failed";
|
||||
}
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return "request_failed";
|
||||
}
|
||||
|
||||
function sessionRequestId(id: number, partition: string | null): string {
|
||||
return `session:${partition ?? "default"}:${id}`;
|
||||
}
|
||||
|
||||
function createFallbackActiveRequest(
|
||||
id: string,
|
||||
options: {
|
||||
source: DesktopObservedRequestSource;
|
||||
label: string;
|
||||
method?: string;
|
||||
url: string;
|
||||
partition?: string | null;
|
||||
resourceType?: string | null;
|
||||
},
|
||||
): ActiveObservedRequest {
|
||||
return {
|
||||
id,
|
||||
startedAt: Date.now(),
|
||||
kind: options.resourceType === "webSocket" ? "ws" : "http",
|
||||
source: options.source,
|
||||
label: options.label,
|
||||
method: normalizeMethod(options.method),
|
||||
url: sanitizeURL(options.url),
|
||||
partition: options.partition ?? null,
|
||||
resourceType: options.resourceType ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecord(
|
||||
active: ActiveObservedRequest,
|
||||
input: {
|
||||
at: number;
|
||||
phase: DesktopObservedRequest["phase"];
|
||||
status?: number | null;
|
||||
durationMs?: number | null;
|
||||
error?: string | null;
|
||||
},
|
||||
): DesktopObservedRequest {
|
||||
return {
|
||||
id: active.id,
|
||||
at: input.at,
|
||||
phase: input.phase,
|
||||
kind: active.kind,
|
||||
source: active.source,
|
||||
label: active.label,
|
||||
method: active.method,
|
||||
url: active.url,
|
||||
partition: active.partition,
|
||||
resourceType: active.resourceType,
|
||||
status: input.status ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
error: input.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushRecord(record: DesktopObservedRequest): void {
|
||||
observedRecords.push(record);
|
||||
if (observedRecords.length > maxObservedRecords) {
|
||||
observedRecords.splice(0, observedRecords.length - maxObservedRecords);
|
||||
}
|
||||
|
||||
if (!rendererTarget || rendererTarget.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
rendererTarget.send("desktop:network-observed", record);
|
||||
} catch {
|
||||
// Best-effort diagnostics only.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user