This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/ai-history-and-enhancement in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 2bed95114530100ee2ef88d5447455008ab9ccf0 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 19:48:49 2026 +0800 refactor(ai): drop show_service_list — no live-tab handoff, no drift The last capture-replay gap the scan found: show_service_list mounted a card that window.open'd the live layer page, so what the operator saw would drift from what the agent read. Remove it and its now-dead subpage machinery entirely (it was the only sub-page tool): the SUBPAGES tool, SubPageSpec / SubPageKind, the 'subpage' SSE + block, emitSubPage (figure-buffer/context/ chat), the transcript dispatch + history summary arm, ChatSubPageBlock.vue, and the yaml + skills/system prompt mentions. The agent already renders a layer's services with their metrics from captured tools — list_services (the roster) + show_widget / show_line (frozen figures) — so nothing is lost and there is no live-tab drift. Every AI-rendered answer is now a captured, replay-badged snapshot, or an honest text read-out when there is no data to capture. --- apps/bff/src/ai/context.ts | 5 -- apps/bff/src/ai/figure-buffer.ts | 10 --- apps/bff/src/ai/http/chat.ts | 2 - apps/bff/src/ai/resources/prompts/skills.md | 3 - apps/bff/src/ai/resources/prompts/system.md | 4 +- apps/bff/src/ai/resources/tools/visualization.yaml | 11 --- apps/bff/src/ai/skill/visualization/tools.ts | 36 +-------- apps/bff/src/ai/types.ts | 15 ---- apps/ui/src/ai/ChatSubPageBlock.vue | 93 ---------------------- apps/ui/src/ai/ChatTranscript.vue | 2 - apps/ui/src/ai/types.ts | 16 ---- apps/ui/src/ai/useAiConversations.ts | 3 - 12 files changed, 3 insertions(+), 197 deletions(-) diff --git a/apps/bff/src/ai/context.ts b/apps/bff/src/ai/context.ts index 88f776b..2516bb2 100644 --- a/apps/bff/src/ai/context.ts +++ b/apps/bff/src/ai/context.ts @@ -42,7 +42,6 @@ import type { ProposalSpec, BrowserErrorsSpec, LogsSpec, - SubPageSpec, TopologySpec, TracesSpec, ZipkinTracesSpec, @@ -70,10 +69,6 @@ export interface AiRequestContext { * tab-based" UX); without a group, any pending group is flushed first and * this figure emits on its own. The running figure number is owned here. */ emitFigure(fig: { title?: string; figures: ChatFigure[]; group?: string }): void; - /** Mount an embeddable feature view (topology/deployment/service-list) inline - * in the chat. Params only — the UI view fetches its own data over the chat's - * range; verb-gated in the tool like every other read. */ - emitSubPage(spec: SubPageSpec): void; /** Propose a mutating action (profiling) as a decision card — the agent never * fires it; the user approves/dismisses in the UI, which then calls the * existing verb-gated create route. */ diff --git a/apps/bff/src/ai/figure-buffer.ts b/apps/bff/src/ai/figure-buffer.ts index af5a278..7fa7e44 100644 --- a/apps/bff/src/ai/figure-buffer.ts +++ b/apps/bff/src/ai/figure-buffer.ts @@ -36,7 +36,6 @@ import type { SseEvent, BrowserErrorsSpec, LogsSpec, - SubPageSpec, TopologySpec, TracesSpec, ZipkinTracesSpec, @@ -44,9 +43,6 @@ import type { export interface FigureBuffer { emitFigure(fig: { title?: string; figures: ChatFigure[]; group?: string }): void; - /** Emit a sub-page block (topology/deployment/…). Shares the figure number - * sequence; closes any pending figure group first so ordering is preserved. */ - emitSubPage(spec: SubPageSpec): void; /** Emit a proposed-action decision card. Same numbering; flushes first. */ emitProposal(spec: ProposalSpec): void; /** Emit a captured profiling-result (flame) block. Same numbering; flushes first. */ @@ -99,11 +95,6 @@ export function createFigureBuffer(send: (ev: SseEvent) => void): FigureBuffer { } }; - const emitSubPage = (spec: SubPageSpec): void => { - flushFigures(); - send({ type: 'subpage', n: ++figureN, spec }); - }; - const emitProposal = (spec: ProposalSpec): void => { flushFigures(); send({ type: 'proposal', n: ++figureN, spec }); @@ -171,7 +162,6 @@ export function createFigureBuffer(send: (ev: SseEvent) => void): FigureBuffer { return { emitFigure, - emitSubPage, emitProposal, emitProfiling, emitProcessTopology, diff --git a/apps/bff/src/ai/http/chat.ts b/apps/bff/src/ai/http/chat.ts index 49e91f1..517faaa 100644 --- a/apps/bff/src/ai/http/chat.ts +++ b/apps/bff/src/ai/http/chat.ts @@ -162,7 +162,6 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v const { emitFigure, - emitSubPage, emitProposal, emitProfiling, emitProcessTopology, @@ -188,7 +187,6 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v bulkSize: cfg.performance.bulk.dashboard.bulkSize, hasVerb: (verb) => sessionHasVerb(cfg, roles, verb), emitFigure, - emitSubPage, emitProposal, emitProfiling, emitProcessTopology, diff --git a/apps/bff/src/ai/resources/prompts/skills.md b/apps/bff/src/ai/resources/prompts/skills.md index 576ee12..bbebee5 100644 --- a/apps/bff/src/ai/resources/prompts/skills.md +++ b/apps/bff/src/ai/resources/prompts/skills.md @@ -66,9 +66,6 @@ INLINE VIEW TOOLS — mount a REAL feature view inline (read-only, focused on th - show_instance_topology(layer, sourceService, destService) — the per-PAIR INSTANCE map inline: the instances of a SOURCE (client) service and a DEST (server) service as two columns with the instance-to-instance calls BETWEEN them (the topology tab's edge drill-down). Needs BOTH services and they must have a call relationship (source calls dest) — resolve two service names. Use it for "how do service A's instances talk to service B's"; contrast show_deployment (one service's own instance [...] - show_endpoint_dependency(layer, service) — the per-ENDPOINT API-dependency chain inline: the service's PRIMARY endpoint (auto-picked) and its upstream callers + downstream callees across services, the real API-dependency-tab view (expand any node). This is API-DEPENDENCY detection (endpoint→endpoint). It reads EndpointRelation, which is SERVER-SIDE ONLY (no client edge) and comes from NATIVE SkyWalking tracing — so it only has data on a native-trace layer; on a Zipkin/mesh layer (check [...] -SUB-PAGE TOOLS — mount an interactive feature view as a card (opens the full page in a new tab): -- show_service_list(layer) — the services in a layer with their key metrics. - TRIGGERS skill — gated profiling (propose_profiling) + analysis (analyze_profiling) - You are READ-ONLY except for propose_profiling. When metrics + pod logs cannot localise a cause and a PROFILE would confirm a specific hypothesis, call propose_profiling — it presents a decision card (your analysed cause, why profiling, what you expect); the USER approves it in a popout; it runs only after approval; you analyse the result in a LATER turn. Never assume it ran, and never fabricate its output. - Pick the profilingType by READING, not assuming: kb_layer_capabilities(layer).components says which of trace/async/pprof/ebpf/network the layer supports; async-profiler is JVM-only and pprof is Go-only (read the instance language via kb_resolve_scope_drill to choose), trace + eBPF are language-agnostic, network profiling yields a process-conversation graph. The tool re-validates the type against the layer and resolves target instances. diff --git a/apps/bff/src/ai/resources/prompts/system.md b/apps/bff/src/ai/resources/prompts/system.md index 0752e96..8a0b11d 100644 --- a/apps/bff/src/ai/resources/prompts/system.md +++ b/apps/bff/src/ai/resources/prompts/system.md @@ -27,7 +27,7 @@ INVESTIGATION LOOP — for a TROUBLESHOOTING / diagnostic question (a direct req 0. Health triage — for "what is unhealthy / wrong?", start with list_alarms (TELEMETRY): active alarms name the anomaly entity; then explain its metrics. 1. Orient — find the layer + service with list_layers / list_services (CONTEXT); search by name with NO layer when the layer is unknown. For a layer you'll work in, kb_layer_capabilities(layer) gives its vocabulary, components, trace source, and relation metric legends up front — read it in the layer's own terms. 2. Find metrics — BEFORE rendering any metric figure, kb_browse_catalog the EXACT layer+scope you are about to render, and use ONLY an entry from THAT response — its catalog title AND its MQE, both VERBATIM (METRIC-CATALOG). Re-browse for every layer/scope; never reuse a name or MQE you saw in another layer or recall from memory (e.g. do NOT render "HTTP Response Time" in MESH — that title lives in K8S_SERVICE; MESH's is "Avg Response Time"). A made-up, renamed, or wrong-layer/scope metr [...] -3. Render — display a metric with the right show_* widget (chosen by MQE shape) or a whole-view sub-page (VISUALIZATION — see the RENDER GUIDE for the widgets, scopes and limits). +3. Render — display a metric with the right show_* widget (chosen by MQE shape) or a whole feature view inline (VISUALIZATION — see the RENDER GUIDE for the widgets, scopes and limits). 4. Drill — no cross-scope rollup: kb_resolve_scope_drill to the child ids, then render at that finer scope (METRIC-CATALOG). 5. Read pod logs — for a Kubernetes workload, fetch_pod_logs is the error stack (KUBERNETES). @@ -35,7 +35,7 @@ ROOT-CAUSE METHOD For any "why is X wrong / what is the root cause" question, call list_playbooks and get_playbook the best-matching one, then FOLLOW its steps. The method in brief: find the ROOT SERVICE (walk upstream with show_topology; fix a sick upstream first; a Virtual_* remote gives only a client-side edge metric; use kb_resolve_hierarchy to cross a layer boundary into an infra/database layer, where memory / disk / connection causes live) → then the CALLING CHAIN (kb_resolve_scope_drill to the outl [...] INTERACTION MODES — tools resolve in three different ways; handle each correctly: -- AUTO-QUERY — most tools (list_*, kb_*, the show_* metric figures, show_topology / deployment / service_list, list_alarms): they run and return data immediately. Read the result and narrate. +- AUTO-QUERY — most tools (list_*, kb_*, the show_* metric figures, show_topology / deployment, list_alarms): they run and return data immediately. Read the result and narrate. - WAIT-FOR-CONFIRMATION — propose_profiling: you PROPOSE a task as a decision card; it runs ONLY after the user approves it in a popout; you analyse the result in a LATER turn. Never assume it ran, and never fabricate its output. - WAIT-FOR-COLLECTION (fixed-time) — an approved profile is gathered OVER TIME: it runs for its duration and you analyse the result in a LATER turn (never assume it's done). On-demand pod logs are NOT this — fetch_pod_logs returns the current window's lines IMMEDIATELY, so analyse what came back right away; it shows those lines to the operator inline as a read-only result (not a live tail). If that first snapshot is empty, WIDEN the window and retry before concluding the pod is silent — [...] diff --git a/apps/bff/src/ai/resources/tools/visualization.yaml b/apps/bff/src/ai/resources/tools/visualization.yaml index 375c62a..3f6b41a 100644 --- a/apps/bff/src/ai/resources/tools/visualization.yaml +++ b/apps/bff/src/ai/resources/tools/visualization.yaml @@ -89,17 +89,6 @@ show_widget: group: >- cluster related figures into one tabbed block -show_service_list: - description: >- - Render the services in a layer with their key metrics inline in the chat as an interactive view (the same component the dashboards use). Provide the layer (and optionally a service to focus). - params: - layer: >- - OAP layer key, e.g. GENERAL - service: >- - optional service NAME to focus on - title: >- - optional heading for the view - show_hierarchy: description: >- Render a service's CROSS-LAYER hierarchy inline (the topology page's Smartscape overlay): the focused service plus the same logical service projected into its upper layers (e.g. a GENERAL service's MESH / K8S_SERVICE mirrors) and lower layers (backing infrastructure). Use this to show how one service maps across layers — NOT for same-layer dependencies (use show_topology for those). Provide the layer and service name. diff --git a/apps/bff/src/ai/skill/visualization/tools.ts b/apps/bff/src/ai/skill/visualization/tools.ts index 10eee0d..6209990 100644 --- a/apps/bff/src/ai/skill/visualization/tools.ts +++ b/apps/bff/src/ai/skill/visualization/tools.ts @@ -38,7 +38,7 @@ import type { ZipkinTraceListResponse, } from '@skywalking-horizon-ui/api-client'; import type { AiRequestContext } from '../../context.js'; -import type { HierarchyGroup, SubPageKind, TopoPeer } from '../../types.js'; +import type { HierarchyGroup, TopoPeer } from '../../types.js'; import { runWidgets } from '../../../logic/dashboard/run.js'; import { resolveEffectiveLayer } from '../../../logic/layers/effective.js'; import { @@ -387,39 +387,6 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa }, ); - // Sub-page tools mount an embeddable feature VIEW inline (not a widget). They - // carry only params — the UI view fetches its own data over the chat's range. - const SUBPAGES: Array<{ - kind: SubPageKind; - verb: string; - needsService: boolean; - }> = [ - { kind: 'service-list', verb: 'metrics:read', needsService: false }, - ]; - const subPageTools = SUBPAGES.map(({ kind, verb, needsService }) => { - const t = toolPrompt('visualization', `show_${kind.replace(/-/g, '_')}`); - return tool( - async ({ layer, service, title }: { layer: string; service?: string; title?: string }): Promise<string> => { - if (!ctx.hasVerb(verb)) return `Permission denied: the current user lacks ${verb}.`; - if (needsService && !service) return `${kind} needs a service name.`; - const heading = title || `${kind} — ${service ?? layer}`; - ctx.emitSubPage({ kind, title: heading, layer: layer.toUpperCase(), service, range: ctx.range }); - return `Mounted the ${kind} view for ${service ?? layer}. It renders interactively in the chat.`; - }, - { - name: `show_${kind.replace(/-/g, '_')}`, - description: t.description, - schema: z.object({ - layer: z.string().describe(t.p('layer')), - service: needsService - ? z.string().describe(t.p('service')) - : z.string().optional().describe(t.p('service')), - title: z.string().optional().describe(t.p('title')), - }), - }, - ); - }); - // show_hierarchy renders the topology page's cross-layer Smartscape overlay // inline: the focus service + the same logical service projected into upper // (K8S_SERVICE ← MESH ← GENERAL) and lower (→ infra) layers. Params only — the @@ -958,7 +925,6 @@ export function visualizationTools(ctx: AiRequestContext): StructuredToolInterfa make('table'), make('record'), widgetTool, - ...subPageTools, hierarchyTool, topologyTool, deploymentTool, diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts index 2f7362e..037dabd 100644 --- a/apps/bff/src/ai/types.ts +++ b/apps/bff/src/ai/types.ts @@ -55,20 +55,6 @@ export interface ChatFigure { xaxis?: FigureXAxis; } -/** A "sub-page" figure: a feature view surfaced as a card that opens the real - * full page in a new tab. Graph/triage views (topology, deployment, traces, - * logs, browser errors) now embed inline via their own specs; this is the - * remaining link-out (the layer service list). */ -export type SubPageKind = 'service-list'; - -export interface SubPageSpec { - kind: SubPageKind; - title: string; - layer: string; - service?: string; - range: FigureXAxis; -} - /** A PROPOSED mutating action (profiling / live-debug). The agent never fires * it — it presents a decision card (what it found, why this action, what it * expects) and the user approves or dismisses in a popout. On approve the UI @@ -337,7 +323,6 @@ export type SseEvent = | { type: 'thinking'; text: string } | { type: 'tool'; name: string; status: 'running' | 'done' | 'denied' } | { type: 'figure'; n: number; title?: string; layout: FigureLayout; figures: ChatFigure[] } - | { type: 'subpage'; n: number; spec: SubPageSpec } | { type: 'proposal'; n: number; spec: ProposalSpec } | { type: 'profiling'; n: number; spec: ProfilingResultSpec } | { type: 'process-topology'; n: number; spec: ProcessTopologySpec } diff --git a/apps/ui/src/ai/ChatSubPageBlock.vue b/apps/ui/src/ai/ChatSubPageBlock.vue deleted file mode 100644 index 905fbb9..0000000 --- a/apps/ui/src/ai/ChatSubPageBlock.vue +++ /dev/null @@ -1,93 +0,0 @@ -<!-- - Licensed to the Apache Software Foundation (ASF) under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> -<!-- Sub-page figure block. Surfaces a feature view (the layer service list) the - assistant chose, and opens the REAL view (with the resolved route) in a new - tab. Graph/triage views embed inline via their own blocks; this is the - remaining link-out card. --> -<script setup lang="ts"> -import { computed } from 'vue'; -import { useRouter } from 'vue-router'; -import { useI18n } from 'vue-i18n'; -import Icon from '@/components/icons/Icon.vue'; -import type { SubPageSpec } from './types'; - -const props = defineProps<{ n: number; spec: SubPageSpec }>(); -const router = useRouter(); -const { t } = useI18n({ useScope: 'global' }); - -const ROUTE_SUFFIX: Record<SubPageSpec['kind'], string> = { - 'service-list': 'service', -}; - -const href = computed( - () => router.resolve({ path: `/layer/${props.spec.layer.toLowerCase()}/${ROUTE_SUFFIX[props.spec.kind]}` }).href, -); - -function open(): void { - window.open(href.value, '_blank', 'noopener'); -} -</script> - -<template> - <div class="csp"> - <div class="csp__cap">{{ t('Figure {n}', { n }) }} · {{ spec.title }}</div> - <button type="button" class="csp__open" :title="t('Open in a new tab')" @click="open"> - <Icon name="external" :size="13" /> - <span>{{ t('Open in a new tab') }}</span> - </button> - </div> -</template> - -<style scoped> -.csp { - border: 1px solid var(--sw-line-2); - border-radius: 8px; - background: var(--sw-bg-1); - padding: 10px 12px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} -.csp__cap { - font-size: var(--sw-fs-sm); - color: var(--sw-fg-1); - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.csp__open { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 10px; - border: 1px solid var(--sw-line-2); - border-radius: 6px; - background: var(--sw-bg-2); - color: var(--sw-fg-1); - font: inherit; - font-size: var(--sw-fs-sm); - cursor: pointer; -} -.csp__open:hover { - border-color: var(--sw-accent); - color: var(--sw-fg-0); -} -</style> diff --git a/apps/ui/src/ai/ChatTranscript.vue b/apps/ui/src/ai/ChatTranscript.vue index 19e6b2e..001eedd 100644 --- a/apps/ui/src/ai/ChatTranscript.vue +++ b/apps/ui/src/ai/ChatTranscript.vue @@ -20,7 +20,6 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import ChatFigureBlock from './ChatFigureBlock.vue'; -import ChatSubPageBlock from './ChatSubPageBlock.vue'; import ChatProposalBlock from './ChatProposalBlock.vue'; import ChatProfilingBlock from './ChatProfilingBlock.vue'; import ChatProcessTopologyBlock from './ChatProcessTopologyBlock.vue'; @@ -94,7 +93,6 @@ function fmtTime(at: number): string { <!-- eslint-disable-next-line vue/no-v-html -- renderMarkdown escapes HTML before formatting (see markdown.ts), so LLM output cannot inject --> <div v-if="b.kind === 'text'" class="tx__prose" v-html="renderMarkdown(b.text)"></div> <ChatFigureBlock v-else-if="b.kind === 'figure'" :block="b" /> - <ChatSubPageBlock v-else-if="b.kind === 'subpage'" :n="b.n" :spec="b.spec" /> <ChatProposalBlock v-else-if="b.kind === 'proposal'" :block="b" /> <ChatProfilingBlock v-else-if="b.kind === 'profiling'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatProcessTopologyBlock v-else-if="b.kind === 'process-topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts index c2b97a0..332d4a4 100644 --- a/apps/ui/src/ai/types.ts +++ b/apps/ui/src/ai/types.ts @@ -48,20 +48,6 @@ export interface ChatFigure { xaxis?: FigureXAxis; } -// A sub-page figure: a feature view surfaced as a card that opens the real full -// page in a new tab. Graph/triage views (topology, deployment, traces, logs, -// browser errors) embed inline via their own specs; this is the remaining -// link-out (the layer service list). -export type SubPageKind = 'service-list'; - -export interface SubPageSpec { - kind: SubPageKind; - title: string; - layer: string; - service?: string; - range: FigureXAxis; -} - // A proposed mutating action (profiling) — a decision card the user approves in // a popout; the agent never fires it. Carries the reasoning it must justify plus // the type-specific params the approve handler fires with. Mirror of BFF ProposalSpec. @@ -302,7 +288,6 @@ export type SseEvent = | { type: 'thinking'; text: string } | { type: 'tool'; name: string; status: 'running' | 'done' | 'denied' } | { type: 'figure'; n: number; title?: string; layout: FigureLayout; figures: ChatFigure[] } - | { type: 'subpage'; n: number; spec: SubPageSpec } | { type: 'proposal'; n: number; spec: ProposalSpec } | { type: 'profiling'; n: number; spec: ProfilingResultSpec } | { type: 'process-topology'; n: number; spec: ProcessTopologySpec } @@ -324,7 +309,6 @@ export type ProposalStatus = 'pending' | 'approved' | 'dismissed' | 'failed'; export type Block = | { kind: 'text'; text: string } | { kind: 'figure'; n: number; title?: string; layout: FigureLayout; figures: ChatFigure[]; capturedAt?: number } - | { kind: 'subpage'; n: number; spec: SubPageSpec } | { kind: 'proposal'; n: number; spec: ProposalSpec; status: ProposalStatus; taskId?: string; error?: string } | { kind: 'profiling'; n: number; spec: ProfilingResultSpec; capturedAt?: number } | { kind: 'process-topology'; n: number; spec: ProcessTopologySpec; capturedAt?: number } diff --git a/apps/ui/src/ai/useAiConversations.ts b/apps/ui/src/ai/useAiConversations.ts index c095e4f..d974839 100644 --- a/apps/ui/src/ai/useAiConversations.ts +++ b/apps/ui/src/ai/useAiConversations.ts @@ -57,8 +57,6 @@ function textOf(blocks: Block[]): string { parts.push(`(logs: ${b.spec.service})`); } else if (b.kind === 'browser-errors') { parts.push(`(browser errors: ${b.spec.service})`); - } else if (b.kind === 'subpage') { - parts.push(`(sub-page: ${b.spec.title})`); } else if (b.kind === 'proposal') { parts.push(`(suggested action: ${b.spec.kind} for ${b.spec.service})`); } @@ -239,7 +237,6 @@ async function send(text: string): Promise<void> { // Stamp the capture time so a reloaded (frozen) figure documents WHEN its // point-in-time data is from, not read as current. else if (ev.type === 'figure') assistant.blocks.push({ kind: 'figure', n: ev.n, title: ev.title, layout: ev.layout, figures: ev.figures, capturedAt: Date.now() }); - else if (ev.type === 'subpage') assistant.blocks.push({ kind: 'subpage', n: ev.n, spec: ev.spec }); else if (ev.type === 'proposal') assistant.blocks.push({ kind: 'proposal', n: ev.n, spec: ev.spec, status: 'pending' }); else if (ev.type === 'profiling') assistant.blocks.push({ kind: 'profiling', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'process-topology') assistant.blocks.push({ kind: 'process-topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() });
