Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how. | 1 | /** |
| 2 | * The one-time move of agents' own teams to team memberships | |
| 3 | * (migrations/0010_agent_team_memberships.sql). An agent is on teams the | |
| 4 | * way a person is, through identity's team_agents; agents made before | |
| 5 | * that named a team themselves, in `agents.team`. | |
| 6 | * | |
| 7 | * Every cron run takes the agents not moved yet (none, once it is done: | |
| 8 | * a partial index makes that one cheap read) and asks identity to put | |
| 9 | * each on the team its old value names, by slug in any case, in its own | |
| 10 | * workspace. A value that names no team is dropped and logged. Archived | |
| 11 | * and personal agents are marked without joining anything: neither is on | |
| 12 | * a team. Each agent is marked `team_moved_at` once identity answered for | |
| 13 | * its workspace, so a second run does nothing, and identity's insert is | |
| 14 | * idempotent besides. | |
| 15 | */ | |
| 16 | import type { AdoptedAgentTeam } from "@g1t/contracts"; | |
| 17 | ||
| 18 | /** Agents read per run: the move finishes over a few runs in a large installation. */ | |
| 19 | export const MOVE_BATCH = 200; | |
| 20 | ||
| 21 | export type MoveRow = { id: string; workspace_id: string; team: string; scope: string | null; archived_at: string | null }; | |
| 22 | ||
| 23 | /** What identity is asked, per workspace: the agents that may join, by the team each named. */ | |
| 24 | export function claimsByWorkspace(rows: readonly MoveRow[]): Map<string, { agent_id: string; team: string }[]> { | |
| 25 | const out = new Map<string, { agent_id: string; team: string }[]>(); | |
| 26 | for (const row of rows) { | |
| 27 | if (row.archived_at || row.scope === "personal") continue; | |
| 28 | const team = row.team.trim(); | |
| 29 | if (!team) continue; | |
| 30 | out.set(row.workspace_id, [...(out.get(row.workspace_id) ?? []), { agent_id: row.id, team }]); | |
| 31 | } | |
| 32 | return out; | |
| 33 | } | |
| 34 | ||
| 35 | export type MoveReport = { seen: number; added: number; already: number; dropped: { agent_id: string; team: string }[] }; | |
| 36 | ||
| 37 | /** `adopt` is identity's `adopt_agent_teams` (index.ts passes it). */ | |
| 38 | export async function moveAgentTeams( | |
| 39 | db: D1Database, | |
| 40 | adopt: (workspaceId: string, agents: { agent_id: string; team: string }[]) => Promise<AdoptedAgentTeam[]>, | |
| 41 | now: Date = new Date(), | |
| 42 | ): Promise<MoveReport> { | |
| 43 | const rows = await db.prepare( | |
| 44 | `SELECT id, workspace_id, team, scope, archived_at FROM agents | |
| 45 | WHERE team IS NOT NULL AND team <> '' AND team_moved_at IS NULL LIMIT ?`, | |
| 46 | ) | |
| 47 | .bind(MOVE_BATCH) | |
| 48 | .all<MoveRow>(); | |
| 49 | const report: MoveReport = { seen: 0, added: 0, already: 0, dropped: [] }; | |
| 50 | if (!rows.results.length) return report; | |
| 51 | const claims = claimsByWorkspace(rows.results); | |
| 52 | // Agents identity is not asked about (archived, personal) are done as they are. | |
| 53 | const done = new Set(rows.results.filter((row) => !claims.get(row.workspace_id)?.some((c) => c.agent_id === row.id)).map((row) => row.id)); | |
| 54 | for (const [workspaceId, agents] of claims) { | |
| 55 | try { | |
| 56 | const outcomes = await adopt(workspaceId, agents); | |
| 57 | for (const outcome of outcomes) { | |
| 58 | if (outcome.outcome === "added") report.added += 1; | |
| 59 | else if (outcome.outcome === "already") report.already += 1; | |
| 60 | else report.dropped.push({ agent_id: outcome.agent_id, team: outcome.team }); | |
| 61 | } | |
| 62 | for (const agent of agents) done.add(agent.agent_id); | |
| 63 | } catch (error) { | |
| 64 | // Left unmarked: the next run asks again. | |
| 65 | console.error("agents: moving agents' teams to memberships failed for a workspace", workspaceId, String(error)); | |
| 66 | } | |
| 67 | } | |
| 68 | if (done.size) { | |
| 69 | await db.prepare("UPDATE agents SET team_moved_at = ? WHERE id IN (SELECT value FROM json_each(?)) AND team_moved_at IS NULL") | |
| 70 | .bind(now.toISOString(), JSON.stringify([...done])) | |
| 71 | .run(); | |
| 72 | } | |
| 73 | report.seen = done.size; | |
| 74 | for (const drop of report.dropped) console.log("agents: an agent's old team names no team, so it joins none", drop.agent_id, drop.team); | |
| 75 | if (report.seen) console.log("agents: moved agents' teams to memberships", JSON.stringify({ seen: report.seen, added: report.added, already: report.already, dropped: report.dropped.length })); | |
| 76 | return report; | |
| 77 | } |