Skip to content
Closed
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
11 changes: 11 additions & 0 deletions crates/webcodex-core/src/runner_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,16 @@ runner_capabilities! {
#[serde(default, skip_serializing_if = "is_false")]
pub project_build_v1: bool = false;
}
/// Go project lifecycle gateways execute in Runner-owned single-module mode.
/// The Runner forces GO111MODULE=on and GOWORK=off after shell/profile environment
/// preparation so ambient module/workspace mode cannot change the planned graph.
ProjectGoSingleModule => RUNNER_CAPABILITY_PROJECT_GO_SINGLE_MODULE("project_go_single_module_v1"),
v2_baseline = false {
/// Additive project_build/project_validate Go execution semantics. Missing
/// on older Runners is false and must fail closed at typed Job admission.
#[serde(default, skip_serializing_if = "is_false")]
pub project_go_single_module_v1: bool = false;
}
/// The Runner understands the additive portable package scope carried by
/// project validation requests. Older project_validation_v1 Runners reject
/// scoped requests before dispatch rather than interpreting an unknown field.
Expand Down Expand Up @@ -2738,6 +2748,7 @@ mod envelope_tests {
structured_go_test_json: true,
project_validation_v1: false,
project_build_v1: false,
project_go_single_module_v1: false,
project_validation_package_scope_v1: false,
project_validation_test_options_v1: false,
structured_go_test_tool: true,
Expand Down
59 changes: 59 additions & 0 deletions crates/webcodex-core/src/validation_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,43 @@ pub fn structured_validation_target_identity(
))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructuredValidationExecutionContext {
GoProjectSingleModuleV1,
}

impl StructuredValidationExecutionContext {
const fn as_str(self) -> &'static str {
match self {
Self::GoProjectSingleModuleV1 => "go_project_single_module_v1",
}
}
}

/// Derive a distinct durable target identity when the same structured argv
/// executes under materially different Runner-owned semantics. Existing target
/// identities remain byte-for-byte unchanged when no context is applied.
pub fn contextualize_structured_validation_target_identity(
identity: &str,
context: StructuredValidationExecutionContext,
) -> Option<String> {
if !is_structured_validation_target_identity(identity) {
return None;
}
let context = context.as_str();
let mut hasher = Sha256::new();
hasher.update(b"webcodex-validation-target-context-v1\0");
hasher.update((identity.len() as u64).to_le_bytes());
hasher.update(identity.as_bytes());
hasher.update((context.len() as u64).to_le_bytes());
hasher.update(context.as_bytes());
let digest = format!("{:x}", hasher.finalize());
Some(format!(
"{STRUCTURED_VALIDATION_TARGET_PREFIX}{}",
&digest[..VALIDATION_IDENTITY_HEX_LEN]
))
}

fn normalized_validation_target_cwd(value: Option<&Value>) -> Option<String> {
let Some(value) = value else {
return Some(".".to_string());
Expand Down Expand Up @@ -384,6 +421,28 @@ mod tests {
);
}

#[test]
fn execution_context_derives_a_distinct_stable_target_identity() {
let base = structured_validation_target_identity(
ToolValidationIdentityKind::GoTest,
&serde_json::json!({"cwd": ".", "packages": ["./..."]}),
)
.unwrap();
assert_eq!(base, "target:53578a0709b0ce549e125eb7");
let contextual = contextualize_structured_validation_target_identity(
&base,
StructuredValidationExecutionContext::GoProjectSingleModuleV1,
)
.unwrap();
assert_eq!(contextual, "target:44a5798459663307bb9b3ea8");
assert_ne!(contextual, base);
assert!(contextualize_structured_validation_target_identity(
"target:short",
StructuredValidationExecutionContext::GoProjectSingleModuleV1,
)
.is_none());
}

#[test]
fn cargo_test_multi_package_identity_preserves_single_package_compatibility() {
let kind = ToolValidationIdentityKind::CargoTest;
Expand Down
3 changes: 3 additions & 0 deletions crates/webcodex-runner-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result<String,
structured_go_test_json: false,
project_validation_v1: false,
project_build_v1: false,
// Go project single-module semantics are implemented and advertised
// by the running binary, never inferred from generated static config.
project_go_single_module_v1: false,
project_validation_package_scope_v1: false,
project_validation_test_options_v1: false,
// Like JSON parsing, first-class durable go_test support is
Expand Down
18 changes: 18 additions & 0 deletions crates/webcodex-runner-registry/src/job_updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,14 @@ impl RunnerRegistry {
structured_execution.as_ref(),
Some(StructuredJobExecution::ProjectBuild(_))
);
let project_go_single_module_request = matches!(
structured_execution.as_ref(),
Some(StructuredJobExecution::ProjectBuild(plan))
if plan.provenance.backend == "go"
) || validation
.as_ref()
.and_then(|metadata| metadata.project_validation.as_ref())
.is_some_and(|provenance| provenance.backend == "go");
if explicit_shell.is_some()
&& (structured_execution.is_some()
|| !validation_steps.is_empty()
Expand Down Expand Up @@ -1247,6 +1255,16 @@ impl RunnerRegistry {
"capability_unavailable: upgrade target Runner for project_build_v1".to_string(),
);
}
if project_go_single_module_request
&& !runner
.runner_features
.supports(RunnerFeature::ProjectGoSingleModule)
{
return Err(
"capability_unavailable: upgrade target Runner for project_go_single_module_v1"
.to_string(),
);
}
if javascript_script_request
&& !runner
.runner_features
Expand Down
1 change: 1 addition & 0 deletions crates/webcodex-runner-registry/src/tests/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ fn capability_classification_keeps_environment_dependent_features_registration_r
RunnerFeature::Git,
RunnerFeature::StructuredCargoTestExecutionPolicy,
RunnerFeature::StructuredCargoTestLib,
RunnerFeature::ProjectGoSingleModule,
RunnerFeature::StructuredScriptJavascript,
RunnerFeature::StructuredScriptTypescript,
RunnerFeature::ApplyTextEditLineScope,
Expand Down
75 changes: 75 additions & 0 deletions crates/webcodex-runner-registry/src/tests/project_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ fn build_plan() -> ProjectBuildPlan {
}
}

fn go_build_request() -> ProjectBuildRequest {
ProjectBuildRequest {
project_id: "demo".into(),
cwd: None,
adapter: ProjectBuildAdapter::Go,
scope: None,
}
}

fn go_build_plan() -> ProjectBuildPlan {
let request = go_build_request();
let process = canonical_project_build_process("go", &request).unwrap();
ProjectBuildPlan {
provenance: ProjectBuildProvenance {
request,
backend: "go".into(),
recipe_root: ".".into(),
root_digest: "3".repeat(64),
manifest_digest: "4".repeat(64),
invocation_digest: project_build_invocation_digest(&process),
},
process,
}
}

fn registration(project_build: bool) -> RunnerRegisterRequest {
let mut capabilities =
crate::test_support::current_runner_capabilities(RunnerCapabilities::default());
Expand Down Expand Up @@ -147,3 +172,53 @@ async fn project_build_job_handoff_emits_typed_start_build() {
Some("agent:build-runner:demo")
);
}

#[tokio::test]
async fn go_project_build_job_handoff_requires_single_module_capability() {
for supported in [false, true] {
let registry = RunnerRegistry::default();
let mut registration = registration(true);
registration.capabilities.project_go_single_module_v1 = supported;
registry.register(registration).await.unwrap();

let plan = go_build_plan();
let result = registry
.start_job_with_metadata_for_access(
ShellJobOpRequest {
login: false,
op: "start".into(),
client_id: Some("build-runner".into()),
cwd: Some("/tmp/demo".into()),
command: Some(String::new()),
timeout_secs: Some(60),
job_id: None,
since_stdout_line: None,
since_stderr_line: None,
tail_lines: None,
limit: None,
codex: None,
},
"test".into(),
ShellJobStartMetadata {
project_id: Some("agent:build-runner:demo".into()),
project_cwd: Some(".".into()),
purpose: Some("build".into()),
shell: Some("direct_argv".into()),
visibility: ShellJobVisibility::Public,
structured_execution: Some(StructuredJobExecution::ProjectBuild(plan)),
..Default::default()
},
None,
None,
)
.await;

if supported {
assert!(result.is_ok(), "{result:?}");
} else {
let error = result.unwrap_err();
assert!(error.contains("project_go_single_module_v1"), "{error}");
assert!(registry.list_jobs(Some(10)).await.is_empty());
}
}
}
85 changes: 85 additions & 0 deletions crates/webcodex-runner-registry/src/tests/reconciliation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,60 @@ fn cargo_validation_start_metadata(
}
}



fn go_project_validation_start_metadata() -> ShellJobStartMetadata {
use webcodex_core::project_validation::{
ProjectValidationAction, ProjectValidationAdapter, ProjectValidationProvenance,
ProjectValidationRequest,
};

let step = ShellJobValidationStep {
name: "check".to_string(),
program: "go".to_string(),
args: vec!["vet".to_string(), "./...".to_string()],
env: Vec::new(),
};
ShellJobStartMetadata {
project_id: Some(RUNTIME_PROJECT_ID.to_string()),
session_id: Some(SESSION_ID.to_string()),
project_cwd: Some("/srv/demo".to_string()),
purpose: Some("validation".to_string()),
shell: Some("direct_argv".to_string()),
validation_steps: vec![step.clone()],
validation: Some(ShellJobValidationMetadata {
project_validation: Some(ProjectValidationProvenance {
request: ProjectValidationRequest {
project_id: "demo".into(),
cwd: None,
action: ProjectValidationAction::Check,
adapter: ProjectValidationAdapter::Go,
scope: None,
test: None,
},
backend: "go".into(),
recipe_root: ".".into(),
root_digest: "a".repeat(64),
manifest_digest: "b".repeat(64),
invocation_digest: "c".repeat(64),
}),
source_fence: None,
tool: "project_validate".to_string(),
kind: "check".to_string(),
steps: vec![step],
effective_timeout_secs: 600,
sync_wait_secs: 1,
adapter: "go_vet".to_string(),
validation_target_id: Some("target:2123456789abcdef01234567".to_string()),
minimum_tests: None,
require_tests: None,
no_run: None,
}),
visibility: ShellJobVisibility::Public,
..Default::default()
}
}

fn cargo_lib_validation_start_metadata() -> ShellJobStartMetadata {
let mut metadata = cargo_validation_start_metadata(None, None, None);
for step in &mut metadata.validation_steps {
Expand Down Expand Up @@ -584,6 +638,37 @@ async fn old_count_capable_runner_accepts_cargo_validation_without_explicit_exec
assert_eq!(request.kind, "start_validation_job");
}



#[tokio::test]
async fn go_project_validation_is_fenced_again_at_job_admission() {
for supported in [false, true] {
let registry = RunnerRegistry::default();
let mut registration = register_request(INSTANCE_A, empty_inventory());
registration.capabilities.project_validation_v1 = true;
registration.capabilities.project_go_single_module_v1 = supported;
registry.register(registration).await.unwrap();

let metadata = go_project_validation_start_metadata();
assert!(metadata.validation.as_ref().unwrap().is_valid());
let result = registry
.start_job_with_metadata(
start_request("validation"),
"tester".to_string(),
metadata,
)
.await;

if supported {
assert!(result.is_ok(), "{result:?}");
} else {
let error = result.unwrap_err();
assert!(error.contains("project_go_single_module_v1"), "{error}");
assert!(registry.list_jobs(Some(10)).await.is_empty());
}
}
}

#[tokio::test]
async fn project_test_options_are_fenced_again_at_job_admission() {
use webcodex_core::project_validation::*;
Expand Down
3 changes: 3 additions & 0 deletions crates/webcodex-runner/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,9 @@ fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabilities {
capabilities.set(RunnerCapabilityId::StructuredGoTestJson, true);
capabilities.set(RunnerCapabilityId::ProjectValidation, true);
capabilities.set(RunnerCapabilityId::ProjectBuild, true);
// Go project gateways pin GO111MODULE=on and GOWORK=off after prepared shell/profile env is
// applied. Keep this independent for mixed Server/Runner rolling upgrades.
capabilities.set(RunnerCapabilityId::ProjectGoSingleModule, true);
// Portable package scope is additive to project_validation_v1 so mixed
// Server/Runner deployments fail closed before sending the expanded request.
capabilities.set(RunnerCapabilityId::ProjectValidationPackageScope, true);
Expand Down
3 changes: 3 additions & 0 deletions crates/webcodex-runner/src/webcodex_runner/job_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use super::shell::{
configured_explicit_shell_command, configured_prepared_shell_job_command,
configured_shell_job_command, configured_validation_job_command, cwd_allowed,
prepare_detached_process_launch, resolve_prepared_shell_profile,
run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook,
run_process_with_profiles_and_execution_state_with_start_hook,
run_script_with_profiles_and_execution_state_with_start_hook, PreparedShellProfileCache,
};
Expand Down Expand Up @@ -53,6 +54,8 @@ use crate::{
#[cfg(test)]
use webcodex_core::runner_operation::{self, RunnerOperation};

const GO_PROJECT_SINGLE_MODULE_ENV: [(&str, &str); 2] = [("GO111MODULE", "on"), ("GOWORK", "off")];

const JOB_UPDATE_INTERVAL_MS: u64 = 250;
/// Runner-owned liveness cadence for active Jobs. The cadence is independent
/// of stdout/stderr and other Job traffic so one noisy Job cannot starve
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ impl JobManager {
),
_ => unreachable!("shell Job starter received non shell/validation operation"),
};
let project_go_single_module = validation
&& context
.validation
.as_ref()
.and_then(|metadata| metadata.project_validation.as_ref())
.is_some_and(|provenance| provenance.backend == "go");
if validation {
// A project validation can wait in the local queue after its
// admission fence. Recheck the retained project plan after that
Expand Down Expand Up @@ -177,6 +183,9 @@ impl JobManager {
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
);
if project_go_single_module {
command.envs(GO_PROJECT_SINGLE_MODULE_ENV);
}
}
// Raw Shell Jobs and every validation step have no stdin payload.
// Never inherit the Runner's parent-liveness pipe: it stays open
Expand Down
Loading
Loading