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}