config: add revision reads and conflict recovery

This commit is contained in:
2026-08-14 01:41:23 +09:00
parent 544abbbf56
commit 348ac51011
6 changed files with 197 additions and 11 deletions
@@ -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<Option<ConfigTreeSnapshot>> {
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<VirtualPath, ConfigEntry> =
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();
+22
View File
@@ -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<WorkspaceApi>,
AxumPath(path): AxumPath<WorkspaceConfigRevisionPath>,
) -> ApiResult<Json<ConfigTreeSnapshot>> {
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,
@@ -27,6 +27,8 @@
let renamePath = $state("");
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let preflightDigest = $state("");
let conflict = $state(false);
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(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)}
/>
<p class="config-source-status" aria-live="polite">{status}</p>
{#if conflict}
<div class="config-source-conflict" role="alert">
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button>
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button>
</div>
{/if}
{#if diagnostics.length > 0}
<ol class="config-source-diagnostics">
{#each diagnostics as diagnostic}
@@ -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<ConfigTreeSnapshot> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, {
headers: { accept: "application/json" },
}),
);
}
export async function previewConfigTree(
workspaceId: string,
request: Omit<ConfigCommitRequest, "base_revision" | "base_digest">,
request: ConfigPreviewRequest,
fetcher: typeof fetch = fetch,
): Promise<EvaluatedConfigCandidate> {
return await readJson(
@@ -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);
+7 -4
View File
@@ -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);
}