1use super::{
4 AgentMission, AtelierAction, GuardCapability, GuardDecision, GuardRefusal, guard_action,
5};
6use sim_kernel::{Expr, Result, Symbol};
7use sim_lib_stream_core::{DevCassette, DevEvent, LatencyClass};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct AtelierToolDescriptor {
12 pub id: String,
14 pub title: String,
16 pub command: String,
18 pub required_capability: GuardCapability,
20 pub evidence_kind: String,
22 pub repo: Option<String>,
24}
25
26impl AtelierToolDescriptor {
27 fn new(
28 id: impl Into<String>,
29 title: impl Into<String>,
30 command: impl Into<String>,
31 required_capability: GuardCapability,
32 evidence_kind: impl Into<String>,
33 repo: Option<String>,
34 ) -> Self {
35 Self {
36 id: id.into(),
37 title: title.into(),
38 command: command.into(),
39 required_capability,
40 evidence_kind: evidence_kind.into(),
41 repo,
42 }
43 }
44
45 pub fn as_expr(&self) -> Expr {
47 let mut entries = vec![
48 key("id", Expr::String(self.id.clone())),
49 key("title", Expr::String(self.title.clone())),
50 key("command", Expr::String(self.command.clone())),
51 key(
52 "capability",
53 Expr::String(capability_label(&self.required_capability)),
54 ),
55 key("evidence-kind", Expr::String(self.evidence_kind.clone())),
56 ];
57 if let Some(repo) = &self.repo {
58 entries.push(key("repo", Expr::String(repo.clone())));
59 }
60 Expr::Map(entries)
61 }
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct PinUpdateRequest {
67 pub repo: String,
69 pub current_commit: String,
71 pub new_commit: String,
73 pub pushed_commit_exists: bool,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum DocsOperation {
80 Regenerate,
82 HandEdit,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct DocsRegenerationRequest {
89 pub repo: String,
91 pub path: String,
93 pub docs_command: String,
95 pub generated_public_doc: bool,
97 pub operation: DocsOperation,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct ToolRunEvidence {
104 pub descriptor: AtelierToolDescriptor,
106 pub exit_status: i32,
108 pub log_path: String,
110}
111
112impl ToolRunEvidence {
113 pub fn new(
115 descriptor: AtelierToolDescriptor,
116 exit_status: i32,
117 log_path: impl Into<String>,
118 ) -> Self {
119 Self {
120 descriptor,
121 exit_status,
122 log_path: log_path.into(),
123 }
124 }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum AtelierToolAction {
130 Simctl(AtelierToolDescriptor),
132 Validation(ToolRunEvidence),
134 Docs(ToolRunEvidence),
136 PinUpdate(PinUpdateRequest),
138 DocsRegeneration(DocsRegenerationRequest),
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct AtelierToolEvaluation {
145 pub descriptor: AtelierToolDescriptor,
147 pub decision: GuardDecision,
149 pub cassette: DevCassette,
151}
152
153pub fn simctl_tool_descriptors(
159 control_repo: impl Into<String>,
160 docs_repo: impl Into<String>,
161) -> Vec<AtelierToolDescriptor> {
162 let control_repo = control_repo.into();
163 let docs_repo = docs_repo.into();
164 [
165 ("clone", "Clone or update sibling repos"),
166 ("meta-build", "Regenerate the validation meta workspace"),
167 ("audit", "Run the private-data audit"),
168 ("no-github-check", "Assert no GitHub work is enabled"),
169 ("site", "Regenerate the public front page"),
170 ("repos", "List the constellation manifest"),
171 ("atelier-site", "Emit the Atelier Site graph"),
172 ("atelier-index", "Refresh the Atelier index"),
173 ("atelier-radar", "Query ranked Atelier hints"),
174 ("atelier-guard", "Run the Guideline Firewall"),
175 ]
176 .into_iter()
177 .map(|(command, title)| {
178 let capability = if command == "site" {
179 GuardCapability::RegenDocs(docs_repo.clone())
180 } else {
181 GuardCapability::RunValidation(control_repo.clone())
182 };
183 AtelierToolDescriptor::new(
184 format!("simctl/{command}"),
185 title,
186 format!("sh bin/simctl {command}"),
187 capability,
188 "control",
189 Some(control_repo.clone()),
190 )
191 })
192 .collect()
193}
194
195pub fn repo_validation_descriptor(
197 repo: impl Into<String>,
198 validation_command: impl Into<String>,
199) -> AtelierToolDescriptor {
200 let repo = repo.into();
201 AtelierToolDescriptor::new(
202 format!("validation/{repo}"),
203 format!("Validate {repo}"),
204 validation_command,
205 GuardCapability::RunValidation(repo.clone()),
206 "validate",
207 Some(repo),
208 )
209}
210
211pub fn repo_docs_descriptor(
213 repo: impl Into<String>,
214 docs_command: impl Into<String>,
215) -> AtelierToolDescriptor {
216 let repo = repo.into();
217 AtelierToolDescriptor::new(
218 format!("docs/{repo}"),
219 format!("Regenerate docs for {repo}"),
220 docs_command,
221 GuardCapability::RegenDocs(repo.clone()),
222 "docs",
223 Some(repo),
224 )
225}
226
227pub fn evaluate_atelier_tool(
229 mission: &AgentMission,
230 action: AtelierToolAction,
231 atelier_node: Symbol,
232 stream_id: Symbol,
233) -> Result<AtelierToolEvaluation> {
234 let descriptor = action.descriptor();
235 let guard = action.guard_action();
236 let decision = match guard_action(mission, guard.clone()) {
237 GuardDecision::Granted => action.post_guard_decision(guard),
238 refused @ GuardDecision::Refused(_) => refused,
239 };
240 let event = match &decision {
241 GuardDecision::Granted => DevEvent::new(
242 &descriptor.evidence_kind,
243 atelier_node,
244 LatencyClass::OfflineRender,
245 action.payload_expr(&descriptor),
246 )?,
247 GuardDecision::Refused(refusal) => refusal.dev_event(atelier_node)?,
248 };
249 Ok(AtelierToolEvaluation {
250 descriptor,
251 decision,
252 cassette: DevCassette::from_events(stream_id, vec![event])?,
253 })
254}
255
256impl AtelierToolAction {
257 fn descriptor(&self) -> AtelierToolDescriptor {
258 match self {
259 Self::Simctl(descriptor) => descriptor.clone(),
260 Self::Validation(evidence) | Self::Docs(evidence) => evidence.descriptor.clone(),
261 Self::PinUpdate(request) => AtelierToolDescriptor::new(
262 format!("pin/{}", request.repo),
263 format!("Update {} pin", request.repo),
264 format!(
265 "set repos.toml {} commit {}",
266 request.repo, request.new_commit
267 ),
268 GuardCapability::PlanPin,
269 "pin",
270 Some(request.repo.clone()),
271 ),
272 Self::DocsRegeneration(request) => {
273 repo_docs_descriptor(request.repo.clone(), request.docs_command.clone())
274 }
275 }
276 }
277
278 fn guard_action(&self) -> AtelierAction {
279 match self {
280 Self::Simctl(descriptor) => guard_action_for_descriptor(descriptor),
281 Self::Validation(evidence) | Self::Docs(evidence) => {
282 guard_action_for_descriptor(&evidence.descriptor)
283 }
284 Self::PinUpdate(request) => AtelierAction::PlanPin {
285 repo: request.repo.clone(),
286 },
287 Self::DocsRegeneration(request) => AtelierAction::RegenDocs {
288 repo: request.repo.clone(),
289 },
290 }
291 }
292
293 fn post_guard_decision(&self, guard: AtelierAction) -> GuardDecision {
294 match self {
295 Self::PinUpdate(request) if !request.pushed_commit_exists => refused(
296 guard,
297 "pin update requires an existing pushed upstream commit",
298 ),
299 Self::DocsRegeneration(request)
300 if request.generated_public_doc && request.operation == DocsOperation::HandEdit =>
301 {
302 refused(
303 guard,
304 "generated public docs must be regenerated, not hand-edited",
305 )
306 }
307 _ => GuardDecision::Granted,
308 }
309 }
310
311 fn payload_expr(&self, descriptor: &AtelierToolDescriptor) -> Expr {
312 let mut entries = vec![
313 key("tool", Expr::String(descriptor.id.clone())),
314 key("command", Expr::String(descriptor.command.clone())),
315 key(
316 "capability",
317 Expr::String(capability_label(&descriptor.required_capability)),
318 ),
319 ];
320 match self {
321 Self::Validation(evidence) | Self::Docs(evidence) => {
322 entries.push(key(
323 "exit-status",
324 Expr::String(evidence.exit_status.to_string()),
325 ));
326 entries.push(key("log-path", Expr::String(evidence.log_path.clone())));
327 }
328 Self::PinUpdate(request) => {
329 entries.push(key(
330 "current-commit",
331 Expr::String(request.current_commit.clone()),
332 ));
333 entries.push(key("new-commit", Expr::String(request.new_commit.clone())));
334 }
335 Self::DocsRegeneration(request) => {
336 entries.push(key("path", Expr::String(request.path.clone())));
337 entries.push(key(
338 "generated-public-doc",
339 Expr::Bool(request.generated_public_doc),
340 ));
341 }
342 Self::Simctl(_) => {}
343 }
344 Expr::Map(entries)
345 }
346}
347
348fn guard_action_for_descriptor(descriptor: &AtelierToolDescriptor) -> AtelierAction {
349 match &descriptor.required_capability {
350 GuardCapability::RegenDocs(repo) => AtelierAction::RegenDocs { repo: repo.clone() },
351 GuardCapability::RunValidation(repo) => AtelierAction::RunValidation { repo: repo.clone() },
352 GuardCapability::PlanPin => AtelierAction::PlanPin {
353 repo: descriptor.repo.clone().unwrap_or_default(),
354 },
355 GuardCapability::EvalGated => AtelierAction::Eval {
356 label: descriptor.id.clone(),
357 },
358 GuardCapability::EditRepo(repo) => AtelierAction::EditFile {
359 repo: repo.clone(),
360 path: descriptor.command.clone(),
361 },
362 }
363}
364
365fn refused(action: AtelierAction, reason: impl Into<String>) -> GuardDecision {
366 GuardDecision::Refused(GuardRefusal::new(action, reason))
367}
368
369fn capability_label(capability: &GuardCapability) -> String {
370 match capability {
371 GuardCapability::EditRepo(repo) => format!("EditRepo({repo})"),
372 GuardCapability::RegenDocs(repo) => format!("RegenDocs({repo})"),
373 GuardCapability::PlanPin => "PlanPin".to_owned(),
374 GuardCapability::RunValidation(repo) => format!("RunValidation({repo})"),
375 GuardCapability::EvalGated => "EvalGated".to_owned(),
376 }
377}
378
379fn key(name: &str, value: Expr) -> (Expr, Expr) {
380 (Expr::Symbol(Symbol::new(name)), value)
381}