Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ async-tungstenite = { version = "0.27", default-features = false, features = ["t
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
sha2 = "0.10"

reqwest = { version = "0.12", features = ["json", "stream"] }

Expand Down
1 change: 1 addition & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ in [Website deployment](docs/website-deployment.md).
| [Context window management](docs/context-window-management.md) | Agent turns, tool-result bounds, sawtooth compaction, prompt caching, and artifact evidence retention. |
| [CLI telemetry schema](docs/telemetry-schema.md) | Telemetry schema, privacy, and configuration contract for the CLI daemon. |
| [Telemetry runbook](docs/development/telemetry-runbook.md) | Maintainer runbook for operating CLI telemetry. |
| [Model-visible evidence archive](docs/model-visible-evidence.md) | Lossless ToolResult archive, Axiom proxy setup, privacy gates, and recovery. |
| [Release flow](docs/release-flow.md) | GitHub Release workflow, platform build graph, assets, and installer smoke tests. |
| [Website deployment](docs/website-deployment.md) | Vercel deployment runbook for `socai.io`. |
| [Website launch QA](docs/website-launch-qa.md) | Launch checklist used for the `socai.io` rollout. |
Expand Down
26 changes: 15 additions & 11 deletions app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,10 +1134,14 @@ pub async fn agent_task_delete(
// durable staging before their source run directory disappears.
let telemetry = telemetry.inner().clone();
tauri::async_runtime::spawn(async move {
if matches!(terminal_status.as_str(), "cancelled" | "interrupted") {
if let Some(run_dir) = trace_run_dir.as_deref() {
wait_for_trace_staging(run_dir).await;
upload_terminal_run_trace(run_dir, &terminal_status, &telemetry).await;
if let Some(run_dir) = trace_run_dir.as_deref() {
wait_for_observability_staging(run_dir).await;
if !upload_terminal_run_trace(run_dir, &terminal_status, &telemetry).await {
eprintln!(
"preserving deleted task artifacts at {} because observability staging failed",
run_dir.display()
);
return;
}
}
let _ = tokio::task::spawn_blocking(move || {
Expand Down Expand Up @@ -1331,7 +1335,7 @@ async fn run_agent_task_background(
if let Some(run_dir) = snapshot.run_dir.as_deref() {
let _ = mark_agent_run_status(run_dir, "failed", Some(&error));
let _ = mark_run_trace_status(run_dir, "failed");
telemetry.upload_run_trace(run_dir);
telemetry.upload_run_trace(run_dir).await;
}
record_desktop_session(&snapshot, &format!("[task failed: {error}]"), "failed");
telemetry.capture(
Expand Down Expand Up @@ -1367,7 +1371,7 @@ pub(crate) fn record_interrupted_run(snapshot: &AgentTaskSnapshot, message: &str
}

/// Wait for an aborted agent future's trace drop guard, patch the precise
/// terminal state, then stage the trace durably before the task can be deleted.
/// terminal state, then stage trace and evidence durably before deletion.
/// AbortHandle::abort() only schedules cancellation; without this wait the
/// telemetry worker can race `trace.json` creation and silently miss the run.
pub(crate) async fn upload_terminal_run_trace(
Expand All @@ -1379,14 +1383,14 @@ pub(crate) async fn upload_terminal_run_trace(
const READY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);

let run_dir = run_dir.as_ref();
let staged_marker = run_dir.join(".trace-staged");
let staged_marker = run_dir.join(".observability-staged");
if staged_marker.is_file() {
return true;
}
for attempt in 0..READY_RETRIES {
match mark_run_trace_status(run_dir, status) {
Ok(()) => {
if telemetry.upload_run_trace(run_dir) {
if telemetry.upload_run_trace(run_dir).await {
let _ = std::fs::write(staged_marker, b"");
return true;
}
Expand All @@ -1403,11 +1407,11 @@ pub(crate) async fn upload_terminal_run_trace(
false
}

async fn wait_for_trace_staging(run_dir: &std::path::Path) {
async fn wait_for_observability_staging(run_dir: &std::path::Path) {
const WAIT_RETRIES: usize = 50;
const WAIT_DELAY: std::time::Duration = std::time::Duration::from_millis(25);

let marker = run_dir.join(".trace-staged");
let marker = run_dir.join(".observability-staged");
for attempt in 0..WAIT_RETRIES {
if marker.is_file() {
return;
Expand Down Expand Up @@ -1561,7 +1565,7 @@ async fn run_agent_task_on_shared_page(
// and uploads the trace — instead of reporting a completed task.
anyhow::bail!(error);
}
telemetry.upload_run_trace(&outcome.run_dir);
telemetry.upload_run_trace(&outcome.run_dir).await;

let usage = outcome.usage;
let estimated_cost = usage.cost.as_ref().map(|cost| cost.total);
Expand Down
37 changes: 33 additions & 4 deletions app/src-tauri/src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,21 @@ impl DesktopTelemetry {

/// Upload a run's `trace.json` to the traces proxy. No-op when telemetry
/// is off or the file is missing.
pub(crate) fn upload_run_trace(&self, run_dir: impl AsRef<Path>) -> bool {
if let Some(telemetry) = &self.0 {
return telemetry.upload_run_trace(run_dir.as_ref());
pub(crate) async fn upload_run_trace(&self, run_dir: impl AsRef<Path>) -> bool {
let run_dir = run_dir.as_ref().to_path_buf();
let staged = match self.0.clone() {
Some(telemetry) => tokio::task::spawn_blocking({
let run_dir = run_dir.clone();
move || telemetry.upload_run_trace(&run_dir)
})
.await
.unwrap_or(false),
None => true,
};
if staged {
let _ = std::fs::write(run_dir.join(".observability-staged"), b"");
}
false
staged
}
}

Expand All @@ -63,3 +73,22 @@ pub(crate) fn duration_ms(started_at: Option<u64>, finished_at: Option<u64>) ->
_ => None,
}
}

#[cfg(test)]
mod observability_staging_tests {
use super::DesktopTelemetry;

#[tokio::test]
async fn telemetry_opt_out_still_allows_task_artifact_deletion() {
let run_dir = std::env::temp_dir().join(format!(
"socai-observability-opt-out-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&run_dir);
std::fs::create_dir_all(&run_dir).expect("create run directory");
let telemetry = DesktopTelemetry(None);
assert!(telemetry.upload_run_trace(&run_dir).await);
assert!(run_dir.join(".observability-staged").is_file());
std::fs::remove_dir_all(run_dir).expect("remove run directory");
}
}
1 change: 1 addition & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ anyhow.workspace = true
tracing.workspace = true
reqwest.workspace = true
base64.workspace = true
sha2.workspace = true
dirs = "5"
libc.workspace = true
uuid = { version = "1", features = ["v4"] }
Expand Down
68 changes: 65 additions & 3 deletions core/src/agent/run_logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,15 @@ fn safe_component(value: &str, fallback: &str) -> String {
}
}

fn write_json_atomic(path: &Path, value: &Value) -> std::io::Result<()> {
pub(crate) fn write_json_atomic(path: &Path, value: &Value) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
write_bytes_atomic(path, &bytes)
}

pub(crate) fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
let name = path
.file_name()
.and_then(|value| value.to_str())
Expand All @@ -65,6 +69,16 @@ fn write_json_atomic(path: &Path, value: &Value) -> std::io::Result<()> {
Ok(())
}

fn request_wire_format(provider: &str, payload: &Value) -> &'static str {
if payload.get("input").and_then(Value::as_array).is_some() {
"openai_responses"
} else if provider.eq_ignore_ascii_case("anthropic") {
"anthropic_messages"
} else {
"openai_chat"
}
}

fn env_runs_root(value: Option<OsString>) -> Option<PathBuf> {
let value = value?;
let value = value.to_string_lossy().trim().to_string();
Expand Down Expand Up @@ -130,6 +144,7 @@ pub struct AgentRunRecorder {
run_dir: PathBuf,
manifest_path: PathBuf,
manifest: Mutex<Value>,
provider: String,
started: Instant,
finalized: AtomicBool,
}
Expand Down Expand Up @@ -162,6 +177,7 @@ impl AgentRunRecorder {
run_dir,
manifest_path,
manifest: Mutex::new(manifest),
provider: provider.to_string(),
started: Instant::now(),
finalized: AtomicBool::new(false),
})
Expand All @@ -171,6 +187,16 @@ impl AgentRunRecorder {
write_json_atomic(
&self.run_dir.join(format!("llm/{step:03}.request.json")),
payload,
)?;
write_json_atomic(
&self
.run_dir
.join(format!("llm/{step:03}.request.meta.json")),
&json!({
"schema_version": 1,
"wire_format": request_wire_format(&self.provider, payload),
"status": "prepared",
}),
)
}

Expand All @@ -187,6 +213,7 @@ impl AgentRunRecorder {
&self.run_dir.join(format!("llm/{step:03}.response.json")),
&value,
)?;
self.update_llm_request_status(step, "accepted")?;
// Keep running usage/step totals in run.json so the desktop app can
// show them live mid-run; `finish` overwrites with the final figures.
{
Expand Down Expand Up @@ -214,7 +241,20 @@ impl AgentRunRecorder {
"completed_at": timestamp(),
"error": error,
}),
)
)?;
self.update_llm_request_status(step, "failed")
}

fn update_llm_request_status(&self, step: u32, status: &str) -> std::io::Result<()> {
let path = self
.run_dir
.join(format!("llm/{step:03}.request.meta.json"));
let mut metadata = std::fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.unwrap_or_else(|| json!({ "schema_version": 1 }));
metadata["status"] = json!(status);
write_json_atomic(&path, &metadata)
}

pub fn start_tool_call(
Expand Down Expand Up @@ -389,6 +429,28 @@ impl Drop for ToolCallRecorder {
}
}

#[cfg(test)]
mod request_metadata_tests {
use super::request_wire_format;
use serde_json::json;

#[test]
fn records_provider_wire_format_next_to_the_exact_request() {
assert_eq!(
request_wire_format("openai", &json!({ "input": [] })),
"openai_responses"
);
assert_eq!(
request_wire_format("anthropic", &json!({ "messages": [] })),
"anthropic_messages"
);
assert_eq!(
request_wire_format("deepseek", &json!({ "messages": [] })),
"openai_chat"
);
}
}

/// Best-effort terminal update when an entrypoint cancels the agent future.
pub fn mark_agent_run_status(
run_dir: impl AsRef<Path>,
Expand Down
Loading