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,