dsh-skill-hub 0.3.6 → 0.3.8
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/README.md +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2569 -1646
- package/lib/client.js.map +1 -1
- package/lib/index.js +988 -400
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -2
- package/lib/types/client/api.d.ts +15 -5
- package/lib/types/client/helpers.d.ts +10 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +147 -111
- package/lib/types/client/panel/DisabledRow.d.ts +2 -0
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
- package/lib/types/client/panel/dialogs.d.ts +19 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +54 -71
- package/lib/types/client/settings-card.d.ts +20 -17
- package/lib/types/client/settings-form.d.ts +6 -1
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -720
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +9 -22
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/skillfs.d.ts +13 -0
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +30 -28
- package/src/client/SkillHubSettingsCard.tsx +17 -5
- package/src/client/api.ts +108 -184
- package/src/client/helpers.ts +50 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -452
- package/src/client/panel/DisabledRow.tsx +22 -7
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -246
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/ScenesView.tsx +1 -1
- package/src/client/panel/SkillDetailView.tsx +35 -11
- package/src/client/panel/SkillHubPanel.tsx +42 -4
- package/src/client/panel/SkillRow.tsx +5 -2
- package/src/client/panel/SourcesView.tsx +2 -17
- package/src/client/panel/dialogs.tsx +61 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/panel.module.css +1 -8
- package/src/client/panel/useSkillHub.ts +142 -948
- package/src/client/settings-card.tsx +51 -42
- package/src/client/settings-form.ts +19 -5
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -775
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +69 -147
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +42 -0
- package/src/routes.ts +21 -1528
- package/src/skillfs.ts +188 -3
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- package/src/store.ts +7 -737
package/src/skillfs.ts
CHANGED
|
@@ -171,10 +171,21 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
|
|
|
171
171
|
const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
|
|
172
172
|
if (match === null) return { error: 'missing YAML frontmatter (--- block)' }
|
|
173
173
|
let data: unknown
|
|
174
|
+
let rawFrontmatter = match[1]
|
|
174
175
|
try {
|
|
175
|
-
data = load(
|
|
176
|
+
data = load(rawFrontmatter)
|
|
176
177
|
} catch (error) {
|
|
177
|
-
|
|
178
|
+
const repaired = repairFrontmatterScalarFields(rawFrontmatter)
|
|
179
|
+
if (repaired !== null) {
|
|
180
|
+
try {
|
|
181
|
+
data = load(repaired)
|
|
182
|
+
rawFrontmatter = repaired
|
|
183
|
+
} catch {
|
|
184
|
+
return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
return { error: 'invalid YAML frontmatter: ' + (error instanceof Error ? error.message : String(error)) }
|
|
188
|
+
}
|
|
178
189
|
}
|
|
179
190
|
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
180
191
|
return { error: 'frontmatter must be a YAML mapping' }
|
|
@@ -200,6 +211,125 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
|
|
|
200
211
|
return { value: { name, description, ...(whenToUse !== undefined ? { whenToUse } : {}), invocation, content: (match[2] ?? '').trim() } }
|
|
201
212
|
}
|
|
202
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Repair bare scalar fields that contain an unquoted colon (e.g. `description: Build for AWS: ECS`)
|
|
216
|
+
* or an invalid flow-like scalar (`@`, `` ` ``, `[`, `{`). Mirrors codex `repair_frontmatter_scalar_fields`:
|
|
217
|
+
* line-oriented, only touches lines where quoting makes the YAML valid, and preserves block scalars.
|
|
218
|
+
* Returns the repaired frontmatter string when at least one line was quoted, otherwise null.
|
|
219
|
+
*/
|
|
220
|
+
function repairFrontmatterScalarFields(frontmatter: string): string | null {
|
|
221
|
+
let changed = false
|
|
222
|
+
let blockScalarIndent: number | null = null
|
|
223
|
+
const repairedLines: string[] = []
|
|
224
|
+
for (const line of frontmatter.split('\n')) {
|
|
225
|
+
const indent = line.search(/[^ ]/)
|
|
226
|
+
const effectiveIndent = indent === -1 ? line.length : indent
|
|
227
|
+
if (blockScalarIndent !== null) {
|
|
228
|
+
if (line.trim() === '' || effectiveIndent > blockScalarIndent) {
|
|
229
|
+
repairedLines.push(line)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
blockScalarIndent = null
|
|
233
|
+
}
|
|
234
|
+
const colonIndex = line.indexOf(':')
|
|
235
|
+
if (colonIndex === -1) {
|
|
236
|
+
repairedLines.push(line)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
const key = line.slice(0, colonIndex)
|
|
240
|
+
const value = line.slice(colonIndex + 1)
|
|
241
|
+
if (key.trim() === '' || value.length === 0 || !/^\s/.test(value)) {
|
|
242
|
+
repairedLines.push(line)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const trimmedStart = value.trimStart()
|
|
246
|
+
const leadingWhitespace = value.slice(0, value.length - trimmedStart.length)
|
|
247
|
+
let scalar = trimmedStart
|
|
248
|
+
let comment = ''
|
|
249
|
+
// Split trailing `# comment` only when `#` is preceded by whitespace and
|
|
250
|
+
// followed by whitespace/end (YAML comment rule). This preserves `#1` as content.
|
|
251
|
+
for (let idx = 0; idx < trimmedStart.length; idx += 1) {
|
|
252
|
+
if (trimmedStart[idx] === '#') {
|
|
253
|
+
const prev = idx === 0 ? ' ' : trimmedStart[idx - 1]
|
|
254
|
+
const next = idx + 1 < trimmedStart.length ? trimmedStart[idx + 1] : ' '
|
|
255
|
+
if (/\s/.test(prev) && /\s/.test(next)) {
|
|
256
|
+
const commentStart = trimmedStart.slice(0, idx).trimEnd().length
|
|
257
|
+
scalar = trimmedStart.slice(0, commentStart)
|
|
258
|
+
comment = trimmedStart.slice(commentStart)
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
scalar = scalar.trimEnd()
|
|
264
|
+
if (scalar === '') {
|
|
265
|
+
repairedLines.push(line)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
const firstChar = scalar[0]
|
|
269
|
+
if (firstChar === '|' || firstChar === '>') {
|
|
270
|
+
blockScalarIndent = effectiveIndent
|
|
271
|
+
repairedLines.push(line)
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
if (firstChar === "'" || firstChar === '"') {
|
|
275
|
+
repairedLines.push(line)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
let hasColonSeparator = false
|
|
279
|
+
for (let i = 0; i < scalar.length - 1; i += 1) {
|
|
280
|
+
if (scalar[i] === ':' && /\s/.test(scalar[i + 1])) {
|
|
281
|
+
hasColonSeparator = true
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let invalidFlowLike = false
|
|
286
|
+
if ((firstChar === '[' || firstChar === '{' || firstChar === '@' || firstChar === '`')) {
|
|
287
|
+
try {
|
|
288
|
+
load(scalar)
|
|
289
|
+
} catch {
|
|
290
|
+
invalidFlowLike = true
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (!hasColonSeparator && !invalidFlowLike) {
|
|
294
|
+
repairedLines.push(line)
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
const quotedScalar = "'" + scalar.replace(/'/g, "''") + "'"
|
|
298
|
+
repairedLines.push(key + ':' + leadingWhitespace + quotedScalar + comment)
|
|
299
|
+
changed = true
|
|
300
|
+
}
|
|
301
|
+
return changed ? repairedLines.join('\n') : null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Try to repair a SKILL.md's frontmatter in place (unquoted colon etc.). Returns the new file text or null when not fixable. */
|
|
305
|
+
export function repairFrontmatterFileText(text: string): string | null {
|
|
306
|
+
const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
|
|
307
|
+
if (match === null) return null
|
|
308
|
+
const repaired = repairFrontmatterScalarFields(match[1])
|
|
309
|
+
if (repaired === null) return null
|
|
310
|
+
try {
|
|
311
|
+
load(repaired)
|
|
312
|
+
} catch {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
const body = match[2] ?? ''
|
|
316
|
+
return '---\n' + repaired + '\n---' + (body !== '' ? '\n' + body : '')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Repair one file on disk when its frontmatter is auto-fixable. Returns the new text. */
|
|
320
|
+
export async function fixDiagnosticFile(path: string, home = dshHome()): Promise<string> {
|
|
321
|
+
const root = rootOfPath(path, home)
|
|
322
|
+
if (root === undefined) throw new TypeError('not a hub writable skill path: ' + path)
|
|
323
|
+
const text = await readFile(path, 'utf8')
|
|
324
|
+
const repairedText = repairFrontmatterFileText(text)
|
|
325
|
+
if (repairedText === null) throw new TypeError('diagnostic is not auto-fixable: ' + path)
|
|
326
|
+
// Validate the repaired text parses as a legal skill (prevents writing a still-broken file).
|
|
327
|
+
const parsed = parseFrontmatter(repairedText)
|
|
328
|
+
if ('error' in parsed) throw new TypeError('repaired frontmatter still invalid: ' + parsed.error)
|
|
329
|
+
await writeFile(path, repairedText, 'utf8')
|
|
330
|
+
return path
|
|
331
|
+
}
|
|
332
|
+
|
|
203
333
|
/** Official boolean grammar: true/false, 1/0, and the common string spellings. */
|
|
204
334
|
function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
|
|
205
335
|
if (!Object.hasOwn(data, key)) return undefined
|
|
@@ -262,6 +392,55 @@ export function listSkillEntries(root: WritableRoot, home = dshHome()): Promise<
|
|
|
262
392
|
return scanRoot(rootPath(root, home))
|
|
263
393
|
}
|
|
264
394
|
|
|
395
|
+
/** Read UI metadata from `agents/openai.yaml` beside a directory skill (mirrors codex SkillInterface). */
|
|
396
|
+
export async function readSkillInterface(directory: string): Promise<{ displayName?: string; shortDescription?: string; brandColor?: string; iconSmall?: string; iconLarge?: string; defaultPrompt?: string } | undefined> {
|
|
397
|
+
const yamlPath = join(directory, 'agents', 'openai.yaml')
|
|
398
|
+
let text: string
|
|
399
|
+
try {
|
|
400
|
+
text = await readFile(yamlPath, 'utf8')
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined
|
|
403
|
+
}
|
|
404
|
+
let data: unknown
|
|
405
|
+
try {
|
|
406
|
+
data = load(text)
|
|
407
|
+
} catch {
|
|
408
|
+
return undefined
|
|
409
|
+
}
|
|
410
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return undefined
|
|
411
|
+
const record = data as Record<string, unknown>
|
|
412
|
+
const iface = (record.interface ?? record) as Record<string, unknown>
|
|
413
|
+
// codex stores interface under top-level `interface` key, but some templates put display_name at top level; support both.
|
|
414
|
+
const ifaceObj = typeof record.interface === 'object' && record.interface !== null && !Array.isArray(record.interface) ? record.interface as Record<string, unknown> : iface
|
|
415
|
+
const cleanStr = (value: unknown, maxLen: number): string | undefined => {
|
|
416
|
+
if (typeof value !== 'string') return undefined
|
|
417
|
+
const cleaned = value.split(/\s+/).join(' ').trim()
|
|
418
|
+
if (cleaned === '' || cleaned.length > maxLen) return undefined
|
|
419
|
+
return cleaned
|
|
420
|
+
}
|
|
421
|
+
const hexColor = (value: unknown): string | undefined => {
|
|
422
|
+
if (typeof value !== 'string') return undefined
|
|
423
|
+
const trimmed = value.trim()
|
|
424
|
+
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined
|
|
425
|
+
}
|
|
426
|
+
const iconPath = (value: unknown): string | undefined => {
|
|
427
|
+
if (typeof value !== 'string' || value.trim() === '') return undefined
|
|
428
|
+
const trimmed = value.trim().replace(/\\/g, '/')
|
|
429
|
+
if (trimmed.startsWith('/') || trimmed.includes('..')) return undefined
|
|
430
|
+
if (!trimmed.startsWith('assets/')) return undefined
|
|
431
|
+
return trimmed
|
|
432
|
+
}
|
|
433
|
+
const displayName = cleanStr(ifaceObj.display_name, 64)
|
|
434
|
+
const shortDescription = cleanStr(ifaceObj.short_description, 1024)
|
|
435
|
+
const brandColor = hexColor(ifaceObj.brand_color)
|
|
436
|
+
const defaultPrompt = cleanStr(ifaceObj.default_prompt, 1024)
|
|
437
|
+
const iconSmall = iconPath(ifaceObj.icon_small)
|
|
438
|
+
const iconLarge = iconPath(ifaceObj.icon_large)
|
|
439
|
+
const hasAny = displayName !== undefined || shortDescription !== undefined || brandColor !== undefined || iconSmall !== undefined || iconLarge !== undefined || defaultPrompt !== undefined
|
|
440
|
+
if (!hasAny) return undefined
|
|
441
|
+
return { ...(displayName !== undefined ? { displayName } : {}), ...(shortDescription !== undefined ? { shortDescription } : {}), ...(brandColor !== undefined ? { brandColor } : {}), ...(iconSmall !== undefined ? { iconSmall } : {}), ...(iconLarge !== undefined ? { iconLarge } : {}), ...(defaultPrompt !== undefined ? { defaultPrompt } : {}) }
|
|
442
|
+
}
|
|
443
|
+
|
|
265
444
|
/**
|
|
266
445
|
* Walk up from cwd (max 32 levels) for a project marker (.dsh or .git);
|
|
267
446
|
* falls back to cwd itself. The provider roots project skills here.
|
|
@@ -298,9 +477,15 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
|
|
|
298
477
|
}
|
|
299
478
|
const parsed = parseFrontmatter(text)
|
|
300
479
|
if ('error' in parsed) {
|
|
301
|
-
|
|
480
|
+
const fixable = repairFrontmatterFileText(text) !== null
|
|
481
|
+
diagnostics.push({ path: entry.path, root, reason: parsed.error, ...(fixable ? { fixable: true } : {}) })
|
|
302
482
|
continue
|
|
303
483
|
}
|
|
484
|
+
// File was auto-repaired in memory (e.g. unquoted `:`); surface a fixable hint so the user can persist it.
|
|
485
|
+
const repaired = repairFrontmatterFileText(text)
|
|
486
|
+
if (repaired !== null) {
|
|
487
|
+
diagnostics.push({ path: entry.path, root, reason: 'frontmatter contains unquoted colon/bracket (auto-repaired in memory; click Fix to persist)', fixable: true })
|
|
488
|
+
}
|
|
304
489
|
const { value } = parsed
|
|
305
490
|
// The provider registers a skill by its discovery path (directory name or
|
|
306
491
|
// flat file name), so a frontmatter name that diverges from the path makes
|
package/src/stats.test.ts
CHANGED
|
@@ -199,6 +199,29 @@ async function flush(): Promise<void> {
|
|
|
199
199
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
describe('scan read concurrency', () => {
|
|
203
|
+
it('reads session logs in parallel, not one by one', async () => {
|
|
204
|
+
const ids = Array.from({ length: 8 }, (_, i) => 's' + i)
|
|
205
|
+
let inFlight = 0
|
|
206
|
+
let peak = 0
|
|
207
|
+
const query: SessionQueryLike = {
|
|
208
|
+
listSessions: async () => ids.map((id) => record(id)),
|
|
209
|
+
readSession: async (id) => {
|
|
210
|
+
inFlight += 1
|
|
211
|
+
peak = Math.max(peak, inFlight)
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
213
|
+
inFlight -= 1
|
|
214
|
+
return { events: [invocationEvent(String(id))] }
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
// No checkpoint → full path, all 8 sessions read.
|
|
218
|
+
const stats = await readSkillStats(query)
|
|
219
|
+
expect(stats).toHaveLength(8)
|
|
220
|
+
expect(peak).toBeGreaterThan(1)
|
|
221
|
+
expect(peak).toBeLessThanOrEqual(6)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
202
225
|
describe('frozen-bucket incremental scans', () => {
|
|
203
226
|
it('skips frozen sessions and merges checkpoint totals with the recent window', async () => {
|
|
204
227
|
const reads: string[] = []
|
|
@@ -221,15 +244,15 @@ describe('frozen-bucket incremental scans', () => {
|
|
|
221
244
|
},
|
|
222
245
|
onCheckpoint: () => { checkpoints += 1 },
|
|
223
246
|
})
|
|
224
|
-
expect(await reader()).toEqual([]) //
|
|
247
|
+
expect(await reader()).toEqual([]) // 首次调用不等待扫描(无上次总数可端)
|
|
225
248
|
await flush()
|
|
226
249
|
expect(await reader()).toEqual([
|
|
227
250
|
{ name: 'oldskill', count: 5, lastUsed: NOW - 20 * DAY },
|
|
228
251
|
{ name: 'tdd', count: 2, lastUsed: 0 },
|
|
229
252
|
])
|
|
230
|
-
//
|
|
253
|
+
// 冻结会话没有被重读;增量扫描也落盘(含新总数)→ 回调恰好一次。
|
|
231
254
|
expect(reads).toEqual(['fresh'])
|
|
232
|
-
expect(checkpoints).toBe(
|
|
255
|
+
expect(checkpoints).toBe(1)
|
|
233
256
|
})
|
|
234
257
|
|
|
235
258
|
it('runs a full reconciliation when due: rebuilds the frozen bucket and advances the watermark', async () => {
|
|
@@ -263,9 +286,41 @@ describe('frozen-bucket incremental scans', () => {
|
|
|
263
286
|
// 全历史模式(windowDays=0):冻结会话进缓存且仍计入总数。
|
|
264
287
|
frozenSessions: { ancient: { createdAt: NOW - 30 * DAY, counts: { a: { count: 1, lastUsed: 5 } } } },
|
|
265
288
|
lastFullReconcile: NOW,
|
|
289
|
+
lastTotals: [
|
|
290
|
+
{ name: 'a', count: 1, lastUsed: 5 },
|
|
291
|
+
{ name: 'b', count: 2, lastUsed: 0 },
|
|
292
|
+
],
|
|
266
293
|
})
|
|
267
294
|
})
|
|
268
295
|
|
|
296
|
+
it('serves persisted lastTotals instantly on cold start, then merges the rescan', async () => {
|
|
297
|
+
const reads: string[] = []
|
|
298
|
+
const query = fakeQuery(
|
|
299
|
+
[record('fresh', NOW - 1 * DAY)],
|
|
300
|
+
{ fresh: [invocationEvent('tdd', 1)] },
|
|
301
|
+
reads,
|
|
302
|
+
)
|
|
303
|
+
const saved: Array<Record<string, unknown>> = []
|
|
304
|
+
const reader = createSkillStatsReader(query, 60_000, {
|
|
305
|
+
now: () => NOW,
|
|
306
|
+
checkpoint: {
|
|
307
|
+
windowDays: 0,
|
|
308
|
+
frozenBefore: 0,
|
|
309
|
+
frozenSessions: {},
|
|
310
|
+
lastFullReconcile: NOW, // 对账刚做过 → 增量路径
|
|
311
|
+
lastTotals: [{ name: 'oldskill', count: 9, lastUsed: 7 }],
|
|
312
|
+
},
|
|
313
|
+
onCheckpoint: (cp) => { saved.push(cp as unknown as Record<string, unknown>) },
|
|
314
|
+
})
|
|
315
|
+
// 冷启动直接端上次总数,同时后台重扫;重扫后以新鲜值为准(旧数不复活)。
|
|
316
|
+
expect(await reader()).toEqual([{ name: 'oldskill', count: 9, lastUsed: 7 }])
|
|
317
|
+
await flush()
|
|
318
|
+
expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
|
|
319
|
+
expect(reads).toEqual(['fresh'])
|
|
320
|
+
expect(saved).toHaveLength(1)
|
|
321
|
+
expect((saved[0] as { lastTotals: unknown }).lastTotals).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
|
|
322
|
+
})
|
|
323
|
+
|
|
269
324
|
it('re-reads sessions without createdAt on incremental scans (never freezes)', async () => {
|
|
270
325
|
const reads: string[] = []
|
|
271
326
|
const query = fakeQuery(
|
package/src/stats.ts
CHANGED
|
@@ -103,6 +103,24 @@ function isFrozen(record: { header: { createdAt?: number } }, watermark: number)
|
|
|
103
103
|
return typeof created === 'number' && created > 0 && created < watermark
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Bounded-concurrency map: session-log reads are independent decompressions, so wall time scales ~1/limit. Merge stays sequential in index order. */
|
|
107
|
+
async function mapConcurrent<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
108
|
+
const results = new Array<R>(items.length)
|
|
109
|
+
let cursor = 0
|
|
110
|
+
const runners = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
|
|
111
|
+
while (cursor < items.length) {
|
|
112
|
+
const index = cursor
|
|
113
|
+
cursor += 1
|
|
114
|
+
results[index] = await worker(items[index], index)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
await Promise.all(runners)
|
|
118
|
+
return results
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Max parallel session-log reads per scan (read-only; merge is order-independent). */
|
|
122
|
+
const SCAN_READ_CONCURRENCY = 6
|
|
123
|
+
|
|
106
124
|
function mergeInto(totals: Totals, counted: Map<string, InvocationStat>): void {
|
|
107
125
|
for (const [name, stat] of counted) {
|
|
108
126
|
const total = totals[name]
|
|
@@ -153,19 +171,22 @@ async function scan(query: SessionQueryLike, checkpoint: SkillStatsCheckpoint, n
|
|
|
153
171
|
if (dueFullScan) {
|
|
154
172
|
const cache: SkillStatsCheckpoint['frozenSessions'] = {}
|
|
155
173
|
const totals: Totals = {}
|
|
156
|
-
|
|
157
|
-
let counted: Map<string, InvocationStat>
|
|
174
|
+
const countedList = await mapConcurrent(sessions, SCAN_READ_CONCURRENCY, async (record) => {
|
|
158
175
|
try {
|
|
159
|
-
|
|
176
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events)
|
|
160
177
|
} catch {
|
|
161
|
-
|
|
178
|
+
return undefined // unreadable sessions are skipped, never fatal
|
|
162
179
|
}
|
|
180
|
+
})
|
|
181
|
+
sessions.forEach((record, index) => {
|
|
182
|
+
const counted = countedList[index]
|
|
183
|
+
if (counted === undefined) return
|
|
163
184
|
const created = record.header.createdAt
|
|
164
185
|
if (isFrozen(record, cutoff) && counted.size > 0 && typeof created === 'number') {
|
|
165
186
|
cache[record.header.id as unknown as string] = { createdAt: created, counts: Object.fromEntries(counted) }
|
|
166
187
|
}
|
|
167
188
|
if (inWindow(created, windowDays, nowMs)) mergeInto(totals, counted)
|
|
168
|
-
}
|
|
189
|
+
})
|
|
169
190
|
checkpoint.frozenSessions = cache
|
|
170
191
|
checkpoint.frozenBefore = cutoff
|
|
171
192
|
checkpoint.windowDays = windowDays
|
|
@@ -174,13 +195,17 @@ async function scan(query: SessionQueryLike, checkpoint: SkillStatsCheckpoint, n
|
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
const recent: Totals = {}
|
|
177
|
-
|
|
178
|
-
|
|
198
|
+
const unfrozen = sessions.filter((record) => !isFrozen(record, checkpoint.frozenBefore))
|
|
199
|
+
const recentList = await mapConcurrent(unfrozen, SCAN_READ_CONCURRENCY, async (record) => {
|
|
179
200
|
try {
|
|
180
|
-
|
|
201
|
+
return countSkillInvocations((await query.readSession(record.header.id)).events)
|
|
181
202
|
} catch {
|
|
182
|
-
|
|
203
|
+
return undefined // unreadable sessions are skipped, never fatal
|
|
183
204
|
}
|
|
205
|
+
})
|
|
206
|
+
for (const counted of recentList) {
|
|
207
|
+
if (counted === undefined) continue
|
|
208
|
+
mergeInto(recent, counted)
|
|
184
209
|
}
|
|
185
210
|
const totals: Totals = {}
|
|
186
211
|
for (const [id, entry] of Object.entries(checkpoint.frozenSessions)) {
|
|
@@ -216,8 +241,9 @@ export interface SkillStatsReaderOptions {
|
|
|
216
241
|
ttlMs?: number | (() => number)
|
|
217
242
|
/** Rolling window in days; a getter reads the live config each scan. 0 = all history. */
|
|
218
243
|
windowDays?: () => number
|
|
219
|
-
/** Called after
|
|
220
|
-
*
|
|
244
|
+
/** Called after every completed scan (full or incremental) so the host can
|
|
245
|
+
* persist the checkpoint including the fresh totals. Cadence follows the
|
|
246
|
+
* scan TTL (minutes, not days) — the payload is tiny and writes are atomic. */
|
|
221
247
|
onCheckpoint?: (checkpoint: SkillStatsCheckpoint) => void
|
|
222
248
|
}
|
|
223
249
|
|
|
@@ -238,7 +264,9 @@ export interface SkillStatsReaderOptions {
|
|
|
238
264
|
export function createSkillStatsReader(query: SessionQueryLike, ttlMs: number | (() => number) = 300_000, options: SkillStatsReaderOptions = {}): SkillStatsReader {
|
|
239
265
|
const checkpoint: SkillStatsCheckpoint = options.checkpoint ?? { windowDays: 0, frozenBefore: 0, frozenSessions: {}, lastFullReconcile: 0 }
|
|
240
266
|
const now = options.now ?? (() => Date.now())
|
|
241
|
-
|
|
267
|
+
// Cold start serves the last persisted totals instantly (may be a window
|
|
268
|
+
// behind); cachedAt stays 0 so the first call still kicks a background rescan.
|
|
269
|
+
let cached: SkillStat[] | undefined = checkpoint.lastTotals !== undefined ? [...checkpoint.lastTotals] : undefined
|
|
242
270
|
let cachedAt = 0
|
|
243
271
|
let refreshing: Promise<void> | null = null
|
|
244
272
|
let lastScanDurationMs = 0
|
|
@@ -248,16 +276,17 @@ export function createSkillStatsReader(query: SessionQueryLike, ttlMs: number |
|
|
|
248
276
|
const base = typeof ttlMs === 'function' ? ttlMs() : ttlMs
|
|
249
277
|
const ttl = Math.max(base, lastScanDurationMs * STATS_TTL_SCAN_FACTOR)
|
|
250
278
|
if (cached !== undefined && startedAt - cachedAt < ttl) return cached
|
|
251
|
-
// Expired (or first call): hand back the stale totals (empty on
|
|
252
|
-
//
|
|
279
|
+
// Expired (or first call): hand back the stale totals (empty on a truly
|
|
280
|
+
// fresh start) and kick off one background rescan.
|
|
253
281
|
if (refreshing === null) {
|
|
254
282
|
const windowDays = options.windowDays?.() ?? 0
|
|
255
283
|
refreshing = scan(query, checkpoint, startedAt, windowDays)
|
|
256
|
-
.then(({ stats
|
|
284
|
+
.then(({ stats }) => {
|
|
257
285
|
cached = stats
|
|
258
286
|
cachedAt = now()
|
|
259
287
|
lastScanDurationMs = Math.max(0, cachedAt - startedAt)
|
|
260
|
-
|
|
288
|
+
checkpoint.lastTotals = stats
|
|
289
|
+
options.onCheckpoint?.({ ...checkpoint, frozenSessions: { ...checkpoint.frozenSessions } })
|
|
261
290
|
})
|
|
262
291
|
.catch(() => { /* keep the previous totals on scan failure */ })
|
|
263
292
|
.finally(() => { refreshing = null })
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business-rule failure the routes layer can map onto a 4xx status instead
|
|
3
|
+
* of a blanket 500: user input is invalid (validation → 400), the target
|
|
4
|
+
* does not exist (not-found → 404), or the operation conflicts with an
|
|
5
|
+
* invariant (conflict → 409).
|
|
6
|
+
*/
|
|
7
|
+
export class StoreError extends Error {
|
|
8
|
+
readonly kind: 'validation' | 'not-found' | 'conflict'
|
|
9
|
+
constructor(kind: StoreError['kind'], message: string) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'StoreError'
|
|
12
|
+
this.kind = kind
|
|
13
|
+
}
|
|
14
|
+
}
|