Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ed1480b
fix(agents): strip baked team instructions from imported prompts
Oct 8, 2026
12740bd
test(agents): cover the stripped team instructions notice
Oct 8, 2026
9a67046
fix(agents): clean baked team text from earlier beta imports
Oct 8, 2026
7b6c431
fix(agents): clean each imported prompt once, without blocking startup
Oct 8, 2026
2b3317b
test(agents): reopen a real beta import and always report cleanup wri…
Oct 8, 2026
9d49cc2
feat(teams): add shared instructions to any saved team
Oct 8, 2026
640e900
feat(teams): deliver team text on save; deploy only adds members
Oct 8, 2026
4f03c38
feat(agents): sync team text natively and flag it for restart
Oct 8, 2026
2cdbfd9
fix(agents): stage team sync so a refusal writes nothing
Oct 8, 2026
aa74a44
fix(teams): retry only delivery after a save whose delivery failed
Oct 8, 2026
ab62e89
fix(agents): release team text when a team is deleted
Oct 8, 2026
89acbad
fix(agents): allow team sync and defer its catalog read
Oct 9, 2026
a580b07
feat(agents): store team instructions in their own record
Oct 9, 2026
47329bd
fix(teams): recheck, resume and admit team text consistently
Oct 9, 2026
8b70942
fix(agents): check current team text and a fresh catalog on every save
Oct 9, 2026
a61b4a8
fix(agents): skip cleanup for prompts edited since import
Oct 9, 2026
32fa131
Merge remote-tracking branch 'origin/hayt/strip-imported-team-suffix'…
Oct 9, 2026
ff4bf6b
feat(agents): record beta teams on import and finish them natively
Oct 9, 2026
90a896b
fix(agents): start a beta team restore for the whole group in one write
Oct 9, 2026
40c98f9
fix(agents): grant the beta team restore command to the main webview
Oct 9, 2026
49bf8fd
fix(agents): skip only tombstoned beta teams and fit names to the app…
Oct 9, 2026
92acb4b
test(agents): drop an unneeded mut in the beta restore test
Oct 9, 2026
2ac0b18
feat(agents): set up old Buzz teams on import and finish or restore them
Oct 9, 2026
861d40e
fix(agents): show old Buzz team setup only on hosts that list beta teams
Oct 9, 2026
4710b3c
feat(agents): read one import candidate's beta team text for clash ch…
Oct 9, 2026
d893a1e
fix(teams): read team text heads from the writer when replacing them
Oct 9, 2026
2cace4e
fix(teams): serialize team text delivery and retry app-start sync unt…
Oct 9, 2026
f4dcf5b
refactor(agents): remove the per-agent team instruction command
Oct 9, 2026
a9baa14
fix(agents): recover startup team sync from real control failures
Oct 9, 2026
f93c537
test(teams): restore controller import/export tests and cover sync pr…
Oct 9, 2026
ae6a47c
fix(agents): recheck team sync after a retry backs off from busy control
Oct 9, 2026
14c15d6
fix(agents): write the beta team roster that passed the clash check
Oct 9, 2026
26861ae
Merge remote-tracking branch 'origin/duncan/agent-teams-save' into du…
Oct 9, 2026
b581e14
fix(agents): check the candidate's real beta team text before import
Oct 9, 2026
f73aa62
Merge remote-tracking branch 'origin/main' into duncan/beta-team-app
Oct 9, 2026
80672f1
docs(agents): document the unfenced overlapping-team text edit during…
Oct 9, 2026
ea79819
test(builderlab): complete AgentsNative mock in test
Oct 9, 2026
fcfd047
test(agents): assert the import step leaves the member pending on an …
Oct 9, 2026
5af270e
Merge remote-tracking branch 'origin/main' into hayt/beta-team-migration
Oct 10, 2026
ad232d6
fix(agents): keep old Buzz team preview re-runnable
Oct 10, 2026
b0a307e
fix(agents): tie old Buzz restore to the latest preview
Oct 10, 2026
7f1089c
fix(agents): give old Buzz teams a permanent section on the Agents page
Oct 10, 2026
88218bb
fix(agents): keep old Buzz team recovery reachable and honest
Oct 10, 2026
fad36e6
fix(agents): scope old-Buzz focus handoff to one choice and its row
Oct 10, 2026
bb3c540
fix(agents): retry pending beta team reads and label setup separately
Oct 10, 2026
2c14693
fix(agents): keep keyboard focus through beta team setup and retry
Oct 10, 2026
d1b3495
fix(agents): cancel beta team focus handoff on click-away
Oct 11, 2026
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
210 changes: 210 additions & 0 deletions crates/agent-controller/src/beta_migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! Teams carried over from old Buzz. Each imported agent keeps its beta team in
//! `imported.betaTeam`; there is no per-team record. The app's team step reads
//! the current catalog and fills in what is missing, so a rerun is harmless.
//! `betaText` is written once and never touched by team sync.
use crate::{config::Agent, Controller, LegacySource, Result, TeamCatalogEntry};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;

pub(crate) const KEY: &str = "betaTeam";
const NAME_LIMIT: usize = 120;
pub(crate) const FALLBACK_NAME: &str = "Team from old Buzz";

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BetaTeamStatus {
Pending,
Completed,
Skipped,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct BetaTeam {
pub source_id: String,
pub team_id: String,
pub name: String,
/// `None` when old Buzz could not be read and the team was inferred.
pub existed: Option<bool>,
pub source: Option<LegacySource>,
pub beta_text: String,
pub status: BetaTeamStatus,
}
impl BetaTeam {
pub(crate) fn read(agent: &Agent) -> Result<Option<Self>> {
match agent.imported.get(KEY) {
None | Some(Value::Null) => Ok(None),
Some(raw) => serde_json::from_value(raw.clone())
.map(Some)
.map_err(|_| "Invalid saved team from old Buzz".into()),
}
}
pub(crate) fn view(&self) -> BetaTeamView {
BetaTeamView {
team_id: self.team_id.clone(),
name: self.name.clone(),
status: self.status,
}
}
}
/// What the app may show about an agent's beta team. Never the text.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BetaTeamView {
pub team_id: String,
pub name: String,
pub status: BetaTeamStatus,
}

/// The 1.0 team ID for a beta team ID. Hashing every ID, built-in ones
/// included, always yields a valid, collision-resistant 1.0 ID.
pub fn beta_team_id(source_id: &str) -> String {
format!("beta-{:x}", Sha256::digest(source_id.as_bytes()))
}
pub(crate) fn valid_source_id(source_id: &str) -> bool {
!source_id.is_empty() && source_id.len() <= 256 && !source_id.contains('\0')
}
pub(crate) fn team_name(raw: &str) -> String {
let name = raw.trim();
if name.is_empty() {
return FALLBACK_NAME.into();
}
// The app counts the limit in UTF-16 code units; never split a character.
let mut units = 0;
name.chars()
.take_while(|c| {
units += c.len_utf16();
units <= NAME_LIMIT
})
.collect()
}

/// Pending agents of one team, read by the app's team step.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingBetaTeam {
pub team_id: String,
pub name: String,
/// Distinct beta texts of the members, normally one.
pub texts: Vec<String>,
pub members: Vec<BetaTeamMember>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BetaTeamMember {
pub id: String,
pub pubkey: String,
pub revision: u64,
}

/// Read-only grouping of earlier imports that have no `betaTeam` yet.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestorePreview {
pub token: String,
pub groups: Vec<RestoreGroup>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreGroup {
pub team_id: String,
pub name: String,
/// Old Buzz could not be read; the grouping comes from saved agent copies.
pub inferred: bool,
/// Candidate team texts; the user picks one when there are several.
pub texts: Vec<String>,
pub members: Vec<BetaTeamMember>,
#[serde(skip)]
pub(crate) source_id: String,
#[serde(skip)]
pub(crate) source: Option<LegacySource>,
}

impl Controller {
pub fn pending_beta_teams(&self, community: &str, owner: &str) -> Result<Vec<PendingBetaTeam>> {
let relay = crate::config::canonical_relay(community)?;
let mut teams: BTreeMap<String, PendingBetaTeam> = BTreeMap::new();
for agent in self.store.agents()? {
let Some(beta) = BetaTeam::read(&agent)? else {
continue;
};
if beta.status != BetaTeamStatus::Pending
|| agent.relay_url != relay
|| self.verify_team_member_owner(&agent.id, owner).is_err()
{
continue;
}
let team = teams
.entry(beta.team_id.clone())
.or_insert_with(|| PendingBetaTeam {
team_id: beta.team_id.clone(),
name: beta.name.clone(),
texts: Vec::new(),
members: Vec::new(),
});
if !team.texts.contains(&beta.beta_text) {
team.texts.push(beta.beta_text);
}
team.members.push(BetaTeamMember {
id: agent.id,
pubkey: agent.pubkey,
revision: agent.revision,
});
}
Ok(teams.into_values().collect())
}
pub fn restore_beta_teams_preview(
&self,
imports: &mut crate::Imports,
source: LegacySource,
app_data_parent: std::path::PathBuf,
ids: &[String],
) -> Result<RestorePreview> {
imports.restore_preview(source, app_data_parent, ids, self.store.agents()?)
}
/// Start the migration of a whole restore group in one native write, using
/// the chosen text. Each member must still match the previewed revision;
/// afterwards every member finishes like a fresh import's pending agent.
pub fn restore_beta_team(
&mut self,
imports: &crate::Imports,
community: &str,
owner: &str,
token: &str,
team_id: &str,
text: &str,
) -> Result<()> {
let relay = crate::config::canonical_relay(community)?;
let members = imports.restored(token, team_id, text)?;
for (id, _, _) in &members {
self.verify_team_member_owner(id, owner)?;
}
self.store.start_beta_teams(&relay, members)
}
/// One native write per agent: bind it to its team, resolve every team's
/// text on the same document, and record the outcome. `Completed` needs the
/// team readable with the agent on it; `Skipped` needs a team that no
/// longer lists it (a deleted team). Refusals leave the agent pending.
#[allow(clippy::too_many_arguments)]
pub fn finish_beta_team(
&mut self,
id: &str,
revision: u64,
outcome: BetaTeamStatus,
community: &str,
owner: &str,
heads: &BTreeMap<String, TeamCatalogEntry>,
texts: &BTreeMap<String, String>,
) -> Result<()> {
if texts.len() > 500 || texts.keys().any(|team| team.is_empty() || team.len() > 120) {
return Err("Invalid team instructions".into());
}
self.verify_team_member_owner(id, owner)?;
let relay = crate::config::canonical_relay(community)?;
self.store
.finish_beta_team(id, revision, outcome, (&relay, owner), heads, texts)
}
}
#[cfg(test)]
mod tests;
Loading
Loading