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
+53
@@ -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"
|
||||||
|
}
|
||||||
+14
@@ -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"
|
||||||
|
}
|
||||||
+53
@@ -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"
|
||||||
|
}
|
||||||
+17
@@ -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"
|
||||||
|
}
|
||||||
-34
@@ -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"
|
|
||||||
}
|
|
||||||
+17
@@ -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"
|
||||||
|
}
|
||||||
+4
-3
@@ -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"
|
||||||
}
|
}
|
||||||
+34
@@ -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"
|
||||||
|
}
|
||||||
+8
-2
@@ -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"
|
||||||
}
|
}
|
||||||
+18
@@ -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"
|
||||||
|
}
|
||||||
+18
@@ -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"
|
||||||
|
}
|
||||||
+47
@@ -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"
|
||||||
|
}
|
||||||
+47
@@ -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"
|
||||||
|
}
|
||||||
+34
@@ -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"
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}")))
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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![],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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.
|
/// 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,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).
|
||||||
|
|||||||
@@ -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()).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user