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:
2026-10-07 00:14:51 +09:00
parent 3c424fd17d
commit 58462d5844
24 changed files with 1708 additions and 319 deletions
+246 -28
View File
@@ -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();