@nanmicoder/dsh-skills-hub 0.0.1
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/AGENTS.md +9 -0
- package/CONTRIBUTING.md +11 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README_EN.md +120 -0
- package/README_ZH.md +5 -0
- package/assets/readme/hero-en.png +0 -0
- package/assets/readme/hero-zh.png +0 -0
- package/assets/readme/marketplace.png +0 -0
- package/compatibility.json +10 -0
- package/cordis.patch.yml +5 -0
- package/docs/COMPATIBILITY.md +31 -0
- package/docs/CONTRACT.md +125 -0
- package/docs/RELEASE.md +24 -0
- package/lib/client.js +25115 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +51 -0
- package/lib/index.js +176 -0
- package/lib/market/cache.js +113 -0
- package/lib/market/clawhub-provider.js +598 -0
- package/lib/market/install-service.js +394 -0
- package/lib/market/market-service.js +312 -0
- package/lib/market/provider-fetch.js +216 -0
- package/lib/market/skillhub-provider.js +277 -0
- package/lib/market/types.js +82 -0
- package/lib/skills/installed.js +426 -0
- package/lib/skills/management.js +36 -0
- package/lib/skills/read-text.js +23 -0
- package/lib/skills/root.js +112 -0
- package/lib/types/client/api.d.ts +106 -0
- package/lib/types/client/components/FilterBar.d.ts +20 -0
- package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
- package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
- package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
- package/lib/types/client/components/InstalledSkills.d.ts +3 -0
- package/lib/types/client/components/MarkdownView.d.ts +4 -0
- package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
- package/lib/types/client/components/MarketHome.d.ts +12 -0
- package/lib/types/client/components/SecurityBadge.d.ts +16 -0
- package/lib/types/client/components/SkillAvatar.d.ts +14 -0
- package/lib/types/client/components/SkillCard.d.ts +17 -0
- package/lib/types/client/components/SkillDetailView.d.ts +21 -0
- package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
- package/lib/types/client/icons.d.ts +71 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locale-context.d.ts +31 -0
- package/lib/types/client/locales.d.ts +117 -0
- package/lib/types/client/page/MarketPage.d.ts +21 -0
- package/lib/types/client/skill-markdown.d.ts +2 -0
- package/lib/types/client/state.d.ts +119 -0
- package/lib/types/config.d.ts +51 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/market/cache.d.ts +49 -0
- package/lib/types/market/clawhub-provider.d.ts +28 -0
- package/lib/types/market/install-service.d.ts +75 -0
- package/lib/types/market/market-service.d.ts +74 -0
- package/lib/types/market/provider-fetch.d.ts +61 -0
- package/lib/types/market/skillhub-provider.d.ts +13 -0
- package/lib/types/market/types.d.ts +163 -0
- package/lib/types/skills/installed.d.ts +117 -0
- package/lib/types/skills/management.d.ts +7 -0
- package/lib/types/skills/read-text.d.ts +5 -0
- package/lib/types/skills/root.d.ts +48 -0
- package/lib/types/web-routes.d.ts +74 -0
- package/lib/web-routes.js +427 -0
- package/package.json +160 -0
- package/release-notes/v0.0.1.md +11 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Hub configuration.
|
|
3
|
+
*
|
|
4
|
+
* The schema below is the plugin's whole configuration surface: DSH validates
|
|
5
|
+
* the profile's plugin row against it and hands `apply()` the resolved value.
|
|
6
|
+
* Every field carries a default, so a row written as `{}` — or an `apply()`
|
|
7
|
+
* called directly from a test — still boots the plugin with the shipped
|
|
8
|
+
* behaviour instead of failing on a missing key.
|
|
9
|
+
*
|
|
10
|
+
* `skillsRoot: null` means "the user skills directory DSH itself scans"
|
|
11
|
+
* (`$DSH_HOME/skills`); a string is an explicit override for tests, for
|
|
12
|
+
* throwaway profiles, or for a deployment that keeps skills outside the
|
|
13
|
+
* harness home. The reference implementation hard-coded its own
|
|
14
|
+
* `~/.claude/skills`, which is exactly the coupling this field removes.
|
|
15
|
+
*/
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
/**
|
|
18
|
+
* Shipped defaults. Exported so `apply()` can fill in a partially resolved
|
|
19
|
+
* config when the plugin is mounted without going through the schema (unit
|
|
20
|
+
* tests, direct composition), without restating the literals in two places.
|
|
21
|
+
*/
|
|
22
|
+
export const SKILLS_HUB_DEFAULTS = {
|
|
23
|
+
skillsRoot: null,
|
|
24
|
+
clawhubBaseUrl: 'https://clawhub.ai',
|
|
25
|
+
skillhubBaseUrl: 'https://api.skillhub.cn',
|
|
26
|
+
timeoutMs: 15_000,
|
|
27
|
+
requestRetries: 1,
|
|
28
|
+
allowUninstall: true,
|
|
29
|
+
pageSize: 24,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* `null` must stay a legal value for `skillsRoot` (it is the "use the DSH
|
|
33
|
+
* default" marker), so the field is modelled as a union rather than as a
|
|
34
|
+
* nullable string: a plain `z.string().default('')` would make "unset" and
|
|
35
|
+
* "empty string" indistinguishable and would leak an empty path into `path.join`.
|
|
36
|
+
*
|
|
37
|
+
* Schemastery quirk worth knowing: its object resolver drops a key whose
|
|
38
|
+
* resolved value is nullable and which was absent from the input, so
|
|
39
|
+
* `Config({}).skillsRoot` is `undefined`, not `null`. Both mean "use the DSH
|
|
40
|
+
* default" and `apply()` normalizes either to `null` before using it, so a
|
|
41
|
+
* caller that bypasses the schema cannot change the behaviour.
|
|
42
|
+
*/
|
|
43
|
+
export const Config = z.object({
|
|
44
|
+
skillsRoot: z.union([z.string(), z.const(null)]).default(null),
|
|
45
|
+
clawhubBaseUrl: z.string().default(SKILLS_HUB_DEFAULTS.clawhubBaseUrl),
|
|
46
|
+
skillhubBaseUrl: z.string().default(SKILLS_HUB_DEFAULTS.skillhubBaseUrl),
|
|
47
|
+
timeoutMs: z.natural().min(1_000).default(SKILLS_HUB_DEFAULTS.timeoutMs),
|
|
48
|
+
requestRetries: z.natural().default(SKILLS_HUB_DEFAULTS.requestRetries),
|
|
49
|
+
allowUninstall: z.boolean().default(SKILLS_HUB_DEFAULTS.allowUninstall),
|
|
50
|
+
pageSize: z.natural().min(1).max(100).default(SKILLS_HUB_DEFAULTS.pageSize),
|
|
51
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Hub — DeepSeek Harness host plugin.
|
|
3
|
+
*
|
|
4
|
+
* Adds the market half of the Skills Hub panel to a DSH profile:
|
|
5
|
+
*
|
|
6
|
+
* - `/api/skills-hub/*` (browse, search, detail, file preview, installed,
|
|
7
|
+
* install, uninstall) served from the composition's Web server;
|
|
8
|
+
* - installation into the DSH user skills directory, so an installed skill is
|
|
9
|
+
* immediately discoverable by the `skill` tool;
|
|
10
|
+
* - the installed-skill index the market layer asks for via
|
|
11
|
+
* `setInstalledLookup()`.
|
|
12
|
+
*
|
|
13
|
+
* Installation is deliberately not a realm feature: it is plain filesystem work
|
|
14
|
+
* plus HTTP, no agent session is involved, and the tools registry is untouched.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-skills-hub
|
|
17
|
+
*/
|
|
18
|
+
import { Config, SKILLS_HUB_DEFAULTS } from "./config.js";
|
|
19
|
+
import { configureProviderFetch } from "./market/provider-fetch.js";
|
|
20
|
+
import { setInstalledLookup, resetInstalledLookup } from "./market/market-service.js";
|
|
21
|
+
import { installedLookupFrom, scanInstalledSkills } from "./skills/installed.js";
|
|
22
|
+
import { resolveSkillsRoot, resolveSkillsScanRoots } from "./skills/root.js";
|
|
23
|
+
import { registerSkillsHubRoutes, setSkillsHubPageSize, } from "./web-routes.js";
|
|
24
|
+
export const name = 'skills-hub';
|
|
25
|
+
/**
|
|
26
|
+
* Required services.
|
|
27
|
+
*
|
|
28
|
+
* Empty on purpose, and the honest answer for this plugin: nothing must exist for
|
|
29
|
+
* it to *load*. The Web server is looked up lazily (`ctx.get`, both known keys)
|
|
30
|
+
* and bound whenever it appears, so a headless profile keeps the plugin inert
|
|
31
|
+
* instead of pending forever on a service key that composition never provides;
|
|
32
|
+
* the skill registry is optional (it only informs a post-install catalog read);
|
|
33
|
+
* and the market core is pure code with no service dependency at all. Injecting
|
|
34
|
+
* `'webServer'` here would make the plugin unusable in the very profiles where
|
|
35
|
+
* the harness would still boot it, and would break on a composition that spells
|
|
36
|
+
* the service `httpServer`.
|
|
37
|
+
*/
|
|
38
|
+
export const inject = [];
|
|
39
|
+
export { Config };
|
|
40
|
+
/** Web-server service keys, newest first (mirrors `WEB_SERVER_KEYS` in dsh-agent-teams). */
|
|
41
|
+
const WEB_SERVER_KEYS = ['webServer', 'httpServer'];
|
|
42
|
+
/** Fill in defaults when `apply()` is called without going through the schema. */
|
|
43
|
+
function resolveConfig(config) {
|
|
44
|
+
const skillsRoot = typeof config?.skillsRoot === 'string' && config.skillsRoot.trim() !== ''
|
|
45
|
+
? config.skillsRoot
|
|
46
|
+
: null;
|
|
47
|
+
return {
|
|
48
|
+
skillsRoot,
|
|
49
|
+
clawhubBaseUrl: config?.clawhubBaseUrl ?? SKILLS_HUB_DEFAULTS.clawhubBaseUrl,
|
|
50
|
+
skillhubBaseUrl: config?.skillhubBaseUrl ?? SKILLS_HUB_DEFAULTS.skillhubBaseUrl,
|
|
51
|
+
timeoutMs: config?.timeoutMs ?? SKILLS_HUB_DEFAULTS.timeoutMs,
|
|
52
|
+
requestRetries: config?.requestRetries ?? SKILLS_HUB_DEFAULTS.requestRetries,
|
|
53
|
+
allowUninstall: config?.allowUninstall ?? SKILLS_HUB_DEFAULTS.allowUninstall,
|
|
54
|
+
pageSize: config?.pageSize ?? SKILLS_HUB_DEFAULTS.pageSize,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Re-read the skill catalog after a successful filesystem change.
|
|
59
|
+
*
|
|
60
|
+
* There is no public, registry-wide rescan entry point to call: `SkillRegistry`
|
|
61
|
+
* exposes list/snapshot/get/register/registerProvider, the invalidation hooks
|
|
62
|
+
* are either provider-scoped (`SkillProviderControl.invalidate`) or the
|
|
63
|
+
* actor-gated `fs/observed` event — which this plugin must not forge, because
|
|
64
|
+
* the event carries an authoritative tool observation for the whole host. What
|
|
65
|
+
* actually republishes our change is DSH's own watcher: the filesystem skill
|
|
66
|
+
* provider watches every configured root (`watch` defaults to true) and
|
|
67
|
+
* invalidates its catalog on the `addDir`/`unlinkDir` that the atomic publish
|
|
68
|
+
* produces.
|
|
69
|
+
*
|
|
70
|
+
* So this call is a read, not a mutation: it settles the catalog for a consumer
|
|
71
|
+
* asking right after the HTTP response instead of leaving that consumer to race
|
|
72
|
+
* the watcher's stability threshold. A composition without the service (headless
|
|
73
|
+
* profile) simply skips it.
|
|
74
|
+
*/
|
|
75
|
+
async function refreshSkillCatalog(ctx) {
|
|
76
|
+
const skills = ctx.get('skills');
|
|
77
|
+
if (skills?.snapshot === undefined)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
await skills.snapshot({});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
ctx.logger.warn(`skills-hub: skill catalog refresh failed: ${String(error)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Activate the plugin.
|
|
88
|
+
*
|
|
89
|
+
* @param ctx - host plugin context.
|
|
90
|
+
* @param config - resolved {@link SkillsHubConfig} (schema defaults already applied).
|
|
91
|
+
*/
|
|
92
|
+
export function apply(ctx, config) {
|
|
93
|
+
const resolved = resolveConfig(config);
|
|
94
|
+
// Provider endpoints, timeout and retry budget are process-wide in the market
|
|
95
|
+
// core (the reference behaves the same way); the user agent keeps W1's default
|
|
96
|
+
// because the frozen Config surface has no field for it.
|
|
97
|
+
configureProviderFetch({
|
|
98
|
+
clawhubBaseUrl: resolved.clawhubBaseUrl,
|
|
99
|
+
skillhubBaseUrl: resolved.skillhubBaseUrl,
|
|
100
|
+
timeoutMs: resolved.timeoutMs,
|
|
101
|
+
retries: resolved.requestRetries,
|
|
102
|
+
});
|
|
103
|
+
setSkillsHubPageSize(resolved.pageSize);
|
|
104
|
+
let disposed = false;
|
|
105
|
+
/** Rebuild the installed index and hand it to the market layer. */
|
|
106
|
+
const refreshInstalledIndex = async () => {
|
|
107
|
+
const roots = await resolveSkillsScanRoots(resolved.skillsRoot);
|
|
108
|
+
const records = await scanInstalledSkills(roots);
|
|
109
|
+
// A refresh that lost the race with unload must not publish over a newer
|
|
110
|
+
// plugin instance that may already own the seam.
|
|
111
|
+
if (disposed)
|
|
112
|
+
return;
|
|
113
|
+
setInstalledLookup(installedLookupFrom(records));
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The single refresh entry point: routes call it after install/uninstall, and
|
|
117
|
+
* it also runs once at activation. It never throws — the caller has already
|
|
118
|
+
* succeeded at the operation it is refreshing for.
|
|
119
|
+
*/
|
|
120
|
+
const rescan = async () => {
|
|
121
|
+
try {
|
|
122
|
+
await refreshInstalledIndex();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
ctx.logger.warn(`skills-hub: installed-skill index refresh failed: ${String(error)}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await refreshSkillCatalog(ctx);
|
|
129
|
+
};
|
|
130
|
+
ctx.effect(() => () => {
|
|
131
|
+
disposed = true;
|
|
132
|
+
// The market layer is module state that outlives this plugin instance: drop
|
|
133
|
+
// our installed-skill index so a reloaded instance cannot serve stale
|
|
134
|
+
// install state (and `disposed` above stops an in-flight scan from
|
|
135
|
+
// republishing it).
|
|
136
|
+
resetInstalledLookup();
|
|
137
|
+
}, 'skills-hub: activation state');
|
|
138
|
+
// First scan: `apply()` is synchronous, so this cannot be awaited here. Until
|
|
139
|
+
// it settles the market layer keeps its empty default lookup (skills show as
|
|
140
|
+
// installable, and the installer's own conflict check still refuses to clobber
|
|
141
|
+
// an existing directory), and /installed reads the disk directly.
|
|
142
|
+
void rescan();
|
|
143
|
+
// The Web server may bind before or after this plugin (the Loader activates
|
|
144
|
+
// rows concurrently), and older compositions name the service `httpServer`.
|
|
145
|
+
// Bind lazily on the first service event that makes a server reachable.
|
|
146
|
+
let webRegistered = false;
|
|
147
|
+
const registerWebSurface = () => {
|
|
148
|
+
if (webRegistered)
|
|
149
|
+
return;
|
|
150
|
+
const webServer = (ctx.get(WEB_SERVER_KEYS[0]) ?? ctx.get(WEB_SERVER_KEYS[1]));
|
|
151
|
+
if (webServer === undefined)
|
|
152
|
+
return;
|
|
153
|
+
// `requestRejection` belongs to the browser-trust fence of the Connection
|
|
154
|
+
// service. Resolved per request, never captured here: the Connection row can
|
|
155
|
+
// activate after this route is registered, and a snapshot would leave the
|
|
156
|
+
// marketplace readable — and installable — from any web page.
|
|
157
|
+
const gate = () => ctx.get('connection');
|
|
158
|
+
const deps = {
|
|
159
|
+
skillsRoot: () => resolveSkillsRoot(resolved.skillsRoot),
|
|
160
|
+
allowUninstall: () => resolved.allowUninstall,
|
|
161
|
+
rescan,
|
|
162
|
+
};
|
|
163
|
+
// `ctx.effect` runs the factory immediately; `dsh-host-webserver` throws on a
|
|
164
|
+
// duplicate route path, so the flag may only be set once registration has
|
|
165
|
+
// actually succeeded. Setting it first turned a failed registration into a
|
|
166
|
+
// permanent activation failure with no retry on the next service event.
|
|
167
|
+
ctx.effect(() => registerSkillsHubRoutes(webServer, gate, deps), 'skills-hub: HTTP API');
|
|
168
|
+
webRegistered = true;
|
|
169
|
+
};
|
|
170
|
+
registerWebSurface();
|
|
171
|
+
ctx.on('internal/service', (serviceName) => {
|
|
172
|
+
if (serviceName === WEB_SERVER_KEYS[0] || serviceName === WEB_SERVER_KEYS[1])
|
|
173
|
+
registerWebSurface();
|
|
174
|
+
});
|
|
175
|
+
ctx.logger.info(`skills-hub: ready (root=${resolved.skillsRoot ?? '$DSH_HOME/skills'}, uninstall=${resolved.allowUninstall ? 'on' : 'off'})`);
|
|
176
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Market — in-memory TTL cache with stale-while-error support, plus
|
|
3
|
+
* per-source health tracking.
|
|
4
|
+
*
|
|
5
|
+
* Only *upstream* data is cached here (list pages, details, file bodies).
|
|
6
|
+
* Install state is derived per request from the injected installed-skill index
|
|
7
|
+
* and is deliberately never cached: a skill installed a second ago must show up
|
|
8
|
+
* as installed on the very next list call.
|
|
9
|
+
*
|
|
10
|
+
* Everything lives in module scope, so the module needs no DSH service and no
|
|
11
|
+
* filesystem — a unit test can drive it with plain `node --test`.
|
|
12
|
+
*/
|
|
13
|
+
/** Hard cap so a long-lived host process cannot grow the map without bound. */
|
|
14
|
+
const MAX_ENTRIES = 500;
|
|
15
|
+
/** Per-payload TTLs: cheap-to-refetch list pages expire sooner than file bodies. */
|
|
16
|
+
export const MARKET_TTL = {
|
|
17
|
+
list: 5 * 60_000,
|
|
18
|
+
search: 2 * 60_000,
|
|
19
|
+
detail: 10 * 60_000,
|
|
20
|
+
files: 10 * 60_000,
|
|
21
|
+
fileContent: 30 * 60_000,
|
|
22
|
+
};
|
|
23
|
+
class MarketCache {
|
|
24
|
+
entries = new Map();
|
|
25
|
+
get(key) {
|
|
26
|
+
const entry = this.entries.get(key);
|
|
27
|
+
if (!entry)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (Date.now() > entry.expiresAt)
|
|
30
|
+
return undefined;
|
|
31
|
+
// LRU touch: re-insert so the oldest key is the least recently used one.
|
|
32
|
+
this.entries.delete(key);
|
|
33
|
+
this.entries.set(key, entry);
|
|
34
|
+
return entry.value;
|
|
35
|
+
}
|
|
36
|
+
/** Returns the entry even when expired — used for the stale-while-error fallback. */
|
|
37
|
+
getStale(key) {
|
|
38
|
+
const entry = this.entries.get(key);
|
|
39
|
+
if (!entry)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { value: entry.value, storedAt: entry.storedAt };
|
|
42
|
+
}
|
|
43
|
+
set(key, value, ttlMs) {
|
|
44
|
+
if (this.entries.has(key))
|
|
45
|
+
this.entries.delete(key);
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
this.entries.set(key, { value, expiresAt: now + ttlMs, storedAt: now });
|
|
48
|
+
if (this.entries.size > MAX_ENTRIES) {
|
|
49
|
+
// Map iteration is insertion-ordered, so the first key is the oldest.
|
|
50
|
+
const oldest = this.entries.keys().next().value;
|
|
51
|
+
if (oldest !== undefined)
|
|
52
|
+
this.entries.delete(oldest);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
clear() {
|
|
56
|
+
this.entries.clear();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export const marketCache = new MarketCache();
|
|
60
|
+
function freshHealth() {
|
|
61
|
+
return {
|
|
62
|
+
clawhub: { status: 'ok' },
|
|
63
|
+
skillhub: { status: 'ok' },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const sourceHealth = freshHealth();
|
|
67
|
+
/**
|
|
68
|
+
* Record the outcome of one upstream interaction.
|
|
69
|
+
*
|
|
70
|
+
* A `failed` report is downgraded to `degraded` when the source succeeded
|
|
71
|
+
* recently (it answered a moment ago, this request just went wrong) — repeated
|
|
72
|
+
* failures after the last success report `failed`. This mirrors the reference
|
|
73
|
+
* server's `recordSourceFailure`, which is what the UI's source bar was built
|
|
74
|
+
* around; callers that genuinely know better can pass `degraded` explicitly.
|
|
75
|
+
*/
|
|
76
|
+
export function markSourceHealth(source, status, error) {
|
|
77
|
+
const prev = sourceHealth[source];
|
|
78
|
+
if (status === 'ok') {
|
|
79
|
+
sourceHealth[source] = { status: 'ok', lastOkAt: Date.now() };
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (status === 'cached') {
|
|
83
|
+
sourceHealth[source] = { status: 'cached', lastOkAt: prev.lastOkAt, lastError: error };
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
sourceHealth[source] = {
|
|
87
|
+
status: status === 'degraded' || (prev.status === 'ok' && prev.lastOkAt !== undefined)
|
|
88
|
+
? 'degraded'
|
|
89
|
+
: 'failed',
|
|
90
|
+
lastOkAt: prev.lastOkAt,
|
|
91
|
+
lastError: error,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function getSourceHealth(source) {
|
|
95
|
+
const record = sourceHealth[source];
|
|
96
|
+
return {
|
|
97
|
+
status: record.status,
|
|
98
|
+
fetchedAt: record.lastOkAt,
|
|
99
|
+
error: record.lastError,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Test hook: forget every recorded success/failure. */
|
|
103
|
+
export function resetSourceHealth() {
|
|
104
|
+
const fresh = freshHealth();
|
|
105
|
+
for (const source of Object.keys(fresh)) {
|
|
106
|
+
sourceHealth[source] = fresh[source];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Test hook: drop cached payloads and reset source health. */
|
|
110
|
+
export function resetMarketCache() {
|
|
111
|
+
marketCache.clear();
|
|
112
|
+
resetSourceHealth();
|
|
113
|
+
}
|