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
89pub mod inmemory;
90pub mod sqlite;
91pub use inmemory::InMemoryOperatorSessionStore;
92pub use sqlite::SqliteOperatorSessionStore;
93
94// ──────────────────────────────────────────────────────────────────────────
95// OperatorSessionRecord
96// ──────────────────────────────────────────────────────────────────────────
97
98/// One persisted Operator login-flow session.
99///
100/// Field-for-field the durable subset of the server's `LoginSession` —
101/// everything except the process-lifetime WS adapter state, which is
102/// rebuilt empty on reconnect.
103///
104/// # The bearer token is never stored
105///
106/// [`token_digest`](Self::token_digest) holds
107/// `hex(SHA-256(bearer))` — the same fingerprint shape the `/v1/sessions`
108/// path already keys its store by, for the same reason ("the sid handed to
109/// the client is the token nonce itself (a bearer secret), so the server
110/// never uses it as a map key"; see `mse_server::SessionStore`). Every
111/// consumer of this record only ever *compares* a presented bearer
112/// ([`verify_bearer`](Self::verify_bearer)), so nothing downstream needs
113/// the plaintext — it exists only inside `POST /v1/operators`, between
114/// minting and the mint response.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct OperatorSessionRecord {
117    /// Server-minted session id (`S-<hex>`).
118    pub sid: SessionId,
119    /// `hex(SHA-256(bearer))` of the auth token required on the WS upgrade
120    /// and admin routes. Derive with [`Self::digest_of`]; compare with
121    /// [`Self::verify_bearer`]. The plaintext bearer is deliberately absent
122    /// (see the type doc).
123    pub token_digest: String,
124    /// Provider-owned effective capability manifest submitted at join.
125    pub capability_manifest: Option<AgentProviderManifest>,
126    /// Unix epoch seconds when `POST /v1/operators` minted this session.
127    pub joined_at_secs: u64,
128    /// The **confirmed part** of this session's 記名 (model §4.2, **D1**):
129    /// roughly 50 characters the joining AI wrote about what it is working
130    /// on, fixed at join and never rewritten afterwards.
131    ///
132    /// It is what the observed part cannot supply. Two drivers in the same
133    /// worktree produce the same `project_root` / `work_dir` and can hold
134    /// Runs of the same Blueprint; the sentence one of them wrote at join
135    /// exists only in that conversation, which is what makes it an
136    /// identifier (§4.2: 観測部分だけでは足りない).
137    ///
138    /// `None` = the session joined without one. Kept as an absence rather
139    /// than an empty string so a reader can tell "nothing was written" from
140    /// "something was written and it was blank" — `POST /v1/operators` does
141    /// not reject a missing `desc` (unlike **A9** on the assignment side,
142    /// **D1-D5** name no `400`), so the absence is a real and readable
143    /// state.
144    ///
145    /// **D4**: nothing matches on this. It is read by humans and AIs to
146    /// tell sessions apart, never by the server to decide identity.
147    ///
148    /// Additive with `#[serde(default)]` — rows persisted before the 記名
149    /// existed decode as `None`.
150    #[serde(default)]
151    pub desc: Option<String>,
152    /// The **observed part** of this session's 記名 (model §4.2, **D2**):
153    /// one entry per seat this session was assigned, appended by the server
154    /// at each `Assign` and never removed by any API.
155    ///
156    /// Oldest first. Bounded by [`OBSERVED_CAP`] and de-duplicated per
157    /// `(run_id, slot)` — see [`Self::record_observed`].
158    ///
159    /// Additive with `#[serde(default)]`.
160    #[serde(default)]
161    pub observed: Vec<ObservedAssignment>,
162    /// How many `Assign`s have been recorded onto [`Self::observed`] over
163    /// this session's life, including the ones the ring has since dropped
164    /// and the re-assignments folded into an existing entry.
165    ///
166    /// Monotone. `observed_total > observed.len()` is the visible signal
167    /// that the reader is looking at a window rather than the whole
168    /// history.
169    ///
170    /// Additive with `#[serde(default)]`.
171    #[serde(default)]
172    pub observed_total: u64,
173}
174
175/// One `Assign` as the assigned Operator session observed it — the
176/// per-entry shape of the 記名's observed part (model §4.2's second row:
177/// 担当した Run と goal / `project_root` / `work_dir` / `task_metadata` /
178/// 最終活動時刻).
179///
180/// # Every field is what the server could actually read
181///
182/// The three path-ish fields come from the Task row's `task_input_spec`
183/// (the persisted `TaskInputSpec` the launch was given, which is also what
184/// `TaskInputMiddleware` is later built from), and `goal` from the same
185/// row. A launch that carried no Task-level input leaves all three `None`,
186/// and nothing is substituted for them: an invented `project_root` would
187/// be read as a fact about where the work is happening.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct ObservedAssignment {
190    /// The Run whose seat was taken.
191    pub run_id: String,
192    /// Which Blueprint-declared Operator seat (`Run.current`'s key).
193    pub slot: String,
194    /// The owning Task's human-facing goal, when the Task row could be
195    /// read. `None` = the read failed; it is not a claim that the Task has
196    /// no goal (the field is not optional on the Task row).
197    ///
198    /// Cut to [`OBSERVED_TEXT_MAX_BYTES`] — see [`Self::text_truncated`].
199    #[serde(default)]
200    pub goal: Option<String>,
201    /// Task-level project root, from the launch's `TaskInputSpec`. Cut to
202    /// [`OBSERVED_TEXT_MAX_BYTES`] — see [`Self::text_truncated`].
203    #[serde(default)]
204    pub project_root: Option<String>,
205    /// Task-level working directory, from the same spec. Cut to
206    /// [`OBSERVED_TEXT_MAX_BYTES`] — see [`Self::text_truncated`].
207    #[serde(default)]
208    pub work_dir: Option<String>,
209    /// Task-level metadata bag, from the same spec. Dropped when it
210    /// serializes above [`TASK_METADATA_MAX_BYTES`] — see
211    /// [`Self::task_metadata_omitted`].
212    #[serde(default)]
213    pub task_metadata: Option<serde_json::Value>,
214    /// `true` when [`Self::task_metadata`] was present but too large to
215    /// carry, so a reader does not read the `null` as "the launch supplied
216    /// none".
217    #[serde(default)]
218    pub task_metadata_omitted: bool,
219    /// `true` when at least one of [`Self::goal`] / [`Self::project_root`] /
220    /// [`Self::work_dir`] was longer than [`OBSERVED_TEXT_MAX_BYTES`] and
221    /// was cut to fit.
222    ///
223    /// One flag for the three because it answers the one question a cut
224    /// raises — "is what I am reading the whole value?" — and the cut
225    /// values name themselves: each ends in `…`. A flag per field would
226    /// only restate that.
227    ///
228    /// Additive with `#[serde(default)]`: rows written before the ceiling
229    /// existed decode as `false`, which is what they were — nothing had
230    /// been cut.
231    #[serde(default)]
232    pub text_truncated: bool,
233    /// Unix epoch seconds of the `Assign` this entry records — the session's
234    /// last activity when this is its newest entry
235    /// ([`OperatorSessionRecord::last_activity_secs`]).
236    pub at_secs: u64,
237}
238
239impl ObservedAssignment {
240    /// Build an entry, applying the [`TASK_METADATA_MAX_BYTES`] bound to
241    /// `task_metadata` and the [`OBSERVED_TEXT_MAX_BYTES`] bound to each of
242    /// the three caller-supplied strings.
243    ///
244    /// This is the only constructor callers use, which is what makes the
245    /// bounds a property of the type rather than a rule a call site has to
246    /// remember: every entry that reaches the ring came through here.
247    ///
248    /// A metadata value that will not even serialize is treated as an
249    /// oversized one (dropped, flagged) rather than as absent — the failure
250    /// is about carrying it, not about having it.
251    pub fn new(
252        run_id: String,
253        slot: String,
254        goal: Option<String>,
255        project_root: Option<String>,
256        work_dir: Option<String>,
257        task_metadata: Option<serde_json::Value>,
258        at_secs: u64,
259    ) -> Self {
260        let (task_metadata, task_metadata_omitted) = match task_metadata {
261            None => (None, false),
262            Some(value) => match serde_json::to_string(&value) {
263                Ok(text) if text.len() <= TASK_METADATA_MAX_BYTES => (Some(value), false),
264                _ => (None, true),
265            },
266        };
267        let mut text_truncated = false;
268        let goal = cap_text(goal, &mut text_truncated);
269        let project_root = cap_text(project_root, &mut text_truncated);
270        let work_dir = cap_text(work_dir, &mut text_truncated);
271        Self {
272            run_id,
273            slot,
274            goal,
275            project_root,
276            work_dir,
277            task_metadata,
278            task_metadata_omitted,
279            text_truncated,
280            at_secs,
281        }
282    }
283}
284
285/// Cut `value` to the longest prefix that fits in
286/// [`OBSERVED_TEXT_MAX_BYTES`] and mark `truncated`, or hand it back
287/// untouched when it already fits.
288///
289/// The cut lands on a `char` boundary — a byte-sliced `String` would not
290/// be one — and the result carries a trailing `…` so the shortening is
291/// visible in the value and not only in the flag. That marker is why the
292/// output can exceed the ceiling by its own 3 bytes: the bound is on what
293/// a caller can put in, not on the notation this adds.
294fn cap_text(value: Option<String>, truncated: &mut bool) -> Option<String> {
295    let text = value?;
296    if text.len() <= OBSERVED_TEXT_MAX_BYTES {
297        return Some(text);
298    }
299    let mut end = OBSERVED_TEXT_MAX_BYTES;
300    while end > 0 && !text.is_char_boundary(end) {
301        end -= 1;
302    }
303    *truncated = true;
304    let mut cut = String::with_capacity(end + '…'.len_utf8());
305    cut.push_str(&text[..end]);
306    cut.push('…');
307    Some(cut)
308}
309
310impl OperatorSessionRecord {
311    /// Append one `Assign` to the observed part (**D2**).
312    ///
313    /// Two shaping rules, both about keeping the log readable rather than
314    /// about deleting anything:
315    ///
316    /// - **One entry per `(run_id, slot)`.** Re-acquiring a seat this
317    ///   session already holds is the same fact with a newer timestamp, so
318    ///   the existing entry is replaced and moved to the newest position
319    ///   instead of accumulating a row per acquire. A driver that
320    ///   re-acquires after every reconnect would otherwise fill the whole
321    ///   window with one Run.
322    /// - **Newest [`OBSERVED_CAP`] kept.** Past the cap the oldest entry is
323    ///   dropped.
324    ///
325    /// [`Self::observed_total`] counts every call regardless, so a reader
326    /// can tell that folding or dropping happened.
327    pub fn record_observed(&mut self, entry: ObservedAssignment) {
328        self.observed_total = self.observed_total.saturating_add(1);
329        if let Some(pos) = self
330            .observed
331            .iter()
332            .position(|e| e.run_id == entry.run_id && e.slot == entry.slot)
333        {
334            self.observed.remove(pos);
335        }
336        self.observed.push(entry);
337        while self.observed.len() > OBSERVED_CAP {
338            self.observed.remove(0);
339        }
340    }
341
342    /// When this session was last seen doing something — the newest
343    /// [`ObservedAssignment::at_secs`], or [`Self::joined_at_secs`] for a
344    /// session that has never been assigned anything.
345    ///
346    /// **D5**'s default ordering key. Derived rather than stored: a
347    /// separate column would be a second thing to keep in step with the
348    /// log, and the ring only ever drops entries older than the newest one,
349    /// so the derivation cannot go stale.
350    pub fn last_activity_secs(&self) -> u64 {
351        self.observed
352            .iter()
353            .map(|e| e.at_secs)
354            .max()
355            .unwrap_or(0)
356            .max(self.joined_at_secs)
357    }
358
359    /// Digest a plaintext bearer into the at-rest shape
360    /// ([`Self::token_digest`]).
361    ///
362    /// Callers mint a bearer with
363    /// [`operator_bearer_token`](crate::types::operator_bearer_token) and
364    /// keep the plaintext only long enough to answer the mint request.
365    pub fn digest_of(bearer: &str) -> String {
366        crate::types::token_fingerprint(bearer)
367    }
368
369    /// Constant-time check of a presented bearer against
370    /// [`Self::token_digest`].
371    ///
372    /// The comparison runs over the two digests (fixed-width hex), so it
373    /// carries no timing signal about the bearer itself.
374    pub fn verify_bearer(&self, bearer: &str) -> bool {
375        crate::types::ct_eq(
376            self.token_digest.as_bytes(),
377            Self::digest_of(bearer).as_bytes(),
378        )
379    }
380}
381
382/// Errors surfaced by an [`OperatorSessionStore`] implementation.
383#[derive(Debug, Error)]
384pub enum OperatorSessionStoreError {
385    /// No session exists for the given sid.
386    #[error("operator session not found: {0}")]
387    NotFound(SessionId),
388
389    /// Backend-specific failure not covered by the other variants.
390    #[error("other: {0}")]
391    Other(String),
392}
393
394// ──────────────────────────────────────────────────────────────────────────
395// OperatorSessionStore trait
396// ──────────────────────────────────────────────────────────────────────────
397
398/// Persistence interface for Operator login-flow sessions.
399///
400/// Write-through contract on the server side: `POST /v1/operators` calls
401/// [`put`](Self::put) before answering the mint, teardown (`DELETE
402/// /v1/operators/:sid`) calls [`delete`](Self::delete), and a fresh boot
403/// calls [`list`](Self::list) once to rehydrate its in-memory session map.
404#[async_trait]
405pub trait OperatorSessionStore: Send + Sync {
406    /// Backend name — for diagnostics/logging.
407    fn name(&self) -> &str;
408
409    /// Insert or replace the row for `record.sid`. Upsert semantics: sids
410    /// are freshly minted so a same-sid overwrite only happens on a
411    /// deliberate re-put of the same session.
412    async fn put(&self, record: OperatorSessionRecord) -> Result<(), OperatorSessionStoreError>;
413
414    /// Delete the row for `sid`. `NotFound` when no such row exists.
415    async fn delete(&self, sid: &SessionId) -> Result<(), OperatorSessionStoreError>;
416
417    /// List the sessions this store can decode, ascending by
418    /// `joined_at_secs` (mint order, stable for deterministic rehydration).
419    ///
420    /// # Contract: per row, not all-or-nothing
421    ///
422    /// A backend that decodes at-rest bytes back into
423    /// [`OperatorSessionRecord`] **must not** let one undecodable row fail
424    /// the whole call. Such a row is skipped and reported with a
425    /// `tracing::warn!` naming the row and the field that failed; the
426    /// intact rows are still returned. An `Err` from this method therefore
427    /// means the *backend* failed (the file is unreadable, the connection
428    /// is gone) — never that one stored session went bad.
429    ///
430    /// This matters because the sole caller is boot-time rehydration, and
431    /// its own error path is fatal: an `Err` here takes `mse serve` down
432    /// and every healthy session with it. Undecodable rows are reachable
433    /// in practice — an older build could persist shapes a newer one
434    /// rejects (`sid: "op-<uuid>"` predates the `S-<hex>` shape) — so
435    /// all-or-nothing decoding means one stale row bricks the boot.
436    ///
437    /// Skipping the row rather than defaulting the field is deliberate: a
438    /// session restored minus a field it was minted with would come back
439    /// claiming something other than what it is, and would fail later,
440    /// elsewhere, and quietly. Dropping it is the observable choice.
441    ///
442    /// # Backends that never decode
443    ///
444    /// [`InMemoryOperatorSessionStore`] holds live
445    /// [`OperatorSessionRecord`]s, so no row of its can be undecodable and
446    /// it never skips anything. That is consistent with the contract, not
447    /// an exemption from it: "the sessions this store can decode" is every
448    /// session it holds.
449    async fn list(&self) -> Result<Vec<OperatorSessionRecord>, OperatorSessionStoreError>;
450}
451
452// ──────────────────────────────────────────────────────────────────────────
453// Shared inner state used by the InMemory backend.
454// ──────────────────────────────────────────────────────────────────────────
455
456#[derive(Default)]
457pub(crate) struct Inner {
458    /// Insertion order — used as a stable tie-break under `list()`.
459    pub(crate) order: Vec<SessionId>,
460    pub(crate) records: HashMap<SessionId, OperatorSessionRecord>,
461}
462
463pub(crate) type SharedInner = Mutex<Inner>;
464
465// ──────────────────────────────────────────────────────────────────────────
466// tests — the 記名 shaping rules (D1 / D2 / D5)
467// ──────────────────────────────────────────────────────────────────────────
468
469#[cfg(test)]
470mod record_tests {
471    use super::*;
472    use serde_json::json;
473
474    fn record() -> OperatorSessionRecord {
475        OperatorSessionRecord {
476            sid: SessionId::parse("S-1").expect("a well-formed sid"),
477            token_digest: OperatorSessionRecord::digest_of("bearer"),
478            capability_manifest: None,
479            joined_at_secs: 100,
480            desc: None,
481            observed: Vec::new(),
482            observed_total: 0,
483        }
484    }
485
486    fn entry(run: &str, slot: &str, at_secs: u64) -> ObservedAssignment {
487        ObservedAssignment::new(
488            run.to_string(),
489            slot.to_string(),
490            Some("resolve issue #10".to_string()),
491            Some("/repo".to_string()),
492            Some("/repo/.worktrees/topic".to_string()),
493            Some(json!({"issue": 10})),
494            at_secs,
495        )
496    }
497
498    /// Re-taking a seat this session already holds refreshes the one entry
499    /// instead of adding a second, and moves it to the newest position.
500    #[test]
501    fn re_assigning_the_same_seat_folds_into_one_entry() {
502        let mut r = record();
503        r.record_observed(entry("R-a", "phase-a-op", 110));
504        r.record_observed(entry("R-b", "phase-a-op", 120));
505        r.record_observed(entry("R-a", "phase-a-op", 130));
506
507        let seen: Vec<(&str, u64)> = r
508            .observed
509            .iter()
510            .map(|e| (e.run_id.as_str(), e.at_secs))
511            .collect();
512        assert_eq!(seen, vec![("R-b", 120), ("R-a", 130)]);
513        assert_eq!(
514            r.observed_total, 3,
515            "the fold is not a deletion: the count still says three Assigns happened"
516        );
517    }
518
519    /// The same Run in a different seat is a different fact.
520    #[test]
521    fn the_same_run_in_another_seat_is_its_own_entry() {
522        let mut r = record();
523        r.record_observed(entry("R-a", "phase-a-op", 110));
524        r.record_observed(entry("R-a", "phase-b-op", 111));
525        assert_eq!(r.observed.len(), 2);
526    }
527
528    /// Past the cap the oldest entry ages out; the counter keeps saying how
529    /// many there really were.
530    #[test]
531    fn the_log_is_a_ring_bounded_by_the_cap() {
532        let mut r = record();
533        for i in 0..(OBSERVED_CAP + 5) {
534            r.record_observed(entry(&format!("R-{i}"), "phase-a-op", 200 + i as u64));
535        }
536        assert_eq!(r.observed.len(), OBSERVED_CAP);
537        assert_eq!(r.observed[0].run_id, "R-5", "the oldest five aged out");
538        assert_eq!(r.observed_total, (OBSERVED_CAP + 5) as u64);
539    }
540
541    /// D5's ordering key: the newest activity, falling back to the join.
542    #[test]
543    fn last_activity_falls_back_to_the_join_time() {
544        let mut r = record();
545        assert_eq!(r.last_activity_secs(), 100);
546        r.record_observed(entry("R-a", "phase-a-op", 140));
547        assert_eq!(r.last_activity_secs(), 140);
548    }
549
550    /// An oversized metadata bag is dropped *and flagged*, so the `null` is
551    /// not read as "the launch supplied none".
552    #[test]
553    fn oversized_task_metadata_is_dropped_and_flagged() {
554        let big = json!({ "blob": "x".repeat(TASK_METADATA_MAX_BYTES) });
555        let e = ObservedAssignment::new(
556            "R-a".to_string(),
557            "phase-a-op".to_string(),
558            None,
559            None,
560            None,
561            Some(big),
562            1,
563        );
564        assert!(e.task_metadata.is_none());
565        assert!(e.task_metadata_omitted);
566
567        let small = ObservedAssignment::new(
568            "R-a".to_string(),
569            "phase-a-op".to_string(),
570            None,
571            None,
572            None,
573            None,
574            1,
575        );
576        assert!(!small.task_metadata_omitted, "absent is not omitted");
577    }
578
579    /// The bound [`OBSERVED_CAP`]'s doc multiplies by 32 has to exist for
580    /// every field, not only for the JSON bag. `goal` is the one a caller
581    /// controls with no natural size, and it used to be copied verbatim.
582    #[test]
583    fn an_oversized_goal_is_cut_and_flagged() {
584        let e = ObservedAssignment::new(
585            "R-a".to_string(),
586            "phase-a-op".to_string(),
587            Some("g".repeat(OBSERVED_TEXT_MAX_BYTES * 4)),
588            Some("/repo".to_string()),
589            None,
590            None,
591            1,
592        );
593        let goal = e.goal.as_deref().expect("the prefix is kept, not dropped");
594        assert!(
595            goal.len() <= OBSERVED_TEXT_MAX_BYTES + '…'.len_utf8(),
596            "a goal must not enter the ring longer than the ceiling (+ the marker), got {}",
597            goal.len()
598        );
599        assert!(goal.ends_with('…'), "the cut names itself in the value");
600        assert!(e.text_truncated, "and in the flag");
601        assert_eq!(
602            e.project_root.as_deref(),
603            Some("/repo"),
604            "a field that fits is untouched"
605        );
606    }
607
608    /// The cut lands on a `char` boundary — a multi-byte goal must not be
609    /// sliced through the middle of one.
610    #[test]
611    fn the_cut_lands_on_a_char_boundary() {
612        // 3 bytes each, so the ceiling falls inside a character.
613        let text = "あ".repeat(OBSERVED_TEXT_MAX_BYTES);
614        let e = ObservedAssignment::new(
615            "R-a".to_string(),
616            "phase-a-op".to_string(),
617            Some(text),
618            None,
619            None,
620            None,
621            1,
622        );
623        let goal = e.goal.as_deref().expect("kept");
624        assert!(e.text_truncated);
625        assert!(
626            goal.trim_end_matches('…').chars().all(|c| c == 'あ'),
627            "the prefix is whole characters"
628        );
629    }
630
631    /// Nothing is flagged when nothing was cut — the flag is a report, not
632    /// a default.
633    #[test]
634    fn a_short_entry_is_not_flagged() {
635        let e = entry("R-a", "phase-a-op", 1);
636        assert!(!e.text_truncated);
637        assert_eq!(e.goal.as_deref(), Some("resolve issue #10"));
638    }
639}