d365fo-mcp 1.17.2 → 1.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +34 -0
- package/dist/metadata/symbolIndex.d.ts +18 -0
- package/dist/metadata/symbolIndex.js +32 -3
- package/dist/scripts/build-database.js +33 -2
- package/dist/scripts/build-fts.js +33 -2
- package/dist/scripts/startupIndexWorker.js +33 -2
- package/dist/server/serverMode.d.ts +23 -0
- package/dist/server/serverMode.js +26 -0
- package/dist/tools/analysis/searchLabels.js +1 -1
- package/dist/tools/readers/getObjectInfo.js +5 -0
- package/dist/tools/readers/objectXml.d.ts +17 -2
- package/dist/tools/readers/objectXml.js +35 -9
- package/dist/tools/toolHandler.js +15 -5
- package/dist/tools/write/resolveReferences.js +1 -1
- package/dist/utils/indexStaleness.d.ts +11 -3
- package/dist/utils/indexStaleness.js +41 -7
- package/dist/utils/objectFileLookup.d.ts +44 -0
- package/dist/utils/objectFileLookup.js +86 -0
- package/dist/utils/objectNamingRules.js +1 -1
- package/dist/utils/progressReporter.d.ts +25 -0
- package/dist/utils/progressReporter.js +34 -0
- package/dist/workspace/contextSnapshot.js +8 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -104,8 +104,37 @@ console.error = (...args) => {
|
|
|
104
104
|
const isModuleDebugMessage = /^\[[\w\- ]+\]/.test(firstArg) && !hasErrorIndicator;
|
|
105
105
|
if (!isModuleDebugMessage) {
|
|
106
106
|
originalConsoleError(...args);
|
|
107
|
+
return;
|
|
107
108
|
}
|
|
109
|
+
logFileOnly(args);
|
|
108
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* Write a line the client never sees to LOG_FILE anyway.
|
|
113
|
+
*
|
|
114
|
+
* Both filters below the tee — this one and the stdio console.log redirect — exist
|
|
115
|
+
* so the MCP client's stderr pane is not a scroll of operational chatter. They were
|
|
116
|
+
* dropping the line entirely, and since the tee sits on process.stderr, a dropped
|
|
117
|
+
* line never reached the log file either. So a session that hung for five and a
|
|
118
|
+
* half minutes on its first call left a log holding a start banner and nothing
|
|
119
|
+
* else: "Loading symbols…", "Database opened in Xs" and "Database loaded in N ms"
|
|
120
|
+
* are all log.step/log.ok, all suppressed, all the answer to what it was doing.
|
|
121
|
+
* Setting DEBUG_LOGGING=true was the only way to see them, and it turns the client
|
|
122
|
+
* pane into that same scroll. The file is the right place for both.
|
|
123
|
+
*/
|
|
124
|
+
function logFileOnly(args) {
|
|
125
|
+
if (!_logStream)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
// Timestamped, unlike the tee'd lines: these are the progress lines, and the
|
|
129
|
+
// question they answer ("which phase took the five minutes?") is unanswerable
|
|
130
|
+
// without the clock. Time only — the banner above carries the date.
|
|
131
|
+
const at = new Date().toISOString().slice(11, 23);
|
|
132
|
+
_logStream.write(`[${at}] ` + args.map(a => (typeof a === 'string' ? a : String(a))).join(' ') + '\n');
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Mirroring is best-effort; it must never take down the caller.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
109
138
|
// ─── Global safety net ────────────────────────────────────────────────────────
|
|
110
139
|
// An unhandled promise rejection terminates the Node process by default
|
|
111
140
|
// (Node ≥15, --unhandled-rejections=throw). In stdio mode that kills the MCP
|
|
@@ -550,7 +579,12 @@ async function main() {
|
|
|
550
579
|
msg.includes('Error') || msg.includes('error') ||
|
|
551
580
|
msg.includes('Failed') || msg.includes('failed')) {
|
|
552
581
|
process.stderr.write(msg + '\n');
|
|
582
|
+
return;
|
|
553
583
|
}
|
|
584
|
+
// Suppressed from the client's pane, kept in LOG_FILE — see logFileOnly.
|
|
585
|
+
// This is where every startup progress line goes: log.step/ok/detail are
|
|
586
|
+
// console.log, and console.log is this function in stdio mode.
|
|
587
|
+
logFileOnly([msg]);
|
|
554
588
|
};
|
|
555
589
|
console.log = stderrWrite;
|
|
556
590
|
console.info = stderrWrite;
|
|
@@ -299,6 +299,15 @@ export declare class XppSymbolIndex {
|
|
|
299
299
|
*
|
|
300
300
|
* Returns the names of top-level objects that were removed (for cache invalidation).
|
|
301
301
|
*/
|
|
302
|
+
/**
|
|
303
|
+
* How many regular (non-extension) object names the prefix sample may draw.
|
|
304
|
+
*
|
|
305
|
+
* inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
|
|
306
|
+
* coverage threshold, so a few dozen settle the question as well as a few hundred
|
|
307
|
+
* — and this is the band that costs, since a model's extensions are counted in
|
|
308
|
+
* tens while its classes and tables run to thousands.
|
|
309
|
+
*/
|
|
310
|
+
private static readonly REGULAR_NAME_SAMPLE;
|
|
302
311
|
/**
|
|
303
312
|
* Top-level object names belonging to one model — the evidence from which a
|
|
304
313
|
* model's naming prefix is inferred (see utils/modelPrefixInference.ts).
|
|
@@ -330,6 +339,15 @@ export declare class XppSymbolIndex {
|
|
|
330
339
|
* so the same model always yields the same sample — a silent, self-reinforcing
|
|
331
340
|
* failure otherwise, since this server writes names with the inferred prefix and
|
|
332
341
|
* those names become evidence for the next inference.
|
|
342
|
+
*
|
|
343
|
+
* The regular band is capped well below its share of the budget
|
|
344
|
+
* (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
|
|
345
|
+
* carries most of the signal: extensions state the infix outright, while regular
|
|
346
|
+
* objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
|
|
347
|
+
* leading token. Reading 400 of them to settle a 4-name question was paid on the
|
|
348
|
+
* first call of every session. They cannot be dropped altogether — the underscore
|
|
349
|
+
* form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
|
|
350
|
+
* object can decide it.
|
|
333
351
|
*/
|
|
334
352
|
getModelObjectNames(model: string, limit?: number): string[];
|
|
335
353
|
removeSymbolsByFile(filePath: string): {
|
|
@@ -1243,6 +1243,15 @@ export class XppSymbolIndex {
|
|
|
1243
1243
|
*
|
|
1244
1244
|
* Returns the names of top-level objects that were removed (for cache invalidation).
|
|
1245
1245
|
*/
|
|
1246
|
+
/**
|
|
1247
|
+
* How many regular (non-extension) object names the prefix sample may draw.
|
|
1248
|
+
*
|
|
1249
|
+
* inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
|
|
1250
|
+
* coverage threshold, so a few dozen settle the question as well as a few hundred
|
|
1251
|
+
* — and this is the band that costs, since a model's extensions are counted in
|
|
1252
|
+
* tens while its classes and tables run to thousands.
|
|
1253
|
+
*/
|
|
1254
|
+
static REGULAR_NAME_SAMPLE = 60;
|
|
1246
1255
|
/**
|
|
1247
1256
|
* Top-level object names belonging to one model — the evidence from which a
|
|
1248
1257
|
* model's naming prefix is inferred (see utils/modelPrefixInference.ts).
|
|
@@ -1274,6 +1283,15 @@ export class XppSymbolIndex {
|
|
|
1274
1283
|
* so the same model always yields the same sample — a silent, self-reinforcing
|
|
1275
1284
|
* failure otherwise, since this server writes names with the inferred prefix and
|
|
1276
1285
|
* those names become evidence for the next inference.
|
|
1286
|
+
*
|
|
1287
|
+
* The regular band is capped well below its share of the budget
|
|
1288
|
+
* (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
|
|
1289
|
+
* carries most of the signal: extensions state the infix outright, while regular
|
|
1290
|
+
* objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
|
|
1291
|
+
* leading token. Reading 400 of them to settle a 4-name question was paid on the
|
|
1292
|
+
* first call of every session. They cannot be dropped altogether — the underscore
|
|
1293
|
+
* form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
|
|
1294
|
+
* object can decide it.
|
|
1277
1295
|
*/
|
|
1278
1296
|
getModelObjectNames(model, limit = 400) {
|
|
1279
1297
|
if (!model)
|
|
@@ -1285,10 +1303,21 @@ export class XppSymbolIndex {
|
|
|
1285
1303
|
if (cap <= 0)
|
|
1286
1304
|
return [];
|
|
1287
1305
|
const rows = db
|
|
1288
|
-
.prepare(
|
|
1306
|
+
.prepare(
|
|
1307
|
+
// Unary + on parent_name, for the same reason as searchCustomExtensions'
|
|
1308
|
+
// `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
|
|
1309
|
+
// choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
|
|
1310
|
+
// value. NULL is not one value — it is every top-level object of every
|
|
1311
|
+
// model, 180,664 of the 1,188,748 rows on the production DB, against 274
|
|
1312
|
+
// for the one model being asked about. Measured warm: 454 ms on the
|
|
1313
|
+
// parent_name plan, 1 ms on the model plan; cold it is the difference
|
|
1314
|
+
// between a 5-minute first get_workspace_info and an instant one.
|
|
1315
|
+
// EXPLAIN QUERY PLAN must keep reporting
|
|
1316
|
+
// `SEARCH symbols USING INDEX idx_symbols_model`.
|
|
1317
|
+
`SELECT name FROM symbols
|
|
1289
1318
|
WHERE model = ?
|
|
1290
1319
|
AND type ${extensions ? 'LIKE' : 'NOT LIKE'} '%-extension'
|
|
1291
|
-
${extensions ? '' : 'AND parent_name IS NULL'}
|
|
1320
|
+
${extensions ? '' : 'AND +parent_name IS NULL'}
|
|
1292
1321
|
AND type NOT IN ('method', 'field')
|
|
1293
1322
|
ORDER BY type, name
|
|
1294
1323
|
LIMIT ?`)
|
|
@@ -1300,7 +1329,7 @@ export class XppSymbolIndex {
|
|
|
1300
1329
|
// regular objects before the infix evidence is ever read.
|
|
1301
1330
|
const half = Math.max(1, Math.ceil(limit / 2));
|
|
1302
1331
|
const extensionNames = band(true, half);
|
|
1303
|
-
const regularNames = band(false, limit - extensionNames.length);
|
|
1332
|
+
const regularNames = band(false, Math.min(XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length));
|
|
1304
1333
|
return [...extensionNames, ...regularNames];
|
|
1305
1334
|
}
|
|
1306
1335
|
removeSymbolsByFile(filePath) {
|
|
@@ -2271,6 +2271,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2271
2271
|
*
|
|
2272
2272
|
* Returns the names of top-level objects that were removed (for cache invalidation).
|
|
2273
2273
|
*/
|
|
2274
|
+
/**
|
|
2275
|
+
* How many regular (non-extension) object names the prefix sample may draw.
|
|
2276
|
+
*
|
|
2277
|
+
* inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
|
|
2278
|
+
* coverage threshold, so a few dozen settle the question as well as a few hundred
|
|
2279
|
+
* — and this is the band that costs, since a model's extensions are counted in
|
|
2280
|
+
* tens while its classes and tables run to thousands.
|
|
2281
|
+
*/
|
|
2282
|
+
static REGULAR_NAME_SAMPLE = 60;
|
|
2274
2283
|
/**
|
|
2275
2284
|
* Top-level object names belonging to one model — the evidence from which a
|
|
2276
2285
|
* model's naming prefix is inferred (see utils/modelPrefixInference.ts).
|
|
@@ -2302,6 +2311,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2302
2311
|
* so the same model always yields the same sample — a silent, self-reinforcing
|
|
2303
2312
|
* failure otherwise, since this server writes names with the inferred prefix and
|
|
2304
2313
|
* those names become evidence for the next inference.
|
|
2314
|
+
*
|
|
2315
|
+
* The regular band is capped well below its share of the budget
|
|
2316
|
+
* (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
|
|
2317
|
+
* carries most of the signal: extensions state the infix outright, while regular
|
|
2318
|
+
* objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
|
|
2319
|
+
* leading token. Reading 400 of them to settle a 4-name question was paid on the
|
|
2320
|
+
* first call of every session. They cannot be dropped altogether — the underscore
|
|
2321
|
+
* form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
|
|
2322
|
+
* object can decide it.
|
|
2305
2323
|
*/
|
|
2306
2324
|
getModelObjectNames(model, limit = 400) {
|
|
2307
2325
|
if (!model) return [];
|
|
@@ -2310,10 +2328,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2310
2328
|
const band = (extensions, cap) => {
|
|
2311
2329
|
if (cap <= 0) return [];
|
|
2312
2330
|
const rows = db.prepare(
|
|
2331
|
+
// Unary + on parent_name, for the same reason as searchCustomExtensions'
|
|
2332
|
+
// `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
|
|
2333
|
+
// choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
|
|
2334
|
+
// value. NULL is not one value — it is every top-level object of every
|
|
2335
|
+
// model, 180,664 of the 1,188,748 rows on the production DB, against 274
|
|
2336
|
+
// for the one model being asked about. Measured warm: 454 ms on the
|
|
2337
|
+
// parent_name plan, 1 ms on the model plan; cold it is the difference
|
|
2338
|
+
// between a 5-minute first get_workspace_info and an instant one.
|
|
2339
|
+
// EXPLAIN QUERY PLAN must keep reporting
|
|
2340
|
+
// `SEARCH symbols USING INDEX idx_symbols_model`.
|
|
2313
2341
|
`SELECT name FROM symbols
|
|
2314
2342
|
WHERE model = ?
|
|
2315
2343
|
AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
|
|
2316
|
-
${extensions ? "" : "AND parent_name IS NULL"}
|
|
2344
|
+
${extensions ? "" : "AND +parent_name IS NULL"}
|
|
2317
2345
|
AND type NOT IN ('method', 'field')
|
|
2318
2346
|
ORDER BY type, name
|
|
2319
2347
|
LIMIT ?`
|
|
@@ -2322,7 +2350,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2322
2350
|
};
|
|
2323
2351
|
const half = Math.max(1, Math.ceil(limit / 2));
|
|
2324
2352
|
const extensionNames = band(true, half);
|
|
2325
|
-
const regularNames = band(
|
|
2353
|
+
const regularNames = band(
|
|
2354
|
+
false,
|
|
2355
|
+
Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
|
|
2356
|
+
);
|
|
2326
2357
|
return [...extensionNames, ...regularNames];
|
|
2327
2358
|
}
|
|
2328
2359
|
removeSymbolsByFile(filePath) {
|
|
@@ -2210,6 +2210,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2210
2210
|
*
|
|
2211
2211
|
* Returns the names of top-level objects that were removed (for cache invalidation).
|
|
2212
2212
|
*/
|
|
2213
|
+
/**
|
|
2214
|
+
* How many regular (non-extension) object names the prefix sample may draw.
|
|
2215
|
+
*
|
|
2216
|
+
* inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
|
|
2217
|
+
* coverage threshold, so a few dozen settle the question as well as a few hundred
|
|
2218
|
+
* — and this is the band that costs, since a model's extensions are counted in
|
|
2219
|
+
* tens while its classes and tables run to thousands.
|
|
2220
|
+
*/
|
|
2221
|
+
static REGULAR_NAME_SAMPLE = 60;
|
|
2213
2222
|
/**
|
|
2214
2223
|
* Top-level object names belonging to one model — the evidence from which a
|
|
2215
2224
|
* model's naming prefix is inferred (see utils/modelPrefixInference.ts).
|
|
@@ -2241,6 +2250,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2241
2250
|
* so the same model always yields the same sample — a silent, self-reinforcing
|
|
2242
2251
|
* failure otherwise, since this server writes names with the inferred prefix and
|
|
2243
2252
|
* those names become evidence for the next inference.
|
|
2253
|
+
*
|
|
2254
|
+
* The regular band is capped well below its share of the budget
|
|
2255
|
+
* (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
|
|
2256
|
+
* carries most of the signal: extensions state the infix outright, while regular
|
|
2257
|
+
* objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
|
|
2258
|
+
* leading token. Reading 400 of them to settle a 4-name question was paid on the
|
|
2259
|
+
* first call of every session. They cannot be dropped altogether — the underscore
|
|
2260
|
+
* form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
|
|
2261
|
+
* object can decide it.
|
|
2244
2262
|
*/
|
|
2245
2263
|
getModelObjectNames(model, limit = 400) {
|
|
2246
2264
|
if (!model) return [];
|
|
@@ -2249,10 +2267,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2249
2267
|
const band = (extensions, cap) => {
|
|
2250
2268
|
if (cap <= 0) return [];
|
|
2251
2269
|
const rows = db.prepare(
|
|
2270
|
+
// Unary + on parent_name, for the same reason as searchCustomExtensions'
|
|
2271
|
+
// `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
|
|
2272
|
+
// choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
|
|
2273
|
+
// value. NULL is not one value — it is every top-level object of every
|
|
2274
|
+
// model, 180,664 of the 1,188,748 rows on the production DB, against 274
|
|
2275
|
+
// for the one model being asked about. Measured warm: 454 ms on the
|
|
2276
|
+
// parent_name plan, 1 ms on the model plan; cold it is the difference
|
|
2277
|
+
// between a 5-minute first get_workspace_info and an instant one.
|
|
2278
|
+
// EXPLAIN QUERY PLAN must keep reporting
|
|
2279
|
+
// `SEARCH symbols USING INDEX idx_symbols_model`.
|
|
2252
2280
|
`SELECT name FROM symbols
|
|
2253
2281
|
WHERE model = ?
|
|
2254
2282
|
AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
|
|
2255
|
-
${extensions ? "" : "AND parent_name IS NULL"}
|
|
2283
|
+
${extensions ? "" : "AND +parent_name IS NULL"}
|
|
2256
2284
|
AND type NOT IN ('method', 'field')
|
|
2257
2285
|
ORDER BY type, name
|
|
2258
2286
|
LIMIT ?`
|
|
@@ -2261,7 +2289,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2261
2289
|
};
|
|
2262
2290
|
const half = Math.max(1, Math.ceil(limit / 2));
|
|
2263
2291
|
const extensionNames = band(true, half);
|
|
2264
|
-
const regularNames = band(
|
|
2292
|
+
const regularNames = band(
|
|
2293
|
+
false,
|
|
2294
|
+
Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
|
|
2295
|
+
);
|
|
2265
2296
|
return [...extensionNames, ...regularNames];
|
|
2266
2297
|
}
|
|
2267
2298
|
removeSymbolsByFile(filePath) {
|
|
@@ -1381,6 +1381,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1381
1381
|
*
|
|
1382
1382
|
* Returns the names of top-level objects that were removed (for cache invalidation).
|
|
1383
1383
|
*/
|
|
1384
|
+
/**
|
|
1385
|
+
* How many regular (non-extension) object names the prefix sample may draw.
|
|
1386
|
+
*
|
|
1387
|
+
* inferPrefixFromObjectNames needs MIN_SAMPLE (4) of them and decides on a 60 %
|
|
1388
|
+
* coverage threshold, so a few dozen settle the question as well as a few hundred
|
|
1389
|
+
* — and this is the band that costs, since a model's extensions are counted in
|
|
1390
|
+
* tens while its classes and tables run to thousands.
|
|
1391
|
+
*/
|
|
1392
|
+
static REGULAR_NAME_SAMPLE = 60;
|
|
1384
1393
|
/**
|
|
1385
1394
|
* Top-level object names belonging to one model — the evidence from which a
|
|
1386
1395
|
* model's naming prefix is inferred (see utils/modelPrefixInference.ts).
|
|
@@ -1412,6 +1421,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1412
1421
|
* so the same model always yields the same sample — a silent, self-reinforcing
|
|
1413
1422
|
* failure otherwise, since this server writes names with the inferred prefix and
|
|
1414
1423
|
* those names become evidence for the next inference.
|
|
1424
|
+
*
|
|
1425
|
+
* The regular band is capped well below its share of the budget
|
|
1426
|
+
* (REGULAR_NAME_SAMPLE), because it is the expensive half and the cheap half
|
|
1427
|
+
* carries most of the signal: extensions state the infix outright, while regular
|
|
1428
|
+
* objects only have to clear MIN_SAMPLE (4) and MIN_COVERAGE (60 %) for the
|
|
1429
|
+
* leading token. Reading 400 of them to settle a 4-name question was paid on the
|
|
1430
|
+
* first call of every session. They cannot be dropped altogether — the underscore
|
|
1431
|
+
* form ("ConSK_" vs "ConSK") appears in no extension name, so only a regular
|
|
1432
|
+
* object can decide it.
|
|
1415
1433
|
*/
|
|
1416
1434
|
getModelObjectNames(model, limit = 400) {
|
|
1417
1435
|
if (!model) return [];
|
|
@@ -1420,10 +1438,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1420
1438
|
const band = (extensions, cap) => {
|
|
1421
1439
|
if (cap <= 0) return [];
|
|
1422
1440
|
const rows = db.prepare(
|
|
1441
|
+
// Unary + on parent_name, for the same reason as searchCustomExtensions'
|
|
1442
|
+
// `+type IN (…)`: written plainly, `parent_name IS NULL` makes the planner
|
|
1443
|
+
// choose idx_symbols_parent_name, whose ANALYZE stats claim ~13 rows per
|
|
1444
|
+
// value. NULL is not one value — it is every top-level object of every
|
|
1445
|
+
// model, 180,664 of the 1,188,748 rows on the production DB, against 274
|
|
1446
|
+
// for the one model being asked about. Measured warm: 454 ms on the
|
|
1447
|
+
// parent_name plan, 1 ms on the model plan; cold it is the difference
|
|
1448
|
+
// between a 5-minute first get_workspace_info and an instant one.
|
|
1449
|
+
// EXPLAIN QUERY PLAN must keep reporting
|
|
1450
|
+
// `SEARCH symbols USING INDEX idx_symbols_model`.
|
|
1423
1451
|
`SELECT name FROM symbols
|
|
1424
1452
|
WHERE model = ?
|
|
1425
1453
|
AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
|
|
1426
|
-
${extensions ? "" : "AND parent_name IS NULL"}
|
|
1454
|
+
${extensions ? "" : "AND +parent_name IS NULL"}
|
|
1427
1455
|
AND type NOT IN ('method', 'field')
|
|
1428
1456
|
ORDER BY type, name
|
|
1429
1457
|
LIMIT ?`
|
|
@@ -1432,7 +1460,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
1432
1460
|
};
|
|
1433
1461
|
const half = Math.max(1, Math.ceil(limit / 2));
|
|
1434
1462
|
const extensionNames = band(true, half);
|
|
1435
|
-
const regularNames = band(
|
|
1463
|
+
const regularNames = band(
|
|
1464
|
+
false,
|
|
1465
|
+
Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
|
|
1466
|
+
);
|
|
1436
1467
|
return [...extensionNames, ...regularNames];
|
|
1437
1468
|
}
|
|
1438
1469
|
removeSymbolsByFile(filePath) {
|
|
@@ -29,6 +29,29 @@
|
|
|
29
29
|
* created without an Azure re-deploy.
|
|
30
30
|
*/
|
|
31
31
|
export declare const LOCAL_TOOLS: Set<string>;
|
|
32
|
+
/**
|
|
33
|
+
* The LOCAL_TOOLS that do read the symbol database after all, and so must NOT
|
|
34
|
+
* inherit this set's exemption from the dbReady wait in toolHandler.
|
|
35
|
+
*
|
|
36
|
+
* "Local" answers a question about LOCALITY — can this run away from the K:\ drive
|
|
37
|
+
* — and the dbReady exemption rode along on it as if it also meant "needs no
|
|
38
|
+
* index". For two of them it does not:
|
|
39
|
+
*
|
|
40
|
+
* • get_workspace_info infers the model's prefix from symbols.getModelObjectNames
|
|
41
|
+
* and reads getLastIndexedAt. Exempt, it had no timeout either: while the 2.5 GB
|
|
42
|
+
* database opened, the tool a session STARTS with sat there with no answer and
|
|
43
|
+
* no way to fail. Measured live 2026-09-07: 337.5 s, and 356 s / 424 s on two
|
|
44
|
+
* earlier cold starts, where every other tool would have said "still loading,
|
|
45
|
+
* retry" after 55 s. It also answered from the empty stub index when it did
|
|
46
|
+
* return early — a configured prefix reported as if the model had taught it.
|
|
47
|
+
* • update_symbol_index writes THROUGH context.symbolIndex (removeSymbolsByFile,
|
|
48
|
+
* removeLabelsByFile). Before the swap that is the :memory: stub, so the call
|
|
49
|
+
* reports a successful re-index of a database nobody will ever read.
|
|
50
|
+
*
|
|
51
|
+
* The rest (build, bp-check, systest, verify) genuinely only touch the filesystem
|
|
52
|
+
* and the compiler, and keep the exemption.
|
|
53
|
+
*/
|
|
54
|
+
export declare const DB_BACKED_LOCAL_TOOLS: Set<string>;
|
|
32
55
|
/**
|
|
33
56
|
* Tools exposed in EVERY server mode, bypassing the LOCAL_TOOLS partition,
|
|
34
57
|
* because each spans both localities and gates unavailable actions at runtime:
|
|
@@ -36,6 +36,32 @@ export const LOCAL_TOOLS = new Set([
|
|
|
36
36
|
'run_systest_class',
|
|
37
37
|
'get_workspace_info',
|
|
38
38
|
]);
|
|
39
|
+
/**
|
|
40
|
+
* The LOCAL_TOOLS that do read the symbol database after all, and so must NOT
|
|
41
|
+
* inherit this set's exemption from the dbReady wait in toolHandler.
|
|
42
|
+
*
|
|
43
|
+
* "Local" answers a question about LOCALITY — can this run away from the K:\ drive
|
|
44
|
+
* — and the dbReady exemption rode along on it as if it also meant "needs no
|
|
45
|
+
* index". For two of them it does not:
|
|
46
|
+
*
|
|
47
|
+
* • get_workspace_info infers the model's prefix from symbols.getModelObjectNames
|
|
48
|
+
* and reads getLastIndexedAt. Exempt, it had no timeout either: while the 2.5 GB
|
|
49
|
+
* database opened, the tool a session STARTS with sat there with no answer and
|
|
50
|
+
* no way to fail. Measured live 2026-09-07: 337.5 s, and 356 s / 424 s on two
|
|
51
|
+
* earlier cold starts, where every other tool would have said "still loading,
|
|
52
|
+
* retry" after 55 s. It also answered from the empty stub index when it did
|
|
53
|
+
* return early — a configured prefix reported as if the model had taught it.
|
|
54
|
+
* • update_symbol_index writes THROUGH context.symbolIndex (removeSymbolsByFile,
|
|
55
|
+
* removeLabelsByFile). Before the swap that is the :memory: stub, so the call
|
|
56
|
+
* reports a successful re-index of a database nobody will ever read.
|
|
57
|
+
*
|
|
58
|
+
* The rest (build, bp-check, systest, verify) genuinely only touch the filesystem
|
|
59
|
+
* and the compiler, and keep the exemption.
|
|
60
|
+
*/
|
|
61
|
+
export const DB_BACKED_LOCAL_TOOLS = new Set([
|
|
62
|
+
'get_workspace_info',
|
|
63
|
+
'update_symbol_index',
|
|
64
|
+
]);
|
|
39
65
|
/**
|
|
40
66
|
* Tools exposed in EVERY server mode, bypassing the LOCAL_TOOLS partition,
|
|
41
67
|
* because each spans both localities and gates unavailable actions at runtime:
|
|
@@ -72,7 +72,7 @@ function rowKey(r) {
|
|
|
72
72
|
* the 2026-08-07 demo; `action="search"` never did, and search is the call an
|
|
73
73
|
* agent makes BEFORE it reuses a label. Benchmark run d79f62a3 (2026-08-17) took
|
|
74
74
|
* all three labels it needed from one search — the enum's, the field's and the
|
|
75
|
-
* error message's — all reported as resolvable [
|
|
75
|
+
* error message's — all reported as resolvable [ContosoFinanceSK] hits, none of them
|
|
76
76
|
* on disk. `xppc` does not check labels, so the first build passed; the run paid
|
|
77
77
|
* a second build, a second BP check and ~12 AIU to find out.
|
|
78
78
|
*
|
|
@@ -244,6 +244,11 @@ async function readObject(ref, context, allowTypeCorrection = true) {
|
|
|
244
244
|
if (options?.include === 'xml') {
|
|
245
245
|
const xml = await readObjectXml(objectType, name, {
|
|
246
246
|
modelName: options.modelName,
|
|
247
|
+
// Without this the lookup only ever sees the CONFIGURED model's folder, so
|
|
248
|
+
// every read of a Microsoft or other-model object came back as "pass
|
|
249
|
+
// options.modelName" — a round trip for a fact this same tool prints in
|
|
250
|
+
// every other include mode.
|
|
251
|
+
index: context.symbolIndex,
|
|
247
252
|
startLine: options.startLine,
|
|
248
253
|
endLine: options.endLine,
|
|
249
254
|
maxChars: options.maxChars,
|
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
* it. A recursive scan of PackagesLocalDirectory costs seconds; this is one
|
|
9
9
|
* indexed lookup.
|
|
10
10
|
*/
|
|
11
|
+
import { type SymbolFileLookupSource } from '../../utils/objectFileLookup.js';
|
|
11
12
|
export interface ObjectXmlOptions {
|
|
12
13
|
modelName?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The symbol index, when the caller has one. Without it this falls back to the
|
|
16
|
+
* configured model's folder layout, which only ever finds objects in that model.
|
|
17
|
+
*/
|
|
18
|
+
index?: SymbolFileLookupSource;
|
|
13
19
|
/** 1-based, inclusive. Omit both for the whole file (up to maxChars). */
|
|
14
20
|
startLine?: number;
|
|
15
21
|
endLine?: number;
|
|
@@ -21,7 +27,16 @@ export interface ObjectXmlResult {
|
|
|
21
27
|
}
|
|
22
28
|
/** Render a file already located. Split out so it is testable without config. */
|
|
23
29
|
export declare function renderObjectXml(filePath: string, objectType: string, objectName: string, options?: ObjectXmlOptions): Promise<ObjectXmlResult>;
|
|
24
|
-
/**
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The message for an object with no file — never an empty result.
|
|
32
|
+
*
|
|
33
|
+
* `elsewhere` are the models the index says DO hold an object of this name. Naming
|
|
34
|
+
* them is the whole difference between a message the caller can act on and one it
|
|
35
|
+
* can only guess at: "pass options.modelName" sent an agent looking for
|
|
36
|
+
* JournalVoucherNum in "Application Foundation" (wrong) before "Foundation"
|
|
37
|
+
* (right), three calls for one file. An empty list is itself an answer — no model
|
|
38
|
+
* has it, so retrying with a different modelName cannot help.
|
|
39
|
+
*/
|
|
40
|
+
export declare function objectXmlNotFound(objectType: string, objectName: string, modelName?: string, elsewhere?: string[]): ObjectXmlResult;
|
|
26
41
|
export declare function readObjectXml(objectType: string, objectName: string, options?: ObjectXmlOptions): Promise<ObjectXmlResult>;
|
|
27
42
|
//# sourceMappingURL=objectXml.d.ts.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* indexed lookup.
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from 'fs/promises';
|
|
12
|
-
import { findD365FileOnDisk } from '../../utils/objectFileLookup.js';
|
|
12
|
+
import { findD365FileOnDisk, findD365FileViaIndex, modelsHoldingObject, } from '../../utils/objectFileLookup.js';
|
|
13
13
|
/** Default ceiling on returned XML. A large form is well past any useful read. */
|
|
14
14
|
const DEFAULT_MAX_CHARS = 40_000;
|
|
15
15
|
/** Render a file already located. Split out so it is testable without config. */
|
|
@@ -40,19 +40,45 @@ export async function renderObjectXml(filePath, objectType, objectName, options
|
|
|
40
40
|
: '';
|
|
41
41
|
return { isError: false, text: `${header}\n\`\`\`xml\n${body}\n\`\`\`${footer}` };
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The message for an object with no file — never an empty result.
|
|
45
|
+
*
|
|
46
|
+
* `elsewhere` are the models the index says DO hold an object of this name. Naming
|
|
47
|
+
* them is the whole difference between a message the caller can act on and one it
|
|
48
|
+
* can only guess at: "pass options.modelName" sent an agent looking for
|
|
49
|
+
* JournalVoucherNum in "Application Foundation" (wrong) before "Foundation"
|
|
50
|
+
* (right), three calls for one file. An empty list is itself an answer — no model
|
|
51
|
+
* has it, so retrying with a different modelName cannot help.
|
|
52
|
+
*/
|
|
53
|
+
export function objectXmlNotFound(objectType, objectName, modelName, elsewhere = []) {
|
|
54
|
+
const where = elsewhere.filter(m => m !== modelName);
|
|
55
|
+
const hint = where.length > 0
|
|
56
|
+
? `The index has it in ${where.map(m => `"${m}"`).join(' · ')} — retry with ` +
|
|
57
|
+
`options.modelName="${where[0]}". If that also misses, the index row is stale ` +
|
|
58
|
+
`(the file was moved or deleted since the last index run).`
|
|
59
|
+
: `No model in the symbol index holds a ${objectType} of that name, so retrying with ` +
|
|
60
|
+
`a different options.modelName will not help — check the spelling and the ` +
|
|
61
|
+
`objectType, or the object does not exist yet.`;
|
|
45
62
|
return {
|
|
46
63
|
isError: true,
|
|
47
64
|
text: `❌ get_object_info(include="xml"): no file on disk for ${objectType} "${objectName}"` +
|
|
48
|
-
`${modelName ? ` in model "${modelName}"` : ''}.\n
|
|
49
|
-
`The object may live in another model — pass options.modelName — or may not exist yet.`,
|
|
65
|
+
`${modelName ? ` in model "${modelName}"` : ''}.\n${hint}`,
|
|
50
66
|
};
|
|
51
67
|
}
|
|
52
68
|
export async function readObjectXml(objectType, objectName, options = {}) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
// Configured-model layout first: it is a couple of fs.access calls, it is what a
|
|
70
|
+
// freshly created object (not yet indexed) resolves through, and it keeps the
|
|
71
|
+
// common case — reading something in the model being worked in — off the DB.
|
|
72
|
+
const onDisk = await findD365FileOnDisk(objectType, objectName, options.modelName);
|
|
73
|
+
if (onDisk)
|
|
74
|
+
return renderObjectXml(onDisk, objectType, objectName, options);
|
|
75
|
+
// Then the index, which knows every model rather than just the configured one.
|
|
76
|
+
if (options.index) {
|
|
77
|
+
const indexed = await findD365FileViaIndex(options.index, objectType, objectName, options.modelName);
|
|
78
|
+
if (indexed)
|
|
79
|
+
return renderObjectXml(indexed.filePath, objectType, objectName, options);
|
|
80
|
+
return objectXmlNotFound(objectType, objectName, options.modelName, modelsHoldingObject(options.index, objectType, objectName));
|
|
81
|
+
}
|
|
82
|
+
return objectXmlNotFound(objectType, objectName, options.modelName);
|
|
57
83
|
}
|
|
58
84
|
//# sourceMappingURL=objectXml.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { getConfigManager } from '../utils/configManager.js';
|
|
3
|
-
import { SERVER_MODE, LOCAL_TOOLS, TOOL_PROFILE, isToolAllowedInMode, isToolInProfile, } from '../server/serverMode.js';
|
|
3
|
+
import { SERVER_MODE, LOCAL_TOOLS, DB_BACKED_LOCAL_TOOLS, TOOL_PROFILE, isToolAllowedInMode, isToolInProfile, } from '../server/serverMode.js';
|
|
4
4
|
import { BRIDGE_BACKED_TOOLS, awaitBridgeReady } from '../bridge/bridgeReadiness.js';
|
|
5
5
|
import { BRIDGE_FAILURE_MARKER, runWithBridgeFailureScope, renderBridgeFailureNote, } from '../bridge/bridgeFailure.js';
|
|
6
6
|
import { runWithSideEffectScope, renderSideEffectNote, } from '../utils/writeSideEffects.js';
|
|
@@ -40,7 +40,7 @@ import { capToolResponse } from './responseCaps.js';
|
|
|
40
40
|
*/
|
|
41
41
|
const WRITE_CAPABLE_TOOLS = new Set(['d365fo_file', 'labels']);
|
|
42
42
|
import { buildProgressMessage } from '../utils/toolProgressMessage.js';
|
|
43
|
-
import { createProgressReporter } from '../utils/progressReporter.js';
|
|
43
|
+
import { createProgressReporter, startProgressHeartbeat } from '../utils/progressReporter.js';
|
|
44
44
|
/**
|
|
45
45
|
* Extract workspace path from GitHub Copilot _meta.
|
|
46
46
|
* HTTP requests must not overwrite the shared runtimeContext (AsyncLocalStorage
|
|
@@ -101,6 +101,8 @@ function extractWorkspaceFromMeta(meta) {
|
|
|
101
101
|
* The bridge-readiness wait below is untouched — different, much shorter gate.
|
|
102
102
|
*/
|
|
103
103
|
const DB_FREE_TOOLS = new Set(['get_knowledge']);
|
|
104
|
+
/** Tools that drive the progress reporter themselves, and must not be doubled up on. */
|
|
105
|
+
const SELF_REPORTING_TOOLS = new Set(['build_d365fo_project']);
|
|
104
106
|
export function registerToolHandler(server, context) {
|
|
105
107
|
startMetricsLogging();
|
|
106
108
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
@@ -125,8 +127,12 @@ export function registerToolHandler(server, context) {
|
|
|
125
127
|
// ctx.dbReady resolves once the real symbol database is loaded; await it so
|
|
126
128
|
// tools use the real index instead of silently returning empty results.
|
|
127
129
|
// LOCAL_TOOLS need no DB (filesystem/in-memory config only) and skip the
|
|
128
|
-
// wait
|
|
129
|
-
|
|
130
|
+
// wait — except DB_BACKED_LOCAL_TOOLS, which are local AND read the index, and
|
|
131
|
+
// whose exemption cost them the 55 s ceiling too. DB_FREE_TOOLS also skip it,
|
|
132
|
+
// their answer being in-repo static data.
|
|
133
|
+
const skipsDbWait = (LOCAL_TOOLS.has(toolName) && !DB_BACKED_LOCAL_TOOLS.has(toolName)) ||
|
|
134
|
+
DB_FREE_TOOLS.has(toolName);
|
|
135
|
+
if (context.dbReady && !skipsDbWait) {
|
|
130
136
|
const t0 = Date.now();
|
|
131
137
|
// Race dbReady against a 55-second timeout so VS Code's ~60 s client
|
|
132
138
|
// timeout doesn't silently cancel the request. If the DB is still loading
|
|
@@ -268,6 +274,10 @@ export function registerToolHandler(server, context) {
|
|
|
268
274
|
// The reporter never rejects (both sends are try/caught inside), and the
|
|
269
275
|
// transport writes in call order, so the notification still precedes the result.
|
|
270
276
|
void reportProgress(progressMsg, 0);
|
|
277
|
+
// …and then nothing, for however long the tool takes — see startProgressHeartbeat.
|
|
278
|
+
const stopHeartbeat = SELF_REPORTING_TOOLS.has(toolName)
|
|
279
|
+
? null
|
|
280
|
+
: startProgressHeartbeat(reportProgress, progressMsg);
|
|
271
281
|
return (async () => {
|
|
272
282
|
switch (toolName) {
|
|
273
283
|
case 'search':
|
|
@@ -340,7 +350,7 @@ export function registerToolHandler(server, context) {
|
|
|
340
350
|
isError: true,
|
|
341
351
|
};
|
|
342
352
|
}
|
|
343
|
-
})();
|
|
353
|
+
})().finally(() => stopHeartbeat?.());
|
|
344
354
|
}));
|
|
345
355
|
}
|
|
346
356
|
catch (err) {
|
|
@@ -406,7 +406,7 @@ function extensionAddedFields(deps, tableName) {
|
|
|
406
406
|
return names;
|
|
407
407
|
}
|
|
408
408
|
/**
|
|
409
|
-
* `
|
|
409
|
+
* `ConSK_QualityTier` → `qualitytier`. A member added to another model's
|
|
410
410
|
* object carries that model's prefix (applyExtensionMemberPrefix mints it), so
|
|
411
411
|
* the name in the agent's X++ and the name on disk routinely differ by exactly
|
|
412
412
|
* this token.
|
|
@@ -34,6 +34,7 @@ export declare function findNewestMetadataMtime(rootDir: string): MtimeScanResul
|
|
|
34
34
|
export type MtimeScanState = {
|
|
35
35
|
status: 'ready';
|
|
36
36
|
result: MtimeScanResult | null;
|
|
37
|
+
scannedAt: number;
|
|
37
38
|
} | {
|
|
38
39
|
status: 'pending';
|
|
39
40
|
};
|
|
@@ -42,9 +43,16 @@ export type MtimeScanState = {
|
|
|
42
43
|
*
|
|
43
44
|
* `blocking: true` is the old behaviour — scan now, answer now — and is what
|
|
44
45
|
* `diagnostics: true` uses, because the whole point of diagnostics is the full
|
|
45
|
-
* picture. `blocking: false` answers from cache
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* picture. `blocking: false` answers from cache and schedules the walk on a later
|
|
47
|
+
* tick, so the request path never carries it.
|
|
48
|
+
*
|
|
49
|
+
* 'pending' is returned only while NO scan has ever completed for this root. Once
|
|
50
|
+
* one has, an expired entry is served — with the time it was taken — and a refresh
|
|
51
|
+
* is scheduled behind it. Expiring back to 'pending' made the "call again for the
|
|
52
|
+
* verdict" the caller is told to act on unwinnable: the entry lives SCAN_CACHE_MS
|
|
53
|
+
* (30 s) and an agent that re-asks any later than that gets the identical
|
|
54
|
+
* "still running" line, forever. Observed live on 2026-09-07: three
|
|
55
|
+
* get_workspace_info calls 105 s apart, byte-identical output every time.
|
|
48
56
|
*/
|
|
49
57
|
export declare function findNewestMetadataMtimeCached(rootDir: string, opts?: {
|
|
50
58
|
blocking?: boolean;
|
|
@@ -47,16 +47,27 @@ export function findNewestMetadataMtime(rootDir) {
|
|
|
47
47
|
*
|
|
48
48
|
* `blocking: true` is the old behaviour — scan now, answer now — and is what
|
|
49
49
|
* `diagnostics: true` uses, because the whole point of diagnostics is the full
|
|
50
|
-
* picture. `blocking: false` answers from cache
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* picture. `blocking: false` answers from cache and schedules the walk on a later
|
|
51
|
+
* tick, so the request path never carries it.
|
|
52
|
+
*
|
|
53
|
+
* 'pending' is returned only while NO scan has ever completed for this root. Once
|
|
54
|
+
* one has, an expired entry is served — with the time it was taken — and a refresh
|
|
55
|
+
* is scheduled behind it. Expiring back to 'pending' made the "call again for the
|
|
56
|
+
* verdict" the caller is told to act on unwinnable: the entry lives SCAN_CACHE_MS
|
|
57
|
+
* (30 s) and an agent that re-asks any later than that gets the identical
|
|
58
|
+
* "still running" line, forever. Observed live on 2026-09-07: three
|
|
59
|
+
* get_workspace_info calls 105 s apart, byte-identical output every time.
|
|
53
60
|
*/
|
|
54
61
|
export function findNewestMetadataMtimeCached(rootDir, opts = {}) {
|
|
55
62
|
const hit = scanCache.get(rootDir);
|
|
56
|
-
if (hit && Date.now() - hit.at < SCAN_CACHE_MS)
|
|
57
|
-
return { status: 'ready', result: hit.result };
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
if (hit && Date.now() - hit.at < SCAN_CACHE_MS) {
|
|
64
|
+
return { status: 'ready', result: hit.result, scannedAt: hit.at };
|
|
65
|
+
}
|
|
66
|
+
if (opts.blocking) {
|
|
67
|
+
findNewestMetadataMtime(rootDir);
|
|
68
|
+
const fresh = scanCache.get(rootDir);
|
|
69
|
+
return { status: 'ready', result: fresh.result, scannedAt: fresh.at };
|
|
70
|
+
}
|
|
60
71
|
if (!scanInFlight.has(rootDir)) {
|
|
61
72
|
scanInFlight.add(rootDir);
|
|
62
73
|
// setTimeout, not a worker: the walk is synchronous fs work either way, but
|
|
@@ -75,6 +86,11 @@ export function findNewestMetadataMtimeCached(rootDir, opts = {}) {
|
|
|
75
86
|
}, 0);
|
|
76
87
|
timer.unref?.();
|
|
77
88
|
}
|
|
89
|
+
// An expired entry still answers the question better than "ask again" does; the
|
|
90
|
+
// refresh just scheduled replaces it, and checkIndexStaleness qualifies a 'fresh'
|
|
91
|
+
// verdict drawn from an aged scan.
|
|
92
|
+
if (hit)
|
|
93
|
+
return { status: 'ready', result: hit.result, scannedAt: hit.at };
|
|
78
94
|
return { status: 'pending' };
|
|
79
95
|
}
|
|
80
96
|
function scanNewestMetadataMtime(rootDir) {
|
|
@@ -176,6 +192,10 @@ export function checkIndexStaleness(lastIndexedAt, modelMetadataDir, opts = {})
|
|
|
176
192
|
};
|
|
177
193
|
}
|
|
178
194
|
const scan = state.result;
|
|
195
|
+
// How old the walk behind this verdict is. Zero on the blocking path and on a
|
|
196
|
+
// cache hit inside SCAN_CACHE_MS; larger only when an expired entry is being
|
|
197
|
+
// served while its replacement runs — see findNewestMetadataMtimeCached.
|
|
198
|
+
const scanAgeMs = Date.now() - state.scannedAt;
|
|
179
199
|
if (!scan) {
|
|
180
200
|
lines.push(`ℹ️ No metadata files found under ${modelMetadataDir} — nothing to compare.`);
|
|
181
201
|
return {
|
|
@@ -197,6 +217,20 @@ export function checkIndexStaleness(lastIndexedAt, modelMetadataDir, opts = {})
|
|
|
197
217
|
],
|
|
198
218
|
};
|
|
199
219
|
}
|
|
220
|
+
// A 'stale' verdict from an aged scan is still true — files only ever get newer —
|
|
221
|
+
// so only 'fresh' has to admit what it did not look at. TOLERANCE_MS is the window
|
|
222
|
+
// the comparison already forgives, so a scan younger than that adds no blind spot.
|
|
223
|
+
if (scanAgeMs > TOLERANCE_MS) {
|
|
224
|
+
const scanAgeMin = Math.round(scanAgeMs / 60_000);
|
|
225
|
+
lines.push(`✅ No workspace file was newer than the index as of the last scan (${scanAgeMin} min ago).`, ' A rescan is running in the background; call again for the current verdict.');
|
|
226
|
+
return {
|
|
227
|
+
status: 'fresh',
|
|
228
|
+
lines,
|
|
229
|
+
compactLines: [
|
|
230
|
+
`Index : up to date as of ${scanAgeMin} min ago (indexed ${ageHours} h ago, rescan running)`,
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
200
234
|
lines.push('✅ Index is up to date with the workspace.');
|
|
201
235
|
return {
|
|
202
236
|
status: 'fresh',
|
|
@@ -12,6 +12,50 @@
|
|
|
12
12
|
* This handles objects that were just created and are not yet indexed in the symbol database.
|
|
13
13
|
*/
|
|
14
14
|
export declare function findD365FileOnDisk(objectType: string, objectName: string, modelName?: string, explicitPackagePath?: string): Promise<string | null>;
|
|
15
|
+
/**
|
|
16
|
+
* The read surface findD365FileViaIndex needs, stated structurally so a util does
|
|
17
|
+
* not have to import the symbol index (and, through it, half the server) to ask
|
|
18
|
+
* one question of it. XppSymbolIndex satisfies it as-is.
|
|
19
|
+
*/
|
|
20
|
+
export interface SymbolFileLookupSource {
|
|
21
|
+
getReadDb(): {
|
|
22
|
+
prepare(sql: string): {
|
|
23
|
+
all(...params: any[]): any[];
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Where the index says an object lives, once the path has been verified on disk. */
|
|
28
|
+
export interface IndexedObjectFile {
|
|
29
|
+
filePath: string;
|
|
30
|
+
model: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Locate an object's XML through the SYMBOL INDEX rather than through the
|
|
34
|
+
* configured model's folder layout.
|
|
35
|
+
*
|
|
36
|
+
* findD365FileOnDisk answers "where would an object of this name be, in the model
|
|
37
|
+
* I am configured for" — which is the right question for a write and the wrong one
|
|
38
|
+
* for a read. Reads span every model: get_object_info(include="xml") on a Microsoft
|
|
39
|
+
* class, or on another custom model's class, hit the model-shaped lookup, missed,
|
|
40
|
+
* and were answered with "pass options.modelName" — a whole round trip to supply a
|
|
41
|
+
* fact the same server had already printed ("**Model:** Foundation") one call
|
|
42
|
+
* earlier, and one the caller then has to GUESS. Observed 2026-09-07: three such
|
|
43
|
+
* pairs in one session, one of which took three calls because the guess
|
|
44
|
+
* ("Application Foundation") was wrong.
|
|
45
|
+
*
|
|
46
|
+
* The index stores some paths absolute and some relative to a packages root (582
|
|
47
|
+
* of 60,918 classes on the production DB), so a relative row is resolved against
|
|
48
|
+
* the same roots findD365FileOnDisk prefers. Every candidate is checked on disk
|
|
49
|
+
* before it is returned: an index row whose file is gone is exactly the stale
|
|
50
|
+
* state `search` already warns about, and returning its path would turn a clean
|
|
51
|
+
* "not found" into "found it, but could not read it".
|
|
52
|
+
*/
|
|
53
|
+
export declare function findD365FileViaIndex(index: SymbolFileLookupSource, objectType: string, objectName: string, modelName?: string): Promise<IndexedObjectFile | null>;
|
|
54
|
+
/**
|
|
55
|
+
* The models that hold an object of this type and name, for a "not found" message
|
|
56
|
+
* that can name the answer instead of asking the caller to guess it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function modelsHoldingObject(index: SymbolFileLookupSource, objectType: string, objectName: string, limit?: number): string[];
|
|
15
59
|
/**
|
|
16
60
|
* The path an object's XML WOULD have, whether or not it exists yet.
|
|
17
61
|
*
|
|
@@ -147,6 +147,92 @@ export async function findD365FileOnDisk(objectType, objectName, modelName, expl
|
|
|
147
147
|
}
|
|
148
148
|
return null;
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Locate an object's XML through the SYMBOL INDEX rather than through the
|
|
152
|
+
* configured model's folder layout.
|
|
153
|
+
*
|
|
154
|
+
* findD365FileOnDisk answers "where would an object of this name be, in the model
|
|
155
|
+
* I am configured for" — which is the right question for a write and the wrong one
|
|
156
|
+
* for a read. Reads span every model: get_object_info(include="xml") on a Microsoft
|
|
157
|
+
* class, or on another custom model's class, hit the model-shaped lookup, missed,
|
|
158
|
+
* and were answered with "pass options.modelName" — a whole round trip to supply a
|
|
159
|
+
* fact the same server had already printed ("**Model:** Foundation") one call
|
|
160
|
+
* earlier, and one the caller then has to GUESS. Observed 2026-09-07: three such
|
|
161
|
+
* pairs in one session, one of which took three calls because the guess
|
|
162
|
+
* ("Application Foundation") was wrong.
|
|
163
|
+
*
|
|
164
|
+
* The index stores some paths absolute and some relative to a packages root (582
|
|
165
|
+
* of 60,918 classes on the production DB), so a relative row is resolved against
|
|
166
|
+
* the same roots findD365FileOnDisk prefers. Every candidate is checked on disk
|
|
167
|
+
* before it is returned: an index row whose file is gone is exactly the stale
|
|
168
|
+
* state `search` already warns about, and returning its path would turn a clean
|
|
169
|
+
* "not found" into "found it, but could not read it".
|
|
170
|
+
*/
|
|
171
|
+
export async function findD365FileViaIndex(index, objectType, objectName, modelName) {
|
|
172
|
+
// Types with no folder of their own are not objects this can read a file for.
|
|
173
|
+
if (!AOT_FOLDER_BY_OBJECT_TYPE[objectType])
|
|
174
|
+
return null;
|
|
175
|
+
let rows;
|
|
176
|
+
try {
|
|
177
|
+
const db = index.getReadDb();
|
|
178
|
+
// parent_name IS NULL keeps methods and fields of the same name out; the
|
|
179
|
+
// (type, name) index carries this lookup, so it costs a seek, not a scan.
|
|
180
|
+
const sql = `SELECT file_path, model FROM symbols
|
|
181
|
+
WHERE type = ? AND name = ? AND parent_name IS NULL` +
|
|
182
|
+
(modelName ? ` AND model = ?` : ``) +
|
|
183
|
+
` LIMIT 20`;
|
|
184
|
+
const params = modelName ? [objectType, objectName, modelName] : [objectType, objectName];
|
|
185
|
+
rows = db.prepare(sql).all(...params);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null; // index unavailable (stub context during startup, :memory:) — caller falls back
|
|
189
|
+
}
|
|
190
|
+
if (rows.length === 0)
|
|
191
|
+
return null;
|
|
192
|
+
// Resolved on first need: most rows carry an absolute path (60,336 of 60,918
|
|
193
|
+
// classes on the production DB), and those need no config at all.
|
|
194
|
+
let roots = null;
|
|
195
|
+
const packageRoots = async () => {
|
|
196
|
+
if (roots)
|
|
197
|
+
return roots;
|
|
198
|
+
const configManager = getConfigManager();
|
|
199
|
+
await configManager.ensureLoaded();
|
|
200
|
+
roots = [
|
|
201
|
+
await configManager.getCustomPackagesPath(),
|
|
202
|
+
configManager.getPackagePath() || fallbackPackagePath(),
|
|
203
|
+
await configManager.getMicrosoftPackagesPath(),
|
|
204
|
+
].filter((r) => !!r);
|
|
205
|
+
return roots;
|
|
206
|
+
};
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
const candidates = path.isAbsolute(row.file_path)
|
|
209
|
+
? [row.file_path]
|
|
210
|
+
: (await packageRoots()).map(root => path.join(root, row.file_path));
|
|
211
|
+
for (const candidate of candidates) {
|
|
212
|
+
try {
|
|
213
|
+
await fs.access(candidate);
|
|
214
|
+
return { filePath: candidate, model: row.model };
|
|
215
|
+
}
|
|
216
|
+
catch { /* next candidate */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The models that hold an object of this type and name, for a "not found" message
|
|
223
|
+
* that can name the answer instead of asking the caller to guess it.
|
|
224
|
+
*/
|
|
225
|
+
export function modelsHoldingObject(index, objectType, objectName, limit = 5) {
|
|
226
|
+
try {
|
|
227
|
+
const rows = index.getReadDb().prepare(`SELECT DISTINCT model FROM symbols
|
|
228
|
+
WHERE type = ? AND name = ? AND parent_name IS NULL
|
|
229
|
+
ORDER BY model LIMIT ?`).all(objectType, objectName, limit);
|
|
230
|
+
return rows.map(r => r.model);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
150
236
|
/**
|
|
151
237
|
* The path an object's XML WOULD have, whether or not it exists yet.
|
|
152
238
|
*
|
|
@@ -54,7 +54,7 @@ const UNDERSCORE_EXTENSION = /^[A-Za-z]\w*_Extension$/;
|
|
|
54
54
|
* Reinterpret `objectType` when the proposed name is unmistakably an extension.
|
|
55
55
|
*
|
|
56
56
|
* Callers reach for the base type — "is this a valid *form* name?" — while proposing
|
|
57
|
-
* `
|
|
57
|
+
* `ConCore_TaxTransReportChangeLog.ConSKExtension`. Validated as a plain form
|
|
58
58
|
* that trips the "non-extension objects must not contain underscores" rule and comes
|
|
59
59
|
* back as a hard ERROR, which is both wrong and a wasted round trip: run f2e7b71a
|
|
60
60
|
* asked with `form`, was refused, and asked again with `form-extension` (T56 → T59).
|
|
@@ -23,6 +23,31 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
23
23
|
* for a tool whose total is unknown. `total` may be omitted for open-ended work.
|
|
24
24
|
*/
|
|
25
25
|
export type ProgressReporter = (message: string, progress: number, total?: number) => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* How often an in-flight tool call re-announces itself.
|
|
28
|
+
*
|
|
29
|
+
* Long enough that a normal call never sends one, short enough to stay inside the
|
|
30
|
+
* request timeouts clients reset on each notification (VS Code's is ~60 s).
|
|
31
|
+
*/
|
|
32
|
+
export declare const PROGRESS_HEARTBEAT_MS = 15000;
|
|
33
|
+
/**
|
|
34
|
+
* Keep saying "still running" until the returned stop() is called.
|
|
35
|
+
*
|
|
36
|
+
* The dispatcher sends one notification when a tool starts, and then nothing for
|
|
37
|
+
* however long it takes — so a call that runs for minutes is indistinguishable from
|
|
38
|
+
* a hung one. On 2026-09-07 a first get_workspace_info ran 337.5 s behind a cold
|
|
39
|
+
* database open: the IDE showed "⚙️ Reading workspace configuration" and no further
|
|
40
|
+
* sign of life for five and a half minutes, and the server log (which mirrors
|
|
41
|
+
* stderr, and startup progress does not reach stderr) held nothing either.
|
|
42
|
+
*
|
|
43
|
+
* A tick says the call is alive and how long it has been running, and — for clients
|
|
44
|
+
* that honour progress notifications — resets their request timeout, the mechanism
|
|
45
|
+
* build_d365fo_project already relies on to survive a long xppc. Elapsed seconds is
|
|
46
|
+
* the progress value because MCP requires it to increase and no total is known.
|
|
47
|
+
* Nothing fires before the first tick, so tools answering in milliseconds send
|
|
48
|
+
* exactly what they sent before.
|
|
49
|
+
*/
|
|
50
|
+
export declare function startProgressHeartbeat(report: ProgressReporter, message: string, everyMs?: number): () => void;
|
|
26
51
|
/** The slice of the SDK's request `extra` that the reporter needs. */
|
|
27
52
|
export interface ProgressRequestExtra {
|
|
28
53
|
_meta?: Record<string, unknown>;
|
|
@@ -16,6 +16,40 @@
|
|
|
16
16
|
* Both are best-effort: a client that rejects or ignores them must never fail
|
|
17
17
|
* or stall the tool.
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* How often an in-flight tool call re-announces itself.
|
|
21
|
+
*
|
|
22
|
+
* Long enough that a normal call never sends one, short enough to stay inside the
|
|
23
|
+
* request timeouts clients reset on each notification (VS Code's is ~60 s).
|
|
24
|
+
*/
|
|
25
|
+
export const PROGRESS_HEARTBEAT_MS = 15_000;
|
|
26
|
+
/**
|
|
27
|
+
* Keep saying "still running" until the returned stop() is called.
|
|
28
|
+
*
|
|
29
|
+
* The dispatcher sends one notification when a tool starts, and then nothing for
|
|
30
|
+
* however long it takes — so a call that runs for minutes is indistinguishable from
|
|
31
|
+
* a hung one. On 2026-09-07 a first get_workspace_info ran 337.5 s behind a cold
|
|
32
|
+
* database open: the IDE showed "⚙️ Reading workspace configuration" and no further
|
|
33
|
+
* sign of life for five and a half minutes, and the server log (which mirrors
|
|
34
|
+
* stderr, and startup progress does not reach stderr) held nothing either.
|
|
35
|
+
*
|
|
36
|
+
* A tick says the call is alive and how long it has been running, and — for clients
|
|
37
|
+
* that honour progress notifications — resets their request timeout, the mechanism
|
|
38
|
+
* build_d365fo_project already relies on to survive a long xppc. Elapsed seconds is
|
|
39
|
+
* the progress value because MCP requires it to increase and no total is known.
|
|
40
|
+
* Nothing fires before the first tick, so tools answering in milliseconds send
|
|
41
|
+
* exactly what they sent before.
|
|
42
|
+
*/
|
|
43
|
+
export function startProgressHeartbeat(report, message, everyMs = PROGRESS_HEARTBEAT_MS) {
|
|
44
|
+
const startedAt = Date.now();
|
|
45
|
+
const timer = setInterval(() => {
|
|
46
|
+
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
|
|
47
|
+
void report(`${message} — still running, ${elapsedSec}s`, elapsedSec);
|
|
48
|
+
}, everyMs);
|
|
49
|
+
// Never hold the process open for a heartbeat.
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
return () => clearInterval(timer);
|
|
52
|
+
}
|
|
19
53
|
/**
|
|
20
54
|
* Build a reporter bound to one in-flight tool call. Always returns a callable —
|
|
21
55
|
* when the client offers neither channel the reporter is simply a no-op, so
|
|
@@ -184,7 +184,14 @@ export async function buildContextSnapshot(context, opts = {}) {
|
|
|
184
184
|
recentObjects = cached.objects;
|
|
185
185
|
}
|
|
186
186
|
else {
|
|
187
|
-
|
|
187
|
+
// 'pending' only until the FIRST scan lands. After that an expired entry is
|
|
188
|
+
// served and refreshed behind the answer, because "call again for the list"
|
|
189
|
+
// is otherwise a promise this cannot keep: the entry lives RECENT_CACHE_MS
|
|
190
|
+
// (30 s), so any re-ask later than that finds it expired and is told to call
|
|
191
|
+
// again — the same line, forever. Half a minute stale was already declared
|
|
192
|
+
// an accurate answer here; several minutes stale still beats no answer.
|
|
193
|
+
recentObjects = cached?.objects ?? [];
|
|
194
|
+
recentPending = !cached;
|
|
188
195
|
// Not awaited. The result lands in the cache above, so the NEXT call
|
|
189
196
|
// shows it — the information is deferred, never dropped.
|
|
190
197
|
if (!recentScanInFlight.has(workspacePath)) {
|