|
| 1 | +//! Minimal, secret-free composition contracts for the native-v2 engine. |
| 2 | +//! |
| 3 | +//! `GraphSpec` remains the graph language. This module only binds executable graph leaves to one |
| 4 | +//! graph-wide harness/provider lane and defines the neutral values exchanged by admission, the |
| 5 | +//! reducer, the runner, and the run ledger. It deliberately contains no admission or execution |
| 6 | +//! policy. |
| 7 | +
|
| 8 | +use std::collections::{BTreeMap, BTreeSet}; |
| 9 | +use std::fmt; |
| 10 | +use std::num::NonZeroU64; |
| 11 | + |
| 12 | +use openengine_cluster_protocol::{ |
| 13 | + CompiledGraphIr, GraphSpec, IdempotencyKey, NodeName, RunId, WorkerOutcome, WorkerRef, |
| 14 | +}; |
| 15 | +use serde::{Deserialize, Serialize}; |
| 16 | +use serde_json::Value; |
| 17 | +use thiserror::Error; |
| 18 | + |
| 19 | +use crate::execution::SessionScope; |
| 20 | +use crate::worker_catalog::{ModelId, ReasoningEffort}; |
| 21 | + |
| 22 | +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] |
| 23 | +#[serde(rename_all = "snake_case")] |
| 24 | +pub enum CodexProvider { |
| 25 | + #[serde(rename = "openai")] |
| 26 | + OpenAi, |
| 27 | + #[serde(rename = "openrouter")] |
| 28 | + OpenRouter, |
| 29 | +} |
| 30 | + |
| 31 | +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] |
| 32 | +#[serde(rename_all = "snake_case")] |
| 33 | +pub enum ClaudeProvider { |
| 34 | + Anthropic, |
| 35 | + #[serde(rename = "openrouter")] |
| 36 | + OpenRouter, |
| 37 | +} |
| 38 | + |
| 39 | +/// One harness/provider lane for the entire graph. |
| 40 | +/// |
| 41 | +/// The tagged variants make unsupported pairings unrepresentable without a second validation |
| 42 | +/// table: Codex supports OpenAI/OpenRouter and Claude supports Anthropic/OpenRouter. |
| 43 | +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] |
| 44 | +#[serde(deny_unknown_fields, tag = "harness", rename_all = "snake_case")] |
| 45 | +pub enum RuntimePlan { |
| 46 | + Codex { |
| 47 | + provider: CodexProvider, |
| 48 | + nodes: BTreeMap<NodeName, NodeRuntimeBinding>, |
| 49 | + }, |
| 50 | + Claude { |
| 51 | + provider: ClaudeProvider, |
| 52 | + nodes: BTreeMap<NodeName, NodeRuntimeBinding>, |
| 53 | + }, |
| 54 | +} |
| 55 | + |
| 56 | +impl RuntimePlan { |
| 57 | + #[must_use] |
| 58 | + pub fn nodes(&self) -> &BTreeMap<NodeName, NodeRuntimeBinding> { |
| 59 | + match self { |
| 60 | + Self::Codex { nodes, .. } | Self::Claude { nodes, .. } => nodes, |
| 61 | + } |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/// Runtime configuration for an executable graph leaf. |
| 66 | +/// |
| 67 | +/// Environment values are intentionally absent. The controller resolves only these declared |
| 68 | +/// names immediately before invocation. Git delivery is graph-visible but is not an agent session. |
| 69 | +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] |
| 70 | +#[serde(deny_unknown_fields, tag = "kind", rename_all = "snake_case")] |
| 71 | +pub enum NodeRuntimeBinding { |
| 72 | + Agent { |
| 73 | + model: ModelId, |
| 74 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 75 | + effort: Option<ReasoningEffort>, |
| 76 | + #[serde( |
| 77 | + default, |
| 78 | + rename = "sessionScope", |
| 79 | + skip_serializing_if = "is_execution_scope" |
| 80 | + )] |
| 81 | + session_scope: SessionScope, |
| 82 | + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] |
| 83 | + env: BTreeSet<EnvironmentVariableName>, |
| 84 | + }, |
| 85 | + GitDelivery { |
| 86 | + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] |
| 87 | + env: BTreeSet<EnvironmentVariableName>, |
| 88 | + }, |
| 89 | +} |
| 90 | + |
| 91 | +fn is_execution_scope(scope: &SessionScope) -> bool { |
| 92 | + *scope == SessionScope::Execution |
| 93 | +} |
| 94 | + |
| 95 | +impl NodeRuntimeBinding { |
| 96 | + #[must_use] |
| 97 | + pub fn declared_environment(&self) -> &BTreeSet<EnvironmentVariableName> { |
| 98 | + match self { |
| 99 | + Self::Agent { env, .. } | Self::GitDelivery { env } => env, |
| 100 | + } |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +#[derive(Clone, Debug, Eq, Error, PartialEq)] |
| 105 | +pub enum EnvironmentVariableNameError { |
| 106 | + #[error("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")] |
| 107 | + Invalid, |
| 108 | + #[error("environment variable name must be at most 128 bytes")] |
| 109 | + TooLong, |
| 110 | +} |
| 111 | + |
| 112 | +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] |
| 113 | +#[serde(try_from = "String")] |
| 114 | +pub struct EnvironmentVariableName(String); |
| 115 | + |
| 116 | +impl EnvironmentVariableName { |
| 117 | + pub fn new(value: impl Into<String>) -> Result<Self, EnvironmentVariableNameError> { |
| 118 | + let value = value.into(); |
| 119 | + if value.len() > 128 { |
| 120 | + return Err(EnvironmentVariableNameError::TooLong); |
| 121 | + } |
| 122 | + let mut bytes = value.bytes(); |
| 123 | + let valid_first = bytes |
| 124 | + .next() |
| 125 | + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_'); |
| 126 | + if !valid_first || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') { |
| 127 | + return Err(EnvironmentVariableNameError::Invalid); |
| 128 | + } |
| 129 | + Ok(Self(value)) |
| 130 | + } |
| 131 | + |
| 132 | + #[must_use] |
| 133 | + pub fn as_str(&self) -> &str { |
| 134 | + &self.0 |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +impl TryFrom<String> for EnvironmentVariableName { |
| 139 | + type Error = EnvironmentVariableNameError; |
| 140 | + |
| 141 | + fn try_from(value: String) -> Result<Self, Self::Error> { |
| 142 | + Self::new(value) |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +impl fmt::Display for EnvironmentVariableName { |
| 147 | + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 148 | + formatter.write_str(&self.0) |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +/// The immutable, secret-free request admitted by a selected target. |
| 153 | +/// |
| 154 | +/// Target selection is transport/CLI routing and is therefore not duplicated in this payload. |
| 155 | +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] |
| 156 | +#[serde(deny_unknown_fields, rename_all = "camelCase")] |
| 157 | +pub struct RunSubmission { |
| 158 | + pub graph: GraphSpec, |
| 159 | + pub initial_input: Value, |
| 160 | + pub runtime: RuntimePlan, |
| 161 | + #[serde(default)] |
| 162 | + pub ship: bool, |
| 163 | + pub submission_key: IdempotencyKey, |
| 164 | +} |
| 165 | + |
| 166 | +/// Admission's secret-free output. The compiler promotes the unchanged `GraphSpec` to the existing |
| 167 | +/// verified `CompiledGraphIr`; later stages never execute raw graph syntax. |
| 168 | +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] |
| 169 | +#[serde(deny_unknown_fields, rename_all = "camelCase")] |
| 170 | +pub struct AdmittedRun { |
| 171 | + pub graph: CompiledGraphIr, |
| 172 | + pub initial_input: Value, |
| 173 | + pub runtime: RuntimePlan, |
| 174 | + pub ship: bool, |
| 175 | +} |
| 176 | + |
| 177 | +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] |
| 178 | +#[error("identity must be greater than zero")] |
| 179 | +pub struct IdentityError; |
| 180 | + |
| 181 | +macro_rules! identity_type { |
| 182 | + ($name:ident) => { |
| 183 | + #[derive( |
| 184 | + Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, |
| 185 | + )] |
| 186 | + #[serde(try_from = "u64")] |
| 187 | + pub struct $name(NonZeroU64); |
| 188 | + |
| 189 | + impl $name { |
| 190 | + pub fn new(value: u64) -> Result<Self, IdentityError> { |
| 191 | + NonZeroU64::new(value).map(Self).ok_or(IdentityError) |
| 192 | + } |
| 193 | + |
| 194 | + #[must_use] |
| 195 | + pub const fn get(self) -> u64 { |
| 196 | + self.0.get() |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + impl TryFrom<u64> for $name { |
| 201 | + type Error = IdentityError; |
| 202 | + |
| 203 | + fn try_from(value: u64) -> Result<Self, Self::Error> { |
| 204 | + Self::new(value) |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + impl fmt::Display for $name { |
| 209 | + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 210 | + self.0.fmt(formatter) |
| 211 | + } |
| 212 | + } |
| 213 | + }; |
| 214 | +} |
| 215 | + |
| 216 | +identity_type!(NodeInstanceId); |
| 217 | +identity_type!(ExecutionId); |
| 218 | + |
| 219 | +/// Stable address for one dispatch. A node instance survives loop revisits; an execution does not. |
| 220 | +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] |
| 221 | +#[serde(deny_unknown_fields, rename_all = "camelCase")] |
| 222 | +pub struct ExecutionRef { |
| 223 | + pub run_id: RunId, |
| 224 | + pub node: NodeName, |
| 225 | + pub node_instance: NodeInstanceId, |
| 226 | + pub execution: ExecutionId, |
| 227 | +} |
| 228 | + |
| 229 | +/// Secret-free runner request produced by the reducer/supervisor boundary. |
| 230 | +/// |
| 231 | +/// Workspace access and resolved environment values are runtime capabilities and do not belong in |
| 232 | +/// this durable value. |
| 233 | +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] |
| 234 | +#[serde(deny_unknown_fields, rename_all = "camelCase")] |
| 235 | +pub struct NodeInvocation { |
| 236 | + pub reference: ExecutionRef, |
| 237 | + pub worker: WorkerRef, |
| 238 | + pub input: Value, |
| 239 | + pub binding: NodeRuntimeBinding, |
| 240 | +} |
| 241 | + |
| 242 | +/// Normalized completion returned to the supervisor and safe to append to the run ledger. |
| 243 | +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] |
| 244 | +#[serde(deny_unknown_fields, rename_all = "camelCase")] |
| 245 | +pub struct NodeCompletion { |
| 246 | + pub reference: ExecutionRef, |
| 247 | + pub outcome: WorkerOutcome, |
| 248 | +} |
| 249 | + |
| 250 | +#[cfg(test)] |
| 251 | +mod tests { |
| 252 | + use super::*; |
| 253 | + use serde_json::{json, Value}; |
| 254 | + |
| 255 | + fn canonical_submission() -> Value { |
| 256 | + json!({ |
| 257 | + "graph": { |
| 258 | + "profile": "openengine.graph.full/v1", |
| 259 | + "initialInput": { "kind": "null" }, |
| 260 | + "policy": { "policy": "policy.native-v2@1", "default": "deny" }, |
| 261 | + "root": { |
| 262 | + "kind": "seq", |
| 263 | + "name": "run", |
| 264 | + "state": { "kind": "null" }, |
| 265 | + "children": [ |
| 266 | + { |
| 267 | + "kind": "step", |
| 268 | + "name": "worker", |
| 269 | + "worker": "agent.worker@1", |
| 270 | + "input": { "kind": "null" }, |
| 271 | + "output": { "kind": "null" }, |
| 272 | + "inputBindings": [], |
| 273 | + "writeBindings": [], |
| 274 | + "timeoutMs": 60000, |
| 275 | + "attempts": 1 |
| 276 | + }, |
| 277 | + { |
| 278 | + "kind": "succeed", |
| 279 | + "name": "done", |
| 280 | + "output": { "kind": "null" }, |
| 281 | + "bindings": [] |
| 282 | + } |
| 283 | + ], |
| 284 | + "promotedStatePaths": [] |
| 285 | + } |
| 286 | + }, |
| 287 | + "initialInput": null, |
| 288 | + "runtime": { |
| 289 | + "harness": "codex", |
| 290 | + "provider": "openai", |
| 291 | + "nodes": { |
| 292 | + "worker": { |
| 293 | + "kind": "agent", |
| 294 | + "model": "gpt-5.6", |
| 295 | + "effort": "max", |
| 296 | + "env": ["GH_TOKEN", "OPENAI_API_KEY"] |
| 297 | + } |
| 298 | + } |
| 299 | + }, |
| 300 | + "ship": false, |
| 301 | + "submissionKey": "submission-1" |
| 302 | + }) |
| 303 | + } |
| 304 | + |
| 305 | + #[test] |
| 306 | + fn canonical_submission_round_trips_without_changing_graph_spec() { |
| 307 | + let expected = canonical_submission(); |
| 308 | + let submission: RunSubmission = |
| 309 | + serde_json::from_value(expected.clone()).expect("canonical fixture must decode"); |
| 310 | + |
| 311 | + let NodeRuntimeBinding::Agent { session_scope, .. } = submission |
| 312 | + .runtime |
| 313 | + .nodes() |
| 314 | + .get(&NodeName::new("worker").unwrap()) |
| 315 | + .unwrap() |
| 316 | + else { |
| 317 | + panic!("worker must be an agent binding"); |
| 318 | + }; |
| 319 | + assert_eq!(*session_scope, SessionScope::Execution); |
| 320 | + assert_eq!(serde_json::to_value(submission).unwrap(), expected); |
| 321 | + } |
| 322 | + |
| 323 | + #[test] |
| 324 | + fn unsupported_harness_provider_pair_is_rejected_by_shape() { |
| 325 | + let mut fixture = canonical_submission(); |
| 326 | + fixture["runtime"]["provider"] = json!("anthropic"); |
| 327 | + |
| 328 | + assert!(serde_json::from_value::<RunSubmission>(fixture).is_err()); |
| 329 | + } |
| 330 | + |
| 331 | + #[test] |
| 332 | + fn claude_openrouter_lane_round_trips() { |
| 333 | + let mut expected = canonical_submission(); |
| 334 | + expected["runtime"]["harness"] = json!("claude"); |
| 335 | + expected["runtime"]["provider"] = json!("openrouter"); |
| 336 | + expected["runtime"]["nodes"]["worker"]["model"] = json!("claude-sonnet-5"); |
| 337 | + |
| 338 | + let submission: RunSubmission = serde_json::from_value(expected.clone()).unwrap(); |
| 339 | + assert_eq!(serde_json::to_value(submission).unwrap(), expected); |
| 340 | + } |
| 341 | + |
| 342 | + #[test] |
| 343 | + fn environment_values_and_graph_runtime_fields_are_rejected() { |
| 344 | + let mut secret_fixture = canonical_submission(); |
| 345 | + secret_fixture["runtime"]["nodes"]["worker"]["env"] = json!({ "OPENAI_API_KEY": "secret" }); |
| 346 | + assert!(serde_json::from_value::<RunSubmission>(secret_fixture).is_err()); |
| 347 | + |
| 348 | + let mut graph_fixture = canonical_submission(); |
| 349 | + graph_fixture["graph"]["root"]["children"][0]["model"] = json!("gpt-5.6"); |
| 350 | + assert!(serde_json::from_value::<RunSubmission>(graph_fixture).is_err()); |
| 351 | + } |
| 352 | + |
| 353 | + #[test] |
| 354 | + fn environment_names_and_execution_identities_are_bounded() { |
| 355 | + assert!(EnvironmentVariableName::new("GH_TOKEN").is_ok()); |
| 356 | + assert!(EnvironmentVariableName::new("GH-TOKEN").is_err()); |
| 357 | + assert!(EnvironmentVariableName::new("1TOKEN").is_err()); |
| 358 | + assert!(NodeInstanceId::new(0).is_err()); |
| 359 | + assert!(ExecutionId::new(0).is_err()); |
| 360 | + assert_eq!(ExecutionId::new(7).unwrap().get(), 7); |
| 361 | + } |
| 362 | +} |
0 commit comments