From cc694d914a3696fd8b28bbfb67d3d7901f0c5d05 Mon Sep 17 00:00:00 2001 From: forhappy Date: Thu, 20 Aug 2026 20:44:17 -0700 Subject: [PATCH] fix(history): prompt to rebuild unsupported revision graphs --- CHANGELOG.md | 4 + COMPATIBILITY.md | 4 +- MIGRATION.md | 6 +- crates/compass-history/src/graph_read.rs | 27 +++++- .../vscode/src/history/buildArguments.test.ts | 12 +++ .../src/history/rebuildRecovery.test.ts | 37 ++++++++ editors/vscode/src/history/rebuildRecovery.ts | 31 +++++++ editors/vscode/src/views/historyPanel.ts | 86 ++++++++++++++----- 8 files changed, 178 insertions(+), 29 deletions(-) create mode 100644 editors/vscode/src/history/rebuildRecovery.test.ts create mode 100644 editors/vscode/src/history/rebuildRecovery.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e57244cf..d78da6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Keep Codebase Evolution strict when a stored revision uses an unsupported + artifact layout, and offer to rebuild the affected revision with the current + Compass version instead of mapping legacy history records. + - Make community detail graphs easier to scan in both exported HTML and VS Code by grouping node kinds into accessible color-and-shape families, coloring edges by relationship purpose while retaining confidence strokes, diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ef1b3955..35212814 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -91,8 +91,8 @@ Versioned history remains on realization schema 1, store-format root `compass/store-format/v1`, and the `compass/v1` realization-root namespace. The visible output-path cutover does not change those serialized contracts. Historical realizations containing former hidden artifact paths are not mapped -or rewritten; rebuild those revisions when they must be materialized with the -current visible artifact layout. +or rewritten; run `compass history rebuild REV` for a revision that must use +the current visible artifact layout. The current local build publishes `graph.json` (`compass.graph/1`) directly under the selected output root by default. It also materializes diff --git a/MIGRATION.md b/MIGRATION.md index 6c36f40c..00caf8a8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -172,9 +172,9 @@ compass update . The history realization schema and SQLite store format remain at v1. Compass does not rewrite immutable realizations or map former hidden artifact paths. -Use `compass history build` to recreate any revision that must materialize with -the current visible artifact layout; archive the existing history database -first only when it is needed for audit or rollback. +Use `compass history rebuild` to recreate any revision that must materialize +with the current visible artifact layout; archive the existing history +database first only when it is needed for audit or rollback. ## Compass Store sidecar upgrades diff --git a/crates/compass-history/src/graph_read.rs b/crates/compass-history/src/graph_read.rs index f5b182fe..cf340e85 100644 --- a/crates/compass-history/src/graph_read.rs +++ b/crates/compass-history/src/graph_read.rs @@ -77,9 +77,10 @@ impl RealizationReader<'_> { "labels.json" => sink.labels(value)?, "analysis.json" => {} _ => { - return Err(HistoryError::InvalidArtifacts(format!( - "unknown analysis sidecar {path}" - ))); + return Err(unsupported_analysis_sidecar( + &self.published.version.git_commit, + &path, + )); } } } @@ -196,3 +197,23 @@ fn text(bytes: &[u8], kind: &str) -> Result { String::from_utf8(bytes.to_vec()) .map_err(|error| HistoryError::InvalidArtifacts(format!("non-UTF-8 {kind}: {error}"))) } + +fn unsupported_analysis_sidecar(revision: &str, path: &str) -> HistoryError { + HistoryError::InvalidArtifacts(format!( + "stored graph for revision {revision} uses an unsupported artifact layout ({path}); rebuild this revision graph with the current Compass version" + )) +} + +#[cfg(test)] +mod tests { + use super::unsupported_analysis_sidecar; + + #[test] + fn unsupported_sidecars_request_a_current_graph_rebuild() { + let error = unsupported_analysis_sidecar("abcdef123456", ".compass_analysis.json"); + assert_eq!( + error.to_string(), + "invalid graph artifacts: stored graph for revision abcdef123456 uses an unsupported artifact layout (.compass_analysis.json); rebuild this revision graph with the current Compass version" + ); + } +} diff --git a/editors/vscode/src/history/buildArguments.test.ts b/editors/vscode/src/history/buildArguments.test.ts index 5d675cd2..b449702a 100644 --- a/editors/vscode/src/history/buildArguments.test.ts +++ b/editors/vscode/src/history/buildArguments.test.ts @@ -28,4 +28,16 @@ describe("history build arguments", () => { "history", "enable" ]); }); + + it("uses explicit rebuild semantics for an obsolete stored graph", () => { + expect(buildHistoryArgs({ + revision: "abc", + all: false, + firstParent: false, + rebuild: true, + profile: { kind: "configured" } + })).toEqual([ + "history", "rebuild", "abc", "--format", "json", "--events", "jsonl" + ]); + }); }); diff --git a/editors/vscode/src/history/rebuildRecovery.test.ts b/editors/vscode/src/history/rebuildRecovery.test.ts new file mode 100644 index 00000000..5c7db6d5 --- /dev/null +++ b/editors/vscode/src/history/rebuildRecovery.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + RevisionGraphRebuildRequired, + rebuildRequiredMessage, + withRevisionGraphContext +} from "./rebuildRecovery"; + +describe("history graph rebuild recovery", () => { + it("identifies the affected revision from a strict history reader failure", async () => { + const detail = "invalid graph artifacts: stored graph for revision abc uses an unsupported artifact layout (old.json); rebuild this revision graph with the current Compass version"; + const result = withRevisionGraphContext("abcdef123456", async () => { + throw new Error(detail); + }); + + await expect(result).rejects.toMatchObject({ + name: "RevisionGraphRebuildRequired", + commit: "abcdef123456", + detail + }); + await expect(result).rejects.toBeInstanceOf(RevisionGraphRebuildRequired); + }); + + it("preserves unrelated failures", async () => { + const original = new Error("history database is unavailable"); + const result = withRevisionGraphContext("abcdef123456", async () => { + throw original; + }); + + await expect(result).rejects.toBe(original); + }); + + it("uses concise, actionable copy", () => { + expect(rebuildRequiredMessage("abcdef123456")).toBe( + "The stored graph for abcdef123 uses an unsupported format. Rebuild it with the current Compass version, then try again." + ); + }); +}); diff --git a/editors/vscode/src/history/rebuildRecovery.ts b/editors/vscode/src/history/rebuildRecovery.ts new file mode 100644 index 00000000..64714a97 --- /dev/null +++ b/editors/vscode/src/history/rebuildRecovery.ts @@ -0,0 +1,31 @@ +const REBUILD_REQUIRED_MARKER = + "rebuild this revision graph with the current Compass version"; + +export class RevisionGraphRebuildRequired extends Error { + constructor( + public readonly commit: string, + public readonly detail: string + ) { + super(rebuildRequiredMessage(commit)); + this.name = "RevisionGraphRebuildRequired"; + } +} + +export async function withRevisionGraphContext( + commit: string, + load: () => Promise +): Promise { + try { + return await load(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + if (detail.includes(REBUILD_REQUIRED_MARKER)) { + throw new RevisionGraphRebuildRequired(commit, detail); + } + throw error; + } +} + +export function rebuildRequiredMessage(commit: string): string { + return `The stored graph for ${commit.slice(0, 9)} uses an unsupported format. Rebuild it with the current Compass version, then try again.`; +} diff --git a/editors/vscode/src/views/historyPanel.ts b/editors/vscode/src/views/historyPanel.ts index b5cf7eda..dc9fe7c0 100644 --- a/editors/vscode/src/views/historyPanel.ts +++ b/editors/vscode/src/views/historyPanel.ts @@ -4,6 +4,10 @@ import type { HistoryTimeline } from "@compass/viewer"; import { buildEnableHistoryArgs, buildHistoryArgs } from "../history/buildArguments"; import { loadSemanticDiff } from "../history/diffClient"; import { loadChangeCounts } from "../history/changeCountsClient"; +import { + RevisionGraphRebuildRequired, + withRevisionGraphContext +} from "../history/rebuildRecovery"; import { RevisionStore } from "../history/revisionStore"; import { loadTimeline } from "../history/timelineClient"; import { @@ -169,7 +173,7 @@ export async function openHistoryPanel( activePanelBuild.command.cancel(); } }); - const buildRevision = async (commit: string): Promise => { + const buildRevision = async (commit: string, rebuild = false): Promise => { if (disposed) return; if (session.activeWriter) { await postMessage({ @@ -197,7 +201,7 @@ export async function openHistoryPanel( value: { kind: "from" as const } } ], - { title: `Build graph for ${commit.slice(0, 9)}` } + { title: `${rebuild ? "Rebuild" : "Build"} graph for ${commit.slice(0, 9)}` } ); if (disposed) return; if (!profile) { @@ -235,6 +239,7 @@ export async function openHistoryPanel( revision: commit, all: false, firstParent: false, + rebuild, profile: selectedProfile }), (event) => { @@ -250,7 +255,7 @@ export async function openHistoryPanel( const result = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: `Building Compass graph for ${commit.slice(0, 9)}`, + title: `${rebuild ? "Rebuilding" : "Building"} Compass graph for ${commit.slice(0, 9)}`, cancellable: true }, async (progress, token) => { @@ -407,10 +412,13 @@ export async function openHistoryPanel( const generation = ++viewGeneration; activeComparison = undefined; activeSourceCommits = new Set(); - const revision = await revisions.load( + const revision = await withRevisionGraphContext( message.commit, - graphNodeLimit, - historyIdentity(entry) + () => revisions.load( + message.commit, + graphNodeLimit, + historyIdentity(entry) + ) ); if (generation !== viewGeneration) return; activeSourceCommits = new Set([message.commit]); @@ -438,11 +446,14 @@ export async function openHistoryPanel( realization: message.realization, fingerprint: message.fingerprint }; - const revision = await revisions.loadCommunity( + const revision = await withRevisionGraphContext( message.commit, - message.communityId, - graphNodeLimit, - expected + () => revisions.loadCommunity( + message.commit, + message.communityId, + graphNodeLimit, + expected + ) ); await postMessage({ type: "communityGraph", @@ -464,9 +475,17 @@ export async function openHistoryPanel( const generation = ++viewGeneration; activeComparison = undefined; activeSourceCommits = new Set([message.commit]); - const [current, parent, semanticDiff, counts] = await Promise.all([ - revisions.load(message.commit, graphNodeLimit, historyIdentity(currentEntry)), - revisions.load(message.parent, graphNodeLimit, historyIdentity(parentEntry)), + const [current, parent] = await Promise.all([ + withRevisionGraphContext( + message.commit, + () => revisions.load(message.commit, graphNodeLimit, historyIdentity(currentEntry)) + ), + withRevisionGraphContext( + message.parent, + () => revisions.load(message.parent, graphNodeLimit, historyIdentity(parentEntry)) + ) + ]); + const [semanticDiff, counts] = await Promise.all([ loadSemanticDiff(session, message.parent, message.commit), loadChangeCounts(session, message.commit, message.parent) ]); @@ -536,19 +555,25 @@ export async function openHistoryPanel( try { [current, parent] = await Promise.all([ message.hasCurrent - ? revisions.loadCommunity( + ? withRevisionGraphContext( message.commit, - message.communityId, - graphNodeLimit, - comparisonState.currentIdentity + () => revisions.loadCommunity( + message.commit, + message.communityId, + graphNodeLimit, + comparisonState.currentIdentity + ) ) : undefined, message.hasParent - ? revisions.loadCommunity( + ? withRevisionGraphContext( message.parent, - message.communityId, - graphNodeLimit, - comparisonState.parentIdentity + () => revisions.loadCommunity( + message.parent, + message.communityId, + graphNodeLimit, + comparisonState.parentIdentity + ) ) : undefined ]); @@ -593,6 +618,25 @@ export async function openHistoryPanel( ); } } catch (error) { + if (error instanceof RevisionGraphRebuildRequired) { + output.appendLine(`[history:error] ${error.detail}`); + const action = await vscode.window.showWarningMessage( + error.message, + "Rebuild graph" + ); + if (action === "Rebuild graph") { + await buildRevision(error.commit, true); + } else { + const commit = typeof message?.commit === "string" ? message.commit : error.commit; + await postMessage({ + type: "error", + operation: historyOperationFor(message), + commit, + message: error.message + }); + } + return; + } if (message?.type === "buildRevision" && typeof message.commit === "string") { const fullMessage = error instanceof Error ? error.message : String(error); output.appendLine(`[history:error] ${fullMessage}`);