diff --git a/Cargo.lock b/Cargo.lock index 672ddbc4..de317867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,6 +589,31 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "config-source" +version = "0.1.0" +dependencies = [ + "decodal", + "decodal-language-service", + "decodal-language-tools", + "pretty_assertions", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "ts-rs", +] + +[[package]] +name = "config-source-wasm" +version = "0.1.0" +dependencies = [ + "config-source", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -961,9 +986,29 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "decodal" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4291c87ce887fafc0acf9f40f4bc17e111457e9d62f1b1530113be6b7a7f1a21" +checksum = "2b6e47d6bc66cd3cd42c8df8ff77a994a7743e889045c6b762a6dbc360ad8494" + +[[package]] +name = "decodal-language-service" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25e462dce7c86743bd229ba91b831daf7928d524de9cef4ef861257ca156aa8" +dependencies = [ + "decodal", +] + +[[package]] +name = "decodal-language-tools" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d8e3eb978cb1c2259df838ba2a8102bc45553d7b415216c80fc5b6a3f378c6" +dependencies = [ + "decodal", + "serde_json", + "wasm-bindgen", +] [[package]] name = "deltae" @@ -3748,6 +3793,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_cbor_2" version = "0.13.0" @@ -6172,6 +6228,7 @@ dependencies = [ "async-trait", "axum", "chrono", + "config-source", "flow", "futures", "manifest", diff --git a/Cargo.toml b/Cargo.toml index e8f55dcd..78bd14e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ members = [ "crates/tools", "crates/fs-operation", "crates/flow", + "crates/config-source", + "crates/config-source-wasm", "crates/workdir", "crates/tui", "crates/memory", @@ -47,6 +49,8 @@ default-members = [ "crates/tools", "crates/fs-operation", "crates/flow", + "crates/config-source", + "crates/config-source-wasm", "crates/workdir", "crates/tui", "crates/memory", @@ -82,6 +86,7 @@ session-analytics = { path = "crates/session-analytics" } session-store = { path = "crates/session-store" } secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } +config-source = { path = "crates/config-source" } fs-operation = { path = "crates/fs-operation" } workdir = { path = "crates/workdir" } tui = { path = "crates/tui" } @@ -93,7 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" } async-trait = "0.1" axum = "0.8" base64 = "0.22.1" -decodal = "0.1.1" +decodal = "0.2.0" +decodal-language-service = "0.2.0" +decodal-language-tools = "0.2.0" fs4 = "0.13" futures = "0.3" libc = "0.2" diff --git a/crates/config-source-wasm/Cargo.toml b/crates/config-source-wasm/Cargo.toml new file mode 100644 index 00000000..eebee1eb --- /dev/null +++ b/crates/config-source-wasm/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "config-source-wasm" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +config-source.workspace = true +serde.workspace = true +serde-wasm-bindgen = "0.6.5" +wasm-bindgen = "0.2.105" diff --git a/crates/config-source-wasm/src/lib.rs b/crates/config-source-wasm/src/lib.rs new file mode 100644 index 00000000..5b992f64 --- /dev/null +++ b/crates/config-source-wasm/src/lib.rs @@ -0,0 +1,172 @@ +use config_source::{ + ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract, + VirtualPath, +}; +use serde_wasm_bindgen::{Serializer, from_value}; +use std::cell::RefCell; +use wasm_bindgen::prelude::*; + +thread_local! { + static SESSION: RefCell> = const { RefCell::new(None) }; +} + +#[wasm_bindgen] +pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> { + let snapshot: ConfigTreeSnapshot = decode(snapshot)?; + SESSION.with(|session| session.replace(Some(snapshot))); + Ok(()) +} + +#[wasm_bindgen] +pub fn apply_changes(changes: JsValue) -> Result { + let changes: Vec = decode(changes)?; + SESSION.with(|session| { + let mut session = session.borrow_mut(); + let snapshot = session + .as_ref() + .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))? + .apply(&changes) + .map_err(js_error)?; + *session = Some(snapshot.clone()); + encode(snapshot) + }) +} + +#[wasm_bindgen] +pub fn changes_between(base: JsValue, candidate: JsValue) -> Result { + let base: ConfigTreeSnapshot = decode(base)?; + let candidate: ConfigTreeSnapshot = decode(candidate)?; + encode(base.changes_to(&candidate)) +} + +#[wasm_bindgen] +pub fn evaluate_current(contract: JsValue) -> Result { + let contract: ToolchainContract = decode(contract)?; + SESSION.with(|session| { + let session = session.borrow(); + let snapshot = session + .as_ref() + .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; + encode( + SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + encode(&diagnostics).unwrap_or_else(|_| JsValue::from_str("evaluation failed")) + })?, + ) + }) +} + +#[derive(serde::Serialize)] +struct WasmCompletionResult { + from: usize, + items: Vec, +} + +#[derive(serde::Serialize)] +struct WasmCompletionItem { + label: String, + kind: String, + detail: Option, + priority: i32, +} + +#[wasm_bindgen] +pub fn complete_current( + entrypoint: String, + source: String, + utf16_offset: usize, + explicit: bool, +) -> Result { + let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?; + SESSION.with(|session| { + let session = session.borrow(); + let snapshot = session + .as_ref() + .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; + let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?; + let result = SnapshotEnvironment::new(snapshot.clone()) + .complete(&entrypoint, &source, utf8_byte_offset, explicit) + .map_err(|error| JsValue::from_str(&format!("{error:?}")))? + .map(|result| WasmCompletionResult { + from: result.from, + items: result + .items + .into_iter() + .map(|item| WasmCompletionItem { + label: item.label, + kind: format!("{:?}", item.kind).to_lowercase(), + detail: item.detail, + priority: item.priority, + }) + .collect(), + }); + encode(result) + }) +} + +#[wasm_bindgen] +pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result { + let snapshot: ConfigTreeSnapshot = decode(snapshot)?; + let contract: ToolchainContract = decode(contract)?; + encode( + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + encode(&diagnostics).unwrap_or_else(|_| JsValue::from_str("evaluation failed")) + })?, + ) +} + +#[wasm_bindgen] +pub fn analyze_snapshot( + snapshot: JsValue, + entrypoint: String, + source_override: Option, +) -> Result { + let snapshot: ConfigTreeSnapshot = decode(snapshot)?; + let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?; + encode(SnapshotEnvironment::new(snapshot).analyze(&entrypoint, source_override.as_deref())) +} + +#[wasm_bindgen] +pub fn format_source(source: String) -> Result { + SnapshotEnvironment::new(ConfigTreeSnapshot::empty()) + .format(&source) + .map_err(|error| JsValue::from_str(&error)) +} + +fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result { + let mut units = 0usize; + for (byte_offset, character) in source.char_indices() { + if units == utf16_offset { + return Ok(byte_offset); + } + units += character.len_utf16(); + if units > utf16_offset { + return Err(JsValue::from_str("UTF-16 offset splits a surrogate pair")); + } + } + if units == utf16_offset { + Ok(source.len()) + } else { + Err(JsValue::from_str("UTF-16 offset is outside the source")) + } +} + +fn decode(value: JsValue) -> Result { + from_value(value).map_err(|error| JsValue::from_str(&error.to_string())) +} + +fn encode(value: T) -> Result { + value + .serialize(&Serializer::json_compatible()) + .map_err(|error| JsValue::from_str(&error.to_string())) +} + +fn js_error(error: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&error.to_string()) +} + +#[allow(dead_code)] +fn _assert_serializable(_: EvaluationResult) {} diff --git a/crates/config-source/Cargo.toml b/crates/config-source/Cargo.toml new file mode 100644 index 00000000..d86dc5d5 --- /dev/null +++ b/crates/config-source/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "config-source" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +decodal.workspace = true +decodal-language-service.workspace = true +decodal-language-tools.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +ts-rs = "12.0.1" + +[dev-dependencies] +pretty_assertions = "1" diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs new file mode 100644 index 00000000..49c0a9f5 --- /dev/null +++ b/crates/config-source/src/lib.rs @@ -0,0 +1,1002 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use decodal::{ + Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader, + LoadedImport, Span, +}; +use decodal_language_service::{CompletionResult, LanguageService}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 1; +pub const DECODAL_VERSION: &str = "0.2.0"; +pub const DEFAULT_SCHEMA_VERSION: u32 = 1; +pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1; +pub const MAX_ENTRY_COUNT: usize = 256; +pub const MAX_CHANGE_COUNT: usize = 256; +pub const MAX_ENTRY_BYTES: usize = 256 * 1024; +pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_PATH_BYTES: usize = 512; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)] +#[serde(transparent)] +pub struct VirtualPath(String); + +impl VirtualPath { + pub fn parse(value: impl AsRef) -> Result { + let value = value.as_ref(); + if value.is_empty() { + return Err(ConfigTreeError::InvalidPath( + "path must not be empty".into(), + )); + } + if value.len() > MAX_PATH_BYTES { + return Err(ConfigTreeError::LimitExceeded("path bytes")); + } + if value.starts_with('/') + || value.contains('\\') + || value.contains('\0') + || value.contains("://") + { + return Err(ConfigTreeError::InvalidPath(value.into())); + } + let mut normalized = Vec::new(); + for component in value.split('/') { + if component.is_empty() || component == "." || component == ".." { + return Err(ConfigTreeError::InvalidPath(value.into())); + } + if component.chars().any(char::is_control) { + return Err(ConfigTreeError::InvalidPath(value.into())); + } + normalized.push(component); + } + Ok(Self(normalized.join("/"))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + fn parent_components(&self) -> Vec<&str> { + let mut components = self.0.split('/').collect::>(); + components.pop(); + components + } +} + +impl fmt::Display for VirtualPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[serde(rename_all = "snake_case")] +pub enum ConfigContentType { + Decodal, + Text, +} + +impl ConfigContentType { + pub fn media_type(self) -> &'static str { + match self { + Self::Decodal => "text/x-decodal", + Self::Text => "text/plain", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +pub struct ConfigEntry { + pub path: VirtualPath, + pub content_type: ConfigContentType, + pub content: String, + pub content_digest: String, +} + +impl ConfigEntry { + pub fn new( + path: VirtualPath, + content_type: ConfigContentType, + content: impl Into, + ) -> Result { + let content = content.into(); + if content.len() > MAX_ENTRY_BYTES { + return Err(ConfigTreeError::LimitExceeded("entry bytes")); + } + Ok(Self { + path, + content_type, + content_digest: digest_bytes(content.as_bytes()), + content, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +pub struct ConfigTreeSnapshot { + #[ts(type = "number")] + pub revision: u64, + pub digest: String, + pub entries: BTreeMap, +} + +impl ConfigTreeSnapshot { + pub fn empty() -> Self { + Self::from_entries(0, Vec::new()).expect("empty config snapshot is valid") + } + + pub fn from_entries( + revision: u64, + entries: impl IntoIterator, + ) -> Result { + let mut ordered = BTreeMap::new(); + let mut total = 0usize; + for entry in entries { + total = total + .checked_add(entry.content.len()) + .ok_or(ConfigTreeError::LimitExceeded("total bytes"))?; + if total > MAX_TOTAL_BYTES { + return Err(ConfigTreeError::LimitExceeded("total bytes")); + } + if ordered.insert(entry.path.clone(), entry).is_some() { + return Err(ConfigTreeError::DuplicatePath); + } + } + if ordered.len() > MAX_ENTRY_COUNT { + return Err(ConfigTreeError::LimitExceeded("entry count")); + } + let digest = snapshot_digest(&ordered); + Ok(Self { + revision, + digest, + entries: ordered, + }) + } + + pub fn list_prefix(&self, prefix: Option<&VirtualPath>) -> Vec<&ConfigEntry> { + self.entries + .values() + .filter(|entry| { + prefix.is_none_or(|prefix| { + entry.path == *prefix + || entry + .path + .as_str() + .strip_prefix(prefix.as_str()) + .is_some_and(|rest| rest.starts_with('/')) + }) + }) + .collect() + } + + pub fn get(&self, path: &VirtualPath) -> Option<&ConfigEntry> { + self.entries.get(path) + } + + pub fn changes_to(&self, candidate: &Self) -> Vec { + let mut changes = Vec::new(); + for (path, base_entry) in &self.entries { + match candidate.entries.get(path) { + None => changes.push(ConfigTreeChange::Delete { + path: path.clone(), + expected_digest: base_entry.content_digest.clone(), + }), + Some(candidate_entry) + if candidate_entry.content_digest != base_entry.content_digest => + { + changes.push(ConfigTreeChange::Update { + path: path.clone(), + expected_digest: base_entry.content_digest.clone(), + content: candidate_entry.content.clone(), + }); + } + Some(_) => {} + } + } + for (path, entry) in &candidate.entries { + if !self.entries.contains_key(path) { + changes.push(ConfigTreeChange::Create { + path: path.clone(), + content_type: entry.content_type, + content: entry.content.clone(), + }); + } + } + changes + } + + pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result { + if changes.len() > MAX_CHANGE_COUNT { + return Err(ConfigTreeError::LimitExceeded("change count")); + } + let mut entries = self.entries.clone(); + let mut touched = BTreeSet::new(); + for change in changes { + for path in change.paths() { + if !touched.insert(path.clone()) { + return Err(ConfigTreeError::PathChangedMoreThanOnce(path.clone())); + } + } + match change { + ConfigTreeChange::Create { + path, + content_type, + content, + } => { + if entries.contains_key(path) { + return Err(ConfigTreeError::AlreadyExists(path.clone())); + } + entries.insert( + path.clone(), + ConfigEntry::new(path.clone(), *content_type, content.clone())?, + ); + } + ConfigTreeChange::Update { + path, + expected_digest, + content, + } => { + let current = entries + .get(path) + .ok_or_else(|| ConfigTreeError::NotFound(path.clone()))?; + if ¤t.content_digest != expected_digest { + return Err(ConfigTreeError::EntryConflict(path.clone())); + } + entries.insert( + path.clone(), + ConfigEntry::new(path.clone(), current.content_type, content.clone())?, + ); + } + ConfigTreeChange::Rename { + from, + to, + expected_digest, + } => { + if entries.contains_key(to) { + return Err(ConfigTreeError::AlreadyExists(to.clone())); + } + let current = entries + .remove(from) + .ok_or_else(|| ConfigTreeError::NotFound(from.clone()))?; + if ¤t.content_digest != expected_digest { + return Err(ConfigTreeError::EntryConflict(from.clone())); + } + entries.insert( + to.clone(), + ConfigEntry::new(to.clone(), current.content_type, current.content)?, + ); + } + ConfigTreeChange::Delete { + path, + expected_digest, + } => { + let current = entries + .get(path) + .ok_or_else(|| ConfigTreeError::NotFound(path.clone()))?; + if ¤t.content_digest != expected_digest { + return Err(ConfigTreeError::EntryConflict(path.clone())); + } + entries.remove(path); + } + } + } + Self::from_entries(self.revision, entries.into_values()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConfigTreeChange { + Create { + path: VirtualPath, + content_type: ConfigContentType, + content: String, + }, + Update { + path: VirtualPath, + expected_digest: String, + content: String, + }, + Rename { + from: VirtualPath, + to: VirtualPath, + expected_digest: String, + }, + Delete { + path: VirtualPath, + expected_digest: String, + }, +} + +impl ConfigTreeChange { + fn paths(&self) -> Vec<&VirtualPath> { + match self { + Self::Create { path, .. } | Self::Update { path, .. } | Self::Delete { path, .. } => { + vec![path] + } + Self::Rename { from, to, .. } => vec![from, to], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +pub struct ToolchainContract { + pub contract_version: u32, + pub decodal_version: String, + pub schema_version: u32, + pub entrypoints: Vec, + pub import_policy_version: u32, + pub fingerprint: String, +} + +impl ToolchainContract { + pub fn new( + schema_version: u32, + mut entrypoints: Vec, + import_policy_version: u32, + ) -> Self { + entrypoints.sort(); + entrypoints.dedup(); + let mut contract = Self { + contract_version: CONFIG_SOURCE_CONTRACT_VERSION, + decodal_version: DECODAL_VERSION.to_string(), + schema_version, + entrypoints, + import_policy_version, + fingerprint: String::new(), + }; + contract.fingerprint = digest_bytes( + serde_json::to_vec(&( + contract.contract_version, + &contract.decodal_version, + contract.schema_version, + &contract.entrypoints, + contract.import_policy_version, + )) + .expect("toolchain contract serializes") + .as_slice(), + ); + contract + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +pub struct ConfigSpan { + pub start_byte: u32, + pub end_byte: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +pub struct ConfigDiagnosticLabel { + pub span: ConfigSpan, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] +pub struct ConfigDiagnostic { + pub path: VirtualPath, + #[ts(type = "number")] + pub revision: u64, + pub tree_digest: String, + pub kind: String, + pub span: ConfigSpan, + pub message: String, + pub labels: Vec, + pub notes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct EvaluatedProjection { + pub entrypoint: VirtualPath, + #[ts(type = "unknown")] + pub data_json: serde_json::Value, + pub projection_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +pub struct EvaluationResult { + pub projections: Vec, + pub projection_digest: String, +} + +#[derive(Debug, Clone)] +pub struct SnapshotEnvironment { + snapshot: ConfigTreeSnapshot, +} + +impl SnapshotEnvironment { + pub fn new(snapshot: ConfigTreeSnapshot) -> Self { + Self { snapshot } + } + + pub fn snapshot(&self) -> &ConfigTreeSnapshot { + &self.snapshot + } + + pub fn evaluate_contract( + &self, + contract: &ToolchainContract, + ) -> Result> { + let service = LanguageService::new(self); + let diagnostics = self + .snapshot + .entries + .values() + .filter(|entry| entry.content_type == ConfigContentType::Decodal) + .flat_map(|entry| { + service + .analyze(entry.path.as_str(), entry.path.as_str(), &entry.content) + .diagnostics + .iter() + .map(|diagnostic| { + project_diagnostic(&self.snapshot, entry.path.clone(), diagnostic) + }) + .collect::>() + }) + .collect::>(); + if !diagnostics.is_empty() { + return Err(diagnostics); + } + let mut projections = Vec::new(); + for entrypoint in &contract.entrypoints { + let Some(entry) = self.snapshot.get(entrypoint) else { + return Err(vec![self.config_error( + entrypoint.clone(), + "entrypoint_missing", + "configured entrypoint is missing", + )]); + }; + if entry.content_type != ConfigContentType::Decodal { + return Err(vec![self.config_error( + entrypoint.clone(), + "entrypoint_not_decodal", + "configured entrypoint is not Decodal source", + )]); + } + match service.evaluate(entrypoint.as_str(), entrypoint.as_str(), &entry.content) { + Ok(data) => { + let data_json = decodal_data_to_json(&data); + let projection_digest = digest_bytes( + serde_json::to_vec(&data_json) + .expect("Decodal projection serializes") + .as_slice(), + ); + projections.push(EvaluatedProjection { + entrypoint: entrypoint.clone(), + data_json, + projection_digest, + }); + } + Err(diagnostic) => { + return Err(vec![project_diagnostic( + &self.snapshot, + entrypoint.clone(), + &diagnostic, + )]); + } + } + } + let projection_digest = digest_bytes( + serde_json::to_vec(&projections) + .expect("projection set serializes") + .as_slice(), + ); + Ok(EvaluationResult { + projections, + projection_digest, + }) + } + + pub fn analyze( + &self, + entrypoint: &VirtualPath, + source_override: Option<&str>, + ) -> Vec { + let Some(entry) = self.snapshot.get(entrypoint) else { + return vec![self.config_error( + entrypoint.clone(), + "entrypoint_missing", + "configured entrypoint is missing", + )]; + }; + let service = LanguageService::new(self); + service + .analyze( + entrypoint.as_str(), + entrypoint.as_str(), + source_override.unwrap_or(&entry.content), + ) + .diagnostics + .iter() + .map(|diagnostic| project_diagnostic(&self.snapshot, entrypoint.clone(), diagnostic)) + .collect() + } + + pub fn complete( + &self, + entrypoint: &VirtualPath, + source: &str, + utf8_byte_offset: usize, + explicit: bool, + ) -> decodal::Result> { + LanguageService::new(self).complete(entrypoint.as_str(), source, utf8_byte_offset, explicit) + } + + pub fn format(&self, source: &str) -> Result { + decodal_language_tools::format_source(source).map_err(|error| error.to_string()) + } + + fn config_error( + &self, + path: VirtualPath, + kind: impl Into, + message: impl Into, + ) -> ConfigDiagnostic { + ConfigDiagnostic { + path, + revision: self.snapshot.revision, + tree_digest: self.snapshot.digest.clone(), + kind: kind.into(), + span: ConfigSpan { + start_byte: 0, + end_byte: 0, + }, + message: message.into(), + labels: Vec::new(), + notes: Vec::new(), + } + } +} + +impl HostEnvironment for &SnapshotEnvironment { + type Loader = SnapshotImportLoader; + + fn create_loader(&self) -> Self::Loader { + SnapshotImportLoader { + snapshot: self.snapshot.clone(), + } + } + + fn configure_engine(&self, _engine: &mut Engine) -> decodal::Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct SnapshotImportLoader { + snapshot: ConfigTreeSnapshot, +} + +impl SnapshotImportLoader { + pub fn resolve( + &self, + current_key: Option<&str>, + specifier: &str, + ) -> Result { + let current = current_key + .map(VirtualPath::parse) + .transpose()? + .ok_or_else(|| ConfigTreeError::InvalidImport(specifier.into()))?; + resolve_import(¤t, specifier) + } +} + +impl ImportLoader for SnapshotImportLoader { + fn load( + &mut self, + current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + let path = self.resolve(current_key, specifier).map_err(import_error)?; + let entry = self.snapshot.get(&path).ok_or_else(|| { + Diagnostic::new( + DiagnosticKind::Import, + Span::default(), + format!("virtual config import is missing: {path}"), + ) + })?; + Ok(LoadedImport::source( + path.as_str(), + path.as_str(), + entry.content.clone(), + )) + } + + fn complete_import( + &mut self, + current_key: Option<&str>, + prefix: &str, + ) -> decodal::Result> { + let current = current_key + .map(VirtualPath::parse) + .transpose() + .map_err(import_error)?; + Ok(import_completions(&self.snapshot, current.as_ref(), prefix) + .into_iter() + .map(|specifier| ImportCandidate::new(specifier).with_detail("virtual config source")) + .collect()) + } +} + +pub fn resolve_import( + current: &VirtualPath, + specifier: &str, +) -> Result { + if specifier.is_empty() + || specifier.starts_with('/') + || specifier.contains('\\') + || specifier.contains('\0') + || specifier.contains("://") + || specifier.contains(':') + { + return Err(ConfigTreeError::InvalidImport(specifier.into())); + } + let mut components = if specifier.starts_with("./") || specifier.starts_with("../") { + current.parent_components() + } else { + Vec::new() + }; + for component in specifier.split('/') { + match component { + "" | "." => {} + ".." => { + components + .pop() + .ok_or_else(|| ConfigTreeError::ImportEscape(specifier.into()))?; + } + value => components.push(value), + } + } + VirtualPath::parse(components.join("/")) +} + +pub fn import_completions( + snapshot: &ConfigTreeSnapshot, + current: Option<&VirtualPath>, + prefix: &str, +) -> Vec { + let mut candidates = BTreeSet::new(); + for path in snapshot.entries.keys() { + if current == Some(path) { + continue; + } + let absolute = path.as_str().to_string(); + if absolute.starts_with(prefix) { + candidates.insert(absolute); + } + if let Some(current) = current { + let current_parent = current.parent_components(); + let target = path.as_str().split('/').collect::>(); + let mut common = 0usize; + while common < current_parent.len() + && common < target.len() + && current_parent[common] == target[common] + { + common += 1; + } + let mut relative = vec![".."; current_parent.len().saturating_sub(common)]; + relative.extend_from_slice(&target[common..]); + let specifier = if relative.first().is_some_and(|item| *item == "..") { + relative.join("/") + } else { + format!("./{}", relative.join("/")) + }; + if specifier.starts_with(prefix) { + candidates.insert(specifier); + } + } + } + candidates.into_iter().collect() +} + +fn project_diagnostic( + snapshot: &ConfigTreeSnapshot, + fallback_path: VirtualPath, + diagnostic: &Diagnostic, +) -> ConfigDiagnostic { + ConfigDiagnostic { + path: fallback_path, + revision: snapshot.revision, + tree_digest: snapshot.digest.clone(), + kind: diagnostic_kind(diagnostic.kind).to_string(), + span: ConfigSpan { + start_byte: diagnostic.span.start, + end_byte: diagnostic.span.end, + }, + message: diagnostic.message.clone(), + labels: diagnostic + .labels + .iter() + .map(|label| ConfigDiagnosticLabel { + span: ConfigSpan { + start_byte: label.span.start, + end_byte: label.span.end, + }, + message: label.message.clone(), + }) + .collect(), + notes: diagnostic.notes.clone(), + } +} + +fn diagnostic_kind(kind: DiagnosticKind) -> &'static str { + match kind { + DiagnosticKind::Syntax => "syntax", + DiagnosticKind::UnresolvedIdentifier => "unresolved_identifier", + DiagnosticKind::TypeMismatch => "type_mismatch", + DiagnosticKind::ConstraintViolation => "constraint_violation", + DiagnosticKind::Conflict => "conflict", + DiagnosticKind::DefaultConflict => "default_conflict", + DiagnosticKind::Cycle => "cycle", + DiagnosticKind::Import => "import", + DiagnosticKind::MatchFailure => "match_failure", + DiagnosticKind::Materialize => "materialize", + DiagnosticKind::UnsupportedFeature => "unsupported_feature", + } +} + +fn import_error(error: ConfigTreeError) -> Diagnostic { + Diagnostic::new(DiagnosticKind::Import, Span::default(), error.to_string()) +} + +fn decodal_data_to_json(data: &Data) -> serde_json::Value { + match data { + Data::Bool(value) => serde_json::Value::Bool(*value), + Data::Int(value) => serde_json::Value::Number((*value).into()), + Data::Float(value) => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Data::String(value) => serde_json::Value::String(value.clone()), + Data::Array(values) => { + serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect()) + } + Data::Object(fields) => serde_json::Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), decodal_data_to_json(&field.value))) + .collect(), + ), + } +} + +fn snapshot_digest(entries: &BTreeMap) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"yoi-config-tree-v1\0"); + for (path, entry) in entries { + hasher.update(path.as_str().as_bytes()); + hasher.update([0]); + hasher.update(entry.content_type.media_type().as_bytes()); + hasher.update([0]); + hasher.update(entry.content.as_bytes()); + hasher.update([0]); + } + format_digest(hasher.finalize().as_slice()) +} + +pub fn digest_bytes(bytes: &[u8]) -> String { + format_digest(Sha256::digest(bytes).as_slice()) +} + +fn format_digest(bytes: &[u8]) -> String { + let mut output = String::from("sha256:"); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum ConfigTreeError { + #[error("invalid virtual config path: {0}")] + InvalidPath(String), + #[error("invalid virtual config import: {0}")] + InvalidImport(String), + #[error("virtual config import escapes the tree: {0}")] + ImportEscape(String), + #[error("virtual config path already exists: {0}")] + AlreadyExists(VirtualPath), + #[error("virtual config path was not found: {0}")] + NotFound(VirtualPath), + #[error("virtual config entry changed: {0}")] + EntryConflict(VirtualPath), + #[error("virtual config path changed more than once in one candidate: {0}")] + PathChangedMoreThanOnce(VirtualPath), + #[error("duplicate virtual config path")] + DuplicatePath, + #[error("virtual config limit exceeded: {0}")] + LimitExceeded(&'static str), +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ts_rs::TS; + + #[test] + fn exports_typescript_contract() { + let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/workspace/config-source/generated/types"); + std::fs::create_dir_all(&output).unwrap(); + macro_rules! export { + ($type:ty) => { + <$type>::export_all(&ts_rs::Config::default().with_out_dir(&output)).unwrap(); + }; + } + export!(VirtualPath); + export!(ConfigContentType); + export!(ConfigEntry); + export!(ConfigTreeSnapshot); + export!(ConfigTreeChange); + export!(ToolchainContract); + export!(ConfigSpan); + export!(ConfigDiagnosticLabel); + export!(ConfigDiagnostic); + export!(EvaluatedProjection); + export!(EvaluationResult); + } + + fn path(value: &str) -> VirtualPath { + VirtualPath::parse(value).unwrap() + } + + fn entry(path_value: &str, content: &str) -> ConfigEntry { + ConfigEntry::new(path(path_value), ConfigContentType::Decodal, content).unwrap() + } + + #[test] + fn virtual_paths_reject_ambiguous_or_escaping_forms() { + for invalid in ["", "/root.dcdl", "a//b", "a/./b", "a/../b", "a\\b", "a\0b"] { + assert!(VirtualPath::parse(invalid).is_err(), "{invalid:?}"); + } + assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl"); + } + + #[test] + fn candidate_changes_are_atomic_ordered_and_conflict_checked() { + let base = ConfigTreeSnapshot::from_entries( + 7, + [ + entry("profiles/a.dcdl", "{ a = 1; }"), + entry("shared.dcdl", "{}"), + ], + ) + .unwrap(); + let updated = base + .apply(&[ + ConfigTreeChange::Update { + path: path("profiles/a.dcdl"), + expected_digest: base.entries[&path("profiles/a.dcdl")] + .content_digest + .clone(), + content: "{ a = 2; }".into(), + }, + ConfigTreeChange::Rename { + from: path("shared.dcdl"), + to: path("lib/shared.dcdl"), + expected_digest: base.entries[&path("shared.dcdl")].content_digest.clone(), + }, + ]) + .unwrap(); + assert_eq!( + updated + .entries + .keys() + .map(VirtualPath::as_str) + .collect::>(), + ["lib/shared.dcdl", "profiles/a.dcdl"] + ); + assert_ne!(updated.digest, base.digest); + assert!(matches!( + base.apply(&[ConfigTreeChange::Delete { + path: path("shared.dcdl"), + expected_digest: "sha256:stale".into(), + }]), + Err(ConfigTreeError::EntryConflict(_)) + )); + } + + #[test] + fn snapshot_digest_is_deterministic() { + let left = ConfigTreeSnapshot::from_entries( + 1, + [entry("z.dcdl", "{}"), entry("a.dcdl", "{ x = 1; }")], + ) + .unwrap(); + let right = ConfigTreeSnapshot::from_entries( + 99, + [entry("a.dcdl", "{ x = 1; }"), entry("z.dcdl", "{}")], + ) + .unwrap(); + assert_eq!(left.digest, right.digest); + } + + #[test] + fn relative_imports_and_completion_share_the_snapshot_namespace() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ + entry("profiles/main.dcdl", r#"import "../shared/value.dcdl""#), + entry("shared/value.dcdl", "{ answer = 42; }"), + entry("other.dcdl", "{}"), + ], + ) + .unwrap(); + assert_eq!( + resolve_import(&path("profiles/main.dcdl"), "../shared/value.dcdl").unwrap(), + path("shared/value.dcdl") + ); + assert!(resolve_import(&path("main.dcdl"), "../escape.dcdl").is_err()); + assert_eq!( + import_completions(&snapshot, Some(&path("profiles/main.dcdl")), "../sh"), + ["../shared/value.dcdl"] + ); + } + + #[test] + fn host_environment_evaluation_uses_only_snapshot_imports() { + let snapshot = ConfigTreeSnapshot::from_entries( + 3, + [ + entry("profiles/main.dcdl", r#"import "./shared.dcdl""#), + entry("profiles/shared.dcdl", "{ answer = 42; }"), + ], + ) + .unwrap(); + let contract = ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path("profiles/main.dcdl")], + DEFAULT_IMPORT_POLICY_VERSION, + ); + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap(); + assert_eq!(result.projections[0].data_json["answer"], 42); + } + + #[test] + fn candidate_evaluation_rejects_invalid_unreferenced_decodal_source() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ + entry("workspace.dcdl", "{ answer = 42; }"), + entry("unused.dcdl", "{ broken = ; }"), + ], + ) + .unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![path("workspace.dcdl")], 1)) + .unwrap_err(); + assert_eq!(diagnostics[0].path, path("unused.dcdl")); + assert_eq!(diagnostics[0].kind, "syntax"); + } + + #[test] + fn missing_import_and_cycles_are_structured_failures() { + let missing = + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", r#"import "./missing.dcdl""#)]) + .unwrap(); + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + let diagnostics = SnapshotEnvironment::new(missing) + .evaluate_contract(&contract) + .unwrap_err(); + assert_eq!(diagnostics[0].kind, "import"); + + let cycle = ConfigTreeSnapshot::from_entries( + 1, + [ + entry("a.dcdl", r#"import "./b.dcdl""#), + entry("b.dcdl", r#"import "./a.dcdl""#), + ], + ) + .unwrap(); + let diagnostics = SnapshotEnvironment::new(cycle) + .evaluate_contract(&ToolchainContract::new(1, vec![path("a.dcdl")], 1)) + .unwrap_err(); + assert_eq!(diagnostics[0].kind, "cycle"); + } +} diff --git a/crates/flow/src/definition.rs b/crates/flow/src/definition.rs index 28dd1599..8849e7b8 100644 --- a/crates/flow/src/definition.rs +++ b/crates/flow/src/definition.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use std::fmt::Write as _; -use decodal::{Engine, LoadedSource, SourceLoader}; +use decodal::{Engine, ImportLoader, LoadedImport}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -527,12 +527,12 @@ fn content_digest(content: &str) -> String { struct RejectImports; -impl SourceLoader for RejectImports { +impl ImportLoader for RejectImports { fn load( &mut self, _current_key: Option<&str>, specifier: &str, - ) -> decodal::Result { + ) -> decodal::Result { Err(decodal::Diagnostic::new( decodal::DiagnosticKind::Import, decodal::Span::default(), diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index a610fe20..b8362347 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -1,4 +1,4 @@ -use decodal::{Engine, LoadedSource, SourceLoader}; +use decodal::{Engine, ImportLoader, LoadedImport}; use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -424,12 +424,12 @@ impl<'a> ArchiveSourceLoader<'a> { } } -impl SourceLoader for ArchiveSourceLoader<'_> { +impl ImportLoader for ArchiveSourceLoader<'_> { fn load( &mut self, current_key: Option<&str>, specifier: &str, - ) -> decodal::Result { + ) -> decodal::Result { let path = archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier) .map_err(import_diagnostic)?; @@ -453,11 +453,7 @@ impl SourceLoader for ArchiveSourceLoader<'_> { format!("archive source missing: {path}"), ) })?; - Ok(LoadedSource { - key: path.clone(), - name: path.clone(), - source: source.clone(), - }) + Ok(LoadedImport::source(path.clone(), path, source.clone())) } } @@ -746,7 +742,12 @@ mod tests { let loaded = loader .load(Some("profiles/main.dcdl"), "./shared.dcdl") .unwrap(); - assert_eq!(loaded.key, "profiles/shared.dcdl"); + match loaded { + LoadedImport::Source(source) => { + assert_eq!(source.key, "profiles/shared.dcdl"); + } + LoadedImport::Value(_) => panic!("expected source import"), + } } #[test] diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 4f7f7e4f..b2008cfb 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -19,6 +19,7 @@ async-trait.workspace = true axum = { workspace = true, features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } futures.workspace = true +config-source.workspace = true flow = { path = "../flow" } manifest.workspace = true protocol = { workspace = true } diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs new file mode 100644 index 00000000..c469de6c --- /dev/null +++ b/crates/workspace-server/src/config_source.rs @@ -0,0 +1,619 @@ +use chrono::{SecondsFormat, Utc}; +use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeChange, ConfigTreeSnapshot, DECODAL_VERSION, + DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult, SnapshotEnvironment, + ToolchainContract, VirtualPath, +}; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result, SqliteWorkspaceStore}; + +pub const DEFAULT_CONFIG_ENTRYPOINT: &str = "workspace.dcdl"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct WorkspaceConfigState { + pub snapshot: ConfigTreeSnapshot, + pub contract: ToolchainContract, + pub projection_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct EvaluatedConfigCandidate { + #[ts(type = "number")] + pub base_revision: u64, + pub base_digest: String, + pub snapshot: ConfigTreeSnapshot, + pub contract: ToolchainContract, + pub evaluation: EvaluationResult, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct ConfigCommitRequest { + #[ts(type = "number")] + pub base_revision: u64, + pub base_digest: String, + pub changes: Vec, + pub entrypoints: Vec, + pub toolchain_fingerprint: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct ConfigPreviewRequest { + pub changes: Vec, + pub entrypoints: Vec, +} + +impl SqliteWorkspaceStore { + pub fn load_workspace_config( + &self, + workspace_id: &str, + ) -> Result> { + self.with_conn(|conn| load_state(conn, workspace_id)) + } + + pub fn load_workspace_config_revision( + &self, + workspace_id: &str, + revision: u64, + ) -> Result> { + self.with_conn(|conn| { + let manifest = conn + .query_row( + "SELECT tree_digest, manifest_json FROM workspace_config_tree_revisions WHERE workspace_id = ?1 AND revision = ?2", + params![workspace_id, revision as i64], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let Some((stored_digest, manifest_json)) = manifest else { + return Ok(None); + }; + let entries: std::collections::BTreeMap = + serde_json::from_str(&manifest_json) + .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; + let snapshot = ConfigTreeSnapshot::from_entries(revision, entries.into_values()) + .map_err(config_error)?; + if snapshot.digest != stored_digest { + return Err(Error::RegistryInconsistency(format!( + "virtual config revision digest mismatch for Workspace {workspace_id} revision {revision}" + ))); + } + Ok(Some(snapshot)) + }) + } + + pub fn evaluate_workspace_config_candidate( + &self, + workspace_id: &str, + request: &ConfigCommitRequest, + ) -> Result { + let current = self + .load_workspace_config(workspace_id)? + .unwrap_or_else(empty_state); + if current.snapshot.revision != request.base_revision + || current.snapshot.digest != request.base_digest + { + return Err(config_conflict(format!( + "base revision/digest mismatch; current revision is {}", + current.snapshot.revision + ))); + } + let expected_contract = ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + request.entrypoints.clone(), + DEFAULT_IMPORT_POLICY_VERSION, + ); + 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, request.entrypoints.clone()) + } + + pub fn preview_workspace_config( + &self, + workspace_id: &str, + request: &ConfigPreviewRequest, + ) -> Result { + let current = self + .load_workspace_config(workspace_id)? + .unwrap_or_else(empty_state); + evaluate_candidate(current, &request.changes, request.entrypoints.clone()) + } + + pub fn commit_evaluated_workspace_config( + &self, + workspace_id: &str, + candidate: &EvaluatedConfigCandidate, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let workspace_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", + [workspace_id], + |row| row.get(0), + )?; + if !workspace_exists { + return Err(Error::WorkspaceIdMismatch); + } + let current = load_state(&tx, workspace_id)?.unwrap_or_else(empty_state); + if current.snapshot.revision != candidate.base_revision + || current.snapshot.digest != candidate.base_digest + { + return Err(config_conflict(format!( + "base revision/digest mismatch; current revision is {}", + current.snapshot.revision + ))); + } + let next_revision = current.snapshot.revision + 1; + let mut snapshot = candidate.snapshot.clone(); + snapshot.revision = next_revision; + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + tx.execute( + r#"INSERT INTO workspace_config_trees ( + workspace_id, revision, tree_digest, schema_version, entrypoints_json, + decodal_version, import_policy_version, toolchain_fingerprint, + projection_digest, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(workspace_id) DO UPDATE SET + revision = excluded.revision, + tree_digest = excluded.tree_digest, + schema_version = excluded.schema_version, + decodal_version = excluded.decodal_version, + import_policy_version = excluded.import_policy_version, + toolchain_fingerprint = excluded.toolchain_fingerprint, + projection_digest = excluded.projection_digest, + updated_at = excluded.updated_at"#, + params![ + workspace_id, + next_revision as i64, + snapshot.digest, + candidate.contract.schema_version, + serde_json::to_string(&candidate.contract.entrypoints) + .map_err(|error| Error::Store(error.to_string()))?, + candidate.contract.decodal_version, + candidate.contract.import_policy_version, + candidate.contract.fingerprint, + candidate.evaluation.projection_digest, + now, + ], + )?; + tx.execute( + "DELETE FROM workspace_config_entries WHERE workspace_id = ?1", + [workspace_id], + )?; + for entry in snapshot.entries.values() { + tx.execute( + r#"INSERT INTO workspace_config_entries ( + workspace_id, path, content_type, content, content_digest + ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![ + workspace_id, + entry.path.as_str(), + content_type_label(entry.content_type), + entry.content, + entry.content_digest, + ], + )?; + } + let manifest_json = serde_json::to_string(&snapshot.entries) + .map_err(|error| Error::Store(error.to_string()))?; + tx.execute( + r#"INSERT INTO workspace_config_tree_revisions ( + workspace_id, revision, tree_digest, toolchain_fingerprint, + projection_digest, manifest_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, + params![ + workspace_id, + next_revision as i64, + snapshot.digest, + candidate.contract.fingerprint, + candidate.evaluation.projection_digest, + manifest_json, + now, + ], + )?; + tx.commit()?; + Ok(WorkspaceConfigState { + snapshot, + contract: candidate.contract.clone(), + projection_digest: candidate.evaluation.projection_digest.clone(), + }) + }) + } + + pub fn evaluate_and_commit_workspace_config( + &self, + workspace_id: &str, + request: &ConfigCommitRequest, + ) -> Result { + let candidate = self.evaluate_workspace_config_candidate(workspace_id, request)?; + self.commit_evaluated_workspace_config(workspace_id, &candidate) + } +} + +fn evaluate_candidate( + current: WorkspaceConfigState, + changes: &[ConfigTreeChange], + entrypoints: Vec, +) -> Result { + let snapshot = current.snapshot.apply(changes).map_err(config_error)?; + let contract = ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + entrypoints, + DEFAULT_IMPORT_POLICY_VERSION, + ); + let evaluation = SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + Error::InvalidInput( + serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "virtual config evaluation failed".to_string()), + ) + })?; + Ok(EvaluatedConfigCandidate { + base_revision: current.snapshot.revision, + base_digest: current.snapshot.digest, + snapshot, + contract, + evaluation, + }) +} + +fn load_state( + conn: &rusqlite::Connection, + workspace_id: &str, +) -> Result> { + let header = conn + .query_row( + r#"SELECT revision, tree_digest, schema_version, entrypoints_json, + decodal_version, import_policy_version, toolchain_fingerprint, projection_digest + FROM workspace_config_trees WHERE workspace_id = ?1"#, + [workspace_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, u32>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + )) + }, + ) + .optional()?; + let Some(( + revision, + stored_digest, + schema_version, + entrypoints_json, + decodal_version, + import_policy_version, + fingerprint, + projection_digest, + )) = header + else { + return Ok(None); + }; + let mut statement = conn.prepare( + r#"SELECT path, content_type, content, content_digest + FROM workspace_config_entries WHERE workspace_id = ?1 ORDER BY path"#, + )?; + let entries = statement + .query_map([workspace_id], |row| { + let path = row.get::<_, String>(0)?; + let content_type = row.get::<_, String>(1)?; + let content = row.get::<_, String>(2)?; + let stored_entry_digest = row.get::<_, String>(3)?; + Ok((path, content_type, content, stored_entry_digest)) + })? + .collect::, _>>()? + .into_iter() + .map(|(path, content_type, content, stored_entry_digest)| { + let path = VirtualPath::parse(path).map_err(config_error)?; + let entry = ConfigEntry::new(path, parse_content_type(&content_type)?, content) + .map_err(config_error)?; + if entry.content_digest != stored_entry_digest { + return Err(Error::RegistryInconsistency(format!( + "virtual config entry digest mismatch for {}", + entry.path + ))); + } + Ok(entry) + }) + .collect::>>()?; + let snapshot = + ConfigTreeSnapshot::from_entries(revision as u64, entries).map_err(config_error)?; + if snapshot.digest != stored_digest { + return Err(Error::RegistryInconsistency(format!( + "virtual config tree digest mismatch for Workspace {workspace_id}" + ))); + } + let entrypoints: Vec = serde_json::from_str(&entrypoints_json) + .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; + let contract = ToolchainContract::new(schema_version, entrypoints, import_policy_version); + if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint { + return Err(Error::RegistryInconsistency(format!( + "virtual config toolchain metadata mismatch for Workspace {workspace_id}" + ))); + } + Ok(Some(WorkspaceConfigState { + snapshot, + contract, + projection_digest, + })) +} + +fn empty_state() -> WorkspaceConfigState { + WorkspaceConfigState { + snapshot: ConfigTreeSnapshot::empty(), + contract: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + Vec::new(), + DEFAULT_IMPORT_POLICY_VERSION, + ), + projection_digest: config_source::digest_bytes(b"[]"), + } +} + +fn content_type_label(value: ConfigContentType) -> &'static str { + match value { + ConfigContentType::Decodal => "decodal", + ConfigContentType::Text => "text", + } +} + +fn parse_content_type(value: &str) -> Result { + match value { + "decodal" => Ok(ConfigContentType::Decodal), + "text" => Ok(ConfigContentType::Text), + _ => Err(Error::RegistryInconsistency(format!( + "unknown virtual config content type {value:?}" + ))), + } +} + +fn config_error(error: impl std::fmt::Display) -> Error { + Error::InvalidInput(error.to_string()) +} + +fn config_conflict(message: impl Into) -> Error { + Error::WorkspaceConfigConflict(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ControlPlaneStore, WorkspaceRecord}; + + fn workspace() -> WorkspaceRecord { + WorkspaceRecord { + workspace_id: "w-config".into(), + owner_account_id: None, + display_name: "Config".into(), + state: "active".into(), + created_at: "2026-08-13T00:00:00Z".into(), + updated_at: "2026-08-13T00:00:00Z".into(), + } + } + + fn path(value: &str) -> VirtualPath { + VirtualPath::parse(value).unwrap() + } + + #[tokio::test] + async fn invalid_candidate_is_never_persisted() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store.upsert_workspace(&workspace()).await.unwrap(); + let current = ConfigTreeSnapshot::empty(); + let error = store + .evaluate_and_commit_workspace_config( + "w-config", + &ConfigCommitRequest { + base_revision: 0, + base_digest: current.digest, + changes: vec![ConfigTreeChange::Create { + path: path(DEFAULT_CONFIG_ENTRYPOINT), + content_type: ConfigContentType::Decodal, + content: "{ broken = ; }".into(), + }], + entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ) + .fingerprint, + }, + ) + .unwrap_err(); + assert!(matches!(error, Error::InvalidInput(_))); + assert!(store.load_workspace_config("w-config").unwrap().is_none()); + } + + #[tokio::test] + async fn valid_candidate_commits_snapshot_revision_and_provenance_atomically() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store.upsert_workspace(&workspace()).await.unwrap(); + let empty = ConfigTreeSnapshot::empty(); + let committed = 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 = 42; }".into(), + }], + entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ) + .fingerprint, + }, + ) + .unwrap(); + assert_eq!(committed.snapshot.revision, 1); + assert_eq!(committed.contract.decodal_version, DECODAL_VERSION); + assert!(!committed.projection_digest.is_empty()); + let reread = store.load_workspace_config("w-config").unwrap().unwrap(); + assert_eq!(reread.snapshot, committed.snapshot); + } + + #[tokio::test] + async fn stale_cas_cannot_overwrite_newer_tree() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store.upsert_workspace(&workspace()).await.unwrap(); + let empty = ConfigTreeSnapshot::empty(); + let request = ConfigCommitRequest { + base_revision: 0, + base_digest: empty.digest, + changes: vec![ConfigTreeChange::Create { + path: path(DEFAULT_CONFIG_ENTRYPOINT), + content_type: ConfigContentType::Decodal, + content: "{ answer = 42; }".into(), + }], + entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ) + .fingerprint, + }; + let candidate = store + .evaluate_workspace_config_candidate("w-config", &request) + .unwrap(); + store + .commit_evaluated_workspace_config("w-config", &candidate) + .unwrap(); + let error = store + .commit_evaluated_workspace_config("w-config", &candidate) + .unwrap_err(); + 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(); + store.upsert_workspace(&workspace()).await.unwrap(); + let empty = ConfigTreeSnapshot::empty(); + let error = 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 = 42; }".into(), + }], + entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: "sha256:stale-toolchain".into(), + }, + ) + .unwrap_err(); + assert!(matches!(error, Error::WorkspaceConfigConflict(_))); + assert!(store.load_workspace_config("w-config").unwrap().is_none()); + } + + #[test] + fn exports_typescript_transport_contract() { + use ts_rs::TS; + let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/workspace/config-source/generated/types"); + let config = ts_rs::Config::default().with_out_dir(&output); + WorkspaceConfigState::export_all(&config).unwrap(); + EvaluatedConfigCandidate::export_all(&config).unwrap(); + ConfigCommitRequest::export_all(&config).unwrap(); + ConfigPreviewRequest::export_all(&config).unwrap(); + } + + #[test] + fn migration_creates_config_authority_without_changing_applied_migrations() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store + .with_conn(|conn| { + for table in [ + "workspace_config_trees", + "workspace_config_entries", + "workspace_config_tree_revisions", + ] { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table], + |row| row.get(0), + )?; + assert!(exists, "missing {table}"); + } + Ok(()) + }) + .unwrap(); + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index daa9f66e..811a3f0a 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -8,6 +8,7 @@ pub mod auth; pub mod authority; pub mod companion; pub mod config; +pub mod config_source; pub mod hosts; pub mod identity; pub mod memory_backend; @@ -106,6 +107,8 @@ pub enum Error { TicketAssignmentConflict(String), #[error("Workdir attachment conflict: {0}")] WorkdirAttachmentConflict(String), + #[error("Workspace config update conflict: {0}")] + WorkspaceConfigConflict(String), #[error("Registry inconsistency: {0}")] RegistryInconsistency(String), #[error("Worker source identity is invalid: {0}")] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9e0f3042..5a01c6e3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12,6 +12,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, patch, post, put}; use axum::{Json, Router}; use chrono::{Duration, SecondsFormat, Utc}; +use config_source::ConfigTreeSnapshot; use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource}; use futures::{SinkExt, StreamExt}; use memory::backend::{ @@ -61,6 +62,7 @@ use crate::companion::{ CompanionStatusResponse, CompanionTranscriptProjection, }; use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime}; +use crate::config_source::{ConfigCommitRequest, ConfigPreviewRequest}; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, @@ -252,6 +254,7 @@ const ORCHESTRATOR_ATTENTION_PROMPT: &str = include_str!(concat!( pub struct WorkspaceApi { pub(crate) config: ServerConfig, pub(crate) store: Arc, + config_store: Arc, authority: SqliteWorkspaceAuthority, runtime: Arc, companion: Arc, @@ -741,7 +744,11 @@ impl WorkspaceApi { let runtime = Arc::new(runtime); let companion = Arc::new(CompanionConsole::disabled()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); + let config_store = Arc::new(crate::SqliteWorkspaceStore::open( + config.database_path.clone(), + )?); let api = Self { + config_store, authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), @@ -1132,6 +1139,26 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/settings/workspace", get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), ) + .route( + "/api/w/{workspace_id}/config/source-tree", + get(scoped_get_workspace_config_tree), + ) + .route( + "/api/w/{workspace_id}/config/source-tree/preview", + post(scoped_preview_workspace_config_tree), + ) + .route( + "/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), + ) .route( "/api/w/{workspace_id}/settings/profiles", get(scoped_get_profile_settings).post(scoped_create_profile_source), @@ -2418,6 +2445,113 @@ async fn scoped_update_workspace_settings( )) } +#[derive(Debug, Deserialize)] +struct WorkspaceConfigRevisionPath { + workspace_id: String, + revision: u64, +} + +async fn scoped_get_workspace_config_revision( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let snapshot = api + .config_store + .load_workspace_config_revision(&path.workspace_id, path.revision)? + .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.revision.to_string())))?; + Ok(Json(snapshot)) +} + +#[derive(Debug, Deserialize)] +struct WorkspaceConfigEntryPath { + workspace_id: String, + path: String, +} + +#[derive(Debug, Serialize)] +struct WorkspaceConfigTreeResponse { + snapshot: ConfigTreeSnapshot, + contract: config_source::ToolchainContract, + projection_digest: String, +} + +async fn scoped_get_workspace_config_tree( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .unwrap_or_else(|| crate::config_source::WorkspaceConfigState { + snapshot: ConfigTreeSnapshot::empty(), + contract: config_source::ToolchainContract::new( + config_source::DEFAULT_SCHEMA_VERSION, + Vec::new(), + config_source::DEFAULT_IMPORT_POLICY_VERSION, + ), + projection_digest: config_source::digest_bytes(b"[]"), + }); + Ok(Json(WorkspaceConfigTreeResponse { + snapshot: state.snapshot, + contract: state.contract, + projection_digest: state.projection_digest, + })) +} + +async fn scoped_get_workspace_config_entry( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let virtual_path = config_source::VirtualPath::parse(&path.path) + .map_err(|error| ApiError::from(Error::InvalidInput(error.to_string())))?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + let entry = state + .snapshot + .get(&virtual_path) + .cloned() + .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.path)))?; + Ok(Json(entry)) +} + +async fn scoped_preview_workspace_config_tree( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + api.config_store + .preview_workspace_config(&path.workspace_id, &request)?, + )) +} + +async fn scoped_commit_workspace_config_tree( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .evaluate_and_commit_workspace_config(&path.workspace_id, &request)?; + Ok(( + StatusCode::CREATED, + Json(WorkspaceConfigTreeResponse { + snapshot: state.snapshot, + contract: state.contract, + projection_digest: state.projection_digest, + }), + )) +} + async fn scoped_get_profile_settings( State(api): State, AxumPath(path): AxumPath, @@ -11273,9 +11407,9 @@ impl IntoResponse for ApiError { Error::BrowserMergeConfirmationRequired | Error::BrowserReopenConfirmationRequired => { StatusCode::FORBIDDEN } - Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => { - StatusCode::CONFLICT - } + Error::TicketAssignmentConflict(_) + | Error::WorkdirAttachmentConflict(_) + | Error::WorkspaceConfigConflict(_) => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { StatusCode::BAD_REQUEST diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 23236931..47aea7a7 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -166,6 +166,11 @@ const MIGRATIONS: &[Migration] = &[ name: "create Worker mutation source proof replay guard", apply: create_worker_mutation_source_proof_replay_guard, }, + Migration { + version: 30, + name: "create Workspace virtual config source authority", + apply: create_workspace_config_source_authority, + }, ]; struct Migration { @@ -4527,6 +4532,49 @@ fn current_schema_version(conn: &Connection) -> Result { .map_err(Error::from) } +fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" + CREATE TABLE workspace_config_trees ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL CHECK (revision >= 0), + tree_digest TEXT NOT NULL, + schema_version INTEGER NOT NULL, + entrypoints_json TEXT NOT NULL, + decodal_version TEXT NOT NULL, + import_policy_version INTEGER NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE TABLE workspace_config_entries ( + workspace_id TEXT NOT NULL, + path TEXT NOT NULL, + content_type TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + PRIMARY KEY (workspace_id, path), + FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX idx_workspace_config_entries_prefix + ON workspace_config_entries(workspace_id, path); + CREATE TABLE workspace_config_tree_revisions ( + workspace_id TEXT NOT NULL, + revision INTEGER NOT NULL, + tree_digest TEXT NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, revision), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + "#, + )?; + Ok(()) +} + fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -5133,7 +5181,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 29); + assert_eq!(current_schema_version(&conn).unwrap(), 30); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -5166,7 +5214,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 29); + assert_eq!(current_schema_version(&conn).unwrap(), 30); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -5233,7 +5281,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 29); + assert_eq!(current_schema_version(&conn).unwrap(), 30); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 6571eeb6..07317361 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -15,9 +15,10 @@ "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", - "@codemirror/state": "npm:@codemirror/state@6.5.2", - "@codemirror/view": "npm:@codemirror/view@6.38.8", - "decodal-codemirror": "npm:decodal-codemirror@0.1.2", + "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", + "@codemirror/state": "npm:@codemirror/state@6.7.1", + "@codemirror/view": "npm:@codemirror/view@6.43.8", + "decodal-codemirror": "npm:decodal-codemirror@0.1.6", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index fbd75248..0811f3fa 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -1,15 +1,18 @@ { "version": "5", "specifiers": { - "npm:@codemirror/state@6.5.2": "6.5.2", - "npm:@codemirror/view@6.38.8": "6.38.8", + "jsr:@std/assert@*": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "npm:@codemirror/autocomplete@6.20.0": "6.20.0", + "npm:@codemirror/state@6.7.1": "6.7.1", + "npm:@codemirror/view@6.43.8": "6.43.8", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", "npm:clsx@2.1.1": "2.1.1", "npm:cookie@0.6.0": "0.6.0", - "npm:decodal-codemirror@0.1.2": "0.1.2", + "npm:decodal-codemirror@0.1.6": "0.1.6_@codemirror+view@6.43.8", "npm:devalue@5.6.4": "5.6.4", "npm:gen-interface-jp@0.8.0": "0.8.0", "npm:set-cookie-parser@2.7.2": "2.7.2", @@ -20,7 +23,27 @@ "npm:typescript@5.9.3": "5.9.3", "npm:vite@7.2.7": "7.2.7" }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + } + }, "npm": { + "@codemirror/autocomplete@6.20.0": { + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/common" + ] + }, "@codemirror/language@6.12.4": { "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "dependencies": [ @@ -32,14 +55,14 @@ "style-mod" ] }, - "@codemirror/state@6.5.2": { - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "@codemirror/state@6.7.1": { + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "dependencies": [ "@marijn/find-cluster-break" ] }, - "@codemirror/view@6.38.8": { - "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", + "@codemirror/view@6.43.8": { + "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", "dependencies": [ "@codemirror/state", "crelt", @@ -531,10 +554,11 @@ "ms" ] }, - "decodal-codemirror@0.1.2": { - "integrity": "sha512-VR+cFsBLPb9kZU3gJhElZck+IUVhOC1HoWgA8bxP1kxwn+eCZaeBHErHESlRZbsWvN2J5T8VKcDCtxLDYe9QyA==", + "decodal-codemirror@0.1.6_@codemirror+view@6.43.8": { + "integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==", "dependencies": [ "@codemirror/language", + "@codemirror/view", "@lezer/highlight", "@lezer/lr" ] @@ -975,14 +999,15 @@ }, "workspace": { "dependencies": [ - "npm:@codemirror/state@6.5.2", - "npm:@codemirror/view@6.38.8", + "npm:@codemirror/autocomplete@6.20.0", + "npm:@codemirror/state@6.7.1", + "npm:@codemirror/view@6.43.8", "npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:clsx@2.1.1", "npm:cookie@0.6.0", - "npm:decodal-codemirror@0.1.2", + "npm:decodal-codemirror@0.1.6", "npm:devalue@5.6.4", "npm:set-cookie-parser@2.7.2", "npm:shiki@3.13.0", diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte new file mode 100644 index 00000000..d2a183f0 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -0,0 +1,350 @@ + + +
+ + +
+
+
+ Virtual path + {selectedPath || "Select or create a source"} +
+
+ + + + + + + +
+
+ source = value} + onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} + /> +

{status}

+ {#if conflict} + + {/if} + {#if diagnostics.length > 0} +
    + {#each diagnostics as diagnostic} +
  1. + {diagnostic.kind} + {diagnostic.message} + bytes {diagnostic.span.start_byte}–{diagnostic.span.end_byte} +
  2. + {/each} +
+ {/if} +
+
diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts new file mode 100644 index 00000000..94bc8ebd --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -0,0 +1,84 @@ +import type { + ConfigCommitRequest, + ConfigEntry, + ConfigPreviewRequest, + ConfigTreeSnapshot, + EvaluatedConfigCandidate, + WorkspaceConfigTreeResponse, +} from "./types.ts"; + +function sourceTreeUrl(workspaceId: string): string { + return `/api/w/${encodeURIComponent(workspaceId)}/config/source-tree`; +} + +async function readJson(response: Response): Promise { + if (!response.ok) { + const body = await response.text(); + throw new Error(body || `${response.status} ${response.statusText}`); + } + return await response.json() as T; +} + +export async function fetchConfigTree( + workspaceId: string, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher(sourceTreeUrl(workspaceId), { + headers: { accept: "application/json" }, + }), + ); +} + +export async function fetchConfigEntry( + workspaceId: string, + path: string, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher( + `${sourceTreeUrl(workspaceId)}/entries/${encodeURIComponent(path)}`, + { headers: { accept: "application/json" } }, + ), + ); +} + +export async function fetchConfigRevision( + workspaceId: string, + revision: number, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher(`${sourceTreeUrl(workspaceId)}/revisions/${revision}`, { + headers: { accept: "application/json" }, + }), + ); +} + +export async function previewConfigTree( + workspaceId: string, + request: ConfigPreviewRequest, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }), + ); +} + +export async function commitConfigTree( + workspaceId: string, + request: ConfigCommitRequest, + fetcher: typeof fetch = fetch, +): Promise { + return await readJson( + await fetcher(`${sourceTreeUrl(workspaceId)}/commit`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }), + ); +} diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts new file mode 100644 index 00000000..f6e5ecf0 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts @@ -0,0 +1,65 @@ +/* tslint:disable */ +/* eslint-disable */ + +export function analyze_snapshot(snapshot: any, entrypoint: string, source_override?: string | null): any; + +export function apply_changes(changes: any): any; + +export function changes_between(base: any, candidate: any): any; + +export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any; + +export function evaluate_current(contract: any): any; + +export function evaluate_snapshot(snapshot: any, contract: any): any; + +export function formatSource(source: string): string; + +export function format_source(source: string): string; + +export function set_snapshot(snapshot: any): void; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number]; + readonly apply_changes: (a: any) => [number, number, number]; + readonly changes_between: (a: any, b: any) => [number, number, number]; + readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; + readonly evaluate_current: (a: any) => [number, number, number]; + readonly evaluate_snapshot: (a: any, b: any) => [number, number, number]; + readonly format_source: (a: number, b: number) => [number, number, number, number]; + readonly set_snapshot: (a: any) => [number, number]; + readonly formatSource: (a: number, b: number) => [number, number]; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_exn_store: (a: number) => void; + readonly __externref_table_alloc: () => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js new file mode 100644 index 00000000..713887df --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js @@ -0,0 +1,658 @@ +/* @ts-self-types="./config_source_wasm.d.ts" */ + +/** + * @param {any} snapshot + * @param {string} entrypoint + * @param {string | null} [source_override] + * @returns {any} + */ +export function analyze_snapshot(snapshot, entrypoint, source_override) { + const ptr0 = passStringToWasm0(entrypoint, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + var ptr1 = isLikeNone(source_override) ? 0 : passStringToWasm0(source_override, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + const ret = wasm.analyze_snapshot(snapshot, ptr0, len0, ptr1, len1); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {any} changes + * @returns {any} + */ +export function apply_changes(changes) { + const ret = wasm.apply_changes(changes); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {any} base + * @param {any} candidate + * @returns {any} + */ +export function changes_between(base, candidate) { + const ret = wasm.changes_between(base, candidate); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {string} entrypoint + * @param {string} source + * @param {number} utf16_offset + * @param {boolean} explicit + * @returns {any} + */ +export function complete_current(entrypoint, source, utf16_offset, explicit) { + const ptr0 = passStringToWasm0(entrypoint, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.complete_current(ptr0, len0, ptr1, len1, utf16_offset, explicit); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {any} contract + * @returns {any} + */ +export function evaluate_current(contract) { + const ret = wasm.evaluate_current(contract); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {any} snapshot + * @param {any} contract + * @returns {any} + */ +export function evaluate_snapshot(snapshot, contract) { + const ret = wasm.evaluate_snapshot(snapshot, contract); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {string} source + * @returns {string} + */ +export function formatSource(source) { + let deferred2_0; + let deferred2_1; + try { + const ptr0 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.formatSource(ptr0, len0); + deferred2_0 = ret[0]; + deferred2_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred2_0, deferred2_1, 1); + } +} + +/** + * @param {string} source + * @returns {string} + */ +export function format_source(source) { + let deferred3_0; + let deferred3_1; + try { + const ptr0 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.format_source(ptr0, len0); + var ptr2 = ret[0]; + var len2 = ret[1]; + if (ret[3]) { + ptr2 = 0; len2 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred3_0 = ptr2; + deferred3_1 = len2; + return getStringFromWasm0(ptr2, len2); + } finally { + wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); + } +} + +/** + * @param {any} snapshot + */ +export function set_snapshot(snapshot) { + const ret = wasm.set_snapshot(snapshot); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_2e59b1b37a9a34c3: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_Number_e6ffdb596c888833: function(arg0) { + const ret = Number(arg0); + return ret; + }, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_bigint_get_as_i64_2c5082002e4826e2: function(arg0, arg1) { + const v = arg1; + const ret = typeof(v) === 'bigint' ? v : undefined; + getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_boolean_get_a86c216575a75c30: function(arg0) { + const v = arg0; + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }, + __wbg___wbindgen_debug_string_dd5d2d07ce9e6c57: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_in_4bd7a57e54337366: function(arg0, arg1) { + const ret = arg0 in arg1; + return ret; + }, + __wbg___wbindgen_is_bigint_6c98f7e945dacdde: function(arg0) { + const ret = typeof(arg0) === 'bigint'; + return ret; + }, + __wbg___wbindgen_is_function_49868bde5eb1e745: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_object_40c5a80572e8f9d3: function(arg0) { + const val = arg0; + const ret = typeof(val) === 'object' && val !== null; + return ret; + }, + __wbg___wbindgen_is_string_b29b5c5a8065ba1a: function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }, + __wbg___wbindgen_is_undefined_c0cca72b82b86f4d: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_jsval_eq_7d430e744a913d26: function(arg0, arg1) { + const ret = arg0 === arg1; + return ret; + }, + __wbg___wbindgen_jsval_loose_eq_3a72ae764d46d944: function(arg0, arg1) { + const ret = arg0 == arg1; + return ret; + }, + __wbg___wbindgen_number_get_7579aab02a8a620c: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_string_get_914df97fcfa788f2: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_81fc77679af83bc6: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_call_7f2987183bb62793: function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments); }, + __wbg_done_547d467e97529006: function(arg0) { + const ret = arg0.done; + return ret; + }, + __wbg_entries_616b1a459b85be0b: function(arg0) { + const ret = Object.entries(arg0); + return ret; + }, + __wbg_get_4848e350b40afc16: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_ed0642c4b9d31ddf: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_get_unchecked_7d7babe32e9e6a54: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_with_ref_key_6412cf3094599694: function(arg0, arg1) { + const ret = arg0[arg1]; + return ret; + }, + __wbg_instanceof_ArrayBuffer_ff7c1337a5e3b33a: function(arg0) { + let result; + try { + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Map_a10a2795ef4bfe97: function(arg0) { + let result; + try { + result = arg0 instanceof Map; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Uint8Array_4b8da683deb25d72: function(arg0) { + let result; + try { + result = arg0 instanceof Uint8Array; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_isArray_db61795ad004c139: function(arg0) { + const ret = Array.isArray(arg0); + return ret; + }, + __wbg_isSafeInteger_ea83862ba994770c: function(arg0) { + const ret = Number.isSafeInteger(arg0); + return ret; + }, + __wbg_iterator_de403ef31815a3e6: function() { + const ret = Symbol.iterator; + return ret; + }, + __wbg_length_0c32cb8543c8e4c8: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_6e821edde497a532: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_new_4f9fafbb3909af72: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_99cabae501c0a8a0: function() { + const ret = new Map(); + return ret; + }, + __wbg_new_a560378ea1240b14: function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }, + __wbg_new_f3c9df4f38f3f798: function() { + const ret = new Array(); + return ret; + }, + __wbg_next_01132ed6134b8ef5: function(arg0) { + const ret = arg0.next; + return ret; + }, + __wbg_next_b3713ec761a9dbfd: function() { return handleError(function (arg0) { + const ret = arg0.next(); + return ret; + }, arguments); }, + __wbg_prototypesetcall_3e05eb9545565046: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_set_08463b1df38a7e29: function(arg0, arg1, arg2) { + const ret = arg0.set(arg1, arg2); + return ret; + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + arg0[arg1] = arg2; + }, + __wbg_set_6c60b2e8ad0e9383: function(arg0, arg1, arg2) { + arg0[arg1 >>> 0] = arg2; + }, + __wbg_value_7f6052747ccf940f: function(arg0) { + const ret = arg0.value; + return ret; + }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0) { + // Cast intrinsic for `I64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./config_source_wasm_bg.js": import0, + }; +} + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('config_source_wasm_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm new file mode 100644 index 00000000..9ca6bd64 Binary files /dev/null and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts new file mode 100644 index 00000000..70f74b45 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number]; +export const apply_changes: (a: any) => [number, number, number]; +export const changes_between: (a: any, b: any) => [number, number, number]; +export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; +export const evaluate_current: (a: any) => [number, number, number]; +export const evaluate_snapshot: (a: any, b: any) => [number, number, number]; +export const format_source: (a: number, b: number) => [number, number, number, number]; +export const set_snapshot: (a: any) => [number, number]; +export const formatSource: (a: number, b: number) => [number, number]; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_exn_store: (a: number) => void; +export const __externref_table_alloc: () => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_start: () => void; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigCommitRequest.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigCommitRequest.ts new file mode 100644 index 00000000..ab262091 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigCommitRequest.ts @@ -0,0 +1,5 @@ +// 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 ConfigCommitRequest = { base_revision: number, base_digest: string, changes: Array, entrypoints: Array, toolchain_fingerprint: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigContentType.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigContentType.ts new file mode 100644 index 00000000..bef199f9 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigContentType.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfigContentType = "decodal" | "text"; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts new file mode 100644 index 00000000..f1de18e3 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnostic.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigDiagnosticLabel } from "./ConfigDiagnosticLabel"; +import type { ConfigSpan } from "./ConfigSpan"; +import type { VirtualPath } from "./VirtualPath"; + +export type ConfigDiagnostic = { path: VirtualPath, revision: number, tree_digest: string, kind: string, span: ConfigSpan, message: string, labels: Array, notes: Array, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnosticLabel.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnosticLabel.ts new file mode 100644 index 00000000..487335f0 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigDiagnosticLabel.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigSpan } from "./ConfigSpan"; + +export type ConfigDiagnosticLabel = { span: ConfigSpan, message: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigEntry.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigEntry.ts new file mode 100644 index 00000000..1e16ad74 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigEntry.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigContentType } from "./ConfigContentType"; +import type { VirtualPath } from "./VirtualPath"; + +export type ConfigEntry = { path: VirtualPath, content_type: ConfigContentType, content: string, content_digest: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigPreviewRequest.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigPreviewRequest.ts new file mode 100644 index 00000000..6bf8acb1 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigPreviewRequest.ts @@ -0,0 +1,5 @@ +// 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, entrypoints: Array, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSpan.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSpan.ts new file mode 100644 index 00000000..fb28eb7d --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSpan.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfigSpan = { start_byte: number, end_byte: number, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeChange.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeChange.ts new file mode 100644 index 00000000..9f2442c4 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeChange.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigContentType } from "./ConfigContentType"; +import type { VirtualPath } from "./VirtualPath"; + +export type ConfigTreeChange = { "kind": "create", path: VirtualPath, content_type: ConfigContentType, content: string, } | { "kind": "update", path: VirtualPath, expected_digest: string, content: string, } | { "kind": "rename", from: VirtualPath, to: VirtualPath, expected_digest: string, } | { "kind": "delete", path: VirtualPath, expected_digest: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts new file mode 100644 index 00000000..5f723ce9 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigTreeSnapshot.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfigEntry } from "./ConfigEntry"; +import type { VirtualPath } from "./VirtualPath"; + +export type ConfigTreeSnapshot = { revision: number, digest: string, entries: { [key in VirtualPath]: ConfigEntry }, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/EvaluatedConfigCandidate.ts b/web/workspace/src/lib/workspace/config-source/generated/types/EvaluatedConfigCandidate.ts new file mode 100644 index 00000000..f71ba29a --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/EvaluatedConfigCandidate.ts @@ -0,0 +1,6 @@ +// 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, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/EvaluatedProjection.ts b/web/workspace/src/lib/workspace/config-source/generated/types/EvaluatedProjection.ts new file mode 100644 index 00000000..86f4d2e1 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/EvaluatedProjection.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VirtualPath } from "./VirtualPath"; + +export type EvaluatedProjection = { entrypoint: VirtualPath, data_json: unknown, projection_digest: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/EvaluationResult.ts b/web/workspace/src/lib/workspace/config-source/generated/types/EvaluationResult.ts new file mode 100644 index 00000000..b2061ee9 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/EvaluationResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EvaluatedProjection } from "./EvaluatedProjection"; + +export type EvaluationResult = { projections: Array, projection_digest: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts new file mode 100644 index 00000000..66f3d3c6 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VirtualPath } from "./VirtualPath"; + +export type ToolchainContract = { contract_version: number, decodal_version: string, schema_version: number, entrypoints: Array, import_policy_version: number, fingerprint: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/VirtualPath.ts b/web/workspace/src/lib/workspace/config-source/generated/types/VirtualPath.ts new file mode 100644 index 00000000..12c7d5ec --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/VirtualPath.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type VirtualPath = string; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigState.ts b/web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigState.ts new file mode 100644 index 00000000..592277cc --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigState.ts @@ -0,0 +1,5 @@ +// 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 { ToolchainContract } from "./ToolchainContract"; + +export type WorkspaceConfigState = { snapshot: ConfigTreeSnapshot, contract: ToolchainContract, projection_digest: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/toolchain.ts b/web/workspace/src/lib/workspace/config-source/toolchain.ts new file mode 100644 index 00000000..cf27ed0f --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/toolchain.ts @@ -0,0 +1,62 @@ +import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts"; +import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts"; + +type Command = + | Omit, "id"> + | Omit, "id"> + | Omit, "id"> + | Omit, "id"> + | Omit, "id"> + | Omit, "id"> + | Omit, "id">; + +export class ConfigSourceToolchain { + #worker: Worker; + #nextId = 1; + #pending = new Map void; reject: (reason: unknown) => void }>(); + + constructor(worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), { type: "module" })) { + this.#worker = worker; + worker.addEventListener("message", (event: MessageEvent) => { + const pending = this.#pending.get(event.data.id); + if (!pending) return; + this.#pending.delete(event.data.id); + if (event.data.ok) pending.resolve(event.data.result); + else pending.reject(event.data.error); + }); + } + + setSnapshot(snapshot: ConfigTreeSnapshot): Promise { + return this.#request({ kind: "set_snapshot", snapshot }); + } + applyChanges(changes: ConfigTreeChange[]): Promise { + return this.#request({ kind: "apply_changes", changes }); + } + changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise { + return this.#request({ kind: "changes_between", base, candidate }); + } + analyze(path: string, source?: string): Promise { + return this.#request({ kind: "analyze", path, source }); + } + evaluate(contract: ToolchainContract) { + return this.#request({ kind: "evaluate", contract }); + } + complete(path: string, source: string, utf16Offset: number, explicit = false): Promise { + return this.#request({ kind: "complete", path, source, utf16Offset, explicit }); + } + format(source: string): Promise { + return this.#request({ kind: "format", source }); + } + close(): void { + this.#worker.terminate(); + for (const pending of this.#pending.values()) pending.reject(new Error("config source toolchain was closed")); + this.#pending.clear(); + } + #request(request: Command): Promise { + const id = this.#nextId++; + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve: (value) => resolve(value as T), reject }); + this.#worker.postMessage({ ...request, id }); + }); + } +} diff --git a/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts b/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts new file mode 100644 index 00000000..32885cb7 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts @@ -0,0 +1,64 @@ +import init, { + analyze_snapshot, + apply_changes, + changes_between, + complete_current, + evaluate_current, + format_source, + set_snapshot, +} from "./generated/config_source_wasm.js"; +import type { ConfigTreeChange } from "./types.ts"; + +export type ConfigSourceWorkerRequest = + | { id: number; kind: "set_snapshot"; snapshot: unknown } + | { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] } + | { id: number; kind: "changes_between"; base: unknown; candidate: unknown } + | { id: number; kind: "analyze"; path: string; source?: string } + | { id: number; kind: "evaluate"; contract: unknown } + | { id: number; kind: "complete"; path: string; source: string; utf16Offset: number; explicit: boolean } + | { id: number; kind: "format"; source: string }; + +export type ConfigSourceWorkerResponse = + | { id: number; ok: true; result: unknown } + | { id: number; ok: false; error: unknown }; + +const ready = init(); +let snapshot: unknown = null; + +self.onmessage = async (event: MessageEvent): Promise => { + const request = event.data; + try { + await ready; + let result: unknown; + switch (request.kind) { + case "set_snapshot": + snapshot = request.snapshot; + set_snapshot(request.snapshot); + result = null; + break; + case "apply_changes": + snapshot = apply_changes(request.changes); + result = snapshot; + break; + case "changes_between": + result = changes_between(request.base, request.candidate); + break; + case "analyze": + if (!snapshot) throw new Error("config source snapshot is not initialized"); + result = analyze_snapshot(snapshot, request.path, request.source); + break; + case "evaluate": + result = evaluate_current(request.contract); + break; + case "complete": + result = complete_current(request.path, request.source, request.utf16Offset, request.explicit); + break; + case "format": + result = format_source(request.source); + break; + } + self.postMessage({ id: request.id, ok: true, result }); + } catch (error) { + self.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : error }); + } +}; diff --git a/web/workspace/src/lib/workspace/config-source/types.ts b/web/workspace/src/lib/workspace/config-source/types.ts new file mode 100644 index 00000000..f8fa6ac8 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/types.ts @@ -0,0 +1,16 @@ +// Core and HTTP DTOs are generated from Rust with ts-rs. +export type { ConfigContentType } from "./generated/types/ConfigContentType.ts"; +export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts"; +export type { ConfigDiagnosticLabel } from "./generated/types/ConfigDiagnosticLabel.ts"; +export type { ConfigEntry } from "./generated/types/ConfigEntry.ts"; +export type { ConfigSpan } from "./generated/types/ConfigSpan.ts"; +export type { ConfigTreeChange } from "./generated/types/ConfigTreeChange.ts"; +export type { ConfigTreeSnapshot } from "./generated/types/ConfigTreeSnapshot.ts"; +export type { EvaluatedProjection } from "./generated/types/EvaluatedProjection.ts"; +export type { EvaluationResult } from "./generated/types/EvaluationResult.ts"; +export type { ToolchainContract } from "./generated/types/ToolchainContract.ts"; +export type { VirtualPath } from "./generated/types/VirtualPath.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"; diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 16db8214..20e58e61 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -1,5 +1,6 @@ + + + Configuration | {workspaceName} + + +
+ + + +
diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts new file mode 100644 index 00000000..c5202276 --- /dev/null +++ b/web/workspace/test/config-source/api.test.ts @@ -0,0 +1,64 @@ +/// + +import { assert, assertEquals } from "jsr:@std/assert"; +import { + commitConfigTree, + fetchConfigEntry, + fetchConfigRevision, + fetchConfigTree, + previewConfigTree, +} from "../../src/lib/workspace/config-source/api.ts"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +Deno.test("config source API stays workspace-scoped and separates preview from commit", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetcher = ((input: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(input), init }); + return Promise.resolve(response({ ok: true })); + }) as typeof fetch; + + await fetchConfigTree("w/one", fetcher); + await fetchConfigRevision("w/one", 7, fetcher); + await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher); + await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher); + await commitConfigTree("w/one", { + base_revision: 4, + base_digest: "sha256:base", + changes: [], + entrypoints: [], + toolchain_fingerprint: "sha256:toolchain", + }, fetcher); + + assertEquals(calls.map((call) => call.url), [ + "/api/w/w%2Fone/config/source-tree", + "/api/w/w%2Fone/config/source-tree/revisions/7", + "/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl", + "/api/w/w%2Fone/config/source-tree/preview", + "/api/w/w%2Fone/config/source-tree/commit", + ]); + assertEquals(calls[3].init?.method, "POST"); + assertEquals(calls[4].init?.method, "POST"); + assert( + String(calls[4].init?.body).includes('"base_digest":"sha256:base"'), + ); +}); + +Deno.test("config source API surfaces failed evaluation instead of treating it as a draft write", async () => { + const fetcher = (() => + Promise.resolve( + new Response("structured diagnostics", { status: 422 }), + )) as typeof fetch; + let message = ""; + try { + await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher); + } catch (error) { + message = String(error); + } + assert(message.includes("structured diagnostics")); +}); diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts new file mode 100644 index 00000000..a2f30077 --- /dev/null +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -0,0 +1,67 @@ +/// + +import { assertEquals } from "jsr:@std/assert"; +// The generated wasm-bindgen loader is JavaScript with an adjacent declaration file. +// @ts-expect-error Deno checks the generated JS implementation rather than its .d.ts. +import init, { + analyze_snapshot, + evaluate_snapshot, +} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js"; +import type { ConfigTreeSnapshot, ToolchainContract } from "../../src/lib/workspace/config-source/types.ts"; + +const bytes = await Deno.readFile( + new URL("../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", import.meta.url), +); +await init({ module_or_path: bytes }); + +const snapshot: ConfigTreeSnapshot = { + revision: 4, + digest: "sha256:test-tree", + entries: { + "workspace.dcdl": { + path: "workspace.dcdl", + content_type: "decodal", + content: 'import "./lib/value.dcdl"', + content_digest: "sha256:root", + }, + "lib/value.dcdl": { + path: "lib/value.dcdl", + content_type: "decodal", + content: "{ answer = 42; }", + content_digest: "sha256:value", + }, + }, +}; + +const contract: ToolchainContract = { + contract_version: 1, + decodal_version: "0.2.0", + schema_version: 1, + entrypoints: ["workspace.dcdl"], + import_policy_version: 1, + fingerprint: "sha256:test-contract", +}; + +Deno.test("generated WASM evaluates the same virtual import contract", () => { + const result = evaluate_snapshot(snapshot, contract) as { + projections: Array<{ data_json: { answer: number } }>; + }; + assertEquals(result.projections[0].data_json, { answer: 42 }); +}); + +Deno.test("generated WASM diagnostics carry snapshot provenance", () => { + const diagnostics = analyze_snapshot( + snapshot, + "workspace.dcdl", + "{ broken = ; }", + ) as Array<{ + path: string; + revision: number; + tree_digest: string; + kind: string; + }>; + assertEquals(diagnostics[0].path, "workspace.dcdl"); + assertEquals(diagnostics[0].revision, 4); + assertEquals(diagnostics[0].tree_digest, "sha256:test-tree"); + assertEquals(diagnostics[0].kind, "syntax"); +});