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§
Sourcefn host(&self, id: HostId) -> Result<Option<Host>, StoreError>
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.
Sourcefn set_runner_root_override(
&self,
id: HostId,
expected: Option<&LocalAbsolutePath>,
new_root: Option<&LocalAbsolutePath>,
expected_uncleaned: u16,
) -> Result<(), StoreError>
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.
Sourcefn insert_policy(&self, policy: &ScalePolicy) -> Result<(), StoreError>
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.
Sourcefn update_policy(
&self,
policy: &ScalePolicy,
expected_revision: u64,
) -> Result<(), StoreError>
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.
Sourcefn update_policy_confirming_active_count(
&self,
policy: &ScalePolicy,
expected_revision: u64,
expected_active: u16,
) -> Result<(), StoreError>
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.
Sourcefn update_policy_confirming_uncleaned_count(
&self,
policy: &ScalePolicy,
expected_revision: u64,
expected_uncleaned: u16,
) -> Result<(), StoreError>
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.
Sourcefn remove_policy(
&self,
id: PolicyId,
expected_revision: u64,
) -> Result<(), StoreError>
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
Sourcefn policy(&self, id: PolicyId) -> Result<Option<ScalePolicy>, StoreError>
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.
Sourcefn policies(&self) -> Result<Vec<ScalePolicy>, StoreError>
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.
Sourcefn record_attempt(&self, attempt: &RunnerAttempt) -> Result<(), StoreError>
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.
Sourcefn attempt(&self, id: AttemptId) -> Result<Option<RunnerAttempt>, StoreError>
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.
Sourcefn attempts(&self) -> Result<Vec<RunnerAttempt>, StoreError>
fn attempts(&self) -> Result<Vec<RunnerAttempt>, StoreError>
Sourcefn attempts_for_policy(
&self,
policy_id: PolicyId,
) -> Result<Vec<RunnerAttempt>, StoreError>
fn attempts_for_policy( &self, policy_id: PolicyId, ) -> Result<Vec<RunnerAttempt>, StoreError>
Sourcefn active_attempts_for_policy(
&self,
policy_id: PolicyId,
) -> Result<Vec<RunnerAttempt>, StoreError>
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.
Sourcefn uncleaned_attempts_for_policy(
&self,
policy_id: PolicyId,
) -> Result<Vec<RunnerAttempt>, StoreError>
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.
Sourcefn slot_leases_for_policy(
&self,
policy_id: PolicyId,
) -> Result<Vec<RunnerAttempt>, StoreError>
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.
Sourcefn uncleaned_ephemeral_attempts(&self) -> Result<Vec<RunnerAttempt>, StoreError>
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.
Sourcefn remove_attempt(&self, id: AttemptId) -> Result<bool, StoreError>
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".