Skip to main content

Store

Trait Store 

Source
pub trait Store:
    Debug
    + Send
    + Sync {
Show 20 methods // Required methods fn put_host(&self, host: &Host) -> Result<(), StoreError>; fn host(&self, id: HostId) -> Result<Option<Host>, StoreError>; fn hosts(&self) -> Result<Vec<Host>, StoreError>; fn set_runner_root_override( &self, id: HostId, expected: Option<&LocalAbsolutePath>, new_root: Option<&LocalAbsolutePath>, expected_uncleaned: u16, ) -> Result<(), StoreError>; fn insert_policy(&self, policy: &ScalePolicy) -> Result<(), StoreError>; fn update_policy( &self, policy: &ScalePolicy, expected_revision: u64, ) -> Result<(), StoreError>; fn update_policy_confirming_active_count( &self, policy: &ScalePolicy, expected_revision: u64, expected_active: u16, ) -> Result<(), StoreError>; fn update_policy_confirming_uncleaned_count( &self, policy: &ScalePolicy, expected_revision: u64, expected_uncleaned: u16, ) -> Result<(), StoreError>; fn remove_policy( &self, id: PolicyId, expected_revision: u64, ) -> Result<(), StoreError>; fn policy(&self, id: PolicyId) -> Result<Option<ScalePolicy>, StoreError>; fn policies(&self) -> Result<Vec<ScalePolicy>, StoreError>; fn record_attempt(&self, attempt: &RunnerAttempt) -> Result<(), StoreError>; fn attempt( &self, id: AttemptId, ) -> Result<Option<RunnerAttempt>, StoreError>; fn attempts(&self) -> Result<Vec<RunnerAttempt>, StoreError>; fn attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>; fn active_attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>; fn uncleaned_attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>; fn slot_leases_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>; fn uncleaned_ephemeral_attempts( &self, ) -> Result<Vec<RunnerAttempt>, StoreError>; fn remove_attempt(&self, id: AttemptId) -> Result<bool, StoreError>;
}
Expand description

Durable storage for the three things that must survive a restart.

A trait rather than a concrete type so that b1’s logic and the testkit fixtures stay usable with no database — a capacity calculation or a recovery decision needs neither a file nor this trait — and so that a caller can be written against storage without being written against SQLite.

Send + Sync because the agent holds one of these across tasks while the TUI reads through the same handle. SqliteStore earns it with an internal mutex; a test double should do the same rather than being !Sync and forcing every caller to change shape.

Required Methods§

Source

fn put_host(&self, host: &Host) -> Result<(), StoreError>

Insert or replace this host.

§Errors

StoreError::Sqlite on an I/O failure.

Source

fn host(&self, id: HostId) -> Result<Option<Host>, StoreError>

One host, re-validated.

§Errors

StoreError::CorruptHost or StoreError::CorruptColumn for a row the domain refuses.

Source

fn hosts(&self) -> Result<Vec<Host>, StoreError>

Every host, re-validated.

§Errors

As Store::host.

Source

fn set_runner_root_override( &self, id: HostId, expected: Option<&LocalAbsolutePath>, new_root: Option<&LocalAbsolutePath>, expected_uncleaned: u16, ) -> Result<(), StoreError>

Move the configured runner root, and only that column, while both the override the caller read and the uncleaned ephemeral count it observed still hold.

expected is the override the caller read, and new_root the one it wants stored; None on either side means “the platform default”. A mutation from and to the same value is a no-op that still runs both predicates, which is what makes host reset-runtime-root safe to run twice.

Why this is not Store::put_host with a changed field. 02-target-architecture.md: “The store exposes a targeted host-root mutation rather than writing a stale whole Host value. In one SQLite transaction it compares the previously read override, confirms the count of uncleaned ephemeral attempts, and updates only runner_root_override. This prevents a simultaneous capacity or service-mode change from being overwritten.” A whole-record upsert built from a Host read seconds ago would silently roll back a host set-capacity that landed in between, and no revision column exists on hosts to catch it.

What is counted. Every attempt in the journal whose workspace is ephemeral and whose state is not cleaned — see Store::uncleaned_ephemeral_attempts, which is the read a caller uses to obtain the number it passes here, so the two cannot disagree about the set. Implementations must evaluate both predicates in the same write transaction as the update; a separate read followed by a write does not satisfy this contract.

§Errors

StoreError::RunnerRootChanged when the stored override moved, StoreError::UncleanedCountChanged when the count moved, StoreError::NotFound when the host row is gone. In every case nothing is written.

Source

fn insert_policy(&self, policy: &ScalePolicy) -> Result<(), StoreError>

Add a policy that is not there yet.

§Errors

StoreError::AlreadyExists when the id is taken. Use Store::update_policy to change an existing policy: this call carries no revision check because there is no previous revision to check against.

Source

fn update_policy( &self, policy: &ScalePolicy, expected_revision: u64, ) -> Result<(), StoreError>

Write a changed policy, but only if nobody else changed it first.

expected_revision is the revision the caller read, not the one the policy now carries: every successful domain mutation advances ScalePolicy::revision, so a caller that loaded revision 3 and called set_max_capacity holds revision 4 and passes 3 here. The write matches on 3 and stores 4.

§Errors

StoreError::StaleRevision when the stored revision is not expected_revision — nothing is written, and the caller must re-read rather than retry — or StoreError::NotFound when the row is gone.

Source

fn update_policy_confirming_active_count( &self, policy: &ScalePolicy, expected_revision: u64, expected_active: u16, ) -> Result<(), StoreError>

Atomically update a policy only while its revision and active-attempt count are exactly the values the caller observed. Implementations must evaluate both predicates in the same write transaction as the update; composing Store::attempts_for_policy and Store::update_policy does not satisfy this contract.

Source

fn update_policy_confirming_uncleaned_count( &self, policy: &ScalePolicy, expected_revision: u64, expected_uncleaned: u16, ) -> Result<(), StoreError>

Atomically update a policy only while its revision and uncleaned attempt count are exactly the values the caller observed.

The fence repo set-workspace needs, and the reason it is not Store::update_policy_confirming_active_count: an attempt that has concluded but not yet been cleaned is not active, and it is exactly the attempt a workspace-path change must be refused behind. It still owns the directory under the old root, and if it is persistent it still holds its slot lease. 04-security-recovery.md: “A repository path setting cannot change while any attempt for that policy is active or unresolved.”

03-migration-rollout.md states the transaction boundary this implements: “The policy store operation compares its revision and confirms the uncleaned policy-attempt count. Both checks happen inside the same SQLite write transaction as the mutation. The existing whole-record put_host and active-count-only policy guard are not sufficient for these commands.” Composing Store::uncleaned_attempts_for_policy and Store::update_policy therefore does not satisfy this contract, even though that read is where the caller gets the number it passes here.

§Errors

StoreError::StaleRevision, StoreError::UncleanedCountChanged, or StoreError::NotFound. In every case nothing is written.

Source

fn remove_policy( &self, id: PolicyId, expected_revision: u64, ) -> Result<(), StoreError>

Delete a policy, subject to the same revision check as a write.

Deleting is a mutation like any other and races the same way: an operator removing a repository while the TUI enables it must not silently win.

Attempts belonging to the policy are deliberately left in place; see the note on attempts.policy_id in the schema.

§Errors

As Store::update_policy.

Source

fn policy(&self, id: PolicyId) -> Result<Option<ScalePolicy>, StoreError>

One policy, re-validated.

§Errors

StoreError::CorruptPolicy for a row violating D19’s shape or min <= max; StoreError::CorruptColumn for an unreadable column.

Source

fn policies(&self) -> Result<Vec<ScalePolicy>, StoreError>

Every policy, re-validated.

§Errors

As Store::policy. One corrupt row fails the whole call rather than being skipped: a silently short policy list is a host that quietly stops serving a repository, which is the failure nobody notices.

Source

fn record_attempt(&self, attempt: &RunnerAttempt) -> Result<(), StoreError>

Journal an attempt, inserting it or updating it in place.

The journal has one writer — the agent holds the single-instance lock (05-infrastructure.md) — so there is no revision token here. created_at is written once at insert and is never overwritten by a later call, which is the storage half of the domain’s “created_at never moves”.

§Errors

StoreError::UnrepresentablePath for a non-UTF-8 runtime path, otherwise StoreError::Sqlite.

Source

fn attempt(&self, id: AttemptId) -> Result<Option<RunnerAttempt>, StoreError>

One attempt, re-validated.

§Errors

StoreError::CorruptAttempt for a state/outcome/timestamp combination this crate’s transitions cannot produce.

Source

fn attempts(&self) -> Result<Vec<RunnerAttempt>, StoreError>

Every attempt, oldest first. This is the input to e3’s startup recovery.

§Errors

As Store::attempt.

Source

fn attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>

Every attempt of one policy, oldest first.

§Errors

As Store::attempt.

Source

fn active_attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>

The attempts of one policy that still occupy a host capacity slot.

“Active” is AttemptState::counts_against_capacity — every non-terminal state — and this is the narrower of the two questions a workspace mutation asks. It is here beside its counterpart so that the distinction is visible at the trait rather than reconstructed by each caller from Store::attempts_for_policy and a filter each writes slightly differently.

§Errors

As Store::attempt.

Source

fn uncleaned_attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>

The attempts of one policy that have not been cleaned, oldest first.

A superset of Store::active_attempts_for_policy: it also holds the terminal attempts whose cleanup has not completed. Those are invisible to capacity and decisive for a path change, which is the distinction 04-security-recovery.md draws between “active” and “unresolved”.

This is the read that produces expected_uncleaned for Store::update_policy_confirming_uncleaned_count, and c2’s allocator input: “Load uncleaned attempts for the policy” (02-target-architecture.md, “Slot allocation”).

§Errors

As Store::attempt.

Source

fn slot_leases_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>

The durable slot leases one policy holds, oldest first.

Every uncleaned persistent attempt, which is the same set the partial unique index one_uncleaned_persistent_attempt_per_slot enforces uniqueness over. It deliberately includes a terminal attempt whose cleanup failed: “Every persistent attempt whose state is not cleaned is a durable slot lease, including a terminal attempt whose cleanup failed” (02-target-architecture.md). Every returned attempt therefore answers true to RunnerAttempt::holds_slot_lease and carries a slot.

The filesystem is never consulted to answer this. Invariant 6: “Uncleaned attempt rows, the database lease constraint, and the allocation lock remain authoritative; the filesystem is never scanned to infer ownership or capacity.”

§Errors

As Store::attempt.

Source

fn uncleaned_ephemeral_attempts(&self) -> Result<Vec<RunnerAttempt>, StoreError>

Every uncleaned ephemeral attempt on this host, oldest first.

The read that produces expected_uncleaned for Store::set_runner_root_override, and the reason it is host-wide rather than per-policy: these attempts are the ones whose directories sit under the host runner root, and an attempt that outlived its policy row still owns one (see the note on attempts.policy_id in the schema). A count scoped to the policies of one host would drop exactly those, and 04-security-recovery.md requires unknown-policy attempts to keep their fail-closed ownership behaviour rather than to become invisible.

§Errors

As Store::attempt.

Source

fn remove_attempt(&self, id: AttemptId) -> Result<bool, StoreError>

Forget one attempt. Returns whether a row was removed.

§Errors

StoreError::Sqlite on an I/O failure.

Dyn Compatibility§

This trait is dyn compatible.

In older versions of Rust, dyn compatibility was called "object safety".

Implementors§