@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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K4 e2e: `baize upgrade <name> --file <tgz>` — offline commercial upgrade
|
|
3
|
+
* channel. The tarball IS the source (checkForUpdates/downloadToTemp skipped);
|
|
4
|
+
* version gate via K6 getLocalVersion (package.json first); the existing
|
|
5
|
+
* 9-step runUpgrade pipeline (backup → smart merge → npm install → baseline →
|
|
6
|
+
* restart) executes unchanged, with rollback on failure.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import {
|
|
14
|
+
generateManifest,
|
|
15
|
+
loadManifest,
|
|
16
|
+
saveMergeBaseline,
|
|
17
|
+
} from '../cli/lib/manifest.js';
|
|
18
|
+
|
|
19
|
+
const DRIVER = path.join(import.meta.dirname, 'helpers', 'run-upgrade-file-driver.mjs');
|
|
20
|
+
|
|
21
|
+
let tmpRoot;
|
|
22
|
+
let baizeDir;
|
|
23
|
+
let skillsDir;
|
|
24
|
+
let shimDir;
|
|
25
|
+
let failFlag;
|
|
26
|
+
|
|
27
|
+
function mkTmp() {
|
|
28
|
+
return fs.mkdtempSync(path.join(tmpRoot, 'test-'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeFile(dir, relPath, content) {
|
|
32
|
+
const full = path.join(dir, relPath);
|
|
33
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
34
|
+
fs.writeFileSync(full, content);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readFile(dir, relPath) {
|
|
38
|
+
return fs.readFileSync(path.join(dir, relPath), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Install a v1 fixture: real skill dir + authoritative baseline manifest +
|
|
42
|
+
* components.json registration (required: upgrade refuses unregistered comps). */
|
|
43
|
+
function installV1(name) {
|
|
44
|
+
const dest = path.join(skillsDir, name);
|
|
45
|
+
const source = mkTmp();
|
|
46
|
+
writeFile(source, 'a.js', 'v1');
|
|
47
|
+
writeFile(source, 'package.json', JSON.stringify({ name, version: '1.0.0' }));
|
|
48
|
+
writeFile(dest, 'a.js', 'v1');
|
|
49
|
+
writeFile(dest, 'package.json', JSON.stringify({ name, version: '1.0.0' }));
|
|
50
|
+
saveMergeBaseline(dest, source, generateManifest(source));
|
|
51
|
+
const componentsFile = path.join(baizeDir, '.baize', 'components.json');
|
|
52
|
+
fs.mkdirSync(path.dirname(componentsFile), { recursive: true });
|
|
53
|
+
const components = JSON.parse(fs.existsSync(componentsFile) ? fs.readFileSync(componentsFile, 'utf8') : '{}');
|
|
54
|
+
components[name] = {
|
|
55
|
+
version: '1.0.0',
|
|
56
|
+
repo: `baize-ai/${name}`,
|
|
57
|
+
skillDir: dest,
|
|
58
|
+
installedAt: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
fs.writeFileSync(componentsFile, JSON.stringify(components, null, 2));
|
|
61
|
+
return dest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pack a real .tgz (single wrapper dir, npm publish layout). */
|
|
65
|
+
function buildTarball(name, version, { pkgName = name, aContent = `v-${version}` } = {}) {
|
|
66
|
+
const dir = mkTmp();
|
|
67
|
+
writeFile(dir, path.join('package', 'a.js'), aContent);
|
|
68
|
+
writeFile(dir, path.join('package', 'package.json'), JSON.stringify({ name: pkgName, version }));
|
|
69
|
+
const out = path.join(os.tmpdir(), `upgrade-file-${name}-${version}-${Math.random().toString(36).slice(2)}.tgz`);
|
|
70
|
+
execTar(dir);
|
|
71
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
return out;
|
|
73
|
+
|
|
74
|
+
function execTar(from) {
|
|
75
|
+
spawnSync('tar', ['czf', out, '-C', from, 'package'], { encoding: 'utf8' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runFileUpgrade(name, tgzPath, { check = false } = {}) {
|
|
80
|
+
const args = [DRIVER, name, tgzPath];
|
|
81
|
+
if (check) args.push('--check');
|
|
82
|
+
const child = spawnSync(process.execPath, args, {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
env: {
|
|
85
|
+
...process.env,
|
|
86
|
+
BAIZE_DIR: baizeDir,
|
|
87
|
+
PATH: shimDir + path.delimiter + process.env.PATH,
|
|
88
|
+
BAIZE_TEST_BASELINE_COMMIT_FAIL: '0',
|
|
89
|
+
},
|
|
90
|
+
timeout: 60000,
|
|
91
|
+
});
|
|
92
|
+
let body = null;
|
|
93
|
+
try { body = JSON.parse(child.stdout); } catch { /* surfaced below */ }
|
|
94
|
+
return { status: child.status, stderr: child.stderr, body };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeAll(() => {
|
|
98
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'baize-upgrade-file-e2e-'));
|
|
99
|
+
baizeDir = path.join(tmpRoot, 'baize-home');
|
|
100
|
+
skillsDir = path.join(baizeDir, '.claude', 'skills');
|
|
101
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
// npm shim: the pipeline's step4 must never hit the network; the fail flag
|
|
104
|
+
// lets a test exercise rollback-on-npm-install-failure.
|
|
105
|
+
shimDir = path.join(tmpRoot, 'shim-bin');
|
|
106
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
107
|
+
failFlag = path.join(shimDir, 'npm-fail');
|
|
108
|
+
fs.writeFileSync(path.join(shimDir, 'npm'), [
|
|
109
|
+
'#!/bin/sh',
|
|
110
|
+
`if [ -e "${failFlag}" ]; then echo "simulated npm failure" >&2; exit 1; fi`,
|
|
111
|
+
'exit 0',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n'), { mode: 0o755 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterAll(() => {
|
|
117
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('baize upgrade <name> --file <tgz> (K4)', () => {
|
|
121
|
+
test('upgrades through the real 9-step pipeline from a local tarball', () => {
|
|
122
|
+
const name = 'file-upgrade-happy';
|
|
123
|
+
const skillDir = installV1(name);
|
|
124
|
+
const tgz = buildTarball(name, '2.0.0', { aContent: 'v2' });
|
|
125
|
+
|
|
126
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
127
|
+
|
|
128
|
+
expect(status).toBe(0);
|
|
129
|
+
expect(body.success).toBe(true);
|
|
130
|
+
expect(body.from).toBe('1.0.0');
|
|
131
|
+
expect(body.to).toBe('2.0.0');
|
|
132
|
+
expect(body.source).toMatchObject({ type: 'local-tarball' });
|
|
133
|
+
// Pipeline actually ran (steps 1-9 present) — not a shortcut path
|
|
134
|
+
const stepNames = body.steps.map((s) => s.name);
|
|
135
|
+
expect(stepNames).toEqual(expect.arrayContaining(['stop_service', 'backup', 'smart_merge', 'npm_install', 'commit_baseline']));
|
|
136
|
+
// New files landed
|
|
137
|
+
expect(readFile(skillDir, 'a.js')).toBe('v2');
|
|
138
|
+
expect(JSON.parse(readFile(skillDir, 'package.json')).version).toBe('2.0.0');
|
|
139
|
+
// Authoritative baseline advanced to the tarball content
|
|
140
|
+
const manifest = loadManifest(skillDir);
|
|
141
|
+
expect(manifest.files['a.js']).toBeDefined();
|
|
142
|
+
// components.json bumped
|
|
143
|
+
const components = JSON.parse(readFile(path.join(baizeDir, '.baize'), 'components.json'));
|
|
144
|
+
expect(components[name].version).toBe('2.0.0');
|
|
145
|
+
expect(components[name].upgradedAt).toBeTruthy();
|
|
146
|
+
fs.rmSync(tgz, { force: true });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('rejects a tarball whose version is not higher (version_not_higher)', () => {
|
|
150
|
+
const name = 'file-upgrade-gate';
|
|
151
|
+
installV1(name);
|
|
152
|
+
const tgz = buildTarball(name, '1.0.0', { aContent: 'should-not-land' });
|
|
153
|
+
|
|
154
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
155
|
+
|
|
156
|
+
expect(status).toBe(1);
|
|
157
|
+
expect(body.success).toBe(false);
|
|
158
|
+
expect(body.error).toBe('version_not_higher');
|
|
159
|
+
expect(body.local).toBe('1.0.0');
|
|
160
|
+
expect(body.incoming).toBe('1.0.0');
|
|
161
|
+
// Installation untouched
|
|
162
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
163
|
+
const components = JSON.parse(readFile(path.join(baizeDir, '.baize'), 'components.json'));
|
|
164
|
+
expect(components[name].version).toBe('1.0.0');
|
|
165
|
+
fs.rmSync(tgz, { force: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('rejects a downgrade the same way', () => {
|
|
169
|
+
const name = 'file-upgrade-downgrade';
|
|
170
|
+
installV1(name);
|
|
171
|
+
const tgz = buildTarball(name, '0.9.0', { aContent: 'old-code' });
|
|
172
|
+
|
|
173
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
174
|
+
|
|
175
|
+
expect(status).toBe(1);
|
|
176
|
+
expect(body.error).toBe('version_not_higher');
|
|
177
|
+
expect(body.incoming).toBe('0.9.0');
|
|
178
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
179
|
+
fs.rmSync(tgz, { force: true });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('rejects a tarball whose package name mismatches the installed component', () => {
|
|
183
|
+
const name = 'file-upgrade-mismatch';
|
|
184
|
+
installV1(name);
|
|
185
|
+
const tgz = buildTarball(name, '2.0.0', { pkgName: 'some-other-package' });
|
|
186
|
+
|
|
187
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
188
|
+
|
|
189
|
+
expect(status).toBe(1);
|
|
190
|
+
expect(body.success).toBe(false);
|
|
191
|
+
expect(body.error).toMatch(/manifest name mismatch/);
|
|
192
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
193
|
+
fs.rmSync(tgz, { force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('rolls back the installation when npm install fails mid-pipeline', () => {
|
|
197
|
+
const name = 'file-upgrade-rollback';
|
|
198
|
+
const skillDir = installV1(name);
|
|
199
|
+
const tgz = buildTarball(name, '2.0.0', { aContent: 'v2' });
|
|
200
|
+
|
|
201
|
+
fs.writeFileSync(failFlag, '');
|
|
202
|
+
try {
|
|
203
|
+
const { status, body } = runFileUpgrade(name, tgz);
|
|
204
|
+
expect(status).toBe(1);
|
|
205
|
+
expect(body.success).toBe(false);
|
|
206
|
+
expect(body.failedStep).toBe(4);
|
|
207
|
+
expect(body.rollback?.performed).toBe(true);
|
|
208
|
+
// Original code restored from .backup
|
|
209
|
+
expect(readFile(skillDir, 'a.js')).toBe('v1');
|
|
210
|
+
expect(JSON.parse(readFile(skillDir, 'package.json')).version).toBe('1.0.0');
|
|
211
|
+
} finally {
|
|
212
|
+
fs.rmSync(failFlag, { force: true });
|
|
213
|
+
}
|
|
214
|
+
fs.rmSync(tgz, { force: true });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('--check compares versions without touching the installation', () => {
|
|
218
|
+
const name = 'file-upgrade-check';
|
|
219
|
+
installV1(name);
|
|
220
|
+
const tgz = buildTarball(name, '2.0.0');
|
|
221
|
+
|
|
222
|
+
const { status, body } = runFileUpgrade(name, tgz, { check: true });
|
|
223
|
+
|
|
224
|
+
expect(status).toBe(0);
|
|
225
|
+
expect(body).toMatchObject({ action: 'check', success: true, hasUpdate: true, current: '1.0.0', latest: '2.0.0' });
|
|
226
|
+
expect(readFile(path.join(skillsDir, name), 'a.js')).toBe('v1');
|
|
227
|
+
fs.rmSync(tgz, { force: true });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K6: getLocalVersion must read skillDir/package.json version first (SKILL.md
|
|
3
|
+
* frontmatter is stale by design — it historically stayed at 0.1.0 while
|
|
4
|
+
* package.json advanced, breaking upgrade version gating). SKILL.md remains
|
|
5
|
+
* the fallback for components that never shipped a package.json.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
|
|
11
|
+
import { getLocalVersion } from '../cli/lib/upgrade.js';
|
|
12
|
+
|
|
13
|
+
let tmp;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'local-version-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function write(rel, content) {
|
|
24
|
+
const full = path.join(tmp, rel);
|
|
25
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
26
|
+
fs.writeFileSync(full, content);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('getLocalVersion reads package.json first (K6)', () => {
|
|
30
|
+
test('package.json version wins over SKILL.md frontmatter', () => {
|
|
31
|
+
write('SKILL.md', '---\nname: a2a\nversion: 0.1.0\n---\n# A2A\n');
|
|
32
|
+
write('package.json', JSON.stringify({ name: 'x', version: '0.2.0' }));
|
|
33
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '0.2.0' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('falls back to SKILL.md frontmatter when package.json is absent', () => {
|
|
37
|
+
write('SKILL.md', '---\nname: a2a\nversion: 1.2.3\n---\n# A2A\n');
|
|
38
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '1.2.3' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('falls back to SKILL.md when package.json version is invalid semver', () => {
|
|
42
|
+
write('SKILL.md', '---\nname: a2a\nversion: 3.4.5\n---\n# A2A\n');
|
|
43
|
+
write('package.json', JSON.stringify({ name: 'x', version: 'not-a-version' }));
|
|
44
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '3.4.5' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('falls back to SKILL.md when package.json is unparseable', () => {
|
|
48
|
+
write('SKILL.md', '---\nname: a2a\nversion: 3.4.5\n---\n# A2A\n');
|
|
49
|
+
write('package.json', '{broken');
|
|
50
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '3.4.5' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('prerelease versions in package.json are accepted', () => {
|
|
54
|
+
write('package.json', JSON.stringify({ name: 'x', version: '0.2.0-beta.1' }));
|
|
55
|
+
expect(getLocalVersion(tmp)).toEqual({ success: true, version: '0.2.0-beta.1' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('fails when neither source has a version', () => {
|
|
59
|
+
write('SKILL.md', '---\nname: a2a\n---\n# A2A\n');
|
|
60
|
+
const result = getLocalVersion(tmp);
|
|
61
|
+
expect(result.success).toBe(false);
|
|
62
|
+
expect(result.error).toMatch(/Version not found/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('fails for a missing skill dir', () => {
|
|
66
|
+
const missing = path.join(tmp, 'nope');
|
|
67
|
+
const result = getLocalVersion(missing);
|
|
68
|
+
expect(result.success).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -827,6 +827,11 @@ switch (args[0]) {
|
|
|
827
827
|
break;
|
|
828
828
|
case 'enable': out({ ok: true, ...(mode ? { mode } : {}) }); break;
|
|
829
829
|
case 'disable': out({ ok: true, ...(mode ? { mode } : {}) }); break;
|
|
830
|
+
case 'restart':
|
|
831
|
+
// D49 K3: upgrade pipeline must restart the daemon via the a2a cli.
|
|
832
|
+
require('fs').writeFileSync(process.env.BAIZE_A2A_PATH + '/restart-called', '1');
|
|
833
|
+
out({ ok: true });
|
|
834
|
+
break;
|
|
830
835
|
default: console.error('unknown: ' + args.join(' ')); process.exit(1);
|
|
831
836
|
}
|
|
832
837
|
`);
|
|
@@ -1030,21 +1035,95 @@ switch (args[0]) {
|
|
|
1030
1035
|
});
|
|
1031
1036
|
});
|
|
1032
1037
|
|
|
1033
|
-
test('
|
|
1038
|
+
test('second upload with the same version is rejected as version_not_higher (409)', async () => {
|
|
1034
1039
|
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
1035
1040
|
const tgz = buildTarball({ 'package.json': validManifest });
|
|
1036
1041
|
try {
|
|
1037
1042
|
const first = await installUpload(ctx, tgz);
|
|
1038
1043
|
expect(first.status).toBe(200);
|
|
1039
1044
|
const second = await installUpload(ctx, tgz);
|
|
1040
|
-
expect(second.status).toBe(
|
|
1041
|
-
expect(second.body).toMatchObject({
|
|
1042
|
-
|
|
1045
|
+
expect(second.status).toBe(409);
|
|
1046
|
+
expect(second.body).toMatchObject({
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: 'version_not_higher',
|
|
1049
|
+
local: '9.9.9',
|
|
1050
|
+
incoming: '9.9.9',
|
|
1051
|
+
});
|
|
1052
|
+
// Installation untouched by the rejected upload
|
|
1053
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'package.json'), 'utf8'));
|
|
1054
|
+
expect(pkg.version).toBe('9.9.9');
|
|
1043
1055
|
} finally {
|
|
1044
1056
|
fs.rmSync(tgz, { force: true });
|
|
1045
1057
|
}
|
|
1046
1058
|
});
|
|
1047
1059
|
|
|
1060
|
+
test('uploading a lower version than installed is rejected with local/incoming detail', async () => {
|
|
1061
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
1062
|
+
const v1 = buildTarball({ 'package.json': validManifest });
|
|
1063
|
+
const v0 = buildTarball({ 'package.json': JSON.stringify({ name: '@baize-ai/baize-a2a', version: '1.0.0' }) });
|
|
1064
|
+
try {
|
|
1065
|
+
expect((await installUpload(ctx, v1)).status).toBe(200);
|
|
1066
|
+
const downgrade = await installUpload(ctx, v0);
|
|
1067
|
+
expect(downgrade.status).toBe(409);
|
|
1068
|
+
expect(downgrade.body).toMatchObject({ ok: false, error: 'version_not_higher', local: '9.9.9', incoming: '1.0.0' });
|
|
1069
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'package.json'), 'utf8'));
|
|
1070
|
+
expect(pkg.version).toBe('9.9.9');
|
|
1071
|
+
} finally {
|
|
1072
|
+
fs.rmSync(v1, { force: true });
|
|
1073
|
+
fs.rmSync(v0, { force: true });
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test('uploading a higher version upgrades in place: files replaced, components.json bumped, a2a cli restart called', async () => {
|
|
1078
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
1079
|
+
const v1 = buildTarball({
|
|
1080
|
+
'package.json': validManifest,
|
|
1081
|
+
'SKILL.md': '---\nname: a2a\n---\n# A2A v9\n',
|
|
1082
|
+
});
|
|
1083
|
+
const v2 = buildTarball({
|
|
1084
|
+
'package.json': JSON.stringify({ name: '@baize-ai/baize-a2a', version: '10.0.0' }),
|
|
1085
|
+
'SKILL.md': '---\nname: a2a\nversion: 10.0.0\n---\n# A2A v10\n',
|
|
1086
|
+
'new-module.js': 'export const fresh = true;\n',
|
|
1087
|
+
});
|
|
1088
|
+
try {
|
|
1089
|
+
expect((await installUpload(ctx, v1)).status).toBe(200);
|
|
1090
|
+
const second = await installUpload(ctx, v2);
|
|
1091
|
+
expect(second.status).toBe(200);
|
|
1092
|
+
expect(second.body).toMatchObject({
|
|
1093
|
+
ok: true,
|
|
1094
|
+
version: '10.0.0',
|
|
1095
|
+
previousVersion: '9.9.9',
|
|
1096
|
+
upgraded: true,
|
|
1097
|
+
restarted: true,
|
|
1098
|
+
});
|
|
1099
|
+
// K3/K5: the upgrade restarted the daemon through the a2a cli
|
|
1100
|
+
expect(fs.existsSync(path.join(fakeA2aDir, 'restart-called'))).toBe(true);
|
|
1101
|
+
// Files replaced in place
|
|
1102
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'package.json'), 'utf8'));
|
|
1103
|
+
expect(pkg.version).toBe('10.0.0');
|
|
1104
|
+
expect(fs.readFileSync(path.join(ctx.skillsDir, 'a2a', 'new-module.js'), 'utf8')).toContain('fresh');
|
|
1105
|
+
// Registration preserved, version bumped, both timestamps present
|
|
1106
|
+
const components = JSON.parse(fs.readFileSync(path.join(ctx.root, '.baize', 'components.json'), 'utf8'));
|
|
1107
|
+
expect(components.a2a).toMatchObject({
|
|
1108
|
+
version: '10.0.0',
|
|
1109
|
+
repo: 'baize-ai/baize-a2a',
|
|
1110
|
+
skillDir: path.join(ctx.skillsDir, 'a2a'),
|
|
1111
|
+
});
|
|
1112
|
+
expect(components.a2a.installedAt).toBeTruthy();
|
|
1113
|
+
expect(components.a2a.upgradedAt).toBeTruthy();
|
|
1114
|
+
// Backup taken before any write (upgrade.js .backup convention)
|
|
1115
|
+
const backupRoot = path.join(ctx.skillsDir, 'a2a', '.backup');
|
|
1116
|
+
expect(fs.readdirSync(backupRoot)).toHaveLength(1);
|
|
1117
|
+
// Backup holds the pre-upgrade code
|
|
1118
|
+
const backupTs = fs.readdirSync(backupRoot)[0];
|
|
1119
|
+
const backupPkg = JSON.parse(fs.readFileSync(path.join(backupRoot, backupTs, 'package.json'), 'utf8'));
|
|
1120
|
+
expect(backupPkg.version).toBe('9.9.9');
|
|
1121
|
+
} finally {
|
|
1122
|
+
fs.rmSync(v1, { force: true });
|
|
1123
|
+
fs.rmSync(v2, { force: true });
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1048
1127
|
test('install requires a session when a password is set', async () => {
|
|
1049
1128
|
ctx = await startServer({ envContent: 'BAIZE_WEB_PASSWORD=secret123\n', extraEnv: a2aEnv() });
|
|
1050
1129
|
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/install`, { method: 'POST' });
|