@evomap/evolver 1.89.2 → 1.89.4
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/.cursor/BUGBOT.md +182 -0
- package/.env.example +68 -0
- package/.git-commit-guard-token +1 -0
- package/.github/CODEOWNERS +63 -0
- package/.github/ISSUE_TEMPLATE/good_first_issue.md +23 -0
- package/.github/pull_request_template.md +45 -0
- package/.github/workflows/test.yml +75 -0
- package/CHANGELOG.md +1237 -0
- package/README.ja-JP.md +1 -3
- package/README.ko-KR.md +1 -3
- package/README.md +86 -530
- package/README.public.md +569 -0
- package/README.zh-CN.md +1 -3
- package/SECURITY.md +108 -0
- package/assets/gep/events.jsonl +3 -0
- package/assets/gep/genes.json +496 -0
- package/examples/atp-consumer-quickstart.md +100 -0
- package/examples/hello-world.md +38 -0
- package/index.js +44 -48
- package/package.json +6 -17
- package/proxy-package.json +39 -0
- package/public.manifest.json +143 -0
- package/src/adapters/hookAdapter.js +2 -0
- package/src/adapters/scripts/_lockPaths.js +74 -0
- package/src/adapters/scripts/evolver-session-start.js +19 -27
- package/src/config.js +23 -0
- package/src/evolve/guards.js +721 -1
- package/src/evolve/pipeline/collect.js +1283 -1
- package/src/evolve/pipeline/dispatch.js +421 -1
- package/src/evolve/pipeline/enrich.js +440 -1
- package/src/evolve/pipeline/hub.js +319 -1
- package/src/evolve/pipeline/select.js +274 -1
- package/src/evolve/pipeline/signals.js +206 -1
- package/src/evolve/utils.js +264 -1
- package/src/evolve.js +350 -1
- package/src/experiment/agentRunner.js +229 -0
- package/src/experiment/cli.js +159 -0
- package/src/experiment/comparison.js +233 -0
- package/src/experiment/metrics.js +75 -0
- package/src/forceUpdate.js +311 -30
- package/src/gep/a2aProtocol.js +4455 -1
- package/src/gep/antiAbuseTelemetry.js +233 -0
- package/src/gep/autoDistillConv.js +205 -1
- package/src/gep/autoDistillLlm.js +315 -1
- package/src/gep/candidateEval.js +92 -1
- package/src/gep/candidates.js +198 -1
- package/src/gep/contentHash.js +30 -1
- package/src/gep/conversationSniffer.js +266 -1
- package/src/gep/crypto.js +89 -1
- package/src/gep/curriculum.js +163 -1
- package/src/gep/deviceId.js +218 -1
- package/src/gep/envFingerprint.js +118 -1
- package/src/gep/epigenetics.js +31 -1
- package/src/gep/execBridge.js +711 -1
- package/src/gep/explore.js +289 -1
- package/src/gep/hash.js +15 -1
- package/src/gep/hubFetch.js +359 -1
- package/src/gep/hubReview.js +207 -1
- package/src/gep/hubSearch.js +526 -1
- package/src/gep/hubVerify.js +306 -1
- package/src/gep/learningSignals.js +89 -1
- package/src/gep/memoryGraph.js +1374 -1
- package/src/gep/memoryGraphAdapter.js +203 -1
- package/src/gep/mutation.js +203 -1
- package/src/gep/narrativeMemory.js +108 -1
- package/src/gep/openPRRegistry.js +205 -1
- package/src/gep/personality.js +423 -1
- package/src/gep/policyCheck.js +599 -1
- package/src/gep/prompt.js +836 -1
- package/src/gep/recallInject.js +409 -1
- package/src/gep/recallVerifier.js +318 -1
- package/src/gep/reflection.js +177 -1
- package/src/gep/sanitize.js +9 -0
- package/src/gep/selector.js +602 -1
- package/src/gep/skillDistiller.js +1294 -1
- package/src/gep/solidify.js +1699 -1
- package/src/gep/strategy.js +136 -1
- package/src/gep/tokenSavings.js +88 -1
- package/src/gep/validator/sandboxExecutor.js +29 -1
- package/src/gep/workspaceKeychain.js +174 -1
- package/src/proxy/extensions/traceControl.js +99 -1
- package/src/proxy/index.js +14 -5
- package/src/proxy/inject.js +52 -1
- package/src/proxy/lifecycle/manager.js +30 -0
- package/src/proxy/mailbox/store.js +2 -1
- package/src/proxy/router/messages_route.js +13 -2
- package/src/proxy/trace/extractor.js +646 -1
- package/src/proxy/trace/usage.js +105 -1
- package/CONTRIBUTING.md +0 -19
- package/assets/cover.png +0 -0
- package/assets/gep/genes.seed.json +0 -245
- package/scripts/a2a_export.js +0 -63
- package/scripts/a2a_ingest.js +0 -79
- package/scripts/a2a_promote.js +0 -118
- package/scripts/analyze_by_skill.js +0 -121
- package/scripts/build_binaries.js +0 -479
- package/scripts/check-changelog.js +0 -166
- package/scripts/extract_log.js +0 -85
- package/scripts/generate_history.js +0 -75
- package/scripts/gep_append_event.js +0 -96
- package/scripts/gep_personality_report.js +0 -234
- package/scripts/human_report.js +0 -147
- package/scripts/recall-verify-report.js +0 -234
- package/scripts/recover_loop.js +0 -61
- package/scripts/seed-merchants.js +0 -91
- package/scripts/suggest_version.js +0 -89
- package/scripts/validate-modules.js +0 -38
- package/scripts/validate-suite.js +0 -78
- package/skills/index.json +0 -14
- /package/{skills → bundled-skills}/_meta/SKILL.md +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/experiment/metrics.js
|
|
2
|
+
//
|
|
3
|
+
// Pure, table-driven mapping from a human metric label (e.g. "完成耗时 (s)",
|
|
4
|
+
// "轮次", "token", "通过率") onto a per-arm field + comparison direction.
|
|
5
|
+
// No I/O, no side effects -- safe to unit-test in isolation.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
function num(v, fallback) {
|
|
9
|
+
const n = Number(v);
|
|
10
|
+
return Number.isFinite(n) ? n : (fallback === undefined ? 0 : fallback);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function round(n, digits) {
|
|
14
|
+
const f = Math.pow(10, digits);
|
|
15
|
+
return Math.round((num(n) + Number.EPSILON) * f) / f;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Ordered rules. The FIRST rule whose any keyword is a (case-insensitive)
|
|
19
|
+
// substring of the metric label wins. Order matters: pass-rate / rounds /
|
|
20
|
+
// tokens / cost are checked before duration so a label like "通过率" is not
|
|
21
|
+
// swallowed by a looser rule.
|
|
22
|
+
const METRIC_RULES = [
|
|
23
|
+
{ keys: ['通过率', 'pass', 'success', 'accuracy', '准确', '正确率'], field: 'passRate', lowerIsBetter: false },
|
|
24
|
+
{ keys: ['轮次', 'turn', 'round', 'step', 'iteration', '迭代'], field: 'rounds', lowerIsBetter: true },
|
|
25
|
+
{ keys: ['token', '令牌'], field: 'tokensTotal', lowerIsBetter: true },
|
|
26
|
+
{ keys: ['成本', 'cost', 'usd', '价格', '费用'], field: 'costUsd', lowerIsBetter: true },
|
|
27
|
+
{ keys: ['耗时', 'duration', 'latency', '延迟', '秒', 'second', '(s)', 'time'], field: 'durationMs', lowerIsBetter: true },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a metric label to the per-arm field used for scoring, the
|
|
32
|
+
* comparison direction, and the display unit.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} metricStr
|
|
35
|
+
* @returns {{ metricField: string, lowerIsBetter: boolean, scoreUnit: string, recognized: boolean }}
|
|
36
|
+
*/
|
|
37
|
+
function deriveMetric(metricStr) {
|
|
38
|
+
const m = String(metricStr || '').toLowerCase();
|
|
39
|
+
for (const rule of METRIC_RULES) {
|
|
40
|
+
if (rule.keys.some((k) => m.includes(String(k).toLowerCase()))) {
|
|
41
|
+
if (rule.field === 'durationMs') {
|
|
42
|
+
// Seconds-flavoured labels ("(s)", "秒", "seconds") -> report in seconds.
|
|
43
|
+
if (/\(s\)|秒|second/.test(m)) {
|
|
44
|
+
return { metricField: 'durationSec', lowerIsBetter: true, scoreUnit: 'seconds', recognized: true };
|
|
45
|
+
}
|
|
46
|
+
return { metricField: 'durationMs', lowerIsBetter: true, scoreUnit: 'ms', recognized: true };
|
|
47
|
+
}
|
|
48
|
+
return { metricField: rule.field, lowerIsBetter: rule.lowerIsBetter, scoreUnit: 'raw', recognized: true };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Unrecognized -> degrade to pass-rate (higher is better). Caller records a warning.
|
|
52
|
+
return { metricField: 'passRate', lowerIsBetter: false, scoreUnit: 'raw', recognized: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pull the scalar score for one arm given the resolved metric field.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} arm a normalized arm (see comparison.normalizeArm)
|
|
59
|
+
* @param {string} metricField
|
|
60
|
+
* @returns {number}
|
|
61
|
+
*/
|
|
62
|
+
function scoreArm(arm, metricField) {
|
|
63
|
+
if (!arm) return 0;
|
|
64
|
+
switch (metricField) {
|
|
65
|
+
case 'durationSec': return round(num(arm.durationMs) / 1000, 2);
|
|
66
|
+
case 'durationMs': return num(arm.durationMs);
|
|
67
|
+
case 'rounds': return num(arm.rounds);
|
|
68
|
+
case 'tokensTotal': return num(arm.tokensTotal);
|
|
69
|
+
case 'costUsd': return round(num(arm.costUsd), 4);
|
|
70
|
+
case 'passRate': return round(num(arm.passRate), 4);
|
|
71
|
+
default: return round(num(arm.passRate), 4);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { deriveMetric, scoreArm, METRIC_RULES, round, num };
|
package/src/forceUpdate.js
CHANGED
|
@@ -38,6 +38,173 @@ const FORCE_UPDATE_NOOP = Symbol('FORCE_UPDATE_NOOP');
|
|
|
38
38
|
// fire its own reportForceUpdateOutcome. See test/forceUpdateConcurrencyGuard.test.js.
|
|
39
39
|
const FORCE_UPDATE_BUSY = Symbol('FORCE_UPDATE_BUSY');
|
|
40
40
|
|
|
41
|
+
// Structured failure taxonomy. Historically every failing branch of
|
|
42
|
+
// _executeForceUpdateInner just `return false`, so the only thing that ever
|
|
43
|
+
// reached the hub (via reportForceUpdateOutcome) was the literal string
|
|
44
|
+
// "executeForceUpdate returned false" — degit-missing, tag-404, version
|
|
45
|
+
// mismatch and copy-EPERM were all indistinguishable in EvolverUpgradeAttempt.
|
|
46
|
+
// Each branch now returns _fail(code, detail); the reporter encodes it as
|
|
47
|
+
// `error = code + ': ' + detail`, so operators can GROUP BY the code prefix
|
|
48
|
+
// without any hub schema / DB migration. Codes are a small stable set — keep
|
|
49
|
+
// new ones coarse and additive so historical `error LIKE 'code%'` queries
|
|
50
|
+
// don't churn.
|
|
51
|
+
const FORCE_UPDATE_FAIL_CODES = Object.freeze({
|
|
52
|
+
INSTALL_GUARD_NAME_MISMATCH: 'install_guard_name_mismatch',
|
|
53
|
+
INSTALL_GUARD_UNREADABLE: 'install_guard_unreadable',
|
|
54
|
+
BAD_REQUIRED_VERSION: 'bad_required_version',
|
|
55
|
+
CURRENT_VERSION_UNPARSABLE: 'current_version_unparsable',
|
|
56
|
+
NPX_NOT_FOUND: 'npx_not_found',
|
|
57
|
+
DEGIT_TIMEOUT: 'degit_timeout',
|
|
58
|
+
DEGIT_FAILED: 'degit_failed',
|
|
59
|
+
DOWNLOAD_INCOMPLETE: 'download_incomplete',
|
|
60
|
+
DOWNLOADED_VERSION_MISMATCH: 'downloaded_version_mismatch',
|
|
61
|
+
DELETE_FAILED: 'delete_failed',
|
|
62
|
+
COPY_FAILED: 'copy_failed',
|
|
63
|
+
ALL_CHANNELS_EXHAUSTED: 'all_channels_exhausted',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Build the structured failure result that replaces a bare `return false`.
|
|
67
|
+
// Shape: { ok:false, code, detail }. Distinct from `true`, FORCE_UPDATE_NOOP
|
|
68
|
+
// and FORCE_UPDATE_BUSY, so the three call sites' `result === true` /
|
|
69
|
+
// `result === SENTINEL` checks keep classifying it as "failed" unchanged —
|
|
70
|
+
// this is backward compatible. Frozen so a downstream consumer cannot mutate
|
|
71
|
+
// the code/detail before it is reported. detail is best-effort context (an
|
|
72
|
+
// errno, a version delta, an entry name); it is redacted + truncated to
|
|
73
|
+
// ERROR_MAX by the reporter before it leaves the process.
|
|
74
|
+
function _fail(code, detail) {
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
ok: false,
|
|
77
|
+
code: String(code),
|
|
78
|
+
detail: detail == null ? '' : String(detail),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Compact "CODE: message" rendering of a thrown error for the detail field.
|
|
83
|
+
function _errStr(e) {
|
|
84
|
+
if (!e) return 'unknown';
|
|
85
|
+
var code = e.code ? String(e.code) + ': ' : '';
|
|
86
|
+
return code + (e.message != null ? String(e.message) : String(e));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map a Channel 1 (GitHub Release / degit) throw to a structured failure.
|
|
90
|
+
// `phase` records how far the try block got before throwing, so a readFileSync
|
|
91
|
+
// ENOENT (truncated download) is not misread as an npx ENOENT (npx missing):
|
|
92
|
+
// 'degit' -> the npx/degit spawn itself
|
|
93
|
+
// 'parse' -> degit exited 0 but the downloaded package.json is missing/invalid
|
|
94
|
+
// 'copy' -> the staged tree downloaded fine but cpSync into INSTALL_ROOT failed
|
|
95
|
+
function _classifyChannel1Error(e, phase) {
|
|
96
|
+
if (phase === 'delete') {
|
|
97
|
+
var deleteEntry = e && e._evolverEntry ? String(e._evolverEntry) + ': ' : '';
|
|
98
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.DELETE_FAILED, deleteEntry + _errStr(e));
|
|
99
|
+
}
|
|
100
|
+
if (phase === 'copy') {
|
|
101
|
+
var entry = e && e._evolverEntry ? String(e._evolverEntry) + ': ' : '';
|
|
102
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.COPY_FAILED, entry + _errStr(e));
|
|
103
|
+
}
|
|
104
|
+
if (phase === 'parse') {
|
|
105
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.DOWNLOAD_INCOMPLETE,
|
|
106
|
+
'missing/invalid package.json in downloaded tree: ' + _errStr(e));
|
|
107
|
+
}
|
|
108
|
+
// phase === 'degit' (the spawn). ENOENT here is the npx binary itself, not a
|
|
109
|
+
// file inside the download — that distinction is exactly why `phase` exists.
|
|
110
|
+
if (e && e.code === 'ENOENT') {
|
|
111
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.NPX_NOT_FOUND, _errStr(e));
|
|
112
|
+
}
|
|
113
|
+
// execFileSync timeout kills the child with SIGTERM (and sets .killed); some
|
|
114
|
+
// platforms surface ETIMEDOUT instead. Either way it is a 60s timeout.
|
|
115
|
+
if (e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT')) {
|
|
116
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.DEGIT_TIMEOUT,
|
|
117
|
+
'degit timed out after 60s' + (e.signal ? ' (signal=' + e.signal + ')' : ''));
|
|
118
|
+
}
|
|
119
|
+
// Generic degit/network/tag-not-found failure. degit prints the real reason
|
|
120
|
+
// ("could not find commit hash for v…", "could not resolve host") to stderr,
|
|
121
|
+
// so keep a tail of it. Redact + strip control chars HERE, before the tail
|
|
122
|
+
// slice: the downstream reporter redact (a2aProtocol.reportForceUpdateOutcome)
|
|
123
|
+
// runs after this, so slicing first could chop a token's prefix anchor and
|
|
124
|
+
// let the bare value slip past the prefix-anchored redact patterns. Stripping
|
|
125
|
+
// ANSI/NUL/newlines also keeps the persisted error free of terminal-injection
|
|
126
|
+
// sequences and log-line noise.
|
|
127
|
+
var detail = _errStr(e);
|
|
128
|
+
var stderr = '';
|
|
129
|
+
if (e && e.stderr != null) {
|
|
130
|
+
try {
|
|
131
|
+
var redactString = require('./gep/sanitize').redactString;
|
|
132
|
+
stderr = redactString(String(e.stderr)).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
|
133
|
+
} catch (_) {
|
|
134
|
+
// sanitize unavailable — still strip control chars so logs stay clean.
|
|
135
|
+
stderr = String(e.stderr).replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (stderr) detail += ' | stderr=' + stderr.slice(-300);
|
|
139
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.DEGIT_FAILED, detail);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _isRetryableFsLockError(e) {
|
|
143
|
+
var code = e && e.code;
|
|
144
|
+
return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES' ||
|
|
145
|
+
code === 'ENOTEMPTY' || code === 'EMFILE' || code === 'ENFILE';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _waitForFsLockRetry() {
|
|
149
|
+
var until = Date.now() + 200;
|
|
150
|
+
while (Date.now() < until) { /* spin */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _retryFsLockOperation(fn) {
|
|
154
|
+
var err = null;
|
|
155
|
+
for (var attempt = 0; attempt < 3; attempt++) {
|
|
156
|
+
try {
|
|
157
|
+
return fn();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
err = e;
|
|
160
|
+
if (!_isRetryableFsLockError(e)) break;
|
|
161
|
+
if (attempt < 2) _waitForFsLockRetry();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _recoverPackageCommitMarkerIfMissing(installRoot) {
|
|
168
|
+
var pkgDst = path.join(installRoot, 'package.json');
|
|
169
|
+
if (fs.existsSync(pkgDst)) return false;
|
|
170
|
+
var entries;
|
|
171
|
+
try {
|
|
172
|
+
entries = fs.readdirSync(installRoot);
|
|
173
|
+
} catch (_) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
var backups = entries
|
|
177
|
+
.filter(function (name) { return /^package\.json\.\d+\.evolver-old$/.test(name); })
|
|
178
|
+
.sort();
|
|
179
|
+
for (var i = backups.length - 1; i >= 0; i--) {
|
|
180
|
+
try {
|
|
181
|
+
fs.renameSync(path.join(installRoot, backups[i]), pkgDst);
|
|
182
|
+
console.warn('[ForceUpdate] Recovered package.json commit marker from ' + backups[i]);
|
|
183
|
+
return true;
|
|
184
|
+
} catch (_) {}
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _restorePackageBackup(pkgBackup, pkgDst) {
|
|
190
|
+
if (!fs.existsSync(pkgBackup)) return false;
|
|
191
|
+
if (fs.existsSync(pkgDst)) {
|
|
192
|
+
try { fs.rmSync(pkgBackup, { force: true }); } catch (_) {}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
fs.renameSync(pkgBackup, pkgDst);
|
|
197
|
+
return true;
|
|
198
|
+
} catch (_) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _isForceUpdateKeepEntry(name) {
|
|
204
|
+
return name === 'node_modules' || name === 'memory' || name === '.git' || name === 'MEMORY.md' ||
|
|
205
|
+
name === '.env' || name === '.env.local' || name === 'USER.md' || name === '.evolver';
|
|
206
|
+
}
|
|
207
|
+
|
|
41
208
|
// Module-level mutex: shared by every caller that requires('../forceUpdate'),
|
|
42
209
|
// so the heartbeat-thread trigger in a2aProtocol.js and the evolve-tick path
|
|
43
210
|
// in enrich/pipeline cannot run executeForceUpdate concurrently. This is a
|
|
@@ -163,12 +330,33 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
163
330
|
console.warn('[ForceUpdate] Refusing — ' + INSTALL_ROOT +
|
|
164
331
|
'/package.json has name="' + (pkg && pkg.name) +
|
|
165
332
|
'", expected "@evomap/evolver". Aborting to avoid data loss.');
|
|
166
|
-
return
|
|
333
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_NAME_MISMATCH,
|
|
334
|
+
'install root package.json name="' + (pkg && pkg.name) + '", expected "@evomap/evolver"');
|
|
167
335
|
}
|
|
168
336
|
} catch (e) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
337
|
+
if (_recoverPackageCommitMarkerIfMissing(INSTALL_ROOT)) {
|
|
338
|
+
try {
|
|
339
|
+
const recoveredPkg = JSON.parse(fs.readFileSync(path.join(INSTALL_ROOT, 'package.json'), 'utf8'));
|
|
340
|
+
if (!recoveredPkg || (recoveredPkg.name !== '@evomap/evolver' && recoveredPkg.name !== 'evolver')) {
|
|
341
|
+
console.warn('[ForceUpdate] Refusing — recovered ' + INSTALL_ROOT +
|
|
342
|
+
'/package.json has name="' + (recoveredPkg && recoveredPkg.name) +
|
|
343
|
+
'", expected "@evomap/evolver". Aborting to avoid data loss.');
|
|
344
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_NAME_MISMATCH,
|
|
345
|
+
'recovered install root package.json name="' + (recoveredPkg && recoveredPkg.name) +
|
|
346
|
+
'", expected "@evomap/evolver"');
|
|
347
|
+
}
|
|
348
|
+
} catch (recoverReadErr) {
|
|
349
|
+
console.warn('[ForceUpdate] Refusing — cannot read recovered ' + INSTALL_ROOT +
|
|
350
|
+
'/package.json: ' + (recoverReadErr && recoverReadErr.message || recoverReadErr));
|
|
351
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_UNREADABLE,
|
|
352
|
+
'cannot read recovered install root package.json: ' + _errStr(recoverReadErr));
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
console.warn('[ForceUpdate] Refusing — cannot read ' + INSTALL_ROOT +
|
|
356
|
+
'/package.json: ' + (e && e.message || e));
|
|
357
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.INSTALL_GUARD_UNREADABLE,
|
|
358
|
+
'cannot read install root package.json: ' + _errStr(e));
|
|
359
|
+
}
|
|
172
360
|
}
|
|
173
361
|
|
|
174
362
|
const requiredVersion = normalizeRequiredVersion(forceUpdate.required_version);
|
|
@@ -176,7 +364,8 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
176
364
|
console.warn('[ForceUpdate] Refusing — required_version "' +
|
|
177
365
|
String(forceUpdate.required_version || '').replace(/^[>=^~\s]+/, '') +
|
|
178
366
|
'" is not a concrete semver (ranges not accepted).');
|
|
179
|
-
return
|
|
367
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.BAD_REQUIRED_VERSION,
|
|
368
|
+
'required_version=' + JSON.stringify(forceUpdate && forceUpdate.required_version) + ' is not a concrete semver');
|
|
180
369
|
}
|
|
181
370
|
|
|
182
371
|
function getCurrentVersion() {
|
|
@@ -207,7 +396,8 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
207
396
|
if (versionCmp === null) {
|
|
208
397
|
console.warn('[ForceUpdate] Refusing — current installed version "' +
|
|
209
398
|
currentVersion + '" is not a concrete semver.');
|
|
210
|
-
return
|
|
399
|
+
return _fail(FORCE_UPDATE_FAIL_CODES.CURRENT_VERSION_UNPARSABLE,
|
|
400
|
+
'current installed version "' + currentVersion + '" is not a concrete semver');
|
|
211
401
|
}
|
|
212
402
|
if (versionCmp >= 0) {
|
|
213
403
|
console.log('[ForceUpdate] already satisfies required version, no-op (current=' +
|
|
@@ -230,6 +420,14 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
230
420
|
const TMP_TARGET = fs.mkdtempSync(path.join(os.tmpdir(), '.evolver-update-tmp-'));
|
|
231
421
|
|
|
232
422
|
// Channel 1: GitHub Release (via degit pinned to exact version tag)
|
|
423
|
+
//
|
|
424
|
+
// channel1Failure captures the structured reason this channel failed, so the
|
|
425
|
+
// terminal `return` can surface it instead of a bare `false`. `phase` tracks
|
|
426
|
+
// how far we got before any throw, so _classifyChannel1Error can tell a
|
|
427
|
+
// degit-spawn failure (phase 'degit') from a truncated download (phase
|
|
428
|
+
// 'parse') from a delete/copy-into-INSTALL_ROOT failure.
|
|
429
|
+
var channel1Failure = null;
|
|
430
|
+
var phase = 'degit';
|
|
233
431
|
try {
|
|
234
432
|
console.log('[ForceUpdate] Channel 1: GitHub Release download (v' + requiredVersion + ')...');
|
|
235
433
|
var npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
@@ -240,6 +438,7 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
240
438
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
241
439
|
timeout: 60000, windowsHide: true, maxBuffer: MAX_EXEC_BUFFER,
|
|
242
440
|
});
|
|
441
|
+
phase = 'parse';
|
|
243
442
|
var tmpPkg = JSON.parse(fs.readFileSync(path.join(TMP_TARGET, 'package.json'), 'utf8'));
|
|
244
443
|
// Require exact version match — a ">=" check would allow a compromised hub to
|
|
245
444
|
// request version "0.0.1" and install any version including unreleased HEAD code.
|
|
@@ -247,44 +446,110 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
247
446
|
var entries = fs.readdirSync(INSTALL_ROOT, { withFileTypes: true });
|
|
248
447
|
for (var ei = 0; ei < entries.length; ei++) {
|
|
249
448
|
var eName = entries[ei].name;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
449
|
+
// package.json is the install's commit marker: keep the OLD one in
|
|
450
|
+
// place through the entire delete+copy below and swap in the new one
|
|
451
|
+
// atomically at the very end (see "commit marker" block). If it were
|
|
452
|
+
// deleted here and any later cpSync threw (ENOSPC, a Windows lock that
|
|
453
|
+
// outlasts the retries, a kill), the install root would be left with
|
|
454
|
+
// no package.json — and the install-guard at the top of this function
|
|
455
|
+
// refuses on an unreadable package.json, wedging the node in
|
|
456
|
+
// install_guard_unreadable on every subsequent attempt with no path
|
|
457
|
+
// that ever re-copies it. Deferring it keeps the install self-healing.
|
|
458
|
+
if (_isForceUpdateKeepEntry(eName) || eName === 'package.json') continue;
|
|
459
|
+
try {
|
|
460
|
+
(function (entryName) {
|
|
461
|
+
phase = 'delete';
|
|
462
|
+
_retryFsLockOperation(function () {
|
|
463
|
+
fs.rmSync(path.join(INSTALL_ROOT, entryName), {
|
|
464
|
+
recursive: true, force: true, maxRetries: 3, retryDelay: 200,
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
})(eName);
|
|
468
|
+
} catch (rmErr) {
|
|
469
|
+
console.warn('[ForceUpdate] rmSync failed for ' + eName + ': ' + (rmErr.message || rmErr));
|
|
470
|
+
try { rmErr._evolverEntry = eName; } catch (_) {}
|
|
471
|
+
throw rmErr;
|
|
472
|
+
}
|
|
253
473
|
}
|
|
474
|
+
phase = 'copy';
|
|
254
475
|
var newEntries = fs.readdirSync(TMP_TARGET, { withFileTypes: true });
|
|
255
476
|
for (var ni = 0; ni < newEntries.length; ni++) {
|
|
477
|
+
// Deferred: package.json is the commit marker, written last after every
|
|
478
|
+
// other entry has copied successfully (see below). Keep-list entries are
|
|
479
|
+
// local state and must not be overwritten by the downloaded release.
|
|
480
|
+
if (newEntries[ni].name === 'package.json' || _isForceUpdateKeepEntry(newEntries[ni].name)) continue;
|
|
256
481
|
var src = path.join(TMP_TARGET, newEntries[ni].name);
|
|
257
482
|
var dst = path.join(INSTALL_ROOT, newEntries[ni].name);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
break;
|
|
266
|
-
} catch (cpErr) {
|
|
267
|
-
copyErr = cpErr;
|
|
268
|
-
var code = cpErr && cpErr.code;
|
|
269
|
-
if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES') break;
|
|
270
|
-
// Brief busy-wait — execFileSync has already blocked the event loop,
|
|
271
|
-
// so a synchronous spin is acceptable here.
|
|
272
|
-
var until = Date.now() + 200;
|
|
273
|
-
while (Date.now() < until) { /* spin */ }
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (copyErr) {
|
|
483
|
+
try {
|
|
484
|
+
(function (copySrc, copyDst) {
|
|
485
|
+
_retryFsLockOperation(function () {
|
|
486
|
+
fs.cpSync(copySrc, copyDst, { recursive: true });
|
|
487
|
+
});
|
|
488
|
+
})(src, dst);
|
|
489
|
+
} catch (copyErr) {
|
|
277
490
|
console.warn('[ForceUpdate] cpSync failed for ' + newEntries[ni].name + ': ' + (copyErr.message || copyErr));
|
|
491
|
+
// Tag the failing entry so _classifyChannel1Error can name it in the
|
|
492
|
+
// copy_failed detail. phase is already 'copy' here.
|
|
493
|
+
try { copyErr._evolverEntry = newEntries[ni].name; } catch (_) {}
|
|
278
494
|
throw copyErr;
|
|
279
495
|
}
|
|
280
496
|
}
|
|
497
|
+
// Commit marker: every other entry copied successfully, so swap in the
|
|
498
|
+
// new package.json LAST. POSIX gets an atomic rename-over-existing. Windows
|
|
499
|
+
// cannot rename over an existing destination, so it first renames the old
|
|
500
|
+
// package.json to a recoverable same-directory backup; the install guard
|
|
501
|
+
// restores that backup on the next tick if the process dies mid-commit.
|
|
502
|
+
var pkgSrc = path.join(TMP_TARGET, 'package.json');
|
|
503
|
+
var pkgDst = path.join(INSTALL_ROOT, 'package.json');
|
|
504
|
+
var pkgTmp = pkgDst + '.' + process.pid + '.evolver-tmp';
|
|
505
|
+
var pkgBackup = pkgDst + '.' + process.pid + '.evolver-old';
|
|
506
|
+
try {
|
|
507
|
+
_retryFsLockOperation(function () {
|
|
508
|
+
try { fs.rmSync(pkgTmp, { force: true }); } catch (_) {}
|
|
509
|
+
fs.cpSync(pkgSrc, pkgTmp);
|
|
510
|
+
if (process.platform === 'win32') {
|
|
511
|
+
if (!fs.existsSync(pkgDst)) _recoverPackageCommitMarkerIfMissing(INSTALL_ROOT);
|
|
512
|
+
try { fs.rmSync(pkgBackup, { force: true }); } catch (_) {}
|
|
513
|
+
fs.renameSync(pkgDst, pkgBackup);
|
|
514
|
+
try {
|
|
515
|
+
fs.renameSync(pkgTmp, pkgDst);
|
|
516
|
+
} catch (commitErr) {
|
|
517
|
+
_restorePackageBackup(pkgBackup, pkgDst);
|
|
518
|
+
throw commitErr;
|
|
519
|
+
}
|
|
520
|
+
try { fs.rmSync(pkgBackup, { force: true }); } catch (_) {}
|
|
521
|
+
} else {
|
|
522
|
+
fs.renameSync(pkgTmp, pkgDst);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
} catch (pkgErr) {
|
|
526
|
+
_restorePackageBackup(pkgBackup, pkgDst);
|
|
527
|
+
try { fs.rmSync(pkgTmp, { force: true }); } catch (_) {}
|
|
528
|
+
console.warn('[ForceUpdate] package.json commit (atomic replace) failed: ' + (pkgErr.message || pkgErr));
|
|
529
|
+
try { pkgErr._evolverEntry = 'package.json commit'; } catch (_) {}
|
|
530
|
+
throw pkgErr;
|
|
531
|
+
}
|
|
281
532
|
try { fs.rmSync(TMP_TARGET, { recursive: true, force: true }); } catch (_) {}
|
|
282
533
|
console.log('[ForceUpdate] GitHub Release update successful: ' + tmpPkg.version);
|
|
283
534
|
return true;
|
|
284
535
|
}
|
|
536
|
+
// degit succeeded and produced a parseable package.json, but it did not
|
|
537
|
+
// satisfy the exact-version check above. Two distinct causes, two codes:
|
|
538
|
+
if (!tmpPkg.version) {
|
|
539
|
+
// degit produced a parseable package.json with no version field — a
|
|
540
|
+
// malformed/incomplete download, not a stale/tampered tag mismatch.
|
|
541
|
+
channel1Failure = _fail(FORCE_UPDATE_FAIL_CODES.DOWNLOAD_INCOMPLETE,
|
|
542
|
+
'downloaded package.json has no version field');
|
|
543
|
+
} else {
|
|
544
|
+
// version present but not the exact tag we asked for (stale tag, mirror
|
|
545
|
+
// lag, or a tampered/redirected tag). Refuse and record the delta.
|
|
546
|
+
channel1Failure = _fail(FORCE_UPDATE_FAIL_CODES.DOWNLOADED_VERSION_MISMATCH,
|
|
547
|
+
'downloaded version=' + JSON.stringify(tmpPkg.version) + ', expected ' + requiredVersion);
|
|
548
|
+
}
|
|
285
549
|
try { fs.rmSync(TMP_TARGET, { recursive: true, force: true }); } catch (_) {}
|
|
286
550
|
} catch (e) {
|
|
287
|
-
|
|
551
|
+
channel1Failure = _classifyChannel1Error(e, phase);
|
|
552
|
+
console.warn('[ForceUpdate] GitHub Release failed (' + channel1Failure.code + '):', e && e.message || e);
|
|
288
553
|
try { fs.rmSync(TMP_TARGET, { recursive: true, force: true }); } catch (_) {}
|
|
289
554
|
// Fall through to Channel 2 (manual download URL hint) instead of
|
|
290
555
|
// returning. A Channel 1 error (degit missing, network down, tag not
|
|
@@ -301,7 +566,12 @@ function _executeForceUpdateInner(forceUpdate) {
|
|
|
301
566
|
} catch (_) {}
|
|
302
567
|
|
|
303
568
|
console.warn('[ForceUpdate] All automatic channels exhausted. Current version: ' + getCurrentVersion());
|
|
304
|
-
|
|
569
|
+
// Surface the concrete Channel 1 failure when we have one (the common case:
|
|
570
|
+
// degit/network/copy/version-mismatch). channel1Failure is null only when
|
|
571
|
+
// Channel 1 was never entered, which cannot happen here — but fall back to a
|
|
572
|
+
// terminal code so the reporter never lands on the legacy "returned false".
|
|
573
|
+
return channel1Failure || _fail(FORCE_UPDATE_FAIL_CODES.ALL_CHANNELS_EXHAUSTED,
|
|
574
|
+
'no automatic channel succeeded; current=' + getCurrentVersion() + ' target=' + requiredVersion);
|
|
305
575
|
}
|
|
306
576
|
|
|
307
577
|
// Test-only hook: re-implements the EXACT same operator-strip + semver
|
|
@@ -318,10 +588,21 @@ function _isAcceptedRequiredVersionForTesting(raw) {
|
|
|
318
588
|
return normalizeRequiredVersion(raw) !== '';
|
|
319
589
|
}
|
|
320
590
|
|
|
591
|
+
// Type guard: is `result` a structured failure (vs true / NOOP / BUSY)?
|
|
592
|
+
// Call sites use this to decide whether to forward result as opts.failure to
|
|
593
|
+
// reportForceUpdateOutcome. Kept tiny and dependency-free so all three
|
|
594
|
+
// duplicated triggers (a2aProtocol heartbeat, proxy manager, enrich tick) can
|
|
595
|
+
// share one definition.
|
|
596
|
+
function isForceUpdateFailure(result) {
|
|
597
|
+
return !!result && typeof result === 'object' && result.ok === false && typeof result.code === 'string';
|
|
598
|
+
}
|
|
599
|
+
|
|
321
600
|
module.exports = {
|
|
322
601
|
executeForceUpdate,
|
|
323
602
|
FORCE_UPDATE_NOOP,
|
|
324
603
|
FORCE_UPDATE_BUSY,
|
|
604
|
+
FORCE_UPDATE_FAIL_CODES,
|
|
605
|
+
isForceUpdateFailure,
|
|
325
606
|
// Test-only hook: reset the in-flight mutex so unit tests do not leak state
|
|
326
607
|
// across cases. Production callers must NOT touch this -- the mutex is the
|
|
327
608
|
// load-bearing invariant that prevents concurrent state-file writes.
|