@baize-ai/core 0.3.14 → 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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@baize-ai/core",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
- "description": "Baize (\u767d\u6cfd) \u2014 autonomous AI agent infrastructure",
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
+ }
@@ -1719,10 +1719,10 @@ function updateA2aInstallArea(body) {
1719
1719
  if (!el || !fileInput || !btn) return;
1720
1720
  const version = body && (body.version || body.installedVersion);
1721
1721
  if (version) {
1722
- el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')}。如需升级请重新上传模块包。`;
1722
+ el.textContent = `已安装 baize-a2a v${String(version).replace(/^v/, '')} — 上传更高版本的模块包(.tgz)可在线升级(相同或更低版本将被拒绝)。`;
1723
1723
  if (state) { state.textContent = '已安装'; state.className = 'channel-state ok'; }
1724
- fileInput.disabled = true;
1725
- btn.disabled = true;
1724
+ fileInput.disabled = false;
1725
+ btn.disabled = false;
1726
1726
  } else {
1727
1727
  el.textContent = '未安装 — 上传 baize-a2a 模块包(.tgz)完成安装。';
1728
1728
  if (state) { state.textContent = '未安装'; state.className = 'channel-state warn'; }
@@ -1782,11 +1782,16 @@ function updateA2aHealth(body, peerCountOverride) {
1782
1782
  } else if (_lastA2aPeerCount !== null) {
1783
1783
  peerCount = _lastA2aPeerCount;
1784
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))}`);
1785
1789
  const rows = [
1786
1790
  ['Daemon 健康', healthy ? '✅ 正常' : '❌ 异常'],
1787
1791
  ['运行模式', modeLabel],
1788
1792
  ['Peer 数', peerCount],
1789
1793
  ['最近心跳', a2aHeartbeatLabel(body)],
1794
+ ...(bsLabel ? [['init 自启动', bsLabel]] : []),
1790
1795
  ].filter(([, v]) => v !== undefined && v !== null && v !== '');
1791
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('');
1792
1797
  if (state) {
@@ -2282,7 +2287,7 @@ async function submitA2aInstall(basePath, btn) {
2282
2287
  }
2283
2288
  const original = btn.textContent;
2284
2289
  btn.disabled = true;
2285
- btn.textContent = '上传安装中...';
2290
+ btn.textContent = '上传处理中...';
2286
2291
  feedback.textContent = '';
2287
2292
  feedback.className = '';
2288
2293
  const fd = new FormData();
@@ -2291,11 +2296,23 @@ async function submitA2aInstall(basePath, btn) {
2291
2296
  const { status, body } = await adminFetch(basePath, '/api/admin/a2a/install', { method: 'POST', body: fd });
2292
2297
  if (body.ok === true || body.success === true) {
2293
2298
  const version = body.version ? ` v${String(body.version).replace(/^v/, '')}` : '';
2294
- feedback.textContent = `✅ 安装成功${version}`;
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;
2295
2305
  feedback.className = 'a2a-feedback-ok';
2296
2306
  fileInput.value = '';
2297
- setAdminMsg(`✅ baize-a2a 安装成功${version}`);
2298
- 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);
2299
2316
  } else {
2300
2317
  const msg = body.error || body.message || `HTTP ${status}`;
2301
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">上传安装</button>
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
- * Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
366
- * Pipeline: entry listing + path-safety vetting (no `..`/absolute/links)
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 {Promise<{ok:boolean, version?:string, error?:string}>}
368
+ * @returns {{ error: string } | { pkg: object, entries: string[] }}
373
369
  */
374
- export async function installA2aTarball(tarballPath, {
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 { ok: false, error: '安装包必须是 .tar.gz 归档' };
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 { ok: false, error: '上传文件不存在或不可读' };
379
+ return { error: '上传文件不存在或不可读' };
391
380
  }
392
381
  if (stat.size > A2A_INSTALL_MAX_BYTES) {
393
- return { ok: false, error: `文件超过 ${A2A_INSTALL_MAX_MB}MB 限制` };
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 { ok: false, error: `无法读取归档: ${err.message}` };
388
+ return { error: `无法读取归档: ${err.message}` };
400
389
  }
401
- if (entries.length === 0) return { ok: false, error: '归档为空' };
390
+ if (entries.length === 0) return { error: '归档为空' };
402
391
  const unsafe = entries.find(isUnsafeArchiveEntry);
403
- if (unsafe) return { ok: false, error: `归档包含不安全的路径条目: ${unsafe}` };
392
+ if (unsafe) return { error: `归档包含不安全的路径条目: ${unsafe}` };
404
393
  try {
405
394
  assertNoArchiveLinks(file);
406
395
  } catch (err) {
407
- return { ok: false, error: err.message };
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 { ok: false, error: `manifest 解析失败: ${err.message}` };
402
+ return { error: `manifest 解析失败: ${err.message}` };
414
403
  }
415
- if (!pkg) return { ok: false, error: '归档缺少 package.json' };
404
+ if (!pkg) return { error: '归档缺少 package.json' };
416
405
  if (pkg.name !== A2A_PACKAGE_NAME) {
417
- return { ok: false, error: `manifest name 不符: 期望 ${A2A_PACKAGE_NAME},实际 ${pkg.name}` };
406
+ return { error: `manifest name 不符: 期望 ${A2A_PACKAGE_NAME},实际 ${pkg.name}` };
418
407
  }
419
408
  if (typeof pkg.version !== 'string' || !pkg.version.trim()) {
420
- return { ok: false, error: 'manifest 缺少 version' };
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
- installA2aTarball,
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 against `..` /
1390
- // absolute paths / links before extraction; manifest must be the a2a package
1391
- // with a version; then extract to SKILLS_DIR/a2a → npm install --omit=dev
1392
- // components.json. Response: {ok:true, version} | {ok:false, error}.
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 installA2aTarball(file.path, { skillsDir: SKILLS_DIR, originalName: file.originalname });
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: `安装失败: ${e.message}` });
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('catalogue includes a2a with the contract config schema (target: config)', async () => {
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
- const a2a = channels.find((c) => c.name === 'a2a');
243
- expect(a2a).toBeTruthy();
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 = (await ca.discoverChannels({ fetch: noResultsFetch })).find((c) => c.name === 'a2a');
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);