Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const BOOT_STEPS = [
{ id: "deps-install", label: "Installing js-yaml" },
{ id: "env-probe", label: "Probing environment" },
{ id: "catalog", label: "Loading catalogs" },
{ id: "composition", label: "Building composition" },
{ id: "ready", label: "Ready" },
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

import { PHASE_BY_ID } from "./wizard-phases.mjs";
import { applyPatch, writeState, readState, validateInferredPipeline, activeFingerprint, normalizeExecutionReports } from "../state/store.mjs";
import { assembleComposition, computeStage2Necessity } from "../composition/assembler.mjs";
import { buildCompositionFromCli } from "../composition/artifact-cli.mjs";
import { computePipelineFastPath } from "../composition/pipeline-fast-path.mjs";
import { fsDeps } from "./instances.mjs";
import { snapshot } from "./snapshot.mjs";

Expand Down Expand Up @@ -266,50 +267,39 @@ export async function applyComposition(inst, input) {
};
}

// Deterministic composition refresh from local filesystem — no LLM.
//
// TEMPORARY. Delete this helper + every call site once `specify composition
// list --json` returns fully-resolved artifact stacks with per-layer
// `active: true` markers. At that point the LLM `composition.refresh`
// collapses to a single-line CLI call and the "slow LLM path" this helper
// works around ceases to exist.
// Deterministic composition refresh — uses `specify artifact list --json` +
// `specify artifact info <id> --json` via composition/artifact-cli.mjs.
//
// Purpose: after any catalog change (preset/extension install, remove,
// swap, priority change) the composition needs to be rebuilt. This
// helper rebuilds `{ presets, extensions, artifacts }` locally in
// milliseconds by reading manifests directly — the LLM Stage 1 turn is
// retired entirely.
// swap, priority change) the composition needs to be rebuilt. This helper
// rebuilds `{ presets, extensions, artifacts }` from the CLI in
// milliseconds — no LLM Stage 1 turn required.
//
// Trivial Stage 2 shortcut: `computeStage2Necessity` inspects the freshly
// assembled composition and decides whether the LLM Stage 2 pipeline
// inference is actually needed. When it isn't (no new commands, no
// wraps/prepends/appends directives), we synthesize `inferredPipeline`
// from the canonical spine here. When it IS needed, we skip pipeline
// synthesis and the prior `inferredPipeline` carries forward until the
// user clicks Refresh Now on the Composition tab to invoke the LLM path.
// Trivial pipeline shortcut: `computePipelineFastPath` inspects the freshly
// assembled composition and decides whether an LLM pipeline-inference turn
// is needed. When it isn't (no new commands, no wrap/prepend/append
// directives), we synthesize `inferredPipeline` from the canonical
// spine here. When we can't fast-path, we skip pipeline synthesis and the
// prior `inferredPipeline` carries forward until the user clicks Refresh
// Now on the Composition tab to invoke the LLM path.
//
// Runs silently on catalog changes — failures degrade to a warn log and
// leave the composition slice alone.
export async function runFastComposition(inst, { reason } = {}) {
if (!inst?.workspacePath) return { ok: false, reason: "no-workspace" };
try {
const payload = await assembleComposition({
const payload = await buildCompositionFromCli({
workspaceRoot: inst.workspacePath,
presetItems: inst.cachedPresetItems ?? [],
extensionItems: inst.cachedExtensionItems ?? [],
});
// `_presetManifests` is a side channel used only for Stage 2
// necessity detection — never persisted.
const presetManifests = payload._presetManifests ?? [];
delete payload._presetManifests;

const stage2 = computeStage2Necessity(payload, presetManifests);
if (!stage2.needed && stage2.syntheticPipeline) {
payload.inferredPipeline = stage2.syntheticPipeline;
const fastPath = computePipelineFastPath(payload);
if (fastPath.canSynthesize) {
payload.inferredPipeline = fastPath.syntheticPipeline;
}

await applyComposition(inst, payload);
return { ok: true, reason, stage2Needed: stage2.needed };
return { ok: true, reason, pipelineFastPath: fastPath.canSynthesize };
} catch (err) {
return { ok: false, reason: String(err?.message ?? err) };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function getAugmentedPath() {
* PATH is augmented with known SDK / uv / pipx install locations so
* `specify` resolves even when the user's shell PATH doesn't include them.
*/
export async function specifyRun(args, cwd) {
export async function specifyRun(args, cwd, { timeoutMs = 20_000 } = {}) {
const augmentedPath = await getAugmentedPath();
return new Promise((resolve) => {
const child = spawn("specify", args, {
Expand All @@ -50,9 +50,18 @@ export async function specifyRun(args, cwd) {
env: { ...process.env, PATH: augmentedPath },
});
let stdout = "";
let settled = false;
const done = (val) => { if (!settled) { settled = true; resolve(val); } };
// Hard cap so a wedged CLI (network hang, uv resolver stuck, etc.)
// can't freeze catalog hydration forever. On timeout we return the
// partial stdout — callers already tolerate empty/malformed output.
const timer = setTimeout(() => {
try { child.kill(); } catch { /* best-effort */ }
done(stdout || null);
}, timeoutMs);
child.stdout?.on("data", (d) => { stdout += String(d); });
child.on("error", () => resolve(null));
child.on("close", () => resolve(stdout));
child.on("error", () => { clearTimeout(timer); done(null); });
child.on("close", () => { clearTimeout(timer); done(stdout); });
});
}

Expand Down Expand Up @@ -85,47 +94,59 @@ export async function hydrateFromCatalogSources(inst, sources, cfg) {
return;
}
const installed = inst.workspacePath ? await listInstalled(inst.workspacePath) : EMPTY_INSTALLED;
const items = [];
for (const src of sources) {
if (!src?.url) continue;
try {
const data = await fetchCatalogJson(src.url);
const entries = data?.[dataKey];
if (!entries || typeof entries !== "object") continue;
for (const [id, raw] of Object.entries(entries)) {
const itemId = raw?.id ?? id;
const itemName = raw?.name ?? itemId;
const nameKey = String(itemName).toLowerCase();
// Match by id first; fall back to display-name so catalog
// entries whose declared id differs from the installed
// manifest's id still show as installed (e.g. catalog `foo`
// vs installed `foo-full-preset`).
let installedId = null;
if (installed.ids.has(itemId)) installedId = itemId;
else if (installed.byName.has(nameKey)) installedId = installed.byName.get(nameKey);
const base = {
id: itemId,
// Real installed id — used by Remove to call
// `specify <group> remove <installedId>` correctly.
installedId: installedId ?? itemId,
name: itemName,
source: src.name,
version: raw?.version ?? null,
description: raw?.description ?? "",
active: !!installedId,
downloadUrl: raw?.download_url ?? null,
installAllowed: src.installAllowed !== false,
author: raw?.author ?? null,
repository: raw?.repository ?? null,
homepage: raw?.homepage ?? null,
documentation: raw?.documentation ?? null,
license: raw?.license ?? null,
};
const extras = extraFields ? extraFields(raw, { installedId, installed }) : null;
items.push(extras ? { ...base, ...extras } : base);

// Fetch every source in parallel. Sources are independent, and prior
// serial iteration meant a slow source dragged the whole hydrate step.
// With fetchCatalogJson now timeout-bounded, worst case is one source
// times out at 15s instead of blocking every subsequent fetch behind it.
const fetched = await Promise.all(
sources.map(async (src) => {
if (!src?.url) return { src: null, data: null };
try {
return { src, data: await fetchCatalogJson(src.url) };
} catch {
// best-effort catalog hydrate; a failing source is skipped
return { src, data: null };
}
} catch {
// best-effort catalog hydrate; a failing source is skipped
}),
);

const items = [];
for (const { src, data } of fetched) {
if (!src || !data) continue;
const entries = data?.[dataKey];
if (!entries || typeof entries !== "object") continue;
for (const [id, raw] of Object.entries(entries)) {
const itemId = raw?.id ?? id;
const itemName = raw?.name ?? itemId;
const nameKey = String(itemName).toLowerCase();
// Match by id first; fall back to display-name so catalog
// entries whose declared id differs from the installed
// manifest's id still show as installed (e.g. catalog `foo`
// vs installed `foo-full-preset`).
let installedId = null;
if (installed.ids.has(itemId)) installedId = itemId;
else if (installed.byName.has(nameKey)) installedId = installed.byName.get(nameKey);
const base = {
id: itemId,
// Real installed id — used by Remove to call
// `specify <group> remove <installedId>` correctly.
installedId: installedId ?? itemId,
name: itemName,
source: src.name,
version: raw?.version ?? null,
description: raw?.description ?? "",
active: !!installedId,
downloadUrl: raw?.download_url ?? null,
installAllowed: src.installAllowed !== false,
author: raw?.author ?? null,
repository: raw?.repository ?? null,
homepage: raw?.homepage ?? null,
documentation: raw?.documentation ?? null,
license: raw?.license ?? null,
};
const extras = extraFields ? extraFields(raw, { installedId, installed }) : null;
items.push(extras ? { ...base, ...extras } : base);
}
}
inst[outputField] = items;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,15 @@ export const BUNDLE_CATALOG_URL = {
community: "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
};

export async function fetchCatalogJson(url) {
const res = await fetch(url, { redirect: "follow" });
export async function fetchCatalogJson(url, { timeoutMs = 15_000 } = {}) {
// Guard against indefinite hangs. Without a signal, a stalled socket
// (slow DNS, TCP RST loss, CDN outage) blocks the caller forever —
// which is fatal for boot because hydrateCatalogs awaits each fetch
// before continuing. 15s is generous for a static GitHub raw file.
const res = await fetch(url, {
redirect: "follow",
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.json();
}
Loading