d365fo-mcp 1.17.0 → 1.17.2
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 +33 -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/scripts/build-database.js +38 -5
- package/dist/scripts/build-fts.js +38 -5
- package/dist/scripts/extract-metadata.js +38 -5
- package/dist/scripts/startupIndexWorker.js +4171 -0
- package/dist/tools/toolHandler.js +1 -1
- package/dist/utils/configManager.d.ts +26 -0
- package/dist/utils/configManager.js +83 -30
- package/dist/utils/packagesRoot.d.ts +54 -3
- package/dist/utils/packagesRoot.js +87 -14
- package/dist/utils/workspaceDetector.d.ts +2 -0
- package/dist/utils/workspaceDetector.js +30 -12
- 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';
|
|
@@ -300,8 +301,15 @@ async function initializeServices() {
|
|
|
300
301
|
log.warn('No symbols found in database — run `npm run index-metadata` first');
|
|
301
302
|
log.detail('or set METADATA_PATH and the server will index on startup');
|
|
302
303
|
// If metadata path exists, index it
|
|
304
|
+
let metadataAccessible = false;
|
|
303
305
|
try {
|
|
304
306
|
await fs.access(METADATA_PATH);
|
|
307
|
+
metadataAccessible = true;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
log.warn('Metadata path not accessible — starting with empty index');
|
|
311
|
+
}
|
|
312
|
+
if (metadataAccessible) {
|
|
305
313
|
log.step(`Indexing metadata from ${METADATA_PATH}` + glyph.ellipsis);
|
|
306
314
|
serverState.statusMessage = 'Indexing metadata...';
|
|
307
315
|
const modelNamesStr = process.env.CUSTOM_MODELS || 'CustomModel';
|
|
@@ -310,11 +318,31 @@ async function initializeServices() {
|
|
|
310
318
|
// Single pass over all requested models — the FTS index is rebuilt once at the
|
|
311
319
|
// end of the call, so looping per model would repeat a full-table rebuild.
|
|
312
320
|
log.detail(`indexing ${modelNames.join(', ')}` + glyph.ellipsis);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
321
|
+
try {
|
|
322
|
+
if (canIndexOffThread(DB_PATH, LABELS_DB_PATH)) {
|
|
323
|
+
// On a worker thread: the build is synchronous end to end, and inline
|
|
324
|
+
// it blocked the event loop for its whole duration — every tool call,
|
|
325
|
+
// get_workspace_info included, hung until it finished. dbReady is still
|
|
326
|
+
// held until it completes, so symbol-backed tools keep answering "still
|
|
327
|
+
// loading" rather than returning empty results; the loop stays free.
|
|
328
|
+
const { elapsedMs } = await indexMetadataOffThread({
|
|
329
|
+
dbPath: DB_PATH,
|
|
330
|
+
labelsDbPath: LABELS_DB_PATH,
|
|
331
|
+
metadataPath: METADATA_PATH,
|
|
332
|
+
modelNames,
|
|
333
|
+
output: process.stderr,
|
|
334
|
+
});
|
|
335
|
+
log.detail(`indexed in ${(elapsedMs / 1000).toFixed(1)}s on a worker thread`);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
await symbolIndex.indexMetadataDirectory(METADATA_PATH, modelNames);
|
|
339
|
+
}
|
|
340
|
+
log.ok(`Indexed ${symbolIndex.getSymbolCount().toLocaleString('en-US')} symbols from ${modelNames.length} model(s)`);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
log.warn(`Metadata indexing failed — starting with empty index: ${error}`);
|
|
344
|
+
log.detail('run `npm run index-metadata` to build the database');
|
|
345
|
+
}
|
|
318
346
|
}
|
|
319
347
|
}
|
|
320
348
|
else {
|
|
@@ -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
|
|
@@ -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",
|
|
@@ -5394,6 +5404,15 @@ import * as fs6 from "fs";
|
|
|
5394
5404
|
var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
|
|
5395
5405
|
var PREFERRED_DRIVES = ["C", "K", "J", "I"];
|
|
5396
5406
|
var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
5407
|
+
var DRIVE_SCAN_BUDGET_MS = 2e3;
|
|
5408
|
+
var SLOW_PROBE_MS = 1e3;
|
|
5409
|
+
var lastReport = null;
|
|
5410
|
+
function driveLettersToProbe(pinnedSpec) {
|
|
5411
|
+
const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
|
|
5412
|
+
if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
|
|
5413
|
+
const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
|
|
5414
|
+
return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
|
|
5415
|
+
}
|
|
5397
5416
|
var realIo = {
|
|
5398
5417
|
// Read through to process.platform on every access rather than snapshotting it
|
|
5399
5418
|
// at import time — a frozen copy makes the scan ignore a platform override, so
|
|
@@ -5423,20 +5442,34 @@ function plausibility(root, io) {
|
|
|
5423
5442
|
if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
|
|
5424
5443
|
return 1;
|
|
5425
5444
|
}
|
|
5426
|
-
function scanPackagesRoots(io = realIo) {
|
|
5445
|
+
function scanPackagesRoots(io = realIo, opts = {}) {
|
|
5427
5446
|
if (io.platform !== "win32") return [];
|
|
5447
|
+
const clock = opts.clock ?? Date.now;
|
|
5448
|
+
const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
|
|
5449
|
+
const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
|
|
5450
|
+
const report = { probed: [], skipped: [], slow: [], pinned };
|
|
5428
5451
|
const hits = [];
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
5432
|
-
if (!io.isDirectory(root)) continue;
|
|
5452
|
+
const start = clock();
|
|
5453
|
+
for (const letter of letters) {
|
|
5433
5454
|
const preferred = PREFERRED_DRIVES.indexOf(letter);
|
|
5455
|
+
if (!pinned && preferred === -1 && clock() - start > budgetMs) {
|
|
5456
|
+
report.skipped.push(letter);
|
|
5457
|
+
continue;
|
|
5458
|
+
}
|
|
5459
|
+
const t0 = clock();
|
|
5460
|
+
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
5461
|
+
const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
|
|
5462
|
+
const ms = clock() - t0;
|
|
5463
|
+
report.probed.push(letter);
|
|
5464
|
+
if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
|
|
5465
|
+
if (!hit) continue;
|
|
5434
5466
|
hits.push({
|
|
5435
5467
|
root,
|
|
5436
5468
|
score: plausibility(root, io),
|
|
5437
5469
|
rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
|
|
5438
5470
|
});
|
|
5439
5471
|
}
|
|
5472
|
+
lastReport = report;
|
|
5440
5473
|
return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
|
|
5441
5474
|
}
|
|
5442
5475
|
var cached = null;
|
|
@@ -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",
|
|
@@ -5199,6 +5209,15 @@ import * as fs4 from "fs";
|
|
|
5199
5209
|
var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
|
|
5200
5210
|
var PREFERRED_DRIVES = ["C", "K", "J", "I"];
|
|
5201
5211
|
var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
5212
|
+
var DRIVE_SCAN_BUDGET_MS = 2e3;
|
|
5213
|
+
var SLOW_PROBE_MS = 1e3;
|
|
5214
|
+
var lastReport = null;
|
|
5215
|
+
function driveLettersToProbe(pinnedSpec) {
|
|
5216
|
+
const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
|
|
5217
|
+
if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
|
|
5218
|
+
const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
|
|
5219
|
+
return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
|
|
5220
|
+
}
|
|
5202
5221
|
var realIo = {
|
|
5203
5222
|
// Read through to process.platform on every access rather than snapshotting it
|
|
5204
5223
|
// at import time — a frozen copy makes the scan ignore a platform override, so
|
|
@@ -5228,20 +5247,34 @@ function plausibility(root, io) {
|
|
|
5228
5247
|
if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
|
|
5229
5248
|
return 1;
|
|
5230
5249
|
}
|
|
5231
|
-
function scanPackagesRoots(io = realIo) {
|
|
5250
|
+
function scanPackagesRoots(io = realIo, opts = {}) {
|
|
5232
5251
|
if (io.platform !== "win32") return [];
|
|
5252
|
+
const clock = opts.clock ?? Date.now;
|
|
5253
|
+
const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
|
|
5254
|
+
const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
|
|
5255
|
+
const report = { probed: [], skipped: [], slow: [], pinned };
|
|
5233
5256
|
const hits = [];
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
5237
|
-
if (!io.isDirectory(root)) continue;
|
|
5257
|
+
const start = clock();
|
|
5258
|
+
for (const letter of letters) {
|
|
5238
5259
|
const preferred = PREFERRED_DRIVES.indexOf(letter);
|
|
5260
|
+
if (!pinned && preferred === -1 && clock() - start > budgetMs) {
|
|
5261
|
+
report.skipped.push(letter);
|
|
5262
|
+
continue;
|
|
5263
|
+
}
|
|
5264
|
+
const t0 = clock();
|
|
5265
|
+
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
5266
|
+
const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
|
|
5267
|
+
const ms = clock() - t0;
|
|
5268
|
+
report.probed.push(letter);
|
|
5269
|
+
if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
|
|
5270
|
+
if (!hit) continue;
|
|
5239
5271
|
hits.push({
|
|
5240
5272
|
root,
|
|
5241
5273
|
score: plausibility(root, io),
|
|
5242
5274
|
rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
|
|
5243
5275
|
});
|
|
5244
5276
|
}
|
|
5277
|
+
lastReport = report;
|
|
5245
5278
|
return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
|
|
5246
5279
|
}
|
|
5247
5280
|
var cached = null;
|
|
@@ -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",
|
|
@@ -2633,6 +2643,15 @@ import * as fs6 from "fs";
|
|
|
2633
2643
|
var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
|
|
2634
2644
|
var PREFERRED_DRIVES = ["C", "K", "J", "I"];
|
|
2635
2645
|
var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
2646
|
+
var DRIVE_SCAN_BUDGET_MS = 2e3;
|
|
2647
|
+
var SLOW_PROBE_MS = 1e3;
|
|
2648
|
+
var lastReport = null;
|
|
2649
|
+
function driveLettersToProbe(pinnedSpec) {
|
|
2650
|
+
const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
|
|
2651
|
+
if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
|
|
2652
|
+
const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
|
|
2653
|
+
return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
|
|
2654
|
+
}
|
|
2636
2655
|
var realIo = {
|
|
2637
2656
|
// Read through to process.platform on every access rather than snapshotting it
|
|
2638
2657
|
// at import time — a frozen copy makes the scan ignore a platform override, so
|
|
@@ -2662,20 +2681,34 @@ function plausibility(root, io) {
|
|
|
2662
2681
|
if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
|
|
2663
2682
|
return 1;
|
|
2664
2683
|
}
|
|
2665
|
-
function scanPackagesRoots(io = realIo) {
|
|
2684
|
+
function scanPackagesRoots(io = realIo, opts = {}) {
|
|
2666
2685
|
if (io.platform !== "win32") return [];
|
|
2686
|
+
const clock = opts.clock ?? Date.now;
|
|
2687
|
+
const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
|
|
2688
|
+
const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
|
|
2689
|
+
const report = { probed: [], skipped: [], slow: [], pinned };
|
|
2667
2690
|
const hits = [];
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
2671
|
-
if (!io.isDirectory(root)) continue;
|
|
2691
|
+
const start = clock();
|
|
2692
|
+
for (const letter of letters) {
|
|
2672
2693
|
const preferred = PREFERRED_DRIVES.indexOf(letter);
|
|
2694
|
+
if (!pinned && preferred === -1 && clock() - start > budgetMs) {
|
|
2695
|
+
report.skipped.push(letter);
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
const t0 = clock();
|
|
2699
|
+
const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
|
|
2700
|
+
const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
|
|
2701
|
+
const ms = clock() - t0;
|
|
2702
|
+
report.probed.push(letter);
|
|
2703
|
+
if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
|
|
2704
|
+
if (!hit) continue;
|
|
2673
2705
|
hits.push({
|
|
2674
2706
|
root,
|
|
2675
2707
|
score: plausibility(root, io),
|
|
2676
2708
|
rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
|
|
2677
2709
|
});
|
|
2678
2710
|
}
|
|
2711
|
+
lastReport = report;
|
|
2679
2712
|
return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
|
|
2680
2713
|
}
|
|
2681
2714
|
var cached = null;
|