Files
openchamber/packages/web/server/lib/walkthrough/index.js
T
Bohdan Triapitsyn 34d0ff7383 feat(walkthrough): guided AI walkthrough for diffs, branches, and PRs (#2572)
A diff is ordered by file path, which is almost never the order in which a
change makes sense. This adds a Walkthrough surface that reorders it: the model
groups related hunks into stops, explains what each group changes about
behavior, and orders the stops so each builds on the last. It explains and
orders; judging code stays with the existing Review action.

Reviews uncommitted work (all, staged, unstaged), a branch against its base, or
a pull request. Generation is always user-initiated — nothing runs on a timer,
on a file change, or as a side effect of opening a panel.

Invariants worth preserving:

- Hunk identity is derived on the server and only there. Ids are content
  hashes, so an anchor that no longer resolves is proof the code it described
  changed, and staleness needs no heuristics. The client matches ids to ids and
  never recomputes them; two implementations would have to agree forever.
- The digest is never truncated. A diff that does not fit the model's context
  is refused with an actionable reason, because a walkthrough written against
  half a diff reads as confident and is wrong.
- Nothing disappears. Lockfiles and other generated output are excluded from
  the model's input by name — never by size — and everything no stop covers is
  listed at the end, so "have I seen all of it" stays answerable.
- Cost is explicit. Results are content-addressed, so returning the working
  tree to an earlier state costs nothing; generation outlives its request, so a
  refresh detaches the client rather than discarding paid-for work, and only an
  explicit cancel stops it.

Supporting changes to shared modules:

- git: expose the existing getRangeDiff as GET /api/git
  listUntrackedPaths and getUntrackedDiffs. The latter resolve the repository
  once for a batch instead of per file, taking a panel
  ~340ms on an 80-file working tree.
- small-model: structured output across four wire forma
  and abort signal, and an onOverflow policy so an oversized prompt fails
  loudly instead of being silently clipped. A provider
  remembered so the prompt-side fallback goes first next time.
- models.dev metadata: surface structured_output as tri
  false blocks a model, a missing field does not, because the catalog omits it
  for roughly half of all models.

Desktop and tablet only: VS Code serves Git through its
these routes, and the mobile shell does not consume the surface registry.

Docs: packages/docs walkthrough page in English and all eight locales.
2026-08-02 16:22:55 +03:00

510 lines
18 KiB
JavaScript

import { getRepositoryRoot } from '../git/service.js';
import { describeSmallModel, generateSmallModelText } from '../small-model/index.js';
import { buildDigest } from './digest.js';
import { indexHunks } from './hunks.js';
import { buildPrompt, JSON_SHAPE_INSTRUCTION } from './prompt.js';
import { normalizeWalkthrough, parseModelJson, responseSchema } from './schema.js';
import {
buildCacheKey,
pruneMissingRepositories,
readCachedWalkthrough,
readPointer,
writeCachedWalkthrough,
writePointer,
} from './store.js';
import { readWalkthroughModelOverride } from './model-settings.js';
import { loadSourceSections, parseSource, sourceKey, WalkthroughSourceError } from './sources.js';
// Walkthrough generation is always user-initiated and never automatic: it costs
// tokens, and a background regeneration on every keystroke would be a way to
// spend a budget without anyone deciding to.
// This module is imported lazily, which means module-level work lands on the
// first walkthrough request. Housekeeping has no business being there, so it is
// deferred and never awaited: the request proceeds immediately and the prune
// interleaves behind it.
setTimeout(() => {
void pruneMissingRepositories().catch(() => {
// Housekeeping failing is not worth surfacing or retrying.
});
}, 0).unref?.();
// A hang guard, not a pace-setter. Losing a nearly-finished generation wastes
// real money and minutes, while an over-long deadline only holds a job slot, so
// this errs long. It scales because a three-hunk edit and a 500-hunk pull
// request have no business sharing a deadline.
const GENERATION_TIMEOUT_BASE_MS = 120_000;
const GENERATION_TIMEOUT_PER_HUNK_MS = 1_000;
const GENERATION_TIMEOUT_MAX_MS = 900_000;
const generationTimeoutMs = (hunkCount) => Math.min(
GENERATION_TIMEOUT_MAX_MS,
GENERATION_TIMEOUT_BASE_MS + Math.max(0, hunkCount) * GENERATION_TIMEOUT_PER_HUNK_MS,
);
// A full walkthrough is a few thousand tokens of JSON, but reasoning models
// spend the same budget thinking first and return nothing if it runs out. The
// reserve subtracted from the input budget matches this exactly, so a bigger
// answer allowance costs input room rather than overrunning the context.
const MAX_OUTPUT_TOKENS = 24_000;
const fail = (message, statusCode, extra = {}) =>
Object.assign(new Error(message), { statusCode, ...extra });
// Generation outlives the request that started it.
//
// A dropped connection and a deliberate cancel look identical at the socket, so
// tying the work to the request lifetime meant an accidental refresh threw away
// a minute of paid-for work. Jobs are keyed by repository + source, so a client
// that comes back attaches to the running job instead of starting a second one,
// and cancelling is an explicit request rather than a side effect of leaving.
const jobs = new Map();
// Providers that answered a schema request with a 4xx. Retrying the schema on
// every generation means paying for a call we already know will fail, so the
// refusal is remembered and the fallback goes first next time.
//
// Process-lifetime only, on purpose: a provider that gains structured-output
// support should not need a settings change to be tried again — a restart is
// enough, and the cost of one wasted first attempt after that is small.
const schemaRefusedBy = new Set();
const modelKey = (model) => `${model.providerID}/${model.modelID}`;
const jobKey = (repoRoot, sourceKeyValue) => `${repoRoot}\0${sourceKeyValue}`;
/**
* Coarse stages, reported so a long wait is legible.
*
* Only phases a person can actually wait on are named. Building the digest and
* reading the cache take single-digit milliseconds; giving them their own rows
* would imply progress where there is none. `retrying` appears only when a
* provider rejects the schema and the prompt-side fallback runs.
*/
export const GENERATION_STAGES = ['collecting', 'asking', 'retrying', 'assembling'];
const setStage = (repoRoot, sourceKeyValue, stage) => {
const job = jobs.get(jobKey(repoRoot, sourceKeyValue));
if (job) job.stage = stage;
};
/**
* Current stage of a running generation, or `null` when nothing is running.
* Reads memory only — no git, no network — so it is cheap to poll.
*/
export function getGenerationStage(repoRoot, sourceKeyValue) {
return jobs.get(jobKey(repoRoot, sourceKeyValue))?.stage ?? null;
}
/**
* Whether a generation is currently running for a source. Lets a reconnecting
* client show progress instead of an empty panel.
*/
export function isGenerating(repoRoot, sourceKeyValue) {
return jobs.has(jobKey(repoRoot, sourceKeyValue));
}
/**
* Resolve the pair the job registry is keyed by, for callers that need to look
* a job up without doing any diff work.
*/
export async function getRepositoryRootFor(directory, rawSource) {
const source = parseSource(rawSource);
return { repoRoot: await getRepositoryRoot(directory), sourceKey: sourceKey(source) };
}
/**
* Stop a running generation. Only an explicit request does this — leaving the
* page does not.
*/
export async function cancelWalkthroughGeneration({ directory, source: rawSource }) {
const source = parseSource(rawSource);
const repoRoot = await getRepositoryRoot(directory);
const job = jobs.get(jobKey(repoRoot, sourceKey(source)));
if (!job) return { cancelled: false };
job.controller.abort();
return { cancelled: true };
}
const modelLabel = (model) => `${model.providerID}/${model.modelID}`;
/**
* Resolve the model for this feature: the walkthrough override when set,
* otherwise whatever the small-model chain resolves to.
*/
/**
* Resolve the model for this feature. An explicit per-review choice outranks the
* saved setting, which in turn outranks the small-model chain — the user picking
* a roomier model for a risky change is the most specific intent there is.
*/
const resolveModel = (directory, explicitModel) => describeSmallModel({
directory,
outputReserveTokens: MAX_OUTPUT_TOKENS,
overrideModel: explicitModel || readWalkthroughModelOverride(),
});
export const __testing = { generationTimeoutMs };
/**
* Current diff for a source, parsed into files and hunks.
*/
async function loadCurrentDiff(directory, source, deps) {
const { sections } = await loadSourceSections(directory, source, deps);
const built = buildDigest(sections);
return built;
}
const stopHunkIds = (walkthrough) =>
walkthrough.chapters.flatMap((chapter) => chapter.stops.flatMap((stop) => stop.hunkIds));
/**
* Compare a stored walkthrough against the diff as it is right now.
*
* Staleness is not a heuristic here: a hunk id is a hash of the hunk's content,
* so an anchor that no longer resolves is proof that the code it described has
* changed or gone. Anchors that still resolve are still accurate.
*/
function resolveAgainstCurrent(walkthrough, hunkIndex) {
const missingHunkIds = [];
const staleStopIds = [];
for (const chapter of walkthrough.chapters) {
for (const stop of chapter.stops) {
const missing = stop.hunkIds.filter((id) => !hunkIndex.has(id));
if (missing.length === 0) continue;
missingHunkIds.push(...missing);
staleStopIds.push(stop.id);
}
}
const covered = new Set(stopHunkIds(walkthrough));
const uncoveredHunkIds = [...hunkIndex.keys()].filter((id) => !covered.has(id));
return {
isStale: missingHunkIds.length > 0,
missingHunkIds,
staleStopIds,
uncoveredHunkIds,
};
}
const serializeHunks = (files) => files.flatMap((file) => file.hunks.map((hunk) => ({
id: hunk.id,
path: file.path,
oldPath: file.oldPath || null,
status: file.status,
scope: file.scope,
header: hunk.header,
newStart: hunk.newStart,
added: hunk.added,
deleted: hunk.deleted,
patch: hunk.patch,
})));
/**
* Read the last walkthrough for a source, resolved against the current diff.
* Never generates and never spends tokens.
*/
export async function getWalkthrough({ directory, source: rawSource, model: explicitModel }, deps = {}) {
const source = parseSource(rawSource);
const repoRoot = await getRepositoryRoot(directory);
const key = sourceKey(source);
const pointer = readPointer(repoRoot, key);
// One diff, one model lookup, both answers. These used to be separate
// endpoints the client called in parallel, which meant every panel open ran
// the whole git pipeline twice.
const [built, model] = await Promise.all([
loadCurrentDiff(directory, source, deps),
resolveModel(directory, explicitModel).catch(() => null),
]);
const { files } = built;
const hunkIndex = indexHunks(files);
const readiness = computeReadiness({ ...built, model, source });
const base = {
source,
hunks: serializeHunks(files),
hunkCount: hunkIndex.size,
readiness,
generating: isGenerating(repoRoot, key),
};
const entry = pointer ? readCachedWalkthrough(pointer.cacheKey) : null;
if (!entry) {
// No pointer, or the pointer outlived its entry (eviction, manual cleanup).
// "No walkthrough" is the truthful answer either way; the pointer is left
// for the next generation to overwrite.
return { ...base, walkthrough: null };
}
return {
...base,
walkthrough: entry.walkthrough,
model: entry.model,
generatedAt: entry.generatedAt,
...resolveAgainstCurrent(entry.walkthrough, hunkIndex),
};
}
/**
* Whether the resolved model can do this job, computed from a digest the caller
* already built.
*
* Folded into the walkthrough read rather than living on its own endpoint: both
* answers need the same diff, and computing it twice doubled the git work on
* every panel open.
*/
function computeReadiness({ model, digest, files, fileCount, hunkCount, generatedFileCount, source }) {
if (!model) return { ready: false, reason: 'no-model' };
if (hunkCount === 0) {
// "Only a lockfile changed" is a different answer from "nothing changed",
// and the user can act on it (commit and move on) rather than wonder why
// the review refuses.
const reason = files.length > 0 && generatedFileCount === files.length ? 'only-generated' : 'empty-diff';
return { ready: false, reason, model, generatedFileCount };
}
const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source });
const requiredChars = prompt.length + system.length;
if (model.structuredOutput === false) {
return { ready: false, reason: 'structured-output-unsupported', model, requiredChars };
}
if (requiredChars > model.inputCharBudget) {
return {
ready: false,
reason: 'context-too-small',
model,
requiredChars,
availableChars: model.inputCharBudget,
};
}
return { ready: true, model, requiredChars, availableChars: model.inputCharBudget, hunkCount, fileCount };
}
/**
* Generate a walkthrough for a source.
*
* Returns the cached entry when the diff, model, and prompt are all unchanged —
* which also means returning to a previous state of the working tree costs
* nothing.
*/
export async function generateWalkthrough({ directory, source: rawSource, force = false, model: explicitModel }, deps = {}) {
const source = parseSource(rawSource);
const repoRoot = await getRepositoryRoot(directory);
const key = sourceKey(source);
// Attach to a running job rather than starting a second one. A user who
// refreshed and pressed the button again wants the answer, not two bills.
const existing = jobs.get(jobKey(repoRoot, key));
if (existing) return existing.promise;
const controller = new AbortController();
const promise = runGeneration({ directory, source, repoRoot, key, force, explicitModel, signal: controller.signal }, deps)
.finally(() => {
if (jobs.get(jobKey(repoRoot, key))?.controller === controller) {
jobs.delete(jobKey(repoRoot, key));
}
});
jobs.set(jobKey(repoRoot, key), { controller, promise, stage: 'collecting' });
return promise;
}
async function runGeneration({ directory, source, repoRoot, key, force, explicitModel, signal }, deps) {
const model = await resolveModel(directory, explicitModel);
if (!model) {
throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' });
}
const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps);
setStage(repoRoot, key, 'asking');
if (hunkCount === 0) {
if (files.length > 0 && generatedFileCount === files.length) {
throw fail('Only generated files changed — there is nothing to review', 400, { code: 'only-generated' });
}
throw fail('There are no changes to review', 400, { code: 'empty-diff' });
}
const cacheKey = buildCacheKey({
repoRoot,
sourceKey: key,
providerID: model.providerID,
modelID: model.modelID,
files,
});
const hunkIndex = indexHunks(files);
if (!force) {
const cached = readCachedWalkthrough(cacheKey);
if (cached) {
writePointer(repoRoot, key, {
repoRoot,
sourceKey: key,
cacheKey,
generatedAt: cached.generatedAt,
});
return {
source,
walkthrough: cached.walkthrough,
model: cached.model,
generatedAt: cached.generatedAt,
fromCache: true,
hunks: serializeHunks(files),
hunkCount,
...resolveAgainstCurrent(cached.walkthrough, hunkIndex),
};
}
}
// A forced regeneration hands the model its own previous narrative so it can
// keep what is still true instead of starting from a blank page. The old
// anchors are deliberately not included — they belong to code that has moved.
let previousWalkthrough = null;
const pointer = readPointer(repoRoot, key);
if (pointer) {
const previousEntry = readCachedWalkthrough(pointer.cacheKey);
if (previousEntry && previousEntry.cacheKey !== cacheKey) {
previousWalkthrough = previousEntry.walkthrough;
}
}
const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough });
if (model.structuredOutput === false) {
throw fail(
`${modelLabel(model)} cannot produce structured output — choose a different small model`,
409,
{ code: 'structured-output-unsupported', model },
);
}
const run = (options) => generateSmallModelText({
prompt: options.prompt,
system: options.system,
directory,
model: `${model.providerID}/${model.modelID}`,
responseSchema: options.responseSchema,
onOverflow: 'error',
timeoutMs: generationTimeoutMs(hunkCount),
maxOutputTokens: MAX_OUTPUT_TOKENS,
signal,
});
// Roughly half the catalog does not declare `structured_output`, and some of
// those providers reject the schema outright. A rejected request shape is not
// a dead end: the shape can travel in the prompt instead, and the response
// parser is already tolerant of imperfect JSON.
const withSchema = () => run({ prompt, system, responseSchema });
const withoutSchema = () => run({
prompt,
system: `${system}\n${JSON_SHAPE_INSTRUCTION}`,
responseSchema: undefined,
});
const asRequestFailure = (error) => {
if (error?.code === 'context-too-small') {
return fail(error.message, 409, {
code: 'context-too-small',
model,
requiredChars: error.requiredChars,
availableChars: error.availableChars,
});
}
if (error?.code === 'output-exhausted') {
return fail(error.message, 409, { code: 'output-exhausted', model });
}
return null;
};
const refusesSchema = (error) => error?.code === 'structured-output-unsupported'
|| (Number(error?.status) >= 400 && Number(error?.status) < 500);
let raw;
let usedSchema = false;
if (schemaRefusedBy.has(modelKey(model))) {
// Already known to refuse: skip straight to the fallback rather than pay
// for a call whose failure is a foregone conclusion.
setStage(repoRoot, key, 'retrying');
try {
raw = await withoutSchema();
} catch (error) {
throw asRequestFailure(error) ?? error;
}
} else {
try {
raw = await withSchema();
usedSchema = true;
} catch (error) {
const failure = asRequestFailure(error);
if (failure) throw failure;
if (!refusesSchema(error)) throw error;
schemaRefusedBy.add(modelKey(model));
setStage(repoRoot, key, 'retrying');
try {
raw = await withoutSchema();
} catch (fallbackError) {
throw asRequestFailure(fallbackError) ?? fallbackError;
}
}
}
setStage(repoRoot, key, 'assembling');
let walkthrough;
try {
walkthrough = normalizeWalkthrough(parseModelJson(raw.text), idByAlias);
} catch (error) {
// Without schema support the model was asked for JSON in prose and did not
// deliver: that is a capability problem the user can fix by switching model,
// so it gets the picker rather than a parser error.
if (!usedSchema) {
throw fail(
`${modelLabel(model)} could not return the structured response a walkthrough needs`,
409,
{ code: 'structured-output-unsupported', model },
);
}
throw fail(
`${modelLabel(model)} did not return a usable walkthrough — try a different small model`,
502,
{ code: 'invalid-walkthrough', model, cause: error?.message },
);
}
const generatedAt = new Date().toISOString();
const entry = {
cacheKey,
generatedAt,
repoRoot,
sourceKey: key,
model: { providerID: model.providerID, modelID: model.modelID, source: model.source },
walkthrough,
};
// A failed write costs a regeneration next time; it must never fail the
// request that already produced a good walkthrough.
writeCachedWalkthrough(cacheKey, entry);
writePointer(repoRoot, key, { repoRoot, sourceKey: key, cacheKey, generatedAt });
return {
source,
walkthrough,
model: entry.model,
generatedAt,
fromCache: false,
hunks: serializeHunks(files),
hunkCount,
...resolveAgainstCurrent(walkthrough, hunkIndex),
};
}
export { WalkthroughSourceError };