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}