evolcore 0.0.27 → 0.0.28
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 +26 -15
- package/dist/agents/baseagent.js +10 -5
- package/dist/agents/codex/app-server-client.js +7 -0
- package/dist/agents/codex/catalog.js +7 -1
- package/dist/agents/codex-runner.js +48 -11
- package/dist/aun/menu/client.js +1 -0
- package/dist/channels/aun.js +23 -7
- package/dist/cli/daemon-commands.js +77 -140
- package/dist/cli/diag.js +1 -1
- package/dist/cli/menu-client.js +4 -0
- package/dist/cli/restart-monitor.js +65 -17
- package/dist/core/command/command-handler.js +29 -1
- package/dist/core/command/diag-service.js +292 -27
- package/dist/core/command/menu-handler.js +16 -19
- package/dist/core/command/menu-protocol.js +3 -0
- package/dist/core/command/slash-handler.js +24 -15
- package/dist/core/message/im-renderer.js +21 -4
- package/dist/core/message/message-bridge.js +47 -3
- package/dist/core/message/response-engine.js +29 -10
- package/dist/core/permission/ec-command-parser.js +15 -7
- package/dist/core/permission/tool-policy.js +1 -1
- package/dist/core/session/session-manager.js +4 -0
- package/dist/index.js +14 -153
- package/dist/product.js +1 -0
- package/dist/utils/atomic-write.js +98 -0
- package/dist/utils/cross-platform.js +87 -16
- package/dist/utils/npm-ops.js +119 -4
- package/dist/utils/package-version-info.js +244 -0
- package/dist/utils/windows-shell-trust.js +37 -0
- package/kits/docs/channels/aun.md +8 -0
- package/package.json +1 -1
|
@@ -33,6 +33,7 @@ import { guardGroupSlashCommand, parseFullAccessCommand } from '../command/slash
|
|
|
33
33
|
import { isFullAccessEnabled } from '../../config-store.js';
|
|
34
34
|
import { createAgentPolicyAuthorization } from '../role/runtime-policy.js';
|
|
35
35
|
import { auditFullAccessEvent } from '../auth/authorization-audit.js';
|
|
36
|
+
import { DIAGNOSTIC_AGENT_AID, DiagnosticSessionRouter, parseDiagnosticEvent, isDiagnosticSessionActive } from '../command/diag-service.js';
|
|
36
37
|
import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './message-admission.js';
|
|
37
38
|
const CONTACT_REJECTION_NOTICE = '[rejection] Not accepting messages from you.';
|
|
38
39
|
const REJECTABLE_PEER_TYPES = new Set(['ai', 'bot', 'agent', 'service']);
|
|
@@ -115,6 +116,7 @@ export class MessageBridge {
|
|
|
115
116
|
menuTokenStore = new MenuTokenStore();
|
|
116
117
|
handoffRuntime;
|
|
117
118
|
sessionRenewService;
|
|
119
|
+
diagnosticRouter;
|
|
118
120
|
aidStatsCollector;
|
|
119
121
|
contactBindRuntimeChecker;
|
|
120
122
|
interactionRouter;
|
|
@@ -129,6 +131,13 @@ export class MessageBridge {
|
|
|
129
131
|
this.processOwnersProvider = processOwnersProvider;
|
|
130
132
|
this.defaultDebounce = defaultDebounce ?? 0;
|
|
131
133
|
this.sessionRenewService = new SessionRenewService(sessionManager, processor, { eventBus });
|
|
134
|
+
this.diagnosticRouter = new DiagnosticSessionRouter(sessionManager, async (sessionId) => {
|
|
135
|
+
// Revoke in the router first, then clear pending work before awaiting the
|
|
136
|
+
// runner interruption. Delayed debounce and recovery are fenced below.
|
|
137
|
+
this.messageQueue.clearBySession(sessionId);
|
|
138
|
+
this.messageQueue.markActiveTerminal(sessionId, 'stop');
|
|
139
|
+
await this.processor.interruptSession(sessionId, 'stop');
|
|
140
|
+
});
|
|
132
141
|
}
|
|
133
142
|
/** Inject EvolAgentRegistry so owner lookups/writes route to agent.json for agent-owned channels. */
|
|
134
143
|
setAgentRegistry(registry) {
|
|
@@ -971,6 +980,37 @@ export class MessageBridge {
|
|
|
971
980
|
await this.sendAccessDenied(adapter, channelName, msg, sendReply);
|
|
972
981
|
return;
|
|
973
982
|
}
|
|
983
|
+
// Diagnostic invitations carry their own execution boundary. Resolve
|
|
984
|
+
// that boundary before ordinary command/session routing so a new case
|
|
985
|
+
// cannot inherit an older Codex thread.
|
|
986
|
+
let diagnosticSession;
|
|
987
|
+
if (selfAid === DIAGNOSTIC_AGENT_AID && resolvedChannelType === 'aun'
|
|
988
|
+
&& chatType === 'private' && msg.source !== 'handoff') {
|
|
989
|
+
try {
|
|
990
|
+
const diagnosticEvent = parseDiagnosticEvent(content);
|
|
991
|
+
if (diagnosticEvent) {
|
|
992
|
+
releaseAdmission = await this.acquireAdmission(admissionRoute);
|
|
993
|
+
const routed = await this.diagnosticRouter.route({
|
|
994
|
+
event: diagnosticEvent, message: msg, channel: channelName,
|
|
995
|
+
channelType: resolvedChannelType, selfAid,
|
|
996
|
+
chatDir: chatDirPath(resolvePaths().sessionsDir, resolvedChannelType, msg.channelId, selfAid, channelName),
|
|
997
|
+
projectPath: owningAgent?.projectPath ?? this.defaultProjectPath,
|
|
998
|
+
baseagent: owningAgent?.baseagent,
|
|
999
|
+
});
|
|
1000
|
+
if ('notice' in routed) {
|
|
1001
|
+
await sendReply(msg.channelId, routed.notice, msg.replyContext);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
diagnosticSession = routed.session;
|
|
1005
|
+
diagnosticSession.identity = identity;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
logger.warn(`[MessageBridge] Diagnostic admission failed: ${error instanceof Error ? error.message : 'unknown error'}`);
|
|
1010
|
+
await sendReply(msg.channelId, '诊断会话隔离未完成,本次未执行诊断。请检查诊断端日志后重试。', msg.replyContext);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
974
1014
|
// /fa is an execution prefix, not a session-mode switch or a normal
|
|
975
1015
|
// quick command. Only this trusted bridge path can construct the
|
|
976
1016
|
// internal per-call authorization carried into ResponseEngine.
|
|
@@ -1186,9 +1226,10 @@ export class MessageBridge {
|
|
|
1186
1226
|
// transaction. Without this per-route single-flight, a second message
|
|
1187
1227
|
// can observe the just-renewed empty session before the first message
|
|
1188
1228
|
// has been appended and create another renewed session.
|
|
1189
|
-
|
|
1229
|
+
if (!releaseAdmission)
|
|
1230
|
+
releaseAdmission = await this.acquireAdmission(admissionRoute);
|
|
1190
1231
|
const hadMainSession = msg.threadId ? true : this.sessionManager.hasMainSession(channelName, msg.channelId, msg.channelType || effectiveChannelType, msg.selfAID || selfAid);
|
|
1191
|
-
let session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, owningAgent?.baseagent, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType, identity, readTopicLable(msg) ?? readTopicLable(msg.replyContext?.metadata));
|
|
1232
|
+
let session = diagnosticSession ?? await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, owningAgent?.baseagent, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType, identity, readTopicLable(msg) ?? readTopicLable(msg.replyContext?.metadata));
|
|
1192
1233
|
if (session.threadId && typeof this.messageQueue.reserveSessionIngress === 'function') {
|
|
1193
1234
|
releaseSessionIngress = this.messageQueue.reserveSessionIngress(session.id);
|
|
1194
1235
|
}
|
|
@@ -1201,7 +1242,7 @@ export class MessageBridge {
|
|
|
1201
1242
|
const inboundCausation = restoredCausation
|
|
1202
1243
|
? deriveCausation(restoredCausation)
|
|
1203
1244
|
: createRootCausation();
|
|
1204
|
-
const renewResult = await this.sessionRenewService.resolve({
|
|
1245
|
+
const renewResult = session.metadata?.diagnostic ? { session } : await this.sessionRenewService.resolve({
|
|
1205
1246
|
session,
|
|
1206
1247
|
channelName,
|
|
1207
1248
|
channelType: msg.channelType || effectiveChannelType,
|
|
@@ -1338,6 +1379,9 @@ export class MessageBridge {
|
|
|
1338
1379
|
const enqueueAgentName = owningAgent?.name ?? '<unknown>';
|
|
1339
1380
|
const selfAID = session.selfAID;
|
|
1340
1381
|
const doEnqueue = async (m) => {
|
|
1382
|
+
// A cancellation can arrive while this message is being debounced.
|
|
1383
|
+
if (!isDiagnosticSessionActive(session, this.sessionManager.getChatDir(session)))
|
|
1384
|
+
return;
|
|
1341
1385
|
const enqueue = handoffCandidate ? this.messageQueue.enqueuePersisted.bind(this.messageQueue) : this.messageQueue.enqueue.bind(this.messageQueue);
|
|
1342
1386
|
try {
|
|
1343
1387
|
return await enqueue(session.id, m, session.projectPath, {
|
|
@@ -5,6 +5,7 @@ import os from 'os';
|
|
|
5
5
|
import crypto from 'crypto';
|
|
6
6
|
import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken, formatAsyncQuestionAnswer, parseAsyncUserQuestions, } from '../../agents/runner-types.js';
|
|
7
7
|
import { loadedBaseagentIds } from '../../agents/baseagent-availability.js';
|
|
8
|
+
import { isDiagnosticSessionActive } from '../command/diag-service.js';
|
|
8
9
|
import { evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission } from './message-admission.js';
|
|
9
10
|
import { authorizeAccess, authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
|
|
10
11
|
import { buildTaskRuntimeEnv, ensureRuntimeLockDir, ensureSessionRuntimeDir } from '../../cli/task-context.js';
|
|
@@ -1878,6 +1879,28 @@ export class ResponseEngine {
|
|
|
1878
1879
|
async processMessageWithMonitors(message) {
|
|
1879
1880
|
const idleMs = (this.globalSettings.idleMonitor?.timeout ?? 120) * 1000;
|
|
1880
1881
|
const totalExecutionMs = this.totalExecutionLimitMs();
|
|
1882
|
+
// A discarded handoff must be dropped before any bound-session lookup.
|
|
1883
|
+
// Restored origin deliveries may point at a session that has already been
|
|
1884
|
+
// replaced; resolving it first would reactivate the stale session path.
|
|
1885
|
+
if (message.handoffDelivery
|
|
1886
|
+
&& this.handoffRuntime
|
|
1887
|
+
&& this.handoffRuntime.isDiscardedDelivery(message)) {
|
|
1888
|
+
logger.info(`[ResponseEngine] Skip stale handoff delivery before session resolution:`
|
|
1889
|
+
+ ` direction=${message.handoffDelivery.direction}`
|
|
1890
|
+
+ ` handoff=${message.handoffDelivery.handoffId}`);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
// Includes persisted queues, recovery triggers and delayed owner replies.
|
|
1894
|
+
// A closed diagnostic must not activate its old main session again.
|
|
1895
|
+
const diagnosticBoundId = message.boundSessionId ?? message.triggerMeta?.boundSessionId;
|
|
1896
|
+
if (diagnosticBoundId) {
|
|
1897
|
+
const bound = await this.sessionManager.getSessionById(diagnosticBoundId);
|
|
1898
|
+
if (bound && (bound.metadata?.diagnostic || bound.metadata?.diagnosticStatus === 'cancelled')
|
|
1899
|
+
&& !isDiagnosticSessionActive(bound, this.sessionManager.getChatDir(bound))) {
|
|
1900
|
+
logger.info(`[ResponseEngine] Skip closed diagnostic session ${bound.id}`);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1881
1904
|
if (message.taskRecovery) {
|
|
1882
1905
|
const current = message.boundSessionId
|
|
1883
1906
|
? await this.sessionManager.getSessionById(message.boundSessionId)
|
|
@@ -1887,17 +1910,12 @@ export class ResponseEngine {
|
|
|
1887
1910
|
return;
|
|
1888
1911
|
}
|
|
1889
1912
|
}
|
|
1890
|
-
if (message.handoffDelivery
|
|
1891
|
-
&& this.handoffRuntime
|
|
1892
|
-
&& this.handoffRuntime.isDiscardedDelivery(message)) {
|
|
1893
|
-
logger.info(`[ResponseEngine] Skip stale handoff delivery before session resolution:`
|
|
1894
|
-
+ ` direction=${message.handoffDelivery.direction}`
|
|
1895
|
-
+ ` handoff=${message.handoffDelivery.handoffId}`);
|
|
1896
|
-
return;
|
|
1897
|
-
}
|
|
1898
1913
|
// 先解析会话,再优先用 session.metadata.channelKey 精确定位实例级 adapter
|
|
1899
1914
|
// message.channel 现在存实例名(channelName),可直接用于精确路由
|
|
1900
1915
|
const { session, absoluteProjectPath } = await this.resolveSession(message);
|
|
1916
|
+
if ((session.metadata?.diagnostic || session.metadata?.diagnosticStatus === 'cancelled')
|
|
1917
|
+
&& !isDiagnosticSessionActive(session, this.sessionManager.getChatDir(session)))
|
|
1918
|
+
return;
|
|
1901
1919
|
const accessChannelKey = this.messageChannelKey(message, session);
|
|
1902
1920
|
const accessChannelInfo = this.resolveChannelInfo(accessChannelKey);
|
|
1903
1921
|
const accessAdapter = accessChannelInfo?.adapter;
|
|
@@ -2874,6 +2892,7 @@ export class ResponseEngine {
|
|
|
2874
2892
|
flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
|
|
2875
2893
|
suppressActivityItems: isProactive ? false : middleOutputMode !== 'all',
|
|
2876
2894
|
suppressIntermediateText: isProactive ? false : middleOutputMode === 'none',
|
|
2895
|
+
deferTextUntilBoundary: (message.channelType ?? session.channelType) === 'aun' && session.chatType === 'private',
|
|
2877
2896
|
operationalNoticesAsText: !isProactive && middleOutputMode !== 'none',
|
|
2878
2897
|
fileMarkerPattern: options?.fileMarkerPattern,
|
|
2879
2898
|
send: async (payload) => {
|
|
@@ -5245,7 +5264,7 @@ export class ResponseEngine {
|
|
|
5245
5264
|
// valid mapped owner/admin to role=none at the execution boundary.
|
|
5246
5265
|
if (resolvedIdentity)
|
|
5247
5266
|
bound.identity = resolvedIdentity;
|
|
5248
|
-
if (bound.threadId) {
|
|
5267
|
+
if (bound.threadId || bound.metadata?.diagnostic) {
|
|
5249
5268
|
const absoluteProjectPath = path.isAbsolute(bound.projectPath)
|
|
5250
5269
|
? bound.projectPath : path.resolve(process.cwd(), bound.projectPath);
|
|
5251
5270
|
return { session: bound, absoluteProjectPath };
|
|
@@ -5705,7 +5724,7 @@ export class ResponseEngine {
|
|
|
5705
5724
|
this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: visibleText, isFinal: false });
|
|
5706
5725
|
}
|
|
5707
5726
|
if (visibleText && !shouldSuppress()) {
|
|
5708
|
-
await renderer.addText(visibleText, event.outputTokens, event.turn, event.assistantUuid);
|
|
5727
|
+
await renderer.addText(visibleText, event.outputTokens, event.turn, event.assistantUuid, event.phase);
|
|
5709
5728
|
hasProjectedCurrentReplyText = true;
|
|
5710
5729
|
}
|
|
5711
5730
|
}
|
|
@@ -376,6 +376,14 @@ function parseFailure(issue, input, offset, tokenIndex, dialect) {
|
|
|
376
376
|
inputLength: input.length,
|
|
377
377
|
};
|
|
378
378
|
}
|
|
379
|
+
// PowerShell uses smart quotes as delimiters, including when the opening
|
|
380
|
+
// quote was ASCII. Treating them as text changes argv and delegation hashes.
|
|
381
|
+
function isPowerShellSingleQuote(char) {
|
|
382
|
+
return char !== undefined && /['\u2018\u2019\u201a\u201b]/u.test(char);
|
|
383
|
+
}
|
|
384
|
+
function isPowerShellDoubleQuote(char) {
|
|
385
|
+
return char !== undefined && /["\u201c\u201d\u201e]/u.test(char);
|
|
386
|
+
}
|
|
379
387
|
function parseLiteralPowerShellCommand(command, options = {}) {
|
|
380
388
|
const input = trimShellSpacing(command);
|
|
381
389
|
if (!input)
|
|
@@ -399,11 +407,11 @@ function parseLiteralPowerShellCommand(command, options = {}) {
|
|
|
399
407
|
for (let index = 0; index < input.length; index++) {
|
|
400
408
|
const char = input[index];
|
|
401
409
|
if (quote === 'single') {
|
|
402
|
-
if (char
|
|
403
|
-
token +=
|
|
410
|
+
if (isPowerShellSingleQuote(char) && isPowerShellSingleQuote(input[index + 1])) {
|
|
411
|
+
token += input[index + 1];
|
|
404
412
|
index++;
|
|
405
413
|
}
|
|
406
|
-
else if (char
|
|
414
|
+
else if (isPowerShellSingleQuote(char)) {
|
|
407
415
|
if (!isLiteralArgBoundary(input[index + 1]))
|
|
408
416
|
return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
|
|
409
417
|
quote = null;
|
|
@@ -420,7 +428,7 @@ function parseLiteralPowerShellCommand(command, options = {}) {
|
|
|
420
428
|
return parseFailure('unsafe-expansion', input, index, argv.length, 'powershell');
|
|
421
429
|
}
|
|
422
430
|
if (!options.allowManagedTmpDir || !options.managedTempDir
|
|
423
|
-
|| (next !== undefined && !/[\\/\s"']/u.test(next))) {
|
|
431
|
+
|| (next !== undefined && !/[\\/\s"']/u.test(next) && !isPowerShellSingleQuote(next) && !isPowerShellDoubleQuote(next))) {
|
|
424
432
|
return parseFailure('unsafe-expansion', input, index, argv.length, 'powershell');
|
|
425
433
|
}
|
|
426
434
|
// Append the registered value directly: spaces remain in this argument,
|
|
@@ -431,7 +439,7 @@ function parseLiteralPowerShellCommand(command, options = {}) {
|
|
|
431
439
|
continue;
|
|
432
440
|
}
|
|
433
441
|
if (quote === 'double') {
|
|
434
|
-
if (char
|
|
442
|
+
if (isPowerShellDoubleQuote(char)) {
|
|
435
443
|
if (!isLiteralArgBoundary(input[index + 1]))
|
|
436
444
|
return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
|
|
437
445
|
quote = null;
|
|
@@ -458,12 +466,12 @@ function parseLiteralPowerShellCommand(command, options = {}) {
|
|
|
458
466
|
if (/\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f') {
|
|
459
467
|
return parseFailure('invalid-control-char', input, index, argv.length, 'powershell');
|
|
460
468
|
}
|
|
461
|
-
if (char
|
|
469
|
+
if (isPowerShellSingleQuote(char)) {
|
|
462
470
|
tokenStarted = true;
|
|
463
471
|
quote = 'single';
|
|
464
472
|
continue;
|
|
465
473
|
}
|
|
466
|
-
if (char
|
|
474
|
+
if (isPowerShellDoubleQuote(char)) {
|
|
467
475
|
tokenStarted = true;
|
|
468
476
|
quote = 'double';
|
|
469
477
|
continue;
|
|
@@ -2334,7 +2334,7 @@ export function evaluateToolPreflight(toolName, input, context) {
|
|
|
2334
2334
|
}
|
|
2335
2335
|
if (ecCommand.issue === 'invalid-quote') {
|
|
2336
2336
|
const message = shellDialect === 'powershell'
|
|
2337
|
-
? '🔒 EC 命令引号未闭合或存在错误嵌套;PowerShell
|
|
2337
|
+
? '🔒 EC 命令引号未闭合或存在错误嵌套;PowerShell 也会解释中文弯引号。请按 PowerShell 规则转义,或把消息正文写入当前 $TMPDIR 后使用 --text-from-file'
|
|
2338
2338
|
: shellDialect === 'cmd'
|
|
2339
2339
|
? '🔒 EC 命令引号未闭合或存在错误嵌套;cmd.exe carrier 的命令正文请保持一个完整参数,不要用未配对的双引号'
|
|
2340
2340
|
: '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"';
|
|
@@ -1501,6 +1501,10 @@ export class SessionManager {
|
|
|
1501
1501
|
if (identityMetadata?.sessionBoundaryOperationId) {
|
|
1502
1502
|
metadata.sessionBoundaryOperationId = identityMetadata.sessionBoundaryOperationId;
|
|
1503
1503
|
}
|
|
1504
|
+
// Only an explicit caller may bind a diagnostic case. Never inherit a
|
|
1505
|
+
// previous case (or any provider thread binding) from the active session.
|
|
1506
|
+
if (identityMetadata?.diagnostic)
|
|
1507
|
+
metadata.diagnostic = { ...identityMetadata.diagnostic };
|
|
1504
1508
|
const session = {
|
|
1505
1509
|
id: generateSessionId(),
|
|
1506
1510
|
channel,
|
package/dist/index.js
CHANGED
|
@@ -103,12 +103,10 @@ import { atomicWriteJson } from './core/session/session-fs-store.js';
|
|
|
103
103
|
import { DAEMON_RUNTIME_EPOCH_ENV, ensureProcessManagedTempDir } from './cli/task-context.js';
|
|
104
104
|
import { appendMessageLog, buildOutboundEntry, classifyAunPayloadForLog } from './core/message/message-log.js';
|
|
105
105
|
import { normalizeAunMentionEntries } from './aun/msg/mention-schema.js';
|
|
106
|
-
import { MAIN_PACKAGE_NAME } from './product.js';
|
|
107
106
|
import { sanitizeMenuLogValue } from './core/command/menu-protocol.js';
|
|
108
107
|
import fs from 'fs';
|
|
109
108
|
import crypto from 'crypto';
|
|
110
109
|
import { fileURLToPath } from 'url';
|
|
111
|
-
import * as platform from './utils/cross-platform.js';
|
|
112
110
|
const controlMenuDeduper = new MenuRequestDeduper();
|
|
113
111
|
/** 出站 payload 摘要(用于 channel-out.log) */
|
|
114
112
|
function summarizeOutboundPayload(payload) {
|
|
@@ -372,160 +370,23 @@ function seedUpgradeCheckTrigger(manager, owner) {
|
|
|
372
370
|
fs.mkdirSync(dir, { recursive: true });
|
|
373
371
|
const scriptPath = path.join(dir, scriptName);
|
|
374
372
|
const packageRoot = getPackageRoot();
|
|
375
|
-
|
|
376
|
-
const packageJsonPath = path.join(packageRoot, 'package.json');
|
|
377
|
-
const devMode = fs.existsSync(path.join(packageRoot, 'src', 'index.ts'));
|
|
378
|
-
// Resolve the launch argv at generation time: in a checkout this points the
|
|
379
|
-
// monitor at src/ through tsx, so a `npm run build` that wipes dist/ cannot
|
|
380
|
-
// take the watchdog down with it.
|
|
381
|
-
const monitorEntry = platform.resolveCliEntry(packageRoot);
|
|
382
|
-
const stageParent = path.join(resolvePaths().root, 'data', 'monitor-stage');
|
|
373
|
+
// Keep this generated script check-only. It never starts a monitor or runs npm install.
|
|
383
374
|
fs.writeFileSync(scriptPath, `#!/usr/bin/env node
|
|
384
|
-
const
|
|
385
|
-
const
|
|
386
|
-
const { existsSync, readFileSync } = fs;
|
|
387
|
-
const { spawn } = require('node:child_process');
|
|
388
|
-
|
|
389
|
-
const CLI_PATH = ${JSON.stringify(monitorEntry.entry)};
|
|
390
|
-
const CLI_ARGS = ${JSON.stringify(monitorEntry.args)};
|
|
391
|
-
const PACKAGE_ROOT = ${JSON.stringify(packageRoot)};
|
|
392
|
-
const STAGE_PARENT = ${JSON.stringify(stageParent)};
|
|
393
|
-
const PACKAGE_JSON = ${JSON.stringify(packageJsonPath)};
|
|
394
|
-
const PACKAGE_NAME = ${JSON.stringify(MAIN_PACKAGE_NAME)};
|
|
395
|
-
const DEV_MODE = ${devMode ? 'true' : 'false'};
|
|
396
|
-
|
|
397
|
-
function emit(outcome, text) {
|
|
398
|
-
process.stdout.write(JSON.stringify({ outcome, text, files: [] }));
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function compareVersions(a, b) {
|
|
402
|
-
const pa = a.split('-')[0].split('.').map(Number);
|
|
403
|
-
const pb = b.split('-')[0].split('.').map(Number);
|
|
404
|
-
const count = Math.max(pa.length, pb.length);
|
|
405
|
-
for (let i = 0; i < count; i += 1) {
|
|
406
|
-
const av = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
407
|
-
const bv = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
408
|
-
if (av < bv) return -1;
|
|
409
|
-
if (av > bv) return 1;
|
|
410
|
-
}
|
|
411
|
-
return 0;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async function latestVersion() {
|
|
415
|
-
const controller = new AbortController();
|
|
416
|
-
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
417
|
-
try {
|
|
418
|
-
const response = await fetch('https://registry.npmjs.org/' + encodeURIComponent(PACKAGE_NAME) + '/latest', {
|
|
419
|
-
signal: controller.signal,
|
|
420
|
-
headers: { Accept: 'application/json' },
|
|
421
|
-
});
|
|
422
|
-
if (!response.ok) return null;
|
|
423
|
-
const data = await response.json();
|
|
424
|
-
return typeof data.version === 'string' ? data.version : null;
|
|
425
|
-
} finally {
|
|
426
|
-
clearTimeout(timer);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
375
|
+
const { pathToFileURL } = require('node:url');
|
|
376
|
+
const entry = ${JSON.stringify(path.join(packageRoot, 'dist', 'utils', 'package-version-info.js'))};
|
|
430
377
|
async function main() {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
} catch {
|
|
440
|
-
emit('error', 'failed to read local ec version');
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (typeof localVersion !== 'string' || !localVersion) {
|
|
444
|
-
emit('error', 'failed to read local ec version');
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
let remoteVersion;
|
|
449
|
-
try {
|
|
450
|
-
remoteVersion = await latestVersion();
|
|
451
|
-
} catch {
|
|
452
|
-
remoteVersion = null;
|
|
453
|
-
}
|
|
454
|
-
if (!remoteVersion) {
|
|
455
|
-
emit('error', 'failed to check evolcore latest version');
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
if (compareVersions(localVersion, remoteVersion) >= 0) {
|
|
459
|
-
emit('noop', 'evolcore is already up to date (' + localVersion + '; latest ' + remoteVersion + ')');
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
if (!existsSync(CLI_PATH)) {
|
|
463
|
-
emit('error', 'restart-monitor entry not found: ' + CLI_PATH);
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Installed mode: copy the monitor out of the package before it upgrades that
|
|
468
|
-
// same package. Mirrors prepareMonitorStage(); kept inline because this file
|
|
469
|
-
// is generated standalone CJS. Any failure degrades to launching in place —
|
|
470
|
-
// losing upgrade isolation beats losing the upgrade.
|
|
471
|
-
let launchArgs = CLI_ARGS;
|
|
472
|
-
const launchEnv = { ...process.env };
|
|
473
|
-
if (!DEV_MODE && STAGE_PARENT) {
|
|
474
|
-
try {
|
|
475
|
-
const stageDir = path.join(STAGE_PARENT, 'upgrade-' + process.pid + '-' + Date.now());
|
|
476
|
-
fs.mkdirSync(stageDir, { recursive: true });
|
|
477
|
-
fs.cpSync(path.join(PACKAGE_ROOT, 'dist'), path.join(stageDir, 'dist'), { recursive: true });
|
|
478
|
-
if (existsSync(PACKAGE_JSON)) fs.copyFileSync(PACKAGE_JSON, path.join(stageDir, 'package.json'));
|
|
479
|
-
const modulesSource = path.join(PACKAGE_ROOT, 'node_modules');
|
|
480
|
-
if (existsSync(modulesSource)) {
|
|
481
|
-
const staged = path.join(stageDir, 'node_modules');
|
|
482
|
-
try {
|
|
483
|
-
// junction works unelevated on Windows; dir symlink elsewhere.
|
|
484
|
-
fs.symlinkSync(modulesSource, staged, process.platform === 'win32' ? 'junction' : 'dir');
|
|
485
|
-
fs.readdirSync(staged);
|
|
486
|
-
} catch (linkError) {
|
|
487
|
-
fs.rmSync(staged, { recursive: true, force: true });
|
|
488
|
-
fs.cpSync(modulesSource, staged, { recursive: true });
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
const stagedEntry = path.join(stageDir, 'dist', 'cli', 'index.js');
|
|
492
|
-
if (existsSync(stagedEntry)) {
|
|
493
|
-
launchArgs = [stagedEntry];
|
|
494
|
-
launchEnv.EVOLCORE_TARGET_PACKAGE_ROOT = PACKAGE_ROOT;
|
|
495
|
-
launchEnv.EVOLCORE_MONITOR_STAGE = stageDir;
|
|
496
|
-
}
|
|
497
|
-
} catch (stageError) {
|
|
498
|
-
// Fall through with the in-place entry.
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
try {
|
|
503
|
-
const child = spawn(process.execPath, [...launchArgs, 'restart-monitor'], {
|
|
504
|
-
detached: true,
|
|
505
|
-
stdio: 'ignore',
|
|
506
|
-
windowsHide: process.platform === 'win32',
|
|
507
|
-
env: launchEnv,
|
|
508
|
-
});
|
|
509
|
-
child.unref();
|
|
510
|
-
const pid = child.pid;
|
|
511
|
-
// The daemon is still running here — the monitor is what stops it. So a
|
|
512
|
-
// monitor that dies on load costs the upgrade, not the service; still,
|
|
513
|
-
// report it accurately instead of claiming success.
|
|
514
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
515
|
-
let alive = false;
|
|
516
|
-
try { process.kill(pid, 0); alive = true; } catch (e) { alive = false; }
|
|
517
|
-
if (!alive) {
|
|
518
|
-
emit('error', 'restart-monitor exited during startup; upgrade to ' + remoteVersion + ' not applied');
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
emit('success', 'evolcore upgrade available: ' + localVersion + ' -> ' + remoteVersion + '; restart-monitor started');
|
|
522
|
-
} catch (error) {
|
|
523
|
-
emit('error', 'failed to start restart-monitor: ' + (error && error.message ? error.message : String(error)));
|
|
524
|
-
}
|
|
378
|
+
const { refreshManagedPackageVersions } = await import(pathToFileURL(entry).href);
|
|
379
|
+
const results = await refreshManagedPackageVersions(undefined, { source: 'daily-check' });
|
|
380
|
+
const failed = results.some(result => result.status === 'unavailable');
|
|
381
|
+
process.stdout.write(JSON.stringify({
|
|
382
|
+
outcome: failed ? 'error' : 'success',
|
|
383
|
+
text: results.map(result => result.packageName + ': ' + result.status).join('; '),
|
|
384
|
+
files: [],
|
|
385
|
+
}));
|
|
525
386
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
387
|
+
main().catch(() => {
|
|
388
|
+
process.stdout.write(JSON.stringify({ outcome: 'error', text: 'Version information refresh failed', files: [] }));
|
|
389
|
+
process.exitCode = 1;
|
|
529
390
|
});
|
|
530
391
|
`);
|
|
531
392
|
// Remove the pre-Node script left by older installations so it cannot be
|
package/dist/product.js
CHANGED
|
@@ -14,6 +14,7 @@ export const MIN_ECWEB_VERSION = '0.0.8';
|
|
|
14
14
|
export const DAEMON_CONFIG_FILENAME = 'daemon.json';
|
|
15
15
|
export const DAEMON_SCHEMA_NAME = 'daemon';
|
|
16
16
|
export const RUNTIME_HOME_ENV = 'EVOLCORE_HOME';
|
|
17
|
+
export const TARGET_PACKAGE_ROOT_ENV = 'EVOLCORE_TARGET_PACKAGE_ROOT';
|
|
17
18
|
export const SESSION_ID_ENV = 'EVOLCORE_SESSION_ID';
|
|
18
19
|
export const SELF_AID_ENV = 'EVOLCORE_SELF_AID';
|
|
19
20
|
export const DEFAULT_RUNTIME_HOME_DIRNAME = '.evolcore';
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import path from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
import { randomUUID } from 'crypto';
|
|
20
|
+
import { isProcessRunning } from './cross-platform.js';
|
|
18
21
|
const HOT = '_'; // foo.json_ 旧版热备
|
|
19
22
|
const TMP = '__'; // foo.json__ 写入中
|
|
20
23
|
const HOT_BACKUP_FALLBACK_CODES = new Set(['EACCES', 'ENOSYS', 'ENOTSUP', 'EPERM', 'EXDEV']);
|
|
@@ -178,3 +181,98 @@ export function atomicWriteBytes(filePath, content) {
|
|
|
178
181
|
fs.writeFileSync(tmp, content);
|
|
179
182
|
fs.renameSync(tmp, filePath);
|
|
180
183
|
}
|
|
184
|
+
const pauseFileWrite = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
185
|
+
/** Cross-process lock for package state. A complete owner file is linked into place atomically. */
|
|
186
|
+
export async function withFileLock(file, action, timeoutMs = 30000) {
|
|
187
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
188
|
+
const token = randomUUID();
|
|
189
|
+
const candidate = `${file}.${process.pid}.${token}.candidate`;
|
|
190
|
+
const owner = JSON.stringify({ token, pid: process.pid, host: os.hostname() });
|
|
191
|
+
fs.writeFileSync(candidate, owner, { flag: 'wx', mode: 0o600 });
|
|
192
|
+
const deadline = Date.now() + timeoutMs;
|
|
193
|
+
try {
|
|
194
|
+
while (true) {
|
|
195
|
+
try {
|
|
196
|
+
fs.linkSync(candidate, file);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code !== 'EEXIST')
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
205
|
+
const existing = JSON.parse(raw);
|
|
206
|
+
if (typeof existing.token === 'string' && /^[a-f0-9-]{36}$/.test(existing.token)
|
|
207
|
+
&& existing.host === os.hostname() && existing.pid !== process.pid
|
|
208
|
+
&& Number.isSafeInteger(existing.pid) && existing.pid > 0 && !isProcessRunning(existing.pid)
|
|
209
|
+
&& fs.readFileSync(file, 'utf8') === raw) {
|
|
210
|
+
fs.unlinkSync(file);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch { /* Unknown or concurrently released owner: wait. */ }
|
|
215
|
+
if (Date.now() >= deadline)
|
|
216
|
+
throw new Error('Package state lock timed out');
|
|
217
|
+
await pauseFileWrite(40 + Math.floor(Math.random() * 20));
|
|
218
|
+
}
|
|
219
|
+
const lockIdentity = fs.statSync(file);
|
|
220
|
+
try {
|
|
221
|
+
return await action();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
const releaseDeadline = Date.now() + 5000;
|
|
225
|
+
while (true) {
|
|
226
|
+
try {
|
|
227
|
+
const current = fs.statSync(file);
|
|
228
|
+
if (current.dev !== lockIdentity.dev || current.ino !== lockIdentity.ino) {
|
|
229
|
+
throw new Error('Package lock ownership changed');
|
|
230
|
+
}
|
|
231
|
+
fs.unlinkSync(file);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (error.code === 'ENOENT')
|
|
236
|
+
break;
|
|
237
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code ?? '')
|
|
238
|
+
|| Date.now() >= releaseDeadline)
|
|
239
|
+
throw error;
|
|
240
|
+
await pauseFileWrite(50);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
fs.rmSync(candidate, { force: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Atomic replacement without the in-place Windows fallback used by protected config files. */
|
|
250
|
+
export async function atomicReplaceJson(file, value) {
|
|
251
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
252
|
+
try {
|
|
253
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
254
|
+
try {
|
|
255
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
256
|
+
fs.fsyncSync(fd);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
fs.closeSync(fd);
|
|
260
|
+
}
|
|
261
|
+
const deadline = Date.now() + 5000;
|
|
262
|
+
for (let attempt = 0;; attempt++) {
|
|
263
|
+
try {
|
|
264
|
+
fs.renameSync(temporary, file);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code ?? '')
|
|
269
|
+
|| Date.now() >= deadline)
|
|
270
|
+
throw error;
|
|
271
|
+
await pauseFileWrite(Math.min(200, 25 * (attempt + 1)) + Math.floor(Math.random() * 25));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
fs.rmSync(temporary, { force: true });
|
|
277
|
+
}
|
|
278
|
+
}
|