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.
Files changed (35) hide show
  1. package/dist/config/settings.js +13 -0
  2. package/dist/index.js +67 -5
  3. package/dist/metadata/startupIndexWorker.d.ts +37 -0
  4. package/dist/metadata/startupIndexWorker.js +43 -0
  5. package/dist/metadata/startupIndexing.d.ts +31 -0
  6. package/dist/metadata/startupIndexing.js +66 -0
  7. package/dist/metadata/symbolIndex.d.ts +18 -0
  8. package/dist/metadata/symbolIndex.js +32 -3
  9. package/dist/scripts/build-database.js +71 -7
  10. package/dist/scripts/build-fts.js +71 -7
  11. package/dist/scripts/extract-metadata.js +38 -5
  12. package/dist/scripts/startupIndexWorker.js +4202 -0
  13. package/dist/server/serverMode.d.ts +23 -0
  14. package/dist/server/serverMode.js +26 -0
  15. package/dist/tools/analysis/searchLabels.js +1 -1
  16. package/dist/tools/readers/getObjectInfo.js +5 -0
  17. package/dist/tools/readers/objectXml.d.ts +17 -2
  18. package/dist/tools/readers/objectXml.js +35 -9
  19. package/dist/tools/toolHandler.js +16 -6
  20. package/dist/tools/write/resolveReferences.js +1 -1
  21. package/dist/utils/configManager.d.ts +26 -0
  22. package/dist/utils/configManager.js +83 -30
  23. package/dist/utils/indexStaleness.d.ts +11 -3
  24. package/dist/utils/indexStaleness.js +41 -7
  25. package/dist/utils/objectFileLookup.d.ts +44 -0
  26. package/dist/utils/objectFileLookup.js +86 -0
  27. package/dist/utils/objectNamingRules.js +1 -1
  28. package/dist/utils/packagesRoot.d.ts +54 -3
  29. package/dist/utils/packagesRoot.js +87 -14
  30. package/dist/utils/progressReporter.d.ts +25 -0
  31. package/dist/utils/progressReporter.js +34 -0
  32. package/dist/utils/workspaceDetector.d.ts +2 -0
  33. package/dist/utils/workspaceDetector.js +30 -12
  34. package/dist/workspace/contextSnapshot.js +8 -1
  35. package/package.json +2 -2
@@ -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
- await symbolIndex.indexMetadataDirectory(METADATA_PATH, modelNames);
314
- log.ok(`Indexed ${symbolIndex.getSymbolCount().toLocaleString('en-US')} symbols from ${modelNames.length} model(s)`);
315
- }
316
- catch {
317
- log.warn('Metadata path not accessible — starting with empty index');
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(`SELECT name FROM symbols
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(false, limit - extensionNames.length);
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
- for (const letter of SCANNED_DRIVES) {
5430
- if (!io.isDirectory(`${letter}:\\`)) continue;
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;