Skip to content

Commit bb5edf2

Browse files
committed
Parallelize catalog hydration to cut boot time
Boot's `hydrateCatalogs` used to walk preset → extension → bundle serially, and each hydrator walked its 2–3 source URLs serially inside `hydrateFromCatalogSources`. That's ~8 GitHub GETs strictly serial on a cold cache, plus 3 sequential `specify <kind> list` shell-outs, for what is entirely disjoint state. Two changes: * Run `hydratePresetsForSources` / `hydrateExtensionsForSources` / `hydrateBundlesForSources` via `Promise.all` — they touch independent cache slices. * Inside `hydrateFromCatalogSources`, `Promise.all` the per-source `fetchCatalogJson` calls before folding into the items array. Order of items is preserved because we still iterate the resolved array in source order. All 211 tests pass.
1 parent 0a62ac8 commit bb5edf2

2 files changed

Lines changed: 31 additions & 15 deletions

File tree

‎plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/catalog/shared.mjs‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,27 @@ export async function hydrateFromCatalogSources(inst, sources, cfg) {
8585
return;
8686
}
8787
const installed = inst.workspacePath ? await listInstalled(inst.workspacePath) : EMPTY_INSTALLED;
88+
// Fetch all catalog JSONs in parallel — each one is an independent
89+
// remote GET and sources are typically 2–3 per kind. Serial iteration
90+
// was the dominant per-hydrator latency on cold caches.
91+
const fetched = await Promise.all(
92+
sources.map(async (src) => {
93+
if (!src?.url) return { src: null, data: null };
94+
try {
95+
const data = await fetchCatalogJson(src.url);
96+
return { src, data };
97+
} catch {
98+
// best-effort catalog hydrate; a failing source is skipped
99+
return { src, data: null };
100+
}
101+
}),
102+
);
88103
const items = [];
89-
for (const src of sources) {
90-
if (!src?.url) continue;
91-
try {
92-
const data = await fetchCatalogJson(src.url);
93-
const entries = data?.[dataKey];
94-
if (!entries || typeof entries !== "object") continue;
95-
for (const [id, raw] of Object.entries(entries)) {
104+
for (const { src, data } of fetched) {
105+
if (!src) continue;
106+
const entries = data?.[dataKey];
107+
if (!entries || typeof entries !== "object") continue;
108+
for (const [id, raw] of Object.entries(entries)) {
96109
const itemId = raw?.id ?? id;
97110
const itemName = raw?.name ?? itemId;
98111
const nameKey = String(itemName).toLowerCase();
@@ -123,9 +136,6 @@ export async function hydrateFromCatalogSources(inst, sources, cfg) {
123136
};
124137
const extras = extraFields ? extraFields(raw, { installedId, installed }) : null;
125138
items.push(extras ? { ...base, ...extras } : base);
126-
}
127-
} catch {
128-
// best-effort catalog hydrate; a failing source is skipped
129139
}
130140
}
131141
inst[outputField] = items;

‎plugins/spec-kit-copilot-wizard/extensions/speckit-wizard-canvas/extension.mjs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async function onOpen(ctx) {
103103
startStateWatcher(inst, { snapshot, normalizeHookArtifactsInComposition }).catch(() => { /* best-effort */ });
104104
startArtifactWatcher(inst, { snapshot }).catch(() => { /* best-effort */ });
105105
return {
106-
title: "Spec Kit Wizard",
106+
title: "Spec Kit Wizard - Dev",
107107
url: inst.url,
108108
};
109109
}
@@ -241,6 +241,11 @@ async function hydrateCatalogs(inst) {
241241
// catalogs (and does NOT register them via `specify preset catalog add`).
242242
// Third-party catalogs a user has added via the CLI will NOT appear here
243243
// — that is intentional in the current scope.
244+
// Kick off preset / extension / bundle hydration in parallel — they
245+
// touch disjoint state (each writes its own cachedCatalogSources +
246+
// cachedItems slice) and each fires several sequential remote GETs.
247+
// Running them serially was the dominant boot-time catalog cost.
248+
const jobs = [];
244249
if (!inst.cachedCatalogSources?.length) {
245250
const bootstrap = [
246251
{
@@ -269,7 +274,7 @@ async function hydrateCatalogs(inst) {
269274
},
270275
];
271276
inst.cachedCatalogSources = bootstrap;
272-
await hydratePresetsForSources(inst, bootstrap).catch(() => {});
277+
jobs.push(hydratePresetsForSources(inst, bootstrap).catch(() => {}));
273278
}
274279
if (!inst.cachedExtensionCatalogSources?.length) {
275280
const extBootstrap = [
@@ -291,7 +296,7 @@ async function hydrateCatalogs(inst) {
291296
},
292297
];
293298
inst.cachedExtensionCatalogSources = extBootstrap;
294-
await hydrateExtensionsForSources(inst, extBootstrap).catch(() => {});
299+
jobs.push(hydrateExtensionsForSources(inst, extBootstrap).catch(() => {}));
295300
}
296301
if (!inst.cachedBundleCatalogSources?.length) {
297302
const bundleBootstrap = [
@@ -313,8 +318,9 @@ async function hydrateCatalogs(inst) {
313318
},
314319
];
315320
inst.cachedBundleCatalogSources = bundleBootstrap;
316-
await hydrateBundlesForSources(inst, bundleBootstrap).catch(() => {});
321+
jobs.push(hydrateBundlesForSources(inst, bundleBootstrap).catch(() => {}));
317322
}
323+
await Promise.all(jobs);
318324
}
319325

320326
// Legacy hydrateOnce removed — bootAsync in this file supersedes it. The
@@ -343,7 +349,7 @@ setSession(await joinSession({
343349
canvases: [
344350
createCanvas({
345351
id: "speckit-wizard",
346-
displayName: "Spec Kit Wizard",
352+
displayName: "Spec Kit Wizard - Dev",
347353
description:
348354
"Wizard UX driving the Spec-Driven Development lifecycle (setup → constitution → specify → clarify → plan → tasks → implement) via the spec-kit-copilot skills plugin.",
349355
inputSchema: {

0 commit comments

Comments
 (0)