1use std::{
2 cmp::Ordering,
3 fs::{self, OpenOptions},
4 io::Write,
5 path::Path,
6};
7
8use chrono::{DateTime, SecondsFormat, Utc};
9use serde_json::{Map, Value, json};
10use thiserror::Error;
11
12use crate::{AgentProfile, AgentStateDelta, DeltaApplication, Document, object, profile_digest};
13
14#[derive(Debug, Error)]
16pub enum ApplyError {
17 #[error(transparent)]
19 Conflict(#[from] ConflictError),
20 #[error("{0}")]
22 Message(String),
23}
24
25#[derive(Debug, Error)]
27#[error("{0}")]
28pub struct ConflictError(pub String);
29
30#[derive(Default)]
32pub struct ApplyOptions<'a> {
33 pub approved: bool,
35 pub actor: Option<&'a str>,
37 pub now: Option<DateTime<Utc>>,
39}
40
41fn pointer_tokens(pointer: &str) -> Vec<String> {
42 pointer
43 .trim_start_matches('/')
44 .split('/')
45 .filter(|part| !part.is_empty())
46 .map(|part| part.replace("~1", "/").replace("~0", "~"))
47 .collect()
48}
49
50fn modify(
51 current: &mut Value,
52 tokens: &[String],
53 kind: &str,
54 value: Option<&Value>,
55) -> Result<bool, ApplyError> {
56 if tokens.is_empty() {
57 *current = value.cloned().unwrap_or(Value::Null);
58 return Ok(false);
59 }
60 let token = &tokens[0];
61 let last = tokens.len() == 1;
62 match current {
63 Value::Object(map) => {
64 if last {
65 match kind {
66 "remove" => return Ok(map.remove(token).is_none()),
67 "add" | "replace" => {
68 map.insert(token.clone(), value.cloned().unwrap_or(Value::Null));
69 return Ok(false);
70 }
71 _ => return Err(ApplyError::Message(format!("unknown operation {kind:?}"))),
72 }
73 }
74 if !map.contains_key(token) {
75 map.insert(token.clone(), Value::Object(Map::new()));
76 }
77 modify(map.get_mut(token).unwrap(), &tokens[1..], kind, value)
78 }
79 Value::Array(items) => {
80 let index = if let Some(id) = token.strip_prefix("id:") {
81 items.iter().position(|item| {
82 object(Some(item)).get("id").and_then(Value::as_str) == Some(id)
83 })
84 } else if token == "-" {
85 Some(items.len())
86 } else {
87 token.parse::<usize>().ok()
88 };
89 let Some(index) = index else {
90 return Ok(true);
91 };
92 if last {
93 match kind {
94 "add" if index <= items.len() => {
95 items.insert(index, value.cloned().unwrap_or(Value::Null));
96 Ok(false)
97 }
98 "remove" if index < items.len() => {
99 items.remove(index);
100 Ok(false)
101 }
102 "replace" if index < items.len() => {
103 items[index] = value.cloned().unwrap_or(Value::Null);
104 Ok(false)
105 }
106 "add" | "remove" | "replace" => Ok(true),
107 _ => Err(ApplyError::Message(format!("unknown operation {kind:?}"))),
108 }
109 } else if index < items.len() {
110 modify(&mut items[index], &tokens[1..], kind, value)
111 } else {
112 Ok(true)
113 }
114 }
115 _ => Ok(true),
116 }
117}
118
119fn apply_operation(
120 document: &mut Document,
121 operation: &Document,
122 warnings: &mut Vec<String>,
123) -> Result<(), ApplyError> {
124 let kind = operation
125 .get("op")
126 .and_then(Value::as_str)
127 .unwrap_or_default();
128 let pointer = operation
129 .get("path")
130 .and_then(Value::as_str)
131 .unwrap_or_default();
132 if pointer != "/state" && !pointer.starts_with("/state/") {
133 return Err(ApplyError::Message(format!(
134 "operation path {pointer:?} is outside /state"
135 )));
136 }
137 let mut root = Value::Object(std::mem::take(document));
138 let missing = modify(
139 &mut root,
140 &pointer_tokens(pointer),
141 kind,
142 operation.get("value"),
143 )?;
144 *document = root.as_object().cloned().unwrap_or_default();
145 if missing {
146 if kind == "remove" {
147 warnings.push(format!("remove on missing path {pointer:?} ignored"));
148 Ok(())
149 } else {
150 Err(ApplyError::Message(format!(
151 "path {pointer:?} does not resolve"
152 )))
153 }
154 } else {
155 Ok(())
156 }
157}
158
159pub fn apply_delta(
163 profile: &AgentProfile,
164 delta: &AgentStateDelta,
165 options: ApplyOptions<'_>,
166) -> Result<DeltaApplication, ApplyError> {
167 let metadata = object(profile.get("metadata"));
168 let current = metadata
169 .get("revision")
170 .and_then(Value::as_u64)
171 .unwrap_or(1);
172 let target = object(delta.get("target"));
173 if target.get("name").and_then(Value::as_str) != metadata.get("name").and_then(Value::as_str) {
174 return Err(ApplyError::Message(format!(
175 "delta targets {:?} but profile is {:?}",
176 target.get("name"),
177 metadata.get("name")
178 )));
179 }
180 if target.get("revision").and_then(Value::as_u64) != Some(current) {
181 return Err(ConflictError(format!(
182 "delta targets revision {:?} but profile is at {current}",
183 target.get("revision")
184 ))
185 .into());
186 }
187 if let Some(pinned) = target.get("digest").and_then(Value::as_str) {
188 if profile_digest(profile).map_err(|error| ApplyError::Message(error.to_string()))?
189 != pinned
190 {
191 return Err(ApplyError::Message(
192 "target.digest does not match profile".into(),
193 ));
194 }
195 }
196 let lifecycle = object(object(profile.get("spec")).get("lifecycle"));
197 let writeback = lifecycle
198 .get("writeback")
199 .and_then(Value::as_str)
200 .unwrap_or("propose");
201 if writeback == "off" {
202 return Err(ApplyError::Message("lifecycle.writeback is 'off'".into()));
203 }
204 if writeback == "propose" && !options.approved {
205 return Err(ApplyError::Message(
206 "lifecycle.writeback is 'propose'; explicit approval is required".into(),
207 ));
208 }
209 let mut working = profile.clone();
210 working
211 .entry("state")
212 .or_insert_with(|| Value::Object(Map::new()));
213 let mut warnings = vec![];
214 let operations = delta
215 .get("operations")
216 .and_then(Value::as_array)
217 .cloned()
218 .unwrap_or_default();
219 for (index, raw) in operations.iter().enumerate() {
220 apply_operation(
221 &mut working,
222 &raw.as_object().cloned().unwrap_or_default(),
223 &mut warnings,
224 )
225 .map_err(|error| ApplyError::Message(format!("operation {index}: {error}")))?;
226 }
227 let stamp = options
228 .now
229 .unwrap_or_else(Utc::now)
230 .to_rfc3339_opts(SecondsFormat::Secs, true);
231 enforce_retention(&mut working, &mut warnings, &stamp);
232 if let Some(metadata) = working.get_mut("metadata").and_then(Value::as_object_mut) {
233 metadata.insert("revision".into(), (current + 1).into());
234 metadata.insert("updated_at".into(), stamp.clone().into());
235 }
236 if !operations.is_empty() {
237 if let Some(state) = working.get_mut("state").and_then(Value::as_object_mut) {
238 let revision = state.get("revision").and_then(Value::as_u64).unwrap_or(0) + 1;
239 state.insert("revision".into(), revision.into());
240 state.insert("updated_at".into(), stamp.clone().into());
241 }
242 }
243 let session = object(delta.get("session"));
244 let actor = options.actor.unwrap_or("oap-rust");
245 let by = session.get("id").and_then(Value::as_str).unwrap_or(actor);
246 let mut entry = Map::from_iter([
247 ("revision".into(), (current + 1).into()),
248 ("at".into(), stamp.clone().into()),
249 ("by".into(), by.into()),
250 (
251 "change".into(),
252 delta
253 .get("summary")
254 .cloned()
255 .unwrap_or_else(|| format!("{} state operations", operations.len()).into()),
256 ),
257 ("sections".into(), json!(["state"])),
258 ]);
259 if let Some(id) = session.get("id") {
260 entry.insert("session_id".into(), id.clone());
261 }
262 if let Some(harness) = session.get("harness") {
263 entry.insert("harness".into(), harness.clone());
264 }
265 if options.approved {
266 entry.insert("approved_by".into(), actor.into());
267 }
268 working
269 .entry("history")
270 .or_insert_with(|| Value::Array(vec![]))
271 .as_array_mut()
272 .unwrap()
273 .push(Value::Object(entry));
274 enforce_retention(&mut working, &mut warnings, &stamp);
275 let pending_proposals = delta
276 .get("proposals")
277 .and_then(Value::as_array)
278 .into_iter()
279 .flatten()
280 .filter_map(|raw| raw.as_object().cloned())
281 .map(|mut proposal| {
282 let path = proposal
283 .get("path")
284 .and_then(Value::as_str)
285 .unwrap_or_default();
286 if [
287 "/spec/tools",
288 "/spec/permissions",
289 "/spec/memory",
290 "/spec/runtime/subagents",
291 ]
292 .iter()
293 .any(|prefix| path.starts_with(prefix))
294 {
295 proposal.insert("risk".into(), "high".into());
296 }
297 proposal
298 })
299 .collect();
300 Ok(DeltaApplication {
301 profile: working,
302 warnings,
303 pending_proposals,
304 })
305}
306
307fn sort_value(entry: &Value, strategy: &str) -> Value {
308 let object = object(Some(entry));
309 if strategy == "least_confident" {
310 object
311 .get("confidence")
312 .cloned()
313 .unwrap_or_else(|| json!(1.0))
314 } else if strategy == "oldest" {
315 object
316 .get("learned_at")
317 .or_else(|| object.get("opened_at"))
318 .cloned()
319 .unwrap_or_default()
320 } else {
321 ["last_used_at", "updated_at", "learned_at"]
322 .iter()
323 .find_map(|key| object.get(*key))
324 .cloned()
325 .unwrap_or_default()
326 }
327}
328fn compare(left: &Value, right: &Value) -> Ordering {
329 match (left.as_f64(), right.as_f64()) {
330 (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
331 _ => left
332 .as_str()
333 .unwrap_or_default()
334 .cmp(right.as_str().unwrap_or_default()),
335 }
336}
337
338fn enforce_retention(profile: &mut AgentProfile, warnings: &mut Vec<String>, now: &str) {
339 let retention =
340 object(object(object(profile.get("spec")).get("lifecycle")).get("retention")).clone();
341 let strategy = retention
342 .get("eviction")
343 .and_then(Value::as_str)
344 .unwrap_or("least_recently_used");
345 if let Some(state) = profile.get_mut("state").and_then(Value::as_object_mut) {
346 for collection in ["facts", "preferences"] {
347 let Some(entries) = state.get_mut(collection).and_then(Value::as_array_mut) else {
348 continue;
349 };
350 entries.retain(|raw| {
351 let entry = object(Some(raw));
352 let expired = retention.contains_key("fact_ttl_days")
353 && entry
354 .get("expires_at")
355 .and_then(Value::as_str)
356 .is_some_and(|expiry| expiry < now)
357 && entry.get("pinned") != Some(&Value::Bool(true));
358 if expired {
359 warnings.push(format!(
360 "evicted expired {collection} entry {:?}",
361 entry.get("id").and_then(Value::as_str).unwrap_or_default()
362 ));
363 }
364 !expired
365 });
366 if collection == "facts" {
367 if let Some(cap) = retention
368 .get("max_facts")
369 .and_then(Value::as_u64)
370 .map(|value| value as usize)
371 {
372 if entries.len() > cap {
373 let mut candidates: Vec<_> = entries
374 .iter()
375 .filter(|entry| {
376 object(Some(entry)).get("pinned") != Some(&Value::Bool(true))
377 })
378 .cloned()
379 .collect();
380 candidates.sort_by(|a, b| {
381 compare(&sort_value(a, strategy), &sort_value(b, strategy))
382 });
383 let drop_count = entries.len().saturating_sub(cap).min(candidates.len());
384 let dropped: BTreeIds = candidates
385 .into_iter()
386 .take(drop_count)
387 .filter_map(|entry| {
388 object(Some(&entry))
389 .get("id")
390 .and_then(Value::as_str)
391 .map(str::to_owned)
392 })
393 .collect();
394 for id in &dropped {
395 warnings
396 .push(format!("evicted {collection} entry {id:?} ({strategy})"));
397 }
398 entries.retain(|entry| {
399 !object(Some(entry))
400 .get("id")
401 .and_then(Value::as_str)
402 .is_some_and(|id| dropped.contains(id))
403 });
404 }
405 }
406 }
407 }
408 if let Some(cap) = retention
409 .get("max_open_threads")
410 .and_then(Value::as_u64)
411 .map(|value| value as usize)
412 {
413 if let Some(threads) = state.get_mut("open_threads").and_then(Value::as_array_mut) {
414 if threads.len() > cap {
415 let mut closed: Vec<_> = threads
416 .iter()
417 .filter(|thread| {
418 matches!(
419 object(Some(thread)).get("status").and_then(Value::as_str),
420 Some("done" | "abandoned")
421 )
422 })
423 .cloned()
424 .collect();
425 closed.sort_by_key(|thread| {
426 object(Some(thread))
427 .get("updated_at")
428 .and_then(Value::as_str)
429 .unwrap_or_default()
430 .to_owned()
431 });
432 let dropped: BTreeIds = closed
433 .into_iter()
434 .take((threads.len() - cap).min(threads.len()))
435 .filter_map(|entry| {
436 object(Some(&entry))
437 .get("id")
438 .and_then(Value::as_str)
439 .map(str::to_owned)
440 })
441 .collect();
442 for id in &dropped {
443 warnings.push(format!("evicted closed thread {id:?}"));
444 }
445 threads.retain(|entry| {
446 !object(Some(entry))
447 .get("id")
448 .and_then(Value::as_str)
449 .is_some_and(|id| dropped.contains(id))
450 });
451 if threads.len() > cap {
452 let remove = threads.len() - cap;
453 threads.drain(..remove);
454 warnings.push(
455 "open_threads still over cap after evicting closed threads".into(),
456 );
457 }
458 }
459 }
460 }
461 }
462 let history_cap = retention
463 .get("max_history")
464 .and_then(Value::as_u64)
465 .unwrap_or(50) as usize;
466 if let Some(history) = profile.get_mut("history").and_then(Value::as_array_mut) {
467 if history.len() > history_cap {
468 let remove = history.len() - history_cap;
469 history.drain(..remove);
470 }
471 }
472}
473
474type BTreeIds = std::collections::BTreeSet<String>;
475
476pub fn serialize(document: &Document, format: crate::OapFormat) -> Result<String, ApplyError> {
478 match format {
479 crate::OapFormat::Json => serde_json::to_string_pretty(document)
480 .map(|text| text + "\n")
481 .map_err(|error| ApplyError::Message(error.to_string())),
482 crate::OapFormat::Yaml => serde_yaml_ng::to_string(document)
483 .map_err(|error| ApplyError::Message(error.to_string())),
484 crate::OapFormat::Markdown => {
485 let mut copy = document.clone();
486 let instructions = copy
487 .get_mut("spec")
488 .and_then(Value::as_object_mut)
489 .and_then(|spec| spec.get_mut("role"))
490 .and_then(Value::as_object_mut)
491 .and_then(|role| role.remove("instructions"))
492 .and_then(|value| value.as_str().map(str::to_owned))
493 .unwrap_or_default();
494 let yaml = serde_yaml_ng::to_string(©)
495 .map_err(|error| ApplyError::Message(error.to_string()))?;
496 Ok(format!(
497 "---\n{}---\n{}\n",
498 yaml.trim_start_matches("---\n"),
499 instructions.trim_end()
500 ))
501 }
502 }
503}
504
505pub fn write_atomically(path: impl AsRef<Path>, data: &[u8]) -> Result<(), ApplyError> {
507 let path = path.as_ref();
508 let directory = path.parent().unwrap_or_else(|| Path::new("."));
509 let temporary = directory.join(format!(
510 ".{}.{}.tmp",
511 path.file_name()
512 .and_then(|name| name.to_str())
513 .unwrap_or("oap"),
514 std::process::id()
515 ));
516 let result = (|| {
517 let mut file = OpenOptions::new()
518 .create_new(true)
519 .write(true)
520 .open(&temporary)?;
521 file.write_all(data)?;
522 file.sync_all()?;
523 drop(file);
524 fs::rename(&temporary, path)?;
525 if let Ok(directory) = fs::File::open(directory) {
526 directory.sync_all()?;
527 }
528 Ok::<(), std::io::Error>(())
529 })();
530 if result.is_err() {
531 let _ = fs::remove_file(&temporary);
532 }
533 result.map_err(|error| ApplyError::Message(error.to_string()))
534}