Skip to main content

lean_ctx/core/
work_graph_store.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Crash-safe, project-scoped persistence for the local bounded Work Graph.
3
4use std::collections::BTreeMap;
5use std::fs;
6use std::io::Read;
7#[cfg(unix)]
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::core::agents::FileLock;
14use crate::core::work_graph::BoundedWorkGraph;
15
16const STORE_SCHEMA_VERSION: u16 = 1;
17const MAX_STORE_BYTES: u64 = 4 * 1024 * 1024;
18const MAX_GRAPHS: usize = 32;
19
20#[cfg(any(test, feature = "test-support"))]
21thread_local! {
22    static FAIL_NEXT_SAVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
23}
24
25#[cfg(any(test, feature = "test-support"))]
26pub fn with_save_failure<T>(action: impl FnOnce() -> T) -> T {
27    struct Reset;
28    impl Drop for Reset {
29        fn drop(&mut self) {
30            FAIL_NEXT_SAVE.set(false);
31        }
32    }
33    let _reset = Reset;
34    FAIL_NEXT_SAVE.set(true);
35    action()
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct WorkGraphStore {
41    schema_version: u16,
42    project_root_hash: String,
43    graphs: BTreeMap<String, BoundedWorkGraph>,
44}
45
46pub enum ClaimNodeExecution {
47    Claimed(Box<crate::core::work_graph::WorkNode>),
48    RecoveredExpired,
49}
50
51impl WorkGraphStore {
52    fn empty(project_root: &str) -> Self {
53        Self {
54            schema_version: STORE_SCHEMA_VERSION,
55            project_root_hash: crate::core::project_hash::hash_project_root(project_root),
56            graphs: BTreeMap::new(),
57        }
58    }
59
60    pub fn graph(&self, graph_id: &str) -> Option<&BoundedWorkGraph> {
61        self.graphs.get(graph_id)
62    }
63
64    /// Derive child delivery authority only from this validated durable snapshot.
65    /// Certificate fields select a node but never supply its expected identity.
66    pub fn child_delivery_authority(
67        &self,
68        certificate: &crate::core::a2a::task::delivery_delegation::DeliveryDelegationV1,
69        policy: &crate::core::a2a::task::TaskAuthorityConfigV1,
70        expected: &crate::core::a2a::task::TaskAuthorityExpectationV1<'_>,
71    ) -> Result<crate::core::a2a::task::TaskAuthorityConfigV1, String> {
72        use crate::core::a2a::task::delivery_delegation::DeliveryExecutionBindingV1;
73        let graph = self
74            .graph(&certificate.execution.graph_id)
75            .ok_or("delegated graph is missing")?;
76        let node = graph
77            .get_node(&certificate.execution.node_id)
78            .ok_or("delegated node is missing")?;
79        let now_ms = u64::try_from(expected.now.timestamp_millis())
80            .map_err(|_| "invalid verification time")?;
81        if node.status != crate::core::work_graph::NodeStatus::Active
82            || !node.execution_started
83            || node
84                .lease_expires_epoch_ms
85                .is_none_or(|expiry| expiry <= now_ms)
86        {
87            return Err("delegated execution is not live".into());
88        }
89        let fence = node
90            .execution_fence
91            .as_deref()
92            .ok_or("execution fence missing")?;
93        let attempt = node.delivery_attempt.ok_or("execution attempt missing")?;
94        let task = crate::core::work_graph_delegation::execution_key_for_scope(
95            &self.project_root_hash,
96            graph.graph_id(),
97            &node.node_id,
98            fence,
99        )?;
100        let live = DeliveryExecutionBindingV1 {
101            graph_id: graph.graph_id().into(),
102            node_id: node.node_id.clone(),
103            fence: fence.into(),
104            task_id: crate::core::work_graph_delegation::attempt_task_id(&task, attempt),
105            attempt,
106        };
107        let host = graph
108            .root_agent_for(&node.node_id)
109            .map_err(|error| error.to_string())?;
110        let expected_host = crate::core::a2a::task::TaskAuthorityExpectationV1 {
111            sender: host,
112            recipient: expected.recipient,
113            tenant_id: expected.tenant_id,
114            project_id: expected.project_id,
115            now: expected.now,
116        };
117        certificate
118            .verify_for_execution(policy, &expected_host, &live, &node.agent_id)
119            .map_err(|error| error.to_string())
120    }
121
122    /// Full-state write precondition, distinct from the compact observation cache
123    /// validator. This proves freshness only, never caller authorization.
124    pub fn graph_revision(&self, graph_id: &str) -> Result<String, String> {
125        validate_id(graph_id, "graph_id")?;
126        let graph = self
127            .graph(graph_id)
128            .ok_or_else(|| format!("graph not found: {graph_id}"))?;
129        let bytes = serde_json::to_vec(&(&self.project_root_hash, graph_id, graph))
130            .map_err(|error| error.to_string())?;
131        let mut hash = blake3::Hasher::new();
132        hash.update(b"lean-ctx:work-graph-write-revision:v1\0");
133        hash.update(&bytes);
134        Ok(hash.finalize().to_hex().to_string())
135    }
136
137    /// Compare and mutate under the same existing persistence lock. Callers must
138    /// authorize the principal inside the callback before changing graph state.
139    pub fn mutate_graph_if_revision<T>(
140        project_root: &str,
141        graph_id: &str,
142        expected_revision: &str,
143        mutate: impl FnOnce(&mut BoundedWorkGraph) -> Result<T, String>,
144    ) -> Result<T, String> {
145        Self::mutate_if_revision(project_root, graph_id, expected_revision, |store| {
146            mutate(store.graph_mut(graph_id)?)
147        })
148    }
149
150    /// [`Self::mutate_graph_if_revision`] for mutations that need the whole
151    /// store (a claim checks path conflicts across graphs).
152    pub fn mutate_if_revision<T>(
153        project_root: &str,
154        graph_id: &str,
155        expected_revision: &str,
156        mutate: impl FnOnce(&mut Self) -> Result<T, String>,
157    ) -> Result<T, String> {
158        if expected_revision.len() != 64
159            || !expected_revision
160                .bytes()
161                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
162        {
163            return Err("invalid work graph write revision".into());
164        }
165        Self::mutate(project_root, |store| {
166            if store.graph_revision(graph_id)? != expected_revision {
167                return Err("work graph revision conflict".into());
168            }
169            mutate(store)
170        })
171    }
172
173    pub fn graph_ids(&self) -> impl Iterator<Item = &str> {
174        self.graphs.keys().map(String::as_str)
175    }
176
177    pub fn graph_ids_for_agent<'a>(&'a self, agent_id: &'a str) -> impl Iterator<Item = &'a str> {
178        self.graphs
179            .iter()
180            .filter(move |(_, graph)| graph.contains_agent(agent_id))
181            .map(|(graph_id, _)| graph_id.as_str())
182    }
183
184    pub fn create(&mut self, graph_id: &str, mut graph: BoundedWorkGraph) -> Result<(), String> {
185        validate_id(graph_id, "graph_id")?;
186        if self.graphs.contains_key(graph_id) {
187            return Err(format!(
188                "duplicate graph: {graph_id} (inspect it with action=get or choose a new graph_id)"
189            ));
190        }
191        if self.graphs.len() >= MAX_GRAPHS {
192            return Err(format!("work graph store at capacity ({MAX_GRAPHS})"));
193        }
194        graph
195            .bind_graph_id(graph_id)
196            .map_err(|error| error.to_string())?;
197        self.graphs.insert(graph_id.to_string(), graph);
198        Ok(())
199    }
200
201    pub fn graph_mut(&mut self, graph_id: &str) -> Result<&mut BoundedWorkGraph, String> {
202        validate_id(graph_id, "graph_id")?;
203        self.graphs
204            .get_mut(graph_id)
205            .ok_or_else(|| format!("graph not found: {graph_id}"))
206    }
207
208    pub fn claim_node_execution(
209        &mut self,
210        graph_id: &str,
211        node_id: &str,
212        now_epoch_ms: u64,
213        lease_expires_epoch_ms: u64,
214    ) -> Result<ClaimNodeExecution, String> {
215        validate_id(graph_id, "graph_id")?;
216        validate_id(node_id, "node_id")?;
217        let requested = self
218            .graph(graph_id)
219            .and_then(|graph| graph.get_node(node_id))
220            .ok_or_else(|| format!("node not found: {node_id}"))?
221            .path_claims
222            .clone();
223        if !requested.is_empty()
224            && self.graphs.iter().any(|(candidate_graph_id, graph)| {
225                graph.has_live_path_conflict(
226                    &requested,
227                    now_epoch_ms,
228                    (candidate_graph_id == graph_id).then_some(node_id),
229                )
230            })
231        {
232            return Err("path claim conflicts with a live work graph execution".into());
233        }
234        let graph = self.graph_mut(graph_id)?;
235        // An expired lease stops the node; the stop revokes its delegated
236        // delivery. The host owns the child process and is told to end it.
237        let stopped = graph
238            .recover_expired_execution(node_id, now_epoch_ms)
239            .map_err(|error| error.to_string())?;
240        if !stopped.is_empty() {
241            return Ok(ClaimNodeExecution::RecoveredExpired);
242        }
243        graph
244            .claim_or_resume_active_until(node_id, lease_expires_epoch_ms)
245            .cloned()
246            .map(Box::new)
247            .map(ClaimNodeExecution::Claimed)
248            .map_err(|error| error.to_string())
249    }
250
251    pub fn load(project_root: &str) -> Result<Self, String> {
252        let path = store_path(project_root)?;
253        let parent = path
254            .parent()
255            .ok_or_else(|| "invalid work graph path".to_string())?;
256        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
257        reject_symlink(parent)?;
258        let _lock = FileLock::acquire(&path.with_extension("lock"))?;
259        load_at(&path, project_root)
260    }
261
262    pub fn mutate<T>(
263        project_root: &str,
264        mutate: impl FnOnce(&mut Self) -> Result<T, String>,
265    ) -> Result<T, String> {
266        let path = store_path(project_root)?;
267        let parent = path
268            .parent()
269            .ok_or_else(|| "invalid work graph path".to_string())?;
270        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
271        reject_symlink(parent)?;
272        #[cfg(unix)]
273        let directory = open_parent_directory(&path).map_err(|error| error.to_string())?;
274        let _lock = FileLock::acquire(&path.with_extension("lock"))?;
275        #[cfg(unix)]
276        let mut store = load_in_directory(&directory, &path, project_root)?;
277        #[cfg(not(unix))]
278        let mut store = load_at(&path, project_root)?;
279        let result = mutate(&mut store)?;
280        store.validate()?;
281        #[cfg(any(test, feature = "test-support"))]
282        if FAIL_NEXT_SAVE.replace(false) {
283            return Err("injected work graph persistence failure".into());
284        }
285        #[cfg(unix)]
286        save_in_directory(&directory, &path, &store)?;
287        #[cfg(not(unix))]
288        save_at(&path, &store)?;
289        Ok(result)
290    }
291
292    fn validate(&self) -> Result<(), String> {
293        if self.graphs.len() > MAX_GRAPHS {
294            return Err(format!("work graph store exceeds {MAX_GRAPHS} graphs"));
295        }
296        for (graph_id, graph) in &self.graphs {
297            validate_id(graph_id, "graph_id")?;
298            if graph.graph_id() != graph_id {
299                return Err("work graph key/id mismatch".to_string());
300            }
301            graph
302                .validate_invariants()
303                .map_err(|error| error.to_string())?;
304        }
305        Ok(())
306    }
307}
308
309pub fn validate_id(value: &str, field: &str) -> Result<(), String> {
310    if value.is_empty()
311        || value.len() > 128
312        || !value
313            .bytes()
314            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
315    {
316        return Err(format!(
317            "invalid {field}: expected 1..=128 ASCII [A-Za-z0-9._-]"
318        ));
319    }
320    Ok(())
321}
322
323fn store_path(project_root: &str) -> Result<PathBuf, String> {
324    let hash = crate::core::project_hash::hash_project_root(project_root);
325    Ok(crate::core::paths::state_dir()?
326        .join("work-graphs")
327        .join(format!("{hash}.json")))
328}
329
330fn load_at(path: &Path, project_root: &str) -> Result<WorkGraphStore, String> {
331    let bytes = match read_bounded_regular_nofollow(path) {
332        Ok(bytes) => bytes,
333        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
334            return Ok(WorkGraphStore::empty(project_root));
335        }
336        Err(error) => return Err(error.to_string()),
337    };
338    let mut store: WorkGraphStore =
339        serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
340    let expected = crate::core::project_hash::hash_project_root(project_root);
341    if store.schema_version != STORE_SCHEMA_VERSION || store.project_root_hash != expected {
342        return Err("work graph store scope or schema mismatch".to_string());
343    }
344    for (graph_id, graph) in &mut store.graphs {
345        graph
346            .bind_graph_id(graph_id)
347            .map_err(|error| error.to_string())?;
348    }
349    store.validate()?;
350    Ok(store)
351}
352
353#[cfg(unix)]
354fn load_in_directory(
355    directory: &fs::File,
356    path: &Path,
357    project_root: &str,
358) -> Result<WorkGraphStore, String> {
359    let leaf = path
360        .file_name()
361        .ok_or_else(|| "work graph store has no filename".to_string())?;
362    let bytes = match read_bounded_regular_from_directory(directory, leaf) {
363        Ok(bytes) => bytes,
364        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
365            return Ok(WorkGraphStore::empty(project_root));
366        }
367        Err(error) => return Err(error.to_string()),
368    };
369    decode_store(&bytes, project_root)
370}
371
372#[cfg(unix)]
373fn decode_store(bytes: &[u8], project_root: &str) -> Result<WorkGraphStore, String> {
374    let mut store: WorkGraphStore =
375        serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
376    let expected = crate::core::project_hash::hash_project_root(project_root);
377    if store.schema_version != STORE_SCHEMA_VERSION || store.project_root_hash != expected {
378        return Err("work graph store scope or schema mismatch".to_string());
379    }
380    for (graph_id, graph) in &mut store.graphs {
381        graph
382            .bind_graph_id(graph_id)
383            .map_err(|error| error.to_string())?;
384    }
385    store.validate()?;
386    Ok(store)
387}
388
389#[cfg(unix)]
390fn read_bounded_regular_nofollow(path: &Path) -> std::io::Result<Vec<u8>> {
391    let leaf = path
392        .file_name()
393        .ok_or_else(|| std::io::Error::other("work graph store has no filename"))?;
394    let directory = open_parent_directory(path)?;
395    read_bounded_regular_from_directory(&directory, leaf)
396}
397
398#[cfg(unix)]
399fn open_parent_directory(path: &Path) -> std::io::Result<fs::File> {
400    use std::os::unix::fs::OpenOptionsExt;
401    let parent = path
402        .parent()
403        .ok_or_else(|| std::io::Error::other("work graph store has no parent"))?;
404    fs::OpenOptions::new()
405        .read(true)
406        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
407        .open(parent)
408}
409
410#[cfg(unix)]
411fn read_bounded_regular_from_directory(
412    directory: &fs::File,
413    leaf: &std::ffi::OsStr,
414) -> std::io::Result<Vec<u8>> {
415    use std::ffi::CString;
416    use std::os::fd::{AsRawFd, FromRawFd};
417    use std::os::unix::ffi::OsStrExt;
418
419    let leaf = CString::new(leaf.as_bytes())
420        .map_err(|_| std::io::Error::other("work graph filename contains NUL"))?;
421    // SAFETY: directory is a held directory FD and leaf is NUL-terminated.
422    let fd = unsafe {
423        libc::openat(
424            directory.as_raw_fd(),
425            leaf.as_ptr(),
426            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
427        )
428    };
429    if fd < 0 {
430        return Err(std::io::Error::last_os_error());
431    }
432    // SAFETY: successful openat returned a new owned descriptor.
433    let file = unsafe { fs::File::from_raw_fd(fd) };
434    read_opened_store(file)
435}
436
437#[cfg(not(unix))]
438fn read_bounded_regular_nofollow(path: &Path) -> std::io::Result<Vec<u8>> {
439    if let Ok(metadata) = fs::symlink_metadata(path) {
440        if crate::core::pathutil::is_symlink_or_reparse(&metadata) {
441            return Err(std::io::Error::other("refusing linked work graph store"));
442        }
443    }
444    read_opened_store(fs::OpenOptions::new().read(true).open(path)?)
445}
446
447fn read_opened_store(mut file: fs::File) -> std::io::Result<Vec<u8>> {
448    let before = file.metadata()?;
449    if !before.file_type().is_file() {
450        return Err(std::io::Error::other(
451            "work graph store must be a regular file",
452        ));
453    }
454    if before.len() > MAX_STORE_BYTES {
455        return Err(std::io::Error::other(format!(
456            "work graph store exceeds {MAX_STORE_BYTES} bytes"
457        )));
458    }
459    let mut bytes = Vec::with_capacity(before.len() as usize);
460    (&mut file)
461        .take(MAX_STORE_BYTES + 1)
462        .read_to_end(&mut bytes)?;
463    if bytes.len() as u64 > MAX_STORE_BYTES {
464        return Err(std::io::Error::other(format!(
465            "work graph store exceeds {MAX_STORE_BYTES} bytes"
466        )));
467    }
468    let after = file.metadata()?;
469    if before.len() != after.len()
470        || before
471            .modified()
472            .ok()
473            .zip(after.modified().ok())
474            .is_some_and(|(before, after)| before != after)
475    {
476        return Err(std::io::Error::other(
477            "work graph store changed during read",
478        ));
479    }
480    Ok(bytes)
481}
482
483#[cfg(not(unix))]
484fn save_at(path: &Path, store: &WorkGraphStore) -> Result<(), String> {
485    let bytes = serde_json::to_vec(store).map_err(|error| error.to_string())?;
486    if bytes.len() as u64 > MAX_STORE_BYTES {
487        return Err(format!("work graph store exceeds {MAX_STORE_BYTES} bytes"));
488    }
489    #[cfg(unix)]
490    {
491        secure_atomic_write_nofollow(path, &bytes).map_err(|error| error.to_string())
492    }
493    #[cfg(not(unix))]
494    {
495        crate::core::atomic_fs::try_atomic_write(path, &bytes, None)
496            .map_err(|error| error.to_string())
497    }
498}
499
500#[cfg(unix)]
501fn save_in_directory(
502    directory: &fs::File,
503    path: &Path,
504    store: &WorkGraphStore,
505) -> Result<(), String> {
506    let bytes = serde_json::to_vec(store).map_err(|error| error.to_string())?;
507    if bytes.len() as u64 > MAX_STORE_BYTES {
508        return Err(format!("work graph store exceeds {MAX_STORE_BYTES} bytes"));
509    }
510    let leaf = path
511        .file_name()
512        .ok_or_else(|| "work graph store has no filename".to_string())?;
513    secure_atomic_write_in_directory(directory, leaf, &bytes).map_err(|error| error.to_string())
514}
515
516#[cfg(unix)]
517fn secure_atomic_write_in_directory(
518    directory: &fs::File,
519    leaf: &std::ffi::OsStr,
520    bytes: &[u8],
521) -> std::io::Result<()> {
522    use std::ffi::CString;
523    use std::os::fd::{AsRawFd, FromRawFd};
524    use std::os::unix::ffi::OsStrExt;
525    use std::time::{SystemTime, UNIX_EPOCH};
526
527    let leaf = CString::new(leaf.as_bytes())
528        .map_err(|_| std::io::Error::other("work graph filename contains NUL"))?;
529    let nonce = SystemTime::now()
530        .duration_since(UNIX_EPOCH)
531        .map_or(0, |duration| duration.as_nanos());
532    let temp = CString::new(format!(".work-graph.tmp.{}.{}", std::process::id(), nonce))
533        .expect("generated work graph temp name contains no NUL");
534    let directory_fd = directory.as_raw_fd();
535    // SAFETY: directory_fd is held for this operation and temp is NUL-terminated.
536    let temp_fd = unsafe {
537        libc::openat(
538            directory_fd,
539            temp.as_ptr(),
540            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
541            0o600,
542        )
543    };
544    if temp_fd < 0 {
545        return Err(std::io::Error::last_os_error());
546    }
547    // SAFETY: successful openat returned a new owned descriptor.
548    let mut file = unsafe { fs::File::from_raw_fd(temp_fd) };
549    let write_result = (|| {
550        file.write_all(bytes)?;
551        file.sync_all()?;
552        // SAFETY: file owns a live descriptor; the fixed mode contains no unsupported bits.
553        if unsafe { libc::fchmod(file.as_raw_fd(), 0o600) } != 0 {
554            return Err(std::io::Error::last_os_error());
555        }
556        // SAFETY: both names are NUL-terminated and resolved relative to the same held FD.
557        if unsafe { libc::renameat(directory_fd, temp.as_ptr(), directory_fd, leaf.as_ptr()) } != 0
558        {
559            return Err(std::io::Error::last_os_error());
560        }
561        directory.sync_all()
562    })();
563    if write_result.is_err() {
564        // SAFETY: temp is NUL-terminated and directory_fd remains live.
565        unsafe {
566            libc::unlinkat(directory_fd, temp.as_ptr(), 0);
567        }
568    }
569    write_result
570}
571
572fn reject_symlink(path: &Path) -> Result<(), String> {
573    let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
574    if metadata.file_type().is_symlink() {
575        return Err(format!(
576            "refusing symlink work graph path: {}",
577            path.display()
578        ));
579    }
580    Ok(())
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::core::work_graph::WorkNodeBudget;
587
588    fn graph_with_claim(root: &str, child: &str, claim: &str) -> BoundedWorkGraph {
589        let mut graph = BoundedWorkGraph::default();
590        graph
591            .add_root(
592                root.into(),
593                format!("{root}-agent"),
594                format!("capsule:{root}"),
595                WorkNodeBudget {
596                    tokens_allocated: 100,
597                    tokens_consumed: 0,
598                    cost_micros_allocated: 100,
599                    cost_micros_consumed: 0,
600                },
601            )
602            .unwrap();
603        graph
604            .queue_child(
605                root,
606                child.into(),
607                format!("{child}-agent"),
608                format!("capsule:{child}"),
609                WorkNodeBudget {
610                    tokens_allocated: 20,
611                    tokens_consumed: 0,
612                    cost_micros_allocated: 20,
613                    cost_micros_consumed: 0,
614                },
615            )
616            .unwrap();
617        graph.set_path_claims(child, vec![claim.into()]).unwrap();
618        graph
619    }
620
621    #[test]
622    fn write_revision_detects_hidden_changes_and_rejects_stale_mutation() {
623        let isolated = crate::core::data_dir::isolated_data_dir();
624        let project = isolated.path().to_str().unwrap();
625        WorkGraphStore::mutate(project, |store| {
626            store.create("graph", graph_with_claim("root", "child", "path:src/old"))
627        })
628        .unwrap();
629        let before = WorkGraphStore::load(project).unwrap();
630        let revision = before.graph_revision("graph").unwrap();
631        let observation = before.graph("graph").unwrap().observation();
632        WorkGraphStore::mutate_graph_if_revision(project, "graph", &revision, |graph| {
633            graph
634                .set_path_claims("child", vec!["path:src/new".into()])
635                .map_err(|error| error.to_string())
636        })
637        .unwrap();
638        let after = WorkGraphStore::load(project).unwrap();
639        assert_eq!(observation, after.graph("graph").unwrap().observation());
640        assert_ne!(revision, after.graph_revision("graph").unwrap());
641        let mut invoked = false;
642        let result = WorkGraphStore::mutate_graph_if_revision(project, "graph", &revision, |_| {
643            invoked = true;
644            Ok(())
645        });
646        assert_eq!(result.unwrap_err(), "work graph revision conflict");
647        assert!(!invoked);
648        assert_eq!(
649            after.graph_revision("graph").unwrap(),
650            WorkGraphStore::load(project)
651                .unwrap()
652                .graph_revision("graph")
653                .unwrap()
654        );
655    }
656
657    #[test]
658    fn ids_are_bounded_and_path_safe() {
659        assert!(validate_id("graph-1.node", "graph_id").is_ok());
660        assert!(validate_id("../escape", "graph_id").is_err());
661        assert!(validate_id("", "graph_id").is_err());
662        assert!(validate_id(&"a".repeat(129), "graph_id").is_err());
663    }
664
665    #[test]
666    fn store_round_trip_preserves_project_scope_and_graph() {
667        let temp = tempfile::tempdir().expect("tempdir");
668        let path = temp.path().join("graph.json");
669        let mut store = WorkGraphStore::empty("/project/a");
670        let mut graph = BoundedWorkGraph::default();
671        graph
672            .add_root(
673                "root".to_string(),
674                "agent-1".to_string(),
675                "capsule:sha256:abc".to_string(),
676                WorkNodeBudget {
677                    tokens_allocated: 100,
678                    tokens_consumed: 0,
679                    cost_micros_allocated: 50,
680                    cost_micros_consumed: 0,
681                },
682            )
683            .expect("root");
684        store.create("graph-1", graph).expect("create");
685        #[cfg(unix)]
686        {
687            let directory = open_parent_directory(&path).expect("parent");
688            save_in_directory(&directory, &path, &store).expect("save");
689        }
690        #[cfg(not(unix))]
691        save_at(&path, &store).expect("save");
692
693        let loaded = load_at(&path, "/project/a").expect("load");
694        assert_eq!(loaded.graph("graph-1").expect("graph").total_count(), 1);
695        assert!(load_at(&path, "/project/b").is_err());
696    }
697
698    #[cfg(unix)]
699    #[test]
700    fn store_rejects_symlink_target() {
701        use std::os::unix::fs::symlink;
702
703        let temp = tempfile::tempdir().expect("tempdir");
704        let target = temp.path().join("target.json");
705        fs::write(&target, b"{}").expect("target");
706        let link = temp.path().join("graph.json");
707        symlink(&target, &link).expect("symlink");
708        assert!(load_at(&link, "/project/a").is_err());
709    }
710
711    #[cfg(unix)]
712    #[test]
713    fn store_rejects_symlink_parent_directory() {
714        use std::os::unix::fs::symlink;
715
716        let temp = tempfile::tempdir().expect("tempdir");
717        let target = temp.path().join("target");
718        fs::create_dir(&target).expect("target dir");
719        fs::write(target.join("graph.json"), b"{}").expect("target");
720        let link = temp.path().join("linked-parent");
721        symlink(&target, &link).expect("symlink");
722        assert!(load_at(&link.join("graph.json"), "/project/a").is_err());
723    }
724
725    #[test]
726    fn store_rejects_oversized_regular_file_before_allocation() {
727        let temp = tempfile::tempdir().expect("tempdir");
728        let path = temp.path().join("graph.json");
729        let file = fs::File::create(&path).expect("file");
730        file.set_len(MAX_STORE_BYTES + 1).expect("set length");
731        assert!(load_at(&path, "/project/a").is_err());
732    }
733
734    #[cfg(unix)]
735    #[test]
736    fn store_rejects_fifo_without_blocking() {
737        use std::ffi::CString;
738        use std::os::unix::ffi::OsStrExt;
739
740        let temp = tempfile::tempdir().expect("tempdir");
741        let path = temp.path().join("graph.fifo");
742        let raw = CString::new(path.as_os_str().as_bytes()).expect("path");
743        // SAFETY: raw is a valid NUL-terminated path owned for this call.
744        assert_eq!(unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, 0);
745        assert!(load_at(&path, "/project/a").is_err());
746    }
747
748    #[cfg(unix)]
749    #[test]
750    fn store_write_stays_with_held_parent_after_path_replacement() {
751        use std::os::unix::fs::{OpenOptionsExt, symlink};
752
753        let temp = tempfile::tempdir().expect("tempdir");
754        let parent = temp.path().join("store");
755        let relocated = temp.path().join("relocated");
756        let attacker = temp.path().join("attacker");
757        fs::create_dir(&parent).expect("parent");
758        fs::create_dir(&attacker).expect("attacker");
759        let directory = fs::OpenOptions::new()
760            .read(true)
761            .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
762            .open(&parent)
763            .expect("held parent");
764        let store = WorkGraphStore::empty("/project/a");
765        save_in_directory(&directory, &parent.join("graph.json"), &store).expect("initial store");
766        let loaded = load_in_directory(&directory, &parent.join("graph.json"), "/project/a")
767            .expect("load through held directory");
768        fs::rename(&parent, &relocated).expect("replace parent");
769        symlink(&attacker, &parent).expect("attacker symlink");
770
771        save_in_directory(&directory, &parent.join("graph.json"), &loaded)
772            .expect("write through held directory");
773
774        assert!(load_at(&relocated.join("graph.json"), "/project/a").is_ok());
775        assert!(!attacker.join("graph.json").exists());
776    }
777
778    #[test]
779    fn store_serializes_overlapping_path_claims_across_graphs() {
780        let mut store = WorkGraphStore::empty("/project/a");
781        store
782            .create(
783                "g1",
784                graph_with_claim("root1", "child1", "path:project/src"),
785            )
786            .unwrap();
787        store
788            .create(
789                "g2",
790                graph_with_claim("root2", "child2", "path:project/src/lib.rs"),
791            )
792            .unwrap();
793
794        store.claim_node_execution("g1", "child1", 10, 100).unwrap();
795        assert!(store.claim_node_execution("g2", "child2", 10, 100).is_err());
796        assert!(matches!(
797            store
798                .claim_node_execution("g1", "child1", 100, 200)
799                .unwrap(),
800            ClaimNodeExecution::RecoveredExpired
801        ));
802        assert_eq!(
803            store
804                .graph("g1")
805                .unwrap()
806                .get_node("child1")
807                .unwrap()
808                .status,
809            crate::core::work_graph::NodeStatus::Stopped
810        );
811        // The expired incarnation is stopped durably; its delegated delivery
812        // is revoked and its path claims no longer block other work.
813        let bytes = serde_json::to_vec(&store).unwrap();
814        let mut store: WorkGraphStore = serde_json::from_slice(&bytes).unwrap();
815        store.validate().unwrap();
816        assert!(store.claim_node_execution("g2", "child2", 100, 200).is_ok());
817        assert_eq!(
818            store
819                .graph("g2")
820                .unwrap()
821                .get_node("child2")
822                .unwrap()
823                .status,
824            crate::core::work_graph::NodeStatus::Active
825        );
826    }
827}