Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions __tests__/ui-flow-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
import { MAX_TRAIL_HOPS } from '../src/ui-server/api/trail-store';
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
import { ToolHandler } from '../src/mcp/tools';
import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
Expand Down Expand Up @@ -600,11 +601,14 @@ describe('GET /api/flow — refusals', () => {
expect(payload.hint).toMatch(/\?from=/);
});

it('caps the number of trail hops it will read', async () => {
const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
const payload = await getFlow(`?${query}`, 400);
it('caps the number of trail hops it will read at what the trail store saves (#1976)', async () => {
const hopsQuery = (n: number) => Array.from({ length: n }, (_, i) => `hop=s${i}xx`).join('&');
// A 64-hop trail is one the store saves, so it must be readable as a flow.
const saved = await request(`/api/flow?${hopsQuery(MAX_TRAIL_HOPS)}`);
expect(saved.status).toBe(200);
const payload = await getFlow(`?${hopsQuery(MAX_TRAIL_HOPS + 1)}`, 400);
expect(payload.code).toBe('bad-request');
expect(payload.error).toMatch(/longer than this endpoint reads/);
expect(payload.error).toMatch(/longer than this endpoint reads \(64\)/);
});

it('is listed on the API index', async () => {
Expand Down
37 changes: 37 additions & 0 deletions __tests__/ui-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
setNavigationDriver,
symbolHref,
trail,
resolveTrailNames,
type GraphAdapter,
type NavigationDriver,
type WireFlowPayload,
Expand Down Expand Up @@ -784,3 +785,39 @@ describe('@colbymchenry/codegraph-ui — the published shape', () => {
expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
});
});

describe('a long trail (#1976)', () => {
it('keeps at most the 64 hops the store saves and /api/flow reads, dropping the oldest', () => {
for (let i = 0; i < 70; i++) trail.push({ id: `function:h${i}` });
expect(trail.hops).toHaveLength(64);
expect(trail.hops[0]?.id).toBe('function:h6');
expect(trail.hops[63]?.id).toBe('function:h69');
});

it('asks for its names in batches /api/nodes accepts', async () => {
const { adapter } = mockAdapter();
const batches: number[] = [];
adapter.nodes = (ids) => {
batches.push(ids.length);
const items = ids.map(
(id) =>
({
id,
kind: 'function',
name: id.slice('function:'.length),
qualifiedName: id,
file: 'src/a.ts',
line: 1,
endLine: 1,
language: 'typescript',
}) as WireNodeRef
);
return Promise.resolve({ items, missing: [] });
};
setGraphAdapter(adapter);
for (let i = 0; i < 64; i++) trail.push({ id: `function:h${i}` });
await resolveTrailNames();
expect(batches).toEqual([60, 4]);
expect(trail.hops.every((hop) => hop.name)).toBe(true);
});
});
5 changes: 3 additions & 2 deletions src/ui-server/api/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ import {
} from '../../graph/dynamic-boundary-report';
import { highlightLines, type HighlightResult } from '../highlight';
import { badRequest, intParam } from './respond';
// Hops accepted from a trail: the same cap the trail store saves under, so a
// trail that can be saved can also be read as a flow (#1976).
import { MAX_TRAIL_HOPS } from './trail-store';
import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
import { resolveProjectFile } from '../security';
import {
Expand Down Expand Up @@ -80,8 +83,6 @@ const HIGHLIGHT_LEAD_MAX = 200;
/** Distinct paths returned. The header's flow picker is a short list or nothing. */
export const MAX_FLOWS = 4;

/** Hops accepted from a trail. The trail bar itself is not much longer than this. */
const MAX_TRAIL_HOPS = 24;

// =============================================================================
// Wire shapes
Expand Down
18 changes: 16 additions & 2 deletions ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,22 @@ export function fetchSearch(
}

/** Names and locations for ids you already have — what the trail redraws with. */
export function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
return getGraphAdapter().nodes(ids, signal);
/** Ids `/api/nodes` answers per request (`MAX_NODE_REFS` on the server). */
const NODE_REFS_PER_REQUEST = 60;

/**
* Names and kinds for a set of ids. A trail can hold more hops than one request
* answers, so the ids go out in batches and the answers are merged (#1976).
*/
export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise<WireNodeRefs> {
if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal);
const merged: WireNodeRefs = { items: [], missing: [] };
for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) {
const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal);
merged.items.push(...batch.items);
merged.missing.push(...batch.missing);
}
return merged;
}

export function fetchEntryPoints(
Expand Down
11 changes: 9 additions & 2 deletions ui/src/lib/trail.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export type { HopDirection, TrailHop } from './trail-codec';

let hops = $state<TrailHop[]>([]);

/**
* Hops one trail keeps — the trail store's `MAX_TRAIL_HOPS`, which `/api/flow`
* reads up to as well, so any trail on the bar can be saved and read as a flow.
* Past it the oldest hop falls off the front (#1976).
*/
const MAX_TRAIL_HOPS = 64;

/**
* Every name this session has learned, by id.
*
Expand Down Expand Up @@ -70,7 +77,7 @@ export const trail = {
kind: hop.kind ?? null,
dir: hop.dir ?? (hops.length === 0 ? 'start' : 'down'),
},
];
].slice(-MAX_TRAIL_HOPS);
},

/**
Expand Down Expand Up @@ -119,7 +126,7 @@ export const trail = {
if (encodeTrail(decoded) === encodeTrail(hops)) return;
// Names survive the change — including for hops this trail dropped earlier
// and history has just brought back.
hops = decoded.map((h) => {
hops = decoded.slice(-MAX_TRAIL_HOPS).map((h) => {
const seen = known.get(h.id);
return seen ? { ...h, name: seen.name, kind: seen.kind } : h;
});
Expand Down