Skip to content

Commit ff6f3f2

Browse files
committed
ci: build registry-index.json in-repo for dashboard real-time updates
Pair with the existing plugins-index.json path (which feeds the daemon's signed install lane). registry-index.json mirrors the dict-shaped payload the registry-worker's cron currently builds via 40+ GitHub Contents API calls — but built locally from the checked-out tree by scripts/build-registry-index.mjs, so the worker only fetches ONE file per category type (2 total: plugins + registry) on refresh. Workflow now picks up content changes across all 8 category dirs (was: plugins/ only) so dashboard updates land within seconds of a push instead of waiting for the 02:00 UTC cron tick. The dashboard's /api/registry endpoint reads kv_store('registry_data'); the worker's forced-refresh now writes that key with these bytes and purges the Cache-API entry, so the next dashboard hit sees fresh data instead of the 1h-cached previous payload. Generated counts on first build: 11p 17h 32a 60s 44c 57pr 22w 33mcp.
1 parent f9821d5 commit ff6f3f2

3 files changed

Lines changed: 293 additions & 17 deletions

File tree

.github/workflows/refresh-cache.yml

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
name: Refresh registry-worker cache
22

3-
# On every push that changes plugins/, regenerate plugins-index.json
4-
# (the daemon-shaped flat array the registry-worker signs and the
5-
# librefang daemon installs from), commit it back, then poke the
6-
# worker's forced-refresh endpoint so it re-signs the new bytes
7-
# immediately — without this, daemon installs lag up to ~24h behind
8-
# the next 02:00 UTC cron tick.
3+
# On every push that changes any registry content, regenerate the two
4+
# in-repo indexes the registry-worker ingests:
5+
# plugins-index.json — daemon-shaped flat plugins array (signed)
6+
# registry-index.json — dict-shaped dashboard payload (unsigned)
7+
# then poke the worker's forced-refresh endpoint so it pulls both
8+
# (2 subrequests total, regardless of registry size — important under
9+
# Workers Free's 50-subrequest budget) and re-signs / stores them.
910
#
10-
# Walking the on-disk repo (instead of the worker hitting GitHub
11-
# Contents API per-file) keeps the worker's refresh path under the
12-
# Workers Free 50-subrequest budget regardless of registry size.
11+
# Without this, dashboard + daemon would have to wait for the next
12+
# 02:00 UTC cron tick to see content changes (up to ~24h delay).
1313

1414
on:
1515
push:
1616
branches: [main]
1717
paths:
1818
- 'plugins/**'
19+
- 'agents/**'
20+
- 'skills/**'
21+
- 'hands/**'
22+
- 'channels/**'
23+
- 'providers/**'
24+
- 'workflows/**'
25+
- 'mcp/**'
1926
- 'scripts/build-plugins-index.mjs'
20-
workflow_dispatch: # manual trigger for ops / first-deploy
27+
- 'scripts/build-registry-index.mjs'
28+
workflow_dispatch:
2129

2230
permissions:
23-
contents: write # needed to commit the regenerated index back
31+
contents: write # commit regenerated index files back
2432

2533
jobs:
2634
refresh:
@@ -32,18 +40,20 @@ jobs:
3240
with:
3341
node-version: '20'
3442

35-
- name: Rebuild plugins-index.json
36-
run: node scripts/build-plugins-index.mjs
43+
- name: Rebuild indexes
44+
run: |
45+
node scripts/build-plugins-index.mjs
46+
node scripts/build-registry-index.mjs
3747
38-
- name: Commit regenerated index if changed
48+
- name: Commit regenerated indexes if changed
3949
run: |
4050
git config user.name "github-actions[bot]"
4151
git config user.email "github-actions[bot]@users.noreply.github.com"
42-
git add plugins-index.json
52+
git add plugins-index.json registry-index.json
4353
if git diff --cached --quiet; then
44-
echo "plugins-index.json already up-to-date"
54+
echo "indexes already up-to-date"
4555
else
46-
git commit -m "chore: regenerate plugins-index.json"
56+
git commit -m "chore: regenerate registry indexes"
4757
git push
4858
fi
4959

registry-index.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

scripts/build-registry-index.mjs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/usr/bin/env node
2+
// Build registry-index.json from the on-disk content tree.
3+
//
4+
// Mirrors the dict-shaped payload the registry-worker's
5+
// `refreshRegistryCache` would produce by walking the GitHub Contents API
6+
// — but does it from the checked-out repo, so the worker's forced-refresh
7+
// path stays at a single subrequest regardless of registry size.
8+
//
9+
// Output shape (consumed by dashboard via /api/registry):
10+
// {
11+
// hands:[{id,name,description,category,icon,tags?,i18n?}, ...],
12+
// channels:[...], providers:[...], workflows:[...], agents:[...],
13+
// plugins:[...], skills:[...], mcp:[...],
14+
// handsCount: ..., ..., pluginsCount: ..., skillsCount: ...,
15+
// fetchedAt: <ISO>,
16+
// signature: <directory-signature>, // for cron-skip parity
17+
// }
18+
19+
import fs from 'node:fs'
20+
import path from 'node:path'
21+
import crypto from 'node:crypto'
22+
23+
const repoRoot = path.resolve(new URL('.', import.meta.url).pathname, '..')
24+
const outPath = path.join(repoRoot, 'registry-index.json')
25+
26+
// Layouts: directory-with-manifest categories use `<name>/<manifest>`;
27+
// flat-file categories list `*.toml` directly. mcp tolerates both
28+
// (matches the existing worker `m.name.endsWith('.toml') ? mcp/<name> :
29+
// mcp/<name>/MCP.toml`).
30+
const DIR_MANIFEST = {
31+
hands: 'HAND.toml',
32+
agents: 'agent.toml',
33+
plugins: 'plugin.toml',
34+
}
35+
const SKILL_DIR = 'SKILL.md'
36+
const FLAT_TOML = ['channels', 'providers', 'workflows']
37+
38+
function pickString(text, key) {
39+
const m = text.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'))
40+
return m ? m[1] : ''
41+
}
42+
43+
function pickStringArray(text, key) {
44+
const m = text.match(new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, 'm'))
45+
if (!m) return undefined
46+
const items = m[1].match(/"([^"]*)"/g)?.map(s => s.replace(/"/g, ''))
47+
return items?.length ? items : undefined
48+
}
49+
50+
function parseI18n(text) {
51+
const i18n = {}
52+
const re = /\[i18n\.([a-zA-Z-]+)\]\s*\n([^[]*?)(?=\n\[|\n*$)/g
53+
let m
54+
while ((m = re.exec(text)) !== null) {
55+
const descM = (m[2] || '').match(/description\s*=\s*"([^"]*)"/)
56+
if (descM) i18n[m[1]] = { description: descM[1] }
57+
}
58+
return i18n
59+
}
60+
61+
function parseToml(text, fallbackId) {
62+
const out = {
63+
id: pickString(text, 'id') || fallbackId,
64+
name: pickString(text, 'name') || fallbackId,
65+
description: pickString(text, 'description'),
66+
category: pickString(text, 'category'),
67+
icon: pickString(text, 'icon'),
68+
}
69+
const version = pickString(text, 'version')
70+
if (version) out.version = version
71+
const tags = pickStringArray(text, 'tags')
72+
if (tags?.length) out.tags = tags
73+
const needs = pickStringArray(text, 'needs')
74+
if (needs?.length) out.needs = needs
75+
const i18n = parseI18n(text)
76+
if (Object.keys(i18n).length) out.i18n = i18n
77+
return out
78+
}
79+
80+
function parseSkillMd(text, fallbackId) {
81+
const fm = text.match(/^---\s*\n([\s\S]*?)\n---/)
82+
if (!fm) return null
83+
const get = key => {
84+
const m = fm[1].match(new RegExp(`^${key}\\s*:\\s*"?([^"\\n]*?)"?\\s*$`, 'm'))
85+
return m ? m[1].trim() : ''
86+
}
87+
return {
88+
id: get('id') || fallbackId,
89+
name: get('name') || fallbackId,
90+
description: get('description'),
91+
category: 'skills',
92+
icon: '',
93+
}
94+
}
95+
96+
function loadDirCategory(category, manifest) {
97+
const dir = path.join(repoRoot, category)
98+
if (!fs.existsSync(dir)) return { items: [], rawNames: [] }
99+
const entries = fs
100+
.readdirSync(dir, { withFileTypes: true })
101+
.filter(d => d.isDirectory() && d.name !== 'README.md')
102+
.sort((a, b) => a.name.localeCompare(b.name))
103+
104+
const items = []
105+
for (const e of entries) {
106+
const f = path.join(dir, e.name, manifest)
107+
if (!fs.existsSync(f)) continue
108+
items.push(parseToml(fs.readFileSync(f, 'utf8'), e.name))
109+
}
110+
return { items, rawNames: entries.map(e => e.name) }
111+
}
112+
113+
function loadFlatTomlCategory(category) {
114+
const dir = path.join(repoRoot, category)
115+
if (!fs.existsSync(dir)) return { items: [], rawNames: [] }
116+
const files = fs
117+
.readdirSync(dir)
118+
.filter(n => n.endsWith('.toml') && n !== 'README.md')
119+
.sort((a, b) => a.localeCompare(b))
120+
121+
const items = []
122+
for (const f of files) {
123+
const id = f.replace(/\.toml$/, '')
124+
items.push(parseToml(fs.readFileSync(path.join(dir, f), 'utf8'), id))
125+
}
126+
return { items, rawNames: files }
127+
}
128+
129+
function loadSkills() {
130+
const dir = path.join(repoRoot, 'skills')
131+
if (!fs.existsSync(dir)) return { items: [], rawNames: [] }
132+
const dirs = fs
133+
.readdirSync(dir, { withFileTypes: true })
134+
.filter(d => d.isDirectory())
135+
.sort((a, b) => a.name.localeCompare(b.name))
136+
137+
const items = []
138+
for (const d of dirs) {
139+
const f = path.join(dir, d.name, SKILL_DIR)
140+
if (!fs.existsSync(f)) continue
141+
const parsed = parseSkillMd(fs.readFileSync(f, 'utf8'), d.name)
142+
if (parsed) items.push(parsed)
143+
}
144+
return { items, rawNames: dirs.map(d => d.name) }
145+
}
146+
147+
function loadMcp() {
148+
const dir = path.join(repoRoot, 'mcp')
149+
if (!fs.existsSync(dir)) return { items: [], rawNames: [] }
150+
const entries = fs
151+
.readdirSync(dir, { withFileTypes: true })
152+
.filter(e => e.name !== 'README.md')
153+
.sort((a, b) => a.name.localeCompare(b.name))
154+
155+
const items = []
156+
const rawNames = []
157+
for (const e of entries) {
158+
let manifest
159+
let id
160+
if (e.isFile() && e.name.endsWith('.toml')) {
161+
manifest = path.join(dir, e.name)
162+
id = e.name.replace(/\.toml$/, '')
163+
} else if (e.isDirectory()) {
164+
manifest = path.join(dir, e.name, 'MCP.toml')
165+
id = e.name
166+
if (!fs.existsSync(manifest)) continue
167+
} else {
168+
continue
169+
}
170+
items.push(parseToml(fs.readFileSync(manifest, 'utf8'), id))
171+
rawNames.push(e.name)
172+
}
173+
return { items, rawNames }
174+
}
175+
176+
const hands = loadDirCategory('hands', DIR_MANIFEST.hands)
177+
const agents = loadDirCategory('agents', DIR_MANIFEST.agents)
178+
const plugins = loadDirCategory('plugins', DIR_MANIFEST.plugins)
179+
const skills = loadSkills()
180+
const channels = loadFlatTomlCategory('channels')
181+
const providers = loadFlatTomlCategory('providers')
182+
const workflows = loadFlatTomlCategory('workflows')
183+
const mcp = loadMcp()
184+
185+
// Signature mirrors the worker's per-category `name@sha`-style join, but
186+
// keyed off the file content hash so the registry-worker cron can detect
187+
// "no real change" identically to its current GitHub-Contents-API path.
188+
function categorySig(category, rawNames) {
189+
return rawNames
190+
.map(n => {
191+
const p = path.join(repoRoot, category, n)
192+
let bytes = ''
193+
if (fs.existsSync(p)) {
194+
if (fs.statSync(p).isDirectory()) {
195+
// hash the directory's manifest for the signature
196+
for (const m of ['HAND.toml', 'agent.toml', 'plugin.toml', 'SKILL.md', 'MCP.toml']) {
197+
const inner = path.join(p, m)
198+
if (fs.existsSync(inner)) {
199+
bytes = fs.readFileSync(inner, 'utf8')
200+
break
201+
}
202+
}
203+
} else {
204+
bytes = fs.readFileSync(p, 'utf8')
205+
}
206+
}
207+
const sha = crypto.createHash('sha1').update(bytes).digest('hex')
208+
return `${n}@${sha}`
209+
})
210+
.join(',')
211+
}
212+
213+
const signature = [
214+
'hands', 'channels', 'providers', 'workflows',
215+
'agents', 'plugins', 'skills', 'mcp',
216+
]
217+
.map((c, _) => {
218+
const raw = ({ hands, channels, providers, workflows, agents, plugins, skills, mcp }[c]).rawNames
219+
return `${c}=${categorySig(c, raw)}`
220+
})
221+
.join('|')
222+
223+
const result = {
224+
hands: hands.items,
225+
channels: channels.items,
226+
providers: providers.items,
227+
workflows: workflows.items,
228+
agents: agents.items,
229+
plugins: plugins.items,
230+
skills: skills.items,
231+
mcp: mcp.items,
232+
handsCount: hands.rawNames.length,
233+
channelsCount: channels.rawNames.length,
234+
providersCount: providers.rawNames.length,
235+
workflowsCount: workflows.rawNames.length,
236+
agentsCount: agents.rawNames.length,
237+
pluginsCount: plugins.rawNames.length,
238+
skillsCount: skills.rawNames.length,
239+
mcpCount: mcp.rawNames.length,
240+
// fetchedAt intentionally OMITTED — including a wall-clock timestamp
241+
// here would make every CI run produce a diff and an empty commit
242+
// even when the registry hasn't changed. The worker stamps an
243+
// updated_at into D1 when it ingests the file, which is what
244+
// dashboards should display anyway.
245+
signature,
246+
}
247+
248+
const json = JSON.stringify(result)
249+
const prev = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : null
250+
if (prev === json) {
251+
console.log(
252+
`registry-index.json unchanged (${plugins.rawNames.length} plugins, ` +
253+
`${hands.rawNames.length} hands, ${agents.rawNames.length} agents, ` +
254+
`${skills.rawNames.length} skills, ${channels.rawNames.length} channels, ` +
255+
`${providers.rawNames.length} providers, ${workflows.rawNames.length} workflows, ` +
256+
`${mcp.rawNames.length} mcp)`,
257+
)
258+
process.exit(0)
259+
}
260+
fs.writeFileSync(outPath, json)
261+
console.log(
262+
`registry-index.json updated: ${plugins.rawNames.length}p ${hands.rawNames.length}h ` +
263+
`${agents.rawNames.length}a ${skills.rawNames.length}s ${channels.rawNames.length}c ` +
264+
`${providers.rawNames.length}pr ${workflows.rawNames.length}w ${mcp.rawNames.length}mcp`,
265+
)

0 commit comments

Comments
 (0)