diff --git a/crates/agentos-actor-plugin/src/actions/contract_surface.rs b/crates/agentos-actor-plugin/src/actions/contract_surface.rs index 212b08863c..6c0e68921e 100644 --- a/crates/agentos-actor-plugin/src/actions/contract_surface.rs +++ b/crates/agentos-actor-plugin/src/actions/contract_surface.rs @@ -250,10 +250,45 @@ pub const ACTION_CONTRACTS: &[ActionContract] = &[ reply_shape: ReplyShape::Array, ts_signature: "listSoftware: (c: Ctx) => Promise;", }, + // Observe-only actions (`actions::OBSERVE_ONLY`): dispatched without + // booting a sleeping VM. See `dispatch_observe` in actions/mod.rs. + ActionContract { + name: "getRuntimeHealth", + reply_shape: ReplyShape::Object(&[ + "agentExits", + "booted", + "sessions", + "sidecar", + "stderrTail", + "warnings", + ]), + ts_signature: "getRuntimeHealth: (c: Ctx) => Promise;", + }, + ActionContract { + name: "listSessions", + reply_shape: ReplyShape::Array, + ts_signature: "listSessions: (c: Ctx) => Promise;", + }, + ActionContract { + name: "cancelPrompt", + reply_shape: ReplyShape::Unit, + ts_signature: "cancelPrompt: (c: Ctx, sessionId: string) => Promise;", + }, + ActionContract { + name: "listPendingPermissions", + reply_shape: ReplyShape::Array, + ts_signature: + "listPendingPermissions: (c: Ctx) => Promise;", + }, ]; #[allow(dead_code)] pub const EVENT_CONTRACTS: &[EventContract] = &[ + EventContract { + name: "fsChanged", + payload_shape: ReplyShape::Object(&["dirs", "overflow"]), + ts_signature: "fsChanged: FsChangedPayload;", + }, EventContract { name: "sessionEvent", payload_shape: ReplyShape::Object(&["event", "sessionId"]), @@ -264,6 +299,11 @@ pub const EVENT_CONTRACTS: &[EventContract] = &[ payload_shape: ReplyShape::Object(&["request", "sessionId"]), ts_signature: "permissionRequest: PermissionRequestPayload;", }, + EventContract { + name: "permissionResolved", + payload_shape: ReplyShape::Object(&["permissionId", "reply", "sessionId"]), + ts_signature: "permissionResolved: PermissionResolvedPayload;", + }, EventContract { name: "agentCrashed", payload_shape: ReplyShape::Object(&["event", "sessionId"]), @@ -511,6 +551,61 @@ const DTO_INTERFACES: &[TsInterface] = &[ field("commands", "string[]"), ], }, + TsInterface { + name: "LiveSessionInfo", + fields: &[field("sessionId", "string"), field("agentType", "string")], + }, + TsInterface { + name: "PendingPermissionInfo", + fields: &[ + field("sessionId", "string"), + field("permissionId", "string"), + optional_field("description", "string"), + field("params", "Record"), + field("requestedAt", "number"), + ], + }, + TsInterface { + name: "RuntimeLimitWarning", + fields: &[ + field("ts", "number"), + field("limit", "string"), + field("category", "string"), + field("observed", "number"), + field("capacity", "number"), + field("fillPercent", "number"), + ], + }, + TsInterface { + name: "RuntimeAgentExit", + fields: &[ + field("ts", "number"), + field("sessionId", "string"), + field("agentType", "string"), + field("exitCode", "number | null"), + field("restart", "string"), + field("restartCount", "number"), + ], + }, + TsInterface { + name: "RuntimeStderrLine", + fields: &[field("ts", "number"), field("line", "string")], + }, + TsInterface { + name: "RuntimeSidecarInfo", + fields: &[field("state", "string"), field("activeVmCount", "number")], + }, + TsInterface { + name: "RuntimeHealth", + fields: &[ + field("booted", "boolean"), + field("sessions", "number | null"), + field("sidecar", "RuntimeSidecarInfo | null"), + field("warnings", "RuntimeLimitWarning[]"), + field("agentExits", "RuntimeAgentExit[]"), + field("stderrTail", "RuntimeStderrLine[]"), + ], + }, ]; const fn field(name: &'static str, ty: &'static str) -> TsField { diff --git a/crates/agentos-actor-plugin/src/actions/filesystem.rs b/crates/agentos-actor-plugin/src/actions/filesystem.rs index df9a1d62d5..c121e8c26d 100644 --- a/crates/agentos-actor-plugin/src/actions/filesystem.rs +++ b/crates/agentos-actor-plugin/src/actions/filesystem.rs @@ -264,3 +264,45 @@ impl From for BatchReadResultDto { } } } + +/// `fsChanged` broadcast payload: one coalesced guest filesystem change +/// window from the runtime's `filesystem.changed` events. +#[derive(Serialize)] +struct FsChangedEvent { + dirs: Vec, + overflow: bool, +} + +pub(crate) fn encode_fs_changed_event(dirs: Vec, overflow: bool) -> Result> { + super::encode_event_arg(&FsChangedEvent { dirs, overflow }) +} + +/// One `fsChanged` pump per VM lifetime, spawned on fresh boot next to the +/// health pumps: fans the client's coalesced `on_fs_changed` stream out to +/// actor clients as `fsChanged` broadcasts (the inspector Filesystem tab's +/// realtime invalidation signal). Tracked in [`Vars::fs_change_task`] so VM +/// teardown aborts it; a lagged subscriber already degrades to +/// `overflow: true` inside the client stream, so nothing is lost silently. +pub(crate) fn spawn_fs_change_pump(host: &crate::host_ctx::HostCtx, vm: &AgentOs, vars: &mut super::Vars) { + use futures::StreamExt; + + let (mut stream, subscription) = vm.on_fs_changed(); + let host = host.clone(); + vars.fs_change_task = Some(tokio::spawn(async move { + // RAII guard: dropping the stream on abort is the unsubscribe. + let _subscription = subscription; + while let Some(change) = stream.next().await { + match encode_fs_changed_event(change.dirs, change.overflow) { + Ok(bytes) => { + let status = host.broadcast(b"fsChanged".to_vec(), bytes); + if status != rivet_actor_plugin_abi::AbiStatus::Ok { + tracing::warn!(?status, "fsChanged broadcast failed"); + } + } + Err(error) => { + tracing::warn!(?error, "failed to encode fsChanged broadcast"); + } + } + } + })); +} diff --git a/crates/agentos-actor-plugin/src/actions/health.rs b/crates/agentos-actor-plugin/src/actions/health.rs new file mode 100644 index 0000000000..9c798bb6a5 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/health.rs @@ -0,0 +1,353 @@ +//! Runtime health for the inspector's observe-only actions (`getRuntimeHealth` +//! / `listSessions`): bounded post-mortem buffers of limit warnings and agent +//! exits plus non-waking snapshots of VM liveness. +//! +//! [`HealthBuffers`] is owned by `actor_worker` NEXT TO the `vm` slot — not +//! inside [`Vars`] — because the buffers must survive VM sleep: the point is +//! reading warnings and crash exits post-mortem while `booted == false`. The +//! pump tasks feeding them are per-VM-lifetime and are tracked/aborted through +//! [`Vars::health_tasks`] like the other pumps (the buffers keep their +//! contents; only the feeds stop). + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agentos_client::{AgentExitEvent, AgentOs, LimitWarning, SessionInfo}; +use futures::StreamExt; +use serde::Serialize; + +use super::Vars; +use crate::host_ctx::HostCtx; + +/// Bounded buffer caps (oldest entries dropped at cap), mirroring the +/// host-shim reference implementation in `rivet-opencode-example/server.ts`. +pub const LIMIT_WARNINGS_CAP: usize = 50; +pub const AGENT_EXITS_CAP: usize = 50; + +/// One buffered `limit_warning` structured event (`RuntimeHealth.warnings`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeLimitWarningDto { + /// Epoch milliseconds when the warning was observed host-side. + pub ts: f64, + pub limit: String, + pub category: String, + pub observed: f64, + pub capacity: f64, + pub fill_percent: f64, +} + +/// One buffered unexpected adapter exit (`RuntimeHealth.agentExits`), keyed by +/// the client-facing external session id. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeAgentExitDto { + /// Epoch milliseconds when the exit was observed host-side. + pub ts: f64, + pub session_id: String, + pub agent_type: String, + pub exit_code: Option, + pub restart: String, + pub restart_count: u32, +} + +/// One buffered adapter stderr line (`RuntimeHealth.stderrTail`). Always empty +/// at runtime today — see [`runtime_health`] — but kept in the contract so the +/// inspector's `RuntimeHealth` shape stays stable when a stderr feed lands. +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeStderrLineDto { + pub ts: f64, + pub line: String, +} + +/// `RuntimeHealth.sidecar`: the non-waking client-side sidecar descriptor +/// subset the status strip renders. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeSidecarInfoDto { + pub state: String, + pub active_vm_count: u32, +} + +/// Reply of `getRuntimeHealth` (TS `RuntimeHealth` in the inspector tabs). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeHealthDto { + pub booted: bool, + /// Live session count; `null` while the VM is asleep. + pub sessions: Option, + /// Sidecar descriptor; `null` while the VM is asleep (the client-side + /// handle is dropped with the VM, so there is no non-waking source). + pub sidecar: Option, + pub warnings: Vec, + pub agent_exits: Vec, + pub stderr_tail: Vec, +} + +/// One row of `listSessions`: a live (loaded-in-VM) session under its +/// client-facing external id. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveSessionInfoDto { + pub session_id: String, + pub agent_type: String, +} + +/// Shared, bounded post-mortem health buffers. Cloning shares the underlying +/// storage (`Arc`), so pump tasks write into the same buffers `actor_worker` +/// snapshots from. +#[derive(Clone, Default)] +pub struct HealthBuffers { + inner: Arc>, +} + +#[derive(Default)] +struct HealthBuffersInner { + warnings: VecDeque, + agent_exits: VecDeque, +} + +fn now_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +fn push_capped(buf: &mut VecDeque, item: T, cap: usize) { + buf.push_back(item); + while buf.len() > cap { + buf.pop_front(); + } +} + +impl HealthBuffers { + /// Lock the buffers, recovering from poisoning: the guarded sections are + /// pure pushes/clones, so a poisoned lock means a panic elsewhere at + /// worst — health reporting must not cascade it into more panics. + fn lock(&self) -> std::sync::MutexGuard<'_, HealthBuffersInner> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Record a runtime limit warning (oldest dropped past + /// [`LIMIT_WARNINGS_CAP`]). + pub fn push_limit_warning(&self, warning: &LimitWarning) { + let dto = RuntimeLimitWarningDto { + ts: now_ms(), + limit: warning.limit.clone(), + category: warning.category.clone(), + observed: warning.observed, + capacity: warning.capacity, + fill_percent: warning.fill_percent, + }; + push_capped(&mut self.lock().warnings, dto, LIMIT_WARNINGS_CAP); + } + + /// Record an unexpected adapter exit under the client-facing external + /// session id (oldest dropped past [`AGENT_EXITS_CAP`]). + pub fn push_agent_exit(&self, external_session_id: &str, event: &AgentExitEvent) { + let dto = RuntimeAgentExitDto { + ts: now_ms(), + session_id: external_session_id.to_owned(), + agent_type: event.agent_type.clone(), + exit_code: event.exit_code, + restart: event.restart.clone(), + restart_count: event.restart_count, + }; + push_capped(&mut self.lock().agent_exits, dto, AGENT_EXITS_CAP); + } + + fn snapshot(&self) -> (Vec, Vec) { + let inner = self.lock(); + ( + inner.warnings.iter().cloned().collect(), + inner.agent_exits.iter().cloned().collect(), + ) + } +} + +/// Snapshot the current runtime health WITHOUT waking the VM: liveness fields +/// come from the optional live handle (all client-side state — `list_sessions` +/// and `sidecar().describe()` never round-trip to the sidecar), buffers from +/// the post-mortem store. +pub(crate) fn runtime_health(vm: Option<&AgentOs>, buffers: &HealthBuffers) -> RuntimeHealthDto { + let (sessions, sidecar) = match vm { + Some(vm) => { + let description = vm.sidecar().describe(); + ( + Some(vm.list_sessions().len() as u64), + Some(RuntimeSidecarInfoDto { + state: description.state.as_str().to_owned(), + active_vm_count: description.active_vm_count, + }), + ) + } + None => (None, None), + }; + let (warnings, agent_exits) = buffers.snapshot(); + RuntimeHealthDto { + booted: vm.is_some(), + sessions, + sidecar, + warnings, + agent_exits, + // The Rust client has no per-VM adapter stderr stream (adapter stderr + // is written straight to host stderr in `deliver_acp_ext_event`; the + // TS `onAgentStderr` equivalent is a create-time config hook), so the + // tail is always empty until the client grows a subscription. + stderr_tail: Vec::new(), + } +} + +/// The live (loaded-in-VM) sessions keyed by EXTERNAL session id, so rows match +/// `listPersistedSessions` records. `[]` while the VM is asleep. +pub(crate) fn list_live_sessions(vm: Option<&AgentOs>, vars: &Vars) -> Vec { + match vm { + Some(vm) => external_session_infos(vm.list_sessions(), vars), + None => Vec::new(), + } +} + +/// Map the client's live-session infos (keyed by LIVE ACP session id) back to +/// client-facing external ids by inverting `Vars::live_sessions`; a live id +/// with no remap IS the external id (native / not-yet-resumed case). +fn external_session_infos(live: Vec, vars: &Vars) -> Vec { + let external_by_live: std::collections::HashMap<&str, &str> = vars + .live_sessions + .iter() + .map(|(external, live)| (live.as_str(), external.as_str())) + .collect(); + live.into_iter() + .map(|info| LiveSessionInfoDto { + session_id: external_by_live + .get(info.session_id.as_str()) + .map(|external| (*external).to_owned()) + .unwrap_or(info.session_id), + agent_type: info.agent_type, + }) + .collect() +} + +/// Start the per-VM-lifetime health pumps after a fresh VM boot. Currently one +/// pump: the `limit_warning` subscription. Agent exits are teed into the +/// buffers by the existing per-session exit-capture pump +/// (`session::spawn_exit_capture`) rather than double-subscribing here, and +/// there is no stderr pump (see [`runtime_health`]). Tracked in +/// [`Vars::health_tasks`] so VM teardown aborts it; the buffers survive. +pub(crate) fn spawn_health_pumps( + host: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + buffers: &HealthBuffers, +) { + let (mut stream, subscription) = vm.on_limit_warning(); + let host = host.clone(); + let buffers = buffers.clone(); + vars.health_tasks.push(tokio::spawn(async move { + // Keep the RAII guard alive for the pump's lifetime; dropping the + // stream (on abort / channel close) is the unsubscribe. + let _subscription = subscription; + while let Some(warning) = stream.next().await { + // Host-visible per repo policy: near-capacity warnings must reach + // the host log, not just the buffered inspector snapshot. + host.log_warn(&format!( + "agent-os limit warning: {} {}/{} ({}%)", + warning.limit, warning.observed, warning.capacity, warning.fill_percent + )); + buffers.push_limit_warning(&warning); + } + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn warning(limit: &str) -> LimitWarning { + LimitWarning { + limit: limit.to_owned(), + category: "queue".to_owned(), + observed: 80.0, + capacity: 100.0, + fill_percent: 80.0, + } + } + + fn exit_event(session_id: &str) -> AgentExitEvent { + AgentExitEvent { + session_id: session_id.to_owned(), + agent_type: "pi".to_owned(), + process_id: "proc-1".to_owned(), + exit_code: Some(1), + restart: "restarted".to_owned(), + restart_count: 1, + max_restarts: 3, + } + } + + #[test] + fn buffers_drop_oldest_at_cap() { + let buffers = HealthBuffers::default(); + for i in 0..(LIMIT_WARNINGS_CAP + 5) { + buffers.push_limit_warning(&warning(&format!("limit-{i}"))); + } + for i in 0..(AGENT_EXITS_CAP + 5) { + buffers.push_agent_exit(&format!("session-{i}"), &exit_event("live-1")); + } + let (warnings, exits) = buffers.snapshot(); + assert_eq!(warnings.len(), LIMIT_WARNINGS_CAP); + assert_eq!(warnings[0].limit, "limit-5", "oldest warnings dropped"); + assert_eq!(exits.len(), AGENT_EXITS_CAP); + assert_eq!(exits[0].session_id, "session-5", "oldest exits dropped"); + } + + #[test] + fn agent_exit_rows_use_the_external_session_id() { + let buffers = HealthBuffers::default(); + buffers.push_agent_exit("external-1", &exit_event("live-9")); + let (_, exits) = buffers.snapshot(); + assert_eq!(exits[0].session_id, "external-1"); + } + + #[test] + fn runtime_health_reports_unbooted_vm_with_surviving_buffers() { + let buffers = HealthBuffers::default(); + buffers.push_limit_warning(&warning("vm_open_fds")); + buffers.push_agent_exit("session-1", &exit_event("session-1")); + + let health = runtime_health(None, &buffers); + + assert!(!health.booted); + assert_eq!(health.sessions, None); + assert!(health.sidecar.is_none()); + // The post-mortem point: buffered telemetry stays readable unbooted. + assert_eq!(health.warnings.len(), 1); + assert_eq!(health.agent_exits.len(), 1); + assert!(health.stderr_tail.is_empty()); + } + + #[test] + fn external_session_infos_invert_the_live_remap() { + let mut vars = Vars::default(); + vars.live_sessions + .insert("external-1".to_owned(), "live-1".to_owned()); + let live = vec![ + SessionInfo { + session_id: "live-1".to_owned(), + agent_type: "pi".to_owned(), + }, + SessionInfo { + session_id: "native-2".to_owned(), + agent_type: "default".to_owned(), + }, + ]; + + let rows = external_session_infos(live, &vars); + + assert_eq!(rows[0].session_id, "external-1", "remapped to external id"); + assert_eq!(rows[1].session_id, "native-2", "native ids pass through"); + } +} diff --git a/crates/agentos-actor-plugin/src/actions/mod.rs b/crates/agentos-actor-plugin/src/actions/mod.rs index 8b4f9e533b..2a65f703fd 100644 --- a/crates/agentos-actor-plugin/src/actions/mod.rs +++ b/crates/agentos-actor-plugin/src/actions/mod.rs @@ -13,7 +13,9 @@ mod contract_surface; pub(crate) mod cron; pub(crate) mod filesystem; +pub(crate) mod health; pub(crate) mod network; +pub(crate) mod permissions; pub(crate) mod preview; pub(crate) mod process; pub(crate) mod session; @@ -45,12 +47,29 @@ pub struct Vars { pub permission_tasks: HashMap>, /// Shell data/stderr/exit broadcast pump tasks (one triple per `openShell`). /// The pumps end on their own when the shell exits (stream close); this - /// list exists so VM teardown aborts any still-live pumps. Bounded by the - /// client's shell registries, not here. + /// list exists so VM teardown aborts any still-live pumps. Bounded via + /// [`Vars::track_shell_task`], which prunes finished handles on every push + /// so a long-lived VM's open/close shell cycles cannot accumulate them. pub shell_tasks: Vec>, /// One cron event pump per VM lifetime. It fans `AgentOs::cron_events()` to /// actor clients as `cronEvent` broadcasts. pub cron_task: Option>, + /// Health pump tasks (one `limit_warning` subscription per VM lifetime, + /// spawned by `health::spawn_health_pumps` on fresh boot). Tracked here so + /// VM teardown aborts the feeds; the [`health::HealthBuffers`] they write + /// into are owned by `actor_worker` and deliberately SURVIVE teardown. + pub health_tasks: Vec>, + /// One `fsChanged` broadcast pump per VM lifetime (spawned by + /// `filesystem::spawn_fs_change_pump` on fresh boot), fanning the client's + /// coalesced filesystem-change stream out to actor clients. + pub fs_change_task: Option>, + /// Bounded buffer of unanswered permission requests, fed by the permission + /// pump and served by the observe-only `listPendingPermissions` backfill. + /// Lives here (NOT next to `health` in `actor_worker`) because the + /// client-side reply slots die with the VM: pending entries must not + /// survive sleep, and [`Vars::clear`] drops them with the rest of the + /// per-VM state. + pub pending_permissions: permissions::PendingPermissions, } impl Vars { @@ -63,6 +82,13 @@ impl Vars { .unwrap_or(external_session_id) } + /// Track a shell/process broadcast pump task, pruning already-finished + /// handles first so the list stays bounded by the number of LIVE pumps. + pub fn track_shell_task(&mut self, task: JoinHandle<()>) { + self.shell_tasks.retain(|t| !t.is_finished()); + self.shell_tasks.push(task); + } + /// Abort and clear all in-flight pump tasks (event capture + permission /// requests). Called on VM teardown (sleep / destroy / run-loop exit). pub fn clear(&mut self) { @@ -78,10 +104,33 @@ impl Vars { if let Some(task) = self.cron_task.take() { task.abort(); } + if let Some(task) = self.fs_change_task.take() { + task.abort(); + } + for task in self.health_tasks.drain(..) { + task.abort(); + } self.live_sessions.clear(); + // The reply slots behind these entries die with the VM — keeping them + // would advertise requests that can no longer be answered. + self.pending_permissions.clear(); } } +/// Actions dispatched on the observe-only lane (`dispatch_observe`): they run +/// against the CURRENT `Option` and MUST NOT boot a sleeping VM. The +/// inspector's status strip polls `getRuntimeHealth`/`listSessions` on every +/// open tab, and observing a sleeping actor must not wake it; `cancelPrompt` +/// has nothing to cancel without a live VM (typed error, never a boot); and +/// `listPendingPermissions` is `[]` while asleep because pending requests die +/// with the VM (`Vars::clear`). +pub const OBSERVE_ONLY: &[&str] = &[ + "cancelPrompt", + "getRuntimeHealth", + "listPendingPermissions", + "listSessions", +]; + /// Decode positional CBOR args into `T`. /// /// The rivetkit client wire wraps values CBOR can't carry in JSON-compat @@ -210,7 +259,7 @@ pub mod contract { use rivet_actor_plugin_abi as abi; use serde_json::json; - use super::{cron, filesystem, network, preview, process, session, shell}; + use super::{cron, filesystem, health, network, permissions, preview, process, session, shell}; pub use super::contract_surface::{ render_actor_actions_ts, ActionContract, EventContract, ReplyShape, ACTION_CONTRACTS, @@ -256,7 +305,10 @@ pub mod contract { | "listCronJobs" | "listPersistedSessions" | "listMounts" - | "listSoftware" => super::decode_as::<()>(args).map(|_| ()), + | "listSoftware" + | "getRuntimeHealth" + | "listSessions" + | "listPendingPermissions" => super::decode_as::<()>(args).map(|_| ()), "writeProcessStdin" => { super::decode_as::<(u32, filesystem::WriteFileContent)>(args).map(|_| ()) } @@ -272,6 +324,7 @@ pub mod contract { | "cancelCronJob" | "closeSession" | "getSessionEvents" + | "cancelPrompt" | "expireSignedPreviewUrl" => super::decode_as::<(String,)>(args).map(|_| ()), "vmFetch" => super::decode_as::<(u16, String, Option)>(args) .map(|_| ()) @@ -303,6 +356,7 @@ pub mod contract { | "cancelCronJob" | "closeSession" | "getSessionEvents" + | "cancelPrompt" | "expireSignedPreviewUrl" => { vec![json!(["/workspace/file.txt"])] } @@ -334,7 +388,10 @@ pub mod contract { | "listCronJobs" | "listPersistedSessions" | "listMounts" - | "listSoftware" => vec![json!([])], + | "listSoftware" + | "getRuntimeHealth" + | "listSessions" + | "listPendingPermissions" => vec![json!([])], "writeProcessStdin" => vec![json!([42, ["$Uint8Array", "aGVsbG8="]])], "openShell" => vec![ json!([]), @@ -377,6 +434,7 @@ pub mod contract { | "cancelCronJob" | "closeSession" | "getSessionEvents" + | "cancelPrompt" | "expireSignedPreviewUrl" => { vec![("path/id must be a string", json!([42]))] } @@ -413,7 +471,10 @@ pub mod contract { | "listCronJobs" | "listPersistedSessions" | "listMounts" - | "listSoftware" => vec![( + | "listSoftware" + | "getRuntimeHealth" + | "listSessions" + | "listPendingPermissions" => vec![( "zero-arg action must not accept extras", json!(["unexpected"]), )], @@ -467,6 +528,7 @@ pub mod contract { | "cancelCronJob" | "closeSession" | "respondPermission" + | "cancelPrompt" | "expireSignedPreviewUrl" => encode(&()), "stat" => encode(&VirtualStat { mode: 0o100644, @@ -580,12 +642,57 @@ pub mod contract { version: Some("0.0.1".to_owned()), commands: vec!["ls".to_owned()], }]), + // Booted-VM sample with every buffer populated so the nested + // warning/exit/stderr shapes are exercised too (the runtime ships + // an empty stderrTail today — see `health::runtime_health`). + "getRuntimeHealth" => encode(&health::RuntimeHealthDto { + booted: true, + sessions: Some(1), + sidecar: Some(health::RuntimeSidecarInfoDto { + state: "ready".to_owned(), + active_vm_count: 1, + }), + warnings: vec![health::RuntimeLimitWarningDto { + ts: 1.0, + limit: "javascript_event_channel".to_owned(), + category: "queue".to_owned(), + observed: 82.0, + capacity: 100.0, + fill_percent: 82.0, + }], + agent_exits: vec![health::RuntimeAgentExitDto { + ts: 1.0, + session_id: "session-1".to_owned(), + agent_type: "pi".to_owned(), + exit_code: Some(1), + restart: "restarted".to_owned(), + restart_count: 1, + }], + stderr_tail: vec![health::RuntimeStderrLineDto { + ts: 1.0, + line: "adapter warning".to_owned(), + }], + }), + "listSessions" => encode(&vec![health::LiveSessionInfoDto { + session_id: "session-1".to_owned(), + agent_type: "default".to_owned(), + }]), + "listPendingPermissions" => encode(&vec![permissions::PendingPermissionDto { + session_id: "session-1".to_owned(), + permission_id: "permission-1".to_owned(), + description: Some("run command".to_owned()), + params: json!({ "toolCall": { "title": "Bash" } }), + requested_at: 1.0, + }]), other => Err(anyhow!("unknown action {other}")), } } pub fn encode_sample_event(name: &str) -> Result> { match name { + "fsChanged" => { + filesystem::encode_fs_changed_event(vec![String::from("/tmp")], false) + } "sessionEvent" => session::encode_session_event( "session-1", &json!({ "jsonrpc": "2.0", "method": "session/update", "params": {} }), @@ -596,6 +703,9 @@ pub mod contract { Some("run command"), &json!({ "toolCall": { "title": "Bash" } }), ), + "permissionResolved" => { + session::encode_permission_resolved_event("session-1", "permission-1", "once") + } "agentCrashed" => session::encode_agent_crashed_event( "session-1", &AgentExitEvent { @@ -667,19 +777,22 @@ pub mod contract { /// Dispatch one decoded action against a live VM. `host` provides the actor's /// SQLite database (via `db_*`) for the persistence-backed arms (signed preview /// URLs + session metadata); `vm` is the live `AgentOs`; `vars` is the -/// ephemeral session-resume state. +/// ephemeral session-resume state; `health` is the worker-owned post-mortem +/// buffer store the session exit pump tees into. /// /// ⚠️ SOURCE OF TRUTH / KEEP IN SYNC ⚠️ -/// This match statement is mirrored one-to-one by `contract_surface.rs`, which -/// generates the TypeScript `AgentOsActions` surface used to type -/// `createClient()`. Every `"name" =>` arm below must have a -/// corresponding contract row with matching positional args and serialized -/// return type. Update both in the same change. +/// This match statement — together with the observe-only arms in +/// [`dispatch_observe`] below — is mirrored one-to-one by +/// `contract_surface.rs`, which generates the TypeScript `AgentOsActions` +/// surface used to type `createClient()`. Every `"name" =>` +/// arm must have a corresponding contract row with matching positional args +/// and serialized return type. Update both in the same change. pub(crate) async fn dispatch( host: &HostCtx, vm: &AgentOs, config: &crate::config::AgentOsConfigJson, vars: &mut Vars, + health: &health::HealthBuffers, name: &str, args: &[u8], token: u64, @@ -828,7 +941,7 @@ pub(crate) async fn dispatch( Ok((pid,)) => { let host = host.clone(); let vm = vm.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { match process::wait_process(&vm, pid).await { Ok(code) => reply_ok(&host, token, &code), Err(error) => reply_err(&host, token, error), @@ -926,7 +1039,9 @@ pub(crate) async fn dispatch( }); match decoded { Ok((agent_type, options)) => { - match session::create_session(host, vm, vars, &agent_type, options).await { + match session::create_session(host, vm, vars, health, &agent_type, options) + .await + { Ok(id) => reply_ok_encoded( host, token, @@ -970,8 +1085,15 @@ pub(crate) async fn dispatch( }, "respondPermission" => match decode_as::<(String, String, String)>(args) { Ok((session_id, permission_id, reply)) => { - match session::respond_permission(vm, vars, &session_id, &permission_id, &reply) - .await + match session::respond_permission( + host, + vm, + vars, + &session_id, + &permission_id, + &reply, + ) + .await { Ok(()) => reply_ok(host, token, &()), Err(error) => reply_err(host, token, error), @@ -1035,7 +1157,7 @@ pub(crate) async fn dispatch( Ok((shell_id,)) => { let host = host.clone(); let vm = vm.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { match shell::wait_shell(&vm, &shell_id).await { Ok(exit_code) => reply_ok(&host, token, &exit_code), Err(error) => reply_err(&host, token, error), @@ -1075,3 +1197,44 @@ pub(crate) async fn dispatch( } } } + +/// Dispatch one [`OBSERVE_ONLY`] action against the CURRENT VM state. Unlike +/// [`dispatch`], `vm` is optional and this path is reached BEFORE +/// `vm::ensure_vm` in `actor_worker` — these are poll-style inspector actions +/// (the status strip hits `getRuntimeHealth` every 5s, `listSessions` every +/// 10s), and observing a sleeping actor must never boot its VM. +/// +/// ⚠️ Same contract-sync rule as [`dispatch`]: every arm here needs a matching +/// `contract_surface.rs` row (the `action_contract` test parses both match +/// statements together). +pub(crate) async fn dispatch_observe( + host: &HostCtx, + vm: Option<&AgentOs>, + vars: &Vars, + health: &health::HealthBuffers, + name: &str, + args: &[u8], + token: u64, +) { + match name { + "getRuntimeHealth" => reply_ok(host, token, &health::runtime_health(vm, health)), + "listSessions" => reply_ok(host, token, &health::list_live_sessions(vm, vars)), + // Pending permission backfill for the inspector banner. `list()` + // expire-sweeps first; a sleeping VM already cleared the buffer via + // `Vars::clear` (the reply slots died with it), so this is `[]` then. + "listPendingPermissions" => reply_ok(host, token, &vars.pending_permissions.list()), + "cancelPrompt" => match decode_as::<(String,)>(args) { + Ok((session_id,)) => match session::cancel_prompt(vm, vars, &session_id).await { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, + other => { + host.reply_err( + token, + &format!("agent-os action is not observe-only: {other}"), + ); + } + } +} diff --git a/crates/agentos-actor-plugin/src/actions/permissions.rs b/crates/agentos-actor-plugin/src/actions/permissions.rs new file mode 100644 index 0000000000..6651a002e9 --- /dev/null +++ b/crates/agentos-actor-plugin/src/actions/permissions.rs @@ -0,0 +1,263 @@ +//! Pending permission requests for the inspector's permission banner: the +//! plugin-side buffer behind the observe-only `listPendingPermissions` backfill +//! action. The `permissionRequest` broadcast alone is live-only — a request +//! raised while no inspector iframe was open would stay invisible until the +//! runtime's auto-reject timeout — so the permission pump also records each +//! request here until it is answered (`respondPermission`) or expires. +//! +//! [`PendingPermissions`] lives INSIDE [`Vars`] (unlike +//! [`super::health::HealthBuffers`], which deliberately survives sleep): +//! the client-side reply slots die with the VM, so a pending entry that +//! outlived its VM would advertise a request that can no longer be answered. +//! `Vars::clear()` on sleep/destroy/run-loop exit drops the buffer with the +//! rest of the per-VM state. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agentos_client::PERMISSION_TIMEOUT_MS; +use serde::Serialize; +use serde_json::Value as JsonValue; + +/// Bounded buffer cap: past this the oldest entry is dropped with a +/// host-visible warning (see [`PendingPermissions::insert`]). Requests expire +/// server-side after `PERMISSION_TIMEOUT_MS` anyway, so more than this many +/// simultaneously-pending requests means something is already wrong. +pub const PENDING_PERMISSIONS_CAP: usize = 64; + +/// One row of `listPendingPermissions` (TS `PendingPermissionInfo`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingPermissionDto { + /// Client-facing EXTERNAL session id — same key as the + /// `permissionRequest` broadcast, so the inspector can dedupe on + /// `sessionId:permissionId`. + pub session_id: String, + pub permission_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Raw ACP request params, forwarded verbatim like the broadcast. + pub params: JsonValue, + /// Epoch milliseconds when the runtime observed the request. + pub requested_at: f64, +} + +fn now_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +/// Shared, bounded pending-permission buffer. Cloning shares the underlying +/// storage (`Arc`), so the permission pump writes into the same buffer the +/// action dispatchers read from. +#[derive(Clone, Default)] +pub struct PendingPermissions { + inner: Arc>>, +} + +impl PendingPermissions { + /// Lock the buffer, recovering from poisoning: the guarded sections are + /// pure pushes/clones/retains, so a poisoned lock means a panic elsewhere + /// at worst — permission bookkeeping must not cascade it into more panics. + fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Record a pending request. At [`PENDING_PERMISSIONS_CAP`] the oldest + /// entry is dropped and returned so the caller can surface a host-visible + /// warning (a `tracing::warn!` fires here regardless — the drop is never + /// silent). Re-inserting an existing `sessionId:permissionId` replaces the + /// old entry instead of duplicating it. + pub fn insert( + &self, + session_id: &str, + permission_id: &str, + description: Option<&str>, + params: &JsonValue, + ) -> Option { + self.insert_at(session_id, permission_id, description, params, now_ms()) + } + + fn insert_at( + &self, + session_id: &str, + permission_id: &str, + description: Option<&str>, + params: &JsonValue, + now: f64, + ) -> Option { + let mut buf = self.lock(); + expire_in_place(&mut buf, now); + buf.retain(|entry| { + entry.session_id != session_id || entry.permission_id != permission_id + }); + buf.push_back(PendingPermissionDto { + session_id: session_id.to_owned(), + permission_id: permission_id.to_owned(), + description: description.map(str::to_owned), + params: params.clone(), + requested_at: now, + }); + if buf.len() > PENDING_PERMISSIONS_CAP { + let dropped = buf.pop_front(); + if let Some(dropped) = &dropped { + tracing::warn!( + session_id = %dropped.session_id, + permission_id = %dropped.permission_id, + cap = PENDING_PERMISSIONS_CAP, + "pending permission buffer full; dropped the oldest request", + ); + } + return dropped; + } + None + } + + /// Snapshot the still-pending requests, oldest first (expired entries are + /// swept before the snapshot). + pub fn list(&self) -> Vec { + self.list_at(now_ms()) + } + + fn list_at(&self, now: f64) -> Vec { + let mut buf = self.lock(); + expire_in_place(&mut buf, now); + buf.iter().cloned().collect() + } + + /// True when `sessionId:permissionId` is still pending (expired entries + /// are swept first). + pub fn contains(&self, session_id: &str, permission_id: &str) -> bool { + self.contains_at(session_id, permission_id, now_ms()) + } + + fn contains_at(&self, session_id: &str, permission_id: &str, now: f64) -> bool { + let mut buf = self.lock(); + expire_in_place(&mut buf, now); + buf.iter() + .any(|entry| entry.session_id == session_id && entry.permission_id == permission_id) + } + + /// Remove an answered request; `false` when it was absent (already + /// answered, expired, or never buffered). + pub fn remove(&self, session_id: &str, permission_id: &str) -> bool { + self.remove_at(session_id, permission_id, now_ms()) + } + + fn remove_at(&self, session_id: &str, permission_id: &str, now: f64) -> bool { + let mut buf = self.lock(); + expire_in_place(&mut buf, now); + let before = buf.len(); + buf.retain(|entry| { + entry.session_id != session_id || entry.permission_id != permission_id + }); + buf.len() != before + } + + /// Drop everything. Called from `Vars::clear()` on VM teardown: the reply + /// slots the entries point at die with the VM. + pub fn clear(&self) { + self.lock().clear(); + } +} + +/// Sweep entries older than the runtime's `PERMISSION_TIMEOUT_MS`: past that +/// the client auto-rejected the request, so the reply slot is gone and the +/// entry only advertises an unanswerable card. Debug-logged, not warned — the +/// auto-reject is the runtime's documented behavior, not a fault here. +fn expire_in_place(buf: &mut VecDeque, now: f64) { + let timeout = PERMISSION_TIMEOUT_MS as f64; + let before = buf.len(); + buf.retain(|entry| now - entry.requested_at < timeout); + let expired = before - buf.len(); + if expired > 0 { + tracing::debug!(expired, "swept expired pending permission requests"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn insert_n(pending: &PendingPermissions, count: usize, at: f64) { + for i in 0..count { + pending.insert_at("session-1", &format!("perm-{i}"), None, &json!({}), at); + } + } + + #[test] + fn insert_drops_oldest_at_cap_and_returns_it_for_the_warning() { + let pending = PendingPermissions::default(); + insert_n(&pending, PENDING_PERMISSIONS_CAP, 0.0); + // The cap'th insert must evict the oldest and hand it back so the + // pump can log the host-visible warning. + let dropped = pending + .insert_at("session-1", "perm-overflow", None, &json!({}), 0.0) + .expect("insert past the cap returns the dropped entry"); + assert_eq!(dropped.permission_id, "perm-0", "oldest entry dropped"); + + let rows = pending.list_at(0.0); + assert_eq!(rows.len(), PENDING_PERMISSIONS_CAP); + assert_eq!(rows[0].permission_id, "perm-1"); + assert_eq!( + rows.last().unwrap().permission_id, + "perm-overflow", + "newest entry kept" + ); + } + + #[test] + fn expiry_sweep_drops_stale_entries_on_access() { + let pending = PendingPermissions::default(); + pending.insert_at("session-1", "perm-old", None, &json!({}), 0.0); + pending.insert_at("session-1", "perm-new", None, &json!({}), 1_000.0); + + // At exactly the timeout the old entry's reply slot has auto-rejected. + let now = PERMISSION_TIMEOUT_MS as f64; + let rows = pending.list_at(now); + assert_eq!(rows.len(), 1, "expired entry swept on list"); + assert_eq!(rows[0].permission_id, "perm-new"); + assert!(!pending.contains_at("session-1", "perm-old", now)); + assert!(!pending.remove_at("session-1", "perm-old", now)); + } + + #[test] + fn insert_remove_lifecycle_round_trips() { + let pending = PendingPermissions::default(); + assert!(pending + .insert_at( + "session-1", + "perm-1", + Some("run a command"), + &json!({ "toolCall": { "title": "Bash" } }), + 0.0, + ) + .is_none()); + assert!(pending.contains_at("session-1", "perm-1", 0.0)); + + assert!(pending.remove_at("session-1", "perm-1", 0.0)); + assert!(!pending.contains_at("session-1", "perm-1", 0.0)); + assert!( + !pending.remove_at("session-1", "perm-1", 0.0), + "second remove reports the entry as gone" + ); + assert!(pending.list_at(0.0).is_empty()); + } + + #[test] + fn reinserting_the_same_key_replaces_instead_of_duplicating() { + let pending = PendingPermissions::default(); + pending.insert_at("session-1", "perm-1", Some("first"), &json!({}), 0.0); + pending.insert_at("session-1", "perm-1", Some("second"), &json!({}), 1.0); + + let rows = pending.list_at(1.0); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].description.as_deref(), Some("second")); + } +} diff --git a/crates/agentos-actor-plugin/src/actions/session.rs b/crates/agentos-actor-plugin/src/actions/session.rs index 7e4b8c0b29..6c7244e22b 100644 --- a/crates/agentos-actor-plugin/src/actions/session.rs +++ b/crates/agentos-actor-plugin/src/actions/session.rs @@ -19,6 +19,8 @@ use futures::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as JsonValue}; +use super::health::HealthBuffers; +use super::permissions::PENDING_PERMISSIONS_CAP; use super::Vars; use crate::persistence::{ insert_session_event, query_rows, reconstruct_transcript_to_file, run_stmt, @@ -198,6 +200,34 @@ pub(crate) fn encode_permission_request_event( )) } +/// Build the `permissionResolved` broadcast body: `{ sessionId, permissionId, +/// reply }` (TS `PermissionResolvedPayload`), broadcast after a successful +/// `respondPermission` so every open inspector drops its card — without it a +/// second viewer's card dangles until the request would have expired. +fn permission_resolved_payload( + external_session_id: &str, + permission_id: &str, + reply: &str, +) -> JsonValue { + json!({ + "sessionId": external_session_id, + "permissionId": permission_id, + "reply": reply, + }) +} + +pub(crate) fn encode_permission_resolved_event( + external_session_id: &str, + permission_id: &str, + reply: &str, +) -> Result> { + super::encode_event_arg(&permission_resolved_payload( + external_session_id, + permission_id, + reply, + )) +} + /// Map the wire reply string to a [`PermissionReply`] (`"once"` / `"always"` / /// `"reject"`), matching the TS `PermissionReply` union. fn parse_permission_reply(reply: &str) -> Result { @@ -244,12 +274,30 @@ fn spawn_permission_pump( old.abort(); } let ctx = ctx.clone(); + let pending = vars.pending_permissions.clone(); let external = external_session_id.to_owned(); let handle = tokio::spawn(async move { // Keep the RAII guard alive for the pump's lifetime; dropping the stream // (on abort / channel close) is the unsubscribe. let _subscription = subscription; while let Some(request) = stream.next().await { + // Buffer for the `listPendingPermissions` backfill BEFORE the live + // broadcast, so an inspector opened later still sees the request. + // At cap the oldest entry is evicted — host-visible per repo + // policy, never silent (that request becomes unanswerable through + // `respondPermission` until it times out server-side). + if let Some(dropped) = pending.insert( + &external, + &request.permission_id, + request.description.as_deref(), + &request.params, + ) { + ctx.log_warn(&format!( + "agent-os pending permission buffer full (cap {PENDING_PERMISSIONS_CAP}): \ + dropped oldest request {} for session {}", + dropped.permission_id, dropped.session_id + )); + } let body = encode_permission_request_event( &external, &request.permission_id, @@ -270,20 +318,71 @@ fn spawn_permission_pump( .insert(live_session_id.to_owned(), handle); } +/// Typed guard for [`respond_permission`]: the id must still be in the +/// plugin-side pending map (inserted by the permission pump, removed on the +/// first successful reply, lazily expired past the runtime's +/// `PERMISSION_TIMEOUT_MS`, dropped wholesale on VM teardown). The +/// "already answered or expired" text is stable — the inspector matches it to +/// render a quiet expired card instead of a failure. +fn require_pending(vars: &Vars, session_id: &str, permission_id: &str) -> Result<()> { + if vars.pending_permissions.contains(session_id, permission_id) { + return Ok(()); + } + Err(anyhow!( + "no pending permission request {permission_id} for session {session_id}: \ + it was already answered or expired" + )) +} + /// Answer a permission request raised by the session's guest agent -/// (`respondPermission`). Resolves the pending reply slot through the client's -/// `respond_permission`, keyed by the live session id. +/// (`respondPermission`). Requires the request to still be pending +/// plugin-side (typed error otherwise), resolves the reply slot through the +/// client's `respond_permission` keyed by the live session id, then removes +/// the pending entry and broadcasts `permissionResolved` so every other open +/// inspector drops its card. pub async fn respond_permission( + ctx: &HostCtx, vm: &AgentOs, vars: &Vars, session_id: &str, permission_id: &str, reply: &str, ) -> Result<()> { - let reply = parse_permission_reply(reply)?; + let parsed = parse_permission_reply(reply)?; + require_pending(vars, session_id, permission_id)?; let live_session_id = vars.live_id(session_id).to_owned(); - vm.respond_permission(&live_session_id, permission_id, reply) + vm.respond_permission(&live_session_id, permission_id, parsed) .await?; + // Only after the reply slot actually resolved: a failed forward keeps the + // entry so the request stays answerable (or expires on its own). + vars.pending_permissions.remove(session_id, permission_id); + match encode_permission_resolved_event(session_id, permission_id, reply) { + Ok(cbor) => { + let _ = ctx.broadcast(b"permissionResolved".to_vec(), cbor); + } + Err(error) => { + tracing::warn!(?error, "failed to encode permission resolved broadcast"); + } + } + Ok(()) +} + +/// Cancel the in-flight prompt for a session (`cancelPrompt`) — the +/// observe-only port of the rivetkit 2.3.3 wrapper's `cancelPrompt`, which +/// forwarded `AgentOs.cancelSession` against the live VM and refused to boot +/// one. `vm` is optional because the action dispatches on the observe-only +/// lane: with the VM asleep there is nothing running to cancel, and that must +/// be a typed error rather than a silent success (or a VM boot). +pub async fn cancel_prompt(vm: Option<&AgentOs>, vars: &Vars, session_id: &str) -> Result<()> { + let Some(vm) = vm else { + return Err(anyhow!("VM is not booted; no prompt to cancel")); + }; + // Map external -> live like the other session actions. + let live_session_id = vars.live_id(session_id).to_owned(); + // `cancel_session` resolves any pending prompt with `stopReason: cancelled` + // and forwards `session/cancel`; an unknown/not-live session surfaces as a + // typed client error. + vm.cancel_session(&live_session_id).await?; Ok(()) } @@ -298,7 +397,10 @@ fn exit_capture_key(live_session_id: &str) -> String { /// `live_session_id` and spawn a task that live-broadcasts each event to /// connected clients (`conn.on("agentCrashed")`), including the sidecar's /// auto-restart outcome — the actor-side counterpart of the core -/// `onAgentExit` hook. Broadcast-only: the durable transcript stays limited to +/// `onAgentExit` hook. Each event is also teed into the post-mortem +/// [`HealthBuffers`] (the `getRuntimeHealth` `agentExits` buffer, which +/// survives VM sleep) so this stays the single per-session exit subscription. +/// Broadcast-only otherwise: the durable transcript stays limited to /// real session events. Tracked in [`Vars::capture_tasks`] under /// [`exit_capture_key`] so it shares the close/sleep/destroy cancellation path /// with the session/update pump. @@ -306,6 +408,7 @@ fn spawn_exit_capture( ctx: &HostCtx, vm: &AgentOs, vars: &mut Vars, + health: &HealthBuffers, external_session_id: &str, live_session_id: &str, ) { @@ -321,6 +424,7 @@ fn spawn_exit_capture( old.abort(); } let ctx = ctx.clone(); + let health = health.clone(); let external = external_session_id.to_owned(); let handle = tokio::spawn(async move { // Keep the RAII guard alive for the lifetime of the pump; dropping the @@ -336,6 +440,7 @@ fn spawn_exit_capture( max_restarts = event.max_restarts, "agent adapter exited unexpectedly", ); + health.push_agent_exit(&external, &event); let event_value = match serde_json::to_value(&event) { Ok(value) => value, Err(error) => { @@ -360,6 +465,7 @@ pub async fn create_session( ctx: &HostCtx, vm: &AgentOs, vars: &mut Vars, + health: &HealthBuffers, agent_type: &str, dto: CreateSessionOptionsDto, ) -> Result { @@ -402,8 +508,9 @@ pub async fn create_session( // subscribed before the agent runs, or requests would auto-reject. spawn_event_capture(ctx, vm, vars, &session_id, &session_id); spawn_permission_pump(ctx, vm, vars, &session_id, &session_id); - // Live adapter-crash notifications for connected clients (`agentCrashed`). - spawn_exit_capture(ctx, vm, vars, &session_id, &session_id); + // Live adapter-crash notifications for connected clients (`agentCrashed`), + // teed into the post-mortem health buffers for `getRuntimeHealth`. + spawn_exit_capture(ctx, vm, vars, health, &session_id, &session_id); // The generated TS action surface types this as `Promise`, so the // reply must be the bare session id, not a `{ sessionId }` wrapper. Ok(session_id) @@ -725,6 +832,45 @@ mod tests { assert_eq!(body["request"]["description"], JsonValue::Null); } + #[test] + fn permission_resolved_body_matches_ts_payload_shape() { + // The TS listener arg is `PermissionResolvedPayload { sessionId, + // permissionId, reply }` — flat, unlike the request's nested shape. + let data = permission_resolved_payload("sess-1", "perm-7", "always"); + assert_eq!( + data, + json!({ + "sessionId": "sess-1", + "permissionId": "perm-7", + "reply": "always", + }) + ); + } + + #[test] + fn respond_permission_on_missing_id_is_a_typed_error() { + // Not in the pending map — already answered by another viewer, expired + // past PERMISSION_TIMEOUT_MS, or dropped with the VM. The inspector + // matches the stable "already answered or expired" text to show the + // quiet expired state, so this guard must fire BEFORE the forward. + let vars = Vars::default(); + let err = require_pending(&vars, "session-1", "perm-1") + .expect_err("missing pending entry must be a typed error"); + assert!( + err.to_string().contains("already answered or expired"), + "got: {err}" + ); + assert!(err.to_string().contains("perm-1"), "names the id: {err}"); + } + + #[test] + fn require_pending_passes_for_a_buffered_request() { + let vars = Vars::default(); + vars.pending_permissions + .insert("session-1", "perm-1", None, &json!({})); + require_pending(&vars, "session-1", "perm-1").expect("buffered request is answerable"); + } + #[test] fn parse_permission_reply_maps_each_wire_value() { assert_eq!( @@ -747,4 +893,20 @@ mod tests { assert!(err.contains("invalid permission reply"), "got: {err}"); assert!(err.contains("maybe"), "names the bad value: {err}"); } + + #[tokio::test] + async fn cancel_prompt_with_vm_asleep_is_a_typed_error() { + // Observe-only lane contract: with no live VM there is no prompt to + // cancel — a typed error naming the situation, never a silent success + // (and never a VM boot; `cancel_prompt` cannot even reach `ensure_vm`). + let vars = Vars::default(); + let err = cancel_prompt(None, &vars, "session-1") + .await + .expect_err("asleep VM must not silently succeed"); + assert!( + err.to_string() + .contains("VM is not booted; no prompt to cancel"), + "got: {err}" + ); + } } diff --git a/crates/agentos-actor-plugin/src/actions/shell.rs b/crates/agentos-actor-plugin/src/actions/shell.rs index 3c6824c609..71acc42309 100644 --- a/crates/agentos-actor-plugin/src/actions/shell.rs +++ b/crates/agentos-actor-plugin/src/actions/shell.rs @@ -45,7 +45,16 @@ pub struct OpenShellDto { fn broadcast_event(host: &HostCtx, name: &[u8], payload: &T) { match super::encode_event_arg(payload) { Ok(bytes) => { - let _ = host.broadcast(name.to_vec(), bytes); + let status = host.broadcast(name.to_vec(), bytes); + if status != rivet_actor_plugin_abi::AbiStatus::Ok { + // A dropped delivery is invisible client-side (terminals just + // miss output); the failure must at least be host-visible. + tracing::warn!( + ?status, + event = %String::from_utf8_lossy(name), + "shell event broadcast failed" + ); + } } Err(error) => { tracing::warn!(?error, "failed to encode shell event broadcast"); @@ -111,7 +120,7 @@ pub fn open_shell( let data_host = host.clone(); let data_shell_id = shell_id.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { while let Some(chunk) = data_stream.next().await { broadcast_event( &data_host, @@ -126,7 +135,7 @@ pub fn open_shell( let stderr_host = host.clone(); let stderr_shell_id = shell_id.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { while let Some(chunk) = stderr_stream.next().await { broadcast_event( &stderr_host, @@ -142,16 +151,23 @@ pub fn open_shell( let exit_host = host.clone(); let exit_vm = vm.clone(); let exit_shell_id = shell_id.clone(); - vars.shell_tasks.push(tokio::spawn(async move { - if let Ok(exit_code) = exit_vm.wait_shell(&exit_shell_id).await { - broadcast_event( + vars.track_shell_task(tokio::spawn(async move { + match exit_vm.wait_shell(&exit_shell_id).await { + Ok(exit_code) => broadcast_event( &exit_host, b"shellExit", &ShellExitEvent { shell_id: &exit_shell_id, exit_code, }, - ); + ), + // No shellExit reaches clients on this path; VM teardown is covered + // by the vmShutdown broadcast, but the failure itself must not vanish. + Err(error) => tracing::warn!( + ?error, + shell_id = %exit_shell_id, + "wait_shell failed; shellExit not broadcast" + ), } })); @@ -198,7 +214,7 @@ pub fn spawn_process_output_pumps(host: &HostCtx, vm: &AgentOs, vars: &mut Vars, if let Ok(mut stream) = stdout { let host = host.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { while let Some(chunk) = stream.next().await { broadcast_event( &host, @@ -214,7 +230,7 @@ pub fn spawn_process_output_pumps(host: &HostCtx, vm: &AgentOs, vars: &mut Vars, } if let Ok(mut stream) = stderr { let host = host.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { while let Some(chunk) = stream.next().await { broadcast_event( &host, @@ -230,7 +246,7 @@ pub fn spawn_process_output_pumps(host: &HostCtx, vm: &AgentOs, vars: &mut Vars, } let host = host.clone(); let vm = vm.clone(); - vars.shell_tasks.push(tokio::spawn(async move { + vars.track_shell_task(tokio::spawn(async move { if let Ok(exit_code) = vm.wait_process(pid).await { broadcast_event(&host, b"processExit", &ProcessExitEvent { pid, exit_code }); } diff --git a/crates/agentos-actor-plugin/src/lib.rs b/crates/agentos-actor-plugin/src/lib.rs index 5cadc455b5..60675523bf 100644 --- a/crates/agentos-actor-plugin/src/lib.rs +++ b/crates/agentos-actor-plugin/src/lib.rs @@ -337,6 +337,12 @@ async fn actor_worker( ) { let mut vm: Option = None; let mut vars = actions::Vars::default(); + // Post-mortem runtime health buffers (limit warnings + agent exits) for the + // observe-only `getRuntimeHealth` action. Owned here NEXT TO `vm` — not in + // `vars` — because they deliberately SURVIVE VM sleep: the inspector reads + // warnings and crash exits post-mortem while `booted == false`. Only the + // pump tasks feeding them (tracked in `vars.health_tasks`) die with the VM. + let health = actions::health::HealthBuffers::default(); loop { let job = tokio::select! { _ = cancel.cancelled() => break, @@ -347,6 +353,34 @@ async fn actor_worker( }; match job { ActorJob::Action { token, payload } => { + // Decode BEFORE VM bring-up: observe-only actions (and + // malformed payloads) must never boot a sleeping VM. + let (name, action_args) = match abi::decode_action_payload(&payload) { + Ok(decoded) => decoded, + Err(_) => { + let _ = host.reply_err(token, "malformed action event payload"); + continue; + } + }; + if actions::OBSERVE_ONLY.contains(&name.as_str()) { + // Observe-only lane: dispatch against the current + // `Option` without `ensure_vm`. A sleeping VM + // stays asleep; `cancelPrompt` replies with a typed error. + tracing::debug!(action = %name, "agent-os observe action start"); + actions::dispatch_observe( + &host, + vm.as_ref(), + &vars, + &health, + &name, + &action_args, + token, + ) + .await; + tracing::debug!(action = %name, "agent-os observe action done"); + continue; + } + let was_booted = vm.is_some(); if let Err(error) = vm::ensure_vm(&host, &sidecar_path, &config, &pool, &mut vm).await { @@ -358,25 +392,26 @@ async fn actor_worker( let _ = host.reply_err(token, "vm unavailable after bring-up"); continue; }; - match abi::decode_action_payload(&payload) { - Ok((name, action_args)) => { - tracing::debug!(action = %name, "agent-os action start"); - actions::dispatch( - &host, - vm_ref, - &config, - &mut vars, - &name, - &action_args, - token, - ) - .await; - tracing::debug!(action = %name, "agent-os action done"); - } - Err(_) => { - let _ = host.reply_err(token, "malformed action event payload"); - } + if !was_booted { + // Fresh boot: start the per-VM-lifetime health pumps. + // Aborted via `vars.clear()` on sleep/destroy; the buffers + // themselves survive in `health`. + actions::health::spawn_health_pumps(&host, vm_ref, &mut vars, &health); + actions::filesystem::spawn_fs_change_pump(&host, vm_ref, &mut vars); } + tracing::debug!(action = %name, "agent-os action start"); + actions::dispatch( + &host, + vm_ref, + &config, + &mut vars, + &health, + &name, + &action_args, + token, + ) + .await; + tracing::debug!(action = %name, "agent-os action done"); } ActorJob::Http { token, payload } => { // Preview proxy: do NOT bring the VM up for HTTP (matches r6's diff --git a/crates/agentos-actor-plugin/tests/action_contract.rs b/crates/agentos-actor-plugin/tests/action_contract.rs index 96404209ea..66aea2b431 100644 --- a/crates/agentos-actor-plugin/tests/action_contract.rs +++ b/crates/agentos-actor-plugin/tests/action_contract.rs @@ -8,8 +8,11 @@ use ciborium::Value as CborValue; #[test] fn dispatcher_arms_have_contract_rows() { let dispatcher = include_str!("../src/actions/mod.rs"); + // Everything from the first dispatcher onward: `dispatch` (VM-required + // arms) and `dispatch_observe` (the observe-only arms) are the last two + // items in the file and together mirror ACTION_CONTRACTS one-to-one. let dispatch = dispatcher - .split("pub(crate) async fn dispatch") + .splitn(2, "pub(crate) async fn dispatch") .nth(1) .expect("dispatch function exists"); let dispatch_arms = dispatch_arm_names(dispatch); @@ -21,6 +24,84 @@ fn dispatcher_arms_have_contract_rows() { ); } +#[test] +fn observe_only_actions_are_contract_rows() { + let contract_names = contract_names(); + for name in agentos_actor_plugin::actions::OBSERVE_ONLY { + assert!( + contract_names.contains(name), + "observe-only action {name} missing from ACTION_CONTRACTS" + ); + } + // The non-waking lane exists for exactly the promoted inspector actions. + assert_eq!( + agentos_actor_plugin::actions::OBSERVE_ONLY, + &[ + "cancelPrompt", + "getRuntimeHealth", + "listPendingPermissions", + "listSessions" + ] + ); +} + +#[test] +fn runtime_health_reply_buffers_use_camel_case_field_names() { + let encoded = contract::encode_sample_reply("getRuntimeHealth").unwrap(); + let decoded = contract::decode_reply_value(&encoded).unwrap(); + let CborValue::Map(payload) = decoded else { + panic!("getRuntimeHealth reply must be an object"); + }; + let CborValue::Array(warnings) = object_field(&payload, "warnings") else { + panic!("warnings must be an array"); + }; + assert_object_keys( + "getRuntimeHealth.warnings[0]", + &warnings[0], + &["ts", "limit", "category", "observed", "capacity", "fillPercent"], + ); + let CborValue::Array(exits) = object_field(&payload, "agentExits") else { + panic!("agentExits must be an array"); + }; + assert_object_keys( + "getRuntimeHealth.agentExits[0]", + &exits[0], + &[ + "ts", + "sessionId", + "agentType", + "exitCode", + "restart", + "restartCount", + ], + ); + assert_object_keys( + "getRuntimeHealth.sidecar", + object_field(&payload, "sidecar"), + &["state", "activeVmCount"], + ); + let CborValue::Array(stderr_tail) = object_field(&payload, "stderrTail") else { + panic!("stderrTail must be an array"); + }; + assert_object_keys( + "getRuntimeHealth.stderrTail[0]", + &stderr_tail[0], + &["ts", "line"], + ); +} + +#[test] +fn list_sessions_reply_rows_match_persisted_session_id_keys() { + let encoded = contract::encode_sample_reply("listSessions").unwrap(); + let decoded = contract::decode_reply_value(&encoded).unwrap(); + let CborValue::Array(rows) = decoded else { + panic!("listSessions reply must be an array"); + }; + // Rows carry the EXTERNAL session id under the same `sessionId` key as + // `listPersistedSessions` records so the inspector can cross-reference. + assert_object_keys("listSessions[0]", &rows[0], &["sessionId", "agentType"]); +} + #[test] fn client_arg_payloads_decode_for_every_action() { for action in ACTION_CONTRACTS { @@ -208,7 +289,7 @@ fn ts_dto_field_names_match_rust_contract_fixture() { "VirtualStat", "packages/core/src/runtime.ts", core_runtime, - "export interface VirtualStat { mode: number; size: number; blocks: number; dev: number; rdev: number; isDirectory: boolean; isSymbolicLink: boolean; atimeMs: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; ino: number; nlink: number; uid: number; gid: number; }", + "export interface VirtualStat { mode: number; size: number; sizeExact?: bigint; blocks: number; dev: number; rdev: number; isDirectory: boolean; isSymbolicLink: boolean; atimeMs: number; mtimeMs: number; ctimeMs: number; birthtimeMs: number; ino: number; inoExact?: bigint; nlink: number; nlinkExact?: bigint; uid: number; gid: number; }", ), ( "ExecResult", diff --git a/crates/agentos-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs index 4f2ef41491..ea4f8c5c51 100644 --- a/crates/agentos-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -27,7 +27,11 @@ use agentos_protocol::ACP_EXTENSION_NAMESPACE; use serde_json::{json, Map, Value}; use tokio::sync::Mutex; -const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); +// Cold adapter boot must parse and execute the whole adapter bundle inside the +// guest before it can answer `initialize`; large adapters (opencode ~11MB) +// measure 8-11s on a loaded dev machine, so 10s made bootstrap a coin flip. +// The 10s stall watchdog below still surfaces slow boots as breadcrumbs. +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(60); const SESSION_NEW_TIMEOUT: Duration = Duration::from_secs(30); const SESSION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); // While an ACP request is in flight the stdio loop is inside the extension @@ -1808,7 +1812,8 @@ async fn send_json_rpc_request( }); } return Err(SidecarError::InvalidState(format!( - "timed out waiting for ACP response id={response_id}; {cancel_status}" + "timed out waiting for ACP response id={response_id}; adapter did not answer {method} within the {}s limit; {cancel_status}", + timeout.as_secs() ))); } let remaining = deadline.saturating_duration_since(now); @@ -3260,7 +3265,7 @@ mod tests { #[test] fn request_timeout_uses_acp_method_overrides() { - assert_eq!(request_timeout("initialize"), Duration::from_secs(10)); + assert_eq!(request_timeout("initialize"), Duration::from_secs(60)); assert_eq!(request_timeout("session/new"), Duration::from_secs(30)); assert_eq!(request_timeout("session/prompt"), Duration::from_secs(600)); assert_eq!( diff --git a/crates/bridge/src/queue_tracker.rs b/crates/bridge/src/queue_tracker.rs index f85d0dfd17..3512a404e2 100644 --- a/crates/bridge/src/queue_tracker.rs +++ b/crates/bridge/src/queue_tracker.rs @@ -86,6 +86,7 @@ pub enum TrackedLimit { VmInodes, VmRecursiveFsDepth, VmRecursiveFsEntries, + FsChangedDirtyDirs, V8HeapBytes, V8CpuTimeMs, V8WallClockMs, @@ -118,6 +119,7 @@ impl TrackedLimit { TrackedLimit::VmInodes => "vm_inodes", TrackedLimit::VmRecursiveFsDepth => "vm_recursive_fs_depth", TrackedLimit::VmRecursiveFsEntries => "vm_recursive_fs_entries", + TrackedLimit::FsChangedDirtyDirs => "fs_changed_dirty_dirs", TrackedLimit::V8HeapBytes => "v8_heap_bytes", TrackedLimit::V8CpuTimeMs => "v8_cpu_time_ms", TrackedLimit::V8WallClockMs => "v8_wall_clock_ms", @@ -148,6 +150,7 @@ impl TrackedLimit { | TrackedLimit::VmInodes | TrackedLimit::VmRecursiveFsDepth | TrackedLimit::VmRecursiveFsEntries => LimitCategory::Resource, + TrackedLimit::FsChangedDirtyDirs => LimitCategory::Queue, TrackedLimit::V8HeapBytes | TrackedLimit::WasmMemoryBytes => LimitCategory::Memory, TrackedLimit::V8CpuTimeMs | TrackedLimit::V8WallClockMs | TrackedLimit::WasmFuelMs => { LimitCategory::Cpu diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 7bfc806fb4..52709f8bc3 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -7,10 +7,12 @@ use std::collections::{BTreeMap, HashMap, VecDeque}; use std::io::Write; +use std::pin::Pin; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Weak}; use std::time::Duration; +use futures::Stream; use scc::{HashMap as SccHashMap, HashSet as SccHashSet}; use serde::Deserialize; use serde_json::{Map, Value}; @@ -40,6 +42,7 @@ use crate::session::{ SessionModeState, }; use crate::sidecar::{AgentOsSidecar, AgentOsSidecarPlacement, AgentOsSidecarVmLease}; +use crate::stream::Subscription; use crate::transport::{SidecarProcess, WireSidecarCallback}; use agentos_sidecar_client::TransportError; @@ -172,6 +175,54 @@ pub struct AgentOs { inner: Arc, } +/// A near-capacity warning for one bounded limit (a queue/buffer, a saturating +/// resource cap, or a memory envelope) inside the VM runtime, parsed from the +/// sidecar's `limit_warning` structured event. Mirrors the TS `LimitWarning` +/// delivered to the `onLimitWarning` option: delivered once per threshold +/// crossing (the runtime edge-triggers with hysteresis, so this never spams). +#[derive(Debug, Clone, PartialEq)] +pub struct LimitWarning { + /// Stable limit name, e.g. `"javascript_event_channel"` or `"vm_open_fds"`. + pub limit: String, + /// Limit class: `"queue"`, `"resource"`, or `"memory"`. + pub category: String, + /// Current observed usage. + pub observed: f64, + /// Configured capacity. + pub capacity: f64, + /// Observed fill as a percentage of capacity (0–100). + pub fill_percent: f64, +} + +pub type LimitWarningStream = Pin + Send>>; +pub type LimitWarningSubscription = (LimitWarningStream, Subscription); + +/// Broadcast capacity for `on_limit_warning` subscribers. Warnings are +/// edge-triggered (once per threshold crossing), so a small buffer suffices. +const LIMIT_WARNING_CHANNEL_CAPACITY: usize = 64; + +/// One coalesced guest filesystem change window, from the sidecar's +/// `filesystem.changed` structured events. Parity surface for the TS +/// `onFsChanged` create-time option. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FsChanged { + /// Absolute guest directories whose direct entries changed (parents of + /// mutated paths, plus removed/renamed directories themselves). + pub dirs: Vec, + /// The sidecar's per-window dirty-set overflowed; treat the whole tree as + /// changed. Also synthesized client-side when a subscriber lags and events + /// are dropped, so an invalidation is never silently lost. + pub overflow: bool, +} + +pub type FsChangedStream = Pin + Send>>; +pub type FsChangedSubscription = (FsChangedStream, Subscription); + +/// Broadcast capacity for `on_fs_changed` subscribers. Events are already +/// coalesced sidecar-side (≤ ~3/s per VM), and a lagged receiver degrades to a +/// synthetic overflow rather than losing changes. +const FS_CHANGED_CHANNEL_CAPACITY: usize = 256; + pub(crate) struct AgentOsInner { // Transport / connection / VM handle. pub(crate) transport: Arc, @@ -243,6 +294,12 @@ pub(crate) struct AgentOsInner { /// can abort it; the pump only exits on its own when the shared transport's event channel closes, /// which does not happen while sibling VMs keep the transport alive. Mirrors `pending_shell_exits`. pub(crate) acp_event_pump: parking_lot::Mutex>>, + /// Fan-out for the sidecar's `limit_warning` structured events + /// ([`AgentOs::on_limit_warning`]); fed by the ACP event pump. + pub(crate) limit_warning_tx: broadcast::Sender, + /// Fan-out for this VM's `filesystem.changed` structured events + /// ([`AgentOs::on_fs_changed`]); fed by the ACP event pump. + pub(crate) fs_changed_tx: broadcast::Sender, } impl AgentOs { @@ -614,6 +671,8 @@ impl AgentOs { in_process_mounts: SccHashMap::new(), disposed: AtomicBool::new(false), acp_event_pump: parking_lot::Mutex::new(None), + limit_warning_tx: broadcast::channel(LIMIT_WARNING_CHANNEL_CAPACITY).0, + fs_changed_tx: broadcast::channel(FS_CHANGED_CHANNEL_CAPACITY).0, }; let client = AgentOs { @@ -867,6 +926,47 @@ impl AgentOs { self.inner.sidecar.clone() } + /// Subscribe to near-capacity limit warnings from the VM runtime (the sidecar's + /// `limit_warning` structured events). Parity surface for the TS `onLimitWarning` create-time + /// option, expressed in this client's subscription style (`on_session_event` / `on_agent_exit`): + /// only warnings observed after subscription are delivered. + pub fn on_limit_warning(&self) -> LimitWarningSubscription { + let rx = self.inner.limit_warning_tx.subscribe(); + let stream = futures::stream::unfold(rx, move |mut rx| async move { + loop { + match rx.recv().await { + Ok(warning) => return Some((warning, rx)), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }); + (Box::pin(stream), Subscription::noop()) + } + + /// Subscribe to coalesced guest filesystem changes (the sidecar's + /// `filesystem.changed` structured events, VM-scoped). Parity surface for + /// the TS `onFsChanged` create-time option. A lagged subscriber receives a + /// synthetic `FsChanged { dirs: [], overflow: true }` instead of silently + /// missing windows — consumers must treat overflow as "refresh everything". + pub fn on_fs_changed(&self) -> FsChangedSubscription { + let rx = self.inner.fs_changed_tx.subscribe(); + let stream = futures::stream::unfold(rx, move |mut rx| async move { + match rx.recv().await { + Ok(event) => Some((event, rx)), + Err(broadcast::error::RecvError::Lagged(_)) => Some(( + FsChanged { + dirs: Vec::new(), + overflow: true, + }, + rx, + )), + Err(broadcast::error::RecvError::Closed) => None, + } + }); + (Box::pin(stream), Subscription::noop()) + } + pub fn projected_agents(&self) -> Vec { self.inner.projected_agents.lock().clone() } @@ -900,12 +1000,46 @@ fn spawn_acp_event_pump(client: &AgentOs) { tracing::warn!(?error, "failed to deliver acp extension event"); } } + Ok((ownership, wire::EventPayload::StructuredEvent(event))) => { + match event.name.as_str() { + // Runtime limit warnings fan out to `on_limit_warning` + // subscribers. Process-global signal, so no ownership + // filter — mirrors the TS `_handleSidecarEvent` + // `limit_warning` path. + "limit_warning" => { + let Some(inner) = inner.upgrade() else { + break; + }; + if inner.disposed.load(Ordering::SeqCst) { + break; + } + let _ = inner + .limit_warning_tx + .send(parse_limit_warning(&event.detail)); + } + // Coalesced guest filesystem changes are VM-scoped: + // transports are shared across VMs, so only this VM's + // events fan out to `on_fs_changed` subscribers. + "filesystem.changed" => { + let Some(inner) = inner.upgrade() else { + break; + }; + if inner.disposed.load(Ordering::SeqCst) { + break; + } + if wire_ownership_vm_id(&ownership) != Some(inner.vm_id.as_str()) { + continue; + } + let _ = inner.fs_changed_tx.send(parse_fs_changed(&event.detail)); + } + _ => {} + } + } Ok(( _, wire::EventPayload::VmLifecycleEvent(_) | wire::EventPayload::ProcessOutputEvent(_) - | wire::EventPayload::ProcessExitedEvent(_) - | wire::EventPayload::StructuredEvent(_), + | wire::EventPayload::ProcessExitedEvent(_), )) => {} Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Closed) => break, @@ -915,6 +1049,52 @@ fn spawn_acp_event_pump(client: &AgentOs) { *client.inner.acp_event_pump.lock() = Some(handle); } +/// Parse the string detail map of a `limit_warning` structured event into a +/// [`LimitWarning`], matching the TS `_handleLimitWarning` parsing exactly +/// (missing keys -> empty string, unparsable numbers -> 0). +fn parse_limit_warning(detail: &HashMap) -> LimitWarning { + let to_number = |key: &str| -> f64 { + detail + .get(key) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) + }; + LimitWarning { + limit: detail.get("limit").cloned().unwrap_or_default(), + category: detail.get("category").cloned().unwrap_or_default(), + observed: to_number("observed"), + capacity: to_number("capacity"), + fill_percent: to_number("fillPercent"), + } +} + +/// Parse the string detail map of a `filesystem.changed` structured event. A +/// malformed payload degrades to `{ dirs: [], overflow: true }` (full refresh) +/// with a warning — an invalidation signal must never be dropped silently. +fn parse_fs_changed(detail: &HashMap) -> FsChanged { + let overflow = detail.get("overflow").map(String::as_str) == Some("true"); + let dirs = match detail.get("dirs") { + Some(raw) => match serde_json::from_str::>(raw) { + Ok(dirs) => dirs, + Err(error) => { + tracing::warn!(?error, "malformed filesystem.changed dirs; degrading to overflow"); + return FsChanged { + dirs: Vec::new(), + overflow: true, + }; + } + }, + None => { + tracing::warn!("filesystem.changed event without dirs; degrading to overflow"); + return FsChanged { + dirs: Vec::new(), + overflow: true, + }; + } + }; + FsChanged { dirs, overflow } +} + fn deliver_acp_ext_event( inner: &AgentOsInner, envelope: wire::ExtEnvelope, @@ -4198,4 +4378,28 @@ mod tests { assert_eq!(wasm.prewarm_timeout_ms, Some(30_000)); assert_eq!(wasm.runner_heap_limit_mb, Some(2_048)); } + + #[test] + fn parse_fs_changed_reads_dirs_and_overflow() { + let mut detail = std::collections::HashMap::new(); + detail.insert(String::from("dirs"), String::from(r#"["/tmp","/workspace"]"#)); + detail.insert(String::from("overflow"), String::from("false")); + let parsed = super::parse_fs_changed(&detail); + assert_eq!(parsed.dirs, vec!["/tmp", "/workspace"]); + assert!(!parsed.overflow); + } + + #[test] + fn parse_fs_changed_degrades_malformed_payloads_to_overflow() { + let mut detail = std::collections::HashMap::new(); + detail.insert(String::from("dirs"), String::from("not-json")); + detail.insert(String::from("overflow"), String::from("false")); + let parsed = super::parse_fs_changed(&detail); + assert!(parsed.overflow, "malformed dirs must degrade to full refresh"); + assert!(parsed.dirs.is_empty()); + + let empty = std::collections::HashMap::new(); + let parsed = super::parse_fs_changed(&empty); + assert!(parsed.overflow, "missing dirs must degrade to full refresh"); + } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index f845e5f7bd..1854675b53 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -58,7 +58,10 @@ pub const CRON_JOB_LIMIT: usize = 1024; // Public re-exports // --------------------------------------------------------------------------- -pub use agent_os::{AgentOs, PackageDescriptor, ProjectedAgent}; +pub use agent_os::{ + AgentOs, FsChanged, FsChangedStream, FsChangedSubscription, LimitWarning, LimitWarningStream, + LimitWarningSubscription, PackageDescriptor, ProjectedAgent, +}; pub use error::{ClientError, ClientResult}; pub use sidecar::{ AgentOsSidecar, AgentOsSidecarDescription, AgentOsSidecarPlacement, SidecarState, diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index fe52813d08..43aefde9aa 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -304,6 +304,26 @@ impl AgentOs { let handle = tokio::spawn(async move { let mut events = agent.transport().subscribe_wire_events(); + // Spawn failed (transport error or a rejected execute): the shell id was already + // handed to the caller, so the failure must still terminate the shell's lifecycle — + // record a synthetic 127 exit (POSIX command-start failure) so `wait_shell` resolves + // and exit-driven surfaces (e.g. the actor plugin's `shellExit` broadcast) observe it, + // instead of leaving a phantom shell that never outputs and never exits. + let fail_spawn = |error: &dyn std::fmt::Debug| { + tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed"); + { + let mut retained = agent.inner().closed_shell_exit_codes.lock(); + retained.push_back((exit_shell_id.clone(), 127)); + while retained.len() > crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT { + retained.pop_front(); + } + } + let _ = exit_tx.send_replace(Some(127)); + agent.inner().pending_shell_exits.remove(&exit_key); + agent.inner().shells.remove_if(&exit_shell_id, |existing| { + existing.process_id == route_process_id + }); + }; let response = match agent .transport() .request_wire( @@ -314,25 +334,32 @@ impl AgentOs { { Ok(response) => response, Err(error) => { - tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed"); - // Drop the dead entry so later shell calls report ShellNotFound rather than hang. - agent.inner().shells.remove(&exit_shell_id); - agent.inner().pending_shell_exits.remove(&exit_key); + fail_spawn(&error); return; } }; // Record the real kernel pid on the entry (TS `ShellHandle.pid`) and release the write // gate so any queued `write_shell`/`close_shell` proceed against the live spawn. - if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse { - pid: Some(pid), - .. - }) = response - { - agent - .inner() - .shells - .update(&exit_shell_id, |_, existing| existing.pid = pid); + match response { + wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse { + pid: Some(pid), + .. + }) => { + agent + .inner() + .shells + .update(&exit_shell_id, |_, existing| existing.pid = pid); + } + wire::ResponsePayload::ProcessStartedResponse(_) => {} + wire::ResponsePayload::RejectedResponse(rejected) => { + fail_spawn(&rejected_to_error(rejected)); + return; + } + other => { + fail_spawn(&format!("unexpected execute response: {other:?}")); + return; + } } // send_replace, not send: `watch::Sender::send` REFUSES to store the // value while no receiver exists (and the initial receiver is dropped @@ -344,7 +371,17 @@ impl AgentOs { loop { let (_scope, payload) = match events.recv().await { Ok(value) => value, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { + // Dropped wire events can include this shell's output — or its + // exit. Losing them silently would strand the shell "live", so + // at least make the loss host-visible. + tracing::warn!( + missed, + shell_id = %exit_shell_id, + "shell wire-event subscriber lagged; output/exit events dropped" + ); + continue; + } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; match payload { @@ -473,15 +510,32 @@ impl AgentOs { } }; - if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse { - pid: Some(pid), - .. - }) = response - { - agent - .inner() - .shells - .update(&exit_shell_id, |_, existing| existing.pid = pid); + match response { + wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse { + pid: Some(pid), + .. + }) => { + agent + .inner() + .shells + .update(&exit_shell_id, |_, existing| existing.pid = pid); + } + wire::ResponsePayload::ProcessStartedResponse(_) => {} + // A rejected execute is a spawn failure: same terminal handling as the + // transport-error branch above, so the terminal never looks live. + other => { + let error = match other { + wire::ResponsePayload::RejectedResponse(rejected) => { + rejected_to_error(rejected).to_string() + } + unexpected => format!("unexpected execute response: {unexpected:?}"), + }; + tracing::warn!(%error, shell_id = %exit_shell_id, "acp_open_terminal spawn rejected"); + agent.inner().shells.remove(&exit_shell_id); + agent.inner().pending_shell_exits.remove(&exit_key); + let _ = exit_tx.send(Some(1)); + return; + } } // send_replace, not send: `watch::Sender::send` REFUSES to store the // value while no receiver exists (and the initial receiver is dropped @@ -494,7 +548,14 @@ impl AgentOs { loop { let (_scope, payload) = match events.recv().await { Ok(value) => value, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { + tracing::warn!( + missed, + shell_id = %exit_shell_id, + "terminal wire-event subscriber lagged; output/exit events dropped" + ); + continue; + } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; match payload { diff --git a/crates/client/tests/fs_e2e.rs b/crates/client/tests/fs_e2e.rs index e11c7e556a..76dcade3e8 100644 --- a/crates/client/tests/fs_e2e.rs +++ b/crates/client/tests/fs_e2e.rs @@ -142,3 +142,37 @@ async fn filesystem_surface_round_trips() { os.shutdown().await.expect("shutdown"); } + +#[tokio::test] +async fn fs_changed_events_follow_writes() { + if !common::require_sidecar("fs_changed_events_follow_writes") { + return; + } + let os = common::new_vm_with_sidecar_pool("fs-changed-events").await; + + let (mut stream, _subscription) = os.on_fs_changed(); + os.write_file( + "/tmp/fs-evt.txt", + FileContent::Text("fs event probe".to_string()), + ) + .await + .expect("write probe file"); + + // The sidecar coalesces for ~300ms before emitting; wait generously. + let event = tokio::time::timeout(std::time::Duration::from_secs(10), async { + use futures::StreamExt; + loop { + let Some(change) = stream.next().await else { + panic!("fs change stream closed without an event"); + }; + if change.overflow || change.dirs.iter().any(|dir| dir == "/tmp") { + return change; + } + } + }) + .await + .expect("fsChanged event within deadline"); + assert!(event.overflow || event.dirs.iter().any(|dir| dir == "/tmp")); + + os.shutdown().await.expect("shutdown"); +} diff --git a/crates/native-sidecar/src/execution.rs b/crates/native-sidecar/src/execution.rs index ef336720d7..6ec45c98a2 100644 --- a/crates/native-sidecar/src/execution.rs +++ b/crates/native-sidecar/src/execution.rs @@ -1387,6 +1387,7 @@ pub(crate) struct JavascriptSyncRpcServiceRequest<'a, B> { pub(crate) socket_paths: &'a JavascriptSocketPathContext, pub(crate) kernel: &'a mut SidecarKernel, pub(crate) kernel_readiness: KernelSocketReadinessRegistry, + pub(crate) fs_changes: Arc, pub(crate) process: &'a mut ActiveProcess, pub(crate) sync_request: &'a JavascriptSyncRpcRequest, pub(crate) resource_limits: &'a ResourceLimits, @@ -1433,6 +1434,7 @@ struct LoopbackHttpResponseWaitRequest<'a, B> { socket_paths: &'a JavascriptSocketPathContext, kernel: &'a mut SidecarKernel, kernel_readiness: KernelSocketReadinessRegistry, + fs_changes: Arc, process: &'a mut ActiveProcess, resource_limits: &'a ResourceLimits, request_key: (u64, u64), @@ -1445,6 +1447,7 @@ pub(crate) struct LoopbackHttpDispatchRequest<'a, B> { pub(crate) socket_paths: &'a JavascriptSocketPathContext, pub(crate) kernel: &'a mut SidecarKernel, pub(crate) kernel_readiness: KernelSocketReadinessRegistry, + pub(crate) fs_changes: Arc, pub(crate) process: &'a mut ActiveProcess, pub(crate) resource_limits: &'a ResourceLimits, pub(crate) server_id: u64, @@ -4481,6 +4484,7 @@ where let socket_paths = build_javascript_socket_path_context(vm)?; let resource_limits = vm.kernel.resource_limits().clone(); let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let fs_changes = Arc::clone(&vm.fs_changes); let process = vm .active_processes .get_mut(&target_process_id) @@ -4497,6 +4501,7 @@ where socket_paths: &socket_paths, kernel: &mut vm.kernel, kernel_readiness, + fs_changes: Arc::clone(&fs_changes), process, resource_limits: &resource_limits, server_id, @@ -8261,6 +8266,7 @@ where socket_paths: &socket_paths, kernel: &mut vm.kernel, kernel_readiness, + fs_changes: Arc::clone(&vm.fs_changes), process: child, sync_request: &request, resource_limits: &resource_limits, @@ -9977,6 +9983,7 @@ fn sync_host_directory_tree_to_kernel_inner( kernel_error(error) )) })?; + vm.fs_changes.mark(&guest_path, Instant::now()); } sync_host_directory_tree_to_kernel_inner( vm, @@ -10062,7 +10069,9 @@ fn sync_host_directory_tree_to_kernel_inner( } }; match vm.kernel.write_file(&guest_path, bytes) { - Ok(()) => {} + // The mtime fast-path above skips unchanged entries, so every + // write that lands here is a real change worth broadcasting. + Ok(()) => vm.fs_changes.mark(&guest_path, Instant::now()), // ENOENT here means the guest-side path cannot currently // receive the write (e.g. it is a symlink whose target was // just unlinked by the guest — vim's swap-file dance). The @@ -16455,6 +16464,7 @@ where socket_paths, kernel, kernel_readiness, + fs_changes, process, sync_request: request, resource_limits, @@ -16715,11 +16725,11 @@ where } "fs.chmodSync" | "fs.promises.chmod" => { let response = - service_javascript_fs_sync_rpc(kernel, process, process.kernel_pid, request)?; + service_javascript_fs_sync_rpc(kernel, &fs_changes, process, process.kernel_pid, request)?; mirror_process_chmod_to_host(process, request)?; Ok(response) } - _ => service_javascript_fs_sync_rpc(kernel, process, process.kernel_pid, request), + _ => service_javascript_fs_sync_rpc(kernel, &fs_changes, process, process.kernel_pid, request), }?; Ok(response.into()) } @@ -19634,6 +19644,7 @@ fn service_host_fetch_target_event( socket_paths: &JavascriptSocketPathContext, kernel: &mut SidecarKernel, kernel_readiness: &KernelSocketReadinessRegistry, + fs_changes: &Arc, process: &mut ActiveProcess, resource_limits: &ResourceLimits, wait: Duration, @@ -19660,6 +19671,7 @@ where socket_paths, kernel, kernel_readiness: Arc::clone(kernel_readiness), + fs_changes: Arc::clone(fs_changes), process, sync_request: &request, resource_limits, @@ -19707,6 +19719,7 @@ where for _ in 0..32 { let dns = vm.dns.clone(); let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let fs_changes = Arc::clone(&vm.fs_changes); let Some(process) = vm.active_processes.get_mut(target_process_id) else { break; }; @@ -19717,6 +19730,7 @@ where socket_paths, &mut vm.kernel, &kernel_readiness, + &fs_changes, process, resource_limits, Duration::from_millis(1), @@ -19888,6 +19902,7 @@ where { let dns = vm.dns.clone(); let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let fs_changes = Arc::clone(&vm.fs_changes); let process = vm .active_processes .get_mut(target_process_id) @@ -19903,6 +19918,7 @@ where socket_paths, &mut vm.kernel, &kernel_readiness, + &fs_changes, process, resource_limits, Duration::from_millis(5), @@ -20097,6 +20113,7 @@ where socket_paths, kernel, kernel_readiness, + fs_changes, process, resource_limits, request_key, @@ -20137,6 +20154,7 @@ where socket_paths, kernel, kernel_readiness: Arc::clone(&kernel_readiness), + fs_changes: Arc::clone(&fs_changes), process, sync_request: &request, resource_limits, @@ -20185,6 +20203,7 @@ where socket_paths, kernel, kernel_readiness, + fs_changes, process, resource_limits, server_id, @@ -20215,6 +20234,7 @@ where socket_paths, kernel, kernel_readiness, + fs_changes, process, resource_limits, request_key: (server_id, request_id), diff --git a/crates/native-sidecar/src/filesystem.rs b/crates/native-sidecar/src/filesystem.rs index 989f5b8651..0e149f0095 100644 --- a/crates/native-sidecar/src/filesystem.rs +++ b/crates/native-sidecar/src/filesystem.rs @@ -432,6 +432,7 @@ where let response = core_guest_filesystem_call(&mut vm.kernel, payload.clone()) .map_err(native_guest_filesystem_core_error)?; mirror_guest_filesystem_shadow_after_call(vm, &payload)?; + mark_guest_filesystem_change(vm, &payload); response }; @@ -502,6 +503,52 @@ fn sync_guest_filesystem_shadow_before_call( Ok(()) } +/// Feed the VM's `filesystem.changed` coalescer after a successful host +/// fs-API mutation. Read-side operations mark nothing. +fn mark_guest_filesystem_change(vm: &VmState, payload: &GuestFilesystemCallRequest) { + let now = std::time::Instant::now(); + let changes = &vm.fs_changes; + match payload.operation { + GuestFilesystemOperation::WriteFile + | GuestFilesystemOperation::Pwrite + | GuestFilesystemOperation::CreateDir + | GuestFilesystemOperation::Mkdir + | GuestFilesystemOperation::Symlink + | GuestFilesystemOperation::Chmod + | GuestFilesystemOperation::Chown + | GuestFilesystemOperation::Utimes + | GuestFilesystemOperation::Truncate => changes.mark(&payload.path, now), + GuestFilesystemOperation::RemoveFile + | GuestFilesystemOperation::RemoveDir + | GuestFilesystemOperation::Remove => changes.mark_removed(&payload.path, now), + GuestFilesystemOperation::Copy => { + if let Some(destination) = payload.destination_path.as_deref() { + changes.mark_removed(destination, now); + } + } + GuestFilesystemOperation::Move | GuestFilesystemOperation::Rename => { + changes.mark_removed(&payload.path, now); + if let Some(destination) = payload.destination_path.as_deref() { + changes.mark_removed(destination, now); + } + } + GuestFilesystemOperation::Link => { + if let Some(destination) = payload.destination_path.as_deref() { + changes.mark(destination, now); + } + } + GuestFilesystemOperation::ReadFile + | GuestFilesystemOperation::Pread + | GuestFilesystemOperation::Exists + | GuestFilesystemOperation::Stat + | GuestFilesystemOperation::Lstat + | GuestFilesystemOperation::ReadDir + | GuestFilesystemOperation::ReadDirRecursive + | GuestFilesystemOperation::Realpath + | GuestFilesystemOperation::ReadLink => {} + } +} + fn mirror_guest_filesystem_shadow_after_call( vm: &mut VmState, payload: &GuestFilesystemCallRequest, @@ -827,6 +874,9 @@ where log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); return Ok(()); }; + if response.is_ok() { + mark_python_vfs_rpc_change(vm, &request); + } let Some(process) = vm.active_processes.get_mut(process_id) else { log_stale_process_event(&sidecar.bridge, vm_id, process_id, "python VFS RPC"); return Ok(()); @@ -844,6 +894,36 @@ where } } +/// Feed the VM's `filesystem.changed` coalescer after a successful mutating +/// Python VFS RPC. Paths that fail normalization were already rejected by the +/// handler, so this only sees requests whose operation succeeded. +fn mark_python_vfs_rpc_change(vm: &VmState, request: &PythonVfsRpcRequest) { + let now = Instant::now(); + let Ok(path) = normalize_python_vfs_rpc_path(&request.path) else { + return; + }; + match request.method { + PythonVfsRpcMethod::Write + | PythonVfsRpcMethod::Mkdir + | PythonVfsRpcMethod::Symlink + | PythonVfsRpcMethod::Setattr => vm.fs_changes.mark(&path, now), + PythonVfsRpcMethod::Unlink | PythonVfsRpcMethod::Rmdir => { + vm.fs_changes.mark_removed(&path, now); + } + PythonVfsRpcMethod::Rename => { + vm.fs_changes.mark_removed(&path, now); + if let Some(destination) = request + .destination + .as_deref() + .and_then(|destination| normalize_python_vfs_rpc_path(destination).ok()) + { + vm.fs_changes.mark_removed(&destination, now); + } + } + _ => {} + } +} + fn stale_filesystem_request_error( sidecar: &NativeSidecar, vm_id: &str, @@ -1221,6 +1301,102 @@ fn fs_sync_request_marks_host_write_dirty( }) } +/// Feed the VM's `filesystem.changed` coalescer for a mutating guest fs +/// sync-RPC. Marking is pre-dispatch and best-effort: a spurious mark for an +/// op that then fails only costs one redundant listing refetch, while argument +/// shapes this helper cannot resolve are logged at debug level rather than +/// failing the guest operation over observability bookkeeping. +fn mark_fs_sync_rpc_change( + fs_changes: &crate::fs_changes::FsChangeTracker, + kernel: &SidecarKernel, + process: &ActiveProcess, + kernel_pid: u32, + request: &JavascriptSyncRpcRequest, +) { + let now = Instant::now(); + let path_arg = |index: usize| -> Option { + match javascript_sync_rpc_path_arg(process, &request.args, index, "fs change mark") { + Ok(path) => Some(path), + Err(error) => { + tracing::debug!(method = %request.method, %error, "fs change mark skipped"); + None + } + } + }; + match request.method.as_str() { + // Descriptor-based writes: resolve the fd back to its path; stdio and + // unresolved fds are not filesystem entries and mark nothing. + "fs.write" | "fs.writeSync" | "fs.writevSync" | "fs.futimesSync" => { + let Ok(fd) = javascript_sync_rpc_arg_u32(&request.args, 0, "fs change mark fd") else { + return; + }; + if fd <= 2 { + return; + } + if let Some(mapped) = process.mapped_host_fd(fd) { + // Only the guest-visible alias belongs in guest-facing events; + // a mapped fd without one has no guest listing to invalidate. + if let Some(guest_path) = mapped.guest_path.as_deref() { + fs_changes.mark(guest_path, now); + } + return; + } + match kernel.fd_path(EXECUTION_DRIVER_NAME, kernel_pid, fd) { + Ok(path) => fs_changes.mark(&path, now), + Err(error) => { + tracing::debug!(method = %request.method, fd, %error, "fs change mark skipped"); + } + } + } + // Entry created or content/metadata changed at the path argument. A + // writable open is included for its O_CREAT case. + "fs.open" + | "fs.openSync" + | "fs.writeFileSync" + | "fs.promises.writeFile" + | "fs.mkdirSync" + | "fs.promises.mkdir" + | "fs.chmodSync" + | "fs.promises.chmod" + | "fs.chownSync" + | "fs.promises.chown" + | "fs.utimesSync" + | "fs.promises.utimes" + | "fs.lutimesSync" + | "fs.promises.lutimes" => { + if let Some(path) = path_arg(0) { + fs_changes.mark(&path, now); + } + } + "fs.unlinkSync" | "fs.promises.unlink" | "fs.rmdirSync" | "fs.promises.rmdir" => { + if let Some(path) = path_arg(0) { + fs_changes.mark_removed(&path, now); + } + } + "fs.renameSync" | "fs.promises.rename" => { + if let Some(path) = path_arg(0) { + fs_changes.mark_removed(&path, now); + } + if let Some(destination) = path_arg(1) { + fs_changes.mark_removed(&destination, now); + } + } + // copyFile(src, dest) / link(existing, new) / symlink(target, path): + // the second argument is the entry that appears. + "fs.copyFileSync" + | "fs.promises.copyFile" + | "fs.linkSync" + | "fs.promises.link" + | "fs.symlinkSync" + | "fs.promises.symlink" => { + if let Some(destination) = path_arg(1) { + fs_changes.mark(&destination, now); + } + } + _ => {} + } +} + pub(crate) fn service_javascript_fs_read_sync_rpc( kernel: &mut SidecarKernel, process: &mut ActiveProcess, @@ -1255,6 +1431,7 @@ pub(crate) fn service_javascript_fs_read_sync_rpc( pub(crate) fn service_javascript_fs_sync_rpc( kernel: &mut SidecarKernel, + fs_changes: &crate::fs_changes::FsChangeTracker, process: &mut ActiveProcess, kernel_pid: u32, request: &JavascriptSyncRpcRequest, @@ -1262,6 +1439,7 @@ pub(crate) fn service_javascript_fs_sync_rpc( let _phase_timer = FsSyncPhaseTimer::start(request.method.as_str()); if fs_sync_request_marks_host_write_dirty(request)? { process.mark_host_write_dirty(); + mark_fs_sync_rpc_change(fs_changes, kernel, process, kernel_pid, request); } match request.method.as_str() { "fs.open" | "fs.openSync" => { diff --git a/crates/native-sidecar/src/fs_changes.rs b/crates/native-sidecar/src/fs_changes.rs new file mode 100644 index 0000000000..904edb5d4c --- /dev/null +++ b/crates/native-sidecar/src/fs_changes.rs @@ -0,0 +1,186 @@ +//! Per-VM coalescer for guest filesystem mutations, feeding the +//! `filesystem.changed` structured wire event. +//! +//! Mutation handlers mark the parent directory of every successful write-side +//! operation; the stdio loop drains due trackers on its existing tick and emits +//! one VM-scoped event per flush window. Coalescing is lossy by design: past +//! `MAX_FS_CHANGED_DIRS` distinct directories in one window the tracker latches +//! `overflow` and consumers treat the whole tree as changed, so the event stays +//! truthful without an unbounded set. That degraded-but-honest representation is +//! why overflow is not a hard error. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; + +use agentos_bridge::queue_tracker::{register_queue, QueueGauge, TrackedLimit}; + +/// Distinct dirty directories buffered per VM per flush window. Raise by +/// recompiling; overflow collapses to a whole-tree invalidation rather than +/// dropping changes. +pub(crate) const MAX_FS_CHANGED_DIRS: usize = 64; + +/// Trailing-edge flush delay from the first mark in a window. Bounds the event +/// rate to ~3/s per VM under sustained guest churn. +pub(crate) const FS_CHANGED_FLUSH_INTERVAL: Duration = Duration::from_millis(300); + +/// One coalesced flush: the directories whose direct entries changed, and +/// whether the window overflowed (consumers must treat everything as changed). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FsChangeFlush { + pub(crate) dirs: Vec, + pub(crate) overflow: bool, +} + +struct FsChangeState { + dirs: BTreeSet, + overflow: bool, + first_marked_at: Option, +} + +pub(crate) struct FsChangeTracker { + inner: Mutex, + gauge: Arc, +} + +impl FsChangeTracker { + pub(crate) fn new() -> Self { + Self { + inner: Mutex::new(FsChangeState { + dirs: BTreeSet::new(), + overflow: false, + first_marked_at: None, + }), + gauge: register_queue(TrackedLimit::FsChangedDirtyDirs, MAX_FS_CHANGED_DIRS), + } + } + + /// Record a mutation of the entry at `path`: its parent directory's listing + /// is now stale. + pub(crate) fn mark(&self, path: &str, now: Instant) { + self.insert(parent_dir(path), now); + } + + /// Record removal or rename of the entry at `path`. Beyond the parent, the + /// path itself is marked: if it was a directory, queries against its own + /// listing must also go stale. + pub(crate) fn mark_removed(&self, path: &str, now: Instant) { + self.insert(parent_dir(path), now); + self.insert(normalize_dir(path), now); + } + + fn insert(&self, dir: String, now: Instant) { + let mut state = self.inner.lock().expect("fs change tracker poisoned"); + if state.first_marked_at.is_none() { + state.first_marked_at = Some(now); + } + if state.overflow { + return; + } + state.dirs.insert(dir); + self.gauge.observe_depth(state.dirs.len()); + if state.dirs.len() >= MAX_FS_CHANGED_DIRS { + tracing::warn!( + limit = "fs_changed_dirty_dirs", + capacity = MAX_FS_CHANGED_DIRS, + "fs change window overflowed; collapsing to whole-tree invalidation" + ); + state.dirs.clear(); + state.overflow = true; + } + } + + /// Drain and reset the tracker if a flush window elapsed. Returns `None` + /// while empty or before the window closes; `now` is a parameter so tests + /// stay sleep-free. + pub(crate) fn take_due(&self, now: Instant) -> Option { + let mut state = self.inner.lock().expect("fs change tracker poisoned"); + let first = state.first_marked_at?; + if now.duration_since(first) < FS_CHANGED_FLUSH_INTERVAL { + return None; + } + let dirs: Vec = std::mem::take(&mut state.dirs).into_iter().collect(); + let overflow = std::mem::take(&mut state.overflow); + state.first_marked_at = None; + self.gauge.observe_depth(0); + if dirs.is_empty() && !overflow { + return None; + } + Some(FsChangeFlush { dirs, overflow }) + } +} + +/// Parent directory of a normalized absolute guest path (`/a/b.txt` → `/a`, +/// `/a` → `/`, `/` → `/`). Trailing slashes are stripped first so `/a/b/` +/// resolves like `/a/b`. +pub(crate) fn parent_dir(path: &str) -> String { + let trimmed = normalize_dir(path); + match trimmed.rfind('/') { + Some(0) | None => "/".to_owned(), + Some(idx) => trimmed[..idx].to_owned(), + } +} + +fn normalize_dir(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_owned() + } else { + trimmed.to_owned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn flush_deadline(start: Instant) -> Instant { + start + FS_CHANGED_FLUSH_INTERVAL + } + + #[test] + fn parent_dir_handles_root_and_trailing_slashes() { + assert_eq!(parent_dir("/a/b.txt"), "/a"); + assert_eq!(parent_dir("/a"), "/"); + assert_eq!(parent_dir("/a/b/"), "/a"); + assert_eq!(parent_dir("/"), "/"); + } + + #[test] + fn marks_dedupe_and_flush_resets() { + let tracker = FsChangeTracker::new(); + let start = Instant::now(); + tracker.mark("/tmp/a.txt", start); + tracker.mark("/tmp/b.txt", start); + assert!(tracker.take_due(start).is_none(), "window still open"); + let flush = tracker + .take_due(flush_deadline(start)) + .expect("due after the window"); + assert_eq!(flush.dirs, vec!["/tmp".to_owned()]); + assert!(!flush.overflow); + assert!(tracker.take_due(flush_deadline(start)).is_none(), "reset"); + } + + #[test] + fn removed_dir_marks_itself_and_parent() { + let tracker = FsChangeTracker::new(); + let start = Instant::now(); + tracker.mark_removed("/tmp/dir", start); + let flush = tracker.take_due(flush_deadline(start)).expect("due"); + assert_eq!(flush.dirs, vec!["/tmp".to_owned(), "/tmp/dir".to_owned()]); + } + + #[test] + fn cap_latches_overflow_and_collapses_dirs() { + let tracker = FsChangeTracker::new(); + let start = Instant::now(); + for i in 0..(MAX_FS_CHANGED_DIRS + 5) { + tracker.mark(&format!("/d{i}/f"), start); + } + let flush = tracker.take_due(flush_deadline(start)).expect("due"); + assert!(flush.overflow); + assert!(flush.dirs.is_empty()); + } +} diff --git a/crates/native-sidecar/src/lib.rs b/crates/native-sidecar/src/lib.rs index 9ec9638234..1133317a8a 100644 --- a/crates/native-sidecar/src/lib.rs +++ b/crates/native-sidecar/src/lib.rs @@ -9,6 +9,7 @@ pub(crate) mod crypto_cipher; pub(crate) mod execution; pub mod extension; pub(crate) mod filesystem; +pub(crate) mod fs_changes; #[allow(dead_code)] pub(crate) mod json_rpc; pub mod limits; diff --git a/crates/native-sidecar/src/service.rs b/crates/native-sidecar/src/service.rs index c15445f1dd..4c1aca449a 100644 --- a/crates/native-sidecar/src/service.rs +++ b/crates/native-sidecar/src/service.rs @@ -1242,6 +1242,38 @@ where wire_dispatch_result(result) } + /// Drain every VM's due `filesystem.changed` window into VM-scoped wire + /// event frames. `now` is a parameter so tests can close flush windows + /// without sleeping; the stdio loop passes `Instant::now()` on its tick. + pub fn take_due_fs_change_event_frames( + &mut self, + now: std::time::Instant, + ) -> Result, SidecarError> { + let mut frames = Vec::new(); + for (vm_id, vm) in &self.vms { + let Some(flush) = vm.fs_changes.take_due(now) else { + continue; + }; + let dirs = serde_json::to_string(&flush.dirs).map_err(|error| { + SidecarError::InvalidState(format!("unencodable fs change dirs: {error}")) + })?; + let mut detail = std::collections::HashMap::new(); + detail.insert(String::from("dirs"), dirs); + detail.insert(String::from("overflow"), flush.overflow.to_string()); + let event = EventFrame::new( + OwnershipScope::vm(&vm.connection_id, &vm.session_id, vm_id), + EventPayload::Structured(crate::protocol::StructuredEvent { + name: String::from("filesystem.changed"), + detail, + }), + ); + frames.push(crate::wire::event_frame_from_compat(event).map_err(|error| { + SidecarError::InvalidState(format!("invalid fs change event frame: {error}")) + })?); + } + Ok(frames) + } + pub async fn poll_event_wire( &mut self, ownership: &crate::wire::OwnershipScope, @@ -2191,6 +2223,7 @@ where ))); } let kernel_readiness = Arc::clone(&vm.kernel_socket_readiness); + let fs_changes = Arc::clone(&vm.fs_changes); let Some(target_process) = vm.active_processes.get_mut(&payload.process_id) else { return Err(SidecarError::InvalidState(format!( @@ -2205,6 +2238,7 @@ where socket_paths: &socket_paths, kernel: &mut vm.kernel, kernel_readiness, + fs_changes: Arc::clone(&fs_changes), process: target_process, resource_limits: &resource_limits, server_id: payload.server_id, @@ -2275,6 +2309,7 @@ where socket_paths: &socket_paths, kernel: &mut vm.kernel, kernel_readiness, + fs_changes: Arc::clone(&vm.fs_changes), process, sync_request: &request, resource_limits: &resource_limits, diff --git a/crates/native-sidecar/src/state.rs b/crates/native-sidecar/src/state.rs index bb290b277c..55233214d5 100644 --- a/crates/native-sidecar/src/state.rs +++ b/crates/native-sidecar/src/state.rs @@ -357,6 +357,9 @@ pub(crate) struct VmState { /// packages ship no `agentos-package.json`, so agent enumeration and /// resolution read this instead of the guest filesystem. pub(crate) projected_agent_launch: BTreeMap, + /// Coalesced guest filesystem mutations feeding the `filesystem.changed` + /// wire event; drained by the stdio loop on its tick. + pub(crate) fs_changes: std::sync::Arc, } /// Launch parameters for one projected agent package. diff --git a/crates/native-sidecar/src/stdio.rs b/crates/native-sidecar/src/stdio.rs index fb1bfe15ea..3229534aa5 100644 --- a/crates/native-sidecar/src/stdio.rs +++ b/crates/native-sidecar/src/stdio.rs @@ -199,7 +199,6 @@ async fn run_async(extensions: Vec>) -> Result<(), Box>) -> Result<(), Box { - let _ = read_error_tx.send(message); - break; - } Err(StdinFrameQueueError::Closed) => break, } if should_stop { @@ -347,6 +342,9 @@ async fn run_async(extensions: Vec>) -> Result<(), Box { @@ -655,20 +653,28 @@ fn frame_kind(frame: &ProtocolFrame) -> &'static str { #[derive(Debug, Clone, PartialEq, Eq)] enum StdinFrameQueueError { - Full(String), Closed, } +// Apply backpressure rather than killing the sidecar when the host writes +// faster than the event loop drains — the exact mirror of the stdout fix in +// `send_output_frame`. Booting a VM with an agent floods stdin (config, +// software projection, session setup) while the loop is busy; previously +// `try_send` turned that transient backlog into a "stdin frame queue exceeded +// 128 pending frames" error that exited the whole sidecar and made agent boots +// fail outright. Parking the dedicated reader thread stops further reads, the +// OS pipe buffer fills, and the host's writes block — natural, recoverable +// backpressure. It never deadlocks: the async loop drains the queue +// independently, and if the receiver is gone the send fails and the reader +// exits (same terminal path as before). Near-capacity telemetry still flows +// through the `sidecar_stdin_frames` gauge. fn enqueue_stdin_frame( sender: &tokio::sync::mpsc::Sender, String>>, frame: Result, String>, ) -> Result<(), StdinFrameQueueError> { - sender.try_send(frame).map_err(|error| match error { - tokio::sync::mpsc::error::TrySendError::Full(_) => StdinFrameQueueError::Full(format!( - "stdin frame queue exceeded {MAX_STDIN_FRAME_QUEUE} pending frames" - )), - tokio::sync::mpsc::error::TrySendError::Closed(_) => StdinFrameQueueError::Closed, - }) + sender + .blocking_send(frame) + .map_err(|_closed| StdinFrameQueueError::Closed) } fn flush_sidecar_requests( @@ -791,17 +797,6 @@ mod tests { #[test] fn stdio_work_queues_are_bounded() { - let (stdin_tx, _stdin_rx) = - channel::, String>>(MAX_STDIN_FRAME_QUEUE); - for _ in 0..MAX_STDIN_FRAME_QUEUE { - enqueue_stdin_frame(&stdin_tx, Ok(None)) - .expect("stdin frame queue should accept capacity"); - } - assert!(matches!( - enqueue_stdin_frame(&stdin_tx, Ok(None)), - Err(StdinFrameQueueError::Full(_)) - )); - let (event_ready_tx, _event_ready_rx) = channel::<()>(MAX_EVENT_READY_QUEUE); event_ready_tx .try_send(()) @@ -812,6 +807,39 @@ mod tests { )); } + // Regression: a full stdin frame queue must apply backpressure (park the + // reader thread until the async loop drains a slot), NOT tear the sidecar + // down. Booting a VM with an agent floods stdin (config, software + // projection, session setup) while the loop is busy; the old `try_send` + // turned that backlog into a "stdin frame queue exceeded 128 pending + // frames" error that exited the whole sidecar and failed the boot. + #[test] + fn stdin_frame_queue_applies_backpressure_instead_of_crashing() { + const SENDS: usize = 8; + let (stdin_tx, mut stdin_rx) = channel::, String>>(2); + let producer = thread::spawn(move || { + for _ in 0..SENDS { + enqueue_stdin_frame(&stdin_tx, Ok(None)) + .expect("a full queue must park the sender, not fail"); + } + }); + let mut received = 0; + while received < SENDS { + if stdin_rx.blocking_recv().is_some() { + received += 1; + } + } + producer.join().expect("producer thread"); + + // A dropped receiver (dead event loop) is the only terminal condition. + let (closed_tx, closed_rx) = channel::, String>>(1); + drop(closed_rx); + assert!(matches!( + enqueue_stdin_frame(&closed_tx, Ok(None)), + Err(StdinFrameQueueError::Closed) + )); + } + // Regression: a full stdout frame queue must apply backpressure (block the // producer until the writer drains a slot), NOT tear the sidecar down. The // old `try_send` turned a slow host reader into a `BrokenPipe` error that diff --git a/crates/native-sidecar/src/vm.rs b/crates/native-sidecar/src/vm.rs index be3afd17c3..15638b0214 100644 --- a/crates/native-sidecar/src/vm.rs +++ b/crates/native-sidecar/src/vm.rs @@ -358,6 +358,7 @@ where signal_states: BTreeMap::new(), packages_staging_root: None, projected_agent_launch: BTreeMap::new(), + fs_changes: std::sync::Arc::new(crate::fs_changes::FsChangeTracker::new()), }, ); diff --git a/crates/native-sidecar/tests/fixtures/limits-inventory.json b/crates/native-sidecar/tests/fixtures/limits-inventory.json index 65f5098b8a..97530635d7 100644 --- a/crates/native-sidecar/tests/fixtures/limits-inventory.json +++ b/crates/native-sidecar/tests/fixtures/limits-inventory.json @@ -1114,5 +1114,11 @@ "path": "crates/execution/src/wasm.rs", "class": "invariant", "rationale": "Host-side LRU entry cap for cached wasm module bytes; process-wide cache sizing, not per-VM policy." + }, + { + "name": "MAX_FS_CHANGED_DIRS", + "path": "crates/native-sidecar/src/fs_changes.rs", + "class": "invariant", + "rationale": "Per-VM dirty-directory coalescing bound for the filesystem.changed event; overflow collapses to a whole-tree invalidation instead of dropping changes, so it is event granularity, not a guest-allocatable resource." } ] diff --git a/crates/native-sidecar/tests/fs_change_events.rs b/crates/native-sidecar/tests/fs_change_events.rs new file mode 100644 index 0000000000..7e3fd8e7aa --- /dev/null +++ b/crates/native-sidecar/tests/fs_change_events.rs @@ -0,0 +1,189 @@ +//! Wire-level coverage for the coalesced `filesystem.changed` structured event: +//! guest filesystem mutations mark the per-VM tracker, and draining after the +//! flush window emits one VM-scoped event frame with the changed parent +//! directories (or an overflow collapse past the dirty-dir bound). + +mod support; + +use std::path::Path; +use std::time::{Duration, Instant}; + +use agentos_native_sidecar::wire::{ + EventPayload, GuestFilesystemCallRequest, GuestFilesystemOperation, GuestRuntimeKind, + OwnershipScope, RequestPayload, ResponsePayload, RootFilesystemEntryEncoding, +}; + +use crate::support::{authenticate_wire, create_vm_wire, open_session_wire, wire_request, wire_vm}; + +/// Comfortably past `FS_CHANGED_FLUSH_INTERVAL` (300ms) without sleeping — +/// `take_due_fs_change_event_frames` takes `now` as a parameter. +const PAST_FLUSH_WINDOW: Duration = Duration::from_secs(1); + +fn base_fs_call(operation: GuestFilesystemOperation, path: &str) -> GuestFilesystemCallRequest { + GuestFilesystemCallRequest { + operation, + path: String::from(path), + destination_path: None, + target: None, + content: None, + encoding: None, + recursive: false, + max_depth: None, + mode: None, + uid: None, + gid: None, + atime_ms: None, + mtime_ms: None, + len: None, + offset: None, + } +} + +fn dispatch_fs_call( + sidecar: &mut agentos_native_sidecar::NativeSidecar, + scope: &(String, String, String), + request_id: i64, + payload: GuestFilesystemCallRequest, +) { + let (connection_id, session_id, vm_id) = scope; + let response = sidecar + .dispatch_wire_blocking(wire_request( + request_id, + wire_vm(connection_id, session_id, vm_id), + RequestPayload::GuestFilesystemCallRequest(payload), + )) + .expect("dispatch guest filesystem call"); + match response.response.payload { + ResponsePayload::GuestFilesystemResultResponse(_) => {} + other => panic!("expected guest_filesystem_result response, got {other:?}"), + } +} + +fn drain_fs_change_events( + sidecar: &mut agentos_native_sidecar::NativeSidecar, + now: Instant, +) -> Vec<(OwnershipScope, Vec, bool)> { + sidecar + .take_due_fs_change_event_frames(now) + .expect("drain fs change event frames") + .into_iter() + .map(|frame| match frame.payload { + EventPayload::StructuredEvent(event) => { + assert_eq!(event.name, "filesystem.changed"); + let dirs: Vec = serde_json::from_str( + event.detail.get("dirs").expect("dirs detail present"), + ) + .expect("dirs detail is a JSON string array"); + let overflow = event + .detail + .get("overflow") + .expect("overflow detail present") + == "true"; + (frame.ownership, dirs, overflow) + } + other => panic!("expected structured event payload, got {other:?}"), + }) + .collect() +} + +#[test] +fn fs_change_events_suite() { + support::acquire_sidecar_runtime_test_lock(); + let mut sidecar = support::new_sidecar("fs-change-events"); + let connection_id = authenticate_wire(&mut sidecar, "conn-1"); + let session_id = open_session_wire(&mut sidecar, 2, &connection_id); + let cwd = support::temp_dir("fs-change-events-cwd"); + let (vm_id, _) = create_vm_wire( + &mut sidecar, + 3, + &connection_id, + &session_id, + GuestRuntimeKind::JavaScript, + Path::new(&cwd), + ); + let scope = (connection_id.clone(), session_id.clone(), vm_id.clone()); + + // VM creation itself projects files; drain whatever window that opened so + // the assertions below observe only this test's mutations. + let _ = drain_fs_change_events(&mut sidecar, Instant::now() + PAST_FLUSH_WINDOW); + + // Two writes into the same directory plus a mkdir: parents dedupe, and the + // window holds until the flush interval elapses. + dispatch_fs_call(&mut sidecar, &scope, 10, { + let mut call = base_fs_call(GuestFilesystemOperation::WriteFile, "/tmp/a.txt"); + call.content = Some(String::from("a")); + call.encoding = Some(RootFilesystemEntryEncoding::Utf8); + call + }); + dispatch_fs_call(&mut sidecar, &scope, 11, { + let mut call = base_fs_call(GuestFilesystemOperation::WriteFile, "/tmp/b.txt"); + call.content = Some(String::from("b")); + call.encoding = Some(RootFilesystemEntryEncoding::Utf8); + call + }); + dispatch_fs_call( + &mut sidecar, + &scope, + 12, + base_fs_call(GuestFilesystemOperation::Mkdir, "/tmp/newdir"), + ); + + assert!( + drain_fs_change_events(&mut sidecar, Instant::now()).is_empty(), + "no frame before the flush window closes" + ); + + let events = drain_fs_change_events(&mut sidecar, Instant::now() + PAST_FLUSH_WINDOW); + assert_eq!(events.len(), 1, "one coalesced frame per VM per window"); + let (ownership, dirs, overflow) = &events[0]; + assert!(!overflow); + assert_eq!(dirs, &vec![String::from("/tmp")], "parents dedupe"); + match ownership { + OwnershipScope::VmOwnership(vm_scope) => assert_eq!(vm_scope.vm_id, vm_id), + other => panic!("expected vm-scoped ownership, got {other:?}"), + } + assert!( + drain_fs_change_events(&mut sidecar, Instant::now() + PAST_FLUSH_WINDOW).is_empty(), + "drain resets the window" + ); + + // A rename marks source and destination parents plus the moved entry + // itself (its own listing goes stale if it was a directory). + dispatch_fs_call(&mut sidecar, &scope, 20, { + let mut call = base_fs_call(GuestFilesystemOperation::Rename, "/tmp/a.txt"); + call.destination_path = Some(String::from("/tmp/newdir/a.txt")); + call + }); + let events = drain_fs_change_events(&mut sidecar, Instant::now() + PAST_FLUSH_WINDOW); + assert_eq!(events.len(), 1); + let (_, dirs, overflow) = &events[0]; + assert!(!overflow); + for expected in ["/tmp", "/tmp/a.txt", "/tmp/newdir", "/tmp/newdir/a.txt"] { + assert!( + dirs.contains(&String::from(expected)), + "rename should mark {expected}; got {dirs:?}" + ); + } + + // Past the dirty-dir bound the window collapses to overflow with no dirs. + for index in 0..70 { + dispatch_fs_call( + &mut sidecar, + &scope, + 100 + index, + { + let mut call = base_fs_call( + GuestFilesystemOperation::Mkdir, + &format!("/tmp/overflow-{index}/leaf"), + ); + call.recursive = true; + call + }, + ); + } + let events = drain_fs_change_events(&mut sidecar, Instant::now() + PAST_FLUSH_WINDOW); + assert_eq!(events.len(), 1); + let (_, dirs, overflow) = &events[0]; + assert!(overflow, "past the bound the event collapses to overflow"); + assert!(dirs.is_empty()); +} diff --git a/packages/agentos/package.json b/packages/agentos/package.json index 954b84b8ce..69cfad16d4 100644 --- a/packages/agentos/package.json +++ b/packages/agentos/package.json @@ -82,6 +82,10 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/headless": "^6.0.0", + "@xterm/xterm": "^6.0.0", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index f741bba5d7..bc3c4ae878 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -356,29 +356,26 @@ const AGENTOS_INSPECTOR_CONFIG = { icon: "comments", }, { - id: "filesystem", - label: "Filesystem", + id: "terminal", + label: "Terminal", source: INSPECTOR_TABS_ASSET_DIR, - icon: "folder-tree", + icon: "terminal", }, { - id: "processes", - label: "Processes", - source: INSPECTOR_TABS_ASSET_DIR, - icon: "microchip", - }, - { - id: "software", - label: "Software", + id: "filesystem", + label: "Filesystem", source: INSPECTOR_TABS_ASSET_DIR, - icon: "box-archive", + icon: "folder-tree", }, { - id: "mounts", - label: "Mounts", + id: "system", + label: "System", source: INSPECTOR_TABS_ASSET_DIR, - icon: "hard-drive", + icon: "layer-group", }, + // Processes/software/mounts live as sections inside the System tab. + // `metadata` is dashboard-owned and not hideable (the schema only + // accepts the six built-in ids below), so it stays. ...["workflow", "database", "state", "queue", "connections", "console"].map( (id) => ({ id, hidden: true as const }), ), diff --git a/packages/agentos/src/generated/actor-actions.generated.ts b/packages/agentos/src/generated/actor-actions.generated.ts index 9671fd7470..215f2f36de 100644 --- a/packages/agentos/src/generated/actor-actions.generated.ts +++ b/packages/agentos/src/generated/actor-actions.generated.ts @@ -118,6 +118,56 @@ export interface SoftwareInfo { commands: string[]; } +export interface LiveSessionInfo { + sessionId: string; + agentType: string; +} + +export interface PendingPermissionInfo { + sessionId: string; + permissionId: string; + description?: string; + params: Record; + requestedAt: number; +} + +export interface RuntimeLimitWarning { + ts: number; + limit: string; + category: string; + observed: number; + capacity: number; + fillPercent: number; +} + +export interface RuntimeAgentExit { + ts: number; + sessionId: string; + agentType: string; + exitCode: number | null; + restart: string; + restartCount: number; +} + +export interface RuntimeStderrLine { + ts: number; + line: string; +} + +export interface RuntimeSidecarInfo { + state: string; + activeVmCount: number; +} + +export interface RuntimeHealth { + booted: boolean; + sessions: number | null; + sidecar: RuntimeSidecarInfo | null; + warnings: RuntimeLimitWarning[]; + agentExits: RuntimeAgentExit[]; + stderrTail: RuntimeStderrLine[]; +} + export type AgentOsActions = { readFile: (c: Ctx, path: string) => Promise; writeFile: (c: Ctx, path: string, content: string | Uint8Array) => Promise; @@ -161,4 +211,8 @@ export type AgentOsActions = { expireSignedPreviewUrl: (c: Ctx, token: string) => Promise; listMounts: (c: Ctx) => Promise; listSoftware: (c: Ctx) => Promise; + getRuntimeHealth: (c: Ctx) => Promise; + listSessions: (c: Ctx) => Promise; + cancelPrompt: (c: Ctx, sessionId: string) => Promise; + listPendingPermissions: (c: Ctx) => Promise; }; diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index d745dd1706..7842ef19d2 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -75,6 +75,7 @@ export type { AgentOsEvents, CronEventPayload, PermissionRequestPayload, + PermissionResolvedPayload, PersistedSessionEvent, PersistedSessionRecord, ProcessExitPayload, diff --git a/packages/agentos/src/inspector-tabs/assets.d.ts b/packages/agentos/src/inspector-tabs/assets.d.ts new file mode 100644 index 0000000000..1760304d6f --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets.d.ts @@ -0,0 +1,5 @@ +// Vite emits imported SVGs as asset URLs in the tabs bundle. +declare module "*.svg" { + const src: string; + export default src; +} diff --git a/packages/agentos/src/inspector-tabs/assets/agentos-hero-logo.svg b/packages/agentos/src/inspector-tabs/assets/agentos-hero-logo.svg new file mode 100644 index 0000000000..e3443834cc --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/agentos-hero-logo.svg @@ -0,0 +1,74 @@ + + + + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/browserbase.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/browserbase.svg new file mode 100644 index 0000000000..e10699d74e --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/browserbase.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/claude-code.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/claude-code.svg new file mode 100644 index 0000000000..c2c148c2d8 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/claude-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/codex.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/codex.svg new file mode 100644 index 0000000000..6922a74eae --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/codex.svg @@ -0,0 +1 @@ +OpenAI diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/coreutils.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/coreutils.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/coreutils.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/curl.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/curl.svg new file mode 100644 index 0000000000..5788acc2d8 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/curl.svg @@ -0,0 +1 @@ +curl \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/diffutils.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/diffutils.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/diffutils.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/duckdb.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/duckdb.svg new file mode 100644 index 0000000000..ac31e6f96b --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/duckdb.svg @@ -0,0 +1 @@ +DuckDB \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/findutils.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/findutils.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/findutils.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/gawk.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/gawk.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/gawk.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/git.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/git.svg new file mode 100644 index 0000000000..52ced6bb97 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/git.svg @@ -0,0 +1 @@ +Git \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/grep.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/grep.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/grep.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/gzip.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/gzip.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/gzip.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/jq.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/jq.svg new file mode 100644 index 0000000000..1f9e2ddf6d --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/jq.svg @@ -0,0 +1 @@ + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/nodejs.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/nodejs.svg new file mode 100644 index 0000000000..3cc5893e0c --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/nodejs.svg @@ -0,0 +1 @@ +Node.js \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/opencode.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/opencode.svg new file mode 100644 index 0000000000..6d12bab7e0 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/opencode.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/pi.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/pi.svg new file mode 100644 index 0000000000..bdc0fddce9 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/pi.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/python.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/python.svg new file mode 100644 index 0000000000..e1b14ec64b --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/python.svg @@ -0,0 +1 @@ +Python \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/sed.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/sed.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/sed.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/sqlite3.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/sqlite3.svg new file mode 100644 index 0000000000..3bfccb838a --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/sqlite3.svg @@ -0,0 +1 @@ +SQLite \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/super-memory.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/super-memory.svg new file mode 100644 index 0000000000..3120c43499 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/super-memory.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/tar.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/tar.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/tar.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/vim.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/vim.svg new file mode 100644 index 0000000000..63d15125b0 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/vim.svg @@ -0,0 +1 @@ +Vim \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/assets/software-logos/wget.svg b/packages/agentos/src/inspector-tabs/assets/software-logos/wget.svg new file mode 100644 index 0000000000..edfb29bee5 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/assets/software-logos/wget.svg @@ -0,0 +1 @@ +GNU \ No newline at end of file diff --git a/packages/agentos/src/inspector-tabs/common.tsx b/packages/agentos/src/inspector-tabs/common.tsx index 0cd43c4013..daa4fb75da 100644 --- a/packages/agentos/src/inspector-tabs/common.tsx +++ b/packages/agentos/src/inspector-tabs/common.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from "react"; +import agentOsHeroLogo from "./assets/agentos-hero-logo.svg"; +import { type ActionErrorLayer, isInspectorActionError } from "./lib/actor-client"; import { cn } from "./lib/cn"; import React, { useState } from "react"; @@ -11,6 +13,52 @@ export function AgentOsEmpty({ children }: { children: ReactNode }) { ); } +const LAYER_LABEL: Record = { + gateway: "Gateway unreachable", + auth: "Not authorized", + contract: "Not supported by this runtime", + runtime: "Runtime error", + timeout: "Timed out", +}; + +/** "This runtime doesn't expose X" empty state for contract-layer failures — + * the graceful-degradation path for actors built against other runtimes. */ +export function UnsupportedAction({ action, className }: { action?: string; className?: string }) { + return ( +
+ Not supported by this runtime + {action ? ( + + the actor does not expose {action} + + ) : null} +
+ ); +} + +/** Compact inline error note: layer label + message + hint. Used by inline + * (non-suspense) error paths; the suspense path renders via TabBoundary. */ +export function ActionErrorNote({ error, className }: { error: unknown; className?: string }) { + if (isInspectorActionError(error) && error.layer === "contract") { + return ; + } + const layer = isInspectorActionError(error) ? error.layer : undefined; + const message = error instanceof Error ? error.message : String(error); + const hint = isInspectorActionError(error) ? error.hint : undefined; + return ( +
+
+ + + {layer ? LAYER_LABEL[layer] : "Error"} + +
+
{message}
+ {hint ?
{hint}
: null} +
+ ); +} + export type DotColor = "green" | "amber" | "red" | "muted"; const DOT_CLASS: Record = { green: "text-green-500", @@ -67,7 +115,7 @@ function CopyIcon({ className }: { className?: string }) { ); } -function CheckIcon({ className }: { className?: string }) { +export function CheckIcon({ className }: { className?: string }) { return ( + ); +} + +/** Small bordered icon button for tab chrome (new/refresh/upload/…). The + * accessible name comes from `title`, which doubles as the tooltip. */ +export function IconButton({ + title, + onClick, + disabled, + destructive, + className, + children, +}: { + title: string; + onClick: () => void; + disabled?: boolean; + destructive?: boolean; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + +const iconProps = { + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": true, +} as const; + +export function PlusIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function SearchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function ArrowLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function RefreshIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function FolderPlusIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function UploadIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function DownloadIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function PencilIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function TrashIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + /** Inline folder/file glyphs for the filesystem tree. */ export function FileGlyph({ dir, className }: { dir: boolean; className?: string }) { return ( diff --git a/packages/agentos/src/inspector-tabs/lib/actor-client.ts b/packages/agentos/src/inspector-tabs/lib/actor-client.ts index 99962cc799..c4de484584 100644 --- a/packages/agentos/src/inspector-tabs/lib/actor-client.ts +++ b/packages/agentos/src/inspector-tabs/lib/actor-client.ts @@ -32,10 +32,89 @@ export function tabIdFromUrl(): string | undefined { return idx >= 0 ? parts[idx + 1] : undefined; } -export interface ActionError { - group?: string; - code?: string; - message?: string; +/** Which hop of the dashboard→gateway→actor→VM chain an action failure came + * from. Drives per-layer rendering (tab-boundary.tsx / common.tsx) and the + * retry policy (main.tsx): contract/auth failures never retry. */ +export type ActionErrorLayer = "gateway" | "auth" | "contract" | "runtime" | "timeout"; + +export class InspectorActionError extends Error { + readonly layer: ActionErrorLayer; + readonly action: string; + readonly hint: string; + constructor(layer: ActionErrorLayer, action: string, message: string, hint: string) { + super(message); + this.name = "InspectorActionError"; + this.layer = layer; + this.action = action; + this.hint = hint; + } +} + +export function isInspectorActionError(error: unknown): error is InspectorActionError { + return ( + error instanceof InspectorActionError || + (typeof error === "object" && + error !== null && + (error as { name?: string }).name === "InspectorActionError") + ); +} + +/** Map a raw transport/actor error onto the failing layer. Order matters: + * abort before contract before runtime (messages overlap on "error"). */ +function classifyActionError(action: string, error: unknown): InspectorActionError { + const message = error instanceof Error ? error.message : String(error); + const rivet = (error ?? {}) as { + group?: string; + code?: string; + statusCode?: number; + name?: string; + }; + if (rivet.name === "AbortError" || /\baborted\b/i.test(message)) { + return new InspectorActionError( + "timeout", + action, + `${action} timed out`, + "The VM may still be booting or the call is hung — retry in a few seconds.", + ); + } + if (/was not found/i.test(message) && /action/i.test(message)) { + return new InspectorActionError( + "contract", + action, + `This runtime does not expose the \`${action}\` action.`, + "The actor was built against a different agentOS runtime version; this panel is unavailable.", + ); + } + if ( + rivet.statusCode === 401 || + rivet.statusCode === 403 || + rivet.code === "unauthorized" || + /bearer token|x-rivet-token|unauthorized|forbidden/i.test(message) + ) { + return new InspectorActionError( + "auth", + action, + message, + "The inspector's token was rejected — reload the dashboard to refresh credentials.", + ); + } + if (rivet.code === "internal_error" || /internal error/i.test(message)) { + return new InspectorActionError( + "runtime", + action, + `${action} failed inside the actor runtime.`, + "The engine masks the underlying error. Common causes: the agent adapter timed out while booting (retry — a warm VM answers faster) or the sidecar exited. The server log has the raw error.", + ); + } + if (error instanceof TypeError || /fetch failed|failed to fetch|networkerror/i.test(message)) { + return new InspectorActionError( + "gateway", + action, + `Could not reach the actor gateway (${message}).`, + "The engine/gateway is unreachable — check that the server is running.", + ); + } + return new InspectorActionError("runtime", action, message || `${action} failed`, "See server logs for the underlying error."); } function getHandle(): Any { @@ -63,6 +142,8 @@ export async function callAction( const timer = opts.timeoutMs ? setTimeout(() => ctrl.abort(), opts.timeoutMs) : undefined; try { return (await h.action({ name, args, signal: ctrl.signal })) as T; + } catch (error) { + throw classifyActionError(name, error); } finally { if (timer) clearTimeout(timer); } diff --git a/packages/agentos/src/inspector-tabs/lib/health.ts b/packages/agentos/src/inspector-tabs/lib/health.ts new file mode 100644 index 0000000000..d1cd161b96 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/health.ts @@ -0,0 +1,13 @@ +// Feature detection for the observe-only runtime-health actions. The queries +// themselves (healthQueryOptions / liveSessionsQueryOptions / cancelPrompt) +// live in lib/source.ts now that getRuntimeHealth / listSessions / +// cancelPrompt are real contract actions; this module keeps only the +// contract-layer detection callers use to hide health UI when a vendored tab +// bundle runs against an OLDER runtime that lacks the actions (those reject +// with a contract-layer InspectorActionError; the status strip hides itself +// and the composer disables its Stop button). +import { isInspectorActionError } from "./actor-client"; + +export function isMissingHealthAction(error: unknown): boolean { + return isInspectorActionError(error) && error.layer === "contract"; +} diff --git a/packages/agentos/src/inspector-tabs/lib/shell-capture.tsx b/packages/agentos/src/inspector-tabs/lib/shell-capture.tsx new file mode 100644 index 0000000000..e9cbc9b7ae --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/shell-capture.tsx @@ -0,0 +1,183 @@ +// Background shell recorder, mounted in every inspector tab document EXCEPT +// the terminal (which owns the store while it is on screen). The dashboard +// swaps the iframe per tab, so while the user is on Filesystem or Transcript +// this component keeps each live shell's state current in a headless terminal +// mirror (lib/shell-mirror) — answering the shell's terminal queries so its +// line editor does not fall back to destructive clear-screen repaints — and +// persists serialized snapshots on pagehide. Switching back to the terminal +// then replays a scrollback with no gap and no corruption. +import { useEffect, useRef } from "react"; +import { useAgentOsActor } from "./rivet"; +import type { ShellMirror } from "./shell-mirror"; +import { + loadPersistedShells, + type PersistedShells, + savePersistedShells, + trimScrollback, +} from "./shell-store"; +import { agentOsSource, decodeActionBytes } from "./source"; +import type { ShellDataPayload, ShellExitPayload } from "./types"; + +interface CaptureStore { + payload: PersistedShells; + /** One headless mirror per live shell; exited/dead shells keep their final + * snapshot in the payload record instead. */ + mirrors: Map; + /** Streaming decoders per shell per stream — chunk boundaries can split + * multibyte UTF-8, and stdout/stderr interleave. */ + decoders: Map; +} + +export function ShellOutputCapture({ actorId }: { actorId: string }) { + const storeRef = useRef(null); + // The headless-terminal module loads lazily (it must stay out of the shared + // main bundle); events arriving before it is ready are queued, bounded. + const pendingRef = useRef<{ shellId: string; stream: "out" | "err"; data: Uint8Array }[]>([]); + const readyRef = useRef(false); + // Query replies are separate HTTP requests; serialize them so two in flight + // cannot reorder the shell's input bytes. The chain never rejects. + const writeChainRef = useRef>(Promise.resolve()); + + const reply = (shellId: string, data: string) => { + writeChainRef.current = writeChainRef.current.then(() => + agentOsSource.writeShell(shellId, data).then( + () => undefined, + (error) => { + console.warn(`agentos inspector: capture reply to ${shellId} failed`, error); + }, + ), + ); + }; + + const decodersFor = (s: CaptureStore, shellId: string) => { + let d = s.decoders.get(shellId); + if (!d) { + d = { out: new TextDecoder(), err: new TextDecoder() }; + s.decoders.set(shellId, d); + } + return d; + }; + + const feed = (shellId: string, stream: "out" | "err", data: Uint8Array) => { + const s = storeRef.current; + if (!s) return; + const mirror = s.mirrors.get(shellId); + if (!mirror) return; + mirror.write(decodersFor(s, shellId)[stream].decode(data, { stream: true })); + }; + + // Boot the store once: load the persisted payload, then the mirror module, + // then a seeded mirror per live shell. All later events feed the mirrors. + useEffect(() => { + const payload = loadPersistedShells(actorId); + if (!payload || !payload.shells.some((s) => (s.status ?? "live") === "live")) return; + let disposed = false; + void import("./shell-mirror").then(({ ShellMirror: Mirror }) => { + if (disposed) return; + const mirrors = new Map(); + for (const record of payload.shells) { + if ((record.status ?? "live") !== "live") continue; + const mirror = new Mirror({ + cols: payload.cols ?? 80, + rows: payload.rows ?? 24, + onReply: (data) => reply(record.shellId, data), + }); + // Answer queries once the seed replay is done: this document is + // the only terminal the shell has while the tab is hidden. + mirror.seed(record.scrollback, true); + mirrors.set(record.shellId, mirror); + } + storeRef.current = { payload, mirrors, decoders: new Map() }; + readyRef.current = true; + for (const event of pendingRef.current) feed(event.shellId, event.stream, event.data); + pendingRef.current = []; + }); + return () => { + disposed = true; + const s = storeRef.current; + if (s) { + for (const mirror of s.mirrors.values()) mirror.dispose(); + s.mirrors.clear(); + } + storeRef.current = null; + readyRef.current = false; + }; + }, [actorId]); + + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + + const onShellOutput = (raw: unknown, stream: "out" | "err") => { + const payload = raw as ShellDataPayload | undefined; + if (!payload?.shellId) return; + const data = decodeActionBytes(payload.data); + if (!readyRef.current) { + if (pendingRef.current.length < 1024) pendingRef.current.push({ shellId: payload.shellId, stream, data }); + return; + } + feed(payload.shellId, stream, data); + }; + useAgentEvent("shellData", (raw) => onShellOutput(raw, "out")); + useAgentEvent("shellStderr", (raw) => onShellOutput(raw, "err")); + + // A shell that ends while hidden: freeze its final snapshot (plus the note + // the terminal would have shown) into the record and save immediately. + const finish = (shellId: string, status: "exited" | "dead", note: string, exitCode?: number) => { + const s = storeRef.current; + const record = s?.payload.shells.find((sh) => sh.shellId === shellId); + if (!s || !record || (record.status ?? "live") !== "live") return; + const mirror = s.mirrors.get(shellId); + record.status = status; + record.exitCode = exitCode; + record.scrollback = trimScrollback((mirror ? mirror.serialize() : record.scrollback) + note); + if (mirror) { + mirror.dispose(); + s.mirrors.delete(shellId); + } + savePersistedShells(actorId, s.payload); + }; + useAgentEvent("shellExit", (raw) => { + const payload = raw as ShellExitPayload | undefined; + if (!payload?.shellId) return; + finish( + payload.shellId, + "exited", + `\r\n\x1b[2m[shell exited with code ${payload.exitCode}]\x1b[0m\r\n`, + payload.exitCode, + ); + }); + useAgentEvent("vmShutdown", (raw) => { + const s = storeRef.current; + if (!s) return; + const reason = (raw as { reason?: string } | undefined)?.reason; + const note = `\r\n\x1b[2m[VM shut down${reason ? ` (${reason})` : ""} — shell terminated]\x1b[0m\r\n`; + for (const record of [...s.payload.shells]) { + if ((record.status ?? "live") === "live") finish(record.shellId, "dead", note); + } + }); + + // Persist on every pagehide — even with no new output, the fresh savedAt + // attests that a capture document was alive (the terminal's reattach uses + // that to decide whether a real gap note is warranted). + useEffect(() => { + const flush = () => { + const s = storeRef.current; + if (!s) return; + for (const record of s.payload.shells) { + const mirror = s.mirrors.get(record.shellId); + if (mirror) record.scrollback = trimScrollback(mirror.serialize()); + } + savePersistedShells(actorId, s.payload); + }; + window.addEventListener("pagehide", flush); + return () => { + window.removeEventListener("pagehide", flush); + flush(); + }; + }, [actorId]); + + return null; +} diff --git a/packages/agentos/src/inspector-tabs/lib/shell-mirror.ts b/packages/agentos/src/inspector-tabs/lib/shell-mirror.ts new file mode 100644 index 0000000000..856f242d96 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/shell-mirror.ts @@ -0,0 +1,86 @@ +// Headless terminal mirror for one shell — the piece that makes hidden shells +// behave. The VM shell's line editor (reedline) queries the terminal (cursor +// position reports, "ESC[6n") and, when no answer arrives, falls back to +// repainting its prompt with a home+clear-screen sequence — wiping everything +// on the next replay. A visible xterm answers those queries automatically; a +// mirror answers them for shells nobody is looking at (background shells in +// the terminal tab, and every shell while another inspector tab is open), and +// its serialized buffer is the clean scrollback snapshot that gets persisted. +// +// This module is imported lazily (dynamic import) so the headless terminal +// stays out of the shared main bundle, mirroring the xterm.js confinement of +// the terminal chunk. +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as HeadlessTerminal } from "@xterm/headless"; + +export class ShellMirror { + private readonly term: HeadlessTerminal; + private readonly serializer: SerializeAddon; + /** Gate query answers: off while seeding persisted scrollback (answers to + * long-gone queries must not reach the live shell) and for the shell the + * visible xterm is rendering (it answers; double replies corrupt input). */ + private answering = false; + /** True once the seed snapshot has been processed — `serialize()` before + * that returns an empty screen, so callers fall back to the raw text. */ + private ready = false; + + constructor(opts: { + cols: number; + rows: number; + /** Forward terminal query replies (DSR/CPR, DA, …) to the shell. */ + onReply: (data: string) => void; + }) { + this.term = new HeadlessTerminal({ + cols: opts.cols, + rows: opts.rows, + scrollback: 1000, + allowProposedApi: true, + }); + this.serializer = new SerializeAddon(); + this.term.loadAddon(this.serializer); + this.term.onData((data) => { + if (this.answering) opts.onReply(data); + }); + } + + /** Seed persisted scrollback; enables query answering once processed. */ + seed(snapshot: string, answerQueries: boolean): void { + if (!snapshot) { + this.ready = true; + this.answering = answerQueries; + return; + } + this.term.write(snapshot, () => { + this.ready = true; + this.answering = answerQueries; + }); + } + + isReady(): boolean { + return this.ready; + } + + setAnswering(on: boolean): void { + this.answering = on; + } + + write(text: string): void { + this.term.write(text); + } + + resize(cols: number, rows: number): void { + if (cols > 0 && rows > 0 && (this.term.cols !== cols || this.term.rows !== rows)) { + this.term.resize(cols, rows); + } + } + + /** ANSI-complete snapshot of scrollback + screen, safe to replay: it + * reconstructs state and contains no queries or destructive clears. */ + serialize(): string { + return this.serializer.serialize(); + } + + dispose(): void { + this.term.dispose(); + } +} diff --git a/packages/agentos/src/inspector-tabs/lib/shell-store.ts b/packages/agentos/src/inspector-tabs/lib/shell-store.ts new file mode 100644 index 0000000000..7e3150bcb2 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/lib/shell-store.ts @@ -0,0 +1,71 @@ +// Shared persisted-shells store for the terminal tab AND the background +// output capture that runs in every OTHER tab document. The dashboard swaps +// the inspector iframe per tab, so shell state lives in sessionStorage keyed +// by actor; whichever tab document is currently alive keeps appending live +// `shellData` output to it. That is what makes a tab switch lossless: the +// terminal reattaches to a scrollback that never stopped recording. + +export const MAX_SCROLLBACK_CHARS = 128 * 1024; + +export const shellsKey = (actorId: string) => `agentos-inspector:shells:${actorId}`; + +export interface PersistedShellRecord { + shellId: string; + name?: string; + openedAt?: number; + scrollback: string; + status?: "live" | "exited" | "dead"; + exitCode?: number; +} + +export interface PersistedShells { + shells: PersistedShellRecord[]; + active: string | null; + counter?: number; + /** When this payload was written — lets the terminal detect a real capture + * gap (no inspector document alive) vs an ordinary tab switch. */ + savedAt?: number; + /** Terminal dimensions at save time, so capture mirrors (lib/shell-mirror) + * emulate at the size the PTYs were actually resized to. */ + cols?: number; + rows?: number; +} + +export function loadPersistedShells(actorId: string): PersistedShells | null { + try { + const raw = sessionStorage.getItem(shellsKey(actorId)); + if (!raw) return null; + const parsed = JSON.parse(raw) as PersistedShells; + return Array.isArray(parsed?.shells) && parsed.shells.length > 0 ? parsed : null; + } catch { + return null; + } +} + +export function savePersistedShells(actorId: string, payload: PersistedShells): void { + try { + sessionStorage.setItem( + shellsKey(actorId), + JSON.stringify({ ...payload, savedAt: Date.now() }), + ); + } catch (error) { + console.warn("agentos inspector: failed to persist shells", error); + } +} + +export function clearPersistedShells(actorId: string): void { + sessionStorage.removeItem(shellsKey(actorId)); +} + +// Cap scrollback without starting the kept tail mid-character or (bounded +// search) mid-line: a cut surrogate pair or half an ANSI escape replays as +// garbage on the next shell switch. +export function trimScrollback(text: string): string { + if (text.length <= MAX_SCROLLBACK_CHARS) return text; + let out = text.slice(-MAX_SCROLLBACK_CHARS); + const first = out.charCodeAt(0); + if (first >= 0xdc00 && first <= 0xdfff) out = out.slice(1); + const nl = out.indexOf("\n"); + if (nl !== -1 && nl < 4096) out = out.slice(nl + 1); + return out; +} diff --git a/packages/agentos/src/inspector-tabs/lib/source.ts b/packages/agentos/src/inspector-tabs/lib/source.ts index fd2ff908fe..e5adc63710 100644 --- a/packages/agentos/src/inspector-tabs/lib/source.ts +++ b/packages/agentos/src/inspector-tabs/lib/source.ts @@ -3,17 +3,21 @@ // the gateway (actor-client) and transforms the result into the display type the // ported component expects. Action names/shapes are the actual ones, not the // mockup's aspirational `agentOs*` names. -import { queryOptions } from "@tanstack/react-query"; -import { callAction } from "./actor-client"; +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import { callAction, isInspectorActionError } from "./actor-client"; import type { FileContent, FsEntry, JsonRpcNotification, MountInfo, + PendingPermissionInfo, PersistedSessionEvent, PersistedSessionRecord, ProcessInfo, + ProcessTreeNode, ReaddirEntry, + RuntimeHealth, + SignedPreviewUrl, SoftwareBundle, SoftwareInfo, TranscriptEvent, @@ -37,6 +41,7 @@ function softwareInfoToBundle(info: SoftwareInfo): SoftwareBundle { : "user"; return { name: `${name} · ${info.kind}`, + slug: (pkg.split("/").filter(Boolean).pop() ?? pkg).toLowerCase(), version: info.version ?? "—", source, binaries: info.commands ?? [], @@ -48,7 +53,7 @@ function joinPath(dir: string, name: string): string { return dir === "/" ? `/${name}` : `${dir}/${name}`; } -function decodeActionBytes(output: unknown): Uint8Array { +export function decodeActionBytes(output: unknown): Uint8Array { // rivetkit's json decoder may already hand back a real Uint8Array. if (output instanceof Uint8Array) return output; // JSON encoding wraps Uint8Array as ["$Uint8Array", base64]. @@ -72,6 +77,9 @@ function decodeActionBytes(output: unknown): Uint8Array { return new Uint8Array(); } +/** Preview limit for the file viewer; larger files load only on request. */ +const MAX_PREVIEW_BYTES = 4 * 1024 * 1024; + function bytesToDisplay(bytes: Uint8Array): string | null { // Heuristic binary check: NUL byte in the first 8 KiB. const probe = bytes.subarray(0, 8192); @@ -98,13 +106,63 @@ export function mapNotification(n: JsonRpcNotification, seq: number): Transcript case "agent_thought_chunk": return { kind: "thinking", seq, text }; case "tool_call": - case "tool_call_update": + case "tool_call_update": { + // ACP tool content: text blocks become output; diff entries are + // summarized by path (full diff rendering stays behind "raw"). + const outputParts: string[] = []; + if (Array.isArray(u.content)) { + for (const c of u.content as Record[]) { + if (!c || typeof c !== "object") continue; + if (c.type === "content") { + const inner = c.content as { type?: string; text?: string } | undefined; + if (inner?.type === "text" && typeof inner.text === "string") { + outputParts.push(inner.text); + } + } else if (c.type === "diff" && typeof c.path === "string") { + outputParts.push(`[edit] ${c.path}`); + } + } + } + const locations = Array.isArray(u.locations) + ? (u.locations as { path?: string }[]) + .map((l) => l?.path) + .filter((p): p is string => typeof p === "string") + : undefined; return { kind: "tool", seq, + toolCallId: typeof u.toolCallId === "string" ? u.toolCallId : undefined, tool: (u.title as string) ?? (u.toolCallId as string) ?? "tool", status: u.status as string | undefined, + input: u.rawInput, + output: outputParts.length > 0 ? outputParts.join("\n") : undefined, + locations: locations && locations.length > 0 ? locations : undefined, + }; + } + case "plan": { + const entries = Array.isArray(u.entries) + ? (u.entries as Record[]).map((e) => ({ + content: + typeof e?.content === "string" ? e.content : JSON.stringify(e?.content ?? ""), + status: typeof e?.status === "string" ? e.status : undefined, + })) + : []; + return { kind: "plan", seq, entries }; + } + case "current_mode_update": + return { + kind: "notice", + seq, + text: `Mode changed to ${String(u.currentModeId ?? "unknown")}`, + }; + case "available_commands_update": { + const count = Array.isArray(u.availableCommands) ? u.availableCommands.length : 0; + return { + kind: "notice", + seq, + text: `${count} agent command${count === 1 ? "" : "s"} available`, }; + } default: return { kind: "raw", seq, label: kind ?? n.method, json: u }; } @@ -130,6 +188,17 @@ export const agentOsSource = { queryOptions({ queryKey: k(actorId, "processes"), queryFn: () => callAction("listProcesses", []), + // Keep the table current while the tab is open; processExit broadcasts + // also invalidate it immediately. + refetchInterval: 5_000, + }), + + // Full kernel process forest (every process, not just SDK-spawned). + processTreeQueryOptions: (actorId: string) => + queryOptions({ + queryKey: k(actorId, "process-tree"), + queryFn: () => callAction("processTree", [], { timeoutMs: 10_000 }), + refetchInterval: 5_000, }), // Lazy per-directory listing via ONE `readdirEntries` call: the sidecar @@ -159,6 +228,7 @@ export const agentOsSource = { name: e.name, path: p, dir: e.isDirectory, + symlink: e.isSymbolicLink, }; }); return entries.sort( @@ -167,21 +237,59 @@ export const agentOsSource = { }, }), - fileContentQueryOptions: (actorId: string, path: string | null) => + fileContentQueryOptions: (actorId: string, path: string | null, force = false) => queryOptions({ - queryKey: k(actorId, "file", path ?? ""), + queryKey: k(actorId, "file", path ?? "", force ? "force" : "guarded"), enabled: !!path, + // Selecting another file keeps the previous one on screen until the + // new content lands, instead of flashing the whole viewer. + placeholderData: keepPreviousData, queryFn: async (): Promise => { const p = path as string; - const [bytes, stat] = await Promise.all([ - callAction("readFile", [p]).then(decodeActionBytes), - callAction("stat", [p]), - ]); + // Stat first: reading a huge file drags megabytes through the + // gateway just to preview it. Past the limit, skip the read until + // the viewer's explicit "Load anyway". + const stat = await callAction("stat", [p]); + // Device/fifo/socket nodes (/dev/stdout, …) are streams: reading + // them fails or hangs, so never try. Regular files and symlinks + // (followed by readFile) proceed. + const fileType = (stat.mode ?? 0) & 0o170000; + if ( + fileType === 0o020000 || // character device + fileType === 0o060000 || // block device + fileType === 0o010000 || // fifo + fileType === 0o140000 // socket + ) { + return { + path: p, + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + text: null, + bytes: null, + oversize: false, + special: true, + }; + } + if (!force && stat.size > MAX_PREVIEW_BYTES) { + return { + path: p, + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + text: null, + bytes: null, + oversize: true, + }; + } + const bytes = decodeActionBytes( + await callAction("readFile", [p], { timeoutMs: 30_000 }), + ); return { path: p, sizeBytes: stat.size, mtimeMs: stat.mtimeMs, text: bytesToDisplay(bytes), + bytes, + oversize: false, }; }, }), @@ -212,4 +320,124 @@ export const agentOsSource = { mapTranscriptEvent, ), }), + + // ── Composer actions (transcript tab) ───────────────────────────────── + // Imperative (not queries): the composer drives the agent. Streamed output + // arrives on the existing `sessionEvent` subscription; `sendPrompt` resolves + // when the turn completes. + sendPrompt: (sessionId: string, text: string) => + callAction<{ text?: string }>("sendPrompt", [sessionId, text]), + // Older runtimes return the sessionId string; the rivetkit wrapper returns + // `{ sessionId }` — callers normalize. + createSession: (agentType: string, options: { env?: Record }) => + callAction("createSession", [agentType, options]), + + // ── Permission approvals (global banner, permission-prompts.tsx) ───── + // Answers a pending `permissionRequest` broadcast. The runtime auto-rejects + // after its permission timeout and another viewer may answer first, so a + // late reply fails with a typed runtime error ("already answered or + // expired") — callers render that as already-handled, not a failure. + respondPermission: (sessionId: string, permissionId: string, reply: "once" | "always" | "reject") => + callAction("respondPermission", [sessionId, permissionId, reply]), + + // ── Process control (processes tab) ─────────────────────────────────── + killProcess: (pid: number) => callAction("killProcess", [pid]), + stopProcess: (pid: number) => callAction("stopProcess", [pid]), + + // ── Shell / PTY (terminal tab) ───────────────────────────────────────── + // Output arrives on the `shellData`/`shellStderr`/`shellExit` broadcasts. + openShell: (options: { cols?: number; rows?: number; command?: string; cwd?: string }) => + callAction<{ shellId: string }>("openShell", [options]), + writeShell: (shellId: string, data: string) => callAction("writeShell", [shellId, data]), + resizeShell: (shellId: string, cols: number, rows: number) => + callAction("resizeShell", [shellId, cols, rows]), + closeShell: (shellId: string) => callAction("closeShell", [shellId]), + + // ── Filesystem mutations (filesystem tab) ────────────────────────────── + writeFile: (path: string, content: Uint8Array | string) => + callAction("writeFile", [path, content], { timeoutMs: 30_000 }), + mkdir: (path: string) => callAction("mkdir", [path]), + moveEntry: (from: string, to: string) => callAction("move", [from, to]), + deleteFile: (path: string, options: { recursive?: boolean }) => + callAction("deleteFile", [path, options]), + + // ── Session management (transcript tab) ──────────────────────────────── + closeSession: (sessionId: string) => callAction("closeSession", [sessionId]), + + // ── Signed preview URLs (system tab) ─────────────────────────────────── + createSignedPreviewUrl: (port: number, ttlSeconds: number) => + callAction("createSignedPreviewUrl", [port, ttlSeconds]), + expireSignedPreviewUrl: (token: string) => callAction("expireSignedPreviewUrl", [token]), }; + +// ── Runtime health (observe-only actions) ───────────────────────────── +// getRuntimeHealth / listSessions / cancelPrompt are real contract actions on +// the current runtime, dispatched on its non-waking observe-only lane (they +// never boot a sleeping VM). Feature detection stays: vendored tab bundles run +// against OLDER runtimes without these actions, which reject at the contract +// layer (see lib/health.ts's isMissingHealthAction) — the status strip hides +// itself and the composer disables its Stop button. + +export const healthQueryOptions = (actorId: string) => + queryOptions({ + queryKey: k(actorId, "runtime-health"), + queryFn: () => callAction("getRuntimeHealth", [], { timeoutMs: 8_000 }), + refetchInterval: 5_000, + // Contract-missing is permanent for this actor: never retry, and stop + // polling (react-query keeps refetching errored queries otherwise). + retry: false, + refetchOnMount: false, + }); + +/** Live (loaded-in-VM) sessions — used to mark sidebar rows as running. Rows + * carry the EXTERNAL session id, so they cross-reference listPersistedSessions + * records directly. Returns null when the runtime doesn't expose the action + * (callers fall back to record.status). */ +export const liveSessionsQueryOptions = (actorId: string) => + queryOptions({ + queryKey: k(actorId, "live-sessions"), + queryFn: async (): Promise | null> => { + try { + const live = await callAction<{ sessionId: string }[]>("listSessions", []); + return new Set(live.map((s) => s.sessionId)); + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") return null; + throw error; + } + }, + refetchInterval: 10_000, + retry: false, + }); + +/** Pending permission requests buffered runtime-side — the one-off backfill + * for the permission banner (permission-prompts.tsx), so requests broadcast + * while no inspector iframe was open still render. No refetchInterval: after + * the mount fetch, updates arrive on the `permissionRequest` / + * `permissionResolved` broadcasts. Returns null when the runtime doesn't + * expose the action (older runtime) — the banner then stays live-only. */ +export const pendingPermissionsQueryOptions = (actorId: string) => + queryOptions({ + queryKey: k(actorId, "pending-permissions"), + queryFn: async (): Promise => { + try { + return await callAction("listPendingPermissions", []); + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") return null; + throw error; + } + }, + retry: false, + }); + +/** Best-effort prompt cancellation; resolves false when unsupported. A booted + * runtime with nothing running rejects with a runtime error ("VM is not + * booted" / unknown session), which propagates to the caller. */ +export async function cancelPrompt(sessionId: string): Promise { + try { + await callAction("cancelPrompt", [sessionId]); + return true; + } catch (error) { + if (isInspectorActionError(error) && error.layer === "contract") return false; + throw error; + } +} diff --git a/packages/agentos/src/inspector-tabs/lib/types.ts b/packages/agentos/src/inspector-tabs/lib/types.ts index ead8979b0c..670b6133f4 100644 --- a/packages/agentos/src/inspector-tabs/lib/types.ts +++ b/packages/agentos/src/inspector-tabs/lib/types.ts @@ -4,6 +4,8 @@ // ── Software ────────────────────────────────────────────────────────── export interface SoftwareBundle { name: string; + /** Package basename ("coreutils", "claude-code") — keys the logo lookup. */ + slug: string; version: string; source: "rivet-dev" | "user"; binaries: string[]; // command names the package ships (from SoftwareInfo.commands) @@ -29,8 +31,84 @@ export interface ProcessInfo { /** Epoch milliseconds when the process was spawned. */ startedAt: number; } +/** Raw `allProcesses`/`processTree` node fields — the full kernel process + * table (every process, not just SDK-spawned). Mirrors the Rust wire shape; + * `startTime`/`exitTime` are epoch milliseconds. */ +export interface KernelProcessInfo { + pid: number; + ppid: number; + pgid: number; + sid: number; + driver: string; + command: string; + args: string[]; + cwd: string; + status: "running" | "exited"; + exitCode: number | null; + startTime: number; + exitTime: number | null; +} +/** Raw `processTree` node: kernel info + children. */ +export interface ProcessTreeNode extends KernelProcessInfo { + children: ProcessTreeNode[]; +} +/** Live `processOutput` broadcast payload mirror (Rust owns broadcasts). + * `data` arrives Uint8Array-shaped but encoding-dependent — normalize with + * `decodeActionBytes`. Only SDK-`spawn`ed pids have output pumps. */ +export interface ProcessOutputPayload { + pid: number; + stream: "stdout" | "stderr"; + data: unknown; +} +/** Live `processExit` broadcast payload mirror. */ +export interface ProcessExitPayload { + pid: number; + exitCode: number; +} + +// ── Shell / terminal ────────────────────────────────────────────────── +/** Live `shellData`/`shellStderr` broadcast payload mirror. `data` is + * Uint8Array-shaped but encoding-dependent — normalize with + * `decodeActionBytes`. */ +export interface ShellDataPayload { + shellId: string; + data: unknown; +} +/** Live `shellExit` broadcast payload mirror. */ +export interface ShellExitPayload { + shellId: string; + exitCode: number; +} + +/** Mirror of the `agentCrashed` broadcast payload (Rust owns broadcasts). */ +export interface AgentCrashedPayload { + sessionId: string; + event: { + agentType?: string; + exitCode?: number | null; + restart?: string; + restartCount?: number; + }; +} + +/** Raw `createSignedPreviewUrl` result. `path` is relative to the gateway + * origin serving this iframe. */ +export interface SignedPreviewUrl { + path: string; + token: string; + port: number; + expiresAt: number; +} // ── Filesystem ──────────────────────────────────────────────────────── +/** `fsChanged` broadcast: one coalesced guest filesystem change window. + * Rust owns broadcasts — mirror of the plugin's FsChangedEvent. */ +export interface FsChangedPayload { + /** Absolute guest directories whose direct entries changed. */ + dirs: string[]; + /** The window overflowed its bound; treat the whole tree as changed. */ + overflow: boolean; +} /** Raw `readdirRecursive` entry. */ export interface DirEntry { path: string; @@ -45,6 +123,8 @@ export interface FsEntry { path: string; dir: boolean; size?: number; + /** Reported lstat-style (not followed); rendered with a link marker. */ + symlink?: boolean; /** Virtual/system fs (/proc, /sys, …) — shown but not stat-ed or expanded, * because touching it wedges the VM sidecar. */ virtual?: boolean; @@ -58,6 +138,8 @@ export interface ReaddirEntry { } /** Raw `stat` shape (subset we use). */ export interface VirtualStat { + /** POSIX mode bits (file type in the top nibble). */ + mode: number; size: number; mtimeMs: number; isDirectory: boolean; @@ -67,7 +149,13 @@ export interface FileContent { path: string; sizeBytes: number; mtimeMs: number; - text: string | null; // null = binary + text: string | null; // null = binary or not loaded + /** Raw bytes for download / image preview; null when skipped (oversize). */ + bytes: Uint8Array | null; + /** True when the file exceeded the preview limit and was not read. */ + oversize: boolean; + /** True for device/fifo/socket nodes — streams with no readable contents. */ + special?: boolean; } // ── Mounts ──────────────────────────────────────────────────────────── @@ -86,8 +174,9 @@ export interface PersistedSessionRecord { agentType: string; createdAt: number; /** VM-liveness activity status: "running" = loaded in the VM, "idle" = - * persisted but hibernated (resumable). */ - status: "running" | "idle"; + * persisted but hibernated (resumable). Absent on runtimes whose records + * carry no status — liveness then comes from `listSessions` (lib/source.ts). */ + status?: "running" | "idle"; } export interface JsonRpcNotification { jsonrpc: "2.0"; @@ -109,9 +198,85 @@ export interface PersistedSessionEvent { createdAt: number; } /** Mapped, displayable transcript event (defensive; unknown → "raw"). Carries - * the source `seq` for stable keys/ordering. */ + * the source `seq` for stable keys/ordering. Tool events keep `toolCallId` so + * the render pipeline can merge a call and its status updates into one card. */ export type TranscriptEvent = { seq: number } & ( | { kind: "user" | "assistant" | "thinking"; text: string } - | { kind: "tool"; tool: string; status?: string } + | { + kind: "tool"; + tool: string; + toolCallId?: string; + status?: string; + input?: unknown; + output?: string; + locations?: string[]; + } + | { kind: "plan"; entries: { content: string; status?: string }[] } + | { kind: "notice"; text: string } + | { kind: "permission"; text: string } | { kind: "raw"; label: string; json: unknown } + | { kind: "error"; text: string } ); + +/** Mirror of the `permissionRequest` broadcast payload (Rust owns broadcasts). + * The agent's turn blocks on the reply and the runtime auto-rejects after its + * permission timeout (~120s). */ +export interface PermissionRequestPayload { + sessionId: string; + request: { + permissionId: string; + description?: string; + params: Record; + }; +} +/** Mirror of the `permissionResolved` broadcast payload (Rust owns + * broadcasts): a `respondPermission` succeeded, so any other viewer's pending + * card for this `sessionId:permissionId` is stale and should drop. */ +export interface PermissionResolvedPayload { + sessionId: string; + permissionId: string; + reply: "once" | "always" | "reject"; +} +/** Raw `listPendingPermissions` row (observe-only; never boots the VM) — an + * unanswered permission request buffered runtime-side so a banner opened + * AFTER the `permissionRequest` broadcast can still backfill it. Same + * `sessionId:permissionId` identity as the broadcast; `requestedAt` is the + * runtime's receipt time (epoch ms), used for the ~120s expiry countdown. */ +export interface PendingPermissionInfo { + sessionId: string; + permissionId: string; + description?: string; + params: Record; + requestedAt: number; +} + +// ── Runtime health (`getRuntimeHealth`, observe-only; see lib/source.ts) ── +export interface RuntimeLimitWarning { + ts: number; + limit: string; + category: string; + observed: number; + capacity: number; + fillPercent: number; +} +export interface RuntimeAgentExit { + ts: number; + sessionId: string; + agentType: string; + exitCode: number | null; + restart: string; + restartCount: number; +} +export interface RuntimeHealth { + booted: boolean; + sessions: number | null; + sidecar: { state: string; activeVmCount: number } | null; + warnings: RuntimeLimitWarning[]; + agentExits: RuntimeAgentExit[]; + stderrTail: { ts: number; line: string }[]; +} + +/** Live `vmShutdown` broadcast payload mirror (Rust owns broadcasts). */ +export interface VmShutdownPayload { + reason?: "sleep" | "destroy" | "error" | string; +} diff --git a/packages/agentos/src/inspector-tabs/main.tsx b/packages/agentos/src/inspector-tabs/main.tsx index 4a18026e8f..8b11682d54 100644 --- a/packages/agentos/src/inspector-tabs/main.tsx +++ b/packages/agentos/src/inspector-tabs/main.tsx @@ -1,8 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { dehydrate, hydrate, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { lazy, type ComponentType, StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; -import { tabIdFromUrl } from "./lib/actor-client"; +import { isInspectorActionError, tabIdFromUrl } from "./lib/actor-client"; import { RivetProvider } from "./lib/rivet"; +import { ShellOutputCapture } from "./lib/shell-capture"; +import { PermissionPrompts } from "./permission-prompts"; import { TabBoundary } from "./tab-boundary"; import React from "react"; @@ -11,25 +13,49 @@ import "./styles.css"; // Tab registry: id → lazy component. Add a tab here + register the same id in // actor.ts `inspectorTabs` pointing `source` at this shared asset dir. const TABS: Record Promise<{ default: ComponentType<{ actorId: string }> }>> = { - software: () => - import("./tabs/software").then((m) => ({ default: m.SoftwareTabConnected })), - processes: () => - import("./tabs/processes").then((m) => ({ default: m.ProcessesTabConnected })), - filesystem: () => - import("./tabs/filesystem").then((m) => ({ default: m.FilesystemTabConnected })), - mounts: () => - import("./tabs/mounts").then((m) => ({ default: m.MountsTabConnected })), transcript: () => import("./tabs/transcript").then((m) => ({ default: m.TranscriptTabConnected })), + terminal: () => + import("./tabs/terminal").then((m) => ({ default: m.TerminalTabConnected })), + filesystem: () => + import("./tabs/filesystem").then((m) => ({ default: m.FilesystemTabConnected })), + system: () => + import("./tabs/system").then((m) => ({ default: m.SystemTabConnected })), +}; + +// Hosts vendor built copies of this bundle and pin their tab-id config at +// server start, so ids that existed in older configs must keep rendering. +// Software, Mounts, and Processes merged into System; route their ids there. +const LEGACY_TAB_ALIASES: Record = { + software: "system", + mounts: "system", + processes: "system", }; +// Theme comes from the dashboard via the iframe URL; the tokens and all +// `dark:` variants key off this class. Absent param = dark (today's default). +document.documentElement.classList.toggle( + "dark", + new URLSearchParams(window.location.search).get("theme") !== "light", +); + const queryClient = new QueryClient({ defaultOptions: { queries: { // VM-backed actions (listProcesses/readdir/readFile/stat) time out on // the FIRST call after the actor's VM hibernates and cold-wakes; - // retry so the query recovers once the VM is warm. - retry: 3, + // retry so the query recovers once the VM is warm. Contract/auth + // failures are permanent for this actor — retrying only delays the + // error UI by the full backoff, so fail those immediately. + retry: (failureCount, error) => { + if ( + isInspectorActionError(error) && + (error.layer === "contract" || error.layer === "auth") + ) { + return false; + } + return failureCount < 3; + }, retryDelay: (attempt) => Math.min(1500 * 2 ** attempt, 8000), refetchOnWindowFocus: false, staleTime: 5_000, @@ -37,6 +63,42 @@ const queryClient = new QueryClient({ }, }); +// The dashboard swaps this iframe out on every tab switch, so each switch is +// a full document boot. Carry the query cache across in sessionStorage: the +// next document renders every tab from cached data instantly (no loading +// states) and refetches in the background (5s staleTime). Excluded keys: +// `file` bodies hold Uint8Arrays (huge and not JSON-safe) and `live-sessions` +// holds a Set — both re-fetch quickly and neither survives JSON. +const QUERY_CACHE_KEY = "agentos-inspector:query-cache"; +const QUERY_CACHE_EXCLUDED_KINDS = new Set(["file", "live-sessions"]); +const QUERY_CACHE_MAX_BYTES = 3 * 1024 * 1024; +try { + const raw = sessionStorage.getItem(QUERY_CACHE_KEY); + if (raw) hydrate(queryClient, JSON.parse(raw)); +} catch (error) { + console.warn("agentos inspector: failed to restore query cache", error); +} +window.addEventListener("pagehide", () => { + try { + const state = dehydrate(queryClient, { + shouldDehydrateQuery: (query) => + query.state.status === "success" && + !QUERY_CACHE_EXCLUDED_KINDS.has(String(query.queryKey[2])), + }); + const raw = JSON.stringify(state); + // sessionStorage shares a ~5MB origin budget with the shell scrollback. + if (raw.length > QUERY_CACHE_MAX_BYTES) { + console.warn( + `agentos inspector: query cache too large to persist (${raw.length} chars)`, + ); + return; + } + sessionStorage.setItem(QUERY_CACHE_KEY, raw); + } catch (error) { + console.warn("agentos inspector: failed to persist query cache", error); + } +}); + function App() { const [auth, setAuthState] = useState<{ actorId: string; authToken: string }>(); @@ -54,7 +116,8 @@ function App() { }, []); const tabId = tabIdFromUrl(); - const loader = tabId ? TABS[tabId] : undefined; + const resolvedTabId = tabId ? (LEGACY_TAB_ALIASES[tabId] ?? tabId) : undefined; + const loader = resolvedTabId ? TABS[resolvedTabId] : undefined; if (!loader) { return
Unknown inspector tab: {String(tabId)}
; @@ -66,12 +129,26 @@ function App() { const Tab = lazy(loader); // RivetProvider owns the shared rivetkit client (typed `useActor` + the // `callAction` transport). One boundary catches both the code-split chunk - // load and the tab's useSuspenseQuery data load. + // load and the tab's useSuspenseQuery data load. VM status renders as + // compact badges inside each tab's own top bar (vm-status-badges.tsx), + // not as a dedicated row here. return ( - - - +
+ {/* Permission prompts sit outside the boundary: an agent blocked on + approval must stay answerable from every tab even when the tab + body itself is broken. */} + + {/* Every non-terminal document keeps recording shell output into + the shared store, so switching back to the terminal loses + nothing (the terminal itself owns the store while mounted). */} + {resolvedTabId !== "terminal" ? : null} +
+ + + +
+
); } diff --git a/packages/agentos/src/inspector-tabs/permission-prompts.tsx b/packages/agentos/src/inspector-tabs/permission-prompts.tsx new file mode 100644 index 0000000000..53b90acc8e --- /dev/null +++ b/packages/agentos/src/inspector-tabs/permission-prompts.tsx @@ -0,0 +1,230 @@ +// Global permission-approval banner, mounted in main.tsx above every tab (each +// tab is its own iframe, so "global" means "in every iframe's chrome"). An +// agent blocked on a `permissionRequest` broadcast otherwise looks frozen: the +// turn hangs until the runtime auto-rejects at its permission timeout (~120s). +// Cards come from two sources merged on `sessionId:permissionId`: a one-off +// `listPendingPermissions` backfill on mount (requests broadcast while no +// inspector iframe was open) plus the live `permissionRequest` stream. The +// `permissionResolved` broadcast drops cards another viewer already answered. +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { ChevronRight } from "./common"; +import { isInspectorActionError } from "./lib/actor-client"; +import { useAgentOsActor } from "./lib/rivet"; +import { agentOsSource, pendingPermissionsQueryOptions } from "./lib/source"; +import type { PermissionRequestPayload, PermissionResolvedPayload } from "./lib/types"; +import React from "react"; + +// Mirrors the runtime's PERMISSION_TIMEOUT_MS: past this the reply slot is +// gone server-side, so the card flips to expired instead of offering buttons. +const PERMISSION_TIMEOUT_MS = 120_000; +// Bounded queue: drop the oldest card beyond this (they expire server-side +// anyway; an unbounded queue would just stack dead cards). +const MAX_CARDS = 32; + +interface PermissionCard { + sessionId: string; + permissionId: string; + description?: string; + params: Record; + receivedAt: number; + state: "pending" | "busy" | "expired" | "failed"; + error?: string; +} + +function cardKey(card: Pick): string { + return `${card.sessionId}:${card.permissionId}`; +} + +const REPLIES = [ + { reply: "once", label: "Approve once" }, + { reply: "always", label: "Always allow" }, + { reply: "reject", label: "Deny" }, +] as const; + +export function PermissionPrompts({ actorId }: { actorId: string }) { + const [cards, setCards] = useState([]); + const cardsRef = useRef(cards); + cardsRef.current = cards; + + // One-off backfill of requests broadcast before this iframe subscribed. + // `data === null` means the runtime predates `listPendingPermissions` + // (contract-layer error) — stay live-only silently. + const backfill = useQuery(pendingPermissionsQueryOptions(actorId)); + const backfillRows = backfill.data; + useEffect(() => { + if (!backfillRows || backfillRows.length === 0) return; + setCards((prev) => { + // Live cards win the dedupe: a broadcast that raced the fetch already + // carries the same request (identity is sessionId:permissionId). + const have = new Set(prev.map(cardKey)); + const added = backfillRows + .filter((row) => !have.has(cardKey(row))) + .map( + (row): PermissionCard => ({ + sessionId: row.sessionId, + permissionId: row.permissionId, + description: row.description, + params: row.params ?? {}, + // Runtime receipt time, so the expiry countdown measures the + // request's real age rather than when this viewer opened. + receivedAt: row.requestedAt, + state: "pending", + }), + ); + if (added.length === 0) return prev; + return [...prev, ...added] + .sort((a, b) => a.receivedAt - b.receivedAt) + .slice(-MAX_CARDS); + }); + }, [backfillRows]); + + // Broadcast names aren't in the typed event schema (Rust owns broadcasts) — + // same cast pattern as transcript.tsx / vm-status-badges.tsx. + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + useAgentEvent("permissionRequest", (payload) => { + const p = payload as PermissionRequestPayload | undefined; + const request = p?.request; + if (!p?.sessionId || !request?.permissionId) return; + setCards((prev) => { + const next: PermissionCard = { + sessionId: p.sessionId, + permissionId: request.permissionId, + description: request.description, + params: request.params ?? {}, + receivedAt: Date.now(), + state: "pending", + }; + const deduped = prev.filter((c) => cardKey(c) !== cardKey(next)); + return [...deduped, next].slice(-MAX_CARDS); + }); + }); + // Another viewer (or a headless client) answered: its card is stale here, + // so drop it outright — an untouched card needs no "handled elsewhere" + // residue. A busy card stays: its own in-flight respond reports the + // outcome (the "already answered or expired" error → quiet expired state). + useAgentEvent("permissionResolved", (payload) => { + const p = payload as PermissionResolvedPayload | undefined; + if (!p?.sessionId || !p?.permissionId) return; + const key = cardKey(p); + setCards((prev) => prev.filter((c) => cardKey(c) !== key || c.state === "busy")); + }); + + // Flip pending cards to expired once the runtime's reply slot is gone. + useEffect(() => { + if (!cards.some((c) => c.state === "pending" || c.state === "busy")) return; + const timer = setInterval(() => { + const now = Date.now(); + setCards((prev) => + prev.map((c) => + c.state === "pending" && now - c.receivedAt > PERMISSION_TIMEOUT_MS + ? { ...c, state: "expired" as const } + : c, + ), + ); + }, 5_000); + return () => clearInterval(timer); + }, [cards]); + + const respond = async (card: PermissionCard, reply: "once" | "always" | "reject") => { + const key = cardKey(card); + const patch = (changes: Partial) => + setCards((prev) => prev.map((c) => (cardKey(c) === key ? { ...c, ...changes } : c))); + patch({ state: "busy" }); + try { + await agentOsSource.respondPermission(card.sessionId, card.permissionId, reply); + setCards((prev) => prev.filter((c) => cardKey(c) !== key)); + } catch (error) { + // A missing reply slot means the request timed out server-side or another + // viewer answered first — quiet outcome, not an error card. + const message = error instanceof Error ? error.message : String(error); + if (/pending|not found|expired|timed out|already/i.test(message)) { + patch({ state: "expired" }); + return; + } + const hint = isInspectorActionError(error) ? ` — ${error.hint}` : ""; + patch({ state: "failed", error: `${message}${hint}` }); + } + }; + + const dismiss = (card: PermissionCard) => + setCards((prev) => prev.filter((c) => cardKey(c) !== cardKey(card))); + + if (cards.length === 0) return null; + + return ( +
+ {cards.map((card) => ( +
+
+
+
+ Agent requests permission + + session …{card.sessionId.slice(-10)} + +
+
+ {card.description ?? card.permissionId} +
+ {Object.keys(card.params).length > 0 ? ( +
+ + + Details + +
+										{JSON.stringify(card.params, null, 2)}
+									
+
+ ) : null} +
+ {card.state === "pending" || card.state === "busy" ? ( +
+ {REPLIES.map(({ reply, label }) => ( + + ))} +
+ ) : ( +
+ + {card.state === "expired" + ? "Expired or handled elsewhere" + : (card.error ?? "Reply failed")} + + +
+ )} +
+
+ ))} +
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/software-logos.ts b/packages/agentos/src/inspector-tabs/software-logos.ts new file mode 100644 index 0000000000..0bcab2dc30 --- /dev/null +++ b/packages/agentos/src/inspector-tabs/software-logos.ts @@ -0,0 +1,80 @@ +// Package logos, vendored from the registry site +// (website/public/images/registry) so the inspector works offline. Keyed by +// the package basename (SoftwareBundle.slug); packages without a logo fall +// back to a letter avatar in the Software list. +import browserbase from "./assets/software-logos/browserbase.svg"; +import claudeCode from "./assets/software-logos/claude-code.svg"; +import codex from "./assets/software-logos/codex.svg"; +import coreutils from "./assets/software-logos/coreutils.svg"; +import curl from "./assets/software-logos/curl.svg"; +import diffutils from "./assets/software-logos/diffutils.svg"; +import duckdb from "./assets/software-logos/duckdb.svg"; +import findutils from "./assets/software-logos/findutils.svg"; +import gawk from "./assets/software-logos/gawk.svg"; +import git from "./assets/software-logos/git.svg"; +import grep from "./assets/software-logos/grep.svg"; +import gzip from "./assets/software-logos/gzip.svg"; +import jq from "./assets/software-logos/jq.svg"; +import nodejs from "./assets/software-logos/nodejs.svg"; +import opencode from "./assets/software-logos/opencode.svg"; +import pi from "./assets/software-logos/pi.svg"; +import python from "./assets/software-logos/python.svg"; +import sed from "./assets/software-logos/sed.svg"; +import sqlite3 from "./assets/software-logos/sqlite3.svg"; +import superMemory from "./assets/software-logos/super-memory.svg"; +import tar from "./assets/software-logos/tar.svg"; +import vim from "./assets/software-logos/vim.svg"; +import wget from "./assets/software-logos/wget.svg"; + +/** Dark-mode legibility per logo, hue-true: brightness lifts dark brand colors + * without shifting them (GNU red stays red, curl navy stays navy); invert is + * reserved for near-black monochrome marks, which have no hue to break. + * Colorful logos (git, python, node, …) need nothing. */ +export const SOFTWARE_LOGO_DARK_CLASS: Record = { + // GNU family, #A42E2B dark red. + coreutils: "dark:brightness-[1.8]", + diffutils: "dark:brightness-[1.8]", + findutils: "dark:brightness-[1.8]", + gawk: "dark:brightness-[1.8]", + grep: "dark:brightness-[1.8]", + gzip: "dark:brightness-[1.8]", + sed: "dark:brightness-[1.8]", + tar: "dark:brightness-[1.8]", + wget: "dark:brightness-[1.8]", + // Deep navy / purple marks. + codex: "dark:brightness-[2.1]", + curl: "dark:brightness-[2.6]", + sqlite3: "dark:brightness-[2.6]", + // Near-black monochrome marks. + duckdb: "dark:invert", + jq: "dark:invert", + opencode: "dark:invert", + pi: "dark:invert", +}; + +export const SOFTWARE_LOGOS: Record = { + browserbase, + "claude-code": claudeCode, + codex, + coreutils, + curl, + diffutils, + duckdb, + findutils, + gawk, + git, + grep, + gzip, + jq, + node: nodejs, + nodejs, + opencode, + pi, + python, + sed, + sqlite3, + "super-memory": superMemory, + tar, + vim, + wget, +}; diff --git a/packages/agentos/src/inspector-tabs/styles.css b/packages/agentos/src/inspector-tabs/styles.css index 5ba1d9a4a4..6f1e553d62 100644 --- a/packages/agentos/src/inspector-tabs/styles.css +++ b/packages/agentos/src/inspector-tabs/styles.css @@ -46,6 +46,12 @@ body, #root { height: 100%; margin: 0; + /* The tab app is a fixed-viewport UI (every pane scrolls internally). A + page-level scrollbar must never appear: with classic (space-consuming) + scrollbars, transient overflow during terminal output triggers a + vertical-bar → narrower content → horizontal-bar → refit → retract + feedback loop that visibly flashes both scrollbars. */ + overflow: hidden; } body { diff --git a/packages/agentos/src/inspector-tabs/svg.d.ts b/packages/agentos/src/inspector-tabs/svg.d.ts new file mode 100644 index 0000000000..e5d5dc958e --- /dev/null +++ b/packages/agentos/src/inspector-tabs/svg.d.ts @@ -0,0 +1,6 @@ +// Vite emits imported SVGs as hashed asset files; the default export is the +// URL. (tsconfig has "types": [], so vite/client is not in scope.) +declare module "*.svg" { + const url: string; + export default url; +} diff --git a/packages/agentos/src/inspector-tabs/tab-boundary.tsx b/packages/agentos/src/inspector-tabs/tab-boundary.tsx index c4cf411a55..148c44f066 100644 --- a/packages/agentos/src/inspector-tabs/tab-boundary.tsx +++ b/packages/agentos/src/inspector-tabs/tab-boundary.tsx @@ -1,5 +1,8 @@ +import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { Component, type ReactNode, Suspense } from "react"; +import { ActionErrorNote, UnsupportedAction } from "./common"; +import { isInspectorActionError } from "./lib/actor-client"; import React from "react"; function TabFallback() { @@ -16,27 +19,51 @@ function TabFallback() { ); } -class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> { +class ErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + { error?: Error } +> { state: { error?: Error } = {}; static getDerivedStateFromError(error: Error) { return { error }; } + #retry = () => { + this.props.onReset?.(); + this.setState({ error: undefined }); + }; render() { - if (this.state.error) { - return ( -
- {this.state.error.message || "Failed to load."} -
- ); + const { error } = this.state; + if (!error) return this.props.children; + // Contract-layer failures are a capability gap, not a fault: render the + // quiet unsupported state with no retry (retrying cannot succeed). + if (isInspectorActionError(error) && error.layer === "contract") { + return ; } - return this.props.children; + return ( +
+ + +
+ ); } } export function TabBoundary({ children }: { children: ReactNode }) { + // QueryErrorResetBoundary clears React Query's cached suspense errors so the + // Retry button actually refetches instead of re-throwing the stale error. return ( - - }>{children} - + + {({ reset }) => ( + + }>{children} + + )} + ); } diff --git a/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx b/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx index 9385240db3..0c33d8be4d 100644 --- a/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/filesystem.tsx @@ -1,26 +1,54 @@ -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { AgentOsEmpty, ChevronRight, FileGlyph, formatBytes, relativeTime } from "../common"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActionErrorNote, + AgentOsEmpty, + AgentOsWordmark, + ArrowLeftIcon, + CheckIcon, + ChevronRight, + DownloadIcon, + FileGlyph, + FolderPlusIcon, + formatBytes, + IconButton, + PencilIcon, + RefreshIcon, + relativeTime, + TrashIcon, + UploadIcon, +} from "../common"; import { cn } from "../lib/cn"; +import { useAgentOsActor } from "../lib/rivet"; import { agentOsSource } from "../lib/source"; -import type { FsEntry } from "../lib/types"; +import type { FsChangedPayload, FsEntry } from "../lib/types"; import { ScrollArea } from "../ui/scroll-area"; +import { VmBootGate } from "../vm-boot-gate"; +import { VmStatusBadges } from "../vm-status-badges"; import React from "react"; +const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|avif)$/i; + function FileTreeItem({ actorId, entry, depth, selectedPath, onSelect, + openPaths, + onToggle, }: { actorId: string; entry: FsEntry; depth: number; selectedPath: string | null; onSelect: (e: FsEntry) => void; + /** Expansion lives in the parent (persisted): it must survive the per-tab + * iframe swap, which unmounts this whole tree. */ + openPaths: ReadonlySet; + onToggle: (path: string) => void; }) { - const [open, setOpen] = useState(false); + const open = openPaths.has(entry.path); const isSelected = entry.path === selectedPath; const expandable = entry.dir && !entry.virtual; const childrenQuery = useQuery( @@ -32,7 +60,7 @@ function FileTreeItem({
- {expandable && open ? ( - childrenQuery.isLoading ? ( -
- loading… -
- ) : ( - (childrenQuery.data ?? []).map((child) => ( + {/* No loading row: most directories list in well under a frame, so a + transient "loading…" reads as a glitch. Children simply appear. */} + {expandable && open + ? (childrenQuery.data ?? []).map((child) => ( )) - ) - ) : null} + : null} ); } -function FileViewer({ actorId, path }: { actorId: string; path: string | null }) { - const { data, error, isFetching } = useQuery( - agentOsSource.fileContentQueryOptions(actorId, path), +function downloadBytes(bytes: Uint8Array, filename: string): void { + const url = URL.createObjectURL(new Blob([bytes as BlobPart])); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +function FileViewer({ + actorId, + path, + onMutated, + onDeleted, + onRenamed, +}: { + actorId: string; + path: string | null; + onMutated: () => void; + onDeleted: () => void; + onRenamed: (to: string) => void; +}) { + const [force, setForce] = useState(false); + const [renameDraft, setRenameDraft] = useState(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [mutationError, setMutationError] = useState(null); + // Per-file view state resets when the selection changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on path change + useEffect(() => { + setForce(false); + setRenameDraft(null); + setConfirmingDelete(false); + setMutationError(null); + }, [path]); + const { data, error } = useQuery( + agentOsSource.fileContentQueryOptions(actorId, path, force), ); - if (!path) return Select a file to view its contents.; - if (error) return Failed to read {path}: {(error as Error).message}; - if (!data || isFetching) return Loading {path}…; + const imageUrl = useMemo(() => { + if (!data?.bytes || data.text !== null || !IMAGE_EXTENSIONS.test(data.path)) return null; + return URL.createObjectURL(new Blob([data.bytes as BlobPart])); + }, [data]); + useEffect(() => { + return () => { + if (imageUrl) URL.revokeObjectURL(imageUrl); + }; + }, [imageUrl]); + + if (!path) + return ( + +
+ + Select a file to view its contents. +
+
+ ); + if (error) return ; + // Only the very first open shows a loading state; switching files keeps the + // previous content until the new one lands (keepPreviousData in source.ts). + if (!data) return Loading {path}…; + + const filename = data.path.split("/").pop() ?? data.path; + const rename = async () => { + const to = renameDraft?.trim(); + if (!to || to === data.path) { + setRenameDraft(null); + return; + } + setMutationError(null); + try { + await agentOsSource.moveEntry(data.path, to); + setRenameDraft(null); + onRenamed(to); + onMutated(); + } catch (err) { + setMutationError(err); + } + }; + const remove = async () => { + if (!confirmingDelete) { + setConfirmingDelete(true); + setTimeout(() => setConfirmingDelete(false), 3_000); + return; + } + setMutationError(null); + try { + await agentOsSource.deleteFile(data.path, {}); + onDeleted(); + onMutated(); + } catch (err) { + setMutationError(err); + } + }; + return (
-
- {data.path} - {formatBytes(data.sizeBytes)} +
+ {renameDraft !== null ? ( + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void rename(); + if (e.key === "Escape") setRenameDraft(null); + }} + spellCheck={false} + autoFocus + aria-label="New path" + className="min-w-0 flex-1 rounded border bg-background px-2 py-1 font-mono text-xs focus:outline-none" + /> + ) : ( + {data.path} + )} + {formatBytes(data.sizeBytes)} {relativeTime(data.mtimeMs)} + data.bytes && downloadBytes(data.bytes, filename)} + > + + + (renameDraft !== null ? void rename() : setRenameDraft(data.path))} + > + {renameDraft !== null ? ( + + ) : ( + + )} + + {/* Delete keeps a two-step confirm: the icon arms it, the explicit + text disarms accidental clicks on an irreversible action. */} + {confirmingDelete ? ( + + ) : ( + void remove()}> + + + )}
+ {mutationError ? : null} - {data.text === null ? ( + {data.special ? (
- Binary file ({formatBytes(data.sizeBytes)}) — preview unavailable. + Device or stream — no readable contents. +
+ ) : data.oversize ? ( +
+ + Large file ({formatBytes(data.sizeBytes)}) — preview skipped to avoid dragging it + through the gateway. + +
+ ) : data.text === null ? ( + imageUrl ? ( +
+ {filename} +
+ ) : ( +
+ Binary file ({formatBytes(data.sizeBytes)}) — use Download to save it. +
+ ) ) : (
 						{data.text}
@@ -111,6 +309,12 @@ function FileViewer({ actorId, path }: { actorId: string; path: string | null })
 
 /** Normalize a user-typed root: ensure a single leading slash, collapse
  * repeated slashes, and drop a trailing slash (except for root itself). */
+/** Parent directory of a path ("/" for top-level entries). */
+function parentDir(path: string): string {
+	const idx = path.lastIndexOf("/");
+	return idx <= 0 ? "/" : path.slice(0, idx);
+}
+
 function normalizeRoot(input: string): string {
 	let s = input.trim();
 	if (s === "") return "/";
@@ -120,13 +324,95 @@ function normalizeRoot(input: string): string {
 	return s;
 }
 
+const joinRoot = (root: string, name: string) => (root === "/" ? `/${name}` : `${root}/${name}`);
+
 export function FilesystemTabConnected({ actorId }: { actorId: string }) {
+	// The root filesystem is in-memory and served by the VM's kernel: a
+	// sleeping VM has no file tree, and listing would boot it. Gate first.
+	return (
+		
+			
+		
+	);
+}
+
+// Browsing state survives tab switches: the dashboard swaps the iframe per
+// tab, so the root path, open file, and expanded directories persist per
+// actor (same pattern as the terminal's shells).
+const fsStateKey = (actorId: string) => `agentos-inspector:fs:${actorId}`;
+// Expansion is bounded so a long browsing session cannot bloat the payload;
+// oldest-expanded entries drop first (Set iteration is insertion-ordered).
+const MAX_PERSISTED_OPEN_DIRS = 200;
+
+function loadFsState(actorId: string): {
+	root: string;
+	selectedPath: string | null;
+	openPaths: string[];
+} {
+	try {
+		const raw = sessionStorage.getItem(fsStateKey(actorId));
+		if (raw) {
+			const parsed = JSON.parse(raw) as {
+				root?: unknown;
+				selectedPath?: unknown;
+				openPaths?: unknown;
+			};
+			return {
+				root: typeof parsed.root === "string" ? parsed.root : "/",
+				selectedPath: typeof parsed.selectedPath === "string" ? parsed.selectedPath : null,
+				openPaths: Array.isArray(parsed.openPaths)
+					? parsed.openPaths.filter((p): p is string => typeof p === "string")
+					: [],
+			};
+		}
+	} catch {
+		// Malformed storage falls back to defaults.
+	}
+	return { root: "/", selectedPath: null, openPaths: [] };
+}
+
+function FilesystemLoaded({ actorId }: { actorId: string }) {
 	// `root` drives the listing/refetch; `draft` tracks keystrokes locally so
 	// typing never refetches. `root` is committed 500ms after typing stops (or
 	// immediately on Enter) so we don't refetch on every keystroke.
-	const [root, setRoot] = useState("/");
-	const [draft, setDraft] = useState("/");
-	const [selectedPath, setSelectedPath] = useState(null);
+	const initial = useRef(loadFsState(actorId)).current;
+	const [root, setRoot] = useState(initial.root);
+	const [draft, setDraft] = useState(initial.root);
+	const [selectedPath, setSelectedPath] = useState(initial.selectedPath);
+	const [openPaths, setOpenPaths] = useState>(
+		() => new Set(initial.openPaths),
+	);
+	const toggleOpen = (path: string) =>
+		setOpenPaths((prev) => {
+			const next = new Set(prev);
+			if (next.has(path)) next.delete(path);
+			else {
+				next.add(path);
+				if (next.size > MAX_PERSISTED_OPEN_DIRS) {
+					const oldest = next.values().next().value;
+					if (oldest !== undefined) next.delete(oldest);
+				}
+			}
+			return next;
+		});
+	useEffect(() => {
+		try {
+			sessionStorage.setItem(
+				fsStateKey(actorId),
+				JSON.stringify({ root, selectedPath, openPaths: [...openPaths] }),
+			);
+		} catch (error) {
+			console.warn("agentos inspector: failed to persist filesystem state", error);
+		}
+	}, [actorId, root, selectedPath, openPaths]);
+	const [newFolderDraft, setNewFolderDraft] = useState(null);
+	const [treeError, setTreeError] = useState(null);
+	const uploadInputRef = useRef(null);
+	const queryClient = useQueryClient();
 
 	const rootsQuery = useQuery(agentOsSource.listDirQueryOptions(actorId, root));
 	// `null` data = the path is not a listable directory (does not exist / is a
@@ -143,10 +429,136 @@ export function FilesystemTabConnected({ actorId }: { actorId: string }) {
 		return () => clearTimeout(id);
 	}, [draft]);
 
+	// Every directory listing under this actor (the tree fetches per-level).
+	const refreshTree = () =>
+		queryClient.invalidateQueries({ queryKey: ["agent-os", actorId, "dir"] });
+
+	// Realtime: the runtime broadcasts coalesced guest filesystem changes
+	// (`fsChanged`, at most one per ~300ms window per VM). Invalidate exactly
+	// the changed directory listings — collapsed levels just go stale and
+	// refetch on expand — plus the open file when its directory changed. Old
+	// vendored runtimes never emit the event, so this quietly degrades to the
+	// manual Refresh button.
+	const actor = useAgentOsActor();
+	const useAgentEvent = actor.useEvent as (
+		name: string,
+		handler: (payload: unknown) => void,
+	) => void;
+	const selectedPathRef = useRef(selectedPath);
+	selectedPathRef.current = selectedPath;
+	useAgentEvent("fsChanged", (raw) => {
+		const payload = raw as FsChangedPayload | undefined;
+		const dirs = Array.isArray(payload?.dirs)
+			? payload.dirs.filter((dir): dir is string => typeof dir === "string")
+			: null;
+		if (!dirs || payload?.overflow) {
+			// Overflow or malformed payload: full refresh, same as Refresh.
+			void queryClient.invalidateQueries({
+				queryKey: ["agent-os", actorId, "dir"],
+			});
+			void queryClient.invalidateQueries({
+				queryKey: ["agent-os", actorId, "file"],
+			});
+			return;
+		}
+		for (const dir of dirs) {
+			void queryClient.invalidateQueries({
+				queryKey: ["agent-os", actorId, "dir", dir],
+				exact: true,
+			});
+		}
+		const openFile = selectedPathRef.current;
+		if (
+			openFile &&
+			(dirs.includes(parentDir(openFile)) || dirs.includes(openFile))
+		) {
+			// Prefix key: covers the force/guarded content variants; the
+			// queryFn stats first, so oversize files stay cheap to refresh.
+			void queryClient.invalidateQueries({
+				queryKey: ["agent-os", actorId, "file", openFile],
+			});
+		}
+	});
+
+	// Folder actions target the location shown in the path bar, which follows
+	// tree clicks (a folder moves it there; a file moves it to its parent).
+	const currentDir = normalizeRoot(draft);
+
+	const createFolder = async () => {
+		const name = newFolderDraft?.trim();
+		if (!name) {
+			setNewFolderDraft(null);
+			return;
+		}
+		setTreeError(null);
+		try {
+			await agentOsSource.mkdir(name.startsWith("/") ? name : joinRoot(currentDir, name));
+			setNewFolderDraft(null);
+			await refreshTree();
+		} catch (error) {
+			setTreeError(error);
+		}
+	};
+
+	const upload = async (file: File) => {
+		setTreeError(null);
+		try {
+			const bytes = new Uint8Array(await file.arrayBuffer());
+			await agentOsSource.writeFile(joinRoot(currentDir, file.name), bytes);
+			await refreshTree();
+		} catch (error) {
+			setTreeError(error);
+		}
+	};
+
 	return (
 		
-
-
+
+
+ Files + + void refreshTree()}> + + + setNewFolderDraft((v) => (v === null ? "" : null))} + > + + + uploadInputRef.current?.click()} + > + + + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void upload(file); + }} + /> +
+ {/* The browsing root gets its own bar: sharing the header row with + the action icons left it truncated and cramped. Clicking into a + folder moves the bar there, so the arrow walks back up. */} +
+ { + const up = parentDir(currentDir); + setDraft(up); + setRoot((cur) => (up !== cur ? up : cur)); + }} + className="size-5" + > + + setDraft(e.target.value)} @@ -164,17 +576,38 @@ export function FilesystemTabConnected({ actorId }: { actorId: string }) { className="w-full bg-transparent font-mono text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/40 focus:text-foreground" />
+ {newFolderDraft !== null ? ( +
+ setNewFolderDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void createFolder(); + if (e.key === "Escape") setNewFolderDraft(null); + }} + spellCheck={false} + autoFocus + aria-label="New folder name" + placeholder={`folder name (created under ${currentDir})`} + className="w-full rounded border bg-background px-2 py-1 font-mono text-xs focus:outline-none" + /> +
+ ) : null} + {treeError ? : null} {rootsQuery.isLoading ? ( Loading {root}… ) : rootsQuery.error ? ( - - Failed to list {root}: {(rootsQuery.error as Error).message} - + ) : notADir ? ( Not a directory, or does not exist: {root} ) : roots.length === 0 ? ( - Empty directory. + +
+ + Empty directory. +
+
) : ( roots.map((entry) => ( setSelectedPath(e.path)} + onSelect={(e) => { + if (e.dir) { + setDraft(e.path); + } else { + setSelectedPath(e.path); + setDraft(parentDir(e.path)); + } + }} + openPaths={openPaths} + onToggle={toggleOpen} /> )) )}
-
- +
+ {/* VM trouble chips float over the viewer, below its header row so + they never cover the file action buttons; the dropdown has the + full pane width to open into (it clipped inside the sidebar). */} +
+ +
+ void refreshTree()} + onDeleted={() => setSelectedPath(null)} + onRenamed={(to) => setSelectedPath(to)} + />
); diff --git a/packages/agentos/src/inspector-tabs/tabs/mounts.tsx b/packages/agentos/src/inspector-tabs/tabs/mounts.tsx deleted file mode 100644 index a0ee48686c..0000000000 --- a/packages/agentos/src/inspector-tabs/tabs/mounts.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { AgentOsEmpty, CopyButton } from "../common"; -import { agentOsSource } from "../lib/source"; -import type { MountInfo } from "../lib/types"; -import { Badge } from "../ui/badge"; -import { ScrollArea } from "../ui/scroll-area"; -import React from "react"; - -const isEmptyConfig = (config: undefined | null | any): config is null | undefined => { - return typeof config === "undefined" || config === null || Object.keys(config).length === 0; -} - -export function MountsTab({ mounts }: { mounts: MountInfo[] }) { - return ( -
- {mounts.length === 0 ? ( - No mounts configured on this actor. - ) : ( - - - - - - - - - - - - {mounts.map((m) => ( - - - - - - - ))} - -
PathKindAccessConfig
{m.path} - - {m.kind} - - - {m.readOnly ? "read-only" : "read-write"} - - {isEmptyConfig(m.config) ? ( - - ) : ( -
- - {JSON.stringify(m.config)} - - -
- )} -
-
- )} -
- ); -} - -export function MountsTabConnected({ actorId }: { actorId: string }) { - const { data } = useSuspenseQuery(agentOsSource.mountsQueryOptions(actorId)); - return ; -} diff --git a/packages/agentos/src/inspector-tabs/tabs/processes.tsx b/packages/agentos/src/inspector-tabs/tabs/processes.tsx index 8f8bb974b3..790510078e 100644 --- a/packages/agentos/src/inspector-tabs/tabs/processes.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/processes.tsx @@ -1,9 +1,13 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { AgentOsEmpty, StatusDot } from "../common"; -import { agentOsSource } from "../lib/source"; -import type { ProcessInfo } from "../lib/types"; -import { ScrollArea } from "../ui/scroll-area"; +// Kernel process table for the System tab: one dense table sized to content, +// with detail on demand — clicking a row expands it inline (fields, stop/kill, +// live output tail) instead of a permanently open side pane. +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { ActionErrorNote, ChevronRight, relativeTime, StatusDot } from "../common"; +import { cn } from "../lib/cn"; +import { useAgentOsActor } from "../lib/rivet"; +import { agentOsSource, decodeActionBytes } from "../lib/source"; +import type { KernelProcessInfo, ProcessExitPayload, ProcessOutputPayload, ProcessTreeNode } from "../lib/types"; import React from "react"; /** Format an epoch-ms spawn time for display; `—` when absent. */ @@ -12,87 +16,266 @@ function formatStartedAt(startedAt: number | undefined): string { return new Date(startedAt).toLocaleTimeString(); } -function ProcessDetail({ p }: { p: ProcessInfo }) { - const rows: [string, string][] = [ - ["pid", String(p.pid)], - ["command", p.command], - ["args", p.args.join(" ") || "—"], - ["status", p.running ? "running" : "exited"], - ["exit code", p.exitCode == null ? "—" : String(p.exitCode)], - ["started", formatStartedAt(p.startedAt)], - ]; +/** Tree → rows with depth for indentation. Running processes sort before + * exited at every level; ties break newest-first so fresh activity is on top. */ +interface ProcessRow extends KernelProcessInfo { + depth: number; +} + +function flattenTree(nodes: ProcessTreeNode[], depth = 0, out: ProcessRow[] = []): ProcessRow[] { + const sorted = [...nodes].sort( + (a, b) => + Number(b.status === "running") - Number(a.status === "running") || + b.startTime - a.startTime, + ); + for (const node of sorted) { + const { children, ...info } = node; + out.push({ ...info, depth }); + flattenTree(children, depth + 1, out); + } + return out; +} + +// ── Live output tail ─────────────────────────────────────────────────────── +// Bounded per-pid buffers fed by the `processOutput` broadcast. Only pids +// spawned through the SDK have output pumps; everything else shows nothing. +const MAX_TRACKED_PIDS = 32; +const MAX_BUFFER_CHARS = 64_000; + +class OutputBuffers { + private buffers = new Map(); + + append(pid: number, text: string): void { + const prev = this.buffers.get(pid); + if (prev === undefined && this.buffers.size >= MAX_TRACKED_PIDS) { + // Bounded: drop the oldest-tracked pid's buffer. + const oldest = this.buffers.keys().next().value; + if (oldest !== undefined) this.buffers.delete(oldest); + } + const next = (prev ?? "") + text; + this.buffers.set(pid, next.length > MAX_BUFFER_CHARS ? next.slice(-MAX_BUFFER_CHARS) : next); + } + + get(pid: number): string | undefined { + return this.buffers.get(pid); + } +} + +/** Label-over-value cell for the expanded detail grid — compact, no divider + * rows, the layout VM dashboards use for inspect summaries. */ +function Field({ label, value }: { label: string; value: string }) { return ( -
-
- pid {p.pid} - {p.command} +
+
{label}
+
+ {value}
- -
- {rows.map(([key, val]) => ( -
-
{key}
-
{val}
-
- ))} -
-
); } -export function ProcessesTabConnected({ actorId }: { actorId: string }) { - const { data } = useSuspenseQuery(agentOsSource.processesQueryOptions(actorId)); - const [selectedPid, setSelectedPid] = useState(); - const selected = data.find((p) => p.pid === selectedPid) ?? data[0]; - +function ExpandedDetail({ + p, + outputTail, + onStop, + onKill, + actionError, +}: { + p: ProcessRow; + outputTail?: string; + onStop: () => void; + onKill: () => void; + actionError: unknown; +}) { + const [confirming, setConfirming] = useState<"stop" | "kill" | null>(null); + const confirmTimer = useRef | undefined>(undefined); + const arm = (which: "stop" | "kill", run: () => void) => { + if (confirming === which) { + clearTimeout(confirmTimer.current); + setConfirming(null); + run(); + return; + } + setConfirming(which); + clearTimeout(confirmTimer.current); + confirmTimer.current = setTimeout(() => setConfirming(null), 3_000); + }; return ( -
- {data.length === 0 ? ( - No processes running. +
+
+ + + + + + + + +
+ {p.status === "running" ? ( +
+ + +
+ ) : null} + {actionError ? : null} + {outputTail ? ( +
+
Output (live tail)
+
+						{outputTail}
+					
+
) : ( -
- - - - - - - - {/* */} - {/* */} - - - - - {data.map((p) => ( - setSelectedPid(p.pid)} - className={`cursor-pointer border-b border-foreground/[0.06] hover:bg-muted/50 ${ - selected?.pid === p.pid ? "bg-muted/50" : "" - }`} - > - - - - {/* mem, cpu */} - - - ))} - -
PIDCommandStartedMemCPUStatus
{p.pid}{p.command} {p.args.join(" ")}{formatStartedAt(p.startedAt)} - - - {p.running ? "running" : "exited"} - -
-
-
- {selected ? : Select a process.} -
+
+ No live output. Only processes spawned through the SDK stream stdout/stderr here.
)}
); } + +/** Running/total counts for the System overview; same query key as the table + * so React Query serves both from one fetch. */ +export function useProcessCounts(actorId: string): { running: number; total: number } { + const { data: tree } = useSuspenseQuery(agentOsSource.processTreeQueryOptions(actorId)); + const rows = flattenTree(tree); + return { running: rows.filter((p) => p.status === "running").length, total: rows.length }; +} + +export function ProcessTable({ actorId }: { actorId: string }) { + const { data: tree } = useSuspenseQuery(agentOsSource.processTreeQueryOptions(actorId)); + const queryClient = useQueryClient(); + const [expandedPid, setExpandedPid] = useState(null); + const [actionError, setActionError] = useState(null); + + const rows = flattenTree(tree); + + // Live output tail for SDK-spawned pids; exit refreshes the table. + const buffersRef = useRef(new OutputBuffers()); + const [, setOutputVersion] = useState(0); + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + useAgentEvent("processOutput", (raw) => { + const payload = raw as ProcessOutputPayload | undefined; + if (!payload || typeof payload.pid !== "number") return; + const text = new TextDecoder("utf-8", { fatal: false }).decode( + decodeActionBytes(payload.data), + ); + if (!text) return; + buffersRef.current.append(payload.pid, text); + setOutputVersion((v) => v + 1); + }); + useAgentEvent("processExit", (raw) => { + const payload = raw as ProcessExitPayload | undefined; + if (!payload || typeof payload.pid !== "number") return; + buffersRef.current.append(payload.pid, `\n[exited ${payload.exitCode}]`); + setOutputVersion((v) => v + 1); + void queryClient.invalidateQueries({ + queryKey: agentOsSource.processTreeQueryOptions(actorId).queryKey, + }); + }); + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: agentOsSource.processTreeQueryOptions(actorId).queryKey, + }); + const runControl = async (action: () => Promise) => { + setActionError(null); + try { + await action(); + await invalidate(); + } catch (error) { + setActionError(error); + } + }; + + if (rows.length === 0) { + return
No processes in the VM.
; + } + return ( +
+ + + + + + + + + + + {rows.map((p) => ( + + setExpandedPid((cur) => (cur === p.pid ? null : p.pid))} + className={cn( + "cursor-pointer border-b border-foreground/[0.06] hover:bg-muted/50", + expandedPid === p.pid && "bg-muted/40", + p.status === "exited" && "opacity-50", + )} + > + + + + + + + {expandedPid === p.pid ? ( + + + + ) : null} + + ))} + +
+ PIDCommandStartedStatus
+ + {p.pid} + + {p.depth > 0 ? : null} + {p.command} + {p.args.length > 0 ? ( + {p.args.join(" ")} + ) : null} + + + {formatStartedAt(p.startTime)} + + + + {p.status} + +
+ void runControl(() => agentOsSource.stopProcess(p.pid))} + onKill={() => void runControl(() => agentOsSource.killProcess(p.pid))} + actionError={actionError} + /> +
+
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/tabs/software.tsx b/packages/agentos/src/inspector-tabs/tabs/software.tsx deleted file mode 100644 index 317b158f90..0000000000 --- a/packages/agentos/src/inspector-tabs/tabs/software.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { AgentOsEmpty, ChevronRight } from "../common"; -import { cn } from "../lib/cn"; -import { agentOsSource } from "../lib/source"; -import type { SoftwareBundle } from "../lib/types"; -import { Badge } from "../ui/badge"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "../ui/collapsible"; -import { ScrollArea } from "../ui/scroll-area"; -import React from "react"; - -function SoftwareRow({ bundle }: { bundle: SoftwareBundle }) { - const [open, setOpen] = useState(false); - const hasBinaries = bundle.binaries.length > 0; - - return ( - - - - {bundle.name} - {hasBinaries ? ( - - {bundle.binaries.length} cmd{bundle.binaries.length === 1 ? "" : "s"} - - ) : null} - - {bundle.version} - - - {bundle.source} - - - {hasBinaries ? ( - -
- {bundle.binaries.map((bin) => ( - - {bin} - - ))} -
-
- ) : null} -
- ); -} - -export function SoftwareTab({ software }: { software: SoftwareBundle[] }) { - return ( -
- {software.length === 0 ? ( - No software bundles installed. - ) : ( - -
- {software.map((bundle) => ( - - ))} -
-
- )} -
- ); -} - -export function SoftwareTabConnected({ actorId }: { actorId: string }) { - const { data } = useSuspenseQuery(agentOsSource.softwareQueryOptions(actorId)); - return ; -} diff --git a/packages/agentos/src/inspector-tabs/tabs/system.tsx b/packages/agentos/src/inspector-tabs/tabs/system.tsx new file mode 100644 index 0000000000..fac432f09a --- /dev/null +++ b/packages/agentos/src/inspector-tabs/tabs/system.tsx @@ -0,0 +1,322 @@ +// "What is this VM made of and what is it doing": the live process tree plus +// installed software, configured mounts, preview links, and the actor id in +// one scroll view, keeping the tab bar to the high-traffic surfaces +// (transcript, terminal, filesystem). +import { useSuspenseQueries } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { ActionErrorNote, ChevronRight, CopyButton } from "../common"; +import { cn } from "../lib/cn"; +import { agentOsSource } from "../lib/source"; +import type { MountInfo, SignedPreviewUrl, SoftwareBundle } from "../lib/types"; +import { Badge } from "../ui/badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { SOFTWARE_LOGO_DARK_CLASS, SOFTWARE_LOGOS } from "../software-logos"; +import { ScrollArea } from "../ui/scroll-area"; +import { VmBootGate } from "../vm-boot-gate"; +import { VmStatusBadges } from "../vm-status-badges"; +import { ProcessTable, useProcessCounts } from "./processes"; +import React from "react"; + +function SoftwareRow({ bundle }: { bundle: SoftwareBundle }) { + const [open, setOpen] = useState(false); + const hasBinaries = bundle.binaries.length > 0; + const logo = SOFTWARE_LOGOS[bundle.slug]; + + return ( + + + + {/* Theme-aware chip: white in light mode (the logos' native + background), muted in dark mode with a per-logo hue-true + treatment so dark artwork stays legible. */} + + {logo ? ( + + ) : ( + + {bundle.slug.charAt(0)} + + )} + + {bundle.name} + {hasBinaries ? ( + + {bundle.binaries.length} cmd{bundle.binaries.length === 1 ? "" : "s"} + + ) : null} + {bundle.version} + + {bundle.source} + + + {hasBinaries ? ( + +
+ {bundle.binaries.map((bin) => ( + + {bin} + + ))} +
+
+ ) : null} +
+ ); +} + +const isEmptyConfig = (config: unknown): config is null | undefined => + typeof config === "undefined" || + config === null || + Object.keys(config as object).length === 0; + +function MountsTable({ mounts }: { mounts: MountInfo[] }) { + return ( + + + + + + + + + + + {mounts.map((m) => ( + + + + + + + ))} + +
PathKindAccessConfig
{m.path} + + {m.kind} + + + {m.readOnly ? "read-only" : "read-write"} + + {isEmptyConfig(m.config) ? ( + + ) : ( +
+ + {JSON.stringify(m.config)} + + +
+ )} +
+ ); +} + +// Signed preview links: proxy HTTP to a port inside the VM. Local state only — +// links created here are revocable here; links created by code are not listed +// (there is no enumeration action). +function PreviewLinks() { + const [portDraft, setPortDraft] = useState("3000"); + const [links, setLinks] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const create = async () => { + const port = Number.parseInt(portDraft, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + setError(new Error(`Not a valid port: ${portDraft}`)); + return; + } + setBusy(true); + setError(null); + try { + const link = await agentOsSource.createSignedPreviewUrl(port, 900); + setLinks((prev) => [...prev.filter((l) => l.token !== link.token), link]); + } catch (err) { + setError(err); + } finally { + setBusy(false); + } + }; + const revoke = async (link: SignedPreviewUrl) => { + setError(null); + try { + await agentOsSource.expireSignedPreviewUrl(link.token); + setLinks((prev) => prev.filter((l) => l.token !== link.token)); + } catch (err) { + setError(err); + } + }; + + return ( +
+
+ + setPortDraft(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && void create()} + inputMode="numeric" + className="w-20 rounded border bg-background px-2 py-1 font-mono focus:outline-none" + /> + + + Signed URL to an HTTP server on that port, valid 15 minutes. Boots the VM if asleep. + +
+ {error ? : null} + {links.length > 0 ? ( +
+ {links.map((link) => ( +
+ + port {link.port}: {link.path} + + + expires {new Date(link.expiresAt).toLocaleTimeString()} + + +
+ ))} +
+ ) : null} +
+ ); +} + +/** Card section, matching the dashboard's settings idiom: title inside a + * rounded bordered card, tables in an inner border. */ +function Card({ + title, + right, + children, +}: { + title: string; + right?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {title} + + {right} +
+ {children} +
+ ); +} + +export function SystemTabConnected({ actorId }: { actorId: string }) { + // Every section here (process tree, software commands, mounts) is + // enumerated by the running VM, so opening the tab would wake a sleeping + // one. Gate first. + return ( + + + + ); +} + +function SystemLoaded({ actorId }: { actorId: string }) { + const [software, mounts] = useSuspenseQueries({ + queries: [ + agentOsSource.softwareQueryOptions(actorId), + agentOsSource.mountsQueryOptions(actorId), + ], + }); + const processCounts = useProcessCounts(actorId); + const count = (text: string) => ( + {text} + ); + return ( +
+ {/* VM trouble chips float top-right like every other tab; nothing + renders while the VM is healthy. */} +
+ +
+ +
+ +
+ +
+
+ + {software.data.length === 0 ? ( +
No software bundles installed.
+ ) : ( +
+ {software.data.map((bundle) => ( + + ))} +
+ )} +
+ + {mounts.data.length === 0 ? ( +
No mounts configured on this actor.
+ ) : ( +
+ +
+ )} +
+ + + +
+
+
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/tabs/terminal.tsx b/packages/agentos/src/inspector-tabs/tabs/terminal.tsx new file mode 100644 index 0000000000..5db715db1c --- /dev/null +++ b/packages/agentos/src/inspector-tabs/tabs/terminal.tsx @@ -0,0 +1,733 @@ +// Interactive PTY into the VM, on the contracted shell surface: `openShell` + +// `writeShell`/`resizeShell`/`closeShell` actions and the `shellData`/ +// `shellStderr`/`shellExit` broadcasts. xterm.js stays confined to this lazy +// chunk — nothing outside this file may import it, or it lands in the shared +// main bundle. +// +// Starting is an explicit gate because `openShell` boots a sleeping VM; merely +// opening the tab must never wake anything — including the reattach liveness +// probe, which consults the observe-only `getRuntimeHealth` before touching +// any shell action. +// +// Shells survive tab switches: the dashboard swaps this iframe out when +// another tab is opened, so open shell ids plus captured scrollback persist +// in sessionStorage (lib/shell-store) and reattach on return. While another +// inspector tab is on screen, ITS document keeps recording shell output into +// the same store (lib/shell-capture), so an ordinary tab switch loses +// nothing; only a window with no inspector document open leaves a gap, and +// reattach says so then. Shells die with the VM (sleep/shutdown) regardless. +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useReducer, useRef, useState } from "react"; +import { + ActionErrorNote, + AgentOsEmpty, + AgentOsWordmark, + IconButton, + PlusIcon, + relativeTime, + StatusDot, +} from "../common"; +import { isInspectorActionError } from "../lib/actor-client"; +import { cn } from "../lib/cn"; +import { useAgentOsActor } from "../lib/rivet"; +import { ShellMirror } from "../lib/shell-mirror"; +import { + clearPersistedShells, + loadPersistedShells, + type PersistedShells, + savePersistedShells, + trimScrollback, +} from "../lib/shell-store"; +import { agentOsSource, decodeActionBytes, healthQueryOptions } from "../lib/source"; +import type { ShellDataPayload, ShellExitPayload } from "../lib/types"; +import { ScrollArea } from "../ui/scroll-area"; +import { VmStatusBadges } from "../vm-status-badges"; +import "@xterm/xterm/css/xterm.css"; +import React from "react"; + +// Batch keystrokes into one `writeShell` per frame: the actor worker executes +// actions serially, so per-keystroke calls would queue behind slow actions. +const WRITE_FLUSH_MS = 16; +const RESIZE_DEBOUNCE_MS = 150; +// Bounded: shells fan their output out to every connected dashboard client, +// and scrollback lives in sessionStorage (~5 MB budget shared per origin). +const MAX_SHELLS = 4; +// The persisted payload is older than this ⇒ no inspector document was alive +// to capture output in between (an ordinary tab switch re-saves within +// milliseconds), so reattach flags the gap. +const CAPTURE_GAP_MS = 10_000; +const CAPTURE_GAP_NOTE = + "\r\n\x1b[2m[reattached — output while no inspector tab was open was not captured]\x1b[0m\r\n"; + +type ShellStatus = "live" | "exited" | "dead"; + +interface ShellEntry { + shellId: string; + /** Display name ("sh 1", "sh 2", …) — shell ids are opaque and unreadable. */ + name: string; + openedAt: number; + status: ShellStatus; + exitCode?: number; +} + +export function TerminalTabConnected({ actorId }: { actorId: string }) { + const [shells, setShellsState] = useState([]); + const [activeId, setActiveIdState] = useState(null); + const [opening, setOpening] = useState(false); + const [startError, setStartError] = useState(null); + // 30s ticker so the sidebar's relativeTime labels don't freeze. + const [, bumpClock] = useReducer((n: number) => n + 1, 0); + const queryClient = useQueryClient(); + const containerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const writeBufRef = useRef(""); + const flushTimerRef = useRef | undefined>(undefined); + // Flushes are separate HTTP requests; two in flight can arrive at the actor + // out of order and reorder the typed bytes. Chain them so each flush is + // sent only after the previous one is acked. The chain never rejects. + const writeChainRef = useRef>(Promise.resolve()); + // Imperative mirrors, updated synchronously WITH every state set (never + // during render): the broadcast handlers and the write path must see a new + // shell the instant `openShell` resolves — a render-lagged mirror drops + // output that arrives before React commits (the shell's first prompt). + const shellsRef = useRef([]); + const activeIdRef = useRef(null); + // One headless mirror per LIVE shell (lib/shell-mirror): it answers the + // shell's terminal queries whenever the visible xterm is not showing that + // shell (background here, or every shell while another inspector tab has + // the iframe), and its serialized buffer is the clean snapshot used for + // rendering on switch and for persistence. + const mirrorsRef = useRef(new Map()); + const setShells = (next: ShellEntry[]) => { + shellsRef.current = next; + setShellsState(next); + }; + const setActiveId = (next: string | null) => { + // The visible xterm answers queries for the shell it renders; the + // mirror answers for everyone else. Exactly one answerer per shell. + if (activeIdRef.current !== next) { + const prev = activeIdRef.current; + if (prev) mirrorsRef.current.get(prev)?.setAnswering(true); + if (next) mirrorsRef.current.get(next)?.setAnswering(false); + } + activeIdRef.current = next; + setActiveIdState(next); + }; + // Per-shell scrollback with one streaming decoder PER stream: stdout and + // stderr chunks interleave, and a shared decoder corrupts multibyte UTF-8 + // split across chunks of different streams. + const scrollbackRef = useRef( + new Map(), + ); + // True while renderShell() replays scrollback into xterm. Replayed bytes + // include terminal queries the shell once sent (DSR/CPR "ESC[6n", …), and + // xterm answers them by EMITTING input — forwarding those stale answers to + // the live shell corrupts its line editor (reedline repaints the prompt + // over command output). Drop everything onData produces during a replay. + const replayingRef = useRef(false); + // Monotonic display-name counter ("sh 1", "sh 2", …), persisted so names + // stay unique across tab switches. + const nameCounterRef = useRef(1); + + const scrollbackEntry = (shellId: string) => { + const map = scrollbackRef.current; + let entry = map.get(shellId); + if (!entry) { + entry = { text: "", out: new TextDecoder(), err: new TextDecoder() }; + map.set(shellId, entry); + } + return entry; + }; + + // Close a shell without awaiting the caller's flow; the failure is logged, + // not surfaced — the runtime reaps shells on VM sleep regardless. + const closeQuietly = (shellId: string) => { + void agentOsSource.closeShell(shellId).catch((error) => { + console.warn(`agentos inspector: closeShell(${shellId}) failed`, error); + }); + }; + + // Mirror query replies ride the same serialized write chain as keystrokes + // (reply errors are log-only: the shell may just have exited). + const mirrorReply = (shellId: string, data: string) => { + writeChainRef.current = writeChainRef.current.then(() => + agentOsSource.writeShell(shellId, data).then( + () => undefined, + (error) => { + console.warn(`agentos inspector: query reply to ${shellId} failed`, error); + }, + ), + ); + }; + + const ensureMirror = (shellId: string, seed: string, answering: boolean): ShellMirror => { + let mirror = mirrorsRef.current.get(shellId); + if (!mirror) { + const term = termRef.current; + mirror = new ShellMirror({ + cols: term?.cols ?? 80, + rows: term?.rows ?? 24, + onReply: (data) => mirrorReply(shellId, data), + }); + mirror.seed(seed, answering); + mirrorsRef.current.set(shellId, mirror); + } + return mirror; + }; + + // Freeze a mirror's final state into the raw scrollback (plus a closing + // note) and drop it — used when a shell exits or dies. + const freezeMirror = (shellId: string, note: string) => { + const mirror = mirrorsRef.current.get(shellId); + const entry = scrollbackEntry(shellId); + entry.text = trimScrollback( + (mirror?.isReady() ? mirror.serialize() : entry.text) + note, + ); + if (mirror) { + mirror.dispose(); + mirrorsRef.current.delete(shellId); + } + }; + + const flushWrites = () => { + flushTimerRef.current = undefined; + const data = writeBufRef.current; + // Always drain: a buffer left behind when no shell is active would leak + // into whichever shell becomes active next. + writeBufRef.current = ""; + const shellId = activeIdRef.current; + if (!shellId || !data) return; + writeChainRef.current = writeChainRef.current.then(() => + agentOsSource.writeShell(shellId, data).then( + () => undefined, + (error) => { + termRef.current?.write( + `\r\n\x1b[31m[input failed: ${error instanceof Error ? error.message : String(error)}]\x1b[0m\r\n`, + ); + }, + ), + ); + }; + + const ensureTerm = (): Terminal | null => { + if (termRef.current) return termRef.current; + const container = containerRef.current; + if (!container) return null; + const term = new Terminal({ + cursorBlink: true, + fontSize: 12, + fontFamily: '"IBM Plex Mono", ui-monospace, monospace', + // Match the dashboard theme tokens (--background: 240 6% 4%). + theme: { background: "#0a0a0b" }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(container); + fit.fit(); + term.onData((data) => { + // Answers xterm generates while replaying scrollback are stale + // responses to long-answered queries — never forward them. + if (replayingRef.current) return; + // Keystrokes only go to a LIVE shell; an exited/dead one has no PTY, + // and every write would just spam failing actions. + const entry = shellsRef.current.find((s) => s.shellId === activeIdRef.current); + if (entry?.status !== "live") return; + writeBufRef.current += data; + if (flushTimerRef.current === undefined) { + flushTimerRef.current = setTimeout(flushWrites, WRITE_FLUSH_MS); + } + }); + termRef.current = term; + fitRef.current = fit; + return term; + }; + + // Show `shellId`'s scrollback in the (single) xterm instance. Live shells + // render from their mirror's serialized snapshot (clean state, no queries + // or destructive repaints); exited/dead ones from the frozen raw text. + const renderShell = (shellId: string) => { + const term = ensureTerm(); + if (!term) return; + term.reset(); + const mirror = mirrorsRef.current.get(shellId); + const text = mirror?.isReady() + ? mirror.serialize() + : scrollbackRef.current.get(shellId)?.text; + if (text) { + replayingRef.current = true; + // The write callback runs after xterm has processed the replayed + // bytes (and synchronously emitted any query answers into onData). + term.write(text, () => { + replayingRef.current = false; + }); + } + term.focus(); + }; + + // Mark shells dead (VM slept / shell reaped): note in scrollback, status in + // the sidebar, and a repaint if one of them is on screen. + const markDead = (shellIds: string[], note: string) => { + if (shellIds.length === 0) return; + for (const id of shellIds) freezeMirror(id, note); + setShells( + shellsRef.current.map((s) => + shellIds.includes(s.shellId) && s.status === "live" ? { ...s, status: "dead" } : s, + ), + ); + const active = activeIdRef.current; + if (active && shellIds.includes(active)) renderShell(active); + }; + + const switchTo = (shellId: string) => { + if (shellId === activeIdRef.current) { + // Already showing — just hand focus back to the terminal (the click + // landed on the sidebar row, which otherwise keeps it). + termRef.current?.focus(); + return; + } + // Drop keystrokes buffered for the previous shell. + writeBufRef.current = ""; + setActiveId(shellId); + renderShell(shellId); + // The xterm viewport is shared across shells but the runtime resize only + // ever targeted the previously-active PTY — sync this one to it. + const term = termRef.current; + const entry = shellsRef.current.find((s) => s.shellId === shellId); + if (term && entry?.status === "live") { + void agentOsSource.resizeShell(shellId, term.cols, term.rows).catch((error) => { + console.warn("agentos inspector: resizeShell failed", error); + }); + } + }; + + const start = async () => { + if (shellsRef.current.filter((s) => s.status === "live").length >= MAX_SHELLS) { + setStartError(new Error(`Shell limit reached (${MAX_SHELLS}) — close one first.`)); + return; + } + setStartError(null); + setOpening(true); + try { + const term = ensureTerm(); + const { shellId } = await agentOsSource.openShell({ + cols: term?.cols, + rows: term?.rows, + }); + const name = `sh ${nameCounterRef.current++}`; + // Answering starts off — this shell becomes active below, and the + // visible xterm answers for the active shell. + ensureMirror(shellId, "", false); + setShells([ + ...shellsRef.current, + { shellId, name, openedAt: Date.now(), status: "live" }, + ]); + setActiveId(shellId); + term?.reset(); + term?.focus(); + } catch (error) { + setStartError(error); + } finally { + setOpening(false); + } + }; + + const closeOne = (shellId: string) => { + const entry = shellsRef.current.find((s) => s.shellId === shellId); + if (entry?.status === "live") closeQuietly(shellId); + mirrorsRef.current.get(shellId)?.dispose(); + mirrorsRef.current.delete(shellId); + scrollbackRef.current.delete(shellId); + const next = shellsRef.current.filter((s) => s.shellId !== shellId); + if (activeIdRef.current === shellId) { + // Drop keystrokes buffered for the closing shell before the fallback + // becomes the flush target. + writeBufRef.current = ""; + clearTimeout(flushTimerRef.current); + flushTimerRef.current = undefined; + const fallback = next[next.length - 1]?.shellId ?? null; + setActiveId(fallback); + if (fallback) renderShell(fallback); + else termRef.current?.reset(); + } + setShells(next); + }; + + // Persist shells + scrollback so a tab switch (iframe swap) can reattach. + // React unmount cleanup does not run when the dashboard swaps the iframe's + // document away, so save on pagehide. Live AND exited shells persist (the + // sidebar keeps exited history until explicitly closed); dead ones are gone. + useEffect(() => { + const persist = () => { + const keep = shellsRef.current.filter((s) => s.status !== "dead"); + if (keep.length === 0) { + clearPersistedShells(actorId); + return; + } + const active = activeIdRef.current; + const payload: PersistedShells = { + shells: keep.map((s) => { + const mirror = mirrorsRef.current.get(s.shellId); + return { + shellId: s.shellId, + name: s.name, + openedAt: s.openedAt, + scrollback: trimScrollback( + mirror?.isReady() + ? mirror.serialize() + : (scrollbackRef.current.get(s.shellId)?.text ?? ""), + ), + status: s.status === "exited" ? "exited" : "live", + exitCode: s.exitCode, + }; + }), + // Clamp: reattach must never select a shell that is not in the list. + active: active && keep.some((s) => s.shellId === active) ? active : null, + counter: nameCounterRef.current, + cols: termRef.current?.cols, + rows: termRef.current?.rows, + }; + savePersistedShells(actorId, payload); + }; + window.addEventListener("pagehide", persist); + return () => { + window.removeEventListener("pagehide", persist); + persist(); + }; + }, [actorId]); + + // Reattach shells from a previous mount of this tab. The container div is + // always laid out, so xterm can be created with real dimensions right here + // (effects run after the DOM commit). Liveness of persisted-live shells is + // probed without ever waking the VM: the observe-only `getRuntimeHealth` + // answers "asleep" (shells died with the VM) before any shell action runs. + useEffect(() => { + const persisted = loadPersistedShells(actorId); + if (!persisted) return; + clearPersistedShells(actorId); + const entries: ShellEntry[] = persisted.shells.map((s, i) => ({ + shellId: s.shellId, + name: s.name ?? `sh ${i + 1}`, + openedAt: s.openedAt ?? Date.now(), + status: s.status ?? "live", + exitCode: s.exitCode, + })); + // An ordinary tab switch re-saves the payload continuously (the other + // tab's document records output too — lib/shell-capture), so a stale + // payload means a real capture gap worth flagging. + const captureGap = Date.now() - (persisted.savedAt ?? 0) > CAPTURE_GAP_MS; + const activeId = + persisted.active && entries.some((s) => s.shellId === persisted.active) + ? persisted.active + : (entries[entries.length - 1]?.shellId ?? null); + for (const s of persisted.shells) { + // SET (not append) so a StrictMode double-mount cannot duplicate the + // scrollback; the endsWith guard keeps the marker single for the same + // reason. + const entry = scrollbackEntry(s.shellId); + const note = + captureGap && + (s.status ?? "live") === "live" && + !s.scrollback.endsWith(CAPTURE_GAP_NOTE) + ? CAPTURE_GAP_NOTE + : ""; + entry.text = trimScrollback(s.scrollback + note); + // Live shells get a mirror seeded with the snapshot; it answers + // queries for every shell except the one the xterm will render. + if ((s.status ?? "live") === "live") { + ensureMirror(s.shellId, entry.text, s.shellId !== activeId); + } + } + nameCounterRef.current = persisted.counter ?? persisted.shells.length + 1; + setShells(entries); + setActiveId(activeId); + if (activeId) renderShell(activeId); + + const liveIds = entries.filter((s) => s.status === "live").map((s) => s.shellId); + let cancelled = false; + if (liveIds.length > 0) { + void (async () => { + let booted: boolean; + try { + booted = (await queryClient.fetchQuery(healthQueryOptions(actorId))).booted; + } catch (error) { + // Older runtime without the action, or a transient failure: we + // cannot tell without waking the VM — leave the shells live and + // let the first interaction surface reality. + console.warn("agentos inspector: reattach health check failed", error); + return; + } + if (cancelled) return; + if (!booted) { + markDead(liveIds, "\r\n\x1b[2m[VM is asleep — shell terminated]\x1b[0m\r\n"); + return; + } + // VM already up: cheap idempotent per-shell probe, carrying the REAL + // terminal size so reattached PTYs match the viewport. A reaped shell + // answers with a runtime error and gets marked dead instead of + // silently eating keystrokes. + const term = termRef.current; + for (const id of liveIds) { + void agentOsSource + .resizeShell(id, term?.cols ?? 80, term?.rows ?? 24) + .catch((error) => { + if (cancelled) return; + // Only a runtime-layer rejection means the shell is gone; a + // gateway/auth/timeout blip must not kill a live shell's row. + if (isInspectorActionError(error) && error.layer === "runtime") { + markDead( + [id], + "\r\n\x1b[2m[shell no longer exists — the VM may have slept]\x1b[0m\r\n", + ); + } else { + console.warn( + `agentos inspector: reattach probe for ${id} failed (transient)`, + error, + ); + } + }); + } + })(); + } + return () => { + cancelled = true; + }; + }, [actorId, queryClient]); + + // Fit-to-container, propagated to the active PTY (debounced). + useEffect(() => { + const container = containerRef.current; + if (!container) return; + let timer: ReturnType | undefined; + const observer = new ResizeObserver(() => { + clearTimeout(timer); + timer = setTimeout(() => { + const term = termRef.current; + fitRef.current?.fit(); + if (term) { + // Mirrors track the shared viewport size too, so their + // snapshots reflow like the visible terminal. + for (const mirror of mirrorsRef.current.values()) { + mirror.resize(term.cols, term.rows); + } + } + const shellId = activeIdRef.current; + if (term && shellId) { + void agentOsSource.resizeShell(shellId, term.cols, term.rows).catch((error) => { + console.warn("agentos inspector: resizeShell failed", error); + }); + } + }, RESIZE_DEBOUNCE_MS); + }); + observer.observe(container); + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, []); + + // Dispose the xterm instance and the mirrors on unmount. Shells are + // deliberately NOT closed: they persist across tab switches (the persist + // effect's cleanup snapshots the mirrors first) and are reaped on VM sleep. + useEffect(() => { + return () => { + clearTimeout(flushTimerRef.current); + termRef.current?.dispose(); + termRef.current = null; + for (const mirror of mirrorsRef.current.values()) mirror.dispose(); + mirrorsRef.current.clear(); + }; + }, []); + + // Broadcast streams. `shellData`/`shellStderr` fan out to every connected + // client, so keep only output for shells this iframe owns; the active one + // also renders live. + const actor = useAgentOsActor(); + const useAgentEvent = actor.useEvent as ( + name: string, + handler: (payload: unknown) => void, + ) => void; + const onShellOutput = (raw: unknown, stream: "out" | "err") => { + const payload = raw as ShellDataPayload | undefined; + if (!payload?.shellId) return; + if (!shellsRef.current.some((s) => s.shellId === payload.shellId)) return; + const sb = scrollbackEntry(payload.shellId); + const text = sb[stream].decode(decodeActionBytes(payload.data), { stream: true }); + sb.text = trimScrollback(sb.text + text); + // The mirror tracks every shell's true screen state (and answers + // queries for non-active ones); the visible xterm renders the active. + mirrorsRef.current.get(payload.shellId)?.write(text); + if (payload.shellId === activeIdRef.current) termRef.current?.write(text); + }; + useAgentEvent("shellData", (raw) => onShellOutput(raw, "out")); + useAgentEvent("shellStderr", (raw) => onShellOutput(raw, "err")); + useAgentEvent("shellExit", (raw) => { + const payload = raw as ShellExitPayload | undefined; + if (!payload?.shellId) return; + if (!shellsRef.current.some((s) => s.shellId === payload.shellId)) return; + const note = `\r\n\x1b[2m[shell exited with code ${payload.exitCode}]\x1b[0m\r\n`; + freezeMirror(payload.shellId, note); + setShells( + shellsRef.current.map((s) => + s.shellId === payload.shellId + ? { ...s, status: "exited", exitCode: payload.exitCode } + : s, + ), + ); + if (payload.shellId === activeIdRef.current) termRef.current?.write(note); + }); + useAgentEvent("vmShutdown", (raw) => { + const live = shellsRef.current.filter((s) => s.status === "live").map((s) => s.shellId); + if (live.length === 0) return; + const reason = (raw as { reason?: string } | undefined)?.reason; + markDead( + live, + `\r\n\x1b[2m[VM shut down${reason ? ` (${reason})` : ""} — shell terminated]\x1b[0m\r\n`, + ); + }); + + const hasShells = shells.length > 0; + // Keep "Ns ago" labels moving while any rows are visible. + useEffect(() => { + if (!hasShells) return; + const timer = setInterval(bumpClock, 30_000); + return () => clearInterval(timer); + }, [hasShells]); + + const liveCount = shells.filter((s) => s.status === "live").length; + // Sidebar + content, matching the transcript and filesystem tabs. Exited + // shells stay listed with their scrollback (readable history) until closed. + return ( +
+
+
+ + Shells{liveCount > 0 ? ` · ${liveCount} live` : ""} + + + {/* Always visible, like the transcript's + — it starts a shell + directly, whether or not any exist yet. */} + {opening ? ( + Starting… + ) : ( + void start()}> + + + )} +
+ {!hasShells ? ( + No shells yet. + ) : ( + +
+ {shells.map((s) => ( +
switchTo(s.shellId)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + switchTo(s.shellId); + } + }} + className={cn( + "group flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left", + s.shellId === activeId ? "bg-muted" : "hover:bg-muted/50", + )} + > + +
+
+ {s.name} · {relativeTime(s.openedAt)} + {s.status === "exited" ? ( + · exited + ) : null} + {s.status === "dead" ? ( + · gone + ) : null} +
+
+ …{s.shellId.slice(-10)} +
+
+ +
+ ))} +
+
+ )} +
+
+ {/* VM trouble chips float over the top-right corner; nothing renders + while the VM is healthy. Above the empty-state overlay. */} +
+ +
+ {hasShells && startError ? ( +
+ {startError instanceof Error ? startError.message : String(startError)} +
+ ) : null} + {/* Always laid out: xterm must open and fit against a visible, + sized container (open/fit on display:none silently keeps the + 80x24 defaults, which then get pushed to real PTYs). The empty + state overlays it instead of replacing it. */} +
+ {!hasShells && !opening ? ( +
+ +
+ + Interactive shell into the VM. + + {startError ? ( + + ) : null} +
+
+
+ ) : null} +
+
+ ); +} diff --git a/packages/agentos/src/inspector-tabs/tabs/transcript.tsx b/packages/agentos/src/inspector-tabs/tabs/transcript.tsx index d7582c6f87..e984f64f50 100644 --- a/packages/agentos/src/inspector-tabs/tabs/transcript.tsx +++ b/packages/agentos/src/inspector-tabs/tabs/transcript.tsx @@ -1,61 +1,541 @@ -import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; -import { AgentOsEmpty, relativeTime, StatusDot } from "../common"; +import { useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActionErrorNote, + AgentOsEmpty, + AgentOsWordmark, + ChevronRight, + CopyButton, + IconButton, + PlusIcon, + relativeTime, + SearchIcon, + StatusDot, +} from "../common"; +import { isInspectorActionError } from "../lib/actor-client"; import { cn } from "../lib/cn"; import { useAgentOsActor } from "../lib/rivet"; -import { agentOsSource, mapNotification } from "../lib/source"; -import type { JsonRpcNotification, SessionEventPayload, TranscriptEvent } from "../lib/types"; +import { + agentOsSource, + cancelPrompt, + liveSessionsQueryOptions, + mapNotification, +} from "../lib/source"; +import type { + AgentCrashedPayload, + JsonRpcNotification, + PermissionRequestPayload, + SessionEventPayload, + TranscriptEvent, +} from "../lib/types"; import { ScrollArea } from "../ui/scroll-area"; +import { VmStatusBadges } from "../vm-status-badges"; import React from "react"; -function EventFrame({ label, meta, children }: { label: string; meta?: string; children: React.ReactNode }) { - return ( -
-
- {label} - {meta ? {meta} : null} -
- {children} -
- ); -} - +// Chat-style transcript rendering. Conversation content (user/assistant) gets +// bubbles; plumbing (usage updates, unknown ACP events) collapses to one-line +// chips with the raw JSON behind an expander so it never dominates the pane. function TranscriptEventView({ event }: { event: TranscriptEvent }) { switch (event.kind) { case "user": + return ( +
+
+ {event.text || "—"} +
+
+ ); case "assistant": return ( - -

{event.text || "—"}

-
+
+
+ {event.text || "—"} +
+
); case "thinking": return ( - -

{event.text || "—"}

-
+
+
+ {event.text || "—"} +
+
); - case "tool": + case "tool": { + const hasBody = + event.input !== undefined || event.output !== undefined || !!event.locations?.length; + const summary = ( + <> + {event.tool} + {event.status ? ( + {event.status} + ) : null} + + ); + if (!hasBody) { + return ( +
{summary}
+ ); + } return ( - - {event.status ?? ""} - +
+ + + {summary} + +
+ {event.input !== undefined ? ( +
+
Input
+
+									{JSON.stringify(event.input, null, 2)}
+								
+
+ ) : null} + {event.output !== undefined ? ( +
+
Output
+
+									{event.output}
+								
+
+ ) : null} + {event.locations?.length ? ( +
+ {event.locations.join(" ")} +
+ ) : null} +
+
+ ); + } + case "plan": + return ( +
+
Plan
+
    + {event.entries.map((entry, i) => ( +
  • + + + {entry.content} + +
  • + ))} +
+
+ ); + case "notice": + return ( +
{event.text}
+ ); + case "permission": + return ( +
+ + {event.text} +
+ ); + case "error": + return ( +
{event.text}
); default: return ( - -
+				
+ + + {event.label} + +
 						{JSON.stringify(event.json, null, 2)}
 					
- +
); } } +// Pins the transcript to the newest event — but only while the user is already +// at the bottom. Scrolling up to read history detaches the pin (tracked by an +// IntersectionObserver against the ScrollArea viewport); returning to the +// bottom re-attaches it. +function ScrollAnchor({ count }: { count: number }) { + const ref = useRef(null); + const stickRef = useRef(true); + useEffect(() => { + const el = ref.current; + const viewport = el?.closest("[data-radix-scroll-area-viewport]"); + if (!el || !viewport) return; + const observer = new IntersectionObserver( + ([entry]) => { + stickRef.current = entry.isIntersecting; + }, + { root: viewport, rootMargin: "0px 0px 96px 0px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + useEffect(() => { + if (stickRef.current) ref.current?.scrollIntoView({ block: "end" }); + }, [count]); + return
; +} + +// ── Render pipeline: raw event list → displayable rows ──────────────────── +// The ACP stream is chunked (one event per message fragment) and tool calls +// arrive as a `tool_call` plus N `tool_call_update`s. Coalesce so one message +// renders as one bubble and one tool call as one card that updates in place. +type KeyedEvent = TranscriptEvent & { key: string }; + +function coalesceTranscript(events: KeyedEvent[]): KeyedEvent[] { + const out: KeyedEvent[] = []; + const toolIndex = new Map(); + let planIndex: number | null = null; + for (const e of events) { + const last = out[out.length - 1]; + if ( + (e.kind === "user" || e.kind === "assistant" || e.kind === "thinking") && + last?.kind === e.kind + ) { + out[out.length - 1] = { ...last, text: last.text + e.text }; + continue; + } + if (e.kind === "tool" && e.toolCallId) { + const idx = toolIndex.get(e.toolCallId); + if (idx !== undefined) { + const prev = out[idx] as Extract; + out[idx] = { + ...prev, + // Updates often omit the title (the mapper then falls back to the + // id) — never overwrite a real title with the id. + tool: e.tool !== e.toolCallId ? e.tool : prev.tool, + status: e.status ?? prev.status, + input: e.input ?? prev.input, + output: e.output ?? prev.output, + locations: e.locations ?? prev.locations, + }; + continue; + } + toolIndex.set(e.toolCallId, out.length); + } + if (e.kind === "plan") { + // Plan updates are full snapshots: replace the existing card in place. + if (planIndex !== null) { + out[planIndex] = { ...out[planIndex], entries: e.entries } as KeyedEvent; + continue; + } + planIndex = out.length; + } + out.push(e); + } + return out; +} + +// Subtle three-dot pulse shown as the last row while a turn is in flight. +function TurnInFlight() { + return ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ); +} + +// Composer defaults persist in localStorage: they contain the API key the +// user pastes, which never leaves the browser except inside createSession's +// env (sent only to this actor). +const LS_AGENT_TYPE = "agentos-inspector:composer-agent-type"; +const LS_ENV_JSON = "agentos-inspector:composer-env"; +// Selected-session persistence across the per-tab iframe swap. +const transcriptStateKey = (actorId: string) => `agentos-inspector:transcript:${actorId}`; +const DEFAULT_ENV_JSON = JSON.stringify( + { + ANTHROPIC_API_KEY: "", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "anthropic/claude-haiku-4-5-20251001" }), + }, + null, + 2, +); + +// The composer's lazy create path and the sidebar's "+" button build sessions +// from the same localStorage-backed defaults, so both produce the same thing. +function newSessionDefaults(): { agentType: string; env: Record } { + const agentType = (localStorage.getItem(LS_AGENT_TYPE) ?? "opencode").trim() || "opencode"; + const raw = localStorage.getItem(LS_ENV_JSON) ?? DEFAULT_ENV_JSON; + let env: Record; + try { + env = JSON.parse(raw) as Record; + } catch (error) { + throw new Error(`Session env is not valid JSON: ${(error as Error).message}`); + } + return { agentType, env }; +} + +function Composer({ + actorId, + sessionId, + sessionStatus, + onSessionCreated, + onErrorEvent, + onBusyChange, +}: { + actorId: string; + sessionId: string | null; + sessionStatus?: string; + onSessionCreated: (sessionId: string) => void; + onErrorEvent: (text: string) => void; + onBusyChange?: (busy: boolean) => void; +}) { + const [draft, setDraft] = useState(""); + const [busy, setBusyState] = useState(false); + const setBusy = (b: boolean) => { + setBusyState(b); + onBusyChange?.(b); + }; + const [stopUnsupported, setStopUnsupported] = useState(false); + const [optionsOpen, setOptionsOpen] = useState(false); + const [agentType, setAgentType] = useState( + () => localStorage.getItem(LS_AGENT_TYPE) ?? "opencode", + ); + const [envJson, setEnvJson] = useState(() => localStorage.getItem(LS_ENV_JSON) ?? DEFAULT_ENV_JSON); + // The in-flight session survives re-renders; also used by Stop. + const activeSessionRef = useRef(null); + // Agent-type suggestions from the installed software (fetched only while the + // options panel is open). Free text stays authoritative: the datalist is a + // hint, and agent types are derived from package names heuristically. + const software = useQuery({ + ...agentOsSource.softwareQueryOptions(actorId), + enabled: optionsOpen, + }); + const agentTypeSuggestions = (software.data ?? []) + .filter((bundle) => bundle.name.endsWith("· agent")) + .map((bundle) => bundle.name.split(" · ")[0]?.split("/").pop() ?? "") + .filter(Boolean); + + const send = async () => { + const text = draft.trim(); + if (!text || busy) return; + setBusy(true); + setDraft(""); + try { + let sid = sessionId; + if (!sid) { + let env: Record; + try { + env = JSON.parse(envJson); + } catch (e) { + throw new Error(`Session env is not valid JSON: ${(e as Error).message}`); + } + const raw = await agentOsSource.createSession(agentType.trim() || "opencode", { env }); + sid = typeof raw === "string" ? raw : raw.sessionId; + onSessionCreated(sid); + } + activeSessionRef.current = sid; + await agentOsSource.sendPrompt(sid, text); + } catch (error) { + let hint = isInspectorActionError(error) ? ` — ${error.hint}` : ""; + // Prompting a persisted-but-idle session (from an earlier VM boot) + // fails inside the runtime; the generic crash hint is misleading there. + if (sessionId && sessionStatus !== "running") { + hint = + " — this session is idle (not live in the current VM boot). Send without a selection to start a fresh session, or resume it from a client."; + } + onErrorEvent(`${error instanceof Error ? error.message : String(error)}${hint}`); + } finally { + activeSessionRef.current = null; + setBusy(false); + } + }; + + const stop = async () => { + const sid = activeSessionRef.current ?? sessionId; + if (!sid) return; + try { + const supported = await cancelPrompt(sid); + if (!supported) { + setStopUnsupported(true); + onErrorEvent("Stop is not supported by this runtime (no cancelPrompt action)."); + } + } catch (error) { + onErrorEvent(error instanceof Error ? error.message : String(error)); + } + }; + + return ( +
+
+ {optionsOpen && !sessionId ? ( +
+ +