dsh-skill-hub 0.3.23 → 0.3.25
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/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +31 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +41 -12
- package/lib/types/client/api.d.ts +22 -0
- package/package.json +1 -1
- package/src/client/api.test.ts +51 -0
- package/src/client/api.ts +32 -1
- package/src/index.ts +49 -12
- package/src/lifecycle.test.ts +101 -0
package/lib/index.js
CHANGED
|
@@ -5126,7 +5126,34 @@ function apply(ctx, config) {
|
|
|
5126
5126
|
}
|
|
5127
5127
|
return current();
|
|
5128
5128
|
};
|
|
5129
|
+
/**
|
|
5130
|
+
* Whether this plugin's fiber is still mounted.
|
|
5131
|
+
*
|
|
5132
|
+
* Async work started from `apply()` — the stats wiring and the persistence
|
|
5133
|
+
* probe — can settle after the fiber unloaded: a config rewrite reloads the
|
|
5134
|
+
* plugin, and disable/enable tears it down. Service reads on an inactive
|
|
5135
|
+
* context throw instead of returning undefined, so every continuation
|
|
5136
|
+
* re-checks this before touching `ctx` (issue #19: a cold-start abort with
|
|
5137
|
+
* `cannot get required service "systemPrompt" in inactive context`).
|
|
5138
|
+
*
|
|
5139
|
+
* The generation counters cannot cover this: they only invalidate a build
|
|
5140
|
+
* superseded by a *newer* build, and teardown does not bump them.
|
|
5141
|
+
*/
|
|
5142
|
+
const mounted = () => {
|
|
5143
|
+
const fiber = ctx.fiber;
|
|
5144
|
+
return fiber === void 0 || fiber.uid !== null;
|
|
5145
|
+
};
|
|
5146
|
+
/** Logger for continuations that may outlive the fiber (#19). */
|
|
5147
|
+
const logInfo = (message) => {
|
|
5148
|
+
if (mounted()) ctx.logger.info(message);
|
|
5149
|
+
};
|
|
5150
|
+
const logWarn = (message, error) => {
|
|
5151
|
+
if (!mounted()) return;
|
|
5152
|
+
if (error === void 0) ctx.logger.warn(message);
|
|
5153
|
+
else ctx.logger.warn(message, error);
|
|
5154
|
+
};
|
|
5129
5155
|
const sync = () => {
|
|
5156
|
+
if (!mounted()) return;
|
|
5130
5157
|
if (disposeSection !== void 0) {
|
|
5131
5158
|
disposeSection();
|
|
5132
5159
|
disposeSection = void 0;
|
|
@@ -5178,32 +5205,33 @@ function apply(ctx, config) {
|
|
|
5178
5205
|
const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), ".agents");
|
|
5179
5206
|
for (const root of [join(home, "skills"), join(agentsHome, "skills")]) try {
|
|
5180
5207
|
const c = await cleanupLeftoverImportDirs(root);
|
|
5181
|
-
if (c > 0)
|
|
5208
|
+
if (c > 0) logInfo(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`);
|
|
5182
5209
|
} catch (error) {
|
|
5183
|
-
|
|
5210
|
+
logWarn("[dsh-skill-hub] startup cleanup failed", error);
|
|
5184
5211
|
}
|
|
5185
5212
|
try {
|
|
5186
5213
|
const reconciled = await reconcileDisabledSkills(store, home);
|
|
5187
|
-
if (reconciled.length > 0)
|
|
5214
|
+
if (reconciled.length > 0) logInfo(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(", ")}`);
|
|
5188
5215
|
} catch (error) {
|
|
5189
|
-
|
|
5216
|
+
logWarn("[dsh-skill-hub] startup disabled-skill reconcile failed", error);
|
|
5190
5217
|
}
|
|
5191
5218
|
try {
|
|
5192
5219
|
const pruned = await pruneStaleDisabledSkills(store, home);
|
|
5193
|
-
if (pruned.repaired.length > 0 || pruned.removed.length > 0)
|
|
5220
|
+
if (pruned.repaired.length > 0 || pruned.removed.length > 0) logInfo(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(", ") || "-"}), removed ${pruned.removed.length} (${pruned.removed.join(", ") || "-"})`);
|
|
5194
5221
|
} catch (error) {
|
|
5195
|
-
|
|
5222
|
+
logWarn("[dsh-skill-hub] startup disabled-record prune failed", error);
|
|
5196
5223
|
}
|
|
5197
5224
|
})();
|
|
5198
5225
|
(async () => {
|
|
5199
5226
|
try {
|
|
5200
5227
|
const legacy = await store.getConfig();
|
|
5228
|
+
if (!mounted()) return;
|
|
5201
5229
|
if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
|
|
5202
5230
|
const settings = settingsOf();
|
|
5203
5231
|
if (settings !== void 0) await settings.update(ENTRY_ID, legacy);
|
|
5204
5232
|
}
|
|
5205
5233
|
} catch (error) {
|
|
5206
|
-
|
|
5234
|
+
logWarn("[dsh-skill-hub] sidecar config migration into the settings namespace failed", error);
|
|
5207
5235
|
}
|
|
5208
5236
|
})();
|
|
5209
5237
|
let statsQuery;
|
|
@@ -5216,7 +5244,7 @@ function apply(ctx, config) {
|
|
|
5216
5244
|
const cold = statsPersistence;
|
|
5217
5245
|
(async () => {
|
|
5218
5246
|
const checkpoint = await store.getSkillStatsState().catch(() => void 0);
|
|
5219
|
-
if (generation !== statsGeneration) return;
|
|
5247
|
+
if (generation !== statsGeneration || !mounted()) return;
|
|
5220
5248
|
const scanMinutes = () => {
|
|
5221
5249
|
const value = current().statsScanMinutes;
|
|
5222
5250
|
return typeof value === "number" && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes;
|
|
@@ -5247,11 +5275,11 @@ function apply(ctx, config) {
|
|
|
5247
5275
|
},
|
|
5248
5276
|
onCheckpoint: (next) => {
|
|
5249
5277
|
store.saveSkillStatsState(next).catch((error) => {
|
|
5250
|
-
ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
|
|
5278
|
+
if (mounted()) ctx.logger.warn("[dsh-skill-hub] persisting skill-stats checkpoint failed", error);
|
|
5251
5279
|
});
|
|
5252
5280
|
}
|
|
5253
5281
|
});
|
|
5254
|
-
if (generation !== statsGeneration) return;
|
|
5282
|
+
if (generation !== statsGeneration || !mounted()) return;
|
|
5255
5283
|
ctx.logger.info(`[dsh-skill-hub] stats seam: ${cold === void 0 ? "session-query (fallback)" : "session-persistence (cold)"}`);
|
|
5256
5284
|
stats = reader;
|
|
5257
5285
|
sync();
|
|
@@ -5265,15 +5293,16 @@ function apply(ctx, config) {
|
|
|
5265
5293
|
const raw = pctx.sessionPersistence;
|
|
5266
5294
|
const seam = asPersistenceSeam(raw);
|
|
5267
5295
|
if (seam === void 0) {
|
|
5268
|
-
ctx.logger.warn("[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback");
|
|
5296
|
+
if (mounted()) ctx.logger.warn("[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback");
|
|
5269
5297
|
return;
|
|
5270
5298
|
}
|
|
5271
5299
|
try {
|
|
5272
5300
|
await seam.list();
|
|
5273
5301
|
} catch (error) {
|
|
5274
|
-
ctx.logger.warn("[dsh-skill-hub] persistence seam probe failed, staying on query fallback", error);
|
|
5302
|
+
if (mounted()) ctx.logger.warn("[dsh-skill-hub] persistence seam probe failed, staying on query fallback", error);
|
|
5275
5303
|
return;
|
|
5276
5304
|
}
|
|
5305
|
+
if (!mounted()) return;
|
|
5277
5306
|
statsPersistence = seam;
|
|
5278
5307
|
wireStats();
|
|
5279
5308
|
})();
|
|
@@ -7,6 +7,28 @@ import { type CatalogResponse, type ConfigResponse, type CreateRequest, type Cre
|
|
|
7
7
|
export declare class SkillHubApiError extends Error {
|
|
8
8
|
constructor(message: string);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve one route path against the page's base.
|
|
12
|
+
*
|
|
13
|
+
* Every route in this family is registered on the server as a root-absolute
|
|
14
|
+
* path (`/api/skill-hub/...`), which is right there but wrong in a browser
|
|
15
|
+
* behind a reverse proxy that owns a path prefix: the page loads from
|
|
16
|
+
* `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
|
|
17
|
+
* so the gateway answers 404 HTML and `readJson` reports "invalid JSON
|
|
18
|
+
* response" (issue #18). Resolving against `document.baseURI` is the host's own
|
|
19
|
+
* convention — `dsh-client-connection` opens its browser carrier with
|
|
20
|
+
* `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
|
|
21
|
+
* exactly the same absolute path while a prefixed one keeps its prefix, and the
|
|
22
|
+
* gateway strips it before forwarding: the server still receives
|
|
23
|
+
* `/api/skill-hub/*`.
|
|
24
|
+
*
|
|
25
|
+
* `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
|
|
26
|
+
* routes on the server), which is why the prefix is applied here at the single
|
|
27
|
+
* outbound boundary rather than in the constants.
|
|
28
|
+
* @param path - one route path, root-absolute unless it is already a full URL.
|
|
29
|
+
* @returns the URL to fetch.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveApiUrl(path: string): string;
|
|
10
32
|
/** The browser half's only data entry point. */
|
|
11
33
|
export declare class SkillHubApi {
|
|
12
34
|
/** One GET round trip (query already encoded by the caller). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-hub",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.25",
|
|
4
4
|
"description": "In-GUI skill hub for DeepSeek Harness (dsh): browse the full local skill catalog from the official ctx.skills registry (every root + third-party providers), toggle skills on/off, inspect bodies, surface frontmatter diagnostics, and scaffold new skills — plus a codex-style skill market (built-in catalog, upstream update checks, one-click update-all) with tracked source sync. The full manager beyond the read-only dsh-skill-manager browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root-absolute route paths must resolve against the page's base, not the site
|
|
3
|
+
* root: behind a reverse proxy that owns a path prefix (`https://host/app/dsh/`)
|
|
4
|
+
* a root-absolute URL loses the prefix and the gateway answers 404 HTML — the
|
|
5
|
+
* `HTTP 404: invalid JSON response` banner in issue #18.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { SkillHubApi, resolveApiUrl } from './api.ts'
|
|
10
|
+
|
|
11
|
+
describe('resolveApiUrl (issue #18)', () => {
|
|
12
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
13
|
+
|
|
14
|
+
it('resolves to the site root for a plain deployment', () => {
|
|
15
|
+
vi.stubGlobal('document', { baseURI: 'http://127.0.0.1:3080/' })
|
|
16
|
+
expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('http://127.0.0.1:3080/api/skill-hub/catalog')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps a reverse proxy path prefix', () => {
|
|
20
|
+
vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
|
|
21
|
+
expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('passes full URLs through and tolerates a missing document (SSR/tests)', () => {
|
|
25
|
+
expect(resolveApiUrl('https://cdn.example.com/x.json')).toBe('https://cdn.example.com/x.json')
|
|
26
|
+
expect(resolveApiUrl('/api/skill-hub/catalog')).toBe('/api/skill-hub/catalog')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('SkillHubApi outbound boundary (issue #18)', () => {
|
|
31
|
+
afterEach(() => { vi.unstubAllGlobals() })
|
|
32
|
+
|
|
33
|
+
it('sends every request through the resolved URL, prefix included', async () => {
|
|
34
|
+
vi.stubGlobal('document', { baseURI: 'https://host.example.com/app/dsh/' })
|
|
35
|
+
const seen: string[] = []
|
|
36
|
+
vi.stubGlobal('fetch', async (url: string) => {
|
|
37
|
+
seen.push(String(url))
|
|
38
|
+
return new Response(JSON.stringify({ ok: true, skills: [], disabled: [], diagnostics: [] }), {
|
|
39
|
+
status: 200,
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const api = new SkillHubApi()
|
|
45
|
+
await api.catalog()
|
|
46
|
+
|
|
47
|
+
// One outbound path for the whole family: the prefix is applied once, here.
|
|
48
|
+
expect(seen).toHaveLength(1)
|
|
49
|
+
expect(seen[0]).toBe('https://host.example.com/app/dsh/api/skill-hub/catalog')
|
|
50
|
+
})
|
|
51
|
+
})
|
package/src/client/api.ts
CHANGED
|
@@ -83,6 +83,37 @@ async function readJson<T>(response: Response): Promise<T> {
|
|
|
83
83
|
return body as T
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Resolve one route path against the page's base.
|
|
88
|
+
*
|
|
89
|
+
* Every route in this family is registered on the server as a root-absolute
|
|
90
|
+
* path (`/api/skill-hub/...`), which is right there but wrong in a browser
|
|
91
|
+
* behind a reverse proxy that owns a path prefix: the page loads from
|
|
92
|
+
* `https://host/app/dsh/` while a root-absolute URL drops the prefix entirely,
|
|
93
|
+
* so the gateway answers 404 HTML and `readJson` reports "invalid JSON
|
|
94
|
+
* response" (issue #18). Resolving against `document.baseURI` is the host's own
|
|
95
|
+
* convention — `dsh-client-connection` opens its browser carrier with
|
|
96
|
+
* `new URL(path.slice(1), document.baseURI)` — so a root deployment resolves to
|
|
97
|
+
* exactly the same absolute path while a prefixed one keeps its prefix, and the
|
|
98
|
+
* gateway strips it before forwarding: the server still receives
|
|
99
|
+
* `/api/skill-hub/*`.
|
|
100
|
+
*
|
|
101
|
+
* `SKILL_HUB_API_ROOT` must stay absolute (the same constant registers the
|
|
102
|
+
* routes on the server), which is why the prefix is applied here at the single
|
|
103
|
+
* outbound boundary rather than in the constants.
|
|
104
|
+
* @param path - one route path, root-absolute unless it is already a full URL.
|
|
105
|
+
* @returns the URL to fetch.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveApiUrl(path: string): string {
|
|
108
|
+
if (!path.startsWith('/')) return path
|
|
109
|
+
if (typeof document === 'undefined') return path
|
|
110
|
+
try {
|
|
111
|
+
return new URL(path.slice(1), document.baseURI).href
|
|
112
|
+
} catch {
|
|
113
|
+
return path
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
/**
|
|
87
118
|
* Fetch with a hard timeout: a hung connection (proxy, stalled keep-alive,
|
|
88
119
|
* host wedged) must never leave the panel stuck on "loading" forever — it
|
|
@@ -92,7 +123,7 @@ async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, ms
|
|
|
92
123
|
const controller = new AbortController()
|
|
93
124
|
const timer = setTimeout(() => controller.abort(), ms)
|
|
94
125
|
try {
|
|
95
|
-
return await fetch(input, { ...init, signal: controller.signal })
|
|
126
|
+
return await fetch(typeof input === 'string' ? resolveApiUrl(input) : input, { ...init, signal: controller.signal })
|
|
96
127
|
} finally {
|
|
97
128
|
clearTimeout(timer)
|
|
98
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -221,7 +221,40 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
221
221
|
// (including the config route) stays mounted even with the master switch
|
|
222
222
|
// off so the settings card can always read and re-enable the hub; the
|
|
223
223
|
// business routes answer 503 while disabled.
|
|
224
|
+
/**
|
|
225
|
+
* Whether this plugin's fiber is still mounted.
|
|
226
|
+
*
|
|
227
|
+
* Async work started from `apply()` — the stats wiring and the persistence
|
|
228
|
+
* probe — can settle after the fiber unloaded: a config rewrite reloads the
|
|
229
|
+
* plugin, and disable/enable tears it down. Service reads on an inactive
|
|
230
|
+
* context throw instead of returning undefined, so every continuation
|
|
231
|
+
* re-checks this before touching `ctx` (issue #19: a cold-start abort with
|
|
232
|
+
* `cannot get required service "systemPrompt" in inactive context`).
|
|
233
|
+
*
|
|
234
|
+
* The generation counters cannot cover this: they only invalidate a build
|
|
235
|
+
* superseded by a *newer* build, and teardown does not bump them.
|
|
236
|
+
*/
|
|
237
|
+
const mounted = (): boolean => {
|
|
238
|
+
// `fiber` has always been public on a Cordis context, but stay tolerant of
|
|
239
|
+
// a host whose Cordis predates it: absent means "we cannot tell", and the
|
|
240
|
+
// pre-#19 behaviour is the safer answer there.
|
|
241
|
+
const fiber = (ctx as { fiber?: { uid: number | null } }).fiber
|
|
242
|
+
return fiber === undefined || fiber.uid !== null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Logger for continuations that may outlive the fiber (#19). */
|
|
246
|
+
const logInfo = (message: string): void => {
|
|
247
|
+
if (mounted()) ctx.logger.info(message)
|
|
248
|
+
}
|
|
249
|
+
const logWarn = (message: string, error?: unknown): void => {
|
|
250
|
+
if (!mounted()) return
|
|
251
|
+
if (error === undefined) ctx.logger.warn(message)
|
|
252
|
+
else ctx.logger.warn(message, error)
|
|
253
|
+
}
|
|
254
|
+
|
|
224
255
|
const sync = (): void => {
|
|
256
|
+
// A late stats continuation may still reach here after teardown (#19).
|
|
257
|
+
if (!mounted()) return
|
|
225
258
|
if (disposeSection !== undefined) {
|
|
226
259
|
disposeSection()
|
|
227
260
|
disposeSection = undefined
|
|
@@ -304,9 +337,9 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
304
337
|
for (const root of [join(home, 'skills'), join(agentsHome, 'skills')]) {
|
|
305
338
|
try {
|
|
306
339
|
const c = await cleanupLeftoverImportDirs(root)
|
|
307
|
-
if (c > 0)
|
|
340
|
+
if (c > 0) logInfo(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`)
|
|
308
341
|
} catch (error) {
|
|
309
|
-
|
|
342
|
+
logWarn('[dsh-skill-hub] startup cleanup failed', error)
|
|
310
343
|
}
|
|
311
344
|
}
|
|
312
345
|
// 对账:磁盘上已有 .disabled、sidecar 却无记录(状态文件被恢复/手改、旧版本
|
|
@@ -314,32 +347,34 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
314
347
|
try {
|
|
315
348
|
const reconciled = await reconcileDisabledSkills(store, home)
|
|
316
349
|
if (reconciled.length > 0) {
|
|
317
|
-
|
|
350
|
+
logInfo(`[dsh-skill-hub] startup reconciled ${reconciled.length} disabled skill record(s): ${reconciled.map((entry) => entry.name).join(', ')}`)
|
|
318
351
|
}
|
|
319
352
|
} catch (error) {
|
|
320
|
-
|
|
353
|
+
logWarn('[dsh-skill-hub] startup disabled-skill reconcile failed', error)
|
|
321
354
|
}
|
|
322
355
|
// 反向对账:记录还在、文件已不在 → 修路径或删记录。不做这一步,那条记录会
|
|
323
356
|
// 把名字永久锁死(启用/新建/删除三条路都失败)。
|
|
324
357
|
try {
|
|
325
358
|
const pruned = await pruneStaleDisabledSkills(store, home)
|
|
326
359
|
if (pruned.repaired.length > 0 || pruned.removed.length > 0) {
|
|
327
|
-
|
|
360
|
+
logInfo(`[dsh-skill-hub] startup pruned disabled records: repaired ${pruned.repaired.length} (${pruned.repaired.map((entry) => entry.name).join(', ') || '-'}), removed ${pruned.removed.length} (${pruned.removed.join(', ') || '-'})`)
|
|
328
361
|
}
|
|
329
362
|
} catch (error) {
|
|
330
|
-
|
|
363
|
+
logWarn('[dsh-skill-hub] startup disabled-record prune failed', error)
|
|
331
364
|
}
|
|
332
365
|
})()
|
|
333
366
|
|
|
334
367
|
void (async () => {
|
|
335
368
|
try {
|
|
336
369
|
const legacy = await store.getConfig()
|
|
370
|
+
// The read is awaited; the plugin may have unloaded meanwhile (#19).
|
|
371
|
+
if (!mounted()) return
|
|
337
372
|
if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
|
|
338
373
|
const settings = settingsOf()
|
|
339
374
|
if (settings !== undefined) await settings.update(ENTRY_ID, legacy as Record<string, unknown>)
|
|
340
375
|
}
|
|
341
376
|
} catch (error) {
|
|
342
|
-
|
|
377
|
+
logWarn('[dsh-skill-hub] sidecar config migration into the settings namespace failed', error)
|
|
343
378
|
}
|
|
344
379
|
})()
|
|
345
380
|
|
|
@@ -375,7 +410,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
375
410
|
// 重启后面板秒出旧数、后台重扫。扫描间隔与滚动窗口都从设置命名空间
|
|
376
411
|
// 实时读取——卡片里改完即生效,无需重启。
|
|
377
412
|
const checkpoint = await store.getSkillStatsState().catch(() => undefined)
|
|
378
|
-
if (generation !== statsGeneration) return
|
|
413
|
+
if (generation !== statsGeneration || !mounted()) return
|
|
379
414
|
const scanMinutes = (): number => {
|
|
380
415
|
const value = current().statsScanMinutes
|
|
381
416
|
return typeof value === 'number' && value >= 1 ? Math.floor(value) : HUB_CONFIG_DEFAULTS.statsScanMinutes
|
|
@@ -407,11 +442,11 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
407
442
|
}),
|
|
408
443
|
onCheckpoint: (next) => {
|
|
409
444
|
void store.saveSkillStatsState(next).catch((error) => {
|
|
410
|
-
ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
|
|
445
|
+
if (mounted()) ctx.logger.warn('[dsh-skill-hub] persisting skill-stats checkpoint failed', error)
|
|
411
446
|
})
|
|
412
447
|
},
|
|
413
448
|
})
|
|
414
|
-
if (generation !== statsGeneration) return
|
|
449
|
+
if (generation !== statsGeneration || !mounted()) return
|
|
415
450
|
ctx.logger.info(`[dsh-skill-hub] stats seam: ${cold === undefined ? 'session-query (fallback)' : 'session-persistence (cold)'}`)
|
|
416
451
|
stats = reader
|
|
417
452
|
sync()
|
|
@@ -427,15 +462,17 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
427
462
|
const raw = (pctx as unknown as { sessionPersistence?: unknown }).sessionPersistence
|
|
428
463
|
const seam = asPersistenceSeam(raw)
|
|
429
464
|
if (seam === undefined) {
|
|
430
|
-
ctx.logger.warn('[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback')
|
|
465
|
+
if (mounted()) ctx.logger.warn('[dsh-skill-hub] sessionPersistence shape mismatch, staying on query fallback')
|
|
431
466
|
return
|
|
432
467
|
}
|
|
433
468
|
try {
|
|
434
469
|
await seam.list() // probe: headers only, must succeed before trusting the cold path
|
|
435
470
|
} catch (error) {
|
|
436
|
-
ctx.logger.warn('[dsh-skill-hub] persistence seam probe failed, staying on query fallback', error)
|
|
471
|
+
if (mounted()) ctx.logger.warn('[dsh-skill-hub] persistence seam probe failed, staying on query fallback', error)
|
|
437
472
|
return
|
|
438
473
|
}
|
|
474
|
+
// The probe is awaited, so the plugin may have unloaded meanwhile (#19).
|
|
475
|
+
if (!mounted()) return
|
|
439
476
|
statsPersistence = seam
|
|
440
477
|
wireStats()
|
|
441
478
|
})()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin lifecycle: asynchronous work started from `apply()` must not touch the
|
|
3
|
+
* context after the fiber unloaded. Issue #19 was a cold-start abort —
|
|
4
|
+
* `cannot get required service "systemPrompt" in inactive context` — when the
|
|
5
|
+
* stats wiring settled after a config-driven reload had torn the plugin down.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
12
|
+
import { apply } from './index.ts'
|
|
13
|
+
|
|
14
|
+
/** Minimal context double: records what the plugin registers, exposes a controllable fiber. */
|
|
15
|
+
function fakeHost() {
|
|
16
|
+
const sections: string[] = []
|
|
17
|
+
const routes: string[] = []
|
|
18
|
+
const injected: Array<(context: unknown) => void> = []
|
|
19
|
+
const fiber: { uid: number | null } = { uid: 1 }
|
|
20
|
+
const context = {
|
|
21
|
+
fiber,
|
|
22
|
+
logger: { info: () => {}, warn: () => {} },
|
|
23
|
+
/** No optional services (settings) and no Connection: every optional path degrades. */
|
|
24
|
+
get: () => undefined,
|
|
25
|
+
skills: {
|
|
26
|
+
registerProvider: () => () => {},
|
|
27
|
+
snapshot: async () => ({ skills: [], complete: true }),
|
|
28
|
+
get: async () => undefined,
|
|
29
|
+
},
|
|
30
|
+
systemPrompt: {
|
|
31
|
+
section: (spec: { name: string }) => {
|
|
32
|
+
sections.push(spec.name)
|
|
33
|
+
return () => {}
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
webServer: {
|
|
37
|
+
register: (route: { path: string }) => {
|
|
38
|
+
routes.push(route.path)
|
|
39
|
+
return () => {}
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
effect: (body: () => unknown) => {
|
|
43
|
+
const produced = body()
|
|
44
|
+
return () => {
|
|
45
|
+
if (typeof produced === 'function') (produced as () => void)()
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
inject: (_names: unknown, run: (child: unknown) => void) => { injected.push(run) },
|
|
49
|
+
}
|
|
50
|
+
return { context, sections, routes, fiber, injected }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The session-query seam the stats wiring accepts. */
|
|
54
|
+
const queryStub = {
|
|
55
|
+
listSessions: async () => [],
|
|
56
|
+
readSession: async () => ({ events: [] }),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('plugin lifecycle (issue #19)', () => {
|
|
60
|
+
let home: string
|
|
61
|
+
const originalHome = process.env.DSH_HOME
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
home = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-lifecycle-'))
|
|
65
|
+
process.env.DSH_HOME = home
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
if (originalHome === undefined) delete process.env.DSH_HOME
|
|
70
|
+
else process.env.DSH_HOME = originalHome
|
|
71
|
+
await rm(home, { recursive: true, force: true })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('mounts the announced section and route family once for an active fiber', () => {
|
|
75
|
+
const host = fakeHost()
|
|
76
|
+
apply(host.context as never, undefined)
|
|
77
|
+
expect(host.sections).toEqual(['plugin:dsh-skill-hub'])
|
|
78
|
+
expect(host.routes).toContain('/api/skill-hub/catalog')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('drops a late stats continuation instead of syncing on an inactive context', async () => {
|
|
82
|
+
const host = fakeHost()
|
|
83
|
+
apply(host.context as never, undefined)
|
|
84
|
+
const beforeUnload = host.sections.length
|
|
85
|
+
|
|
86
|
+
// The session-query service arrives: wireStats() starts an async build that
|
|
87
|
+
// first awaits the persisted scan checkpoint. The nested persistence probe
|
|
88
|
+
// is left unmounted (the child `inject` never calls back).
|
|
89
|
+
expect(host.injected).toHaveLength(1)
|
|
90
|
+
host.injected[0]!({ sessionQuery: queryStub, inject: () => {} })
|
|
91
|
+
|
|
92
|
+
// A config rewrite (or disable) unloads the fiber while that await is in
|
|
93
|
+
// flight — the exact window issue #19 aborted on.
|
|
94
|
+
host.fiber.uid = null
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
96
|
+
|
|
97
|
+
// Without the guard the continuation called sync(), which re-registered the
|
|
98
|
+
// announcement on the dead context (and threw on a real host).
|
|
99
|
+
expect(host.sections).toHaveLength(beforeUnload)
|
|
100
|
+
})
|
|
101
|
+
})
|