Skip to main content

acyclic_fs/
lineage.rs

1//! Durable direct-parent workspace lineage.
2//!
3//! Immutable generations retain content ancestry, while this module records the
4//! customer-facing workspace relationship needed for recursive agent trees and
5//! direct-parent publication authorization.  The record is deliberately small
6//! and storage-neutral so distributed authority backends can persist it with
7//! the same compare-and-swap discipline as workspace heads.
8
9use crate::{
10    AsyncAuthorityStore, AsyncObjectStore, ForkOptions, GenerationId, IdempotencyKey, Workspace,
11    WorkspaceError, WorkspaceId,
12};
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15use std::future::Future;
16use std::sync::Mutex;
17use thiserror::Error;
18
19const LINEAGE_VERSION: u32 = 1;
20const MAXIMUM_LINEAGE_CAS_ATTEMPTS: usize = 16;
21
22/// Durable identity and direct parent of one SDK workspace.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct WorkspaceLineageRecord {
25    /// Serialization contract version.
26    pub version: u32,
27    /// Monotonic optimistic-concurrency revision.
28    pub revision: u64,
29    /// Whether the planned child authority and its initial generation exist.
30    pub ready: bool,
31    /// Child workspace described by this record.
32    pub workspace_id: WorkspaceId,
33    /// Canonical workspace name needed to reopen it after process restart.
34    pub workspace_name: String,
35    /// Direct parent allowed to receive publication from the child.
36    pub parent_workspace_id: Option<WorkspaceId>,
37    /// Canonical direct-parent name needed for restart recovery.
38    pub parent_workspace_name: Option<String>,
39    /// Exact parent generation from which the child was created.
40    pub fork_generation: GenerationId,
41    /// Exact first generation in the child workspace, used for local diffs.
42    pub initial_generation: GenerationId,
43}
44
45fn lineage_shape_is_valid(record: &WorkspaceLineageRecord) -> bool {
46    let parent_shape = match (
47        record.parent_workspace_id,
48        record.parent_workspace_name.as_deref(),
49    ) {
50        (None, None) => record.fork_generation == record.initial_generation,
51        (Some(parent_id), Some(parent_name)) => {
52            parent_id != record.workspace_id && crate::WorkspaceName::new(parent_name).is_ok()
53        }
54        _ => false,
55    };
56    record.version == LINEAGE_VERSION
57        && record.revision > 0
58        && crate::WorkspaceName::new(&record.workspace_name).is_ok()
59        && parent_shape
60}
61
62/// Durable optimistic-concurrency adapter for workspace lineage.
63pub trait WorkspaceLineageStore: Send + Sync {
64    /// Adapter error.
65    type Error: std::error::Error + Send + Sync + 'static;
66
67    /// Loads the record for one workspace, if registered.
68    fn load(
69        &self,
70        workspace_id: WorkspaceId,
71    ) -> impl Future<Output = Result<Option<WorkspaceLineageRecord>, Self::Error>> + Send;
72
73    /// Atomically replaces `expected_revision`; zero creates a record.
74    fn compare_and_swap(
75        &self,
76        workspace_id: WorkspaceId,
77        expected_revision: u64,
78        replacement: WorkspaceLineageRecord,
79    ) -> impl Future<Output = Result<bool, Self::Error>> + Send;
80}
81
82/// Failure while recording or authenticating workspace lineage.
83#[derive(Debug, Error)]
84pub enum WorkspaceLineageError<E: std::error::Error + 'static> {
85    /// The durable adapter failed.
86    #[error("workspace-lineage store failed: {0}")]
87    Store(E),
88    /// The workspace operation failed.
89    #[error(transparent)]
90    Workspace(#[from] WorkspaceError),
91    /// Persisted state uses an unsupported version or mismatched identity.
92    #[error("workspace-lineage state is incompatible")]
93    IncompatibleState,
94    /// An existing workspace is already bound to different lineage.
95    #[error("workspace lineage conflicts with its durable registration")]
96    ConflictingRegistration,
97    /// Optimistic registration contention exceeded the bounded retry policy.
98    #[error("workspace lineage registration remained contended")]
99    Contended,
100    /// Publication was requested by a workspace other than the direct parent.
101    #[error("only the direct parent may receive workspace publication")]
102    UnauthorizedJoin,
103    /// A lineage walk encountered a cycle or exceeded the caller's bound.
104    #[error("workspace lineage traversal exceeded its safe bound")]
105    TraversalLimit,
106}
107
108/// Core recursive-workspace graph over one durable lineage adapter.
109pub struct WorkspaceGraph<S> {
110    store: S,
111}
112
113impl<S> WorkspaceGraph<S> {
114    /// Creates a graph over a durable state adapter.
115    #[must_use]
116    pub const fn new(store: S) -> Self {
117        Self { store }
118    }
119
120    /// Borrows the underlying adapter.
121    #[must_use]
122    pub const fn store(&self) -> &S {
123        &self.store
124    }
125}
126
127impl<S: WorkspaceLineageStore> WorkspaceGraph<S> {
128    /// Registers a root workspace with no publication parent.
129    pub async fn register_root<A: AsyncAuthorityStore, O: AsyncObjectStore>(
130        &self,
131        workspace: &Workspace<A, O>,
132    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
133        if let Some(existing) = self
134            .store
135            .load(workspace.id())
136            .await
137            .map_err(WorkspaceLineageError::Store)?
138        {
139            if lineage_shape_is_valid(&existing)
140                && existing.ready
141                && existing.workspace_id == workspace.id()
142                && existing.workspace_name == workspace.name().as_str()
143                && existing.parent_workspace_id.is_none()
144                && existing.parent_workspace_name.is_none()
145            {
146                return Ok(existing);
147            }
148            return Err(WorkspaceLineageError::ConflictingRegistration);
149        }
150        let generation = workspace.head().await?;
151        self.register(WorkspaceLineageRecord {
152            version: LINEAGE_VERSION,
153            revision: 1,
154            ready: true,
155            workspace_id: workspace.id(),
156            workspace_name: workspace.name().as_str().to_owned(),
157            parent_workspace_id: None,
158            parent_workspace_name: None,
159            fork_generation: generation.id(),
160            initial_generation: generation.id(),
161        })
162        .await
163    }
164
165    /// Forks a real SDK workspace and durably records its direct parent.
166    pub async fn fork<A: AsyncAuthorityStore, O: AsyncObjectStore>(
167        &self,
168        parent: &Workspace<A, O>,
169        destination: impl AsRef<str>,
170        idempotency_key: IdempotencyKey,
171    ) -> Result<Workspace<A, O>, WorkspaceLineageError<S::Error>> {
172        let destination =
173            crate::WorkspaceName::new(destination.as_ref()).map_err(WorkspaceError::from)?;
174        let generation = parent.head().await?;
175        let child_id = parent
176            .fork_workspace_id(destination.as_str())
177            .map_err(WorkspaceError::from)?;
178        let planned = WorkspaceLineageRecord {
179            version: LINEAGE_VERSION,
180            revision: 1,
181            ready: false,
182            workspace_id: child_id,
183            workspace_name: destination.as_str().to_owned(),
184            parent_workspace_id: Some(parent.id()),
185            parent_workspace_name: Some(parent.name().as_str().to_owned()),
186            fork_generation: generation.id(),
187            initial_generation: generation.id(),
188        };
189        let planned = self.register(planned).await?;
190        let child = parent
191            .fork(
192                destination.as_str(),
193                ForkOptions::from_generation(generation.clone(), idempotency_key),
194            )
195            .await?;
196        let initial_generation = child.head().await?;
197        if child.id() != child_id {
198            return Err(WorkspaceLineageError::ConflictingRegistration);
199        }
200        if planned.ready && planned.initial_generation != initial_generation.id() {
201            return Err(WorkspaceLineageError::ConflictingRegistration);
202        }
203        let mut ready = planned;
204        ready.ready = true;
205        ready.initial_generation = initial_generation.id();
206        self.promote_ready(ready).await?;
207        Ok(child)
208    }
209
210    /// Registers a child created by another SDK-owned fork façade.
211    ///
212    /// Lazy workspaces use this after their source-overlay binding and physical
213    /// workspace fork have both become durable. Repeating the exact lineage is
214    /// idempotent; a competing parent or fork point is rejected.
215    pub async fn register_existing_child<A: AsyncAuthorityStore, O: AsyncObjectStore>(
216        &self,
217        parent: &Workspace<A, O>,
218        child: &Workspace<A, O>,
219        fork_generation: crate::GenerationId,
220    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
221        let initial_generation = child.head().await?.id();
222        self.register_existing_child_at_generation(
223            parent,
224            child,
225            fork_generation,
226            initial_generation,
227        )
228        .await
229    }
230
231    /// Registers an existing child after it has advanced beyond its initial
232    /// fork generation, while still authenticating that exact fork point.
233    pub async fn register_existing_child_at_generation<
234        A: AsyncAuthorityStore,
235        O: AsyncObjectStore,
236    >(
237        &self,
238        parent: &Workspace<A, O>,
239        child: &Workspace<A, O>,
240        fork_generation: crate::GenerationId,
241        initial_generation: crate::GenerationId,
242    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
243        // Do not let a metadata adapter manufacture publication authority.
244        // The child's authenticated initial generation must be the generation
245        // root produced by an SDK fork from the claimed parent generation.
246        parent.generation(fork_generation).await?;
247        let initial = child.generation(initial_generation).await?;
248        let expected_child = parent
249            .fork_workspace_id(child.name().as_str())
250            .map_err(WorkspaceError::from)?;
251        if child.id() != expected_child {
252            return Err(WorkspaceLineageError::UnauthorizedJoin);
253        }
254        if initial.parents().await?.as_slice() != [fork_generation] {
255            return Err(WorkspaceLineageError::UnauthorizedJoin);
256        }
257        self.register(WorkspaceLineageRecord {
258            version: LINEAGE_VERSION,
259            revision: 1,
260            ready: true,
261            workspace_id: child.id(),
262            workspace_name: child.name().as_str().to_owned(),
263            parent_workspace_id: Some(parent.id()),
264            parent_workspace_name: Some(parent.name().as_str().to_owned()),
265            fork_generation,
266            initial_generation,
267        })
268        .await
269    }
270
271    /// Verifies that `parent` is the child's exact direct publication target.
272    pub async fn authorize_join(
273        &self,
274        child: WorkspaceId,
275        parent: WorkspaceId,
276    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
277        let record = self.required(child).await?;
278        if record.parent_workspace_id != Some(parent) {
279            return Err(WorkspaceLineageError::UnauthorizedJoin);
280        }
281        Ok(record)
282    }
283
284    /// Resolves one durable workspace identity to its canonical lineage record.
285    ///
286    /// This is the restart-safe lookup used by thin adapters that retain only
287    /// workspace IDs in compatibility state.
288    pub async fn resolve(
289        &self,
290        workspace: WorkspaceId,
291    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
292        self.required(workspace).await
293    }
294
295    /// Returns the direct-parent chain, nearest parent first.
296    pub async fn ancestors(
297        &self,
298        workspace: WorkspaceId,
299        maximum: u32,
300    ) -> Result<Vec<WorkspaceLineageRecord>, WorkspaceLineageError<S::Error>> {
301        let mut next = Some(workspace);
302        let mut seen = BTreeSet::new();
303        let mut records = Vec::new();
304        while let Some(id) = next {
305            if !seen.insert(id) || records.len() >= maximum as usize {
306                return Err(WorkspaceLineageError::TraversalLimit);
307            }
308            let record = self.required(id).await?;
309            next = record.parent_workspace_id;
310            if id != workspace {
311                records.push(record);
312            }
313        }
314        Ok(records)
315    }
316
317    async fn required(
318        &self,
319        workspace_id: WorkspaceId,
320    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
321        let record = self
322            .store
323            .load(workspace_id)
324            .await
325            .map_err(WorkspaceLineageError::Store)?
326            .ok_or(WorkspaceLineageError::IncompatibleState)?;
327        if record.workspace_id != workspace_id || !lineage_shape_is_valid(&record) {
328            return Err(WorkspaceLineageError::IncompatibleState);
329        }
330        if !record.ready {
331            return Err(WorkspaceLineageError::IncompatibleState);
332        }
333        Ok(record)
334    }
335
336    async fn register(
337        &self,
338        record: WorkspaceLineageRecord,
339    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
340        if !lineage_shape_is_valid(&record) {
341            return Err(WorkspaceLineageError::IncompatibleState);
342        }
343        if let Some(existing) = self
344            .store
345            .load(record.workspace_id)
346            .await
347            .map_err(WorkspaceLineageError::Store)?
348        {
349            if lineage_shape_is_valid(&existing) && same_lineage(&existing, &record) {
350                if record.ready && !existing.ready {
351                    return self.promote_ready(record).await;
352                }
353                return Ok(existing);
354            }
355            return Err(WorkspaceLineageError::ConflictingRegistration);
356        }
357        if self
358            .store
359            .compare_and_swap(record.workspace_id, 0, record.clone())
360            .await
361            .map_err(WorkspaceLineageError::Store)?
362        {
363            return Ok(record);
364        }
365        let existing = self
366            .store
367            .load(record.workspace_id)
368            .await
369            .map_err(WorkspaceLineageError::Store)?
370            .ok_or(WorkspaceLineageError::IncompatibleState)?;
371        if lineage_shape_is_valid(&existing)
372            && same_lineage(&existing, &record)
373            && existing.ready == record.ready
374        {
375            Ok(existing)
376        } else if lineage_shape_is_valid(&existing)
377            && same_lineage(&existing, &record)
378            && record.ready
379            && !existing.ready
380        {
381            self.promote_ready(record).await
382        } else {
383            Err(WorkspaceLineageError::ConflictingRegistration)
384        }
385    }
386
387    async fn promote_ready(
388        &self,
389        ready: WorkspaceLineageRecord,
390    ) -> Result<WorkspaceLineageRecord, WorkspaceLineageError<S::Error>> {
391        if !ready.ready || !lineage_shape_is_valid(&ready) {
392            return Err(WorkspaceLineageError::IncompatibleState);
393        }
394        for _ in 0..MAXIMUM_LINEAGE_CAS_ATTEMPTS {
395            let existing = self
396                .store
397                .load(ready.workspace_id)
398                .await
399                .map_err(WorkspaceLineageError::Store)?
400                .ok_or(WorkspaceLineageError::IncompatibleState)?;
401            if !lineage_shape_is_valid(&existing) || !same_lineage(&existing, &ready) {
402                return Err(WorkspaceLineageError::ConflictingRegistration);
403            }
404            if existing.ready {
405                return Ok(existing);
406            }
407            let mut replacement = ready.clone();
408            replacement.revision = existing
409                .revision
410                .checked_add(1)
411                .ok_or(WorkspaceLineageError::IncompatibleState)?;
412            if self
413                .store
414                .compare_and_swap(ready.workspace_id, existing.revision, replacement.clone())
415                .await
416                .map_err(WorkspaceLineageError::Store)?
417            {
418                return Ok(replacement);
419            }
420        }
421        Err(WorkspaceLineageError::Contended)
422    }
423}
424
425fn same_lineage(left: &WorkspaceLineageRecord, right: &WorkspaceLineageRecord) -> bool {
426    left.version == right.version
427        && left.workspace_id == right.workspace_id
428        && left.workspace_name == right.workspace_name
429        && left.parent_workspace_id == right.parent_workspace_id
430        && left.parent_workspace_name == right.parent_workspace_name
431        && left.fork_generation == right.fork_generation
432        && (!left.ready || !right.ready || left.initial_generation == right.initial_generation)
433}
434
435/// Process-local lineage adapter for tests and embedded callers.
436#[derive(Default)]
437pub struct MemoryWorkspaceLineageStore {
438    records: Mutex<BTreeMap<WorkspaceId, WorkspaceLineageRecord>>,
439}
440
441impl MemoryWorkspaceLineageStore {
442    /// Creates an empty adapter.
443    #[must_use]
444    pub fn new() -> Self {
445        Self::default()
446    }
447}
448
449/// Process-local lineage synchronization failure.
450#[derive(Debug, Error)]
451#[error("workspace-lineage memory store is unavailable")]
452pub struct MemoryWorkspaceLineageStoreError;
453
454impl WorkspaceLineageStore for MemoryWorkspaceLineageStore {
455    type Error = MemoryWorkspaceLineageStoreError;
456
457    async fn load(
458        &self,
459        workspace_id: WorkspaceId,
460    ) -> Result<Option<WorkspaceLineageRecord>, Self::Error> {
461        self.records
462            .lock()
463            .map_err(|_| MemoryWorkspaceLineageStoreError)
464            .map(|records| records.get(&workspace_id).cloned())
465    }
466
467    async fn compare_and_swap(
468        &self,
469        workspace_id: WorkspaceId,
470        expected_revision: u64,
471        replacement: WorkspaceLineageRecord,
472    ) -> Result<bool, Self::Error> {
473        let mut records = self
474            .records
475            .lock()
476            .map_err(|_| MemoryWorkspaceLineageStoreError)?;
477        let revision = records
478            .get(&workspace_id)
479            .map_or(0, |record| record.revision);
480        if revision != expected_revision {
481            return Ok(false);
482        }
483        records.insert(workspace_id, replacement);
484        Ok(true)
485    }
486}
487
488#[cfg(test)]
489#[allow(clippy::expect_used)]
490mod tests {
491    use super::*;
492    use crate::{Digest, Fs, WorkspaceName};
493
494    fn workspace(name: &str) -> WorkspaceId {
495        WorkspaceId::derive([9; 16], &WorkspaceName::new(name).expect("valid name"))
496    }
497
498    fn generation(byte: u8) -> GenerationId {
499        GenerationId::new(Digest::from_bytes([byte; 32]))
500    }
501
502    #[tokio::test]
503    async fn malformed_lineage_shape_never_enters_the_graph() {
504        let graph = WorkspaceGraph::new(MemoryWorkspaceLineageStore::new());
505        let child = workspace("child");
506        let malformed = WorkspaceLineageRecord {
507            version: LINEAGE_VERSION,
508            revision: 1,
509            ready: true,
510            workspace_id: child,
511            workspace_name: "child".to_owned(),
512            parent_workspace_id: Some(child),
513            parent_workspace_name: None,
514            fork_generation: generation(1),
515            initial_generation: generation(2),
516        };
517
518        assert!(matches!(
519            graph.register(malformed).await,
520            Err(WorkspaceLineageError::IncompatibleState)
521        ));
522    }
523
524    #[tokio::test]
525    async fn direct_parent_authorization_and_recursive_walk_are_durable() {
526        let graph = WorkspaceGraph::new(MemoryWorkspaceLineageStore::new());
527        let root = workspace("root");
528        let child = workspace("child");
529        let grandchild = workspace("grandchild");
530        graph
531            .register(WorkspaceLineageRecord {
532                version: LINEAGE_VERSION,
533                revision: 1,
534                ready: true,
535                workspace_id: root,
536                workspace_name: "root".to_owned(),
537                parent_workspace_id: None,
538                parent_workspace_name: None,
539                fork_generation: generation(1),
540                initial_generation: generation(1),
541            })
542            .await
543            .expect("root registration");
544        for (workspace_id, parent_workspace_id, byte) in [(child, root, 2), (grandchild, child, 3)]
545        {
546            graph
547                .register(WorkspaceLineageRecord {
548                    version: LINEAGE_VERSION,
549                    revision: 1,
550                    ready: true,
551                    workspace_id,
552                    workspace_name: if workspace_id == child {
553                        "child".to_owned()
554                    } else {
555                        "grandchild".to_owned()
556                    },
557                    parent_workspace_id: Some(parent_workspace_id),
558                    parent_workspace_name: Some(if parent_workspace_id == root {
559                        "root".to_owned()
560                    } else {
561                        "child".to_owned()
562                    }),
563                    fork_generation: generation(byte),
564                    initial_generation: generation(byte),
565                })
566                .await
567                .expect("child registration");
568        }
569        graph
570            .authorize_join(grandchild, child)
571            .await
572            .expect("direct parent");
573        assert!(matches!(
574            graph.authorize_join(grandchild, root).await,
575            Err(WorkspaceLineageError::UnauthorizedJoin)
576        ));
577        let ancestors = graph
578            .ancestors(grandchild, 3)
579            .await
580            .expect("bounded ancestry");
581        assert_eq!(
582            ancestors
583                .iter()
584                .map(|record| record.workspace_id)
585                .collect::<Vec<_>>(),
586            vec![child, root]
587        );
588    }
589
590    #[tokio::test]
591    async fn a_durable_planned_child_is_completed_by_retry_after_a_crash() {
592        let fs = Fs::memory();
593        let parent = fs.create_workspace("root").await.expect("root");
594        let graph = WorkspaceGraph::new(MemoryWorkspaceLineageStore::new());
595        graph.register_root(&parent).await.expect("register root");
596        let parent_head = parent.head().await.expect("parent head");
597        let child_id = parent
598            .fork_workspace_id("child")
599            .expect("derived child identity");
600        let planned = WorkspaceLineageRecord {
601            version: LINEAGE_VERSION,
602            revision: 1,
603            ready: false,
604            workspace_id: child_id,
605            workspace_name: "child".to_owned(),
606            parent_workspace_id: Some(parent.id()),
607            parent_workspace_name: Some(parent.name().as_str().to_owned()),
608            fork_generation: parent_head.id(),
609            initial_generation: parent_head.id(),
610        };
611        graph.register(planned).await.expect("persist fork intent");
612        assert!(matches!(
613            graph.resolve(child_id).await,
614            Err(WorkspaceLineageError::IncompatibleState)
615        ));
616
617        let child = graph
618            .fork(&parent, "child", IdempotencyKey::from_bytes([31; 16]))
619            .await
620            .expect("retry completes child");
621        assert_eq!(child.id(), child_id);
622        let recovered = graph.resolve(child_id).await.expect("ready lineage");
623        assert!(recovered.ready);
624        assert_eq!(recovered.parent_workspace_id, Some(parent.id()));
625        assert_eq!(
626            recovered.initial_generation,
627            child.head().await.expect("head").id()
628        );
629    }
630}