dsh-edt-projects 0.12.0 → 0.12.1
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/README.md +6 -0
- package/lib/index.js +130 -13
- package/lib/repo.mjs +33 -1
- package/lib/schema.mjs +27 -0
- package/lib/workspace-run.mjs +210 -41
- package/lib/workspace.mjs +344 -31
- package/locale/en.json +1 -1
- package/locale/ru.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,14 @@ SonarQube/GitLab.
|
|
|
14
14
|
В **linked git worktree** плагин поднимает отдельное окружение EDT
|
|
15
15
|
(`<worktree>/build/ws`, свой порт и логи), импортирует проекты и монтирует
|
|
16
16
|
`mcp__edt__*` туда; включённость и настройки наследуются от основного checkout.
|
|
17
|
+
Работает совместно с [dsh-git-worktree](https://github.com/LaoYueHanNi/dsh-git-worktree)
|
|
18
|
+
(`<repo>/.dsh/gitworktree/<branch>/`), `build/` не требует ручной настройки.
|
|
17
19
|
Управление — инструмент `edt_workspace`.
|
|
18
20
|
|
|
21
|
+
В workspace включаются plain text mode (DSH не рендерит embedded resource) и
|
|
22
|
+
`get_form_screenshot` с JVM-флагом `-DnativeFormBufferedLayoutRender=true`;
|
|
23
|
+
выключаются настройками `workspace.plainText` и `workspace.formScreenshots`.
|
|
24
|
+
|
|
19
25
|
## Установка
|
|
20
26
|
|
|
21
27
|
```text
|
package/lib/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import { normalizeTestTooling } from './tests.mjs';
|
|
|
44
44
|
import { initFile, upgradeFile } from './config-file.mjs';
|
|
45
45
|
import { stringifyConfig, validateConfig } from './schema.mjs';
|
|
46
46
|
import { createSkillsProvider } from './skills.mjs';
|
|
47
|
-
import { repositoriesUnder } from './repo.mjs';
|
|
47
|
+
import { repositoriesUnder, repositoryAt, resolveContextCwd } from './repo.mjs';
|
|
48
48
|
import {
|
|
49
49
|
BUILD_DIR,
|
|
50
50
|
detectEdtHome,
|
|
@@ -54,16 +54,20 @@ import {
|
|
|
54
54
|
pickFreePort,
|
|
55
55
|
pidAlive,
|
|
56
56
|
prepareWorkspaceSnapshot,
|
|
57
|
+
prefValue,
|
|
57
58
|
probeHealth,
|
|
58
59
|
readPort,
|
|
60
|
+
readPrefs,
|
|
59
61
|
removeStamp,
|
|
62
|
+
requiredJava,
|
|
63
|
+
resolveEdtVm,
|
|
60
64
|
resolveWorkspaceTarget,
|
|
61
65
|
stateOf as workspaceState,
|
|
66
|
+
tailFile,
|
|
62
67
|
writePort,
|
|
63
68
|
writeStamp,
|
|
64
|
-
writeWorkspaceSnapshot,
|
|
65
69
|
} from './workspace.mjs';
|
|
66
|
-
import { cmdStop as stopWorkspaceNow } from './workspace-run.mjs';
|
|
70
|
+
import { cmdInit, cmdStop as stopWorkspaceNow, lockState } from './workspace-run.mjs';
|
|
67
71
|
|
|
68
72
|
const HEADER = `## Работа с 1С через MCP
|
|
69
73
|
|
|
@@ -393,7 +397,20 @@ export async function apply(ctx, config) {
|
|
|
393
397
|
const loggedErrors = new Set();
|
|
394
398
|
let skillsControl;
|
|
395
399
|
|
|
396
|
-
const
|
|
400
|
+
const originCwdOf = (agent) => sessionCwd(agent, ctx.workingDirectory);
|
|
401
|
+
const workCwdOf = (agent) => {
|
|
402
|
+
const session = agent?.session;
|
|
403
|
+
if (session === undefined) return originCwdOf(agent);
|
|
404
|
+
try {
|
|
405
|
+
const dir = ctx.workingDirectory?.get?.(session);
|
|
406
|
+
return typeof dir === 'string' && dir !== '' ? dir : originCwdOf(agent);
|
|
407
|
+
} catch {
|
|
408
|
+
return originCwdOf(agent);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
// Контекст сессии — эффективный каталог, если это worktree того же проекта.
|
|
412
|
+
const cwdOf = (agent) =>
|
|
413
|
+
resolveContextCwd({ origin: originCwdOf(agent), work: workCwdOf(agent) });
|
|
397
414
|
|
|
398
415
|
/** Уникальные пути в порядке появления (для слоёв настроек и allowlist). */
|
|
399
416
|
const uniquePaths = (values) => {
|
|
@@ -448,7 +465,24 @@ export async function apply(ctx, config) {
|
|
|
448
465
|
return { cwd, repositories, enabledRepositories, paths, effective, activation, workspace };
|
|
449
466
|
};
|
|
450
467
|
|
|
451
|
-
const contextFor = (agent) =>
|
|
468
|
+
const contextFor = (agent) => {
|
|
469
|
+
const base = projectContextForCwd(cwdOf(agent));
|
|
470
|
+
const override = agent === undefined ? undefined : agentStates.get(agent)?.workspaceRoot;
|
|
471
|
+
if (override === undefined) return base;
|
|
472
|
+
|
|
473
|
+
const repo = repositoryAt(override);
|
|
474
|
+
const repositories =
|
|
475
|
+
repo === undefined
|
|
476
|
+
? [{ root: override, mainRoot: base.workspace?.mainRoot ?? override, isWorktree: true }]
|
|
477
|
+
: [{ ...repo, isWorktree: true }];
|
|
478
|
+
const workspace = resolveWorkspaceTarget({
|
|
479
|
+
cwd: override,
|
|
480
|
+
config: base.effective.config,
|
|
481
|
+
repositories,
|
|
482
|
+
env: process.env,
|
|
483
|
+
});
|
|
484
|
+
return { ...base, cwd: override, repositories, workspace, workspaceOverride: true };
|
|
485
|
+
};
|
|
452
486
|
const effectiveFor = (agent) => contextFor(agent).effective;
|
|
453
487
|
|
|
454
488
|
/** Каталог, от которого считается корень BSL и относительный workspace.root. */
|
|
@@ -485,6 +519,7 @@ export async function apply(ctx, config) {
|
|
|
485
519
|
phase: phase?.phase,
|
|
486
520
|
since: phase?.since,
|
|
487
521
|
detail: phase?.detail,
|
|
522
|
+
warning: phase?.warning,
|
|
488
523
|
imported: current.imported?.projects?.length,
|
|
489
524
|
settings: plan.settings,
|
|
490
525
|
warnings: plan.warnings ?? [],
|
|
@@ -559,6 +594,11 @@ export async function apply(ctx, config) {
|
|
|
559
594
|
if (entry.unavailable) value = 'unavailable';
|
|
560
595
|
if (item?.fiber && !item.error) value = 'mounted';
|
|
561
596
|
if (item?.error) value = 'failed';
|
|
597
|
+
// Пока EDT-окружение не готово, mounted с нулём инструментов выглядит
|
|
598
|
+
// как поломка: показываем фазу подъёма.
|
|
599
|
+
if (entry.name === 'edt' && item?.fiber && !item.error && workspace.state === 'worktree' && workspace.status !== 'ready') {
|
|
600
|
+
value = workspace.status === 'failed' || workspace.status === 'stale' ? 'failed' : 'starting';
|
|
601
|
+
}
|
|
562
602
|
return {
|
|
563
603
|
name: entry.name,
|
|
564
604
|
state: value,
|
|
@@ -777,6 +817,25 @@ export async function apply(ctx, config) {
|
|
|
777
817
|
return Date.now() - updated < budget;
|
|
778
818
|
};
|
|
779
819
|
|
|
820
|
+
/** Убеждается, что фоновый run действительно начал работу (а не упал на lock). */
|
|
821
|
+
const launchStarted = async (buildDir, settings = {}, timeoutMs = 5000) => {
|
|
822
|
+
// Тот же срок устаревания, что в cmdInit/cmdRun: иначе lock, который они
|
|
823
|
+
// уже считают осиротевшим, здесь выглядел бы как живой запуск.
|
|
824
|
+
const maxAgeMs = (Math.max(settings.startTimeout ?? 1800, settings.importTimeout ?? 3600) + 600) * 1000;
|
|
825
|
+
const deadline = Date.now() + timeoutMs;
|
|
826
|
+
while (Date.now() < deadline) {
|
|
827
|
+
const current = workspaceState(buildDir, { alive: cheapAlive });
|
|
828
|
+
const phase = current.progress?.edt?.phase;
|
|
829
|
+
if (current.label === 'starting' || current.label === 'ready' || current.label === 'failed') return true;
|
|
830
|
+
if (phase !== undefined && phase !== 'ready' && phase !== 'failed') return true;
|
|
831
|
+
// run мог только взяться за lock, ещё не записав ни progress, ни pid.
|
|
832
|
+
const runLock = lockState(join(buildDir, 'init-run.lock'), { maxAgeMs });
|
|
833
|
+
if (runLock.exists === true && runLock.stale === false) return true;
|
|
834
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
};
|
|
838
|
+
|
|
780
839
|
/**
|
|
781
840
|
* Гарантирует подъём окружения: занимает порт, пишет снимок и запускает
|
|
782
841
|
* фоновый init. Готовое/стартующее окружение только принимается.
|
|
@@ -835,9 +894,23 @@ export async function apply(ctx, config) {
|
|
|
835
894
|
settings: plan.settings,
|
|
836
895
|
});
|
|
837
896
|
if (options.import === true) workspaceSnapshot.forceImport = true;
|
|
838
|
-
|
|
897
|
+
const initLog = join(key, 'logs', 'init.log');
|
|
898
|
+
const code = await cmdInit(plan.root, {}, { snapshot: workspaceSnapshot });
|
|
899
|
+
if (code !== 0) {
|
|
900
|
+
const tail = tailFile(initLog);
|
|
901
|
+
return {
|
|
902
|
+
...workspaceState(key, { alive: cheapAlive }),
|
|
903
|
+
launchError: `init завершился с кодом ${code}${tail === null ? '' : `: ${tail}`}`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
839
906
|
record.startedByUs = true;
|
|
840
|
-
|
|
907
|
+
if (!(await launchStarted(key, plan.settings))) {
|
|
908
|
+
const tail = tailFile(initLog);
|
|
909
|
+
return {
|
|
910
|
+
...workspaceState(key, { alive: cheapAlive }),
|
|
911
|
+
launchError: `run не стартовал${tail === null ? '' : `: ${tail}`}`,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
841
914
|
return workspaceState(key, { alive: cheapAlive });
|
|
842
915
|
} catch (error) {
|
|
843
916
|
ctx.logger?.warn?.(`mcp-hub: окружение EDT не поднялось: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -874,7 +947,8 @@ export async function apply(ctx, config) {
|
|
|
874
947
|
// Окружение поднимается до монтирования edt: нужен порт для адреса.
|
|
875
948
|
if (context.workspace.state === 'ok') {
|
|
876
949
|
try {
|
|
877
|
-
await ensureWorkspace(agent, context);
|
|
950
|
+
const launched = await ensureWorkspace(agent, context);
|
|
951
|
+
if (launched?.launchError !== undefined) ctx.logger?.warn?.(`mcp-hub: ${launched.launchError}`);
|
|
878
952
|
} catch (error) {
|
|
879
953
|
ctx.logger?.warn?.(`mcp-hub: окружение EDT не поднялось: ${error instanceof Error ? error.message : String(error)}`);
|
|
880
954
|
}
|
|
@@ -894,6 +968,7 @@ export async function apply(ctx, config) {
|
|
|
894
968
|
const state = agentStates.get(agent);
|
|
895
969
|
if (state === undefined || !state.active) return;
|
|
896
970
|
state.active = false;
|
|
971
|
+
state.workspaceRoot = undefined;
|
|
897
972
|
for (const dispose of state.disposers.splice(0)) {
|
|
898
973
|
try {
|
|
899
974
|
dispose();
|
|
@@ -924,7 +999,8 @@ export async function apply(ctx, config) {
|
|
|
924
999
|
}
|
|
925
1000
|
|
|
926
1001
|
if (context.workspace.state === 'ok') {
|
|
927
|
-
await ensureWorkspace(agent, context);
|
|
1002
|
+
const launched = await ensureWorkspace(agent, context);
|
|
1003
|
+
if (launched?.launchError !== undefined) ctx.logger?.warn?.(`mcp-hub: ${launched.launchError}`);
|
|
928
1004
|
// Порт появился после init — адрес edt изменился, перемонтируем.
|
|
929
1005
|
try {
|
|
930
1006
|
await reload(agent, 'edt');
|
|
@@ -1084,13 +1160,35 @@ export async function apply(ctx, config) {
|
|
|
1084
1160
|
}
|
|
1085
1161
|
if (info.imported !== undefined) lines.push(`Импортировано проектов: ${info.imported}`);
|
|
1086
1162
|
const home = detectEdtHome(info.settings, { platform: process.platform, env: process.env });
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
);
|
|
1163
|
+
const required = requiredJava(home.home, { platform: process.platform });
|
|
1164
|
+
const vm = resolveEdtVm(info.settings, home.home, { platform: process.platform, env: process.env });
|
|
1165
|
+
lines.push(`EDT: ${home.home ?? 'не найден'}${home.error === undefined ? '' : ` — ${home.error}`}`);
|
|
1166
|
+
lines.push(`Java: ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
|
|
1167
|
+
if (info.status !== 'ready') {
|
|
1168
|
+
if (home.mcpBundle === false) {
|
|
1169
|
+
lines.push('EDT-MCP: по файлам не подтверждён; готовность проверит /health');
|
|
1170
|
+
} else if (Array.isArray(home.mcpEvidence) && home.mcpEvidence.length > 0) {
|
|
1171
|
+
lines.push(`EDT-MCP: ${home.mcpEvidence[0]}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1090
1174
|
const importMode = info.settings.import;
|
|
1091
1175
|
lines.push(`Импорт проектов: ${importMode === true ? 'да' : importMode === false ? 'нет' : 'авто (если workspace пуст)'}`);
|
|
1176
|
+
lines.push(`Plain text: ${info.settings.plainText === false ? 'нет' : 'да'}; скриншоты форм: ${info.settings.formScreenshots === false ? 'нет' : 'да'}`);
|
|
1177
|
+
const prefsText = readPrefs(info.buildDir);
|
|
1178
|
+
const plainPref = prefsText === undefined ? undefined : prefValue(prefsText, 'mcpPlainTextMode');
|
|
1179
|
+
lines.push(
|
|
1180
|
+
`Plain text в prefs: ${prefsText === undefined ? 'файла ещё нет' : plainPref === undefined ? 'ключа нет' : plainPref === 'true' ? 'да' : 'нет'}`,
|
|
1181
|
+
);
|
|
1182
|
+
if (prefsText !== undefined && info.settings.plainText !== false && plainPref !== 'true') {
|
|
1183
|
+
lines.push('! prefs не содержат mcpPlainTextMode=true — выполните restart окружения');
|
|
1184
|
+
}
|
|
1185
|
+
if (info.warning !== undefined) {
|
|
1186
|
+
const warning = info.warning;
|
|
1187
|
+
const memory = warning.used === undefined ? '' : ` (${warning.used}/${warning.total} MB)`;
|
|
1188
|
+
lines.push(`! импорт шёл на пределе памяти${memory} — поднимите workspace.xmxImport`);
|
|
1189
|
+
}
|
|
1092
1190
|
for (const warning of info.warnings) lines.push(`! ${warning}`);
|
|
1093
|
-
lines.push(`Логи: ${join(info.buildDir, 'logs')}`);
|
|
1191
|
+
lines.push(`Логи: ${join(info.buildDir, 'logs')}; импорт: ${join(info.wsDir, '.metadata', '.log')}`);
|
|
1094
1192
|
return lines.join('\n');
|
|
1095
1193
|
};
|
|
1096
1194
|
|
|
@@ -1115,6 +1213,9 @@ export async function apply(ctx, config) {
|
|
|
1115
1213
|
if (plan === undefined || plan.state !== 'ok') {
|
|
1116
1214
|
throw new Error(`EDT-окружение недоступно: ${plan?.message ?? workspaceText(plan)}`);
|
|
1117
1215
|
}
|
|
1216
|
+
// root=… не только поднимает окружение, но и становится контекстом
|
|
1217
|
+
// сессии: entriesFor подменит URL edt на порт этого workspace.
|
|
1218
|
+
if (args.root !== undefined && args.root !== '') stateOf(exec?.agent).workspaceRoot = plan.root;
|
|
1118
1219
|
const record = registerWorkspaceSession(plan.buildDir, exec?.agent, plan.settings);
|
|
1119
1220
|
|
|
1120
1221
|
if (action === 'stop') {
|
|
@@ -1137,6 +1238,16 @@ export async function apply(ctx, config) {
|
|
|
1137
1238
|
const forceImport = args.import === true || action === 'import';
|
|
1138
1239
|
const result = await ensureWorkspace(exec?.agent, { ...context, workspace: plan }, { force: true, import: forceImport });
|
|
1139
1240
|
if (result === undefined) throw new Error('Не удалось запустить подъём окружения: смотрите логи build/logs.');
|
|
1241
|
+
if (result.launchError !== undefined) throw new Error(`${result.launchError}\n\n${workspaceText(plan)}`);
|
|
1242
|
+
if (result.label === 'failed') {
|
|
1243
|
+
const info = describeWorkspace(plan, { alive: cheapAlive });
|
|
1244
|
+
const warning = info.warning;
|
|
1245
|
+
const memory =
|
|
1246
|
+
warning === undefined
|
|
1247
|
+
? ''
|
|
1248
|
+
: `\n! импорт шёл на пределе памяти${warning.used === undefined ? '' : ` (${warning.used}/${warning.total} MB)`} — поднимите workspace.xmxImport`;
|
|
1249
|
+
throw new Error(`EDT-окружение не поднялось: ${info.detail ?? 'см. build/logs/edt.log'}${memory}`);
|
|
1250
|
+
}
|
|
1140
1251
|
if (result.label === 'ready') {
|
|
1141
1252
|
return { text: `EDT уже готов (порт ${result.port ?? '-'}).\n\n${workspaceText(plan)}` };
|
|
1142
1253
|
}
|
|
@@ -1181,6 +1292,11 @@ export async function apply(ctx, config) {
|
|
|
1181
1292
|
const { errors, warnings } = validateConfig(effective.config);
|
|
1182
1293
|
const activation = context.activation;
|
|
1183
1294
|
const workspace = describeWorkspace(context.workspace, { alive: cheapAlive });
|
|
1295
|
+
const origin = originCwdOf(exec?.agent);
|
|
1296
|
+
const dirsLine =
|
|
1297
|
+
origin === context.cwd
|
|
1298
|
+
? `Каталог: ${context.cwd}`
|
|
1299
|
+
: `Каталог: исходный ${origin}, рабочий ${context.cwd}${context.workspaceOverride === true ? ' (через edt_workspace root)' : ''}`;
|
|
1184
1300
|
const workspaceLine =
|
|
1185
1301
|
workspace.state === 'worktree'
|
|
1186
1302
|
? `EDT-окружение worktree: ${workspace.status}, порт ${workspace.port ?? '-'}, ${workspace.root}`
|
|
@@ -1189,6 +1305,7 @@ export async function apply(ctx, config) {
|
|
|
1189
1305
|
: 'EDT-окружение worktree: выключено (поднимается только в git worktree)';
|
|
1190
1306
|
const head = [
|
|
1191
1307
|
`Проект: ${activation.enabled ? 'включён' : 'выключен'} — ${activationReason(activation)}`,
|
|
1308
|
+
dirsLine,
|
|
1192
1309
|
`Источники: ${effective.sources.length > 0 ? effective.sources.join(', ') : 'только cordis.patch.yml'}`,
|
|
1193
1310
|
workspaceLine,
|
|
1194
1311
|
`Действующие настройки:\n${stringifyConfig(effective.config)}`,
|
package/lib/repo.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
-
import { normalizeProjectPath } from './enable.mjs';
|
|
14
|
+
import { normalizeProjectPath, matchesProject } from './enable.mjs';
|
|
15
15
|
|
|
16
16
|
/** Сколько родителей просматривать в поисках `.git`. */
|
|
17
17
|
const MAX_UP = 8;
|
|
@@ -151,3 +151,35 @@ export function uniqueRepositories(repositories, { platform = process.platform }
|
|
|
151
151
|
}
|
|
152
152
|
return out;
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Каталог, от которого плагин считает проект.
|
|
157
|
+
*
|
|
158
|
+
* `origin` — неизменяемый `session.header.cwd`, `work` — эффективный каталог
|
|
159
|
+
* сессии (`ctx.workingDirectory`). Переключаемся на work только если это
|
|
160
|
+
* linked worktree, связанный с исходным репозиторием: `cd` в подкаталог или в
|
|
161
|
+
* чужой репозиторий проект не переключает, а worktree того же репозитория —
|
|
162
|
+
* переключает. Возвращается корень worktree, а не подкаталог, чтобы настройки
|
|
163
|
+
* и окружение не зависели от `cd` внутри него.
|
|
164
|
+
* @returns {string} абсолютный каталог проекта.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveContextCwd({ origin, work, platform = process.platform } = {}) {
|
|
167
|
+
if (typeof origin !== 'string' || origin === '') {
|
|
168
|
+
return typeof work === 'string' && work !== '' ? work : process.cwd();
|
|
169
|
+
}
|
|
170
|
+
if (typeof work !== 'string' || work === '' || isSamePath(work, origin, { platform })) return origin;
|
|
171
|
+
|
|
172
|
+
const workRepo = repositoryAt(work, { platform });
|
|
173
|
+
if (workRepo === undefined || workRepo.isWorktree !== true) return origin;
|
|
174
|
+
|
|
175
|
+
const originRepo = repositoryAt(origin, { platform });
|
|
176
|
+
if (originRepo === undefined) return origin;
|
|
177
|
+
|
|
178
|
+
const related =
|
|
179
|
+
isSamePath(workRepo.mainRoot, originRepo.root, { platform }) ||
|
|
180
|
+
isSamePath(workRepo.mainRoot, origin, { platform }) ||
|
|
181
|
+
matchesProject(originRepo.root, workRepo.mainRoot, { platform }) ||
|
|
182
|
+
matchesProject(origin, workRepo.mainRoot, { platform });
|
|
183
|
+
|
|
184
|
+
return related ? workRepo.root : origin;
|
|
185
|
+
}
|
package/lib/schema.mjs
CHANGED
|
@@ -33,6 +33,10 @@ export const DEFAULTS = {
|
|
|
33
33
|
projects: [],
|
|
34
34
|
ports: { min: 20000, max: 39999 },
|
|
35
35
|
stopOnSessionEnd: true,
|
|
36
|
+
// MCP-инструменты EDT-MCP, которые нам нужны в worktree: plain text
|
|
37
|
+
// вместо embedded resource (DSH их не рендерит) и скриншоты форм.
|
|
38
|
+
plainText: true,
|
|
39
|
+
formScreenshots: true,
|
|
36
40
|
},
|
|
37
41
|
tests: {
|
|
38
42
|
yaxunit: { enabled: false, path: '', project: '' },
|
|
@@ -53,12 +57,24 @@ export const KNOWN = {
|
|
|
53
57
|
'servers.*.url': { type: 'string', description: 'Адрес MCP-сервера (streamable-http).' },
|
|
54
58
|
'servers.*.headers': { type: 'object', description: 'Заголовки запросов, например Authorization.' },
|
|
55
59
|
'servers.*.root': { type: 'string', description: 'Корень рабочего пространства (для bsl).' },
|
|
60
|
+
'servers.*.transport': { type: 'string', description: 'Транспорт: stdio или streamable-http.' },
|
|
61
|
+
'servers.*.disabled': { type: 'boolean', description: 'Не монтировать сервер.' },
|
|
62
|
+
'servers.*.unavailable': { type: 'boolean', description: 'Сервер описан, но не настроен (нет переменных окружения).' },
|
|
63
|
+
'servers.*.command': { type: 'string', description: 'Команда запуска для stdio-сервера.' },
|
|
64
|
+
'servers.*.args': { type: 'array', description: 'Аргументы команды stdio-сервера.' },
|
|
65
|
+
'servers.*.env': { type: 'object', description: 'Переменные окружения stdio-сервера.' },
|
|
66
|
+
'servers.*.cwd': { type: 'string', description: 'Рабочий каталог stdio-сервера.' },
|
|
67
|
+
'servers.*.maxInstructionBytes': { type: 'number', description: 'Предел текста инструкций сервера.' },
|
|
56
68
|
'servers.*.lazy': { type: 'boolean', description: 'Не подключать на старте, только по edt_pin.' },
|
|
57
69
|
'servers.*.shared': { type: 'boolean', description: 'Устарело: серверы всегда монтируются в скоупе сессии.' },
|
|
58
70
|
'servers.*.provision': { type: 'boolean', description: 'Параметры запуска добираются на edt_pin.' },
|
|
59
71
|
'servers.*.toolCallTimeoutMs': { type: 'number', description: 'Таймаут вызова инструмента.' },
|
|
60
72
|
'servers.*.failOnStartupError': { type: 'boolean', description: 'Падать, если стартовое подключение не удалось.' },
|
|
61
73
|
'servers.*.reconnect': { type: 'object', description: 'Политика переподключения.' },
|
|
74
|
+
'servers.*.reconnect.enabled': { type: 'boolean', description: 'Включено ли переподключение.' },
|
|
75
|
+
'servers.*.reconnect.initialDelayMs': { type: 'number', description: 'Начальная задержка переподключения, мс.' },
|
|
76
|
+
'servers.*.reconnect.maxDelayMs': { type: 'number', description: 'Максимальная задержка переподключения, мс.' },
|
|
77
|
+
'servers.*.reconnect.maxAttempts': { type: 'number', description: 'Максимум попыток переподключения.' },
|
|
62
78
|
'servers.bsl.jar': { type: 'string', description: 'Готовый jar BSL Language Server.' },
|
|
63
79
|
'servers.bsl.url': { type: 'string', description: 'Прямой URL на jar вместо GitHub.' },
|
|
64
80
|
'servers.bsl.version': { type: 'string', description: 'Тег релиза BSL LS, например v1.0.7.' },
|
|
@@ -86,6 +102,8 @@ export const KNOWN = {
|
|
|
86
102
|
'workspace.ports.min': { type: 'number', description: 'Нижняя граница диапазона портов.' },
|
|
87
103
|
'workspace.ports.max': { type: 'number', description: 'Верхняя граница диапазона портов.' },
|
|
88
104
|
'workspace.stopOnSessionEnd': { type: 'boolean', description: 'Останавливать EDT, когда закрылась последняя сессия на этом worktree.' },
|
|
105
|
+
'workspace.plainText': { type: 'boolean', description: 'Plain text mode EDT-MCP вместо embedded resource (DSH их не рендерит).' },
|
|
106
|
+
'workspace.formScreenshots': { type: 'boolean', description: 'Включить get_form_screenshot и JVM-флаг nativeFormBufferedLayoutRender.' },
|
|
89
107
|
'tests.yaxunit.enabled': { type: 'boolean', description: 'Включить правила тестируемости YAxUnit.' },
|
|
90
108
|
'tests.yaxunit.path': { type: 'string', description: 'Каталог тестов как образец стиля.' },
|
|
91
109
|
'tests.yaxunit.project': { type: 'string', description: 'EDT-проект, в который писать тесты.' },
|
|
@@ -217,6 +235,15 @@ export function validateConfig(config) {
|
|
|
217
235
|
errors.push('projects: ожидался массив строк');
|
|
218
236
|
}
|
|
219
237
|
|
|
238
|
+
if (isPlainObject(config.servers)) {
|
|
239
|
+
for (const [name, server] of Object.entries(config.servers)) {
|
|
240
|
+
if (!isPlainObject(server)) continue;
|
|
241
|
+
if (Array.isArray(server.args) && !server.args.every((item) => typeof item === 'string')) {
|
|
242
|
+
errors.push(`servers.${name}.args: ожидался массив строк`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
220
247
|
if (isPlainObject(config.workspace)) {
|
|
221
248
|
const workspace = config.workspace;
|
|
222
249
|
if (workspace.import !== undefined && ![true, false, 'auto'].includes(workspace.import)) {
|
package/lib/workspace-run.mjs
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
// Все зависимости, которые трогают процессы и время, передаются через `deps` —
|
|
15
15
|
// так сценарии проверяются без запуска настоящего EDT.
|
|
16
16
|
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
17
|
-
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
+
appendImportDigest,
|
|
23
24
|
buildWorkspaceSnapshot,
|
|
24
25
|
clearProgress,
|
|
25
26
|
detectEdtHome,
|
|
@@ -28,6 +29,8 @@ import {
|
|
|
28
29
|
formatElapsed,
|
|
29
30
|
guiArgs,
|
|
30
31
|
importArgs,
|
|
32
|
+
importDigest,
|
|
33
|
+
importMemoryWarning,
|
|
31
34
|
importScriptText,
|
|
32
35
|
isPortFree,
|
|
33
36
|
killTree,
|
|
@@ -40,6 +43,7 @@ import {
|
|
|
40
43
|
readPort,
|
|
41
44
|
readWorkspaceSnapshot,
|
|
42
45
|
registeredProjectsDir,
|
|
46
|
+
requiredJava,
|
|
43
47
|
resolveEdtVm,
|
|
44
48
|
stateOf,
|
|
45
49
|
tailFile,
|
|
@@ -134,25 +138,70 @@ function lockInfo(file) {
|
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
/**
|
|
141
|
+
/** Сколько живёт lock без обновления, если владелец не отвечает. */
|
|
142
|
+
const LOCK_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
143
|
+
|
|
144
|
+
/** Состояние lock для диагностики: живой ли pid, возраст, просрочен ли. */
|
|
145
|
+
export function lockState(file, deps = {}) {
|
|
146
|
+
const info = lockInfo(file);
|
|
147
|
+
if (info === null) return { exists: false };
|
|
148
|
+
const pid = Number.parseInt(info.pid, 10);
|
|
149
|
+
const at = Date.parse(info.at ?? '');
|
|
150
|
+
const ageMs = Number.isNaN(at) ? undefined : Math.max(0, Date.now() - at);
|
|
151
|
+
const maxAgeMs = deps.maxAgeMs ?? LOCK_MAX_AGE_MS;
|
|
152
|
+
const alive = deps.alive ?? ((value) => pidAlive(value));
|
|
153
|
+
const live = Number.isInteger(pid) && pid > 0 && alive(pid);
|
|
154
|
+
return {
|
|
155
|
+
exists: true,
|
|
156
|
+
pid: Number.isInteger(pid) ? pid : undefined,
|
|
157
|
+
at: Number.isNaN(at) ? undefined : new Date(at).toISOString(),
|
|
158
|
+
ageMs,
|
|
159
|
+
stale: !live || (ageMs !== undefined && ageMs > maxAgeMs),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Захватывает lock. Чужой lock считается stale, если pid не жив, образ не
|
|
165
|
+
* совпадает (`image`) или возраст больше `maxAgeMs`; stale перехватывается.
|
|
166
|
+
*/
|
|
138
167
|
export function acquireLock(file, deps = {}) {
|
|
139
168
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
140
169
|
const alive = deps.alive ?? ((pid) => pidAlive(pid));
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
170
|
+
const image = deps.image;
|
|
171
|
+
const maxAgeMs = deps.maxAgeMs ?? LOCK_MAX_AGE_MS;
|
|
172
|
+
const now = deps.now?.() ?? new Date();
|
|
173
|
+
|
|
174
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
175
|
+
const existing = lockInfo(file);
|
|
176
|
+
if (existing !== null) {
|
|
177
|
+
const pid = Number.parseInt(existing.pid, 10);
|
|
178
|
+
const at = Date.parse(existing.at ?? '');
|
|
179
|
+
const tooOld = !Number.isNaN(at) && now.getTime() - at > maxAgeMs;
|
|
180
|
+
const live = Number.isInteger(pid) && pid > 0 && alive(pid);
|
|
181
|
+
const sameImage = live && (image === undefined || pidAlive(pid, [image]));
|
|
182
|
+
if (live && sameImage && !tooOld) return false;
|
|
183
|
+
try {
|
|
184
|
+
unlinkSync(file);
|
|
185
|
+
} catch {
|
|
186
|
+
// lock мог исчезнуть между чтением и удалением
|
|
187
|
+
}
|
|
188
|
+
}
|
|
144
189
|
try {
|
|
145
|
-
|
|
190
|
+
writeFileSync(
|
|
191
|
+
file,
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
pid: process.pid,
|
|
194
|
+
at: now.toISOString(),
|
|
195
|
+
image: path.basename(process.execPath),
|
|
196
|
+
}),
|
|
197
|
+
{ flag: 'wx' },
|
|
198
|
+
);
|
|
199
|
+
return true;
|
|
146
200
|
} catch {
|
|
147
|
-
// lock
|
|
201
|
+
// кто-то успел создать lock — повторим проверку
|
|
148
202
|
}
|
|
149
203
|
}
|
|
150
|
-
|
|
151
|
-
writeFileSync(file, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }), { flag: 'wx' });
|
|
152
|
-
return true;
|
|
153
|
-
} catch {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
204
|
+
return false;
|
|
156
205
|
}
|
|
157
206
|
|
|
158
207
|
export function releaseLock(file) {
|
|
@@ -233,25 +282,54 @@ export async function cmdInit(root, args = {}, deps = {}) {
|
|
|
233
282
|
mkdirSync(buildDir, { recursive: true });
|
|
234
283
|
writeWorkspaceSnapshot(buildDir, snapshot);
|
|
235
284
|
|
|
236
|
-
const state = stateOf(buildDir, { alive: deps.alive });
|
|
237
|
-
if (state.label === 'ready' || state.label === 'starting') {
|
|
238
|
-
logTo(buildDir, `init: окружение уже ${state.label === 'ready' ? 'готово' : 'поднимается'}, делать нечего`);
|
|
239
|
-
return 0;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
285
|
const lock = filePath(buildDir, 'lock');
|
|
243
286
|
if (!acquireLock(lock, deps)) {
|
|
244
287
|
logTo(buildDir, 'init: другой процесс уже занимает порт, выходим');
|
|
245
288
|
return 0;
|
|
246
289
|
}
|
|
247
290
|
try {
|
|
291
|
+
// Осиротевший run-lock не должен блокировать повторный init.
|
|
292
|
+
const runLock = filePath(buildDir, 'runLock');
|
|
293
|
+
const maxAgeMs = (Math.max(snapshot.settings.startTimeout, snapshot.settings.importTimeout) + 600) * 1000;
|
|
294
|
+
const running = lockState(runLock, { ...deps, maxAgeMs });
|
|
295
|
+
if (running.exists && running.stale !== true) {
|
|
296
|
+
logTo(buildDir, `init: run уже идёт (pid ${running.pid ?? '?'}, возраст ${running.ageMs === undefined ? '?' : formatElapsed(new Date(Date.now() - running.ageMs).toISOString())})`);
|
|
297
|
+
return 4;
|
|
298
|
+
}
|
|
299
|
+
if (running.exists) {
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(runLock);
|
|
302
|
+
} catch {
|
|
303
|
+
// lock уже исчез
|
|
304
|
+
}
|
|
305
|
+
logTo(buildDir, 'init: снят осиротевший run-lock');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const state = stateOf(buildDir, { alive: deps.alive });
|
|
309
|
+
if (state.label === 'ready') {
|
|
310
|
+
logTo(buildDir, 'init: окружение уже готово, делать нечего');
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
const alive = deps.alive ?? ((pid) => pidAlive(pid, ['1cedt']));
|
|
314
|
+
if (state.label === 'starting' && state.pid !== null && alive(state.pid.pid)) {
|
|
315
|
+
logTo(buildDir, 'init: окружение уже поднимается, делать нечего');
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
if (state.label === 'starting') {
|
|
319
|
+
// Осталась только запись прогресса от упавшего run: начинаем заново.
|
|
320
|
+
clearProgress(buildDir);
|
|
321
|
+
}
|
|
322
|
+
|
|
248
323
|
let port = readPort(buildDir);
|
|
249
324
|
const free = deps.isPortFree ?? isPortFree;
|
|
250
325
|
if (port === null || !(await free(port))) {
|
|
251
326
|
port = await (deps.pickFreePort ?? pickFreePort)(snapshot.settings.ports, { isFree: free });
|
|
252
327
|
writePort(buildDir, port);
|
|
253
328
|
}
|
|
254
|
-
const prefs = writePrefs(buildDir, port
|
|
329
|
+
const prefs = writePrefs(buildDir, port, {
|
|
330
|
+
plainText: snapshot.settings.plainText,
|
|
331
|
+
formScreenshots: snapshot.settings.formScreenshots,
|
|
332
|
+
});
|
|
255
333
|
logTo(buildDir, `init: порт ${port}, prefs ${path.relative(root, prefs)}, запускаю run в фоне`);
|
|
256
334
|
const logPath = path.join(buildDir, 'logs', 'init.log');
|
|
257
335
|
(deps.spawnDetached ?? spawnDetached)(process.execPath, [SELF, 'run', '--root', root], logPath, root, deps);
|
|
@@ -277,9 +355,11 @@ export async function cmdRun(root, args = {}, deps = {}) {
|
|
|
277
355
|
mkdirSync(path.join(buildDir, 'logs'), { recursive: true });
|
|
278
356
|
|
|
279
357
|
const lock = filePath(buildDir, 'runLock');
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
358
|
+
const maxAgeMs = (Math.max(settings.startTimeout, settings.importTimeout) + 600) * 1000;
|
|
359
|
+
if (!acquireLock(lock, { ...deps, maxAgeMs, image: path.basename(process.execPath) })) {
|
|
360
|
+
const holder = lockState(lock, { ...deps, maxAgeMs });
|
|
361
|
+
log(`run: другой запуск уже идёт (pid ${holder.pid ?? '?'}, возраст ${holder.ageMs === undefined ? '?' : `${Math.round(holder.ageMs / 1000)}s`}), выходим`);
|
|
362
|
+
return 2;
|
|
283
363
|
}
|
|
284
364
|
|
|
285
365
|
try {
|
|
@@ -297,7 +377,11 @@ export async function cmdRun(root, args = {}, deps = {}) {
|
|
|
297
377
|
return 1;
|
|
298
378
|
}
|
|
299
379
|
if (detected.mcpBundle !== true) {
|
|
300
|
-
|
|
380
|
+
// Это только файловая эвристика: бандл может стоять в пользовательской
|
|
381
|
+
// configuration area, а готовность подтверждает /health.
|
|
382
|
+
log('edt: EDT-MCP не подтверждён по файлам; готовность проверит /health');
|
|
383
|
+
} else if (Array.isArray(detected.mcpEvidence) && detected.mcpEvidence.length > 0) {
|
|
384
|
+
log(`edt: EDT-MCP найден: ${detected.mcpEvidence[0]}`);
|
|
301
385
|
}
|
|
302
386
|
|
|
303
387
|
const port = readPort(buildDir);
|
|
@@ -315,14 +399,37 @@ export async function cmdRun(root, args = {}, deps = {}) {
|
|
|
315
399
|
log('edt: FAILED: в установке нет 1cedtcli, импорт проектов невозможен');
|
|
316
400
|
return 1;
|
|
317
401
|
}
|
|
402
|
+
|
|
403
|
+
let importWarning;
|
|
404
|
+
let lastImportDetail;
|
|
318
405
|
if (importNeeded) {
|
|
406
|
+
const metadataLog = path.join(snapshot.wsDir, '.metadata', '.log');
|
|
407
|
+
let offset = 0;
|
|
408
|
+
try {
|
|
409
|
+
offset = statSync(metadataLog).size;
|
|
410
|
+
} catch {
|
|
411
|
+
offset = 0;
|
|
412
|
+
}
|
|
413
|
+
const total = snapshot.projects.length;
|
|
319
414
|
const scriptPath = writeImportScript(buildDir, snapshot.projects);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
415
|
+
const seenProjects = new Set();
|
|
416
|
+
let lastProject;
|
|
417
|
+
const tick = () => {
|
|
418
|
+
const digest = (deps.importDigest ?? importDigest)(buildDir, offset, total, deps);
|
|
419
|
+
offset = digest.offset;
|
|
420
|
+
(deps.appendImportDigest ?? appendImportDigest)(buildDir, digest.lines);
|
|
421
|
+
const memory = importMemoryWarning(digest.text);
|
|
422
|
+
if (memory !== undefined) importWarning = memory;
|
|
423
|
+
for (const name of digest.started ?? []) seenProjects.add(name);
|
|
424
|
+
if (digest.current !== undefined) lastProject = digest.current;
|
|
425
|
+
const count = total > 0 ? Math.min(seenProjects.size, total) : seenProjects.size;
|
|
426
|
+
lastImportDetail = `${count}/${total}${lastProject === undefined ? '' : ` — ${lastProject}`}`;
|
|
427
|
+
writeProgress(buildDir, 'edt', 'importing', lastImportDetail, deps.now?.(), memory === undefined ? undefined : { warning: memory });
|
|
428
|
+
};
|
|
429
|
+
lastImportDetail = `0/${total}`;
|
|
430
|
+
writeProgress(buildDir, 'edt', 'importing', lastImportDetail, deps.now?.());
|
|
431
|
+
log(`edt: импорт ${total} проект(ов) в ${path.relative(root, snapshot.wsDir)} (xmx ${settings.xmxImport}, timeout ${settings.importTimeout}s)`);
|
|
432
|
+
const ticker = setInterval(tick, deps.importPollMs ?? 5000);
|
|
326
433
|
ticker.unref?.();
|
|
327
434
|
try {
|
|
328
435
|
await (deps.runCapture ?? runCapture)(
|
|
@@ -333,15 +440,20 @@ export async function cmdRun(root, args = {}, deps = {}) {
|
|
|
333
440
|
);
|
|
334
441
|
} catch (error) {
|
|
335
442
|
clearInterval(ticker);
|
|
336
|
-
|
|
337
|
-
|
|
443
|
+
tick();
|
|
444
|
+
const detail = `импорт не удался (${lastImportDetail}): ${error?.message ?? error}; см. build/ws/.metadata/.log`;
|
|
445
|
+
writeProgress(buildDir, 'edt', 'import-failed', detail, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
|
|
338
446
|
writeStamp(buildDir, 'failed', deps.now?.());
|
|
339
|
-
log(`edt: FAILED:
|
|
447
|
+
log(`edt: FAILED: ${detail}`);
|
|
340
448
|
return 1;
|
|
341
449
|
}
|
|
342
450
|
clearInterval(ticker);
|
|
451
|
+
tick();
|
|
343
452
|
writeImported(buildDir, snapshot.projects, deps.now?.());
|
|
344
|
-
log(
|
|
453
|
+
log(`edt: импорт завершён (${lastImportDetail})`);
|
|
454
|
+
if (importWarning !== undefined) {
|
|
455
|
+
log(`edt: импорт шёл на пределе памяти (${importWarning.used ?? '?'}/${importWarning.total ?? '?'} MB) — поднимите workspace.xmxImport`);
|
|
456
|
+
}
|
|
345
457
|
if (snapshot.forceImport === true) {
|
|
346
458
|
const { forceImport, ...rest } = snapshot;
|
|
347
459
|
writeWorkspaceSnapshot(buildDir, rest);
|
|
@@ -350,16 +462,26 @@ export async function cmdRun(root, args = {}, deps = {}) {
|
|
|
350
462
|
log('edt: импорт не требуется');
|
|
351
463
|
}
|
|
352
464
|
|
|
353
|
-
writePrefs(buildDir, port
|
|
465
|
+
writePrefs(buildDir, port, {
|
|
466
|
+
plainText: settings.plainText,
|
|
467
|
+
formScreenshots: settings.formScreenshots,
|
|
468
|
+
});
|
|
354
469
|
const runningPid = readPid(buildDir)?.pid;
|
|
355
470
|
if (runningPid !== undefined && (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(runningPid)) {
|
|
356
471
|
log(`edt: уже запущен (pid ${runningPid})`);
|
|
357
472
|
} else {
|
|
358
473
|
const vm = (deps.resolveEdtVm ?? resolveEdtVm)(settings, detected.home, deps);
|
|
474
|
+
const required = (deps.requiredJava ?? requiredJava)(detected.home, { platform: process.platform });
|
|
475
|
+
log(`edt: Java ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
|
|
359
476
|
writeProgress(buildDir, 'edt', 'gui');
|
|
360
477
|
const gui = (deps.spawnDetached ?? spawnDetached)(
|
|
361
478
|
detected.bin,
|
|
362
|
-
guiArgs({
|
|
479
|
+
guiArgs({
|
|
480
|
+
wsDir: snapshot.wsDir,
|
|
481
|
+
xmx: settings.xmxIde,
|
|
482
|
+
vm,
|
|
483
|
+
nativeFormBufferedLayoutRender: settings.formScreenshots,
|
|
484
|
+
}),
|
|
363
485
|
path.join(buildDir, 'logs', 'edt.log'),
|
|
364
486
|
root,
|
|
365
487
|
deps,
|
|
@@ -377,17 +499,30 @@ export async function cmdRun(root, args = {}, deps = {}) {
|
|
|
377
499
|
removeStamp(buildDir, 'ready');
|
|
378
500
|
writeProgress(buildDir, 'edt', 'health');
|
|
379
501
|
log(`edt: жду /health на порту ${port} (до ${Math.round(settings.startTimeout / 60)} мин)`);
|
|
380
|
-
const ready = await (deps.
|
|
381
|
-
|
|
382
|
-
|
|
502
|
+
const ready = await (deps.waitReady ?? waitReady)(
|
|
503
|
+
{
|
|
504
|
+
port,
|
|
505
|
+
pid: readPid(buildDir)?.pid,
|
|
506
|
+
alive: (value) => (deps.alive ?? ((pid) => pidAlive(pid, ['1cedt'])))(value),
|
|
507
|
+
logPath: path.join(buildDir, 'logs', 'edt.log'),
|
|
508
|
+
timeoutMs: settings.startTimeout * 1000,
|
|
509
|
+
},
|
|
510
|
+
deps,
|
|
511
|
+
);
|
|
512
|
+
if (ready.ok === true) {
|
|
513
|
+
writeProgress(buildDir, 'edt', 'ready', undefined, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
|
|
383
514
|
writeStamp(buildDir, 'ready', deps.now?.());
|
|
384
515
|
removeStamp(buildDir, 'failed');
|
|
385
516
|
log('edt: READY');
|
|
386
517
|
return 0;
|
|
387
518
|
}
|
|
388
|
-
|
|
519
|
+
const detail =
|
|
520
|
+
ready.reason === 'process-exited'
|
|
521
|
+
? `процесс EDT завершился${ready.detail === undefined ? '' : `: ${ready.detail}`}`
|
|
522
|
+
: `health timeout${ready.detail === undefined ? '' : `: ${ready.detail}`}`;
|
|
523
|
+
writeProgress(buildDir, 'edt', 'failed', detail, deps.now?.(), importWarning === undefined ? undefined : { warning: importWarning });
|
|
389
524
|
writeStamp(buildDir, 'failed', deps.now?.());
|
|
390
|
-
log(
|
|
525
|
+
log(`edt: FAILED: ${detail}`);
|
|
391
526
|
return 1;
|
|
392
527
|
} finally {
|
|
393
528
|
releaseLock(lock);
|
|
@@ -413,6 +548,33 @@ export async function waitHealth(port, timeoutMs, deps = {}) {
|
|
|
413
548
|
return false;
|
|
414
549
|
}
|
|
415
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Ждёт готовности EDT и замечает смерть процесса: если pid умер, а /health
|
|
553
|
+
* молчит grace-период, возвращает причину сразу, не выжидая весь startTimeout.
|
|
554
|
+
* @returns {Promise<{ ok: boolean, reason?: 'process-exited'|'timeout', detail?: string }>}
|
|
555
|
+
*/
|
|
556
|
+
export async function waitReady({ port, pid, alive, logPath, timeoutMs }, deps = {}) {
|
|
557
|
+
const probe = deps.probeHealth ?? probeHealth;
|
|
558
|
+
const delay = deps.sleep ?? sleep;
|
|
559
|
+
const pollMs = deps.pollMs ?? HEALTH_POLL_MS;
|
|
560
|
+
const graceMs = deps.processGraceMs ?? 15_000;
|
|
561
|
+
const deadline = Date.now() + timeoutMs;
|
|
562
|
+
let deadSince;
|
|
563
|
+
const detail = () => (typeof logPath === 'string' ? tailFile(logPath, 300) ?? undefined : undefined);
|
|
564
|
+
|
|
565
|
+
while (Date.now() < deadline) {
|
|
566
|
+
if (await probe(port, deps)) return { ok: true };
|
|
567
|
+
if (pid !== undefined && typeof alive === 'function' && !alive(pid)) {
|
|
568
|
+
deadSince ??= Date.now();
|
|
569
|
+
if (Date.now() - deadSince >= graceMs) return { ok: false, reason: 'process-exited', detail: detail() };
|
|
570
|
+
} else {
|
|
571
|
+
deadSince = undefined;
|
|
572
|
+
}
|
|
573
|
+
await delay(pollMs);
|
|
574
|
+
}
|
|
575
|
+
return { ok: false, reason: 'timeout', detail: detail() };
|
|
576
|
+
}
|
|
577
|
+
|
|
416
578
|
/** Останавливает EDT этого worktree. */
|
|
417
579
|
export async function cmdStop(root, deps = {}) {
|
|
418
580
|
const buildDir = path.join(root, 'build');
|
|
@@ -471,6 +633,13 @@ export function statusReport(root, deps = {}) {
|
|
|
471
633
|
`workspace: ${snapshot?.wsDir ?? path.join(buildDir, 'ws')}`,
|
|
472
634
|
`порт: ${state.port ?? '-'} pid: ${state.pid?.pid ?? '-'}`,
|
|
473
635
|
];
|
|
636
|
+
const settings = snapshot?.settings ?? normalizeWorkspaceSettings({});
|
|
637
|
+
const home = detectEdtHome(settings, { platform: process.platform, env: process.env });
|
|
638
|
+
const required = requiredJava(home.home, { platform: process.platform });
|
|
639
|
+
const vm = resolveEdtVm(settings, home.home, { platform: process.platform, env: process.env });
|
|
640
|
+
lines.push(`EDT: ${home.home ?? 'не найден'}`);
|
|
641
|
+
lines.push(`Java: ${vm ?? 'авто'}${required.major === undefined ? '' : ` (EDT требует ${required.major})`}`);
|
|
642
|
+
lines.push(`Plain text: ${settings.plainText === false ? 'нет' : 'да'}; скриншоты форм: ${settings.formScreenshots === false ? 'нет' : 'да'}`);
|
|
474
643
|
if (progress?.phase) {
|
|
475
644
|
lines.push(`фаза: ${progress.phase}${progress.since ? ` (${formatElapsed(progress.since)})` : ''}${progress.detail ? ` — ${progress.detail}` : ''}`);
|
|
476
645
|
}
|
package/lib/workspace.mjs
CHANGED
|
@@ -10,17 +10,22 @@
|
|
|
10
10
|
// поднятое opencode в том же worktree, плагин принимает как готовое.
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
12
|
import {
|
|
13
|
+
appendFileSync,
|
|
14
|
+
closeSync,
|
|
13
15
|
existsSync,
|
|
14
16
|
mkdirSync,
|
|
17
|
+
openSync,
|
|
15
18
|
readFileSync,
|
|
16
19
|
readdirSync,
|
|
17
20
|
readlinkSync,
|
|
21
|
+
readSync,
|
|
18
22
|
renameSync,
|
|
19
23
|
rmSync,
|
|
20
24
|
statSync,
|
|
21
25
|
writeFileSync,
|
|
22
26
|
} from 'node:fs';
|
|
23
27
|
import { createServer, connect } from 'node:net';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
24
29
|
import path from 'node:path';
|
|
25
30
|
|
|
26
31
|
import { DEFAULTS } from './schema.mjs';
|
|
@@ -115,6 +120,8 @@ export function normalizeWorkspaceSettings(raw = {}, env = process.env) {
|
|
|
115
120
|
projects: Array.isArray(value.projects) ? value.projects.filter((item) => text(item) !== undefined).map((item) => item.trim()) : [],
|
|
116
121
|
ports: min <= max ? { min, max } : { min: Math.min(min, max), max: Math.max(min, max) },
|
|
117
122
|
stopOnSessionEnd: pick('stopOnSessionEnd', defaults.stopOnSessionEnd) !== false,
|
|
123
|
+
plainText: pick('plainText', defaults.plainText) !== false,
|
|
124
|
+
formScreenshots: pick('formScreenshots', defaults.formScreenshots) !== false,
|
|
118
125
|
};
|
|
119
126
|
}
|
|
120
127
|
|
|
@@ -318,12 +325,60 @@ export function mergePrefsText(current, values = {}) {
|
|
|
318
325
|
return out;
|
|
319
326
|
}
|
|
320
327
|
|
|
328
|
+
/** Значения инструментов, отключённых в EDT-MCP по умолчанию. */
|
|
329
|
+
export const DEFAULT_DISABLED_TOOLS = ['git', 'ask_workmate', 'get_form_screenshot'];
|
|
330
|
+
|
|
331
|
+
/** Значение ключа prefs (первое вхождение). */
|
|
332
|
+
export function prefValue(text, key) {
|
|
333
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
334
|
+
const index = line.indexOf('=');
|
|
335
|
+
if (index > 0 && line.slice(0, index) === key) return line.slice(index + 1);
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Список отключённых инструментов из prefs (или дефолт EDT-MCP). */
|
|
341
|
+
function disabledToolsList(current) {
|
|
342
|
+
const raw = prefValue(current, 'mcpDisabledTools');
|
|
343
|
+
return (raw === undefined ? DEFAULT_DISABLED_TOOLS : raw.split(','))
|
|
344
|
+
.map((item) => item.trim())
|
|
345
|
+
.filter((item) => item !== '');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function disabledToolsWithout(current, tool) {
|
|
349
|
+
return [...new Set(disabledToolsList(current).filter((item) => item !== tool))].join(',');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function disabledToolsWith(current, tool) {
|
|
353
|
+
return [...new Set([...disabledToolsList(current), tool])].join(',');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Содержимое prefs EDT-MCP этого workspace (undefined — файла нет). */
|
|
357
|
+
export function readPrefs(buildDir) {
|
|
358
|
+
return readText(prefsPath(buildDir));
|
|
359
|
+
}
|
|
360
|
+
|
|
321
361
|
/** prefs, включающие MCP-сервер EDT на нужном порту при старте. */
|
|
322
|
-
export function writePrefs(buildDir, port) {
|
|
362
|
+
export function writePrefs(buildDir, port, options = {}) {
|
|
323
363
|
const file = prefsPath(buildDir);
|
|
324
364
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
325
365
|
const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
326
|
-
const
|
|
366
|
+
const values = {
|
|
367
|
+
mcpServerHttpEnabled: 'true',
|
|
368
|
+
mcpServerAutoStart: 'true',
|
|
369
|
+
mcpServerPort: port,
|
|
370
|
+
// DSH не рендерит embedded resource: markdown-инструменты EDT-MCP должны
|
|
371
|
+
// отдавать текст (Plain text mode).
|
|
372
|
+
mcpPlainTextMode: String(options.plainText !== false),
|
|
373
|
+
};
|
|
374
|
+
// get_form_screenshot выключен в EDT-MCP по умолчанию. Список всегда
|
|
375
|
+
// приводим к настройке: при true убираем инструмент, при false — возвращаем,
|
|
376
|
+
// иначе prefs от прошлого запуска оставят его включённым.
|
|
377
|
+
values.mcpDisabledTools =
|
|
378
|
+
options.formScreenshots !== false
|
|
379
|
+
? disabledToolsWithout(current, 'get_form_screenshot')
|
|
380
|
+
: disabledToolsWith(current, 'get_form_screenshot');
|
|
381
|
+
const next = mergePrefsText(current, values);
|
|
327
382
|
atomicWrite(file, next);
|
|
328
383
|
return file;
|
|
329
384
|
}
|
|
@@ -472,12 +527,17 @@ export function readProgress(buildDir) {
|
|
|
472
527
|
return readJson(filePath(buildDir, 'progress')) ?? null;
|
|
473
528
|
}
|
|
474
529
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
530
|
+
/**
|
|
531
|
+
* @param now момент записи (для тестов передаётся явно: `since` не должен
|
|
532
|
+
* зависеть от разрешения часов).
|
|
533
|
+
*/
|
|
534
|
+
export function writeProgress(buildDir, kind, phase, detail, now = new Date(), extra = undefined) {
|
|
535
|
+
const prev = readProgress(buildDir) ?? { startedAt: now.toISOString() };
|
|
536
|
+
const stamp = now.toISOString();
|
|
478
537
|
const was = prev[kind];
|
|
479
|
-
prev[kind] = { phase, since: was?.phase === phase && was.since ? was.since :
|
|
538
|
+
prev[kind] = { phase, since: was?.phase === phase && was.since ? was.since : stamp, updated: stamp };
|
|
480
539
|
if (detail !== undefined) prev[kind].detail = detail;
|
|
540
|
+
if (extra !== undefined && typeof extra === 'object') Object.assign(prev[kind], extra);
|
|
481
541
|
atomicWrite(filePath(buildDir, 'progress'), `${JSON.stringify(prev, null, 2)}\n`);
|
|
482
542
|
}
|
|
483
543
|
|
|
@@ -499,6 +559,111 @@ export function tailFile(file, maxLen = 200) {
|
|
|
499
559
|
return lines.length === 0 ? null : lines[lines.length - 1].slice(0, maxLen);
|
|
500
560
|
}
|
|
501
561
|
|
|
562
|
+
/** Длина буфера без обрезанной многобайтовой UTF-8 последовательности. */
|
|
563
|
+
function completeUtf8Length(buffer) {
|
|
564
|
+
let index = buffer.length - 1;
|
|
565
|
+
let continuation = 0;
|
|
566
|
+
while (index >= 0 && (buffer[index] & 0xc0) === 0x80 && continuation < 3) {
|
|
567
|
+
index -= 1;
|
|
568
|
+
continuation += 1;
|
|
569
|
+
}
|
|
570
|
+
if (index < 0) return buffer.length;
|
|
571
|
+
const lead = buffer[index];
|
|
572
|
+
let needed;
|
|
573
|
+
if ((lead & 0x80) === 0) needed = 1;
|
|
574
|
+
else if ((lead & 0xe0) === 0xc0) needed = 2;
|
|
575
|
+
else if ((lead & 0xf0) === 0xe0) needed = 3;
|
|
576
|
+
else if ((lead & 0xf8) === 0xf0) needed = 4;
|
|
577
|
+
else return buffer.length;
|
|
578
|
+
const have = buffer.length - index;
|
|
579
|
+
return have >= needed ? buffer.length : index;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Читает файл с байтового смещения (для растущего workspace `.metadata/.log`). */
|
|
583
|
+
export function readFromOffset(file, offset, { maxBytes = 512 * 1024 } = {}) {
|
|
584
|
+
let fd;
|
|
585
|
+
try {
|
|
586
|
+
fd = openSync(file, 'r');
|
|
587
|
+
const size = statSync(file).size;
|
|
588
|
+
const start = Math.max(0, Math.min(offset ?? 0, size));
|
|
589
|
+
const length = Math.min(Math.max(0, size - start), maxBytes);
|
|
590
|
+
if (length === 0) return { text: '', offset: size };
|
|
591
|
+
const buffer = Buffer.alloc(length);
|
|
592
|
+
readSync(fd, buffer, 0, length, start);
|
|
593
|
+
const complete = completeUtf8Length(buffer);
|
|
594
|
+
return {
|
|
595
|
+
text: buffer.subarray(0, complete).toString('utf8'),
|
|
596
|
+
// Следующий вызов дочитает остаток, а не пропустит его.
|
|
597
|
+
offset: start + complete,
|
|
598
|
+
};
|
|
599
|
+
} catch {
|
|
600
|
+
return { text: '', offset: offset ?? 0 };
|
|
601
|
+
} finally {
|
|
602
|
+
if (fd !== undefined) closeSync(fd);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const PROJECT_STARTED = /Project context is started:\s*(.+?)\s*$/;
|
|
607
|
+
const PROJECT_STARTING = /Project context is being started:\s*(.+?)(?:\s+\([A-Z_]+\))?\s*$/;
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Прогресс импорта по `build/ws/.metadata/.log`: сколько проектов стартовало
|
|
611
|
+
* и какой проект сейчас в работе. Счётчик ограничен `total`.
|
|
612
|
+
*/
|
|
613
|
+
export function parseImportProgress(text, total = 0) {
|
|
614
|
+
const started = new Set();
|
|
615
|
+
let lastStarted;
|
|
616
|
+
let lastStarting;
|
|
617
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
618
|
+
const startedMatch = PROJECT_STARTED.exec(line);
|
|
619
|
+
if (startedMatch !== null) {
|
|
620
|
+
lastStarted = startedMatch[1].trim();
|
|
621
|
+
started.add(lastStarted);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const startingMatch = PROJECT_STARTING.exec(line);
|
|
625
|
+
if (startingMatch !== null) lastStarting = startingMatch[1].trim();
|
|
626
|
+
}
|
|
627
|
+
const count = total > 0 ? Math.min(started.size, total) : started.size;
|
|
628
|
+
const current = lastStarting !== undefined && !started.has(lastStarting) ? lastStarting : lastStarted;
|
|
629
|
+
return { started: [...started], startedCount: count, total, current };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Свежий кусок metadata-лога, прогресс импорта и полезные строки. */
|
|
633
|
+
export function importDigest(buildDir, offset, total, deps = {}) {
|
|
634
|
+
const file = path.join(buildDir, 'ws', '.metadata', '.log');
|
|
635
|
+
const { text, offset: next } = (deps.readFromOffset ?? readFromOffset)(file, offset);
|
|
636
|
+
const progress = parseImportProgress(text, total);
|
|
637
|
+
const lines = text
|
|
638
|
+
.split(/\r?\n/)
|
|
639
|
+
.filter((line) => /Project context|CPU overload|Memory \d+ MB|!ERROR|Exception/.test(line));
|
|
640
|
+
return { ...progress, offset: next, lines, text };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Копирует значимые строки импорта в build/logs/edt-import.log. */
|
|
644
|
+
export function appendImportDigest(buildDir, lines) {
|
|
645
|
+
if (!Array.isArray(lines) || lines.length === 0) return;
|
|
646
|
+
const file = path.join(buildDir, 'logs', 'edt-import.log');
|
|
647
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
648
|
+
appendFileSync(file, `${lines.join('\n')}\n`, 'utf8');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Признак нехватки памяти при импорте: `CPU overload. Memory X MB used of Y MB`
|
|
653
|
+
* или явный `CPU overload`. Возвращает последнее измерение, если загрузка ≥85 %.
|
|
654
|
+
*/
|
|
655
|
+
export function importMemoryWarning(text) {
|
|
656
|
+
const matches = [...String(text ?? '').matchAll(/Memory\s+(\d+)\s+MB\s+used\s+of\s+(\d+)\s+MB/gi)];
|
|
657
|
+
const last = matches.at(-1);
|
|
658
|
+
const cpuOverload = /CPU overload/i.test(String(text ?? ''));
|
|
659
|
+
if (last === undefined) return cpuOverload ? { cpuOverload: true } : undefined;
|
|
660
|
+
const used = Number.parseInt(last[1], 10);
|
|
661
|
+
const total = Number.parseInt(last[2], 10);
|
|
662
|
+
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return undefined;
|
|
663
|
+
const ratio = used / total;
|
|
664
|
+
return ratio >= 0.85 || cpuOverload ? { used, total, ratio, cpuOverload } : undefined;
|
|
665
|
+
}
|
|
666
|
+
|
|
502
667
|
/** Снимок настроек окружения — для диагностики и фонового процесса. */
|
|
503
668
|
export function buildWorkspaceSnapshot({ root, buildDir, wsDir, mainRoot, settings, projects, now = new Date() }) {
|
|
504
669
|
return {
|
|
@@ -598,18 +763,97 @@ export function killTree(pid, { force = false, platform = process.platform, run
|
|
|
598
763
|
|
|
599
764
|
// --- установка EDT ----------------------------------------------------------
|
|
600
765
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
766
|
+
const MCP_BUNDLE = /com\.ditrix\.edt\.mcp\.server|ditrix/i;
|
|
767
|
+
|
|
768
|
+
function bundleNames(dir) {
|
|
769
|
+
try {
|
|
770
|
+
return readdirSync(dir).filter((name) => MCP_BUNDLE.test(name));
|
|
771
|
+
} catch {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Значение ключа Eclipse `config.ini` (без разбора секций). */
|
|
777
|
+
function iniValue(text, key) {
|
|
778
|
+
const match = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*(.+)$`, 'm').exec(String(text ?? ''));
|
|
779
|
+
return match === null ? undefined : match[1].trim();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** `file\:configuration/` -> абсолютный путь. */
|
|
783
|
+
function resolveEclipsePath(value, baseDir) {
|
|
784
|
+
if (typeof value !== 'string' || value === '') return undefined;
|
|
785
|
+
const raw = value.replace(/\\:/g, ':').replace(/^file:/i, '');
|
|
786
|
+
if (raw === '') return undefined;
|
|
787
|
+
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(baseDir, raw);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Области конфигурации, в которых Eclipse ищет бандлы. */
|
|
791
|
+
function configurationAreas(home, { platform = process.platform, eclipseRoots } = {}) {
|
|
792
|
+
const areas = [];
|
|
793
|
+
if (typeof home === 'string' && home !== '') {
|
|
794
|
+
// Сам каталог установки: <home>/plugins и <home>/features.
|
|
795
|
+
areas.push(home);
|
|
796
|
+
const configDir = path.join(home, 'configuration');
|
|
797
|
+
areas.push(configDir);
|
|
798
|
+
try {
|
|
799
|
+
const shared = iniValue(readFileSync(path.join(configDir, 'config.ini'), 'utf8'), 'osgi.sharedConfiguration.area');
|
|
800
|
+
const resolved = resolveEclipsePath(shared, configDir);
|
|
801
|
+
if (resolved !== undefined && resolved !== configDir) areas.push(resolved);
|
|
802
|
+
} catch {
|
|
803
|
+
// config.ini может отсутствовать — не мешает проверке установки
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
const ini = readFileSync(path.join(home, platform === 'win32' ? '1cedt.ini' : '1cedt.ini'), 'utf8');
|
|
807
|
+
const line = /^-configuration\s+(.+)$/m.exec(ini)?.[1]?.trim();
|
|
808
|
+
const resolved = resolveEclipsePath(line, home);
|
|
809
|
+
if (resolved !== undefined) areas.push(resolved);
|
|
810
|
+
} catch {
|
|
811
|
+
// 1cedt.ini может отсутствовать
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const homeDir = platform === 'win32' ? homedir() : (process.env.HOME ?? homedir());
|
|
816
|
+
for (const root of eclipseRoots ?? [path.join(homeDir, '.eclipse')]) {
|
|
604
817
|
let entries;
|
|
605
818
|
try {
|
|
606
|
-
entries = readdirSync(
|
|
819
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
607
820
|
} catch {
|
|
608
821
|
continue;
|
|
609
822
|
}
|
|
610
|
-
|
|
823
|
+
for (const entry of entries) {
|
|
824
|
+
if (!entry.isDirectory() || !/^org\.eclipse\.platform_/.test(entry.name)) continue;
|
|
825
|
+
areas.push(path.join(root, entry.name, 'configuration'));
|
|
826
|
+
}
|
|
611
827
|
}
|
|
612
|
-
return
|
|
828
|
+
return [...new Set(areas)];
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Где реально лежит EDT-MCP: установка, shared area или пользовательская
|
|
833
|
+
* configuration area (`~/.eclipse/org.eclipse.platform_*`). Это подсказка для
|
|
834
|
+
* диагностики, а не критерий выбора установки.
|
|
835
|
+
* @returns {{ found: boolean, evidence: string[] }}
|
|
836
|
+
*/
|
|
837
|
+
export function mcpEvidence(home, options = {}) {
|
|
838
|
+
const evidence = [];
|
|
839
|
+
for (const area of configurationAreas(home, options)) {
|
|
840
|
+
for (const sub of ['plugins', 'features']) {
|
|
841
|
+
const hits = bundleNames(path.join(area, sub));
|
|
842
|
+
if (hits.length > 0) evidence.push(`${path.join(area, sub)}: ${hits[0]}`);
|
|
843
|
+
}
|
|
844
|
+
const bundles = path.join(area, 'org.eclipse.equinox.simpleconfigurator', 'bundles.info');
|
|
845
|
+
try {
|
|
846
|
+
if (MCP_BUNDLE.test(readFileSync(bundles, 'utf8'))) evidence.push(bundles);
|
|
847
|
+
} catch {
|
|
848
|
+
// bundles.info может отсутствовать
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return { found: evidence.length > 0, evidence };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Установлен ли в этой EDT плагин EDT-MCP (обёртка над mcpEvidence). */
|
|
855
|
+
export function hasMcpBundle(home, options = {}) {
|
|
856
|
+
return mcpEvidence(home, options).found;
|
|
613
857
|
}
|
|
614
858
|
|
|
615
859
|
const VERSION_PATTERN = /^1c-edt-(\d{4})\.(\d+)\.(\d+)\+(\d+)-/;
|
|
@@ -631,9 +875,13 @@ const compareVersion = (left, right) => {
|
|
|
631
875
|
|
|
632
876
|
/**
|
|
633
877
|
* Установка EDT: явная из настроек/`DSH_1C_EDT_HOME` либо старшая найденная.
|
|
634
|
-
*
|
|
878
|
+
* Наличие EDT-MCP в файлах — только диагностическая подсказка: бандл может
|
|
879
|
+
* стоять в пользовательской configuration area, а окончательно готовность
|
|
880
|
+
* подтверждает `/health`.
|
|
881
|
+
* @returns {{ home?: string, bin?: string, cli?: string, mcpBundle?: boolean,
|
|
882
|
+
* mcpEvidence?: string[], source?: string, error?: string }}
|
|
635
883
|
*/
|
|
636
|
-
export function detectEdtHome(settings, { platform = process.platform, env = process.env, roots } = {}) {
|
|
884
|
+
export function detectEdtHome(settings, { platform = process.platform, env = process.env, roots, eclipseRoots } = {}) {
|
|
637
885
|
const binary = platform === 'win32' ? '1cedt.exe' : '1cedt';
|
|
638
886
|
const cli = platform === 'win32' ? '1cedtcli.exe' : '1cedtcli';
|
|
639
887
|
const explicit = text(env?.DSH_1C_EDT_HOME) ?? text(settings?.home?.[platform]);
|
|
@@ -642,11 +890,13 @@ export function detectEdtHome(settings, { platform = process.platform, env = pro
|
|
|
642
890
|
const bin = path.join(home, binary);
|
|
643
891
|
if (!existsSync(bin)) return { error: `в ${home} нет ${binary}`, home };
|
|
644
892
|
const cliPath = path.join(home, cli);
|
|
893
|
+
const evidence = mcpEvidence(home, { platform, env, eclipseRoots });
|
|
645
894
|
return {
|
|
646
895
|
home,
|
|
647
896
|
bin,
|
|
648
897
|
cli: existsSync(cliPath) ? cliPath : undefined,
|
|
649
|
-
mcpBundle:
|
|
898
|
+
mcpBundle: evidence.found,
|
|
899
|
+
mcpEvidence: evidence.evidence,
|
|
650
900
|
source: text(env?.DSH_1C_EDT_HOME) ? 'env' : 'config',
|
|
651
901
|
};
|
|
652
902
|
}
|
|
@@ -666,48 +916,111 @@ export function detectEdtHome(settings, { platform = process.platform, env = pro
|
|
|
666
916
|
if (version === null) continue;
|
|
667
917
|
const home = path.join(root, entry.name);
|
|
668
918
|
if (!existsSync(path.join(home, binary))) continue;
|
|
669
|
-
candidates.push({ home, version
|
|
919
|
+
candidates.push({ home, version });
|
|
670
920
|
}
|
|
671
921
|
}
|
|
672
922
|
if (candidates.length === 0) {
|
|
673
923
|
return { error: `1C:EDT не найден; задайте workspace.home или DSH_1C_EDT_HOME (искали в ${searchRoots.join(', ')})` };
|
|
674
924
|
}
|
|
925
|
+
// Выбор только по версии: наличие бандла не должно решать, какую установку
|
|
926
|
+
// запускать (бандл может лежать в пользовательской configuration area).
|
|
675
927
|
candidates.sort((left, right) => compareVersion(right.version, left.version));
|
|
676
|
-
const chosen = candidates
|
|
928
|
+
const chosen = candidates[0];
|
|
677
929
|
const cliPath = path.join(chosen.home, cli);
|
|
930
|
+
const evidence = mcpEvidence(chosen.home, { platform, env, eclipseRoots });
|
|
678
931
|
return {
|
|
679
932
|
home: chosen.home,
|
|
680
933
|
bin: path.join(chosen.home, binary),
|
|
681
934
|
cli: existsSync(cliPath) ? cliPath : undefined,
|
|
682
|
-
mcpBundle:
|
|
935
|
+
mcpBundle: evidence.found,
|
|
936
|
+
mcpEvidence: evidence.evidence,
|
|
683
937
|
source: 'autodetect',
|
|
684
938
|
};
|
|
685
939
|
}
|
|
686
940
|
|
|
687
|
-
/**
|
|
941
|
+
/**
|
|
942
|
+
* Требования EDT к Java из `1cedt.ini`: major из
|
|
943
|
+
* `-Dosgi.requiredJavaVersion` и явный `-vm` (до `-vmargs`), если задан.
|
|
944
|
+
*/
|
|
945
|
+
export function requiredJava(home, { platform = process.platform } = {}) {
|
|
946
|
+
const result = { major: undefined, vm: undefined };
|
|
947
|
+
if (typeof home !== 'string' || home === '') return result;
|
|
948
|
+
let ini;
|
|
949
|
+
try {
|
|
950
|
+
ini = readFileSync(path.join(home, '1cedt.ini'), 'utf8');
|
|
951
|
+
} catch {
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
const major = /-Dosgi\.requiredJavaVersion=(\d+)/.exec(ini)?.[1];
|
|
955
|
+
if (major !== undefined) result.major = Number.parseInt(major, 10);
|
|
956
|
+
|
|
957
|
+
const lines = ini.split(/\r?\n/);
|
|
958
|
+
const vmargsAt = lines.findIndex((line) => line.trim() === '-vmargs');
|
|
959
|
+
const launchLines = vmargsAt === -1 ? lines : lines.slice(0, vmargsAt);
|
|
960
|
+
const vmAt = launchLines.findIndex((line) => line.trim() === '-vm');
|
|
961
|
+
let vm;
|
|
962
|
+
if (vmAt !== -1) {
|
|
963
|
+
vm = launchLines.slice(vmAt + 1).find((line) => line.trim() !== '')?.trim();
|
|
964
|
+
} else {
|
|
965
|
+
vm = /^\s*-vm\s+(.+)$/m.exec(launchLines.join('\n'))?.[1]?.trim();
|
|
966
|
+
}
|
|
967
|
+
if (vm !== undefined && vm !== '') {
|
|
968
|
+
result.vm = path.isAbsolute(vm) ? path.normalize(vm) : path.resolve(home, vm);
|
|
969
|
+
}
|
|
970
|
+
return result;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** JDK рядом с установкой EDT: `{ major, path }`, по убыванию major. */
|
|
974
|
+
function jdkCandidates(components, javaBinary) {
|
|
975
|
+
let entries;
|
|
976
|
+
try {
|
|
977
|
+
entries = readdirSync(components, { withFileTypes: true });
|
|
978
|
+
} catch {
|
|
979
|
+
return [];
|
|
980
|
+
}
|
|
981
|
+
const candidates = [];
|
|
982
|
+
for (const entry of entries) {
|
|
983
|
+
if (!entry.isDirectory()) continue;
|
|
984
|
+
const major = /jdk-(?:full-)?(\d+)/i.exec(entry.name)?.[1];
|
|
985
|
+
if (major === undefined) continue;
|
|
986
|
+
const bin = path.join(components, entry.name, 'bin', javaBinary);
|
|
987
|
+
if (!existsSync(bin)) continue;
|
|
988
|
+
candidates.push({ major: Number.parseInt(major, 10), path: bin });
|
|
989
|
+
}
|
|
990
|
+
return candidates.sort((left, right) => right.major - left.major);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Java для EDT: явная настройка → `-vm` из ini → JDK рядом с установкой по
|
|
995
|
+
* требуемой major (точная, затем ближайшая старшая, затем старшая доступная).
|
|
996
|
+
*/
|
|
688
997
|
export function resolveEdtVm(settings, home, { platform = process.platform, env = process.env } = {}) {
|
|
689
998
|
const explicit = text(env?.DSH_1C_EDT_VM) ?? text(settings?.vm);
|
|
690
999
|
if (explicit !== undefined) return explicit;
|
|
691
1000
|
if (typeof home !== 'string' || home === '') return undefined;
|
|
1001
|
+
|
|
1002
|
+
const required = requiredJava(home, { platform });
|
|
1003
|
+
if (required.vm !== undefined && existsSync(required.vm)) return required.vm;
|
|
1004
|
+
|
|
692
1005
|
const javaBinary = platform === 'win32' ? 'javaw.exe' : 'java';
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
} catch {
|
|
702
|
-
return undefined;
|
|
703
|
-
}
|
|
1006
|
+
const candidates = jdkCandidates(path.dirname(home), javaBinary);
|
|
1007
|
+
if (candidates.length === 0) return undefined;
|
|
1008
|
+
if (required.major === undefined) return candidates[0].path;
|
|
1009
|
+
|
|
1010
|
+
const exact = candidates.find((candidate) => candidate.major === required.major);
|
|
1011
|
+
if (exact !== undefined) return exact.path;
|
|
1012
|
+
const newer = [...candidates].reverse().find((candidate) => candidate.major >= required.major);
|
|
1013
|
+
return (newer ?? candidates[0]).path;
|
|
704
1014
|
}
|
|
705
1015
|
|
|
706
1016
|
/** Аргументы запуска EDT GUI с отдельным workspace. */
|
|
707
|
-
export function guiArgs({ wsDir, xmx, vm }) {
|
|
1017
|
+
export function guiArgs({ wsDir, xmx, vm, nativeFormBufferedLayoutRender = false }) {
|
|
708
1018
|
const args = ['-nosplash', '-data', wsDir, '--launcher.appendVmargs'];
|
|
709
1019
|
if (typeof vm === 'string' && vm !== '') args.push('-vm', vm);
|
|
710
1020
|
args.push('-vmargs', `-Xmx${xmx}`);
|
|
1021
|
+
// Требование EDT-MCP для get_form_screenshot: свойство должно идти после
|
|
1022
|
+
// -vmargs. На Windows включает буферизованный рендер форм на всю сессию.
|
|
1023
|
+
if (nativeFormBufferedLayoutRender) args.push('-DnativeFormBufferedLayoutRender=true');
|
|
711
1024
|
return args;
|
|
712
1025
|
}
|
|
713
1026
|
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "1C:EDT + BSL MCP",
|
|
4
|
-
"description": "MCP hub for 1C development: active only in selected projects (.dsh-1c.json or the projects list). There EDT is connected up front, while BSL Language Server (diagnostics, types, navigation), standards and a task tracker are mounted on demand via edt_pin; in a git worktree the plugin raises its own EDT workspace under build/ws. 1C rules enter the prompt only there."
|
|
4
|
+
"description": "MCP hub for 1C development: active only in selected projects (.dsh-1c.json or the projects list). There EDT is connected up front, while BSL Language Server (diagnostics, types, navigation), standards and a task tracker are mounted on demand via edt_pin; in a git worktree (including dsh-git-worktree) the plugin raises its own EDT workspace under build/ws with plain text and form screenshots. 1C rules enter the prompt only there."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/ru.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "1C:EDT + BSL MCP",
|
|
4
|
-
"description": "Хаб MCP-серверов для разработки на 1С: включается только в выбранных проектах (файл .dsh-1c.json или список projects). В них EDT подключён сразу, BSL Language Server (диагностика, типы, навигация), стандарты и система задач — по требованию через edt_pin; в git worktree плагин поднимает отдельный EDT workspace в build/ws
|
|
4
|
+
"description": "Хаб MCP-серверов для разработки на 1С: включается только в выбранных проектах (файл .dsh-1c.json или список projects). В них EDT подключён сразу, BSL Language Server (диагностика, типы, навигация), стандарты и система задач — по требованию через edt_pin; в git worktree (в т.ч. dsh-git-worktree) плагин поднимает отдельный EDT workspace в build/ws с plain text и скриншотами форм. Правила 1С появляются в промпте только там."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-edt-projects",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Project-scoped MCP hub for 1C development: active only in selected projects, EDT connected up front there, a per-worktree EDT workspace under build/ws, BSL Language Server, a task tracker and the v8std standards MCP mounted on demand, bundled SonarQube and GitLab skills, with model-facing usage rules",
|
|
6
6
|
"license": "MIT",
|