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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ This changelog is automatically generated by AI when releases are published.

For detailed design rationale and implementation notes, see `docs/superpowers/specs/2026-04-29-telemetry-streams-design.md`.

## [3.1.0] - 2026-11-08

### Fixed

- **Repositories no org has connected stay on your computer**: in connected mode, authorship notes, commit summaries (with their prompts), file-change counts and prompt transcripts from a repository that none of your orgs has connected were uploaded to the org you signed in to — so a personal or client repository's prompts could show up in a workplace dashboard. Now each repository's data goes only to the org that connected it, and a repository no org has connected keeps its data on this computer, checked again once a day in case it gets connected. Transcripts now carry their repository, so they are routed the same way. Device-code logins (no personal access token) are routed too. `autter sync status` shows how many records are kept on this computer and why. See [changelog/unconnected-repos-stay-local.md](changelog/unconnected-repos-stay-local.md).

## [3.0.0] - 2026-11-04

### Added
Expand Down
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
Expand Up @@ -5,7 +5,7 @@ resolver = "3"

[package]
name = "autter"
version = "3.0.0"
version = "3.1.0"
edition = "2024"
default-run = "autter"

Expand Down
27 changes: 27 additions & 0 deletions changelog/unconnected-repos-stay-local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Repositories no org has connected stay on your computer

Before 3.1.0, connected mode sent every repository's data to an org. When autter could not tell which org a repository belonged to, it used the org you signed in to. That included repositories nobody had connected: a personal project or a client's repository worked on from a work login ended up in the workplace's dashboard, with its prompts, transcripts and file paths.

## What changed

- Before uploading, autter asks the server which of your orgs has connected the repository (by its default remote, `owner/repo`). The answer is cached for an hour.
- An org connected it: the data goes to that org. With a personal access token autter uses a token for that org; a device-code login uploads only when it is that login's org.
- No org connected it, or the commit has no remote: the data stays in the local queues and is not uploaded anywhere. It is offered again once a day, so connecting the repository on the web later uploads it.
- The server could not be reached: nothing is decided; the upload is retried on the normal schedule.
- Prompt transcripts now record their repository (`repo_url` in the transcript's metadata) and are routed like everything else. Transcripts queued by older versions have no repository, so they stay on this computer.
- The server checks the repository again on every upload and refuses data for a repository the token's org hasn't connected (`repo_not_connected`). autter keeps refused records locally instead of retrying them.
- Waiting for a repository to be connected is not counted as a failed upload, so it never makes autter give up on a record, and `autter sync status` doesn't report it as a stuck queue.

## Checking it

`autter sync status` lists records kept on this computer because their repository isn't connected:

```
Kept on this computer (repository not connected to any of your orgs): 4 authorship notes, 4 commit summaries, 3 transcripts, 12 file-change records
```

`autter sync status --json` reports the same numbers under `not_connected`.

## Requires

The updated Autter API: the repository lookup for device-code logins and the upload check ship with it.
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@autter/cli",
"version": "3.0.0",
"version": "3.1.0",
"description": "Git-native AI authorship tracking: records which lines were written by AI agents and links them to the agent, model, and prompt. This package is a thin bootstrapper that installs the native autter binary.",
"license": "Apache-2.0",
"homepage": "https://autter.dev",
Expand Down
131 changes: 111 additions & 20 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,17 +286,23 @@ fn acquire_refresh_file_lock() -> Option<crate::utils::LockFile> {
/// Prefix marking a Personal Access Token (mirrors the backend).
const PAT_PREFIX: &str = "autter_pat_";

/// In-process cache: repo remote URL → resolved owning org id (None = untracked).
static ORG_RESOLVE_CACHE: Lazy<Mutex<std::collections::HashMap<String, Option<String>>>> =
/// How long a repo → org answer is trusted before asking the server again, so a
/// repo connected (or disconnected) on the web is picked up by a running daemon.
const ORG_RESOLVE_TTL_SECS: i64 = 3_600;

/// In-process cache: repo remote URL → (owning org id, None = no org connected it;
/// unix time it was resolved). Only answers from the server are cached, never
/// errors: an outage must not turn into "this repo isn't connected".
static ORG_RESOLVE_CACHE: Lazy<Mutex<std::collections::HashMap<String, (Option<String>, i64)>>> =
Lazy::new(|| Mutex::new(std::collections::HashMap::new()));

/// In-process cache: org id → (access token, unix expiry).
static ORG_TOKEN_CACHE: Lazy<Mutex<std::collections::HashMap<String, (String, i64)>>> =
Lazy::new(|| Mutex::new(std::collections::HashMap::new()));

/// Load the stored Personal Access Token (the refresh-token slot), if the user is
/// logged in with a PAT. Per-org routing only works with a PAT; device refresh
/// tokens return None so callers fall back to the single home-org token.
/// logged in with a PAT. Only a PAT can mint tokens for other orgs; a device
/// login is scoped to the one org it signed in to.
fn load_stored_pat() -> Option<String> {
let creds = CredentialStore::new().load().ok().flatten()?;
if creds.is_refresh_token_expired() {
Expand All @@ -308,29 +314,108 @@ fn load_stored_pat() -> Option<String> {
Some(creds.refresh_token)
}

/// Resolve which org owns the repo at `repo_url`, cached in-process. Returns None
/// when no org tracks it, the user isn't logged in with a PAT, or resolution fails
/// — callers then fall back to the home org.
pub fn resolve_org_for_repo_cached(repo_url: &str) -> Option<String> {
/// Error text a queue stores on items parked because their repo isn't connected
/// to any of the user's orgs. Matches the server's rejection reason.
pub const REPO_NOT_CONNECTED: &str = "repo_not_connected";

/// How long parked items wait before their repo is checked again.
pub const UNCONNECTED_RECHECK_SECS: i64 = 86_400;

/// Ask the server which of the user's orgs connected `repo_url`. Uses the PAT
/// when logged in with one, otherwise the device login's access token.
/// `Ok(None)`: no org connected it. `Err`: couldn't find out right now.
fn resolve_org_for_repo(
repo_url: &str,
access_token: Option<&str>,
) -> Result<Option<String>, String> {
let now = chrono::Utc::now().timestamp();
if let Ok(cache) = ORG_RESOLVE_CACHE.lock()
&& let Some(hit) = cache.get(repo_url)
&& let Some((hit, at)) = cache.get(repo_url)
&& now - *at < ORG_RESOLVE_TTL_SECS
{
return hit.clone();
return Ok(hit.clone());
}
let pat = load_stored_pat()?;
let resolved = OAuthClient::new()
.resolve_org_for_repo(&pat, repo_url)
.ok()
.flatten();
let client = OAuthClient::new();
let resolved = if let Some(pat) = load_stored_pat() {
client.resolve_org_for_repo(&pat, repo_url)?
} else if let Some(token) = access_token {
client.resolve_org_for_repo_with_token(token, repo_url)?
} else {
return Err("not logged in".to_string());
};
if let Ok(mut cache) = ORG_RESOLVE_CACHE.lock() {
cache.insert(repo_url.to_string(), resolved.clone());
cache.insert(repo_url.to_string(), (resolved.clone(), now));
}
Ok(resolved)
}

/// Resolve which org owns the repo at `repo_url`, cached in-process. Returns None
/// when no org has connected it, or it can't be resolved right now. For reads
/// (memory, badge, wrapped); uploads use [`route_for_repo`], which tells those
/// two cases apart.
pub fn resolve_org_for_repo_cached(repo_url: &str) -> Option<String> {
let token = try_load_auth_token().ok();
resolve_org_for_repo(repo_url, token.as_deref()).ok().flatten()
}

/// Where one repo's data may be uploaded.
pub enum RepoRoute {
/// Upload with this client; its token is scoped to the org that connected the repo.
Upload(ApiClient),
/// None of the user's orgs connected this repo (or the login can't reach the
/// org that did). Keep the data on the machine and check again later
/// ([`UNCONNECTED_RECHECK_SECS`]); never send it to the login org instead.
NotConnected,
/// Couldn't decide right now (offline, not logged in, server error). Retry
/// on the normal schedule.
Unavailable(String),
}

/// Decide where data for `repo_url` goes. Data without a repo is kept local:
/// there is no way to show it belongs to any org.
pub fn route_for_repo(repo_url: Option<&str>) -> RepoRoute {
route_for_repo_at(repo_url, None)
}

/// [`route_for_repo`] against a specific API base URL (`None` = configured default).
pub fn route_for_repo_at(repo_url: Option<&str>, base_url: Option<String>) -> RepoRoute {
let Some(repo_url) = repo_url.map(str::trim).filter(|url| !url.is_empty()) else {
return RepoRoute::NotConnected;
};
let default = ApiClient::new(ApiContext::new(base_url.clone()));
let Some(login_token) = default.context().auth_token.clone() else {
// An API key is bound to one org and can't be routed; the server still
// refuses repos that org hasn't connected.
if default.has_api_key() {
return RepoRoute::Upload(default);
}
return RepoRoute::Unavailable("not logged in".to_string());
};
let org = match resolve_org_for_repo(repo_url, Some(&login_token)) {
Ok(Some(org)) => org,
Ok(None) => return RepoRoute::NotConnected,
Err(err) => return RepoRoute::Unavailable(err),
};
let login_org = crate::auth::identity::extract_identity_from_access_token(&login_token)
.active_org_id;
if login_org.as_deref() == Some(org.as_str()) {
return RepoRoute::Upload(default);
}
if load_stored_pat().is_none() {
// A device login is scoped to one org and can't mint a token for
// another. The repo belongs to that other org, not the login org.
return RepoRoute::NotConnected;
}
match access_token_for_org(&org) {
Some(token) => RepoRoute::Upload(ApiClient::new(ApiContext::with_auth(base_url, token))),
None => RepoRoute::Unavailable(format!("could not get a token for org {}", org)),
}
resolved
}

/// Get an access token scoped to `org_id`, minted from the stored PAT and cached
/// in-process until near expiry. Returns None if not logged in with a PAT or the
/// mint fails (caller should NOT fall back to another org — retry instead).
/// in-process until near expiry. Without a PAT, returns the login token only if
/// it is already scoped to `org_id`. Returns None otherwise or if the mint fails
/// (caller should NOT fall back to another org — retry instead).
pub fn access_token_for_org(org_id: &str) -> Option<String> {
let now = chrono::Utc::now().timestamp();
if let Ok(cache) = ORG_TOKEN_CACHE.lock()
Expand All @@ -339,7 +424,13 @@ pub fn access_token_for_org(org_id: &str) -> Option<String> {
{
return Some(token.clone());
}
let pat = load_stored_pat()?;
let Some(pat) = load_stored_pat() else {
// A device login can't mint tokens, but its own token already works
// for the org it signed in to.
let token = try_load_auth_token().ok()?;
let identity = crate::auth::identity::extract_identity_from_access_token(&token);
return (identity.active_org_id.as_deref() == Some(org_id)).then_some(token);
};
let creds = OAuthClient::new()
.exchange_pat_for_org(&pat, Some(org_id))
.ok()?;
Expand Down
7 changes: 7 additions & 0 deletions src/api/data_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ fn require_success(
Ok(body.to_string())
}

/// True when an upload failed because the server refused the repo: it isn't
/// connected to the token's org. The data should be kept local, not retried soon.
pub fn is_repo_not_connected(err: &AutterError) -> bool {
let message = err.to_string();
message.contains("status 403") && message.contains(crate::api::client::REPO_NOT_CONNECTED)
}

impl ApiClient {
pub fn upload_commit_authorship_summary(
&self,
Expand Down
15 changes: 15 additions & 0 deletions src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ pub struct NotesUploadRequest {
pub struct NotesUploadResponse {
pub success_count: usize,
pub failure_count: usize,
/// Notes the server refused because their repo isn't connected to the
/// token's org. Absent from older servers.
#[serde(default)]
pub rejected: Vec<NotesUploadRejection>,
}

/// One note the server refused, and why (`repo_not_connected`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NotesUploadRejection {
pub commit_sha: String,
pub error: String,
}

/// Response from a notes read request — maps commit_sha → note content.
Expand Down Expand Up @@ -211,6 +222,10 @@ pub struct FileChangeUploadFailure {
pub struct FileChangeUploadResponse {
#[serde(default)]
pub failed: Vec<FileChangeUploadFailure>,
/// Rows the server refused because their repo isn't connected to the
/// token's org. Absent from older servers.
#[serde(default)]
pub rejected: Vec<FileChangeUploadFailure>,
}

/// Single result from CA prompt store batch read
Expand Down
31 changes: 25 additions & 6 deletions src/auth/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,34 @@ impl OAuthClient {
.map_err(|e| format!("Token sign-in failed: {}", e))
}

/// Resolve which org owns a repository (by `owner/repo` or remote URL), so a
/// push can be routed to the right org. Returns `None` when no org tracks it
/// (the caller should fall back to the PAT's home org).
/// Resolve which of the PAT owner's orgs has connected a repository (by
/// `owner/repo` or remote URL), so a push can be routed to the right org.
/// `Ok(None)` means no org has connected it: its data stays on the machine.
pub fn resolve_org_for_repo(&self, pat: &str, repo: &str) -> Result<Option<String>, String> {
let url = format!("{}/worker/oauth/resolve-org", self.base_url);
let body = serde_json::json!({ "pat": pat, "repo": repo });
self.post_resolve_org(serde_json::json!({ "pat": pat, "repo": repo }), None)
}

/// Same as [`Self::resolve_org_for_repo`] for a device-code login, which has
/// no PAT: the caller is identified by its access token instead.
pub fn resolve_org_for_repo_with_token(
&self,
access_token: &str,
repo: &str,
) -> Result<Option<String>, String> {
self.post_resolve_org(serde_json::json!({ "repo": repo }), Some(access_token))
}

fn post_resolve_org(
&self,
body: serde_json::Value,
bearer: Option<&str>,
) -> Result<Option<String>, String> {
let url = format!("{}/worker/oauth/resolve-org", self.base_url);
let (_agent, request) = ApiContext::http_post(&url, Some(30));
let request = request.set("Content-Type", "application/json");
let mut request = request.set("Content-Type", "application/json");
if let Some(token) = bearer {
request = request.set("Authorization", &format!("Bearer {}", token));
}
let response = http::send_with_body(request, &body.to_string())
.map_err(|e| format!("Failed to connect to server: {}", e))?;

Expand Down
11 changes: 7 additions & 4 deletions src/auth/notice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,12 +536,15 @@ fn read_pending_sync_counts() -> Option<PendingSyncCounts> {
let file_changes = crate::file_changes::FileChangesDatabase::global()
.ok()
.and_then(|db| db.lock().ok()?.count_pending().ok())?;
// Items parked because their repo isn't connected aren't waiting on the
// upload pipeline; counting them would make a healthy sync look stuck.
let kept = crate::upload_hold::unconnected_counts();
Some(PendingSyncCounts {
metrics,
notes,
commit_summaries,
transcripts,
file_changes,
notes: (notes - kept.notes).max(0),
commit_summaries: (commit_summaries - kept.commit_summaries).max(0),
transcripts: (transcripts - kept.transcripts).max(0),
file_changes: (file_changes - kept.file_changes).max(0),
})
}

Expand Down
15 changes: 13 additions & 2 deletions src/authorship/cas_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,14 @@ pub enum TranscriptDestination {
///
/// `checkpoints` is the working log for the commit being processed; it is the
/// only place the transcript file path is available (via `agent_metadata`).
/// `repo_url` is the commit's remote: it travels with each transcript so the
/// upload only goes to an org that connected that repo.
pub fn enqueue_prompt_transcripts(
prompts: &mut BTreeMap<String, PromptRecord>,
sessions: &mut BTreeMap<String, SessionRecord>,
checkpoints: &[Checkpoint],
destination: TranscriptDestination,
repo_url: Option<&str>,
) {
if prompts.is_empty() && sessions.is_empty() {
return;
Expand All @@ -71,7 +74,9 @@ pub fn enqueue_prompt_transcripts(
let Some(path) = transcript_paths.get(hash) else {
continue;
};
if let Ok(Some(cas_hash)) = enqueue_transcript_file(path, &record.agent_id, destination) {
if let Ok(Some(cas_hash)) =
enqueue_transcript_file(path, &record.agent_id, destination, repo_url)
{
record.messages_url = Some(format!("cas:{cas_hash}"));
}
}
Expand All @@ -83,7 +88,9 @@ pub fn enqueue_prompt_transcripts(
let Some(path) = transcript_paths.get(session_key) else {
continue;
};
if let Ok(Some(cas_hash)) = enqueue_transcript_file(path, &record.agent_id, destination) {
if let Ok(Some(cas_hash)) =
enqueue_transcript_file(path, &record.agent_id, destination, repo_url)
{
record.messages_url = Some(format!("cas:{cas_hash}"));
}
}
Expand Down Expand Up @@ -117,6 +124,7 @@ fn enqueue_transcript_file(
path: &str,
agent_id: &AgentId,
destination: TranscriptDestination,
repo_url: Option<&str>,
) -> Result<Option<String>, AutterError> {
let Some(messages) = messages_from_transcript_file(path)? else {
return Ok(None);
Expand All @@ -141,6 +149,9 @@ fn enqueue_transcript_file(
metadata.insert("tool".to_string(), agent_id.tool.clone());
metadata.insert("model".to_string(), agent_id.model.clone());
metadata.insert("session_id".to_string(), agent_id.id.clone());
if let Some(repo_url) = repo_url {
metadata.insert("repo_url".to_string(), repo_url.to_string());
}

let db = InternalDatabase::global()?;
let mut db_lock = db
Expand Down
Loading
Loading