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:
co-authored by
Claude Opus 5
parent
e2c312b728
commit
0ad53da49c
@@ -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] = &[
|
||||||
|
// A–E
|
||||||
|
"Amara", "Anjali", "Arjun", "Astrid", "Ayo", "Bilal", "Camila", "Chidi", "Dagny", "Dilnoza",
|
||||||
|
"Ekaterina", "Elias", "Esi", "Eun-ji",
|
||||||
|
// F–J
|
||||||
|
"Farida", "Fatou", "Freya", "Gabriel", "Giulia", "Hasan", "Hina", "Ibrahim", "Ingrid", "Isabela",
|
||||||
|
"Jaromir", "Jing", "Josefina", "Junko",
|
||||||
|
// K–O
|
||||||
|
"Kaito", "Kalinda", "Kwame", "Lars", "Leilani", "Lucia", "Mateo", "Meredith", "Mira", "Nadia",
|
||||||
|
"Neelam", "Niamh", "Nkechi", "Oleksii", "Omar", "Oskar",
|
||||||
|
// P–T
|
||||||
|
"Paloma", "Priya", "Rafael", "Ravi", "Renata", "Robert", "Rosalind", "Sadia", "Salome", "Sanjay",
|
||||||
|
"Sipho", "Soren", "Tariq", "Thandiwe", "Tim", "Tomasz", "Tuva",
|
||||||
|
// U–Z
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user