From 6e8c0555dca835ec63ddb834781f127c80d50ca6 Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Sat, 5 Sep 2026 00:20:11 +0800 Subject: [PATCH 1/2] feat(skill-store): report Hub install-success after local fetch commit Wire B-5/KDP-3 POST /a2a/skill/store/:id/install-success after evolver fetch --skill writes files, so trending can count unique successful installs without treating download attempts as popularity. --- README.md | 4 ++ README.zh-CN.md | 3 + index.js | 22 ++++++ src/gep/skillInstallSuccess.js | 73 +++++++++++++++++++ test/skillInstallSuccess.test.js | 118 +++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 src/gep/skillInstallSuccess.js create mode 100644 test/skillInstallSuccess.test.js diff --git a/README.md b/README.md index f5f8054b..f3d76087 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,10 @@ evolver fetch --skill --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. diff --git a/README.zh-CN.md b/README.zh-CN.md index d57696e4..f99f6dc6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -270,6 +270,9 @@ evolver fetch --skill --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,建议使用单条简单命令,避免嵌套引号: diff --git a/index.js b/index.js index c6ddb355..ba31429d 100755 --- a/index.js +++ b/index.js @@ -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) { @@ -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.'); diff --git a/src/gep/skillInstallSuccess.js b/src/gep/skillInstallSuccess.js new file mode 100644 index 00000000..8bacdda2 --- /dev/null +++ b/src/gep/skillInstallSuccess.js @@ -0,0 +1,73 @@ +'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. + */ + +/** + * @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 }>} opts.hubFetch + * @param {() => Record} 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, +}; diff --git a/test/skillInstallSuccess.test.js b/test/skillInstallSuccess.test.js new file mode 100644 index 00000000..b8a16731 --- /dev/null +++ b/test/skillInstallSuccess.test.js @@ -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'); + }); +}); From d370b38120bbbf14a41402c13a08e2ebacca348c Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Sat, 5 Sep 2026 00:23:00 +0800 Subject: [PATCH 2/2] docs(skill-store): note install-success uses node_secret headers --- src/gep/skillInstallSuccess.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gep/skillInstallSuccess.js b/src/gep/skillInstallSuccess.js index 8bacdda2..ad486bd5 100644 --- a/src/gep/skillInstallSuccess.js +++ b/src/gep/skillInstallSuccess.js @@ -5,6 +5,7 @@ * 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). */ /**