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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/i18n/de/warp.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ cockpit-task-peek-no-plan = Kein strukturierter Aufgabenplan
cockpit-task-peek-state-waiting = Wartet auf Dich
cockpit-task-peek-state-working = Arbeitet
cockpit-task-peek-state-idle = Inaktiv
cockpit-tree-managed-agent = Verwalteter Agent – Details in der Kontoansicht
menu-tab-pin = Tab anheften
menu-tab-unpin = Tab lösen
theme-editor-open = Theme-Editor öffnen
Expand Down
1 change: 1 addition & 0 deletions app/i18n/en/warp.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -3028,6 +3028,7 @@ cockpit-task-peek-no-plan = No structured task plan
cockpit-task-peek-state-waiting = Waiting on you
cockpit-task-peek-state-working = Working
cockpit-task-peek-state-idle = Idle
cockpit-tree-managed-agent = Managed agent – details in the account view
menu-tab-pin = Pin tab
menu-tab-unpin = Unpin tab
theme-editor-open = Open theme editor
Expand Down
31 changes: 31 additions & 0 deletions app/src/app_state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,37 @@ fn test_code_pane_snapshot_with_multiple_tabs() {
assert!(matches!(source, Some(CodeSource::Link { .. })));
}

#[test]
fn persisted_cockpit_selection_maps_to_sessions_view() {
// Before the accounts became their own sidebar view (#504) the whole
// Cockpit persisted as "Cockpit". Such a snapshot must keep restoring the
// sessions tree, and the sessions tree must keep that wire name, while
// the new accounts view persists under its own name.
let stored =
r#"{"left_panel_displayed_tab":"Cockpit","pane_group_id":"side-panel","width":287}"#;
let snapshot = serde_json::from_str::<LeftPanelSnapshot>(stored)
.expect("a Cockpit snapshot from before #504 must remain readable");
assert_eq!(
snapshot.left_panel_displayed_tab,
LeftPanelDisplayedTab::Cockpit
);
assert_eq!(
LeftPanelDisplayedTab::from(ToolPanelView::Cockpit),
LeftPanelDisplayedTab::Cockpit,
"the sessions tree persists under the pre-#504 name"
);

let accounts = LeftPanelSnapshot {
left_panel_displayed_tab: ToolPanelView::CockpitAccounts.into(),
pane_group_id: "side-panel".to_string(),
width: 287,
};
assert_eq!(
serde_json::to_value(&accounts).unwrap()["left_panel_displayed_tab"],
serde_json::json!("CockpitAccounts")
);
}

#[test]
fn test_left_panel_snapshot_preserves_drive_wire_name() {
for stored_variant in ["ZapDrive", "ZaplexDrive"] {
Expand Down
118 changes: 72 additions & 46 deletions app/src/cockpit/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ use zaplex_cockpit::fleet::sort_hosts;
use zaplex_cockpit::HostNode;
use zaplex_cockpit::{
apply_oauth_usage, apply_session_verdicts, build_snapshot_with_cache, fold_inventory,
mark_registry_bound_hosts_unverified, session_key, AccountOverrides, AgentInventoryStatus,
Attention, CockpitSnapshot, FleetTree, HostAvailability, PricingTable, Provider,
RegisteredHost, RemoteHost, ScanHealth, SeenTurns, SessionSnapshot, SessionVerdict,
mark_registry_bound_hosts_unverified, session_key, AccountOverrides, AccountUsage,
AgentInventoryStatus, Attention, CockpitSnapshot, FleetTree, HostAvailability, PricingTable,
Provider, RegisteredHost, RemoteHost, ScanHealth, SeenTurns, SessionSnapshot, SessionVerdict,
TerminalLink, TranscriptScanCache,
};
// Cross-host daemon fold is a native-only concern: the `agent_session` module
Expand Down Expand Up @@ -113,6 +113,67 @@ fn should_apply_refresh_result(current_generation: u64, completed_generation: u6
current_generation == completed_generation
}

/// What a remote-manager event means for the Cockpit refresh pipeline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RemoteRefresh {
/// The set of open connections changed. Scans already in flight describe
/// the old topology, so their results must not be applied (#160: a late
/// inventory must not re-add a disconnected host).
Topology,
/// Session inventory may have changed on an unchanged topology.
Inventory,
/// Neither connections nor session inventory changed.
Ignore,
}

fn remote_refresh(event: &RemoteServerManagerEvent) -> RemoteRefresh {
match event {
RemoteServerManagerEvent::HostConnected { .. }
| RemoteServerManagerEvent::SessionConnected { .. }
| RemoteServerManagerEvent::SessionDisconnected { .. }
| RemoteServerManagerEvent::SessionReconnected { .. }
| RemoteServerManagerEvent::SessionDeregistered { .. }
| RemoteServerManagerEvent::HostDisconnected { .. } => RemoteRefresh::Topology,
RemoteServerManagerEvent::SessionExited { .. }
| RemoteServerManagerEvent::SessionOpened { .. }
| RemoteServerManagerEvent::SessionInventoryChanged { .. } => RemoteRefresh::Inventory,
RemoteServerManagerEvent::SessionConnecting { .. }
| RemoteServerManagerEvent::SessionConnectionFailed { .. }
| RemoteServerManagerEvent::NavigatedToDirectory { .. }
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataUpdated { .. }
| RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. }
| RemoteServerManagerEvent::BufferUpdated { .. }
| RemoteServerManagerEvent::SetupStateChanged { .. }
| RemoteServerManagerEvent::BinaryCheckComplete { .. }
| RemoteServerManagerEvent::BinaryInstallComplete { .. }
| RemoteServerManagerEvent::ClientRequestFailed { .. }
| RemoteServerManagerEvent::ServerMessageDecodingError { .. }
| RemoteServerManagerEvent::SessionOutput { .. }
| RemoteServerManagerEvent::SessionNotice { .. }
| RemoteServerManagerEvent::ManagedLaunchOpened { .. }
| RemoteServerManagerEvent::ManagedLaunchFailed { .. } => RemoteRefresh::Ignore,
}
}

/// The local contribution to the live tree: every live account session plus
/// Antigravity's per-workspace resume registry. Antigravity is deliberately
/// Idle: its disk state proves a resumable conversation, not a running
/// process. Claude/Codex dormant history (`AccountUsage::idle_sessions`)
/// remains on its account-detail surfaces and never enters the tree; adding
/// it here would turn provider enablement into a broad Conductor behavior
/// change.
fn local_tree_sessions(
accounts: &[AccountUsage],
antigravity: Vec<SessionSnapshot>,
) -> Vec<SessionSnapshot> {
accounts
.iter()
.flat_map(|account| account.sessions.iter().cloned())
.chain(antigravity)
.collect()
}

/// Actor-local single-flight state for full cockpit builds. Ordinary requests
/// reserve one follow-up build without invalidating useful work already running.
/// Connection changes and disabling invalidate results from the previous topology.
Expand Down Expand Up @@ -354,39 +415,16 @@ impl CockpitModel {
ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, _event, ctx| {
me.spawn_refresh(ctx);
});
ctx.subscribe_to_model(
&RemoteServerManager::handle(ctx),
|me, event, ctx| match event {
RemoteServerManagerEvent::HostConnected { .. }
| RemoteServerManagerEvent::SessionConnected { .. }
| RemoteServerManagerEvent::SessionDisconnected { .. }
| RemoteServerManagerEvent::SessionReconnected { .. }
| RemoteServerManagerEvent::SessionDeregistered { .. }
| RemoteServerManagerEvent::HostDisconnected { .. } => {
ctx.subscribe_to_model(&RemoteServerManager::handle(ctx), |me, event, ctx| {
match remote_refresh(event) {
RemoteRefresh::Topology => {
me.refresh_flight.invalidate();
me.spawn_refresh(ctx);
}
RemoteServerManagerEvent::SessionExited { .. }
| RemoteServerManagerEvent::SessionOpened { .. }
| RemoteServerManagerEvent::SessionInventoryChanged { .. } => me.spawn_refresh(ctx),
RemoteServerManagerEvent::SessionConnecting { .. }
| RemoteServerManagerEvent::SessionConnectionFailed { .. }
| RemoteServerManagerEvent::NavigatedToDirectory { .. }
| RemoteServerManagerEvent::RepoMetadataSnapshot { .. }
| RemoteServerManagerEvent::RepoMetadataUpdated { .. }
| RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. }
| RemoteServerManagerEvent::BufferUpdated { .. }
| RemoteServerManagerEvent::SetupStateChanged { .. }
| RemoteServerManagerEvent::BinaryCheckComplete { .. }
| RemoteServerManagerEvent::BinaryInstallComplete { .. }
| RemoteServerManagerEvent::ClientRequestFailed { .. }
| RemoteServerManagerEvent::ServerMessageDecodingError { .. }
| RemoteServerManagerEvent::SessionOutput { .. }
| RemoteServerManagerEvent::SessionNotice { .. } => {}
RemoteServerManagerEvent::ManagedLaunchOpened { .. }
| RemoteServerManagerEvent::ManagedLaunchFailed { .. } => {}
},
);
RemoteRefresh::Inventory => me.spawn_refresh(ctx),
RemoteRefresh::Ignore => {}
}
});

// Hook status (open prompts) and terminal lifetime change what is
// openable and what needs the user without any rescan.
Expand Down Expand Up @@ -666,25 +704,13 @@ impl CockpitModel {
.map(|node_id| (node_id, daemon.host_id.clone()))
})
.collect();
// Local contribution: every live account session plus
// Antigravity's per-workspace resume registry. Antigravity is
// deliberately Idle: its disk state proves a resumable
// conversation, not a running process. Claude/Codex dormant
// histories remain on their existing account-detail surfaces;
// adding all of them here would turn provider enablement into a
// broad Conductor behavior change.
let antigravity = zaplex_cockpit::antigravity_idle_sessions(
&inputs.home,
scan_now,
zaplex_cockpit::IDLE_MAX_AGE,
zaplex_cockpit::IDLE_SESSION_LIMIT,
);
let local: Vec<SessionSnapshot> = snapshot
.accounts
.iter()
.flat_map(|account| account.sessions.iter().cloned())
.chain(antigravity)
.collect();
let local = local_tree_sessions(&snapshot.accounts, antigravity);
let local_label = inputs.local_label.clone();
let mut inventory = fold_inventory(inputs.local_label, local, Vec::new());
apply_local_scan_health(&mut inventory, &snapshot.health);
Expand Down
104 changes: 88 additions & 16 deletions app/src/cockpit/model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ fn initial_scan_state_is_loading_not_empty() {

#[test]
fn stale_inventory_cannot_readd_disconnected_host() {
assert!(should_apply_refresh_result(2, 2));
assert!(
!should_apply_refresh_result(2, 1),
"a scan requested before the current generation must be ignored"
);

let local = HostNode {
host: "local".to_string(),
is_local: true,
Expand All @@ -42,32 +36,110 @@ fn stale_inventory_cannot_readd_disconnected_host() {
needs_me: 0,
};
let remote = remote_host(
"devhost",
"host-dev",
session("dev-session", zaplex_cockpit::SessionState::Active),
"remote",
"host-remote",
session("remote-session", zaplex_cockpit::SessionState::Active),
);
let stale_result = FleetTree {
// A full scan starts while the remote host is still connected.
let mut flight = RefreshSingleFlight::default();
assert!(flight.request());
let in_flight = flight.generation;
let mut visible = FleetTree {
hosts: vec![local, remote],
needs_me: 0,
};
let mut visible = stale_result.clone();

// The final session to that host closes before the scan returns. The
// disconnect is a topology change, which the model answers by invalidating
// the running generation and dropping the root synchronously.
let disconnect = RemoteServerManagerEvent::HostDisconnected {
host_id: warp_core::HostId::new("host-remote".to_string()),
};
assert_eq!(remote_refresh(&disconnect), RemoteRefresh::Topology);
flight.invalidate();
assert!(reconcile_live_daemon_roots(
&mut visible,
&mut ManagedFleetInventory::default(),
"local",
&[],
));
let current_generation = 2;
let stale_generation = 1;
if should_apply_refresh_result(current_generation, stale_generation) {
visible = stale_result;
}

// The late scan result belongs to the old topology and is discarded.
assert!(
!should_apply_refresh_result(flight.generation, in_flight),
"a scan started before the disconnect must be ignored"
);
assert_eq!(visible.hosts.len(), 1);
assert!(visible.hosts[0].is_local);
}

#[test]
fn connection_changes_invalidate_inflight_scans_but_inventory_changes_do_not() {
let host_id = || warp_core::HostId::new("host-remote".to_string());
assert_eq!(
remote_refresh(&RemoteServerManagerEvent::HostConnected { host_id: host_id() }),
RemoteRefresh::Topology,
"a newly connected host must not be hidden by an older scan"
);
assert_eq!(
remote_refresh(&RemoteServerManagerEvent::HostDisconnected { host_id: host_id() }),
RemoteRefresh::Topology,
);
assert_eq!(
remote_refresh(&RemoteServerManagerEvent::SessionInventoryChanged { host_id: host_id() }),
RemoteRefresh::Inventory,
"a managed Stop/Restart refreshes without discarding the running scan"
);
}

#[test]
fn dormant_account_history_never_enters_local_tree() {
let live = raw_row("live", zaplex_cockpit::SessionState::Active);
let dormant = raw_row("dormant", zaplex_cockpit::SessionState::Idle);
let account = zaplex_cockpit::AccountUsage {
account: zaplex_cockpit::Account {
provider: Provider::Claude,
key: "claude".to_string(),
config_dir: PathBuf::from("/accounts/claude"),
label: "Claude".to_string(),
provider_account_id: None,
email: None,
org: None,
role: None,
plan_tier: None,
is_default: true,
},
block5h: zaplex_cockpit::WindowTotals::default(),
today: zaplex_cockpit::WindowTotals::default(),
today_by_session: Default::default(),
week: zaplex_cockpit::WindowTotals::default(),
reset5h: None,
reset_week: None,
heat: 0.0,
heat_week: 0.0,
heat_opus: None,
heat_sonnet: None,
sessions: vec![live],
idle_sessions: vec![dormant],
status: zaplex_cockpit::AccountStatus::Live,
provenance: zaplex_cockpit::UsageProvenance::Estimate,
};
let resumable = raw_row("antigravity-resume", zaplex_cockpit::SessionState::Idle);

let local = local_tree_sessions(std::slice::from_ref(&account), vec![resumable]);

let ids: Vec<&str> = local
.iter()
.map(|session| session.session_id.as_str())
.collect();
assert_eq!(
ids,
vec!["live", "antigravity-resume"],
"dormant Claude/Codex history stays in the account detail, not the live tree"
);
assert_eq!(account.idle_sessions.len(), 1, "the history itself is kept");
}

#[test]
fn blocked_build_coalesces_refresh_triggers_into_one_rerun() {
use std::sync::atomic::{AtomicUsize, Ordering};
Expand Down
Loading
Loading