feat: 增加纯 Kafka AgentRun 运行观察
This commit is contained in:
@@ -336,6 +336,7 @@
|
||||
"runtimePath": { "type": "string" },
|
||||
"imageTagMode": { "type": "string", "enum": ["short", "full"] },
|
||||
"workbench": { "$ref": "#/$defs/workbenchUiPolicy" },
|
||||
"agentObserver": { "$ref": "#/$defs/agentObserverPolicy" },
|
||||
"observability": { "$ref": "#/$defs/runtimeObservabilityPolicy" },
|
||||
"opencode": { "$ref": "#/$defs/opencodeRuntime" },
|
||||
"runtimeStore": { "$ref": "#/$defs/runtimeStore" },
|
||||
@@ -610,6 +611,46 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"agentObserverPolicy": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"retentionEventLimit",
|
||||
"retentionScanLimit",
|
||||
"retentionTimeoutMs",
|
||||
"retentionGroupPrefix",
|
||||
"sseHeartbeatMs",
|
||||
"reconnectDelayMs",
|
||||
"batchMaxEvents",
|
||||
"liveBufferLimit",
|
||||
"reducerEventLimit",
|
||||
"reducerEntityLimit",
|
||||
"treeNodeLimit",
|
||||
"inspectorEventTailLimit",
|
||||
"virtualListWindowLimit",
|
||||
"changeHighlightMs",
|
||||
"staleAfterMs",
|
||||
"reducedMotion"
|
||||
],
|
||||
"properties": {
|
||||
"retentionEventLimit": { "type": "integer", "minimum": 1 },
|
||||
"retentionScanLimit": { "type": "integer", "minimum": 1 },
|
||||
"retentionTimeoutMs": { "type": "integer", "minimum": 250 },
|
||||
"retentionGroupPrefix": { "type": "string", "minLength": 1 },
|
||||
"sseHeartbeatMs": { "type": "integer", "minimum": 1000 },
|
||||
"reconnectDelayMs": { "type": "integer", "minimum": 100 },
|
||||
"batchMaxEvents": { "type": "integer", "minimum": 1 },
|
||||
"liveBufferLimit": { "type": "integer", "minimum": 1 },
|
||||
"reducerEventLimit": { "type": "integer", "minimum": 1 },
|
||||
"reducerEntityLimit": { "type": "integer", "minimum": 1 },
|
||||
"treeNodeLimit": { "type": "integer", "minimum": 1 },
|
||||
"inspectorEventTailLimit": { "type": "integer", "minimum": 1 },
|
||||
"virtualListWindowLimit": { "type": "integer", "minimum": 1 },
|
||||
"changeHighlightMs": { "type": "integer", "minimum": 0 },
|
||||
"staleAfterMs": { "type": "integer", "minimum": 1000 },
|
||||
"reducedMotion": { "type": "string", "enum": ["respect-system"] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"externalPostgres": {
|
||||
"type": "object",
|
||||
"required": ["enabled", "serviceName", "endpointAddress", "port"],
|
||||
|
||||
@@ -378,6 +378,23 @@ lanes:
|
||||
enabled: true
|
||||
maxEntries: 200
|
||||
maxRetainedBytes: 1048576
|
||||
agentObserver:
|
||||
retentionEventLimit: 10000
|
||||
retentionScanLimit: 1000000
|
||||
retentionTimeoutMs: 30000
|
||||
retentionGroupPrefix: hwlab-v03-agent-observer-replay
|
||||
sseHeartbeatMs: 15000
|
||||
reconnectDelayMs: 1500
|
||||
batchMaxEvents: 100
|
||||
liveBufferLimit: 2000
|
||||
reducerEventLimit: 2000
|
||||
reducerEntityLimit: 500
|
||||
treeNodeLimit: 500
|
||||
inspectorEventTailLimit: 100
|
||||
virtualListWindowLimit: 160
|
||||
changeHighlightMs: 1200
|
||||
staleAfterMs: 30000
|
||||
reducedMotion: respect-system
|
||||
observability:
|
||||
traceExplorerUrlTemplate: /v1/workbench/traces/{trace_id}/events
|
||||
prometheusOperatorResources: false
|
||||
|
||||
@@ -1317,9 +1317,17 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run,
|
||||
sourceSeq,
|
||||
runId,
|
||||
commandId,
|
||||
taskId: firstText(payload.taskId, sourceEvent.taskId),
|
||||
attemptId: firstText(command.attemptId, sourceEvent.attemptId, payload.attemptId),
|
||||
runnerId: firstText(command.runnerId, sourceEvent.runnerId, payload.runnerId),
|
||||
backend: firstText(run.backendProfile, payload.backendProfile, payload.providerProfile),
|
||||
providerId: firstText(run.providerId, payload.providerId),
|
||||
projectId: firstText(run.projectId, payload.projectId),
|
||||
workspace: firstText(payload.workspace, payload.targetWorkspace, payload.cwd),
|
||||
runStatus: firstText(run.status, payload.runStatus),
|
||||
terminalStatus: firstText(run.terminalStatus, payload.terminalStatus),
|
||||
commandState: firstText(command.state, payload.commandState),
|
||||
commandType: firstText(command.type, payload.commandType),
|
||||
agentRunEventType: type,
|
||||
createdAt: timestampValue(sourceEvent.createdAt ?? input.producedAt),
|
||||
valuesPrinted: false
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const handoff = createWorkbenchKafkaRefreshHandoff({
|
||||
allowUnscoped: true,
|
||||
liveBufferLimit: policy.liveBufferLimit,
|
||||
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),
|
||||
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) {
|
||||
return {
|
||||
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"),
|
||||
heartbeatMs: positiveInteger(env.HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS, "HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS")
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import { drainWorkbenchRealtimeConnections, handleWorkbenchReadModelHttp, handle
|
||||
import { handleWorkbenchDebugFakeSseHttp } from "./workbench-debug-fake-sse.ts";
|
||||
import { handleWorkbenchKafkaSseDebugHttp } from "./workbench-kafka-sse-debug.ts";
|
||||
import { handleWorkbenchLaunchHttp } from "./server-workbench-launch-http.ts";
|
||||
import { handleAgentObserverHttp } from "./server-agent-observer-http.ts";
|
||||
import { startWorkbenchEmptySessionGc } from "./workbench-empty-session-gc.ts";
|
||||
import { startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
|
||||
import { handleM3IoControlHttp } from "./server-m3-http.ts";
|
||||
@@ -758,6 +759,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/agent-observer/events") {
|
||||
await handleAgentObserverHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/workbench/launches") {
|
||||
await handleWorkbenchLaunchHttp(request, response, options);
|
||||
return;
|
||||
@@ -1012,6 +1018,7 @@ function navIdForRestPath(pathname, method = "GET") {
|
||||
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
|
||||
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/") || pathname === "/v1/workbench/debug/kafka-sse" || pathname.startsWith("/v1/workbench/debug/kafka-sse/")) return "workbench.debug";
|
||||
if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/projection-events" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
|
||||
if (pathname === "/v1/agent-observer/events") return "workbench.code";
|
||||
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
|
||||
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
|
||||
if (pathname === "/v1/admin/secrets" || pathname.startsWith("/v1/admin/secrets/")) return "admin.secrets";
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
export function createWorkbenchKafkaRefreshHandoff(options = {}) {
|
||||
const sessionId = textValue(options.sessionId);
|
||||
const traceId = textValue(options.traceId);
|
||||
const allowUnscoped = options.allowUnscoped === true;
|
||||
const liveBufferLimit = positiveInteger(options.liveBufferLimit);
|
||||
if (!sessionId && !traceId) throw handoffError("workbench_kafka_refresh_scope_missing", "Kafka refresh handoff requires a session or trace scope.", "idle");
|
||||
if (!sessionId && !traceId && !allowUnscoped) throw handoffError("workbench_kafka_refresh_scope_missing", "Kafka refresh handoff requires a session or trace scope.", "idle");
|
||||
if (!liveBufferLimit) throw handoffError("workbench_kafka_refresh_buffer_limit_invalid", "Kafka refresh handoff requires an explicit positive live buffer limit.", "idle");
|
||||
if (typeof options.subscribeLive !== "function" || typeof options.queryRetention !== "function" || typeof options.deliverEvent !== "function" || typeof options.deliverConnected !== "function") {
|
||||
throw handoffError("workbench_kafka_refresh_runtime_invalid", "Kafka refresh handoff runtime callbacks are incomplete.", "idle");
|
||||
@@ -169,7 +170,7 @@ export function createWorkbenchKafkaRefreshHandoff(options = {}) {
|
||||
function validateReplayRecords(result) {
|
||||
const records = Array.isArray(result?.events) ? result.events : [];
|
||||
counts.matched = records.length;
|
||||
if (traceId && records.length === 0) {
|
||||
if (!allowUnscoped && traceId && records.length === 0) {
|
||||
throw handoffError("workbench_kafka_refresh_trace_empty", "Trace-scoped Kafka refresh found no retained events after a complete scan.", phase);
|
||||
}
|
||||
const replayPartitions = new Set();
|
||||
|
||||
@@ -20,7 +20,8 @@ const FORWARDED_REQUEST_HEADERS = Object.freeze([
|
||||
]);
|
||||
|
||||
export function isCloudWebSseRoute(pathname) {
|
||||
return /^\/v1\/agent\/chat\/trace\/[^/]+\/stream$/u.test(String(pathname || ""));
|
||||
const value = String(pathname || "");
|
||||
return value === "/v1/agent-observer/events" || /^\/v1\/agent\/chat\/trace\/[^/]+\/stream$/u.test(value);
|
||||
}
|
||||
|
||||
export function isEventStreamResponse(headers = {}) {
|
||||
|
||||
@@ -171,6 +171,8 @@ async function runtimeConfigFromEnv(options = {}) {
|
||||
const config = { displayTime: displayTimeConfigFromEnv() };
|
||||
const workbench = workbenchRuntimeConfigFromEnv();
|
||||
if (workbench) config.workbench = workbench;
|
||||
const agentObserver = agentObserverRuntimeConfigFromEnv();
|
||||
if (agentObserver) config.agentObserver = agentObserver;
|
||||
const opencode = await opencodeRuntimeConfigFromEnv(options);
|
||||
if (opencode) config.opencode = opencode;
|
||||
return config;
|
||||
@@ -424,6 +426,52 @@ function workbenchRuntimeConfigFromEnv() {
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
function agentObserverRuntimeConfigFromEnv() {
|
||||
const names = [
|
||||
"HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS",
|
||||
"HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS",
|
||||
"HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT",
|
||||
"HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT",
|
||||
"HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT",
|
||||
"HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT",
|
||||
"HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT",
|
||||
"HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT",
|
||||
"HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS",
|
||||
"HWLAB_AGENT_OBSERVER_STALE_AFTER_MS",
|
||||
"HWLAB_AGENT_OBSERVER_REDUCED_MOTION"
|
||||
];
|
||||
if (names.every((name) => process.env[name] === undefined || process.env[name] === "")) return null;
|
||||
const value = {
|
||||
nodeId: requiredRuntimeConfigValue(process.env.HWLAB_AGENT_OBSERVER_NODE_ID, "HWLAB_AGENT_OBSERVER_NODE_ID"),
|
||||
lane: requiredRuntimeConfigValue(process.env.HWLAB_AGENT_OBSERVER_LANE, "HWLAB_AGENT_OBSERVER_LANE"),
|
||||
reconnectDelayMs: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS"),
|
||||
batchMaxEvents: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS"),
|
||||
liveBufferLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT"),
|
||||
reducerEventLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT"),
|
||||
reducerEntityLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT"),
|
||||
treeNodeLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT"),
|
||||
inspectorEventTailLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT, "HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT"),
|
||||
virtualListWindowLimit: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT, "HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT"),
|
||||
changeHighlightMs: requiredNonNegativeInteger(process.env.HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS, "HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS"),
|
||||
staleAfterMs: requiredPositiveWorkbenchInteger(process.env.HWLAB_AGENT_OBSERVER_STALE_AFTER_MS, "HWLAB_AGENT_OBSERVER_STALE_AFTER_MS"),
|
||||
reducedMotion: requiredRuntimeConfigValue(process.env.HWLAB_AGENT_OBSERVER_REDUCED_MOTION, "HWLAB_AGENT_OBSERVER_REDUCED_MOTION")
|
||||
};
|
||||
if (value.reducedMotion !== "respect-system") throw new Error("HWLAB_AGENT_OBSERVER_REDUCED_MOTION must be respect-system");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredNonNegativeInteger(value, name) {
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < 0) throw new Error(`${name} is required and must be a non-negative integer`);
|
||||
return number;
|
||||
}
|
||||
|
||||
function requiredRuntimeConfigValue(value, name) {
|
||||
const result = String(value ?? "").trim();
|
||||
if (!result) throw new Error(`${name} is required`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredWorkbenchRealtimeFeature(value, name) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1") return true;
|
||||
|
||||
@@ -1147,6 +1147,13 @@ function runtimeWorkbenchRawHwlabEventWindowPolicy(deploy, profile) {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeAgentObserverPolicy(deploy, profile) {
|
||||
if (!isRuntimeLane(profile)) return null;
|
||||
const policy = runtimeLaneConfig(deploy, profile)?.agentObserver;
|
||||
if (!policy || typeof policy !== "object" || Array.isArray(policy)) return null;
|
||||
return cloneJson(policy);
|
||||
}
|
||||
|
||||
function runtimeTraceExplorerUrlTemplate(deploy, profile) {
|
||||
if (!isRuntimeLane(profile)) return null;
|
||||
const template = runtimeLaneConfig(deploy, profile)?.observability?.traceExplorerUrlTemplate;
|
||||
@@ -1556,6 +1563,31 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
|
||||
if (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-edge-proxy") {
|
||||
upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint);
|
||||
}
|
||||
if (runtimeLane && (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-cloud-web")) {
|
||||
const agentObserverPolicy = runtimeAgentObserverPolicy(deploy, profile);
|
||||
if (agentObserverPolicy) {
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RETENTION_EVENT_LIMIT", String(agentObserverPolicy.retentionEventLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RETENTION_SCAN_LIMIT", String(agentObserverPolicy.retentionScanLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RETENTION_TIMEOUT_MS", String(agentObserverPolicy.retentionTimeoutMs));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RETENTION_GROUP_PREFIX", String(agentObserverPolicy.retentionGroupPrefix));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_SSE_HEARTBEAT_MS", String(agentObserverPolicy.sseHeartbeatMs));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_RECONNECT_DELAY_MS", String(agentObserverPolicy.reconnectDelayMs));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_BATCH_MAX_EVENTS", String(agentObserverPolicy.batchMaxEvents));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_LIVE_BUFFER_LIMIT", String(agentObserverPolicy.liveBufferLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCER_EVENT_LIMIT", String(agentObserverPolicy.reducerEventLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCER_ENTITY_LIMIT", String(agentObserverPolicy.reducerEntityLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_TREE_NODE_LIMIT", String(agentObserverPolicy.treeNodeLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_INSPECTOR_EVENT_TAIL_LIMIT", String(agentObserverPolicy.inspectorEventTailLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_VIRTUAL_LIST_WINDOW_LIMIT", String(agentObserverPolicy.virtualListWindowLimit));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_CHANGE_HIGHLIGHT_MS", String(agentObserverPolicy.changeHighlightMs));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_STALE_AFTER_MS", String(agentObserverPolicy.staleAfterMs));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_REDUCED_MOTION", String(agentObserverPolicy.reducedMotion));
|
||||
if (serviceId === "hwlab-cloud-web") {
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_NODE_ID", runtimeNodeIdForProfile(deploy, profile, nodeId));
|
||||
upsertEnv(container.env, "HWLAB_AGENT_OBSERVER_LANE", profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (runtimeLane && serviceId === "hwlab-edge-proxy") {
|
||||
useLocalHealthProbe(container, "/health");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface AgentObserverStreamCallbacks {
|
||||
onEnvelope: (payload: unknown) => void;
|
||||
onConnected: (payload: unknown) => void;
|
||||
onHeartbeat: (payload: unknown) => void;
|
||||
onStreamError: (payload: unknown) => void;
|
||||
onTransportError: () => void;
|
||||
}
|
||||
|
||||
export function openAgentObserverStream(callbacks: AgentObserverStreamCallbacks): EventSource {
|
||||
const source = new EventSource("/v1/agent-observer/events", { withCredentials: true });
|
||||
source.addEventListener("hwlab.event.v1", (event) => callbacks.onEnvelope(parseEvent(event)));
|
||||
source.addEventListener("agent-observer.connected", (event) => callbacks.onConnected(parseEvent(event)));
|
||||
source.addEventListener("agent-observer.heartbeat", (event) => callbacks.onHeartbeat(parseEvent(event)));
|
||||
source.addEventListener("agent-observer.error", (event) => callbacks.onStreamError(parseEvent(event)));
|
||||
source.onerror = () => callbacks.onTransportError();
|
||||
return source;
|
||||
}
|
||||
|
||||
function parseEvent(event: Event): unknown {
|
||||
if (!(event instanceof MessageEvent) || typeof event.data !== "string") return null;
|
||||
try {
|
||||
return JSON.parse(event.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bot,
|
||||
Bug,
|
||||
ChartBar,
|
||||
CircleHelp,
|
||||
@@ -61,6 +62,13 @@ const navSections = [
|
||||
icon: Monitor,
|
||||
navId: "workbench.code",
|
||||
},
|
||||
{
|
||||
name: "AgentRuns",
|
||||
label: "智能体运行",
|
||||
path: "/agents/runs",
|
||||
icon: Bot,
|
||||
navId: "workbench.code",
|
||||
},
|
||||
{
|
||||
name: "WorkbenchDebug",
|
||||
label: "调试",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface AgentObserverRuntimeConfig {
|
||||
nodeId: string;
|
||||
lane: string;
|
||||
reconnectDelayMs: number;
|
||||
batchMaxEvents: number;
|
||||
liveBufferLimit: number;
|
||||
reducerEventLimit: number;
|
||||
reducerEntityLimit: number;
|
||||
treeNodeLimit: number;
|
||||
inspectorEventTailLimit: number;
|
||||
virtualListWindowLimit: number;
|
||||
changeHighlightMs: number;
|
||||
staleAfterMs: number;
|
||||
reducedMotion: "respect-system";
|
||||
}
|
||||
|
||||
export function agentObserverRuntimeConfig(): AgentObserverRuntimeConfig | null {
|
||||
const value = window.HWLAB_CLOUD_WEB_CONFIG?.agentObserver;
|
||||
if (!value) return null;
|
||||
const config = {
|
||||
nodeId: text(value.nodeId),
|
||||
lane: text(value.lane),
|
||||
reconnectDelayMs: positive(value.reconnectDelayMs),
|
||||
batchMaxEvents: positive(value.batchMaxEvents),
|
||||
liveBufferLimit: positive(value.liveBufferLimit),
|
||||
reducerEventLimit: positive(value.reducerEventLimit),
|
||||
reducerEntityLimit: positive(value.reducerEntityLimit),
|
||||
treeNodeLimit: positive(value.treeNodeLimit),
|
||||
inspectorEventTailLimit: positive(value.inspectorEventTailLimit),
|
||||
virtualListWindowLimit: positive(value.virtualListWindowLimit),
|
||||
changeHighlightMs: nonNegative(value.changeHighlightMs),
|
||||
staleAfterMs: positive(value.staleAfterMs),
|
||||
reducedMotion: value.reducedMotion
|
||||
};
|
||||
if (Object.values(config).some((entry) => entry === null) || config.reducedMotion !== "respect-system") return null;
|
||||
return config as AgentObserverRuntimeConfig;
|
||||
}
|
||||
|
||||
function positive(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
function nonNegative(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import "./styles/workbench.css";
|
||||
import "./styles/console-pages.css";
|
||||
import "./styles/console-foundation.css";
|
||||
import "./styles/console-r2.css";
|
||||
import "./styles/agent-observer.css";
|
||||
|
||||
function initThemeClass(): void {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
|
||||
@@ -10,6 +10,7 @@ const WorkbenchDebugView = () => import("@/views/workbench/WorkbenchDebugView.vu
|
||||
const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue");
|
||||
const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
|
||||
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
|
||||
const AgentRunsView = () => import("@/views/agents/AgentRunsView.vue");
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: "/", redirect: "/workbench" },
|
||||
@@ -19,6 +20,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/workbench/debug", name: "WorkbenchDebug", component: WorkbenchDebugView, meta: { requiresAuth: true, navId: "workbench.debug", title: "Workbench 调试", section: "workbench-debug" } },
|
||||
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
|
||||
{ path: "/agents/runs", name: "AgentRuns", component: AgentRunsView, meta: { requiresAuth: true, navId: "workbench.code", title: "AgentRun 运行观察", section: "agents" } },
|
||||
{ path: "/agents/runs/:runId", name: "AgentRunDetail", component: AgentRunsView, meta: { requiresAuth: true, navId: "workbench.code", navParent: "AgentRuns", title: "AgentRun 详情", section: "agents" } },
|
||||
{ path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } },
|
||||
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, navId: "project.overview", title: "项目", section: "project" } },
|
||||
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
|
||||
+1
-1
@@ -12,6 +12,6 @@ declare module "vue-router" {
|
||||
navId?: string;
|
||||
navParent?: string;
|
||||
title?: string;
|
||||
section?: "workbench" | "workbench-debug" | "opencode" | "project" | "admin" | "user" | "system";
|
||||
section?: "workbench" | "workbench-debug" | "opencode" | "project" | "agents" | "admin" | "user" | "system";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { openAgentObserverStream } from "@/api/agentObserver";
|
||||
import { agentObserverRuntimeConfig, type AgentObserverRuntimeConfig } from "@/config/agent-observer";
|
||||
import { asRecord, nonEmptyString } from "@/utils";
|
||||
|
||||
export type AgentObserverView = "table" | "cards" | "tree";
|
||||
export type AgentObserverDensity = "compact" | "standard";
|
||||
export type AgentObserverPhase = "idle" | "replay" | "live" | "reconnecting" | "stale" | "error";
|
||||
|
||||
export interface AgentObserverEvent {
|
||||
id: string;
|
||||
sourceEventId: string | null;
|
||||
runId: string;
|
||||
commandId: string | null;
|
||||
sessionId: string | null;
|
||||
traceId: string | null;
|
||||
sourceSeq: number | null;
|
||||
createdAt: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
label: string;
|
||||
message: string;
|
||||
terminal: boolean;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentObserverRun {
|
||||
id: string;
|
||||
taskId: string | null;
|
||||
commandId: string | null;
|
||||
attemptId: string | null;
|
||||
runnerId: string | null;
|
||||
sessionId: string | null;
|
||||
traceId: string | null;
|
||||
projectId: string | null;
|
||||
workspace: string | null;
|
||||
backend: string | null;
|
||||
providerId: string | null;
|
||||
runStatus: string;
|
||||
commandState: string | null;
|
||||
commandType: string | null;
|
||||
phase: string;
|
||||
result: string | null;
|
||||
errorCode: string | null;
|
||||
lastMessage: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
lastSourceSeq: number | null;
|
||||
eventIds: string[];
|
||||
changedAtMs: number;
|
||||
}
|
||||
|
||||
export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
const entities = ref<Record<string, AgentObserverRun>>({});
|
||||
const orderedIds = ref<string[]>([]);
|
||||
const events = ref<AgentObserverEvent[]>([]);
|
||||
const selectedRunId = ref<string | null>(null);
|
||||
const view = ref<AgentObserverView>("table");
|
||||
const density = ref<AgentObserverDensity>("compact");
|
||||
const search = ref("");
|
||||
const statusFilter = ref("all");
|
||||
const projectFilter = ref("all");
|
||||
const agentFilter = ref("all");
|
||||
const timeWindow = ref("all");
|
||||
const sort = ref("updated-desc");
|
||||
const phase = ref<AgentObserverPhase>("idle");
|
||||
const lastEventAt = ref<string | null>(null);
|
||||
const connectedAt = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const nowMs = ref(Date.now());
|
||||
const config = ref<AgentObserverRuntimeConfig | null>(null);
|
||||
const seenEventIds = new Set<string>();
|
||||
const queue: unknown[] = [];
|
||||
let source: EventSource | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let staleTimer: number | null = null;
|
||||
let flushScheduled = false;
|
||||
let manuallyClosed = false;
|
||||
|
||||
const runs = computed(() => orderedIds.value.map((id) => entities.value[id]).filter((item): item is AgentObserverRun => Boolean(item)));
|
||||
const selectedRun = computed(() => selectedRunId.value ? entities.value[selectedRunId.value] ?? null : null);
|
||||
const visibleRuns = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
const result = runs.value.filter((run) => {
|
||||
if (statusFilter.value !== "all" && statusGroup(run.runStatus) !== statusFilter.value) return false;
|
||||
if (projectFilter.value !== "all" && run.projectId !== projectFilter.value) return false;
|
||||
if (agentFilter.value !== "all" && (run.backend ?? run.providerId) !== agentFilter.value) return false;
|
||||
if (!insideTimeWindow(run.updatedAt ?? run.startedAt, timeWindow.value)) return false;
|
||||
if (!needle) return true;
|
||||
return [run.id, run.taskId, run.commandId, run.sessionId, run.traceId, run.projectId, run.workspace, run.backend, run.providerId, run.errorCode, run.lastMessage]
|
||||
.some((value) => value?.toLowerCase().includes(needle));
|
||||
});
|
||||
return result.sort((left, right) => compareRuns(left, right, sort.value));
|
||||
});
|
||||
const projects = computed(() => unique(runs.value.map((run) => run.projectId)));
|
||||
const agents = computed(() => unique(runs.value.map((run) => run.backend ?? run.providerId)));
|
||||
const selectedEvents = computed(() => {
|
||||
const run = selectedRun.value;
|
||||
if (!run || !config.value) return [];
|
||||
const ids = new Set(run.eventIds.slice(-config.value.inspectorEventTailLimit));
|
||||
return events.value.filter((event) => ids.has(event.id));
|
||||
});
|
||||
const stats = computed(() => {
|
||||
const values = visibleRuns.value;
|
||||
return {
|
||||
total: values.length,
|
||||
running: values.filter((run) => statusGroup(run.runStatus) === "running").length,
|
||||
waiting: values.filter((run) => statusGroup(run.runStatus) === "waiting").length,
|
||||
failed: values.filter((run) => statusGroup(run.runStatus) === "failed").length,
|
||||
terminal: values.filter((run) => statusGroup(run.runStatus) === "terminal").length,
|
||||
runners: new Set(values.map((run) => run.runnerId).filter(Boolean)).size
|
||||
};
|
||||
});
|
||||
const dataAgeMs = computed(() => lastEventAt.value ? Math.max(0, nowMs.value - Date.parse(lastEventAt.value)) : null);
|
||||
|
||||
function connect(): void {
|
||||
disconnect(false);
|
||||
config.value = agentObserverRuntimeConfig();
|
||||
if (!config.value) {
|
||||
phase.value = "error";
|
||||
error.value = "Agent observer runtime config 未由 owning YAML 渲染。";
|
||||
return;
|
||||
}
|
||||
manuallyClosed = false;
|
||||
phase.value = entities.value && orderedIds.value.length > 0 ? "reconnecting" : "replay";
|
||||
error.value = null;
|
||||
source = openAgentObserverStream({
|
||||
onEnvelope: enqueueEnvelope,
|
||||
onConnected: () => {
|
||||
connectedAt.value = new Date().toISOString();
|
||||
phase.value = "live";
|
||||
error.value = null;
|
||||
},
|
||||
onHeartbeat: () => {
|
||||
if (phase.value === "stale") phase.value = "live";
|
||||
},
|
||||
onStreamError: (payload) => {
|
||||
const record = asRecord(payload);
|
||||
error.value = nonEmptyString(record?.message) ?? "Kafka observer stream failed.";
|
||||
phase.value = "error";
|
||||
},
|
||||
onTransportError: scheduleReconnect
|
||||
});
|
||||
if (staleTimer === null) {
|
||||
staleTimer = window.setInterval(() => {
|
||||
nowMs.value = Date.now();
|
||||
if (phase.value === "live" && dataAgeMs.value !== null && dataAgeMs.value > (config.value?.staleAfterMs ?? Number.MAX_SAFE_INTEGER)) phase.value = "stale";
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect(manual = true): void {
|
||||
manuallyClosed = manual;
|
||||
source?.close();
|
||||
source = null;
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
if (manual && staleTimer !== null) window.clearInterval(staleTimer);
|
||||
if (manual) staleTimer = null;
|
||||
}
|
||||
|
||||
function reconnect(): void {
|
||||
manuallyClosed = false;
|
||||
connect();
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
source?.close();
|
||||
source = null;
|
||||
if (manuallyClosed || reconnectTimer !== null || !config.value) return;
|
||||
phase.value = "reconnecting";
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, config.value.reconnectDelayMs);
|
||||
}
|
||||
|
||||
function enqueueEnvelope(payload: unknown): void {
|
||||
if (!config.value) return;
|
||||
if (queue.length >= config.value.liveBufferLimit) {
|
||||
phase.value = "error";
|
||||
error.value = "客户端 Kafka 事件缓冲达到 YAML 上限。";
|
||||
source?.close();
|
||||
return;
|
||||
}
|
||||
queue.push(payload);
|
||||
if (flushScheduled) return;
|
||||
flushScheduled = true;
|
||||
queueMicrotask(flushQueue);
|
||||
}
|
||||
|
||||
function flushQueue(): void {
|
||||
flushScheduled = false;
|
||||
if (!config.value) return;
|
||||
const batch = queue.splice(0, config.value.batchMaxEvents);
|
||||
for (const payload of batch) reduceEnvelope(payload);
|
||||
if (queue.length > 0) {
|
||||
flushScheduled = true;
|
||||
queueMicrotask(flushQueue);
|
||||
}
|
||||
}
|
||||
|
||||
function reduceEnvelope(payload: unknown): void {
|
||||
if (!config.value) return;
|
||||
const envelope = asRecord(payload);
|
||||
const body = asRecord(envelope?.event);
|
||||
const context = asRecord(envelope?.context);
|
||||
const runId = nonEmptyString(envelope?.runId) ?? nonEmptyString(body?.runId);
|
||||
const eventId = nonEmptyString(envelope?.eventId) ?? nonEmptyString(envelope?.sourceEventId);
|
||||
if (!runId || !eventId || seenEventIds.has(eventId)) return;
|
||||
seenEventIds.add(eventId);
|
||||
const createdAt = nonEmptyString(body?.createdAt) ?? nonEmptyString(envelope?.producedAt);
|
||||
const sourceSeq = integer(body?.sourceSeq) ?? integer(context?.sourceSeq);
|
||||
const event: AgentObserverEvent = {
|
||||
id: eventId,
|
||||
sourceEventId: nonEmptyString(envelope?.sourceEventId),
|
||||
runId,
|
||||
commandId: nonEmptyString(envelope?.commandId) ?? nonEmptyString(body?.commandId),
|
||||
sessionId: nonEmptyString(envelope?.sessionId) ?? nonEmptyString(envelope?.hwlabSessionId),
|
||||
traceId: nonEmptyString(envelope?.traceId),
|
||||
sourceSeq,
|
||||
createdAt,
|
||||
type: nonEmptyString(body?.type) ?? "event",
|
||||
status: nonEmptyString(body?.status) ?? "running",
|
||||
label: nonEmptyString(body?.label) ?? "agentrun:event",
|
||||
message: nonEmptyString(body?.message) ?? nonEmptyString(body?.text) ?? "",
|
||||
terminal: body?.terminal === true,
|
||||
raw: body ?? {}
|
||||
};
|
||||
events.value.push(event);
|
||||
while (events.value.length > config.value.reducerEventLimit) {
|
||||
const removed = events.value.shift();
|
||||
if (removed) seenEventIds.delete(removed.id);
|
||||
}
|
||||
|
||||
const previous = entities.value[runId];
|
||||
const sequenceIsCurrent = !previous || sourceSeq === null || previous.lastSourceSeq === null || sourceSeq >= previous.lastSourceSeq;
|
||||
const terminalStatus = nonEmptyString(body?.terminalStatus);
|
||||
const runStatus = sequenceIsCurrent
|
||||
? normalizeRunStatus(nonEmptyString(body?.runStatus), terminalStatus, event.status, event.terminal, previous?.runStatus)
|
||||
: previous.runStatus;
|
||||
const next: AgentObserverRun = {
|
||||
id: runId,
|
||||
taskId: nonEmptyString(body?.taskId) ?? previous?.taskId ?? null,
|
||||
commandId: event.commandId ?? previous?.commandId ?? null,
|
||||
attemptId: nonEmptyString(body?.attemptId) ?? previous?.attemptId ?? null,
|
||||
runnerId: nonEmptyString(body?.runnerId) ?? previous?.runnerId ?? null,
|
||||
sessionId: event.sessionId ?? previous?.sessionId ?? null,
|
||||
traceId: event.traceId ?? previous?.traceId ?? null,
|
||||
projectId: nonEmptyString(body?.projectId) ?? nonEmptyString(context?.projectId) ?? previous?.projectId ?? null,
|
||||
workspace: nonEmptyString(body?.workspace) ?? previous?.workspace ?? null,
|
||||
backend: nonEmptyString(body?.backend) ?? previous?.backend ?? null,
|
||||
providerId: nonEmptyString(body?.providerId) ?? previous?.providerId ?? null,
|
||||
runStatus,
|
||||
commandState: nonEmptyString(body?.commandState) ?? previous?.commandState ?? null,
|
||||
commandType: nonEmptyString(body?.commandType) ?? previous?.commandType ?? null,
|
||||
phase: event.label.replace(/^agentrun:/u, ""),
|
||||
result: event.terminal ? (event.message || terminalStatus) : (previous?.result ?? null),
|
||||
errorCode: nonEmptyString(body?.errorCode) ?? nonEmptyString(body?.failureKind) ?? previous?.errorCode ?? null,
|
||||
lastMessage: event.message || (previous?.lastMessage ?? null),
|
||||
startedAt: previous?.startedAt ?? createdAt,
|
||||
updatedAt: createdAt ?? previous?.updatedAt ?? null,
|
||||
lastSourceSeq: sequenceIsCurrent ? sourceSeq ?? previous?.lastSourceSeq ?? null : previous.lastSourceSeq,
|
||||
eventIds: [...(previous?.eventIds ?? []), eventId].slice(-config.value.inspectorEventTailLimit),
|
||||
changedAtMs: Date.now()
|
||||
};
|
||||
entities.value = { ...entities.value, [runId]: next };
|
||||
orderedIds.value = [runId, ...orderedIds.value.filter((id) => id !== runId)];
|
||||
trimEntities();
|
||||
lastEventAt.value = createdAt ?? new Date().toISOString();
|
||||
nowMs.value = Date.now();
|
||||
}
|
||||
|
||||
function trimEntities(): void {
|
||||
if (!config.value || orderedIds.value.length <= config.value.reducerEntityLimit) return;
|
||||
const keep = orderedIds.value.slice(0, config.value.reducerEntityLimit);
|
||||
if (selectedRunId.value && !keep.includes(selectedRunId.value) && entities.value[selectedRunId.value]) keep[keep.length - 1] = selectedRunId.value;
|
||||
const next: Record<string, AgentObserverRun> = {};
|
||||
for (const id of keep) if (entities.value[id]) next[id] = entities.value[id];
|
||||
entities.value = next;
|
||||
orderedIds.value = keep;
|
||||
}
|
||||
|
||||
function selectRun(runId: string | null): void {
|
||||
selectedRunId.value = runId;
|
||||
}
|
||||
|
||||
return {
|
||||
entities, orderedIds, events, runs, visibleRuns, selectedRun, selectedEvents, selectedRunId,
|
||||
view, density, search, statusFilter, projectFilter, agentFilter, timeWindow, sort, phase, lastEventAt,
|
||||
connectedAt, error, config, projects, agents, stats, dataAgeMs,
|
||||
connect, disconnect, reconnect, selectRun
|
||||
};
|
||||
});
|
||||
|
||||
function normalizeRunStatus(runStatus: string | null, terminalStatus: string | null, eventStatus: string, terminal: boolean, previous = "unknown"): string {
|
||||
if (terminalStatus) return terminalStatus === "cancelled" ? "canceled" : terminalStatus;
|
||||
if (terminal) return eventStatus;
|
||||
if (["completed", "failed", "canceled", "cancelled"].includes(previous)) return previous;
|
||||
return runStatus ?? eventStatus ?? previous;
|
||||
}
|
||||
|
||||
function statusGroup(status: string): string {
|
||||
const value = status.toLowerCase();
|
||||
if (["failed", "error", "rejected"].includes(value)) return "failed";
|
||||
if (["completed", "succeeded", "canceled", "cancelled"].includes(value)) return "terminal";
|
||||
if (["queued", "pending", "waiting", "admitted"].includes(value)) return "waiting";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function compareRuns(left: AgentObserverRun, right: AgentObserverRun, sort: string): number {
|
||||
if (sort === "id-asc") return left.id.localeCompare(right.id);
|
||||
if (sort === "status-asc") return left.runStatus.localeCompare(right.runStatus) || left.id.localeCompare(right.id);
|
||||
const leftTime = Date.parse(left.updatedAt ?? left.startedAt ?? "") || 0;
|
||||
const rightTime = Date.parse(right.updatedAt ?? right.startedAt ?? "") || 0;
|
||||
return sort === "updated-asc" ? leftTime - rightTime : rightTime - leftTime;
|
||||
}
|
||||
|
||||
function unique(values: Array<string | null>): string[] {
|
||||
return [...new Set(values.filter((value): value is string => Boolean(value)))].sort();
|
||||
}
|
||||
|
||||
function insideTimeWindow(value: string | null, window: string): boolean {
|
||||
if (window === "all") return true;
|
||||
const duration = window === "15m" ? 15 * 60_000 : window === "1h" ? 60 * 60_000 : window === "6h" ? 6 * 60 * 60_000 : 0;
|
||||
if (!duration || !value) return false;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) && timestamp >= Date.now() - duration;
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : null;
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { useSessionsStore } from "./sessions";
|
||||
export { useHwpodStore } from "./hwpod";
|
||||
export { useCaseRunStore } from "./caserun";
|
||||
export { useAccessStore } from "./access";
|
||||
export { useAgentObserverStore } from "./agent-observer";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
.agent-observer-page { height: calc(100dvh - var(--platform-topbar-height, 56px)); min-height: 0; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); overflow: hidden; }
|
||||
.agent-observer-page .page-command-bar { padding-block: 12px; }
|
||||
.agent-observer-page .page-command-description { max-width: 74ch; }
|
||||
.agent-observer-page .page-command-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.agent-observer-page select, .agent-observer-search { min-height: 34px; border: 1px solid var(--console-border); background: var(--console-surface); color: var(--console-text); border-radius: 6px; }
|
||||
.agent-observer-page select { padding: 0 28px 0 9px; }
|
||||
.agent-observer-search { display: flex; align-items: center; gap: 7px; padding: 0 8px; min-width: min(330px, 100%); }
|
||||
.agent-observer-search input { width: 100%; min-width: 100px; border: 0; outline: 0; background: transparent; color: inherit; }
|
||||
.agent-observer-search button { border: 0; background: transparent; color: var(--console-muted); display: inline-flex; }
|
||||
.agent-observer-stream { min-height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); font-size: 12px; }
|
||||
.agent-observer-stream[data-phase="live"] svg { color: var(--console-success); }
|
||||
.agent-observer-stream[data-phase="error"], .agent-observer-stream[data-phase="stale"] { border-color: color-mix(in srgb, var(--console-danger) 45%, var(--console-border)); }
|
||||
.agent-observer-target { display: inline-flex; align-items: center; min-height: 32px; padding: 0 8px; border: 1px solid var(--console-border); border-radius: 6px; color: var(--console-muted); font-size: 11px; }
|
||||
.agent-observer-segment { display: inline-flex; border: 1px solid var(--console-border); border-radius: 6px; overflow: hidden; }
|
||||
.agent-observer-segment button, .agent-observer-density { min-height: 32px; display: inline-flex; align-items: center; gap: 5px; border: 0; border-right: 1px solid var(--console-border); padding: 0 9px; background: var(--console-surface); color: var(--console-muted); }
|
||||
.agent-observer-segment button:last-child { border-right: 0; }
|
||||
.agent-observer-segment button[aria-pressed="true"] { background: var(--console-accent-soft); color: var(--console-accent); }
|
||||
.agent-observer-density { border: 1px solid var(--console-border); border-radius: 6px; }
|
||||
.agent-observer-status-strip { min-height: 38px; display: flex; align-items: center; gap: 0; overflow-x: auto; border-block: 1px solid var(--console-border); background: var(--console-surface-muted); }
|
||||
.agent-observer-status-strip > span { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; padding: 0 13px; border-right: 1px solid var(--console-border); font-size: 12px; color: var(--console-muted); }
|
||||
.agent-observer-status-strip strong { color: var(--console-text); font-size: 14px; }
|
||||
.agent-observer-status-strip [data-tone="danger"] strong { color: var(--console-danger); }
|
||||
.agent-observer-window-note { margin-left: auto; }
|
||||
.agent-observer-inline-error { padding: 7px 13px; border-bottom: 1px solid color-mix(in srgb, var(--console-danger) 35%, var(--console-border)); background: color-mix(in srgb, var(--console-danger) 8%, var(--console-surface)); color: var(--console-danger); font-size: 12px; }
|
||||
.agent-observer-workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
|
||||
.agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); }
|
||||
.agent-observer-content { min-width: 0; min-height: 0; overflow: hidden; background: var(--console-surface); }
|
||||
.agent-observer-skeleton { height: 100%; padding: 12px; display: grid; align-content: start; gap: 7px; }
|
||||
.agent-observer-skeleton span { height: 48px; border: 1px solid var(--console-border); background: linear-gradient(90deg, var(--console-surface-muted), var(--console-surface), var(--console-surface-muted)); background-size: 200% 100%; animation: agent-observer-shimmer 1.4s linear infinite; }
|
||||
@keyframes agent-observer-shimmer { to { background-position: -200% 0; } }
|
||||
.agent-observer-empty { height: 100%; display: grid; place-content: center; justify-items: center; gap: 9px; color: var(--console-muted); text-align: center; padding: 24px; }
|
||||
.agent-observer-empty strong { color: var(--console-text); }
|
||||
.agent-observer-table { height: 100%; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.agent-observer-table-head, .agent-observer-table-row { display: grid; grid-template-columns: 110px minmax(180px, 1.25fr) minmax(120px, .8fr) minmax(180px, 1.1fr) minmax(145px, .9fr) 105px minmax(125px, .8fr) minmax(170px, 1fr); align-items: center; column-gap: 10px; min-width: 1180px; }
|
||||
.agent-observer-table-head { position: sticky; top: 0; z-index: 2; min-height: 36px; padding: 0 12px; border-bottom: 1px solid var(--console-border); background: var(--console-surface-muted); color: var(--console-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.agent-observer-table .console-virtual-list { overflow: auto; }
|
||||
.agent-observer-table-row { height: 100%; padding: 0 12px; border-bottom: 1px solid var(--console-border-subtle, var(--console-border)); cursor: default; outline: 0; }
|
||||
.agent-observer-table-row:hover, .agent-observer-table-row[data-selected="true"] { background: var(--console-accent-soft); }
|
||||
.agent-observer-table-row[data-recent="true"] { box-shadow: inset 3px 0 var(--console-accent); }
|
||||
.agent-observer-table-row > span { min-width: 0; display: grid; gap: 2px; overflow: hidden; }
|
||||
.agent-observer-table-row strong, .agent-observer-table-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-observer-table-row strong { font-size: 12px; font-weight: 650; }
|
||||
.agent-observer-table-row small { color: var(--console-muted); font-size: 11px; }
|
||||
.mono { font-family: var(--console-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
|
||||
.agent-observer-card-list { height: 100%; overflow: auto; padding: 10px; display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); align-content: start; gap: 8px; }
|
||||
.agent-observer-card { min-height: 150px; padding: 10px 12px; border: 1px solid var(--console-border); border-left: 4px solid var(--console-border-strong, var(--console-border)); background: var(--console-surface); display: grid; gap: 6px; overflow: hidden; }
|
||||
.agent-observer-card[data-status="running"] { border-left-color: var(--console-accent); }
|
||||
.agent-observer-card[data-status="failed"] { border-left-color: var(--console-danger); }
|
||||
.agent-observer-card[data-status="completed"] { border-left-color: var(--console-success); }
|
||||
.agent-observer-card[data-selected="true"] { outline: 2px solid var(--console-accent); outline-offset: -2px; }
|
||||
.agent-observer-card header, .agent-observer-card footer { display: flex; justify-content: space-between; gap: 10px; color: var(--console-muted); font-size: 11px; }
|
||||
.agent-observer-card h2, .agent-observer-card p { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-observer-card h2 { font-size: 14px; }.agent-observer-card p { color: var(--console-muted); font-size: 12px; }
|
||||
.agent-observer-card dl { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.agent-observer-card dl div { min-width: 0; }.agent-observer-card dt { color: var(--console-muted); font-size: 10px; }.agent-observer-card dd { margin: 2px 0 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-observer-card footer { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--console-border); padding-top: 6px; }
|
||||
.agent-observer-tree { height: 100%; min-height: 0; padding: 8px 0; }
|
||||
.agent-observer-tree .console-virtual-list { overflow: auto; }
|
||||
.agent-observer-tree-row { position: relative; height: calc(100% - 2px); margin: 0 10px; padding: 0 10px 0 22px; display: grid; grid-template-columns: 100px minmax(150px, 1.2fr) repeat(4, minmax(110px, .8fr)) minmax(120px, 1fr); align-items: center; gap: 9px; border-bottom: 1px solid var(--console-border); font-size: 12px; min-width: 1000px; }
|
||||
.agent-observer-tree-row:hover, .agent-observer-tree-row[aria-selected="true"] { background: var(--console-accent-soft); }
|
||||
.agent-observer-tree-row .tree-line { position: absolute; left: 8px; top: 0; bottom: 0; width: 10px; border-left: 1px solid var(--console-border-strong); border-bottom: 1px solid var(--console-border-strong); }
|
||||
.agent-observer-tree-row > span:not(.tree-line), .agent-observer-tree-row strong, .agent-observer-tree-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-observer-inspector { min-height: 0; border-left: 1px solid var(--console-border); }
|
||||
.agent-observer-inspector-grid { margin: 0; display: grid; gap: 0; }
|
||||
.agent-observer-inspector-grid div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--console-border); }
|
||||
.agent-observer-inspector-grid dt { color: var(--console-muted); font-size: 11px; }.agent-observer-inspector-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.agent-observer-event-tail { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
|
||||
.agent-observer-event-tail li { display: grid; grid-template-columns: 70px 1fr; gap: 4px 8px; padding: 9px 0; border-bottom: 1px solid var(--console-border); }
|
||||
.agent-observer-event-tail span { color: var(--console-muted); font-size: 10px; }.agent-observer-event-tail strong { font-size: 11px; }.agent-observer-event-tail small { grid-column: 2; color: var(--console-muted); overflow-wrap: anywhere; }
|
||||
@media (max-width: 1200px) { .agent-observer-card-list { grid-template-columns: repeat(2, minmax(240px, 1fr)); } }
|
||||
@media (max-width: 960px) { .agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr); }.agent-observer-inspector { position: fixed; z-index: 40; inset: 64px 0 0 auto; width: min(420px, 92vw); background: var(--console-surface); box-shadow: -12px 0 36px rgb(0 0 0 / .16); }.agent-observer-page .page-command-filters { display: grid; grid-template-columns: minmax(210px, 1fr) repeat(3, minmax(130px, auto)); }.agent-observer-search { min-width: 0; } }
|
||||
@media (max-width: 560px) { .agent-observer-page { height: calc(100dvh - 52px); }.agent-observer-page .page-command-description, .agent-observer-target { display: none; }.agent-observer-page .page-command-filters { grid-template-columns: minmax(0, 1fr) auto; }.agent-observer-page select:nth-of-type(n+2), .agent-observer-density, .agent-observer-segment span { display: none; }.agent-observer-status-strip > span:nth-child(n+5) { display: none; }.agent-observer-workspace.has-inspector { display: block; }.agent-observer-inspector { inset: auto 0 0; width: 100%; height: min(68dvh, 620px); border-left: 0; border-top: 1px solid var(--console-border); box-shadow: 0 -14px 36px rgb(0 0 0 / .18); }.agent-observer-card-list { grid-template-columns: minmax(0, 1fr); }.agent-observer-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }.agent-observer-tree-row { grid-template-columns: 90px minmax(150px, 1fr) minmax(120px, .8fr); min-width: 520px; }.agent-observer-tree-row span:nth-of-type(n+3) { display: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .agent-observer-skeleton span { animation: none; }.agent-observer-table-row[data-recent="true"] { box-shadow: inset 2px 0 var(--console-accent); } }
|
||||
+15
@@ -67,6 +67,21 @@ declare global {
|
||||
workbenchRealtimeErrorGapFillMinMs?: number;
|
||||
};
|
||||
};
|
||||
agentObserver?: {
|
||||
nodeId?: string;
|
||||
lane?: string;
|
||||
reconnectDelayMs?: number;
|
||||
batchMaxEvents?: number;
|
||||
liveBufferLimit?: number;
|
||||
reducerEventLimit?: number;
|
||||
reducerEntityLimit?: number;
|
||||
treeNodeLimit?: number;
|
||||
inspectorEventTailLimit?: number;
|
||||
virtualListWindowLimit?: number;
|
||||
changeHighlightMs?: number;
|
||||
staleAfterMs?: number;
|
||||
reducedMotion?: "respect-system";
|
||||
};
|
||||
opencode?: {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Activity, Columns3, LayoutGrid, List, Network, RefreshCw, Search, X } from "lucide-vue-next";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import InspectorPanel from "@/components/console/InspectorPanel.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import VirtualList from "@/components/common/VirtualList.vue";
|
||||
import { useAgentObserverStore, type AgentObserverRun, type AgentObserverView } from "@/stores/agent-observer";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const observer = useAgentObserverStore();
|
||||
const inspectorTab = ref<"summary" | "events" | "lineage">("summary");
|
||||
const urlApplying = ref(false);
|
||||
const windowedRuns = computed(() => observer.visibleRuns.slice(0, observer.config?.virtualListWindowLimit ?? 0));
|
||||
const treeRuns = computed(() => windowedRuns.value.slice(0, observer.config?.treeNodeLimit ?? 0));
|
||||
const ageLabel = computed(() => formatAge(observer.dataAgeMs));
|
||||
const streamLabel = computed(() => ({ idle: "未连接", replay: "回放", live: "Live", reconnecting: "重连中", stale: "陈旧", error: "传输错误" }[observer.phase]));
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteState();
|
||||
observer.connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => observer.disconnect());
|
||||
|
||||
watch(() => route.fullPath, applyRouteState);
|
||||
watch(
|
||||
() => [observer.view, observer.density, observer.search, observer.statusFilter, observer.projectFilter, observer.agentFilter, observer.timeWindow, observer.sort, observer.selectedRunId],
|
||||
() => { if (!urlApplying.value) void syncUrl(); }
|
||||
);
|
||||
|
||||
function applyRouteState(): void {
|
||||
urlApplying.value = true;
|
||||
observer.view = parseView(route.query.view);
|
||||
observer.density = route.query.density === "standard" ? "standard" : "compact";
|
||||
observer.search = textQuery(route.query.q);
|
||||
observer.statusFilter = textQuery(route.query.status) || "all";
|
||||
observer.projectFilter = textQuery(route.query.project) || "all";
|
||||
observer.agentFilter = textQuery(route.query.agent) || "all";
|
||||
observer.timeWindow = textQuery(route.query.window) || "all";
|
||||
observer.sort = textQuery(route.query.sort) || "updated-desc";
|
||||
observer.selectRun(typeof route.params.runId === "string" ? route.params.runId : null);
|
||||
queueMicrotask(() => { urlApplying.value = false; });
|
||||
}
|
||||
|
||||
async function syncUrl(): Promise<void> {
|
||||
const query = compactQuery({
|
||||
view: observer.view,
|
||||
density: observer.density,
|
||||
q: observer.search,
|
||||
status: observer.statusFilter === "all" ? "" : observer.statusFilter,
|
||||
project: observer.projectFilter === "all" ? "" : observer.projectFilter,
|
||||
agent: observer.agentFilter === "all" ? "" : observer.agentFilter,
|
||||
window: observer.timeWindow === "all" ? "" : observer.timeWindow,
|
||||
sort: observer.sort === "updated-desc" ? "" : observer.sort
|
||||
});
|
||||
const location = observer.selectedRunId
|
||||
? { name: "AgentRunDetail", params: { runId: observer.selectedRunId }, query }
|
||||
: { name: "AgentRuns", query };
|
||||
await router.replace(location);
|
||||
}
|
||||
|
||||
function selectRun(run: AgentObserverRun): void {
|
||||
observer.selectRun(run.id);
|
||||
}
|
||||
|
||||
function openRun(run: AgentObserverRun): void {
|
||||
observer.selectRun(run.id);
|
||||
void syncUrl();
|
||||
}
|
||||
|
||||
function closeInspector(): void {
|
||||
observer.selectRun(null);
|
||||
}
|
||||
|
||||
function setView(value: AgentObserverView): void {
|
||||
observer.view = value;
|
||||
}
|
||||
|
||||
function rowKey(run: AgentObserverRun): string {
|
||||
return run.id;
|
||||
}
|
||||
|
||||
function statusTone(status: string): string {
|
||||
if (["failed", "error", "rejected"].includes(status)) return "failed";
|
||||
if (["completed", "succeeded"].includes(status)) return "completed";
|
||||
if (["canceled", "cancelled"].includes(status)) return "canceled";
|
||||
if (["queued", "pending", "waiting", "admitted"].includes(status)) return "pending";
|
||||
return "running";
|
||||
}
|
||||
|
||||
function shortId(value: string | null, fallback = "—"): string {
|
||||
if (!value) return fallback;
|
||||
return value.length > 24 ? `${value.slice(0, 11)}…${value.slice(-8)}` : value;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
function duration(run: AgentObserverRun): string {
|
||||
if (!run.startedAt) return "—";
|
||||
const end = ["completed", "failed", "canceled", "cancelled"].includes(run.runStatus) && run.updatedAt ? Date.parse(run.updatedAt) : Date.now();
|
||||
return formatAge(Math.max(0, end - Date.parse(run.startedAt)));
|
||||
}
|
||||
|
||||
function formatAge(value: number | null): string {
|
||||
if (value === null || !Number.isFinite(value)) return "无数据";
|
||||
if (value < 1000) return "刚刚";
|
||||
if (value < 60_000) return `${Math.floor(value / 1000)} 秒`;
|
||||
if (value < 3_600_000) return `${Math.floor(value / 60_000)} 分钟`;
|
||||
return `${Math.floor(value / 3_600_000)} 小时`;
|
||||
}
|
||||
|
||||
function parseView(value: unknown): AgentObserverView {
|
||||
return value === "cards" || value === "tree" ? value : "table";
|
||||
}
|
||||
|
||||
function textQuery(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function compactQuery(value: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="agent-observer-page" :data-density="observer.density">
|
||||
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="纯 Kafka retention replay → live;表格、卡片、关系树和 Inspector 共享同一 reducer、查询与选择。" sticky>
|
||||
<template #actions>
|
||||
<span class="agent-observer-stream" :data-phase="observer.phase" aria-live="polite">
|
||||
<Activity :size="15" aria-hidden="true" />
|
||||
<strong>{{ streamLabel }}</strong>
|
||||
<span>{{ ageLabel }}</span>
|
||||
</span>
|
||||
<span v-if="observer.config" class="agent-observer-target mono">{{ observer.config.nodeId }}/{{ observer.config.lane }}</span>
|
||||
<button class="btn btn-secondary" type="button" @click="observer.reconnect"><RefreshCw :size="15" aria-hidden="true" />重连</button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<label class="agent-observer-search">
|
||||
<Search :size="16" aria-hidden="true" />
|
||||
<span class="sr-only">搜索 run、task 或 session</span>
|
||||
<input v-model="observer.search" type="search" placeholder="搜索 run / task / session / error" />
|
||||
<button v-if="observer.search" type="button" title="清除搜索" aria-label="清除搜索" @click="observer.search = ''"><X :size="14" /></button>
|
||||
</label>
|
||||
<select v-model="observer.statusFilter" aria-label="运行状态">
|
||||
<option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option>
|
||||
</select>
|
||||
<select v-model="observer.projectFilter" aria-label="项目">
|
||||
<option value="all">全部项目</option><option v-for="project in observer.projects" :key="project" :value="project">{{ project }}</option>
|
||||
</select>
|
||||
<select v-model="observer.agentFilter" aria-label="Agent 或 Profile">
|
||||
<option value="all">全部 Agent</option><option v-for="agent in observer.agents" :key="agent" :value="agent">{{ agent }}</option>
|
||||
</select>
|
||||
<select v-model="observer.timeWindow" aria-label="时间窗口"><option value="all">全部窗口</option><option value="15m">最近 15 分钟</option><option value="1h">最近 1 小时</option><option value="6h">最近 6 小时</option></select>
|
||||
<select v-model="observer.sort" aria-label="排序">
|
||||
<option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">Run ID</option>
|
||||
</select>
|
||||
<div class="agent-observer-segment" role="group" aria-label="视图">
|
||||
<button type="button" :aria-pressed="observer.view === 'table'" title="表格" @click="setView('table')"><List :size="16" /><span>表格</span></button>
|
||||
<button type="button" :aria-pressed="observer.view === 'cards'" title="卡片" @click="setView('cards')"><LayoutGrid :size="16" /><span>卡片</span></button>
|
||||
<button type="button" :aria-pressed="observer.view === 'tree'" title="关系树" @click="setView('tree')"><Network :size="16" /><span>树</span></button>
|
||||
</div>
|
||||
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
|
||||
</template>
|
||||
</PageCommandBar>
|
||||
|
||||
<div class="agent-observer-status-strip" aria-label="运行摘要">
|
||||
<span><strong>{{ observer.stats.running }}</strong>运行中</span>
|
||||
<span><strong>{{ observer.stats.waiting }}</strong>等待</span>
|
||||
<span data-tone="danger"><strong>{{ observer.stats.failed }}</strong>失败</span>
|
||||
<span><strong>{{ observer.stats.terminal }}</strong>终态</span>
|
||||
<span><strong>{{ observer.stats.runners }}</strong>Runner</span>
|
||||
<span><strong>{{ observer.stats.total }}</strong>筛选结果</span>
|
||||
<span v-if="windowedRuns.length < observer.visibleRuns.length" class="agent-observer-window-note">DOM 窗口 {{ windowedRuns.length }}/{{ observer.visibleRuns.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="observer.error" class="agent-observer-inline-error" role="status">{{ observer.error }}</div>
|
||||
|
||||
<div class="agent-observer-workspace" :class="{ 'has-inspector': observer.selectedRun }">
|
||||
<main class="agent-observer-content">
|
||||
<div v-if="observer.phase === 'replay' && observer.runs.length === 0" class="agent-observer-skeleton" aria-label="正在回放 Kafka retention">
|
||||
<span v-for="index in 8" :key="index" />
|
||||
</div>
|
||||
<div v-else-if="windowedRuns.length === 0" class="agent-observer-empty">
|
||||
<Activity :size="28" aria-hidden="true" />
|
||||
<strong>当前事件窗口没有匹配运行</strong>
|
||||
<span>保留最后确认投影;可清除筛选或等待 Kafka live 事件。</span>
|
||||
<button class="btn btn-secondary" type="button" @click="observer.search = ''; observer.statusFilter = 'all'; observer.projectFilter = 'all'; observer.agentFilter = 'all'">清除筛选</button>
|
||||
</div>
|
||||
|
||||
<section v-else-if="observer.view === 'table'" class="agent-observer-table" role="grid" aria-label="AgentRun 运行表格">
|
||||
<div class="agent-observer-table-head" role="row">
|
||||
<span role="columnheader">状态</span><span role="columnheader">运行</span><span role="columnheader">Agent</span><span role="columnheader">上下文</span><span role="columnheader">当前阶段</span><span role="columnheader">活动</span><span role="columnheader">Runner</span><span role="columnheader">结果</span>
|
||||
</div>
|
||||
<VirtualList :items="windowedRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 54 : 68" height="100%" :overscan="8" aria-label="AgentRun 虚拟表格行">
|
||||
<template #default="{ item: run }">
|
||||
<div class="agent-observer-table-row" role="row" tabindex="0" :aria-selected="observer.selectedRunId === run.id" :data-selected="observer.selectedRunId === run.id" :data-recent="Date.now() - run.changedAtMs < (observer.config?.changeHighlightMs ?? 0)" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)">
|
||||
<span role="gridcell"><StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" /></span>
|
||||
<span role="gridcell"><strong class="mono">{{ shortId(run.id) }}</strong><small>{{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.attemptId, "attempt —") }}</small></span>
|
||||
<span role="gridcell"><strong>{{ run.backend || run.providerId || "—" }}</strong><small>{{ run.commandType || "AgentRun" }}</small></span>
|
||||
<span role="gridcell"><strong>{{ run.projectId || "—" }}</strong><small :title="run.workspace || ''">{{ shortId(run.workspace) }} · {{ shortId(run.sessionId) }}</small></span>
|
||||
<span role="gridcell"><strong>{{ run.phase }}</strong><small>command {{ run.commandState || "未提供" }}</small></span>
|
||||
<span role="gridcell"><strong>{{ duration(run) }}</strong><small>{{ formatTime(run.updatedAt) }}</small></span>
|
||||
<span role="gridcell"><strong class="mono">{{ shortId(run.runnerId) }}</strong><small>{{ shortId(run.commandId) }}</small></span>
|
||||
<span role="gridcell"><strong>{{ run.errorCode || run.result || "—" }}</strong><small :title="run.lastMessage || ''">{{ shortId(run.lastMessage) }}</small></span>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualList>
|
||||
</section>
|
||||
|
||||
<section v-else-if="observer.view === 'cards'" class="agent-observer-card-list" aria-label="AgentRun 卡片">
|
||||
<article v-for="run in windowedRuns" :key="run.id" class="agent-observer-card" tabindex="0" :data-status="statusTone(run.runStatus)" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @keydown.enter="openRun(run)">
|
||||
<header><StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" /><span>{{ duration(run) }}</span></header>
|
||||
<h2 class="mono">{{ shortId(run.id) }}</h2>
|
||||
<p>{{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.sessionId) }} · {{ run.projectId || "—" }}</p>
|
||||
<dl><div><dt>Agent</dt><dd>{{ run.backend || run.providerId || "—" }}</dd></div><div><dt>Runner</dt><dd class="mono">{{ shortId(run.runnerId) }}</dd></div><div><dt>阶段</dt><dd>{{ run.phase }}</dd></div><div><dt>活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div></dl>
|
||||
<footer>{{ run.errorCode || run.lastMessage || "等待后续 Kafka 事件" }}</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="agent-observer-tree" role="tree" aria-label="AgentRun 关系树">
|
||||
<VirtualList :items="treeRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 44 : 54" height="100%" :overscan="8" aria-label="AgentRun 关系树节点">
|
||||
<template #default="{ item: run }">
|
||||
<div class="agent-observer-tree-row" role="treeitem" :aria-selected="observer.selectedRunId === run.id" tabindex="0" @click="selectRun(run)" @keydown.enter="openRun(run)">
|
||||
<span class="tree-line" aria-hidden="true" />
|
||||
<StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" />
|
||||
<strong class="mono">{{ shortId(run.id) }}</strong>
|
||||
<span>task {{ shortId(run.taskId) }}</span><span>command {{ shortId(run.commandId) }}</span><span>attempt {{ shortId(run.attemptId) }}</span><span>runner {{ shortId(run.runnerId) }}</span><small>{{ run.phase }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualList>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<InspectorPanel v-if="observer.selectedRun" class="agent-observer-inspector" :title="shortId(observer.selectedRun.id)" :subtitle="`${observer.selectedRun.runStatus} · ${observer.selectedRun.phase}`" @close="closeInspector">
|
||||
<template #tabs>
|
||||
<button type="button" role="tab" :aria-selected="inspectorTab === 'summary'" @click="inspectorTab = 'summary'">摘要</button>
|
||||
<button type="button" role="tab" :aria-selected="inspectorTab === 'events'" @click="inspectorTab = 'events'">事件</button>
|
||||
<button type="button" role="tab" :aria-selected="inspectorTab === 'lineage'" @click="inspectorTab = 'lineage'">Lineage</button>
|
||||
</template>
|
||||
<dl v-if="inspectorTab === 'summary'" class="agent-observer-inspector-grid">
|
||||
<div><dt>Run</dt><dd class="mono">{{ observer.selectedRun.id }}</dd></div><div><dt>Task</dt><dd class="mono">{{ observer.selectedRun.taskId || "未提供" }}</dd></div><div><dt>Command</dt><dd class="mono">{{ observer.selectedRun.commandId || "—" }}</dd></div><div><dt>Attempt</dt><dd class="mono">{{ observer.selectedRun.attemptId || "—" }}</dd></div><div><dt>Runner</dt><dd class="mono">{{ observer.selectedRun.runnerId || "—" }}</dd></div><div><dt>Agent/Profile</dt><dd>{{ observer.selectedRun.backend || observer.selectedRun.providerId || "—" }}</dd></div><div><dt>Project</dt><dd>{{ observer.selectedRun.projectId || "—" }}</dd></div><div><dt>Workspace</dt><dd class="mono">{{ observer.selectedRun.workspace || "事件未提供" }}</dd></div><div><dt>结果</dt><dd>{{ observer.selectedRun.errorCode || observer.selectedRun.result || "运行中" }}</dd></div>
|
||||
</dl>
|
||||
<ol v-else-if="inspectorTab === 'events'" class="agent-observer-event-tail">
|
||||
<li v-for="event in observer.selectedEvents" :key="event.id"><span>{{ formatTime(event.createdAt) }}</span><strong>{{ event.label }}</strong><small>{{ event.message || event.status }}</small></li>
|
||||
</ol>
|
||||
<dl v-else class="agent-observer-inspector-grid">
|
||||
<div><dt>Session</dt><dd class="mono">{{ observer.selectedRun.sessionId || "—" }}</dd></div><div><dt>Trace</dt><dd class="mono">{{ observer.selectedRun.traceId || "—" }}</dd></div><div><dt>Run</dt><dd class="mono">{{ observer.selectedRun.id }}</dd></div><div><dt>Command</dt><dd class="mono">{{ observer.selectedRun.commandId || "—" }}</dd></div><div><dt>Source seq</dt><dd>{{ observer.selectedRun.lastSourceSeq ?? "—" }}</dd></div><div><dt>Authority</dt><dd class="mono">hwlab.event.v1</dd></div>
|
||||
</dl>
|
||||
</InspectorPanel>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user