From 348ac510115c9544344554003a08a41b48dfd366 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 14 Aug 2026 01:41:23 +0900 Subject: [PATCH] config: add revision reads and conflict recovery --- crates/workspace-server/src/config_source.rs | 83 +++++++++++++++++++ crates/workspace-server/src/server.rs | 22 +++++ .../config-source/ConfigSourceEditor.svelte | 60 ++++++++++++-- .../src/lib/workspace/config-source/api.ts | 16 +++- .../src/lib/workspace/styles/settings.css | 16 ++++ web/workspace/test/config-source/api.test.ts | 11 ++- 6 files changed, 197 insertions(+), 11 deletions(-) diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 2fea1f43..c469de6c 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -56,6 +56,36 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| load_state(conn, workspace_id)) } + pub fn load_workspace_config_revision( + &self, + workspace_id: &str, + revision: u64, + ) -> Result> { + self.with_conn(|conn| { + let manifest = conn + .query_row( + "SELECT tree_digest, manifest_json FROM workspace_config_tree_revisions WHERE workspace_id = ?1 AND revision = ?2", + params![workspace_id, revision as i64], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let Some((stored_digest, manifest_json)) = manifest else { + return Ok(None); + }; + let entries: std::collections::BTreeMap = + serde_json::from_str(&manifest_json) + .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; + let snapshot = ConfigTreeSnapshot::from_entries(revision, entries.into_values()) + .map_err(config_error)?; + if snapshot.digest != stored_digest { + return Err(Error::RegistryInconsistency(format!( + "virtual config revision digest mismatch for Workspace {workspace_id} revision {revision}" + ))); + } + Ok(Some(snapshot)) + }) + } + pub fn evaluate_workspace_config_candidate( &self, workspace_id: &str, @@ -475,6 +505,59 @@ mod tests { assert!(matches!(error, Error::WorkspaceConfigConflict(_))); } + #[tokio::test] + async fn committed_revision_remains_retrievable_after_later_commit() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store.upsert_workspace(&workspace()).await.unwrap(); + let empty = ConfigTreeSnapshot::empty(); + let contract = ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ); + let first = store + .evaluate_and_commit_workspace_config( + "w-config", + &ConfigCommitRequest { + base_revision: 0, + base_digest: empty.digest, + changes: vec![ConfigTreeChange::Create { + path: path(DEFAULT_CONFIG_ENTRYPOINT), + content_type: ConfigContentType::Decodal, + content: "{ answer = 1; }".into(), + }], + entrypoints: contract.entrypoints.clone(), + toolchain_fingerprint: contract.fingerprint.clone(), + }, + ) + .unwrap(); + let entry = first + .snapshot + .get(&path(DEFAULT_CONFIG_ENTRYPOINT)) + .unwrap(); + store + .evaluate_and_commit_workspace_config( + "w-config", + &ConfigCommitRequest { + base_revision: first.snapshot.revision, + base_digest: first.snapshot.digest.clone(), + changes: vec![ConfigTreeChange::Update { + path: path(DEFAULT_CONFIG_ENTRYPOINT), + expected_digest: entry.content_digest.clone(), + content: "{ answer = 2; }".into(), + }], + entrypoints: contract.entrypoints, + toolchain_fingerprint: contract.fingerprint, + }, + ) + .unwrap(); + let revision = store + .load_workspace_config_revision("w-config", 1) + .unwrap() + .unwrap(); + assert_eq!(revision, first.snapshot); + } + #[tokio::test] async fn commit_rejects_mismatched_toolchain_fingerprint() { let store = SqliteWorkspaceStore::in_memory().unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index afeede77..2028a323 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1151,6 +1151,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/config/source-tree/commit", post(scoped_commit_workspace_config_tree), ) + .route( + "/api/w/{workspace_id}/config/source-tree/revisions/{revision}", + get(scoped_get_workspace_config_revision), + ) .route( "/api/w/{workspace_id}/config/source-tree/entries/{*path}", get(scoped_get_workspace_config_entry), @@ -2441,6 +2445,24 @@ async fn scoped_update_workspace_settings( )) } +#[derive(Debug, Deserialize)] +struct WorkspaceConfigRevisionPath { + workspace_id: String, + revision: u64, +} + +async fn scoped_get_workspace_config_revision( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let snapshot = api + .config_store + .load_workspace_config_revision(&path.workspace_id, path.revision)? + .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.revision.to_string())))?; + Ok(Json(snapshot)) +} + #[derive(Debug, Deserialize)] struct WorkspaceConfigEntryPath { workspace_id: String, diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index ae53e273..a654d702 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -27,6 +27,8 @@ let renamePath = $state(""); let baseSnapshot = $state(null); let preflightDigest = $state(""); + let conflict = $state(false); + let candidateContract = $state(null); let toolchain: ConfigSourceToolchain | null = null; const paths = $derived( @@ -58,6 +60,8 @@ draftChanges = []; renamePath = selectedPath; diagnostics = []; + conflict = false; + candidateContract = null; status = treeState.snapshot.revision === 0 ? "No committed sources yet. Create workspace.dcdl to begin." : `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; @@ -73,6 +77,8 @@ if (treeState) treeState = { ...treeState, snapshot: candidate }; if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); preflightDigest = ""; + candidateContract = null; + conflict = false; } async function select(path: string) { @@ -142,13 +148,16 @@ const candidate = await previewConfigTree(workspaceId, { changes: draftChanges, entrypoints: entrypoints(), - toolchain_fingerprint: treeState.contract.fingerprint, }); + await toolchain?.evaluate(candidate.contract); diagnostics = []; + candidateContract = candidate.contract; preflightDigest = candidate.snapshot.digest; status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`; } catch (error) { - status = String(error); + const message = String(error); + conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); + status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; } finally { busy = false; } @@ -161,7 +170,7 @@ status = "No draft changes to commit."; return; } - if (preflightDigest !== treeState.snapshot.digest) { + if (preflightDigest !== treeState.snapshot.digest || !candidateContract) { status = "Preview the complete candidate successfully before Commit."; return; } @@ -171,11 +180,13 @@ base_revision: baseRevision, base_digest: baseDigest, changes: draftChanges, - entrypoints: entrypoints(), - toolchain_fingerprint: treeState.contract.fingerprint, + entrypoints: candidateContract.entrypoints, + toolchain_fingerprint: candidateContract.fingerprint, }); draftChanges = []; preflightDigest = ""; + candidateContract = null; + conflict = false; baseSnapshot = structuredClone(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; @@ -184,12 +195,39 @@ diagnostics = []; status = `Committed revision ${treeState.snapshot.revision}.`; } catch (error) { - status = String(error); + const message = String(error); + conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); + status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; } finally { busy = false; } } + async function discardAndReload() { + draftChanges = []; + source = ""; + await reload(); + } + + async function reloadAndReapply() { + if (!toolchain || !treeState) return; + const localCandidate = structuredClone(treeState.snapshot); + const remote = await fetchConfigTree(workspaceId); + baseSnapshot = structuredClone(remote.snapshot); + baseRevision = remote.snapshot.revision; + baseDigest = remote.snapshot.digest; + await toolchain.setSnapshot(remote.snapshot); + draftChanges = await toolchain.changesBetween(remote.snapshot, localCandidate); + const candidate = await toolchain.applyChanges(draftChanges); + treeState = { ...remote, snapshot: candidate }; + selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? ""; + source = selectedPath ? candidate.entries[selectedPath].content : ""; + conflict = false; + preflightDigest = ""; + candidateContract = null; + status = "Local candidate reapplied to the latest revision. Preview again before Commit."; + } + function createEntry() { const path = newPath.trim(); if (!path || treeState?.snapshot.entries[path]) return; @@ -210,6 +248,8 @@ treeState = { ...treeState, snapshot: candidate }; if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); preflightDigest = ""; + candidateContract = null; + conflict = false; selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? ""; source = selectedPath ? candidate.entries[selectedPath].content : ""; renamePath = selectedPath; @@ -231,6 +271,8 @@ treeState = { ...treeState, snapshot: candidate }; if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); preflightDigest = ""; + candidateContract = null; + conflict = false; selectedPath = to; source = candidate.entries[to]?.content ?? ""; status = `Rename to ${to} staged. Preview and Commit to persist.`; @@ -282,6 +324,12 @@ onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} />

{status}

+ {#if conflict} + + {/if} {#if diagnostics.length > 0}
    {#each diagnostics as diagnostic} diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index 40722e87..94bc8ebd 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -1,6 +1,8 @@ import type { ConfigCommitRequest, ConfigEntry, + ConfigPreviewRequest, + ConfigTreeSnapshot, EvaluatedConfigCandidate, WorkspaceConfigTreeResponse, } from "./types.ts"; @@ -41,9 +43,21 @@ export async function fetchConfigEntry( ); } +export async function fetchConfigRevision( + workspaceId: string, + revision: number, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, { + headers: { accept: "application/json" }, + }), + ); +} + export async function previewConfigTree( workspaceId: string, - request: Omit, + request: ConfigPreviewRequest, fetcher: typeof fetch = fetch, ): Promise { return await readJson( diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index 09f3bab8..2cc6c704 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -631,6 +631,22 @@ color: var(--text-muted); font-size: 0.8rem; } + .config-source-conflict { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + padding: var(--space-3); + border-top: 1px solid var(--danger); + background: color-mix(in srgb, var(--danger) 8%, transparent); + } + .config-source-conflict button { + border: 1px solid var(--danger); + border-radius: 0.45rem; + background: var(--bg-raised); + color: var(--danger); + padding: 0.4rem 0.6rem; + cursor: pointer; + } .config-source-diagnostics { display: grid; gap: var(--space-2); diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index 5ce7fa96..c5202276 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -4,6 +4,7 @@ import { assert, assertEquals } from "jsr:@std/assert"; import { commitConfigTree, fetchConfigEntry, + fetchConfigRevision, fetchConfigTree, previewConfigTree, } from "../../src/lib/workspace/config-source/api.ts"; @@ -23,8 +24,9 @@ Deno.test("config source API stays workspace-scoped and separates preview from c }) as typeof fetch; await fetchConfigTree("w/one", fetcher); + await fetchConfigRevision("w/one", 7, fetcher); await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher); - await previewConfigTree("w/one", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher); + await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher); await commitConfigTree("w/one", { base_revision: 4, base_digest: "sha256:base", @@ -35,14 +37,15 @@ Deno.test("config source API stays workspace-scoped and separates preview from c assertEquals(calls.map((call) => call.url), [ "/api/w/w%2Fone/config/source-tree", + "/api/w/w%2Fone/config/source-tree/revisions/7", "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", "/api/w/w%2Fone/config/source-tree/preview", "/api/w/w%2Fone/config/source-tree/commit", ]); - assertEquals(calls[2].init?.method, "POST"); assertEquals(calls[3].init?.method, "POST"); + assertEquals(calls[4].init?.method, "POST"); assert( - String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), + String(calls[4].init?.body).includes('"base_digest":"sha256:base"'), ); }); @@ -53,7 +56,7 @@ Deno.test("config source API surfaces failed evaluation instead of treating it a )) as typeof fetch; let message = ""; try { - await previewConfigTree("w", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher); + await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher); } catch (error) { message = String(error); }