diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 7d1eee05..fa20b842 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -154,7 +154,7 @@ The answer also holds: The answer is cut at 20,000 characters. A graph that is too long first keeps fewer history entries per node, then keeps the first nodes that fit. A note under the JSON says how many history entries and nodes it left out. Pass `node` or `history: false` to see the rest. -With `root` or a route path, the tool switches the page's graph to the effects of that injector and waits up to 1.5 seconds for it. If no injector matches, the answer lists the ones the page knows. On Angular 20.0, the answer says the live graph needs Angular 20.1 or later. +With `root` or a route path, the tool asks the page for the effects of that injector and waits up to 1.5 seconds for the answer. It reads that graph once and leaves the Signals page on the target it shows. If no injector matches, the answer lists the ones the page knows. On Angular 20.0, the answer says the live graph needs Angular 20.1 or later. The page reports one graph: the component picked on the Signals page or with highlight, otherwise the deepest component in the primary router outlet. Call highlight first to switch the graph to another component. Only signals a template or an effect has read appear. diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md index fd94632f..d4586328 100644 --- a/apps/docs/src/content/inspectors/signals.md +++ b/apps/docs/src/content/inspectors/signals.md @@ -152,7 +152,7 @@ Entries are sampled instead of exact in these cases: ### Switch the graph from an agent -The `pangular:highlight` tool also switches the graph to the component it highlights. Calling `pangular:inspect-signals` with `root` or a route path switches it to that injector. The picker does not show either choice. +The `pangular:highlight` tool also switches the graph to the component it highlights. The picker does not show that choice. Calling `pangular:inspect-signals` with `root` or a route path reads that injector's graph once and does not change what the Signals page shows. ### Find a stuck effect in a root service diff --git a/packages/devtools/src/__tests__/agent-tools.test.ts b/packages/devtools/src/__tests__/agent-tools.test.ts index 2044ee80..a6e39e24 100644 --- a/packages/devtools/src/__tests__/agent-tools.test.ts +++ b/packages/devtools/src/__tests__/agent-tools.test.ts @@ -273,8 +273,8 @@ describe('agent tools', () => { expect(missing).toMatch(/app-x/); }); - it('switches inspect-signals to the root injector and waits for its graph', async () => { - const { push, call } = await boot(); + it('reads a root injector graph for inspect-signals without switching the panel', async () => { + const { ctx, push, call } = await boot(); const card = { pageId: 'p1', nodes: [{ id: 'a', kind: 'signal', label: 'open', epoch: 0 }], @@ -287,23 +287,120 @@ describe('agent tools', () => { ], }; await push('push-signal-graph', card); - const answer = call('inspect-signals', 'root'); - await new Promise((resolve) => setTimeout(resolve, 20)); - await push('push-signal-graph', { - pageId: 'p1', + const rootGraph = { nodes: [{ id: 'e', kind: 'effect', label: 'persistTrips', epoch: 1 }], edges: [], injector: { id: 'inj-1', name: 'Root' }, environments: card.environments, source: 'selected', - }); - expect(JSON.parse(await answer)).toMatchObject({ + }; + const broadcast = vi.spyOn(ctx.rpc, 'broadcast'); + const asked: unknown[] = []; + broadcast.mockImplementation(async (options: { method: string; args: unknown[] }) => { + if (options.method !== 'pangular:peek-signal-graph') return; + const request = options.args[0] as { requestId: string; pageId?: string; env: string }; + asked.push(request); + setTimeout(() => { + void push('signal-graph-peek-result', { + requestId: request.requestId, + pageId: 'p1', + graph: { ...rootGraph, pageId: 'p1' }, + }); + }, 10); + }); + expect(JSON.parse(await call('inspect-signals', 'root'))).toMatchObject({ + pageId: 'p1', injector: { name: 'Root' }, nodes: [{ label: 'persistTrips' }], }); - expect(JSON.parse(await call('inspect-signals', 'inj-1'))).toMatchObject({ + expect(asked).toEqual([{ requestId: expect.any(String), env: 'root' }]); + expect(JSON.parse(await call('inspect-signals', 'inj-1', { page: 'p1' }))).toMatchObject({ injector: { id: 'inj-1' }, }); + expect(asked[1]).toMatchObject({ pageId: 'p1', env: 'inj-1' }); + // The panel keeps showing the component it showed before. + const methods = broadcast.mock.calls.map(([options]) => (options as { method: string }).method); + expect(methods).not.toContain('pangular:select-signal-component'); + const state = ( + await ( + ctx.rpc as unknown as { + sharedState: { + get: (key: string) => Promise<{ value: () => Record }>; + }; + } + ).sharedState.get('pangular:signal-graph') + ).value() as { graph: { pageId: string }; pages: Record }; + expect(state.graph).toMatchObject({ componentSelector: 'app-card' }); + expect(state.pages['p1']).toMatchObject({ componentSelector: 'app-card' }); + expect(JSON.parse(await call('inspect-signals', 'app-card'))).toMatchObject({ + component: { id: 'c2' }, + }); + }); + + it('ignores a peek answer for another request or a non-matching injector', async () => { + const { ctx, push, call } = await boot(); + await push('push-signal-graph', { + pageId: 'p1', + nodes: [], + edges: [], + componentSelector: 'app-card', + environments: [{ id: 'inj-1', name: 'Root' }], + }); + vi.spyOn(ctx.rpc, 'broadcast').mockImplementation( + async (options: { method: string; args: unknown[] }) => { + if (options.method !== 'pangular:peek-signal-graph') return; + const { requestId } = options.args[0] as { requestId: string }; + void push('signal-graph-peek-result', { requestId: 'other', pageId: 'p1', graph: null }); + void push('signal-graph-peek-result', { + requestId, + pageId: 'p1', + graph: { nodes: [], edges: [], injector: { id: 'inj-1', name: 'Root' } }, + }); + }, + ); + const missing = await call('inspect-signals', '/admin'); + expect(missing).toMatch(/No signal graph for `\/admin`/); + expect(missing).toMatch(/the page knows `Root`/); + }, 10_000); + + it('waits for a known page when a page without a signal graph answers first', async () => { + const { ctx, push, call } = await boot(); + const environments = [{ id: 'inj-1', name: 'Root' }]; + for (const pageId of ['p1', 'p2']) { + await push('push-signal-graph', { + pageId, + nodes: [], + edges: [], + componentSelector: 'app-card', + environments, + }); + } + vi.spyOn(ctx.rpc, 'broadcast').mockImplementation( + async (options: { method: string; args: unknown[] }) => { + if (options.method !== 'pangular:peek-signal-graph') return; + const { requestId } = options.args[0] as { requestId: string }; + // p3 never reported a graph; its empty answer must not end the wait. + void push('signal-graph-peek-result', { requestId, pageId: 'p3', graph: null }); + void push('signal-graph-peek-result', { requestId, pageId: 'p2', graph: null }); + setTimeout(() => { + void push('signal-graph-peek-result', { + requestId, + pageId: 'p1', + graph: { + nodes: [{ id: 'e', kind: 'effect', label: 'persistTrips', epoch: 1 }], + edges: [], + injector: { id: 'inj-1', name: 'Root' }, + environments, + }, + }); + }, 20); + }, + ); + expect(JSON.parse(await call('inspect-signals', 'root'))).toMatchObject({ + pageId: 'p1', + injector: { name: 'Root' }, + nodes: [{ label: 'persistTrips' }], + }); }); it('names the known injectors when a route path matches none', async () => { diff --git a/packages/devtools/src/__tests__/signal-peek.test.ts b/packages/devtools/src/__tests__/signal-peek.test.ts new file mode 100644 index 00000000..4030f07c --- /dev/null +++ b/packages/devtools/src/__tests__/signal-peek.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { collectSignalGraph, type SignalDebugNg, type SignalTarget } from '../signal-graph.ts'; +import { createSignalHistory } from '../signal-history.ts'; +import { PEEK_EVENT, PEEK_RESULT, registerSignalPeek, toPeekRequest } from '../signal-peek.ts'; +import { createSignalPeeks } from '../rpc/signal-peeks.ts'; + +class Root {} +class Page {} + +const platform = { scopes: new Set(['platform']) }; +const root = { scopes: new Set(['environment', 'root']) }; +const environment = new Set([platform, root]); + +/** A fake `ng` with one routed component and effects in the root injector. */ +function fakeNg(): SignalDebugNg { + const components: Record = { 'APP-ROOT': new Root(), 'APP-PAGE': new Page() }; + return { + getComponent: (el) => components[el.tagName] ?? null, + getInjector: (el) => el, + ɵgetSignalGraph: (injector) => + injector === (root as unknown) + ? { + nodes: [ + { id: '10', kind: 'effect', label: 'persistTrips', epoch: 2 }, + { id: '11', kind: 'signal', label: 'trips', epoch: 2, value: 3 }, + ], + edges: [{ consumer: 0, producer: 1 }], + } + : { + nodes: [ + { + id: '1', + kind: 'signal', + label: (injector as Element).tagName.toLowerCase(), + epoch: 1, + value: 1, + }, + ], + edges: [], + }, + ɵgetInjectorResolutionPath: (injector) => [injector, root, platform], + ɵgetInjectorMetadata: (injector) => + environment.has(injector as object) + ? { type: 'environment', source: null } + : { type: 'element', source: injector }, + }; +} + +function fakeRpc() { + const handlers = new Map void>(); + const calls: [string, unknown][] = []; + return { + handlers, + calls, + rpc: { + register: (fn: { name: string; handler: (request: unknown) => void }) => + handlers.set(fn.name, fn.handler), + call: async (name: string, arg: unknown) => { + calls.push([name, arg]); + }, + }, + }; +} + +const page = ` + + + `; + +describe('registerSignalPeek', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('answers with the root graph and leaves the selected target alone', async () => { + document.body.innerHTML = page; + const ng = fakeNg(); + const session = fakeRpc(); + const history = createSignalHistory((value) => value); + const collect = vi.fn((wanted: SignalTarget) => collectSignalGraph(ng, wanted)); + registerSignalPeek(session.rpc, 'p1', collect, history); + + session.handlers.get(PEEK_EVENT)!({ requestId: 'peek1', env: 'root' }); + await Promise.resolve(); + + // Only the injector was read; nothing re-targets the page's own graph. + expect(collect.mock.calls).toEqual([[{ env: 'root' }]]); + expect(session.handlers.has('select-signal-component')).toBe(false); + const [name, answer] = session.calls[0]!; + expect(name).toBe(PEEK_RESULT); + expect(answer).toMatchObject({ + requestId: 'peek1', + pageId: 'p1', + graph: { + pageId: 'p1', + injector: { name: 'Root' }, + nodes: [{ label: 'persistTrips' }, { label: 'trips' }], + history: { '11': [{ epoch: 2, value: 3, source: 'initial' }] }, + }, + }); + }); + + it('answers null instead of the fallback component graph when no injector matches', async () => { + document.body.innerHTML = page; + const session = fakeRpc(); + registerSignalPeek(session.rpc, 'p1', (wanted) => collectSignalGraph(fakeNg(), wanted)); + session.handlers.get(PEEK_EVENT)!({ requestId: 'peek2', env: '/nope' }); + await Promise.resolve(); + expect(session.calls).toEqual([ + [PEEK_RESULT, { requestId: 'peek2', pageId: 'p1', graph: null }], + ]); + }); + + it('ignores requests for another page and malformed requests', async () => { + document.body.innerHTML = page; + const session = fakeRpc(); + registerSignalPeek(session.rpc, 'p1', (wanted) => collectSignalGraph(fakeNg(), wanted)); + session.handlers.get(PEEK_EVENT)!({ requestId: 'peek3', env: 'root', pageId: 'p2' }); + session.handlers.get(PEEK_EVENT)!({ env: 'root' }); + session.handlers.get(PEEK_EVENT)!(null); + await Promise.resolve(); + expect(session.calls).toEqual([]); + expect(toPeekRequest({ requestId: 'r', env: 'root', pageId: 'p1' }, 'p1')).toEqual({ + requestId: 'r', + env: 'root', + }); + }); +}); + +describe('createSignalPeeks', () => { + afterEach(() => vi.useRealTimers()); + + it('resolves with the first graph from the asked page and ignores other pages', async () => { + const peeks = createSignalPeeks(); + const sent: { requestId: string; env: string; pageId?: string }[] = []; + const answer = peeks.request((message) => sent.push(message), 'root', 'p1', ['p1'], 1000); + const { requestId } = sent[0]!; + expect(sent[0]).toEqual({ requestId, env: 'root', pageId: 'p1' }); + peeks.answer({ requestId, pageId: 'p2', graph: { nodes: [], edges: [] } }); + peeks.answer({ requestId, pageId: 'p1', graph: { nodes: [{ id: 'e' }], edges: [] } }); + expect(await answer).toMatchObject({ pageId: 'p1', nodes: [{ id: 'e' }] }); + }); + + it('resolves null once every asked page answered without a graph', async () => { + const peeks = createSignalPeeks(); + let requestId = ''; + const answer = peeks.request( + (m) => (requestId = m.requestId), + 'root', + undefined, + ['p1', 'p2'], + 60_000, + ); + peeks.answer({ requestId, pageId: 'p1', graph: null }); + peeks.answer({ requestId, pageId: 'p1', graph: null }); + peeks.answer({ requestId, pageId: 'p2', graph: null }); + expect(await answer).toBeNull(); + }); + + it('does not count an empty answer from a page that was not expected', async () => { + const peeks = createSignalPeeks(); + let requestId = ''; + let settled = false; + const answer = peeks.request( + (m) => (requestId = m.requestId), + 'root', + undefined, + ['p1', 'p2'], + 60_000, + ); + void answer.then(() => (settled = true)); + peeks.answer({ requestId, pageId: 'p3', graph: null }); + peeks.answer({ requestId, pageId: 'p2', graph: null }); + await Promise.resolve(); + expect(settled).toBe(false); + peeks.answer({ requestId, pageId: 'p1', graph: { nodes: [{ id: 'e' }], edges: [] } }); + expect(await answer).toMatchObject({ pageId: 'p1', nodes: [{ id: 'e' }] }); + }); + + it('resolves null when no page answers in time', async () => { + vi.useFakeTimers(); + const peeks = createSignalPeeks(); + const answer = peeks.request(() => {}, 'root', undefined, ['p1'], 1500); + await vi.advanceTimersByTimeAsync(1500); + expect(await answer).toBeNull(); + }); +}); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index c3bf50c9..bd653fa5 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -446,6 +446,7 @@ export const RPC_INSPECTOR: Record = { 'ping-signal-graph': 'signals', 'forget-signal-page': 'signals', 'select-signal-target': 'signals', + 'signal-graph-peek-result': 'signals', 'get-providers': 'injectors', 'push-injector-tree': 'injectors', 'ping-injector-tree': 'injectors', diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index b2124a4a..ee86c9f2 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -11,6 +11,7 @@ import { trackPageSessions } from './rpc/page-sessions.ts'; import { getBuildMeta } from './rpc/build-meta.ts'; import { getSignals } from './rpc/get-signals.ts'; import { SIGNAL_TOOL_MAX, signalGraphText, type InspectSignalsArgs } from './rpc/signal-tools.ts'; +import { createSignalPeeks } from './rpc/signal-peeks.ts'; import { injectorMatches, isEnvironmentRequest } from './signal-graph.ts'; import { getProviders } from './rpc/get-providers.ts'; import { getNgrxStore, scanNgrxStore } from './rpc/get-ngrx-store.ts'; @@ -315,6 +316,7 @@ const pangular = defineDevframe({ }, }); const signalPages = new Map(); + const signalPeeks = createSignalPeeks(); const injectorTreeState = await my.rpc.sharedState('injector-tree', { initialValue: { @@ -1346,6 +1348,13 @@ const pangular = defineDevframe({ }, }); + register({ + name: 'signal-graph-peek-result', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => signalPeeks.answer(message), + }); + register({ name: 'forget-signal-page', type: 'action', @@ -1821,7 +1830,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-signals', description: - "Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; this switches the page's graph to that injector. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it. Pass `node` (a node id or label) to get only that node with its direct producers and consumers, and `history: false` to leave out value history. The answer is cut at 20,000 characters, first by keeping fewer history entries per node, then fewer nodes, with a note saying what was left out.", + "Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; the page reads that injector's graph once, without changing what the Signals page shows. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it. Pass `node` (a node id or label) to get only that node with its direct producers and consumers, and `history: false` to leave out value history. The answer is cut at 20,000 characters, first by keeping fewer history entries per node, then fewer nodes, with a note saying what was left out.", safety: 'read', inputSchema: { type: 'object', @@ -1863,15 +1872,21 @@ const pangular = defineDevframe({ ).find((entry) => matches(entry.graph))?.graph; let matched = latestMatch(); if (!matched && signalPages.size && isEnvironmentRequest(args.selector)) { - void my.rpc.broadcast({ - method: 'select-signal-component', - args: [page ? { pageId: page, env: args.selector } : { env: args.selector }], - optional: true, - }); - for (let waited = 0; !matched && waited < ENV_WAIT_MS; waited += 50) { - await new Promise((resolve) => setTimeout(resolve, 50)); - matched = latestMatch(); - } + // Ask for this injector's graph once. The page answers without + // changing its selected target, so the Signals page keeps its view. + const peeked = await signalPeeks.request( + (request) => + void my.rpc.broadcast({ + method: 'peek-signal-graph', + args: [request], + optional: true, + }), + args.selector, + page, + page ? [page] : [...signalPages.keys()], + ENV_WAIT_MS, + ); + if (peeked && matches(peeked)) matched = peeked; } const graph = matched ?? diff --git a/packages/devtools/src/overlay-angular-native.ts b/packages/devtools/src/overlay-angular-native.ts index 494ebef6..1f3ebdba 100644 --- a/packages/devtools/src/overlay-angular-native.ts +++ b/packages/devtools/src/overlay-angular-native.ts @@ -25,6 +25,7 @@ import { type SignalTarget, } from './signal-graph.ts'; import { createSignalHistory, installSignalWriteHook } from './signal-history.ts'; +import { registerSignalPeek } from './signal-peek.ts'; export type { AngularNativeNode } from './overlay-angular-native-views.ts'; @@ -338,6 +339,12 @@ async function startSession( void pushSignalGraph(true).catch(() => {}); }, }); + registerSignalPeek( + my.rpc, + pageId, + (target) => collectSignalGraph(angularDebugApi(), target, tree), + signalHistory, + ); my.rpc.register({ name: 'inspect-component-in-page', diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index 66929e97..962254b3 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -24,6 +24,7 @@ import { type SignalTarget, } from './signal-graph.ts'; import { createSignalHistory, installSignalWriteHook } from './signal-history.ts'; +import { registerSignalPeek } from './signal-peek.ts'; import { serializeNamed } from './serialize.ts'; import { nativeScriptTree, @@ -296,6 +297,12 @@ async function startSession( void pushSignalGraph(true).catch(() => {}); }, }); + registerSignalPeek( + my.rpc, + pageId, + (target) => collectSignalGraph(angularDebugApi(), target, tree), + signalHistory, + ); my.rpc.register({ name: 'inspect-component-in-page', diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index d6db794c..4ff351a1 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -35,6 +35,7 @@ import { type PreloadRecord, } from './router-actions.ts'; import { createSignalHistory, installSignalWriteHook } from './signal-history.ts'; +import { registerSignalPeek } from './signal-peek.ts'; export { installSignalWriteHook } from './signal-history.ts'; import { collectComponentTree, componentHostOf } from './component-tree.ts'; import { startComponentPick } from './component-pick.ts'; @@ -595,6 +596,12 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) void pushSignalGraph(true).catch(() => {}); }, }); + registerSignalPeek( + my.rpc, + pageId, + (target) => collectSignalGraph(getNg(), target), + signalHistory, + ); my.rpc.register({ name: 'inspect-component-in-page', diff --git a/packages/devtools/src/rpc/signal-peeks.ts b/packages/devtools/src/rpc/signal-peeks.ts new file mode 100644 index 00000000..419e20b6 --- /dev/null +++ b/packages/devtools/src/rpc/signal-peeks.ts @@ -0,0 +1,62 @@ +import type { SignalGraph } from '../types.ts'; + +type PageGraph = SignalGraph & { pageId?: string }; + +/** + * Pending one-shot graph reads for inspect-signals. The server broadcasts + * `peek-signal-graph` with a `requestId`, and each page that is asked answers + * through `signal-graph-peek-result`. Nothing here touches the shared + * `signal-graph` state, so the Signals page keeps its selection. + */ +export function createSignalPeeks() { + const pending = new Map< + string, + { pageId?: string; waiting: Set; done: (graph: PageGraph | null) => void } + >(); + let seq = 0; + + /** + * Sends one request and resolves with the first graph a page returns, or + * null when every page in `pages` answered without one or none answered + * within `timeoutMs`. Empty answers from pages outside `pages` (an overlay + * that never reported a graph) do not count toward that early null. + */ + function request( + send: (message: { requestId: string; env: string; pageId?: string }) => void, + env: string, + pageId: string | undefined, + pages: readonly string[], + timeoutMs: number, + ): Promise { + if (!pages.length) return Promise.resolve(null); + return new Promise((resolve) => { + const requestId = `peek${++seq}`; + const timer = setTimeout(() => done(null), timeoutMs); + (timer as { unref?: () => void }).unref?.(); + const done = (graph: PageGraph | null) => { + clearTimeout(timer); + pending.delete(requestId); + resolve(graph); + }; + pending.set(requestId, { pageId, waiting: new Set(pages), done }); + send(pageId ? { requestId, env, pageId } : { requestId, env }); + }); + } + + /** Takes a page's answer; ignores unknown requests and answers from pages not asked. */ + function answer(message: unknown): void { + if (!message || typeof message !== 'object') return; + const { requestId, pageId, graph } = message as Record; + if (typeof requestId !== 'string' || typeof pageId !== 'string') return; + const entry = pending.get(requestId); + if (!entry || (entry.pageId && entry.pageId !== pageId)) return; + if (graph && typeof graph === 'object' && Array.isArray((graph as PageGraph).nodes)) { + entry.done({ ...(graph as PageGraph), pageId }); + return; + } + entry.waiting.delete(pageId); + if (!entry.waiting.size) entry.done(null); + } + + return { request, answer }; +} diff --git a/packages/devtools/src/signal-peek.ts b/packages/devtools/src/signal-peek.ts new file mode 100644 index 00000000..1a5f540c --- /dev/null +++ b/packages/devtools/src/signal-peek.ts @@ -0,0 +1,110 @@ +import { injectorMatches, type SignalTarget } from './signal-graph.ts'; +import type { SignalChange, SignalGraph, SignalGraphNode } from './types.ts'; + +/** + * One-shot reads of an environment injector's signal graph for the + * inspect-signals agent tool. Unlike `select-signal-component`, a peek leaves + * the page's selected target alone, so the Signals page keeps showing what the + * user picked. + */ + +/** Page event the server broadcasts to ask for one graph. */ +export const PEEK_EVENT = 'peek-signal-graph'; +/** Server action the page calls with the answer. */ +export const PEEK_RESULT = 'signal-graph-peek-result'; + +export interface SignalPeekRequest { + requestId: string; + env: string; + pageId?: string; +} + +export interface SignalPeekAnswer { + requestId: string; + pageId: string; + graph: (SignalGraph & { pageId: string }) | null; +} + +interface PeekHistory { + collect(nodes: SignalGraphNode[]): Record; + changesOf(id: string): number; +} + +/** The request when it is well formed and meant for this page, otherwise undefined. */ +export function toPeekRequest(request: unknown, pageId: string): SignalPeekRequest | undefined { + if (!request || typeof request !== 'object') return undefined; + const { requestId, env, pageId: forPage } = request as Record; + if (typeof requestId !== 'string' || !requestId || requestId.length > 50) return undefined; + if (typeof env !== 'string' || !env || env.length > 500) return undefined; + if (typeof forPage === 'string' && forPage && forPage !== pageId) return undefined; + return { requestId, env }; +} + +/** + * Collects the graph of the environment injector the request names. Answers + * with `graph: null` when the page has no such injector, rather than the + * component graph the collector falls back to. + */ +export function answerPeek( + request: SignalPeekRequest, + pageId: string, + collect: (target: SignalTarget) => SignalGraph | null, + history?: PeekHistory, +): SignalPeekAnswer { + const graph = collect({ env: request.env }); + if (!graph?.injector || !injectorMatches(graph.injector, request.env)) { + return { requestId: request.requestId, pageId, graph: null }; + } + if (history) { + const statuses = (graph.resources ?? []) + .filter((r) => r.status) + .map((r) => ({ + id: r.id, + kind: 'resource' as const, + label: r.name, + epoch: r.epoch, + value: r.status, + })); + graph.history = history.collect([...graph.nodes, ...statuses]); + for (const item of [...graph.nodes, ...(graph.resources ?? [])]) { + const changes = history.changesOf(item.id); + if (changes) item.changes = changes; + } + } + return { requestId: request.requestId, pageId, graph: { ...graph, pageId } }; +} + +interface PeekRpc { + register(fn: { + name: string; + type: 'event'; + jsonSerializable: true; + handler: (request: unknown) => void; + }): unknown; + call(name: string, ...args: unknown[]): Promise; +} + +/** Answers `peek-signal-graph` requests from the server on this page. */ +export function registerSignalPeek( + rpc: PeekRpc, + pageId: string, + collect: (target: SignalTarget) => SignalGraph | null, + history?: PeekHistory, +): void { + rpc.register({ + name: PEEK_EVENT, + type: 'event', + jsonSerializable: true, + handler: (request: unknown) => { + const wanted = toPeekRequest(request, pageId); + if (!wanted) return; + let answer: SignalPeekAnswer; + try { + answer = answerPeek(wanted, pageId, collect, history); + } catch { + answer = { requestId: wanted.requestId, pageId, graph: null }; + } + void rpc.call(PEEK_RESULT, answer).catch(() => {}); + }, + }); +}