config: commit without preview

This commit is contained in:
2026-08-15 02:06:46 +09:00
parent 404809ab6e
commit 4cf34375a8
12 changed files with 109 additions and 288 deletions
+12 -120
View File
@@ -108,10 +108,8 @@ pub struct WorkspaceConfigState {
pub projection_digest: String, pub projection_digest: String,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[ts(export)]
pub struct EvaluatedConfigCandidate { pub struct EvaluatedConfigCandidate {
#[ts(type = "number")]
pub base_revision: u64, pub base_revision: u64,
pub base_digest: String, pub base_digest: String,
pub snapshot: ConfigTreeSnapshot, pub snapshot: ConfigTreeSnapshot,
@@ -127,14 +125,6 @@ pub struct ConfigCommitRequest {
pub base_digest: String, pub base_digest: String,
pub changes: Vec<ConfigTreeChange>, pub changes: Vec<ConfigTreeChange>,
pub entrypoints: Vec<VirtualPath>, pub entrypoints: Vec<VirtualPath>,
pub toolchain_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct ConfigPreviewRequest {
pub changes: Vec<ConfigTreeChange>,
pub entrypoints: Vec<VirtualPath>,
} }
impl SqliteWorkspaceStore { impl SqliteWorkspaceStore {
@@ -245,13 +235,6 @@ impl SqliteWorkspaceStore {
current.snapshot.revision current.snapshot.revision
))); )));
} }
let expected_contract = main_config_contract_with_schema(schema_bundle.clone());
if expected_contract.fingerprint != request.toolchain_fingerprint {
return Err(config_conflict(format!(
"toolchain fingerprint mismatch; current fingerprint is {}",
expected_contract.fingerprint
)));
}
evaluate_candidate(current, &request.changes, schema_bundle) evaluate_candidate(current, &request.changes, schema_bundle)
} }
@@ -267,31 +250,6 @@ impl SqliteWorkspaceStore {
) )
} }
pub fn preview_workspace_config_with_schema(
&self,
workspace_id: &str,
request: &ConfigPreviewRequest,
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> {
validate_entrypoint_request(&request.entrypoints)?;
let current = self
.load_workspace_config(workspace_id)?
.ok_or_else(config_not_materialized)?;
evaluate_candidate(current, &request.changes, schema_bundle)
}
pub fn preview_workspace_config(
&self,
workspace_id: &str,
request: &ConfigPreviewRequest,
) -> Result<EvaluatedConfigCandidate> {
self.preview_workspace_config_with_schema(
workspace_id,
request,
WorkspaceConfigSchemaBundle::empty(),
)
}
pub fn commit_evaluated_workspace_config( pub fn commit_evaluated_workspace_config(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -943,7 +901,6 @@ mod tests {
base_digest: current.snapshot.digest.clone(), base_digest: current.snapshot.digest.clone(),
changes, changes,
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: current.contract.fingerprint.clone(),
} }
} }
@@ -991,7 +948,6 @@ mod tests {
content: "{ web = {}; }".to_string(), content: "{ web = {}; }".to_string(),
}], }],
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: expected_contract.fingerprint.clone(),
}, },
schema, schema,
) )
@@ -1018,34 +974,6 @@ mod tests {
); );
} }
#[tokio::test]
async fn commit_rejects_stale_schema_bundle_fingerprint() {
let store = open_store().await;
let current = store.load_workspace_config("w-config").unwrap().unwrap();
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:web",
"web",
"1",
"{ web = {}; }",
)
.unwrap()])
.unwrap();
let error = store
.evaluate_workspace_config_candidate_with_schema(
"w-config",
&ConfigCommitRequest {
base_revision: current.snapshot.revision,
base_digest: current.snapshot.digest,
changes: Vec::new(),
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: current.contract.fingerprint,
},
schema,
)
.unwrap_err();
assert!(error.to_string().contains("toolchain fingerprint mismatch"));
}
#[test] #[test]
fn active_state_evaluation_rejects_provider_fingerprint_drift() { fn active_state_evaluation_rejects_provider_fingerprint_drift() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries(
@@ -1361,10 +1289,11 @@ mod tests {
let store = open_store().await; let store = open_store().await;
let current = store.load_workspace_config("w-config").unwrap().unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let candidate = store let candidate = store
.preview_workspace_config( .evaluate_workspace_config_candidate(
"w-config", "w-config",
&ConfigPreviewRequest { &commit_request(
changes: vec![ &current,
vec![
update_main(&current, "{}"), update_main(&current, "{}"),
ConfigTreeChange::Create { ConfigTreeChange::Create {
path: path("module.dcdl"), path: path("module.dcdl"),
@@ -1372,8 +1301,7 @@ mod tests {
content: "{}".into(), content: "{}".into(),
}, },
], ],
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], ),
},
) )
.unwrap(); .unwrap();
@@ -1469,12 +1397,9 @@ mod tests {
}, },
] { ] {
let error = store let error = store
.preview_workspace_config( .evaluate_workspace_config_candidate(
"w-config", "w-config",
&ConfigPreviewRequest { &commit_request(&current, vec![change]),
changes: vec![change],
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
},
) )
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("cannot be")); assert!(error.to_string().contains("cannot be"));
@@ -1484,14 +1409,11 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn browser_cannot_replace_server_owned_entrypoint_contract() { async fn browser_cannot_replace_server_owned_entrypoint_contract() {
let store = open_store().await; let store = open_store().await;
let current = store.load_workspace_config("w-config").unwrap().unwrap();
let mut request = commit_request(&current, Vec::new());
request.entrypoints = vec![path("other.dcdl")];
let error = store let error = store
.preview_workspace_config( .evaluate_workspace_config_candidate("w-config", &request)
"w-config",
&ConfigPreviewRequest {
changes: Vec::new(),
entrypoints: vec![path("other.dcdl")],
},
)
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("must be exactly [main.dcdl]")); assert!(error.to_string().contains("must be exactly [main.dcdl]"));
} }
@@ -1514,12 +1436,6 @@ mod tests {
content: "{ broken = ; }".into(), content: "{ broken = ; }".into(),
}], }],
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: ToolchainContract::new(
DEFAULT_SCHEMA_VERSION,
vec![path(MAIN_CONFIG_ENTRYPOINT)],
DEFAULT_IMPORT_POLICY_VERSION,
)
.fingerprint,
}, },
) )
.unwrap_err(); .unwrap_err();
@@ -1587,7 +1503,6 @@ mod tests {
content: "{ answer = 2; }".into(), content: "{ answer = 2; }".into(),
}], }],
entrypoints: first.contract.entrypoints.clone(), entrypoints: first.contract.entrypoints.clone(),
toolchain_fingerprint: first.contract.fingerprint.clone(),
}, },
) )
.unwrap(); .unwrap();
@@ -1598,27 +1513,6 @@ mod tests {
assert_eq!(revision, first.snapshot); assert_eq!(revision, first.snapshot);
} }
#[tokio::test]
async fn commit_rejects_mismatched_toolchain_fingerprint() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap();
let current = store.load_workspace_config("w-config").unwrap().unwrap();
let error = store
.evaluate_and_commit_workspace_config(
"w-config",
&ConfigCommitRequest {
base_revision: current.snapshot.revision,
base_digest: current.snapshot.digest.clone(),
changes: vec![update_main(&current, "{ answer = 42; }")],
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: "sha256:stale-toolchain".into(),
},
)
.unwrap_err();
assert!(matches!(error, Error::WorkspaceConfigConflict(_)));
assert!(store.load_workspace_config("w-config").unwrap().is_some());
}
#[tokio::test] #[tokio::test]
async fn migration_materializes_main_for_existing_workspace_without_config() { async fn migration_materializes_main_for_existing_workspace_without_config() {
let conn = rusqlite::Connection::open_in_memory().unwrap(); let conn = rusqlite::Connection::open_in_memory().unwrap();
@@ -1653,9 +1547,7 @@ mod tests {
.join("../../web/workspace/src/lib/workspace/config-source/generated/types"); .join("../../web/workspace/src/lib/workspace/config-source/generated/types");
let config = ts_rs::Config::default().with_out_dir(&output); let config = ts_rs::Config::default().with_out_dir(&output);
WorkspaceConfigState::export_all(&config).unwrap(); WorkspaceConfigState::export_all(&config).unwrap();
EvaluatedConfigCandidate::export_all(&config).unwrap();
ConfigCommitRequest::export_all(&config).unwrap(); ConfigCommitRequest::export_all(&config).unwrap();
ConfigPreviewRequest::export_all(&config).unwrap();
} }
#[test] #[test]
+1 -21
View File
@@ -62,7 +62,7 @@ use crate::companion::{
CompanionStatusResponse, CompanionTranscriptProjection, CompanionStatusResponse, CompanionTranscriptProjection,
}; };
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime}; use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
use crate::config_source::{ConfigCommitRequest, ConfigPreviewRequest}; use crate::config_source::ConfigCommitRequest;
use crate::hosts::{ use crate::hosts::{
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID, ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
@@ -1156,10 +1156,6 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/config/source-tree", "/api/w/{workspace_id}/config/source-tree",
get(scoped_get_workspace_config_tree), get(scoped_get_workspace_config_tree),
) )
.route(
"/api/w/{workspace_id}/config/source-tree/preview",
post(scoped_preview_workspace_config_tree),
)
.route( .route(
"/api/w/{workspace_id}/config/source-tree/commit", "/api/w/{workspace_id}/config/source-tree/commit",
post(scoped_commit_workspace_config_tree), post(scoped_commit_workspace_config_tree),
@@ -2514,21 +2510,6 @@ async fn scoped_get_workspace_config_entry(
Ok(Json(entry)) Ok(Json(entry))
} }
async fn scoped_preview_workspace_config_tree(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<ConfigPreviewRequest>,
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let candidate = api.config_store.preview_workspace_config_with_schema(
&path.workspace_id,
&request,
api.config_schema_registry.compose()?,
)?;
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
Ok(Json(candidate))
}
async fn scoped_commit_workspace_config_tree( async fn scoped_commit_workspace_config_tree(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -14114,7 +14095,6 @@ mod tests {
}, },
], ],
entrypoints: current.contract.entrypoints.clone(), entrypoints: current.contract.entrypoints.clone(),
toolchain_fingerprint: current.contract.fingerprint.clone(),
}; };
let candidate = api let candidate = api
.config_store .config_store
@@ -4,7 +4,6 @@
import { import {
commitConfigTree, commitConfigTree,
fetchConfigTree, fetchConfigTree,
previewConfigTree,
} from "./api.ts"; } from "./api.ts";
import { ConfigSourceToolchain } from "./toolchain.ts"; import { ConfigSourceToolchain } from "./toolchain.ts";
import type { import type {
@@ -23,15 +22,15 @@
let diagnostics = $state<ConfigDiagnostic[]>([]); let diagnostics = $state<ConfigDiagnostic[]>([]);
let status = $state("Loading source tree…"); let status = $state("Loading source tree…");
let busy = $state(false); let busy = $state(false);
let draftChanges = $state<ConfigTreeChange[]>([]); let workingChanges = $state<ConfigTreeChange[]>([]);
let baseRevision = $state(0); let baseRevision = $state(0);
let baseDigest = $state(""); let baseDigest = $state("");
let renamePath = $state(""); let renamePath = $state("");
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null); let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let preflightDigest = $state("");
let conflict = $state(false); let conflict = $state(false);
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null); let toolchain = $state.raw<ConfigSourceToolchain | null>(null);
let toolchain: ConfigSourceToolchain | null = null; let analysisReady = $state(false);
let analysisGeneration = 0;
const paths = $derived( const paths = $derived(
treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [], treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [],
@@ -40,8 +39,7 @@
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
); );
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT); const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
onMount(() => { onMount(() => {
toolchain = new ConfigSourceToolchain(); toolchain = new ConfigSourceToolchain();
@@ -49,7 +47,30 @@
return () => toolchain?.close(); return () => toolchain?.close();
}); });
$effect(() => {
const analyzer = toolchain;
const path = selectedPath;
const value = source;
const ready = analysisReady;
const generation = ++analysisGeneration;
diagnostics = [];
if (!analyzer || !path || !ready) return;
const timer = setTimeout(() => {
void analyzer.analyze(path, value).then((result) => {
if (generation === analysisGeneration) diagnostics = result;
}).catch((error) => {
if (generation === analysisGeneration) status = `Analyze failed: ${String(error)}`;
});
}, 250);
return () => {
clearTimeout(timer);
if (generation === analysisGeneration) analysisGeneration += 1;
};
});
async function reload() { async function reload() {
analysisReady = false;
try { try {
treeState = await fetchConfigTree(workspaceId); treeState = await fetchConfigTree(workspaceId);
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
@@ -60,11 +81,11 @@
baseRevision = treeState.snapshot.revision; baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest; baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
draftChanges = []; analysisReady = true;
workingChanges = [];
renamePath = selectedPath; renamePath = selectedPath;
diagnostics = []; diagnostics = [];
conflict = false; conflict = false;
candidateContract = null;
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`; status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
} catch (error) { } catch (error) {
status = String(error); status = String(error);
@@ -76,9 +97,7 @@
if (!change || !toolchain) return; if (!change || !toolchain) return;
const candidate = await toolchain.applyChanges([change]); const candidate = await toolchain.applyChanges([change]);
if (treeState) treeState = { ...treeState, snapshot: candidate }; if (treeState) treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
candidateContract = null;
conflict = false; conflict = false;
} }
@@ -114,27 +133,21 @@
return [MAIN_ENTRYPOINT]; return [MAIN_ENTRYPOINT];
} }
async function analyze() {
if (!toolchain || !treeState || !selectedPath) return;
diagnostics = await toolchain.analyze(selectedPath, source);
status = diagnostics.length === 0 ? "No diagnostics." : `${diagnostics.length} diagnostic(s).`;
}
async function format() { async function format() {
if (!toolchain) return; if (!toolchain) return;
try { try {
source = await toolchain.format(source); source = await toolchain.format(source);
await analyze(); status = "Formatted source. Changes remain local until Commit succeeds.";
} catch (error) { } catch (error) {
status = String(error); status = String(error);
} }
} }
async function formatDraftSources() { async function formatWorkingSources() {
if (!toolchain || !treeState || !baseSnapshot) return; if (!toolchain || !treeState || !baseSnapshot) return;
await stageCurrent(); await stageCurrent();
const paths = new Set<string>(); const paths = new Set<string>();
for (const change of draftChanges) { for (const change of workingChanges) {
if (change.kind === "create" || change.kind === "update") paths.add(change.path); if (change.kind === "create" || change.kind === "update") paths.add(change.path);
if (change.kind === "rename") paths.add(change.to); if (change.kind === "rename") paths.add(change.to);
} }
@@ -157,74 +170,33 @@
if (!formattedAny) return; if (!formattedAny) return;
treeState = { ...treeState, snapshot: candidate }; treeState = { ...treeState, snapshot: candidate };
draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
source = candidate.entries[selectedPath]?.content ?? source; source = candidate.entries[selectedPath]?.content ?? source;
preflightDigest = "";
candidateContract = null;
conflict = false; conflict = false;
} }
async function requestCandidatePreview() { function recordCommitError(error: unknown) {
if (!toolchain) throw new Error("config source toolchain is unavailable");
const candidate = await previewConfigTree(workspaceId, {
changes: draftChanges,
entrypoints: entrypoints(),
});
await toolchain.evaluate(candidate.contract);
diagnostics = [];
candidateContract = candidate.contract;
preflightDigest = candidate.snapshot.digest;
return candidate;
}
function recordCandidateError(error: unknown) {
const message = String(error); const message = String(error);
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
} }
async function preview() {
if (!treeState) return;
busy = true;
try {
await formatDraftSources();
if (draftChanges.length === 0) {
status = "No draft changes to preview.";
return;
}
const candidate = await requestCandidatePreview();
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
} catch (error) {
recordCandidateError(error);
} finally {
busy = false;
}
}
async function commit() { async function commit() {
if (!treeState) return; if (!treeState) return;
busy = true; busy = true;
try { try {
await formatDraftSources(); await formatWorkingSources();
if (draftChanges.length === 0) { if (workingChanges.length === 0) {
status = "No draft changes to commit."; status = "No working changes to commit.";
return; return;
} }
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
await requestCandidatePreview();
}
const contract = candidateContract;
if (!contract) throw new Error("candidate preview did not return a toolchain contract");
treeState = await commitConfigTree(workspaceId, { treeState = await commitConfigTree(workspaceId, {
base_revision: baseRevision, base_revision: baseRevision,
base_digest: baseDigest, base_digest: baseDigest,
changes: draftChanges, changes: workingChanges,
entrypoints: contract.entrypoints, entrypoints: entrypoints(),
toolchain_fingerprint: contract.fingerprint,
}); });
draftChanges = []; workingChanges = [];
preflightDigest = "";
candidateContract = null;
conflict = false; conflict = false;
baseSnapshot = $state.snapshot(treeState.snapshot); baseSnapshot = $state.snapshot(treeState.snapshot);
baseRevision = treeState.snapshot.revision; baseRevision = treeState.snapshot.revision;
@@ -234,21 +206,21 @@
diagnostics = []; diagnostics = [];
status = `Committed formatted revision ${treeState.snapshot.revision}.`; status = `Committed formatted revision ${treeState.snapshot.revision}.`;
} catch (error) { } catch (error) {
recordCandidateError(error); recordCommitError(error);
} finally { } finally {
busy = false; busy = false;
} }
} }
async function discardAndReload() { async function discardAndReload() {
draftChanges = []; workingChanges = [];
source = ""; source = "";
await reload(); await reload();
} }
async function reloadAndReapply() { async function reloadAndReapply() {
if (!toolchain || !treeState) return; if (!toolchain || !treeState) return;
const localChanges = [...draftChanges]; const localChanges = [...workingChanges];
const remote = await fetchConfigTree(workspaceId); const remote = await fetchConfigTree(workspaceId);
baseSnapshot = structuredClone(remote.snapshot); baseSnapshot = structuredClone(remote.snapshot);
baseRevision = remote.snapshot.revision; baseRevision = remote.snapshot.revision;
@@ -256,14 +228,12 @@
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
try { try {
const candidate = await toolchain.applyChanges(localChanges); const candidate = await toolchain.applyChanges(localChanges);
draftChanges = localChanges; workingChanges = localChanges;
treeState = { ...remote, snapshot: candidate }; treeState = { ...remote, snapshot: candidate };
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? ""; selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
source = selectedPath ? candidate.entries[selectedPath].content : ""; source = selectedPath ? candidate.entries[selectedPath].content : "";
conflict = false; conflict = false;
preflightDigest = ""; status = "Local changes reapplied to the latest revision. Commit to persist them.";
candidateContract = null;
status = "Local changes reapplied to the latest revision. Preview again before Commit.";
} catch (error) { } catch (error) {
conflict = true; conflict = true;
status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`; status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
@@ -276,7 +246,7 @@
selectedPath = path; selectedPath = path;
source = "{}\n"; source = "{}\n";
diagnostics = []; diagnostics = [];
status = `Drafting new source ${path}. It is not persisted until Commit succeeds.`; status = `Creating local source ${path}. It is not persisted until Commit succeeds.`;
} }
async function deleteEntry() { async function deleteEntry() {
@@ -288,14 +258,12 @@
}; };
const candidate = await toolchain.applyChanges([change]); const candidate = await toolchain.applyChanges([change]);
treeState = { ...treeState, snapshot: candidate }; treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
candidateContract = null;
conflict = false; conflict = false;
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? ""; selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
source = selectedPath ? candidate.entries[selectedPath].content : ""; source = selectedPath ? candidate.entries[selectedPath].content : "";
renamePath = selectedPath; renamePath = selectedPath;
status = "Delete staged. Preview and Commit to persist the candidate tree."; status = "Delete staged locally. Commit to persist the working tree.";
} }
async function renameEntry() { async function renameEntry() {
@@ -311,13 +279,11 @@
}; };
const candidate = await toolchain.applyChanges([change]); const candidate = await toolchain.applyChanges([change]);
treeState = { ...treeState, snapshot: candidate }; treeState = { ...treeState, snapshot: candidate };
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); if (baseSnapshot) workingChanges = await toolchain.changesBetween(baseSnapshot, candidate);
preflightDigest = "";
candidateContract = null;
conflict = false; conflict = false;
selectedPath = to; selectedPath = to;
source = candidate.entries[to]?.content ?? ""; source = candidate.entries[to]?.content ?? "";
status = `Rename to ${to} staged. Preview and Commit to persist.`; status = `Rename to ${to} staged locally. Commit to persist it.`;
} }
</script> </script>
@@ -342,7 +308,7 @@
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}> <form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
<label for="new-config-path">New path</label> <label for="new-config-path">New path</label>
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" /> <input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" />
<button type="submit">Create draft</button> <button type="submit">Create local source</button>
</form> </form>
</aside> </aside>
@@ -356,9 +322,7 @@
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} /> <input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button> <button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button> <button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button> <button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
<button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button>
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button> <button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
</div> </div>
</header> </header>
@@ -1,9 +1,7 @@
import type { import type {
ConfigCommitRequest, ConfigCommitRequest,
ConfigEntry, ConfigEntry,
ConfigPreviewRequest,
ConfigTreeSnapshot, ConfigTreeSnapshot,
EvaluatedConfigCandidate,
WorkspaceConfigTreeResponse, WorkspaceConfigTreeResponse,
} from "./types.ts"; } from "./types.ts";
@@ -55,20 +53,6 @@ export async function fetchConfigRevision(
); );
} }
export async function previewConfigTree(
workspaceId: string,
request: ConfigPreviewRequest,
fetcher: typeof fetch = fetch,
): Promise<EvaluatedConfigCandidate> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
);
}
export async function commitConfigTree( export async function commitConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigCommitRequest,
@@ -2,4 +2,4 @@
import type { ConfigTreeChange } from "./ConfigTreeChange"; import type { ConfigTreeChange } from "./ConfigTreeChange";
import type { VirtualPath } from "./VirtualPath"; import type { VirtualPath } from "./VirtualPath";
export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, toolchain_fingerprint: string, }; export type ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
@@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeChange } from "./ConfigTreeChange";
import type { VirtualPath } from "./VirtualPath";
export type ConfigPreviewRequest = { changes: Array<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
@@ -1,6 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ConfigTreeSnapshot } from "./ConfigTreeSnapshot";
import type { EvaluationResult } from "./EvaluationResult";
import type { ToolchainContract } from "./ToolchainContract";
export type EvaluatedConfigCandidate = { base_revision: number, base_digest: string, snapshot: ConfigTreeSnapshot, contract: ToolchainContract, evaluation: EvaluationResult, };
@@ -2,7 +2,6 @@ import type {
ConfigDiagnostic, ConfigDiagnostic,
ConfigTreeChange, ConfigTreeChange,
ConfigTreeSnapshot, ConfigTreeSnapshot,
ToolchainContract,
WorkspaceConfigSchemaBundle, WorkspaceConfigSchemaBundle,
} from "./types.ts"; } from "./types.ts";
import { jsonWorkerMessage } from "./toolchain-message.ts"; import { jsonWorkerMessage } from "./toolchain-message.ts";
@@ -20,7 +19,6 @@ type Command =
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "changes_between" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "changes_between" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">; | Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">;
@@ -68,9 +66,6 @@ export class ConfigSourceToolchain {
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> { analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
return this.#request({ kind: "analyze", path, source }); return this.#request({ kind: "analyze", path, source });
} }
evaluate(contract: ToolchainContract) {
return this.#request({ kind: "evaluate", contract });
}
async complete( async complete(
path: string, path: string,
source: string, source: string,
@@ -3,7 +3,6 @@ import init, {
apply_changes, apply_changes,
changes_between, changes_between,
complete_current, complete_current,
evaluate_current,
format_source, format_source,
set_schema_bundle, set_schema_bundle,
set_snapshot, set_snapshot,
@@ -20,7 +19,6 @@ export type ConfigSourceWorkerRequest =
| { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] } | { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] }
| { id: number; kind: "changes_between"; base: unknown; candidate: unknown } | { id: number; kind: "changes_between"; base: unknown; candidate: unknown }
| { id: number; kind: "analyze"; path: string; source?: string } | { id: number; kind: "analyze"; path: string; source?: string }
| { id: number; kind: "evaluate"; contract: unknown }
| { | {
id: number; id: number;
kind: "complete"; kind: "complete";
@@ -65,9 +63,6 @@ self.onmessage = async (
} }
result = analyze_snapshot(snapshot, request.path, request.source); result = analyze_snapshot(snapshot, request.path, request.source);
break; break;
case "evaluate":
result = evaluate_current(request.contract);
break;
case "complete": case "complete":
result = complete_current( result = complete_current(
request.path, request.path,
@@ -13,6 +13,4 @@ export type { EvaluationResult } from "./generated/types/EvaluationResult.ts";
export type { ToolchainContract } from "./generated/types/ToolchainContract.ts"; export type { ToolchainContract } from "./generated/types/ToolchainContract.ts";
export type { VirtualPath } from "./generated/types/VirtualPath.ts"; export type { VirtualPath } from "./generated/types/VirtualPath.ts";
export type { ConfigCommitRequest } from "./generated/types/ConfigCommitRequest.ts"; export type { ConfigCommitRequest } from "./generated/types/ConfigCommitRequest.ts";
export type { ConfigPreviewRequest } from "./generated/types/ConfigPreviewRequest.ts";
export type { EvaluatedConfigCandidate } from "./generated/types/EvaluatedConfigCandidate.ts";
export type { WorkspaceConfigState as WorkspaceConfigTreeResponse } from "./generated/types/WorkspaceConfigState.ts"; export type { WorkspaceConfigState as WorkspaceConfigTreeResponse } from "./generated/types/WorkspaceConfigState.ts";
+9 -9
View File
@@ -6,7 +6,6 @@ import {
fetchConfigEntry, fetchConfigEntry,
fetchConfigRevision, fetchConfigRevision,
fetchConfigTree, fetchConfigTree,
previewConfigTree,
} from "../../src/lib/workspace/config-source/api.ts"; } from "../../src/lib/workspace/config-source/api.ts";
function response(body: unknown, status = 200): Response { function response(body: unknown, status = 200): Response {
@@ -16,7 +15,7 @@ function response(body: unknown, status = 200): Response {
}); });
} }
Deno.test("config source API stays workspace-scoped and separates preview from commit", async () => { Deno.test("config source API commits directly through the workspace scope", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = []; const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => { const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(input), init }); calls.push({ url: String(input), init });
@@ -26,37 +25,38 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
await fetchConfigTree("w/one", fetcher); await fetchConfigTree("w/one", fetcher);
await fetchConfigRevision("w/one", 7, fetcher); await fetchConfigRevision("w/one", 7, fetcher);
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher); await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher);
await commitConfigTree("w/one", { await commitConfigTree("w/one", {
base_revision: 4, base_revision: 4,
base_digest: "sha256:base", base_digest: "sha256:base",
changes: [], changes: [],
entrypoints: [], entrypoints: [],
toolchain_fingerprint: "sha256:toolchain",
}, fetcher); }, fetcher);
assertEquals(calls.map((call) => call.url), [ assertEquals(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/source-tree",
"/api/w/w%2Fone/config/source-tree/revisions/7", "/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/entries/profiles%2Fmain.dcdl",
"/api/w/w%2Fone/config/source-tree/preview",
"/api/w/w%2Fone/config/source-tree/commit", "/api/w/w%2Fone/config/source-tree/commit",
]); ]);
assertEquals(calls[3].init?.method, "POST"); assertEquals(calls[3].init?.method, "POST");
assertEquals(calls[4].init?.method, "POST");
assert( assert(
String(calls[4].init?.body).includes('"base_digest":"sha256:base"'), String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
); );
}); });
Deno.test("config source API surfaces failed evaluation instead of treating it as a draft write", async () => { Deno.test("config source API surfaces failed evaluation instead of treating it as a successful commit", async () => {
const fetcher = (() => const fetcher = (() =>
Promise.resolve( Promise.resolve(
new Response("structured diagnostics", { status: 422 }), new Response("structured diagnostics", { status: 422 }),
)) as typeof fetch; )) as typeof fetch;
let message = ""; let message = "";
try { try {
await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher); await commitConfigTree("w", {
base_revision: 1,
base_digest: "sha256:base",
changes: [],
entrypoints: [],
}, fetcher);
} catch (error) { } catch (error) {
message = String(error); message = String(error);
} }
@@ -99,7 +99,7 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () =>
); );
}); });
Deno.test("preview and commit format every draft Decodal source", async () => { Deno.test("commit formats every working Decodal source without a preview roundtrip", async () => {
const source = await Deno.readTextFile( const source = await Deno.readTextFile(
new URL( new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte", "../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
@@ -108,7 +108,7 @@ Deno.test("preview and commit format every draft Decodal source", async () => {
); );
assert( assert(
source.includes("async function formatDraftSources()") && source.includes("async function formatWorkingSources()") &&
source.includes('change.kind === "create" || change.kind === "update"') && source.includes('change.kind === "create" || change.kind === "update"') &&
source.includes('change.kind === "rename"') && source.includes('change.kind === "rename"') &&
source.includes('entry.content_type !== "decodal"') && source.includes('entry.content_type !== "decodal"') &&
@@ -116,15 +116,39 @@ Deno.test("preview and commit format every draft Decodal source", async () => {
"all changed Decodal entries should be formatted rather than only the selected source", "all changed Decodal entries should be formatted rather than only the selected source",
); );
assert( assert(
source.includes("await formatDraftSources();") && source.includes("await formatWorkingSources();") &&
source.includes("await requestCandidatePreview();") && source.includes("entrypoints: entrypoints()") &&
source.includes("Committed formatted revision"), source.includes("Committed formatted revision"),
"preview and commit should format first and refresh stale preflight before persistence", "commit should format first and send the working changes directly to Backend authority",
); );
assert( assert(
!source.includes( !source.includes("previewConfigTree") &&
"Preview the complete candidate successfully before Commit.", !source.includes("requestCandidatePreview") &&
!source.includes("toolchain_fingerprint"),
"normal commit must not depend on a separate preview or client-echoed toolchain fingerprint",
);
});
Deno.test("config diagnostics analyze continuously with debounce and generation fencing", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
), ),
"format-driven candidate changes should trigger automatic preflight instead of a dead-end error", );
assert(
source.includes("setTimeout(() =>") &&
source.includes("}, 250)") &&
source.includes("analyzer.analyze(path, value)") &&
source.includes("generation === analysisGeneration") &&
source.includes("analysisReady"),
"source changes should trigger only the latest debounced analysis after snapshot initialization",
);
assert(
!source.includes("onclick={analyze}") &&
!source.includes(">Analyze</button>") &&
!source.includes(">Preview</button>"),
"manual Analyze and Preview controls should be removed",
); );
}); });