evolcore 0.0.22 → 0.0.24
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/CHANGELOG.md +74 -14
- package/README.md +4 -0
- package/bin/codex-managed-acl.ps1 +160 -0
- package/bin/codex-managed-hook.mjs +127 -10
- package/bin/ec-safe-output.js +85 -3
- package/bin/install-codex-managed-hooks.mjs +384 -95
- package/dist/agents/baseagent-availability.js +48 -0
- package/dist/agents/baseagent.js +30 -2
- package/dist/agents/claude-runner.js +179 -240
- package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +90 -20
- package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
- package/dist/agents/codex/catalog.js +70 -0
- package/dist/agents/codex/diagnostics.js +369 -0
- package/dist/agents/codex-runner.js +839 -192
- package/dist/agents/ecagent-runner.js +27 -34
- package/dist/agents/gemini-runner.js +62 -8
- package/dist/agents/runner-types.js +1 -1
- package/dist/aun/aid/managed-operation.js +28 -6
- package/dist/aun/aid/store.js +43 -0
- package/dist/aun/menu-client.js +272 -0
- package/dist/aun/msg/group.js +65 -4
- package/dist/aun/msg/index.js +1 -1
- package/dist/aun/msg/managed-operation.js +57 -5
- package/dist/aun/outbox.js +56 -18
- package/dist/aun/rpc/client.js +7 -1
- package/dist/channels/aun.js +178 -77
- package/dist/channels/daemon.js +7 -1
- package/dist/channels/dingtalk.js +1 -0
- package/dist/channels/feishu.js +10 -4
- package/dist/channels/wechat.js +81 -10
- package/dist/channels/wecom.js +2 -1
- package/dist/cli/agent-command.js +29 -3
- package/dist/cli/agent-create.js +538 -0
- package/dist/cli/agent.js +26 -675
- package/dist/cli/aun-commands.js +108 -24
- package/dist/cli/command-log.js +21 -2
- package/dist/cli/ctl-command.js +2 -0
- package/dist/cli/daemon-commands.js +289 -61
- package/dist/cli/index.js +22 -2
- package/dist/cli/init.js +2 -10
- package/dist/cli/managed-command-guard.js +3 -0
- package/dist/cli/menu-client.js +95 -0
- package/dist/cli/model.js +23 -8
- package/dist/cli/restart-monitor.js +432 -8
- package/dist/cli/stats.js +110 -0
- package/dist/cli/task-context.js +12 -2
- package/dist/cli/trigger-command.js +9 -1
- package/dist/config/builtin-roles.js +2 -0
- package/dist/config/schema-migration.js +14 -2
- package/dist/core/auth/agent-delegation.js +10 -7
- package/dist/core/auth/auth-gateway.js +3 -2
- package/dist/core/auth/authorization-audit.js +75 -18
- package/dist/core/auth/operation-authorizer.js +63 -3
- package/dist/core/auth/operation-catalog.js +14 -2
- package/dist/core/auth/remote-access-store.js +136 -0
- package/dist/core/baseagent-loader.js +4 -4
- package/dist/core/bootstrap-messages.js +39 -18
- package/dist/core/bootstrap-service.js +32 -26
- package/dist/core/capability/capability-manager.js +8 -4
- package/dist/core/capability/providers/codex-capability-provider.js +1 -1
- package/dist/core/command/agent-control.js +7 -2
- package/dist/core/command/command-handler.js +128 -30
- package/dist/core/command/daemon-diagnostics.js +448 -0
- package/dist/core/command/menu-catalog.js +6 -1
- package/dist/core/command/menu-handler.js +410 -98
- package/dist/core/command/menu-protocol.js +123 -0
- package/dist/core/command/slash-gate.js +4 -6
- package/dist/core/command/slash-handler.js +199 -70
- package/dist/core/data-migration.js +5 -15
- package/dist/core/event-catalog.js +3 -0
- package/dist/core/handoff/runtime.js +3 -1
- package/dist/core/handoff/store.js +7 -1
- package/dist/core/message/im-renderer.js +13 -6
- package/dist/core/message/message-bridge.js +5 -36
- package/dist/core/message/message-queue.js +20 -15
- package/dist/core/message/message-utils.js +6 -3
- package/dist/core/message/response-engine.js +147 -94
- package/dist/core/model/catalog-service.js +310 -0
- package/dist/core/model/model-catalog.js +134 -334
- package/dist/core/model/model-diagnostics.js +4 -5
- package/dist/core/model/model-fallback.js +9 -16
- package/dist/core/model/model-permission.js +5 -2
- package/dist/core/permission/approval-gateway.js +183 -32
- package/dist/core/permission/protected-paths.js +63 -4
- package/dist/core/permission/sandbox-runtime.js +4 -2
- package/dist/core/permission/tool-policy.js +257 -31
- package/dist/core/runtime-lock.js +13 -8
- package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
- package/dist/core/session/session-manager.js +49 -13
- package/dist/core/session/session-renew.js +1 -0
- package/dist/index.js +214 -57
- package/dist/ipc.js +191 -102
- package/dist/paths.js +23 -1
- package/dist/stats/billing.js +18 -4
- package/dist/trigger/manager.js +3 -1
- package/dist/trigger/parser.js +77 -13
- package/dist/trigger/patch.js +104 -1
- package/dist/utils/atomic-write.js +26 -2
- package/dist/utils/cross-platform.js +436 -1
- package/dist/utils/ecweb-supervisor.js +11 -2
- package/dist/utils/ecweb-utils.js +8 -26
- package/dist/utils/error-utils.js +16 -2
- package/dist/utils/model-prices.jsonl +23 -20
- package/dist/utils/npm-ops.js +70 -15
- package/dist/utils/windows-autostart.js +1 -1
- package/kits/docs/evolcore/INDEX.md +1 -0
- package/kits/docs/evolcore/agent-create.md +13 -0
- package/kits/docs/evolcore/group-collaboration.md +7 -2
- package/kits/docs/evolcore/menu-client.md +34 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
- package/kits/migrations/migrate-role-config-v4.mjs +11 -5
- package/kits/migrations/migrate-role-config-v5.mjs +8 -2
- package/kits/templates/roles/admin.json +2 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/kits/templates/system-fragments/bootstrap.md +8 -1
- package/package.json +16 -4
- package/dist/utils/codex-cli.js +0 -43
package/dist/trigger/patch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeTriggerDefinition, parseDurationMs, TRIGGER_SCHEMA_VERSION } from './validation.js';
|
|
2
2
|
const PATCH_KEYS = new Set([
|
|
3
3
|
'name',
|
|
4
|
+
'description',
|
|
4
5
|
'scheduleType',
|
|
5
6
|
'scheduleValue',
|
|
6
7
|
'timezone',
|
|
@@ -12,6 +13,21 @@ const PATCH_KEYS = new Set([
|
|
|
12
13
|
'maxDuration',
|
|
13
14
|
'concurrency',
|
|
14
15
|
'missedPolicy',
|
|
16
|
+
'feedback',
|
|
17
|
+
'targetChannel',
|
|
18
|
+
'targetChannelId',
|
|
19
|
+
'targetChatType',
|
|
20
|
+
'targetSession',
|
|
21
|
+
'targetThreadId',
|
|
22
|
+
'baseagent',
|
|
23
|
+
'triggerThread',
|
|
24
|
+
'onError',
|
|
25
|
+
'noopSentinel',
|
|
26
|
+
'retryAttempts',
|
|
27
|
+
'retryBackoff',
|
|
28
|
+
'scriptRuntime',
|
|
29
|
+
'scriptArgs',
|
|
30
|
+
'scriptTimeout',
|
|
15
31
|
]);
|
|
16
32
|
export function applyTriggerPatch(definition, input, opts = {}) {
|
|
17
33
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
@@ -48,6 +64,12 @@ export function applyTriggerPatch(definition, input, opts = {}) {
|
|
|
48
64
|
updated.reliability.concurrency = patch.concurrency;
|
|
49
65
|
if (patch.missedPolicy !== undefined)
|
|
50
66
|
updated.reliability.missedPolicy = patch.missedPolicy;
|
|
67
|
+
if (patch.description !== undefined) {
|
|
68
|
+
if (patch.description === null || patch.description === '')
|
|
69
|
+
delete updated.description;
|
|
70
|
+
else
|
|
71
|
+
updated.description = String(patch.description);
|
|
72
|
+
}
|
|
51
73
|
if (patch.maxRuns !== undefined || patch.maxDuration !== undefined) {
|
|
52
74
|
const limits = updated.limits ? { ...updated.limits } : {};
|
|
53
75
|
if (patch.maxRuns !== undefined) {
|
|
@@ -64,6 +86,85 @@ export function applyTriggerPatch(definition, input, opts = {}) {
|
|
|
64
86
|
}
|
|
65
87
|
updated.limits = Object.keys(limits).length > 0 ? limits : undefined;
|
|
66
88
|
}
|
|
89
|
+
// feedback strategy
|
|
90
|
+
if (patch.feedback !== undefined) {
|
|
91
|
+
updated.feedback.strategy = patch.feedback;
|
|
92
|
+
if (patch.feedback !== 'target')
|
|
93
|
+
delete updated.feedback.target;
|
|
94
|
+
}
|
|
95
|
+
// feedback target fields
|
|
96
|
+
const hasTargetPatch = patch.targetChannel !== undefined
|
|
97
|
+
|| patch.targetChannelId !== undefined
|
|
98
|
+
|| patch.targetChatType !== undefined
|
|
99
|
+
|| patch.targetSession !== undefined
|
|
100
|
+
|| patch.targetThreadId !== undefined;
|
|
101
|
+
if (hasTargetPatch) {
|
|
102
|
+
if ((patch.targetChannel === undefined) !== (patch.targetChannelId === undefined)) {
|
|
103
|
+
throw new Error('targetChannel 与 targetChannelId 必须同时指定');
|
|
104
|
+
}
|
|
105
|
+
const prev = updated.feedback.target;
|
|
106
|
+
const next = {
|
|
107
|
+
channelKey: patch.targetChannel ?? prev?.channelKey ?? '',
|
|
108
|
+
channelId: patch.targetChannelId ?? prev?.channelId ?? '',
|
|
109
|
+
session: patch.targetSession ?? prev?.session ?? 'main',
|
|
110
|
+
threadId: patch.targetThreadId === null || patch.targetThreadId === ''
|
|
111
|
+
? undefined
|
|
112
|
+
: (patch.targetThreadId ?? prev?.threadId),
|
|
113
|
+
chatType: patch.targetChatType === null
|
|
114
|
+
? undefined
|
|
115
|
+
: (patch.targetChatType ?? prev?.chatType),
|
|
116
|
+
};
|
|
117
|
+
if (!next.channelKey || !next.channelId) {
|
|
118
|
+
throw new Error('target 修改需要提供有效的 channelKey 和 channelId');
|
|
119
|
+
}
|
|
120
|
+
updated.feedback.target = next;
|
|
121
|
+
if (updated.feedback.strategy !== 'target')
|
|
122
|
+
updated.feedback.strategy = 'target';
|
|
123
|
+
}
|
|
124
|
+
// execution fields
|
|
125
|
+
if (patch.baseagent !== undefined) {
|
|
126
|
+
if (updated.execution.type !== 'trigger_session') {
|
|
127
|
+
throw new Error('baseagent 仅适用于 trigger_session 类型');
|
|
128
|
+
}
|
|
129
|
+
setOptional(updated.execution, 'baseagent', patch.baseagent);
|
|
130
|
+
}
|
|
131
|
+
if (patch.triggerThread !== undefined)
|
|
132
|
+
setOptional(updated.execution, 'thread', patch.triggerThread);
|
|
133
|
+
if (patch.onError !== undefined)
|
|
134
|
+
updated.execution.onError = patch.onError;
|
|
135
|
+
if (patch.noopSentinel !== undefined) {
|
|
136
|
+
if (patch.noopSentinel === null || patch.noopSentinel === '')
|
|
137
|
+
updated.execution.noopSentinel = '[[NOOP]]';
|
|
138
|
+
else
|
|
139
|
+
updated.execution.noopSentinel = patch.noopSentinel;
|
|
140
|
+
}
|
|
141
|
+
// reliability retry
|
|
142
|
+
if (patch.retryAttempts !== undefined || patch.retryBackoff !== undefined) {
|
|
143
|
+
const retry = { ...updated.reliability.retry };
|
|
144
|
+
if (patch.retryAttempts !== undefined)
|
|
145
|
+
retry.maxAttempts = patch.retryAttempts ?? 0;
|
|
146
|
+
if (patch.retryBackoff !== undefined)
|
|
147
|
+
retry.backoffMs = patch.retryBackoff ?? 30_000;
|
|
148
|
+
updated.reliability = { ...updated.reliability, retry };
|
|
149
|
+
}
|
|
150
|
+
// script fields
|
|
151
|
+
if (patch.scriptRuntime !== undefined || patch.scriptArgs !== undefined || patch.scriptTimeout !== undefined) {
|
|
152
|
+
if (updated.execution.type !== 'script' || !updated.execution.script) {
|
|
153
|
+
throw new Error('scriptRuntime/scriptArgs/scriptTimeout 仅适用于 script 类型 trigger');
|
|
154
|
+
}
|
|
155
|
+
const script = { ...updated.execution.script };
|
|
156
|
+
if (patch.scriptRuntime !== undefined)
|
|
157
|
+
script.runtime = patch.scriptRuntime;
|
|
158
|
+
if (patch.scriptArgs !== undefined) {
|
|
159
|
+
if (patch.scriptArgs === null)
|
|
160
|
+
delete script.args;
|
|
161
|
+
else
|
|
162
|
+
script.args = patch.scriptArgs;
|
|
163
|
+
}
|
|
164
|
+
if (patch.scriptTimeout !== undefined)
|
|
165
|
+
script.timeoutMs = patch.scriptTimeout ?? 30_000;
|
|
166
|
+
updated.execution.script = script;
|
|
167
|
+
}
|
|
67
168
|
return normalizeTriggerDefinition({
|
|
68
169
|
...updated,
|
|
69
170
|
id: definition.id,
|
|
@@ -98,7 +199,9 @@ export function replaceEditableTriggerDefinition(existing, input) {
|
|
|
98
199
|
}
|
|
99
200
|
function assertBaseagentImmutable(existing, updated) {
|
|
100
201
|
if (existing.execution.baseagent !== updated.execution.baseagent) {
|
|
101
|
-
|
|
202
|
+
if (existing.execution.type !== 'trigger_session' || updated.execution.type !== 'trigger_session') {
|
|
203
|
+
throw new Error('execution.baseagent is only mutable between trigger_session Triggers');
|
|
204
|
+
}
|
|
102
205
|
}
|
|
103
206
|
}
|
|
104
207
|
function applySourcePatch(updated, patch) {
|
|
@@ -69,8 +69,32 @@ export function atomicWrite(filePath, content) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
// The final rename atomically replaces the old target without an absent-path
|
|
72
|
-
// window.
|
|
73
|
-
|
|
72
|
+
// window. Windows H-class projections can keep the destination pathname
|
|
73
|
+
// open while a sandbox is starting; replacing that directory entry then
|
|
74
|
+
// fails with EPERM. In that narrow case, preserve the target inode and write
|
|
75
|
+
// in place. The hot backup above still provides the previous full version.
|
|
76
|
+
try {
|
|
77
|
+
fs.renameSync(tmp, filePath);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const code = error.code;
|
|
81
|
+
if (process.platform !== 'win32' || code !== 'EPERM')
|
|
82
|
+
throw error;
|
|
83
|
+
const detachedHot = `${hot}.${process.pid}.fallback.tmp`;
|
|
84
|
+
try {
|
|
85
|
+
// refreshHotBackup may have used a hard link. Detach that backup before
|
|
86
|
+
// the in-place write, otherwise updating the live inode would update the
|
|
87
|
+
// hot backup as well.
|
|
88
|
+
fs.copyFileSync(filePath, detachedHot);
|
|
89
|
+
fs.rmSync(hot, { force: true });
|
|
90
|
+
fs.renameSync(detachedHot, hot);
|
|
91
|
+
stableWrite(filePath, content);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
fs.rmSync(detachedHot, { force: true });
|
|
95
|
+
fs.rmSync(tmp, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
74
98
|
}
|
|
75
99
|
export function atomicWriteJson(filePath, value) {
|
|
76
100
|
atomicWrite(filePath, JSON.stringify(value, null, 2) + '\n');
|
|
@@ -1,12 +1,197 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
-
import { fileURLToPath } from 'url';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
3
|
+
import { createRequire } from 'module';
|
|
3
4
|
import { execFileSync, execFile, spawn, spawnSync } from 'child_process';
|
|
4
5
|
import { promisify } from 'util';
|
|
5
6
|
import fs from 'fs';
|
|
6
7
|
import { getProcessStartTime, parseCimDate } from './process-introspect.js';
|
|
7
8
|
import { decodeWindowsOutput } from './windows-output.js';
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
10
|
+
const CLI_PROBE_FAILURE_BACKOFF_MS = 5_000;
|
|
11
|
+
const CLI_PROBE_PACKAGES = {
|
|
12
|
+
codex: '@openai/codex',
|
|
13
|
+
claude: '@anthropic-ai/claude-code',
|
|
14
|
+
gemini: '@google/gemini-cli',
|
|
15
|
+
};
|
|
16
|
+
const cliPathCache = new Map();
|
|
17
|
+
const cliInstallationCache = new Map();
|
|
18
|
+
const cliProbeCache = new Map();
|
|
9
19
|
export const isWindows = process.platform === 'win32';
|
|
20
|
+
/** Resolve a CLI executable without spawning where/which. */
|
|
21
|
+
export function resolveProbePath(command) {
|
|
22
|
+
const pathKey = `${command}\0${process.cwd()}\0${process.env.PATH ?? ''}\0${process.env.PATHEXT ?? ''}`;
|
|
23
|
+
const cachedPath = cliPathCache.get(pathKey);
|
|
24
|
+
if (cachedPath && fs.existsSync(cachedPath))
|
|
25
|
+
return cachedPath;
|
|
26
|
+
cliPathCache.delete(pathKey);
|
|
27
|
+
const directories = (process.env.PATH ?? '').split(path.delimiter);
|
|
28
|
+
if (isWindows)
|
|
29
|
+
directories.unshift(process.cwd());
|
|
30
|
+
const extensions = isWindows
|
|
31
|
+
? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(ext => /\.(com|exe|bat|cmd)$/i.test(ext))
|
|
32
|
+
: [''];
|
|
33
|
+
for (const directory of directories) {
|
|
34
|
+
for (const extension of extensions) {
|
|
35
|
+
const candidate = path.resolve(directory.replace(/^"|"$/g, ''), command + extension);
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.statSync(candidate).isFile())
|
|
38
|
+
continue;
|
|
39
|
+
fs.accessSync(candidate, isWindows ? fs.constants.F_OK : fs.constants.X_OK);
|
|
40
|
+
cliPathCache.set(pathKey, candidate);
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
catch { /* Continue PATH lookup. */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function cliFileIdentity(file) {
|
|
49
|
+
try {
|
|
50
|
+
const real = fs.realpathSync(file);
|
|
51
|
+
const stat = fs.statSync(real);
|
|
52
|
+
return `${real}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return `${file}:missing`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Return an installation fingerprint so probes refresh after CLI upgrades. */
|
|
59
|
+
export function probeInstallationIdentity(command, executable) {
|
|
60
|
+
const cacheKey = `${command}\0${executable}`;
|
|
61
|
+
const cached = cliInstallationCache.get(cacheKey);
|
|
62
|
+
if (cached && cached.files.map(cliFileIdentity).join('|') === cached.identity)
|
|
63
|
+
return cached.identity;
|
|
64
|
+
const files = [executable];
|
|
65
|
+
const packageName = CLI_PROBE_PACKAGES[command];
|
|
66
|
+
if (packageName) {
|
|
67
|
+
files.push(path.join(path.dirname(executable), 'node_modules', packageName, 'package.json'));
|
|
68
|
+
try {
|
|
69
|
+
const real = fs.realpathSync(executable);
|
|
70
|
+
const require = createRequire(real);
|
|
71
|
+
const packageFile = require.resolve(`${packageName}/package.json`);
|
|
72
|
+
files.push(packageFile);
|
|
73
|
+
if (command === 'codex') {
|
|
74
|
+
const platformPackage = `@openai/codex-${process.platform}-${process.arch}`;
|
|
75
|
+
const platformFile = createRequire(packageFile).resolve(`${platformPackage}/package.json`);
|
|
76
|
+
files.push(platformFile);
|
|
77
|
+
const arch = process.arch === 'x64' ? 'x86_64' : 'aarch64';
|
|
78
|
+
const target = isWindows ? `${arch}-pc-windows-msvc`
|
|
79
|
+
: process.platform === 'darwin' ? `${arch}-apple-darwin` : `${arch}-unknown-linux-musl`;
|
|
80
|
+
for (const root of [path.dirname(platformFile), path.dirname(packageFile)]) {
|
|
81
|
+
files.push(path.join(root, 'vendor', target, 'bin', isWindows ? 'codex.exe' : 'codex'));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* Standalone executables have no npm package. */ }
|
|
86
|
+
}
|
|
87
|
+
const uniqueFiles = [...new Set(files)];
|
|
88
|
+
const identity = uniqueFiles.map(cliFileIdentity).join('|');
|
|
89
|
+
cliInstallationCache.set(cacheKey, { files: uniqueFiles, identity });
|
|
90
|
+
return identity;
|
|
91
|
+
}
|
|
92
|
+
/** Clear CLI probe caches after an in-process CLI installation. */
|
|
93
|
+
export function invalidateCliProbes() {
|
|
94
|
+
cliPathCache.clear();
|
|
95
|
+
cliInstallationCache.clear();
|
|
96
|
+
cliProbeCache.clear();
|
|
97
|
+
}
|
|
98
|
+
function cliProbeTimeouts(command, options) {
|
|
99
|
+
return options.timeouts?.length ? options.timeouts : command === 'codex' ? [3_000, 15_000] : [3_000];
|
|
100
|
+
}
|
|
101
|
+
function prepareCliProbe(command, args) {
|
|
102
|
+
const executable = resolveProbePath(command);
|
|
103
|
+
if (!executable)
|
|
104
|
+
throw Object.assign(new Error(`${command} CLI not found`), { code: 'ENOENT' });
|
|
105
|
+
const identity = `${probeInstallationIdentity(command, executable)}|${process.env.ComSpec ?? ''}`;
|
|
106
|
+
const key = JSON.stringify([command, args]);
|
|
107
|
+
let entry = cliProbeCache.get(key);
|
|
108
|
+
if (!entry || entry.identity !== identity || (entry.retryAt !== undefined && Date.now() >= entry.retryAt)) {
|
|
109
|
+
entry = { identity };
|
|
110
|
+
cliProbeCache.set(key, entry);
|
|
111
|
+
}
|
|
112
|
+
const shim = isWindows && /\.(cmd|bat)$/i.test(executable);
|
|
113
|
+
return { entry, command: shim ? process.env.ComSpec ?? 'cmd.exe' : executable,
|
|
114
|
+
args: shim ? ['/d', '/s', '/c', executable, ...args] : args };
|
|
115
|
+
}
|
|
116
|
+
function failCliProbe(entry, error) {
|
|
117
|
+
entry.error = error;
|
|
118
|
+
entry.retryAt = Date.now() + CLI_PROBE_FAILURE_BACKOFF_MS;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
export function probeCliSync(command, args, options = {}) {
|
|
122
|
+
const request = prepareCliProbe(command, args);
|
|
123
|
+
if (request.entry.output !== undefined)
|
|
124
|
+
return request.entry.output;
|
|
125
|
+
if (request.entry.error !== undefined)
|
|
126
|
+
throw request.entry.error;
|
|
127
|
+
for (const [index, timeout] of cliProbeTimeouts(command, options).entries()) {
|
|
128
|
+
const startedAt = Date.now();
|
|
129
|
+
try {
|
|
130
|
+
return request.entry.output = execFileSync(request.command, request.args, { encoding: 'utf8', timeout, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
options.onFailure?.(args, startedAt, error, timeout);
|
|
134
|
+
if (error.code !== 'ETIMEDOUT' || index === cliProbeTimeouts(command, options).length - 1)
|
|
135
|
+
return failCliProbe(request.entry, error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new Error('CLI probe exhausted');
|
|
139
|
+
}
|
|
140
|
+
export async function probeCli(command, args, options = {}) {
|
|
141
|
+
const request = prepareCliProbe(command, args);
|
|
142
|
+
if (request.entry.output !== undefined)
|
|
143
|
+
return request.entry.output;
|
|
144
|
+
if (request.entry.error !== undefined)
|
|
145
|
+
throw request.entry.error;
|
|
146
|
+
if (request.entry.pending)
|
|
147
|
+
return request.entry.pending;
|
|
148
|
+
const run = async () => {
|
|
149
|
+
const timeouts = cliProbeTimeouts(command, options);
|
|
150
|
+
for (const [index, timeout] of timeouts.entries()) {
|
|
151
|
+
const startedAt = Date.now();
|
|
152
|
+
try {
|
|
153
|
+
const output = await new Promise((resolve, reject) => {
|
|
154
|
+
const child = execFile(request.command, request.args, { encoding: 'utf8', timeout, windowsHide: true }, (error, stdout, stderr) => {
|
|
155
|
+
if (error) {
|
|
156
|
+
if (error.killed && error.signal === 'SIGTERM' && error.code == null)
|
|
157
|
+
error.code = 'ETIMEDOUT';
|
|
158
|
+
Object.assign(error, { stdout, stderr, path: request.command, spawnargs: request.args });
|
|
159
|
+
reject(error);
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
resolve(stdout);
|
|
163
|
+
});
|
|
164
|
+
child.stdin?.end();
|
|
165
|
+
});
|
|
166
|
+
return request.entry.output = output;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
options.onFailure?.(args, startedAt, error, timeout);
|
|
170
|
+
if (error.code !== 'ETIMEDOUT' || index === timeouts.length - 1)
|
|
171
|
+
return failCliProbe(request.entry, error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new Error('CLI probe exhausted');
|
|
175
|
+
};
|
|
176
|
+
request.entry.pending = run();
|
|
177
|
+
try {
|
|
178
|
+
return await request.entry.pending;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
request.entry.pending = undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Non-blocking version lookup used by status menus. */
|
|
185
|
+
export function cachedCliVersion(command) {
|
|
186
|
+
try {
|
|
187
|
+
const { entry } = prepareCliProbe(command, ['--version']);
|
|
188
|
+
if (entry.output !== undefined)
|
|
189
|
+
return entry.output.match(/\b\d+\.\d+\.\d+(?:[-+][\w.-]+)?/)?.[0] ?? null;
|
|
190
|
+
void probeCli(command, ['--version']).catch(() => { });
|
|
191
|
+
}
|
|
192
|
+
catch { /* Missing CLI. */ }
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
10
195
|
/**
|
|
11
196
|
* Start a detached EvolCore Node process without creating a console window on
|
|
12
197
|
* Windows. The child is unref'd here so callers cannot accidentally keep the
|
|
@@ -22,6 +207,256 @@ export function spawnDetachedNode(args, env = {}) {
|
|
|
22
207
|
child.unref();
|
|
23
208
|
return child;
|
|
24
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Spawn the restart monitor and wait until it proves it is running.
|
|
212
|
+
*
|
|
213
|
+
* Every caller stops (or kills) the daemon right after this returns, so a
|
|
214
|
+
* monitor that dies while loading leaves nothing running and nobody watching.
|
|
215
|
+
* In a checkout the monitor is executed through tsx, where a syntax error
|
|
216
|
+
* anywhere in its import graph is fatal at load time — and the usual
|
|
217
|
+
* `stdio: 'ignore'` would throw that diagnostic away. So stderr is captured,
|
|
218
|
+
* and success requires the child to still be alive after it had a chance to
|
|
219
|
+
* fail, not merely to have been spawned.
|
|
220
|
+
*
|
|
221
|
+
* `isAliveInRegistry` lets the caller define proof-of-life without dragging the
|
|
222
|
+
* instance registry into this module.
|
|
223
|
+
*/
|
|
224
|
+
export async function launchMonitorAndConfirm(args, env, isAliveInRegistry, hasOtherLiveMonitor, timeoutMs = 15_000) {
|
|
225
|
+
const child = spawn(process.execPath, [...args], {
|
|
226
|
+
detached: true,
|
|
227
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
228
|
+
windowsHide: isWindows,
|
|
229
|
+
env: { ...process.env, ...env },
|
|
230
|
+
});
|
|
231
|
+
let stderrTail = '';
|
|
232
|
+
child.stderr?.on('data', (chunk) => {
|
|
233
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
|
|
234
|
+
});
|
|
235
|
+
// Capturing stderr must not defeat detached-process semantics. A referenced
|
|
236
|
+
// pipe keeps the caller's event loop alive until the monitor exits, which
|
|
237
|
+
// makes restart commands wait for the monitor's full lifetime.
|
|
238
|
+
child.stderr?.unref?.();
|
|
239
|
+
let exitCode = null;
|
|
240
|
+
let spawnError = null;
|
|
241
|
+
child.on('exit', code => { exitCode = code; });
|
|
242
|
+
child.on('error', err => { spawnError = err.message; });
|
|
243
|
+
const childPid = child.pid;
|
|
244
|
+
child.unref();
|
|
245
|
+
if (!childPid)
|
|
246
|
+
return { ok: false, reason: 'spawn 未返回 PID' };
|
|
247
|
+
const deadline = Date.now() + timeoutMs;
|
|
248
|
+
while (Date.now() < deadline) {
|
|
249
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
250
|
+
if (spawnError)
|
|
251
|
+
return { ok: false, reason: spawnError, stderrTail };
|
|
252
|
+
if (exitCode !== null) {
|
|
253
|
+
// Exiting cleanly is legitimate when another monitor already owns this
|
|
254
|
+
// restart (single-instance election) — that one is alive and in charge.
|
|
255
|
+
if (exitCode === 0 && hasOtherLiveMonitor(childPid))
|
|
256
|
+
return { ok: true };
|
|
257
|
+
return { ok: false, reason: `进程已退出,exit=${exitCode}`, stderrTail };
|
|
258
|
+
}
|
|
259
|
+
// Registering means the module graph loaded and the monitor started work.
|
|
260
|
+
if (isAliveInRegistry(childPid))
|
|
261
|
+
return { ok: true };
|
|
262
|
+
}
|
|
263
|
+
// Still running but not yet registered: a slow start is not a failure.
|
|
264
|
+
if (isProcessRunning(childPid))
|
|
265
|
+
return { ok: true };
|
|
266
|
+
return { ok: false, reason: '进程未登记且已消失', stderrTail };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Copy the monitor out of the package directory so an upgrade cannot replace it
|
|
270
|
+
* mid-flight.
|
|
271
|
+
*
|
|
272
|
+
* This is the installed-package counterpart of running the monitor from `src/`
|
|
273
|
+
* in a checkout: `npm install -g` swaps the whole package directory, and the
|
|
274
|
+
* monitor has ~170 lazy `await import()` sites, so a module it has not loaded
|
|
275
|
+
* yet would come from the new version while the rest is still the old one.
|
|
276
|
+
* Staging `dist/` elsewhere keeps its module graph on one consistent version.
|
|
277
|
+
*
|
|
278
|
+
* Only `dist/` and `package.json` are copied (a few MB). `node_modules` is
|
|
279
|
+
* linked, because copying it is hundreds of MB. The link degrades in steps so
|
|
280
|
+
* that no platform is left without a path forward:
|
|
281
|
+
*
|
|
282
|
+
* 1. symlink — `junction` on Windows (works without elevation, unlike a
|
|
283
|
+
* `dir` symlink which needs admin or developer mode), plain dir symlink
|
|
284
|
+
* elsewhere. Junctions cannot span volumes, hence the fallbacks.
|
|
285
|
+
* 2. full copy — slow but always permitted.
|
|
286
|
+
* 3. skip staging — caller runs the monitor in place, exactly as before.
|
|
287
|
+
* Losing the upgrade isolation is much better than losing the restart.
|
|
288
|
+
*/
|
|
289
|
+
export function prepareMonitorStage(packageRoot, stageParent) {
|
|
290
|
+
const distSource = path.join(packageRoot, 'dist');
|
|
291
|
+
const modulesSource = path.join(packageRoot, 'node_modules');
|
|
292
|
+
if (!fs.existsSync(distSource)) {
|
|
293
|
+
return { stageDir: null, linkMode: 'none', note: 'dist/ 不存在,跳过 stage' };
|
|
294
|
+
}
|
|
295
|
+
// Clear stages left behind by a monitor that was killed before self-cleanup.
|
|
296
|
+
try {
|
|
297
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
298
|
+
for (const entry of fs.readdirSync(stageParent, { withFileTypes: true })) {
|
|
299
|
+
if (!entry.isDirectory())
|
|
300
|
+
continue;
|
|
301
|
+
const full = path.join(stageParent, entry.name);
|
|
302
|
+
if (fs.statSync(full).mtimeMs < cutoff)
|
|
303
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch { /* stageParent may not exist yet */ }
|
|
307
|
+
const stageDir = path.join(stageParent, `${process.pid}-${Date.now()}`);
|
|
308
|
+
try {
|
|
309
|
+
fs.mkdirSync(stageDir, { recursive: true });
|
|
310
|
+
fs.cpSync(distSource, path.join(stageDir, 'dist'), { recursive: true });
|
|
311
|
+
const pkgJson = path.join(packageRoot, 'package.json');
|
|
312
|
+
if (fs.existsSync(pkgJson))
|
|
313
|
+
fs.copyFileSync(pkgJson, path.join(stageDir, 'package.json'));
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
try {
|
|
317
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
return { stageDir: null, linkMode: 'none', note: `复制 dist/ 失败:${error.message}` };
|
|
321
|
+
}
|
|
322
|
+
if (!fs.existsSync(modulesSource)) {
|
|
323
|
+
// Dependencies hoisted elsewhere; nothing to link, node resolves upward.
|
|
324
|
+
return { stageDir, linkMode: 'none' };
|
|
325
|
+
}
|
|
326
|
+
const stageModules = path.join(stageDir, 'node_modules');
|
|
327
|
+
try {
|
|
328
|
+
fs.symlinkSync(modulesSource, stageModules, isWindows ? 'junction' : 'dir');
|
|
329
|
+
// A junction to another volume is created but unusable, so prove it reads.
|
|
330
|
+
fs.readdirSync(stageModules);
|
|
331
|
+
return { stageDir, linkMode: 'symlink' };
|
|
332
|
+
}
|
|
333
|
+
catch (linkError) {
|
|
334
|
+
try {
|
|
335
|
+
fs.rmSync(stageModules, { recursive: true, force: true });
|
|
336
|
+
}
|
|
337
|
+
catch { }
|
|
338
|
+
try {
|
|
339
|
+
fs.cpSync(modulesSource, stageModules, { recursive: true });
|
|
340
|
+
return {
|
|
341
|
+
stageDir,
|
|
342
|
+
linkMode: 'copy',
|
|
343
|
+
note: `链接 node_modules 失败(${linkError.code ?? 'unknown'}),已改为完整复制`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
catch (copyError) {
|
|
347
|
+
try {
|
|
348
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
catch { }
|
|
351
|
+
return {
|
|
352
|
+
stageDir: null,
|
|
353
|
+
linkMode: 'none',
|
|
354
|
+
note: `无法准备 stage(link: ${linkError.message}; copy: ${copyError.message})`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** Remove a staging directory created by prepareMonitorStage. */
|
|
360
|
+
export function cleanupMonitorStage(stageDir) {
|
|
361
|
+
try {
|
|
362
|
+
// Remove the link itself, never what it points at.
|
|
363
|
+
const modules = path.join(stageDir, 'node_modules');
|
|
364
|
+
const stat = fs.lstatSync(modules, { throwIfNoEntry: false });
|
|
365
|
+
if (stat?.isSymbolicLink())
|
|
366
|
+
fs.unlinkSync(modules);
|
|
367
|
+
}
|
|
368
|
+
catch { /* fall through to the recursive removal */ }
|
|
369
|
+
try {
|
|
370
|
+
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
catch { }
|
|
373
|
+
}
|
|
374
|
+
/** True when the current process may write into `dir`. */
|
|
375
|
+
export function isDirWritable(dir) {
|
|
376
|
+
try {
|
|
377
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Resolve the launch argv for the restart monitor, staging it out of the
|
|
386
|
+
* package directory when that package can be replaced underneath it.
|
|
387
|
+
*
|
|
388
|
+
* Checkout: `resolveCliEntry` already points at `src/` via tsx, which no build
|
|
389
|
+
* can delete — nothing to stage.
|
|
390
|
+
*
|
|
391
|
+
* Installed: the monitor lives in the very directory `npm install -g` swaps,
|
|
392
|
+
* so it is copied to `stageParent` first and told where the real package is
|
|
393
|
+
* through `EVOLCORE_TARGET_PACKAGE_ROOT`. If staging cannot be done we fall
|
|
394
|
+
* back to launching in place: losing upgrade isolation is far better than
|
|
395
|
+
* losing the ability to restart.
|
|
396
|
+
*/
|
|
397
|
+
export function resolveMonitorLaunch(packageRoot, stageParent) {
|
|
398
|
+
const resolved = resolveCliEntry(packageRoot);
|
|
399
|
+
if (resolved.usingSource) {
|
|
400
|
+
return { args: resolved.args, entry: resolved.entry, env: {}, stageDir: null };
|
|
401
|
+
}
|
|
402
|
+
const stage = prepareMonitorStage(packageRoot, stageParent);
|
|
403
|
+
if (!stage.stageDir) {
|
|
404
|
+
return {
|
|
405
|
+
args: resolved.args,
|
|
406
|
+
entry: resolved.entry,
|
|
407
|
+
env: {},
|
|
408
|
+
stageDir: null,
|
|
409
|
+
note: stage.note ? `${stage.note};本次未启用 stage 隔离` : undefined,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const stagedEntry = path.join(stage.stageDir, 'dist', 'cli', 'index.js');
|
|
413
|
+
if (!fs.existsSync(stagedEntry)) {
|
|
414
|
+
cleanupMonitorStage(stage.stageDir);
|
|
415
|
+
return { args: resolved.args, entry: resolved.entry, env: {}, stageDir: null, note: 'stage 缺少入口,已退回原地启动' };
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
args: [stagedEntry],
|
|
419
|
+
entry: stagedEntry,
|
|
420
|
+
env: {
|
|
421
|
+
EVOLCORE_TARGET_PACKAGE_ROOT: packageRoot,
|
|
422
|
+
EVOLCORE_MONITOR_STAGE: stage.stageDir,
|
|
423
|
+
},
|
|
424
|
+
stageDir: stage.stageDir,
|
|
425
|
+
note: stage.note,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Resolve how to launch the EvolCore CLI (`restart`, `restart-monitor`, ...).
|
|
430
|
+
*
|
|
431
|
+
* In a development checkout the compiled `dist/` is deleted and rebuilt by
|
|
432
|
+
* `npm run build`, so anything spawned from `dist/cli/index.js` disappears
|
|
433
|
+
* exactly when a build is in flight. The restart monitor is the watchdog that
|
|
434
|
+
* has to outlive that window, so it must not live inside the artifact it
|
|
435
|
+
* supervises: when sources and tsx are both available we run the monitor
|
|
436
|
+
* straight from `src/`, mirroring what `bin/ec.js` already does for the CLI.
|
|
437
|
+
*
|
|
438
|
+
* An npm-installed package ships `dist/` without `src/` and without tsx (a
|
|
439
|
+
* devDependency), so both checks fail and the caller keeps the previous
|
|
440
|
+
* behaviour byte for byte.
|
|
441
|
+
*/
|
|
442
|
+
export function resolveCliEntry(packageRoot) {
|
|
443
|
+
const sourceEntry = path.join(packageRoot, 'src', 'cli', 'index.ts');
|
|
444
|
+
const distEntry = path.join(packageRoot, 'dist', 'cli', 'index.js');
|
|
445
|
+
if (fs.existsSync(sourceEntry)) {
|
|
446
|
+
// tsx is a devDependency: present in a checkout, absent from the published
|
|
447
|
+
// package. Resolve it relative to the target root so `ec mode` switching
|
|
448
|
+
// between roots cannot borrow the wrong copy.
|
|
449
|
+
try {
|
|
450
|
+
const require = createRequire(path.join(packageRoot, 'package.json'));
|
|
451
|
+
const tsxUrl = pathToFileURL(require.resolve('tsx')).href;
|
|
452
|
+
return { args: ['--import', tsxUrl, sourceEntry], usingSource: true, entry: sourceEntry };
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// tsx missing (e.g. `npm prune --production`): fall through to dist.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { args: [distEntry], usingSource: false, entry: distEntry };
|
|
459
|
+
}
|
|
25
460
|
const ENCODE_PATH_MAX = 200;
|
|
26
461
|
function encodePathHash(s) {
|
|
27
462
|
let h = 0;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
|
-
import { resolvePaths } from '../paths.js';
|
|
4
|
+
import { getPackageRoot, resolvePaths } from '../paths.js';
|
|
5
5
|
import { WEB_PACKAGE_NAME } from '../product.js';
|
|
6
6
|
import { resolveGlobalPkg } from './npm-ops.js';
|
|
7
7
|
import { ecwebVersionRequirementError, resolveEcwebLaunchCommand } from './ecweb-utils.js';
|
|
@@ -218,7 +218,16 @@ export function createEcwebSupervisor(options) {
|
|
|
218
218
|
detached: true,
|
|
219
219
|
stdio: ['ignore', childLogFd, childLogFd],
|
|
220
220
|
windowsHide: platform.isWindows,
|
|
221
|
-
|
|
221
|
+
// ECWeb dynamically imports EvolCore's dist modules for config,
|
|
222
|
+
// roles, authorization and session compatibility. Pin that lookup
|
|
223
|
+
// to the same package root as this daemon; otherwise a separately
|
|
224
|
+
// installed ECWeb can resolve its peer dependency and silently load
|
|
225
|
+
// a different (possibly stale) EvolCore copy from node_modules.
|
|
226
|
+
env: {
|
|
227
|
+
...process.env,
|
|
228
|
+
EVOLCORE_HOME: root,
|
|
229
|
+
EVOLCORE_PACKAGE_ROOT: getPackageRoot(),
|
|
230
|
+
},
|
|
222
231
|
});
|
|
223
232
|
}
|
|
224
233
|
catch (error) {
|