chore: merge develop into hare/develop

This commit is contained in:
2026-10-07 18:03:33 +09:00
117 changed files with 27801 additions and 10459 deletions
+3
View File
@@ -16,8 +16,11 @@ axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
config-source.workspace = true
feature-storage.workspace = true
subjektiv.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
job.workspace = true
protocol = { workspace = true }
runtime-api.workspace = true
server-api.workspace = true
+387 -152
View File
@@ -7,26 +7,28 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use worker_runtime::identity::RuntimeWorkerRef;
use crate::{Error, Result};
pub const BACKEND_JOB_RUNTIME_ID: &str = crate::hosts::EMBEDDED_RUNTIME_ID;
pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8;
pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64;
pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120;
pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600;
pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024;
pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024;
pub const DEFAULT_MAX_ATTEMPTS: u8 = 2;
pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3;
pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024;
pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024;
pub const MAX_JOB_PURPOSE_BYTES: usize = 120;
pub const MAX_JOB_REFERENCE_BYTES: usize = 512;
pub const MAX_JOB_FAILURE_BYTES: usize = 1024;
pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024;
pub use job::{
ABSOLUTE_MAX_ATTEMPTS, ABSOLUTE_MAX_CONCURRENT_JOBS, ABSOLUTE_MAX_RESULT_BYTES,
DEFAULT_JOB_TIMEOUT_SECONDS, DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_CONCURRENT_JOBS,
DEFAULT_MAX_RESULT_BYTES, JobResultSubmission as BackendJobResultSubmission,
MAX_JOB_DELIVERY_BYTES, MAX_JOB_FAILURE_BYTES, MAX_JOB_INPUT_BYTES, MAX_JOB_INSTRUCTION_BYTES,
MAX_JOB_PURPOSE_BYTES, MAX_JOB_REFERENCE_BYTES, MAX_JOB_TIMEOUT_SECONDS, attempt_id,
bounded_failure_detail,
};
// Preserve the Backend InvalidInput surface and its exact historical messages.
fn backend_error(error: job::JobError) -> Error {
match error {
job::JobError::InvalidInput(detail) => {
Error::InvalidInput(detail.replacen("Job ", "Backend Job ", 1))
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -37,6 +39,17 @@ pub enum BackendJobState {
Unknown,
}
impl From<BackendJobState> for job::JobState {
fn from(state: BackendJobState) -> Self {
match state {
BackendJobState::Pending => Self::Pending,
BackendJobState::Completed => Self::Completed,
BackendJobState::Failed => Self::Failed,
BackendJobState::Unknown => Self::Unknown,
}
}
}
impl BackendJobState {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -69,6 +82,19 @@ pub enum BackendJobAttemptState {
Unknown,
}
impl From<BackendJobAttemptState> for job::JobAttemptState {
fn from(state: BackendJobAttemptState) -> Self {
match state {
BackendJobAttemptState::Reserved => Self::Reserved,
BackendJobAttemptState::Dispatching => Self::Dispatching,
BackendJobAttemptState::Dispatched => Self::Dispatched,
BackendJobAttemptState::Completed => Self::Completed,
BackendJobAttemptState::Failed => Self::Failed,
BackendJobAttemptState::Unknown => Self::Unknown,
}
}
}
impl BackendJobAttemptState {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -161,28 +187,29 @@ impl Default for BackendJobLimits {
impl BackendJobLimits {
pub fn validate(&self) -> Result<()> {
if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS
{
return Err(Error::InvalidInput(format!(
"Backend Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}"
)));
job::JobLimits::from(self).validate().map_err(backend_error)
}
}
impl From<&BackendJobLimits> for job::JobLimits {
fn from(limits: &BackendJobLimits) -> Self {
Self {
max_concurrent_jobs: limits.max_concurrent_jobs,
timeout_seconds: limits.timeout_seconds,
max_result_bytes: limits.max_result_bytes,
max_attempts: limits.max_attempts,
}
if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS {
return Err(Error::InvalidInput(format!(
"Backend Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}"
)));
}
}
impl From<job::JobLimits> for BackendJobLimits {
fn from(limits: job::JobLimits) -> Self {
Self {
max_concurrent_jobs: limits.max_concurrent_jobs,
timeout_seconds: limits.timeout_seconds,
max_result_bytes: limits.max_result_bytes,
max_attempts: limits.max_attempts,
}
if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES {
return Err(Error::InvalidInput(format!(
"Backend Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}"
)));
}
if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS {
return Err(Error::InvalidInput(format!(
"Backend Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}"
)));
}
Ok(())
}
}
@@ -261,17 +288,29 @@ pub struct BackendJobRequest {
}
impl BackendJobRequest {
pub fn validate(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
validate_identifier("profile", &self.profile, 256)?;
self.profile_selector()?;
if let Some(key) = &self.serialization_key {
validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?;
/// Adapt neutral intent without granting domain access or writer/notification
/// authority. Validation remains the Backend submission boundary's job;
/// the caller-selected profile is retained exactly, with no fallback.
pub fn from_job_request(request: job::JobRequest) -> Self {
Self {
job_id: request.job_id,
purpose: request.purpose,
input_revision: request.input_revision,
input_ref: request.input_ref,
input: request.input,
instruction: request.instruction,
profile: request.profile,
grants: BackendJobGrants::default(),
serialization_key: request.serialization_key,
source_worker: None,
notification_target: None,
limits: request.limits.into(),
}
}
pub fn validate(&self) -> Result<()> {
let request = self.to_job_request();
request.validate_fields().map_err(backend_error)?;
if let Some(grant) = &self.grants.subjektiv_consolidation {
validate_identifier("grant subject_id", &grant.subject_id, 256)?;
if grant.candidate_ids.len() > 256 {
@@ -297,44 +336,30 @@ impl BackendJobRequest {
));
}
}
let input = serde_json::to_vec(&self.input).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
})?;
if input.len() > MAX_JOB_INPUT_BYTES {
return Err(Error::InvalidInput(format!(
"Backend Job input exceeds {MAX_JOB_INPUT_BYTES} bytes"
)));
}
request.validate_input().map_err(backend_error)?;
self.limits.validate()
}
/// Transport-neutral projection only. Domain grants, writer authority and
/// derived resource locks remain Backend-owned; never fingerprint this
/// projection as a substitute for the full Backend intent.
pub fn to_job_request(&self) -> job::JobRequest {
job::JobRequest {
job_id: self.job_id.clone(),
purpose: self.purpose.clone(),
input_revision: self.input_revision.clone(),
input_ref: self.input_ref.clone(),
input: self.input.clone(),
instruction: self.instruction.clone(),
profile: self.profile.clone(),
serialization_key: self.serialization_key.clone(),
limits: (&self.limits).into(),
}
}
/// Validate selector syntax only. Registry existence and resolution are dispatch authority.
pub fn profile_selector(&self) -> Result<manifest::ProfileSelector> {
use manifest::ProfileSelector;
validate_identifier("profile", &self.profile, 256)?;
let selector = ProfileSelector::parse_cli(&self.profile);
let valid = match &selector {
ProfileSelector::Default => true,
ProfileSelector::Path { .. } => false,
ProfileSelector::Named { source, name } => {
!name.is_empty()
&& !name.starts_with('.')
&& !name.contains(['/', '\\', ':'])
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
&& ![".toml", ".json", ".dcdl", ".nix"]
.iter()
.any(|suffix| name.ends_with(suffix))
&& (source.is_some() || name != "inherit")
}
};
if !valid {
return Err(Error::InvalidInput(
"Backend Job profile must be a manifest registry selector, not raw source or a path".into(),
));
}
Ok(selector)
job::profile_selector(&self.profile).map_err(backend_error)
}
/// Effective single-flight key. Typed grants cannot bypass their subject fence.
@@ -348,20 +373,12 @@ impl BackendJobRequest {
pub fn fingerprint(&self) -> Result<String> {
self.validate()?;
let encoded = serde_json::to_vec(self).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job intent: {error}"))
})?;
Ok(sha256(&encoded))
job::fingerprint(self).map_err(backend_error)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
let input = serde_json::to_string(&self.input).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
})?;
Ok(format!(
"{}\n\nBackend Job envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority.",
self.instruction, self.job_id, attempt_id, self.input_revision, self.input_ref, input
))
job::worker_input_with_label(&self.to_job_request(), attempt_id, "Backend Job")
.map_err(backend_error)
}
}
@@ -451,13 +468,10 @@ pub struct BackendJobReservation {
pub resource_reused: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BackendJobResultSubmission {
pub job_id: String,
pub attempt_id: String,
pub input_revision: String,
pub result: Value,
impl BackendJobReservation {
pub fn outcome(&self) -> job::JobOutcome {
job_outcome(&self.job, &self.attempt)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -467,6 +481,28 @@ pub struct BackendJobResultAcceptance {
pub replayed: bool,
}
impl BackendJobResultAcceptance {
pub fn outcome(&self) -> job::JobOutcome {
job_outcome(&self.job, &self.attempt)
}
}
// Project the durable Job outcome, not Worker lifecycle, cleanup or delivery.
// The paired attempt supplies its own identity, number and execution state.
fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome {
job::JobOutcome {
job_id: job.request.job_id.clone(),
input_revision: job.request.input_revision.clone(),
attempt_id: attempt.attempt_id.clone(),
attempt: attempt.attempt,
state: job.state.into(),
attempt_state: attempt.state.into(),
result: job.result.clone(),
failure_category: job.failure_category.clone(),
failure_detail: job.failure_detail.clone(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendJobWorkerBinding {
pub job_id: String,
@@ -489,10 +525,6 @@ pub struct BackendJobDeliveryRecord {
pub delivered_at: Option<String>,
}
pub fn attempt_id(job_id: &str, attempt: u8) -> String {
format!("{job_id}:attempt:{attempt}")
}
pub fn allocation_key(attempt_id: &str) -> String {
format!("backend-job:{attempt_id}")
}
@@ -515,64 +547,15 @@ pub fn delivery_id(job_id: &str, attempt_id: &str) -> String {
}
fn tracked_request_id(kind: &str, identity: &[u8]) -> String {
format!("backend-job-{kind}:{}", sha256(identity))
format!("backend-job-{kind}:{}", job::sha256(identity))
}
pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> {
let encoded = serde_json::to_string(result)
.map_err(|error| Error::InvalidInput(format!("serialize Backend Job result: {error}")))?;
if encoded.len() > max_bytes as usize {
return Err(Error::InvalidInput(format!(
"Backend Job result exceeds {max_bytes} bytes"
)));
}
Ok((sha256(encoded.as_bytes()), encoded))
}
pub(crate) fn bounded_failure_detail(detail: &str) -> String {
if detail.len() <= MAX_JOB_FAILURE_BYTES {
return detail.to_string();
}
let mut end = MAX_JOB_FAILURE_BYTES;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail[..end].to_string()
job::result_digest(result, max_bytes).map_err(backend_error)
}
fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> {
validate_bounded_text(field, value, max_bytes)?;
if value.chars().any(char::is_control) {
return Err(Error::InvalidInput(format!(
"Backend Job {field} must not contain control characters"
)));
}
Ok(())
}
fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> {
if value.is_empty() || value.trim() != value {
return Err(Error::InvalidInput(format!(
"Backend Job {field} must be non-empty and trimmed"
)));
}
if value.len() > max_bytes {
return Err(Error::InvalidInput(format!(
"Backend Job {field} exceeds {max_bytes} bytes"
)));
}
Ok(())
}
fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(7 + digest.len() * 2);
output.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
job::validate_identifier(field, value, max_bytes).map_err(backend_error)
}
#[cfg(test)]
@@ -596,6 +579,258 @@ mod tests {
}
}
fn reservation() -> BackendJobReservation {
serde_json::from_value(serde_json::json!({
"job": {
"workspace_id": "workspace-a",
"request": request(),
"intent_fingerprint": request().fingerprint().unwrap(),
"state": "pending",
"current_attempt": 2,
"created_at": "created",
"updated_at": "updated"
},
"attempt": {
"workspace_id": "workspace-a",
"job_id": "ticket-check:T-1:r7",
"attempt_id": "ticket-check:T-1:r7:attempt:2",
"attempt": 2,
"input_revision": "7",
"state": "reserved",
"worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"},
"runtime_run_id": "run-a",
"deadline_at": "deadline",
"created_at": "created",
"updated_at": "updated"
},
"replayed": false,
"resource_reused": false
}))
.unwrap()
}
#[test]
fn backend_state_projections_preserve_existing_wire_vocabulary() {
for state in [
BackendJobState::Pending,
BackendJobState::Completed,
BackendJobState::Failed,
BackendJobState::Unknown,
] {
let shared = job::JobState::from(state);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::to_value(shared).unwrap()
);
}
for state in [
BackendJobAttemptState::Reserved,
BackendJobAttemptState::Dispatching,
BackendJobAttemptState::Dispatched,
BackendJobAttemptState::Completed,
BackendJobAttemptState::Failed,
BackendJobAttemptState::Unknown,
] {
let shared = job::JobAttemptState::from(state);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::to_value(shared).unwrap()
);
}
// The shared read vocabulary does not add Backend cancellation support.
assert!(BackendJobState::parse("cancelled").is_err());
assert!(BackendJobAttemptState::parse("cancelled").is_err());
assert!(serde_json::from_str::<BackendJobState>("\"cancelled\"").is_err());
assert!(serde_json::from_str::<BackendJobAttemptState>("\"cancelled\"").is_err());
}
#[test]
fn neutral_request_round_trip_does_not_infer_authority_or_replace_profile() {
for profile in ["builtin:backend-job", "builtin:job", "project:custom-job"] {
for key in [None, Some("resource:7".to_string())] {
let mut neutral = request().to_job_request();
neutral.profile = profile.into();
neutral.serialization_key = key;
neutral.limits = job::JobLimits {
max_concurrent_jobs: 3,
timeout_seconds: 17,
max_result_bytes: 777,
max_attempts: 1,
};
let backend = BackendJobRequest::from_job_request(neutral.clone());
backend.validate().unwrap();
assert_eq!(backend.to_job_request(), neutral);
assert!(backend.grants.is_empty());
assert!(backend.source_worker.is_none());
assert!(backend.notification_target.is_none());
let encoded = serde_json::to_value(&backend).unwrap();
for field in ["grants", "source_worker", "notification_target"] {
assert!(encoded.get(field).is_none());
}
assert_eq!(encoded["profile"], profile);
}
}
// Conversion is not validation and must not normalize an invalid request.
let mut neutral = request().to_job_request();
neutral.limits.timeout_seconds = 0;
neutral.profile = "inherit".into();
let backend = BackendJobRequest::from_job_request(neutral.clone());
assert_eq!(backend.to_job_request(), neutral);
assert!(backend.validate().is_err());
}
#[test]
fn reservation_and_acceptance_outcomes_expose_durable_job_and_attempt_not_host_lifecycle() {
let mut reservation = reservation();
let encoded_before = serde_json::to_value(&reservation).unwrap();
assert_eq!(
reservation.outcome(),
job::JobOutcome {
job_id: reservation.job.request.job_id.clone(),
input_revision: "7".into(),
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
attempt: 2,
state: job::JobState::Pending,
attempt_state: job::JobAttemptState::Reserved,
result: None,
failure_category: None,
failure_detail: None,
}
);
assert_eq!(serde_json::to_value(&reservation).unwrap(), encoded_before);
for (state, attempt_state) in [
(
BackendJobState::Pending,
BackendJobAttemptState::Dispatching,
),
(BackendJobState::Pending, BackendJobAttemptState::Dispatched),
(BackendJobState::Failed, BackendJobAttemptState::Failed),
(BackendJobState::Unknown, BackendJobAttemptState::Unknown),
] {
reservation.job.state = state;
reservation.attempt.state = attempt_state;
reservation.job.failure_category = Some("job-category".into());
reservation.job.failure_detail = Some("job-detail".into());
reservation.attempt.failure_category = Some("attempt-category".into());
let outcome = reservation.outcome();
assert_eq!(outcome.state, state.into());
assert_eq!(outcome.attempt_state, attempt_state.into());
assert_eq!(outcome.failure_category.as_deref(), Some("job-category"));
assert_eq!(outcome.failure_detail.as_deref(), Some("job-detail"));
assert!(outcome.result.is_none());
let accepted = BackendJobResultAcceptance {
job: reservation.job.clone(),
attempt: reservation.attempt.clone(),
replayed: true,
};
assert_eq!(accepted.outcome(), outcome);
}
reservation.job.state = BackendJobState::Completed;
reservation.attempt.state = BackendJobAttemptState::Completed;
reservation.job.result = Some(serde_json::json!({"ok": true}));
reservation.job.failure_category = None;
reservation.job.failure_detail = None;
reservation.attempt.worker_cleanup_state = Some(BackendJobWorkerCleanupState::Failed);
reservation.attempt.worker_cleanup_failure_detail =
Some("cleanup is not model failure".into());
reservation.replayed = true;
reservation.resource_reused = true;
let acceptance = BackendJobResultAcceptance {
job: reservation.job.clone(),
attempt: reservation.attempt.clone(),
replayed: false,
};
let outcome = acceptance.outcome();
assert_eq!(outcome, reservation.outcome());
assert_eq!(outcome.state, job::JobState::Completed);
assert_eq!(outcome.result, Some(serde_json::json!({"ok": true})));
assert!(outcome.failure_category.is_none());
assert!(outcome.failure_detail.is_none());
let encoded = serde_json::to_value(outcome).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 9);
for field in [
"workspace_id",
"worker",
"runtime_run_id",
"request",
"replayed",
"resource_reused",
"worker_cleanup_state",
] {
assert!(encoded.get(field).is_none());
}
}
#[test]
fn extraction_preserves_backend_wire_fingerprint_and_worker_envelope() {
let request = request();
let encoded = serde_json::to_string(&request).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_revision":"7","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
);
assert_eq!(
request.fingerprint().unwrap(),
"sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60"
);
let neutral = request.to_job_request();
neutral.validate().unwrap();
assert_ne!(
request.fingerprint().unwrap(),
neutral.fingerprint().unwrap()
);
assert_eq!(
serde_json::to_value(&request.limits).unwrap(),
serde_json::to_value(&neutral.limits).unwrap()
);
assert_eq!(
request.worker_input("attempt-1").unwrap(),
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_revision: 7\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
);
}
#[test]
fn shared_result_contract_preserves_backend_wire_and_errors() {
let submission = BackendJobResultSubmission {
job_id: "job-1".into(),
attempt_id: "job-1:attempt:1".into(),
input_revision: "7".into(),
result: serde_json::json!({"ok": true}),
};
let encoded = serde_json::to_string(&submission).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"#
);
let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap();
assert_eq!(neutral, submission);
assert_eq!(
result_digest(&submission.result, 128).unwrap(),
job::result_digest(&submission.result, 128).unwrap()
);
assert!(
matches!(result_digest(&submission.result, 4), Err(Error::InvalidInput(detail)) if detail == "Backend Job result exceeds 4 bytes")
);
let mut request = request();
request.job_id = "".into();
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job job_id must be non-empty and trimmed")
);
request.job_id = "job-1".into();
request.limits.timeout_seconds = 0;
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job timeout_seconds must be in 1..=600")
);
// Preserve domain-before-input/limits error ordering as well.
request.grants.subjektiv_consolidation = Some(SubjektivConsolidationGrant {
subject_id: "".into(),
candidate_ids: vec![],
});
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job grant subject_id must be non-empty and trimmed")
);
}
#[test]
fn fingerprint_covers_revision_profile_limits_and_notification_provenance() {
let request = request();
+5 -1
View File
@@ -140,7 +140,11 @@ pub(crate) fn validate_workspace_config_candidate_projections(
};
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
if has_provider("builtin:profile") {
crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?;
crate::profile_settings::project_profiles_from_evaluation(
workspace_id,
&state,
&candidate.evaluation,
)?;
}
if has_provider("builtin:runtime") {
crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?;
File diff suppressed because it is too large Load Diff
+162 -241
View File
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::path::Path;
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
use config_source::ConfigSchemaContribution;
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
use serde::Deserialize;
use server_api::{
@@ -21,24 +21,19 @@ use crate::config_source::{
use crate::store::WorkspaceRecord;
use crate::{Error, Result};
const PROFILE_SCHEMA_SOURCE: &str = r#"{
profile = {
default_profile = String default "builtin:companion";
entries = [...{
selector = String;
source = String;
label = String default "";
description = String default "";
}] default [];
};
}"#;
const PROFILE_SCHEMA_SOURCE: &str = include_str!("../../../resources/config-schema/profile.dcdl");
#[derive(Debug, Default)]
pub struct ProfileConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE)
ConfigSchemaContribution::new("builtin:profile", "profile", "2", PROFILE_SCHEMA_SOURCE)
.map(|schema| {
schema.with_authoring_source(include_str!(
"../../../resources/config-schema/profile-authoring.dcdl"
))
})
.map_err(|error| Error::Config(error.to_string()))
}
}
@@ -59,7 +54,7 @@ struct VirtualProfileSection {
#[serde(deny_unknown_fields)]
struct VirtualProfileEntry {
selector: String,
source: String,
profile: serde_json::Value,
label: String,
description: String,
}
@@ -68,7 +63,6 @@ struct VirtualProfileEntry {
pub struct ProfileConfigProjection {
pub settings: ProfileSettingsResponse,
entries: BTreeMap<String, VirtualProfileEntry>,
sources: BTreeMap<String, String>,
}
pub fn project_profiles_from_workspace_config(
@@ -96,23 +90,42 @@ pub fn project_profiles_from_workspace_config(
"Profile projection digest mismatch for Workspace {workspace_id}"
)));
}
project_profiles_from_evaluation(workspace_id, state, &evaluation)
}
pub(crate) fn project_profiles_from_evaluation(
workspace_id: &str,
state: &WorkspaceConfigState,
evaluation: &config_source::EvaluationResult,
) -> Result<ProfileConfigProjection> {
if evaluation.projection_digest != state.projection_digest {
return Err(Error::Config(
"Profile evaluation does not match Workspace config state".into(),
));
}
let projected = evaluation.projections.first().ok_or_else(|| {
Error::RegistryInconsistency("Workspace config has no active projection".to_string())
})?;
if projected
.data_json
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
.is_some_and(|entries| entries.iter().any(|entry| entry.get("source").is_some()))
{
return Err(profile_validation_error(
"profile_source_registration_removed",
"profile.entries.source is no longer supported: replace source = \"profiles/name.dcdl\" with profile = import \"./profiles/name.dcdl\" (relative to the containing config file). Saved config is not rewritten.",
));
}
let config: VirtualProfileConfig = serde_json::from_value(projected.data_json.clone())
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile));
let mut entries = BTreeMap::new();
let sources = state
.snapshot
.entries
.iter()
.filter(|(_, entry)| entry.content_type == ConfigContentType::Decodal)
.map(|(path, entry)| (path.as_str().to_string(), entry.content.clone()))
.collect::<BTreeMap<_, _>>();
let mut source_summaries = Vec::new();
for entry in config.profile.entries {
if !entry.selector.starts_with("project:") {
if !entry.selector.starts_with("project:")
|| entry.selector.trim_start_matches("project:").is_empty()
{
return Err(profile_validation_error(
"profile_selector_invalid",
"Workspace Profile selectors must use project:*",
@@ -124,61 +137,43 @@ pub fn project_profiles_from_workspace_config(
"Workspace Profile selectors must be unique",
));
}
let source_path = VirtualPath::parse(&entry.source).map_err(|error| {
profile_validation_error("profile_source_path_invalid", &error.to_string())
})?;
let source_entry = state.snapshot.get(&source_path).ok_or_else(|| {
profile_validation_error(
"profile_source_missing",
&format!(
"Profile source {:?} is missing from the active config revision",
entry.source
),
)
})?;
if source_entry.content_type != ConfigContentType::Decodal {
return Err(profile_validation_error(
"profile_source_type_invalid",
"Profile sources must use Decodal content",
));
}
let archive_source = entry.source.clone();
let label = if entry.label.is_empty() {
entry.selector.trim_start_matches("project:").to_string()
} else {
entry.label.clone()
};
resolve_profile_artifact_value(
evaluate_profile_source(&state.snapshot, &source_path)?,
entry.profile.clone(),
ProfileSource::Archive {
archive_id: format!("workspace-config-r{}", state.snapshot.revision),
source: archive_source.clone(),
source: entry.selector.clone(),
},
Path::new("/"),
"workspace-config-validation",
)
.map_err(|error| profile_validation_error("profile_source_invalid", &error.to_string()))?;
.map_err(|error| profile_validation_error("profile_value_invalid", &error.to_string()))?;
profiles.push(WorkspaceProfileSummary {
profile_id: entry.selector.clone(),
selector: entry.selector.clone(),
label,
source_kind: "project".to_string(),
profile_source_id: Some(entry.source.clone()),
profile_source_id: Some(entry.selector.clone()),
description: (!entry.description.is_empty()).then(|| entry.description.clone()),
editable: false,
is_default: config.profile.default_profile == entry.selector,
diagnostics: Vec::new(),
});
let value_bytes = serde_json::to_vec(&entry.profile).expect("Profile value serializes");
source_summaries.push(WorkspaceProfileSourceSummary {
profile_source_id: entry.source.clone(),
display_path: entry.source.clone(),
kind: "virtual_config".to_string(),
content_type: "decodal".to_string(),
content_digest: source_entry.content_digest.clone(),
profile_source_id: entry.selector.clone(),
display_path: format!("profile.entries[{}].profile", source_summaries.len()),
kind: "evaluated_config".to_string(),
content_type: "application/json".to_string(),
content_digest: config_source::digest_bytes(&value_bytes),
provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree,
editable: false,
revision: state.snapshot.revision.to_string(),
size_bytes: source_entry.content.len() as u64,
size_bytes: value_bytes.len() as u64,
diagnostics: Vec::new(),
});
entries.insert(entry.selector.clone(), entry);
@@ -198,46 +193,16 @@ pub fn project_profiles_from_workspace_config(
registry_revision: format!("config:{}", state.snapshot.revision),
config_revision: Some(state.snapshot.revision),
tree_digest: Some(state.snapshot.digest.clone()),
projection_digest: Some(evaluation.projection_digest),
projection_digest: Some(evaluation.projection_digest.clone()),
default_profile: Some(config.profile.default_profile),
profiles,
sources: source_summaries,
diagnostics: Vec::new(),
},
entries,
sources,
})
}
fn evaluate_profile_source(
snapshot: &config_source::ConfigTreeSnapshot,
source_path: &VirtualPath,
) -> Result<serde_json::Value> {
let contract = config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![source_path.clone()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
config_source::WorkspaceConfigSchemaBundle::empty(),
);
let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.map_err(|diagnostics| {
profile_validation_error(
"profile_source_invalid",
&serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "Profile source evaluation failed".to_string()),
)
})?;
evaluation
.projections
.into_iter()
.next()
.map(|projection| projection.data_json)
.ok_or_else(|| {
profile_validation_error("profile_source_invalid", "Profile source has no projection")
})
}
pub fn selector_for_workspace_candidate(
projection: &ProfileConfigProjection,
profile: &str,
@@ -420,6 +385,16 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
selector: &str,
prompt_projection: &worker::WorkspacePromptProjection,
) -> Result<Option<ConfigBundle>> {
if projection.settings.workspace_id != workspace_id
|| projection.settings.config_revision != Some(state.snapshot.revision)
|| projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str())
|| projection.settings.projection_digest.as_deref()
!= Some(state.projection_digest.as_str())
{
return Err(Error::Config(
"Profile projection does not match Workspace config state".into(),
));
}
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
let prompt_catalog = prompt_projection.catalog().clone();
let profile_selector =
@@ -429,12 +404,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
))
})?;
let archive = match projection.entries.get(selector) {
Some(entry) => Some(build_virtual_profile_archive(
selector,
entry,
&projection.sources,
state,
)?),
Some(entry) => Some(build_virtual_profile_archive(selector, entry, state)?),
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
};
let bundle_id = virtual_profile_bundle_id(
@@ -492,19 +462,24 @@ pub(crate) fn resolve_profile_manifest_from_config_bundle(
fn build_virtual_profile_archive(
selector: &str,
entry: &VirtualProfileEntry,
all_sources: &BTreeMap<String, String>,
state: &WorkspaceConfigState,
) -> Result<ProfileSourceArchive> {
let mut closure = BTreeMap::new();
let mut imports = BTreeMap::new();
collect_profile_import_closure(&entry.source, all_sources, &mut closure, &mut imports)?;
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: format!("workspace-config-profile-r{}", state.snapshot.revision),
entrypoints: BTreeMap::from([(selector.to_string(), entry.source.clone())]),
imports,
sources: closure,
})
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
let builtin_sources = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.builtin_import_sources(&state.contract)
.map_err(|diagnostics| {
profile_validation_error(
"profile_builtin_snapshot_invalid",
&serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "builtin import snapshot failed".to_string()),
)
})?;
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
format!("workspace-config-profile-r{}", state.snapshot.revision),
selector.to_string(),
entry.profile.clone(),
builtin_sources,
)
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
}
pub fn workspace_metadata_settings(
@@ -576,124 +551,6 @@ fn profile_validation_error(code: impl Into<String>, message: impl Into<String>)
}
}
fn collect_profile_import_closure(
current_path: &str,
all_sources: &BTreeMap<String, String>,
closure_sources: &mut BTreeMap<String, String>,
imports: &mut BTreeMap<String, String>,
) -> Result<()> {
if closure_sources.contains_key(current_path) {
return Ok(());
}
let content = all_sources.get(current_path).ok_or_else(|| {
profile_validation_error(
"profile_source_import_missing",
&format!("Profile source import closure is missing {current_path}"),
)
})?;
closure_sources.insert(current_path.to_string(), content.clone());
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if !all_sources.contains_key(&target) {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
imports.insert(format!("{current_path}\0{specifier}"), target.clone());
collect_profile_import_closure(&target, all_sources, closure_sources, imports)?;
}
Ok(())
}
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be a virtual relative path",
));
}
let raw = specifier
.strip_prefix("project:")
.or_else(|| specifier.strip_prefix("workspace:"))
.unwrap_or(specifier);
if specifier.contains(':') && raw == specifier {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Unsupported profile source import namespace",
));
}
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
PathBuf::from(raw)
} else {
Path::new(current_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
};
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
Ok(normalized)
}
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir => {
if !normalized.pop() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import escapes the virtual tree",
));
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be relative",
));
}
}
}
Ok(normalized.to_string_lossy().replace('\\', "/"))
}
fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
continue;
}
let Some(index) = trimmed.find("import") else {
continue;
};
let after = trimmed[index + "import".len()..].trim_start();
let Some(first) = after.chars().next() else {
continue;
};
if first == '"' || first == '\'' {
if let Some(end) = after[1..].find(first) {
specifiers.push(after[1..1 + end].to_string());
}
} else {
let ident: String = after
.chars()
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
.collect();
if !ident.is_empty() {
specifiers.push(ident);
}
}
}
specifiers
}
pub fn selector_for_builtin_candidate(
id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> {
@@ -711,6 +568,7 @@ pub fn selector_for_builtin_candidate(
#[cfg(test)]
mod tests {
use super::*;
use config_source::{ConfigContentType, VirtualPath};
#[test]
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
@@ -779,7 +637,7 @@ mod tests {
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#,
)
.unwrap(),
config_source::ConfigEntry::new(
@@ -819,18 +677,18 @@ mod tests {
.entrypoints
.get("project:alpha")
.map(String::as_str),
Some("profiles/alpha.dcdl")
Some("profiles/evaluated.json")
);
assert_eq!(archive.reference.source_graph.source_count, 1);
}
#[test]
fn virtual_config_projection_preserves_import_closure() {
fn virtual_config_projection_materializes_import_closure() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }]; }; }"#,
)
.unwrap(),
config_source::ConfigEntry::new(
@@ -857,8 +715,8 @@ mod tests {
.unwrap()
.unwrap();
let archive = bundle.profile_source_archive.unwrap();
assert_eq!(archive.reference.source_graph.source_count, 2);
assert_eq!(archive.reference.source_graph.import_count, 1);
assert_eq!(archive.reference.source_graph.source_count, 1);
assert_eq!(archive.reference.source_graph.import_count, 0);
}
#[test]
@@ -955,8 +813,8 @@ mod tests {
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [
{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; },
{ selector = "project:beta"; source = "profiles/beta.dcdl"; },
{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; },
{ selector = "project:beta"; profile = import "./profiles/beta.dcdl"; },
]; }; }"#,
)
.unwrap(),
@@ -1002,20 +860,83 @@ mod tests {
}
#[test]
fn virtual_config_projection_rejects_missing_profile_source() {
let state = virtual_state(vec![
config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(),
ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/missing.dcdl"; }]; }; }"#,
)
.unwrap(),
]);
let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err();
assert!(
error
.to_string()
.contains("missing from the active config revision")
fn value_profile_authoring_uses_current_provider_schema_without_inserting_defaults() {
let bundle =
config_source::WorkspaceConfigSchemaBundle::compose([ProfileConfigSchemaProvider
.contribution()
.unwrap()])
.unwrap();
let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new(
VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
).unwrap()]).unwrap();
let environment = config_source::SnapshotEnvironment::new(snapshot.clone())
.with_schema_bundle(bundle.clone());
for (body, token, label) in [
(
"{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema",
"sel",
"selector",
),
(
"{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema",
"pro",
"profile",
),
(
"{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema",
"wor",
"worker",
),
(
"{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema",
"mo",
"mode",
),
(
"{ profile = { entries = [{ profile = { feature = { ti } } }] } } as WorkspaceConfigSchema",
"ti",
"ticket",
),
] {
let offset = body.rfind(token).unwrap() + token.len();
let completion = environment
.complete_config(
&VirtualPath::parse("main.dcdl").unwrap(),
body,
offset,
true,
)
.unwrap()
.unwrap();
assert!(
completion.items.iter().any(|item| item.label == label),
"{label}: {completion:?}"
);
}
for invalid in [
r#"{ profile = { entries = [{ selector = 42; profile = {}; }]; }; } as WorkspaceConfigSchema"#,
r#"{ profile = { entries = [{ selector = "project:alpha"; profile = 42; }]; }; } as WorkspaceConfigSchema"#,
] {
assert!(
!environment
.analyze(&VirtualPath::parse("main.dcdl").unwrap(), Some(invalid))
.is_empty()
);
}
let evaluation = environment
.evaluate_contract(&config_source::ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![VirtualPath::parse("main.dcdl").unwrap()],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
))
.unwrap();
assert_eq!(
evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile"),
Some(&serde_json::json!({}))
);
}
}
File diff suppressed because it is too large Load Diff
@@ -151,23 +151,7 @@ fn bounded_candidate_batch(
ids: impl IntoIterator<Item = String>,
max_result_bytes: u32,
) -> Result<Vec<String>> {
// Reserve the bounded subject and Host surface envelope. Each candidate ID
// appears twice (input claims and verified outcome); account for JSON escaping,
// not raw UTF-8 bytes. The fixed per-item allowance covers action and framing.
let mut estimated_bytes = 2_048_usize;
let mut selected = Vec::new();
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
let id_bytes = serde_json::to_vec(&id)
.map_err(|e| Error::InvalidInput(e.to_string()))?
.len();
let additional = 2 * id_bytes + 128;
if estimated_bytes + additional > max_result_bytes as usize {
break;
}
estimated_bytes += additional;
selected.push(id);
}
Ok(selected)
subjektiv::job::bounded_candidate_batch(ids, max_result_bytes).map_err(Error::from)
}
pub(super) fn dispatch(
@@ -234,25 +218,6 @@ pub(super) fn dispatch(
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
surface: SurfaceResult,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SurfaceResult {
availability: String,
generation_id: String,
store_revision: u64,
#[serde(default)]
snapshot_id: Option<String>,
#[serde(default)]
reason_code: Option<String>,
}
pub(super) fn validate_result(
api: &WorkspaceApi,
request: &BackendJobRequest,
@@ -262,39 +227,18 @@ pub(super) fn validate_result(
let Some(grant) = &request.grants.subjektiv_consolidation else {
return Ok(());
};
let result: ConsolidationResult = serde_json::from_value(value.clone())
.map_err(|e| Error::InvalidInput(format!("invalid consolidation result: {e}")))?;
if result.subject_id != grant.subject_id || result.candidate_ids != grant.candidate_ids {
return Err(Error::InvalidInput(
"consolidation result does not match the immutable Subject/batch grant".into(),
));
}
let store =
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|e| Error::Store(e.to_string()))?;
for candidate_id in &grant.candidate_ids {
if store
.staging_resolution(&grant.subject_id, candidate_id)
.map_err(|e| Error::Store(e.to_string()))?
.is_none()
{
return Err(Error::InvalidInput(format!(
"consolidation candidate `{candidate_id}` is unresolved"
)));
}
}
store
.validate_job_surface_outcome(
&grant.subject_id,
&result.surface.generation_id,
result.surface.store_revision,
&result.surface.availability,
result.surface.snapshot_id.as_deref(),
result.surface.reason_code.as_deref(),
&request.job_id,
&attempt.attempt_id,
)
.map_err(|e| Error::InvalidInput(format!("consolidation surface result mismatch: {e}")))
subjektiv::job::validate_result(
&store,
&grant.subject_id,
&grant.candidate_ids,
&request.job_id,
&attempt.attempt_id,
value,
)
.map_err(Error::from)
}
/// Persist actual candidate dispositions alongside the separate surface outcome.
@@ -313,28 +257,8 @@ pub(super) fn with_dispositions(
let store =
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|e| Error::Store(e.to_string()))?;
let mut dispositions = Vec::new();
for id in &grant.candidate_ids {
let resolution = store
.staging_resolution(&grant.subject_id, id)
.map_err(|e| Error::Store(e.to_string()))?
.ok_or_else(|| {
Error::InvalidInput(format!("candidate `{id}` has no durable disposition"))
})?;
// Full reasons, affected revisions and source edges remain in the existing
// immutable receipt store. Duplicating them makes a valid bounded batch's
// completion unbounded; this result is its verified compact projection.
dispositions.push(serde_json::json!({ "candidate_id": id, "action": resolution.action }));
}
let mut enriched = value.clone();
enriched
.as_object_mut()
.ok_or_else(|| Error::InvalidInput("consolidation result must be an object".into()))?
.insert(
"candidate_dispositions".into(),
serde_json::Value::Array(dispositions),
);
Ok(enriched)
subjektiv::job::with_dispositions(&store, &grant.subject_id, &grant.candidate_ids, value)
.map_err(Error::from)
}
#[cfg(test)]
@@ -454,7 +454,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove
entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(),
changes: vec![server_api::ConfigTreeChange::Update {
path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(),
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; source = \"profiles/job-recipe.dcdl\"; }]; }; }".into(),
content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; profile = import \"./profiles/job-recipe.dcdl\"; }]; }; }".into(),
}, server_api::ConfigTreeChange::Create {
path: "profiles/job-recipe.dcdl".into(), content_type: server_api::ConfigContentType::Decodal,
content: "{ slug = \"job-recipe\"; scope = \"workspace_read\"; model = { id = \"fixture-model\"; }; engine = { instruction = \"role.default\"; }; feature = { task = { enabled = true; }; }; }".into(),
@@ -0,0 +1,859 @@
use super::*;
use crate::profile_settings::{
build_virtual_profile_config_bundle, project_profiles_from_workspace_config,
};
const PROFILE: &str = r#"{
slug = "alpha";
worker = { mode = "wip"; };
model = { ref = "codex-oauth/gpt-5.6-sol"; };
feature = { task = { enabled = true; }; ticket = { enabled = false; }; };
}"#;
fn request(
api: &WorkspaceApi,
source: &str,
extra: Vec<config_source::ConfigTreeChange>,
) -> ConfigCommitRequest {
let state = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let main = state
.snapshot
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
.unwrap();
let mut changes = vec![config_source::ConfigTreeChange::Update {
path: main.path.clone(),
expected_digest: main.content_digest.clone(),
content: source.into(),
}];
changes.extend(extra);
ConfigCommitRequest {
base_revision: state.snapshot.revision,
base_digest: state.snapshot.digest,
entrypoints: state.contract.entrypoints,
changes,
}
}
fn source(value: &str) -> String {
format!(
r#"{{ profile = {{ default_profile = "project:alpha"; entries = [{{ selector = "project:alpha"; label = "Alpha"; description = "Value recipe"; profile = {value}; }}]; }}; }}"#
)
}
#[tokio::test]
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let app = build_router(api.clone());
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
let commit = format!("{tree}/commit");
let initial = get_json_authenticated(app.clone(), &tree, &token).await;
let saved = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(
serde_json::to_value(request(
&api,
&source(r#"import "$builtin/profiles/companion.dcdl""#),
vec![config_source::ConfigTreeChange::Create {
// An ordinary Workspace file at the matching suffix must not rescue
// an unknown builtin import.
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
))
.unwrap(),
),
&token,
StatusCode::CREATED,
)
.await;
assert_eq!(
saved["snapshot"]["revision"].as_u64().unwrap(),
initial["snapshot"]["revision"].as_u64().unwrap() + 1
);
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
assert_eq!(listed, saved);
let entries = listed["snapshot"]["entries"].as_object().unwrap();
assert!(entries.contains_key("profiles/private.dcdl"));
assert!(
entries
.keys()
.all(|key| key != "$builtin" && !key.starts_with("$builtin/"))
);
let shadow = get_json_authenticated(
app.clone(),
&format!("{tree}/entries/profiles%2Fprivate.dcdl"),
&token,
)
.await;
assert_eq!(shadow, entries["profiles/private.dcdl"]);
assert!(
shadow["content"]
.as_str()
.unwrap()
.contains("slug = \"alpha\"")
);
let active = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap();
let base = json!({
"base_revision": listed["snapshot"]["revision"],
"base_digest": listed["snapshot"]["digest"],
"entrypoints": listed["contract"]["entrypoints"],
});
let reserved = "$builtin/profiles/companion.dcdl";
for change in [
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
] {
let mut body = base.clone();
body["changes"] = json!([change]);
let error = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(body),
&token,
StatusCode::BAD_REQUEST,
)
.await;
assert!(
error["message"]
.as_str()
.unwrap()
.contains("builtin namespace is read-only"),
"{error}"
);
assert_eq!(
get_json_authenticated(app.clone(), &tree, &token).await,
listed
);
}
let mut body = base;
body["changes"] = json!([{
"kind": "update", "path": "main.dcdl", "expected_digest": main_digest,
"content": source(r#"import "$builtin/profiles/private.dcdl""#),
}]);
let error = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(body),
&token,
StatusCode::BAD_REQUEST,
)
.await;
let message = error["message"].as_str().unwrap();
assert!(
message.contains(
"unknown or non-public read-only builtin source: $builtin/profiles/private.dcdl"
),
"{error}"
);
assert!(message.contains("\"kind\":\"import\""), "{error}");
assert_eq!(get_json_authenticated(app, &tree, &token).await, listed);
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
active
);
}
#[tokio::test]
async fn value_profiles_builtin_companion_composition_seals_observed_sources() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let recipe = r#"(import "$builtin/profiles/companion.dcdl") // {
worker = { mode = "wip"; };
feature = {
task = { enabled = true; };
ticket = { enabled = false; };
workspace_config = { enabled = true; };
};
}"#;
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(
&api,
&source(r#"import "./recipes/custom.dcdl""#),
vec![
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/custom.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: recipe.into(),
},
// Builtin relative imports must not be shadowed by Workspace files.
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("profiles/base.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: "{ slug = \"workspace-shadow\"; }".into(),
},
],
),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
let archive = bundle.profile_source_archive.as_ref().unwrap();
let verified = archive.verify().unwrap();
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone());
let builtin_sources = environment.builtin_import_sources(&state.contract).unwrap();
assert_eq!(
builtin_sources
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
vec![
"$builtin/profiles/base.dcdl",
"$builtin/profiles/companion.dcdl",
]
);
assert_eq!(
archive.reference.source_graph.source_count,
builtin_sources.len() + 1
);
assert_eq!(archive.reference.source_graph.import_count, 0);
assert_eq!(
verified.manifest().entrypoints["project:alpha"],
"profiles/evaluated.json"
);
for (key, bytes) in &builtin_sources {
let source = verified
.manifest()
.sources
.iter()
.find(|source| &source.source_key == key)
.unwrap();
assert_eq!(source.path, *key);
assert_eq!(source.kind, "decodal");
assert_eq!(
source.digest,
worker_runtime::profile_archive::sha256_hex(bytes.as_bytes())
);
assert_eq!(source.size_bytes, bytes.len() as u64);
}
assert!(
verified
.manifest()
.sources
.iter()
.any(|source| source.path == "profiles/evaluated.json" && source.kind == "json")
);
// Resolve using the Runtime archive consumer, without access to the config tree.
let resolved = verified
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"builtin-value-worker",
)
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(!resolved.feature.ticket.enabled);
assert!(resolved.feature.workspace_config.enabled);
let evaluation = environment.evaluate_contract(&state.contract).unwrap();
assert_eq!(
resolved.model.ref_.as_deref(),
evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile/model/ref")
.and_then(serde_json::Value::as_str)
);
let persisted = manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap();
let old_bundle_digest = bundle.metadata.digest.clone();
let updated = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(PROFILE), vec![]),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &updated).unwrap();
let updated_bundle = build_virtual_profile_config_bundle(
&projection,
&updated,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
assert_ne!(old_bundle_digest, updated_bundle.metadata.digest);
assert_eq!(
updated_bundle
.profile_source_archive
.as_ref()
.unwrap()
.reference
.source_graph
.source_count,
1
);
// Editing config changes future snapshots, not a sealed bundle or persisted Worker.
assert_eq!(bundle.metadata.digest, old_bundle_digest);
assert_eq!(
serde_json::to_value(manifest::read_persisted_worker_manifest_snapshot(persisted).unwrap())
.unwrap(),
serde_json::to_value(resolved).unwrap()
);
}
#[tokio::test]
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let forms = [
PROFILE.to_string(),
r#"import "./recipes/alpha.dcdl""#.into(),
r#"(import "./recipes/base.dcdl") // { worker = { mode = "wip"; }; feature.task.enabled = true; }"#.into(),
];
let mut manifests = Vec::new();
let mut saved_manifest = None;
for (index, form) in forms.iter().enumerate() {
let extra = if index == 0 {
vec![
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
},
config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/base.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE
.replace("wip", "tools")
.replace("enabled = true", "enabled = false"),
},
]
} else {
vec![]
};
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(form), extra),
)
.unwrap();
let loaded = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
assert_eq!(loaded, state);
// The Browser receives the same fingerprint-bound authoring metadata through REST.
let wire = workspace_config_state_to_api(loaded.clone());
let decoded: config_source::WorkspaceConfigSchemaBundle =
serde_json::from_value(serde_json::to_value(wire.contract.schema_bundle).unwrap())
.unwrap();
decoded.validate().unwrap();
assert_eq!(decoded, loaded.contract.schema_bundle);
assert!(
decoded
.contributions
.iter()
.find(|entry| entry.provider_id == "builtin:profile")
.unwrap()
.authoring_source
.is_some()
);
let projection =
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &loaded).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("project:alpha")
);
assert_eq!(
projection.settings.config_revision,
Some(state.snapshot.revision)
);
let selected = projection
.settings
.profiles
.iter()
.find(|entry| entry.selector == "project:alpha")
.unwrap();
assert_eq!(selected.label, "Alpha");
assert_eq!(selected.description.as_deref(), Some("Value recipe"));
assert!(selected.is_default);
assert_eq!(selected.profile_source_id.as_deref(), Some("project:alpha"));
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"2026-01-01T00:00:00Z",
"project:alpha",
)
.unwrap()
.unwrap();
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
assert_eq!(
bundle.metadata.revision,
state.snapshot.revision.to_string()
);
let archive = bundle.profile_source_archive.as_ref().unwrap();
let verified = archive.verify().unwrap();
// This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports.
let resolved = verified
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"value-worker",
)
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(!resolved.feature.ticket.enabled);
assert!(!resolved.feature.ticket.authoring);
assert!(!resolved.feature.sub_worker.enabled);
assert_eq!(archive.reference.source_graph.import_count, 0);
let evaluation = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.evaluate_contract(&state.contract)
.unwrap();
let value = evaluation.projections[0]
.data_json
.pointer("/profile/entries/0/profile")
.unwrap();
assert!(value.is_object());
assert!(
value.get("engine").is_none(),
"omission must remain omission in the Profile value"
);
let mut snapshot = serde_json::to_value(&resolved).unwrap();
snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only
manifests.push(snapshot);
if index == 0 {
saved_manifest =
Some(manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap());
}
if index == 2 {
let mut other_state = state.clone();
other_state.snapshot.revision += 1;
assert!(
build_virtual_profile_config_bundle(
&projection,
&other_state,
TEST_WORKSPACE_ID,
"created",
"project:alpha"
)
.unwrap_err()
.to_string()
.contains("does not match")
);
}
}
assert_eq!(manifests[0], manifests[1]);
assert_eq!(manifests[1], manifests[2]);
// Saving a new recipe is not an operation on existing Workers or their persisted Manifest.
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(
&api,
&source(
&PROFILE
.replace("wip", "tools")
.replace("enabled = true", "enabled = false"),
),
vec![],
),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
let fresh = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
&bundle,
"project:alpha",
)
.unwrap();
assert_eq!(fresh.worker.mode, manifest::WorkerMode::Tools);
assert!(!fresh.feature.task.enabled);
let restored =
manifest::read_persisted_worker_manifest_snapshot(saved_manifest.unwrap()).unwrap();
assert_eq!(restored.worker.mode, manifest::WorkerMode::Wip);
assert!(restored.feature.task.enabled);
}
#[tokio::test]
async fn value_profiles_scalar_and_table_scopes_analyze_save_and_resolve_equally() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let scalar = format!(
r#"{PROFILE} // {{ scope = "workspace_read"; delegation_scope = "workspace_write"; }}"#
);
let table = format!(
r#"{PROFILE} // {{ scope = {{ intent = "workspace_read"; }}; delegation_scope = {{ intent = "workspace_write"; }}; }}"#
);
let mut scopes = Vec::new();
for (index, form) in [scalar.clone(), table, r#"import "./scoped.dcdl""#.into()]
.iter()
.enumerate()
{
let extra = if index == 0 {
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("scoped.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: scalar.clone(),
}]
} else {
vec![]
};
let authored = format!("{} as WorkspaceConfigSchema", source(form));
let state =
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &authored, extra))
.unwrap();
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.with_schema_bundle(state.contract.schema_bundle.clone());
assert!(
environment
.analyze(
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
None
)
.is_empty(),
"{authored}"
);
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
let resolved = bundle
.profile_source_archive
.as_ref()
.unwrap()
.verify()
.unwrap()
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"scoped-worker",
)
.unwrap();
assert_eq!(resolved.scope.allow.len(), 1);
assert_eq!(resolved.delegation_scope.allow.len(), 1);
let scope = serde_json::to_value(&resolved.scope).unwrap();
let delegation = serde_json::to_value(&resolved.delegation_scope).unwrap();
assert_eq!(scope["allow"][0]["target"], "/runtime-worker");
assert_eq!(scope["allow"][0]["permission"], "read");
assert_eq!(delegation["allow"][0]["target"], "/runtime-worker");
assert_eq!(delegation["allow"][0]["permission"], "write");
scopes.push((scope, delegation));
}
assert_eq!(scopes[0], scopes[1]);
assert_eq!(scopes[1], scopes[2]);
}
fn profile_fixture_decodal(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Object(fields) => format!(
"{{ {} }}",
fields
.iter()
.map(|(key, value)| format!("{key} = {};", profile_fixture_decodal(value)))
.collect::<Vec<_>>()
.join(" ")
),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(profile_fixture_decodal)
.collect::<Vec<_>>()
.join(", ")
),
serde_json::Value::Null => panic!("fixture omission must not be represented as null"),
value => value.to_string(),
}
}
#[tokio::test]
async fn value_profiles_authoring_corpus_preserves_effective_runtime_settings() {
let cases: Vec<serde_json::Value> = serde_json::from_str(include_str!(
"../../../../../resources/config-schema/profile-authoring-values.json"
))
.unwrap();
// The canonical token fixture must exercise every serializable setting,
// so adding a field to CompactionConfigPartial cannot silently outgrow the
// editor-shape coverage. Aliases/ratio helpers have separate corpus cases.
let default = serde_json::to_value(manifest::CompactionConfigPartial::default()).unwrap();
let canonical = cases
.iter()
.find(|case| case["name"] == "canonical_compaction_tokens")
.unwrap();
assert_eq!(
default
.as_object()
.unwrap()
.keys()
.collect::<std::collections::BTreeSet<_>>(),
canonical["profile"]["compaction"]
.as_object()
.unwrap()
.keys()
.filter(|name| name.as_str() != "kind")
.collect()
);
for case in cases {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let recipe = profile_fixture_decodal(&case["profile"]);
for (index, (form, patched)) in [
(recipe.clone(), false),
(r#"import "./recipe.dcdl""#.into(), false),
(
r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(),
true,
),
]
.iter()
.enumerate()
{
let extra = if index == 0 {
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipe.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: recipe.clone(),
}]
} else {
vec![]
};
let authored = format!("{} as WorkspaceConfigSchema", source(form));
let state = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &authored, extra),
)
.unwrap();
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone())
.with_schema_bundle(state.contract.schema_bundle.clone());
let diagnostics = environment.analyze(
&config_source::VirtualPath::parse("main.dcdl").unwrap(),
None,
);
assert!(diagnostics.is_empty(), "{}: {diagnostics:?}", case["name"]);
let mut expected = case["profile"].clone();
if *patched {
expected["description"] = serde_json::json!("patched");
}
let evaluated = environment.evaluate_contract(&state.contract).unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/profile/entries/0/profile"),
Some(&expected)
);
let projection =
project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
let bundle = build_virtual_profile_config_bundle(
&projection,
&state,
TEST_WORKSPACE_ID,
"created",
"project:alpha",
)
.unwrap()
.unwrap();
let resolved = bundle
.profile_source_archive
.as_ref()
.unwrap()
.verify()
.unwrap()
.resolve_profile(
"project:alpha",
Path::new("/runtime-worker"),
"corpus-worker",
)
.unwrap();
let manifest = serde_json::to_value(resolved).unwrap();
for (pointer, value) in case["manifest_expect"].as_object().unwrap() {
assert_eq!(
manifest.pointer(pointer),
Some(value),
"{}: {pointer}",
case["name"]
);
}
}
}
}
#[tokio::test]
async fn value_profiles_invalid_saves_preserve_the_active_config() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let before = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &source(PROFILE), vec![]),
)
.unwrap();
let invalid = [
(source("42"), ""),
(source(r#"{ worker = { mode = "not-a-mode"; }; }"#), "profile_value_invalid"),
(source(r#"{ worker = { name = "runtime-only"; }; }"#), "profile_value_invalid"),
(source(r#"{ scope = 42; }"#), "profile_value_invalid"),
(source(r#"{ scope = "not-an-intent"; }"#), "profile_value_invalid"),
(source(r#"{ delegation_scope = { intent = 42; }; }"#), "profile_value_invalid"),
(source(r#"{ scope = { intent = "workspace_read"; unknown = true; }; }"#), "profile_value_invalid"),
(source(r#"{ scope = { intent = "workspace_read"; deny_write = 42; }; }"#), "profile_value_invalid"),
(source(r#"{ delegation_scope = { intent = "workspace_write"; symlink_policy = 42; }; }"#), "profile_value_invalid"),
(source(r#"import "./missing.dcdl""#), ""),
(source(r#"import "../outside.dcdl""#), ""),
(r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }, { selector = "project:alpha"; profile = {}; }]; }; }"#.into(), "profile_selector_duplicate"),
(r#"{ profile = { default_profile = "project:unknown"; }; }"#.into(), "unknown_default_profile"),
(r#"{ profile = { entries = [{ selector = "project:"; profile = {}; }]; }; }"#.into(), "profile_selector_invalid"),
(r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#.into(), ""),
];
for (source, code) in invalid {
let error =
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &source, vec![]))
.unwrap_err();
assert!(error.to_string().contains(code), "{error}");
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
before
);
}
}
#[tokio::test]
async fn value_profiles_legacy_source_config_requires_explicit_lossless_migration() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let mut contributions = api.config_schema_registry.compose().unwrap().contributions;
let profile_schema = contributions
.iter_mut()
.find(|entry| entry.provider_id == "builtin:profile")
.unwrap();
*profile_schema = config_source::ConfigSchemaContribution::new("builtin:profile", "profile", "1", r#"{
profile = { default_profile = String default "builtin:companion";
entries = [...{ selector = String; source = String; label = String default ""; description = String default ""; }] default [];
};
}"#).unwrap();
let old_schema = config_source::WorkspaceConfigSchemaBundle::compose(contributions).unwrap();
let old_source = r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; label = "Alpha"; source = "recipes/alpha.dcdl"; }]; }; }"#;
let candidate = api
.config_store
.evaluate_workspace_config_candidate_with_schema(
TEST_WORKSPACE_ID,
&request(
&api,
old_source,
vec![config_source::ConfigTreeChange::Create {
path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
),
old_schema,
)
.unwrap();
// Fixture simulates a previously persisted v1 schema/revision.
let legacy = api
.config_store
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
.unwrap();
let error = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &legacy).unwrap_err();
assert!(
error
.to_string()
.contains("profile_source_registration_removed")
);
assert!(error.to_string().contains("profile = import"));
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
legacy
);
assert!(
commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, old_source, vec![]))
.is_err()
);
assert_eq!(
api.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap(),
legacy
);
let migrated_source = old_source.replace(
r#"source = "recipes/alpha.dcdl""#,
r#"profile = import "./recipes/alpha.dcdl""#,
);
let migrated = commit_workspace_config_tree(
&api,
TEST_WORKSPACE_ID,
&request(&api, &migrated_source, vec![]),
)
.unwrap();
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &migrated).unwrap();
assert_eq!(
projection.settings.default_profile.as_deref(),
Some("project:alpha")
);
assert_eq!(
migrated
.snapshot
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap()),
legacy
.snapshot
.get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap())
);
assert_eq!(
projection
.settings
.profiles
.iter()
.find(|entry| entry.selector == "project:alpha")
.unwrap()
.label,
"Alpha"
);
}
File diff suppressed because it is too large Load Diff