@baize-ai/core 0.3.14 → 0.3.16

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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * K5: step8 restart hook. A nohup-started baize-a2a daemon (Docker entrypoint)
3
+ * is invisible to pm2, so the pm2-only restart never refreshed its code (C6).
4
+ * For service name 'baize-a2a' step8 must prefer `node <a2a-cli> restart`
5
+ * (daemon-ctl covers nohup pid + pm2 + waitReady), falling back to the
6
+ * existing pm2 path when the CLI is missing or fails.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
12
+ import { step8_startService } from '../cli/lib/upgrade.js';
13
+
14
+ let tmp;
15
+
16
+ beforeEach(() => {
17
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'restart-hook-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(tmp, { recursive: true, force: true });
22
+ });
23
+
24
+ function makeCtx({ component = 'a2a', serviceWasRunning = true, skillMd = null } = {}) {
25
+ const skillDir = path.join(tmp, component);
26
+ fs.mkdirSync(skillDir, { recursive: true });
27
+ if (skillMd) fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd);
28
+ return {
29
+ component,
30
+ skillDir,
31
+ serviceWasRunning,
32
+ steps: [],
33
+ mergeConflicts: [],
34
+ mergedFiles: [],
35
+ };
36
+ }
37
+
38
+ const throwingPm2 = {
39
+ restartManagedProcess: () => { throw new Error('pm2 restart must not be called'); },
40
+ restartFromEcosystem: () => { throw new Error('pm2 ecosystem restart must not be called'); },
41
+ };
42
+
43
+ describe('step8 a2a cli restart hook (K5)', () => {
44
+ test('service name baize-a2a prefers the a2a cli restart and skips pm2', () => {
45
+ const ctx = makeCtx();
46
+ const calls = [];
47
+ const spawnSyncFn = (cmd, args, opts) => {
48
+ calls.push({ cmd, args, opts });
49
+ return { status: 0, stdout: '{"ok":true}' };
50
+ };
51
+ const step = step8_startService(ctx, {
52
+ ...throwingPm2,
53
+ spawnSyncFn,
54
+ resolveA2aCli: () => '/fake/a2a/scripts/cli.js',
55
+ });
56
+ expect(step.status).toBe('done');
57
+ expect(step.message).toBe('baize-a2a (a2a cli restart)');
58
+ expect(calls).toHaveLength(1);
59
+ expect(calls[0].args[0]).toBe('/fake/a2a/scripts/cli.js');
60
+ expect(calls[0].args[1]).toBe('restart');
61
+ // JSON contract on stdout + stdin closed immediately (a2a cli merges stdin)
62
+ expect(calls[0].args).toContain('--json');
63
+ expect(calls[0].opts).toMatchObject({ timeout: 120000 });
64
+ expect(calls[0].opts.input).toBe('');
65
+ });
66
+
67
+ test('hook fires even when pm2 never managed the service (nohup daemon)', () => {
68
+ // Docker nohup daemon: pm2 jlist has no entry → serviceWasRunning=false.
69
+ // The hook must still run, otherwise the daemon keeps the old code (C6).
70
+ const ctx = makeCtx({ serviceWasRunning: false });
71
+ const step = step8_startService(ctx, {
72
+ ...throwingPm2,
73
+ spawnSyncFn: () => ({ status: 0, stdout: '{"ok":true}' }),
74
+ resolveA2aCli: () => '/fake/a2a/scripts/cli.js',
75
+ });
76
+ expect(step.status).toBe('done');
77
+ });
78
+
79
+ test('falls back to the pm2 path when the cli restart fails', () => {
80
+ const ctx = makeCtx();
81
+ let restartCalls = 0;
82
+ const step = step8_startService(ctx, {
83
+ spawnSyncFn: () => ({ status: 1, stdout: '{"ok":false,"error":"boom"}' }),
84
+ resolveA2aCli: () => '/fake/a2a/scripts/cli.js',
85
+ restartManagedProcess: () => { restartCalls += 1; },
86
+ restartFromEcosystem: () => { throw new Error('unexpected'); },
87
+ });
88
+ expect(step.status).toBe('done');
89
+ expect(step.message).toBe('baize-a2a');
90
+ expect(restartCalls).toBe(1);
91
+ });
92
+
93
+ test('falls back to the pm2 path when no a2a cli can be resolved', () => {
94
+ const ctx = makeCtx();
95
+ let spawnCalls = 0;
96
+ const step = step8_startService(ctx, {
97
+ spawnSyncFn: () => { spawnCalls += 1; return { status: 0 }; },
98
+ resolveA2aCli: () => null,
99
+ restartManagedProcess: () => {},
100
+ restartFromEcosystem: () => { throw new Error('unexpected'); },
101
+ });
102
+ expect(spawnCalls).toBe(0);
103
+ expect(step.status).toBe('done');
104
+ expect(step.message).toBe('baize-a2a');
105
+ });
106
+
107
+ test('non-a2a components never trigger the hook (pm2 path directly)', () => {
108
+ const ctx = makeCtx({
109
+ component: 'telegram',
110
+ skillMd: '---\nname: telegram\nlifecycle:\n service:\n name: baize-telegram\n---\n# T\n',
111
+ });
112
+ let spawnCalls = 0;
113
+ const step = step8_startService(ctx, {
114
+ spawnSyncFn: () => { spawnCalls += 1; return { status: 0 }; },
115
+ restartManagedProcess: () => {},
116
+ restartFromEcosystem: () => { throw new Error('unexpected'); },
117
+ });
118
+ expect(spawnCalls).toBe(0);
119
+ expect(step.status).toBe('done');
120
+ expect(step.message).toBe('baize-telegram');
121
+ });
122
+
123
+ test('non-a2a component that was not running stays skipped', () => {
124
+ const ctx = makeCtx({ component: 'telegram', serviceWasRunning: false });
125
+ const step = step8_startService(ctx, { ...throwingPm2, spawnSyncFn: () => ({ status: 0 }) });
126
+ expect(step.status).toBe('skipped');
127
+ expect(step.message).toBe('was not running');
128
+ });
129
+ });
@@ -744,7 +744,7 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
744
744
  expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: [] });
745
745
  });
746
746
 
747
- test('PUT authz validates mode enum and list entries', async () => {
747
+ test('PUT authz validates mode enum; allow/block ignored and forced empty (D52 governance)', async () => {
748
748
  ctx = await startServer({ extraEnv: a2aEnv() });
749
749
  const put = async (body) => {
750
750
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
@@ -754,17 +754,15 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
754
754
  });
755
755
  return { status: res.status, body: await res.json() };
756
756
  };
757
- expect((await put({ mode: 'bogus' })).status).toBe(400);
758
- expect((await put({ mode: 'open', allow: 'x' })).status).toBe(400); // allow not an array
759
- expect((await put({ mode: 'open', block: [''], allow: [] })).status).toBe(400); // empty string element
760
- expect((await put({ mode: 'open', block: [42], allow: [] })).status).toBe(400); // non-string element
761
- expect((await put({ mode: 'open', block: ['a', 'a'], allow: [] })).status).toBe(400); // duplicate
762
- const ok = await put({ mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: [] });
757
+ expect((await put({ mode: 'bogus' })).status).toBe(400); // invalid mode rejected
758
+ expect((await put({})).status).toBe(400); // missing mode rejected
759
+ // D52: lists are admin-managed — any allow/block payload is ignored []
760
+ const ok = await put({ mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: ['x'] });
763
761
  expect(ok.status).toBe(200);
764
- expect(ok.body).toEqual({ success: true, mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: [] });
762
+ expect(ok.body).toEqual({ success: true, mode: 'allowlist', allow: [], block: [] });
765
763
  });
766
764
 
767
- test('PUT authz writes config.json preserving other fields and trims entries', async () => {
765
+ test('PUT authz writes config.json preserving other fields; lists forced empty', async () => {
768
766
  ctx = await startServer({ extraEnv: a2aEnv() });
769
767
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
770
768
  method: 'PUT',
@@ -772,16 +770,16 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
772
770
  body: JSON.stringify({ mode: 'open', allow: [], block: [' peer_9 ', 'peer_10'] }),
773
771
  });
774
772
  expect(res.status).toBe(200);
775
- expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: ['peer_9', 'peer_10'] });
773
+ expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: [] });
776
774
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
777
- expect(cfg.authz).toEqual({ mode: 'open', allow: [], block: ['peer_9', 'peer_10'] });
775
+ expect(cfg.authz).toEqual({ mode: 'open', allow: [], block: [] });
778
776
  expect(cfg.enabled).toBe(true);
779
777
  expect(cfg.agentId).toBe('agent_test');
780
778
  expect(cfg.cert).toEqual({ keyPath: '', certPath: '' });
781
779
  expect(cfg.name).toBeUndefined();
782
780
  });
783
781
 
784
- test('PUT authz creates config.json when missing', async () => {
782
+ test('PUT authz creates config.json when missing (mode only)', async () => {
785
783
  fs.rmSync(configFile());
786
784
  ctx = await startServer({ extraEnv: a2aEnv() });
787
785
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
@@ -791,7 +789,7 @@ describe('web-console a2a authz routes (D22 单元 C)', () => {
791
789
  });
792
790
  expect(res.status).toBe(200);
793
791
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
794
- expect(cfg.authz).toEqual({ mode: 'allowlist', allow: ['peer_1'], block: [] });
792
+ expect(cfg.authz).toEqual({ mode: 'allowlist', allow: [], block: [] });
795
793
  });
796
794
 
797
795
  test('authz routes require session when a password is set', async () => {
@@ -827,6 +825,11 @@ switch (args[0]) {
827
825
  break;
828
826
  case 'enable': out({ ok: true, ...(mode ? { mode } : {}) }); break;
829
827
  case 'disable': out({ ok: true, ...(mode ? { mode } : {}) }); break;
828
+ case 'restart':
829
+ // D49 K3: upgrade pipeline must restart the daemon via the a2a cli.
830
+ require('fs').writeFileSync(process.env.BAIZE_A2A_PATH + '/restart-called', '1');
831
+ out({ ok: true });
832
+ break;
830
833
  default: console.error('unknown: ' + args.join(' ')); process.exit(1);
831
834
  }
832
835
  `);
@@ -1030,21 +1033,95 @@ switch (args[0]) {
1030
1033
  });
1031
1034
  });
1032
1035
 
1033
- test('install rejects when a2a is already installed', async () => {
1036
+ test('second upload with the same version is rejected as version_not_higher (409)', async () => {
1034
1037
  ctx = await startServer({ extraEnv: a2aEnv() });
1035
1038
  const tgz = buildTarball({ 'package.json': validManifest });
1036
1039
  try {
1037
1040
  const first = await installUpload(ctx, tgz);
1038
1041
  expect(first.status).toBe(200);
1039
1042
  const second = await installUpload(ctx, tgz);
1040
- expect(second.status).toBe(400);
1041
- expect(second.body).toMatchObject({ ok: false });
1042
- expect(second.body.error).toMatch(/已安装/);
1043
+ expect(second.status).toBe(409);
1044
+ expect(second.body).toMatchObject({
1045
+ ok: false,
1046
+ error: 'version_not_higher',
1047
+ local: '9.9.9',
1048
+ incoming: '9.9.9',
1049
+ });
1050
+ // Installation untouched by the rejected upload
1051
+ const pkg = JSON.parse(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'package.json'), 'utf8'));
1052
+ expect(pkg.version).toBe('9.9.9');
1043
1053
  } finally {
1044
1054
  fs.rmSync(tgz, { force: true });
1045
1055
  }
1046
1056
  });
1047
1057
 
1058
+ test('uploading a lower version than installed is rejected with local/incoming detail', async () => {
1059
+ ctx = await startServer({ extraEnv: a2aEnv() });
1060
+ const v1 = buildTarball({ 'package.json': validManifest });
1061
+ const v0 = buildTarball({ 'package.json': JSON.stringify({ name: '@baize-ai/baize-a2a', version: '1.0.0' }) });
1062
+ try {
1063
+ expect((await installUpload(ctx, v1)).status).toBe(200);
1064
+ const downgrade = await installUpload(ctx, v0);
1065
+ expect(downgrade.status).toBe(409);
1066
+ expect(downgrade.body).toMatchObject({ ok: false, error: 'version_not_higher', local: '9.9.9', incoming: '1.0.0' });
1067
+ const pkg = JSON.parse(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'package.json'), 'utf8'));
1068
+ expect(pkg.version).toBe('9.9.9');
1069
+ } finally {
1070
+ fs.rmSync(v1, { force: true });
1071
+ fs.rmSync(v0, { force: true });
1072
+ }
1073
+ });
1074
+
1075
+ test('uploading a higher version upgrades in place: files replaced, components.json bumped, a2a cli restart called', async () => {
1076
+ ctx = await startServer({ extraEnv: a2aEnv() });
1077
+ const v1 = buildTarball({
1078
+ 'package.json': validManifest,
1079
+ 'SKILL.md': '---\nname: a2a\n---\n# A2A v9\n',
1080
+ });
1081
+ const v2 = buildTarball({
1082
+ 'package.json': JSON.stringify({ name: '@baize-ai/baize-a2a', version: '10.0.0' }),
1083
+ 'SKILL.md': '---\nname: a2a\nversion: 10.0.0\n---\n# A2A v10\n',
1084
+ 'new-module.js': 'export const fresh = true;\n',
1085
+ });
1086
+ try {
1087
+ expect((await installUpload(ctx, v1)).status).toBe(200);
1088
+ const second = await installUpload(ctx, v2);
1089
+ expect(second.status).toBe(200);
1090
+ expect(second.body).toMatchObject({
1091
+ ok: true,
1092
+ version: '10.0.0',
1093
+ previousVersion: '9.9.9',
1094
+ upgraded: true,
1095
+ restarted: true,
1096
+ });
1097
+ // K3/K5: the upgrade restarted the daemon through the a2a cli
1098
+ expect(fs.existsSync(path.join(fakeA2aDir, 'restart-called'))).toBe(true);
1099
+ // Files replaced in place
1100
+ const pkg = JSON.parse(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'package.json'), 'utf8'));
1101
+ expect(pkg.version).toBe('10.0.0');
1102
+ expect(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'new-module.js'), 'utf8')).toContain('fresh');
1103
+ // Registration preserved, version bumped, both timestamps present
1104
+ const components = JSON.parse(fs.readFileSync(path.join(ctx.root, '.baize', 'components.json'), 'utf8'));
1105
+ expect(components.a2a).toMatchObject({
1106
+ version: '10.0.0',
1107
+ repo: 'baize-ai/baize-a2a',
1108
+ skillDir: path.join(ctx.skillsDir, 'a2a'),
1109
+ });
1110
+ expect(components.a2a.installedAt).toBeTruthy();
1111
+ expect(components.a2a.upgradedAt).toBeTruthy();
1112
+ // Backup taken before any write (upgrade.js .backup convention)
1113
+ const backupRoot = path.join(ctx.skillsDir, 'a2a', '.backup');
1114
+ expect(fs.readdirSync(backupRoot)).toHaveLength(1);
1115
+ // Backup holds the pre-upgrade code
1116
+ const backupTs = fs.readdirSync(backupRoot)[0];
1117
+ const backupPkg = JSON.parse(fs.readFileSync(path.join(backupRoot, backupTs, 'package.json'), 'utf8'));
1118
+ expect(backupPkg.version).toBe('9.9.9');
1119
+ } finally {
1120
+ fs.rmSync(v1, { force: true });
1121
+ fs.rmSync(v2, { force: true });
1122
+ }
1123
+ });
1124
+
1048
1125
  test('install requires a session when a password is set', async () => {
1049
1126
  ctx = await startServer({ envContent: 'BAIZE_WEB_PASSWORD=secret123\n', extraEnv: a2aEnv() });
1050
1127
  const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/install`, { method: 'POST' });