@stofone/dsh-agent-roster 0.10.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/README.md +16 -4
- package/README.zh.md +13 -4
- package/client.js +84 -18
- package/index.js +8 -2
- package/lib/remote.js +137 -64
- package/locale/en.json +4 -1
- package/locale/zh.json +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.11.0] - 2026-11-03
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Expanding a collection no longer walks its rosters one by one.** `fetchCollection` runs up to six
|
|
12
|
+
fetches at a time and keeps the index order for both rosters and diagnostics: the 21-roster
|
|
13
|
+
`agent-roster-collective` went from **9.4 s to 0.8 s** on this machine (serial vs parallel, real network).
|
|
14
|
+
- The collection route memoises the fetched result for 60 s, and the panel keeps the last expansion in
|
|
15
|
+
memory — re-opening a collection paints immediately instead of re-fetching 21 files.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- A filter box inside an expanded collection: narrows by roster id, title or member name, and shows
|
|
20
|
+
`shown / total`. It only hides rows; it never writes anything.
|
|
21
|
+
|
|
22
|
+
## [0.10.7] - 2026-11-03
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
- README (both languages) now describes the cache that knows its own version, the two remote-source
|
|
27
|
+
diagnostics added in 0.10.6, and a `lib/` listing that matches what is actually there — the previous one
|
|
28
|
+
still named only the v1-era modules and omitted `compile2`/`plan`/`migrate`/`run`/`role`/`roster2`.
|
|
29
|
+
- Test count corrected to 297/297.
|
|
30
|
+
|
|
7
31
|
## [0.10.6] - 2026-11-03
|
|
8
32
|
|
|
9
33
|
### Fixed
|
|
@@ -524,7 +548,9 @@
|
|
|
524
548
|
- 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
|
|
525
549
|
且所有返回值先过 `lib/json.js#lossless`。
|
|
526
550
|
|
|
527
|
-
[Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.
|
|
551
|
+
[Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.0...main
|
|
552
|
+
[0.11.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.7...v0.11.0
|
|
553
|
+
[0.10.7]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.6...v0.10.7
|
|
528
554
|
[0.10.6]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.5...v0.10.6
|
|
529
555
|
[0.10.5]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.4...v0.10.5
|
|
530
556
|
[0.10.4]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.3...v0.10.4
|
package/README.md
CHANGED
|
@@ -392,9 +392,18 @@ repository's `index/registry.json`, and anyone who subscribes can see them in th
|
|
|
392
392
|
- **How often it refreshes**: TTL **one hour** — that is the unauthenticated GitHub budget of 60 requests/hour.
|
|
393
393
|
`?refresh=1` (or the tools' `refresh`) bypasses the cache, throttled to one try per 60 seconds so the quota is not
|
|
394
394
|
burned. On failure the last cached index is reused and labelled "cached (fetch failed)" in the panel.
|
|
395
|
+
The cache records **which shape of index it holds** (`CACHE_VERSION`): a plugin upgrade that changes the
|
|
396
|
+
index shape invalidates old caches and refetches once, so "I upgraded but the panel still 404s" is not a
|
|
397
|
+
thing you have to debug by deleting a directory.
|
|
395
398
|
- **Pulling**: writes are idempotent and report one of `created`, `updated` (explicit overwrite), `unchanged`
|
|
396
399
|
(same content, nothing written) or `conflict` (the local copy differs and is **not** overwritten by default).
|
|
397
400
|
Conflicts are yours to resolve: merge in the editor, or pull again with `overwrite` in the request body.
|
|
401
|
+
- **Expanding is fast and filterable**: opening a collection fetches its rosters in parallel (up to six in
|
|
402
|
+
flight) instead of one after another — the 21-roster collective went from **9.4 s to 0.8 s** on this
|
|
403
|
+
machine. The route memoises the fetched result for 60 seconds, and the panel keeps the last expansion in
|
|
404
|
+
memory, so re-opening is instant and re-opening after a minute costs one round of six requests, not 21.
|
|
405
|
+
A filter box inside the expanded list narrows by id, title or member name; it only hides rows, it never
|
|
406
|
+
changes data.
|
|
398
407
|
- **Where each roster lives**: a collection's `rosters[]` is just the list of ids; the optional
|
|
399
408
|
`files` map says where each one is (`{"core-review": "dist/core-review-1.0.0.json"}`). It is optional —
|
|
400
409
|
without it the fetcher assumes the v1 layout `<id>/roster.yaml` — but a v2 library needs it, because an
|
|
@@ -441,7 +450,7 @@ Validation reports errors, warnings and infos with `file`, `path` and `reason`.
|
|
|
441
450
|
- safety: `reserved-character` (`^`, `$`, `|`, braces), `forbidden-content` (prompt-injection patterns), `single-line-required`
|
|
442
451
|
- limits: `string-too-long`, `too-many-members`, `too-many-values`
|
|
443
452
|
- library level: `library-missing`, `library-unusable`, `missing-roster-file`, `multiple-roster-files`, `unexpected-file`, `unreadable-file`, `unreadable-dir`, `duplicate-roster-id`, `tool-policy-cross-check-skipped`
|
|
444
|
-
- remote sources: `index-unreachable`, `index-invalid`, `index-refresh-throttled`, `index-source-invalid`, `collection-fetch-failed`, `collection-not-found`
|
|
453
|
+
- remote sources: `index-unreachable`, `index-invalid`, `index-refresh-throttled`, `index-source-invalid`, `collection-fetch-failed`, `collection-not-found`, `index-may-be-stale` (a v1-path fetch failed on a collection that has no `files` map — the index is probably older than the library), `collection-roster-needs-roles` (the entry points at a v2 assembly, which cannot travel as one file)
|
|
445
454
|
|
|
446
455
|
An empty library is not an error. A missing one is reported as info, not error. `tool-policy-cross-check-skipped` is emitted **once per library** (not once per roster) when no `knownToolPolicies` are configured — a per-roster warning would turn one fact into n lines of noise.
|
|
447
456
|
|
|
@@ -462,8 +471,11 @@ Layout:
|
|
|
462
471
|
|
|
463
472
|
```
|
|
464
473
|
index.js host plugin: Config, apply, six routes
|
|
465
|
-
lib/ format,
|
|
466
|
-
|
|
474
|
+
lib/ reading: format, format-v2, frontmatter, parse, validate, role, roster2,
|
|
475
|
+
capabilities, organization, registry, remote, library, home
|
|
476
|
+
compiling: compile2, plan, migrate, run
|
|
477
|
+
speaking to the runtime: bridge, verify, diff, digest, render, tools, service,
|
|
478
|
+
teams-state, captain, write, apply, serialize, config-view, json, http
|
|
467
479
|
client.js client plugin: sidebar entry, main panel (rosters/teams/health), settings section
|
|
468
480
|
locale/ panel + listing dictionaries (en/zh)
|
|
469
481
|
schema/ roster v1 / role v1 / roster v2 / compiled v1 / capabilities / organization / registry
|
|
@@ -480,7 +492,7 @@ test/ unit tests
|
|
|
480
492
|
|
|
481
493
|
Verified on this machine:
|
|
482
494
|
|
|
483
|
-
- `npm test` —
|
|
495
|
+
- `npm test` — 300/300 passing (format, parse, validate, registry, paths, bridge, verify buckets, digests and provenance, roster diff, capability requirements and the capability gate, library organization, registry schema, lossless JSON, write plan/apply, all routes, panel snapshot and hints, dictionary consistency, tool contracts, client loader contract, library snapshot, config view, package manifest, declared fields and captain block, schema/constant alignment, the v2 role/roster/compiled layers and their schema alignment, v1→v2 migration, run records feeding the plan, remote registry and one-click install).
|
|
484
496
|
- `npm run smoke` — `apply()` registers all six tools through a real `defineTool` from a DSH bundle; `roster_list` / `roster_show` / `roster_create` / `roster_verify` / `roster_diff` execute, every output survives `lossless` checks, `path` overrides are honored, effect disposal runs cleanly. The same command then loads `client.js` against a stubbed module table and asserts the three slot registrations, style injection and effect disposal.
|
|
485
497
|
- CLI: `validate --dir examples` returns exit 0 over both example libraries.
|
|
486
498
|
- Installed into a real profile with the plugin manager (`plugin_manager install_bundle` against this directory → `link:` dependency + bundle entry); the profile re-reads the linked files on restart.
|
package/README.zh.md
CHANGED
|
@@ -369,6 +369,12 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
|
|
|
369
369
|
留空 = 只用官方源 `dsh-agent-roster/dsh-agent-roster` 的 `index/registry.json`;显式写 `false` 才彻底不连远端。
|
|
370
370
|
- **多久更新一次**:TTL **一小时**——等于未认证 GitHub 的 60 次/小时配额。`?refresh=1`(或工具的 `refresh`)
|
|
371
371
|
可绕过缓存,但 60 秒内最多一次,避免把配额烧光。拉取失败时沿用上次缓存并在页面上写明「缓存(拉取失败)」。
|
|
372
|
+
缓存里记着**它存的是哪一种形状的索引**(`CACHE_VERSION`):升级插件后如果索引形状变了,旧缓存自动失效、重取一次,
|
|
373
|
+
所以「我升级了,面板却还 404」不需要靠删目录来排查。
|
|
374
|
+
- **展开很快、也能查找**:展开一个集合时档案是**并发**拉的(最多 6 条在飞),不是一份一份排队——
|
|
375
|
+
21 份的集合在本机从 **9.4 秒降到 0.8 秒**。服务端把结果记 60 秒,面板也记住上次展开的内容,
|
|
376
|
+
所以再点开是瞬间的,隔一分钟再点开也只花一轮 6 个请求而不是 21 个。展开区里带一个查找框,
|
|
377
|
+
按 id、标题或成员名过滤;它只隐藏行,不改任何数据。
|
|
372
378
|
- **拉取到本机**:写入是幂等的,四种结果都会报给你——
|
|
373
379
|
`created`(新写入)、`updated`(显式覆盖)、`unchanged`(内容一致,不写盘)、`conflict`(本地不一样,**默认不覆盖**)。
|
|
374
380
|
冲突要你自己决定:要么在编辑器里合并,要么再点一次「拉取到本机」并在请求里带 `overwrite`。
|
|
@@ -417,7 +423,7 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
|
|
|
417
423
|
- 安全:`reserved-character`(`^`、`$`、`|`、花括号)、`forbidden-content`(提示注入特征)、`single-line-required`
|
|
418
424
|
- 上限:`string-too-long`、`too-many-members`、`too-many-values`
|
|
419
425
|
- 档案库级:`library-missing`、`library-unusable`、`missing-roster-file`、`multiple-roster-files`、`unexpected-file`、`unreadable-file`、`unreadable-dir`、`duplicate-roster-id`、`tool-policy-cross-check-skipped`
|
|
420
|
-
- 远端源:`index-unreachable`、`index-invalid`、`index-refresh-throttled`、`index-source-invalid`、`collection-fetch-failed`、`collection-not-found`
|
|
426
|
+
- 远端源:`index-unreachable`、`index-invalid`、`index-refresh-throttled`、`index-source-invalid`、`collection-fetch-failed`、`collection-not-found`、`index-may-be-stale`(没有 `files` 映射却回退到 v1 路径并失败——索引多半比档案库旧)、`collection-roster-needs-roles`(条目指向 v2 组装,而组装不能以单文件旅行)
|
|
421
427
|
|
|
422
428
|
空档案库不是错误;目录不存在只算 info。`tool-policy-cross-check-skipped` **每座档案库只发一条**(不是每份档案一条)——没配 `knownToolPolicies` 是一件库级的事,按档案重复只会把一个事实放大成 n 行噪音。
|
|
423
429
|
|
|
@@ -436,8 +442,11 @@ node scripts/check-locale.mjs # client.js 里的静态 t() 键必须两本词
|
|
|
436
442
|
|
|
437
443
|
```
|
|
438
444
|
index.js 宿主半边:Config、apply、六条路由
|
|
439
|
-
lib/ format /
|
|
440
|
-
|
|
445
|
+
lib/ 读:format / format-v2 / frontmatter / parse / validate / role / roster2 /
|
|
446
|
+
capabilities / organization / registry / remote / library / home
|
|
447
|
+
编译:compile2 / plan / migrate / run
|
|
448
|
+
对运行时说话:bridge / verify / diff / digest / render / tools / service /
|
|
449
|
+
teams-state / captain / write / apply / serialize / config-view / json / http
|
|
441
450
|
client.js 客户端半边:左侧栏图标 + 主面板(档案/团队/体检)+ 设置页小节
|
|
442
451
|
locale/ 面板与列表词典(en/zh)
|
|
443
452
|
schema/ roster v1 / role v1 / roster v2 / compiled v1 / capabilities / organization / registry
|
|
@@ -454,7 +463,7 @@ test/ 单测
|
|
|
454
463
|
|
|
455
464
|
本机已验证:
|
|
456
465
|
|
|
457
|
-
- `npm test` ——
|
|
466
|
+
- `npm test` —— 300/300 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
|
|
458
467
|
- `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
|
|
459
468
|
- CLI:`validate --dir examples` 对两座示例库退出码 0。
|
|
460
469
|
- 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
|
package/client.js
CHANGED
|
@@ -1663,11 +1663,27 @@ window.__ModuleLoader__.load({
|
|
|
1663
1663
|
* 拉取是**显式动作**:后端按内容比对,相同的跳过、不同的默认不覆盖(会把冲突数报回来)。
|
|
1664
1664
|
* 前端不做这份判断——它只把结果说清楚,避免"点一下按钮,本地改动就没了"。
|
|
1665
1665
|
*/
|
|
1666
|
+
// 展开过的 collection 记在模块里:再点开先画缓存,再在后台刷新。
|
|
1667
|
+
// 服务端也有一份 60s 缓存,两层各管一段——这层管"切换页签不白等",那层管"重复展开不打网络"。
|
|
1668
|
+
const SOURCE_DETAIL_TTL_MS = 60_000;
|
|
1669
|
+
const sourceDetailCache = new Map();
|
|
1670
|
+
|
|
1671
|
+
function cachedSourceDetail(key) {
|
|
1672
|
+
const entry = sourceDetailCache.get(key);
|
|
1673
|
+
if (entry === undefined) return null;
|
|
1674
|
+
if (Date.now() - entry.at >= SOURCE_DETAIL_TTL_MS) {
|
|
1675
|
+
sourceDetailCache.delete(key);
|
|
1676
|
+
return null;
|
|
1677
|
+
}
|
|
1678
|
+
return entry.body;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1666
1681
|
function SourcesTab({ remote, onToast, onPulled, onRefresh, refreshing }) {
|
|
1667
1682
|
useTranslation();
|
|
1668
1683
|
const [pulling, setPulling] = useState("");
|
|
1669
1684
|
const [openId, setOpenId] = useState("");
|
|
1670
1685
|
const [detail, setDetail] = useState(null);
|
|
1686
|
+
const [filter, setFilter] = useState("");
|
|
1671
1687
|
|
|
1672
1688
|
const collections = remote?.collections ?? [];
|
|
1673
1689
|
const indexes = remote?.indexes ?? [];
|
|
@@ -1700,24 +1716,77 @@ window.__ModuleLoader__.load({
|
|
|
1700
1716
|
}
|
|
1701
1717
|
}
|
|
1702
1718
|
|
|
1719
|
+
function detailKey(collection) {
|
|
1720
|
+
return `${collection.origin?.source ?? ""}::${collection.id}`;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1703
1723
|
async function toggle(collection) {
|
|
1704
1724
|
if (openId === collection.id) {
|
|
1705
1725
|
setOpenId("");
|
|
1706
1726
|
setDetail(null);
|
|
1707
1727
|
return;
|
|
1708
1728
|
}
|
|
1729
|
+
const key = detailKey(collection);
|
|
1730
|
+
const remembered = cachedSourceDetail(key);
|
|
1709
1731
|
setOpenId(collection.id);
|
|
1710
|
-
|
|
1732
|
+
setFilter("");
|
|
1733
|
+
// 有缓存就先出内容,用户看到的"等待"只剩网络那一半。
|
|
1734
|
+
setDetail(remembered === null ? { id: collection.id, status: "loading", body: null } : { id: collection.id, status: "ready", body: remembered });
|
|
1711
1735
|
const query = new URLSearchParams({ id: collection.id });
|
|
1712
1736
|
if (collection.origin?.source !== undefined) query.set("source", collection.origin.source);
|
|
1713
1737
|
const result = await readJson(`${COLLECTION_ROUTE}?${query.toString()}`);
|
|
1714
1738
|
if (result.ok && result.body !== null) {
|
|
1739
|
+
sourceDetailCache.set(key, { at: Date.now(), body: result.body });
|
|
1715
1740
|
setDetail({ id: collection.id, status: "ready", body: result.body });
|
|
1716
|
-
} else {
|
|
1741
|
+
} else if (remembered === null) {
|
|
1717
1742
|
setDetail({ id: collection.id, status: "error", body: null });
|
|
1718
1743
|
}
|
|
1719
1744
|
}
|
|
1720
1745
|
|
|
1746
|
+
/**
|
|
1747
|
+
* 展开区:先把「查什么」和「查到几条」放出来,再列档案。
|
|
1748
|
+
* 查找只过滤视图,不改任何数据,也不触发网络。
|
|
1749
|
+
*/
|
|
1750
|
+
function renderDetail() {
|
|
1751
|
+
const all = detail?.body?.rosters ?? [];
|
|
1752
|
+
const needle = filter.trim().toLowerCase();
|
|
1753
|
+
const shown =
|
|
1754
|
+
needle === ""
|
|
1755
|
+
? all
|
|
1756
|
+
: all.filter((roster) =>
|
|
1757
|
+
[roster.id, roster.title, roster.description, ...(roster.members ?? []).map((member) => member.name)]
|
|
1758
|
+
.filter((value) => typeof value === "string")
|
|
1759
|
+
.some((value) => value.toLowerCase().includes(needle)),
|
|
1760
|
+
);
|
|
1761
|
+
return h(
|
|
1762
|
+
"div",
|
|
1763
|
+
{ className: "rstr-source-detail" },
|
|
1764
|
+
h(
|
|
1765
|
+
"div",
|
|
1766
|
+
{ className: "rstr-source-filter" },
|
|
1767
|
+
h("input", {
|
|
1768
|
+
className: "rstr-input rstr-search",
|
|
1769
|
+
value: filter,
|
|
1770
|
+
placeholder: t("source.filterPlaceholder"),
|
|
1771
|
+
"aria-label": t("source.filter"),
|
|
1772
|
+
onChange: (event) => setFilter(event.target.value),
|
|
1773
|
+
}),
|
|
1774
|
+
h("span", { className: "rstr-muted rstr-small" }, t("source.filterCount", { shown: shown.length, total: all.length })),
|
|
1775
|
+
),
|
|
1776
|
+
shown.map((roster) =>
|
|
1777
|
+
h(
|
|
1778
|
+
"div",
|
|
1779
|
+
{ key: roster.id, className: "rstr-source-roster" },
|
|
1780
|
+
h("span", { className: "rstr-mono" }, roster.id),
|
|
1781
|
+
h("span", { className: "rstr-muted" }, roster.title ?? ""),
|
|
1782
|
+
h(Badge, { tone: "neutral" }, `${(roster.members ?? []).length}`),
|
|
1783
|
+
),
|
|
1784
|
+
),
|
|
1785
|
+
shown.length === 0 ? h("p", { className: "rstr-muted" }, t("panel.noMatch")) : null,
|
|
1786
|
+
(detail.body?.diagnostics ?? []).length > 0 ? h(DiagnosticList, { items: detail.body.diagnostics }) : null,
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1721
1790
|
// 刷新是档案源自己的动作:绕过 TTL 重新回源,而不是重读本地快照。
|
|
1722
1791
|
const head = h(
|
|
1723
1792
|
"div",
|
|
@@ -1810,22 +1879,7 @@ window.__ModuleLoader__.load({
|
|
|
1810
1879
|
? h("p", { className: "rstr-muted" }, t("source.loading"))
|
|
1811
1880
|
: detail.status === "error"
|
|
1812
1881
|
? h("p", { className: "rstr-muted" }, t("misc.failed"))
|
|
1813
|
-
:
|
|
1814
|
-
"div",
|
|
1815
|
-
{ className: "rstr-source-detail" },
|
|
1816
|
-
(detail.body?.rosters ?? []).map((roster) =>
|
|
1817
|
-
h(
|
|
1818
|
-
"div",
|
|
1819
|
-
{ key: roster.id, className: "rstr-source-roster" },
|
|
1820
|
-
h("span", { className: "rstr-mono" }, roster.id),
|
|
1821
|
-
h("span", { className: "rstr-muted" }, roster.title ?? ""),
|
|
1822
|
-
h(Badge, { tone: "neutral" }, `${(roster.members ?? []).length}`),
|
|
1823
|
-
),
|
|
1824
|
-
),
|
|
1825
|
-
(detail.body?.diagnostics ?? []).length > 0
|
|
1826
|
-
? h(DiagnosticList, { items: detail.body.diagnostics })
|
|
1827
|
-
: null,
|
|
1828
|
-
)
|
|
1882
|
+
: renderDetail()
|
|
1829
1883
|
: null,
|
|
1830
1884
|
),
|
|
1831
1885
|
),
|
|
@@ -2447,6 +2501,18 @@ window.__ModuleLoader__.load({
|
|
|
2447
2501
|
border-left: 2px solid var(--dsw-alias-border-l2);
|
|
2448
2502
|
margin-left: 2px;
|
|
2449
2503
|
}
|
|
2504
|
+
.rstr-source-filter {
|
|
2505
|
+
display: flex;
|
|
2506
|
+
align-items: center;
|
|
2507
|
+
gap: 10px;
|
|
2508
|
+
min-width: 0;
|
|
2509
|
+
padding: 2px 0 6px;
|
|
2510
|
+
}
|
|
2511
|
+
.rstr-source-filter .rstr-search {
|
|
2512
|
+
flex: 1 1 auto;
|
|
2513
|
+
min-width: 0;
|
|
2514
|
+
max-width: 320px;
|
|
2515
|
+
}
|
|
2450
2516
|
.rstr-source-roster {
|
|
2451
2517
|
display: grid;
|
|
2452
2518
|
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr) auto;
|
package/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { validateRoster } from "./lib/validate.js";
|
|
|
25
25
|
import { validateRoster2 } from "./lib/roster2.js";
|
|
26
26
|
import { sendError, sendJson, createAuthGate, readJsonBody, requireMethod } from "./lib/http.js";
|
|
27
27
|
import { buildPanelSnapshot, installCollection, loadRemotePanel, resolveRoots } from "./lib/service.js";
|
|
28
|
-
import { fetchCollection } from "./lib/remote.js";
|
|
28
|
+
import { createTtlCache, fetchCollection } from "./lib/remote.js";
|
|
29
29
|
import { registerRosterTools } from "./lib/tools.js";
|
|
30
30
|
import { deleteRosterDocument, readRosterDocument, writeRosterDocument } from "./lib/write.js";
|
|
31
31
|
|
|
@@ -129,6 +129,10 @@ export function apply(ctx, config) {
|
|
|
129
129
|
"library route",
|
|
130
130
|
);
|
|
131
131
|
|
|
132
|
+
// 同一份 collection 一分钟内被展开两次,第二次不该再打一轮请求。
|
|
133
|
+
// 缓存的是**已校验的结果**,不是响应体,所以不存在"把非法内容缓存下来"的问题。
|
|
134
|
+
const collectionCache = createTtlCache({ ttlMs: 60_000 });
|
|
135
|
+
|
|
132
136
|
register(
|
|
133
137
|
"/plugins/dsh-agent-roster/collection",
|
|
134
138
|
async (req, res) => {
|
|
@@ -145,7 +149,9 @@ export function apply(ctx, config) {
|
|
|
145
149
|
sendJson(res, 404, { ok: false, error: { code: "collection-not-found" }, known: remote.collections.map((item) => item.id) });
|
|
146
150
|
return;
|
|
147
151
|
}
|
|
148
|
-
const
|
|
152
|
+
const cacheKey = `${source}::${id}`;
|
|
153
|
+
const cached = query.get("refresh") === "1" ? null : collectionCache.get(cacheKey);
|
|
154
|
+
const fetched = cached ?? collectionCache.set(cacheKey, await fetchCollection(collection));
|
|
149
155
|
sendJson(res, 200, {
|
|
150
156
|
ok: fetched.diagnostics.every((item) => item.severity !== "error"),
|
|
151
157
|
collection: {
|
package/lib/remote.js
CHANGED
|
@@ -550,76 +550,75 @@ export function validateRegistry(value, meta = {}) {
|
|
|
550
550
|
* @param {(id: string) => boolean} [options.only] 只要这几份(面板按需拉取时用)
|
|
551
551
|
* @returns {Promise<{ collection: object, rosters: object[], diagnostics: object[] }>}
|
|
552
552
|
*/
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
loaded = { url, fileName, text: await response.text() };
|
|
573
|
-
break;
|
|
574
|
-
} catch (error) {
|
|
575
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
if (loaded === null) {
|
|
579
|
-
diagnostics.push(diagnostic("warn", "collection-fetch-failed", `${collection.id}/${rosterId} 拉取失败:${lastError || "未知原因"}`));
|
|
580
|
-
// 没有 files 映射时退回的是 v1 路径;v2 库在那条路径上必然 404。
|
|
581
|
-
// 这时最可能的原因是"索引是升级前缓存的",提示一句比让用户猜有用。
|
|
582
|
-
if (listedPath === null) {
|
|
583
|
-
diagnostics.push(
|
|
584
|
-
diagnostic(
|
|
585
|
-
"info",
|
|
586
|
-
"index-may-be-stale",
|
|
587
|
-
`${collection.id} 的索引里没有 files 映射,回退到了 v1 路径;若刚升级过插件,先刷新索引(缓存按版本失效,也可删 .index-cache)`,
|
|
588
|
-
),
|
|
589
|
-
);
|
|
553
|
+
/**
|
|
554
|
+
* 拉一份档案(成功就返回 roster,失败就返回诊断)。
|
|
555
|
+
*
|
|
556
|
+
* 抽成独立函数是为了让 `fetchCollection` 能并发跑多份:串行 21 次 HTTPS 往返
|
|
557
|
+
* 实测 2.4 秒,用户点「展开」就是等它。并发只改变等待形状,不改变结果形状。
|
|
558
|
+
*/
|
|
559
|
+
async function fetchOneRoster(collection, rosterId, { fetchImpl }) {
|
|
560
|
+
let loaded = null;
|
|
561
|
+
let lastError = "";
|
|
562
|
+
const listedPath = collection.files?.[rosterId] ?? null;
|
|
563
|
+
const candidateNames = listedPath === null ? ["roster.yaml", "roster.yml", "roster.json"] : [null];
|
|
564
|
+
for (const fileName of candidateNames) {
|
|
565
|
+
const url = listedPath === null ? collectionFileUrl(collection, rosterId, fileName) : collectionFileUrl(collection, rosterId, "");
|
|
566
|
+
try {
|
|
567
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
568
|
+
if (!response.ok) {
|
|
569
|
+
lastError = `HTTP ${String(response.status)}`;
|
|
570
|
+
continue;
|
|
590
571
|
}
|
|
591
|
-
|
|
572
|
+
loaded = { url, fileName, text: await response.text() };
|
|
573
|
+
break;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
592
576
|
}
|
|
593
|
-
|
|
594
|
-
|
|
577
|
+
}
|
|
578
|
+
if (loaded === null) {
|
|
579
|
+
const diagnostics = [
|
|
580
|
+
diagnostic("warn", "collection-fetch-failed", `${collection.id}/${rosterId} 拉取失败:${lastError || "未知原因"}`),
|
|
581
|
+
];
|
|
582
|
+
// 没有 files 映射时退回的是 v1 路径;v2 库在那条路径上必然 404。
|
|
583
|
+
// 这时最可能的原因是"索引是升级前缓存的",提示一句比让用户猜有用。
|
|
584
|
+
if (listedPath === null) {
|
|
595
585
|
diagnostics.push(
|
|
586
|
+
diagnostic(
|
|
587
|
+
"info",
|
|
588
|
+
"index-may-be-stale",
|
|
589
|
+
`${collection.id} 的索引里没有 files 映射,回退到了 v1 路径;若刚升级过插件,先刷新索引(缓存按版本失效,也可删 .index-cache)`,
|
|
590
|
+
),
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return { roster: null, diagnostics };
|
|
594
|
+
}
|
|
595
|
+
const parsed = parseDocumentText(loaded.text, { file: loaded.url });
|
|
596
|
+
if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === ROSTER2_KIND) {
|
|
597
|
+
return {
|
|
598
|
+
roster: null,
|
|
599
|
+
diagnostics: [
|
|
596
600
|
diagnostic(
|
|
597
601
|
"warn",
|
|
598
602
|
"collection-roster-needs-roles",
|
|
599
603
|
`${collection.id}/${rosterId} 是 v2 组装,成员是角色引用,单个文件拉不到角色目录;索引应指向 dist/ 里的编译产物`,
|
|
600
604
|
),
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (!checked.ok) {
|
|
619
|
-
diagnostics.push(...checked.diagnostics);
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
rosters.push({
|
|
605
|
+
],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
let checked;
|
|
609
|
+
if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === COMPILED_KIND) {
|
|
610
|
+
const compiled = validateCompiled(parsed.value, { file: loaded.url });
|
|
611
|
+
if (!compiled.ok) return { roster: null, diagnostics: compiled.diagnostics };
|
|
612
|
+
checked = validateRoster(flattenCompiledToRoster(compiled.compiled), { file: loaded.url, expectedId: rosterId });
|
|
613
|
+
checked = { ...checked, format: "compiled" };
|
|
614
|
+
} else {
|
|
615
|
+
checked = parsed.ok
|
|
616
|
+
? validateRoster(parsed.value, { file: loaded.url, expectedId: rosterId })
|
|
617
|
+
: { ok: false, diagnostics: parsed.diagnostics };
|
|
618
|
+
}
|
|
619
|
+
if (!checked.ok) return { roster: null, diagnostics: checked.diagnostics };
|
|
620
|
+
return {
|
|
621
|
+
roster: {
|
|
623
622
|
...checked.roster,
|
|
624
623
|
...(checked.format === undefined ? {} : { format: checked.format }),
|
|
625
624
|
origin: {
|
|
@@ -631,12 +630,86 @@ export async function fetchCollection(collection, options = {}) {
|
|
|
631
630
|
path: collection.path,
|
|
632
631
|
url: loaded.url,
|
|
633
632
|
},
|
|
634
|
-
}
|
|
635
|
-
|
|
633
|
+
},
|
|
634
|
+
diagnostics: [],
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** 并发上限:raw.githubusercontent 是无认证的,6 条并行既快又不会被当成抓取器。 */
|
|
639
|
+
export const FETCH_CONCURRENCY = 6;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* 拉取一个 collection 里的档案。
|
|
643
|
+
*
|
|
644
|
+
* 并发上限由 `options.concurrency` 覆盖(测试用它把时序摊平);返回的 `rosters`
|
|
645
|
+
* 与 `diagnostics` 都保持索引顺序,和串行版本的输出逐字一致。
|
|
646
|
+
*
|
|
647
|
+
* @param {object} collection `validateRegistry` 校验过的 collection
|
|
648
|
+
* @param {object} [options]
|
|
649
|
+
* @param {string} [options.only] 只拉这一份(测试/预览用)
|
|
650
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
651
|
+
* @param {number} [options.concurrency]
|
|
652
|
+
* @returns {Promise<{collection: object, rosters: object[], diagnostics: object[]}>}
|
|
653
|
+
*/
|
|
654
|
+
export async function fetchCollection(collection, options = {}) {
|
|
655
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
656
|
+
const wanted = collection.rosters.filter((id) => options.only === undefined || options.only(id));
|
|
657
|
+
const concurrency = Math.max(1, Math.min(Number.isInteger(options.concurrency) ? options.concurrency : FETCH_CONCURRENCY, wanted.length || 1));
|
|
658
|
+
const results = new Array(wanted.length);
|
|
659
|
+
let next = 0;
|
|
660
|
+
const workers = Array.from({ length: Math.min(concurrency, wanted.length) }, async () => {
|
|
661
|
+
for (;;) {
|
|
662
|
+
const index = next;
|
|
663
|
+
next += 1;
|
|
664
|
+
if (index >= wanted.length) return;
|
|
665
|
+
results[index] = await fetchOneRoster(collection, wanted[index], { fetchImpl });
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
await Promise.all(workers);
|
|
636
669
|
|
|
670
|
+
const rosters = [];
|
|
671
|
+
const diagnostics = [];
|
|
672
|
+
for (const result of results) {
|
|
673
|
+
if (result === undefined) continue;
|
|
674
|
+
if (result.roster !== null) rosters.push(result.roster);
|
|
675
|
+
diagnostics.push(...result.diagnostics);
|
|
676
|
+
}
|
|
637
677
|
return { collection, rosters, diagnostics };
|
|
638
678
|
}
|
|
639
679
|
|
|
680
|
+
/**
|
|
681
|
+
* 一份进程内的 TTL 缓存。
|
|
682
|
+
*
|
|
683
|
+
* 用途只有一个:同一个 collection 在短时间内被展开两次时,第二次不该再打 21 个请求。
|
|
684
|
+
* 缓存的是**结果**(已校验的档案),不是响应体——所以没有"缓存了不合法内容"的风险。
|
|
685
|
+
*
|
|
686
|
+
* @param {{ttlMs?: number, now?: () => number}} [options]
|
|
687
|
+
*/
|
|
688
|
+
export function createTtlCache({ ttlMs = 60_000, now = () => Date.now() } = {}) {
|
|
689
|
+
const entries = new Map();
|
|
690
|
+
return {
|
|
691
|
+
get(key) {
|
|
692
|
+
const entry = entries.get(key);
|
|
693
|
+
if (entry === undefined) return null;
|
|
694
|
+
if (now() - entry.at >= ttlMs) {
|
|
695
|
+
entries.delete(key);
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
return entry.value;
|
|
699
|
+
},
|
|
700
|
+
set(key, value) {
|
|
701
|
+
entries.set(key, { at: now(), value });
|
|
702
|
+
return value;
|
|
703
|
+
},
|
|
704
|
+
clear() {
|
|
705
|
+
entries.clear();
|
|
706
|
+
},
|
|
707
|
+
get size() {
|
|
708
|
+
return entries.size;
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
640
713
|
/** 拼一份档案的 raw 地址(relativePath 由索引给出,或回退到 v1 的 `<id>/roster.<ext>`)。 */
|
|
641
714
|
export function collectionFileUrl(collection, rosterId, fileName) {
|
|
642
715
|
const repo = collection.repo.replace(/^github:/, "");
|
package/locale/en.json
CHANGED
|
@@ -298,5 +298,8 @@
|
|
|
298
298
|
"undeclared": "not declared, runtime uses {actual}",
|
|
299
299
|
"unknown": "deployment unreadable"
|
|
300
300
|
}
|
|
301
|
-
}
|
|
301
|
+
},
|
|
302
|
+
"source.filter": "Filter this collection",
|
|
303
|
+
"source.filterPlaceholder": "Filter by id, title or member",
|
|
304
|
+
"source.filterCount": "{shown} of {total}"
|
|
302
305
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED