Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/apps/desktop/src/api/config_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ pub struct GetRuntimeLoggingInfoRequest {}
#[derive(Debug, Deserialize, Default)]
pub struct ExportDiagnosticsBundleRequest {}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendFlowChatDiagnosticsRequest {
pub entries: Vec<Value>,
}

fn to_json_value<T: Serialize>(value: T, context: &str) -> Result<Value, String> {
serde_json::to_value(value).map_err(|e| format!("Failed to serialize {}: {}", context, e))
}
Expand Down Expand Up @@ -373,6 +379,18 @@ pub async fn export_diagnostics_bundle(
to_json_value(bundle_info, "diagnostics bundle info")
}

#[tauri::command]
pub async fn append_flow_chat_diagnostics(
_state: State<'_, AppState>,
request: AppendFlowChatDiagnosticsRequest,
) -> Result<usize, String> {
tauri::async_runtime::spawn_blocking(move || {
crate::logging::append_flow_chat_diagnostics(&request.entries)
})
.await
.map_err(|error| format!("Flow Chat diagnostics writer task failed: {}", error))?
}

#[tauri::command]
pub async fn get_agent_profile_configs(_state: State<'_, AppState>) -> Result<Value, String> {
let agent_profiles =
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"account_sync_settings",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"append_flow_chat_diagnostics",
RemoteWorkspacePolicy::LocalOnly,
),
(
"account_token_expired",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,7 @@ pub async fn run() {
get_global_config_health,
get_runtime_logging_info,
export_diagnostics_bundle,
append_flow_chat_diagnostics,
get_runtime_capabilities,
speech_list_models,
speech_download_model,
Expand Down
154 changes: 153 additions & 1 deletion src/apps/desktop/src/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
use bitfun_core::infrastructure::get_path_manager_arc;
use chrono::Local;
use serde::Serialize;
use serde_json::Value;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{
atomic::{AtomicU8, Ordering},
OnceLock,
Mutex, OnceLock,
};
use std::thread;
use tauri::{plugin::TauriPlugin, Runtime};
Expand All @@ -15,9 +18,15 @@ use tauri_plugin_log::{fern, RotationStrategy, Target, TargetKind, TimezoneStrat
const SESSION_DIR_PATTERN: &str = r"^\d{8}T\d{6}$";
const MAX_LOG_SESSIONS: usize = 10;
const FLASHGREP_LOG_TARGET_PREFIX: &str = "flashgrep";
const FLOW_CHAT_LOG_FILE_NAME: &str = "flowchat.log";
const FLOW_CHAT_LOG_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
const FLOW_CHAT_LOG_MAX_BATCH_ENTRIES: usize = 256;
const FLOW_CHAT_LOG_MAX_BATCH_BYTES: usize = 1024 * 1024;
const FLOW_CHAT_LOG_MAX_ENTRY_BYTES: usize = 32 * 1024;
static SESSION_LOG_DIR: OnceLock<PathBuf> = OnceLock::new();
// Default to Debug in early development for easier diagnostics
static CURRENT_LOG_LEVEL: AtomicU8 = AtomicU8::new(level_filter_to_u8(log::LevelFilter::Debug));
static FLOW_CHAT_DIAGNOSTICS_WRITE_LOCK: Mutex<()> = Mutex::new(());

fn get_thread_id() -> u64 {
let thread_id = thread::current().id();
Expand Down Expand Up @@ -152,6 +161,12 @@ pub fn session_log_dir() -> Option<PathBuf> {
SESSION_LOG_DIR.get().cloned()
}

pub fn flow_chat_log_path() -> PathBuf {
session_log_dir()
.unwrap_or_else(resolve_logs_root)
.join(FLOW_CHAT_LOG_FILE_NAME)
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeLoggingInfo {
Expand All @@ -161,6 +176,7 @@ pub struct RuntimeLoggingInfo {
pub ai_log_path: String,
pub flashgrep_log_path: String,
pub webview_log_path: String,
pub flow_chat_log_path: String,
pub previous_unexpected_exit: Option<crate::crash_diagnostics::UnexpectedExitInfo>,
}

Expand All @@ -181,10 +197,110 @@ pub fn get_runtime_logging_info() -> RuntimeLoggingInfo {
.join("webview.log")
.to_string_lossy()
.to_string(),
flow_chat_log_path: session_dir
.join(FLOW_CHAT_LOG_FILE_NAME)
.to_string_lossy()
.to_string(),
previous_unexpected_exit: crate::crash_diagnostics::previous_unexpected_exit(),
}
}

fn rotated_flow_chat_log_path(path: &std::path::Path, index: usize) -> PathBuf {
PathBuf::from(format!("{}.{}", path.to_string_lossy(), index))
}

fn rotate_flow_chat_log_if_needed(
path: &std::path::Path,
incoming_bytes: usize,
max_file_size: u64,
) -> Result<(), String> {
let current_size = fs::metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0);
if current_size + incoming_bytes as u64 <= max_file_size {
return Ok(());
}

let first_backup = rotated_flow_chat_log_path(path, 1);
let second_backup = rotated_flow_chat_log_path(path, 2);
if second_backup.exists() {
fs::remove_file(&second_backup)
.map_err(|error| format!("Failed to remove old Flow Chat log backup: {}", error))?;
}
if first_backup.exists() {
fs::rename(&first_backup, &second_backup)
.map_err(|error| format!("Failed to rotate Flow Chat log backup: {}", error))?;
}
if path.exists() {
fs::rename(path, &first_backup)
.map_err(|error| format!("Failed to rotate Flow Chat log: {}", error))?;
}
Ok(())
}

fn serialize_flow_chat_diagnostics(entries: &[Value]) -> Result<Vec<u8>, String> {
if entries.is_empty() {
return Ok(Vec::new());
}
if entries.len() > FLOW_CHAT_LOG_MAX_BATCH_ENTRIES {
return Err(format!(
"Flow Chat diagnostics batch exceeds {} entries",
FLOW_CHAT_LOG_MAX_BATCH_ENTRIES
));
}

let mut output = Vec::new();
for entry in entries {
if !entry.is_object() {
return Err("Flow Chat diagnostics entries must be JSON objects".to_string());
}
let serialized = serde_json::to_vec(entry)
.map_err(|error| format!("Failed to serialize Flow Chat diagnostic: {}", error))?;
if serialized.len() > FLOW_CHAT_LOG_MAX_ENTRY_BYTES {
return Err(format!(
"Flow Chat diagnostic entry exceeds {} bytes",
FLOW_CHAT_LOG_MAX_ENTRY_BYTES
));
}
if output.len() + serialized.len() + 1 > FLOW_CHAT_LOG_MAX_BATCH_BYTES {
return Err(format!(
"Flow Chat diagnostics batch exceeds {} bytes",
FLOW_CHAT_LOG_MAX_BATCH_BYTES
));
}
output.extend_from_slice(&serialized);
output.push(b'\n');
}
Ok(output)
}

pub fn append_flow_chat_diagnostics(entries: &[Value]) -> Result<usize, String> {
if entries.is_empty() {
return Ok(0);
}

let output = serialize_flow_chat_diagnostics(entries)?;
let _guard = FLOW_CHAT_DIAGNOSTICS_WRITE_LOCK
.lock()
.map_err(|_| "Flow Chat diagnostics writer lock is poisoned".to_string())?;
let path = flow_chat_log_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create Flow Chat log directory: {}", error))?;
}
rotate_flow_chat_log_if_needed(&path, output.len(), FLOW_CHAT_LOG_MAX_FILE_SIZE)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("Failed to open Flow Chat log: {}", error))?;
file.write_all(&output)
.map_err(|error| format!("Failed to write Flow Chat log: {}", error))?;
file.flush()
.map_err(|error| format!("Failed to flush Flow Chat log: {}", error))?;
Ok(entries.len())
}

fn is_flashgrep_target(target: &str) -> bool {
target.starts_with(FLASHGREP_LOG_TARGET_PREFIX)
}
Expand Down Expand Up @@ -431,3 +547,39 @@ pub fn spawn_log_cleanup_task() {
cleanup_old_log_sessions().await;
});
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn serializes_flow_chat_diagnostics_as_bounded_json_lines() {
let entries = vec![
serde_json::json!({"sequence": 1, "message": "first"}),
serde_json::json!({"sequence": 2, "message": "second"}),
];

let serialized = serialize_flow_chat_diagnostics(&entries).expect("serialize entries");
let text = String::from_utf8(serialized).expect("valid UTF-8");

assert_eq!(text.lines().count(), 2);
assert!(text.contains("\"sequence\":1"));
assert!(text.ends_with('\n'));
assert!(serialize_flow_chat_diagnostics(&[serde_json::json!("invalid")]).is_err());
}

#[test]
fn rotates_flow_chat_log_before_the_next_batch_exceeds_the_limit() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let path = temp_dir.path().join(FLOW_CHAT_LOG_FILE_NAME);
fs::write(&path, b"12345678").expect("write active log");

rotate_flow_chat_log_if_needed(&path, 4, 10).expect("rotate log");

assert!(!path.exists());
assert_eq!(
fs::read(rotated_flow_chat_log_path(&path, 1)).expect("read backup"),
b"12345678"
);
}
}
5 changes: 5 additions & 0 deletions src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ pub struct AppLoggingConfig {
/// Whether diagnostic logs may include sensitive troubleshooting payloads.
#[serde(default = "default_true")]
pub include_sensitive_diagnostics: bool,
/// Whether the local UI records detailed Flow Chat viewport diagnostics.
#[serde(default)]
pub flow_chat_diagnostics: bool,
/// Per-request AI model exchange tracing configuration for developer diagnostics.
#[serde(default)]
pub model_exchange_tracing: ModelExchangeTracingConfig,
Expand Down Expand Up @@ -1727,6 +1730,7 @@ impl Default for AppLoggingConfig {
// Set to Debug in early development for easier diagnostics
level: "debug".to_string(),
include_sensitive_diagnostics: true,
flow_chat_diagnostics: false,
model_exchange_tracing: ModelExchangeTracingConfig::default(),
}
}
Expand Down Expand Up @@ -2822,6 +2826,7 @@ mod tests {
.expect("logging config without sensitive preference should deserialize");

assert!(config.include_sensitive_diagnostics);
assert!(!config.flow_chat_diagnostics);
assert_eq!(
config.model_exchange_tracing.mode,
ModelExchangeTracingMode::Off
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ Important details:
- the real footer height is `MESSAGE_LIST_FOOTER_HEIGHT + totalBottomReservationPx`
- reservation space is not real content height
- reservations may define a `floorPx`
- only reservation space above the floor is consumable
- a floor prevents unrelated shrink reconciliation from dropping live scroll range
- collapse floors still drain from measured content growth or deliberate downward
user navigation; pin floors drain only through the sticky-pin settlement path
- all measurements that compare old vs new content height must use:

```ts
Expand Down Expand Up @@ -261,7 +263,13 @@ timer fallback for browsers that delay timers. While the intent is alive, the
grow branch of `measureHeightChange` protects the collapse reservation, but it
may still consume measured content growth from the sticky pin reservation.
Once the intent settles, residual collapse space is transferred to the settled
sticky pin in one state/DOM update and any deferred follow is replayed.
sticky pin in one state/DOM update when the pinned item still owns the viewport.
If a collapsing header owns the viewport, the footer is instead reduced
atomically to the minimum range that can retain the current `scrollTop`, then
that settled range is promoted to a protected collapse floor before the
semantic anchor is restored. This prevents a clear-and-reacquire frame without
retaining the full provisional estimate; later content growth can still drain
the protected range. Any deferred follow is then replayed.

## C. Follow-Output Mode (continuous tail)

Expand Down Expand Up @@ -347,8 +355,11 @@ If a future collapsible component shows the same "header drops" or "flash on col
- Sticky pin floors must shrink from measured content growth, not a transient
target-element position.
- A user gesture that exits pinned mode must release the semantic anchor and
clear or atomically transfer the pin reservation in the same operation; an
idle coordinator must never retain a live pin reservation.
atomically transfer the pin reservation to a protected collapse range in the
same operation; an idle coordinator must never retain a live pin reservation.
- Unsignaled shrink reconciliation must not reduce a protected collapse floor;
only measured growth, downward navigation, bottom arrival, or an explicit
reservation reset may consume it.
- Pre-collapse intent must capture the anchor before the component shrinks.
- Compensation must not be consumed too early during active layout transitions.
- Session changes and empty-list resets must clear compensation and anchor state.
Expand Down Expand Up @@ -388,6 +399,25 @@ If a future collapsible component shows the same "header drops" or "flash on col

## If You Need To Change This Logic

### Opt-in viewport diagnostics

Enable `app.logging.flow_chat_diagnostics` from the logging settings only while
reproducing a viewport stability issue. The frontend records bounded JSONL
batches to `flowchat.log` in the current session log directory. When disabled,
probe payloads are not evaluated and no timer, IPC request, or file is created.

The diagnostic schema groups events by hypothesis:

- `A`: user scroll intent, pin release, reservation transfer, and tail handoff
- `B`: semantic anchor capture, correction, release, or unexpected reacquisition
- `C`: content measurement, Footer compensation, and physical range changes
- `D`: Virtuoso scroll compensation and tail-follow ownership
- `E`: streaming tool-card collapse intent and anchor preservation

Do not add message content, tool arguments, file contents, or other sensitive
payloads to this channel. Keep all data producers lazy and guard hot-path probes
with `flowChatDiagnostics.isEnabled()` before allocating probe objects.

Use this checklist:

1. Verify the live tail stays expanded when a conversation ends with an action.
Expand Down
Loading