feat: register Workspace Profiles as Decodal values (T-710)
Project the evaluated Workspace Profile value into a revision-bound JSON archive without source-path registration or Decodal re-evaluation. Preserve partial-value omissions and explicit overrides, validate before save, and reject legacy registrations with a documented explicit migration.
Fix array-element schema completion and provide fingerprint-bound authoring shapes independently of materialization defaults. Carry metadata through REST, regenerate TypeScript/OpenAPI/WASM artifacts, and exercise save/projection/Runtime consumption and persisted Manifest restore boundaries.
Validation: root cargo check; cargo test -p config-source; cargo test -p worker-runtime; cargo test -p server-api --features typescript; focused profile_settings/value_profiles/archive tests; 39 web config-source/profile API tests; deno task check; cargo fmt --all -- --check; git diff --check HEAD.
Full workspace-server test: 641 passed, one pre-existing ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output assertion failure. The same filter fails at base 3c424fd17d in a separately archived source tree and isolated target directory. Remaining server crate tests pass with only that filter skipped (641 lib + 11 bin); unrelated checker code is unchanged.
This commit is contained in:
@@ -125,6 +125,35 @@ pub struct ProfileSourceArchiveInput {
|
||||
|
||||
impl ProfileSourceArchive {
|
||||
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
|
||||
Self::build_with_source_format(input, "decodal", "text/x-decodal")
|
||||
}
|
||||
|
||||
/// Archive an already evaluated Profile value without Decodal evaluation or conversion.
|
||||
pub fn build_evaluated_profile(
|
||||
id: String,
|
||||
selector: String,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
let path = "profiles/evaluated.json".to_string();
|
||||
let source = serde_json::to_string(&value)
|
||||
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
|
||||
Self::build_with_source_format(
|
||||
ProfileSourceArchiveInput {
|
||||
id,
|
||||
entrypoints: BTreeMap::from([(selector, path.clone())]),
|
||||
imports: BTreeMap::new(),
|
||||
sources: BTreeMap::from([(path, source)]),
|
||||
},
|
||||
"json",
|
||||
"application/json",
|
||||
)
|
||||
}
|
||||
|
||||
fn build_with_source_format(
|
||||
input: ProfileSourceArchiveInput,
|
||||
kind: &str,
|
||||
content_type: &str,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
if input.sources.len() > MAX_SOURCES {
|
||||
return Err(ProfileArchiveError::LimitExceeded("source count"));
|
||||
}
|
||||
@@ -145,8 +174,8 @@ impl ProfileSourceArchive {
|
||||
source_meta.push(ProfileSourceArchiveSource {
|
||||
path: path.clone(),
|
||||
source_key: path.clone(),
|
||||
kind: "decodal".to_string(),
|
||||
content_type: default_decodal_content_type(),
|
||||
kind: kind.to_string(),
|
||||
content_type: content_type.to_string(),
|
||||
digest: sha256_hex(source.as_bytes()),
|
||||
size_bytes: size,
|
||||
});
|
||||
@@ -288,7 +317,17 @@ impl VerifiedProfileSourceArchive {
|
||||
actual: source.source_key.clone(),
|
||||
});
|
||||
}
|
||||
if source.content_type != default_decodal_content_type() {
|
||||
let expected_content_type = match source.kind.as_str() {
|
||||
"decodal" => "text/x-decodal",
|
||||
"json" => "application/json",
|
||||
_ => {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.kind.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if source.content_type != expected_content_type {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.content_type.clone(),
|
||||
@@ -297,12 +336,6 @@ impl VerifiedProfileSourceArchive {
|
||||
if !manifest_paths.insert(source.path.clone()) {
|
||||
return Err(ProfileArchiveError::DuplicateSource(source.path.clone()));
|
||||
}
|
||||
if source.kind != "decodal" {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.kind.clone(),
|
||||
});
|
||||
}
|
||||
let content = entries
|
||||
.get(&source.path)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?;
|
||||
@@ -373,30 +406,50 @@ impl VerifiedProfileSourceArchive {
|
||||
.entrypoints
|
||||
.get(selector)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?;
|
||||
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
|
||||
let source = self
|
||||
.sources
|
||||
.get(path)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
||||
let module = engine.add_root_source(path, path, source).map_err(|err| {
|
||||
ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
let metadata = self
|
||||
.manifest
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| &source.path == path)
|
||||
.ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?;
|
||||
let json = match metadata.kind.as_str() {
|
||||
"json" => serde_json::from_str(source)
|
||||
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?,
|
||||
"decodal" => {
|
||||
let mut engine = Engine::new(ArchiveSourceLoader { archive: self });
|
||||
let module = engine.add_root_source(path, path, source).map_err(|err| {
|
||||
ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
}
|
||||
})?;
|
||||
let value =
|
||||
engine
|
||||
.eval_module(module)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let data =
|
||||
engine
|
||||
.materialize(&value)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
decodal_data_to_json(&data)
|
||||
}
|
||||
})?;
|
||||
let value = engine
|
||||
.eval_module(module)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let data = engine
|
||||
.materialize(&value)
|
||||
.map_err(|err| ProfileArchiveError::Decodal {
|
||||
path: path.clone(),
|
||||
message: format!("{err:?}"),
|
||||
})?;
|
||||
let json = decodal_data_to_json(&data);
|
||||
_ => {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: path.clone(),
|
||||
kind: metadata.kind.clone(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
json,
|
||||
ProfileSource::Archive {
|
||||
@@ -674,6 +727,171 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn evaluated_profile() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"slug": "evaluated",
|
||||
"description": "Evaluated profile",
|
||||
"scope": "workspace_read",
|
||||
"worker": { "mode": "wip" },
|
||||
"feature": {
|
||||
"task": { "enabled": true },
|
||||
"memory": { "enabled": true },
|
||||
"sub_worker": { "enabled": true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn evaluated_archive() -> ProfileSourceArchive {
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
"evaluated-archive".to_string(),
|
||||
"project:evaluated".to_string(),
|
||||
evaluated_profile(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_preserves_explicit_worker_mode_and_features() {
|
||||
let archive = evaluated_archive();
|
||||
let verified = archive.verify().unwrap();
|
||||
let source = &verified.manifest().sources[0];
|
||||
assert_eq!(source.kind, "json");
|
||||
assert_eq!(source.content_type, "application/json");
|
||||
assert_eq!(verified.manifest().imports.len(), 0);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&verified.sources[&source.path]).unwrap(),
|
||||
evaluated_profile()
|
||||
);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manifest = verified
|
||||
.resolve_profile("project:evaluated", root.path(), "evaluated-worker")
|
||||
.unwrap();
|
||||
let json = serde_json::to_value(&manifest).unwrap();
|
||||
assert_eq!(manifest.worker.name, "evaluated-worker");
|
||||
assert_eq!(json["worker"]["mode"], "wip");
|
||||
assert_eq!(json["feature"]["task"]["enabled"], true);
|
||||
assert_eq!(json["feature"]["memory"]["profile"]["enabled"], true);
|
||||
assert_eq!(json["feature"]["sub_worker"]["enabled"], true);
|
||||
}
|
||||
|
||||
// Recompute the outer digest so verification reaches the source-level checks.
|
||||
fn repack_archive(
|
||||
archive: &mut ProfileSourceArchive,
|
||||
manifest: &ProfileSourceArchiveManifest,
|
||||
sources: &BTreeMap<String, String>,
|
||||
) {
|
||||
let mut content = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut content);
|
||||
append_bytes(
|
||||
&mut builder,
|
||||
MANIFEST_PATH,
|
||||
&serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for (path, source) in sources {
|
||||
append_bytes(&mut builder, path, source.as_bytes()).unwrap();
|
||||
}
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
archive.reference.digest = sha256_hex(&content);
|
||||
archive.reference.size_bytes = content.len() as u64;
|
||||
archive.content = content;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_reuses_source_size_limit() {
|
||||
assert!(matches!(
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
"oversized".to_string(),
|
||||
"default".to_string(),
|
||||
serde_json::Value::String("x".repeat(MAX_SOURCE_BYTES as usize)),
|
||||
),
|
||||
Err(ProfileArchiveError::LimitExceeded("source bytes"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_rejects_archive_and_source_tampering() {
|
||||
let mut archive = evaluated_archive();
|
||||
archive.content[0] ^= 1;
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::ArchiveDigestMismatch { .. })
|
||||
));
|
||||
|
||||
let mut archive = evaluated_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
let path = verified.manifest.sources[0].path.clone();
|
||||
verified
|
||||
.sources
|
||||
.get_mut(&path)
|
||||
.unwrap()
|
||||
.replace_range(0..1, "[");
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::SourceDigestMismatch { path: actual, .. }) if actual == path
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_requires_matching_source_kind_and_content_type() {
|
||||
for (kind, content_type) in [
|
||||
("json", "text/x-decodal"),
|
||||
("decodal", "application/json"),
|
||||
("unknown", "application/json"),
|
||||
] {
|
||||
let mut archive = evaluated_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
verified.manifest.sources[0].kind = kind.to_string();
|
||||
verified.manifest.sources[0].content_type = content_type.to_string();
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::UnsupportedSource { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_does_not_fallback_to_filesystem_or_default() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let profiles = root.path().join("profiles");
|
||||
std::fs::create_dir(&profiles).unwrap();
|
||||
let local_profile = profiles.join("evaluated.json");
|
||||
std::fs::write(
|
||||
&local_profile,
|
||||
serde_json::to_vec(&evaluated_profile()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let verified = evaluated_archive().verify().unwrap();
|
||||
for selector in [
|
||||
"default",
|
||||
"profiles/evaluated.json",
|
||||
local_profile.to_str().unwrap(),
|
||||
] {
|
||||
assert!(matches!(
|
||||
verified.resolve_profile(selector, root.path(), "evaluated-worker"),
|
||||
Err(ProfileArchiveError::MissingEntrypoint(actual)) if actual == selector
|
||||
));
|
||||
}
|
||||
|
||||
// A valid local file must not rescue an invalid archived Profile value either.
|
||||
let invalid = ProfileSourceArchive::build_evaluated_profile(
|
||||
"invalid".to_string(),
|
||||
"project:evaluated".to_string(),
|
||||
serde_json::json!({ "not_a_profile_field": true }),
|
||||
)
|
||||
.unwrap()
|
||||
.verify()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
invalid.resolve_profile("project:evaluated", root.path(), "evaluated-worker"),
|
||||
Err(ProfileArchiveError::Profile { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_and_verify_archive() {
|
||||
let archive = sample_archive();
|
||||
|
||||
Reference in New Issue
Block a user