Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/webcodex-core/src/runner_protocol/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,31 @@ pub struct ShellJobContext {
}

impl ShellJobContext {
/// Bounded ordered step names retained by admission and recovery.
pub fn validation_steps_are_valid(&self) -> bool {
self.validation_steps.len() <= 3
&& self
.validation_steps
.iter()
.enumerate()
.all(|(index, step)| {
matches!(step.as_str(), "format" | "check" | "lint" | "test")
&& !self.validation_steps[..index].contains(step)
})
}

/// Optional executable metadata must describe the same ordered steps.
pub fn validation_metadata_is_valid(&self) -> bool {
self.validation.as_ref().is_none_or(|metadata| {
metadata.is_valid()
&& metadata
.steps
.iter()
.map(|step| &step.name)
.eq(self.validation_steps.iter())
})
}

/// Semantic execution identities shared by Runner admission and Server
/// reconciliation. Interpreter executable names are not shell identities.
pub fn is_valid_shell(shell: &str) -> bool {
Expand Down
41 changes: 41 additions & 0 deletions crates/webcodex-core/src/runner_protocol/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
use super::*;

#[test]
fn validation_context_step_names_are_bounded_unique_and_closed() {
for (steps, expected) in [
(vec![], true),
(vec!["format", "check", "test"], true),
(vec!["lint"], true),
(vec!["check", "check"], false),
(vec!["format", "check", "lint", "test"], false),
(vec!["unknown"], false),
] {
let context: ShellJobContext = serde_json::from_value(serde_json::json!({
"command_preview": "cargo check",
"validation_steps": steps,
}))
.unwrap();
assert_eq!(context.validation_steps_are_valid(), expected, "{steps:?}");
}
}

#[test]
fn validation_context_metadata_must_match_retained_step_names() {
let mut context: ShellJobContext = serde_json::from_value(serde_json::json!({
"command_preview": "cargo check",
"validation_steps": ["check"],
"validation": {
"tool": "cargo_check", "kind": "check", "adapter": "cargo_check",
"steps": [{"name": "check", "program": "cargo", "args": ["check"]}],
"effective_timeout_secs": 60, "sync_wait_secs": 5,
},
}))
.unwrap();
assert!(context.validation_metadata_is_valid());
context.validation_steps = vec!["test".into()];
assert!(!context.validation_metadata_is_valid());
context.validation_steps = vec!["check".into()];
context.validation.as_mut().unwrap().adapter = "go_test".into();
assert!(!context.validation_metadata_is_valid());
context.validation = None;
assert!(context.validation_metadata_is_valid());
}

// Golden wire bytes captured against the explicit pre-catalog struct. These
// freeze required false keys, omission and field order, not a capability inventory.
const LEGACY_DEFAULT_JSON: &str = concat!(
Expand Down
35 changes: 3 additions & 32 deletions crates/webcodex-runner-registry/src/reconciliation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,7 @@ fn validate_context(
}
}
if context.purpose.as_deref().is_some_and(|purpose| {
!matches!(
purpose,
"validation"
| "test"
| "build"
| "format"
| "release"
| "diagnostic"
| "operation"
| "other"
)
webcodex_core::workflow_session_contract::ExecutionPurpose::parse(purpose).is_none()
}) {
return Err("job inventory purpose is invalid".to_string());
}
Expand All @@ -98,29 +88,10 @@ fn validate_context(
}) {
return Err("job inventory shell is invalid".to_string());
}
if context.validation_steps.len() > 3
|| context
.validation_steps
.iter()
.collect::<HashSet<_>>()
.len()
!= context.validation_steps.len()
|| context
.validation_steps
.iter()
.any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test"))
{
if !context.validation_steps_are_valid() {
return Err("job inventory validation_steps are invalid".to_string());
}
if context.validation.as_ref().is_some_and(|validation| {
!validation.is_valid()
|| validation
.steps
.iter()
.map(|step| step.name.clone())
.collect::<Vec<_>>()
!= context.validation_steps
}) {
if !context.validation_metadata_is_valid() {
return Err("job inventory validation metadata is invalid".to_string());
}
if context
Expand Down
45 changes: 10 additions & 35 deletions crates/webcodex-runner/src/webcodex_runner/job_manager/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use super::lifecycle::{job_prestart_lifecycle, runner_job_is_active};
use super::*;
use webcodex_core::workflow_session_contract::ExecutionPurpose;
pub(super) fn validate_runner_job_context_operation(
context: &ShellJobContext,
operation: &RunnerJobOperation,
Expand Down Expand Up @@ -41,19 +42,11 @@ pub(super) fn validate_runner_job_context_operation(
if context.ssh_resource.is_some() && context.workflow_session_id.is_none() {
return Err("job recovery context SSH resource requires a Workflow Session".to_string());
}
if context.purpose.as_deref().is_some_and(|purpose| {
!matches!(
purpose,
"validation"
| "test"
| "build"
| "format"
| "release"
| "diagnostic"
| "operation"
| "other"
)
}) {
if context
.purpose
.as_deref()
.is_some_and(|purpose| ExecutionPurpose::parse(purpose).is_none())
{
return Err("job recovery context purpose is invalid".to_string());
}
if context
Expand Down Expand Up @@ -100,31 +93,13 @@ pub(super) fn validate_runner_job_context_operation(
}
_ => Vec::new(),
};
if context.validation_steps != validation_steps
|| context
.validation_steps
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
!= context.validation_steps.len()
|| context
.validation_steps
.iter()
.any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test"))
{
if context.validation_steps != validation_steps || !context.validation_steps_are_valid() {
return Err("job recovery context validation_steps are invalid".to_string());
}
let validation_context = matches!(operation, RunnerJobOperation::StartValidation(_));
if context.validation.as_ref().is_some_and(|metadata| {
!validation_context
|| !metadata.is_valid()
|| metadata
.steps
.iter()
.map(|step| step.name.clone())
.collect::<Vec<_>>()
!= context.validation_steps
}) {
if (!validation_context && context.validation.is_some())
|| !context.validation_metadata_is_valid()
{
return Err("job recovery context validation metadata is invalid".to_string());
}
if context
Expand Down
46 changes: 9 additions & 37 deletions crates/webcodex-runner/src/webcodex_runner/job_manager/detached.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use super::lifecycle::{runner_job_is_active, runner_job_is_terminal};
use super::retained::job_update_from_snapshot;
use super::*;
use webcodex_core::workflow_session_contract::ExecutionPurpose;
pub(super) fn validate_detached_recovery_context(
context: &ShellJobContext,
client_id: &str,
Expand Down Expand Up @@ -32,19 +33,11 @@ pub(super) fn validate_detached_recovery_context(
if context.ssh_resource.is_some() && context.workflow_session_id.is_none() {
return Err("detached Job recovery SSH resource requires a Workflow Session".to_string());
}
if context.purpose.as_deref().is_some_and(|purpose| {
!matches!(
purpose,
"validation"
| "test"
| "build"
| "format"
| "release"
| "diagnostic"
| "operation"
| "other"
)
}) {
if context
.purpose
.as_deref()
.is_some_and(|purpose| ExecutionPurpose::parse(purpose).is_none())
{
return Err("detached Job recovery purpose is invalid".to_string());
}
if context.shell.as_deref().is_some_and(|shell| {
Expand All @@ -55,31 +48,10 @@ pub(super) fn validate_detached_recovery_context(
}) {
return Err("detached Job recovery shell is invalid".to_string());
}
if !context.validation_steps.is_empty() {
if !(1..=3).contains(&context.validation_steps.len())
|| context
.validation_steps
.iter()
.collect::<HashSet<_>>()
.len()
!= context.validation_steps.len()
|| context
.validation_steps
.iter()
.any(|step| !matches!(step.as_str(), "format" | "check" | "lint" | "test"))
{
return Err("detached Job recovery validation_steps are invalid".to_string());
}
if !context.validation_steps_are_valid() {
return Err("detached Job recovery validation_steps are invalid".to_string());
}
if context.validation.as_ref().is_some_and(|metadata| {
!metadata.is_valid()
|| metadata
.steps
.iter()
.map(|step| step.name.clone())
.collect::<Vec<_>>()
!= context.validation_steps
}) {
if !context.validation_metadata_is_valid() {
return Err("detached Job recovery validation metadata is invalid".to_string());
}
if context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,45 @@ use super::common::{
wrapped_output_schema,
};

fn artifact_read_arguments_schema(selector: (&str, &str), description: &str) -> Value {
let mut schema = json!({
"type": "object",
"description": description,
"additionalProperties": false,
"properties": {
"project": {"type": "string", "minLength": 1},
"path": {"type": "string", "minLength": 1},
"offset": {"type": "integer", "minimum": 0},
"length": {"type": "integer", "minimum": 1, "maximum": 65536},
"expected_sha256": {
"type": "string",
"minLength": 64,
"maxLength": 64,
"pattern": "^[0-9a-f]{64}$"
},
"session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"}
},
"required": [
"project",
"path",
selector.0,
"offset",
"length",
"expected_sha256"
]
});
schema["properties"][selector.0] = json!({"type": "string", "const": selector.1});
schema
}

fn read_project_artifact_suggested_call_schema() -> Value {
suggested_tool_call_schema(
webcodex_core::runtime_contract::GeneratedFollowUpKind::MechanicallyFollowable,
"read_project_artifact_chunk",
json!({
"type": "object",
"description": "Parser-ready next ranged read of the same exact full-file artifact incarnation.",
"additionalProperties": false,
"properties": {
"project": {"type": "string", "minLength": 1},
"path": {"type": "string", "minLength": 1},
"encoding": {"type": "string", "const": "base64"},
"offset": {"type": "integer", "minimum": 0},
"length": {"type": "integer", "minimum": 1, "maximum": 65536},
"expected_sha256": {
"type": "string",
"minLength": 64,
"maxLength": 64,
"pattern": "^[0-9a-f]{64}$"
},
"session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"}
},
"required": [
"project",
"path",
"encoding",
"offset",
"length",
"expected_sha256"
]
}),
artifact_read_arguments_schema(
("encoding", "base64"),
"Parser-ready next ranged read of the same exact full-file artifact incarnation.",
),
"Parser-ready advisory call for the next ranged read of the same exact artifact content snapshot. It grants no Project or Session authority.",
)
}
Expand All @@ -44,33 +52,10 @@ fn project_artifact_suggested_call_schema() -> Value {
suggested_tool_call_schema(
webcodex_core::runtime_contract::GeneratedFollowUpKind::MechanicallyFollowable,
"inspect_project_artifact",
json!({
"type": "object",
"description": "Parser-ready continuation for one more bounded inspect of the same exact full-file artifact incarnation.",
"additionalProperties": false,
"properties": {
"project": {"type": "string", "minLength": 1},
"path": {"type": "string", "minLength": 1},
"action": {"type": "string", "const": "inspect"},
"offset": {"type": "integer", "minimum": 0},
"length": {"type": "integer", "minimum": 1, "maximum": 65536},
"expected_sha256": {
"type": "string",
"minLength": 64,
"maxLength": 64,
"pattern": "^[0-9a-f]{64}$"
},
"session_id": {"type": "string", "pattern": "^wc_sess_([A-Za-z0-9_-]{16}|[0-9a-f]{32})$"}
},
"required": [
"project",
"path",
"action",
"offset",
"length",
"expected_sha256"
]
}),
artifact_read_arguments_schema(
("action", "inspect"),
"Parser-ready continuation for one more bounded inspect of the same exact full-file artifact incarnation.",
),
"Parser-ready advisory continuation for inspect_project_artifact(action=inspect). It carries the observed full-file SHA-256 fence and grants no Project or Session authority.",
)
}
Expand Down
Loading
Loading