roder_core/runtime/
thread_policy.rs1use super::*;
2use roder_api::extension_state::{ExtensionStateRecord, ExtensionStoreScope};
3
4const POLICY_EXTENSION: &str = "roder-core";
5const POLICY_KEY: &str = "thread-policy-mode";
6
7impl Runtime {
8 pub async fn set_thread_policy_mode(
10 &self,
11 thread_id: &str,
12 mode: PolicyMode,
13 ) -> anyhow::Result<()> {
14 let previous_mode = self.effective_policy_mode_for_thread(thread_id).await;
15 let mut modes = self.thread_policy_modes.write().await;
16 if let Some(store) = &self.thread_store {
17 let id = thread_id.to_string();
18 anyhow::ensure!(
19 store.load_thread_metadata(&id).await?.is_some(),
20 "thread not found: {thread_id}"
21 );
22 store
23 .append_extension_state(
24 &id,
25 &ExtensionStateRecord {
26 extension_id: POLICY_EXTENSION.into(),
27 key: POLICY_KEY.into(),
28 scope: ExtensionStoreScope::Thread {
29 thread_id: id.clone(),
30 },
31 schema_version: 1,
32 value: serde_json::to_value(mode)?,
33 },
34 )
35 .await?;
36 }
37 modes.insert(thread_id.into(), mode);
38 drop(modes);
39 self.emit(RoderEvent::PolicyModeChanged(PolicyModeChanged {
40 thread_id: thread_id.into(),
41 turn_id: None,
42 previous_mode,
43 new_mode: mode,
44 reason: Some("thread mode selection".into()),
45 timestamp: OffsetDateTime::now_utc(),
46 }))
47 .await;
48 self.auto_resolve_pending_tool_approvals().await;
49 Ok(())
50 }
51
52 pub async fn thread_policy_mode(&self, thread_id: &str) -> anyhow::Result<Option<PolicyMode>> {
53 let mut modes = self.thread_policy_modes.write().await;
54 if let Some(mode) = modes.get(thread_id).copied() {
55 return Ok(Some(mode));
56 }
57 let Some(store) = &self.thread_store else {
58 return Ok(None);
59 };
60 let states = store.load_extension_states(&thread_id.to_string()).await?;
61 let record = states
62 .iter()
63 .rev()
64 .find(|state| state.extension_id == POLICY_EXTENSION && state.key == POLICY_KEY);
65 let Some(record) = record else {
66 return Ok(None);
67 };
68 anyhow::ensure!(
69 record.schema_version == 1
70 && record.scope
71 == (ExtensionStoreScope::Thread {
72 thread_id: thread_id.into()
73 }),
74 "invalid thread policy state"
75 );
76 let mode = serde_json::from_value(record.value.clone())?;
77 modes.insert(thread_id.into(), mode);
78 Ok(Some(mode))
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[tokio::test]
87 async fn thread_policy_plan_exit_changes_only_the_requesting_thread() {
88 let mut builder = roder_api::extension::ExtensionRegistryBuilder::new();
89 builder.inference_engine(Arc::new(crate::fake_provider::FakeInferenceEngine));
90 let runtime = Runtime::new(builder.build().unwrap(), Default::default()).unwrap();
91 runtime
92 .set_thread_policy_mode("a", PolicyMode::Plan)
93 .await
94 .unwrap();
95 runtime
96 .set_thread_policy_mode("b", PolicyMode::Plan)
97 .await
98 .unwrap();
99 runtime
100 .record_pending_plan_exit(PendingPlanExit::new(
101 "a".into(),
102 "turn".into(),
103 "exit".into(),
104 PolicyMode::AcceptAll,
105 Some("Approved implementation".into()),
106 vec![],
107 ))
108 .await;
109 runtime
110 .resolve_pending_plan_exit("exit", true)
111 .await
112 .unwrap();
113 assert_eq!(
114 runtime.effective_policy_mode_for_thread("a").await,
115 PolicyMode::AcceptAll
116 );
117 assert_eq!(
118 runtime.effective_policy_mode_for_thread("b").await,
119 PolicyMode::Plan
120 );
121 assert_eq!(runtime.status().await.policy_mode, PolicyMode::Default);
122 }
123
124 #[tokio::test]
125 async fn thread_policy_denied_plan_exit_reports_scoped_mode() {
126 let mut builder = roder_api::extension::ExtensionRegistryBuilder::new();
127 builder.inference_engine(Arc::new(crate::fake_provider::FakeInferenceEngine));
128 let runtime = Runtime::new(builder.build().unwrap(), Default::default()).unwrap();
129 runtime
130 .set_thread_policy_mode("a", PolicyMode::Plan)
131 .await
132 .unwrap();
133 runtime
134 .set_policy_mode(PolicyMode::Bypass, None)
135 .await
136 .unwrap();
137 let mut events = runtime.subscribe_events();
138 runtime
139 .record_pending_plan_exit(PendingPlanExit::new(
140 "a".into(),
141 "turn".into(),
142 "exit".into(),
143 PolicyMode::Default,
144 None,
145 vec![],
146 ))
147 .await;
148 runtime
149 .resolve_pending_plan_exit("exit", false)
150 .await
151 .unwrap();
152 loop {
153 if let RoderEvent::PolicyExitPlanResolved(event) = events.recv().await.unwrap().event {
154 assert!(!event.approved);
155 assert_eq!(event.resolved_mode, PolicyMode::Plan);
156 break;
157 }
158 }
159 assert_eq!(
160 runtime.effective_policy_mode_for_thread("a").await,
161 PolicyMode::Plan
162 );
163 assert_eq!(runtime.status().await.policy_mode, PolicyMode::Bypass);
164 }
165}