Files
evolver-publish 141025d024 Release v1.92.0
2026-07-16 15:18:45 +08:00

1083 lines
43 KiB
JavaScript

// End-to-end test for `node index.js sync` dedup logic.
//
// We spin up an in-process HTTP mock for /a2a/assets/published-by-me (and
// purchased) so we can verify what the CLI does given a controlled set of
// Hub responses. The two failure modes we want to lock down:
//
// 1. A bundled default-seed gene (id e.g. `gene_gep_repair_from_errors`,
// no hub_asset_id) must NOT cause Hub copies of the same id to silently
// skip on first sync. We allow the default to win unless the user
// passes --force, but the run must clearly report "id_collision" so
// the user understands why nothing changed.
// 2. With --force, the local entry is overwritten with the Hub copy and a
// hub_asset_id is recorded; subsequent runs without --force become
// no-ops via the hub_asset_id dedup check.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const { spawn } = require('child_process');
function startMock(handlers) {
const requests = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
requests.push({ method: req.method, pathname: url.pathname, headers: req.headers, url });
const route = handlers[url.pathname];
if (!route) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not_found' }));
return;
}
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
try {
const result = route({ url, body, headers: req.headers });
res.writeHead(result.status || 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body || {}));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
resolve({ server, url: 'http://127.0.0.1:' + addr.port, requests });
});
});
}
// Async version of spawn so the in-process mock HTTP server keeps servicing
// requests while the child runs. spawnSync would block this event loop and
// deadlock against our own mock.
function runSync(env, extraArgs) {
const cwd = path.resolve(__dirname, '..');
const argv = ['index.js', 'sync', '--scope=published'].concat(extraArgs || []);
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, argv, {
cwd,
env: { ...process.env, EVOMAP_HUB_ALLOW_INSECURE: '1', ...env },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
const t = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error('runSync timed out\nstdout=' + stdout + '\nstderr=' + stderr));
}, 15000);
child.on('exit', (status, signal) => {
clearTimeout(t);
resolve({ stdout, stderr, status, signal });
});
child.on('error', (e) => { clearTimeout(t); reject(e); });
});
}
function mkSandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-'));
const assetsDir = path.join(root, 'assets', 'gep');
fs.mkdirSync(assetsDir, { recursive: true });
return { root, assetsDir };
}
function listFilesRecursive(root) {
if (!fs.existsSync(root)) return [];
const files = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
files.push(...listFilesRecursive(fullPath).map((relativePath) => path.join(entry.name, relativePath)));
}
else files.push(path.relative(root, fullPath));
}
return files.sort();
}
function snapshotFiles(root) {
return Object.fromEntries(listFilesRecursive(root).map((relativePath) => [
relativePath,
fs.readFileSync(path.join(root, relativePath)).toString('base64'),
]));
}
function unauthenticatedEnv(evolverHome) {
return {
A2A_NODE_ID: '',
A2A_NODE_SECRET: '',
EVOMAP_NODE_SECRET: '',
A2A_NODE_SECRET_VERSION: '',
EVOMAP_NODE_SECRET_VERSION: '',
A2A_HUB_TOKEN: '',
EVOLVER_HOME: evolverHome,
GEP_ASSETS_DIR: path.join(evolverHome, 'assets', 'gep'),
};
}
function writePersistedIdentity(evolverHome, { nodeId, secret, version, source, suppression }) {
fs.mkdirSync(evolverHome, { recursive: true });
if (nodeId) fs.writeFileSync(path.join(evolverHome, 'node_id'), nodeId);
if (secret) fs.writeFileSync(path.join(evolverHome, 'node_secret'), secret);
if (version) fs.writeFileSync(path.join(evolverHome, 'node_secret_version'), String(version));
if (source) fs.writeFileSync(path.join(evolverHome, 'node_secret_source'), source);
if (suppression) fs.writeFileSync(path.join(evolverHome, 'node_secret_env_suppressed'), suppression);
}
function writeMailboxIdentity(evolverHome, state) {
const mailboxDir = path.join(evolverHome, 'mailbox');
fs.mkdirSync(mailboxDir, { recursive: true });
fs.writeFileSync(path.join(mailboxDir, 'state.json'), JSON.stringify(state, null, 2) + '\n');
}
describe('sync dedup (id collision vs hub_asset_id)', () => {
let mock;
before(async () => {
const hubAsset = {
asset_id: 'hub-asset-aaaa1111',
asset_type: 'Gene',
local_id: 'gene_gep_repair_from_errors',
payload: {
id: 'gene_gep_repair_from_errors',
category: 'repair',
signals_match: ['error'],
strategy: ['hub-strategy-step'],
validation: [],
summary: 'hub copy',
},
};
mock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: { assets: [hubAsset], count: 1, has_more: false, next_cursor: null, node_ids: ['node-test'] },
}),
'/a2a/assets/purchased': () => ({
body: { assets: [], count: 0, has_more: false, next_cursor: null, node_ids: ['node-test'] },
}),
// Detail endpoint not needed because we ship payload inline.
});
});
after(() => {
if (mock) mock.server.close();
});
it('skips a hub asset whose local_id matches a default-seed gene (no --force)', async () => {
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: 'gene_gep_repair_from_errors', strategy: ['local-default'] }] }, null, 2)
);
const r = await runSync({
A2A_HUB_URL: mock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
});
assert.equal(r.status, 0, 'sync should exit 0; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stdout, /id_collision=1/);
assert.match(r.stdout, /--force/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.equal(genes.length, 1);
assert.equal(genes[0].strategy[0], 'local-default', 'local default must be preserved');
assert.equal(genes[0].hub_asset_id, undefined, 'no hub_asset_id should be written without --force');
});
it('overwrites the default-seed gene with the Hub copy when --force is set', async () => {
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: 'gene_gep_repair_from_errors', strategy: ['local-default'] }] }, null, 2)
);
const r = await runSync({
A2A_HUB_URL: mock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
}, ['--force']);
assert.equal(r.status, 0, 'sync should exit 0; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stdout, /synced=1/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.equal(genes.length, 1);
assert.equal(genes[0].strategy[0], 'hub-strategy-step', 'Hub strategy must overwrite local default');
assert.equal(genes[0].hub_asset_id, 'hub-asset-aaaa1111');
});
it('is idempotent on a second run: hub_asset_id match -> already_synced (not re-fetched)', async () => {
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({
version: 1,
genes: [{
id: 'gene_gep_repair_from_errors',
strategy: ['hub-strategy-step'],
hub_asset_id: 'hub-asset-aaaa1111',
synced_at: '2026-05-04T00:00:00.000Z',
}],
}, null, 2)
);
const r = await runSync({
A2A_HUB_URL: mock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
});
assert.equal(r.status, 0);
assert.match(r.stdout, /already_synced=1/);
assert.match(r.stdout, /id_collision=0/);
assert.doesNotMatch(r.stdout, /--force/, 'no --force suggestion when there is no real id collision');
});
it('does not seed or create asset-store files in dry-run mode', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-'));
const assetsDir = path.join(root, 'missing', 'gep');
const evolverHome = path.join(root, 'home');
const exportPath = path.join(root, 'dry-run.gepx');
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
EVOLVER_HOME: evolverHome,
GEP_ASSETS_DIR: assetsDir,
}, ['--dry-run', '--force', '--export=' + exportPath]);
assert.equal(r.status, 0, 'dry-run should exit 0; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stdout, /\[dry-run\] Would sync: Gene hub-asset-aaaa1111/);
assert.match(r.stdout, /dry-run mode: no asset-store or export files were modified/);
assert.match(r.stdout, /\[dry-run\] Would export to /);
const requests = mock.requests.slice(requestCountBefore);
assert.equal(requests.filter((request) => request.pathname === '/a2a/assets/hub-asset-aaaa1111').length, 0,
'inline list payload must avoid an unnecessary detail request');
assert.equal(fs.existsSync(assetsDir), false, 'dry-run must not create the asset-store directory');
assert.equal(fs.existsSync(evolverHome), false, 'authenticated dry-run must not create identity state');
assert.equal(fs.existsSync(exportPath), false, 'dry-run must not create an export bundle');
assert.deepEqual(listFilesRecursive(root), [], 'dry-run must not create store, lock, temp, or export files');
});
it('counts a dry-run detail HTTP failure as a fetch error without reporting success', async () => {
const detailAssetId = 'hub-asset-detail-500';
const detailMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{ asset_id: detailAssetId, asset_type: 'Gene', local_id: 'gene_detail_500' }],
count: 1,
has_more: false,
next_cursor: null,
},
}),
['/a2a/assets/' + detailAssetId]: () => ({ status: 500, body: { error: 'detail unavailable' } }),
});
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-detail-500-'));
const assetsDir = path.join(root, 'missing', 'gep');
const evolverHome = path.join(root, 'home');
try {
const r = await runSync({
A2A_HUB_URL: detailMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
EVOLVER_HOME: evolverHome,
GEP_ASSETS_DIR: assetsDir,
}, ['--dry-run']);
assert.equal(r.status, 1, 'dry-run should fail when an asset cannot be validated; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stdout, /synced=0/);
assert.match(r.stdout, /errors=1/);
assert.doesNotMatch(r.stdout, /Would sync:/);
assert.ok(detailMock.requests.some((request) =>
request.method === 'GET' && request.pathname === '/a2a/assets/' + detailAssetId));
assert.deepEqual(listFilesRecursive(root), [], 'failed dry-run detail fetch must not write any files');
} finally {
detailMock.server.close();
}
});
it('falls back to an id-collision skip when detail fetch fails for a listed local id', async () => {
const assetId = 'hub-asset-collision-detail-500';
const localId = 'gene_collision_detail_500';
const detailMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{ asset_id: assetId, asset_type: 'Gene', local_id: localId }],
count: 1,
has_more: false,
next_cursor: null,
},
}),
['/a2a/assets/' + assetId]: () => ({ status: 500, body: { error: 'detail unavailable' } }),
});
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: localId, strategy: ['keep-local'] }] }, null, 2)
);
try {
const result = await runSync({
A2A_HUB_URL: detailMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
});
assert.equal(result.status, 0, 'known local collision must fail closed; stdout=' + result.stdout + ' stderr=' + result.stderr);
assert.match(result.stdout, /synced=0/);
assert.match(result.stdout, /id_collision=1/);
assert.match(result.stdout, /errors=0/);
assert.match(result.stdout, /--force/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.deepEqual(genes, [{ id: localId, strategy: ['keep-local'] }]);
} finally {
detailMock.server.close();
}
});
it('fetches and validates missing list payload during dry-run before reporting success', async () => {
const detailAssetId = 'hub-asset-detail-success';
const detailMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{ asset_id: detailAssetId, asset_type: 'Gene', local_id: 'gene_detail_success' }],
count: 1,
has_more: false,
next_cursor: null,
},
}),
['/a2a/assets/' + detailAssetId]: () => ({
body: {
payload: {
id: 'gene_detail_success',
category: 'repair',
signals_match: ['error'],
strategy: ['validated-detail-step'],
validation: [],
},
},
}),
});
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-detail-success-'));
const assetsDir = path.join(root, 'missing', 'gep');
const evolverHome = path.join(root, 'home');
try {
const r = await runSync({
A2A_HUB_URL: detailMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
EVOLVER_HOME: evolverHome,
GEP_ASSETS_DIR: assetsDir,
}, ['--dry-run']);
assert.equal(r.status, 0, 'dry-run detail fetch should succeed; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stdout, new RegExp('Would sync: Gene ' + detailAssetId));
assert.match(r.stdout, /synced=1/);
assert.match(r.stdout, /errors=0/);
assert.ok(detailMock.requests.some((request) =>
request.method === 'GET' && request.pathname === '/a2a/assets/' + detailAssetId));
assert.deepEqual(listFilesRecursive(root), [], 'successful dry-run detail fetch must remain read-only');
} finally {
detailMock.server.close();
}
});
it('preserves standard Gene fields in dry-run validation and real sync', async () => {
const assetId = 'hub-asset-standard-gene';
const genePayload = {
id: 'gene_standard_contract',
category: 'repair',
signals_match: ['error'],
strategy: ['inspect logs'],
validation: ['node --test'],
preconditions: ['tests exist'],
constraints: { max_files: 3, forbidden_paths: ['.git'] },
anti_patterns: ['ignore failures'],
routing_hint: { tier: 'mid', reasoning_level: 'high' },
summary: 'standard contract gene',
};
const standardMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{ asset_id: assetId, asset_type: 'Gene', local_id: genePayload.id, payload: genePayload }],
count: 1,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
try {
const env = {
A2A_HUB_URL: standardMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
};
const dryRunResult = await runSync(env, ['--dry-run', '--force']);
const realResult = await runSync(env, ['--force']);
assert.equal(dryRunResult.status, 0, 'dry-run should validate the standard Gene; stdout=' + dryRunResult.stdout + ' stderr=' + dryRunResult.stderr);
assert.match(dryRunResult.stdout, new RegExp('Would sync: Gene ' + assetId));
assert.equal(realResult.status, 0, 'real sync should persist the same validated Gene; stdout=' + realResult.stdout + ' stderr=' + realResult.stderr);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
const syncedGene = genes.find((gene) => gene.hub_asset_id === assetId);
assert.ok(syncedGene);
assert.deepEqual(syncedGene.signals_match, genePayload.signals_match);
assert.deepEqual(syncedGene.preconditions, genePayload.preconditions);
assert.deepEqual(syncedGene.constraints, genePayload.constraints);
assert.deepEqual(syncedGene.anti_patterns, genePayload.anti_patterns);
assert.deepEqual(syncedGene.routing_hint, genePayload.routing_hint);
} finally {
standardMock.server.close();
}
});
it('rejects invalid Gene payloads consistently without writing them', async () => {
const assetId = 'hub-asset-invalid-gene';
const invalidMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{
asset_id: assetId,
asset_type: 'Gene',
local_id: 'gene_invalid_contract',
payload: { id: 'gene_invalid_contract', category: 'repair', signals: ['wrong-field'], strategy: ['step'] },
}],
count: 1,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
try {
const env = {
A2A_HUB_URL: invalidMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
};
const dryRunResult = await runSync(env, ['--dry-run', '--force']);
const realResult = await runSync(env, ['--force']);
assert.equal(dryRunResult.status, 1);
assert.equal(realResult.status, 1);
assert.match(dryRunResult.stdout, /errors=1/);
assert.doesNotMatch(dryRunResult.stdout, /Would sync:/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.equal(genes.some((gene) => gene.hub_asset_id === assetId), false);
} finally {
invalidMock.server.close();
}
});
it('falls back to an id-collision skip when a listed local id has a malformed payload', async () => {
const assetId = 'hub-asset-collision-malformed';
const localId = 'gene_collision_malformed';
const malformedMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{
asset_id: assetId,
asset_type: 'Gene',
local_id: localId,
payload: { id: localId, category: 'repair', strategy: ['invalid-without-signals-match'] },
}],
count: 1,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: localId, strategy: ['keep-local'] }] }, null, 2)
);
try {
const result = await runSync({
A2A_HUB_URL: malformedMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
});
assert.equal(result.status, 0, 'known local collision must fail closed; stdout=' + result.stdout + ' stderr=' + result.stderr);
assert.match(result.stdout, /id_collision=1/);
assert.match(result.stdout, /errors=0/);
assert.match(result.stdout, /--force/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.deepEqual(genes, [{ id: localId, strategy: ['keep-local'] }]);
} finally {
malformedMock.server.close();
}
});
it('uses the prepared payload id for collision safety when the list local id is an alias', async () => {
const assetId = 'hub-asset-list-id-alias';
const listLocalId = 'gene_list_alias';
const payloadId = 'gene_effective_collision';
const aliasMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{
asset_id: assetId,
asset_type: 'Gene',
local_id: listLocalId,
payload: {
id: payloadId,
category: 'repair',
signals_match: ['error'],
strategy: ['hub-copy'],
validation: [],
},
}],
count: 1,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: payloadId, strategy: ['keep-local'] }] }, null, 2)
);
try {
const result = await runSync({
A2A_HUB_URL: aliasMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
});
assert.equal(result.status, 0, 'effective payload id collision must be skipped; stdout=' + result.stdout + ' stderr=' + result.stderr);
assert.match(result.stdout, /id_collision=1/);
assert.match(result.stdout, /errors=0/);
assert.match(result.stdout, /--force/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.deepEqual(genes, [{ id: payloadId, strategy: ['keep-local'] }]);
} finally {
aliasMock.server.close();
}
});
it('rejects duplicate effective local ids in one Hub batch instead of overwriting', async () => {
const duplicateMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: ['first', 'second'].map((suffix) => ({
asset_id: 'hub-asset-' + suffix,
asset_type: 'Gene',
local_id: 'gene_duplicate',
payload: {
id: 'gene_duplicate',
category: 'repair',
signals_match: ['error'],
strategy: [suffix],
validation: [],
},
})),
count: 2,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
try {
const result = await runSync({
A2A_HUB_URL: duplicateMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
}, ['--force']);
assert.equal(result.status, 1);
assert.match(result.stdout, /synced=1/);
assert.match(result.stdout, /errors=1/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
const duplicates = genes.filter((gene) => gene.id === 'gene_duplicate');
assert.equal(duplicates.length, 1);
assert.equal(duplicates[0].hub_asset_id, 'hub-asset-first');
} finally {
duplicateMock.server.close();
}
});
it('still reports a duplicate batch error after the first prepared asset hits a local collision', async () => {
const localId = 'gene_duplicate_collision';
const duplicateMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: ['first', 'second'].map((suffix) => ({
asset_id: 'hub-collision-' + suffix,
asset_type: 'Gene',
local_id: localId,
payload: {
id: localId,
category: 'repair',
signals_match: ['error'],
strategy: [suffix],
validation: [],
},
})),
count: 2,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: localId, strategy: ['keep-local'] }] }, null, 2)
);
try {
const result = await runSync({
A2A_HUB_URL: duplicateMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
});
assert.equal(result.status, 1, 'duplicate prepared ids must remain an error; stdout=' + result.stdout + ' stderr=' + result.stderr);
assert.match(result.stdout, /synced=0/);
assert.match(result.stdout, /id_collision=1/);
assert.match(result.stdout, /errors=1/);
const genes = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes;
assert.deepEqual(genes, [{ id: localId, strategy: ['keep-local'] }]);
} finally {
duplicateMock.server.close();
}
});
it('keeps an upsert I/O failure as an error instead of reclassifying it as a collision', async () => {
const assetId = 'hub-asset-upsert-io-error';
const localId = 'gene_upsert_io_error';
const { assetsDir } = mkSandbox();
fs.writeFileSync(
path.join(assetsDir, 'genes.json'),
JSON.stringify({ version: 1, genes: [{ id: localId, strategy: ['existing-local'] }] }, null, 2)
);
const movedAssetsDir = assetsDir + '-before-io-error';
const ioErrorMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [{ asset_id: assetId, asset_type: 'Gene', local_id: localId }],
count: 1,
has_more: false,
next_cursor: null,
},
}),
['/a2a/assets/' + assetId]: () => {
// The CLI loads the local store before requesting detail. Replace the
// directory now so the validated asset reaches upsert and fails there.
fs.renameSync(assetsDir, movedAssetsDir);
fs.writeFileSync(assetsDir, 'not a directory');
return {
body: {
payload: {
id: localId,
category: 'repair',
signals_match: ['error'],
strategy: ['hub-copy'],
validation: [],
},
},
};
},
});
try {
const result = await runSync({
A2A_HUB_URL: ioErrorMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
}, ['--force']);
assert.equal(result.status, 1, 'upsert I/O failure must remain an error; stdout=' + result.stdout + ' stderr=' + result.stderr);
assert.match(result.stdout, /synced=0/);
assert.match(result.stdout, /id_collision=0/);
assert.match(result.stdout, /errors=1/);
const genes = JSON.parse(fs.readFileSync(path.join(movedAssetsDir, 'genes.json'), 'utf8')).genes;
assert.deepEqual(genes, [{ id: localId, strategy: ['existing-local'] }]);
} finally {
ioErrorMock.server.close();
}
});
it('refuses unauthenticated dry-run without registering or writing identity state', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-auth-'));
const evolverHome = path.join(root, 'home');
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
}, ['--dry-run']);
assert.notEqual(r.status, 0, 'unauthenticated dry-run must fail; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stderr, /Dry-run requires an existing node_secret/);
assert.deepEqual(mock.requests.slice(requestCountBefore), [], 'dry-run must not call any Hub endpoint');
assert.equal(fs.existsSync(evolverHome), false, 'dry-run must not create EVOLVER_HOME');
assert.deepEqual(listFilesRecursive(root), [], 'dry-run must not create identity or asset-store files');
});
it('refuses env-secret dry-run without generating a node id', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-node-id-'));
const evolverHome = path.join(root, 'home');
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
A2A_NODE_SECRET: 'b'.repeat(64),
}, ['--dry-run']);
assert.notEqual(r.status, 0, 'dry-run without node id must fail; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stderr, /Dry-run requires an existing node_id/);
assert.deepEqual(mock.requests.slice(requestCountBefore), [], 'dry-run must fail before any Hub request');
assert.equal(fs.existsSync(evolverHome), false, 'dry-run must not generate node_id or EVOLVER_HOME');
assert.deepEqual(listFilesRecursive(root), []);
});
it('keeps persisted rotated-only identity byte-for-byte unchanged during dry-run', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-persisted-'));
const evolverHome = path.join(root, 'home');
writePersistedIdentity(evolverHome, {
nodeId: 'node_bbbbbbbbbbbb',
secret: 'c'.repeat(64),
version: 7,
source: 'hub_rotate',
});
const before = snapshotFiles(evolverHome);
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
}, ['--dry-run']);
assert.equal(r.status, 0, 'persisted rotated dry-run should succeed; stdout=' + r.stdout + ' stderr=' + r.stderr);
const requests = mock.requests.slice(requestCountBefore);
assert.ok(requests.length > 0);
assert.ok(requests.every((request) => request.headers.authorization === 'Bearer ' + 'c'.repeat(64)));
assert.ok(requests.every((request) => request.headers['x-evomap-node-secret-version'] === '7'));
assert.deepEqual(snapshotFiles(evolverHome), before, 'dry-run must not backfill mailbox state');
});
it('keeps mailbox rotated-only identity byte-for-byte unchanged during dry-run', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-mailbox-'));
const evolverHome = path.join(root, 'home');
writePersistedIdentity(evolverHome, { nodeId: 'node_cccccccccccc' });
writeMailboxIdentity(evolverHome, {
node_secret: 'd'.repeat(64),
node_secret_version: '8',
node_secret_source: 'hub_rotate',
marker: 'preserve-me',
});
const before = snapshotFiles(evolverHome);
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
}, ['--dry-run']);
assert.equal(r.status, 0, 'mailbox rotated dry-run should succeed; stdout=' + r.stdout + ' stderr=' + r.stderr);
const requests = mock.requests.slice(requestCountBefore);
assert.ok(requests.length > 0);
assert.ok(requests.every((request) => request.headers.authorization === 'Bearer ' + 'd'.repeat(64)));
assert.ok(requests.every((request) => request.headers['x-evomap-node-secret-version'] === '8'));
assert.deepEqual(snapshotFiles(evolverHome), before, 'dry-run must not backfill persisted secret files');
});
it('uses mailbox-only identity during dry-run without changing any bytes', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-mailbox-only-'));
const evolverHome = path.join(root, 'home');
const nodeSecret = '1'.repeat(64);
writeMailboxIdentity(evolverHome, {
node_id: 'node_eeeeeeeeeeee',
node_secret: nodeSecret,
marker: 'preserve-mailbox-only',
});
const before = snapshotFiles(evolverHome);
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
}, ['--dry-run']);
assert.equal(r.status, 0, 'mailbox-only dry-run should succeed; stdout=' + r.stdout + ' stderr=' + r.stderr);
const requests = mock.requests.slice(requestCountBefore);
assert.ok(requests.length > 0, 'mailbox-only dry-run should perform Hub GET requests');
assert.ok(requests.every((request) => request.method === 'GET'));
assert.ok(requests.every((request) => request.headers.authorization === 'Bearer ' + nodeSecret));
assert.ok(requests.every((request) => request.pathname === '/a2a/assets/published-by-me'));
assert.deepEqual(snapshotFiles(evolverHome), before, 'dry-run must not backfill or rewrite mailbox-only identity');
});
it('uses the same mailbox-only node identity for dry-run and real sync', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-mailbox-identity-parity-'));
const evolverHome = path.join(root, 'home');
const assetsDir = path.join(root, 'assets', 'gep');
const nodeId = 'node_eeeeeeeeeeee';
writeMailboxIdentity(evolverHome, {
node_id: nodeId,
node_secret: '1'.repeat(64),
});
const requestCountBefore = mock.requests.length;
const dryRunResult = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
GEP_ASSETS_DIR: assetsDir,
}, ['--dry-run', '--force']);
const realResult = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
GEP_ASSETS_DIR: assetsDir,
}, ['--force']);
assert.equal(dryRunResult.status, 0, 'dry-run should succeed; stdout=' + dryRunResult.stdout + ' stderr=' + dryRunResult.stderr);
assert.equal(realResult.status, 0, 'real sync should succeed; stdout=' + realResult.stdout + ' stderr=' + realResult.stderr);
const requests = mock.requests.slice(requestCountBefore);
assert.ok(requests.length >= 2);
assert.ok(requests.every((request) => request.url.searchParams.get('node_id') === nodeId));
assert.equal(fs.readFileSync(path.join(evolverHome, 'node_id'), 'utf8'), nodeId);
});
it('keeps stale suppression state unchanged while using the current env secret', async () => {
const crypto = require('crypto');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-sync-dry-run-suppression-'));
const evolverHome = path.join(root, 'home');
const staleSecret = 'e'.repeat(64);
const currentSecret = 'f'.repeat(64);
const staleMarker = 'sha256:' + crypto.createHash('sha256').update(staleSecret).digest('hex');
writePersistedIdentity(evolverHome, {
nodeId: 'node_dddddddddddd',
secret: 'a'.repeat(64),
version: 4,
source: 'env_seed',
suppression: staleMarker,
});
const before = snapshotFiles(evolverHome);
const requestCountBefore = mock.requests.length;
const r = await runSync({
A2A_HUB_URL: mock.url,
...unauthenticatedEnv(evolverHome),
A2A_NODE_SECRET: currentSecret,
A2A_NODE_SECRET_VERSION: '9',
}, ['--dry-run']);
assert.equal(r.status, 0, 'current env secret dry-run should succeed; stdout=' + r.stdout + ' stderr=' + r.stderr);
const requests = mock.requests.slice(requestCountBefore);
assert.ok(requests.length > 0, 'authenticated dry-run should perform GET requests');
assert.ok(requests.every((request) => request.method === 'GET'));
assert.ok(requests.every((request) => request.headers.authorization === 'Bearer ' + currentSecret));
assert.deepEqual(snapshotFiles(evolverHome), before, 'dry-run must not clear suppression or rewrite persisted credentials');
});
it('reads an existing store during dry-run without changing its files', async () => {
const { assetsDir } = mkSandbox();
const genesFile = path.join(assetsDir, 'genes.json');
const capsulesFile = path.join(assetsDir, 'capsules.json');
fs.writeFileSync(genesFile, JSON.stringify({
version: 1,
genes: [{
id: 'gene_gep_repair_from_errors',
strategy: ['hub-strategy-step'],
hub_asset_id: 'hub-asset-aaaa1111',
}],
}, null, 2));
fs.writeFileSync(capsulesFile, JSON.stringify({
version: 1,
capsules: [{ id: 'local-only-capsule', summary: 'keep me' }],
}, null, 2));
const beforeGenes = fs.readFileSync(genesFile);
const beforeCapsules = fs.readFileSync(capsulesFile);
const genesMtime = fs.statSync(genesFile).mtimeMs;
const capsulesMtime = fs.statSync(capsulesFile).mtimeMs;
const r = await runSync({
A2A_HUB_URL: mock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
}, ['--dry-run']);
assert.equal(r.status, 0, 'dry-run should exit 0; stdout=' + r.stdout + ' stderr=' + r.stderr);
assert.match(r.stdout, /already_synced=1/);
assert.match(r.stdout, /Local-only \(not on Hub\): genes=0 capsules=1/);
assert.deepEqual(fs.readFileSync(genesFile), beforeGenes);
assert.deepEqual(fs.readFileSync(capsulesFile), beforeCapsules);
assert.equal(fs.statSync(genesFile).mtimeMs, genesMtime);
assert.equal(fs.statSync(capsulesFile).mtimeMs, capsulesMtime);
assert.deepEqual(listFilesRecursive(assetsDir), ['capsules.json', 'genes.json']);
});
});
describe('sync persisted asset integrity', () => {
it('preserves first-party Capsule source types in dry-run and real sync', async () => {
const sourceTypes = ['skill2gep_hook', 'conversation_distillation'];
const sourceTypeMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: sourceTypes.map((sourceType) => ({
asset_id: 'hub-source-type-' + sourceType,
asset_type: 'Capsule',
local_id: 'capsule_' + sourceType,
payload: {
id: 'capsule_' + sourceType,
outcome: { status: 'success', score: 1 },
source_type: sourceType,
},
})),
count: sourceTypes.length,
has_more: false,
next_cursor: null,
},
}),
});
const { assetsDir } = mkSandbox();
try {
const env = {
A2A_HUB_URL: sourceTypeMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
};
const dryRunResult = await runSync(env, ['--dry-run']);
const realResult = await runSync(env);
assert.equal(dryRunResult.status, 0, 'dry-run should accept first-party source types; stdout=' + dryRunResult.stdout + ' stderr=' + dryRunResult.stderr);
assert.match(dryRunResult.stdout, /synced=2/);
assert.match(dryRunResult.stdout, /errors=0/);
assert.equal(realResult.status, 0, 'real sync should persist first-party source types; stdout=' + realResult.stdout + ' stderr=' + realResult.stderr);
assert.match(realResult.stdout, /synced=2/);
assert.match(realResult.stdout, /errors=0/);
const capsules = JSON.parse(fs.readFileSync(path.join(assetsDir, 'capsules.json'), 'utf8')).capsules;
for (const sourceType of sourceTypes) {
const capsule = capsules.find((item) => item.hub_asset_id === 'hub-source-type-' + sourceType);
assert.ok(capsule, 'expected persisted capsule for ' + sourceType);
assert.equal(capsule.source_type, sourceType);
}
} finally {
sourceTypeMock.server.close();
}
});
it('recomputes Gene and Capsule asset ids after sync metadata is added', async () => {
const { verifyAssetId } = require('../src/gep/contentHash');
const staleAssetId = 'sha256:' + '0'.repeat(64);
const integrityMock = await startMock({
'/a2a/assets/published-by-me': () => ({
body: {
assets: [
{
asset_id: 'hub-integrity-gene',
asset_type: 'Gene',
local_id: 'gene_integrity',
payload: {
type: 'Gene',
id: 'gene_integrity',
asset_id: staleAssetId,
category: 'regulatory',
signals_match: ['policy_violation'],
trigger: 'manual_review',
strategy: ['enforce policy'],
validation: ['node --test'],
metadata: { author: 'hub' },
},
},
{
asset_id: 'hub-integrity-capsule',
asset_type: 'Capsule',
local_id: 'capsule_integrity',
payload: {
type: 'Capsule',
id: 'capsule_integrity',
asset_id: staleAssetId,
trigger: ['policy_violation'],
gene: 'gene_integrity',
summary: 'policy enforced',
confidence: 0.9,
blast_radius: { files: 1, lines: 2 },
outcome: { status: 'success', score: 0.9 },
execution_trace: [],
content: 'safe markdown content',
diff: 'diff --git a/src/example.js b/src/example.js',
success_reason: 'validation passed',
trigger_context: { prompt: 'review policy' },
},
},
],
count: 2,
has_more: false,
next_cursor: null,
},
}),
'/a2a/assets/purchased': () => ({
body: { assets: [], count: 0, has_more: false, next_cursor: null },
}),
});
const { assetsDir } = mkSandbox();
try {
const result = await runSync({
A2A_HUB_URL: integrityMock.url,
A2A_NODE_ID: 'node_aaaaaaaaaaaa',
A2A_NODE_SECRET: 'a'.repeat(64),
GEP_ASSETS_DIR: assetsDir,
}, ['--force']);
assert.equal(result.status, 0, 'sync should persist both assets; stdout=' + result.stdout + ' stderr=' + result.stderr);
assert.doesNotMatch(result.stderr, /Gene schema validation warning/);
const gene = JSON.parse(fs.readFileSync(path.join(assetsDir, 'genes.json'), 'utf8')).genes
.find((item) => item.id === 'gene_integrity');
const capsule = JSON.parse(fs.readFileSync(path.join(assetsDir, 'capsules.json'), 'utf8')).capsules
.find((item) => item.id === 'capsule_integrity');
assert.ok(gene);
assert.ok(capsule);
assert.notEqual(gene.asset_id, staleAssetId);
assert.notEqual(capsule.asset_id, staleAssetId);
assert.equal(verifyAssetId(gene), true);
assert.equal(verifyAssetId(capsule), true);
assert.equal(gene.category, 'regulatory');
assert.equal(gene.trigger, 'manual_review');
assert.deepEqual(gene.metadata, { author: 'hub' });
assert.equal(capsule.content, 'safe markdown content');
assert.equal(capsule.diff, 'diff --git a/src/example.js b/src/example.js');
assert.equal(capsule.success_reason, 'validation passed');
assert.deepEqual(capsule.trigger_context, { prompt: 'review policy' });
} finally {
integrityMock.server.close();
}
});
});