Docs

workspace

Source: crates/lvcs-ws/src/workspace/mod.rs.

/// The document a span of a view comes from. Ordered (decision 89): the
/// base, then overrides by workspace id and then override id.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Layer {
    /// The `file` document at the path: the bottom of the stack.
    Base,
    /// An override document of this workspace or of one it stacks on,
    /// with the id of the workspace it belongs to, so a stacked workspace
    /// can address any layer exactly (`OVERRIDE_ADDRESS` with both ids).
    Override {
        /// The workspace the override belongs to.
        workspace: WorkspaceId,
        /// The override's id.
        id: OverrideId,
    },
}

/// One run of a view's text taken from one layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Span {
    /// Code points of the view text; never 0.
    pub len: usize,
    /// The layer the run is taken from.
    pub layer: Layer,
    /// Where the run starts in the layer's `/content`, in code points at
    /// that layer's heads in the view's frontier, so a workspace stacked on
    /// this one can place anchors made in any layer.
    pub from: usize,
}

/// Which layer each part of a view's body came from (design §8), so a save
/// through the mirror reaches the right layer and a file with no overrides
/// can be materialized as a clone of the base file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpanMap {
    /// A text body: spans covering it in order, their lengths summing to
    /// its code points (none for an empty text).
    Text(Vec<Span>),
    /// A binary body, taken whole from one layer (binary files have no
    /// ranges, design §3).
    Binary(Layer),
    /// A deleted body: the base file is deleted and this workspace holds
    /// no range override of it, or a whiteout in this workspace or one it
    /// stacks on hides it.
    Deleted,
}

/// A workspace's composed view of one file (design §8, decision 82).
///
/// It is the base view with each claimed range swapped for its override's
/// text, whiteouts deleted, and workspace-local files added. A claim whose
/// whole
/// range the base deleted (`AnchorSpan::Collapsed { at }`) keeps its
/// override's text at the gap where the range was, overrides collapsed at
/// one gap in claim-time start order and then id (decisions 92 and 99).
/// While the base file is deleted, its anchor spans are ignored and the
/// file stays present as this workspace's range overrides of it, their
/// texts in claim-time start order and then id (decision 92).
/// `DocRendered`-shaped:
/// the shell hashes `body` and hands it to this workspace's mirror as a
/// `DocRendered`, and to every workspace stacked on this one as its base
/// render. `heads` is the view's frontier: the sorted, deduplicated union of
/// the heads of every document composed (the base view's and each
/// override's), and `layers` maps each of those documents, as the layer it
/// is, to its own heads (decision 89): the base view's layers and each
/// override of this workspace the view composes, so a claim on any layer
/// is made at the heads of the document that holds its characters. `after`
/// keeps the mirror's save rule (decisions 75 and 78):
/// the workspace emits exactly one render whose `after` is the `stamp` of
/// each [`ViewSave`], once every layer edit that save produced is answered
/// (for a save a part of which could not be routed, with the next save of
/// its path that lands whole), and `None` for every other render.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewRendered {
    /// The file's path.
    pub path: WsPath,
    /// The view's frontier.
    #[serde(with = "cbor::heads")]
    pub heads: Vec<ChangeHash>,
    /// The composed body.
    pub body: FileBody,
    /// The view's executable bit.
    pub executable: bool,
    /// The `stamp` of the [`ViewSave`] this render answers; `None` for
    /// every other render.
    pub after: Option<u64>,
    /// Which layer each part of `body` came from.
    pub spans: SpanMap,
    /// Each document composed, as the layer it is, with its own heads.
    #[serde(with = "cbor::head_map")]
    pub layers: BTreeMap<Layer, Vec<ChangeHash>>,
}

/// An `edit` of `path` through this workspace's view (design §8).
///
/// `edit.base` is a view frontier (a [`ViewRendered`]'s `heads`, so it
/// mixes the heads of several documents), and the splices are code points
/// of the view text there. Copy-on-write (design §8): a splice spanning
/// several layers is split at their boundaries, its inserted text going to
/// the layer where it starts (decision 100); the part of the edit inside an
/// existing claim goes to that override; the parts outside every claim that
/// fall in one maximal unclaimed run of a layer, a run that a collapsed
/// override's gap bounds, so no claim holds one strictly inside it
/// (decision 100), are claimed first, together, as one range (`Claim`, a
/// new override) and go there; nothing reaches a base document (decision
/// 99). An insertion at a gap where overrides of this workspace with empty
/// text sit (emptied, or collapsed, decision 92) goes to the first of them
/// in claim-time start order, then id; else one at a gap that touches this
/// workspace's claims goes to the override whose claim ends just before the
/// gap, else to the one that starts just after it (an override whose text
/// is empty keeps its claim); only a gap touching none of them is claimed
/// (decisions 95 and 99). An edit or save that creates the file where the
/// base has none, or a deleted one (at genesis or after history, whited out
/// or not), makes it a workspace-local file, which merge-back recreates
/// (decision 98). One that creates the file in a view showing none of this
/// workspace's claims of it (whited out, or a binary base) claims the whole
/// document. Either way the claims of the path that the view hides leave
/// the index with it (range claims, and whole-document ones under a
/// concurrent whiteout, M3), since claims never overlap (decision 84). The
/// edit applies whole or not at all (decision 90): its parts outside every
/// claim are claimed and their new overrides initialized first, its claims
/// to one document one at a time (each after the previous `Claimed` and the
/// watch that includes it), and its parts are routed together only once
/// every claim has succeeded; a refused claim answers the edit `Rejected`
/// with nothing routed (an override already created keeps its claim-time
/// text). The edits and saves of one path run one at a time, each starting
/// once the one before is answered, and an edit whose `base` is older than
/// the path's newest view is rebased onto that view (the changes in between
/// are kept, as for concurrent edits) and split through its span map. An
/// edit that arrives during a merge-back (after its `ws.merge` request)
/// waits for the merge's answer, one that arrived before the request runs
/// before the merge, and one whose `base` is a view from before the last
/// merge-back of its path is rejected `UnknownBase` (decision 94).
/// `participant` is the connection's participant: the claimant key of any
/// override the edit claims. `edit.req` is unique in the workspace
/// (decision 91): the shell assigns it, increasing per workspace, and maps
/// the answer back to the connection's own request id, since a workspace
/// answers out of order across claim round trips; the actor rejects
/// (`Failed`) a `ViewEdit` that starts while an edit with its `req` is in
/// flight (started and not yet answered). Answered with exactly one
/// [`WorkspaceOutput::Answer`] for `edit.req`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewEdit {
    /// The file edited.
    pub path: WsPath,
    /// The connection's participant: the claimant of any override the edit
    /// claims.
    pub participant: ParticipantKey,
    /// The edit, its splices in the view's code points at `edit.base`.
    pub edit: Edit,
}

/// A save through this workspace's mirror: the mirror's `DiskEdit` for
/// `path`, which the shell routes here instead of to a document (decision
/// 82).
///
/// It waits for its path like a [`ViewEdit`], is diffed against the view it
/// was made against (of the views named by the path's latest landed save,
/// its `candidates`, and the newest view, the one whose text differs least
/// from its own, ties to the earliest: the mirror's rule, decision 78; when
/// that is a view from before the last merge-back of its path, the first
/// view after the merge instead, reported as stale with
/// [`WorkspaceOutput::StaleSave`], decision 94), rebased onto the newest
/// view, split through the span map, and
/// routed like a [`ViewEdit`]: to the overrides it touches, claiming new
/// ones outside every claim, never to a base document. Each part reaches
/// its override as a text save whose `loaded` heads are the override's
/// heads in the view the part was made from, so the override diffs it
/// against exactly that text, as a view edit's part is applied at its
/// base, and never against its author's latest change there. A removal of
/// a file the view shows whites the path out, recording the heads of the
/// base render that the view its `base` names was composed from (decision 86;
/// none when it names no view the workspace emitted), so merge-back never
/// deletes a base edit that never reached disk; this workspace's range
/// claims of the path leave the index with it, so a whiteout and range
/// claims never coexist (decision 97). Concurrent whiteouts of one path
/// (M3: participants on two peers removing it) write one index key, and
/// the entry the index resolves it to is the path's whiteout, whose heads
/// govern merge-back and whose participant its conflict names; the
/// removal is done once the index shows the path whited out by anyone.
/// Such a whiteout may hide a whole-document claim of the path (a
/// removal of a view showing one goes to that override instead), which
/// leaves the index when the path is created again, as for a
/// [`ViewEdit`].
/// A derived save (row 180: `edit.derived` is `Mark`) is never split: one
/// that changes the file goes to the view's whole-document or
/// workspace-local override when it has one, and otherwise claims the
/// whole file, as a non-text body's save does, retiring the path's range
/// claims; either way the override takes its whole text with the save's
/// flag, and an override document treats a `Keep` save as derived when its
/// own `/meta/derived` is true. `participant` is
/// the daemon's default participant, the mirror's author (decision 64).
/// Like a view edit it applies whole or not at all. Answered with exactly
/// one [`ViewRendered`] whose `after` is `edit.stamp`. A save that can't
/// be routed (a claim refused or lost) is answered with the render of the
/// next save of its path that lands whole, which holds its text too, since
/// a save is the whole file: so the mirror never writes a view without a
/// save over it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewSave {
    /// The file saved.
    pub path: WsPath,
    /// The daemon's default participant, the mirror's author.
    pub participant: ParticipantKey,
    /// The mirror's `DiskEdit`.
    pub edit: DiskEdit,
}

/// How a document, or this workspace's view, answered an edit.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EditOutcome {
    /// Applied; the document's heads (for a view, the view frontier) after
    /// it.
    Applied {
        /// The heads after the edit.
        #[serde(with = "cbor::heads")]
        heads: Vec<ChangeHash>,
    },
    /// Refused, for this reason.
    Rejected(RejectReason),
}

/// A document answered an `Edit` or `BundleEdit` this workspace routed to
/// it.
///
/// It is the document's `DocOutput::Changed` for that `req` (`Applied`) or
/// `DocOutput::Rejected`, which the shell routes back here by `address` and
/// `req`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayerAnswer {
    /// The document that answered.
    pub address: String,
    /// The routed edit's request.
    pub req: RequestId,
    /// How the document answered.
    pub outcome: EditOutcome,
}

/// A stacked workspace's merge-back, landed on the workspace it stacks on
/// (design §8, decision 101's M3 deferral; frozen at the M3 freeze for
/// `M3.workspace`).
///
/// The base workspace absorbs it copy-on-write, as view edits of its own
/// overrides (claiming where its view's text comes from a lower layer, as
/// for a [`ViewEdit`]), so the work reaches `main` only through the base
/// workspace's own merge-back, and nothing is written to any base document
/// or the registry. `from` is the merging workspace and `req` its merge's
/// request, which [`Absorbed`] echoes; `origin` and `merger` are the
/// merging participant's (the claimant of any override the absorb claims).
/// `paths` holds each path the merge changes: `base` is the base
/// workspace's view frontier of the path the merging workspace composed
/// against (its base render's `heads`), and `changes` are written over the
/// base workspace's view there, splices in view code points, as a
/// document's [`BundleEdit`]s are over it.
///
/// The base workspace absorbs each path's changes in order, as ops of the
/// path that run after the view edits and saves that arrived before the
/// bundle, by `merger`: `Text` and `Recreate` as a view edit at `base`
/// (rebased onto the path's newest view, like any view edit; a `Recreate`
/// inserts its text into the view there, which shows the file deleted),
/// and `Binary`, `Executable`, and `Removed` as the matching save (a
/// removal whites the path out, recording the base render the view at
/// `base` was composed from). The path's first change is refused
/// `UnknownBase` when `base` is no view the base workspace emitted since
/// the last merge-back of the path, and `Failed` when it replaces the
/// whole file (`Recreate`, `Binary`, `Removed`) and the path's newest view
/// is no longer `base`, so it never drops an edit the merging workspace
/// didn't see (a recreate onto a view that shows the file again is a
/// conflict that keeps the base, decision 98), and `Failed` when the path
/// sets a binary body or an executable bit that the base workspace's view
/// would never take: one that swaps in range claims of the base workspace,
/// or would once the path's text edit claims ranges in it, shows its
/// base's body and bit (claims never combine, decision 84). A later change
/// that can't land then (after a restart) is refused `Failed` too. A
/// refused change is the path's outcome, and its later changes
/// never run; otherwise the path is `Applied` at the view frontier after
/// its last change, answered (except at a restart) only once the base
/// workspace has rendered that view, so the merging workspace's base
/// render already shows it.
///
/// `paths` is never empty: a stacked merge whose plan changes nothing
/// finishes without a bundle, so no `Absorbed` with no outcome answers a
/// merge, and the merging workspace counts a path an `Absorbed` doesn't
/// name as `Rejected(Failed)`. (The base workspace answers a bundle with
/// no path, which no workspace sends, at once with no outcome, and a path
/// with no change `Rejected(Failed)`.)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StackedBundle {
    /// The merging workspace.
    pub from: WorkspaceId,
    /// Its merge's request, which [`Absorbed`] echoes.
    pub req: RequestId,
    /// The merging participant's origin.
    pub origin: Origin,
    /// The merging participant: the claimant of any override the absorb
    /// claims.
    pub merger: ParticipantKey,
    /// Each path the merge changes, with its changes.
    pub paths: BTreeMap<WsPath, StackedChange>,
}

/// One path's changes in a [`StackedBundle`]: in the order they apply,
/// as `merge::plan` gives one document's (a body change, then an
/// executable bit), never empty.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StackedChange {
    /// The base workspace's view frontier of the path that the merging
    /// workspace composed against.
    #[serde(with = "cbor::heads")]
    pub base: Vec<ChangeHash>,
    /// The changes, in order, written over the view there.
    pub changes: Vec<BundleChange>,
}

/// The base workspace's answer to a [`StackedBundle`] from `from` for its
/// merge `req`.
///
/// It holds each path's outcome: `Applied` with the base workspace's view
/// frontier after it, or `Rejected` (`UnknownBase` for a `base` the base
/// workspace emitted no view at since the last merge-back of the path,
/// `Failed` when it can't absorb now, and otherwise the refusal of the
/// change's claim or routed edit, as for a view edit). Each change applies
/// whole or not at all, like a view edit, and a path's changes after a
/// refused one never run (a restart can end a path between its body change
/// and its executable bit); the stacked workspace closes its changeset only
/// when every path applied, and otherwise answers its merge with the
/// refused paths as `rejected` (decision 88).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Absorbed {
    /// The merging workspace.
    pub from: WorkspaceId,
    /// Its merge's request.
    pub req: RequestId,
    /// Each path's outcome.
    pub outcomes: BTreeMap<WsPath, EditOutcome>,
}

/// Outputs of the workspace actor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceOutput {
    /// The composed view of one file, for this workspace's mirror and every
    /// workspace stacked on this one.
    Rendered(ViewRendered),
    /// Route `edit` to the override document at `address`, one of this
    /// workspace's (copy-on-write, design §8: an edit inside an existing
    /// claim goes to that override, and one outside every claim to the new
    /// override its claim created; nothing a workspace writes reaches a base
    /// document before merge-back). `edit.req` is chosen by the workspace
    /// actor, increasing per workspace; the shell keeps it apart from
    /// connection requests and answers with [`WorkspaceViewEvents::LayerAnswer`].
    Edit {
        /// The override document.
        address: String,
        /// The edit routed.
        edit: Edit,
    },
    /// Route part of a [`ViewSave`] to the override document at `address`,
    /// one of this workspace's, carrying the save's `stamp`: a text save of
    /// the override's whole new text, or a binary, executable-bit, or
    /// removal change of a whole-document or workspace-local override. The
    /// override answers with exactly one `OverrideRendered` whose `after` is
    /// that stamp. Never routed to a base document: nothing a workspace
    /// writes reaches one before merge-back.
    Save {
        /// The override document.
        address: String,
        /// The save's change, with its stamp.
        edit: DiskEdit,
    },
    /// Ask the document at `address`, which holds the claimed characters in
    /// the base view, to anchor a claim at that document's heads in the
    /// view (its layer's entry in [`ViewRendered::layers`], decision 89); it
    /// answers with `Claimed`.
    Claim {
        /// The document holding the claimed characters.
        address: String,
        /// The claim.
        claim: Claim,
    },
    /// Create the override document at `address` (`OVERRIDE_ADDRESS` with
    /// this workspace's id and the new override's id) and deliver `init` as
    /// its first event.
    CreateOverride {
        /// The new override document's address.
        address: String,
        /// Its first event.
        init: OverrideInit,
    },
    /// Set this workspace's watched anchors in the document at `address`.
    Watch {
        /// The watched document.
        address: String,
        /// This workspace's anchors in it.
        watch: WatchAnchors,
    },
    /// Change this workspace's index document (`WORKSPACE_INDEX_ADDRESS`).
    Index(IndexChange),
    /// Answer a [`ViewEdit`] with its `edit.req`.
    Answer {
        /// The answered edit's `edit.req`.
        req: RequestId,
        /// How the view answered it.
        outcome: EditOutcome,
    },
    /// A text [`ViewSave`] made against a view from before the last
    /// merge-back of its path is diffed against the first view after the
    /// merge instead (decision 94), mirroring `DocOutput::StaleSave`, for
    /// the daemon's `stale_save` push: emitted when the save starts, before
    /// anything it routes, with `chosen_base` the frontier of the view the
    /// save is diffed against and `newest_base` that of the newest view of
    /// `path` then.
    StaleSave {
        /// The saved file.
        path: WsPath,
        /// The frontier of the view the save is diffed against.
        chosen_base: Vec<ChangeHash>,
        /// The frontier of the path's newest view.
        newest_base: Vec<ChangeHash>,
    },
    /// Read the document at `address` at past heads, for a merge-back's
    /// inputs; `read.req` is chosen like a routed edit's, and the answer
    /// comes back as [`WorkspaceViewEvents::ReadAtDone`].
    ReadAt {
        /// The document read.
        address: String,
        /// What to read.
        read: ReadAt,
    },
    /// Route one merge-back change of the plan to the base document at
    /// `address`; answered with [`WorkspaceViewEvents::LayerAnswer`].
    Bundle {
        /// The base document.
        address: String,
        /// The change.
        edit: BundleEdit,
    },
    /// Write the bundle's merge record to `.lvcs/registry`
    /// (`REGISTRY_ADDRESS`), once every change of the bundle is answered
    /// and applied. A bundle a change of which was rejected gets no record,
    /// so none of it becomes visible (decision 48), and opens no thread.
    Record(RecordBundle),
    /// Create the thread document at `address` (`THREAD_ADDRESS`) for a
    /// merge-back conflict and deliver `open` as its first event.
    OpenThread {
        /// The new thread document's address.
        address: String,
        /// Its first event.
        open: ThreadOpen,
    },
    /// A stacked merge-back: the shell delivers `bundle` to the workspace
    /// `into` (this workspace's base) as [`WorkspaceViewEvents::StackedBundle`], or,
    /// when that workspace isn't running, answers this one with an
    /// [`Absorbed`] refusing every path `Failed`. `bundle.paths` is never
    /// empty.
    MergeInto {
        /// The workspace this one stacks on.
        into: WorkspaceId,
        /// The bundle it absorbs.
        bundle: StackedBundle,
    },
    /// The answer to a [`WorkspaceViewEvents::StackedBundle`], which the shell delivers
    /// to `from` as [`WorkspaceViewEvents::Absorbed`].
    Absorbed(Absorbed),
    /// Answer a [`MergeRequest`]. A merge that writes its record closes the
    /// changeset first (decision 94): once every document it changed has
    /// rendered the change, every override and whiteout it read leaves the
    /// index (`IndexEdit::RemoveOverride`, `RemoveLocal` for a
    /// workspace-local file, `ClearWhiteout`), and the merge is answered
    /// when the index render shows that, after the views of its paths,
    /// which fall through to the merged base. (A clear is shown once the
    /// path no longer holds the whiteout the merge read: another
    /// participant's concurrent whiteout of the path, which the clear never
    /// saw, stays for the next merge-back.) A conflict's thread names the
    /// override documents, which keep their text. A merge a read of which
    /// was refused, or a bundle change of which was rejected, writes no
    /// record, opens no thread, and retires nothing: it is answered with
    /// [`MergeResult::rejected`] naming those documents (decision 88). A
    /// stacked merge closes its changeset the same way once every path it
    /// sent applied and its base renders show them, and is answered with
    /// a record it never writes, its documents the paths it changed, each
    /// with the workspace below's view frontiers before and after; a path
    /// refused or missing from the `Absorbed` answer is `rejected`.
    Merged(MergeResult),
}