Merge branch 'work/00001KY8KRJKK-virtual-config-tree' into develop
This commit is contained in:
Generated
+59
-2
@@ -589,6 +589,31 @@ dependencies = [
|
|||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -961,9 +986,29 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal"
|
name = "decodal"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "deltae"
|
name = "deltae"
|
||||||
@@ -3748,6 +3793,17 @@ dependencies = [
|
|||||||
"serde_derive",
|
"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]]
|
[[package]]
|
||||||
name = "serde_cbor_2"
|
name = "serde_cbor_2"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -6172,6 +6228,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"config-source",
|
||||||
"flow",
|
"flow",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
|||||||
+8
-1
@@ -19,6 +19,8 @@ members = [
|
|||||||
"crates/tools",
|
"crates/tools",
|
||||||
"crates/fs-operation",
|
"crates/fs-operation",
|
||||||
"crates/flow",
|
"crates/flow",
|
||||||
|
"crates/config-source",
|
||||||
|
"crates/config-source-wasm",
|
||||||
"crates/workdir",
|
"crates/workdir",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
@@ -47,6 +49,8 @@ default-members = [
|
|||||||
"crates/tools",
|
"crates/tools",
|
||||||
"crates/fs-operation",
|
"crates/fs-operation",
|
||||||
"crates/flow",
|
"crates/flow",
|
||||||
|
"crates/config-source",
|
||||||
|
"crates/config-source-wasm",
|
||||||
"crates/workdir",
|
"crates/workdir",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
@@ -82,6 +86,7 @@ session-analytics = { path = "crates/session-analytics" }
|
|||||||
session-store = { path = "crates/session-store" }
|
session-store = { path = "crates/session-store" }
|
||||||
secrets = { path = "crates/secrets" }
|
secrets = { path = "crates/secrets" }
|
||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
|
config-source = { path = "crates/config-source" }
|
||||||
fs-operation = { path = "crates/fs-operation" }
|
fs-operation = { path = "crates/fs-operation" }
|
||||||
workdir = { path = "crates/workdir" }
|
workdir = { path = "crates/workdir" }
|
||||||
tui = { path = "crates/tui" }
|
tui = { path = "crates/tui" }
|
||||||
@@ -93,7 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.22.1"
|
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"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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<Option<ConfigTreeSnapshot>> = 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<JsValue, JsValue> {
|
||||||
|
let changes: Vec<ConfigTreeChange> = 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<JsValue, JsValue> {
|
||||||
|
let base: ConfigTreeSnapshot = decode(base)?;
|
||||||
|
let candidate: ConfigTreeSnapshot = decode(candidate)?;
|
||||||
|
encode(base.changes_to(&candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn evaluate_current(contract: JsValue) -> Result<JsValue, JsValue> {
|
||||||
|
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<WasmCompletionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct WasmCompletionItem {
|
||||||
|
label: String,
|
||||||
|
kind: String,
|
||||||
|
detail: Option<String>,
|
||||||
|
priority: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn complete_current(
|
||||||
|
entrypoint: String,
|
||||||
|
source: String,
|
||||||
|
utf16_offset: usize,
|
||||||
|
explicit: bool,
|
||||||
|
) -> Result<JsValue, JsValue> {
|
||||||
|
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<JsValue, JsValue> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<JsValue, JsValue> {
|
||||||
|
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<String, JsValue> {
|
||||||
|
SnapshotEnvironment::new(ConfigTreeSnapshot::empty())
|
||||||
|
.format(&source)
|
||||||
|
.map_err(|error| JsValue::from_str(&error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsValue> {
|
||||||
|
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<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
||||||
|
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode<T: serde::Serialize>(value: T) -> Result<JsValue, JsValue> {
|
||||||
|
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) {}
|
||||||
@@ -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"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use decodal::{Engine, LoadedSource, SourceLoader};
|
use decodal::{Engine, ImportLoader, LoadedImport};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
@@ -527,12 +527,12 @@ fn content_digest(content: &str) -> String {
|
|||||||
|
|
||||||
struct RejectImports;
|
struct RejectImports;
|
||||||
|
|
||||||
impl SourceLoader for RejectImports {
|
impl ImportLoader for RejectImports {
|
||||||
fn load(
|
fn load(
|
||||||
&mut self,
|
&mut self,
|
||||||
_current_key: Option<&str>,
|
_current_key: Option<&str>,
|
||||||
specifier: &str,
|
specifier: &str,
|
||||||
) -> decodal::Result<LoadedSource> {
|
) -> decodal::Result<LoadedImport> {
|
||||||
Err(decodal::Diagnostic::new(
|
Err(decodal::Diagnostic::new(
|
||||||
decodal::DiagnosticKind::Import,
|
decodal::DiagnosticKind::Import,
|
||||||
decodal::Span::default(),
|
decodal::Span::default(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use decodal::{Engine, LoadedSource, SourceLoader};
|
use decodal::{Engine, ImportLoader, LoadedImport};
|
||||||
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -424,12 +424,12 @@ impl<'a> ArchiveSourceLoader<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceLoader for ArchiveSourceLoader<'_> {
|
impl ImportLoader for ArchiveSourceLoader<'_> {
|
||||||
fn load(
|
fn load(
|
||||||
&mut self,
|
&mut self,
|
||||||
current_key: Option<&str>,
|
current_key: Option<&str>,
|
||||||
specifier: &str,
|
specifier: &str,
|
||||||
) -> decodal::Result<LoadedSource> {
|
) -> decodal::Result<LoadedImport> {
|
||||||
let path =
|
let path =
|
||||||
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
|
archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier)
|
||||||
.map_err(import_diagnostic)?;
|
.map_err(import_diagnostic)?;
|
||||||
@@ -453,11 +453,7 @@ impl SourceLoader for ArchiveSourceLoader<'_> {
|
|||||||
format!("archive source missing: {path}"),
|
format!("archive source missing: {path}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
Ok(LoadedSource {
|
Ok(LoadedImport::source(path.clone(), path, source.clone()))
|
||||||
key: path.clone(),
|
|
||||||
name: path.clone(),
|
|
||||||
source: source.clone(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,7 +742,12 @@ mod tests {
|
|||||||
let loaded = loader
|
let loaded = loader
|
||||||
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ async-trait.workspace = true
|
|||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
config-source.workspace = true
|
||||||
flow = { path = "../flow" }
|
flow = { path = "../flow" }
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
|
|||||||
@@ -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<ConfigTreeChange>,
|
||||||
|
pub entrypoints: Vec<VirtualPath>,
|
||||||
|
pub toolchain_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct ConfigPreviewRequest {
|
||||||
|
pub changes: Vec<ConfigTreeChange>,
|
||||||
|
pub entrypoints: Vec<VirtualPath>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteWorkspaceStore {
|
||||||
|
pub fn load_workspace_config(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<Option<WorkspaceConfigState>> {
|
||||||
|
self.with_conn(|conn| load_state(conn, workspace_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_workspace_config_revision(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
revision: u64,
|
||||||
|
) -> Result<Option<ConfigTreeSnapshot>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let manifest = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT tree_digest, manifest_json FROM workspace_config_tree_revisions WHERE workspace_id = ?1 AND revision = ?2",
|
||||||
|
params![workspace_id, revision as i64],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let Some((stored_digest, manifest_json)) = manifest else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let entries: std::collections::BTreeMap<VirtualPath, ConfigEntry> =
|
||||||
|
serde_json::from_str(&manifest_json)
|
||||||
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(revision, entries.into_values())
|
||||||
|
.map_err(config_error)?;
|
||||||
|
if snapshot.digest != stored_digest {
|
||||||
|
return Err(Error::RegistryInconsistency(format!(
|
||||||
|
"virtual config revision digest mismatch for Workspace {workspace_id} revision {revision}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Some(snapshot))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_workspace_config_candidate(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
request: &ConfigCommitRequest,
|
||||||
|
) -> Result<EvaluatedConfigCandidate> {
|
||||||
|
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<EvaluatedConfigCandidate> {
|
||||||
|
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<WorkspaceConfigState> {
|
||||||
|
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<WorkspaceConfigState> {
|
||||||
|
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<VirtualPath>,
|
||||||
|
) -> Result<EvaluatedConfigCandidate> {
|
||||||
|
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<Option<WorkspaceConfigState>> {
|
||||||
|
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::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
.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::<Result<Vec<_>>>()?;
|
||||||
|
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<VirtualPath> = 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<ConfigContentType> {
|
||||||
|
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<String>) -> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ pub mod auth;
|
|||||||
pub mod authority;
|
pub mod authority;
|
||||||
pub mod companion;
|
pub mod companion;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod config_source;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod memory_backend;
|
pub mod memory_backend;
|
||||||
@@ -106,6 +107,8 @@ pub enum Error {
|
|||||||
TicketAssignmentConflict(String),
|
TicketAssignmentConflict(String),
|
||||||
#[error("Workdir attachment conflict: {0}")]
|
#[error("Workdir attachment conflict: {0}")]
|
||||||
WorkdirAttachmentConflict(String),
|
WorkdirAttachmentConflict(String),
|
||||||
|
#[error("Workspace config update conflict: {0}")]
|
||||||
|
WorkspaceConfigConflict(String),
|
||||||
#[error("Registry inconsistency: {0}")]
|
#[error("Registry inconsistency: {0}")]
|
||||||
RegistryInconsistency(String),
|
RegistryInconsistency(String),
|
||||||
#[error("Worker source identity is invalid: {0}")]
|
#[error("Worker source identity is invalid: {0}")]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use axum::response::{IntoResponse, Response};
|
|||||||
use axum::routing::{delete, get, patch, post, put};
|
use axum::routing::{delete, get, patch, post, put};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use chrono::{Duration, SecondsFormat, Utc};
|
use chrono::{Duration, SecondsFormat, Utc};
|
||||||
|
use config_source::ConfigTreeSnapshot;
|
||||||
use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource};
|
use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource};
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use memory::backend::{
|
use memory::backend::{
|
||||||
@@ -61,6 +62,7 @@ use crate::companion::{
|
|||||||
CompanionStatusResponse, CompanionTranscriptProjection,
|
CompanionStatusResponse, CompanionTranscriptProjection,
|
||||||
};
|
};
|
||||||
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
|
use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime};
|
||||||
|
use crate::config_source::{ConfigCommitRequest, ConfigPreviewRequest};
|
||||||
use crate::hosts::{
|
use crate::hosts::{
|
||||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
||||||
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
|
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
|
||||||
@@ -252,6 +254,7 @@ const ORCHESTRATOR_ATTENTION_PROMPT: &str = include_str!(concat!(
|
|||||||
pub struct WorkspaceApi {
|
pub struct WorkspaceApi {
|
||||||
pub(crate) config: ServerConfig,
|
pub(crate) config: ServerConfig,
|
||||||
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
||||||
|
config_store: Arc<crate::SqliteWorkspaceStore>,
|
||||||
authority: SqliteWorkspaceAuthority,
|
authority: SqliteWorkspaceAuthority,
|
||||||
runtime: Arc<RuntimeRegistry>,
|
runtime: Arc<RuntimeRegistry>,
|
||||||
companion: Arc<CompanionConsole>,
|
companion: Arc<CompanionConsole>,
|
||||||
@@ -741,7 +744,11 @@ impl WorkspaceApi {
|
|||||||
let runtime = Arc::new(runtime);
|
let runtime = Arc::new(runtime);
|
||||||
let companion = Arc::new(CompanionConsole::disabled());
|
let companion = Arc::new(CompanionConsole::disabled());
|
||||||
let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone());
|
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 {
|
let api = Self {
|
||||||
|
config_store,
|
||||||
authority: SqliteWorkspaceAuthority::new(
|
authority: SqliteWorkspaceAuthority::new(
|
||||||
config.database_path.clone(),
|
config.database_path.clone(),
|
||||||
config.workspace_id.clone(),
|
config.workspace_id.clone(),
|
||||||
@@ -1132,6 +1139,26 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/settings/workspace",
|
"/api/w/{workspace_id}/settings/workspace",
|
||||||
get(scoped_get_workspace_settings).put(scoped_update_workspace_settings),
|
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(
|
.route(
|
||||||
"/api/w/{workspace_id}/settings/profiles",
|
"/api/w/{workspace_id}/settings/profiles",
|
||||||
get(scoped_get_profile_settings).post(scoped_create_profile_source),
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<WorkspaceConfigRevisionPath>,
|
||||||
|
) -> ApiResult<Json<ConfigTreeSnapshot>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let snapshot = api
|
||||||
|
.config_store
|
||||||
|
.load_workspace_config_revision(&path.workspace_id, path.revision)?
|
||||||
|
.ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.revision.to_string())))?;
|
||||||
|
Ok(Json(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WorkspaceConfigEntryPath {
|
||||||
|
workspace_id: String,
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
) -> ApiResult<Json<WorkspaceConfigTreeResponse>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<WorkspaceConfigEntryPath>,
|
||||||
|
) -> ApiResult<Json<config_source::ConfigEntry>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<ConfigPreviewRequest>,
|
||||||
|
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<ConfigCommitRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<WorkspaceConfigTreeResponse>)> {
|
||||||
|
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(
|
async fn scoped_get_profile_settings(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -11273,9 +11407,9 @@ impl IntoResponse for ApiError {
|
|||||||
Error::BrowserMergeConfirmationRequired | Error::BrowserReopenConfirmationRequired => {
|
Error::BrowserMergeConfirmationRequired | Error::BrowserReopenConfirmationRequired => {
|
||||||
StatusCode::FORBIDDEN
|
StatusCode::FORBIDDEN
|
||||||
}
|
}
|
||||||
Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => {
|
Error::TicketAssignmentConflict(_)
|
||||||
StatusCode::CONFLICT
|
| Error::WorkdirAttachmentConflict(_)
|
||||||
}
|
| Error::WorkspaceConfigConflict(_) => StatusCode::CONFLICT,
|
||||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||||
StatusCode::BAD_REQUEST
|
StatusCode::BAD_REQUEST
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "create Worker mutation source proof replay guard",
|
name: "create Worker mutation source proof replay guard",
|
||||||
apply: 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 {
|
struct Migration {
|
||||||
@@ -4527,6 +4532,49 @@ fn current_schema_version(conn: &Connection) -> Result<i64> {
|
|||||||
.map_err(Error::from)
|
.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<()> {
|
fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
@@ -5133,7 +5181,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
|||||||
|
|
||||||
apply_migrations(&conn).unwrap();
|
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());
|
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();
|
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_sources").unwrap());
|
||||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||||
@@ -5233,7 +5281,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
|||||||
|
|
||||||
apply_migrations(&conn).unwrap();
|
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
|
let repositories_sql: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
|
||||||
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
"@codemirror/state": "npm:@codemirror/state@6.5.2",
|
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
||||||
"@codemirror/view": "npm:@codemirror/view@6.38.8",
|
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
||||||
"decodal-codemirror": "npm:decodal-codemirror@0.1.2",
|
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
||||||
|
"decodal-codemirror": "npm:decodal-codemirror@0.1.6",
|
||||||
"clsx": "npm:clsx@2.1.1",
|
"clsx": "npm:clsx@2.1.1",
|
||||||
"cookie": "npm:cookie@0.6.0",
|
"cookie": "npm:cookie@0.6.0",
|
||||||
"devalue": "npm:devalue@5.6.4",
|
"devalue": "npm:devalue@5.6.4",
|
||||||
|
|||||||
Generated
+37
-12
@@ -1,15 +1,18 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
"npm:@codemirror/state@6.5.2": "6.5.2",
|
"jsr:@std/assert@*": "1.0.19",
|
||||||
"npm:@codemirror/view@6.38.8": "6.38.8",
|
"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:@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/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/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:@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:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"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:devalue@5.6.4": "5.6.4",
|
||||||
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
||||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||||
@@ -20,7 +23,27 @@
|
|||||||
"npm:typescript@5.9.3": "5.9.3",
|
"npm:typescript@5.9.3": "5.9.3",
|
||||||
"npm:vite@7.2.7": "7.2.7"
|
"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": {
|
"npm": {
|
||||||
|
"@codemirror/autocomplete@6.20.0": {
|
||||||
|
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@codemirror/language@6.12.4": {
|
"@codemirror/language@6.12.4": {
|
||||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -32,14 +55,14 @@
|
|||||||
"style-mod"
|
"style-mod"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@codemirror/state@6.5.2": {
|
"@codemirror/state@6.7.1": {
|
||||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@marijn/find-cluster-break"
|
"@marijn/find-cluster-break"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@codemirror/view@6.38.8": {
|
"@codemirror/view@6.43.8": {
|
||||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
"integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@codemirror/state",
|
"@codemirror/state",
|
||||||
"crelt",
|
"crelt",
|
||||||
@@ -531,10 +554,11 @@
|
|||||||
"ms"
|
"ms"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"decodal-codemirror@0.1.2": {
|
"decodal-codemirror@0.1.6_@codemirror+view@6.43.8": {
|
||||||
"integrity": "sha512-VR+cFsBLPb9kZU3gJhElZck+IUVhOC1HoWgA8bxP1kxwn+eCZaeBHErHESlRZbsWvN2J5T8VKcDCtxLDYe9QyA==",
|
"integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@codemirror/language",
|
"@codemirror/language",
|
||||||
|
"@codemirror/view",
|
||||||
"@lezer/highlight",
|
"@lezer/highlight",
|
||||||
"@lezer/lr"
|
"@lezer/lr"
|
||||||
]
|
]
|
||||||
@@ -975,14 +999,15 @@
|
|||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@codemirror/state@6.5.2",
|
"npm:@codemirror/autocomplete@6.20.0",
|
||||||
"npm:@codemirror/view@6.38.8",
|
"npm:@codemirror/state@6.7.1",
|
||||||
|
"npm:@codemirror/view@6.43.8",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9",
|
"npm:@sveltejs/adapter-static@3.0.9",
|
||||||
"npm:@sveltejs/kit@2.49.4",
|
"npm:@sveltejs/kit@2.49.4",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
"npm:clsx@2.1.1",
|
"npm:clsx@2.1.1",
|
||||||
"npm:cookie@0.6.0",
|
"npm:cookie@0.6.0",
|
||||||
"npm:decodal-codemirror@0.1.2",
|
"npm:decodal-codemirror@0.1.6",
|
||||||
"npm:devalue@5.6.4",
|
"npm:devalue@5.6.4",
|
||||||
"npm:set-cookie-parser@2.7.2",
|
"npm:set-cookie-parser@2.7.2",
|
||||||
"npm:shiki@3.13.0",
|
"npm:shiki@3.13.0",
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import DecodalSourceEditor from "$lib/workspace/settings/DecodalSourceEditor.svelte";
|
||||||
|
import {
|
||||||
|
commitConfigTree,
|
||||||
|
fetchConfigTree,
|
||||||
|
previewConfigTree,
|
||||||
|
} from "./api.ts";
|
||||||
|
import { ConfigSourceToolchain } from "./toolchain.ts";
|
||||||
|
import type {
|
||||||
|
ConfigDiagnostic,
|
||||||
|
ConfigTreeChange,
|
||||||
|
WorkspaceConfigTreeResponse,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
let { workspaceId }: { workspaceId: string } = $props();
|
||||||
|
let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
|
||||||
|
let selectedPath = $state("");
|
||||||
|
let source = $state("");
|
||||||
|
let newPath = $state("workspace.dcdl");
|
||||||
|
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||||
|
let status = $state("Loading source tree…");
|
||||||
|
let busy = $state(false);
|
||||||
|
let draftChanges = $state<ConfigTreeChange[]>([]);
|
||||||
|
let baseRevision = $state(0);
|
||||||
|
let baseDigest = $state("");
|
||||||
|
let renamePath = $state("");
|
||||||
|
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||||
|
let preflightDigest = $state("");
|
||||||
|
let conflict = $state(false);
|
||||||
|
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
|
||||||
|
let toolchain: ConfigSourceToolchain | null = null;
|
||||||
|
|
||||||
|
const paths = $derived(
|
||||||
|
treeState ? Object.keys(treeState.snapshot.entries).toSorted() : [],
|
||||||
|
);
|
||||||
|
const selected = $derived(
|
||||||
|
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
||||||
|
);
|
||||||
|
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||||
|
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
toolchain = new ConfigSourceToolchain();
|
||||||
|
void reload();
|
||||||
|
return () => toolchain?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
treeState = await fetchConfigTree(workspaceId);
|
||||||
|
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||||
|
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||||
|
}
|
||||||
|
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
|
||||||
|
baseSnapshot = structuredClone(treeState.snapshot);
|
||||||
|
baseRevision = treeState.snapshot.revision;
|
||||||
|
baseDigest = treeState.snapshot.digest;
|
||||||
|
await toolchain?.setSnapshot(treeState.snapshot);
|
||||||
|
draftChanges = [];
|
||||||
|
renamePath = selectedPath;
|
||||||
|
diagnostics = [];
|
||||||
|
conflict = false;
|
||||||
|
candidateContract = null;
|
||||||
|
status = treeState.snapshot.revision === 0
|
||||||
|
? "No committed sources yet. Create workspace.dcdl to begin."
|
||||||
|
: `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||||
|
} catch (error) {
|
||||||
|
status = String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageCurrent() {
|
||||||
|
const change = currentChange();
|
||||||
|
if (!change || !toolchain) return;
|
||||||
|
const candidate = await toolchain.applyChanges([change]);
|
||||||
|
if (treeState) treeState = { ...treeState, snapshot: candidate };
|
||||||
|
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function select(path: string) {
|
||||||
|
await stageCurrent();
|
||||||
|
selectedPath = path;
|
||||||
|
source = treeState?.snapshot.entries[path]?.content ?? "";
|
||||||
|
renamePath = path;
|
||||||
|
diagnostics = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentChange(): ConfigTreeChange | null {
|
||||||
|
if (!treeState || !selectedPath) return null;
|
||||||
|
const entry = treeState.snapshot.entries[selectedPath];
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
kind: "create",
|
||||||
|
path: selectedPath,
|
||||||
|
content_type: "decodal",
|
||||||
|
content: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (source === entry.content) return null;
|
||||||
|
return {
|
||||||
|
kind: "update",
|
||||||
|
path: selectedPath,
|
||||||
|
expected_digest: entry.content_digest,
|
||||||
|
content: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entrypoints(): string[] {
|
||||||
|
if (!treeState) return [];
|
||||||
|
const known = new Set(Object.keys(treeState.snapshot.entries));
|
||||||
|
const configured = treeState.contract.entrypoints.filter((path) => known.has(path));
|
||||||
|
if (configured.length > 0) return configured;
|
||||||
|
if (treeState.snapshot.entries["workspace.dcdl"] || selectedPath === "workspace.dcdl") {
|
||||||
|
return ["workspace.dcdl"];
|
||||||
|
}
|
||||||
|
return selectedPath ? [selectedPath] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyze() {
|
||||||
|
if (!toolchain || !treeState || !selectedPath) return;
|
||||||
|
diagnostics = await toolchain.analyze(selectedPath, source);
|
||||||
|
status = diagnostics.length === 0 ? "No diagnostics." : `${diagnostics.length} diagnostic(s).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function format() {
|
||||||
|
if (!toolchain) return;
|
||||||
|
try {
|
||||||
|
source = await toolchain.format(source);
|
||||||
|
await analyze();
|
||||||
|
} catch (error) {
|
||||||
|
status = String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview() {
|
||||||
|
if (!treeState) return;
|
||||||
|
await stageCurrent();
|
||||||
|
if (draftChanges.length === 0) {
|
||||||
|
status = "No draft changes to preview.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const candidate = await previewConfigTree(workspaceId, {
|
||||||
|
changes: draftChanges,
|
||||||
|
entrypoints: entrypoints(),
|
||||||
|
});
|
||||||
|
await toolchain?.evaluate(candidate.contract);
|
||||||
|
diagnostics = [];
|
||||||
|
candidateContract = candidate.contract;
|
||||||
|
preflightDigest = candidate.snapshot.digest;
|
||||||
|
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
||||||
|
} catch (error) {
|
||||||
|
const message = String(error);
|
||||||
|
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||||
|
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (!treeState) return;
|
||||||
|
await stageCurrent();
|
||||||
|
if (draftChanges.length === 0) {
|
||||||
|
status = "No draft changes to commit.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
|
||||||
|
status = "Preview the complete candidate successfully before Commit.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
treeState = await commitConfigTree(workspaceId, {
|
||||||
|
base_revision: baseRevision,
|
||||||
|
base_digest: baseDigest,
|
||||||
|
changes: draftChanges,
|
||||||
|
entrypoints: candidateContract.entrypoints,
|
||||||
|
toolchain_fingerprint: candidateContract.fingerprint,
|
||||||
|
});
|
||||||
|
draftChanges = [];
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
|
baseSnapshot = structuredClone(treeState.snapshot);
|
||||||
|
baseRevision = treeState.snapshot.revision;
|
||||||
|
baseDigest = treeState.snapshot.digest;
|
||||||
|
await toolchain?.setSnapshot(treeState.snapshot);
|
||||||
|
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||||
|
diagnostics = [];
|
||||||
|
status = `Committed revision ${treeState.snapshot.revision}.`;
|
||||||
|
} catch (error) {
|
||||||
|
const message = String(error);
|
||||||
|
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||||
|
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discardAndReload() {
|
||||||
|
draftChanges = [];
|
||||||
|
source = "";
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAndReapply() {
|
||||||
|
if (!toolchain || !treeState) return;
|
||||||
|
const localChanges = [...draftChanges];
|
||||||
|
const remote = await fetchConfigTree(workspaceId);
|
||||||
|
baseSnapshot = structuredClone(remote.snapshot);
|
||||||
|
baseRevision = remote.snapshot.revision;
|
||||||
|
baseDigest = remote.snapshot.digest;
|
||||||
|
await toolchain.setSnapshot(remote.snapshot);
|
||||||
|
try {
|
||||||
|
const candidate = await toolchain.applyChanges(localChanges);
|
||||||
|
draftChanges = localChanges;
|
||||||
|
treeState = { ...remote, snapshot: candidate };
|
||||||
|
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||||
|
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||||
|
conflict = false;
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
status = "Local changes reapplied to the latest revision. Preview again before Commit.";
|
||||||
|
} catch (error) {
|
||||||
|
conflict = true;
|
||||||
|
status = `Local changes conflict with the latest revision: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntry() {
|
||||||
|
const path = newPath.trim();
|
||||||
|
if (!path || treeState?.snapshot.entries[path]) return;
|
||||||
|
selectedPath = path;
|
||||||
|
source = "{}\n";
|
||||||
|
diagnostics = [];
|
||||||
|
status = `Drafting new source ${path}. It is not persisted until Commit succeeds.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry() {
|
||||||
|
if (!treeState || !selected || !toolchain) return;
|
||||||
|
const change: ConfigTreeChange = {
|
||||||
|
kind: "delete",
|
||||||
|
path: selectedPath,
|
||||||
|
expected_digest: selected.content_digest,
|
||||||
|
};
|
||||||
|
const candidate = await toolchain.applyChanges([change]);
|
||||||
|
treeState = { ...treeState, snapshot: candidate };
|
||||||
|
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
|
selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
|
||||||
|
source = selectedPath ? candidate.entries[selectedPath].content : "";
|
||||||
|
renamePath = selectedPath;
|
||||||
|
status = "Delete staged. Preview and Commit to persist the candidate tree.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameEntry() {
|
||||||
|
if (!treeState || !selected || !toolchain) return;
|
||||||
|
const to = renamePath.trim();
|
||||||
|
if (!to || to === selectedPath) return;
|
||||||
|
await stageCurrent();
|
||||||
|
const change: ConfigTreeChange = {
|
||||||
|
kind: "rename",
|
||||||
|
from: selectedPath,
|
||||||
|
to,
|
||||||
|
expected_digest: selected.content_digest,
|
||||||
|
};
|
||||||
|
const candidate = await toolchain.applyChanges([change]);
|
||||||
|
treeState = { ...treeState, snapshot: candidate };
|
||||||
|
if (baseSnapshot) draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
|
||||||
|
preflightDigest = "";
|
||||||
|
candidateContract = null;
|
||||||
|
conflict = false;
|
||||||
|
selectedPath = to;
|
||||||
|
source = candidate.entries[to]?.content ?? "";
|
||||||
|
status = `Rename to ${to} staged. Preview and Commit to persist.`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="config-source-shell" aria-label="Workspace configuration source tree">
|
||||||
|
<aside class="config-source-tree">
|
||||||
|
<div class="config-source-tree__header">
|
||||||
|
<strong>Source tree</strong>
|
||||||
|
<span>{paths.length}</span>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Virtual configuration paths">
|
||||||
|
{#each paths as path}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={path === selectedPath}
|
||||||
|
onclick={() => select(path)}
|
||||||
|
>{path}</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
|
||||||
|
<label for="new-config-path">New path</label>
|
||||||
|
<input id="new-config-path" bind:value={newPath} placeholder="workspace.dcdl" />
|
||||||
|
<button type="submit">Create draft</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="config-source-workbench">
|
||||||
|
<header class="config-source-workbench__header">
|
||||||
|
<div>
|
||||||
|
<span>Virtual path</span>
|
||||||
|
<strong>{selectedPath || "Select or create a source"}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="config-source-actions">
|
||||||
|
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || busy} />
|
||||||
|
<button type="button" onclick={renameEntry} disabled={!selected || renamePath === selectedPath || busy}>Rename</button>
|
||||||
|
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
|
||||||
|
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
||||||
|
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
||||||
|
<button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button>
|
||||||
|
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || busy}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<DecodalSourceEditor
|
||||||
|
value={source}
|
||||||
|
readonly={!selectedPath || busy}
|
||||||
|
onChange={(value) => source = value}
|
||||||
|
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
|
||||||
|
/>
|
||||||
|
<p class="config-source-status" aria-live="polite">{status}</p>
|
||||||
|
{#if conflict}
|
||||||
|
<div class="config-source-conflict" role="alert">
|
||||||
|
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button>
|
||||||
|
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if diagnostics.length > 0}
|
||||||
|
<ol class="config-source-diagnostics">
|
||||||
|
{#each diagnostics as diagnostic}
|
||||||
|
<li>
|
||||||
|
<strong>{diagnostic.kind}</strong>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
<small>bytes {diagnostic.span.start_byte}–{diagnostic.span.end_byte}</small>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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<T>(response: Response): Promise<T> {
|
||||||
|
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<WorkspaceConfigTreeResponse> {
|
||||||
|
return await readJson(
|
||||||
|
await fetcher(sourceTreeUrl(workspaceId), {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConfigEntry(
|
||||||
|
workspaceId: string,
|
||||||
|
path: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
): Promise<ConfigEntry> {
|
||||||
|
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<ConfigTreeSnapshot> {
|
||||||
|
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<EvaluatedConfigCandidate> {
|
||||||
|
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<WorkspaceConfigTreeResponse> {
|
||||||
|
return await readJson(
|
||||||
|
await fetcher(`${sourceTreeUrl(workspaceId)}/commit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
+65
@@ -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<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||||
|
*
|
||||||
|
* @returns {Promise<InitOutput>}
|
||||||
|
*/
|
||||||
|
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||||
@@ -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 };
|
||||||
Binary file not shown.
+20
@@ -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;
|
||||||
@@ -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<ConfigTreeChange>, entrypoints: Array<VirtualPath>, toolchain_fingerprint: string, };
|
||||||
@@ -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";
|
||||||
@@ -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<ConfigDiagnosticLabel>, notes: Array<string>, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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<ConfigTreeChange>, entrypoints: Array<VirtualPath>, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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 }, };
|
||||||
+6
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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<EvaluatedProjection>, projection_digest: string, };
|
||||||
@@ -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<VirtualPath>, import_policy_version: number, fingerprint: string, };
|
||||||
@@ -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;
|
||||||
@@ -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, };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts";
|
||||||
|
import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts";
|
||||||
|
|
||||||
|
type Command =
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id">
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "changes_between" }>, "id">
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id">
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id">
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id">
|
||||||
|
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">;
|
||||||
|
|
||||||
|
export class ConfigSourceToolchain {
|
||||||
|
#worker: Worker;
|
||||||
|
#nextId = 1;
|
||||||
|
#pending = new Map<number, { resolve: (value: unknown) => 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<ConfigSourceWorkerResponse>) => {
|
||||||
|
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<void> {
|
||||||
|
return this.#request({ kind: "set_snapshot", snapshot });
|
||||||
|
}
|
||||||
|
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
|
||||||
|
return this.#request({ kind: "apply_changes", changes });
|
||||||
|
}
|
||||||
|
changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise<ConfigTreeChange[]> {
|
||||||
|
return this.#request({ kind: "changes_between", base, candidate });
|
||||||
|
}
|
||||||
|
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
|
||||||
|
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<import("@codemirror/autocomplete").CompletionResult | null> {
|
||||||
|
return this.#request({ kind: "complete", path, source, utf16Offset, explicit });
|
||||||
|
}
|
||||||
|
format(source: string): Promise<string> {
|
||||||
|
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<T>(request: Command): Promise<T> {
|
||||||
|
const id = this.#nextId++;
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
this.#pending.set(id, { resolve: (value) => resolve(value as T), reject });
|
||||||
|
this.#worker.postMessage({ ...request, id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ConfigSourceWorkerRequest>): Promise<void> => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
import { decodal } from 'decodal-codemirror';
|
import { decodal } from 'decodal-codemirror';
|
||||||
@@ -9,11 +10,13 @@
|
|||||||
readonly = false,
|
readonly = false,
|
||||||
ariaLabel = 'Decodal source',
|
ariaLabel = 'Decodal source',
|
||||||
onChange = (_value: string) => {},
|
onChange = (_value: string) => {},
|
||||||
|
onComplete = undefined,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let host = $state<HTMLDivElement | null>(null);
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
const initialValue = untrack(() => value);
|
const initialValue = untrack(() => value);
|
||||||
const initialReadonly = untrack(() => readonly);
|
const initialReadonly = untrack(() => readonly);
|
||||||
const handleChange = untrack(() => onChange);
|
const handleChange = untrack(() => onChange);
|
||||||
|
const handleComplete = untrack(() => onComplete);
|
||||||
const editor = new EditorView({
|
const editor = new EditorView({
|
||||||
parent: host,
|
parent: host,
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
@@ -48,6 +52,10 @@
|
|||||||
drawSelection(),
|
drawSelection(),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
decodal(),
|
decodal(),
|
||||||
|
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
|
||||||
|
const doc = context.state.doc.toString();
|
||||||
|
return await handleComplete(doc, context.pos, context.explicit);
|
||||||
|
}] })] : []),
|
||||||
keymap.of([]),
|
keymap.of([]),
|
||||||
EditorState.readOnly.of(initialReadonly),
|
EditorState.readOnly.of(initialReadonly),
|
||||||
EditorView.editable.of(!initialReadonly),
|
EditorView.editable.of(!initialReadonly),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type Diagnostic = {
|
|||||||
export type SettingsSectionId =
|
export type SettingsSectionId =
|
||||||
| "runtime-connections"
|
| "runtime-connections"
|
||||||
| "runtime-inventory"
|
| "runtime-inventory"
|
||||||
|
| "configuration-sources"
|
||||||
| "profile-sources"
|
| "profile-sources"
|
||||||
| "backend-config"
|
| "backend-config"
|
||||||
| "workspace-identity";
|
| "workspace-identity";
|
||||||
@@ -98,6 +99,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
|||||||
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
|
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "configuration-sources",
|
||||||
|
label: "Configuration Sources",
|
||||||
|
status: "editable",
|
||||||
|
summary:
|
||||||
|
"Edit the Server-owned virtual Decodal source tree through one native/WASM toolchain contract.",
|
||||||
|
bullets: [
|
||||||
|
"Virtual paths and imports resolve inside the committed Workspace tree, never from browser or Server host paths.",
|
||||||
|
"Browser analysis is advisory; Server evaluation is required before an atomic revision commit.",
|
||||||
|
"Profile, Skill, Prompt, and Plugin consumers remain on their existing authorities until their follow-up cutovers.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "profile-sources",
|
id: "profile-sources",
|
||||||
label: "Profile Sources",
|
label: "Profile Sources",
|
||||||
@@ -160,6 +173,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
|||||||
return `${SETTINGS_ROUTE}/runtime-connections`;
|
return `${SETTINGS_ROUTE}/runtime-connections`;
|
||||||
case "runtime-inventory":
|
case "runtime-inventory":
|
||||||
return `${SETTINGS_ROUTE}/runtimes`;
|
return `${SETTINGS_ROUTE}/runtimes`;
|
||||||
|
case "configuration-sources":
|
||||||
|
return `${SETTINGS_ROUTE}/configuration`;
|
||||||
case "profile-sources":
|
case "profile-sources":
|
||||||
return `${SETTINGS_ROUTE}/profiles`;
|
return `${SETTINGS_ROUTE}/profiles`;
|
||||||
case "workspace-identity":
|
case "workspace-identity":
|
||||||
|
|||||||
@@ -515,6 +515,174 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
.config-source-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
|
||||||
|
min-height: 40rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
.config-source-tree {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
.config-source-tree__header,
|
||||||
|
.config-source-workbench__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.config-source-tree nav {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
.config-source-tree nav button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.config-source-tree nav button.active,
|
||||||
|
.config-source-tree nav button:hover {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.config-source-create {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.config-source-create label,
|
||||||
|
.config-source-workbench__header span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.config-source-create input,
|
||||||
|
.config-source-actions input {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-strong);
|
||||||
|
padding: 0.45rem;
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.config-source-create button,
|
||||||
|
.config-source-actions button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-strong);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.config-source-actions button.primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
.config-source-actions button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.config-source-actions button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.config-source-workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(20rem, 1fr) auto auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.config-source-workbench__header > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.config-source-workbench__header strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.config-source-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.config-source-status {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.config-source-conflict {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||||
|
}
|
||||||
|
.config-source-conflict button {
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.config-source-diagnostics {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.config-source-diagnostics li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.config-source-diagnostics strong {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.config-source-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.config-source-tree {
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.config-source-tree nav {
|
||||||
|
max-height: 12rem;
|
||||||
|
}
|
||||||
|
.config-source-workbench__header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status-message.error {
|
.status-message.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ConfigSourceEditor from "$lib/workspace/config-source/ConfigSourceEditor.svelte";
|
||||||
|
import type { PageProps } from "./$types";
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let workspaceId = $derived(data.workspace?.workspace_id ?? "");
|
||||||
|
let workspaceName = $derived(data.workspace?.display_name ?? "Workspace");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Configuration | {workspaceName}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="settings-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Workspace settings</p>
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<p>
|
||||||
|
Edit the Server-owned virtual Decodal source tree. Drafts stay in this browser;
|
||||||
|
the Server persists only a fully evaluated candidate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ConfigSourceEditor {workspaceId} />
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
|
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"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user