Skip to main content

mlua_swarm/store/operator_session/
mod.rs

1//! `OperatorSessionStore` — persistence for Operator login-flow sessions.
2//!
3//! One row per minted `POST /v1/operators` session: the sid / bearer token /
4//! capability manifest / mint time / 記名. This is the record that
5//! lets a single-server restart keep every logged-in Operator logged in —
6//! the sibling stores (task / run / replay / trace / …) already persist,
7//! and `RunRecord.operator_sid` persists a *pointer* into this session
8//! space, so leaving the sessions themselves process-volatile stranded
9//! every restored run pin on a `404 unknown sid` after restart.
10//!
11//! Deliberately **not** persisted: the WS adapter state (`tx` sender,
12//! `pending` oneshot map). Both are process-lifetime objects with no
13//! meaningful serialized form — an empty rebuild on the client's next WS
14//! connect (the existing reconnect path) is the correct restoration.
15//!
16//! Current scope:
17//!
18//! - [`InMemoryOperatorSessionStore`] — process-volatile default.
19//! - [`SqliteOperatorSessionStore`] — file-backed persistence via
20//!   `rusqlite-isle` (same shape as [`crate::store::task::SqliteTaskStore`]).
21
22use crate::types::SessionId;
23use crate::AgentProviderManifest;
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::sync::Mutex;
28use thiserror::Error;
29
30/// How many [`ObservedAssignment`] entries one session retains.
31///
32/// **D2** says the observed part is appended to on every `Assign` and has
33/// no delete path; it does not say the row grows forever. A session that
34/// re-acquires in a loop would otherwise put an unbounded column behind
35/// every `GET /v1/operators`, so the log is a ring: over the cap, the
36/// **oldest** entry goes. Nothing a reader has is deleted by an API — the
37/// oldest fact simply ages out, and
38/// [`OperatorSessionRecord::observed_total`] stays monotone so the reader
39/// can see that it did.
40///
41/// The value is set for the thing the log is read for: telling apart the
42/// handful of Runs a driver is currently juggling in one repo. 32 is well
43/// past that.
44///
45/// # The size that count multiplies
46///
47/// A depth is only half of a size, and this ring is rewritten whole to
48/// `observed_json` on every `Assign` and returned for up to
49/// `OPERATORS_LIST_MAX_LIMIT` sessions per `GET /v1/operators` read. So
50/// every field of an entry has a ceiling — [`TASK_METADATA_MAX_BYTES`] for
51/// the JSON bag, [`OBSERVED_TEXT_MAX_BYTES`] for each of the three
52/// caller-supplied strings — and the product is what a reader is actually
53/// promised: at most `32 × (4096 + 3 × 1024)` ≈ 224 KiB of variable
54/// content per session, whatever the launch put in them.
55///
56/// That number is the claim, and it is stated rather than asserted because
57/// the alternative was the shape this used to have: a depth with a
58/// reassuring adjective ("still small enough to serialize whole") in front
59/// of four fields, one of which — `goal` — had no bound at all, so the
60/// sentence was true only of launches that happened to be small.
61pub const OBSERVED_CAP: usize = 32;
62
63/// Serialized-size ceiling for a recorded [`ObservedAssignment::task_metadata`].
64///
65/// `task_metadata` is an arbitrary caller-supplied JSON bag, so it is the
66/// one observed field with no natural size. Above this it is dropped and
67/// [`ObservedAssignment::task_metadata_omitted`] says so — an omission the
68/// reader can see beats a session row that inherits someone's payload
69/// 32 times over.
70pub const TASK_METADATA_MAX_BYTES: usize = 4096;
71
72/// Byte ceiling for each of the three caller-supplied strings on an
73/// [`ObservedAssignment`] — `goal`, `project_root` and `work_dir`.
74///
75/// The same rule [`TASK_METADATA_MAX_BYTES`] applies to the fourth
76/// caller-supplied field, differing in what it does when the ceiling is
77/// hit: these three are **cut, not dropped**. A JSON bag half-carried is
78/// not a JSON bag, but a goal's opening clause and a path's leading
79/// components are exactly what the observed part is read for — telling two
80/// of a driver's Runs apart — so keeping the prefix keeps the field doing
81/// its job. [`ObservedAssignment::text_truncated`] says a cut happened, and
82/// the value itself ends in `…`, so a reader is never handed a shortened
83/// path that reads like a whole one.
84///
85/// 1 KiB is generous for both shapes: a goal is a sentence, and a path that
86/// long is already past what most filesystems will hand out.
87pub const OBSERVED_TEXT_MAX_BYTES: usize = 1024;
88
89/// How long a session may go unaccessed before it expires — the second
90/// exit from `Registered` in model §4.1's state diagram
91/// (`Registered ── 最終アクセスから 24h ──▶ ╳ 削除`), in seconds.
92///
93/// # The rule has no predicate number
94///
95/// It is cited that way throughout this file and the server's, and not as
96/// **O1**, which is a different rule: §4.1's `O1` is `join は無認証`, and
97/// §6's index confirms the `O1-O8` band is the eight numbered Operator
98/// predicates. The 24h horizon appears only in the diagram above them and
99/// was never given a number. Citing a number the model does not carry
100/// makes every one of these doc comments unfollowable in exactly the way
101/// a citation exists to prevent — the reader looks `O1` up and finds a
102/// statement about authentication.
103///
104/// # Enforced at the reads, and on a schedule
105///
106/// The horizon is first of all a rule about what may be *observed*, and
107/// that is where it is enforced: every path that reads a session — the
108/// boot restore, the 記名 list, a single-session read, the WS upgrade —
109/// drops the expired ones it finds and deletes their rows. A session past
110/// the horizon is therefore never returned to anybody, which is the whole
111/// of what the state diagram promises to a reader. That shape is shared
112/// with the two sibling judgments: **A7** examines a seat at reference
113/// time, **O8** cascades at delete time.
114///
115/// The reads alone leave one thing out, and it is not the row on disk. A
116/// teardown also unregisters the session from the engine and the adapter
117/// registry, so until it happens a *dispatch* aimed at the dead sid still
118/// resolves and parks — and a dispatch is not a read, so nothing about it
119/// applies the horizon. "It goes on its own after 24 hours" would
120/// therefore hold only on a server somebody happens to be listing. So the
121/// server also runs the same judgment on a schedule, as the
122/// `operator-session-expiry` job on its periodic-job runner
123/// (`mlua_swarm_server::periodic`), which calls the same read-path
124/// predicate through the same teardown.
125///
126/// That is not a reversal of `31fefc1`, which removed a periodic
127/// stale-`Run` sweeper. What was wrong there was the *predicate* — "a
128/// `Running` row nobody has written to for 3900s has lost its driver" was
129/// stated nowhere else and was false of every healthy run it could reach.
130/// This horizon is stated by the model, applied by four other call sites,
131/// and executed by the teardown a `DELETE` performs; the job contributes
132/// the schedule and nothing else. `periodic`'s module doc carries that
133/// rule for anything else that wants to be scheduled.
134pub const OPERATOR_SESSION_MAX_IDLE_SECS: u64 = 24 * 60 * 60;
135
136pub mod inmemory;
137pub mod sqlite;
138pub use inmemory::InMemoryOperatorSessionStore;
139pub use sqlite::SqliteOperatorSessionStore;
140
141// ──────────────────────────────────────────────────────────────────────────
142// OperatorSessionRecord
143// ──────────────────────────────────────────────────────────────────────────
144
145/// One persisted Operator login-flow session.
146///
147/// Field-for-field the durable subset of the server's `LoginSession` —
148/// everything except the process-lifetime WS adapter state, which is
149/// rebuilt empty on reconnect.
150///
151/// # The bearer token is never stored
152///
153/// [`token_digest`](Self::token_digest) holds
154/// `hex(SHA-256(bearer))` — the same fingerprint shape the `/v1/sessions`
155/// path already keys its store by, for the same reason ("the sid handed to
156/// the client is the token nonce itself (a bearer secret), so the server
157/// never uses it as a map key"; see `mse_server::SessionStore`). Every
158/// consumer of this record only ever *compares* a presented bearer
159/// ([`verify_bearer`](Self::verify_bearer)), so nothing downstream needs
160/// the plaintext — it exists only inside `POST /v1/operators`, between
161/// minting and the mint response.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct OperatorSessionRecord {
164    /// Server-minted session id (`S-<hex>`).
165    pub sid: SessionId,
166    /// `hex(SHA-256(bearer))` of the auth token required on the WS upgrade
167    /// and admin routes. Derive with [`Self::digest_of`]; compare with
168    /// [`Self::verify_bearer`]. The plaintext bearer is deliberately absent
169    /// (see the type doc).
170    pub token_digest: String,
171    /// Provider-owned effective capability manifest submitted at join.
172    pub capability_manifest: Option<AgentProviderManifest>,
173    /// Unix epoch seconds when `POST /v1/operators` minted this session.
174    pub joined_at_secs: u64,
175    /// Unix epoch seconds when this session was last **accessed** — model
176    /// §4.1's `最終アクセス`, the clock the 24h expiry
177    /// ([`OPERATOR_SESSION_MAX_IDLE_SECS`]) runs from.
178    ///
179    /// # Access, not activity
180    ///
181    /// [`Self::last_activity_secs`] answers "when was this session last
182    /// *assigned* something", which is what **D5** sorts the 記名 list by.
183    /// This answers "when did the driver behind this session last show
184    /// itself", which is a wider set of events: attaching a WebSocket,
185    /// reading its own session, being assigned a seat. A driver can be very
186    /// much alive and hold no seat for a day, so expiring on activity would
187    /// reap live sessions.
188    ///
189    /// # Why this one is stored and its sibling is derived
190    ///
191    /// `last_activity_secs` is a maximum over the observed ring, so it
192    /// cannot go stale — every value it reads from is already persisted.
193    /// An access leaves no such trace: nothing about a WS connect or a
194    /// `GET /v1/operators/:sid` is written down anywhere else, so if this
195    /// were derived there would be nothing to derive it from. It is
196    /// advanced by [`Self::touch`] and written through by the server.
197    ///
198    /// Additive with `#[serde(default)]`. A row persisted before this field
199    /// existed decodes as `0`, which would read as "accessed at the epoch"
200    /// and expire it on sight — so every reader goes through
201    /// [`Self::last_access_secs`], which folds `0` back onto the join time.
202    #[serde(default)]
203    pub last_access_secs: u64,
204    /// The **confirmed part** of this session's 記名 (model §4.2, **D1**):
205    /// roughly 50 characters the joining AI wrote about what it is working
206    /// on, fixed at join and never rewritten afterwards.
207    ///
208    /// It is what the observed part cannot supply. Two drivers in the same
209    /// worktree produce the same `project_root` / `work_dir` and can hold
210    /// Runs of the same Blueprint; the sentence one of them wrote at join
211    /// exists only in that conversation, which is what makes it an
212    /// identifier (§4.2: 観測部分だけでは足りない).
213    ///
214    /// `None` = the session joined without one. Kept as an absence rather
215    /// than an empty string so a reader can tell "nothing was written" from
216    /// "something was written and it was blank" — `POST /v1/operators` does
217    /// not reject a missing `desc` (unlike **A9** on the assignment side,
218    /// **D1-D5** name no `400`), so the absence is a real and readable
219    /// state.
220    ///
221    /// **D4**: nothing matches on this. It is read by humans and AIs to
222    /// tell sessions apart, never by the server to decide identity.
223    ///
224    /// Additive with `#[serde(default)]` — rows persisted before the 記名
225    /// existed decode as `None`.
226    #[serde(default)]
227    pub desc: Option<String>,
228    /// The **observed part** of this session's 記名 (model §4.2, **D2**):
229    /// one entry per seat this session was assigned, appended by the server
230    /// at each `Assign` and never removed by any API.
231    ///
232    /// Oldest first. Bounded by [`OBSERVED_CAP`] and de-duplicated per
233    /// `(run_id, slot)` — see [`Self::record_observed`].
234    ///
235    /// Additive with `#[serde(default)]`.
236    #[serde(default)]
237    pub observed: Vec<ObservedAssignment>,
238    /// How many `Assign`s have been recorded onto [`Self::observed`] over
239    /// this session's life, including the ones the ring has since dropped
240    /// and the re-assignments folded into an existing entry.
241    ///
242    /// Monotone. `observed_total > observed.len()` is the visible signal
243    /// that the reader is looking at a window rather than the whole
244    /// history.
245    ///
246    /// Additive with `#[serde(default)]`.
247    #[serde(default)]
248    pub observed_total: u64,
249}
250
251/// One `Assign` as the assigned Operator session observed it — the
252/// per-entry shape of the 記名's observed part (model §4.2's second row:
253/// 担当した Run と goal / `project_root` / `work_dir` / `task_metadata` /
254/// 最終活動時刻).
255///
256/// # Every field is what the server could actually read
257///
258/// The three path-ish fields come from the Task row's `task_input_spec`
259/// (the persisted `TaskInputSpec` the launch was given, which is also what
260/// `TaskInputMiddleware` is later built from), and `goal` from the same
261/// row. A launch that carried no Task-level input leaves all three `None`,
262/// and nothing is substituted for them: an invented `project_root` would
263/// be read as a fact about where the work is happening.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct ObservedAssignment {
266    /// The Run whose seat was taken.
267    pub run_id: String,
268    /// Which Blueprint-declared Operator seat (`Run.current`'s key).
269    pub slot: String,
270    /// The owning Task's human-facing goal, when the Task row could be
271    /// read. `None` = the read failed; it is not a claim that the Task has
272    /// no goal (the field is not optional on the Task row).
273    ///
274    /// Cut to [`OBSERVED_TEXT_MAX_BYTES`] — see [`Self::text_truncated`].
275    #[serde(default)]
276    pub goal: Option<String>,
277    /// Task-level project root, from the launch's `TaskInputSpec`. Cut to
278    /// [`OBSERVED_TEXT_MAX_BYTES`] — see [`Self::text_truncated`].
279    #[serde(default)]
280    pub project_root: Option<String>,
281    /// Task-level working directory, from the same spec. Cut to
282    /// [`OBSERVED_TEXT_MAX_BYTES`] — see [`Self::text_truncated`].
283    #[serde(default)]
284    pub work_dir: Option<String>,
285    /// Task-level metadata bag, from the same spec. Dropped when it
286    /// serializes above [`TASK_METADATA_MAX_BYTES`] — see
287    /// [`Self::task_metadata_omitted`].
288    #[serde(default)]
289    pub task_metadata: Option<serde_json::Value>,
290    /// `true` when [`Self::task_metadata`] was present but too large to
291    /// carry, so a reader does not read the `null` as "the launch supplied
292    /// none".
293    #[serde(default)]
294    pub task_metadata_omitted: bool,
295    /// `true` when at least one of [`Self::goal`] / [`Self::project_root`] /
296    /// [`Self::work_dir`] was longer than [`OBSERVED_TEXT_MAX_BYTES`] and
297    /// was cut to fit.
298    ///
299    /// One flag for the three because it answers the one question a cut
300    /// raises — "is what I am reading the whole value?" — and the cut
301    /// values name themselves: each ends in `…`. A flag per field would
302    /// only restate that.
303    ///
304    /// Additive with `#[serde(default)]`: rows written before the ceiling
305    /// existed decode as `false`, which is what they were — nothing had
306    /// been cut.
307    #[serde(default)]
308    pub text_truncated: bool,
309    /// Unix epoch seconds of the `Assign` this entry records — the session's
310    /// last activity when this is its newest entry
311    /// ([`OperatorSessionRecord::last_activity_secs`]).
312    pub at_secs: u64,
313}
314
315impl ObservedAssignment {
316    /// Build an entry, applying the [`TASK_METADATA_MAX_BYTES`] bound to
317    /// `task_metadata` and the [`OBSERVED_TEXT_MAX_BYTES`] bound to each of
318    /// the three caller-supplied strings.
319    ///
320    /// This is the only constructor callers use, which is what makes the
321    /// bounds a property of the type rather than a rule a call site has to
322    /// remember: every entry that reaches the ring came through here.
323    ///
324    /// A metadata value that will not even serialize is treated as an
325    /// oversized one (dropped, flagged) rather than as absent — the failure
326    /// is about carrying it, not about having it.
327    pub fn new(
328        run_id: String,
329        slot: String,
330        goal: Option<String>,
331        project_root: Option<String>,
332        work_dir: Option<String>,
333        task_metadata: Option<serde_json::Value>,
334        at_secs: u64,
335    ) -> Self {
336        let (task_metadata, task_metadata_omitted) = match task_metadata {
337            None => (None, false),
338            Some(value) => match serde_json::to_string(&value) {
339                Ok(text) if text.len() <= TASK_METADATA_MAX_BYTES => (Some(value), false),
340                _ => (None, true),
341            },
342        };
343        let mut text_truncated = false;
344        let goal = cap_text(goal, &mut text_truncated);
345        let project_root = cap_text(project_root, &mut text_truncated);
346        let work_dir = cap_text(work_dir, &mut text_truncated);
347        Self {
348            run_id,
349            slot,
350            goal,
351            project_root,
352            work_dir,
353            task_metadata,
354            task_metadata_omitted,
355            text_truncated,
356            at_secs,
357        }
358    }
359}
360
361/// Cut `value` to the longest prefix that fits in
362/// [`OBSERVED_TEXT_MAX_BYTES`] and mark `truncated`, or hand it back
363/// untouched when it already fits.
364///
365/// The cut lands on a `char` boundary — a byte-sliced `String` would not
366/// be one — and the result carries a trailing `…` so the shortening is
367/// visible in the value and not only in the flag. That marker is why the
368/// output can exceed the ceiling by its own 3 bytes: the bound is on what
369/// a caller can put in, not on the notation this adds.
370fn cap_text(value: Option<String>, truncated: &mut bool) -> Option<String> {
371    let text = value?;
372    if text.len() <= OBSERVED_TEXT_MAX_BYTES {
373        return Some(text);
374    }
375    let mut end = OBSERVED_TEXT_MAX_BYTES;
376    while end > 0 && !text.is_char_boundary(end) {
377        end -= 1;
378    }
379    *truncated = true;
380    let mut cut = String::with_capacity(end + '…'.len_utf8());
381    cut.push_str(&text[..end]);
382    cut.push('…');
383    Some(cut)
384}
385
386impl OperatorSessionRecord {
387    /// Append one `Assign` to the observed part (**D2**).
388    ///
389    /// Two shaping rules, both about keeping the log readable rather than
390    /// about deleting anything:
391    ///
392    /// - **One entry per `(run_id, slot)`.** Re-acquiring a seat this
393    ///   session already holds is the same fact with a newer timestamp, so
394    ///   the existing entry is replaced and moved to the newest position
395    ///   instead of accumulating a row per acquire. A driver that
396    ///   re-acquires after every reconnect would otherwise fill the whole
397    ///   window with one Run.
398    /// - **Newest [`OBSERVED_CAP`] kept.** Past the cap the oldest entry is
399    ///   dropped.
400    ///
401    /// [`Self::observed_total`] counts every call regardless, so a reader
402    /// can tell that folding or dropping happened.
403    pub fn record_observed(&mut self, entry: ObservedAssignment) {
404        self.observed_total = self.observed_total.saturating_add(1);
405        if let Some(pos) = self
406            .observed
407            .iter()
408            .position(|e| e.run_id == entry.run_id && e.slot == entry.slot)
409        {
410            self.observed.remove(pos);
411        }
412        self.observed.push(entry);
413        while self.observed.len() > OBSERVED_CAP {
414            self.observed.remove(0);
415        }
416    }
417
418    /// When this session was last seen doing something — the newest
419    /// [`ObservedAssignment::at_secs`], or [`Self::joined_at_secs`] for a
420    /// session that has never been assigned anything.
421    ///
422    /// **D5**'s default ordering key. Derived rather than stored: a
423    /// separate column would be a second thing to keep in step with the
424    /// log, and the ring only ever drops entries older than the newest one,
425    /// so the derivation cannot go stale.
426    pub fn last_activity_secs(&self) -> u64 {
427        self.observed
428            .iter()
429            .map(|e| e.at_secs)
430            .max()
431            .unwrap_or(0)
432            .max(self.joined_at_secs)
433    }
434
435    /// When this session was last accessed, for the 24h expiry clock.
436    ///
437    /// Reads [`Self::last_access_secs`], with two foldings that make the
438    /// value safe to compare against a horizon:
439    ///
440    /// - a `0` (a row persisted before the field existed, or a session
441    ///   never touched since it was minted) reads as the **join time**, so
442    ///   a fresh session is never a day old on arrival;
443    /// - an assignment counts as an access even if nothing touched the
444    ///   field, so [`Self::last_activity_secs`] is folded in as well. A
445    ///   session being handed seats is being used, whatever else it does.
446    pub fn last_access_secs(&self) -> u64 {
447        self.last_access_secs.max(self.last_activity_secs())
448    }
449
450    /// Advance [`Self::last_access_secs`] to `now`, never backwards.
451    ///
452    /// Monotone because the clock is not: a `SystemTime` that steps back
453    /// (NTP correction, a suspended laptop) must not make a session look
454    /// older than the last time something saw it. Returns whether the value
455    /// moved, so a caller can skip a durable write that would change
456    /// nothing.
457    pub fn touch(&mut self, now: u64) -> bool {
458        if now <= self.last_access_secs {
459            return false;
460        }
461        self.last_access_secs = now;
462        true
463    }
464
465    /// The 24h horizon: has this session gone
466    /// [`OPERATOR_SESSION_MAX_IDLE_SECS`] without being accessed, as of
467    /// `now`?
468    ///
469    /// A pure predicate over the record. What it *cannot* see is whether a
470    /// socket is attached right now, which is why the server's expiry
471    /// checks pair it with a connectivity read — a driver holding an idle
472    /// WebSocket open is present, and reaping it would be the reaper
473    /// causing the outage it exists to prevent. See
474    /// `mse_server::operator_ws::login`'s expiry note.
475    pub fn is_expired_at(&self, now: u64) -> bool {
476        now.saturating_sub(self.last_access_secs()) >= OPERATOR_SESSION_MAX_IDLE_SECS
477    }
478
479    /// Digest a plaintext bearer into the at-rest shape
480    /// ([`Self::token_digest`]).
481    ///
482    /// Callers mint a bearer with
483    /// [`operator_bearer_token`](crate::types::operator_bearer_token) and
484    /// keep the plaintext only long enough to answer the mint request.
485    pub fn digest_of(bearer: &str) -> String {
486        crate::types::token_fingerprint(bearer)
487    }
488
489    /// Constant-time check of a presented bearer against
490    /// [`Self::token_digest`].
491    ///
492    /// The comparison runs over the two digests (fixed-width hex), so it
493    /// carries no timing signal about the bearer itself.
494    pub fn verify_bearer(&self, bearer: &str) -> bool {
495        crate::types::ct_eq(
496            self.token_digest.as_bytes(),
497            Self::digest_of(bearer).as_bytes(),
498        )
499    }
500}
501
502/// Errors surfaced by an [`OperatorSessionStore`] implementation.
503#[derive(Debug, Error)]
504pub enum OperatorSessionStoreError {
505    /// No session exists for the given sid.
506    #[error("operator session not found: {0}")]
507    NotFound(SessionId),
508
509    /// Backend-specific failure not covered by the other variants.
510    #[error("other: {0}")]
511    Other(String),
512}
513
514// ──────────────────────────────────────────────────────────────────────────
515// OperatorSessionStore trait
516// ──────────────────────────────────────────────────────────────────────────
517
518/// Persistence interface for Operator login-flow sessions.
519///
520/// Write-through contract on the server side: `POST /v1/operators` calls
521/// [`put`](Self::put) before answering the mint, teardown (`DELETE
522/// /v1/operators/:sid`) calls [`delete`](Self::delete), and a fresh boot
523/// calls [`list`](Self::list) once to rehydrate its in-memory session map.
524#[async_trait]
525pub trait OperatorSessionStore: Send + Sync {
526    /// Backend name — for diagnostics/logging.
527    fn name(&self) -> &str;
528
529    /// Insert or replace the row for `record.sid`. Upsert semantics: sids
530    /// are freshly minted so a same-sid overwrite only happens on a
531    /// deliberate re-put of the same session.
532    async fn put(&self, record: OperatorSessionRecord) -> Result<(), OperatorSessionStoreError>;
533
534    /// Delete the row for `sid`. `NotFound` when no such row exists.
535    async fn delete(&self, sid: &SessionId) -> Result<(), OperatorSessionStoreError>;
536
537    /// The row stored under `sid`, **exactly as stored** — `Ok(None)` when
538    /// there is none.
539    ///
540    /// # This one does not apply the horizon, and that is the point
541    ///
542    /// [`list`](Self::list) both filters and deletes, which leaves it
543    /// unable to answer the question its own contract is written around:
544    /// *was the expired row deleted, or merely withheld?* Both produce the
545    /// same `list`. Three assertions elsewhere claimed to check the
546    /// deletion and read it through `list`, so all three would have passed
547    /// on a filter-only backend — the load-bearing half of the contract
548    /// ("Filtering without deleting would hide them from the reader while
549    /// leaving the file growing") was untestable through the trait,
550    /// because the trait exposed no unfiltered read.
551    ///
552    /// This is that read. It reports the backing store's contents and
553    /// applies no judgment of its own, so a caller can tell a deleted row
554    /// from a hidden one.
555    ///
556    /// # It is not a session-resolution path
557    ///
558    /// Nothing in the server resolves a live session through here: a
559    /// running process answers about sessions out of its in-memory map,
560    /// and the durable rows are read exactly once, at boot, by `list`.
561    /// Handing an expired row back is therefore not a way to revive one —
562    /// the row goes to a test or a diagnostic, both of which want the
563    /// truth about the file rather than the truth about who may be served.
564    async fn get(
565        &self,
566        sid: &SessionId,
567    ) -> Result<Option<OperatorSessionRecord>, OperatorSessionStoreError>;
568
569    /// List the sessions this store can decode **and that have not
570    /// expired**, ascending by `joined_at_secs` (mint order, stable for
571    /// deterministic rehydration).
572    ///
573    /// # Contract: an expired row is dropped *and deleted*
574    ///
575    /// A row whose last access is [`OPERATOR_SESSION_MAX_IDLE_SECS`] or
576    /// more in the past is model §4.1's second exit from `Registered`:
577    /// `Registered ── 最終アクセスから 24h ──▶ ╳ 削除` (unnumbered — see
578    /// [`OPERATOR_SESSION_MAX_IDLE_SECS`]). Implementations must omit it from
579    /// the returned vector and remove it from the backing store, reporting
580    /// each removal with a `tracing::info!`.
581    ///
582    /// A `list` that deletes is unusual enough to say why it is here rather
583    /// than in a reaper. The sole caller is boot-time rehydration, which is
584    /// also the only moment a persisted session is read from disk at all —
585    /// so this is where an expired row would otherwise be resurrected, once
586    /// per restart, forever (the row's own driver crashed and lost the
587    /// bearer `DELETE /v1/operators/:sid` wants, so nothing else can ever
588    /// remove it). Filtering without deleting would hide them from the
589    /// reader while leaving the file growing.
590    ///
591    /// The running server sweeps expired sessions on a schedule as well
592    /// (see [`OPERATOR_SESSION_MAX_IDLE_SECS`]), but that job walks the
593    /// live session map — which, at this moment, is the empty one this
594    /// call is about to fill. Boot is the one point where a row exists and
595    /// no session does, so this contract is the sweep's counterpart across
596    /// a restart, not a duplicate of it.
597    ///
598    /// Deleting is safe precisely because the row is expired: no live
599    /// process holds it (it was not in memory — this call is what would
600    /// have put it there), and nothing else refers to it. A `Run.current`
601    /// naming it is repaired by an `acquire` (**A8**), the same repair a
602    /// crashed driver's seat already needs.
603    ///
604    /// # Contract: per row, not all-or-nothing
605    ///
606    /// A backend that decodes at-rest bytes back into
607    /// [`OperatorSessionRecord`] **must not** let one undecodable row fail
608    /// the whole call. Such a row is skipped and reported with a
609    /// `tracing::warn!` naming the row and the field that failed; the
610    /// intact rows are still returned. An `Err` from this method therefore
611    /// means the *backend* failed (the file is unreadable, the connection
612    /// is gone) — never that one stored session went bad.
613    ///
614    /// This matters because the sole caller is boot-time rehydration, and
615    /// its own error path is fatal: an `Err` here takes `mse serve` down
616    /// and every healthy session with it. Undecodable rows are reachable
617    /// in practice — an older build could persist shapes a newer one
618    /// rejects (`sid: "op-<uuid>"` predates the `S-<hex>` shape) — so
619    /// all-or-nothing decoding means one stale row bricks the boot.
620    ///
621    /// Skipping the row rather than defaulting the field is deliberate: a
622    /// session restored minus a field it was minted with would come back
623    /// claiming something other than what it is, and would fail later,
624    /// elsewhere, and quietly. Dropping it is the observable choice.
625    ///
626    /// # Backends that never decode
627    ///
628    /// [`InMemoryOperatorSessionStore`] holds live
629    /// [`OperatorSessionRecord`]s, so no row of its can be undecodable and
630    /// it never skips anything. That is consistent with the contract, not
631    /// an exemption from it: "the sessions this store can decode" is every
632    /// session it holds.
633    async fn list(&self) -> Result<Vec<OperatorSessionRecord>, OperatorSessionStoreError>;
634}
635
636/// The wall clock the expiry horizon is measured against.
637///
638/// A clock that cannot answer yields `0`, which makes `now.saturating_sub`
639/// zero for every record and expires nothing. That is the right way to
640/// fail: an unreadable clock is not evidence that a session is stale, and
641/// this is a deleting path.
642pub(crate) fn expiry_now() -> u64 {
643    std::time::SystemTime::now()
644        .duration_since(std::time::UNIX_EPOCH)
645        .map(|d| d.as_secs())
646        .unwrap_or(0)
647}
648
649/// Split a freshly read set of records into the ones a caller may see and
650/// the sids the 24h horizon has expired, logging one line per expiry.
651///
652/// Shared by both backends so the horizon, the predicate and the wording
653/// are decided once — a backend that drifted on any of the three would
654/// give the same server two different session lifetimes depending on how
655/// it was configured.
656pub(crate) fn partition_expired(
657    records: Vec<OperatorSessionRecord>,
658    now: u64,
659    backend: &str,
660) -> (Vec<OperatorSessionRecord>, Vec<SessionId>) {
661    let mut live = Vec::with_capacity(records.len());
662    let mut expired = Vec::new();
663    for record in records {
664        if record.is_expired_at(now) {
665            tracing::info!(
666                sid = %record.sid,
667                backend,
668                last_access_secs = record.last_access_secs(),
669                idle_secs = now.saturating_sub(record.last_access_secs()),
670                desc = record.desc.as_deref().unwrap_or("<none>"),
671                "operator session expired (24h since last access); dropping the row \
672                 instead of restoring it"
673            );
674            expired.push(record.sid);
675        } else {
676            live.push(record);
677        }
678    }
679    (live, expired)
680}
681
682// ──────────────────────────────────────────────────────────────────────────
683// Shared inner state used by the InMemory backend.
684// ──────────────────────────────────────────────────────────────────────────
685
686#[derive(Default)]
687pub(crate) struct Inner {
688    /// Insertion order — used as a stable tie-break under `list()`.
689    pub(crate) order: Vec<SessionId>,
690    pub(crate) records: HashMap<SessionId, OperatorSessionRecord>,
691}
692
693pub(crate) type SharedInner = Mutex<Inner>;
694
695// ──────────────────────────────────────────────────────────────────────────
696// tests — the 記名 shaping rules (D1 / D2 / D5)
697// ──────────────────────────────────────────────────────────────────────────
698
699#[cfg(test)]
700mod record_tests {
701    use super::*;
702    use serde_json::json;
703
704    fn record() -> OperatorSessionRecord {
705        OperatorSessionRecord {
706            sid: SessionId::parse("S-1").expect("a well-formed sid"),
707            token_digest: OperatorSessionRecord::digest_of("bearer"),
708            capability_manifest: None,
709            joined_at_secs: 100,
710            last_access_secs: 100,
711            desc: None,
712            observed: Vec::new(),
713            observed_total: 0,
714        }
715    }
716
717    fn entry(run: &str, slot: &str, at_secs: u64) -> ObservedAssignment {
718        ObservedAssignment::new(
719            run.to_string(),
720            slot.to_string(),
721            Some("resolve issue #10".to_string()),
722            Some("/repo".to_string()),
723            Some("/repo/.worktrees/topic".to_string()),
724            Some(json!({"issue": 10})),
725            at_secs,
726        )
727    }
728
729    /// Re-taking a seat this session already holds refreshes the one entry
730    /// instead of adding a second, and moves it to the newest position.
731    #[test]
732    fn re_assigning_the_same_seat_folds_into_one_entry() {
733        let mut r = record();
734        r.record_observed(entry("R-a", "phase-a-op", 110));
735        r.record_observed(entry("R-b", "phase-a-op", 120));
736        r.record_observed(entry("R-a", "phase-a-op", 130));
737
738        let seen: Vec<(&str, u64)> = r
739            .observed
740            .iter()
741            .map(|e| (e.run_id.as_str(), e.at_secs))
742            .collect();
743        assert_eq!(seen, vec![("R-b", 120), ("R-a", 130)]);
744        assert_eq!(
745            r.observed_total, 3,
746            "the fold is not a deletion: the count still says three Assigns happened"
747        );
748    }
749
750    /// The same Run in a different seat is a different fact.
751    #[test]
752    fn the_same_run_in_another_seat_is_its_own_entry() {
753        let mut r = record();
754        r.record_observed(entry("R-a", "phase-a-op", 110));
755        r.record_observed(entry("R-a", "phase-b-op", 111));
756        assert_eq!(r.observed.len(), 2);
757    }
758
759    /// Past the cap the oldest entry ages out; the counter keeps saying how
760    /// many there really were.
761    #[test]
762    fn the_log_is_a_ring_bounded_by_the_cap() {
763        let mut r = record();
764        for i in 0..(OBSERVED_CAP + 5) {
765            r.record_observed(entry(&format!("R-{i}"), "phase-a-op", 200 + i as u64));
766        }
767        assert_eq!(r.observed.len(), OBSERVED_CAP);
768        assert_eq!(r.observed[0].run_id, "R-5", "the oldest five aged out");
769        assert_eq!(r.observed_total, (OBSERVED_CAP + 5) as u64);
770    }
771
772    /// D5's ordering key: the newest activity, falling back to the join.
773    #[test]
774    fn last_activity_falls_back_to_the_join_time() {
775        let mut r = record();
776        assert_eq!(r.last_activity_secs(), 100);
777        r.record_observed(entry("R-a", "phase-a-op", 140));
778        assert_eq!(r.last_activity_secs(), 140);
779    }
780
781    /// An oversized metadata bag is dropped *and flagged*, so the `null` is
782    /// not read as "the launch supplied none".
783    #[test]
784    fn oversized_task_metadata_is_dropped_and_flagged() {
785        let big = json!({ "blob": "x".repeat(TASK_METADATA_MAX_BYTES) });
786        let e = ObservedAssignment::new(
787            "R-a".to_string(),
788            "phase-a-op".to_string(),
789            None,
790            None,
791            None,
792            Some(big),
793            1,
794        );
795        assert!(e.task_metadata.is_none());
796        assert!(e.task_metadata_omitted);
797
798        let small = ObservedAssignment::new(
799            "R-a".to_string(),
800            "phase-a-op".to_string(),
801            None,
802            None,
803            None,
804            None,
805            1,
806        );
807        assert!(!small.task_metadata_omitted, "absent is not omitted");
808    }
809
810    /// The bound [`OBSERVED_CAP`]'s doc multiplies by 32 has to exist for
811    /// every field, not only for the JSON bag. `goal` is the one a caller
812    /// controls with no natural size, and it used to be copied verbatim.
813    #[test]
814    fn an_oversized_goal_is_cut_and_flagged() {
815        let e = ObservedAssignment::new(
816            "R-a".to_string(),
817            "phase-a-op".to_string(),
818            Some("g".repeat(OBSERVED_TEXT_MAX_BYTES * 4)),
819            Some("/repo".to_string()),
820            None,
821            None,
822            1,
823        );
824        let goal = e.goal.as_deref().expect("the prefix is kept, not dropped");
825        assert!(
826            goal.len() <= OBSERVED_TEXT_MAX_BYTES + '…'.len_utf8(),
827            "a goal must not enter the ring longer than the ceiling (+ the marker), got {}",
828            goal.len()
829        );
830        assert!(goal.ends_with('…'), "the cut names itself in the value");
831        assert!(e.text_truncated, "and in the flag");
832        assert_eq!(
833            e.project_root.as_deref(),
834            Some("/repo"),
835            "a field that fits is untouched"
836        );
837    }
838
839    /// The cut lands on a `char` boundary — a multi-byte goal must not be
840    /// sliced through the middle of one.
841    #[test]
842    fn the_cut_lands_on_a_char_boundary() {
843        // 3 bytes each, so the ceiling falls inside a character.
844        let text = "あ".repeat(OBSERVED_TEXT_MAX_BYTES);
845        let e = ObservedAssignment::new(
846            "R-a".to_string(),
847            "phase-a-op".to_string(),
848            Some(text),
849            None,
850            None,
851            None,
852            1,
853        );
854        let goal = e.goal.as_deref().expect("kept");
855        assert!(e.text_truncated);
856        assert!(
857            goal.trim_end_matches('…').chars().all(|c| c == 'あ'),
858            "the prefix is whole characters"
859        );
860    }
861
862    /// Nothing is flagged when nothing was cut — the flag is a report, not
863    /// a default.
864    #[test]
865    fn a_short_entry_is_not_flagged() {
866        let e = entry("R-a", "phase-a-op", 1);
867        assert!(!e.text_truncated);
868        assert_eq!(e.goal.as_deref(), Some("resolve issue #10"));
869    }
870}