Skip to content

Commit 850ef3e

Browse files
authored
feat: add batch querying for multi-agent dispatch (#223)
* feat: add batch querying for multi-agent dispatch Add `codegraph batch <command> [targets...]` CLI command, `batchData()` programmatic API, and `batch_query` MCP tool. Runs the same query against multiple targets in one call, returning all results in a single JSON payload with per-target error isolation. Supports 10 commands: fn-impact, context, explain, where, query, fn, impact, deps, flow, complexity. Accepts targets via positional args, --from-file (JSON array or newline-delimited), or --stdin. Impact: 3 functions changed, 3 affected * fix: correct spread order in batch.js comments to match implementation Impact: 1 functions changed, 1 affected
1 parent 6530d27 commit 850ef3e

File tree

6 files changed

+428
-0
lines changed

6 files changed

+428
-0
lines changed

src/batch.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Batch query orchestration — run the same query command against multiple targets
3+
* and return all results in a single JSON payload.
4+
*
5+
* Designed for multi-agent swarms that need to dispatch 20+ queries in one call.
6+
*/
7+
8+
import { complexityData } from './complexity.js';
9+
import { flowData } from './flow.js';
10+
import {
11+
contextData,
12+
explainData,
13+
fileDepsData,
14+
fnDepsData,
15+
fnImpactData,
16+
impactAnalysisData,
17+
queryNameData,
18+
whereData,
19+
} from './queries.js';
20+
21+
/**
22+
* Map of supported batch commands → their data function + first-arg semantics.
23+
* `sig` describes how the target string is passed to the data function:
24+
* - 'name' → dataFn(target, dbPath, opts)
25+
* - 'target' → dataFn(target, dbPath, opts)
26+
* - 'file' → dataFn(target, dbPath, opts)
27+
* - 'dbOnly' → dataFn(dbPath, { ...opts, target }) (target goes into opts)
28+
*/
29+
export const BATCH_COMMANDS = {
30+
'fn-impact': { fn: fnImpactData, sig: 'name' },
31+
context: { fn: contextData, sig: 'name' },
32+
explain: { fn: explainData, sig: 'target' },
33+
where: { fn: whereData, sig: 'target' },
34+
query: { fn: queryNameData, sig: 'name' },
35+
fn: { fn: fnDepsData, sig: 'name' },
36+
impact: { fn: impactAnalysisData, sig: 'file' },
37+
deps: { fn: fileDepsData, sig: 'file' },
38+
flow: { fn: flowData, sig: 'name' },
39+
complexity: { fn: complexityData, sig: 'dbOnly' },
40+
};
41+
42+
/**
43+
* Run a query command against multiple targets, returning all results.
44+
*
45+
* @param {string} command - One of the keys in BATCH_COMMANDS
46+
* @param {string[]} targets - List of target names/paths
47+
* @param {string} [customDbPath] - Path to graph.db
48+
* @param {object} [opts] - Shared options passed to every invocation
49+
* @returns {{ command: string, total: number, succeeded: number, failed: number, results: object[] }}
50+
*/
51+
export function batchData(command, targets, customDbPath, opts = {}) {
52+
const entry = BATCH_COMMANDS[command];
53+
if (!entry) {
54+
throw new Error(
55+
`Unknown batch command "${command}". Valid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
56+
);
57+
}
58+
59+
const results = [];
60+
let succeeded = 0;
61+
let failed = 0;
62+
63+
for (const target of targets) {
64+
try {
65+
let data;
66+
if (entry.sig === 'dbOnly') {
67+
// complexityData(dbPath, { ...opts, target })
68+
data = entry.fn(customDbPath, { ...opts, target });
69+
} else {
70+
// All other: dataFn(target, dbPath, opts)
71+
data = entry.fn(target, customDbPath, opts);
72+
}
73+
results.push({ target, ok: true, data });
74+
succeeded++;
75+
} catch (err) {
76+
results.push({ target, ok: false, error: err.message });
77+
failed++;
78+
}
79+
}
80+
81+
return { command, total: targets.length, succeeded, failed, results };
82+
}
83+
84+
/**
85+
* CLI wrapper — calls batchData and prints JSON to stdout.
86+
*/
87+
export function batch(command, targets, customDbPath, opts = {}) {
88+
const data = batchData(command, targets, customDbPath, opts);
89+
console.log(JSON.stringify(data, null, 2));
90+
}

src/cli.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from 'node:fs';
44
import path from 'node:path';
55
import { Command } from 'commander';
66
import { audit } from './audit.js';
7+
import { BATCH_COMMANDS, batch } from './batch.js';
78
import { buildGraph } from './builder.js';
89
import { loadConfig } from './config.js';
910
import { findCycles, formatCycles } from './cycles.js';
@@ -1150,4 +1151,55 @@ program
11501151
}
11511152
});
11521153

1154+
program
1155+
.command('batch <command> [targets...]')
1156+
.description(
1157+
`Run a query against multiple targets in one call. Output is always JSON.\nValid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
1158+
)
1159+
.option('-d, --db <path>', 'Path to graph.db')
1160+
.option('--from-file <path>', 'Read targets from file (JSON array or newline-delimited)')
1161+
.option('--stdin', 'Read targets from stdin (JSON array)')
1162+
.option('--depth <n>', 'Traversal depth passed to underlying command')
1163+
.option('-f, --file <path>', 'Scope to file (partial match)')
1164+
.option('-k, --kind <kind>', 'Filter by symbol kind')
1165+
.option('-T, --no-tests', 'Exclude test/spec files from results')
1166+
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
1167+
.action(async (command, positionalTargets, opts) => {
1168+
if (opts.kind && !ALL_SYMBOL_KINDS.includes(opts.kind)) {
1169+
console.error(`Invalid kind "${opts.kind}". Valid: ${ALL_SYMBOL_KINDS.join(', ')}`);
1170+
process.exit(1);
1171+
}
1172+
1173+
let targets;
1174+
if (opts.fromFile) {
1175+
const raw = fs.readFileSync(opts.fromFile, 'utf-8').trim();
1176+
if (raw.startsWith('[')) {
1177+
targets = JSON.parse(raw);
1178+
} else {
1179+
targets = raw.split(/\r?\n/).filter(Boolean);
1180+
}
1181+
} else if (opts.stdin) {
1182+
const chunks = [];
1183+
for await (const chunk of process.stdin) chunks.push(chunk);
1184+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
1185+
targets = raw.startsWith('[') ? JSON.parse(raw) : raw.split(/\r?\n/).filter(Boolean);
1186+
} else {
1187+
targets = positionalTargets;
1188+
}
1189+
1190+
if (!targets || targets.length === 0) {
1191+
console.error('No targets provided. Pass targets as arguments, --from-file, or --stdin.');
1192+
process.exit(1);
1193+
}
1194+
1195+
const batchOpts = {
1196+
depth: opts.depth ? parseInt(opts.depth, 10) : undefined,
1197+
file: opts.file,
1198+
kind: opts.kind,
1199+
noTests: resolveNoTests(opts),
1200+
};
1201+
1202+
batch(command, targets, opts.db, batchOpts);
1203+
});
1204+
11531205
program.parse();

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
// Audit (composite report)
99
export { audit, auditData } from './audit.js';
10+
// Batch querying
11+
export { BATCH_COMMANDS, batch, batchData } from './batch.js';
1012
// Branch comparison
1113
export { branchCompareData, branchCompareMermaid } from './branch-compare.js';
1214
// Graph building

src/mcp.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,52 @@ const BASE_TOOLS = [
553553
required: ['target'],
554554
},
555555
},
556+
{
557+
name: 'batch_query',
558+
description:
559+
'Run a query command against multiple targets in one call. Returns all results in a single JSON payload — ideal for multi-agent dispatch.',
560+
inputSchema: {
561+
type: 'object',
562+
properties: {
563+
command: {
564+
type: 'string',
565+
enum: [
566+
'fn-impact',
567+
'context',
568+
'explain',
569+
'where',
570+
'query',
571+
'fn',
572+
'impact',
573+
'deps',
574+
'flow',
575+
'complexity',
576+
],
577+
description: 'The query command to run for each target',
578+
},
579+
targets: {
580+
type: 'array',
581+
items: { type: 'string' },
582+
description: 'List of target names (symbol names or file paths depending on command)',
583+
},
584+
depth: {
585+
type: 'number',
586+
description: 'Traversal depth (for fn-impact, context, fn, flow)',
587+
},
588+
file: {
589+
type: 'string',
590+
description: 'Scope to file (partial match)',
591+
},
592+
kind: {
593+
type: 'string',
594+
enum: ALL_SYMBOL_KINDS,
595+
description: 'Filter symbol kind',
596+
},
597+
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
598+
},
599+
required: ['command', 'targets'],
600+
},
601+
},
556602
{
557603
name: 'branch_compare',
558604
description:
@@ -1035,6 +1081,16 @@ export async function startMCPServer(customDbPath, options = {}) {
10351081
});
10361082
break;
10371083
}
1084+
case 'batch_query': {
1085+
const { batchData } = await import('./batch.js');
1086+
result = batchData(args.command, args.targets, dbPath, {
1087+
depth: args.depth,
1088+
file: args.file,
1089+
kind: args.kind,
1090+
noTests: args.no_tests,
1091+
});
1092+
break;
1093+
}
10381094
case 'branch_compare': {
10391095
const { branchCompareData, branchCompareMermaid } = await import('./branch-compare.js');
10401096
const bcData = await branchCompareData(args.base, args.target, {

0 commit comments

Comments
 (0)