1use crate::protocol::{
2 AgentId, SessionMode, SpawnEnvironmentProfileId, SpawnEnvironmentProfileRevision,
3};
4use gate4agent_runtime_native::{
5 NativeLaunchProfile, NativeLaunchProfileId,
6};
7use gate4agent_types::TransportKind;
8use thiserror::Error;
9use crate::session_environment::NodeSessionMaterializationProfile;
10
11pub const MAX_NODE_ENVIRONMENT_PROFILES: usize = 128;
12
13pub struct NodeEnvironmentProfile {
17 id: SpawnEnvironmentProfileId,
18 revision: SpawnEnvironmentProfileRevision,
19 provider: AgentId,
20 pty: Option<NativeLaunchProfile>,
21 inline: Option<NativeLaunchProfile>,
22 materialization: Option<NodeSessionMaterializationProfile>,
23}
24
25impl NodeEnvironmentProfile {
26 pub fn new(
27 id: SpawnEnvironmentProfileId,
28 revision: SpawnEnvironmentProfileRevision,
29 provider: AgentId,
30 profiles: impl IntoIterator<Item = NativeLaunchProfile>,
31 ) -> Result<Self, NodeEnvironmentProfileError> {
32 Self::new_with_materialization(id, revision, provider, profiles, None)
33 }
34
35 pub fn new_with_materialization(
36 id: SpawnEnvironmentProfileId,
37 revision: SpawnEnvironmentProfileRevision,
38 provider: AgentId,
39 profiles: impl IntoIterator<Item = NativeLaunchProfile>,
40 materialization: Option<NodeSessionMaterializationProfile>,
41 ) -> Result<Self, NodeEnvironmentProfileError> {
42 let mut pty = None;
43 let mut inline = None;
44 for profile in profiles {
45 if profile.agent_id() != &provider {
46 return Err(NodeEnvironmentProfileError::ProviderMismatch);
47 }
48 let destination = match profile.transport() {
49 TransportKind::Pty => &mut pty,
50 TransportKind::Pipe => &mut inline,
51 TransportKind::Acp => {
52 return Err(NodeEnvironmentProfileError::UnsupportedTransport)
53 }
54 };
55 if destination.replace(profile).is_some() {
56 return Err(NodeEnvironmentProfileError::DuplicateTransport);
57 }
58 }
59 if pty.is_none() && inline.is_none() {
60 return Err(NodeEnvironmentProfileError::Empty);
61 }
62 if pty.as_ref().zip(inline.as_ref()).is_some_and(|(pty, inline)| {
63 pty.id() == inline.id()
64 }) {
65 return Err(NodeEnvironmentProfileError::DuplicateNativeProfileId);
66 }
67 Ok(Self {
68 id,
69 revision,
70 provider,
71 pty,
72 inline,
73 materialization,
74 })
75 }
76
77 pub fn id(&self) -> &SpawnEnvironmentProfileId {
78 &self.id
79 }
80
81 pub fn revision(&self) -> &SpawnEnvironmentProfileRevision {
82 &self.revision
83 }
84
85 pub fn provider(&self) -> &AgentId {
86 &self.provider
87 }
88
89 pub(crate) fn into_parts(
90 self,
91 ) -> (
92 EnvironmentProfileBinding,
93 Vec<NativeLaunchProfile>,
94 Option<NodeSessionMaterializationProfile>,
95 ) {
96 let pty_id = self.pty.as_ref().map(|profile| profile.id().clone());
97 let inline_id = self.inline.as_ref().map(|profile| profile.id().clone());
98 let mut profiles = Vec::with_capacity(usize::from(self.pty.is_some()) + usize::from(self.inline.is_some()));
99 profiles.extend(self.pty);
100 profiles.extend(self.inline);
101 (
102 EnvironmentProfileBinding {
103 id: self.id,
104 revision: self.revision,
105 provider: self.provider,
106 pty_id,
107 inline_id,
108 },
109 profiles,
110 self.materialization,
111 )
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub(crate) struct EnvironmentProfileBinding {
117 pub(crate) id: SpawnEnvironmentProfileId,
118 pub(crate) revision: SpawnEnvironmentProfileRevision,
119 pub(crate) provider: AgentId,
120 pty_id: Option<NativeLaunchProfileId>,
121 inline_id: Option<NativeLaunchProfileId>,
122}
123
124impl EnvironmentProfileBinding {
125 pub(crate) fn native_profile_id(
126 &self,
127 mode: SessionMode,
128 ) -> Option<&NativeLaunchProfileId> {
129 match mode {
130 SessionMode::Pty => self.pty_id.as_ref(),
131 SessionMode::Inline => self.inline_id.as_ref(),
132 SessionMode::Acp => None,
138 }
139 }
140
141 pub(crate) fn native_profile_ids(&self) -> impl Iterator<Item = &NativeLaunchProfileId> {
142 self.pty_id.iter().chain(self.inline_id.iter())
143 }
144}
145
146#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
147pub enum NodeEnvironmentProfileError {
148 #[error("node environment profile requires at least one native transport binding")]
149 Empty,
150 #[error("node environment profile native binding targets another provider")]
151 ProviderMismatch,
152 #[error("node environment profile contains duplicate transport bindings")]
153 DuplicateTransport,
154 #[error("node environment profile transport is unsupported")]
155 UnsupportedTransport,
156 #[error("node environment profile reuses one native profile ID across transports")]
157 DuplicateNativeProfileId,
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use gate4agent_catalog::EnvMutation;
164 use gate4agent_runtime_native::{
165 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
166 };
167 use std::ffi::OsString;
168 use std::sync::Arc;
169
170 struct EmptyResolver;
171
172 impl NativeChildEnvironmentResolver for EmptyResolver {
173 fn resolve_child_environment(
174 &self,
175 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
176 Ok(vec![EnvMutation {
177 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
178 value: None,
179 }])
180 }
181 }
182
183 fn native(id: &str, provider: &str, transport: TransportKind) -> NativeLaunchProfile {
184 NativeLaunchProfile::new(
185 NativeLaunchProfileId::new(id).unwrap(),
186 AgentId::new(provider).unwrap(),
187 transport,
188 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
189 Arc::new(EmptyResolver),
190 )
191 .unwrap()
192 }
193
194 #[test]
195 fn node_environment_profile_requires_exact_provider_and_transport_bindings() {
196 let id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
197 let revision = SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
198 let provider = AgentId::new("claude").unwrap();
199 let profile = NodeEnvironmentProfile::new(
200 id.clone(),
201 revision.clone(),
202 provider.clone(),
203 [
204 native("local-claude-pty", "claude", TransportKind::Pty),
205 native("local-claude-pipe", "claude", TransportKind::Pipe),
206 ],
207 )
208 .unwrap();
209 let (binding, native_profiles, materialization) = profile.into_parts();
210 assert!(materialization.is_none());
211 assert_eq!(binding.id, id);
212 assert_eq!(binding.revision, revision);
213 assert_eq!(binding.provider, provider);
214 assert_eq!(native_profiles.len(), 2);
215 assert_eq!(
216 binding.native_profile_id(SessionMode::Pty).unwrap().as_str(),
217 "local-claude-pty",
218 );
219 assert_eq!(
220 binding.native_profile_id(SessionMode::Inline).unwrap().as_str(),
221 "local-claude-pipe",
222 );
223
224 let mismatch = match NodeEnvironmentProfile::new(
225 SpawnEnvironmentProfileId::new("mismatch").unwrap(),
226 SpawnEnvironmentProfileRevision::new("r1").unwrap(),
227 AgentId::new("claude").unwrap(),
228 [native("mismatch-pty", "codex", TransportKind::Pty)],
229 ) {
230 Ok(_) => panic!("provider mismatch was accepted"),
231 Err(error) => error,
232 };
233 assert_eq!(mismatch, NodeEnvironmentProfileError::ProviderMismatch);
234 }
235}