Skip to content

Commit 31b0691

Browse files
authored
feat(skill-store): report Hub install-success after local fetch commit (#622)
* 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. * docs(skill-store): note install-success uses node_secret headers
1 parent 079e022 commit 31b0691

5 files changed

Lines changed: 221 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ evolver fetch --skill <skill_id> --out=./my-skills/
306306
```
307307

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

310314
### Cron / External Runner Keepalive
311315
If you run a periodic keepalive/tick from a cron/agent runner, prefer a single simple command with minimal quoting.

‎README.zh-CN.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,9 @@ evolver fetch --skill <skill_id> --out=./my-skills/
270270
```
271271

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

274277
### Cron / 外部调度器保活
275278
如果你通过 cron 或外部调度器定期触发 evolver,建议使用单条简单命令,避免嵌套引号:

‎index.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2392,6 +2392,10 @@ async function main() {
23922392

23932393
const { getHubUrl, getNodeId, buildHubHeaders, sendHelloToHub, getHubNodeSecret } = require('./src/gep/a2aProtocol');
23942394
const { hubFetch } = require('./src/gep/hubFetch');
2395+
const {
2396+
reportSkillInstallSuccess,
2397+
skillInstallCommitted,
2398+
} = require('./src/gep/skillInstallSuccess');
23952399

23962400
const hubUrl = getHubUrl();
23972401
if (!hubUrl) {
@@ -2589,6 +2593,24 @@ async function main() {
25892593
} else {
25902594
console.log(' Fetch cost: ' + (data.credit_cost || 0) + ' credits');
25912595
}
2596+
2597+
// B-5 / KDP-3: confirm local install *commit* so Hub trending can count
2598+
// unique successful installs. Fail-soft — disk write already succeeded.
2599+
if (skillInstallCommitted(data)) {
2600+
const reportSkillId = String(data.skill_id || skillId).trim() || skillId;
2601+
const report = await reportSkillInstallSuccess({
2602+
hubUrl,
2603+
skillId: reportSkillId,
2604+
nodeId,
2605+
hubFetch,
2606+
buildHeaders: buildHubHeaders,
2607+
});
2608+
if (report.ok) {
2609+
console.log('[fetch] Reported install-success to Hub (B-5 popularity).');
2610+
} else if (isVerbose) {
2611+
console.warn('[fetch] install-success report skipped: ' + (report.error || 'unknown'));
2612+
}
2613+
}
25922614
} catch (error) {
25932615
if (error && error.name === 'TimeoutError') {
25942616
console.error('[fetch] Request timed out (30s). Check your network and A2A_HUB_URL.');

‎src/gep/skillInstallSuccess.js‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict';
2+
3+
/**
4+
* B-5 / KDP-3: after a local Hub skill install *commit*, confirm to Hub so
5+
* trailing-30d unique successful-install trending can populate.
6+
*
7+
* Fail-soft: never throws to the caller; install already landed on disk.
8+
* Auth: node_secret Bearer via buildHeaders (same as skill download).
9+
*/
10+
11+
/**
12+
* @param {object} opts
13+
* @param {string} opts.hubUrl
14+
* @param {string} opts.skillId
15+
* @param {string} opts.nodeId
16+
* @param {(url: string, init: object) => Promise<{ ok: boolean, status: number, text: () => Promise<string> }>} opts.hubFetch
17+
* @param {() => Record<string, string>} opts.buildHeaders
18+
* @param {number} [opts.timeoutMs]
19+
* @returns {Promise<{ ok: boolean, status?: number, error?: string, recorded?: boolean }>}
20+
*/
21+
async function reportSkillInstallSuccess(opts) {
22+
const hubUrl = String(opts && opts.hubUrl || '').replace(/\/+$/, '');
23+
const skillId = String(opts && opts.skillId || '').trim();
24+
const nodeId = String(opts && opts.nodeId || '').trim();
25+
const hubFetch = opts && opts.hubFetch;
26+
const buildHeaders = opts && opts.buildHeaders;
27+
const timeoutMs = Number(opts && opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 10000;
28+
29+
if (!hubUrl) return { ok: false, error: 'no_hub_url' };
30+
if (!skillId) return { ok: false, error: 'no_skill_id' };
31+
if (!nodeId) return { ok: false, error: 'no_node_id' };
32+
if (typeof hubFetch !== 'function') return { ok: false, error: 'no_hub_fetch' };
33+
if (typeof buildHeaders !== 'function') return { ok: false, error: 'no_build_headers' };
34+
35+
const endpoint =
36+
hubUrl + '/a2a/skill/store/' + encodeURIComponent(skillId) + '/install-success';
37+
38+
try {
39+
const resp = await hubFetch(endpoint, {
40+
method: 'POST',
41+
headers: buildHeaders(),
42+
body: JSON.stringify({ sender_id: nodeId }),
43+
signal: AbortSignal.timeout(timeoutMs),
44+
});
45+
if (!resp || !resp.ok) {
46+
const status = resp && typeof resp.status === 'number' ? resp.status : 0;
47+
return { ok: false, status, error: 'install_success_http_' + status };
48+
}
49+
return { ok: true, status: resp.status, recorded: true };
50+
} catch (err) {
51+
return {
52+
ok: false,
53+
error: (err && err.message) || String(err || 'install_success_failed'),
54+
};
55+
}
56+
}
57+
58+
/**
59+
* True when fetch wrote at least one skill artifact to disk.
60+
* @param {{ content?: string, bundled_files?: Array<{ name?: string, content?: string }> }} data
61+
*/
62+
function skillInstallCommitted(data) {
63+
if (!data || typeof data !== 'object') return false;
64+
if (typeof data.content === 'string' && data.content.length > 0) return true;
65+
const bundled = Array.isArray(data.bundled_files) ? data.bundled_files : [];
66+
return bundled.some(
67+
(f) => f && f.name && typeof f.content === 'string' && f.content.length > 0,
68+
);
69+
}
70+
71+
module.exports = {
72+
reportSkillInstallSuccess,
73+
skillInstallCommitted,
74+
};

‎test/skillInstallSuccess.test.js‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use strict';
2+
3+
const { describe, it } = require('node:test');
4+
const assert = require('node:assert/strict');
5+
const fs = require('fs');
6+
const path = require('path');
7+
const {
8+
reportSkillInstallSuccess,
9+
skillInstallCommitted,
10+
} = require('../src/gep/skillInstallSuccess');
11+
12+
describe('skillInstallCommitted', () => {
13+
it('requires content or a non-empty bundled file', () => {
14+
assert.equal(skillInstallCommitted(null), false);
15+
assert.equal(skillInstallCommitted({}), false);
16+
assert.equal(skillInstallCommitted({ content: '' }), false);
17+
assert.equal(skillInstallCommitted({ content: '# Skill' }), true);
18+
assert.equal(
19+
skillInstallCommitted({
20+
bundled_files: [{ name: 'helper.js', content: 'module.exports = 1;' }],
21+
}),
22+
true,
23+
);
24+
assert.equal(
25+
skillInstallCommitted({ bundled_files: [{ name: 'helper.js', content: '' }] }),
26+
false,
27+
);
28+
});
29+
});
30+
31+
describe('reportSkillInstallSuccess', () => {
32+
it('POSTs /install-success with sender_id after local commit', async () => {
33+
/** @type {{ url?: string, init?: object }} */
34+
const seen = {};
35+
const result = await reportSkillInstallSuccess({
36+
hubUrl: 'https://evomap.ai/',
37+
skillId: 'skill_demo',
38+
nodeId: 'node_abc',
39+
buildHeaders: () => ({ Authorization: 'Bearer secret', 'Content-Type': 'application/json' }),
40+
hubFetch: async (url, init) => {
41+
seen.url = url;
42+
seen.init = init;
43+
return { ok: true, status: 200, text: async () => '{}' };
44+
},
45+
});
46+
47+
assert.equal(result.ok, true);
48+
assert.equal(result.recorded, true);
49+
assert.equal(seen.url, 'https://evomap.ai/a2a/skill/store/skill_demo/install-success');
50+
assert.equal(seen.init.method, 'POST');
51+
assert.equal(seen.init.headers.Authorization, 'Bearer secret');
52+
assert.deepEqual(JSON.parse(seen.init.body), { sender_id: 'node_abc' });
53+
});
54+
55+
it('fail-soft on HTTP errors and network failures', async () => {
56+
const httpFail = await reportSkillInstallSuccess({
57+
hubUrl: 'https://evomap.ai',
58+
skillId: 'skill_x',
59+
nodeId: 'node_1',
60+
buildHeaders: () => ({}),
61+
hubFetch: async () => ({ ok: false, status: 401, text: async () => 'nope' }),
62+
});
63+
assert.equal(httpFail.ok, false);
64+
assert.equal(httpFail.status, 401);
65+
66+
const netFail = await reportSkillInstallSuccess({
67+
hubUrl: 'https://evomap.ai',
68+
skillId: 'skill_x',
69+
nodeId: 'node_1',
70+
buildHeaders: () => ({}),
71+
hubFetch: async () => {
72+
throw new Error('boom');
73+
},
74+
});
75+
assert.equal(netFail.ok, false);
76+
assert.match(netFail.error, /boom/);
77+
});
78+
79+
it('rejects missing identity without calling hubFetch', async () => {
80+
let called = false;
81+
const result = await reportSkillInstallSuccess({
82+
hubUrl: 'https://evomap.ai',
83+
skillId: 'skill_x',
84+
nodeId: '',
85+
buildHeaders: () => ({}),
86+
hubFetch: async () => {
87+
called = true;
88+
return { ok: true, status: 200, text: async () => '{}' };
89+
},
90+
});
91+
assert.equal(result.ok, false);
92+
assert.equal(result.error, 'no_node_id');
93+
assert.equal(called, false);
94+
});
95+
});
96+
97+
describe('fetch command wires install-success after disk commit', () => {
98+
const indexSrc = fs.readFileSync(path.join(__dirname, '..', 'index.js'), 'utf8');
99+
100+
it('requires skillInstallSuccess and reports only after local write', () => {
101+
assert.ok(
102+
/require\('\.\/src\/gep\/skillInstallSuccess'\)/.test(indexSrc),
103+
'fetch must load skillInstallSuccess helper',
104+
);
105+
assert.ok(
106+
/reportSkillInstallSuccess\(/.test(indexSrc),
107+
'fetch must call reportSkillInstallSuccess after commit',
108+
);
109+
assert.ok(
110+
/skillInstallCommitted\(/.test(indexSrc),
111+
'fetch must gate report on skillInstallCommitted',
112+
);
113+
const downloadIdx = indexSrc.indexOf("/a2a/skill/store/' + encodeURIComponent(skillId) + '/download'");
114+
const reportIdx = indexSrc.indexOf('reportSkillInstallSuccess(');
115+
assert.ok(downloadIdx !== -1 && reportIdx !== -1 && reportIdx > downloadIdx,
116+
'install-success must run after download path, not replace it');
117+
});
118+
});

0 commit comments

Comments
 (0)