@stofone/dsh-agent-roster 0.10.7 → 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 +17 -1
- package/README.md +7 -1
- package/README.zh.md +5 -1
- 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,21 @@
|
|
|
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
|
+
|
|
7
22
|
## [0.10.7] - 2026-11-03
|
|
8
23
|
|
|
9
24
|
### Documentation
|
|
@@ -533,7 +548,8 @@
|
|
|
533
548
|
- 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
|
|
534
549
|
且所有返回值先过 `lib/json.js#lossless`。
|
|
535
550
|
|
|
536
|
-
[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
|
|
537
553
|
[0.10.7]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.6...v0.10.7
|
|
538
554
|
[0.10.6]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.5...v0.10.6
|
|
539
555
|
[0.10.5]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.4...v0.10.5
|
package/README.md
CHANGED
|
@@ -398,6 +398,12 @@ repository's `index/registry.json`, and anyone who subscribes can see them in th
|
|
|
398
398
|
- **Pulling**: writes are idempotent and report one of `created`, `updated` (explicit overwrite), `unchanged`
|
|
399
399
|
(same content, nothing written) or `conflict` (the local copy differs and is **not** overwritten by default).
|
|
400
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.
|
|
401
407
|
- **Where each roster lives**: a collection's `rosters[]` is just the list of ids; the optional
|
|
402
408
|
`files` map says where each one is (`{"core-review": "dist/core-review-1.0.0.json"}`). It is optional —
|
|
403
409
|
without it the fetcher assumes the v1 layout `<id>/roster.yaml` — but a v2 library needs it, because an
|
|
@@ -486,7 +492,7 @@ test/ unit tests
|
|
|
486
492
|
|
|
487
493
|
Verified on this machine:
|
|
488
494
|
|
|
489
|
-
- `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).
|
|
490
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.
|
|
491
497
|
- CLI: `validate --dir examples` returns exit 0 over both example libraries.
|
|
492
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
|
@@ -371,6 +371,10 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
|
|
|
371
371
|
可绕过缓存,但 60 秒内最多一次,避免把配额烧光。拉取失败时沿用上次缓存并在页面上写明「缓存(拉取失败)」。
|
|
372
372
|
缓存里记着**它存的是哪一种形状的索引**(`CACHE_VERSION`):升级插件后如果索引形状变了,旧缓存自动失效、重取一次,
|
|
373
373
|
所以「我升级了,面板却还 404」不需要靠删目录来排查。
|
|
374
|
+
- **展开很快、也能查找**:展开一个集合时档案是**并发**拉的(最多 6 条在飞),不是一份一份排队——
|
|
375
|
+
21 份的集合在本机从 **9.4 秒降到 0.8 秒**。服务端把结果记 60 秒,面板也记住上次展开的内容,
|
|
376
|
+
所以再点开是瞬间的,隔一分钟再点开也只花一轮 6 个请求而不是 21 个。展开区里带一个查找框,
|
|
377
|
+
按 id、标题或成员名过滤;它只隐藏行,不改任何数据。
|
|
374
378
|
- **拉取到本机**:写入是幂等的,四种结果都会报给你——
|
|
375
379
|
`created`(新写入)、`updated`(显式覆盖)、`unchanged`(内容一致,不写盘)、`conflict`(本地不一样,**默认不覆盖**)。
|
|
376
380
|
冲突要你自己决定:要么在编辑器里合并,要么再点一次「拉取到本机」并在请求里带 `overwrite`。
|
|
@@ -459,7 +463,7 @@ test/ 单测
|
|
|
459
463
|
|
|
460
464
|
本机已验证:
|
|
461
465
|
|
|
462
|
-
- `npm test` ——
|
|
466
|
+
- `npm test` —— 300/300 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
|
|
463
467
|
- `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
|
|
464
468
|
- CLI:`validate --dir examples` 对两座示例库退出码 0。
|
|
465
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