d365fo-mcp 1.17.1 → 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/config/settings.js +13 -0
- package/dist/index.js +67 -5
- package/dist/metadata/startupIndexWorker.d.ts +37 -0
- package/dist/metadata/startupIndexWorker.js +43 -0
- package/dist/metadata/startupIndexing.d.ts +31 -0
- package/dist/metadata/startupIndexing.js +66 -0
- package/dist/metadata/symbolIndex.d.ts +18 -0
- package/dist/metadata/symbolIndex.js +32 -3
- package/dist/scripts/build-database.js +71 -7
- package/dist/scripts/build-fts.js +71 -7
- package/dist/scripts/extract-metadata.js +38 -5
- package/dist/scripts/startupIndexWorker.js +4202 -0
- 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 +16 -6
- package/dist/tools/write/resolveReferences.js +1 -1
- package/dist/utils/configManager.d.ts +26 -0
- package/dist/utils/configManager.js +83 -30
- 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/packagesRoot.d.ts +54 -3
- package/dist/utils/packagesRoot.js +87 -14
- package/dist/utils/progressReporter.d.ts +25 -0
- package/dist/utils/progressReporter.js +34 -0
- package/dist/utils/workspaceDetector.d.ts +2 -0
- package/dist/utils/workspaceDetector.js +30 -12
- package/dist/workspace/contextSnapshot.js +8 -1
- package/package.json +2 -2
package/dist/config/settings.js
CHANGED
|
@@ -87,6 +87,19 @@ export const SETTINGS = [
|
|
|
87
87
|
'which volume that is depends on the VM image (K:, C:, J:, …).',
|
|
88
88
|
placeholder: 'C:\\AOSService\\PackagesLocalDirectory',
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
path: 'environment.scanDrives',
|
|
92
|
+
env: 'D365FO_SCAN_DRIVES',
|
|
93
|
+
section: 'environment',
|
|
94
|
+
tier: 'advanced',
|
|
95
|
+
type: 'string',
|
|
96
|
+
label: 'Drive letters probed for AosService',
|
|
97
|
+
description: 'Comma-separated letters the packages-root scan probes when no packagePath is configured, e.g. "C,K". ' +
|
|
98
|
+
'Empty probes C: to Z: — the letters that have ever held AosService first, the rest inside a 2 s budget. ' +
|
|
99
|
+
'Set it on a machine with a disconnected mapped network drive: one stat on such a drive stalls for the ' +
|
|
100
|
+
'SMB timeout, and the scan runs on the first tool call of a session.',
|
|
101
|
+
placeholder: 'C,K',
|
|
102
|
+
},
|
|
90
103
|
{
|
|
91
104
|
path: 'environment.customModels',
|
|
92
105
|
env: 'CUSTOM_MODELS',
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createXppMcpServer } from './server/mcpServer.js';
|
|
|
15
15
|
import { createStreamableHttpTransport } from './server/transport.js';
|
|
16
16
|
import { XppSymbolIndex } from './metadata/symbolIndex.js';
|
|
17
17
|
import { shouldWarmIndexes, warmIndexes, renderWarmupReport } from './metadata/indexWarmup.js';
|
|
18
|
+
import { canIndexOffThread, indexMetadataOffThread } from './metadata/startupIndexing.js';
|
|
18
19
|
import { XppMetadataParser } from './metadata/xmlParser.js';
|
|
19
20
|
import { WorkspaceScanner } from './workspace/workspaceScanner.js';
|
|
20
21
|
import { HybridSearch } from './workspace/hybridSearch.js';
|
|
@@ -103,8 +104,37 @@ console.error = (...args) => {
|
|
|
103
104
|
const isModuleDebugMessage = /^\[[\w\- ]+\]/.test(firstArg) && !hasErrorIndicator;
|
|
104
105
|
if (!isModuleDebugMessage) {
|
|
105
106
|
originalConsoleError(...args);
|
|
107
|
+
return;
|
|
106
108
|
}
|
|
109
|
+
logFileOnly(args);
|
|
107
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
|
+
}
|
|
108
138
|
// ─── Global safety net ────────────────────────────────────────────────────────
|
|
109
139
|
// An unhandled promise rejection terminates the Node process by default
|
|
110
140
|
// (Node ≥15, --unhandled-rejections=throw). In stdio mode that kills the MCP
|
|
@@ -300,8 +330,15 @@ async function initializeServices() {
|
|
|
300
330
|
log.warn('No symbols found in database — run `npm run index-metadata` first');
|
|
301
331
|
log.detail('or set METADATA_PATH and the server will index on startup');
|
|
302
332
|
// If metadata path exists, index it
|
|
333
|
+
let metadataAccessible = false;
|
|
303
334
|
try {
|
|
304
335
|
await fs.access(METADATA_PATH);
|
|
336
|
+
metadataAccessible = true;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
log.warn('Metadata path not accessible — starting with empty index');
|
|
340
|
+
}
|
|
341
|
+
if (metadataAccessible) {
|
|
305
342
|
log.step(`Indexing metadata from ${METADATA_PATH}` + glyph.ellipsis);
|
|
306
343
|
serverState.statusMessage = 'Indexing metadata...';
|
|
307
344
|
const modelNamesStr = process.env.CUSTOM_MODELS || 'CustomModel';
|
|
@@ -310,11 +347,31 @@ async function initializeServices() {
|
|
|
310
347
|
// Single pass over all requested models — the FTS index is rebuilt once at the
|
|
311
348
|
// end of the call, so looping per model would repeat a full-table rebuild.
|
|
312
349
|
log.detail(`indexing ${modelNames.join(', ')}` + glyph.ellipsis);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
350
|
+
try {
|
|
351
|
+
if (canIndexOffThread(DB_PATH, LABELS_DB_PATH)) {
|
|
352
|
+
// On a worker thread: the build is synchronous end to end, and inline
|
|
353
|
+
// it blocked the event loop for its whole duration — every tool call,
|
|
354
|
+
// get_workspace_info included, hung until it finished. dbReady is still
|
|
355
|
+
// held until it completes, so symbol-backed tools keep answering "still
|
|
356
|
+
// loading" rather than returning empty results; the loop stays free.
|
|
357
|
+
const { elapsedMs } = await indexMetadataOffThread({
|
|
358
|
+
dbPath: DB_PATH,
|
|
359
|
+
labelsDbPath: LABELS_DB_PATH,
|
|
360
|
+
metadataPath: METADATA_PATH,
|
|
361
|
+
modelNames,
|
|
362
|
+
output: process.stderr,
|
|
363
|
+
});
|
|
364
|
+
log.detail(`indexed in ${(elapsedMs / 1000).toFixed(1)}s on a worker thread`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
await symbolIndex.indexMetadataDirectory(METADATA_PATH, modelNames);
|
|
368
|
+
}
|
|
369
|
+
log.ok(`Indexed ${symbolIndex.getSymbolCount().toLocaleString('en-US')} symbols from ${modelNames.length} model(s)`);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
log.warn(`Metadata indexing failed — starting with empty index: ${error}`);
|
|
373
|
+
log.detail('run `npm run index-metadata` to build the database');
|
|
374
|
+
}
|
|
318
375
|
}
|
|
319
376
|
}
|
|
320
377
|
else {
|
|
@@ -522,7 +579,12 @@ async function main() {
|
|
|
522
579
|
msg.includes('Error') || msg.includes('error') ||
|
|
523
580
|
msg.includes('Failed') || msg.includes('failed')) {
|
|
524
581
|
process.stderr.write(msg + '\n');
|
|
582
|
+
return;
|
|
525
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]);
|
|
526
588
|
};
|
|
527
589
|
console.log = stderrWrite;
|
|
528
590
|
console.info = stderrWrite;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-start metadata indexing, off the main thread.
|
|
3
|
+
*
|
|
4
|
+
* When the server starts with an empty symbol database and METADATA_PATH set,
|
|
5
|
+
* it indexes the requested models before it declares itself ready. That build
|
|
6
|
+
* is synchronous end to end — a recursive readdirSync per model, node:sqlite
|
|
7
|
+
* inserts, one FTS rebuild — and inline on the main thread it blocked the
|
|
8
|
+
* event loop for the whole duration: every tool call, get_workspace_info
|
|
9
|
+
* included, hung until the build finished, on exactly the machine where the
|
|
10
|
+
* user was trying the server for the first time.
|
|
11
|
+
*
|
|
12
|
+
* Here the same XppSymbolIndex.indexMetadataDirectory runs on its own
|
|
13
|
+
* connection. WAL mode lets the main thread keep serving from the same file —
|
|
14
|
+
* the startup path never takes the EXCLUSIVE lock the build scripts use, so
|
|
15
|
+
* the two connections coexist — and the main thread sees each model as its
|
|
16
|
+
* transaction commits.
|
|
17
|
+
*
|
|
18
|
+
* Spawned by indexMetadataOffThread() (startupIndexing.ts) and posts:
|
|
19
|
+
* { type: 'done', elapsedMs, symbolCount } | { type: 'error', error }
|
|
20
|
+
*
|
|
21
|
+
* Bundled by build:scripts beside the other workers (tests/packaging/workerBundles).
|
|
22
|
+
*/
|
|
23
|
+
export interface StartupIndexWorkerData {
|
|
24
|
+
dbPath: string;
|
|
25
|
+
labelsDbPath: string;
|
|
26
|
+
metadataPath: string;
|
|
27
|
+
modelNames: string[];
|
|
28
|
+
}
|
|
29
|
+
export type StartupIndexMessage = {
|
|
30
|
+
type: 'done';
|
|
31
|
+
elapsedMs: number;
|
|
32
|
+
symbolCount: number;
|
|
33
|
+
} | {
|
|
34
|
+
type: 'error';
|
|
35
|
+
error: string;
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=startupIndexWorker.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-start metadata indexing, off the main thread.
|
|
3
|
+
*
|
|
4
|
+
* When the server starts with an empty symbol database and METADATA_PATH set,
|
|
5
|
+
* it indexes the requested models before it declares itself ready. That build
|
|
6
|
+
* is synchronous end to end — a recursive readdirSync per model, node:sqlite
|
|
7
|
+
* inserts, one FTS rebuild — and inline on the main thread it blocked the
|
|
8
|
+
* event loop for the whole duration: every tool call, get_workspace_info
|
|
9
|
+
* included, hung until the build finished, on exactly the machine where the
|
|
10
|
+
* user was trying the server for the first time.
|
|
11
|
+
*
|
|
12
|
+
* Here the same XppSymbolIndex.indexMetadataDirectory runs on its own
|
|
13
|
+
* connection. WAL mode lets the main thread keep serving from the same file —
|
|
14
|
+
* the startup path never takes the EXCLUSIVE lock the build scripts use, so
|
|
15
|
+
* the two connections coexist — and the main thread sees each model as its
|
|
16
|
+
* transaction commits.
|
|
17
|
+
*
|
|
18
|
+
* Spawned by indexMetadataOffThread() (startupIndexing.ts) and posts:
|
|
19
|
+
* { type: 'done', elapsedMs, symbolCount } | { type: 'error', error }
|
|
20
|
+
*
|
|
21
|
+
* Bundled by build:scripts beside the other workers (tests/packaging/workerBundles).
|
|
22
|
+
*/
|
|
23
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
24
|
+
import { XppSymbolIndex } from './symbolIndex.js';
|
|
25
|
+
const data = workerData;
|
|
26
|
+
async function run() {
|
|
27
|
+
const started = Date.now();
|
|
28
|
+
// backgroundIndexBuilds: false — this thread IS the background; nesting a
|
|
29
|
+
// second worker per file-path index buys nothing and complicates shutdown.
|
|
30
|
+
const index = new XppSymbolIndex(data.dbPath, data.labelsDbPath, { backgroundIndexBuilds: false });
|
|
31
|
+
try {
|
|
32
|
+
await index.indexMetadataDirectory(data.metadataPath, data.modelNames);
|
|
33
|
+
const symbolCount = index.getSymbolCount();
|
|
34
|
+
parentPort.postMessage({ type: 'done', elapsedMs: Date.now() - started, symbolCount });
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
index.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
run().catch(e => {
|
|
41
|
+
parentPort.postMessage({ type: 'error', error: String(e?.stack ?? e) });
|
|
42
|
+
});
|
|
43
|
+
//# sourceMappingURL=startupIndexWorker.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the first-start metadata index in a worker thread and wait for it.
|
|
3
|
+
*
|
|
4
|
+
* See startupIndexWorker.ts for why. The parent side is thin: it spawns the
|
|
5
|
+
* worker, routes its console output where the caller says (stderr in stdio
|
|
6
|
+
* mode — stdout is the MCP protocol channel there), and settles once with the
|
|
7
|
+
* worker's result. It still WAITS: the caller holds dbReady until the index is
|
|
8
|
+
* populated, so tools that need symbols keep getting the "still loading"
|
|
9
|
+
* answer instead of silently empty results — the difference is that the event
|
|
10
|
+
* loop is free meanwhile, so the tools that need no symbols answer at once.
|
|
11
|
+
*/
|
|
12
|
+
import type { StartupIndexWorkerData } from './startupIndexWorker.js';
|
|
13
|
+
export interface StartupIndexOptions extends StartupIndexWorkerData {
|
|
14
|
+
/** Injected in tests; the real one resolves next to the compiled worker. */
|
|
15
|
+
workerUrl?: URL;
|
|
16
|
+
/** Where the worker's stdout/stderr go. Defaults to the parent's stderr. */
|
|
17
|
+
output?: NodeJS.WritableStream;
|
|
18
|
+
/**
|
|
19
|
+
* Heap cap for the worker. A worker's default old-generation limit is derived
|
|
20
|
+
* from the parent's, which is sized for serving, not for indexing.
|
|
21
|
+
*/
|
|
22
|
+
maxOldGenerationSizeMb?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface StartupIndexResult {
|
|
25
|
+
elapsedMs: number;
|
|
26
|
+
symbolCount: number;
|
|
27
|
+
}
|
|
28
|
+
/** In-memory databases cannot be shared with a worker — the caller indexes inline. */
|
|
29
|
+
export declare function canIndexOffThread(dbPath: string, labelsDbPath: string): boolean;
|
|
30
|
+
export declare function indexMetadataOffThread(opts: StartupIndexOptions): Promise<StartupIndexResult>;
|
|
31
|
+
//# sourceMappingURL=startupIndexing.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the first-start metadata index in a worker thread and wait for it.
|
|
3
|
+
*
|
|
4
|
+
* See startupIndexWorker.ts for why. The parent side is thin: it spawns the
|
|
5
|
+
* worker, routes its console output where the caller says (stderr in stdio
|
|
6
|
+
* mode — stdout is the MCP protocol channel there), and settles once with the
|
|
7
|
+
* worker's result. It still WAITS: the caller holds dbReady until the index is
|
|
8
|
+
* populated, so tools that need symbols keep getting the "still loading"
|
|
9
|
+
* answer instead of silently empty results — the difference is that the event
|
|
10
|
+
* loop is free meanwhile, so the tools that need no symbols answer at once.
|
|
11
|
+
*/
|
|
12
|
+
import { Worker } from 'node:worker_threads';
|
|
13
|
+
/** In-memory databases cannot be shared with a worker — the caller indexes inline. */
|
|
14
|
+
export function canIndexOffThread(dbPath, labelsDbPath) {
|
|
15
|
+
return dbPath !== ':memory:' && labelsDbPath !== ':memory:';
|
|
16
|
+
}
|
|
17
|
+
export function indexMetadataOffThread(opts) {
|
|
18
|
+
const url = opts.workerUrl ?? new URL('./startupIndexWorker.js', import.meta.url);
|
|
19
|
+
const output = opts.output ?? process.stderr;
|
|
20
|
+
const workerData = {
|
|
21
|
+
dbPath: opts.dbPath,
|
|
22
|
+
labelsDbPath: opts.labelsDbPath,
|
|
23
|
+
metadataPath: opts.metadataPath,
|
|
24
|
+
modelNames: opts.modelNames,
|
|
25
|
+
};
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
let settled = false;
|
|
28
|
+
const settle = (fn) => {
|
|
29
|
+
if (settled)
|
|
30
|
+
return;
|
|
31
|
+
settled = true;
|
|
32
|
+
fn();
|
|
33
|
+
};
|
|
34
|
+
let worker;
|
|
35
|
+
try {
|
|
36
|
+
worker = new Worker(url, {
|
|
37
|
+
workerData,
|
|
38
|
+
// Own the streams: with the defaults a worker's stdout is piped straight
|
|
39
|
+
// into the parent's stdout, which in stdio mode is the protocol channel.
|
|
40
|
+
stdout: true,
|
|
41
|
+
stderr: true,
|
|
42
|
+
resourceLimits: { maxOldGenerationSizeMb: opts.maxOldGenerationSizeMb ?? 4096 },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
worker.stdout.pipe(output, { end: false });
|
|
50
|
+
worker.stderr.pipe(output, { end: false });
|
|
51
|
+
worker.on('message', (msg) => {
|
|
52
|
+
if (msg.type === 'done') {
|
|
53
|
+
settle(() => resolve({ elapsedMs: msg.elapsedMs, symbolCount: msg.symbolCount }));
|
|
54
|
+
void worker.terminate();
|
|
55
|
+
}
|
|
56
|
+
else if (msg.type === 'error') {
|
|
57
|
+
settle(() => reject(new Error(msg.error)));
|
|
58
|
+
void worker.terminate();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
worker.once('error', e => settle(() => reject(e)));
|
|
62
|
+
// A promise settles once — this covers "exited before the done message".
|
|
63
|
+
worker.once('exit', code => settle(() => reject(new Error(`startup index worker exited with code ${code}`))));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=startupIndexing.js.map
|
|
@@ -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) {
|
|
@@ -34,6 +34,16 @@ var SETTINGS = [
|
|
|
34
34
|
description: "AOT packages folder (PackagesLocalDirectory) used as the read-only source for indexing. Machine-wide on a traditional VM; UDE resolves it from the XPP config instead. Left empty, the server scans the machine's drives for AosService\\PackagesLocalDirectory \u2014 which volume that is depends on the VM image (K:, C:, J:, \u2026).",
|
|
35
35
|
placeholder: "C:\\AOSService\\PackagesLocalDirectory"
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
path: "environment.scanDrives",
|
|
39
|
+
env: "D365FO_SCAN_DRIVES",
|
|
40
|
+
section: "environment",
|
|
41
|
+
tier: "advanced",
|
|
42
|
+
type: "string",
|
|
43
|
+
label: "Drive letters probed for AosService",
|
|
44
|
+
description: 'Comma-separated letters the packages-root scan probes when no packagePath is configured, e.g. "C,K". Empty probes C: to Z: \u2014 the letters that have ever held AosService first, the rest inside a 2 s budget. Set it on a machine with a disconnected mapped network drive: one stat on such a drive stalls for the SMB timeout, and the scan runs on the first tool call of a session.',
|
|
45
|
+
placeholder: "C,K"
|
|
46
|
+
},
|
|
37
47
|
{
|
|
38
48
|
path: "environment.customModels",
|
|
39
49
|
env: "CUSTOM_MODELS",
|
|
@@ -2261,6 +2271,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2261
2271
|
*
|
|
2262
2272
|
* Returns the names of top-level objects that were removed (for cache invalidation).
|
|
2263
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;
|
|
2264
2283
|
/**
|
|
2265
2284
|
* Top-level object names belonging to one model — the evidence from which a
|
|
2266
2285
|
* model's naming prefix is inferred (see utils/modelPrefixInference.ts).
|
|
@@ -2292,6 +2311,15 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2292
2311
|
* so the same model always yields the same sample — a silent, self-reinforcing
|
|
2293
2312
|
* failure otherwise, since this server writes names with the inferred prefix and
|
|
2294
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.
|
|
2295
2323
|
*/
|
|
2296
2324
|
getModelObjectNames(model, limit = 400) {
|
|
2297
2325
|
if (!model) return [];
|
|
@@ -2300,10 +2328,20 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2300
2328
|
const band = (extensions, cap) => {
|
|
2301
2329
|
if (cap <= 0) return [];
|
|
2302
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`.
|
|
2303
2341
|
`SELECT name FROM symbols
|
|
2304
2342
|
WHERE model = ?
|
|
2305
2343
|
AND type ${extensions ? "LIKE" : "NOT LIKE"} '%-extension'
|
|
2306
|
-
${extensions ? "" : "AND parent_name IS NULL"}
|
|
2344
|
+
${extensions ? "" : "AND +parent_name IS NULL"}
|
|
2307
2345
|
AND type NOT IN ('method', 'field')
|
|
2308
2346
|
ORDER BY type, name
|
|
2309
2347
|
LIMIT ?`
|
|
@@ -2312,7 +2350,10 @@ var XppSymbolIndex = class _XppSymbolIndex {
|
|
|
2312
2350
|
};
|
|
2313
2351
|
const half = Math.max(1, Math.ceil(limit / 2));
|
|
2314
2352
|
const extensionNames = band(true, half);
|
|
2315
|
-
const regularNames = band(
|
|
2353
|
+
const regularNames = band(
|
|
2354
|
+
false,
|
|
2355
|
+
Math.min(_XppSymbolIndex.REGULAR_NAME_SAMPLE, limit - extensionNames.length)
|
|
2356
|
+
);
|
|
2316
2357
|
return [...extensionNames, ...regularNames];
|
|
2317
2358
|
}
|
|
2318
2359
|
removeSymbolsByFile(filePath) {
|
|
@@ -5394,6 +5435,15 @@ import * as fs6 from "fs";
|
|
|
5394
5435
|
var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
|
|
5395
5436
|
var PREFERRED_DRIVES = ["C", "K", "J", "I"];
|
|
5396
5437
|
var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
5438
|
+
var DRIVE_SCAN_BUDGET_MS = 2e3;
|
|
5439
|
+
var SLOW_PROBE_MS = 1e3;
|
|
5440
|
+
var lastReport = null;
|
|
5441
|
+
function driveLettersToProbe(pinnedSpec) {
|
|
5442
|
+
const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
|
|
5443
|
+
if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
|
|
5444
|
+
const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
|
|
5445
|
+
return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
|
|
5446
|
+
}
|
|
5397
5447
|
var realIo = {
|
|
5398
5448
|
// Read through to process.platform on every access rather than snapshotting it
|
|
5399
5449
|
// at import time — a frozen copy makes the scan ignore a platform override, so
|
|
@@ -5423,20 +5473,34 @@ function plausibility(root, io) {
|
|
|
5423
5473
|
if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
|
|
5424
5474
|
return 1;
|
|
5425
5475
|
}
|
|
5426
|
-
function scanPackagesRoots(io = realIo) {
|
|
5476
|
+
function scanPackagesRoots(io = realIo, opts = {}) {
|
|
5427
5477
|
if (io.platform !== "win32") return [];
|
|
5478
|
+
const clock = opts.clock ?? Date.now;
|
|
5479
|
+
const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
|
|
5480
|
+
const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
|
|
5481
|
+
const report = { probed: [], skipped: [], slow: [], pinned };
|
|
5428
5482
|
const hits = [];
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
5432
|
-
if (!io.isDirectory(root)) continue;
|
|
5483
|
+
const start = clock();
|
|
5484
|
+
for (const letter of letters) {
|
|
5433
5485
|
const preferred = PREFERRED_DRIVES.indexOf(letter);
|
|
5486
|
+
if (!pinned && preferred === -1 && clock() - start > budgetMs) {
|
|
5487
|
+
report.skipped.push(letter);
|
|
5488
|
+
continue;
|
|
5489
|
+
}
|
|
5490
|
+
const t0 = clock();
|
|
5491
|
+
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
5492
|
+
const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
|
|
5493
|
+
const ms = clock() - t0;
|
|
5494
|
+
report.probed.push(letter);
|
|
5495
|
+
if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
|
|
5496
|
+
if (!hit) continue;
|
|
5434
5497
|
hits.push({
|
|
5435
5498
|
root,
|
|
5436
5499
|
score: plausibility(root, io),
|
|
5437
5500
|
rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
|
|
5438
5501
|
});
|
|
5439
5502
|
}
|
|
5503
|
+
lastReport = report;
|
|
5440
5504
|
return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
|
|
5441
5505
|
}
|
|
5442
5506
|
var cached = null;
|