Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ evolver fetch --skill <skill_id> --out=./my-skills/
```

Requires `A2A_HUB_URL` to be configured. Browse available skills at [evomap.ai](https://evomap.ai).
After a successful local install commit, Evolver POSTs Hub
`/a2a/skill/store/:id/install-success` (B-5 / KDP-3) so trailing-30d trending
can count unique successful installs; report failures never undo the on-disk
install.

### Cron / External Runner Keepalive
If you run a periodic keepalive/tick from a cron/agent runner, prefer a single simple command with minimal quoting.
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ evolver fetch --skill <skill_id> --out=./my-skills/
```

需要配置 `A2A_HUB_URL`。浏览可用技能请访问 [evomap.ai](https://evomap.ai)。
本地安装写入成功后,Evolver 会向 Hub 发送
`POST /a2a/skill/store/:id/install-success`(B-5 / KDP-3),供 30 天去重成功安装
热门榜计数;上报失败不会回滚已写入的本地文件。

### Cron / 外部调度器保活
如果你通过 cron 或外部调度器定期触发 evolver,建议使用单条简单命令,避免嵌套引号:
Expand Down
22 changes: 22 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2392,6 +2392,10 @@ async function main() {

const { getHubUrl, getNodeId, buildHubHeaders, sendHelloToHub, getHubNodeSecret } = require('./src/gep/a2aProtocol');
const { hubFetch } = require('./src/gep/hubFetch');
const {
reportSkillInstallSuccess,
skillInstallCommitted,
} = require('./src/gep/skillInstallSuccess');

const hubUrl = getHubUrl();
if (!hubUrl) {
Expand Down Expand Up @@ -2589,6 +2593,24 @@ async function main() {
} else {
console.log(' Fetch cost: ' + (data.credit_cost || 0) + ' credits');
}

// B-5 / KDP-3: confirm local install *commit* so Hub trending can count
// unique successful installs. Fail-soft — disk write already succeeded.
if (skillInstallCommitted(data)) {
const reportSkillId = String(data.skill_id || skillId).trim() || skillId;
const report = await reportSkillInstallSuccess({
hubUrl,
skillId: reportSkillId,
nodeId,
hubFetch,
buildHeaders: buildHubHeaders,
});
if (report.ok) {
console.log('[fetch] Reported install-success to Hub (B-5 popularity).');
} else if (isVerbose) {
console.warn('[fetch] install-success report skipped: ' + (report.error || 'unknown'));
}
}
} catch (error) {
if (error && error.name === 'TimeoutError') {
console.error('[fetch] Request timed out (30s). Check your network and A2A_HUB_URL.');
Expand Down
74 changes: 74 additions & 0 deletions src/gep/skillInstallSuccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use strict';

/**
* B-5 / KDP-3: after a local Hub skill install *commit*, confirm to Hub so
* trailing-30d unique successful-install trending can populate.
*
* Fail-soft: never throws to the caller; install already landed on disk.
* Auth: node_secret Bearer via buildHeaders (same as skill download).
*/

/**
* @param {object} opts
* @param {string} opts.hubUrl
* @param {string} opts.skillId
* @param {string} opts.nodeId
* @param {(url: string, init: object) => Promise<{ ok: boolean, status: number, text: () => Promise<string> }>} opts.hubFetch
* @param {() => Record<string, string>} opts.buildHeaders
* @param {number} [opts.timeoutMs]
* @returns {Promise<{ ok: boolean, status?: number, error?: string, recorded?: boolean }>}
*/
async function reportSkillInstallSuccess(opts) {
const hubUrl = String(opts && opts.hubUrl || '').replace(/\/+$/, '');
const skillId = String(opts && opts.skillId || '').trim();
const nodeId = String(opts && opts.nodeId || '').trim();
const hubFetch = opts && opts.hubFetch;
const buildHeaders = opts && opts.buildHeaders;
const timeoutMs = Number(opts && opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 10000;

if (!hubUrl) return { ok: false, error: 'no_hub_url' };
if (!skillId) return { ok: false, error: 'no_skill_id' };
if (!nodeId) return { ok: false, error: 'no_node_id' };
if (typeof hubFetch !== 'function') return { ok: false, error: 'no_hub_fetch' };
if (typeof buildHeaders !== 'function') return { ok: false, error: 'no_build_headers' };

const endpoint =
hubUrl + '/a2a/skill/store/' + encodeURIComponent(skillId) + '/install-success';

try {
const resp = await hubFetch(endpoint, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify({ sender_id: nodeId }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!resp || !resp.ok) {
const status = resp && typeof resp.status === 'number' ? resp.status : 0;
return { ok: false, status, error: 'install_success_http_' + status };
}
return { ok: true, status: resp.status, recorded: true };
} catch (err) {
return {
ok: false,
error: (err && err.message) || String(err || 'install_success_failed'),
};
}
}

/**
* True when fetch wrote at least one skill artifact to disk.
* @param {{ content?: string, bundled_files?: Array<{ name?: string, content?: string }> }} data
*/
function skillInstallCommitted(data) {
if (!data || typeof data !== 'object') return false;
if (typeof data.content === 'string' && data.content.length > 0) return true;
const bundled = Array.isArray(data.bundled_files) ? data.bundled_files : [];
return bundled.some(
(f) => f && f.name && typeof f.content === 'string' && f.content.length > 0,
);
}

module.exports = {
reportSkillInstallSuccess,
skillInstallCommitted,
};
118 changes: 118 additions & 0 deletions test/skillInstallSuccess.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use strict';

const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const {
reportSkillInstallSuccess,
skillInstallCommitted,
} = require('../src/gep/skillInstallSuccess');

describe('skillInstallCommitted', () => {
it('requires content or a non-empty bundled file', () => {
assert.equal(skillInstallCommitted(null), false);
assert.equal(skillInstallCommitted({}), false);
assert.equal(skillInstallCommitted({ content: '' }), false);
assert.equal(skillInstallCommitted({ content: '# Skill' }), true);
assert.equal(
skillInstallCommitted({
bundled_files: [{ name: 'helper.js', content: 'module.exports = 1;' }],
}),
true,
);
assert.equal(
skillInstallCommitted({ bundled_files: [{ name: 'helper.js', content: '' }] }),
false,
);
});
});

describe('reportSkillInstallSuccess', () => {
it('POSTs /install-success with sender_id after local commit', async () => {
/** @type {{ url?: string, init?: object }} */
const seen = {};
const result = await reportSkillInstallSuccess({
hubUrl: 'https://evomap.ai/',
skillId: 'skill_demo',
nodeId: 'node_abc',
buildHeaders: () => ({ Authorization: 'Bearer secret', 'Content-Type': 'application/json' }),
hubFetch: async (url, init) => {
seen.url = url;
seen.init = init;
return { ok: true, status: 200, text: async () => '{}' };
},
});

assert.equal(result.ok, true);
assert.equal(result.recorded, true);
assert.equal(seen.url, 'https://evomap.ai/a2a/skill/store/skill_demo/install-success');
assert.equal(seen.init.method, 'POST');
assert.equal(seen.init.headers.Authorization, 'Bearer secret');
assert.deepEqual(JSON.parse(seen.init.body), { sender_id: 'node_abc' });
});

it('fail-soft on HTTP errors and network failures', async () => {
const httpFail = await reportSkillInstallSuccess({
hubUrl: 'https://evomap.ai',
skillId: 'skill_x',
nodeId: 'node_1',
buildHeaders: () => ({}),
hubFetch: async () => ({ ok: false, status: 401, text: async () => 'nope' }),
});
assert.equal(httpFail.ok, false);
assert.equal(httpFail.status, 401);

const netFail = await reportSkillInstallSuccess({
hubUrl: 'https://evomap.ai',
skillId: 'skill_x',
nodeId: 'node_1',
buildHeaders: () => ({}),
hubFetch: async () => {
throw new Error('boom');
},
});
assert.equal(netFail.ok, false);
assert.match(netFail.error, /boom/);
});

it('rejects missing identity without calling hubFetch', async () => {
let called = false;
const result = await reportSkillInstallSuccess({
hubUrl: 'https://evomap.ai',
skillId: 'skill_x',
nodeId: '',
buildHeaders: () => ({}),
hubFetch: async () => {
called = true;
return { ok: true, status: 200, text: async () => '{}' };
},
});
assert.equal(result.ok, false);
assert.equal(result.error, 'no_node_id');
assert.equal(called, false);
});
});

describe('fetch command wires install-success after disk commit', () => {
const indexSrc = fs.readFileSync(path.join(__dirname, '..', 'index.js'), 'utf8');

it('requires skillInstallSuccess and reports only after local write', () => {
assert.ok(
/require\('\.\/src\/gep\/skillInstallSuccess'\)/.test(indexSrc),
'fetch must load skillInstallSuccess helper',
);
assert.ok(
/reportSkillInstallSuccess\(/.test(indexSrc),
'fetch must call reportSkillInstallSuccess after commit',
);
assert.ok(
/skillInstallCommitted\(/.test(indexSrc),
'fetch must gate report on skillInstallCommitted',
);
const downloadIdx = indexSrc.indexOf("/a2a/skill/store/' + encodeURIComponent(skillId) + '/download'");
const reportIdx = indexSrc.indexOf('reportSkillInstallSuccess(');
assert.ok(downloadIdx !== -1 && reportIdx !== -1 && reportIdx > downloadIdx,
'install-success must run after download path, not replace it');
});
});
Loading