Skip to content
Closed
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
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "jcode"
version = "0.21.0"
version = "0.22.0"
description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools"
edition = "2024"
autobins = false
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/src/ambient/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ impl AmbientRunnerHandle {
child.replace_messages(parent.messages.clone());
child.compaction = parent.compaction.clone();
child.provider_key = parent.provider_key.clone();
child.route_api_method = parent.route_api_method.clone();
child.model = parent.model.clone();
child.subagent_model = parent.subagent_model.clone();
child.improve_mode = parent.improve_mode;
Expand Down
36 changes: 36 additions & 0 deletions crates/jcode-app-core/src/catchup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ pub fn needs_catchup(session_id: &str, updated_at: DateTime<Utc>, status: &Sessi
needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status)
}

/// Snapshot of the persisted catch-up "seen" state, so callers that need to
/// evaluate many sessions at once (e.g. the session picker building its list)
/// can avoid re-reading and re-parsing `catchup_seen.json` once per session.
#[derive(Clone, Default)]
pub struct CatchupSeenSnapshot {
state: PersistedCatchupState,
}

impl CatchupSeenSnapshot {
/// Load the persisted seen-state once from disk.
pub fn load() -> Self {
Self {
state: load_seen_state(),
}
}

/// Same semantics as [`needs_catchup`] but uses this preloaded snapshot
/// instead of re-reading the state file for every call.
pub fn needs_catchup(
&self,
session_id: &str,
updated_at: DateTime<Utc>,
status: &SessionStatus,
) -> bool {
if !is_attention_status(status) {
return false;
}
let seen = self
.state
.seen_at_ms_by_session
.get(session_id)
.copied();
needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status)
}
}

pub(crate) fn needs_catchup_with_seen(
updated_at_ms: i64,
seen_at_ms: Option<i64>,
Expand Down
67 changes: 67 additions & 0 deletions crates/jcode-app-core/src/external_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,59 @@ impl ExternalAuthReviewCandidate {
}
}

impl ExternalAuthReviewCandidate {
/// Coarse telemetry `(provider, method)` labels for the providers this
/// candidate activates on a successful import. Used by the onboarding flow
/// to record `auth_success` so auto-imported logins show up in the
/// activation funnel (they previously did not, because auto-import never
/// flows through the manual `pending_login` telemetry path).
///
/// The method is reported as `"import"` so import-driven activation can be
/// distinguished from manual login in the funnel.
pub fn telemetry_auth_labels(&self) -> Vec<(&'static str, &'static str)> {
const METHOD: &str = "import";
match &self.action {
ExternalAuthReviewAction::CodexLegacy => vec![("openai", METHOD)],
ExternalAuthReviewAction::ClaudeCode => vec![("claude", METHOD)],
ExternalAuthReviewAction::GeminiCli => vec![("gemini", METHOD)],
ExternalAuthReviewAction::Copilot(_) => vec![("copilot", METHOD)],
ExternalAuthReviewAction::Cursor(_) => vec![("cursor", METHOD)],
ExternalAuthReviewAction::SharedExternal(source) => {
auth::external::source_provider_labels(*source)
.into_iter()
.filter_map(|label| {
telemetry_provider_id_for_label(label).map(|id| (id, METHOD))
})
.collect()
}
}
}
}

/// Map a human-facing provider label (as produced by
/// [`auth::external::source_provider_labels`]) to the canonical telemetry
/// provider id used by the activation funnel.
fn telemetry_provider_id_for_label(label: &str) -> Option<&'static str> {
match label {
"OpenAI/Codex" => Some("openai"),
"Claude" => Some("claude"),
"Gemini" => Some("gemini"),
"Antigravity" => Some("antigravity"),
"GitHub Copilot" => Some("copilot"),
"OpenRouter/API-key providers" => Some("openrouter"),
_ => None,
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAuthAutoImportOutcome {
pub imported: usize,
pub messages: Vec<String>,
/// Coarse `(provider, method)` telemetry labels for each provider that was
/// successfully imported, so callers can record `auth_success` for the
/// activation funnel. May contain more entries than `imported` when a
/// single source carries multiple providers.
pub imported_auth_labels: Vec<(&'static str, &'static str)>,
}

impl ExternalAuthAutoImportOutcome {
Expand Down Expand Up @@ -535,6 +584,7 @@ pub async fn run_external_auth_auto_import_candidates(
let mut outcome = ExternalAuthAutoImportOutcome {
imported: 0,
messages: Vec::new(),
imported_auth_labels: Vec::new(),
};

for &index in selected {
Expand All @@ -545,6 +595,9 @@ pub async fn run_external_auth_auto_import_candidates(
match validate_external_auth_review_candidate(candidate).await {
Ok(detail) => {
outcome.imported += 1;
outcome
.imported_auth_labels
.extend(candidate.telemetry_auth_labels());
outcome.messages.push(format!(
"✓ {} (from {}): {}",
candidate.provider_summary, candidate.source_name, detail
Expand Down Expand Up @@ -573,6 +626,7 @@ mod render_markdown_tests {
let outcome = ExternalAuthAutoImportOutcome {
imported: 0,
messages: Vec::new(),
imported_auth_labels: Vec::new(),
};
assert_eq!(
outcome.render_markdown(),
Expand All @@ -590,6 +644,7 @@ mod render_markdown_tests {
"✓ Claude (from Claude Code): Loaded Claude credentials.".to_string(),
"✕ Cursor (from Cursor native): no usable auth token.".to_string(),
],
imported_auth_labels: vec![("openai", "import"), ("claude", "import")],
};
let md = outcome.render_markdown();
assert!(md.starts_with("**Logins imported**"), "got: {md}");
Expand All @@ -613,8 +668,20 @@ mod render_markdown_tests {
let outcome = ExternalAuthAutoImportOutcome {
imported: 1,
messages: vec!["✓ Gemini (from Gemini CLI): Loaded Gemini credentials.".to_string()],
imported_auth_labels: vec![("gemini", "import")],
};
let md = outcome.render_markdown();
assert!(md.contains("Reusing 1 existing login:"), "got: {md}");
}

#[test]
fn fixture_candidate_reports_import_auth_labels() {
use super::ExternalAuthReviewCandidate;
// The fixture points at the legacy Codex action -> OpenAI provider.
let candidate = ExternalAuthReviewCandidate::fixture("OpenAI/Codex", "Codex auth.json");
assert_eq!(
candidate.telemetry_auth_labels(),
vec![("openai", "import")]
);
}
}
1 change: 1 addition & 0 deletions crates/jcode-app-core/src/overnight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ fn create_coordinator_session(parent: &Session, mission: &Option<String>) -> Res
child.replace_messages(parent.messages.clone());
child.compaction = parent.compaction.clone();
child.provider_key = parent.provider_key.clone();
child.route_api_method = parent.route_api_method.clone();
child.reasoning_effort = parent.reasoning_effort.clone();
child.subagent_model = parent.subagent_model.clone();
child.improve_mode = parent.improve_mode;
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/src/server/client_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ fn create_transfer_child_session(
child.working_dir = parent.working_dir.clone();
child.model = parent.model.clone();
child.provider_key = parent.provider_key.clone();
child.route_api_method = parent.route_api_method.clone();
child.subagent_model = parent.subagent_model.clone();
child.improve_mode = parent.improve_mode;
child.autoreview_enabled = parent.autoreview_enabled;
Expand Down
78 changes: 77 additions & 1 deletion crates/jcode-app-core/src/server/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ mod newest_reload_candidate_integration_tests {
//! a temp `JCODE_HOME`. This reproduces the field "/update -> new client,
//! stale server" state and proves the fix: a self-dev daemon now reloads into
//! the freshly installed release instead of its old pinned binary.
use super::newest_reload_candidate;
use super::{canonicalize_or, newer_binary_available, newest_reload_candidate};
use crate::build;
use std::path::Path;
use std::time::{Duration, SystemTime};
Expand Down Expand Up @@ -860,6 +860,82 @@ mod newest_reload_candidate_integration_tests {
crate::env::remove_var("JCODE_HOME");
}
}

/// Re-implements `server_has_newer_binary`'s decision against an *injected*
/// running-daemon path + mtime, so a test can model "the daemon is still the
/// OLD binary" without spawning a real process. It scans the exact same
/// candidate set (both flavors) and uses the same `newer_binary_available`
/// core the production function uses.
fn daemon_reports_update(running: &Path, running_mtime: SystemTime) -> bool {
let running_canonical = canonicalize_or(running.to_path_buf());
let mut candidates = std::collections::HashSet::new();
for is_selfdev in [false, true] {
if let Some((candidate, _label)) = super::server_update_candidate(is_selfdev) {
candidates.insert(canonicalize_or(candidate));
}
}
let with_mtimes = candidates.into_iter().map(|candidate| {
let m = std::fs::metadata(&candidate)
.ok()
.and_then(|m| m.modified().ok());
(candidate, m)
});
newer_binary_available(
Some(running_mtime),
Some(running_canonical.as_path()),
with_mtimes,
)
}

/// The question that matters for shipped users: after a NORMAL (non-self-dev)
/// `/update`, does the long-lived daemon actually advertise + apply the
/// upgrade on reconnect?
///
/// Models a normal install: `shared-server` was tracking `stable`, the daemon
/// is running the old release, and `/update` installs a newer release and
/// advances stable/current/shared-server. We then drive the REAL
/// update-detection core and reload-target resolver and assert both:
/// (1) the daemon reports `server_has_update = true`, and
/// (2) the binary it reloads into is the freshly installed release.
#[test]
fn normal_user_daemon_detects_and_targets_update_after_update() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().expect("temp dir");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp.path());

let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let old_release = "0.14.3";
let new_release = "0.15.0";
let old_path = install_versioned_binary(old_release, base);
install_versioned_binary(new_release, base + Duration::from_secs(60));

// Pre-update state: every channel on the old release (shared-server
// tracking stable). This is the steady state for a normal user.
build::update_stable_symlink(old_release).expect("stable old");
build::update_current_symlink(old_release).expect("current old");
build::update_shared_server_symlink(old_release).expect("shared old");

// `/update` installs the new release and advances the channels. Because
// shared-server was tracking stable, it advances too.
build::advance_shared_server_if_tracking_stable(new_release).expect("advance shared");
build::update_stable_symlink(new_release).expect("stable new");
build::update_current_symlink(new_release).expect("current new");

// (1) The daemon (still the OLD binary) must now SEE the update so it
// reports server_has_update = true to reconnecting clients.
assert!(
daemon_reports_update(&old_path, base),
"normal-user daemon should report a server update after /update advanced the channels"
);

// (2) The binary it reloads into must be the freshly installed release.
assert_eq!(
candidate_version_for(false).as_deref(),
Some(new_release),
"normal-user daemon should reload into the freshly installed release"
);
}
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/src/tool/selfdev/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub fn enter_selfdev_session(
child.compaction = parent.compaction.clone();
child.model = parent.model.clone();
child.provider_key = parent.provider_key.clone();
child.route_api_method = parent.route_api_method.clone();
child.subagent_model = parent.subagent_model.clone();
child.improve_mode = parent.improve_mode;
child.autoreview_enabled = parent.autoreview_enabled;
Expand Down
Loading