feat(workforce): missions group the roster, and agents get human names

Three things, all visible on the agents page.

**The roster looked like it was multiplying.** The sidebar flattened
orgs → companies → teams → agents, which renders a claw once per TEAM it
belongs to. Claws are reused across missions now, so a crew of five that had
run five missions appeared as twenty-five rows of the same five people. The
data was right and the view was lying. `GET /api/workforce` returns the roster
grouped by mission, and the tree renders each mission as a collapsible group,
so the repetition means something: the same colleague under each mission they
staffed. Claws on no mission come back under "Not on a mission" rather than
vanishing. The root now counts DISTINCT people, not rows.

**Agents were named after their jobs.** A team came back as planner, coder,
tester, reviewer, committer — the UI showed the same word twice (name on top,
role beneath) and the roster read as a stack of job tickets. New claws get a
given name from a deliberately wide pool (Amara, Vijay, Tomasz, Meredith…),
unique against the workspace roster AND within the team being minted. The role
is untouched in `job_title`, which is what the mission machinery binds on:
team_members.role_slot and the topology node carry the slot, so nothing
downstream keys off the display name. A reused claw keeps the name it had.

**Two latent reap bugs found while investigating a leak that was not one.**
Containers of completed missions are removed by `spawn_sweeper` after a
30-minute grace, and it works — an earlier report of leaking containers was me
reading that deliberate grace as a bug. But:

  - the sweeper cleared the runtime binding even when teardown FAILED, and it
    selects on `runtime_endpoint IS NOT NULL`. One transient docker error would
    therefore hide a surviving container from the only thing that would retry
    it, permanently. It now asks docker whether the container actually
    survived: gone means clear, still there means keep the binding and retry —
    which closes the orphan path without reintroducing the infinite retry the
    original comment was guarding against.
  - `set_runtime_binding` discarded rows_affected, so a mismatched workspace
    updated nothing and returned Ok. The binding is how the sweeper finds a
    container; a silent no-op there leaks one with no record of anything wrong.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 14:26:42 -07:00
co-authored by Claude Opus 5
parent e2c312b728
commit 0ad53da49c
7 changed files with 468 additions and 19 deletions
+131
View File
@@ -0,0 +1,131 @@
//! Human given names for minted agents.
//!
//! A team used to come back as `planner`, `coder`, `tester`, `reviewer`,
//! `committer` — the roster read as a list of job tickets, and the UI showed
//! the same word twice (name on top, role underneath). A crew you keep should
//! read like people: Meredith, Vijay, Tomasz, Amara.
//!
//! The role is not lost — it stays in `job_title`, which is what the mission
//! machinery binds on. Only the display identity changes.
//!
//! Names are drawn from many naming traditions on purpose: this workforce is
//! not from one place. They are given names only — no surnames — so nobody
//! reads a claw as a specific real person.
/// Given names, deliberately wide. Kept as one flat list rather than grouped by
/// origin: grouping invites picking "one from each", which is a worse kind of
/// tokenism than simply having a broad pool and drawing from it evenly.
pub const NAMES: &[&str] = &[
// AE
"Amara", "Anjali", "Arjun", "Astrid", "Ayo", "Bilal", "Camila", "Chidi", "Dagny", "Dilnoza",
"Ekaterina", "Elias", "Esi", "Eun-ji",
// FJ
"Farida", "Fatou", "Freya", "Gabriel", "Giulia", "Hasan", "Hina", "Ibrahim", "Ingrid", "Isabela",
"Jaromir", "Jing", "Josefina", "Junko",
// KO
"Kaito", "Kalinda", "Kwame", "Lars", "Leilani", "Lucia", "Mateo", "Meredith", "Mira", "Nadia",
"Neelam", "Niamh", "Nkechi", "Oleksii", "Omar", "Oskar",
// PT
"Paloma", "Priya", "Rafael", "Ravi", "Renata", "Robert", "Rosalind", "Sadia", "Salome", "Sanjay",
"Sipho", "Soren", "Tariq", "Thandiwe", "Tim", "Tomasz", "Tuva",
// UZ
"Uma", "Valentina", "Vijay", "Wanjiru", "Yara", "Yusuf", "Zainab", "Zoltan",
];
/// Pick a name not already in `taken`.
///
/// `seed` spreads the starting point so a workspace does not always begin at
/// "Amara" — it is an offset into the list, not randomness, so the choice is
/// reproducible for a given (seed, taken) pair and therefore testable.
///
/// When every name is taken it appends a numeric suffix — `Amara 2` — rather
/// than returning `None` and forcing the caller to invent something. Running
/// out is a nice problem (70+ concurrent agents in one workspace) and a
/// duplicate display name is far less harmful than a failed mission launch.
pub fn pick(taken: &[String], seed: u64) -> String {
let start = (seed % NAMES.len() as u64) as usize;
for i in 0..NAMES.len() {
let candidate = NAMES[(start + i) % NAMES.len()];
if !taken.iter().any(|t| t.eq_ignore_ascii_case(candidate)) {
return candidate.to_string();
}
}
// Second pass with a suffix. `round` starts at 2 so the first repeat reads
// "Amara 2", which is how a person would disambiguate two colleagues.
for round in 2..1000 {
for i in 0..NAMES.len() {
let candidate = format!("{} {}", NAMES[(start + i) % NAMES.len()], round);
if !taken.iter().any(|t| t.eq_ignore_ascii_case(&candidate)) {
return candidate;
}
}
}
// Unreachable in practice; still not a panic.
format!("Agent {seed}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_unique_and_non_empty() {
let mut seen = std::collections::HashSet::new();
for n in NAMES {
assert!(!n.trim().is_empty(), "empty name in the pool");
assert!(seen.insert(n.to_ascii_lowercase()), "duplicate in pool: {n}");
}
assert!(NAMES.len() >= 50, "pool too small to keep a roster varied");
}
#[test]
fn pick_avoids_taken_names() {
let taken: Vec<String> = NAMES.iter().take(10).map(|s| s.to_string()).collect();
let got = pick(&taken, 0);
assert!(
!taken.iter().any(|t| t.eq_ignore_ascii_case(&got)),
"picked a name already taken: {got}"
);
}
#[test]
fn pick_is_case_insensitive_about_taken() {
// A name already on the roster in a different case is still taken —
// "meredith" and "Meredith" are the same colleague.
let taken = vec![NAMES[0].to_ascii_lowercase()];
assert_ne!(pick(&taken, 0).to_ascii_lowercase(), taken[0]);
}
#[test]
fn seed_spreads_the_starting_point() {
// Different seeds should not all hand back the same first name, or a
// fresh workspace always opens with the same roster.
let a = pick(&[], 0);
let b = pick(&[], 7);
assert_ne!(a, b, "seed had no effect on the choice");
}
#[test]
fn exhausting_the_pool_suffixes_rather_than_failing() {
let taken: Vec<String> = NAMES.iter().map(|s| s.to_string()).collect();
let got = pick(&taken, 0);
assert!(
!taken.iter().any(|t| t.eq_ignore_ascii_case(&got)),
"must not reuse a taken name"
);
assert!(got.ends_with(" 2"), "expected a suffixed name, got {got}");
}
#[test]
fn a_full_team_gets_distinct_names() {
// The actual scenario: mint five roles into an empty workspace and get
// five different people, not five "planner"s.
let mut taken: Vec<String> = Vec::new();
for i in 0..5 {
let n = pick(&taken, i);
assert!(!taken.contains(&n), "repeated {n} within one team");
taken.push(n);
}
assert_eq!(taken.len(), 5);
}
}
+3
View File
@@ -4,6 +4,7 @@ pub mod benchmark_runner;
pub mod beszel; pub mod beszel;
pub mod brain_seed; pub mod brain_seed;
pub mod cleanup_sweeper; pub mod cleanup_sweeper;
pub mod agent_names;
pub mod mission_gc; pub mod mission_gc;
pub mod container_exec; pub mod container_exec;
mod error; mod error;
@@ -482,6 +483,8 @@ pub fn router(state: AppState) -> Router {
"/api/missions", "/api/missions",
get(routes::missions::list).post(routes::missions::create), get(routes::missions::list).post(routes::missions::create),
) )
// The roster grouped by mission — what "My Workforce" renders.
.route("/api/workforce", get(routes::missions::workforce))
// The workflow recipe catalog (templates/workflows/*.toml). Serving it // The workflow recipe catalog (templates/workflows/*.toml). Serving it
// lets the client stop mirroring the phase composition table inline. // lets the client stop mirroring the phase composition table inline.
.route("/api/workflows", get(routes::missions::list_workflows)) .route("/api/workflows", get(routes::missions::list_workflows))
+31 -7
View File
@@ -450,6 +450,17 @@ async fn mint_team_from_template(
.await .await
.map_err(|e| format!("stamp template lineage: {e}"))?; .map_err(|e| format!("stamp template lineage: {e}"))?;
// Names already on this workspace's roster, so a newly hired claw does not
// arrive sharing a name with someone already here. Read ONCE — a roster
// query per role would be N queries to answer one question — and extended
// locally as we mint, which also keeps names distinct WITHIN this team.
let mut taken_names: Vec<String> = cm_db::repo::agents::roster(pool, workspace_id)
.await
.map_err(|e| format!("read roster for naming: {e}"))?
.into_iter()
.map(|a| a.name)
.collect();
// For each role: create agent, provision runtime, ingest brain // For each role: create agent, provision runtime, ingest brain
// seed, record link, bind to topology node. // seed, record link, bind to topology node.
for (idx, role) in template.roles.iter().enumerate() { for (idx, role) in template.roles.iter().enumerate() {
@@ -481,14 +492,22 @@ async fn mint_team_from_template(
let agent = Agent { let agent = Agent {
id: cm_domain::AgentId::new(), id: cm_domain::AgentId::new(),
workspace_id, workspace_id,
// The ROLE, not the mission. // A PERSON's name, with the role in `job_title`.
// //
// This was `"{mission title} · {purpose} · {template} · {slot}"`, // This was `"{mission title} · {purpose} · {template} · {slot}"`
// which produced names like "verify: a repo-less research mission // names like "verify: a repo-less research mission keeps its output
// keeps its output · mission · Rust SDLC · planner" unreadable in // · mission · Rust SDLC · planner", unreadable in the roster, the
// the roster, the API and every log line at once. Which mission a // API and every log line at once. Then it was the bare slot, which
// claw is on is context a caller can join to; it is not its name. // fixed the length but made the UI show the same word twice (name
name: role.slot.clone(), // on top, role beneath) and made a roster of five read as five job
// tickets rather than a crew.
//
// The role still lives in `job_title`, which is what the mission
// machinery binds on — `team_members.role_slot` and the topology
// node carry the slot, so nothing downstream keys off the display
// name. Only the reused branch below ignores this, deliberately: a
// claw you already hired keeps the name it already had.
name: crate::agent_names::pick(&taken_names, idx as u64),
job_title: role.slot.clone(), job_title: role.slot.clone(),
// This is the ONLY consumer of the templates' `system_prompt` prose, // This is the ONLY consumer of the templates' `system_prompt` prose,
// and it feeds the *chat* path, not missions: it lands in // and it feeds the *chat* path, not missions: it lands in
@@ -518,6 +537,11 @@ async fn mint_team_from_template(
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default()) cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await .await
.map_err(|e| format!("insert agent {}: {e}", role.slot))?; .map_err(|e| format!("insert agent {}: {e}", role.slot))?;
// Claim the name for the rest of this loop. Without this the
// roster snapshot taken before the loop is stale from the
// second role onward and a five-person team can arrive with
// two Merediths.
taken_names.push(agent.name.clone());
agent.id.as_uuid() agent.id.as_uuid()
} }
}; };
+44 -6
View File
@@ -1023,6 +1023,20 @@ impl MissionRuntimeProvisioner {
Err(format!("{name} gateway did not come back after restart")) Err(format!("{name} gateway did not come back after restart"))
} }
/// Does this mission's runtime container still exist, in any state?
///
/// Used by the sweeper to decide whether a failed teardown left something
/// behind. Deliberately treats an inspect error as "gone": the caller uses
/// this to decide whether to KEEP a binding for a retry, and answering
/// "still there" when docker cannot be reached would pin the binding open
/// on an unreachable daemon rather than on a real container.
pub async fn container_exists(&self, mission_id: Uuid) -> bool {
self.docker
.inspect_container(&container_name(mission_id), None::<InspectContainerOptions>)
.await
.is_ok()
}
/// Force-remove the mission's runtime container AND its host workspace /// Force-remove the mission's runtime container AND its host workspace
/// dir (the `/mission/repo` checkout bind source). Idempotent: a missing /// dir (the `/mission/repo` checkout bind source). Idempotent: a missing
/// container or dir is not an error — this is called both by the terminal /// container or dir is not an error — this is called both by the terminal
@@ -1135,12 +1149,36 @@ async fn sweep_once(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(
if let Err(e) = capture_outstanding_phases(pool, id).await { if let Err(e) = capture_outstanding_phases(pool, id).await {
eprintln!("mission_runtime::sweeper: last-chance capture for {id}: {e}"); eprintln!("mission_runtime::sweeper: last-chance capture for {id}: {e}");
} }
if let Err(e) = prov.teardown_container(id).await { // Clear the binding only once the container is actually GONE, which is
// A not-found is expected when the container was already // not the same as "teardown returned Ok".
// reaped by a docker restart or a manual op; log at info //
// level (via eprintln) and clear the binding anyway so the // This used to clear unconditionally, reasoning that a not-found is
// sweeper doesn't retry forever. // expected and retrying forever is worse. But `teardown_container`
eprintln!("mission_runtime::sweeper: teardown mission {id}: {e}"); // already maps 404/"No such container" to `Ok`, so an `Err` here means
// a real docker failure — and this sweep selects on
// `runtime_endpoint IS NOT NULL`, so clearing after a failed teardown
// hides the surviving container from the only thing that would ever
// retry it. One transient docker error would strand a running
// container until somebody deleted the mission by hand.
//
// Asking docker whether the container still exists settles it without
// reintroducing the infinite retry: if it is gone, drop the binding
// however the call reported itself; if it survives, keep the binding so
// the next sweep tries again and the operator sees a recurring log
// rather than silence.
let teardown = prov.teardown_container(id).await;
if let Err(e) = &teardown {
if prov.container_exists(id).await {
eprintln!(
"mission_runtime::sweeper: teardown mission {id}: {e} — container still \
present, keeping the runtime binding so the next sweep retries"
);
continue;
}
eprintln!(
"mission_runtime::sweeper: teardown mission {id}: {e} — container is gone \
anyway, clearing the binding"
);
} }
if let Err(e) = if let Err(e) =
cm_db::repo::missions::set_runtime_binding(pool, id, workspace_id, None, None, None) cm_db::repo::missions::set_runtime_binding(pool, id, workspace_id, None, None, None)
+112
View File
@@ -1683,6 +1683,118 @@ mod tests {
} }
} }
/// GET /api/workforce — the roster grouped by the mission each claw works on.
///
/// The sidebar used to flatten `orgs → companies → teams → agents`, which
/// rendered a claw once per TEAM it belongs to. Since claws are reused across
/// missions, a crew of five that had run five missions appeared as twenty-five
/// rows of the same five people — the roster looked like it was multiplying.
///
/// Grouping by mission makes that repetition mean something: the same person
/// legitimately appears under each mission they staffed. `agents` is deduped
/// per mission, and claws belonging to no mission come back under `unassigned`
/// so a hand-created claw cannot fall out of the UI entirely.
pub async fn workforce(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
use sqlx::Row;
let ws = user.workspace_id.as_uuid();
// One query, not one-per-mission: the sidebar renders on every navigation.
let rows = sqlx::query(
"SELECT m.id::text AS mission_id,
m.title AS mission_title,
m.status AS mission_status,
m.created_at AS created_at,
a.id::text AS agent_id,
a.name AS agent_name,
a.job_title AS job_title,
a.accent AS accent,
a.status AS agent_status,
tm.role_slot AS role_slot
FROM missions m
JOIN mission_teams mt ON mt.mission_id = m.id
JOIN team_members tm ON tm.team_id = mt.team_id
JOIN agents a ON a.id = tm.claw_id
WHERE m.workspace_id = $1
AND a.deleted_at IS NULL
ORDER BY m.created_at DESC, tm.role_slot ASC",
)
.bind(ws)
.fetch_all(&state.pool)
.await?;
let mut missions: Vec<Value> = Vec::new();
let mut seen_mission: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for r in rows {
let mid: String = r.get("mission_id");
let idx = match seen_mission.get(&mid) {
Some(i) => *i,
None => {
missions.push(serde_json::json!({
"mission_id": mid,
"title": r.get::<String, _>("mission_title"),
"status": r.get::<String, _>("mission_status"),
"agents": Vec::<Value>::new(),
}));
seen_mission.insert(r.get::<String, _>("mission_id"), missions.len() - 1);
missions.len() - 1
}
};
let agent = serde_json::json!({
"id": r.get::<String, _>("agent_id"),
"name": r.get::<String, _>("agent_name"),
"job_title": r.get::<String, _>("job_title"),
"role_slot": r.get::<String, _>("role_slot"),
"accent": r.get::<String, _>("accent"),
"status": r.get::<String, _>("agent_status"),
});
// A claw bound to two NODES of the same mission is still one colleague.
let list = missions[idx]["agents"].as_array_mut().expect("agents array");
let id = agent["id"].clone();
if !list.iter().any(|a| a["id"] == id) {
list.push(agent);
}
}
// Claws on no mission at all — hand-created, or whose missions were
// deleted. Without this they would simply vanish from the sidebar.
let loose = sqlx::query(
"SELECT a.id::text AS agent_id, a.name, a.job_title, a.accent, a.status
FROM agents a
WHERE a.workspace_id = $1
AND a.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM team_members tm
JOIN mission_teams mt ON mt.team_id = tm.team_id
JOIN missions m ON m.id = mt.mission_id
WHERE tm.claw_id = a.id AND m.workspace_id = $1)
ORDER BY a.name ASC",
)
.bind(ws)
.fetch_all(&state.pool)
.await?;
let unassigned: Vec<Value> = loose
.into_iter()
.map(|r| {
serde_json::json!({
"id": r.get::<String, _>("agent_id"),
"name": r.get::<String, _>("name"),
"job_title": r.get::<String, _>("job_title"),
"role_slot": Value::Null,
"accent": r.get::<String, _>("accent"),
"status": r.get::<String, _>("status"),
})
})
.collect();
Ok(Json(serde_json::json!({
"missions": missions,
"unassigned": unassigned,
})))
}
#[cfg(test)] #[cfg(test)]
mod reap_tests { mod reap_tests {
/// A mission's teardown must ask whether anyone else still employs a claw. /// A mission's teardown must ask whether anyone else still employs a claw.
+11 -1
View File
@@ -376,7 +376,7 @@ pub async fn set_runtime_binding(
endpoint: Option<&str>, endpoint: Option<&str>,
pairing_code: Option<&str>, pairing_code: Option<&str>,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
sqlx::query( let r = sqlx::query(
"UPDATE missions "UPDATE missions
SET runtime_container_name = $3, SET runtime_container_name = $3,
runtime_endpoint = $4, runtime_endpoint = $4,
@@ -391,6 +391,16 @@ pub async fn set_runtime_binding(
.bind(pairing_code) .bind(pairing_code)
.execute(pool) .execute(pool)
.await?; .await?;
// A `WHERE id = $1 AND workspace_id = $2` that matches nothing is not an
// error to sqlx — it updates zero rows and returns Ok. That made a
// mismatched workspace indistinguishable from a successful bind, and the
// binding is what the sweeper uses to find a mission's container: a silent
// no-op here leaks a container with no record that anything went wrong.
// Callers log this rather than aborting, which is the point — it becomes
// visible instead of invisible.
if r.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(()) Ok(())
} }
+136 -5
View File
@@ -99,6 +99,34 @@ const ORPHAN_CONTAINER_IDS = new Set([
// hierarchy that root exists to replace. // hierarchy that root exists to replace.
const SYNTHETIC_TREE_IDS = new Set([...ORPHAN_CONTAINER_IDS, "my-workforce"]); const SYNTHETIC_TREE_IDS = new Set([...ORPHAN_CONTAINER_IDS, "my-workforce"]);
// Mission grouping rows under "My Workforce". Like the workforce root these are
// not database rows the claw/team endpoints understand: a mission-group id
// carries a MISSION uuid, so letting it reach `selectTeam` would look valid and
// fetch the wrong thing entirely.
const isGroupingRow = (id: string) =>
id.startsWith("mission-group:") || id === "workforce-unassigned";
// A claw inside a mission group is keyed `<missionId>:<clawId>` so the same
// colleague can appear under several missions without colliding. Everything
// downstream wants the claw id alone.
const clawIdOf = (treeId: string) => {
const i = treeId.lastIndexOf(":");
return i === -1 ? treeId : treeId.slice(i + 1);
};
export interface WorkforceAgent {
id: string;
name: string;
job_title: string;
role_slot: string | null;
accent: string;
status: string;
}
export interface Workforce {
missions: { mission_id: string; title: string; status: string; agents: WorkforceAgent[] }[];
unassigned: WorkforceAgent[];
}
// Gradient palette for structure nodes that don't carry their own (companies, // Gradient palette for structure nodes that don't carry their own (companies,
// teams). Agents bring their own grad/ink. // teams). Agents bring their own grad/ink.
const NODE_GRADS: [string, string][] = [ const NODE_GRADS: [string, string][] = [
@@ -472,7 +500,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// is nothing else to do with it. // is nothing else to do with it.
if (ORPHAN_CONTAINER_IDS.has(item.id)) { setOrphanDialogOpen(true); return; } if (ORPHAN_CONTAINER_IDS.has(item.id)) { setOrphanDialogOpen(true); return; }
if (SYNTHETIC_TREE_IDS.has(item.id)) return; if (SYNTHETIC_TREE_IDS.has(item.id)) return;
if (isClaw && item.level === "claw") { openClaw(item.id); return; } // A mission grouping row is a heading, like the workforce root: the row
// click has already toggled the branch. Falling through would hand a
// MISSION uuid to selectTeam, which is a real-looking id for the wrong
// table.
if (isGroupingRow(item.id)) return;
if (isClaw && item.level === "claw") { openClaw(clawIdOf(item.id)); return; }
onWorldSelect(item.id); onWorldSelect(item.id);
expandPathTo(item.id); expandPathTo(item.id);
}; };
@@ -548,6 +581,21 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const [missionsSel, setMissionsSel] = useState<string | null>(null); const [missionsSel, setMissionsSel] = useState<string | null>(null);
const [missionsRefresh, setMissionsRefresh] = useState(0); const [missionsRefresh, setMissionsRefresh] = useState(0);
// The roster grouped by mission. Refetched when a mission changes so a newly
// staffed mission appears without a reload.
const [workforce, setWorkforce] = useState<Workforce | null>(null);
useEffect(() => {
let alive = true;
fetch("/api/workforce", { cache: "no-store" })
.then(okJson)
.then((d) => { if (alive) setWorkforce(d as Workforce); })
// Leave `workforce` null so the tree falls back to the flat list. An
// empty sidebar would look like "you have no agents", which is a worse
// lie than showing them ungrouped.
.catch(() => { if (alive) setWorkforce(null); });
return () => { alive = false; };
}, [missionsRefresh]);
const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents))); const allAgents = orgs.flatMap((o) => o.companies.flatMap((c) => c.teams.flatMap((t) => t.agents)));
// World: the full expandable org→company→team→agent forest. Agents page: a flat list. // World: the full expandable org→company→team→agent forest. Agents page: a flat list.
const worldRoots: TreeItem[] = orgs.map(orgNode); const worldRoots: TreeItem[] = orgs.map(orgNode);
@@ -598,12 +646,77 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// //
// The World tier keeps the full forest — that visualisation is ABOUT // The World tier keeps the full forest — that visualisation is ABOUT
// structure, so flattening it would remove its subject. // structure, so flattening it would remove its subject.
// Grouped by mission, with the flat list as the fallback.
//
// The flat version rendered `orgs → companies → teams → agents`, which shows
// a claw once per TEAM it belongs to. Claws are reused across missions now,
// so a crew of five that had run five missions appeared as twenty-five rows
// of the same five people and read as the roster multiplying. Grouping by
// mission makes the repetition mean something: the same colleague shows up
// under each mission they staffed.
//
// `/api/workforce` is the source; until it answers (or if it fails) we fall
// back to the flat list rather than rendering an empty sidebar.
const missionGroups: TreeItem[] = (workforce?.missions ?? []).map((m) => ({
// Prefixed so a mission id can never be mistaken for a claw id by the
// selection handler — clicking a group must expand it, not try to open a
// claw page for a mission uuid.
id: `mission-group:${m.mission_id}`,
level: "team",
label: m.title?.trim() || "Untitled mission",
meta: `${m.status} · ${m.agents.length} agent${m.agents.length === 1 ? "" : "s"}`,
status: m.status,
children: m.agents.map((a, i) => ({
// Same claw under two missions would otherwise collide on React keys
// AND on the tree's active-id comparison.
id: `${m.mission_id}:${a.id}`,
level: "claw" as const,
label: a.name,
meta: a.role_slot || a.job_title,
status: a.status,
grad: NODE_GRADS[i % NODE_GRADS.length][0],
ink: NODE_GRADS[i % NODE_GRADS.length][1],
initial: (a.name || "?").trim().charAt(0).toUpperCase(),
})),
}));
const unassignedAgents: TreeItem[] = (workforce?.unassigned ?? []).map((a, i) => ({
id: a.id,
level: "claw" as const,
label: a.name,
meta: a.job_title,
status: a.status,
grad: NODE_GRADS[i % NODE_GRADS.length][0],
ink: NODE_GRADS[i % NODE_GRADS.length][1],
initial: (a.name || "?").trim().charAt(0).toUpperCase(),
}));
const groupedChildren: TreeItem[] = [
...missionGroups,
// Hand-created claws, and claws whose missions were deleted. Shown as a
// peer group so they cannot silently disappear from the sidebar.
...(unassignedAgents.length
? [{
id: "workforce-unassigned",
level: "team" as const,
label: "Not on a mission",
meta: `${unassignedAgents.length} agent${unassignedAgents.length === 1 ? "" : "s"}`,
children: unassignedAgents,
}]
: []),
];
const useGrouped = groupedChildren.length > 0;
// Distinct people, not rows: the same claw on three missions is one colleague.
const distinctAgentCount = useGrouped
? new Set([
...(workforce?.missions ?? []).flatMap((m) => m.agents.map((a) => a.id)),
...(workforce?.unassigned ?? []).map((a) => a.id),
]).size
: allAgents.length;
const workforceRoot: TreeItem = { const workforceRoot: TreeItem = {
id: "my-workforce", id: "my-workforce",
level: "org", level: "org",
label: "My Workforce", label: "My Workforce",
meta: `${allAgents.length} agent${allAgents.length === 1 ? "" : "s"}`, meta: `${distinctAgentCount} agent${distinctAgentCount === 1 ? "" : "s"}`,
children: allAgents.map(clawNode), children: useGrouped ? groupedChildren : allAgents.map(clawNode),
}; };
const treeRoots: TreeItem[] = isWorld ? worldRoots : [workforceRoot]; const treeRoots: TreeItem[] = isWorld ? worldRoots : [workforceRoot];
const treeActiveId = isClaw ? agentId : worldSel; const treeActiveId = isClaw ? agentId : worldSel;
@@ -622,7 +735,17 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
})(); })();
const nodeLevel = new Map(allNodes.map((n) => [n.id, n.level] as const)); const nodeLevel = new Map(allNodes.map((n) => [n.id, n.level] as const));
const levelToKind = (lv?: string): ReapKind => (lv === "team" ? "teams" : lv === "company" ? "companies" : lv === "org" ? "orgs" : "agents"); const levelToKind = (lv?: string): ReapKind => (lv === "team" ? "teams" : lv === "company" ? "companies" : lv === "org" ? "orgs" : "agents");
const selectedItems = allNodes.filter((n) => selectedAgents.has(n.id)).map((n) => ({ id: n.id, name: n.label })); // Selection is keyed by TREE id, but the reap endpoints want the row id. A
// claw inside a mission group is keyed `<missionId>:<clawId>`, and the same
// colleague can be selected under two missions — send one id, once, or the
// purge would be handed a composite key and a duplicate.
const selectedItems = Array.from(
new Map(
allNodes
.filter((n) => selectedAgents.has(n.id))
.map((n) => [clawIdOf(n.id), { id: clawIdOf(n.id), name: n.label }] as const),
).values(),
);
const reapKind: ReapKind = selectedItems.length ? levelToKind(nodeLevel.get(selectedItems[0].id)) : "agents"; const reapKind: ReapKind = selectedItems.length ? levelToKind(nodeLevel.get(selectedItems[0].id)) : "agents";
const crumbStyle = (on: boolean): CSSProperties => const crumbStyle = (on: boolean): CSSProperties =>
@@ -814,12 +937,20 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// "ungrouped-co", "ungrouped-team") aren't real DB rows — the // "ungrouped-co", "ungrouped-team") aren't real DB rows — the
// backend would 422 on the non-UUID id. Silently ignore taps. // backend would 422 on the non-UUID id. Silently ignore taps.
if (SYNTHETIC_TREE_IDS.has(id)) return; if (SYNTHETIC_TREE_IDS.has(id)) return;
// Mission groups are headings, not rows. Their id carries a
// MISSION uuid, so a reap would target the wrong table with a
// perfectly valid-looking id.
if (isGroupingRow(id)) return;
setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; }); setSelectedAgents((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); return next; } const lv = nodeLevel.get(id); const curLv = prev.size ? nodeLevel.get([...prev][0]) : lv; if (lv !== curLv) return new Set([id]); next.add(id); return next; });
}} }}
// Only real (non-synthetic, non-claw) nodes accept an inline // Only real (non-synthetic, non-claw) nodes accept an inline
// rename. Synthetic scaffolding gets swapped for real rows in the // rename. Synthetic scaffolding gets swapped for real rows in the
// next commit (wizard auto-materialize + migration dialog). // next commit (wizard auto-materialize + migration dialog).
canRename={(it) => it.level !== "claw" && !SYNTHETIC_TREE_IDS.has(it.id)} // Mission groups render at team level but are NOT teams — renaming
// one would PATCH /api/teams/<missionId>/name: a well-formed uuid
// pointing at the wrong table, which fails as a silent no-op rather
// than an error.
canRename={(it) => it.level !== "claw" && !SYNTHETIC_TREE_IDS.has(it.id) && !isGroupingRow(it.id)}
onRename={async (id, level, newLabel) => { onRename={async (id, level, newLabel) => {
const path = level === "org" ? "orgs" : level === "company" ? "companies" : level === "team" ? "teams" : null; const path = level === "org" ? "orgs" : level === "company" ? "companies" : level === "team" ? "teams" : null;
if (!path) return; if (!path) return;