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}