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:
co-authored by
Claude Opus 4.8
parent
bba18a4687
commit
3eca4ed70c
@@ -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())
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod companies;
|
||||
pub mod connections;
|
||||
pub mod credits;
|
||||
pub mod files;
|
||||
pub mod messages;
|
||||
pub mod orgs;
|
||||
pub mod outbox;
|
||||
pub mod routines;
|
||||
pub mod run_events;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -107,11 +107,7 @@ pub async fn list_for_workspace(
|
||||
}
|
||||
|
||||
/// A single team, workspace-scoped.
|
||||
pub async fn get_team(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> Result<Team, DbError> {
|
||||
pub async fn get_team(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Team, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, name, kind, graph, status, created_at FROM teams
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct ClaimedTopologyRun {
|
||||
pub checkpoint: Option<Value>,
|
||||
/// Event-journal offset reached so far.
|
||||
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).
|
||||
@@ -79,20 +82,36 @@ pub async fn insert(
|
||||
|
||||
/// Enqueue a durable single-topology run job (`kind = 'run'`, `status = 'queued'`).
|
||||
/// The background worker claims and executes it; the result lands in `comparison`.
|
||||
/// Tier defaults to `team` (drives claws directly).
|
||||
pub async fn enqueue_run(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
task: &str,
|
||||
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> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO topology_runs (id, workspace_id, task, kind, status, graph)
|
||||
VALUES ($1, $2, $3, 'run', 'queued', $4)",
|
||||
"INSERT INTO topology_runs (id, workspace_id, task, kind, status, graph, tier)
|
||||
VALUES ($1, $2, $3, 'run', 'queued', $4, $5)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
task,
|
||||
graph,
|
||||
tier,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -113,7 +132,7 @@ pub async fn claim_next_queued(pool: &PgPool) -> Result<Option<ClaimedTopologyRu
|
||||
FOR UPDATE SKIP LOCKED
|
||||
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)
|
||||
.await?;
|
||||
@@ -124,6 +143,7 @@ pub async fn claim_next_queued(pool: &PgPool) -> Result<Option<ClaimedTopologyRu
|
||||
graph: r.graph,
|
||||
checkpoint: r.checkpoint,
|
||||
last_event_id: r.last_event_id,
|
||||
tier: r.tier,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -148,6 +168,21 @@ pub async fn checkpoint(
|
||||
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.
|
||||
pub async fn complete(pool: &PgPool, id: Uuid, result: &Value) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
|
||||
Reference in New Issue
Block a user