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() -> Vec<AtelierToolDescriptor> {
155 [
156 ("clone", "Clone or update sibling repos"),
157 ("meta-build", "Regenerate the validation meta workspace"),
158 ("audit", "Run the private-data audit"),
159 ("no-github-check", "Assert no GitHub work is enabled"),
160 ("site", "Regenerate the public front page"),
161 ("repos", "List the constellation manifest"),
162 ("atelier-site", "Emit the Atelier Site graph"),
163 ("atelier-index", "Refresh the Atelier index"),
164 ("atelier-radar", "Query ranked Atelier hints"),
165 ("atelier-guard", "Run the Guideline Firewall"),
166 ]
167 .into_iter()
168 .map(|(command, title)| {
169 let capability = if command == "site" {
170 GuardCapability::RegenDocs("sim-say".to_owned())
171 } else {
172 GuardCapability::RunValidation("sim-private".to_owned())
173 };
174 AtelierToolDescriptor::new(
175 format!("simctl/{command}"),
176 title,
177 format!("sh bin/simctl {command}"),
178 capability,
179 "control",
180 Some("sim-private".to_owned()),
181 )
182 })
183 .collect()
184}
185
186pub fn repo_validation_descriptor(
188 repo: impl Into<String>,
189 validation_command: impl Into<String>,
190) -> AtelierToolDescriptor {
191 let repo = repo.into();
192 AtelierToolDescriptor::new(
193 format!("validation/{repo}"),
194 format!("Validate {repo}"),
195 validation_command,
196 GuardCapability::RunValidation(repo.clone()),
197 "validate",
198 Some(repo),
199 )
200}
201
202pub fn repo_docs_descriptor(
204 repo: impl Into<String>,
205 docs_command: impl Into<String>,
206) -> AtelierToolDescriptor {
207 let repo = repo.into();
208 AtelierToolDescriptor::new(
209 format!("docs/{repo}"),
210 format!("Regenerate docs for {repo}"),
211 docs_command,
212 GuardCapability::RegenDocs(repo.clone()),
213 "docs",
214 Some(repo),
215 )
216}
217
218pub fn evaluate_atelier_tool(
220 mission: &AgentMission,
221 action: AtelierToolAction,
222 atelier_node: Symbol,
223 stream_id: Symbol,
224) -> Result<AtelierToolEvaluation> {
225 let descriptor = action.descriptor();
226 let guard = action.guard_action();
227 let decision = match guard_action(mission, guard.clone()) {
228 GuardDecision::Granted => action.post_guard_decision(guard),
229 refused @ GuardDecision::Refused(_) => refused,
230 };
231 let event = match &decision {
232 GuardDecision::Granted => DevEvent::new(
233 &descriptor.evidence_kind,
234 atelier_node,
235 LatencyClass::OfflineRender,
236 action.payload_expr(&descriptor),
237 )?,
238 GuardDecision::Refused(refusal) => refusal.dev_event(atelier_node)?,
239 };
240 Ok(AtelierToolEvaluation {
241 descriptor,
242 decision,
243 cassette: DevCassette::from_events(stream_id, vec![event])?,
244 })
245}
246
247impl AtelierToolAction {
248 fn descriptor(&self) -> AtelierToolDescriptor {
249 match self {
250 Self::Simctl(descriptor) => descriptor.clone(),
251 Self::Validation(evidence) | Self::Docs(evidence) => evidence.descriptor.clone(),
252 Self::PinUpdate(request) => AtelierToolDescriptor::new(
253 format!("pin/{}", request.repo),
254 format!("Update {} pin", request.repo),
255 format!(
256 "set repos.toml {} commit {}",
257 request.repo, request.new_commit
258 ),
259 GuardCapability::PlanPin,
260 "pin",
261 Some(request.repo.clone()),
262 ),
263 Self::DocsRegeneration(request) => {
264 repo_docs_descriptor(request.repo.clone(), request.docs_command.clone())
265 }
266 }
267 }
268
269 fn guard_action(&self) -> AtelierAction {
270 match self {
271 Self::Simctl(descriptor) => guard_action_for_descriptor(descriptor),
272 Self::Validation(evidence) | Self::Docs(evidence) => {
273 guard_action_for_descriptor(&evidence.descriptor)
274 }
275 Self::PinUpdate(request) => AtelierAction::PlanPin {
276 repo: request.repo.clone(),
277 },
278 Self::DocsRegeneration(request) => AtelierAction::RegenDocs {
279 repo: request.repo.clone(),
280 },
281 }
282 }
283
284 fn post_guard_decision(&self, guard: AtelierAction) -> GuardDecision {
285 match self {
286 Self::PinUpdate(request) if !request.pushed_commit_exists => refused(
287 guard,
288 "pin update requires an existing pushed Forgejo commit",
289 ),
290 Self::DocsRegeneration(request)
291 if request.generated_public_doc && request.operation == DocsOperation::HandEdit =>
292 {
293 refused(
294 guard,
295 "generated public docs must be regenerated, not hand-edited",
296 )
297 }
298 _ => GuardDecision::Granted,
299 }
300 }
301
302 fn payload_expr(&self, descriptor: &AtelierToolDescriptor) -> Expr {
303 let mut entries = vec![
304 key("tool", Expr::String(descriptor.id.clone())),
305 key("command", Expr::String(descriptor.command.clone())),
306 key(
307 "capability",
308 Expr::String(capability_label(&descriptor.required_capability)),
309 ),
310 ];
311 match self {
312 Self::Validation(evidence) | Self::Docs(evidence) => {
313 entries.push(key(
314 "exit-status",
315 Expr::String(evidence.exit_status.to_string()),
316 ));
317 entries.push(key("log-path", Expr::String(evidence.log_path.clone())));
318 }
319 Self::PinUpdate(request) => {
320 entries.push(key(
321 "current-commit",
322 Expr::String(request.current_commit.clone()),
323 ));
324 entries.push(key("new-commit", Expr::String(request.new_commit.clone())));
325 }
326 Self::DocsRegeneration(request) => {
327 entries.push(key("path", Expr::String(request.path.clone())));
328 entries.push(key(
329 "generated-public-doc",
330 Expr::Bool(request.generated_public_doc),
331 ));
332 }
333 Self::Simctl(_) => {}
334 }
335 Expr::Map(entries)
336 }
337}
338
339fn guard_action_for_descriptor(descriptor: &AtelierToolDescriptor) -> AtelierAction {
340 match &descriptor.required_capability {
341 GuardCapability::RegenDocs(repo) => AtelierAction::RegenDocs { repo: repo.clone() },
342 GuardCapability::RunValidation(repo) => AtelierAction::RunValidation { repo: repo.clone() },
343 GuardCapability::PlanPin => AtelierAction::PlanPin {
344 repo: descriptor.repo.clone().unwrap_or_default(),
345 },
346 GuardCapability::EvalGated => AtelierAction::Eval {
347 label: descriptor.id.clone(),
348 },
349 GuardCapability::EditRepo(repo) => AtelierAction::EditFile {
350 repo: repo.clone(),
351 path: descriptor.command.clone(),
352 },
353 }
354}
355
356fn refused(action: AtelierAction, reason: impl Into<String>) -> GuardDecision {
357 GuardDecision::Refused(GuardRefusal::new(action, reason))
358}
359
360fn capability_label(capability: &GuardCapability) -> String {
361 match capability {
362 GuardCapability::EditRepo(repo) => format!("EditRepo({repo})"),
363 GuardCapability::RegenDocs(repo) => format!("RegenDocs({repo})"),
364 GuardCapability::PlanPin => "PlanPin".to_owned(),
365 GuardCapability::RunValidation(repo) => format!("RunValidation({repo})"),
366 GuardCapability::EvalGated => "EvalGated".to_owned(),
367 }
368}
369
370fn key(name: &str, value: Expr) -> (Expr, Expr) {
371 (Expr::Symbol(Symbol::new(name)), value)
372}