diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx
index bdbc48c10..89e873665 100644
--- a/graph-ui/src/architecture/BehaviorJourney.tsx
+++ b/graph-ui/src/architecture/BehaviorJourney.tsx
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { behaviorJourney } from './behavior-journey-model';
-import type { SystemSceneModel } from './system-architecture-model';
+import { projectionLimits, type SystemSceneModel } from './system-architecture-model';
+import { architectureText as text } from './strings';
import type { SystemProjection, SystemSymbol } from './system-architecture-source';
import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence';
import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence';
@@ -78,6 +79,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
const entry = journey?.entry ?? entries.find(item => item.id === entryId);
const availableEntries = entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries;
const selectedTarget = targetOptions.find(item => item.id === targetId) ?? path?.nodes.at(-1);
+ const limits = data?.status === 'limited' ? projectionLimits(data) : [];
const sourceKey = caller ? JSON.stringify([project, generation, caller.qualified_name, call?.callsite?.file_path ?? caller.file_path, call?.callsite?.line ?? caller.start_line]) : undefined;
useSelectionEvidence(onSelectionEvidence, !overview && !pending && journey && symbol ? {
project, generation, view: 'architecture-behavior', source: 'indexed behavior projection and call-site evidence',
@@ -158,6 +160,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
selectedNode={selectedEdge ? undefined : selectedNode?.id} selectedEdge={selectedEdge?.id}
onSelectNode={selectNode} onSelectEdge={selectEdge} onExpandNode={follow} onClearSelection={clearSelection}
resetKey={resetKey} planar={planar} active={active} showConnectionLoad={false} presentation="journey" />
+ : limits.length ?
{text.behaviorLimited}{limits.map(limit =>
{limit}
)}
: {filter ? 'No connected path matches this filter.' : targetId !== undefined ? 'No connected call-chain witness was returned for this destination.' : 'No direct call witnesses were returned for this operation.'}
}
{path ? `Showing operations ${(scene.nodes[0]?.depth ?? 0) + 1} to ${(scene.nodes.at(-1)?.depth ?? 0) + 1} of ${path.nodes.length}` : 'Branches are possible calls, not an execution sequence.'}
Double-click an operation to follow its calls
diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx
index 9938d14e0..e3ab9033d 100644
--- a/graph-ui/src/architecture/SystemArchitecture.test.tsx
+++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx
@@ -2,7 +2,7 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import SystemArchitecture, { suggestedBehaviorEntry, type SystemArchitectureProps } from './SystemArchitecture';
+import SystemArchitecture, { flowEntries, suggestedBehaviorEntry, type FlowsLoader, type SystemArchitectureProps } from './SystemArchitecture';
import { isContiguousPath, systemBehaviorGraph, systemComponentGraph, systemComponents, type SystemSceneModel } from './system-architecture-model';
import { loadSystemArchitecture, readSystemArchitecture, type SystemArchitectureLoader, type SystemArchitectureResponse, type SystemProjection, type SystemSymbol } from './system-architecture-source';
import { callToolJson } from '../provider/rpc-transport';
@@ -348,6 +348,22 @@ describe('system architecture workspace', () => {
];
expect(suggestedBehaviorEntry(entries)?.id).toBe(4);
expect(suggestedBehaviorEntry([symbol(5, 'helper', 'format')])).toBeUndefined();
+ const handlers = [{ ...symbol(6, 'commands', 'handle'), file_path: 'django/core/management/commands/runserver.py' },
+ { ...symbol(7, 'tests', 'handle'), file_path: 'tests/admin_scripts/management/commands/base_command.py' },
+ { ...symbol(8, 'app', 'render'), file_path: 'django/shortcuts.py' }];
+ expect(suggestedBehaviorEntry(handlers)).toBeUndefined();
+ expect(suggestedBehaviorEntry(handlers, true)?.id).toBe(6);
+ // Ranked flows keep their order: a shallower but lower-ranked handler does not win.
+ expect(suggestedBehaviorEntry([handlers[0], { ...symbol(9, 'docs', 'run'), file_path: 'docs/_ext/djangodocs.py' }], true)?.id).toBe(6);
+ expect(suggestedBehaviorEntry([...handlers, ...entries], true)?.id).toBe(4);
+ });
+ it('reads flow entries once each and never invents an identity', () => {
+ const node = (id: number | undefined, name: string, filePath?: string) => ({ ...(id === undefined ? {} : { id }), name, ...(filePath ? { filePath } : {}) });
+ expect(flowEntries([
+ { id: 0, label: 'handle → tick', entry: node(36163, 'handle', 'django/core/management/commands/runserver.py'), terminal: node(44340, 'tick'), steps: 40 },
+ { id: 1, label: 'handle → len', entry: node(36163, 'handle', 'django/core/management/commands/runserver.py'), terminal: node(8040, 'len'), steps: 3 },
+ { id: 2, label: 'unknown', entry: node(undefined, 'anonymous'), terminal: node(1, 'x'), steps: 1 },
+ ])).toEqual([{ id: 36163, name: 'handle', qualified_name: '', label: '', file_path: 'django/core/management/commands/runserver.py', component_id: '' }]);
});
it('automatically requests the executable entry once and leaves manual selection authoritative', async () => {
const data = fixture(); data.entrypoints[0] = { ...data.entrypoints[0], name: 'main', file_path: 'src/main.c' };
@@ -529,6 +545,35 @@ describe('system architecture workspace', () => {
await render(vi.fn().mockResolvedValue(response(data)));
expect(container.querySelector('.system-scene-empty[role="status"]')?.textContent).toBe('No component projection is available within this analysis budget.');
});
+ it('fills an empty behavior start list from ranked flows and requests a conventional handler', async () => {
+ const flowsLoader = vi.fn().mockResolvedValue([
+ { id: 0, label: 'run_tests → setup', entry: { id: 9, name: 'run_tests', filePath: 'tests/runtests.py' }, terminal: { id: 10, name: 'setup' }, steps: 12 },
+ { id: 1, label: 'handle → tick', entry: { id: 36163, name: 'handle', filePath: 'django/core/management/commands/runserver.py' }, terminal: { id: 44340, name: 'tick' }, steps: 40 },
+ ]);
+ const chosen = limitedEmptyFixture();
+ chosen.behavior = { ...chosen.behavior!, source_id: 36163 };
+ chosen.warnings = [...chosen.warnings, 'Optional behavior evidence was omitted entirely to keep the base graph within the response memory budget.'];
+ const loader = vi.fn().mockResolvedValueOnce(response(limitedEmptyFixture())).mockResolvedValue(response(chosen));
+ await render(loader, { view: 'behavior', flowsLoader });
+ expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample');
+ expect(loader.mock.calls.at(-1)?.[0]).toEqual({ project: 'sample', entryNodeId: 36163, expectedGeneration: 'g1', includeBehaviorEvidence: true });
+ const start = container.querySelector('[aria-label="Behavior entry point"]')!;
+ expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', 'run_tests · tests/runtests.py',
+ 'handle · django/core/management/commands/runserver.py']);
+ expect(start.value).toBe('36163');
+ const state = container.querySelector('.behavior-loading[role="status"]')!;
+ expect(state.textContent).toContain('The analysis returned no call evidence.');
+ expect(state.textContent).toContain('exceeded its memory budget');
+ expect(state.textContent).toContain('Optional behavior evidence was omitted entirely');
+ await choose('Behavior entry point', '9');
+ expect(loader.mock.calls.at(-1)?.[0].entryNodeId).toBe(9);
+ expect([...start.options].map(option => option.value)).toEqual(['', '9', '36163']);
+ });
+ it('does not ask for flows while the projection classifies entry points', async () => {
+ const flowsLoader = vi.fn().mockResolvedValue([]);
+ await render(vi.fn().mockResolvedValue(response()), { view: 'behavior', flowsLoader });
+ expect(flowsLoader).not.toHaveBeenCalled();
+ });
it('shows failures without retrying continuously', async () => {
vi.useFakeTimers(); const loader = vi.fn().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' });
await render(loader); await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx
index df754544b..1c66642bd 100644
--- a/graph-ui/src/architecture/SystemArchitecture.tsx
+++ b/graph-ui/src/architecture/SystemArchitecture.tsx
@@ -5,6 +5,8 @@ import { loadSystemArchitecture, type SystemArchitectureLoader, type SystemArchi
import { componentBasis, isContiguousPath, projectionLimits, projectionUnavailable, systemOverviewGraph, systemComponents, type SystemSceneEdge, type SystemSceneModel } from './system-architecture-model';
import { architectureText as text } from './strings';
import { connectionLoad } from '../graph/connection-load';
+import { AtlasApi } from '../app/atlas-api';
+import type { FlowSummary } from '../traces/trace-schemas';
import BehaviorJourney from './BehaviorJourney';
import './system-architecture.css';
@@ -15,6 +17,19 @@ export interface SystemArchitectureProps {
onSelectionEvidence?: SelectionEvidenceListener;
onNavigate: (path: string, line?: number, name?: string) => void;
loader?: SystemArchitectureLoader;
+ flowsLoader?: FlowsLoader;
+}
+/** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */
+export type FlowsLoader = (project: string) => Promise;
+const loadFlows: FlowsLoader = project => new AtlasApi().flows(project);
+
+/** Starting operations from flows, once each. The projection supplies qualified name and component on request. */
+export function flowEntries(flows: FlowSummary[]): SystemSymbol[] {
+ const entries = new Map();
+ for (const { entry } of flows) if (entry.id !== undefined && entry.id > 0 && !entries.has(entry.id)) {
+ entries.set(entry.id, { id: entry.id, name: entry.name, qualified_name: '', label: '', file_path: entry.filePath, component_id: '' });
+ }
+ return [...entries.values()];
}
/** A limited or empty projection explains itself with the warnings that limited it. */
@@ -28,13 +43,18 @@ function EvidenceSource({ symbol, onOpen }: { symbol: SystemSymbol; onOpen: (sym
{symbol.file_path ? `${symbol.file_path}${symbol.start_line ? `:${symbol.start_line}` : ''}` : 'Source location unavailable'};
}
-/** Prefer a conventional executable entry when one exists; keep every indexed entry selectable. */
-export function suggestedBehaviorEntry(entries: SystemSymbol[]): SystemSymbol | undefined {
- return entries.filter(entry => /^(main|Main)$/.test(entry.name)
+/**
+ * Prefer a conventional executable entry when one exists; keep every indexed entry selectable.
+ * Ranked flows are unclassified call-graph roots, so among them a conventional handler name also qualifies.
+ */
+export function suggestedBehaviorEntry(entries: SystemSymbol[], handlers = false): SystemSymbol | undefined {
+ const candidates = entries.filter(entry => (/^(main|Main)$/.test(entry.name) || (handlers && /^(run|start|serve|handle|execute|dispatch)$/i.test(entry.name)))
&& entry.file_path && !/(^|\/)(tests?|__tests__|examples?|fixtures?|vendor|node_modules)(\/|$)/i.test(entry.file_path)
- && /\.(c|cc|cpp|go|rs|py|java|cs|js|ts)$/.test(entry.file_path))
+ && /\.(c|cc|cpp|go|rs|py|java|cs|js|ts)$/.test(entry.file_path));
+ // Flows arrive ranked by the server, so its first conventional handler is the best of them.
+ return candidates.filter(entry => /^(main|Main)$/.test(entry.name))
.sort((a, b) => Number(!/^src\/main\./.test(a.file_path!)) - Number(!/^src\/main\./.test(b.file_path!))
- || a.file_path!.split('/').length - b.file_path!.split('/').length || a.file_path!.localeCompare(b.file_path!))[0];
+ || a.file_path!.split('/').length - b.file_path!.split('/').length || a.file_path!.localeCompare(b.file_path!))[0] ?? candidates[0];
}
const handoffs = (symbols: SystemSymbol[]) => symbols.slice(1).filter((symbol, index) => symbol.component_id !== symbols[index].component_id).length;
@@ -43,7 +63,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all'
|| (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type);
/** Poll only while this view is active. A new request key hides stale results before its effect runs. */
-export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture }: SystemArchitectureProps) {
+export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows }: SystemArchitectureProps) {
const [entryChoice, setEntryChoice] = useState<{ project: string; generation?: string; expectedGeneration?: string; id?: number; targetId?: number }>();
const [entryChoices, setEntryChoices] = useState<{ project: string; generation?: string; analysisGeneration: string; entries: SystemSymbol[] }>();
const [snapshot, setSnapshot] = useState<{ project: string; generation?: string; analysisGeneration: string; data: SystemProjection }>();
@@ -64,6 +84,7 @@ export default function SystemArchitecture({ project, generation, view, filter,
const [focusId, setFocusId] = useState();
const [stepIndex, setStepIndex] = useState(0);
const [showConnectionLoad, setShowConnectionLoad] = useState(true);
+ const [flows, setFlows] = useState<{ project: string; generation?: string; entries: SystemSymbol[] }>();
useEffect(() => { setSelection(undefined); setPathIndex(0); setStepIndex(0); }, [filter, cyclesOnly, includeUnconnected, includeTests, connectionView]);
const requestedEntry = view === 'behavior' && entryChoice?.project === project && entryChoice.generation === generation ? entryChoice.id : undefined;
const requestedTarget = requestedEntry === undefined ? undefined : entryChoice?.targetId;
@@ -125,12 +146,23 @@ export default function SystemArchitecture({ project, generation, view, filter,
useEffect(() => { setFocusId(undefined); setExpandedGroups([]); setScopeHistory([]); setSelection(undefined); }, [project, generation]);
const structureData = useMemo(() => data ? { ...data, dependencies: data.dependencies.filter(edge => connectionAllowed(edge.type, connectionView)) } : undefined, [data, connectionView]);
const error = current?.error ?? (current?.response?.status === 'failed' ? current.response.error ?? 'Architecture analysis failed.' : undefined);
- const entries = entryChoices?.project === project && entryChoices.generation === generation ? entryChoices.entries : data?.entrypoints ?? [];
+ const chosenEntries = entryChoices?.project === project && entryChoices.generation === generation ? entryChoices.entries : data?.entrypoints ?? [];
+ // Without classified entry points (limited projections, Python without main) the ranked flows offer the starts.
+ const needsFlows = view === 'behavior' && active && Boolean(data) && !data?.entrypoints.length;
+ useEffect(() => {
+ if (!needsFlows || (flows?.project === project && flows.generation === generation)) return;
+ let live = true;
+ void flowsLoader(project).then(flowEntries, () => []).then(entries => { if (live) setFlows({ project, generation, entries }); });
+ return () => { live = false; };
+ }, [needsFlows, flows, project, generation, flowsLoader]);
+ const flowStarts = useMemo(() => flows?.project === project && flows.generation === generation ? flows.entries : [], [flows, project, generation]);
+ const entries = useMemo(() => data?.entrypoints.length || !flowStarts.length ? chosenEntries
+ : [...new Map([...flowStarts, ...chosenEntries].map(item => [item.id, item])).values()], [data, flowStarts, chosenEntries]);
useEffect(() => {
if (view !== 'behavior' || !data || (entryChoice?.project === project && entryChoice.generation === generation)) return;
- const suggested = suggestedBehaviorEntry(data.entrypoints);
+ const suggested = data.entrypoints.length ? suggestedBehaviorEntry(data.entrypoints) : suggestedBehaviorEntry(flowStarts, true);
if (suggested) setEntryChoice({ project, generation, expectedGeneration: current?.response?.generation ?? snapshot?.analysisGeneration, id: suggested.id });
- }, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration]);
+ }, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration, flowStarts]);
const returnedPaths = useMemo(() => {
const valid = queryData?.paths.filter(path => isContiguousPath(path)) ?? [];
if (queryData?.behavior && requestedTarget === undefined) return [];
diff --git a/graph-ui/src/architecture/behavior-journey-model.test.ts b/graph-ui/src/architecture/behavior-journey-model.test.ts
index 24f511c4d..8eb3eb8f3 100644
--- a/graph-ui/src/architecture/behavior-journey-model.test.ts
+++ b/graph-ui/src/architecture/behavior-journey-model.test.ts
@@ -34,6 +34,15 @@ describe('focused Behavior journeys', () => {
expect(result.scene.focusId).toBeUndefined();
});
+ it('treats the source_id 0 of an unselected projection as no selection', () => {
+ const data = projection({ paths: [path([1, 2])], behavior: behavior([], [], { source_id: 0, complete: false, limits_hit: ['select_source'] }) });
+ const result = behaviorJourney(data);
+ expect(result.entry?.id).toBe(1);
+ expect(result.mode).toBe('choices');
+ expect(drawnPairs(result)).toEqual([[1, 2]]);
+ expect(behaviorJourney({ ...data, entrypoints: [], paths: [] }).mode).toBe('empty');
+ });
+
it('uses exact-source witnesses and valid first hops but never aggregate counts or references', () => {
const data = projection({ paths: [path([1, 2, 8])], dependencies: [
{ source: 'app', target: 'other', type: 'CALLS', count: 500, witnesses: [] },
diff --git a/graph-ui/src/architecture/behavior-journey-model.ts b/graph-ui/src/architecture/behavior-journey-model.ts
index 1597a8787..f3fc79532 100644
--- a/graph-ui/src/architecture/behavior-journey-model.ts
+++ b/graph-ui/src/architecture/behavior-journey-model.ts
@@ -101,7 +101,8 @@ function corridorPath(data: SystemProjection, source: number, target: number, hi
/** A focused static journey: positions describe hops, never catalog distance or runtime order. */
export function behaviorJourney(data: SystemProjection, options: BehaviorJourneyOptions = {}): BehaviorJourney {
- const entryId = options.entryId ?? data.behavior?.source_id ?? data.entrypoints[0]?.id
+ // The server reports source_id 0 when no operation was requested; node IDs start at 1.
+ const entryId = options.entryId ?? (data.behavior?.source_id || undefined) ?? data.entrypoints[0]?.id
?? data.paths.find(validPath)?.entrypoint_id;
const scopedBehavior = data.behavior?.source_id === entryId ? data.behavior : undefined;
const dependencies = [...data.dependencies, ...(data.overview?.connections ?? [])];
diff --git a/graph-ui/src/architecture/behavior-journey.css b/graph-ui/src/architecture/behavior-journey.css
index 4b5fce2e1..e106cb02a 100644
--- a/graph-ui/src/architecture/behavior-journey.css
+++ b/graph-ui/src/architecture/behavior-journey.css
@@ -65,6 +65,8 @@
.behavior-immediate-list button { display: flex; align-items: center; gap: 12px; width: 100%; border-bottom: 1px solid #91a6bb12; }
.behavior-immediate-list small { margin-left: auto; color: #849aaf; font-size: 9px; white-space: nowrap; }
.behavior-loading { min-height: 310px; display: flex; align-items: center; justify-content: center; color: #829bb2; font-size: 12px; padding: 25px; text-align: center; }
+.behavior-limited { display: grid; gap: 8px; max-width: 560px; }
+.behavior-limited strong { color: #c4d6e6; font-weight: 500; }
.system-architecture .system-scene .system-scene-node-label[data-presentation=journey] { width: 116px; height: 58px; gap: 3px; padding: 7px; background: #14212ef2; border-radius: 8px; }
.system-architecture .system-scene .system-scene-node-label[data-presentation=journey] small { font-size: 8px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.system-architecture .system-scene .system-scene-node-label[data-presentation=journey] strong { font-size: 10px; }
diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts
index 9bcaf201e..b084f0405 100644
--- a/graph-ui/src/architecture/strings.ts
+++ b/graph-ui/src/architecture/strings.ts
@@ -77,4 +77,5 @@ export const architectureText = {
cohesion: (value: number) => `${Math.round(value * 100)}% cohesion`,
projectionLimited: 'The analysis returned no system structure for this project.',
projectionEmpty: 'No component projection is available within this analysis budget.',
+ behaviorLimited: 'The analysis returned no call evidence.',
};