config: commit without preview
This commit is contained in:
@@ -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![
|
¤t,
|
||||||
|
vec![
|
||||||
update_main(¤t, "{}"),
|
update_main(¤t, "{}"),
|
||||||
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(¤t, 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(¤t, 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(¤t, "{ 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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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>, };
|
|
||||||
-6
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user