Recursive deploy ladder: Company + Org tiers, mesh mark, two-tier rail

Completes the scale ladder (single → team → company → org). Every tier is a
topology whose nodes are the tier below; running a parent recursively runs each
child's sub-topology down to the leaf claws.

Backend:
- migration 0011: companies/company_teams, orgs/org_companies, topology_runs.tier
- cm-db repos for companies + orgs (mirror teams)
- TurnRequest.attrs (forwarded from node.attrs) for child-id binding
- SubTopologyExecutor (recursive_exec.rs): a parent "turn" runs the child's
  sub-topology; durability via parent updated_at keepalive + cancel propagation
  + depth cap; boxed future breaks the org→company recursion
- topology_worker selects executor by job.tier
- routes: /api/companies, /api/orgs (create/list/get/run) + unified
  /api/structure/{level}/{id} for the zoom canvas

Frontend:
- MeshMark: node-mesh brand glyph (replaces the claw PNG), tier variants
- TopologyGraphView: optional onNodeClick/nodeMeta + dark-token theming
- StructureCanvas + Breadcrumb: one recursive zoom view for every tier
  (drill down on node click, breadcrumb up); TeamRunPanel extracted + shared
- two-tier Discord-style rail: StructureRail (mesh mark + org/company/team
  glyphs + tools popover + deploy + user) | RosterColumn (selected group's
  children, or your claws); SecondaryNav for cross-cutting tools
- ComposeWizard (company/org) wired into DeployWizard; /companies + /orgs pages

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-18 14:25:06 -07:00
co-authored by Claude Opus 4.8
parent bba18a4687
commit 3eca4ed70c
58 changed files with 3221 additions and 229 deletions
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, graph, status, created_at FROM companies\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "graph",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "031ed78afa99d45546c7c679207c710790948596eff040704dd82ce14a93bd5d"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE topology_runs SET updated_at = now() WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "03c69e0e068a797f99366ab01b35e8ffb6eea8add0978994739129c40c7eb9af"
}
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, graph, status, created_at FROM orgs\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "graph",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "261c2c556b286afc6ac8287c59ffc705abf814fd1f57172261b0de6ecb898db5"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO company_teams (company_id, node_id, team_id, role)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "3c82f14be49d6a91443d59cb8e41ce1b99594ed60af98f6e40bb2b6dd11a05a7"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT tokens_in, tokens_out, credits FROM usage_events\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tokens_in",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "tokens_out",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "credits",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "47c1cee8591250819d22e87058c6011bebb77eaf93c1cfa2535db42221d8ecf3"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO org_companies (org_id, node_id, company_id, role)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "482754d2f83f5f3673c6cbc91e0a5dd985ea975bd9fa35f5596cc2a047d26b24"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "INSERT INTO topology_runs (id, workspace_id, task, kind, status, graph)\n VALUES ($1, $2, $3, 'run', 'queued', $4)", "query": "INSERT INTO topology_runs (id, workspace_id, task, kind, status, graph, tier)\n VALUES ($1, $2, $3, 'run', 'queued', $4, $5)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@@ -8,10 +8,11 @@
"Uuid", "Uuid",
"Uuid", "Uuid",
"Text", "Text",
"Jsonb" "Jsonb",
"Text"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "fafbe27a2854e8884efec77567bcf258da8d9c1b825b8e8dcfdc2c90efea31b1" "hash": "61f40d8e59faece3b66148474d43b3dd3d4e5842ecd6f85c616dfb294bd20df3"
} }
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT node_id, team_id, role FROM company_teams WHERE company_id = $1 ORDER BY node_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "node_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "team_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "role",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "7c0dd4348aff56230ff8a03ff1ed791c577682186418df4e634be72d31d5e515"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "UPDATE topology_runs\n SET status = 'running', started_at = COALESCE(started_at, now()), updated_at = now()\n WHERE id = (\n SELECT id FROM topology_runs\n WHERE status = 'queued'\n ORDER BY created_at\n FOR UPDATE SKIP LOCKED\n LIMIT 1\n )\n RETURNING id, workspace_id, task, graph, checkpoint, last_event_id", "query": "UPDATE topology_runs\n SET status = 'running', started_at = COALESCE(started_at, now()), updated_at = now()\n WHERE id = (\n SELECT id FROM topology_runs\n WHERE status = 'queued'\n ORDER BY created_at\n FOR UPDATE SKIP LOCKED\n LIMIT 1\n )\n RETURNING id, workspace_id, task, graph, checkpoint, last_event_id, tier",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -32,6 +32,11 @@
"ordinal": 5, "ordinal": 5,
"name": "last_event_id", "name": "last_event_id",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 6,
"name": "tier",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -43,8 +48,9 @@
false, false,
true, true,
true, true,
false,
false false
] ]
}, },
"hash": "b1da590a84e9d2c84fe3e6683c7566a94be4496117c2458a21428b356e45e911" "hash": "9eae6ca16ffc9346456128ce676ef04f3478f873d6ac5f95154b797f454f44c0"
} }
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO orgs (id, workspace_id, name, kind, graph)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "a69b729c468a89fccebe078eb85af85d468e1b0ed43c07b8e1adbdfe5d13d7d3"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO companies (id, workspace_id, name, kind, graph)\n VALUES ($1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "c19c0bddba9071ee26686992c091567e72eb04cdaac1a0db20591694231e0880"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, status, created_at FROM orgs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "cf6649a034fa49d39d1f714e2a3d7a9f274795da3c27fa2cfe4219edb1885ef3"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, kind, status, created_at FROM companies\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "e81b42a1cfa245e311ec1e156a02669101ec23e60f1df7b88a7c65861f719baa"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT node_id, company_id, role FROM org_companies WHERE org_id = $1 ORDER BY node_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "node_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "company_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "role",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "f3635636287d2e4585ec3650547f356fab1d98be8f575ffda0e52c7283903fbe"
}
+25 -2
View File
@@ -3,6 +3,7 @@
mod error; mod error;
mod extract; mod extract;
mod mcp_door; mod mcp_door;
mod recursive_exec;
mod routes; mod routes;
mod runtime_provision; mod runtime_provision;
mod topology_exec; mod topology_exec;
@@ -141,9 +142,15 @@ pub fn router(state: AppState) -> Router {
.route("/api/billing/stripe", post(routes::billing::stripe_webhook)) .route("/api/billing/stripe", post(routes::billing::stripe_webhook))
.route("/api/team/permissions", get(routes::team::permissions)) .route("/api/team/permissions", get(routes::team::permissions))
.route("/api/topologies", get(routes::topology::catalog)) .route("/api/topologies", get(routes::topology::catalog))
.route("/api/topologies/classify", post(routes::topology::classify_graph)) .route(
"/api/topologies/classify",
post(routes::topology::classify_graph),
)
.route("/api/topologies/build", post(routes::topology::build_graph)) .route("/api/topologies/build", post(routes::topology::build_graph))
.route("/api/topologies/compare", post(routes::topology::compare_topologies)) .route(
"/api/topologies/compare",
post(routes::topology::compare_topologies),
)
.route("/api/topologies/run", post(routes::topology::run_topology)) .route("/api/topologies/run", post(routes::topology::run_topology))
.route( .route(
"/api/teams", "/api/teams",
@@ -151,6 +158,22 @@ pub fn router(state: AppState) -> Router {
) )
.route("/api/teams/{id}", get(routes::teams::get_team)) .route("/api/teams/{id}", get(routes::teams::get_team))
.route("/api/teams/{id}/run", post(routes::teams::run_team)) .route("/api/teams/{id}/run", post(routes::teams::run_team))
.route(
"/api/companies",
get(routes::companies::list_companies).post(routes::companies::create_company),
)
.route("/api/companies/{id}", get(routes::companies::get_company))
.route(
"/api/companies/{id}/run",
post(routes::companies::run_company),
)
.route(
"/api/orgs",
get(routes::orgs::list_orgs).post(routes::orgs::create_org),
)
.route("/api/orgs/{id}", get(routes::orgs::get_org))
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
.route("/api/structure/{level}/{id}", get(routes::structure::node))
.route("/api/topology-runs", get(routes::topology::list_runs)) .route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run)) .route("/api/topology-runs/{id}", get(routes::topology::get_run))
.route( .route(
+31 -10
View File
@@ -30,10 +30,8 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Tools the door exposes, as `(mcp_name, internal_registry_name)`. The agent /// Tools the door exposes, as `(mcp_name, internal_registry_name)`. The agent
/// sees `clawmates__<mcp_name>`; ZeroClaw strips the prefix and calls us with /// sees `clawmates__<mcp_name>`; ZeroClaw strips the prefix and calls us with
/// `<mcp_name>`. We keep MCP names underscore-only (some models choke on dots). /// `<mcp_name>`. We keep MCP names underscore-only (some models choke on dots).
const EXPOSED_TOOLS: &[(&str, &str)] = &[ const EXPOSED_TOOLS: &[(&str, &str)] =
("email_send", "email.send"), &[("email_send", "email.send"), ("slack_post", "slack.post")];
("slack_post", "slack.post"),
];
fn internal_name(mcp_name: &str) -> Option<&'static str> { fn internal_name(mcp_name: &str) -> Option<&'static str> {
EXPOSED_TOOLS EXPOSED_TOOLS
@@ -253,7 +251,11 @@ pub async fn mcp(
"tools/list" => { "tools/list" => {
if authed(&state, &headers).await.is_none() { if authed(&state, &headers).await.is_none() {
return err(req.id, -32001, "unauthorized: missing or invalid bearer token"); return err(
req.id,
-32001,
"unauthorized: missing or invalid bearer token",
);
} }
let tools: Vec<Value> = EXPOSED_TOOLS let tools: Vec<Value> = EXPOSED_TOOLS
.iter() .iter()
@@ -270,11 +272,18 @@ pub async fn mcp(
"tools/call" => { "tools/call" => {
let Some(user) = authed(&state, &headers).await else { let Some(user) = authed(&state, &headers).await else {
return err(req.id, -32001, "unauthorized: missing or invalid bearer token"); return err(
req.id,
-32001,
"unauthorized: missing or invalid bearer token",
);
}; };
let params = req.params.clone().unwrap_or_else(|| json!({})); let params = req.params.clone().unwrap_or_else(|| json!({}));
let mcp_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); let mcp_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or_else(|| json!({})); let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let Some(internal) = internal_name(mcp_name) else { let Some(internal) = internal_name(mcp_name) else {
return tool_result( return tool_result(
@@ -307,9 +316,15 @@ pub async fn mcp(
let agent_id = match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await { let agent_id = match cm_db::repo::agents::roster(&state.pool, user.workspace_id).await {
Ok(roster) if !roster.is_empty() => roster[0].id, Ok(roster) if !roster.is_empty() => roster[0].id,
Ok(_) => { Ok(_) => {
return tool_result(req.id, true, "no agent in workspace to act on behalf of".into()) return tool_result(
req.id,
true,
"no agent in workspace to act on behalf of".into(),
)
}
Err(_) => {
return tool_result(req.id, true, "failed to resolve workspace agent".into())
} }
Err(_) => return tool_result(req.id, true, "failed to resolve workspace agent".into()),
}; };
// Broker-executed tools (e.g. slack.post) need a single-use grant // Broker-executed tools (e.g. slack.post) need a single-use grant
@@ -329,7 +344,13 @@ pub async fn mcp(
match state match state
.runtime .runtime
.execute_door_tool(user.workspace_id, agent_id, internal, args.clone(), approval_id) .execute_door_tool(
user.workspace_id,
agent_id,
internal,
args.clone(),
approval_id,
)
.await .await
{ {
Ok(output) => { Ok(output) => {
+213
View File
@@ -0,0 +1,213 @@
//! Recursive sub-topology executor — the engine behind the upper deploy rungs.
//!
//! A team run drives **claws** directly (the leaf [`ZeroClawDriveExecutor`]). A
//! *company* run is a topology whose nodes are **teams**; an *org* run is a
//! topology whose nodes are **companies**. This executor makes a parent "turn"
//! mean *run the child's whole sub-topology to completion and return its final
//! output* — so the entire `org → company → team → claw` tree collapses into
//! nested [`execute_resumable`] calls, reusing every planner unchanged. Only the
//! leaf ever touches the runtime.
//!
//! **Durability.** A parent run can spend minutes inside one node executing a
//! child sub-topology. Two guards keep that safe on the durable worker:
//! - every leaf turn **touches the parent run's `updated_at`** (via the
//! `keepalive` callback) so the 180s stale sweep never requeues the parent
//! mid-subtree;
//! - the same callback observes **parent cancellation** and halts the whole
//! subtree at the next leaf boundary.
//!
//! Resume is *coarse* in v1: the outer worker checkpoints parent-node-level
//! progress, so a crash re-runs only the in-flight child subtree (completed
//! sibling nodes are skipped). Per-leaf nested checkpointing is a future slice.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use cm_db::repo;
use cm_domain::WorkspaceId;
use cm_orchestrator::{
execute_resumable, GatedAction, OrchestratorError, RunProgress, RunRecord, TurnExecutor,
TurnOutcome, TurnRequest,
};
use cm_topology::TopologyGraph;
use sqlx::PgPool;
use uuid::Uuid;
use crate::topology_exec::ZeroClawDriveExecutor;
/// Backstop against a malformed binding cycle infinitely recursing
/// (org → company → team is depth 2; this caps well above any real nesting).
const MAX_DEPTH: u32 = 4;
/// Which deploy tier this executor drives. The leaf (`team`/claw) tier is the
/// plain [`ZeroClawDriveExecutor`], not represented here.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Tier {
/// Nodes bind companies (`attrs["company_id"]`); each runs a company tier.
Org,
/// Nodes bind teams (`attrs["team_id"]`); each runs the leaf claw tier.
Company,
}
/// A [`TurnExecutor`] whose "turn" runs a sub-topology one tier down.
pub struct SubTopologyExecutor {
pool: PgPool,
workspace_id: WorkspaceId,
tier: Tier,
/// The parent durable run id — every leaf turn touches its `updated_at`.
parent_run_id: Uuid,
/// Shared leaf executor that drives real claws over the gateway.
leaf: Arc<ZeroClawDriveExecutor>,
depth: u32,
}
impl SubTopologyExecutor {
/// Build the top of a recursive run (depth 0) for a durable parent run.
pub fn new(
pool: PgPool,
workspace_id: WorkspaceId,
tier: Tier,
parent_run_id: Uuid,
leaf: Arc<ZeroClawDriveExecutor>,
) -> Self {
SubTopologyExecutor {
pool,
workspace_id,
tier,
parent_run_id,
leaf,
depth: 0,
}
}
/// A child executor one tier down, sharing the same run id + leaf.
fn child(&self, tier: Tier) -> Self {
SubTopologyExecutor {
pool: self.pool.clone(),
workspace_id: self.workspace_id,
tier,
parent_run_id: self.parent_run_id,
leaf: Arc::clone(&self.leaf),
depth: self.depth + 1,
}
}
/// Run a child graph on a nested `SubTopologyExecutor`. Boxed so the
/// org→company recursion has a finite future size (the trait method would
/// otherwise contain itself).
fn run_nested<'a>(
&'a self,
graph: TopologyGraph,
task: String,
child: SubTopologyExecutor,
) -> Pin<Box<dyn Future<Output = Result<RunRecord, OrchestratorError>> + Send + 'a>> {
Box::pin(async move {
let pool = self.pool.clone();
let run_id = self.parent_run_id;
execute_resumable(
&graph,
&task,
&child,
RunProgress::default(),
move |_snap| {
let pool = pool.clone();
async move { keepalive(&pool, run_id).await }
},
)
.await
})
}
}
impl TurnExecutor for SubTopologyExecutor {
async fn run_turn(&self, req: TurnRequest) -> Result<TurnOutcome, OrchestratorError> {
if self.depth >= MAX_DEPTH {
return Err(OrchestratorError::Executor(format!(
"topology nesting exceeds max depth {MAX_DEPTH}"
)));
}
let record = match self.tier {
Tier::Company => {
// Child = a team; run its graph directly on the claws (leaf).
let team_id = child_id(&req, "team_id")?;
let team = repo::teams::get_team(&self.pool, team_id, self.workspace_id)
.await
.map_err(|e| {
OrchestratorError::Executor(format!("load team {team_id}: {e}"))
})?;
let graph = parse_graph(&team.graph)?;
let pool = self.pool.clone();
let run_id = self.parent_run_id;
execute_resumable(
&graph,
&req.task,
&*self.leaf,
RunProgress::default(),
move |_snap| {
let pool = pool.clone();
async move { keepalive(&pool, run_id).await }
},
)
.await?
}
Tier::Org => {
// Child = a company; recurse with a company-tier executor.
let company_id = child_id(&req, "company_id")?;
let company = repo::companies::get(&self.pool, company_id, self.workspace_id)
.await
.map_err(|e| {
OrchestratorError::Executor(format!("load company {company_id}: {e}"))
})?;
let graph = parse_graph(&company.graph)?;
let child = self.child(Tier::Company);
self.run_nested(graph, req.task.clone(), child).await?
}
};
// Bubble the child journal's gated actions + tokens up to the parent
// node, so §15 audit and the run record see the whole subtree.
let gated: Vec<GatedAction> = record.steps.iter().flat_map(|s| s.gated.clone()).collect();
Ok(TurnOutcome {
output: record.final_output,
tokens: record.totals.tokens,
gated,
})
}
}
/// Keep the parent run alive and honor its cancellation from a leaf step.
async fn keepalive(pool: &PgPool, run_id: Uuid) -> Result<(), OrchestratorError> {
// Touch updated_at so the stale-run sweep treats this long run as alive.
let _ = repo::topology_runs::touch(pool, run_id).await;
// Stop the whole subtree if the parent run was cancelled.
if matches!(
repo::topology_runs::current_status(pool, run_id).await,
Ok(Some(ref s)) if s == "cancelled"
) {
return Err(OrchestratorError::Executor("run cancelled".into()));
}
Ok(())
}
/// Resolve a node's child binding (`team_id` / `company_id`) from its attrs,
/// falling back to the `agent` slot (set to the same id at build time).
fn child_id(req: &TurnRequest, key: &str) -> Result<Uuid, OrchestratorError> {
let raw = req
.attrs
.get(key)
.cloned()
.or_else(|| req.agent.clone())
.ok_or_else(|| {
OrchestratorError::Executor(format!("node {} missing {key} binding", req.node_id))
})?;
Uuid::parse_str(raw.trim()).map_err(|_| {
OrchestratorError::Executor(format!("node {} has invalid {key}: {raw}", req.node_id))
})
}
fn parse_graph(v: &serde_json::Value) -> Result<TopologyGraph, OrchestratorError> {
serde_json::from_value(v.clone())
.map_err(|e| OrchestratorError::Executor(format!("invalid child graph: {e}")))
}
+225
View File
@@ -0,0 +1,225 @@
//! Company endpoints — deploy a baseline topology staffed with real *teams*,
//! then run it on the durable recursive runner. The 3rd rung of the deploy
//! ladder (single → team → company → org). Unlike teams, creating a company
//! provisions nothing new: it composes teams that already own provisioned claws.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_topology::{build, TopologyKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// One bound team in the company (becomes a topology node).
#[derive(Deserialize)]
pub struct CompanyMemberInput {
pub team_id: Uuid,
#[serde(default)]
pub role: String,
}
#[derive(Deserialize)]
pub struct CreateCompanyRequest {
pub name: String,
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
pub kind: String,
pub members: Vec<CompanyMemberInput>,
}
#[derive(Serialize)]
pub struct CompanyCreated {
pub company_id: String,
}
fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
}
/// `POST /api/companies` — build the baseline topology over the chosen teams,
/// bind each node to its team, and persist. Validates every team belongs to the
/// workspace first (so a company can only compose teams the caller owns).
pub async fn create_company(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateCompanyRequest>,
) -> Result<(StatusCode, Json<CompanyCreated>), ApiError> {
if body.members.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(&body.kind)?;
// 1. Validate each bound team is real + in this workspace.
for m in &body.members {
cm_db::repo::teams::get_team(&state.pool, m.team_id, user.workspace_id).await?;
}
// 2. Build the topology and bind each node to its team. We write the team id
// into BOTH attrs["team_id"] (descriptive) and attrs["agent"] (so the
// orchestrator forwards it through TurnRequest exactly like a claw alias).
let roles: Vec<&str> = body
.members
.iter()
.map(|m| {
if m.role.is_empty() {
"team"
} else {
m.role.as_str()
}
})
.collect();
let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(m) = body.members.get(i) {
node.attrs.insert("team_id".into(), m.team_id.to_string());
node.attrs.insert("agent".into(), m.team_id.to_string());
}
}
// 3. Persist company + node→team bindings.
let company_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::companies::insert_company(
&state.pool,
company_id,
user.workspace_id,
&body.name,
kind.as_str(),
&graph_json,
)
.await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(m) = body.members.get(i) {
cm_db::repo::companies::add_team(
&state.pool,
company_id,
&node.id,
m.team_id,
&node.role,
)
.await?;
}
}
Ok((
StatusCode::CREATED,
Json(CompanyCreated {
company_id: company_id.to_string(),
}),
))
}
#[derive(Serialize)]
pub struct CompanySummaryOut {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
}
/// `GET /api/companies` — recent companies for the workspace.
pub async fn list_companies(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<CompanySummaryOut>>, ApiError> {
let rows =
cm_db::repo::companies::list_for_workspace(&state.pool, user.workspace_id, 50).await?;
Ok(Json(
rows.into_iter()
.map(|c| CompanySummaryOut {
id: c.id.to_string(),
name: c.name,
kind: c.kind,
status: c.status,
created_at: c.created_at.format(&Rfc3339).unwrap_or_default(),
})
.collect(),
))
}
#[derive(Serialize)]
pub struct CompanyTeamOut {
pub node_id: String,
pub team_id: String,
pub role: String,
}
#[derive(Serialize)]
pub struct CompanyDetail {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
pub graph: Value,
pub members: Vec<CompanyTeamOut>,
}
/// `GET /api/companies/{id}` — a company's graph + node→team bindings.
pub async fn get_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<CompanyDetail>, ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
let members = cm_db::repo::companies::teams_for_company(&state.pool, id).await?;
Ok(Json(CompanyDetail {
id: company.id.to_string(),
name: company.name,
kind: company.kind,
status: company.status,
created_at: company.created_at.format(&Rfc3339).unwrap_or_default(),
graph: company.graph,
members: members
.into_iter()
.map(|m| CompanyTeamOut {
node_id: m.node_id,
team_id: m.team_id.to_string(),
role: m.role,
})
.collect(),
}))
}
#[derive(Deserialize)]
pub struct RunCompanyRequest {
pub task: String,
}
#[derive(Serialize)]
pub struct RunAccepted {
pub run_id: String,
pub status: String,
}
/// `POST /api/companies/{id}/run` — enqueue a durable `company`-tier run; the
/// worker drives the recursive executor (each node runs its team's topology).
pub async fn run_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<RunCompanyRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_tier(
&state.pool,
run_id,
user.workspace_id,
&body.task,
&company.graph,
"company",
)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted {
run_id: run_id.to_string(),
status: "queued".into(),
}),
))
}
+3
View File
@@ -5,15 +5,18 @@ pub mod billing;
pub mod browser; pub mod browser;
pub mod claw_chat; pub mod claw_chat;
pub mod claws; pub mod claws;
pub mod companies;
pub mod files; pub mod files;
pub mod gateway; pub mod gateway;
pub mod health; pub mod health;
pub mod identity; pub mod identity;
pub mod oauth; pub mod oauth;
pub mod orgs;
pub mod routines; pub mod routines;
pub mod sessions; pub mod sessions;
pub mod skills; pub mod skills;
pub mod slack; pub mod slack;
pub mod structure;
pub mod team; pub mod team;
pub mod teams; pub mod teams;
pub mod topology; pub mod topology;
+218
View File
@@ -0,0 +1,218 @@
//! Org endpoints — deploy a baseline topology whose nodes are real *companies*,
//! then run it on the durable recursive runner. The top rung of the deploy
//! ladder (single → team → company → org). Creating an org composes companies
//! that already own their teams (which own their provisioned claws).
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_topology::{build, TopologyKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// One bound company in the org (becomes a topology node).
#[derive(Deserialize)]
pub struct OrgMemberInput {
pub company_id: Uuid,
#[serde(default)]
pub role: String,
}
#[derive(Deserialize)]
pub struct CreateOrgRequest {
pub name: String,
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
pub kind: String,
pub members: Vec<OrgMemberInput>,
}
#[derive(Serialize)]
pub struct OrgCreated {
pub org_id: String,
}
fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
}
/// `POST /api/orgs` — build the baseline topology over the chosen companies,
/// bind each node to its company, and persist. Validates ownership first.
pub async fn create_org(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateOrgRequest>,
) -> Result<(StatusCode, Json<OrgCreated>), ApiError> {
if body.members.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(&body.kind)?;
// 1. Validate each bound company is real + in this workspace.
for m in &body.members {
cm_db::repo::companies::get(&state.pool, m.company_id, user.workspace_id).await?;
}
// 2. Build the topology and bind each node to its company (attrs["company_id"]
// + attrs["agent"], so the orchestrator forwards it through TurnRequest).
let roles: Vec<&str> = body
.members
.iter()
.map(|m| {
if m.role.is_empty() {
"company"
} else {
m.role.as_str()
}
})
.collect();
let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(m) = body.members.get(i) {
node.attrs
.insert("company_id".into(), m.company_id.to_string());
node.attrs.insert("agent".into(), m.company_id.to_string());
}
}
// 3. Persist org + node→company bindings.
let org_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::orgs::insert_org(
&state.pool,
org_id,
user.workspace_id,
&body.name,
kind.as_str(),
&graph_json,
)
.await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(m) = body.members.get(i) {
cm_db::repo::orgs::add_company(&state.pool, org_id, &node.id, m.company_id, &node.role)
.await?;
}
}
Ok((
StatusCode::CREATED,
Json(OrgCreated {
org_id: org_id.to_string(),
}),
))
}
#[derive(Serialize)]
pub struct OrgSummaryOut {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
}
/// `GET /api/orgs` — recent orgs for the workspace.
pub async fn list_orgs(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<OrgSummaryOut>>, ApiError> {
let rows = cm_db::repo::orgs::list_for_workspace(&state.pool, user.workspace_id, 50).await?;
Ok(Json(
rows.into_iter()
.map(|o| OrgSummaryOut {
id: o.id.to_string(),
name: o.name,
kind: o.kind,
status: o.status,
created_at: o.created_at.format(&Rfc3339).unwrap_or_default(),
})
.collect(),
))
}
#[derive(Serialize)]
pub struct OrgCompanyOut {
pub node_id: String,
pub company_id: String,
pub role: String,
}
#[derive(Serialize)]
pub struct OrgDetail {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
pub graph: Value,
pub members: Vec<OrgCompanyOut>,
}
/// `GET /api/orgs/{id}` — an org's graph + node→company bindings.
pub async fn get_org(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<OrgDetail>, ApiError> {
let org = cm_db::repo::orgs::get(&state.pool, id, user.workspace_id).await?;
let members = cm_db::repo::orgs::companies_for_org(&state.pool, id).await?;
Ok(Json(OrgDetail {
id: org.id.to_string(),
name: org.name,
kind: org.kind,
status: org.status,
created_at: org.created_at.format(&Rfc3339).unwrap_or_default(),
graph: org.graph,
members: members
.into_iter()
.map(|m| OrgCompanyOut {
node_id: m.node_id,
company_id: m.company_id.to_string(),
role: m.role,
})
.collect(),
}))
}
#[derive(Deserialize)]
pub struct RunOrgRequest {
pub task: String,
}
#[derive(Serialize)]
pub struct RunAccepted {
pub run_id: String,
pub status: String,
}
/// `POST /api/orgs/{id}/run` — enqueue a durable `org`-tier run; the worker
/// drives the recursive executor (each node runs its company's topology, which
/// runs its teams, which run their claws).
pub async fn run_org(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<RunOrgRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let org = cm_db::repo::orgs::get(&state.pool, id, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_tier(
&state.pool,
run_id,
user.workspace_id,
&body.task,
&org.graph,
"org",
)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted {
run_id: run_id.to_string(),
status: "queued".into(),
}),
))
}
+151
View File
@@ -0,0 +1,151 @@
//! Unified structure endpoint for the recursive zoom canvas.
//!
//! `GET /api/structure/{level}/{id}` returns one level of the
//! `org ▸ company ▸ team ▸ claw` hierarchy in a single polymorphic shape: the
//! level's own topology graph plus its children (the tier below) with their
//! drill targets. The canvas fetches depth-1 lazily — drilling into a child
//! issues another request for that child's level — so a large org never serializes
//! its whole subtree at once.
use axum::extract::{Path, State};
use axum::Json;
use cm_domain::AgentId;
use serde::Serialize;
use serde_json::Value;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// One child node (the tier below) with where it drills to.
#[derive(Serialize)]
pub struct StructureChild {
/// The graph node id this child binds to (matches a node in `graph`).
pub node_id: String,
pub role: String,
/// The child's level: "company" | "team" | "claw".
pub child_level: String,
/// The child's id — the drill target (`/{child_level}s/{child_id}` or, for
/// a claw, its chat at `/claws/{child_id}`).
pub child_id: String,
pub child_name: String,
}
/// One level of the hierarchy: its graph + the children to drill into.
#[derive(Serialize)]
pub struct StructureNode {
/// "org" | "company" | "team" | "claw".
pub level: String,
pub id: String,
pub name: String,
/// Topology kind at this level (None for a claw leaf).
pub kind: Option<String>,
/// This level's TopologyGraph (None for a claw leaf).
pub graph: Option<Value>,
pub children: Vec<StructureChild>,
}
/// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy.
pub async fn node(
State(state): State<AppState>,
Authed(user): Authed,
Path((level, id)): Path<(String, Uuid)>,
) -> Result<Json<StructureNode>, ApiError> {
let ws = user.workspace_id;
match level.as_str() {
"org" => {
let org = cm_db::repo::orgs::get(&state.pool, id, ws).await?;
let bindings = cm_db::repo::orgs::companies_for_org(&state.pool, id).await?;
let mut children = Vec::with_capacity(bindings.len());
for b in bindings {
let name = cm_db::repo::companies::get(&state.pool, b.company_id, ws)
.await
.map(|c| c.name)
.unwrap_or_default();
children.push(StructureChild {
node_id: b.node_id,
role: b.role,
child_level: "company".into(),
child_id: b.company_id.to_string(),
child_name: name,
});
}
Ok(Json(StructureNode {
level: "org".into(),
id: org.id.to_string(),
name: org.name,
kind: Some(org.kind),
graph: Some(org.graph),
children,
}))
}
"company" => {
let company = cm_db::repo::companies::get(&state.pool, id, ws).await?;
let bindings = cm_db::repo::companies::teams_for_company(&state.pool, id).await?;
let mut children = Vec::with_capacity(bindings.len());
for b in bindings {
let name = cm_db::repo::teams::get_team(&state.pool, b.team_id, ws)
.await
.map(|t| t.name)
.unwrap_or_default();
children.push(StructureChild {
node_id: b.node_id,
role: b.role,
child_level: "team".into(),
child_id: b.team_id.to_string(),
child_name: name,
});
}
Ok(Json(StructureNode {
level: "company".into(),
id: company.id.to_string(),
name: company.name,
kind: Some(company.kind),
graph: Some(company.graph),
children,
}))
}
"team" => {
let team = cm_db::repo::teams::get_team(&state.pool, id, ws).await?;
let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?;
let mut children = Vec::with_capacity(members.len());
for m in members {
let name = cm_db::repo::agents::get(&state.pool, AgentId::from(m.claw_id))
.await
.ok()
.filter(|a| a.workspace_id == ws)
.map(|a| a.name)
.unwrap_or_default();
children.push(StructureChild {
node_id: m.node_id,
role: m.role,
child_level: "claw".into(),
child_id: m.claw_id.to_string(),
child_name: name,
});
}
Ok(Json(StructureNode {
level: "team".into(),
id: team.id.to_string(),
name: team.name,
kind: Some(team.kind),
graph: Some(team.graph),
children,
}))
}
"claw" => {
let agent = cm_db::repo::agents::get(&state.pool, AgentId::from(id)).await?;
if agent.workspace_id != ws {
return Err(ApiError::NotFound);
}
Ok(Json(StructureNode {
level: "claw".into(),
id: agent.id.to_string(),
name: agent.name,
kind: None,
graph: None,
children: vec![],
}))
}
_ => Err(ApiError::BadRequest),
}
}
+2 -1
View File
@@ -111,7 +111,8 @@ pub async fn create_team(
.await?; .await?;
for (i, node) in graph.nodes.iter().enumerate() { for (i, node) in graph.nodes.iter().enumerate() {
if let Some(cid) = claw_ids.get(i) { if let Some(cid) = claw_ids.get(i) {
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role).await?; cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role)
.await?;
} }
} }
+5 -3
View File
@@ -60,7 +60,10 @@ pub async fn catalog(_auth: Authed) -> Json<Vec<CatalogEntry>> {
} }
/// `POST /api/topologies/classify` — infer a topology kind from a graph. /// `POST /api/topologies/classify` — infer a topology kind from a graph.
pub async fn classify_graph(_auth: Authed, Json(graph): Json<TopologyGraph>) -> Json<Classification> { pub async fn classify_graph(
_auth: Authed,
Json(graph): Json<TopologyGraph>,
) -> Json<Classification> {
Json(classify(&graph)) Json(classify(&graph))
} }
@@ -106,8 +109,7 @@ pub async fn compare_topologies(
let judge_spec = let judge_spec =
std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string()); std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string());
let (judge_provider, judge_model) = state.runtime.resolve_provider(&judge_spec); let (judge_provider, judge_model) = state.runtime.resolve_provider(&judge_spec);
let executor = let executor = ProviderExecutor::new(exec_provider, exec_model, state.runtime.max_tokens());
ProviderExecutor::new(exec_provider, exec_model, state.runtime.max_tokens());
let scorer = JudgeScorer::new(judge_provider, judge_model, 16); let scorer = JudgeScorer::new(judge_provider, judge_model, 16);
let cmp = compare(&req.graphs, &req.task, &executor, &scorer) let cmp = compare(&req.graphs, &req.task, &executor, &scorer)
.await .await
+4 -1
View File
@@ -71,7 +71,9 @@ impl ZeroClawDriveExecutor {
pub fn from_env() -> Result<Self, String> { pub fn from_env() -> Result<Self, String> {
let gateway_url = let gateway_url =
std::env::var("ZEROCLAW_GATEWAY_URL").map_err(|_| "ZEROCLAW_GATEWAY_URL not set")?; std::env::var("ZEROCLAW_GATEWAY_URL").map_err(|_| "ZEROCLAW_GATEWAY_URL not set")?;
let token = std::env::var("ZEROCLAW_TOKEN").ok().filter(|t| !t.is_empty()); let token = std::env::var("ZEROCLAW_TOKEN")
.ok()
.filter(|t| !t.is_empty());
let pairing_code = std::env::var("ZEROCLAW_PAIRING_CODE").unwrap_or_default(); let pairing_code = std::env::var("ZEROCLAW_PAIRING_CODE").unwrap_or_default();
if token.is_none() && pairing_code.is_empty() { if token.is_none() && pairing_code.is_empty() {
return Err("set ZEROCLAW_TOKEN or ZEROCLAW_PAIRING_CODE".to_string()); return Err("set ZEROCLAW_TOKEN or ZEROCLAW_PAIRING_CODE".to_string());
@@ -360,6 +362,7 @@ mod tests {
node_id: "a".into(), node_id: "a".into(),
role: "researcher".into(), role: "researcher".into(),
agent: None, agent: None,
attrs: Default::default(),
task: "say hi".into(), task: "say hi".into(),
context: vec![], context: vec![],
} }
+63 -25
View File
@@ -10,12 +10,16 @@
//! This reuses the agent-run durability pattern (claim CAS, checkpoint, resume //! This reuses the agent-run durability pattern (claim CAS, checkpoint, resume
//! sweep) without coupling topology runs to the chat-session schema. //! sweep) without coupling topology runs to the chat-session schema.
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use cm_orchestrator::{execute_resumable, RunProgress}; use cm_domain::WorkspaceId;
use cm_orchestrator::{execute_resumable, OrchestratorError, RunProgress, RunRecord, TurnExecutor};
use cm_topology::TopologyGraph; use cm_topology::TopologyGraph;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid;
use crate::recursive_exec::{SubTopologyExecutor, Tier};
use crate::topology_exec::ZeroClawDriveExecutor; use crate::topology_exec::ZeroClawDriveExecutor;
/// Requeue a `running` job whose worker hasn't checkpointed within this window. /// Requeue a `running` job whose worker hasn't checkpointed within this window.
@@ -62,7 +66,7 @@ async fn run_job(pool: &PgPool, job: cm_db::repo::topology_runs::ClaimedTopology
.and_then(|c| serde_json::from_value(c).ok()) .and_then(|c| serde_json::from_value(c).ok())
.unwrap_or_default(); .unwrap_or_default();
let executor = match ZeroClawDriveExecutor::from_env() { let leaf = match ZeroClawDriveExecutor::from_env() {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await; let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
@@ -70,30 +74,27 @@ async fn run_job(pool: &PgPool, job: cm_db::repo::topology_runs::ClaimedTopology
} }
}; };
let pool_cb = pool.clone(); // Select the executor by deploy tier: `team` drives claws directly; the
let result = execute_resumable(&graph, &job.task, &executor, progress, move |snap| { // upper tiers drive the recursive sub-topology executor (which runs each
let pool = pool_cb.clone(); // child tier's graph, all the way down to the same leaf claw executor).
async move { let result = match job.tier.as_str() {
// Best-effort checkpoint: a failed write just means we re-run the "company" | "org" => {
// step on resume (idempotent — topology turns are pure reads here). let tier = if job.tier == "org" {
if let Ok(v) = serde_json::to_value(&snap) { Tier::Org
let _ = } else {
cm_db::repo::topology_runs::checkpoint(&pool, id, &v, snap.completed as i64) Tier::Company
.await; };
let exec = SubTopologyExecutor::new(
pool.clone(),
WorkspaceId::from(job.workspace_id),
tier,
id,
Arc::new(leaf),
);
drive(pool, id, &graph, &job.task, progress, &exec).await
} }
// Honor cancellation at the step boundary: stop before the next turn. _ => drive(pool, id, &graph, &job.task, progress, &leaf).await,
if matches!( };
cm_db::repo::topology_runs::current_status(&pool, id).await,
Ok(Some(ref s)) if s == "cancelled"
) {
return Err(cm_orchestrator::OrchestratorError::Executor(
"run cancelled".into(),
));
}
Ok(())
}
})
.await;
match result { match result {
Ok(record) => { Ok(record) => {
@@ -114,3 +115,40 @@ async fn run_job(pool: &PgPool, job: cm_db::repo::topology_runs::ClaimedTopology
} }
} }
} }
/// Drive a graph to completion with the durable per-step checkpoint +
/// cancellation closure, generic over the executor so the team (leaf) and
/// company/org (recursive) tiers share the same outer durability logic. The
/// checkpoint here is parent-node-level (coarse resume); the recursive executor
/// additionally touches `updated_at` from each inner leaf step to stay alive.
async fn drive<E: TurnExecutor>(
pool: &PgPool,
id: Uuid,
graph: &TopologyGraph,
task: &str,
progress: RunProgress,
executor: &E,
) -> Result<RunRecord, OrchestratorError> {
let pool_cb = pool.clone();
execute_resumable(graph, task, executor, progress, move |snap| {
let pool = pool_cb.clone();
async move {
// Best-effort checkpoint: a failed write just means we re-run the
// step on resume (idempotent — topology turns are pure reads here).
if let Ok(v) = serde_json::to_value(&snap) {
let _ =
cm_db::repo::topology_runs::checkpoint(&pool, id, &v, snap.completed as i64)
.await;
}
// Honor cancellation at the step boundary: stop before the next turn.
if matches!(
cm_db::repo::topology_runs::current_status(&pool, id).await,
Ok(Some(ref s)) if s == "cancelled"
) {
return Err(OrchestratorError::Executor("run cancelled".into()));
}
Ok(())
}
})
.await
}
+26 -7
View File
@@ -44,11 +44,16 @@ async fn durable_run_lifecycle_enqueue_claim_checkpoint_complete() {
assert!(claimed.checkpoint.is_none()); assert!(claimed.checkpoint.is_none());
// A second claim finds nothing (the job is no longer queued). // A second claim finds nothing (the job is no longer queued).
assert!(topology_runs::claim_next_queued(&pool).await.unwrap().is_none()); assert!(topology_runs::claim_next_queued(&pool)
.await
.unwrap()
.is_none());
// Checkpoint mid-run progress. // Checkpoint mid-run progress.
let progress = json!({"completed": 1, "outputs": ["draft"], "records": [], "totals": {}}); let progress = json!({"completed": 1, "outputs": ["draft"], "records": [], "totals": {}});
topology_runs::checkpoint(&pool, id, &progress, 1).await.unwrap(); topology_runs::checkpoint(&pool, id, &progress, 1)
.await
.unwrap();
let st = topology_runs::status(&pool, id, ws).await.unwrap(); let st = topology_runs::status(&pool, id, ws).await.unwrap();
assert_eq!(st.status, "running"); assert_eq!(st.status, "running");
assert_eq!(st.last_event_id, 1); assert_eq!(st.last_event_id, 1);
@@ -73,7 +78,10 @@ async fn stale_running_jobs_are_requeued_for_resume() {
.await .await
.unwrap(); .unwrap();
// Claim it → running. // Claim it → running.
topology_runs::claim_next_queued(&pool).await.unwrap().unwrap(); topology_runs::claim_next_queued(&pool)
.await
.unwrap()
.unwrap();
// Not stale yet (just claimed) → sweep is a no-op. // Not stale yet (just claimed) → sweep is a no-op.
assert_eq!(topology_runs::requeue_stale(&pool, 60.0).await.unwrap(), 0); assert_eq!(topology_runs::requeue_stale(&pool, 60.0).await.unwrap(), 0);
@@ -83,7 +91,10 @@ async fn stale_running_jobs_are_requeued_for_resume() {
assert_eq!(topology_runs::requeue_stale(&pool, 0.0).await.unwrap(), 1); assert_eq!(topology_runs::requeue_stale(&pool, 0.0).await.unwrap(), 1);
let st = topology_runs::status(&pool, id, ws).await.unwrap(); let st = topology_runs::status(&pool, id, ws).await.unwrap();
assert_eq!(st.status, "queued"); assert_eq!(st.status, "queued");
assert!(topology_runs::claim_next_queued(&pool).await.unwrap().is_some()); assert!(topology_runs::claim_next_queued(&pool)
.await
.unwrap()
.is_some());
} }
#[tokio::test] #[tokio::test]
@@ -100,15 +111,23 @@ async fn cancel_transitions_only_active_runs() {
// A queued run cancels; the worker sees the new status (no workspace scope). // A queued run cancels; the worker sees the new status (no workspace scope).
assert!(topology_runs::cancel(&pool, id, ws).await.unwrap()); assert!(topology_runs::cancel(&pool, id, ws).await.unwrap());
assert_eq!( assert_eq!(
topology_runs::current_status(&pool, id).await.unwrap().as_deref(), topology_runs::current_status(&pool, id)
.await
.unwrap()
.as_deref(),
Some("cancelled") Some("cancelled")
); );
assert_eq!(topology_runs::status(&pool, id, ws).await.unwrap().status, "cancelled"); assert_eq!(
topology_runs::status(&pool, id, ws).await.unwrap().status,
"cancelled"
);
// Already terminal → cannot cancel again; wrong workspace → no-op. // Already terminal → cannot cancel again; wrong workspace → no-op.
assert!(!topology_runs::cancel(&pool, id, ws).await.unwrap()); assert!(!topology_runs::cancel(&pool, id, ws).await.unwrap());
let other = seed_workspace(&pool).await; let other = seed_workspace(&pool).await;
let id2 = Uuid::now_v7(); let id2 = Uuid::now_v7();
topology_runs::enqueue_run(&pool, id2, ws, "t", &graph).await.unwrap(); topology_runs::enqueue_run(&pool, id2, ws, "t", &graph)
.await
.unwrap();
assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap()); assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap());
} }
+151
View File
@@ -0,0 +1,151 @@
//! Persistence for companies — a baseline topology staffed with real workspace
//! teams (the 3rd rung of the deploy ladder). `companies` holds the topology
//! graph; `company_teams` is the durable node→team binding. Mirrors `teams`
//! one tier up: where a team binds nodes to claws, a company binds nodes to
//! whole teams, and the recursive executor runs each bound team's sub-topology.
use cm_domain::WorkspaceId;
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
/// A company row summary (list view).
pub struct CompanySummary {
pub id: Uuid,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: OffsetDateTime,
}
/// A full company (graph + metadata).
pub struct Company {
pub id: Uuid,
pub name: String,
pub kind: String,
pub graph: Value,
pub status: String,
pub created_at: OffsetDateTime,
}
/// A node→team binding within a company.
pub struct CompanyTeam {
pub node_id: String,
pub team_id: Uuid,
pub role: String,
}
/// Insert a company (the topology graph). Members are added separately.
pub async fn insert_company(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
name: &str,
kind: &str,
graph: &Value,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO companies (id, workspace_id, name, kind, graph)
VALUES ($1, $2, $3, $4, $5)",
id,
workspace_id.as_uuid(),
name,
kind,
graph,
)
.execute(pool)
.await?;
Ok(())
}
/// Bind a team to a topology node within a company.
pub async fn add_team(
pool: &PgPool,
company_id: Uuid,
node_id: &str,
team_id: Uuid,
role: &str,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO company_teams (company_id, node_id, team_id, role)
VALUES ($1, $2, $3, $4)",
company_id,
node_id,
team_id,
role,
)
.execute(pool)
.await?;
Ok(())
}
/// The most recent companies for a workspace, newest first.
pub async fn list_for_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
limit: i64,
) -> Result<Vec<CompanySummary>, DbError> {
let rows = sqlx::query!(
"SELECT id, name, kind, status, created_at FROM companies
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
workspace_id.as_uuid(),
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| CompanySummary {
id: r.id,
name: r.name,
kind: r.kind,
status: r.status,
created_at: r.created_at,
})
.collect())
}
/// A single company, workspace-scoped.
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Company, DbError> {
let row = sqlx::query!(
"SELECT id, name, kind, graph, status, created_at FROM companies
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
.ok_or(DbError::NotFound)?;
Ok(Company {
id: row.id,
name: row.name,
kind: row.kind,
graph: row.graph,
status: row.status,
created_at: row.created_at,
})
}
/// The node→team bindings for a company.
pub async fn teams_for_company(
pool: &PgPool,
company_id: Uuid,
) -> Result<Vec<CompanyTeam>, DbError> {
let rows = sqlx::query!(
"SELECT node_id, team_id, role FROM company_teams WHERE company_id = $1 ORDER BY node_id",
company_id,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| CompanyTeam {
node_id: r.node_id,
team_id: r.team_id,
role: r.role,
})
.collect())
}
+2
View File
@@ -1,9 +1,11 @@
pub mod agents; pub mod agents;
pub mod audit; pub mod audit;
pub mod companies;
pub mod connections; pub mod connections;
pub mod credits; pub mod credits;
pub mod files; pub mod files;
pub mod messages; pub mod messages;
pub mod orgs;
pub mod outbox; pub mod outbox;
pub mod routines; pub mod routines;
pub mod run_events; pub mod run_events;
+148
View File
@@ -0,0 +1,148 @@
//! Persistence for orgs — a baseline topology whose nodes are whole companies
//! (the top rung of the deploy ladder). `orgs` holds the topology graph;
//! `org_companies` is the durable node→company binding. Mirrors `companies`
//! one tier up; the recursive executor runs each bound company's sub-topology,
//! which in turn runs its teams, which run their claws.
use cm_domain::WorkspaceId;
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
/// An org row summary (list view).
pub struct OrgSummary {
pub id: Uuid,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: OffsetDateTime,
}
/// A full org (graph + metadata).
pub struct Org {
pub id: Uuid,
pub name: String,
pub kind: String,
pub graph: Value,
pub status: String,
pub created_at: OffsetDateTime,
}
/// A node→company binding within an org.
pub struct OrgCompany {
pub node_id: String,
pub company_id: Uuid,
pub role: String,
}
/// Insert an org (the topology graph). Members are added separately.
pub async fn insert_org(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
name: &str,
kind: &str,
graph: &Value,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO orgs (id, workspace_id, name, kind, graph)
VALUES ($1, $2, $3, $4, $5)",
id,
workspace_id.as_uuid(),
name,
kind,
graph,
)
.execute(pool)
.await?;
Ok(())
}
/// Bind a company to a topology node within an org.
pub async fn add_company(
pool: &PgPool,
org_id: Uuid,
node_id: &str,
company_id: Uuid,
role: &str,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO org_companies (org_id, node_id, company_id, role)
VALUES ($1, $2, $3, $4)",
org_id,
node_id,
company_id,
role,
)
.execute(pool)
.await?;
Ok(())
}
/// The most recent orgs for a workspace, newest first.
pub async fn list_for_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
limit: i64,
) -> Result<Vec<OrgSummary>, DbError> {
let rows = sqlx::query!(
"SELECT id, name, kind, status, created_at FROM orgs
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
workspace_id.as_uuid(),
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| OrgSummary {
id: r.id,
name: r.name,
kind: r.kind,
status: r.status,
created_at: r.created_at,
})
.collect())
}
/// A single org, workspace-scoped.
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Org, DbError> {
let row = sqlx::query!(
"SELECT id, name, kind, graph, status, created_at FROM orgs
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
.ok_or(DbError::NotFound)?;
Ok(Org {
id: row.id,
name: row.name,
kind: row.kind,
graph: row.graph,
status: row.status,
created_at: row.created_at,
})
}
/// The node→company bindings for an org.
pub async fn companies_for_org(pool: &PgPool, org_id: Uuid) -> Result<Vec<OrgCompany>, DbError> {
let rows = sqlx::query!(
"SELECT node_id, company_id, role FROM org_companies WHERE org_id = $1 ORDER BY node_id",
org_id,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| OrgCompany {
node_id: r.node_id,
company_id: r.company_id,
role: r.role,
})
.collect())
}
+1 -5
View File
@@ -107,11 +107,7 @@ pub async fn list_for_workspace(
} }
/// A single team, workspace-scoped. /// A single team, workspace-scoped.
pub async fn get_team( pub async fn get_team(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Team, DbError> {
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
) -> Result<Team, DbError> {
let row = sqlx::query!( let row = sqlx::query!(
"SELECT id, name, kind, graph, status, created_at FROM teams "SELECT id, name, kind, graph, status, created_at FROM teams
WHERE id = $1 AND workspace_id = $2", WHERE id = $1 AND workspace_id = $2",
+38 -3
View File
@@ -38,6 +38,9 @@ pub struct ClaimedTopologyRun {
pub checkpoint: Option<Value>, pub checkpoint: Option<Value>,
/// Event-journal offset reached so far. /// Event-journal offset reached so far.
pub last_event_id: i64, pub last_event_id: i64,
/// Deploy tier: `team` drives claws directly; `company`/`org` drive the
/// recursive sub-topology executor.
pub tier: String,
} }
/// Lifecycle status + progress for a durable run (status endpoint). /// Lifecycle status + progress for a durable run (status endpoint).
@@ -79,20 +82,36 @@ pub async fn insert(
/// Enqueue a durable single-topology run job (`kind = 'run'`, `status = 'queued'`). /// Enqueue a durable single-topology run job (`kind = 'run'`, `status = 'queued'`).
/// The background worker claims and executes it; the result lands in `comparison`. /// The background worker claims and executes it; the result lands in `comparison`.
/// Tier defaults to `team` (drives claws directly).
pub async fn enqueue_run( pub async fn enqueue_run(
pool: &PgPool, pool: &PgPool,
id: Uuid, id: Uuid,
workspace_id: WorkspaceId, workspace_id: WorkspaceId,
task: &str, task: &str,
graph: &Value, graph: &Value,
) -> Result<(), DbError> {
enqueue_run_tier(pool, id, workspace_id, task, graph, "team").await
}
/// Enqueue a durable run for a specific deploy tier (`team` | `company` | `org`).
/// The worker selects the matching executor — a `company`/`org` job runs the
/// recursive sub-topology executor, which drives each child tier in turn.
pub async fn enqueue_run_tier(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
task: &str,
graph: &Value,
tier: &str,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
sqlx::query!( sqlx::query!(
"INSERT INTO topology_runs (id, workspace_id, task, kind, status, graph) "INSERT INTO topology_runs (id, workspace_id, task, kind, status, graph, tier)
VALUES ($1, $2, $3, 'run', 'queued', $4)", VALUES ($1, $2, $3, 'run', 'queued', $4, $5)",
id, id,
workspace_id.as_uuid(), workspace_id.as_uuid(),
task, task,
graph, graph,
tier,
) )
.execute(pool) .execute(pool)
.await?; .await?;
@@ -113,7 +132,7 @@ pub async fn claim_next_queued(pool: &PgPool) -> Result<Option<ClaimedTopologyRu
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
LIMIT 1 LIMIT 1
) )
RETURNING id, workspace_id, task, graph, checkpoint, last_event_id", RETURNING id, workspace_id, task, graph, checkpoint, last_event_id, tier",
) )
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
@@ -124,6 +143,7 @@ pub async fn claim_next_queued(pool: &PgPool) -> Result<Option<ClaimedTopologyRu
graph: r.graph, graph: r.graph,
checkpoint: r.checkpoint, checkpoint: r.checkpoint,
last_event_id: r.last_event_id, last_event_id: r.last_event_id,
tier: r.tier,
})) }))
} }
@@ -148,6 +168,21 @@ pub async fn checkpoint(
Ok(()) Ok(())
} }
/// Touch `updated_at` without changing the checkpoint — keeps a long-running
/// job visibly alive to the stale-run sweeper. Used by the recursive executor:
/// a parent (company/org) run can spend minutes inside one node executing a
/// child sub-topology, so every leaf turn touches the parent here to prevent
/// the 180s sweep from requeuing the parent mid-subtree.
pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
sqlx::query!(
"UPDATE topology_runs SET updated_at = now() WHERE id = $1",
id,
)
.execute(pool)
.await?;
Ok(())
}
/// Mark a job completed and store its final result blob. /// Mark a job completed and store its final result blob.
pub async fn complete(pool: &PgPool, id: Uuid, result: &Value) -> Result<(), DbError> { pub async fn complete(pool: &PgPool, id: Uuid, result: &Value) -> Result<(), DbError> {
sqlx::query!( sqlx::query!(
@@ -33,10 +33,22 @@ fn node(id: &str, role: &str) -> Node {
fn hierarchical() -> TopologyGraph { fn hierarchical() -> TopologyGraph {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Hierarchical, TopologyKind::Hierarchical,
vec![node("lead", "coordinator"), node("w1", "researcher"), node("w2", "writer")],
vec![ vec![
Edge { from: "lead".into(), to: "w1".into(), kind: EdgeKind::DelegatesTo }, node("lead", "coordinator"),
Edge { from: "lead".into(), to: "w2".into(), kind: EdgeKind::DelegatesTo }, node("w1", "researcher"),
node("w2", "writer"),
],
vec![
Edge {
from: "lead".into(),
to: "w1".into(),
kind: EdgeKind::DelegatesTo,
},
Edge {
from: "lead".into(),
to: "w2".into(),
kind: EdgeKind::DelegatesTo,
},
], ],
) )
.unwrap() .unwrap()
@@ -45,10 +57,22 @@ fn hierarchical() -> TopologyGraph {
fn pipeline() -> TopologyGraph { fn pipeline() -> TopologyGraph {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Pipeline, TopologyKind::Pipeline,
vec![node("a", "researcher"), node("b", "analyst"), node("c", "writer")],
vec![ vec![
Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::PipesTo }, node("a", "researcher"),
Edge { from: "b".into(), to: "c".into(), kind: EdgeKind::PipesTo }, node("b", "analyst"),
node("c", "writer"),
],
vec![
Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::PipesTo,
},
Edge {
from: "b".into(),
to: "c".into(),
kind: EdgeKind::PipesTo,
},
], ],
) )
.unwrap() .unwrap()
@@ -57,7 +81,11 @@ fn pipeline() -> TopologyGraph {
fn swarm() -> TopologyGraph { fn swarm() -> TopologyGraph {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Swarm, TopologyKind::Swarm,
vec![node("a", "writer"), node("b", "writer"), node("lead", "coordinator")], vec![
node("a", "writer"),
node("b", "writer"),
node("lead", "coordinator"),
],
vec![], vec![],
) )
.unwrap() .unwrap()
@@ -66,11 +94,27 @@ fn swarm() -> TopologyGraph {
fn mesh() -> TopologyGraph { fn mesh() -> TopologyGraph {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Mesh, TopologyKind::Mesh,
vec![node("a", "analyst"), node("b", "analyst"), node("c", "analyst")],
vec![ vec![
Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::PeersWith }, node("a", "analyst"),
Edge { from: "b".into(), to: "c".into(), kind: EdgeKind::PeersWith }, node("b", "analyst"),
Edge { from: "a".into(), to: "c".into(), kind: EdgeKind::PeersWith }, node("c", "analyst"),
],
vec![
Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::PeersWith,
},
Edge {
from: "b".into(),
to: "c".into(),
kind: EdgeKind::PeersWith,
},
Edge {
from: "a".into(),
to: "c".into(),
kind: EdgeKind::PeersWith,
},
], ],
) )
.unwrap() .unwrap()
@@ -79,7 +123,11 @@ fn mesh() -> TopologyGraph {
fn debate() -> TopologyGraph { fn debate() -> TopologyGraph {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Debate, TopologyKind::Debate,
vec![node("proposer", "proposer"), node("critic", "critic"), node("judge", "judge")], vec![
node("proposer", "proposer"),
node("critic", "critic"),
node("judge", "judge"),
],
vec![], vec![],
) )
.unwrap() .unwrap()
@@ -87,11 +135,12 @@ fn debate() -> TopologyGraph {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let (provider, model): (Arc<dyn LlmProvider>, String) = let (provider, model): (Arc<dyn LlmProvider>, String) = match std::env::var("ANTHROPIC_API_KEY")
match std::env::var("ANTHROPIC_API_KEY") { {
Ok(key) if !key.is_empty() => { Ok(key) if !key.is_empty() => (
(Arc::new(cm_llm::AnthropicProvider::new(key)), "claude-sonnet-4-6".to_string()) Arc::new(cm_llm::AnthropicProvider::new(key)),
} "claude-sonnet-4-6".to_string(),
),
_ => ( _ => (
Arc::new(cm_llm::ScriptedProvider::from_toml("").unwrap()), Arc::new(cm_llm::ScriptedProvider::from_toml("").unwrap()),
"scripted".to_string(), "scripted".to_string(),
@@ -128,7 +177,10 @@ async fn main() {
println!("best quality: {:?}", cmp.results[i].kind); println!("best quality: {:?}", cmp.results[i].kind);
} }
if let Some(i) = cmp.best_value { if let Some(i) = cmp.best_value {
println!("best value: {:?} (quality per token)", cmp.results[i].kind); println!(
"best value: {:?} (quality per token)",
cmp.results[i].kind
);
} }
// Workflow of topologies: brainstorm (swarm) → execute (hierarchical) → review (debate). // Workflow of topologies: brainstorm (swarm) → execute (hierarchical) → review (debate).
+27 -4
View File
@@ -100,7 +100,11 @@ pub async fn evolve<E: TurnExecutor, S: Scorer>(
let best = archive let best = archive
.iter() .iter()
.enumerate() .enumerate()
.max_by(|a, b| a.1.quality.partial_cmp(&b.1.quality).unwrap_or(Ordering::Equal)) .max_by(|a, b| {
a.1.quality
.partial_cmp(&b.1.quality)
.unwrap_or(Ordering::Equal)
})
.map(|(i, _)| i); .map(|(i, _)| i);
Ok(Evolution { Ok(Evolution {
@@ -118,7 +122,15 @@ pub async fn evolve_all<E: TurnExecutor, S: Scorer>(
executor: &E, executor: &E,
scorer: &S, scorer: &S,
) -> Result<Evolution, OrchestratorError> { ) -> Result<Evolution, OrchestratorError> {
evolve(roles, task, executor, scorer, &TopologyKind::ALL, &[roles.len()]).await evolve(
roles,
task,
executor,
scorer,
&TopologyKind::ALL,
&[roles.len()],
)
.await
} }
#[cfg(test)] #[cfg(test)]
@@ -151,7 +163,11 @@ mod tests {
"task", "task",
&Echo, &Echo,
&LengthScorer, &LengthScorer,
&[TopologyKind::Hierarchical, TopologyKind::Pipeline, TopologyKind::Swarm], &[
TopologyKind::Hierarchical,
TopologyKind::Pipeline,
TopologyKind::Swarm,
],
&[3], &[3],
) )
.await .await
@@ -166,7 +182,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn grid_spans_sizes_and_skips_oversized() { async fn grid_spans_sizes_and_skips_oversized() {
let roles = ["a", "b", "c"]; let roles = ["a", "b", "c"];
let ev = evolve(&roles, "t", &Echo, &LengthScorer, &[TopologyKind::Pipeline], &[2, 3, 9]) let ev = evolve(
&roles,
"t",
&Echo,
&LengthScorer,
&[TopologyKind::Pipeline],
&[2, 3, 9],
)
.await .await
.unwrap(); .unwrap();
// sizes 2 and 3 evaluated; 9 skipped (> roles.len()). // sizes 2 and 3 evaluated; 9 skipped (> roles.len()).
+15 -3
View File
@@ -163,14 +163,22 @@ mod tests {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Pipeline, TopologyKind::Pipeline,
vec![Node::new("a", "r"), Node::new("b", "w")], vec![Node::new("a", "r"), Node::new("b", "w")],
vec![Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::PipesTo }], vec![Edge {
from: "a".into(),
to: "b".into(),
kind: EdgeKind::PipesTo,
}],
) )
.unwrap() .unwrap()
} }
fn flat() -> TopologyGraph { fn flat() -> TopologyGraph {
TopologyGraph::new( TopologyGraph::new(
TopologyKind::Swarm, TopologyKind::Swarm,
vec![Node::new("a", "r"), Node::new("b", "w"), Node::new("c", "coordinator")], vec![
Node::new("a", "r"),
Node::new("b", "w"),
Node::new("c", "coordinator"),
],
vec![], vec![],
) )
.unwrap() .unwrap()
@@ -200,7 +208,11 @@ mod tests {
let risky = TopologyGraph::new( let risky = TopologyGraph::new(
TopologyKind::Pipeline, TopologyKind::Pipeline,
vec![Node::new("risky1", "r"), Node::new("b", "w")], vec![Node::new("risky1", "r"), Node::new("b", "w")],
vec![Edge { from: "risky1".into(), to: "b".into(), kind: EdgeKind::PipesTo }], vec![Edge {
from: "risky1".into(),
to: "b".into(),
kind: EdgeKind::PipesTo,
}],
) )
.unwrap(); .unwrap();
let cmp = compare(&[risky], "x", &Echo, &LengthScorer).await.unwrap(); let cmp = compare(&[risky], "x", &Echo, &LengthScorer).await.unwrap();
+38 -18
View File
@@ -14,20 +14,20 @@
mod evolve; mod evolve;
mod harness; mod harness;
mod plan;
mod workflow;
#[cfg(feature = "provider")] #[cfg(feature = "provider")]
mod judge; mod judge;
mod plan;
#[cfg(feature = "provider")] #[cfg(feature = "provider")]
mod provider_executor; mod provider_executor;
mod workflow;
pub use evolve::{evolve, evolve_all, EliteCell, Evolution}; pub use evolve::{evolve, evolve_all, EliteCell, Evolution};
pub use harness::{compare, Comparison, Scorer, TopologyResult}; pub use harness::{compare, Comparison, Scorer, TopologyResult};
pub use workflow::{run_workflow, WorkflowRecord};
#[cfg(feature = "provider")] #[cfg(feature = "provider")]
pub use judge::JudgeScorer; pub use judge::JudgeScorer;
#[cfg(feature = "provider")] #[cfg(feature = "provider")]
pub use provider_executor::ProviderExecutor; pub use provider_executor::ProviderExecutor;
pub use workflow::{run_workflow, WorkflowRecord};
use cm_domain::GatedCategory; use cm_domain::GatedCategory;
use cm_topology::{TopologyGraph, TopologyKind}; use cm_topology::{TopologyGraph, TopologyKind};
@@ -84,6 +84,11 @@ pub struct TurnRequest {
/// (heterogeneous topologies) without reconfiguring the server. Falls back /// (heterogeneous topologies) without reconfiguring the server. Falls back
/// to the role→alias map when absent. /// to the role→alias map when absent.
pub agent: Option<String>, pub agent: Option<String>,
/// The full free-form node attributes (`node.attrs`). Carries the binding a
/// recursive executor needs — `attrs["team_id"]` (company tier) or
/// `attrs["company_id"]` (org tier) — so a "turn" can resolve and run the
/// sub-topology one tier down. Leaf (claw) executors ignore this.
pub attrs: std::collections::BTreeMap<String, String>,
/// The top-level task. /// The top-level task.
pub task: String, pub task: String,
/// Upstream context (task and/or prior step outputs) for this turn. /// Upstream context (task and/or prior step outputs) for this turn.
@@ -238,6 +243,7 @@ where
node_id: node.id.clone(), node_id: node.id.clone(),
role: node.role.clone(), role: node.role.clone(),
agent: node.attrs.get("agent").cloned(), agent: node.attrs.get("agent").cloned(),
attrs: node.attrs.clone(),
task: task.to_string(), task: task.to_string(),
context, context,
}) })
@@ -333,7 +339,12 @@ mod tests {
let phases: Vec<_> = rec.steps.iter().map(|s| s.phase).collect(); let phases: Vec<_> = rec.steps.iter().map(|s| s.phase).collect();
assert_eq!( assert_eq!(
phases, phases,
vec![StepPhase::Plan, StepPhase::Work, StepPhase::Work, StepPhase::Synth] vec![
StepPhase::Plan,
StepPhase::Work,
StepPhase::Work,
StepPhase::Synth
]
); );
// The synthesis step saw both children's outputs. // The synthesis step saw both children's outputs.
let synth = rec.steps.last().unwrap(); let synth = rec.steps.last().unwrap();
@@ -390,7 +401,11 @@ mod tests {
}) })
.await .await
.unwrap(); .unwrap();
assert_eq!(c1.0.load(Ordering::SeqCst), 3, "fresh run executes all 3 steps"); assert_eq!(
c1.0.load(Ordering::SeqCst),
3,
"fresh run executes all 3 steps"
);
let snaps = snaps.into_inner(); let snaps = snaps.into_inner();
assert_eq!(snaps.len(), 3); assert_eq!(snaps.len(), 3);
@@ -403,7 +418,11 @@ mod tests {
.unwrap(); .unwrap();
// Only the remaining 2 steps re-run; the result matches the full run. // Only the remaining 2 steps re-run; the result matches the full run.
assert_eq!(c2.0.load(Ordering::SeqCst), 2, "resume runs only remaining steps"); assert_eq!(
c2.0.load(Ordering::SeqCst),
2,
"resume runs only remaining steps"
);
assert_eq!(resumed.steps.len(), 3); assert_eq!(resumed.steps.len(), 3);
assert_eq!(resumed.totals.turns, 3); assert_eq!(resumed.totals.turns, 3);
assert_eq!(resumed.final_output, full.final_output); assert_eq!(resumed.final_output, full.final_output);
@@ -411,11 +430,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn swarm_aggregates_all_workers() { async fn swarm_aggregates_all_workers() {
let graph = g( let graph = g(TopologyKind::Swarm, &["a", "b", "coord"], &[]);
TopologyKind::Swarm,
&["a", "b", "coord"],
&[],
);
// give "coord" a coordinator role so it aggregates // give "coord" a coordinator role so it aggregates
let mut graph = graph; let mut graph = graph;
graph.nodes[2].role = "coordinator".into(); graph.nodes[2].role = "coordinator".into();
@@ -424,16 +439,16 @@ mod tests {
assert_eq!(rec.steps.len(), 4); assert_eq!(rec.steps.len(), 4);
assert_eq!(rec.steps[3].phase, StepPhase::Aggregate); assert_eq!(rec.steps[3].phase, StepPhase::Aggregate);
let agg = rec.steps.last().unwrap(); let agg = rec.steps.last().unwrap();
assert!(agg.output.contains("(a)") && agg.output.contains("(b)") && agg.output.contains("(coord)")); assert!(
agg.output.contains("(a)")
&& agg.output.contains("(b)")
&& agg.output.contains("(coord)")
);
} }
#[tokio::test] #[tokio::test]
async fn blocked_gated_action_is_recorded_not_executed() { async fn blocked_gated_action_is_recorded_not_executed() {
let graph = g( let graph = g(TopologyKind::Pipeline, &["risky1", "b"], &[("risky1", "b")]);
TopologyKind::Pipeline,
&["risky1", "b"],
&[("risky1", "b")],
);
let rec = execute(&graph, "task", &Echo).await.unwrap(); let rec = execute(&graph, "task", &Echo).await.unwrap();
assert_eq!(rec.totals.gated_actions, 1); assert_eq!(rec.totals.gated_actions, 1);
assert_eq!(rec.totals.approvals_blocked, 1); assert_eq!(rec.totals.approvals_blocked, 1);
@@ -456,7 +471,12 @@ mod tests {
let phases: Vec<_> = rec2.steps.iter().map(|s| s.phase).collect(); let phases: Vec<_> = rec2.steps.iter().map(|s| s.phase).collect();
assert_eq!( assert_eq!(
phases, phases,
vec![StepPhase::Work, StepPhase::Work, StepPhase::Synth, StepPhase::Aggregate] vec![
StepPhase::Work,
StepPhase::Work,
StepPhase::Synth,
StepPhase::Aggregate
]
); );
} }
+24 -4
View File
@@ -225,9 +225,29 @@ pub(crate) fn debate(g: &TopologyGraph) -> Result<Vec<PlanStep>, OrchestratorErr
let critic = 1; let critic = 1;
let judge = if n >= 3 { 2 } else { 0 }; let judge = if n >= 3 { 2 } else { 0 };
Ok(vec![ Ok(vec![
PlanStep { node_idx: proposer, phase: StepPhase::Work, use_task: true, ctx_from: vec![] }, PlanStep {
PlanStep { node_idx: critic, phase: StepPhase::Work, use_task: true, ctx_from: vec![0] }, node_idx: proposer,
PlanStep { node_idx: proposer, phase: StepPhase::Synth, use_task: false, ctx_from: vec![0, 1] }, phase: StepPhase::Work,
PlanStep { node_idx: judge, phase: StepPhase::Aggregate, use_task: false, ctx_from: vec![2, 1] }, use_task: true,
ctx_from: vec![],
},
PlanStep {
node_idx: critic,
phase: StepPhase::Work,
use_task: true,
ctx_from: vec![0],
},
PlanStep {
node_idx: proposer,
phase: StepPhase::Synth,
use_task: false,
ctx_from: vec![0, 1],
},
PlanStep {
node_idx: judge,
phase: StepPhase::Aggregate,
use_task: false,
ctx_from: vec![2, 1],
},
]) ])
} }
@@ -139,8 +139,16 @@ mod tests {
Node::new("w2", "worker"), Node::new("w2", "worker"),
], ],
vec![ vec![
Edge { from: "lead".into(), to: "w1".into(), kind: EdgeKind::DelegatesTo }, Edge {
Edge { from: "lead".into(), to: "w2".into(), kind: EdgeKind::DelegatesTo }, from: "lead".into(),
to: "w1".into(),
kind: EdgeKind::DelegatesTo,
},
Edge {
from: "lead".into(),
to: "w2".into(),
kind: EdgeKind::DelegatesTo,
},
], ],
) )
.unwrap(); .unwrap();
+2 -1
View File
@@ -99,7 +99,8 @@ mod tests {
// Stage 2's output reflects stage 1's final output (threaded forward). // Stage 2's output reflects stage 1's final output (threaded forward).
let s1_final = &rec.stages[0].final_output; let s1_final = &rec.stages[0].final_output;
assert!( assert!(
rec.final_output.contains(&s1_final[..s1_final.len().min(4)]), rec.final_output
.contains(&s1_final[..s1_final.len().min(4)]),
"stage 2 should build on stage 1" "stage 2 should build on stage 1"
); );
// Totals are the sum of both stages. // Totals are the sum of both stages.
@@ -0,0 +1,6 @@
import { StructureCanvas } from "@/components/structure/StructureCanvas";
export default async function CompanyPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <StructureCanvas level="company" id={id} />;
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface GroupSummary {
id: string;
name: string;
kind: string;
status: string;
}
export default function CompaniesPage() {
const [companies, setCompanies] = useState<GroupSummary[]>([]);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/companies");
if (r.ok) setCompanies((await r.json()) as GroupSummary[]);
} catch {
/* ignore */
}
})();
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold tracking-tight">Companies</h1>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy a company
</Link>
</div>
{companies.length === 0 ? (
<p className="text-sm text-muted-foreground">
No companies yet — a company is a topology of teams.
</p>
) : (
<ul className="flex flex-col gap-2">
{companies.map((c) => (
<li key={c.id}>
<Link
href={`/companies/${c.id}`}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-muted/30"
>
<span className="text-sm font-medium text-foreground">{c.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
{c.kind}
</span>
<span className="text-xs text-muted-foreground">{c.status}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+13 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { LeftRail } from "@/components/shell/LeftRail"; import { LeftRail } from "@/components/shell/LeftRail";
import { ApiAuthError } from "@/lib/api/http"; import { ApiAuthError } from "@/lib/api/http";
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team"; import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
import { fetchCompanies, fetchOrgs, fetchTeams, type GroupSummary } from "@/lib/api/structure";
import type { Agent, Credits, User } from "@/lib/api/schemas"; import type { Agent, Credits, User } from "@/lib/api/schemas";
// Every workspace page shares the persistent rail (§4). This layout reads // Every workspace page shares the persistent rail (§4). This layout reads
@@ -13,11 +14,19 @@ export default async function WorkspaceLayout({
let user: User; let user: User;
let roster: Agent[]; let roster: Agent[];
let credits: Credits; let credits: Credits;
let orgs: GroupSummary[];
let companies: GroupSummary[];
let teams: GroupSummary[];
try { try {
[user, roster, credits] = await Promise.all([ // Structure lists are non-critical chrome — never let one fail the shell.
const groups = <T,>(p: Promise<T[]>) => p.catch(() => [] as T[]);
[user, roster, credits, orgs, companies, teams] = await Promise.all([
fetchMe(), fetchMe(),
fetchClaws(), fetchClaws(),
fetchCredits(), fetchCredits(),
groups(fetchOrgs()),
groups(fetchCompanies()),
groups(fetchTeams()),
]); ]);
} catch (error) { } catch (error) {
if (error instanceof ApiAuthError) { if (error instanceof ApiAuthError) {
@@ -30,6 +39,9 @@ export default async function WorkspaceLayout({
<LeftRail <LeftRail
user={user} user={user}
roster={roster} roster={roster}
orgs={orgs}
companies={companies}
teams={teams}
creditsBalance={credits.available} creditsBalance={credits.available}
/> />
<main className="min-w-0 flex-1">{children}</main> <main className="min-w-0 flex-1">{children}</main>
@@ -0,0 +1,6 @@
import { StructureCanvas } from "@/components/structure/StructureCanvas";
export default async function OrgPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <StructureCanvas level="org" id={id} />;
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface GroupSummary {
id: string;
name: string;
kind: string;
status: string;
}
export default function OrgsPage() {
const [orgs, setOrgs] = useState<GroupSummary[]>([]);
useEffect(() => {
void (async () => {
try {
const r = await fetch("/api/orgs");
if (r.ok) setOrgs((await r.json()) as GroupSummary[]);
} catch {
/* ignore */
}
})();
}, []);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold tracking-tight">Organizations</h1>
<Link
href="/claws/new"
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white"
>
Deploy an org
</Link>
</div>
{orgs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet — an org is a topology of companies.
</p>
) : (
<ul className="flex flex-col gap-2">
{orgs.map((o) => (
<li key={o.id}>
<Link
href={`/orgs/${o.id}`}
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-muted/30"
>
<span className="text-sm font-medium text-foreground">{o.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
{o.kind}
</span>
<span className="text-xs text-muted-foreground">{o.status}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
@@ -1,6 +1,6 @@
import { TeamView } from "@/components/team/TeamView"; import { StructureCanvas } from "@/components/structure/StructureCanvas";
export default async function TeamPage({ params }: { params: Promise<{ id: string }> }) { export default async function TeamPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
return <TeamView teamId={id} />; return <StructureCanvas level="team" id={id} />;
} }
+141
View File
@@ -0,0 +1,141 @@
// The clawmates brand mark: a node-mesh / topology glyph. The product is a
// platform for deploying meshes of agents at every scale (claw → team → company
// → org), so the mark *is* a topology — nodes joined by edges, in coral. The
// `variant` knob draws the same visual family at different node counts so the
// rail's per-tier glyphs (team/company/org) read as one system.
//
// Pure SVG (no hooks) → usable in server or client components. Edges use
// `currentColor` + non-scaling strokes so it stays crisp from 16px to 44px and
// can inherit the surrounding text color where a caller wants a monochrome mark.
export type MeshVariant = "mark" | "org" | "company" | "team";
interface MeshShape {
nodes: [number, number][];
edges: [number, number][];
}
// Geometry on a 24×24 grid. `mark` is a hub + triangle (the wordmark glyph);
// team/company/org scale the node count up while keeping the meshy feel.
const SHAPES: Record<MeshVariant, MeshShape> = {
team: {
nodes: [
[12, 5],
[5, 18],
[19, 18],
],
edges: [
[0, 1],
[0, 2],
[1, 2],
],
},
mark: {
nodes: [
[12, 12],
[12, 4],
[5, 19],
[19, 19],
],
edges: [
[0, 1],
[0, 2],
[0, 3],
[1, 2],
[1, 3],
[2, 3],
],
},
company: {
nodes: [
[12, 4],
[4, 11],
[20, 11],
[8, 20],
[16, 20],
],
edges: [
[0, 1],
[0, 2],
[1, 2],
[1, 3],
[2, 4],
[3, 4],
],
},
org: {
nodes: [
[12, 3],
[4, 8],
[20, 8],
[4, 16],
[20, 16],
[12, 21],
],
edges: [
[0, 1],
[0, 2],
[1, 2],
[1, 3],
[2, 4],
[3, 4],
[3, 5],
[4, 5],
],
},
};
interface MeshMarkProps {
size?: number;
variant?: MeshVariant;
className?: string;
title?: string;
/** Node fill (defaults to the coral token). */
nodeFill?: string;
/** Edge stroke (defaults to currentColor, so it inherits text color). */
edgeStroke?: string;
}
export function MeshMark({
size = 24,
variant = "mark",
className,
title = "clawmates",
nodeFill = "var(--color-coral)",
edgeStroke = "currentColor",
}: MeshMarkProps) {
const shape = SHAPES[variant];
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={className}
role="img"
aria-label={title}
fill="none"
>
{shape.edges.map(([a, b], i) => {
const p = shape.nodes[a];
const q = shape.nodes[b];
return (
<line
key={`e${i}`}
x1={p[0]}
y1={p[1]}
x2={q[0]}
y2={q[1]}
stroke={edgeStroke}
strokeWidth={1.4}
strokeOpacity={0.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
);
})}
{shape.nodes.map(([x, y], i) => (
<circle key={`n${i}`} cx={x} cy={y} r={2.3} fill={nodeFill} />
))}
</svg>
);
}
+35 -59
View File
@@ -1,35 +1,45 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { Menu, Plus, X } from "lucide-react"; import { Menu } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, User } from "@/lib/api/schemas";
import type { GroupSummary } from "@/lib/api/structure";
import { panelParsers } from "@/lib/url/panel-params"; import { panelParsers } from "@/lib/url/panel-params";
import { RosterList } from "./RosterList"; import { RosterColumn } from "./RosterColumn";
import { ShellNav } from "./ShellNav"; import { StructureRail } from "./StructureRail";
import { UserMenu } from "./UserMenu";
interface LeftRailProps { interface LeftRailProps {
user: User; user: User;
roster: Agent[]; roster: Agent[];
orgs: GroupSummary[];
companies: GroupSummary[];
teams: GroupSummary[];
creditsBalance?: number; creditsBalance?: number;
} }
/** The persistent 176px left rail (measured): wordmark, the claw avatar /** The persistent two-tier left rail (Discord-style): a thin 68px structure
* stack, dashed add tile, global nav + user. At ≤md it collapses behind * column (mesh mark + the org/company/team hierarchy + tools + deploy + user)
* a hamburger and slides in as a drawer over the content. */ * and a 176px context column (the selected group's children, or your claws).
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) { * In full-panel mode the context column collapses away; at ≤md the whole rail
* slides in as a drawer. */
export function LeftRail({
user,
roster,
orgs,
companies,
teams,
creditsBalance,
}: LeftRailProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const [{ app, device }] = useQueryStates(panelParsers, { shallow: true }); const [{ app, device }] = useQueryStates(panelParsers, { shallow: true });
// In full-panel mode the rail collapses to an 80px icon-only strip (md+), // In full-panel mode the context column collapses, leaving the 68px structure
// handing the extra room to the panel overlay. // column and handing the extra room to the panel overlay.
const collapsed = app !== null && device === "full"; const collapsed = app !== null && device === "full";
// Close the drawer when the route changes (render-phase prev-state // Close the drawer when the route changes (render-phase prev-state pattern).
// pattern — no effect, no ref).
const [seenPath, setSeenPath] = useState(pathname); const [seenPath, setSeenPath] = useState(pathname);
if (seenPath !== pathname) { if (seenPath !== pathname) {
setSeenPath(pathname); setSeenPath(pathname);
@@ -56,55 +66,21 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
)} )}
<aside <aside
data-collapsed={collapsed ? "true" : "false"} data-collapsed={collapsed ? "true" : "false"}
className={`group/rail z-50 flex h-dvh w-rail shrink-0 flex-col bg-background pt-2 pb-6 transition-[transform,width] duration-(--duration-normal) ease-app max-md:fixed max-md:shadow-edge ${ className={`group/rail z-50 flex h-dvh shrink-0 bg-background transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:shadow-edge ${
open ? "max-md:translate-x-0" : "max-md:-translate-x-full" open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
} ${collapsed ? "md:w-20" : ""}`} }`}
> >
{/* The logo lives in its own 80px header row (measured): a 48px <StructureRail
black squircle brand tile with our coral mark. */} user={user}
<header className="flex h-20 items-center pl-4 md:group-data-[collapsed=true]/rail:justify-center md:group-data-[collapsed=true]/rail:pl-0"> orgs={orgs}
<Link href="/" aria-label="clawmates" className="inline-flex"> companies={companies}
{/* eslint-disable-next-line @next/next/no-img-element */} teams={teams}
<img creditsBalance={creditsBalance}
src="/images/clawmates-mark.png" onClose={() => setOpen(false)}
alt=""
className="h-11 w-auto"
/> />
<span className="sr-only">clawmates</span> {/* The context column hides in full-panel mode. */}
</Link> <div className="md:group-data-[collapsed=true]/rail:hidden">
<button <RosterColumn roster={roster} />
type="button"
aria-label="Close navigation"
onClick={() => setOpen(false)}
className="ml-auto hidden pr-3 text-muted-foreground max-md:inline-flex"
>
<X aria-hidden size={16} />
</button>
</header>
{/* Vertically centers the claw stack in the rail (safe = no clipping
when it overflows), scrollbar hidden — matches WorkClaw. */}
<div className="flex w-full flex-1 flex-col items-center [justify-content:safe_center] overflow-y-auto overscroll-contain py-4 [&::-webkit-scrollbar]:hidden">
{roster.length === 0 ? (
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
) : (
<RosterList roster={roster} />
)}
<Link
href="/claws/new"
aria-label="New claw"
className="group mt-0.5 flex w-full items-center px-4 py-1 md:group-data-[collapsed=true]/rail:justify-center md:group-data-[collapsed=true]/rail:px-0"
>
<span
aria-hidden
className="inline-flex size-11 shrink-0 items-center justify-center rounded-[15px] border border-dashed border-border text-coral transition-colors duration-(--duration-normal) ease-app group-hover:border-coral"
>
<Plus size={18} />
</span>
</Link>
</div>
<div className="flex flex-col gap-3 px-1 pt-3">
<ShellNav creditsBalance={creditsBalance} />
<UserMenu user={user} />
</div> </div>
</aside> </aside>
</> </>
@@ -0,0 +1,106 @@
"use client";
// The 176px context column of the two-tier rail. It mirrors the structure
// selection: on a group route (/orgs|/companies|/teams/{id}) it lists that
// group's children (the tier below); otherwise it shows the global claw roster.
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { MeshMark } from "@/components/brand/MeshMark";
import type { Agent } from "@/lib/api/schemas";
import type { StructureNode } from "@/lib/api/structure";
import { RosterList } from "./RosterList";
const GROUP_RE = /^\/(orgs|companies|teams)\/([^/]+)/;
const LEVEL: Record<string, "org" | "company" | "team"> = {
orgs: "org",
companies: "company",
teams: "team",
};
const CHILD_BASE: Record<string, string> = {
company: "/companies",
team: "/teams",
claw: "/claws",
};
export function RosterColumn({ roster }: { roster: Agent[] }) {
const pathname = usePathname();
const m = pathname.match(GROUP_RE);
const level = m ? LEVEL[m[1]] : null;
const id = m ? m[2] : null;
const [node, setNode] = useState<StructureNode | null>(null);
// Clear stale children when the selected group changes (render-phase pattern).
const key = `${level ?? ""}:${id ?? ""}`;
const [seenKey, setSeenKey] = useState(key);
if (seenKey !== key) {
setSeenKey(key);
setNode(null);
}
useEffect(() => {
if (!level || !id) return;
let live = true;
void (async () => {
try {
const r = await fetch(`/api/structure/${level}/${id}`);
if (r.ok && live) setNode((await r.json()) as StructureNode);
} catch {
/* ignore */
}
})();
return () => {
live = false;
};
}, [level, id]);
const showGroup = level && id;
return (
<div className="flex h-full w-rail flex-col border-l border-divider-subtle pt-2 pb-6">
<header className="flex h-20 items-center px-4">
<span className="truncate text-sm font-semibold text-foreground">
{showGroup ? (node?.name ?? "…") : "Claws"}
</span>
</header>
<div className="flex w-full flex-1 flex-col overflow-y-auto overscroll-contain px-1 py-2 [&::-webkit-scrollbar]:hidden">
{showGroup ? (
node ? (
node.children.length > 0 ? (
<ul className="flex w-full flex-col gap-0.5">
{node.children.map((c) => (
<li key={c.node_id}>
<Link
href={`${CHILD_BASE[c.child_level]}/${c.child_id}`}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-muted-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg hover:text-foreground"
>
<span className="text-coral">
<MeshMark
size={18}
variant={c.child_level === "claw" ? "team" : c.child_level}
title=""
/>
</span>
<span className="min-w-0 flex-1 truncate">{c.child_name || c.role}</span>
</Link>
</li>
))}
</ul>
) : (
<p className="px-3 text-xs text-muted-foreground">Empty</p>
)
) : (
<p className="px-3 text-xs text-muted-foreground">Loading…</p>
)
) : roster.length === 0 ? (
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
) : (
<RosterList roster={roster} />
)}
</div>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
// The cross-cutting tools (Skills, Apps, Topologies, Approvals, Team, Credits)
// moved out of the primary rail when it became structural. They live behind a
// single tile in the structure rail that opens this popover.
import { usePathname } from "next/navigation";
import { useState } from "react";
import {
Blocks,
CreditCard,
LayoutGrid,
Share2,
ShieldCheck,
Users,
Zap,
} from "lucide-react";
import { GlobalNavItem } from "./GlobalNavItem";
const NAV_ITEMS = [
{ href: "/skills", label: "Skills", icon: Zap },
{ href: "/apps", label: "Apps", icon: Blocks },
{ href: "/topologies", label: "Topologies", icon: Share2 },
{ href: "/approvals", label: "Approvals", icon: ShieldCheck },
{ href: "/team", label: "Team", icon: Users },
{ href: "/credits", label: "Credits", icon: CreditCard },
];
export function SecondaryNav({ creditsBalance }: { creditsBalance?: number }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<div className="relative flex justify-center">
<button
type="button"
aria-label="More"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex size-11 items-center justify-center rounded-[15px] text-muted-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg hover:text-foreground"
>
<LayoutGrid aria-hidden size={18} />
</button>
{open ? (
<>
<button
type="button"
aria-label="Close menu"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40"
/>
<nav
aria-label="Tools"
className="absolute bottom-0 left-full z-50 ml-2 flex w-44 flex-col gap-0.5 rounded-2xl bg-[#1a1a1a] p-2 shadow-popover ring-1 ring-white/[0.06]"
>
{NAV_ITEMS.map((item) => (
<GlobalNavItem
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
active={pathname === item.href || pathname.startsWith(`${item.href}/`)}
trailing={
item.href === "/credits" && creditsBalance !== undefined ? (
<span className={creditsBalance < 0 ? "text-coral" : "text-muted-foreground"}>
{creditsBalance.toLocaleString("en-US")}
</span>
) : undefined
}
/>
))}
</nav>
</>
) : null}
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
// The thin (68px) structure column of the two-tier rail — Discord-style. The
// mesh mark sits on top; below it the deploy hierarchy as small node-cluster
// glyphs (Orgs, then Companies, then Teams), each tier separated by a divider.
// Selecting a tile navigates to its zoom canvas (and drives the context column).
// The bottom holds the tools popover, a Deploy tile, and the user avatar.
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Plus, X } from "lucide-react";
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
import type { GroupSummary } from "@/lib/api/structure";
import type { User } from "@/lib/api/schemas";
import { SecondaryNav } from "./SecondaryNav";
import { UserMenu } from "./UserMenu";
interface StructureRailProps {
user: User;
orgs: GroupSummary[];
companies: GroupSummary[];
teams: GroupSummary[];
creditsBalance?: number;
onClose: () => void;
}
function StructureTile({
href,
name,
variant,
active,
}: {
href: string;
name: string;
variant: MeshVariant;
active: boolean;
}) {
return (
<Link href={href} title={name} aria-label={name} className="flex justify-center">
<span
className={`flex size-11 items-center justify-center rounded-[15px] border transition-colors duration-(--duration-normal) ease-app ${
active
? "border-coral text-coral ring-2 ring-coral"
: "border-border text-muted-foreground hover:border-coral hover:text-coral"
}`}
>
<MeshMark size={22} variant={variant} title="" />
</span>
</Link>
);
}
export function StructureRail({
user,
orgs,
companies,
teams,
creditsBalance,
onClose,
}: StructureRailProps) {
const pathname = usePathname();
const tiers: { items: GroupSummary[]; base: string; variant: MeshVariant }[] = [
{ items: orgs, base: "/orgs", variant: "org" },
{ items: companies, base: "/companies", variant: "company" },
{ items: teams, base: "/teams", variant: "team" },
];
return (
<div className="flex h-full w-[68px] shrink-0 flex-col items-center pt-2 pb-6">
<header className="flex h-20 w-full items-center justify-center">
<Link href="/" aria-label="clawmates" className="inline-flex text-coral">
<MeshMark size={36} title="" />
<span className="sr-only">clawmates</span>
</Link>
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
className="absolute top-6 right-3 hidden text-muted-foreground max-md:inline-flex"
>
<X aria-hidden size={16} />
</button>
</header>
<div className="flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto py-2 [&::-webkit-scrollbar]:hidden">
{tiers.map((tier) =>
tier.items.length > 0 ? (
<div key={tier.base} className="flex w-full flex-col items-center gap-1">
{tier.items.map((g) => (
<StructureTile
key={g.id}
href={`${tier.base}/${g.id}`}
name={g.name}
variant={tier.variant}
active={pathname.startsWith(`${tier.base}/${g.id}`)}
/>
))}
<span className="my-1 h-px w-8 bg-divider-subtle" />
</div>
) : null,
)}
</div>
<div className="flex w-full flex-col items-center gap-2">
<SecondaryNav creditsBalance={creditsBalance} />
<Link
href="/claws/new"
aria-label="Deploy"
title="Deploy"
className="group flex justify-center"
>
<span className="inline-flex size-11 items-center justify-center rounded-[15px] border border-dashed border-border text-coral transition-colors duration-(--duration-normal) ease-app group-hover:border-coral">
<Plus size={18} />
</span>
</Link>
<UserMenu user={user} />
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export interface Crumb {
label: string;
href?: string;
}
/** A compact chevron-separated trail. The last crumb is the current level. */
export function Breadcrumb({ items }: { items: Crumb[] }) {
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-xs">
{items.map((c, i) => {
const last = i === items.length - 1;
return (
<span key={i} className="flex items-center gap-1">
{c.href && !last ? (
<Link
href={c.href}
className="text-muted-foreground transition-colors duration-(--duration-normal) ease-app hover:text-coral"
>
{c.label}
</Link>
) : (
<span className={last ? "font-medium text-foreground" : "text-muted-foreground"}>
{c.label}
</span>
)}
{!last ? (
<ChevronRight aria-hidden size={12} className="text-subtle-foreground" />
) : null}
</span>
);
})}
</nav>
);
}
@@ -0,0 +1,162 @@
"use client";
// The recursive zoom canvas: ONE view for every tier (org → company → team →
// claw). It fetches the level's topology, renders it with clickable nodes that
// drill DOWN a tier (a company node → that company's teams; a team node → its
// claws; a claw node → the claw's chat), shows a breadcrumb to zoom UP, and a
// per-level action panel. Selecting in the rail and drilling in the canvas
// converge on the same routes, so the level is fully URL-derivable.
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
import { TopologyGraphView, type NodeMeta } from "@/components/topology/TopologyGraphView";
import { TeamRunPanel } from "@/components/team/TeamRunPanel";
import { Breadcrumb, type Crumb } from "./Breadcrumb";
import type { StructureChild, StructureLevel, StructureNode } from "@/lib/api/structure";
const TIER_LABEL: Record<StructureLevel, string> = {
org: "Org",
company: "Company",
team: "Team",
claw: "Claw",
};
// Where a child node drills to + the run scope for the action panel.
const CHILD_ROUTE: Record<StructureChild["child_level"], (id: string) => string> = {
company: (id) => `/companies/${id}`,
team: (id) => `/teams/${id}`,
claw: (id) => `/claws/${id}`,
};
const TIER_LIST: Record<StructureLevel, string | undefined> = {
org: "/orgs",
company: "/companies",
team: "/teams",
claw: undefined,
};
const RUN_SCOPE: Partial<Record<StructureLevel, "teams" | "companies" | "orgs">> = {
team: "teams",
company: "companies",
org: "orgs",
};
export function StructureCanvas({ level, id }: { level: StructureLevel; id: string }) {
const router = useRouter();
const [node, setNode] = useState<StructureNode | null>(null);
const [error, setError] = useState<string | null>(null);
// Reset to the loading state when the target changes (render-phase pattern —
// avoids a synchronous setState inside the effect).
const key = `${level}:${id}`;
const [seenKey, setSeenKey] = useState(key);
if (seenKey !== key) {
setSeenKey(key);
setNode(null);
setError(null);
}
useEffect(() => {
let live = true;
void (async () => {
try {
const r = await fetch(`/api/structure/${level}/${id}`);
if (!r.ok) throw new Error(`Failed to load (${r.status})`);
if (live) setNode((await r.json()) as StructureNode);
} catch (e) {
if (live) setError(e instanceof Error ? e.message : "Failed to load");
}
})();
return () => {
live = false;
};
}, [level, id]);
if (error) {
return <div className="p-8 text-sm text-red-500">{error}</div>;
}
if (!node) {
return <div className="p-8 text-sm text-muted-foreground">Loading…</div>;
}
const childByNode = new Map(node.children.map((c) => [c.node_id, c]));
const nodeMeta: Record<string, NodeMeta> = {};
for (const c of node.children) {
nodeMeta[c.node_id] = { label: c.child_name || c.role, drillable: true };
}
const crumbs: Crumb[] = [
{ label: `${TIER_LABEL[level]}s`, href: TIER_LIST[level] },
{ label: node.name },
];
const runScope = RUN_SCOPE[level];
const childLabel = node.children[0]?.child_level
? `${node.children[0].child_level}s`
: "members";
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
<div className="flex flex-col gap-2">
<Breadcrumb items={crumbs} />
<div className="flex items-center gap-3">
<span className="text-coral">
<MeshMark size={28} variant={level as MeshVariant} title="" />
</span>
<div>
<h1 className="text-xl font-semibold tracking-tight">{node.name}</h1>
<p className="text-xs text-muted-foreground">
{node.kind ? <span className="capitalize">{node.kind}</span> : null}
{node.kind ? " · " : ""}
{node.children.length} {childLabel}
</p>
</div>
</div>
</div>
{node.graph ? (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<TopologyGraphView
graph={node.graph}
nodeMeta={nodeMeta}
onNodeClick={(n) => {
const child = childByNode.get(n.id);
if (child) router.push(CHILD_ROUTE[child.child_level](child.child_id));
}}
/>
</div>
) : null}
{node.children.length > 0 ? (
<div>
<p className="mb-2 text-xs font-medium capitalize text-muted-foreground">{childLabel}</p>
<ul className="flex flex-wrap gap-2">
{node.children.map((c) => (
<li key={c.node_id}>
<Link
href={CHILD_ROUTE[c.child_level](c.child_id)}
className="flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-muted/30"
>
<span className="font-medium">{c.child_name || c.role}</span>
<span className="capitalize text-muted-foreground">{c.role}</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
{runScope ? (
<TeamRunPanel
id={id}
scope={runScope}
label={
level === "team"
? "Run this team"
: `Run this ${level} (cascades through its ${childLabel})`
}
/>
) : null}
</div>
);
}
@@ -0,0 +1,133 @@
"use client";
// Run a deployed team on the durable runner with live SSE progress. Extracted
// from TeamView so the recursive zoom canvas (team level) and any team page can
// share one implementation.
import { useEffect, useRef, useState } from "react";
interface StepRecord {
node_id: string;
role: string;
phase: string;
output: string;
}
interface TeamRunPanelProps {
/** The group id to run. */
id: string;
/** Run endpoint base — "teams" | "companies" | "orgs". */
scope?: "teams" | "companies" | "orgs";
label?: string;
}
export function TeamRunPanel({ id, scope = "teams", label = "Run this team" }: TeamRunPanelProps) {
const [task, setTask] = useState("Draft a one-paragraph plan for a product launch.");
const [steps, setSteps] = useState<StepRecord[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [finalOutput, setFinalOutput] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const esRef = useRef<EventSource | null>(null);
useEffect(() => () => esRef.current?.close(), []);
async function run() {
setBusy(true);
setError(null);
setSteps([]);
setFinalOutput(null);
setStatus("queued");
esRef.current?.close();
try {
const res = await fetch(`/api/${scope}/${id}/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task }),
});
if (res.status !== 202) throw new Error(`Run failed (${res.status})`);
const { run_id } = (await res.json()) as { run_id: string };
setStatus("running");
const es = new EventSource(`/api/topology-runs/${run_id}/events`);
esRef.current = es;
es.addEventListener("step", (e) => {
try {
setSteps((s) => [...s, JSON.parse((e as MessageEvent).data) as StepRecord]);
} catch {
/* ignore */
}
});
es.addEventListener("done", (e) => {
try {
const d = JSON.parse((e as MessageEvent).data) as {
status: string;
error: string | null;
final_output: string | null;
};
setStatus(d.status);
if (d.final_output) setFinalOutput(d.final_output);
if (d.error) setError(d.error);
} catch {
/* ignore */
}
es.close();
esRef.current = null;
setBusy(false);
});
es.onerror = () => {
es.close();
esRef.current = null;
setBusy(false);
};
} catch (e) {
setError(e instanceof Error ? e.message : "Run failed");
setBusy(false);
}
}
return (
<div className="flex flex-col gap-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{label}</p>
<textarea
value={task}
onChange={(e) => setTask(e.target.value)}
rows={2}
className="w-full rounded-lg border border-input bg-subtle px-3 py-2 text-sm text-foreground"
/>
<div>
<button
type="button"
onClick={run}
disabled={busy}
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? "Running…" : "Run"}
</button>
{status ? <span className="ml-3 text-xs text-muted-foreground">{status}</span> : null}
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
{steps.length > 0 ? (
<ol className="flex flex-col gap-2">
{steps.map((s, i) => (
<li key={`${s.node_id}-${i}`} className="rounded-md border border-border/60 p-3">
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium capitalize text-foreground">{s.role}</span>
<span>· {s.phase}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-foreground">{s.output}</p>
</li>
))}
</ol>
) : null}
{finalOutput ? (
<div className="rounded-md bg-muted/40 p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">Final output</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{finalOutput}</p>
</div>
) : null}
</div>
);
}
@@ -1,4 +1,4 @@
import type { TopologyGraph } from "@/lib/api/topology"; import type { TopologyGraph, TopologyNode } from "@/lib/api/topology";
const ROW_KINDS = new Set(["pipeline", "ring"]); const ROW_KINDS = new Set(["pipeline", "ring"]);
const STAR_KINDS = new Set(["hierarchical", "hub_spoke", "star_moe", "market"]); const STAR_KINDS = new Set(["hierarchical", "hub_spoke", "star_moe", "market"]);
@@ -6,6 +6,13 @@ const STAR_KINDS = new Set(["hierarchical", "hub_spoke", "star_moe", "market"]);
const W = 640; const W = 640;
const H = 340; const H = 340;
/** Per-node display hints for the recursive zoom canvas: a human label and
* whether clicking it drills into a child level. */
export interface NodeMeta {
label?: string;
drillable?: boolean;
}
/** Position nodes by topology kind: a row for pipeline/ring, a center+row star /** Position nodes by topology kind: a row for pipeline/ring, a center+row star
* for delegation kinds, a circle otherwise. */ * for delegation kinds, a circle otherwise. */
function layout(kind: string, n: number): { x: number; y: number }[] { function layout(kind: string, n: number): { x: number; y: number }[] {
@@ -33,15 +40,29 @@ function layout(kind: string, n: number): { x: number; y: number }[] {
}); });
} }
/** Renders a topology graph as a lightweight SVG (no external deps). */ interface TopologyGraphViewProps {
export function TopologyGraphView({ graph }: { graph: TopologyGraph }) { graph: TopologyGraph;
/** When set, nodes become interactive (drill down a level). */
onNodeClick?: (node: TopologyNode) => void;
/** Optional per-node label / drillability overrides (keyed by node id). */
nodeMeta?: Record<string, NodeMeta>;
}
/** Renders a topology graph as a lightweight SVG (no external deps), themed for
* the dark app shell. With `onNodeClick`, nodes are keyboard-accessible drill
* targets (the recursive zoom canvas uses this to descend org→company→team→claw). */
export function TopologyGraphView({
graph,
onNodeClick,
nodeMeta,
}: TopologyGraphViewProps) {
const index = new Map(graph.nodes.map((node, i) => [node.id, i])); const index = new Map(graph.nodes.map((node, i) => [node.id, i]));
const pos = layout(graph.kind, graph.nodes.length); const pos = layout(graph.kind, graph.nodes.length);
return ( return (
<svg <svg
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
className="h-auto w-full" className="h-auto w-full text-muted-foreground"
role="img" role="img"
aria-label={`${graph.kind} topology with ${graph.nodes.length} nodes`} aria-label={`${graph.kind} topology with ${graph.nodes.length} nodes`}
> >
@@ -56,20 +77,65 @@ export function TopologyGraphView({ graph }: { graph: TopologyGraph }) {
y1={a.y} y1={a.y}
x2={b.x} x2={b.x}
y2={b.y} y2={b.y}
stroke="#64748b" stroke="currentColor"
strokeOpacity={0.35}
strokeWidth={1.5} strokeWidth={1.5}
/> />
); );
})} })}
{graph.nodes.map((node, i) => { {graph.nodes.map((node, i) => {
const p = pos[i]; const p = pos[i];
const meta = nodeMeta?.[node.id];
const drillable = !!onNodeClick && meta?.drillable !== false;
const label = meta?.label ?? node.id;
return ( return (
<g key={node.id}> <g
<circle cx={p.x} cy={p.y} r={24} fill="#e2e8f0" stroke="#94a3b8" /> key={node.id}
<text x={p.x} y={p.y + 4} textAnchor="middle" fontSize={11} fontWeight={600} fill="#0b1220"> className={
{node.id} drillable
? "cursor-pointer outline-none [&:hover_circle]:stroke-coral [&:focus_circle]:stroke-coral"
: undefined
}
role={drillable ? "button" : undefined}
tabIndex={drillable ? 0 : undefined}
aria-label={drillable ? `Open ${label}` : undefined}
onClick={drillable ? () => onNodeClick?.(node) : undefined}
onKeyDown={
drillable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNodeClick?.(node);
}
}
: undefined
}
>
<circle
cx={p.x}
cy={p.y}
r={24}
fill="var(--color-surface-warm-muted)"
stroke="var(--color-border)"
strokeWidth={1.5}
/>
<text
x={p.x}
y={p.y + 4}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fill="var(--color-foreground)"
>
{label.length > 8 ? `${label.slice(0, 7)}…` : label}
</text> </text>
<text x={p.x} y={p.y + 42} textAnchor="middle" fontSize={11} fill="#94a3b8"> <text
x={p.x}
y={p.y + 42}
textAnchor="middle"
fontSize={11}
fill="currentColor"
>
{node.role} {node.role}
</text> </text>
</g> </g>
@@ -0,0 +1,202 @@
"use client";
// Deploy a COMPANY (a topology of teams) or an ORG (a topology of companies):
// pick a baseline topology, select existing children to staff its nodes, give
// each a role, and create. Unlike TeamWizard this provisions nothing new — it
// composes groups that already own their agents one tier down.
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
interface CatalogEntry {
kind: string;
name: string;
description: string;
}
interface GroupSummary {
id: string;
name: string;
kind: string;
}
interface Selection {
childId: string;
role: string;
}
interface ComposeWizardProps {
/** "company" composes teams; "org" composes companies. */
tier: "company" | "org";
}
const TIER_CONF = {
company: {
title: "Company",
childLabel: "teams",
childEndpoint: "/api/teams",
createEndpoint: "/api/companies",
childKey: "team_id" as const,
idKey: "company_id" as const,
detailBase: "/companies",
defaultRole: "team",
},
org: {
title: "Organization",
childLabel: "companies",
childEndpoint: "/api/companies",
createEndpoint: "/api/orgs",
childKey: "company_id" as const,
idKey: "org_id" as const,
detailBase: "/orgs",
defaultRole: "company",
},
};
export function ComposeWizard({ tier }: ComposeWizardProps) {
const router = useRouter();
const conf = TIER_CONF[tier];
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
const [children, setChildren] = useState<GroupSummary[]>([]);
const [kind, setKind] = useState("hierarchical");
const [name, setName] = useState(`New ${conf.title.toLowerCase()}`);
const [selected, setSelected] = useState<Selection[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [c, ch] = await Promise.all([
fetch("/api/topologies").then((r) => (r.ok ? r.json() : [])),
fetch(conf.childEndpoint).then((r) => (r.ok ? r.json() : [])),
]);
setCatalog(c as CatalogEntry[]);
setChildren(ch as GroupSummary[]);
} catch {
/* ignore */
}
})();
}, [conf.childEndpoint]);
const selectedIds = useMemo(() => new Set(selected.map((s) => s.childId)), [selected]);
function toggle(childId: string) {
setSelected((s) =>
selectedIds.has(childId)
? s.filter((x) => x.childId !== childId)
: [...s, { childId, role: conf.defaultRole }],
);
}
function setRole(childId: string, role: string) {
setSelected((s) => s.map((x) => (x.childId === childId ? { ...x, role } : x)));
}
async function create() {
setBusy(true);
setError(null);
try {
const members = selected.map((s) => ({ [conf.childKey]: s.childId, role: s.role }));
const res = await fetch(conf.createEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, kind, members }),
});
if (res.status !== 201) throw new Error(`Create failed (${res.status})`);
const data = (await res.json()) as Record<string, string>;
const id = data[conf.idKey];
router.push(`${conf.detailBase}/${id}`);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed");
setBusy(false);
}
}
return (
<div className="flex w-full max-w-2xl flex-col gap-4">
<label className="text-xs text-muted-foreground">
{conf.title} name
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground"
/>
</label>
<div>
<p className="mb-2 text-sm font-medium">Baseline topology</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{catalog.map((c) => (
<button
key={c.kind}
type="button"
onClick={() => setKind(c.kind)}
className={`rounded-lg border p-3 text-left transition-colors ${
kind === c.kind ? "border-coral bg-surface-warm" : "border-border hover:bg-muted/30"
}`}
>
<p className="text-sm font-medium capitalize text-foreground">{c.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-sm font-medium capitalize">
Select {conf.childLabel} ({selected.length})
</p>
{children.length === 0 ? (
<p className="text-sm text-muted-foreground">
No {conf.childLabel} yet — deploy some first.
</p>
) : (
<ul className="flex flex-col gap-2">
{children.map((c) => {
const sel = selected.find((s) => s.childId === c.id);
return (
<li
key={c.id}
className={`flex items-center gap-2 rounded-lg border p-2 ${
sel ? "border-coral" : "border-border"
}`}
>
<input
type="checkbox"
checked={!!sel}
onChange={() => toggle(c.id)}
className="accent-coral"
/>
<span className="text-sm font-medium text-foreground">{c.name}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xxs capitalize text-muted-foreground">
{c.kind}
</span>
{sel ? (
<input
value={sel.role}
onChange={(e) => setRole(c.id, e.target.value)}
placeholder="role"
className="ml-auto w-32 rounded-md border border-input bg-subtle px-2 py-1 text-xs text-muted-foreground"
/>
) : null}
</li>
);
})}
</ul>
)}
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div>
<button
type="button"
onClick={create}
disabled={busy || selected.length === 0}
className="rounded-full bg-coral px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? "Deploying…" : `Deploy ${conf.title.toLowerCase()}`}
</button>
</div>
</div>
);
}
@@ -6,16 +6,17 @@
import { useState } from "react"; import { useState } from "react";
import { ComposeWizard } from "./ComposeWizard";
import { CreateClawForm } from "./CreateClawForm"; import { CreateClawForm } from "./CreateClawForm";
import { TeamWizard } from "./TeamWizard"; import { TeamWizard } from "./TeamWizard";
type Scope = "choose" | "single" | "team"; type Scope = "choose" | "single" | "team" | "company" | "org";
const TIERS: { key: Scope; title: string; blurb: string; soon?: boolean }[] = [ const TIERS: { key: Scope; title: string; blurb: string; soon?: boolean }[] = [
{ key: "single", title: "Single claw", blurb: "One agent, crafted by you — maximum control & fidelity." }, { key: "single", title: "Single claw", blurb: "One agent, crafted by you — maximum control & fidelity." },
{ key: "team", title: "Team", blurb: "A baseline topology staffed with templated claws." }, { key: "team", title: "Team", blurb: "A baseline topology staffed with templated claws." },
{ key: "company" as Scope, title: "Company", blurb: "A topology of teams.", soon: true }, { key: "company", title: "Company", blurb: "A topology of teams." },
{ key: "org" as Scope, title: "Organization", blurb: "Multiple company templates.", soon: true }, { key: "org", title: "Organization", blurb: "A topology of companies." },
]; ];
export function DeployWizard() { export function DeployWizard() {
@@ -23,6 +24,8 @@ export function DeployWizard() {
if (scope === "single") return <CreateClawForm />; if (scope === "single") return <CreateClawForm />;
if (scope === "team") return <TeamWizard />; if (scope === "team") return <TeamWizard />;
if (scope === "company") return <ComposeWizard tier="company" />;
if (scope === "org") return <ComposeWizard tier="org" />;
return ( return (
<div className="grid w-full max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid w-full max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
import { apiFetch } from "./http";
import { TopologyGraphSchema } from "./topology";
// The deploy hierarchy: org ▸ company ▸ team ▸ claw. Each tier (above a claw) is
// a topology whose nodes are the tier below. These helpers feed the structure
// rail (server-side) and the recursive zoom canvas (client-side, via the
// same-origin /api proxy).
export const GroupSummarySchema = z.object({
id: z.string(),
name: z.string(),
kind: z.string(),
status: z.string(),
created_at: z.string(),
});
export type GroupSummary = z.infer<typeof GroupSummarySchema>;
/** A structure level: its graph + the children to drill into (one tier down). */
export const StructureChildSchema = z.object({
node_id: z.string(),
role: z.string(),
child_level: z.enum(["company", "team", "claw"]),
child_id: z.string(),
child_name: z.string(),
});
export const StructureNodeSchema = z.object({
level: z.enum(["org", "company", "team", "claw"]),
id: z.string(),
name: z.string(),
kind: z.string().nullable(),
graph: TopologyGraphSchema.nullable(),
children: z.array(StructureChildSchema),
});
export type StructureLevel = z.infer<typeof StructureNodeSchema>["level"];
export type StructureChild = z.infer<typeof StructureChildSchema>;
export type StructureNode = z.infer<typeof StructureNodeSchema>;
// --- server-side (rail, via apiFetch) ---
export function fetchOrgs(): Promise<GroupSummary[]> {
return apiFetch(z.array(GroupSummarySchema), "/api/orgs");
}
export function fetchCompanies(): Promise<GroupSummary[]> {
return apiFetch(z.array(GroupSummarySchema), "/api/companies");
}
export function fetchTeams(): Promise<GroupSummary[]> {
return apiFetch(z.array(GroupSummarySchema), "/api/teams");
}
+1
View File
@@ -20,6 +20,7 @@ export const TopologyNodeSchema = z.object({
role: z.string(), role: z.string(),
level: z.number().optional(), level: z.number().optional(),
}); });
export type TopologyNode = z.infer<typeof TopologyNodeSchema>;
export const TopologyEdgeSchema = z.object({ export const TopologyEdgeSchema = z.object({
from: z.string(), from: z.string(),
to: z.string(), to: z.string(),
+56
View File
@@ -0,0 +1,56 @@
-- Companies and Orgs: the upper rungs of the deploy ladder (single → team →
-- company → org). The model is recursive — *every tier is a topology whose
-- nodes are the tier below*. A team binds nodes to claws (migration 0010); a
-- company binds nodes to teams; an org binds nodes to companies. Children are
-- resolved from these tables at execution time (cm-topology stays pure — the
-- parent graph never embeds a child graph, only a string id in node.attrs).
-- A company = a baseline TOPOLOGY staffed with real teams. `graph` is the
-- TopologyGraph whose nodes carry attrs["team_id"] = the bound team's uuid
-- (and attrs["agent"] = the same id, so the recursive executor receives it via
-- TurnRequest the same way a team node forwards its claw alias).
CREATE TABLE companies (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL, -- TopologyKind (snake_case)
graph JSONB NOT NULL, -- TopologyGraph (nodes/edges)
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX companies_workspace_idx ON companies (workspace_id, created_at DESC);
CREATE TABLE company_teams (
company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE,
node_id TEXT NOT NULL, -- topology node id in companies.graph
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (company_id, node_id)
);
-- An org = a TOPOLOGY whose nodes are companies. Nodes carry
-- attrs["company_id"] = the bound company's uuid.
CREATE TABLE orgs (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL,
graph JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX orgs_workspace_idx ON orgs (workspace_id, created_at DESC);
CREATE TABLE org_companies (
org_id UUID NOT NULL REFERENCES orgs (id) ON DELETE CASCADE,
node_id TEXT NOT NULL,
company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (org_id, node_id)
);
-- Which deploy tier a durable run belongs to. 'team' drives claws directly
-- (today's path); 'company'/'org' drive the recursive sub-topology executor.
ALTER TABLE topology_runs ADD COLUMN tier TEXT NOT NULL DEFAULT 'team';