@baize-ai/core 0.3.13 → 0.3.15
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/.dockerignore +0 -2
- package/CHANGELOG.md +18 -0
- package/Dockerfile +0 -13
- package/cli/commands/add.js +3 -0
- package/cli/commands/component.js +289 -40
- package/cli/commands/init.js +43 -0
- package/cli/lib/upgrade.js +55 -15
- package/docker/entrypoint.sh +4 -11
- package/docker-compose.yml +20 -2
- package/package.json +3 -3
- package/scripts/build-push-acr.sh +4 -2
- package/scripts/docker-publish.sh +4 -11
- package/skills/web-console/public/app.js +38 -8
- package/skills/web-console/public/index.html +1 -1
- package/skills/web-console/scripts/a2a-admin.js +225 -26
- package/skills/web-console/scripts/server.js +16 -8
- package/templates/pm2/ecosystem.config.cjs +6 -0
- package/test/channel-admin.test.js +7 -5
- package/test/helpers/run-upgrade-file-driver.mjs +15 -0
- package/test/upgrade-file.test.js +229 -0
- package/test/upgrade-local-version.test.js +70 -0
- package/test/upgrade-restart-hook.test.js +129 -0
- package/test/web-console-routes.test.js +83 -4
package/docker/entrypoint.sh
CHANGED
|
@@ -94,18 +94,9 @@ if ! baize init ${INIT_ARGS}; then
|
|
|
94
94
|
fi
|
|
95
95
|
ok "Workspace ready"
|
|
96
96
|
mkdir -p "${BAIZE_DIR}/.baize"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
# /home/baize/.local-skills. syncSkills (npm postinstall) runs during init
|
|
100
|
-
# with the npm-package skills as source, so apply the local override AFTER
|
|
101
|
-
# init to keep un-released fixes (e.g. D44) authoritative.
|
|
102
|
-
# Official images ship an empty .local-skills — this is a no-op there.
|
|
103
|
-
if [ -d /home/baize/.local-skills ]; then
|
|
104
|
-
cp -rf /home/baize/.local-skills/. "${BAIZE_DIR}/.claude/skills/" 2>/dev/null \
|
|
105
|
-
&& ok "Local-source skills applied (BAIZE_LOCAL_SKILLS=1)"
|
|
106
|
-
fi
|
|
97
|
+
printf '{"status":"ok","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
|
|
98
|
+
|
|
107
99
|
|
|
108
|
-
# ── Pass through channel env vars to .env ─────────────────────────────────────
|
|
109
100
|
|
|
110
101
|
# ── Pass through channel env vars to .env ─────────────────────────────────────
|
|
111
102
|
# baize init doesn't write channel tokens — those come from component installs.
|
|
@@ -161,6 +152,8 @@ PM2_PID=$!
|
|
|
161
152
|
sleep 3
|
|
162
153
|
ok "Services started"
|
|
163
154
|
|
|
155
|
+
# ── Step 4: Start the configured agent runtime in tmux ───────────────────────
|
|
156
|
+
|
|
164
157
|
# ── Step 4: Start the configured agent runtime in tmux ───────────────────────
|
|
165
158
|
# Determine runtime: BAIZE_RUNTIME env var always wins; fall back to config.json.
|
|
166
159
|
if [ -z "${BAIZE_RUNTIME:-}" ]; then
|
package/docker-compose.yml
CHANGED
|
@@ -57,9 +57,27 @@ services:
|
|
|
57
57
|
|
|
58
58
|
# ── Health ────────────────────────────────────────────────────────────────
|
|
59
59
|
healthcheck:
|
|
60
|
-
test:
|
|
60
|
+
test:
|
|
61
|
+
- CMD
|
|
62
|
+
- node
|
|
63
|
+
- -e
|
|
64
|
+
- |
|
|
65
|
+
const fs = require('fs'), net = require('net');
|
|
66
|
+
// Core health = pm2 reports online. A2A (commercial component, D49):
|
|
67
|
+
// when installed and enabled, the container is only healthy if its
|
|
68
|
+
// daemon actually LISTENS (pm2 status alone masked the KI-015
|
|
69
|
+
// silent-idle shape); otherwise the check is skipped.
|
|
70
|
+
try { require('child_process').execSync('pm2 list', { stdio: 'ignore' }); } catch { process.exit(1); }
|
|
71
|
+
try {
|
|
72
|
+
const cfg = JSON.parse(fs.readFileSync('/home/baize/baize/components/a2a/config.json', 'utf8'));
|
|
73
|
+
if (cfg.enabled !== true) process.exit(0);
|
|
74
|
+
const s = net.connect({ host: '127.0.0.1', port: cfg.listenPort || 8443, timeout: 3000 });
|
|
75
|
+
s.on('connect', () => { s.destroy(); process.exit(0); });
|
|
76
|
+
s.on('error', () => process.exit(1));
|
|
77
|
+
s.on('timeout', () => { s.destroy(); process.exit(1); });
|
|
78
|
+
} catch { process.exit(0); }
|
|
61
79
|
interval: 30s
|
|
62
|
-
timeout:
|
|
80
|
+
timeout: 15s
|
|
63
81
|
retries: 3
|
|
64
82
|
start_period: 600s
|
|
65
83
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baize-ai/core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Baize (
|
|
5
|
+
"description": "Baize (白泽) — autonomous AI agent infrastructure",
|
|
6
6
|
"main": "cli/baize.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"baize": "./cli/baize.js"
|
|
@@ -46,4 +46,4 @@
|
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public"
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Multi-platform build + push to Aliyun ACR for the mainland distribution.
|
|
3
|
-
# Usage: bash build-push-acr.sh [VERSION] (default
|
|
3
|
+
# Usage: bash build-push-acr.sh [VERSION] (default = latest published)
|
|
4
|
+
# IMPORTANT: bump this default when a new version is published — it must
|
|
5
|
+
# track @baize-ai/core latest (never point at an un-released version).
|
|
4
6
|
# Requires: docker login already done, qemu-arm64 registered
|
|
5
7
|
# (apt install qemu-user-binfmt), registry mirror configured.
|
|
6
8
|
set -euo pipefail
|
|
7
9
|
|
|
8
|
-
VERSION="${1:-0.3.
|
|
10
|
+
VERSION="${1:-0.3.13}"
|
|
9
11
|
ACR="crpi-8bg62qbrjs3cr3as.cn-hangzhou.personal.cr.aliyuncs.com/baize01/baize-core"
|
|
10
12
|
|
|
11
13
|
echo "== Configure buildkit registry mirror (mainland Docker Hub access) =="
|
|
@@ -30,22 +30,15 @@ ACR_NAMESPACE="${ACR_NAMESPACE:-baize01}"
|
|
|
30
30
|
|
|
31
31
|
# ── Build ────────────────────────────────────────────────────────────────────
|
|
32
32
|
echo "==> Building baize-core:${VERSION} (from npm @baize-ai/core@${VERSION})"
|
|
33
|
-
BUILD_ARGS=(--build-arg "BAZE_CORE_VERSION=${VERSION}")
|
|
34
|
-
rm -rf .docker-local-skills && mkdir -p .docker-local-skills
|
|
35
|
-
if [ "${BAIZE_LOCAL_SKILLS:-0}" = "1" ]; then
|
|
36
|
-
BUILD_ARGS+=(--build-arg BAIZE_LOCAL_SKILLS=1)
|
|
37
|
-
echo "==> Overriding skills with LOCAL repo source (BAIZE_LOCAL_SKILLS=1) — un-released fixes baked in"
|
|
38
|
-
cp -r skills/. .docker-local-skills/
|
|
39
|
-
# Never ship host-compiled node_modules (mac binaries break linux runtime) —
|
|
40
|
-
# dependency binaries come from the image's own npm install.
|
|
41
|
-
find .docker-local-skills -type d -name node_modules -prune -exec rm -rf {} +
|
|
42
|
-
fi
|
|
43
33
|
docker build \
|
|
34
|
+
--build-arg BAZE_CORE_VERSION="${VERSION}" \
|
|
35
|
+
-t "${GHCR_IMAGE}:${VERSION}" \
|
|
36
|
+
-t "${GHCR_IMAGE}:latest" \
|
|
37
|
+
.
|
|
44
38
|
"${BUILD_ARGS[@]}" \
|
|
45
39
|
-t "${GHCR_IMAGE}:${VERSION}" \
|
|
46
40
|
-t "${GHCR_IMAGE}:latest" \
|
|
47
41
|
.
|
|
48
|
-
rm -rf .docker-local-skills
|
|
49
42
|
|
|
50
43
|
if [ -n "${ACR_REGISTRY}" ] && [ -n "${ACR_NAMESPACE}" ]; then
|
|
51
44
|
ACR_IMAGE="${ACR_REGISTRY}/${ACR_NAMESPACE}/baize-core"
|
|
@@ -1655,6 +1655,19 @@ async function loadA2aStatus(basePath) {
|
|
|
1655
1655
|
state.textContent = enabled ? '已启用' : '未启用';
|
|
1656
1656
|
state.className = `channel-state ${enabled ? 'ok' : 'warn'}`;
|
|
1657
1657
|
}
|
|
1658
|
+
// D48: certificate health — the daemon self-heals (re-registers) when the
|
|
1659
|
+
// admin rotated its CA. Only claim "auto-healing" when the daemon is
|
|
1660
|
+
// actually alive to run the 5-min renewal check; otherwise the user must
|
|
1661
|
+
// act (daemon down / renewals exhausted).
|
|
1662
|
+
const certValid = body.certValid === true;
|
|
1663
|
+
const daemonUp = body.daemonHealthy === true;
|
|
1664
|
+
let certLabel = '正常';
|
|
1665
|
+
if (!certValid) {
|
|
1666
|
+
certLabel = daemonUp ? '需续签(自动处理中)' : '证书待续签(daemon 未运行)';
|
|
1667
|
+
}
|
|
1668
|
+
const certRow = body.enabled === true
|
|
1669
|
+
? `<div class="conn-summary-row"><span class="conn-summary-name">证书状态</span><span class="conn-summary-detail"><span class="channel-state ${certValid ? 'ok' : 'warn'}">${certLabel}</span></span></div>`
|
|
1670
|
+
: '';
|
|
1658
1671
|
const rows = [
|
|
1659
1672
|
['启用', enabled ? '是' : '否'],
|
|
1660
1673
|
['Agent ID', body.agentId],
|
|
@@ -1664,7 +1677,7 @@ async function loadA2aStatus(basePath) {
|
|
|
1664
1677
|
['Endpoint', body.endpoint],
|
|
1665
1678
|
['版本', body.version],
|
|
1666
1679
|
].filter(([, v]) => v !== undefined && v !== null && v !== '');
|
|
1667
|
-
el.innerHTML = rows.map(([k, v]) => `<div class="conn-summary-row"><span class="conn-summary-name">${escapeHtml(k)}</span><span class="conn-summary-detail">${escapeHtml(String(v))}</span></div>`).join('');
|
|
1680
|
+
el.innerHTML = rows.map(([k, v]) => `<div class="conn-summary-row"><span class="conn-summary-name">${escapeHtml(k)}</span><span class="conn-summary-detail">${escapeHtml(String(v))}</span></div>`).join('') + certRow;
|
|
1668
1681
|
updateA2aInstallArea(body);
|
|
1669
1682
|
updateA2aModeCard(body);
|
|
1670
1683
|
updateA2aConfigArea(body);
|
|
@@ -1706,10 +1719,10 @@ function updateA2aInstallArea(body) {
|
|
|
1706
1719
|
if (!el || !fileInput || !btn) return;
|
|
1707
1720
|
const version = body && (body.version || body.installedVersion);
|
|
1708
1721
|
if (version) {
|
|
1709
|
-
el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')}
|
|
1722
|
+
el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')} — 上传更高版本的模块包(.tgz)可在线升级(相同或更低版本将被拒绝)。`;
|
|
1710
1723
|
if (state) { state.textContent = '已安装'; state.className = 'channel-state ok'; }
|
|
1711
|
-
fileInput.disabled =
|
|
1712
|
-
btn.disabled =
|
|
1724
|
+
fileInput.disabled = false;
|
|
1725
|
+
btn.disabled = false;
|
|
1713
1726
|
} else {
|
|
1714
1727
|
el.textContent = '未安装 — 上传 baize-a2a 模块包(.tgz)完成安装。';
|
|
1715
1728
|
if (state) { state.textContent = '未安装'; state.className = 'channel-state warn'; }
|
|
@@ -1769,11 +1782,16 @@ function updateA2aHealth(body, peerCountOverride) {
|
|
|
1769
1782
|
} else if (_lastA2aPeerCount !== null) {
|
|
1770
1783
|
peerCount = _lastA2aPeerCount;
|
|
1771
1784
|
}
|
|
1785
|
+
const bs = body && body.bootstrap ? body.bootstrap : null;
|
|
1786
|
+
const bsLabel = !bs ? '' : (bs.status === 'ok' || bs.status === 'launched'
|
|
1787
|
+
? `启动中/已启动(${escapeHtml(String(bs.via || bs.status))})`
|
|
1788
|
+
: `失败:${escapeHtml(String(bs.error || bs.status))}`);
|
|
1772
1789
|
const rows = [
|
|
1773
1790
|
['Daemon 健康', healthy ? '✅ 正常' : '❌ 异常'],
|
|
1774
1791
|
['运行模式', modeLabel],
|
|
1775
1792
|
['Peer 数', peerCount],
|
|
1776
1793
|
['最近心跳', a2aHeartbeatLabel(body)],
|
|
1794
|
+
...(bsLabel ? [['init 自启动', bsLabel]] : []),
|
|
1777
1795
|
].filter(([, v]) => v !== undefined && v !== null && v !== '');
|
|
1778
1796
|
el.innerHTML = rows.map(([k, v]) => `<div class="conn-summary-row"><span class="conn-summary-name">${escapeHtml(k)}</span><span class="conn-summary-detail">${escapeHtml(String(v))}</span></div>`).join('');
|
|
1779
1797
|
if (state) {
|
|
@@ -2269,7 +2287,7 @@ async function submitA2aInstall(basePath, btn) {
|
|
|
2269
2287
|
}
|
|
2270
2288
|
const original = btn.textContent;
|
|
2271
2289
|
btn.disabled = true;
|
|
2272
|
-
btn.textContent = '
|
|
2290
|
+
btn.textContent = '上传处理中...';
|
|
2273
2291
|
feedback.textContent = '';
|
|
2274
2292
|
feedback.className = '';
|
|
2275
2293
|
const fd = new FormData();
|
|
@@ -2278,11 +2296,23 @@ async function submitA2aInstall(basePath, btn) {
|
|
|
2278
2296
|
const { status, body } = await adminFetch(basePath, '/api/admin/a2a/install', { method: 'POST', body: fd });
|
|
2279
2297
|
if (body.ok === true || body.success === true) {
|
|
2280
2298
|
const version = body.version ? ` v${String(body.version).replace(/^v/, '')}` : '';
|
|
2281
|
-
|
|
2299
|
+
const verb = body.upgraded ? '升级' : '安装';
|
|
2300
|
+
let okMsg = `✅ ${verb}成功${version}`;
|
|
2301
|
+
if (body.upgraded && body.restarted === false) {
|
|
2302
|
+
okMsg += `(⚠️ daemon 重启失败:${body.restartError || '未知原因'},请检查状态卡或手动重启)`;
|
|
2303
|
+
}
|
|
2304
|
+
feedback.textContent = okMsg;
|
|
2282
2305
|
feedback.className = 'a2a-feedback-ok';
|
|
2283
2306
|
fileInput.value = '';
|
|
2284
|
-
setAdminMsg(`✅ baize-a2a
|
|
2285
|
-
loadA2aStatus(basePath); //
|
|
2307
|
+
setAdminMsg(`✅ baize-a2a ${verb}成功${version}`);
|
|
2308
|
+
loadA2aStatus(basePath); // 刷新版本/模式/健康/安装卡
|
|
2309
|
+
} else if (body.error === 'version_not_higher') {
|
|
2310
|
+
const local = body.local ? `v${String(body.local).replace(/^v/, '')}` : '未知';
|
|
2311
|
+
const incoming = body.incoming ? `v${String(body.incoming).replace(/^v/, '')}` : '未知';
|
|
2312
|
+
const msg = `上传版本 ${incoming} 不高于已安装版本 ${local},未执行升级`;
|
|
2313
|
+
feedback.textContent = `❌ 升级被拒绝:${msg}`;
|
|
2314
|
+
feedback.className = 'a2a-feedback-error';
|
|
2315
|
+
setAdminMsg(`❌ 升级被拒绝:${msg}`, true);
|
|
2286
2316
|
} else {
|
|
2287
2317
|
const msg = body.error || body.message || `HTTP ${status}`;
|
|
2288
2318
|
feedback.textContent = `❌ 安装失败:${msg}`;
|
|
@@ -323,7 +323,7 @@
|
|
|
323
323
|
<div class="cred-state" id="a2a-install-status" role="status" aria-live="polite">加载中...</div>
|
|
324
324
|
<form id="form-a2a-install" class="settings-form">
|
|
325
325
|
<input type="file" id="a2a-install-file" accept=".tgz,.tar.gz,.zip" aria-label="选择 baize-a2a 模块包">
|
|
326
|
-
<button type="submit" class="btn btn-primary" id="btn-a2a-install"
|
|
326
|
+
<button type="submit" class="btn btn-primary" id="btn-a2a-install">上传安装/升级</button>
|
|
327
327
|
</form>
|
|
328
328
|
<div class="cred-state" id="a2a-install-feedback" role="status" aria-live="polite"></div>
|
|
329
329
|
</div>
|
|
@@ -362,63 +362,76 @@ function rebuildNativeDeps(cwd) {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/**
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* manifest check (name = @baize-ai/baize-a2a, version present) → extract to
|
|
368
|
-
* SKILLS_DIR/a2a → npm install --omit=dev → components.json registration.
|
|
369
|
-
* Already installed → { ok:false, error } naming the installed version
|
|
370
|
-
* (mirrors `baize add`; version bumps go through `baize upgrade a2a`).
|
|
365
|
+
* Shared upload vetting for install/upgrade (D25/D49): extension → size →
|
|
366
|
+
* entry listing → path-traversal scan → link scan → package manifest.
|
|
371
367
|
*
|
|
372
|
-
* @returns {
|
|
368
|
+
* @returns {{ error: string } | { pkg: object, entries: string[] }}
|
|
373
369
|
*/
|
|
374
|
-
|
|
375
|
-
skillsDir = defaultSkillsDir(),
|
|
376
|
-
componentsFile: componentsPath = componentsFile(),
|
|
377
|
-
installDeps = npmInstallDeps,
|
|
378
|
-
rebuildDeps = rebuildNativeDeps,
|
|
379
|
-
originalName = null,
|
|
380
|
-
} = {}) {
|
|
381
|
-
const file = String(tarballPath || '');
|
|
370
|
+
function validateA2aTarball(file, originalName) {
|
|
382
371
|
const nameForType = String(originalName || file || '');
|
|
383
372
|
if (!/\.(?:tar\.gz|tgz)$/i.test(nameForType)) {
|
|
384
|
-
return {
|
|
373
|
+
return { error: '安装包必须是 .tar.gz 归档' };
|
|
385
374
|
}
|
|
386
375
|
let stat;
|
|
387
376
|
try {
|
|
388
377
|
stat = fs.statSync(file);
|
|
389
378
|
} catch {
|
|
390
|
-
return {
|
|
379
|
+
return { error: '上传文件不存在或不可读' };
|
|
391
380
|
}
|
|
392
381
|
if (stat.size > A2A_INSTALL_MAX_BYTES) {
|
|
393
|
-
return {
|
|
382
|
+
return { error: `文件超过 ${A2A_INSTALL_MAX_MB}MB 限制` };
|
|
394
383
|
}
|
|
395
384
|
let entries;
|
|
396
385
|
try {
|
|
397
386
|
entries = listTarballEntries(file);
|
|
398
387
|
} catch (err) {
|
|
399
|
-
return {
|
|
388
|
+
return { error: `无法读取归档: ${err.message}` };
|
|
400
389
|
}
|
|
401
|
-
if (entries.length === 0) return {
|
|
390
|
+
if (entries.length === 0) return { error: '归档为空' };
|
|
402
391
|
const unsafe = entries.find(isUnsafeArchiveEntry);
|
|
403
|
-
if (unsafe) return {
|
|
392
|
+
if (unsafe) return { error: `归档包含不安全的路径条目: ${unsafe}` };
|
|
404
393
|
try {
|
|
405
394
|
assertNoArchiveLinks(file);
|
|
406
395
|
} catch (err) {
|
|
407
|
-
return {
|
|
396
|
+
return { error: err.message };
|
|
408
397
|
}
|
|
409
398
|
let pkg;
|
|
410
399
|
try {
|
|
411
400
|
pkg = readTarballPackageJson(file, entries);
|
|
412
401
|
} catch (err) {
|
|
413
|
-
return {
|
|
402
|
+
return { error: `manifest 解析失败: ${err.message}` };
|
|
414
403
|
}
|
|
415
|
-
if (!pkg) return {
|
|
404
|
+
if (!pkg) return { error: '归档缺少 package.json' };
|
|
416
405
|
if (pkg.name !== A2A_PACKAGE_NAME) {
|
|
417
|
-
return {
|
|
406
|
+
return { error: `manifest name 不符: 期望 ${A2A_PACKAGE_NAME},实际 ${pkg.name}` };
|
|
418
407
|
}
|
|
419
408
|
if (typeof pkg.version !== 'string' || !pkg.version.trim()) {
|
|
420
|
-
return {
|
|
409
|
+
return { error: 'manifest 缺少 version' };
|
|
421
410
|
}
|
|
411
|
+
return { pkg, entries };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
|
|
416
|
+
* Pipeline: entry listing + path-safety vetting (no `..`/absolute/links) →
|
|
417
|
+
* manifest check (name = @baize-ai/baize-a2a, version present) → extract to
|
|
418
|
+
* SKILLS_DIR/a2a → npm install --omit=dev → components.json registration.
|
|
419
|
+
* Already installed → { ok:false, error } naming the installed version
|
|
420
|
+
* (the web route dispatches to upgradeA2aTarball instead — D49 K3).
|
|
421
|
+
*
|
|
422
|
+
* @returns {Promise<{ok:boolean, version?:string, error?:string}>}
|
|
423
|
+
*/
|
|
424
|
+
export async function installA2aTarball(tarballPath, {
|
|
425
|
+
skillsDir = defaultSkillsDir(),
|
|
426
|
+
componentsFile: componentsPath = componentsFile(),
|
|
427
|
+
installDeps = npmInstallDeps,
|
|
428
|
+
rebuildDeps = rebuildNativeDeps,
|
|
429
|
+
originalName = null,
|
|
430
|
+
} = {}) {
|
|
431
|
+
const file = String(tarballPath || '');
|
|
432
|
+
const verdict = validateA2aTarball(file, originalName);
|
|
433
|
+
if (verdict.error) return { ok: false, error: verdict.error };
|
|
434
|
+
const { pkg, entries } = verdict;
|
|
422
435
|
const targetDir = path.join(skillsDir, 'a2a');
|
|
423
436
|
const installed = readComponents(componentsPath).a2a;
|
|
424
437
|
if (fs.existsSync(targetDir) || installed) {
|
|
@@ -456,6 +469,175 @@ export async function installA2aTarball(tarballPath, {
|
|
|
456
469
|
return { ok: true, version: pkg.version };
|
|
457
470
|
}
|
|
458
471
|
|
|
472
|
+
/** Semver compare (no deps; returns -1|0|1, null when unparseable).
|
|
473
|
+
* Prerelease ranks below release; prerelease identifiers compared
|
|
474
|
+
* numerically when numeric, lexically otherwise. */
|
|
475
|
+
export function compareA2aSemver(a, b) {
|
|
476
|
+
const parse = (v) => String(v || '').trim().replace(/^v/, '')
|
|
477
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
478
|
+
const pa = parse(a);
|
|
479
|
+
const pb = parse(b);
|
|
480
|
+
if (!pa || !pb) return null;
|
|
481
|
+
for (const i of [1, 2, 3]) {
|
|
482
|
+
const av = Number(pa[i]);
|
|
483
|
+
const bv = Number(pb[i]);
|
|
484
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
485
|
+
}
|
|
486
|
+
if (pa[4] === pb[4]) return 0;
|
|
487
|
+
if (!pa[4]) return 1;
|
|
488
|
+
if (!pb[4]) return -1;
|
|
489
|
+
const aParts = pa[4].split('.');
|
|
490
|
+
const bParts = pb[4].split('.');
|
|
491
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
492
|
+
const av = aParts[i];
|
|
493
|
+
const bv = bParts[i];
|
|
494
|
+
if (av === bv) continue;
|
|
495
|
+
if (av === undefined) return -1;
|
|
496
|
+
if (bv === undefined) return 1;
|
|
497
|
+
const an = /^\d+$/.test(av) ? Number(av) : null;
|
|
498
|
+
const bn = /^\d+$/.test(bv) ? Number(bv) : null;
|
|
499
|
+
if (an !== null && bn !== null) return an < bn ? -1 : 1;
|
|
500
|
+
if (an !== null) return -1;
|
|
501
|
+
if (bn !== null) return 1;
|
|
502
|
+
return av < bv ? -1 : 1;
|
|
503
|
+
}
|
|
504
|
+
return 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function readInstalledPkgVersion(skillDir) {
|
|
508
|
+
try {
|
|
509
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(skillDir, 'package.json'), 'utf8'));
|
|
510
|
+
return typeof pkg.version === 'string' ? pkg.version.trim() : null;
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Upgrade an installed @baize-ai/baize-a2a from an uploaded .tar.gz
|
|
518
|
+
* (D49 K3 — web 重传 = 升级). Gate: incoming semver must be strictly higher
|
|
519
|
+
* than the installed one (components.json version first, skill package.json
|
|
520
|
+
* fallback); otherwise { ok:false, error:'version_not_higher', local, incoming }.
|
|
521
|
+
* Pipeline: backup skillDir/.backup/<ts> (upgrade.js .backup convention) →
|
|
522
|
+
* extract over skillDir → npm install --omit=dev + rebuild (D44) →
|
|
523
|
+
* components.json version bump → a2a CLI `restart` (covers pm2 AND nohup
|
|
524
|
+
* daemon lifecycles). Extraction/install/rebuild failure rolls the skill dir
|
|
525
|
+
* back from the backup; restart failure is reported, not rolled back (the
|
|
526
|
+
* on-disk upgrade itself succeeded).
|
|
527
|
+
*
|
|
528
|
+
* @returns {Promise<{ok:boolean, version?:string, previousVersion?:string,
|
|
529
|
+
* upgraded?:boolean, restarted?:boolean, restartError?:string, error?:string}>}
|
|
530
|
+
*/
|
|
531
|
+
export async function upgradeA2aTarball(tarballPath, {
|
|
532
|
+
skillsDir = defaultSkillsDir(),
|
|
533
|
+
componentsFile: componentsPath = componentsFile(),
|
|
534
|
+
installDeps = npmInstallDeps,
|
|
535
|
+
rebuildDeps = rebuildNativeDeps,
|
|
536
|
+
restartFn = () => runA2aCli(['restart'], { timeout: 120000 }),
|
|
537
|
+
originalName = null,
|
|
538
|
+
} = {}) {
|
|
539
|
+
const file = String(tarballPath || '');
|
|
540
|
+
const verdict = validateA2aTarball(file, originalName);
|
|
541
|
+
if (verdict.error) return { ok: false, error: verdict.error };
|
|
542
|
+
const { pkg, entries } = verdict;
|
|
543
|
+
const targetDir = path.join(skillsDir, 'a2a');
|
|
544
|
+
const components = readComponents(componentsPath);
|
|
545
|
+
if (!fs.existsSync(targetDir) || !components.a2a) {
|
|
546
|
+
return { ok: false, error: 'a2a 未安装,请先安装' };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const localVersion = components.a2a.version || readInstalledPkgVersion(targetDir);
|
|
550
|
+
const cmp = compareA2aSemver(pkg.version, localVersion);
|
|
551
|
+
if (cmp === null) {
|
|
552
|
+
return { ok: false, error: '版本号无法解析,拒绝升级', local: localVersion || null, incoming: pkg.version };
|
|
553
|
+
}
|
|
554
|
+
if (cmp <= 0) {
|
|
555
|
+
return { ok: false, error: 'version_not_higher', local: localVersion || null, incoming: pkg.version };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Backup before any write (mirrors cli/lib/upgrade.js step2_backup).
|
|
559
|
+
// Copied per top-level entry: cpSync refuses a destination that is a
|
|
560
|
+
// subdirectory of its source (.backup lives inside the skill dir).
|
|
561
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
562
|
+
const backupDir = path.join(targetDir, '.backup', timestamp);
|
|
563
|
+
try {
|
|
564
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
565
|
+
for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) {
|
|
566
|
+
if (['node_modules', '.backup', '.baize'].includes(entry.name)) continue;
|
|
567
|
+
fs.cpSync(path.join(targetDir, entry.name), path.join(backupDir, entry.name), { recursive: true });
|
|
568
|
+
}
|
|
569
|
+
} catch (err) {
|
|
570
|
+
return { ok: false, error: `备份失败,升级中止: ${err.message}` };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Rollback: clear everything except the backup itself, then copy the
|
|
574
|
+
// backup back (top-level .backup inside targetDir must survive the wipe).
|
|
575
|
+
const restoreBackup = async () => {
|
|
576
|
+
try {
|
|
577
|
+
for (const entry of fs.readdirSync(targetDir)) {
|
|
578
|
+
if (entry !== '.backup') fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
|
|
579
|
+
}
|
|
580
|
+
fs.cpSync(backupDir, targetDir, { recursive: true });
|
|
581
|
+
// Best-effort dependency restore for the rolled-back code (node_modules
|
|
582
|
+
// was excluded from the backup); failure here is reported, not fatal.
|
|
583
|
+
await installDeps(targetDir);
|
|
584
|
+
return true;
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
extractTarballTo(file, targetDir, entries);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
const restored = await restoreBackup();
|
|
594
|
+
return { ok: false, error: `解压失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${err.message}` };
|
|
595
|
+
}
|
|
596
|
+
const depResult = await installDeps(targetDir);
|
|
597
|
+
if (!depResult.ok) {
|
|
598
|
+
const restored = await restoreBackup();
|
|
599
|
+
return { ok: false, error: `npm install 失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${depResult.error}` };
|
|
600
|
+
}
|
|
601
|
+
const rebuildResult = await rebuildDeps(targetDir);
|
|
602
|
+
if (!rebuildResult.ok) {
|
|
603
|
+
const restored = await restoreBackup();
|
|
604
|
+
return { ok: false, error: `native 模块重建失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${rebuildResult.error}` };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
components.a2a.version = pkg.version;
|
|
608
|
+
components.a2a.upgradedAt = new Date().toISOString();
|
|
609
|
+
try {
|
|
610
|
+
writeComponents(componentsPath, components);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
const restored = await restoreBackup();
|
|
613
|
+
return { ok: false, error: `components.json 更新失败(${restored ? '已回滚' : '回滚失败,请检查 .backup'}): ${err.message}` };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const restart = await restartFn();
|
|
617
|
+
return {
|
|
618
|
+
ok: true,
|
|
619
|
+
version: pkg.version,
|
|
620
|
+
previousVersion: localVersion || null,
|
|
621
|
+
upgraded: true,
|
|
622
|
+
restarted: Boolean(restart?.success),
|
|
623
|
+
...(restart?.success ? {} : { restartError: restart?.error || 'restart 未返回成功' }),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Web upload entry (D49 K3): 未安装 = 安装(D25 现状);已安装 = 走升级管线。
|
|
629
|
+
* The server route delegates here so the dispatch decision lives next to the
|
|
630
|
+
* install/upgrade implementations.
|
|
631
|
+
*/
|
|
632
|
+
export async function installOrUpgradeA2aTarball(tarballPath, opts = {}) {
|
|
633
|
+
const skillsDir = opts.skillsDir ?? defaultSkillsDir();
|
|
634
|
+
const componentsPath = opts.componentsFile ?? componentsFile();
|
|
635
|
+
const targetDir = path.join(skillsDir, 'a2a');
|
|
636
|
+
const installed = fs.existsSync(targetDir) || Boolean(readComponents(componentsPath).a2a);
|
|
637
|
+
if (installed) return upgradeA2aTarball(tarballPath, opts);
|
|
638
|
+
return installA2aTarball(tarballPath, opts);
|
|
639
|
+
}
|
|
640
|
+
|
|
459
641
|
function schedulerDbPath() {
|
|
460
642
|
return path.join(baizeDir(), 'scheduler', 'scheduler.db');
|
|
461
643
|
}
|
|
@@ -464,6 +646,23 @@ function a2aDbPath() {
|
|
|
464
646
|
return process.env.BAIZE_A2A_DB || path.join(baizeDir(), 'components', 'a2a', 'a2a.db');
|
|
465
647
|
}
|
|
466
648
|
|
|
649
|
+
/**
|
|
650
|
+
* D49: read the `baize init` A2A bootstrap result written by the detached
|
|
651
|
+
* `cli.js start --background` child (see baize-a2a daemon-ctl.bootstrapStatePath).
|
|
652
|
+
* Tolerant when the file does not exist (a2a never bootstrapped).
|
|
653
|
+
* @returns {{status?:string, via?:string, pid?:number, error?:string, at?:string}|null}
|
|
654
|
+
*/
|
|
655
|
+
export function getA2aBootstrapState() {
|
|
656
|
+
const file = process.env.BAIZE_A2A_BOOTSTRAP_STATE
|
|
657
|
+
|| path.join(baizeDir(), 'components', 'a2a', 'bootstrap-state.json');
|
|
658
|
+
try {
|
|
659
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
660
|
+
return raw && typeof raw === 'object' ? raw : null;
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
467
666
|
/**
|
|
468
667
|
* Task board: task_runs rows created by the A2A worker (source = 'a2a', §3).
|
|
469
668
|
* Tolerant when the table/DB does not exist yet (baize-a2a not started).
|
|
@@ -71,8 +71,9 @@ import {
|
|
|
71
71
|
cancelA2aTask,
|
|
72
72
|
getA2aTaskRuns,
|
|
73
73
|
getA2aMessages,
|
|
74
|
-
|
|
74
|
+
installOrUpgradeA2aTarball,
|
|
75
75
|
probeA2aDaemonHealthy,
|
|
76
|
+
getA2aBootstrapState,
|
|
76
77
|
resolveA2aMode,
|
|
77
78
|
getSchedulerTasks,
|
|
78
79
|
} from './a2a-admin.js';
|
|
@@ -1266,6 +1267,7 @@ app.get('/api/admin/a2a/status', async (req, res) => {
|
|
|
1266
1267
|
const payload = { ...(result.json || {}) };
|
|
1267
1268
|
payload.mode = resolveA2aMode(payload.mode);
|
|
1268
1269
|
payload.daemonHealthy = await probeA2aDaemonHealthy();
|
|
1270
|
+
payload.bootstrap = getA2aBootstrapState(); // D49: init bootstrap result (launched/ok/failed)
|
|
1269
1271
|
if (result.success) {
|
|
1270
1272
|
res.json({ success: true, ...payload });
|
|
1271
1273
|
} else {
|
|
@@ -1385,11 +1387,14 @@ app.get('/api/admin/a2a/messages', (req, res) => {
|
|
|
1385
1387
|
}
|
|
1386
1388
|
});
|
|
1387
1389
|
|
|
1388
|
-
// Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25
|
|
1389
|
-
// multipart field `file`; ≤100MB (413 over); entries vetted
|
|
1390
|
-
// absolute paths / links before extraction
|
|
1391
|
-
//
|
|
1392
|
-
// components.json
|
|
1390
|
+
// Install/upgrade @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付,
|
|
1391
|
+
// D49 K3 双语义). multipart field `file`; ≤100MB (413 over); entries vetted
|
|
1392
|
+
// against `..` / absolute paths / links before extraction. Dispatch (in
|
|
1393
|
+
// a2a-admin.js): 未安装 = 安装 (extract to SKILLS_DIR/a2a → npm install →
|
|
1394
|
+
// components.json); 已安装 = 升级 (version gate new>local else 409 → backup →
|
|
1395
|
+
// extract → npm install+rebuild → components.json bump → a2a cli restart).
|
|
1396
|
+
// Response: {ok:true, version, upgraded?} | {ok:false, error} | 409
|
|
1397
|
+
// {ok:false, error:'version_not_higher', local, incoming}.
|
|
1393
1398
|
app.post('/api/admin/a2a/install', (req, res) => {
|
|
1394
1399
|
a2aInstallUpload.single('file')(req, res, async (err) => {
|
|
1395
1400
|
if (err) {
|
|
@@ -1403,10 +1408,13 @@ app.post('/api/admin/a2a/install', (req, res) => {
|
|
|
1403
1408
|
return res.status(400).json({ ok: false, error: '缺少 file 字段(multipart 字段名必须为 file)' });
|
|
1404
1409
|
}
|
|
1405
1410
|
try {
|
|
1406
|
-
const result = await
|
|
1411
|
+
const result = await installOrUpgradeA2aTarball(file.path, { skillsDir: SKILLS_DIR, originalName: file.originalname });
|
|
1412
|
+
if (result.error === 'version_not_higher') {
|
|
1413
|
+
return res.status(409).json(result);
|
|
1414
|
+
}
|
|
1407
1415
|
res.status(result.ok ? 200 : 400).json(result);
|
|
1408
1416
|
} catch (e) {
|
|
1409
|
-
res.status(500).json({ ok: false, error:
|
|
1417
|
+
res.status(500).json({ ok: false, error: `安装/升级失败: ${e.message}` });
|
|
1410
1418
|
} finally {
|
|
1411
1419
|
fs.promises.rm(file.path, { force: true }).catch(() => {});
|
|
1412
1420
|
}
|
|
@@ -127,6 +127,12 @@ function loadComponentServices() {
|
|
|
127
127
|
// Skip components that haven't finished setup (AI-mode install in progress)
|
|
128
128
|
if (meta && meta.setupComplete === false) continue;
|
|
129
129
|
|
|
130
|
+
// D49: the baize-a2a daemon is owned by its own lifecycle manager
|
|
131
|
+
// (daemon-ctl: pm2 + readiness probe + nohup fallback). Registering it
|
|
132
|
+
// here too created a second start path that raced the dedicated one
|
|
133
|
+
// for port 8443 (double-instance) — a2a is intentionally excluded.
|
|
134
|
+
if (name === 'a2a' || (meta && meta.npmPkg === '@baize-ai/baize-a2a')) continue;
|
|
135
|
+
|
|
130
136
|
const skillDir = (meta && meta.skillDir) || path.join(SKILLS_DIR, name);
|
|
131
137
|
|
|
132
138
|
// Try loading the component's own ecosystem.config.cjs
|
|
@@ -237,14 +237,16 @@ describe('installChannel / uninstallChannel', () => {
|
|
|
237
237
|
describe('a2a builtin channel (D19 单元 C)', () => {
|
|
238
238
|
const a2aSchemaKeys = ['enabled', 'adminUrl', 'agentId', 'advertiseUrl', 'listenPort', 'certKeyPath', 'certCertPath'];
|
|
239
239
|
|
|
240
|
-
test('
|
|
240
|
+
test('a2a keeps the contract config schema but is hidden from the catalogue (D33)', async () => {
|
|
241
241
|
const channels = await ca.discoverChannels({ fetch: noResultsFetch });
|
|
242
|
-
|
|
243
|
-
expect(a2a).
|
|
242
|
+
// Commercial project: a2a must not appear in any channel/component listing.
|
|
243
|
+
expect(channels.find((c) => c.name === 'a2a')).toBeUndefined();
|
|
244
|
+
// The builtin definition (used by configure/status) keeps the D19 schema.
|
|
245
|
+
const a2a = ca.BUILTIN_CHANNELS.a2a;
|
|
244
246
|
expect(a2a).toMatchObject({
|
|
247
|
+
name: 'a2a',
|
|
245
248
|
npmPkg: '@baize-ai/baize-a2a',
|
|
246
249
|
repo: 'baize-ai/baize-a2a',
|
|
247
|
-
installed: false,
|
|
248
250
|
});
|
|
249
251
|
expect(a2a.configSchema.map((f) => f.key)).toEqual(a2aSchemaKeys);
|
|
250
252
|
expect(a2a.configSchema.every((f) => f.target === 'config')).toBe(true);
|
|
@@ -286,7 +288,7 @@ describe('a2a builtin channel (D19 单元 C)', () => {
|
|
|
286
288
|
});
|
|
287
289
|
|
|
288
290
|
test('channelStatus for installed a2a: configured only when enabled + both urls', async () => {
|
|
289
|
-
const entry =
|
|
291
|
+
const entry = ca.BUILTIN_CHANNELS.a2a;
|
|
290
292
|
fs.writeFileSync(path.join(baizeDir, '.baize', 'components.json'), JSON.stringify({
|
|
291
293
|
a2a: { version: '0.1.0', source: { type: 'npm' } },
|
|
292
294
|
}));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process driver for the K4 --file upgrade e2e tests: runs the REAL
|
|
3
|
+
* upgradeComponent() flow in a clean process so BAIZE_DIR resolves the fixture
|
|
4
|
+
* root (cli/lib/config.js reads env at import time) — Jest's sandboxed
|
|
5
|
+
* process.env is not inherited by grandchildren, so the flow must run outside
|
|
6
|
+
* the Jest process.
|
|
7
|
+
*
|
|
8
|
+
* argv: <component> <tgzPath> [--check]
|
|
9
|
+
* stdout: the command output (JSON — --json is always passed)
|
|
10
|
+
*/
|
|
11
|
+
const [component, tgzPath, checkFlag] = process.argv.slice(2);
|
|
12
|
+
const { upgradeComponent } = await import('../../cli/commands/component.js');
|
|
13
|
+
const args = [component, '--file', tgzPath, '--yes', '--json'];
|
|
14
|
+
if (checkFlag === '--check') args.push('--check');
|
|
15
|
+
await upgradeComponent(args);
|