Skip to main content

agentmux_srv/backend/blockcontroller/shell/
file_ops.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Block-file append/persist helpers and global-transcript mirroring.
5
6use std::sync::Arc;
7
8use base64::Engine as _;
9
10use crate::backend::storage::filestore::{AppendPos, FileStore};
11use crate::backend::mps;
12
13/// Current unix time in ms, or 0 if the clock is before the epoch (never in
14/// practice; 0 reads as "unknown" on the consumer side, same as no stamp).
15fn unix_ms_now() -> i64 {
16    agentmux_common::time::now_ms()
17}
18
19/// Append one receive-time record to a zone's `output.tsidx` sidecar:
20/// NDJSON `{"off":<byte offset the batch starts at in output>,"ms":<unix ms>}`.
21/// Batch granularity is deliberate — one flush of one turn's output; sub-batch
22/// precision has no UI meaning
23/// (SPEC_AGENT_PANE_SESSION_SCOPED_SCROLLBACK_AND_AGENT_HISTORY_VIEW_2026_08_09.md §4.4).
24/// Fire-and-forget like every other write on this path: any failure logs at
25/// debug and the transcript append is unaffected. Only called for agent
26/// transcript streams (never PTY `term` data).
27fn append_tsidx_entry(fs: &Arc<FileStore>, zone: &str, batch_start: u64, now_ms: i64) {
28    use crate::backend::agent_session::TSIDX_FILE;
29    use crate::backend::storage::error::StoreError;
30
31    if let Ok(None) = fs.stat(zone, TSIDX_FILE) {
32        if let Err(e) = fs.make_file(
33            zone,
34            TSIDX_FILE,
35            std::collections::HashMap::new(),
36            crate::backend::storage::filestore::FileOpts::default(),
37        ) {
38            if !matches!(e, StoreError::AlreadyExists) {
39                tracing::debug!(zone = %zone, error = %e, "tsidx: make_file failed; skipping stamp");
40                return;
41            }
42        }
43    }
44    let line = format!("{{\"off\":{batch_start},\"ms\":{now_ms}}}\n");
45    if let Err(e) = fs.append_data(zone, TSIDX_FILE, line.as_bytes()) {
46        tracing::debug!(zone = %zone, error = %e, "tsidx: append failed");
47    }
48}
49
50/// Persist a user message the controller wrote to the agent's stdin, so
51/// `parseHistoryLines` can reconstruct its `user_message` node on reopen.
52///
53/// A transcript record like any other (Phase 5a-3c): written to the block's
54/// file and the global zone with positions, and published — so a pane reading
55/// the stream by line never sees a gap where this record sits — but marked
56/// `echo: "stdin"`, because the pane already shows the message (its
57/// `agent-message-accepted` node) and must not add a second one. Before 5a-3c
58/// this write published nothing at all.
59pub fn persist_user_line(
60    broker: Option<&mps::Broker>,
61    block_id: &str,
62    data: &[u8],
63    filestore: Option<&Arc<FileStore>>,
64    global_output_zone: Option<&str>,
65) {
66    let global = global_output_zone.and_then(|zone| {
67        crate::backend::agent_session::global_transcript_store().map(|gfs| (gfs, zone))
68    });
69    append_transcript(broker, block_id, data, filestore, global, global_output_zone.is_some(), Some("stdin"));
70}
71
72/// Append data to a block's terminal output file, publish a MPS event,
73/// and write-through to FileStore (if provided).
74///
75/// Port of Go's `HandleAppendBlockFile`.
76///
77/// The FileStore write is fire-and-forget: if it fails we emit a warning but
78/// never propagate the error back to the hot stdout-reader path.
79/// Outcome of the up-front `stat()` this function needs regardless (to know
80/// whether to lazily `make_file` before appending) — reused to compute the
81/// chunk's start offset for the broadcast event too, rather than stat-ing
82/// twice. `Error` preserves the original behavior of skipping write-through
83/// entirely (and omitting `offset` from the broadcast) on a stat failure.
84enum ExistingFileStat {
85    NeedsCreate,
86    Exists { size: u64 },
87    Error,
88}
89
90pub fn handle_append_block_file(
91    broker: &mps::Broker,
92    block_id: &str,
93    filename: &str,
94    data: &[u8],
95    filestore: Option<&Arc<FileStore>>,
96    global_output_zone: Option<&str>,
97) {
98    // Agent transcripts are written first and published with their positions
99    // (Phase 5a-3). Everything else — PTY `term` data — keeps publishing
100    // before the write, below.
101    if filename == crate::backend::agent_session::OUTPUT_FILE {
102        let global = global_output_zone.and_then(|zone| {
103            crate::backend::agent_session::global_transcript_store().map(|gfs| (gfs, zone))
104        });
105        append_transcript(Some(broker), block_id, data, filestore, global, global_output_zone.is_some(), None);
106        return;
107    }
108
109    let data64 = base64::engine::general_purpose::STANDARD.encode(data);
110
111    // Stat once, up front — needed both to decide whether make_file() is
112    // required below AND to compute this chunk's start offset (the file's
113    // size immediately before this append) for the broadcast event, so a
114    // client reconnecting mid-stream can reconcile a chunk arriving during
115    // its own reconnect fetch against what that fetch already covers.
116    // Before the write-through fix (§2.1 of the spec below), "term" reads
117    // always 404'd, so this race was latent — a reconnecting TermWrap had
118    // nothing from the fetch to double-count against. Now that reads
119    // return real content, a chunk landing in the subscribe-then-fetch-
120    // then-flush-held-data window could be written twice (once from the
121    // fetch's response, once from the live/held replay), corrupting
122    // ptyOffset for future reconnects.
123    // See SPEC_TERMINAL_SCROLLBACK_PERSISTENCE_2026_07_23.md §2.1 follow-up.
124    let existing_stat = match filestore {
125        None => None,
126        Some(fs) => Some(match fs.stat(block_id, filename) {
127            Ok(None) => ExistingFileStat::NeedsCreate,
128            Ok(Some(info)) => ExistingFileStat::Exists { size: info.size as u64 },
129            Err(e) => {
130                tracing::warn!(
131                    block_id = %block_id,
132                    filename = %filename,
133                    error = %e,
134                    "filestore stat failed; skipping write-through"
135                );
136                ExistingFileStat::Error
137            }
138        }),
139    };
140    let start_offset = match &existing_stat {
141        None | Some(ExistingFileStat::Error) => None,
142        Some(ExistingFileStat::NeedsCreate) => Some(0),
143        Some(ExistingFileStat::Exists { size }) => Some(*size),
144    };
145
146    let event_data = mps::WSFileEventData {
147        zoneid: block_id.to_string(),
148        filename: filename.to_string(),
149        fileop: mps::FILE_OP_APPEND.to_string(),
150        data64,
151        offset: start_offset,
152        pos: Vec::new(),
153        echo: None,
154    };
155
156    broker.publish_event::<crate::backend::mps_events::BlockFile>(&event_data, vec![format!("block:{block_id}")], 0);
157
158    // Write-through to FileStore for persistent history (Phase 1.3).
159    // Create the file lazily on first append; if the file already exists
160    // we skip make_file and go straight to append_data.
161    if let Some(fs) = filestore {
162        let needs_create = match existing_stat {
163            Some(ExistingFileStat::NeedsCreate) => true,
164            Some(ExistingFileStat::Exists { .. }) => false,
165            // Error already warned above; preserve the original
166            // skip-write-through-entirely behavior.
167            Some(ExistingFileStat::Error) | None => return,
168        };
169
170        if needs_create {
171            if let Err(e) = fs.make_file(
172                block_id,
173                filename,
174                std::collections::HashMap::new(),
175                crate::backend::storage::filestore::FileOpts::default(),
176            ) {
177                // AlreadyExists is benign (race between two appends); anything
178                // else is worth warning about.
179                use crate::backend::storage::error::StoreError;
180                if !matches!(e, StoreError::AlreadyExists) {
181                    tracing::warn!(
182                        block_id = %block_id,
183                        filename = %filename,
184                        error = %e,
185                        "filestore make_file failed; skipping write-through"
186                    );
187                    return;
188                }
189            }
190        }
191
192        match fs.append_data_at(block_id, filename, data) {
193            Ok(actual_start) => {
194                // Receive-time stamp for this batch (§4.4). Gated on
195                // `global_output_zone` — only agent transcript appends carry
196                // one; PTY `term` data and non-agent blocks never get a
197                // sidecar. Stamped at the offset the append itself reports
198                // (read under the store lock), not the pre-append stat —
199                // codex P2 on PR #2508: the stat can go stale when writers
200                // interleave, and a mis-keyed stamp gives replayed lines
201                // another batch's time. (The broadcast event's `offset`
202                // still uses the pre-append stat — pre-existing contract,
203                // reconcile-only consumer, unchanged here.)
204                if global_output_zone.is_some() {
205                    append_tsidx_entry(fs, block_id, actual_start.max(0) as u64, unix_ms_now());
206                }
207            }
208            Err(e) => {
209                tracing::warn!(
210                    block_id = %block_id,
211                    filename = %filename,
212                    error = %e,
213                    "filestore append_data failed"
214                );
215            }
216        }
217        // Note: output.idx is NOT updated incrementally here. It is a lazily-built,
218        // self-validating cache the read path brings up to date whenever output
219        // grows (see `extend_output_idx` in shell/indexing.rs, invoked from the
220        // blockfile:read_range handler in app_api/blockfile.rs). This avoids
221        // every incremental-index failure mode (desync on write failure,
222        // chunk-split lines, blank-line miscounting) at the cost of scanning the
223        // appended bytes on the next read.
224    }
225
226    // Mirror the agent's `output` stream into the GLOBAL transcript zone
227    // (`agent:<defId>:current`) so the conversation loads when this agent is
228    // opened from another build/channel. Fire-and-forget, exactly like the
229    // per-channel write above: a global-store hiccup must never stall the live
230    // pane. Callers pass `Some(zone)` only for the agent output stream, so we
231    // never mirror PTY `term` data or non-agent blocks.
232    if let Some(zone) = global_output_zone {
233        if let Some(gfs) = crate::backend::agent_session::global_transcript_store() {
234            mirror_append_to_global(gfs, zone, data);
235        }
236    }
237}
238
239/// Appends one NDJSON stdout line, plus the newline the frontend's line
240/// splitter needs, to the block's `output` file and `global_output_zone`.
241pub fn append_output_line(
242    broker: &mps::Broker,
243    block_id: &str,
244    line: &str,
245    filestore: Option<&Arc<FileStore>>,
246    global_output_zone: Option<&str>,
247) {
248    let line_with_newline = format!("{line}\n");
249    handle_append_block_file(
250        broker,
251        block_id,
252        crate::backend::agent_session::OUTPUT_FILE,
253        line_with_newline.as_bytes(),
254        filestore,
255        global_output_zone,
256    );
257}
258
259/// Append `data` to the global transcript zone's `output` file, creating it
260/// lazily on first write, and return where it landed. Mirrors the
261/// per-channel write-through in [`handle_append_block_file`]; all errors are
262/// logged and swallowed (`None`) so the hot stdout path is never blocked by
263/// the global store.
264pub(super) fn mirror_append_to_global(gfs: &Arc<FileStore>, zone: &str, data: &[u8]) -> Option<AppendPos> {
265    use crate::backend::agent_session::OUTPUT_FILE;
266    // Global zones are agent transcripts by construction — always stamp
267    // (§4.4), keyed at the offset this append actually landed at (codex P2 on
268    // PR #2508), in the same transaction as the append.
269    append_transcript_lines(gfs, zone, OUTPUT_FILE, data, true, "global transcripts")
270}
271
272/// `append_lines` on a transcript file, creating it on first write (or if
273/// another instance deleted it since). Whole non-blank lines only: a torn
274/// last line is closed, not continued (`filestore/counter.rs`). With `stamp`,
275/// the `output.tsidx` receive-time record is written in the same transaction
276/// (one commit, not two). Errors are logged and swallowed.
277fn append_transcript_lines(fs: &Arc<FileStore>, zone: &str, name: &str, data: &[u8], stamp: bool, what: &str) -> Option<AppendPos> {
278    use crate::backend::agent_session::TSIDX_FILE;
279    use crate::backend::storage::error::StoreError;
280    let append = || {
281        if stamp {
282            fs.append_lines_stamped(zone, name, data, TSIDX_FILE)
283        } else {
284            fs.append_lines(zone, name, data)
285        }
286    };
287    match append() {
288        Ok(pos) => return Some(pos),
289        Err(StoreError::NotFound) => {}
290        Err(e) => {
291            tracing::warn!(zone = %zone, error = %e, "{what}: append failed");
292            return None;
293        }
294    }
295    if let Err(e) = fs.make_file(
296        zone,
297        name,
298        std::collections::HashMap::new(),
299        crate::backend::storage::filestore::FileOpts::default(),
300    ) {
301        if !matches!(e, StoreError::AlreadyExists) {
302            tracing::warn!(zone = %zone, error = %e, "{what}: make_file failed; skipping write");
303            return None;
304        }
305    }
306    append()
307        .map_err(|e| tracing::warn!(zone = %zone, error = %e, "{what}: append failed"))
308        .ok()
309}
310
311/// The agent-transcript half of [`handle_append_block_file`] (Phase 5a-3,
312/// SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.3.7 item 5).
313///
314/// The block's `output` and the agent's global zone are written FIRST, and
315/// the event goes out after, carrying where the records landed in each stream
316/// (`pos`) and the exact byte offset in the block's file. The block's order
317/// lock is held from the first write to the publish, so one block's events
318/// leave in line order. Only transcripts come here: publishing after a
319/// database write would add that write to every terminal keystroke echo.
320///
321/// `stamp_tsidx` is whether this is an agent stream (it has a global zone,
322/// even if no global store is installed) — as before, only those get the
323/// receive-time sidecar.
324pub(super) fn append_transcript(
325    broker: Option<&mps::Broker>,
326    block_id: &str,
327    data: &[u8],
328    filestore: Option<&Arc<FileStore>>,
329    global: Option<(&Arc<FileStore>, &str)>,
330    stamp_tsidx: bool,
331    echo: Option<&str>,
332) {
333    use crate::backend::agent_session::OUTPUT_FILE;
334    // The records exactly as they are written (`append_lines` normalizes to
335    // complete, non-blank lines): the event carries these bytes, and its
336    // `offset` is where they landed, so a consumer can match the event to a
337    // byte range read back from the file (review of #3635). Nothing to write,
338    // nothing to announce.
339    let records = crate::backend::storage::filestore::normalized_records(data);
340    if records.is_empty() {
341        return;
342    }
343    let _order = transcript_order_lock(block_id);
344
345    let mut offset = None;
346    let mut pos = Vec::new();
347    if let Some(fs) = filestore {
348        if let Some(p) = append_transcript_lines(fs, block_id, OUTPUT_FILE, &records, stamp_tsidx, "filestore") {
349            // Past the `\n` that closed a torn last line, if one was written.
350            offset = Some(p.records_offset.max(0) as u64);
351            if let Some(c) = p.counted {
352                pos.push(mps::StreamPos { stream: format!("b:{block_id}"), gen: c.gen, line: c.first_line, lines: c.lines });
353            }
354        }
355    }
356    if let Some((gfs, zone)) = global {
357        if let Some(c) = mirror_append_to_global(gfs, zone, &records).and_then(|p| p.counted) {
358            pos.push(mps::StreamPos { stream: format!("g:{zone}"), gen: c.gen, line: c.first_line, lines: c.lines });
359        }
360    }
361
362    let event_data = mps::WSFileEventData {
363        zoneid: block_id.to_string(),
364        filename: OUTPUT_FILE.to_string(),
365        fileop: mps::FILE_OP_APPEND.to_string(),
366        data64: base64::engine::general_purpose::STANDARD.encode(&records),
367        offset,
368        pos,
369        echo: echo.map(str::to_string),
370    };
371    // No broker (a controller built without one): written, not announced.
372    if let Some(broker) = broker {
373        broker.publish_event::<crate::backend::mps_events::BlockFile>(&event_data, vec![format!("block:{block_id}")], 0);
374    }
375}
376
377/// Run `f` holding `block_id`'s transcript order lock — the lock every append
378/// holds from its first write to its publish (see [`append_transcript`]).
379/// A replace or delete of the block's transcript, and its announcement
380/// ([`publish_transcript_changed`]), run inside it, so no append's write and
381/// event can interleave with them and one block's events stay in order
382/// (review of #3636). `f` must not append to the same block: the lock isn't
383/// reentrant.
384pub fn with_transcript_order<T>(block_id: &str, f: impl FnOnce() -> T) -> T {
385    let _order = transcript_order_lock(block_id);
386    f()
387}
388
389/// Tell a block's panes that its transcript `output` was replaced or deleted
390/// as a whole (Phase 5a-3), so an open pane resyncs at once instead of on its
391/// next read. `fileop` is [`mps::FILE_OP_REPLACE`] — with the new generation
392/// and line count read from `filestore` — or [`mps::FILE_OP_DELETE`]. Call it,
393/// with the replace or delete itself, inside [`with_transcript_order`].
394pub fn publish_transcript_changed(broker: &mps::Broker, block_id: &str, fileop: &str, filestore: &FileStore) {
395    use crate::backend::agent_session::OUTPUT_FILE;
396    let pos = match filestore.line_state(block_id, OUTPUT_FILE) {
397        Ok(Some(state)) => state
398            .counted
399            .map(|c| vec![mps::StreamPos { stream: format!("b:{block_id}"), gen: c.gen, line: 0, lines: c.lines }])
400            .unwrap_or_default(),
401        _ => Vec::new(),
402    };
403    let event_data = mps::WSFileEventData {
404        zoneid: block_id.to_string(),
405        filename: OUTPUT_FILE.to_string(),
406        fileop: fileop.to_string(),
407        data64: String::new(),
408        offset: None,
409        pos,
410        echo: None,
411    };
412    broker.publish_event::<crate::backend::mps_events::BlockFile>(&event_data, vec![format!("block:{block_id}")], 0);
413}
414
415/// Serializes one block's transcript appends with their publish (see
416/// [`append_transcript`]). Striped rather than per block: bounded memory and
417/// nothing to clean up; two blocks that share a stripe only wait for each
418/// other's append.
419fn transcript_order_lock(block_id: &str) -> std::sync::MutexGuard<'static, ()> {
420    use std::hash::{Hash, Hasher};
421    const STRIPES: usize = 64;
422    static LOCKS: [std::sync::Mutex<()>; STRIPES] = [const { std::sync::Mutex::new(()) }; STRIPES];
423    let mut h = std::collections::hash_map::DefaultHasher::new();
424    block_id.hash(&mut h);
425    LOCKS[(h.finish() % STRIPES as u64) as usize]
426        .lock()
427        .unwrap_or_else(|poisoned| poisoned.into_inner())
428}
429
430/// Resolve a block's GLOBAL transcript zone (`agent:<defId>:current`) from its
431/// `agentId` meta, looking the block up in `mstore`. Returns `None` for
432/// non-agent blocks, when there's no store, or when the block can't be loaded —
433/// the caller then passes `None` and no global mirror happens. Shared by the
434/// subprocess / persistent / acp agent controllers.
435pub(crate) fn resolve_global_output_zone(
436    mstore: &Option<Arc<crate::backend::storage::store::Store>>,
437    block_id: &str,
438) -> Option<String> {
439    let store = mstore.as_ref()?;
440    let block = store
441        .must_get::<crate::backend::obj::Block>(block_id)
442        .ok()?;
443    crate::backend::agent_session::agent_zone_for_block_meta(&block.meta)
444}
445
446/// Truncate a block's terminal output file and publish a MPS event.
447/// Port of Go's `HandleTruncateBlockFile`.
448#[allow(dead_code)]
449pub fn handle_truncate_block_file(broker: &mps::Broker, block_id: &str, filename: &str) {
450    let event_data = mps::WSFileEventData {
451        zoneid: block_id.to_string(),
452        filename: filename.to_string(),
453        fileop: mps::FILE_OP_TRUNCATE.to_string(),
454        data64: String::new(),
455        offset: None,
456        pos: Vec::new(),
457        echo: None,
458    };
459
460    broker.publish_event::<crate::backend::mps_events::BlockFile>(&event_data, vec![format!("block:{block_id}")], 0);
461}