Compare commits
22
Commits
4663348a0e
...
d4efb0dba2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4efb0dba2 | ||
|
|
a2bc06d313 | ||
|
|
bf4af48c80 | ||
|
|
0d4cb2c2bc | ||
|
|
a5588b0289 | ||
|
|
2b3ec27757 | ||
|
|
47f986257f | ||
|
|
d6dbd044c8 | ||
|
|
1f0117e35a | ||
|
|
4c32799906 | ||
|
|
c0a4fbc943 | ||
|
|
214d0c5e9f | ||
|
|
854a617777 | ||
|
|
74ee990d7e | ||
|
|
5b7cb55d21 | ||
|
|
9bac84c645 | ||
|
|
ad1cee0b08 | ||
|
|
278cbf90b7 | ||
|
|
267b28a762 | ||
|
|
a3d5a5a96d | ||
|
|
fdb8cfeecc | ||
|
|
56201a6985 |
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT MAX(iteration) FROM topology_runs WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "max",
|
|
||||||
"type_info": "Int4"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "00106727aa8f52160c50750997e79463328846a119d27c67cba0efb5ffcfef2d"
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM loop_orgs WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "01cdcd2bbc04ebfefb5ba2a69bee88ddb732a88117f63841bfd17d07a4b969af"
|
|
||||||
}
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO topology_runs\n (id, workspace_id, task, kind, status, graph, tier,\n loop_id, iteration, parent_run_id)\n VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5, $6, $7)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Jsonb",
|
|
||||||
"Uuid",
|
|
||||||
"Int4",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "03c6baa216872a93409211f09f24a07d81a433d1a94e4f0a9fd2666cec997169"
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO research_topics\n (id, workspace_id, title, description, outcome_kind, topology_kind, repo_id, status, created_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, 'standby', $8)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "15df27d0a94a927c62f0d737a11e391f5a7ff139e95d88516994ff30f782784b"
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO loops\n (id, workspace_id, title, description, graph, task_template,\n triggers, repeat_policy, enabled, next_fire_at,\n webhook_token, webhook_signing_key, created_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Jsonb",
|
|
||||||
"Text",
|
|
||||||
"Jsonb",
|
|
||||||
"Jsonb",
|
|
||||||
"Bool",
|
|
||||||
"Timestamptz",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "18f21e18db478667a6edf42b3e38100574fe82c91df2a19b6d3747c09a7c574d"
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO loop_teams (loop_id, team_id) VALUES ($1, $2)\n ON CONFLICT (loop_id, team_id) DO NOTHING",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "19f7b8608588160f1d76dd7c01ba7ad628e1ee32961a5717ae22002a243a869c"
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE loops SET enabled = $3, updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Bool"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "1b2dc407fe913e1bfa516a7f6c88bd6a3805281dbeefbae485241a23e8d16ee2"
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO research_publish_approvals\n (id, workspace_id, topic_id, requested_by, status)\n VALUES ($1, $2, $3, $4, 'pending')",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "208b41dc1f8cb9f6ff46ce6a01d15c2ddbf604d6a4062f95b1eb99720c244ff2"
|
|
||||||
}
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, graph, task_template, triggers, repeat_policy,\n last_run_id\n FROM loops\n WHERE enabled AND next_fire_at IS NOT NULL AND next_fire_at <= now()",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "graph",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "task_template",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "triggers",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "repeat_policy",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "last_run_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": []
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "2165f2d82b83fc827f133c150c310ea7609eb26b2513e4438ffca30391d55321"
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE research_topics\n SET title = $3, description = $4, outcome_kind = $5, updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "2541fc9bef45124c48cdaa60c070e9ee7ade0d8c7859bbdd81068fb8e28e4a1c"
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE research_topics\n SET status = $3,\n updated_at = now(),\n published_at = CASE\n WHEN $3 = 'publishing' AND published_at IS NULL THEN now()\n ELSE published_at\n END\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "28aff26774a5f6a91adfc56252062b5a1cadb42d498276fc7018a0d0f0fe98d5"
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT agent_id, role_slot FROM loop_agents WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "agent_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "role_slot",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "309f684f9ec2dcfb5b178140a1ac0a4669c470bd1389805f458063cd66db9a89"
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM loops WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "3c3a6e0584505808f3b41d11133f0a15f11dbd44087b9de01096018f1619051a"
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM loop_agents WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "3f74f01c6411409c49ad66edba8a059ca8a72daae3570a6e99d59de7c3b6dc82"
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO loop_agents (loop_id, agent_id, role_slot)\n VALUES ($1, $2, $3)\n ON CONFLICT (loop_id, agent_id) DO UPDATE\n SET role_slot = EXCLUDED.role_slot",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "3fa15bc7add3b658ae2f9dbbd7b95780542899119a5f11f5fd250a1cf656fc93"
|
|
||||||
}
|
|
||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind,\n repo_id, repo_workspace_path,\n zeroclaw_container_name, zeroclaw_gateway_url\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "description",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "outcome_kind",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "updated_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "published_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "topology_kind",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "repo_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "repo_workspace_path",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "zeroclaw_container_name",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "zeroclaw_gateway_url",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "40584d4cb37a3e98c260b113c807a5f7e0e6775c922e60d4321b790a1bad8d27"
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE research_publish_approvals\n SET status = $4, decided_by = $3, decided_at = now()\n WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "40d90c412b44009003ebc692b143382bd157663ed5f2481f08d7abfcc5129b27"
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO research_topic_agents (topic_id, agent_id, role_slot)\n VALUES ($1, $2, $3)\n ON CONFLICT (topic_id, agent_id) DO UPDATE\n SET role_slot = EXCLUDED.role_slot",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "494d34241ef7c27c739ca5cc11114ada1a86bdcc30fe04ac5e7e58416d317679"
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM research_topic_agents WHERE topic_id = $1 AND agent_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "5f8550702d365534f8778f1109b8a0d504e3b724e4d38d247de6ac2cbdd916e3"
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM research_topics WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "60c65d67613db839401ebab66eb28710e292d7bec1debef316634ef735bc48ff"
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT org_id FROM loop_orgs WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "org_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "78647b9a8d7fbcf9789bc1873eb354a5379f6170758327eaa1f147564af90e98"
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE loops\n SET last_run_id = $2, next_fire_at = $3, updated_at = now()\n WHERE id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Timestamptz"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "7b22d878f560708e4e9723d71423e0c5800c1f714b5afc693aea49d352003f1f"
|
|
||||||
}
|
|
||||||
+2
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT id, task, status, kind, created_at, iteration, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
"query": "SELECT id, task, status, kind, created_at, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -30,11 +30,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "iteration",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "finished_at",
|
"name": "finished_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
@@ -51,9 +46,8 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "e7a8b969ddd7fa1e1cc72082e6c39c3295f60b30e274a02cb1d2e6c7aed3da8b"
|
"hash": "7bd5e9fc57fb61830edbf5e94d385bfeedc89bf0daae47c43fdc825caf58dc97"
|
||||||
}
|
}
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, title, description, graph, task_template,\n triggers, repeat_policy, enabled, next_fire_at, last_run_id,\n webhook_token, webhook_signing_key, created_by, created_at, updated_at\n FROM loops\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "description",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "graph",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "task_template",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "triggers",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "repeat_policy",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "enabled",
|
|
||||||
"type_info": "Bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "next_fire_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "last_run_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "webhook_token",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "webhook_signing_key",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "updated_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "911a088dac3b2464d817d76831731a65d0b3dbb9264f538fcde60f48808ab4bd"
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE research_topics\n SET zeroclaw_container_name = $3,\n zeroclaw_gateway_url = $4,\n updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "918d70440223ce0131ce14fca077f4ad1fb9de17c2685d8f3039a92cd2d38022"
|
|
||||||
}
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE topic_id = $1 AND status = 'pending'\n LIMIT 1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "topic_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "requested_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "decided_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "decided_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "989cc49438587f6d49c0e78b716b5001637c064c9128bd9cef639536c4450aeb"
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT count(*) AS n\n FROM topology_runs\n WHERE research_topic_id = $1\n AND status IN ('queued', 'running')",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "n",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "a014c185ae37744045471dfbb4d82f783f23177c4e53bc1348f3384be446aecd"
|
|
||||||
}
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, task, status, kind, created_at, iteration, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 AND loop_id = $2\n ORDER BY iteration DESC NULLS LAST, created_at DESC\n LIMIT $3",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "task",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "kind",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "iteration",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "finished_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Int8"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "a552e2bdbbcf567e1ce057acfe7e5395fd58dad9887dc0c26b87fb993ce2b769"
|
|
||||||
}
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, title, description, graph, task_template,\n triggers, repeat_policy, enabled, next_fire_at, last_run_id,\n webhook_token, webhook_signing_key, created_by, created_at, updated_at\n FROM loops\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "description",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "graph",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "task_template",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "triggers",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "repeat_policy",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "enabled",
|
|
||||||
"type_info": "Bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "next_fire_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "last_run_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "webhook_token",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "webhook_signing_key",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 15,
|
|
||||||
"name": "updated_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "a91674c6d98c029b2e5eb4ead0ab7b974ac839c7d8d44446ff1e864b070174f2"
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE loops\n SET title = $3, description = $4, graph = $5, task_template = $6,\n triggers = $7, repeat_policy = $8, next_fire_at = $9,\n updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Jsonb",
|
|
||||||
"Text",
|
|
||||||
"Jsonb",
|
|
||||||
"Jsonb",
|
|
||||||
"Timestamptz"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "ae226c3156f612252d07fd17aacff0a3c9de6ff6bb25d043914fa996b9b38270"
|
|
||||||
}
|
|
||||||
-55
@@ -1,55 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO research_outcomes (id, topic_id, version, body_md, produced_by_run_id)\n SELECT $1, $2, coalesce(max(version), 0) + 1, $3, $4\n FROM research_outcomes\n WHERE topic_id = $2\n RETURNING id, topic_id, version, body_md, produced_by_run_id, created_at",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "topic_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "body_md",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "produced_by_run_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "b95381b1c598da85edc3577318664576e40003d4113161b2012ea7863f780042"
|
|
||||||
}
|
|
||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind,\n repo_id, repo_workspace_path,\n zeroclaw_container_name, zeroclaw_gateway_url\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "title",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "description",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "outcome_kind",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "created_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 8,
|
|
||||||
"name": "updated_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 9,
|
|
||||||
"name": "published_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 10,
|
|
||||||
"name": "topology_kind",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 11,
|
|
||||||
"name": "repo_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 12,
|
|
||||||
"name": "repo_workspace_path",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 13,
|
|
||||||
"name": "zeroclaw_container_name",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 14,
|
|
||||||
"name": "zeroclaw_gateway_url",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "ba4f424960acc864d6df32e793cec83c173bfea181f19c6b7b9ce223d0c2ded0"
|
|
||||||
}
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, graph, task_template, triggers, repeat_policy,\n last_run_id, webhook_signing_key\n FROM loops\n WHERE webhook_token = $1 AND enabled",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "graph",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "task_template",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "triggers",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "repeat_policy",
|
|
||||||
"type_info": "Jsonb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "last_run_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "webhook_signing_key",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "be08a19d993480156d961cb170633e0133d802db02e1d8a10fa1f8ccac819d18"
|
|
||||||
}
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT agent_id, role_slot FROM research_topic_agents WHERE topic_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "agent_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "role_slot",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "c03f113360a03744f2e448979385c1348f6c49f88263e4583be71c527fba0ccc"
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "DELETE FROM loop_teams WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "c73f821ea00857bcc06add6478a4f999bbf8bdecb96e9fb79cdeafd1d22a2a2f"
|
|
||||||
}
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO skills (id, workspace_id, title, author, description, body)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "c9774a1a234049d0a670229fa3dd1d0d1910f851906320042258b1a4710c4a86"
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO loop_orgs (loop_id, org_id) VALUES ($1, $2)\n ON CONFLICT (loop_id, org_id) DO NOTHING",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "cef235c554c7cc30968e7b9d717442e01d1226c16c7fd85daff2aec97728955a"
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT research_topic_id FROM topology_runs WHERE id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "research_topic_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "dc9b55c84dad42d5b397353d3b5d5ef5af28cc8c51802526c70b8b2f6e4c0c64"
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE research_topics t\n SET status = 'reviewing', updated_at = now()\n WHERE t.id = (\n SELECT research_topic_id FROM topology_runs\n WHERE id = $1 AND research_topic_id IS NOT NULL\n )\n AND t.status = 'processing'\n AND NOT EXISTS (\n SELECT 1 FROM topology_runs\n WHERE research_topic_id = t.id\n AND id <> $1\n AND status IN ('queued', 'running')\n )\n RETURNING t.id",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "e26955627419e4773f72abb9010fcdcd589d777f0fd20b444a629feed2bf75e4"
|
|
||||||
}
|
|
||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "topic_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "requested_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "decided_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "decided_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "e600913908298405fd10dbd7f70d9fd72406f06d5fe165c5e8abfbd5e0c29b19"
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO topology_runs\n (id, workspace_id, task, kind, status, graph, tier, research_topic_id)\n VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Jsonb",
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "ebeb5ef13a40b1693425588096a4ec37dd01ecf6cd9c051aaf37399a69f617bf"
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT team_id FROM loop_teams WHERE loop_id = $1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "team_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "ed832a03e4abe417e1cec7997f35c0e3b337d691fcc6899dc98696fa1fcb6448"
|
|
||||||
}
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, workspace_id, topic_id, requested_by, status,\n decided_by, decided_at, created_at\n FROM research_publish_approvals\n WHERE workspace_id = $1 AND status = 'pending'\n ORDER BY created_at DESC",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "topic_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "requested_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "decided_by",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 6,
|
|
||||||
"name": "decided_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 7,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "ee97028bde490c0b2ac1fc5746d8ecd3079a5f61875925b1153da6bd6b84bfb1"
|
|
||||||
}
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id, topic_id, version, body_md, produced_by_run_id, created_at\n FROM research_outcomes\n WHERE topic_id = $1\n ORDER BY version DESC\n LIMIT 1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "topic_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "version",
|
|
||||||
"type_info": "Int4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "body_md",
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 4,
|
|
||||||
"name": "produced_by_run_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 5,
|
|
||||||
"name": "created_at",
|
|
||||||
"type_info": "Timestamptz"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "fa7257ae21b4faff3e8509c9813d7e1d326660c142bf42f4bb065d07b1982318"
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "UPDATE research_topics\n SET repo_workspace_path = $3, updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "fd3bc406d3a36bef8c2334f5fcdbdbeed977465938a85a970bc39efc21956de3"
|
|
||||||
}
|
|
||||||
Generated
+21
@@ -966,12 +966,14 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.26.2",
|
"tokio-tungstenite 0.26.2",
|
||||||
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4466,6 +4468,19 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial"
|
name = "serial"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -5625,6 +5640,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -503,6 +503,24 @@ async fn handle_frame(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Herdr dispatch ops. Server sends `herdr_dispatch` to open a
|
||||||
|
// sibling pane on the node's Herdr session and start the requested
|
||||||
|
// CLI (claude / codex / kimi / etc.) with a prompt. `herdr_status`
|
||||||
|
// polls that pane's agent_status; `herdr_read` scrapes its recent
|
||||||
|
// transcript. Node just shells out to the `herdr` binary — the
|
||||||
|
// Herdr background daemon is expected to already be running.
|
||||||
|
op @ ("herdr_dispatch"
|
||||||
|
| "herdr_status"
|
||||||
|
| "herdr_read"
|
||||||
|
| "herdr_workspaces"
|
||||||
|
| "herdr_snapshot") => {
|
||||||
|
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||||
|
let (ok, output) = herdr_op(op, &v).await;
|
||||||
|
let _ = out.send(
|
||||||
|
json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Agent-sandbox container ops: drive the REAL DockerDriver so the
|
// Agent-sandbox container ops: drive the REAL DockerDriver so the
|
||||||
// hardening (cap-drop ALL, seccomp, no-net, read-only, non-root) is
|
// hardening (cap-drop ALL, seccomp, no-net, read-only, non-root) is
|
||||||
// byte-identical to the gateway's local sandboxes.
|
// byte-identical to the gateway's local sandboxes.
|
||||||
@@ -658,12 +676,25 @@ fn spawn_container_pty(
|
|||||||
pub(crate) enum PtyTarget {
|
pub(crate) enum PtyTarget {
|
||||||
Host,
|
Host,
|
||||||
Container { container: String, session: String },
|
Container { container: String, session: String },
|
||||||
|
/// Custom argv (Herdr Live Pane uses this to spawn `herdr` directly
|
||||||
|
/// so the browser xterm attaches straight into the node's Herdr TUI
|
||||||
|
/// instead of a login shell).
|
||||||
|
Command { argv: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PtyTarget {
|
impl PtyTarget {
|
||||||
/// Parse from a control frame: a non-empty `container` field selects the
|
/// Parse from a control frame. Precedence: explicit `command` (non-
|
||||||
/// container path (with an optional `session`, default "main").
|
/// empty array) → Command; else `container` → Container; else Host.
|
||||||
pub(crate) fn from_frame(v: &Value) -> Self {
|
pub(crate) fn from_frame(v: &Value) -> Self {
|
||||||
|
if let Some(argv) = v.get("command").and_then(Value::as_array) {
|
||||||
|
let parts: Vec<String> = argv
|
||||||
|
.iter()
|
||||||
|
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||||
|
.collect();
|
||||||
|
if !parts.is_empty() {
|
||||||
|
return PtyTarget::Command { argv: parts };
|
||||||
|
}
|
||||||
|
}
|
||||||
match v.get("container").and_then(Value::as_str) {
|
match v.get("container").and_then(Value::as_str) {
|
||||||
Some(c) if !c.is_empty() => PtyTarget::Container {
|
Some(c) if !c.is_empty() => PtyTarget::Container {
|
||||||
container: c.to_owned(),
|
container: c.to_owned(),
|
||||||
@@ -683,10 +714,42 @@ impl PtyTarget {
|
|||||||
PtyTarget::Container { container, session } => {
|
PtyTarget::Container { container, session } => {
|
||||||
spawn_container_pty(container, session, cols, rows)
|
spawn_container_pty(container, session, cols, rows)
|
||||||
}
|
}
|
||||||
|
PtyTarget::Command { argv } => spawn_command_pty(argv, cols, rows),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn an arbitrary command in a PTY. Argv[0] must be the program;
|
||||||
|
/// if it's a bare name (no slash), it's resolved via the process PATH.
|
||||||
|
/// Missing binary returns a clean error the client sees as a banner.
|
||||||
|
fn spawn_command_pty(argv: &[String], cols: u16, rows: u16) -> Result<PtyParts, String> {
|
||||||
|
let program = argv
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| "command argv is empty".to_string())?;
|
||||||
|
// Resolve bare names against common bin dirs so a headless daemon
|
||||||
|
// (no login shell / no PATH set for herdr install dir) still finds it.
|
||||||
|
let resolved = if program.contains('/') {
|
||||||
|
program.clone()
|
||||||
|
} else {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
let candidates = [
|
||||||
|
format!("{home}/.local/bin/{program}"),
|
||||||
|
format!("/opt/homebrew/bin/{program}"),
|
||||||
|
format!("/usr/local/bin/{program}"),
|
||||||
|
format!("/usr/bin/{program}"),
|
||||||
|
];
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| std::path::Path::new(p).exists())
|
||||||
|
.unwrap_or_else(|| program.clone())
|
||||||
|
};
|
||||||
|
let mut c = CommandBuilder::new(&resolved);
|
||||||
|
for a in argv.iter().skip(1) {
|
||||||
|
c.arg(a);
|
||||||
|
}
|
||||||
|
spawn_pty(c, cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a host login shell in a PTY; stream its output back as pty_out frames.
|
/// Spawn a host login shell in a PTY; stream its output back as pty_out frames.
|
||||||
async fn open_pty(
|
async fn open_pty(
|
||||||
sid: u64,
|
sid: u64,
|
||||||
@@ -711,6 +774,7 @@ async fn open_pty(
|
|||||||
if has_tmux() { "tmux" } else { "login shell" }
|
if has_tmux() { "tmux" } else { "login shell" }
|
||||||
),
|
),
|
||||||
PtyTarget::Container { container, .. } => format!("container {container}"),
|
PtyTarget::Container { container, .. } => format!("container {container}"),
|
||||||
|
PtyTarget::Command { argv } => format!("command {}", argv.join(" ")),
|
||||||
};
|
};
|
||||||
eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}");
|
eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}");
|
||||||
// Immediate banner over the channel: if the browser shows this but no shell,
|
// Immediate banner over the channel: if the browser shows this but no shell,
|
||||||
@@ -839,6 +903,156 @@ fn handle_of(v: &Value) -> SandboxHandle {
|
|||||||
|
|
||||||
/// Run an agent-sandbox container op via the local DockerDriver (full hardening),
|
/// Run an agent-sandbox container op via the local DockerDriver (full hardening),
|
||||||
/// returning the result payload as JSON or an error message.
|
/// returning the result payload as JSON or an error message.
|
||||||
|
/// Herdr control ops. Requires `herdr` in PATH and a running background
|
||||||
|
/// session (Phase 0 install). Returns raw JSON strings from the herdr
|
||||||
|
/// CLI so the server can parse pane_id / agent_status without a
|
||||||
|
/// second RPC hop.
|
||||||
|
///
|
||||||
|
/// Ops:
|
||||||
|
/// herdr_dispatch { mission_id, cli, prompt, direction? } →
|
||||||
|
/// runs `herdr pane split ... && herdr pane run ... "prompt"`
|
||||||
|
/// output = the split's JSON response so the server can extract
|
||||||
|
/// result.pane.pane_id
|
||||||
|
/// herdr_status { pane_id } → `herdr pane get <pane_id>` JSON
|
||||||
|
/// herdr_read { pane_id, lines? } → recent-unwrapped scrollback
|
||||||
|
async fn herdr_op(op: &str, v: &Value) -> (bool, String) {
|
||||||
|
let herdr = match std::env::var("HOME").ok().and_then(|h| {
|
||||||
|
[
|
||||||
|
format!("{h}/.local/bin/herdr"),
|
||||||
|
"/opt/homebrew/bin/herdr".to_string(),
|
||||||
|
"/usr/local/bin/herdr".to_string(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| std::path::Path::new(p).exists())
|
||||||
|
}) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return (false, "herdr binary not found on PATH".into()),
|
||||||
|
};
|
||||||
|
let herdr = std::sync::Arc::new(herdr);
|
||||||
|
let run = move |args: Vec<String>| {
|
||||||
|
let herdr = herdr.clone();
|
||||||
|
async move {
|
||||||
|
let fut = tokio::process::Command::new(herdr.as_str())
|
||||||
|
.args(&args)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(Duration::from_secs(30), fut).await {
|
||||||
|
Ok(Ok(o)) => {
|
||||||
|
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||||
|
if !o.status.success() {
|
||||||
|
s.push_str(&String::from_utf8_lossy(&o.stderr));
|
||||||
|
}
|
||||||
|
(o.status.success(), s)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => (false, format!("spawn error: {e}")),
|
||||||
|
Err(_) => (false, "herdr op timed out".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match op {
|
||||||
|
"herdr_dispatch" => {
|
||||||
|
let mission_id = v
|
||||||
|
.get("mission_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let cli = v.get("cli").and_then(Value::as_str).unwrap_or("claude");
|
||||||
|
let prompt = v.get("prompt").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let direction = v
|
||||||
|
.get("direction")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("right");
|
||||||
|
// 1. Ensure a mission workspace exists (idempotent — label collision falls through).
|
||||||
|
let _ = run(vec![
|
||||||
|
"workspace".into(),
|
||||||
|
"create".into(),
|
||||||
|
"--label".into(),
|
||||||
|
format!("mission-{mission_id}"),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
// 2. Split off a fresh pane in that workspace and read its pane_id.
|
||||||
|
let (ok, split_out) = run(vec![
|
||||||
|
"pane".into(),
|
||||||
|
"split".into(),
|
||||||
|
"--direction".into(),
|
||||||
|
direction.into(),
|
||||||
|
"--no-focus".into(),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
if !ok {
|
||||||
|
return (false, format!("split failed: {split_out}"));
|
||||||
|
}
|
||||||
|
let pane_id = match serde_json::from_str::<Value>(&split_out) {
|
||||||
|
Ok(j) => j
|
||||||
|
.pointer("/result/pane/pane_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
if pane_id.is_empty() {
|
||||||
|
return (false, format!("no pane_id in split response: {split_out}"));
|
||||||
|
}
|
||||||
|
// 3. Rename for operator readability.
|
||||||
|
let _ = run(vec![
|
||||||
|
"pane".into(),
|
||||||
|
"rename".into(),
|
||||||
|
pane_id.clone(),
|
||||||
|
format!("mission-{mission_id}"),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
// 4. Launch the CLI with the prompt inline.
|
||||||
|
let launch = if prompt.is_empty() {
|
||||||
|
cli.to_string()
|
||||||
|
} else {
|
||||||
|
// Single-quoted so shell metacharacters in the prompt don't
|
||||||
|
// reinterpret. Herdr's pane.run sends this verbatim to the shell.
|
||||||
|
let escaped = prompt.replace('\'', "'\\''");
|
||||||
|
format!("{cli} '{escaped}'")
|
||||||
|
};
|
||||||
|
let (rok, rout) = run(vec![
|
||||||
|
"pane".into(),
|
||||||
|
"run".into(),
|
||||||
|
pane_id.clone(),
|
||||||
|
launch,
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"pane_id": pane_id,
|
||||||
|
"split": split_out,
|
||||||
|
"run_output": rout,
|
||||||
|
"run_ok": rok,
|
||||||
|
});
|
||||||
|
(rok, payload.to_string())
|
||||||
|
}
|
||||||
|
"herdr_status" => {
|
||||||
|
let pane = v.get("pane_id").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if pane.is_empty() {
|
||||||
|
return (false, "pane_id required".into());
|
||||||
|
}
|
||||||
|
run(vec!["pane".into(), "get".into(), pane.to_string()]).await
|
||||||
|
}
|
||||||
|
"herdr_read" => {
|
||||||
|
let pane = v.get("pane_id").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
let lines = v.get("lines").and_then(Value::as_u64).unwrap_or(200);
|
||||||
|
if pane.is_empty() {
|
||||||
|
return (false, "pane_id required".into());
|
||||||
|
}
|
||||||
|
run(vec![
|
||||||
|
"pane".into(),
|
||||||
|
"read".into(),
|
||||||
|
pane.to_string(),
|
||||||
|
"--source".into(),
|
||||||
|
"recent-unwrapped".into(),
|
||||||
|
"--lines".into(),
|
||||||
|
lines.to_string(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"herdr_workspaces" => run(vec!["workspace".into(), "list".into()]).await,
|
||||||
|
"herdr_snapshot" => run(vec!["api".into(), "snapshot".into()]).await,
|
||||||
|
_ => (false, format!("unknown herdr op {op}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn sb_op(op: &str, v: &Value) -> (bool, String) {
|
async fn sb_op(op: &str, v: &Value) -> (bool, String) {
|
||||||
let driver = match DockerDriver::connect() {
|
let driver = match DockerDriver::connect() {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
|
|||||||
@@ -299,7 +299,6 @@ async fn run() -> Result<(), String> {
|
|||||||
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
|
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
|
||||||
// Loop scheduler: fires cron-triggered loop iterations. Missed windows
|
// Loop scheduler: fires cron-triggered loop iterations. Missed windows
|
||||||
// fire ONCE and skip the backlog (see cm_runtime::loops for details).
|
// fire ONCE and skip the backlog (see cm_runtime::loops for details).
|
||||||
cm_runtime::spawn_loop_scheduler(pool.clone(), std::time::Duration::from_secs(10));
|
|
||||||
// Expiry/retention sweep: expires stale auth/oauth rows and prunes old
|
// Expiry/retention sweep: expires stale auth/oauth rows and prunes old
|
||||||
// journal/audit rows hourly so unbounded tables don't accumulate.
|
// journal/audit rows hourly so unbounded tables don't accumulate.
|
||||||
cm_api::cleanup_sweeper::spawn(pool.clone(), std::time::Duration::from_secs(3600));
|
cm_api::cleanup_sweeper::spawn(pool.clone(), std::time::Duration::from_secs(3600));
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ pub async fn run(
|
|||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
|
|
||||||
let container = team_container_for_mission(pool, mission_id).await?;
|
let (container, workdir) = exec_target(pool, mission_id).await?;
|
||||||
let cmd = harness.command();
|
let cmd = harness.command();
|
||||||
let raw = docker_exec(&container, &cmd)
|
let raw = docker_exec(&container, &workdir, &cmd)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("exec {cmd:?}: {e}"))?;
|
.map_err(|e| format!("exec {cmd:?}: {e}"))?;
|
||||||
let metrics = parse_output(&raw, &harness);
|
let metrics = parse_output(&raw, &harness);
|
||||||
@@ -229,23 +229,34 @@ async fn phase_config(pool: &PgPool, phase_id: Uuid) -> Result<Value, String> {
|
|||||||
.unwrap_or_else(|| json!({})))
|
.unwrap_or_else(|| json!({})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn team_container_for_mission(pool: &PgPool, mission_id: Uuid) -> Result<String, String> {
|
/// Post-task-#23: shared runtime container + per-mission working dir.
|
||||||
let row = sqlx::query(
|
/// See security_scan::exec_target for the same convention.
|
||||||
"SELECT t.zeroclaw_container
|
async fn exec_target(
|
||||||
FROM missions m
|
pool: &PgPool,
|
||||||
JOIN teams t ON t.id = m.team_id
|
mission_id: Uuid,
|
||||||
WHERE m.id = $1",
|
) -> Result<(String, std::path::PathBuf), String> {
|
||||||
|
let repo_id: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT repo_id FROM missions WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(mission_id)
|
.bind(mission_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("resolve container: {e}"))?;
|
.map_err(|e| format!("resolve mission repo: {e}"))?
|
||||||
row.and_then(|r| {
|
.flatten();
|
||||||
r.try_get::<Option<String>, _>("zeroclaw_container")
|
if repo_id.is_none() {
|
||||||
.ok()
|
return Err(
|
||||||
.flatten()
|
"mission has no repo bound — benchmark requires a repository under mission.repo_id"
|
||||||
})
|
.into(),
|
||||||
.ok_or_else(|| "mission has no team_id / team container".to_string())
|
);
|
||||||
|
}
|
||||||
|
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
|
||||||
|
.unwrap_or_else(|_| "clawmates-runtime".to_string());
|
||||||
|
let root = std::env::var("CLAWMATES_MISSIONS_ROOT")
|
||||||
|
.unwrap_or_else(|_| "/var/lib/clawmates-missions".to_string());
|
||||||
|
let workdir = std::path::PathBuf::from(root)
|
||||||
|
.join(mission_id.to_string())
|
||||||
|
.join("repo");
|
||||||
|
Ok((container, workdir))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_before(pool: &PgPool, phase_id: Uuid) -> Result<Option<Value>, String> {
|
async fn load_before(pool: &PgPool, phase_id: Uuid) -> Result<Option<Value>, String> {
|
||||||
@@ -269,8 +280,8 @@ async fn load_before(pool: &PgPool, phase_id: Uuid) -> Result<Option<Value>, Str
|
|||||||
/// harness — Cargo.toml → CargoBench, package.json with vitest →
|
/// harness — Cargo.toml → CargoBench, package.json with vitest →
|
||||||
/// VitestBench, pyproject with pytest-benchmark → PytestBench.
|
/// VitestBench, pyproject with pytest-benchmark → PytestBench.
|
||||||
async fn auto_detect(pool: &PgPool, mission_id: Uuid) -> Result<Harness, String> {
|
async fn auto_detect(pool: &PgPool, mission_id: Uuid) -> Result<Harness, String> {
|
||||||
let container = team_container_for_mission(pool, mission_id).await?;
|
let (container, workdir) = exec_target(pool, mission_id).await?;
|
||||||
let listing = docker_exec(&container, &["ls".into(), "/workspace/repo".into()])
|
let listing = docker_exec(&container, &workdir, &["ls".into()])
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if listing.contains("Cargo.toml") {
|
if listing.contains("Cargo.toml") {
|
||||||
@@ -287,12 +298,17 @@ async fn auto_detect(pool: &PgPool, mission_id: Uuid) -> Result<Harness, String>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget `docker exec` against the mission's team container.
|
/// Fire-and-forget `docker exec` against the shared runtime container
|
||||||
async fn docker_exec(container: &str, cmd: &[String]) -> Result<String, String> {
|
/// at the mission's working dir.
|
||||||
|
async fn docker_exec(
|
||||||
|
container: &str,
|
||||||
|
workdir: &std::path::Path,
|
||||||
|
cmd: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"exec".to_string(),
|
"exec".to_string(),
|
||||||
"-w".into(),
|
"-w".into(),
|
||||||
"/workspace/repo".into(),
|
workdir.display().to_string(),
|
||||||
container.to_string(),
|
container.to_string(),
|
||||||
];
|
];
|
||||||
args.extend(cmd.iter().cloned());
|
args.extend(cmd.iter().cloned());
|
||||||
|
|||||||
@@ -205,6 +205,13 @@ impl NodeHub {
|
|||||||
/// Open the WS-relay PTY for an allocated session (the fallback path).
|
/// Open the WS-relay PTY for an allocated session (the fallback path).
|
||||||
/// `container` (+ `session`) targets `docker exec` into an agent container on
|
/// `container` (+ `session`) targets `docker exec` into an agent container on
|
||||||
/// the node (the node-placed agent terminal); both `None` ⇒ the host shell.
|
/// the node (the node-placed agent terminal); both `None` ⇒ the host shell.
|
||||||
|
/// `command`, when set to a non-empty argv, wins over both — spawns
|
||||||
|
/// the program directly (used by the Herdr Live Pane to attach xterm.js
|
||||||
|
/// straight to `herdr`).
|
||||||
|
// 8 params is at the target-shape ceiling: (id, sid, cols, rows) address
|
||||||
|
// the session, (container, session, command) address the target. Wrapping
|
||||||
|
// in a struct would add ceremony without collapsing dimensions.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn open_pty(
|
pub async fn open_pty(
|
||||||
&self,
|
&self,
|
||||||
id: NodeId,
|
id: NodeId,
|
||||||
@@ -213,14 +220,19 @@ impl NodeHub {
|
|||||||
rows: u16,
|
rows: u16,
|
||||||
container: Option<&str>,
|
container: Option<&str>,
|
||||||
session: Option<&str>,
|
session: Option<&str>,
|
||||||
|
command: Option<&[String]>,
|
||||||
) {
|
) {
|
||||||
if let Some(conn) = self.get(id).await {
|
if let Some(conn) = self.get(id).await {
|
||||||
let mut frame = json!({ "t": "pty_open", "sid": sid, "cols": cols, "rows": rows });
|
let mut frame = json!({ "t": "pty_open", "sid": sid, "cols": cols, "rows": rows });
|
||||||
if let Some(c) = container {
|
if let Some(cmd) = command.filter(|c| !c.is_empty()) {
|
||||||
frame["container"] = json!(c);
|
frame["command"] = json!(cmd);
|
||||||
}
|
} else {
|
||||||
if let Some(s) = session {
|
if let Some(c) = container {
|
||||||
frame["session"] = json!(s);
|
frame["container"] = json!(c);
|
||||||
|
}
|
||||||
|
if let Some(s) = session {
|
||||||
|
frame["session"] = json!(s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = conn.tx.send(frame.to_string());
|
let _ = conn.tx.send(frame.to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//! Herdr second-runtime dispatch (Phase 1b).
|
||||||
|
//!
|
||||||
|
//! Server-side client for the `herdr_dispatch` / `herdr_status` /
|
||||||
|
//! `herdr_read` ops the fleet-node daemon exposes. Missions with
|
||||||
|
//! `runtime_kind = 'local_herdr'` route through this module instead
|
||||||
|
//! of RuntimeProvisioner + ZeroClaw.
|
||||||
|
//!
|
||||||
|
//! Flow:
|
||||||
|
//! 1. dispatch(mission, task) → node daemon spawns a Herdr pane +
|
||||||
|
//! launches the requested CLI. Returns the (workspace_id, tab_id,
|
||||||
|
//! pane_id) triple; caller persists it on topology_runs so a
|
||||||
|
//! resumed run can reattach rather than double-spawn.
|
||||||
|
//! 2. poll_until_done(pane_id) → periodically issues herdr_status
|
||||||
|
//! until agent_status ∈ {done, idle} or a timeout. Between polls
|
||||||
|
//! the operator can watch the pane live on the node (Phase 2's
|
||||||
|
//! "Live pane" tab surfaces it in-browser).
|
||||||
|
//! 3. read_transcript(pane_id) → final scrape after completion,
|
||||||
|
//! persisted to run_events for the Tasks tab.
|
||||||
|
|
||||||
|
use cm_domain::NodeId;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::fleet::NodeHub;
|
||||||
|
|
||||||
|
/// What the caller needs to persist on the topology_run so a server
|
||||||
|
/// restart can reattach to the same live pane.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DispatchHandle {
|
||||||
|
pub pane_id: String,
|
||||||
|
pub raw_split_response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a Herdr pane on `node` for `mission_id` and start `cli` with
|
||||||
|
/// `prompt`. Returns the pane handle to persist.
|
||||||
|
///
|
||||||
|
/// `cli` is the executable name — "claude", "codex", "kimi", "opencode",
|
||||||
|
/// "omp", "pi". Server callers should validate against the mission's
|
||||||
|
/// team template (a rust_sdlc mission on tank probably wants Claude
|
||||||
|
/// Code; a research mission on morpheus probably wants Kimi).
|
||||||
|
pub async fn dispatch(
|
||||||
|
hub: Arc<NodeHub>,
|
||||||
|
node_id: NodeId,
|
||||||
|
mission_id: Uuid,
|
||||||
|
cli: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<DispatchHandle, String> {
|
||||||
|
let args = json!({
|
||||||
|
"mission_id": mission_id.to_string(),
|
||||||
|
"cli": cli,
|
||||||
|
"prompt": prompt,
|
||||||
|
});
|
||||||
|
let out = hub
|
||||||
|
.call_timeout(node_id, "herdr_dispatch", args, 60)
|
||||||
|
.await?;
|
||||||
|
if !out.ok {
|
||||||
|
return Err(format!("node rejected dispatch: {}", truncate(&out.output, 400)));
|
||||||
|
}
|
||||||
|
let payload: Value = serde_json::from_str(&out.output)
|
||||||
|
.map_err(|e| format!("dispatch payload not json: {e}: {}", truncate(&out.output, 200)))?;
|
||||||
|
let pane_id = payload
|
||||||
|
.get("pane_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| "no pane_id in dispatch response".to_string())?
|
||||||
|
.to_string();
|
||||||
|
let split = payload
|
||||||
|
.get("split")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
Ok(DispatchHandle {
|
||||||
|
pane_id,
|
||||||
|
raw_split_response: split,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current agent state of a pane. Returns raw pane.get JSON
|
||||||
|
/// so the caller can inspect any field (agent, agent_status, cwd,
|
||||||
|
/// process metadata).
|
||||||
|
pub async fn status(
|
||||||
|
hub: Arc<NodeHub>,
|
||||||
|
node_id: NodeId,
|
||||||
|
pane_id: &str,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let out = hub
|
||||||
|
.call_timeout(
|
||||||
|
node_id,
|
||||||
|
"herdr_status",
|
||||||
|
json!({ "pane_id": pane_id }),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !out.ok {
|
||||||
|
return Err(format!("status failed: {}", truncate(&out.output, 300)));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&out.output)
|
||||||
|
.map_err(|e| format!("status not json: {e}: {}", truncate(&out.output, 200)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the full session snapshot from a node's Herdr daemon
|
||||||
|
/// (`herdr api snapshot`). Returns raw JSON so the frontend can render
|
||||||
|
/// workspaces + tabs + panes + agent states without a schema hop.
|
||||||
|
pub async fn snapshot(
|
||||||
|
hub: Arc<NodeHub>,
|
||||||
|
node_id: NodeId,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let out = hub
|
||||||
|
.call_timeout(node_id, "herdr_snapshot", json!({}), 15)
|
||||||
|
.await?;
|
||||||
|
if !out.ok {
|
||||||
|
return Err(format!("snapshot failed: {}", truncate(&out.output, 300)));
|
||||||
|
}
|
||||||
|
serde_json::from_str(&out.output)
|
||||||
|
.map_err(|e| format!("snapshot not json: {e}: {}", truncate(&out.output, 200)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the last `lines` of the pane's scrollback (unwrapped) — used
|
||||||
|
/// to persist a completed run's transcript.
|
||||||
|
pub async fn read_transcript(
|
||||||
|
hub: Arc<NodeHub>,
|
||||||
|
node_id: NodeId,
|
||||||
|
pane_id: &str,
|
||||||
|
lines: u32,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let out = hub
|
||||||
|
.call_timeout(
|
||||||
|
node_id,
|
||||||
|
"herdr_read",
|
||||||
|
json!({ "pane_id": pane_id, "lines": lines }),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !out.ok {
|
||||||
|
return Err(format!("read failed: {}", truncate(&out.output, 300)));
|
||||||
|
}
|
||||||
|
Ok(out.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll status every `poll_secs` until agent_status ∈ terminal set,
|
||||||
|
/// or `timeout_secs` elapses. Returns the final status JSON.
|
||||||
|
///
|
||||||
|
/// Terminal set: 'done' | 'idle' after the pane has been seen at
|
||||||
|
/// least once in a non-idle state (avoids returning immediately for
|
||||||
|
/// a pane that hasn't yet started working).
|
||||||
|
pub async fn wait_for_completion(
|
||||||
|
hub: Arc<NodeHub>,
|
||||||
|
node_id: NodeId,
|
||||||
|
pane_id: &str,
|
||||||
|
poll_secs: u64,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let mut ever_working = false;
|
||||||
|
loop {
|
||||||
|
if started.elapsed() > Duration::from_secs(timeout_secs) {
|
||||||
|
return Err(format!("pane {pane_id} did not complete in {timeout_secs}s"));
|
||||||
|
}
|
||||||
|
let s = status(hub.clone(), node_id, pane_id).await?;
|
||||||
|
let state = s
|
||||||
|
.pointer("/result/agent_status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
match state {
|
||||||
|
"working" | "blocked" => ever_working = true,
|
||||||
|
"done" => return Ok(s),
|
||||||
|
"idle" if ever_working => return Ok(s),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(poll_secs)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..max])
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-91
@@ -7,15 +7,17 @@ pub mod cleanup_sweeper;
|
|||||||
mod error;
|
mod error;
|
||||||
mod extract;
|
mod extract;
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
|
pub mod fleet_herdr;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
mod mcp_skills;
|
mod mcp_skills;
|
||||||
pub mod mission_orchestrator;
|
pub mod mission_orchestrator;
|
||||||
|
pub mod mission_refiner;
|
||||||
|
pub mod mission_workspace;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
pub mod pdf_renderer;
|
pub mod pdf_renderer;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
mod recursive_exec;
|
mod recursive_exec;
|
||||||
pub mod research_container;
|
|
||||||
mod routes;
|
mod routes;
|
||||||
mod runtime_provision;
|
mod runtime_provision;
|
||||||
pub mod security_scan;
|
pub mod security_scan;
|
||||||
@@ -157,6 +159,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/nodes/{id}/tools/{tool}/update",
|
"/api/nodes/{id}/tools/{tool}/update",
|
||||||
post(routes::nodes::tool_update),
|
post(routes::nodes::tool_update),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/nodes/{id}/herdr/session",
|
||||||
|
get(routes::nodes::herdr_session),
|
||||||
|
)
|
||||||
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
||||||
.route(
|
.route(
|
||||||
"/api/fleet/beszel",
|
"/api/fleet/beszel",
|
||||||
@@ -434,11 +440,28 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/missions",
|
"/api/missions",
|
||||||
get(routes::missions::list).post(routes::missions::create),
|
get(routes::missions::list).post(routes::missions::create),
|
||||||
)
|
)
|
||||||
.route("/api/missions/{id}", get(routes::missions::get))
|
.route(
|
||||||
|
"/api/missions/{id}",
|
||||||
|
get(routes::missions::get)
|
||||||
|
.patch(routes::missions::update_meta)
|
||||||
|
.delete(routes::missions::delete),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/status",
|
"/api/missions/{id}/status",
|
||||||
axum::routing::patch(routes::missions::set_status),
|
axum::routing::patch(routes::missions::set_status),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/refine",
|
||||||
|
post(routes::missions::refine),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/herdr-dispatch",
|
||||||
|
post(routes::missions::herdr_dispatch),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/description",
|
||||||
|
patch(routes::missions::set_description),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/benchmark",
|
"/api/missions/{id}/benchmark",
|
||||||
post(routes::missions::trigger_benchmark),
|
post(routes::missions::trigger_benchmark),
|
||||||
@@ -472,91 +495,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/teams/{id}/level-up",
|
"/api/teams/{id}/level-up",
|
||||||
post(routes::level_up::propose_for_team),
|
post(routes::level_up::propose_for_team),
|
||||||
)
|
)
|
||||||
.route(
|
// (research + loops + wizard_repo routes retired in Slice 9
|
||||||
"/api/research",
|
// cleanup — missions is the single workflow surface. Probe
|
||||||
get(routes::research::list_topics).post(routes::research::create_topic),
|
// is kept below if still referenced by any tool.)
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}",
|
|
||||||
get(routes::research::get_topic)
|
|
||||||
.patch(routes::research::patch_topic)
|
|
||||||
.delete(routes::research::delete_topic),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/agents",
|
|
||||||
post(routes::research::attach_agent),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/agents/{agent_id}",
|
|
||||||
axum::routing::delete(routes::research::detach_agent),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/start",
|
|
||||||
post(routes::research::start_topic),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/submit-review",
|
|
||||||
post(routes::research::submit_review),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/request-publish",
|
|
||||||
post(routes::research::request_publish),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/publish-approvals",
|
|
||||||
get(routes::research::list_pending_publish),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/publish-approvals/{id}/approve",
|
|
||||||
post(routes::research::approve_publish),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/publish-approvals/{id}/reject",
|
|
||||||
post(routes::research::reject_publish),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/wizard/refine",
|
|
||||||
post(routes::research::refine_wizard),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/wizard/repo/ensure",
|
|
||||||
post(routes::wizard_repo::ensure_repo),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/wizard/repo/release",
|
|
||||||
post(routes::wizard_repo::release_repo),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/artifact",
|
|
||||||
get(routes::research::get_artifact),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/pipeline-state",
|
|
||||||
get(routes::research_pipeline::pipeline_state),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/research/{id}/active-runs",
|
|
||||||
get(routes::research_pipeline::active_runs),
|
|
||||||
)
|
|
||||||
.route("/api/research/probe", post(routes::probe::probe))
|
|
||||||
.route(
|
|
||||||
"/api/loops",
|
|
||||||
get(routes::loops::list_loops).post(routes::loops::create_loop),
|
|
||||||
)
|
|
||||||
.route("/api/loops/progress", get(routes::loops::list_progress))
|
|
||||||
.route(
|
|
||||||
"/api/loops/{id}",
|
|
||||||
get(routes::loops::get_loop)
|
|
||||||
.patch(routes::loops::patch_loop)
|
|
||||||
.delete(routes::loops::delete_loop),
|
|
||||||
)
|
|
||||||
.route("/api/loops/{id}/run", post(routes::loops::run_now))
|
|
||||||
.route("/api/loops/{id}/enable", post(routes::loops::enable_loop))
|
|
||||||
.route("/api/loops/{id}/disable", post(routes::loops::disable_loop))
|
|
||||||
.route(
|
|
||||||
"/webhooks/loops/{token}",
|
|
||||||
post(routes::loops::webhook_receive),
|
|
||||||
)
|
|
||||||
.route("/api/structure/stats", get(routes::structure::stats))
|
.route("/api/structure/stats", get(routes::structure::stats))
|
||||||
.route(
|
.route(
|
||||||
"/api/structure/orphan-counts",
|
"/api/structure/orphan-counts",
|
||||||
@@ -577,10 +518,6 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/topology-runs/{id}/events",
|
"/api/topology-runs/{id}/events",
|
||||||
get(routes::topology::run_events_sse),
|
get(routes::topology::run_events_sse),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/topology-runs/{id}/container-log",
|
|
||||||
get(routes::topology::run_container_log_sse),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/topology-runs/{id}/cancel",
|
"/api/topology-runs/{id}/cancel",
|
||||||
post(routes::topology::cancel_run),
|
post(routes::topology::cancel_run),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub async fn on_launch(
|
|||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
user_id: cm_domain::UserId,
|
user_id: cm_domain::UserId,
|
||||||
mission_id: Uuid,
|
mission_id: Uuid,
|
||||||
|
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
|
||||||
) -> Result<Option<Uuid>, String> {
|
) -> Result<Option<Uuid>, String> {
|
||||||
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
||||||
.await
|
.await
|
||||||
@@ -82,6 +83,73 @@ pub async fn on_launch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("bind team on mission: {e}"))?;
|
.map_err(|e| format!("bind team on mission: {e}"))?;
|
||||||
|
|
||||||
|
// Ensure the mission's repo is checked out at
|
||||||
|
// $CLAWMATES_MISSIONS_ROOT/{mission_id}/repo — where
|
||||||
|
// security_scan + benchmark_runner exec against. Non-fatal:
|
||||||
|
// missions without a repo (research_only, custom) skip cleanly,
|
||||||
|
// and clone failures log without blocking launch (the operator
|
||||||
|
// sees the error on the canvas via the run's failed status when
|
||||||
|
// a repo-dependent phase tries to fire).
|
||||||
|
match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await {
|
||||||
|
Ok(Some(path)) => eprintln!(
|
||||||
|
"mission_orchestrator: repo checked out at {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"mission_orchestrator: repo checkout for {mission_id} failed (continuing): {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Herdr second-runtime: if runtime_kind='local_herdr', spawn a
|
||||||
|
// pane on target_node running the first available local CLI.
|
||||||
|
// Non-fatal on failure — the operator sees the error in server
|
||||||
|
// logs and can manually retry via POST /herdr-dispatch.
|
||||||
|
if mission.runtime_kind == "local_herdr" {
|
||||||
|
if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) {
|
||||||
|
let prompt = mission.description.clone().unwrap_or_default();
|
||||||
|
// CLI selection precedence:
|
||||||
|
// mission.config.cli → template.config.default_cli → "claude"
|
||||||
|
// Templates encode which agent CLI fits their stack; missions can
|
||||||
|
// override per-run for A/B (kimi on morpheus vs claude on tank).
|
||||||
|
let cli = mission
|
||||||
|
.config
|
||||||
|
.get("cli")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
template
|
||||||
|
.template
|
||||||
|
.config
|
||||||
|
.get("default_cli")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "claude".to_string());
|
||||||
|
match crate::fleet_herdr::dispatch(
|
||||||
|
hub,
|
||||||
|
cm_domain::NodeId::from(node_id),
|
||||||
|
mission_id,
|
||||||
|
&cli,
|
||||||
|
&prompt,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(handle) => eprintln!(
|
||||||
|
"mission_orchestrator: herdr pane {} spawned on node {}",
|
||||||
|
handle.pane_id, node_id
|
||||||
|
),
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"mission_orchestrator: herdr dispatch for {mission_id} failed (continuing): {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"mission_orchestrator: mission {mission_id} is local_herdr but node_hub or target_node missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(team_id))
|
Ok(Some(team_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Mission refiner — take the user's freeform description on a draft
|
||||||
|
//! mission and rewrite it into a coherent, sectioned Markdown brief
|
||||||
|
//! that downstream research + coding agents can ingest cleanly.
|
||||||
|
//!
|
||||||
|
//! Uses Gemini 2.5 Flash (same call shape as level_up.rs) but with a
|
||||||
|
//! text-mode response — we want Markdown out, not JSON.
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_MODEL: &str = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
fn model_name() -> String {
|
||||||
|
std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefineResult {
|
||||||
|
pub original: String,
|
||||||
|
pub refined: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a refined description without touching the database. The
|
||||||
|
/// caller (frontend) reviews the diff and calls `set_description` to
|
||||||
|
/// commit — that separation makes Accept/Cancel + undo trivial without
|
||||||
|
/// an audit table.
|
||||||
|
pub async fn refine(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: cm_domain::WorkspaceId,
|
||||||
|
mission_id: Uuid,
|
||||||
|
) -> Result<RefineResult, String> {
|
||||||
|
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("load mission: {e}"))?
|
||||||
|
.ok_or_else(|| "mission not found".to_string())?;
|
||||||
|
if mission.status != "draft" {
|
||||||
|
return Err(format!("mission is {}, refine only allowed on draft", mission.status));
|
||||||
|
}
|
||||||
|
let raw = mission.description.unwrap_or_default();
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err("description is empty — nothing to refine".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase_kinds: Vec<String> = cm_db::repo::missions::phases_for(pool, mission_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("load phases: {e}"))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.kind)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let refined = call_gemini(&mission.title, &mission.template_kind, &phase_kinds, &raw).await?;
|
||||||
|
|
||||||
|
Ok(RefineResult { original: raw, refined })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_gemini(
|
||||||
|
title: &str,
|
||||||
|
template_kind: &str,
|
||||||
|
phase_kinds: &[String],
|
||||||
|
raw: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let api_key =
|
||||||
|
std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?;
|
||||||
|
let model = model_name();
|
||||||
|
let url = format!(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let system = "You are a technical brief editor for an autonomous software \
|
||||||
|
engineering platform. Rewrite the user's raw mission description into a \
|
||||||
|
clean, sectioned Markdown brief that research + coding agents can ingest \
|
||||||
|
directly. Preserve every concrete fact, requirement, constraint, and \
|
||||||
|
acceptance criterion the user provided — do not invent new scope. \
|
||||||
|
Structure the output with these sections when the source material \
|
||||||
|
supports them (omit sections with nothing to say):\n\
|
||||||
|
\n\
|
||||||
|
# <One-line restated goal>\n\
|
||||||
|
\n\
|
||||||
|
## Objective\n\
|
||||||
|
A 1–3 sentence framing of what success looks like.\n\
|
||||||
|
\n\
|
||||||
|
## Context & Background\n\
|
||||||
|
Any relevant prior art, files, systems, or motivation the user gave.\n\
|
||||||
|
\n\
|
||||||
|
## Scope\n\
|
||||||
|
Bullet list of concrete deliverables (in scope). If the user \
|
||||||
|
called out non-goals, add an `### Out of scope` subsection.\n\
|
||||||
|
\n\
|
||||||
|
## Constraints\n\
|
||||||
|
Technical, stylistic, or process constraints (languages, versions, \
|
||||||
|
style guides, migration paths, existing conventions to respect).\n\
|
||||||
|
\n\
|
||||||
|
## Acceptance Criteria\n\
|
||||||
|
Numbered list of concrete, verifiable pass/fail conditions the \
|
||||||
|
coding agents should treat as done-definitions.\n\
|
||||||
|
\n\
|
||||||
|
## Open Questions\n\
|
||||||
|
Only include if the source material has genuine ambiguity worth \
|
||||||
|
flagging to the research phase before coding starts.\n\
|
||||||
|
\n\
|
||||||
|
Rules:\n\
|
||||||
|
- Output raw Markdown only — no code fence around the whole doc, \
|
||||||
|
no preamble like \"Here is the refined brief\".\n\
|
||||||
|
- Never make up file paths, APIs, repo names, or version numbers.\n\
|
||||||
|
- If the user's text is very short, produce a short brief — do not \
|
||||||
|
pad with generic filler.\n\
|
||||||
|
- Use `**bold**` sparingly for load-bearing terms; do not bold entire \
|
||||||
|
sentences.\n\
|
||||||
|
- Prefer bullet lists over paragraphs for scope, constraints, and criteria.";
|
||||||
|
|
||||||
|
let user = format!(
|
||||||
|
"Mission title: {title}\n\
|
||||||
|
Template kind: {template_kind}\n\
|
||||||
|
Planned phases: {phases}\n\
|
||||||
|
\n\
|
||||||
|
Raw description:\n\
|
||||||
|
---\n\
|
||||||
|
{raw}\n\
|
||||||
|
---",
|
||||||
|
phases = if phase_kinds.is_empty() {
|
||||||
|
"(none configured yet)".to_string()
|
||||||
|
} else {
|
||||||
|
phase_kinds.join(", ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"system_instruction": { "parts": [{ "text": system }] },
|
||||||
|
"contents": [{ "role": "user", "parts": [{ "text": user }] }],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 0.3,
|
||||||
|
"maxOutputTokens": 4096,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("http client: {e}"))?;
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("gemini call: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let code = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("gemini {code}: {}", &body[..body.len().min(500)]));
|
||||||
|
}
|
||||||
|
let json: serde_json::Value = resp.json().await.map_err(|e| format!("gemini json: {e}"))?;
|
||||||
|
let text = json
|
||||||
|
.pointer("/candidates/0/content/parts/0/text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| "gemini response missing text".to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err("gemini returned empty text".into());
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Per-mission repo checkout.
|
||||||
|
//!
|
||||||
|
//! Missions execute their coding / benchmark / security phases against
|
||||||
|
//! a filesystem checkout of `missions.repo_id` at
|
||||||
|
//! `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`. That path is what
|
||||||
|
//! `security_scan::exec_target` + `benchmark_runner::exec_target`
|
||||||
|
//! both `docker exec -w` into.
|
||||||
|
//!
|
||||||
|
//! `ensure_checkout` is called from `mission_orchestrator::on_launch`
|
||||||
|
//! and is idempotent:
|
||||||
|
//! - no repo_id → no-op (Ok(None))
|
||||||
|
//! - dir already a git repo → `fetch + reset --hard origin/<branch>`
|
||||||
|
//! to bring it in sync
|
||||||
|
//! - dir missing → `git clone --depth 1 <url> <path>`
|
||||||
|
//!
|
||||||
|
//! Auth: relies on the ambient git credential setup (SSH agent,
|
||||||
|
//! .netrc, or git-credential helper) in the process environment. We
|
||||||
|
//! deliberately don't embed tokens in URLs — footgun risk outweighs
|
||||||
|
//! the ergonomics, and prod already runs with a helper configured.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn missions_root() -> PathBuf {
|
||||||
|
std::env::var("CLAWMATES_MISSIONS_ROOT")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkout_path(mission_id: Uuid) -> PathBuf {
|
||||||
|
missions_root().join(mission_id.to_string()).join("repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the mission's repo is checked out at `checkout_path`.
|
||||||
|
/// Returns Ok(None) when the mission has no repo bound, Ok(Some(path))
|
||||||
|
/// when a checkout is in place (freshly cloned or brought up-to-date).
|
||||||
|
pub async fn ensure_checkout(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
workspace_id: cm_domain::WorkspaceId,
|
||||||
|
mission_id: Uuid,
|
||||||
|
) -> Result<Option<PathBuf>, String> {
|
||||||
|
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("load mission: {e}"))?
|
||||||
|
.ok_or_else(|| "mission not found".to_string())?;
|
||||||
|
let Some(repo_id) = mission.repo_id else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo = cm_db::repo::repos::get(pool, repo_id, workspace_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("load repo {repo_id}: {e}"))?;
|
||||||
|
let clone_url = repo
|
||||||
|
.clone_url
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| format!("repo {repo_id} has no clone_url"))?;
|
||||||
|
let default_branch = repo.default_branch.as_deref().unwrap_or("main");
|
||||||
|
|
||||||
|
let path = checkout_path(mission_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.join(".git").exists() {
|
||||||
|
fetch_and_reset(&path, default_branch).await?;
|
||||||
|
} else {
|
||||||
|
clone(&path, clone_url).await?;
|
||||||
|
}
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"clone",
|
||||||
|
"--depth",
|
||||||
|
"1",
|
||||||
|
url,
|
||||||
|
&path.display().to_string(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn git clone: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"git clone {url} → exit {}: {}",
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
.chars()
|
||||||
|
.take(400)
|
||||||
|
.collect::<String>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> {
|
||||||
|
let fetch = Command::new("git")
|
||||||
|
.args(["-C", &path.display().to_string(), "fetch", "--depth", "1", "origin", branch])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn git fetch: {e}"))?;
|
||||||
|
if !fetch.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"git fetch origin {branch} → exit {}: {}",
|
||||||
|
fetch.status,
|
||||||
|
String::from_utf8_lossy(&fetch.stderr)
|
||||||
|
.chars()
|
||||||
|
.take(400)
|
||||||
|
.collect::<String>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let reset = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-C",
|
||||||
|
&path.display().to_string(),
|
||||||
|
"reset",
|
||||||
|
"--hard",
|
||||||
|
&format!("origin/{branch}"),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn git reset: {e}"))?;
|
||||||
|
if !reset.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"git reset --hard origin/{branch} → exit {}: {}",
|
||||||
|
reset.status,
|
||||||
|
String::from_utf8_lossy(&reset.stderr)
|
||||||
|
.chars()
|
||||||
|
.take(400)
|
||||||
|
.collect::<String>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,787 +0,0 @@
|
|||||||
//! Per-topic ZeroClaw team containers.
|
|
||||||
//!
|
|
||||||
//! `start_topic` calls [`spawn`] after the git clone succeeds; each active
|
|
||||||
//! research topic gets its own clawmates-runtime container reachable by
|
|
||||||
//! name over the compose network. The container inherits the parent
|
|
||||||
//! server's provider config (ZEROCLAW_providers__* + ZEROCLAW_TOKEN),
|
|
||||||
//! bind-mounts the cloned repo at `/workspace/repo`, and stores per-team
|
|
||||||
//! ZeroClaw state under `/zeroclaw-data`. The container name and gateway
|
|
||||||
//! URL persist on `research_topics` so the topology_worker can point
|
|
||||||
//! `ZeroClawDriveExecutor` at the isolated endpoint for each run.
|
|
||||||
//!
|
|
||||||
//! [`stop`] tears the container down on teardown or topic delete. Both
|
|
||||||
//! functions are idempotent: an already-running container is left alone;
|
|
||||||
//! an already-stopped container is silently pruned.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum};
|
|
||||||
use bollard::query_parameters::{
|
|
||||||
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
|
||||||
StopContainerOptions,
|
|
||||||
};
|
|
||||||
use bollard::Docker;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Result of [`spawn`]. Persist both on `research_topics` so the worker
|
|
||||||
/// and the teardown path can find the container later.
|
|
||||||
pub struct SpawnedContainer {
|
|
||||||
pub name: String,
|
|
||||||
pub gateway_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Image tag the spawned team runs. Overridable in prod so a specific
|
|
||||||
/// pinned digest is used instead of `:latest`. Matches the image the
|
|
||||||
/// compose stack's `clawmates-runtime` service already uses.
|
|
||||||
fn team_image() -> String {
|
|
||||||
std::env::var("CLAWMATES_RESEARCH_TEAM_IMAGE")
|
|
||||||
.unwrap_or_else(|_| "clawmates-runtime:latest".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Docker network the team joins so `clawmates_server` can reach it by
|
|
||||||
/// container name (`http://<name>:42617`). Prod: `clawmates_core`.
|
|
||||||
fn team_network() -> String {
|
|
||||||
std::env::var("CLAWMATES_RESEARCH_TEAM_NETWORK").unwrap_or_else(|_| "clawmates_core".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The container name for a topic. Deterministic so a restart re-spawns
|
|
||||||
/// the SAME container (or reattaches if it's still there).
|
|
||||||
pub fn container_name_for(topic_id: Uuid) -> String {
|
|
||||||
format!("research-{topic_id}-team")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-write the daemon's config.toml under `<state_root>/.zeroclaw/`
|
|
||||||
/// so the freshly-spawned container boots with pairing disabled. Without
|
|
||||||
/// this, per-team daemons come up with `require_pairing = true` and an
|
|
||||||
/// empty `paired_tokens` list, which 401s every incoming ws connect
|
|
||||||
/// from the API server. Per-team containers only accept traffic from
|
|
||||||
/// the API server on the private clawmates_core docker network — safe
|
|
||||||
/// to skip pairing.
|
|
||||||
fn prewrite_daemon_config(state_host_path: &Path) -> Result<(), String> {
|
|
||||||
let cfg_dir = state_host_path.join(".zeroclaw");
|
|
||||||
std::fs::create_dir_all(&cfg_dir).map_err(|e| format!("mkdir {}: {e}", cfg_dir.display()))?;
|
|
||||||
let cfg_path = cfg_dir.join("config.toml");
|
|
||||||
if cfg_path.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Prefer the shared runtime's config as a template so per-team
|
|
||||||
// daemons come up with the full `[agents.*]` + `[providers.*]`
|
|
||||||
// sections. Without them the daemon rejects ws connects like
|
|
||||||
// `?agent=coordinator` with a 400 "Unknown agent". Template path
|
|
||||||
// set on gw-04 via CLAWMATES_RUNTIME_TEMPLATE_CONFIG (bind-mounted
|
|
||||||
// from the shared runtime container's config).
|
|
||||||
//
|
|
||||||
// We strip the template's `[gateway]` block — its `paired_tokens`
|
|
||||||
// list is encrypted with the shared runtime's key and won't
|
|
||||||
// decrypt on a fresh per-team daemon — and replace it with a
|
|
||||||
// clean `[gateway] require_pairing = false`. Per-team containers
|
|
||||||
// live on the private clawmates_core network and only accept
|
|
||||||
// traffic from the API server, so disabling pairing there closes
|
|
||||||
// no security holes.
|
|
||||||
let template_path = std::env::var("CLAWMATES_RUNTIME_TEMPLATE_CONFIG")
|
|
||||||
.unwrap_or_else(|_| "/var/lib/clawmates-runtime-template/config.toml".to_string());
|
|
||||||
let cfg = match std::fs::read_to_string(&template_path) {
|
|
||||||
Ok(t) => rewrite_gateway_section(&t),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"prewrite_daemon_config: template {template_path} not readable ({e}) — \
|
|
||||||
falling back to minimal config, per-team ws connects will 400 on Unknown agent"
|
|
||||||
);
|
|
||||||
"schema_version = 3\n\n[gateway]\nrequire_pairing = false\n".to_string()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
std::fs::write(&cfg_path, cfg).map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the `[gateway]` section of a TOML string with a clean one
|
|
||||||
/// that disables pairing. Preserves everything else (agents, providers,
|
|
||||||
/// etc.) verbatim. The stripped section stops at the next `[header]`.
|
|
||||||
/// Also drops the template's `schema_version` since we prepend our own.
|
|
||||||
fn rewrite_gateway_section(src: &str) -> String {
|
|
||||||
let mut out = String::from("schema_version = 3\n\n[gateway]\nrequire_pairing = false\n");
|
|
||||||
let mut in_gateway = false;
|
|
||||||
for line in src.lines() {
|
|
||||||
if line.starts_with("schema_version") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if line.starts_with("[gateway]") {
|
|
||||||
in_gateway = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if in_gateway {
|
|
||||||
if line.starts_with('[') {
|
|
||||||
in_gateway = false;
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
out.push_str(line);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to the Docker engine. Uses `DOCKER_HOST` when the compose
|
|
||||||
/// stack points at the socket-proxy sidecar (prod); falls back to the
|
|
||||||
/// local socket for dev.
|
|
||||||
pub fn connect() -> Result<Docker, String> {
|
|
||||||
if let Ok(host) = std::env::var("DOCKER_HOST") {
|
|
||||||
Docker::connect_with_http(&host, 30, bollard::API_DEFAULT_VERSION)
|
|
||||||
.map_err(|e| format!("connect DOCKER_HOST={host}: {e}"))
|
|
||||||
} else {
|
|
||||||
Docker::connect_with_local_defaults().map_err(|e| format!("connect local docker: {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Env vars from the parent server process worth propagating into the
|
|
||||||
/// team runtime — provider config, tokens, gateway port. Filtered by
|
|
||||||
/// prefix so we don't drag `PATH`, `HOME`, unrelated secrets, etc.
|
|
||||||
fn inherited_env() -> Vec<String> {
|
|
||||||
// ZAI_ + KIMI_ added 2026-07-11 — the shared runtime's templated
|
|
||||||
// config points several providers at Z.AI's Anthropic proxy
|
|
||||||
// (`ANTHROPIC_AUTH_TOKEN = "$ZAI_API_KEY"`) and Kimi's cli
|
|
||||||
// provider needs KIMI_API_KEY. Without these, the daemon
|
|
||||||
// substitutes empty strings and every LLM call fails with
|
|
||||||
// "LLM request failed" — the executor then times out at 300s
|
|
||||||
// with no events written.
|
|
||||||
const PREFIXES: &[&str] = &[
|
|
||||||
"ZEROCLAW_",
|
|
||||||
"OPENAI_",
|
|
||||||
"ANTHROPIC_",
|
|
||||||
"GEMINI_",
|
|
||||||
"GROQ_",
|
|
||||||
"ZAI_",
|
|
||||||
"KIMI_",
|
|
||||||
// GITEA_TOKEN + GITEA_HOST propagate so gitea-mcp (spawned as
|
|
||||||
// an MCP subprocess by the team's zeroclaw daemon) + the tea
|
|
||||||
// CLI both authenticate against git.redclaw.dev without a
|
|
||||||
// config file bind-mount.
|
|
||||||
"GITEA_",
|
|
||||||
];
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for (k, v) in std::env::vars() {
|
|
||||||
if PREFIXES.iter().any(|p| k.starts_with(p)) {
|
|
||||||
// The team runtime binds its own listener + workspace — don't
|
|
||||||
// let the parent's ZEROCLAW_GATEWAY_URL leak in and confuse it.
|
|
||||||
if k == "ZEROCLAW_GATEWAY_URL" || k == "ZEROCLAW_WORKSPACE" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(format!("{k}={v}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fixed shape for the team runtime's own listener + workspace root.
|
|
||||||
out.push("ZEROCLAW_GATEWAY_PORT=42617".into());
|
|
||||||
out.push("ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace".into());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn (or reattach to) the per-topic team container. Bind-mounts the
|
|
||||||
/// cloned repo at `/workspace/repo` (rw) and a per-topic state directory
|
|
||||||
/// at `/zeroclaw-data`. Idempotent: if a container by the expected name
|
|
||||||
/// already exists it's left alone; if it exists but isn't running it's
|
|
||||||
/// (re)started. Returns the deterministic gateway URL either way.
|
|
||||||
pub async fn spawn(
|
|
||||||
docker: &Docker,
|
|
||||||
topic_id: Uuid,
|
|
||||||
repo_host_path: &Path,
|
|
||||||
state_host_path: &Path,
|
|
||||||
mcp_bearer: Option<&str>,
|
|
||||||
) -> Result<SpawnedContainer, String> {
|
|
||||||
let name = container_name_for(topic_id);
|
|
||||||
let gateway_url = format!("http://{name}:42617");
|
|
||||||
|
|
||||||
// If a container by this name already exists, just make sure it's
|
|
||||||
// running and return its coordinates. Never blow it away — Commit 3
|
|
||||||
// will add explicit teardown; here we're conservative.
|
|
||||||
match docker
|
|
||||||
.inspect_container(&name, None::<InspectContainerOptions>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(info) => {
|
|
||||||
let running = info.state.as_ref().and_then(|s| s.running).unwrap_or(false);
|
|
||||||
if !running {
|
|
||||||
docker
|
|
||||||
.start_container(&name, None::<StartContainerOptions>)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("start existing {name}: {e}"))?;
|
|
||||||
}
|
|
||||||
return Ok(SpawnedContainer { name, gateway_url });
|
|
||||||
}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 404, ..
|
|
||||||
}) => { /* fall through to create */ }
|
|
||||||
Err(e) => return Err(format!("inspect {name}: {e}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the host state dir exists so the mount doesn't fail with
|
|
||||||
// "no such file or directory" the first time a topic starts.
|
|
||||||
std::fs::create_dir_all(state_host_path)
|
|
||||||
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
|
|
||||||
// Pre-write the daemon config so it boots with pairing disabled —
|
|
||||||
// the shared clawmates-runtime container has a paired_tokens list
|
|
||||||
// maintained out-of-band, but per-topic containers are freshly
|
|
||||||
// spawned with an empty store and would 401 every incoming ws
|
|
||||||
// connect. These containers live on the private clawmates_core
|
|
||||||
// docker network and only accept traffic from the API server, so
|
|
||||||
// disabling pairing here is safe.
|
|
||||||
prewrite_daemon_config_with_risk(state_host_path, None, mcp_bearer)?;
|
|
||||||
|
|
||||||
let mut mounts = vec![
|
|
||||||
Mount {
|
|
||||||
target: Some("/workspace/repo".into()),
|
|
||||||
source: Some(repo_host_path.to_string_lossy().into_owned()),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(false),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
Mount {
|
|
||||||
target: Some("/zeroclaw-data".into()),
|
|
||||||
source: Some(state_host_path.to_string_lossy().into_owned()),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(false),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 2026-07-15 (post-diagnosis): claude CLI in-container runs as
|
|
||||||
// uid=0 and refuses --dangerously-skip-permissions under root for
|
|
||||||
// security. Without pre-approved permissions the CLI hangs waiting
|
|
||||||
// for interactive approval → the whole run stalls until the 600s
|
|
||||||
// provider timeout. Bind-mount a settings.json with
|
|
||||||
// permissions.defaultMode=bypassPermissions so the CLI accepts
|
|
||||||
// requests immediately.
|
|
||||||
//
|
|
||||||
// Path via env so deployments can swap in a different settings
|
|
||||||
// file (e.g. a workspace-specific one) without a code change. When
|
|
||||||
// unset the mount is skipped (dev backwards compat).
|
|
||||||
if let Ok(claude_settings_path) = std::env::var("CLAWMATES_CLAUDE_SETTINGS_PATH") {
|
|
||||||
if !claude_settings_path.is_empty() {
|
|
||||||
mounts.push(Mount {
|
|
||||||
target: Some("/root/.claude/settings.json".into()),
|
|
||||||
source: Some(claude_settings_path),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let host_config = HostConfig {
|
|
||||||
mounts: Some(mounts),
|
|
||||||
network_mode: Some(team_network()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = ContainerCreateBody {
|
|
||||||
image: Some(team_image()),
|
|
||||||
// `--verbose` surfaces the daemon's per-request traces to
|
|
||||||
// stderr so `docker logs` shows why an LLM invocation failed
|
|
||||||
// (bad env substitution, provider unreachable, etc.). Without
|
|
||||||
// this, "LLM request failed" comes back as a 500 with no way
|
|
||||||
// to diagnose from outside.
|
|
||||||
cmd: Some(vec![
|
|
||||||
"daemon".into(),
|
|
||||||
"--host".into(),
|
|
||||||
"0.0.0.0".into(),
|
|
||||||
"--verbose".into(),
|
|
||||||
]),
|
|
||||||
env: Some(inherited_env()),
|
|
||||||
host_config: Some(host_config),
|
|
||||||
labels: Some(HashMap::from([
|
|
||||||
("clawmates.role".into(), "research-team".into()),
|
|
||||||
("clawmates.research.topic_id".into(), topic_id.to_string()),
|
|
||||||
])),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.create_container(
|
|
||||||
Some(CreateContainerOptions {
|
|
||||||
name: Some(name.clone()),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("create {name}: {e}"))?;
|
|
||||||
|
|
||||||
docker
|
|
||||||
.start_container(&name, None::<StartContainerOptions>)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("start {name}: {e}"))?;
|
|
||||||
|
|
||||||
// Attach the default `bridge` network AFTER start so the container
|
|
||||||
// has external egress. Without this the team can join
|
|
||||||
// clawmates_core (Internal=true on gw-04) but can't reach
|
|
||||||
// api.anthropic.com — every LLM call fails with
|
|
||||||
// FailedToOpenSocket and the run times out.
|
|
||||||
attach_external_bridge(docker, &name).await;
|
|
||||||
|
|
||||||
Ok(SpawnedContainer { name, gateway_url })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort attach the container to the default `bridge` docker
|
|
||||||
/// network so it can reach the public internet. Silent on the "already
|
|
||||||
/// attached" case (repeat spawns / restarts). Logs any real failure
|
|
||||||
/// with the container name so a broken network isn't invisible.
|
|
||||||
async fn attach_external_bridge(docker: &Docker, name: &str) {
|
|
||||||
// Use the OpenAPI-generated NetworkConnectRequest — the older
|
|
||||||
// ConnectNetworkOptions was deprecated in bollard 0.19.
|
|
||||||
let req = bollard::models::NetworkConnectRequest {
|
|
||||||
container: Some(name.to_string()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
match docker.connect_network("bridge", req).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 403, ..
|
|
||||||
}) => {
|
|
||||||
// "endpoint already exists on network" — idempotent re-attach.
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("attach_external_bridge({name}): {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll the team gateway's `/health` endpoint until it 200s or the
|
|
||||||
/// deadline passes. Called before firing turns against a freshly-spawned
|
|
||||||
/// container so the executor doesn't try to pair against a not-yet-
|
|
||||||
/// listening daemon. Uses reqwest directly — the deadline caps total
|
|
||||||
/// wait so a broken image doesn't hang the worker forever.
|
|
||||||
pub async fn wait_ready(gateway_url: &str, deadline: std::time::Duration) -> Result<(), String> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_millis(1500))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("client build: {e}"))?;
|
|
||||||
let url = format!("{}/health", gateway_url.trim_end_matches('/'));
|
|
||||||
let mut last_err = String::from("no attempt");
|
|
||||||
while start.elapsed() < deadline {
|
|
||||||
match client.get(&url).send().await {
|
|
||||||
Ok(res) if res.status().is_success() => return Ok(()),
|
|
||||||
Ok(res) => last_err = format!("HTTP {}", res.status()),
|
|
||||||
Err(e) => last_err = e.to_string(),
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
||||||
}
|
|
||||||
Err(format!(
|
|
||||||
"team gateway never became ready ({url}): {last_err}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop and remove the per-topic container. Called on topic delete and on
|
|
||||||
/// terminal-state cleanup. Idempotent: a missing container is a no-op.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn stop(docker: &Docker, name: &str) -> Result<(), String> {
|
|
||||||
match docker
|
|
||||||
.stop_container(name, None::<StopContainerOptions>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 404, ..
|
|
||||||
}) => return Ok(()),
|
|
||||||
// 304 = already stopped — fine.
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 304, ..
|
|
||||||
}) => {}
|
|
||||||
Err(e) => return Err(format!("stop {name}: {e}")),
|
|
||||||
}
|
|
||||||
match docker
|
|
||||||
.remove_container(
|
|
||||||
name,
|
|
||||||
Some(RemoveContainerOptions {
|
|
||||||
force: true,
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 404, ..
|
|
||||||
}) => Ok(()),
|
|
||||||
Err(e) => Err(format!("remove {name}: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire-and-forget teardown for a topic's runtime — called from
|
|
||||||
/// `approve_publish` (topic reaches terminal `publishing` state) and
|
|
||||||
/// `delete_topic`. Non-fatal: Docker unreachable or container already
|
|
||||||
/// gone both log a debug line and return so the API response stays
|
|
||||||
/// clean. Callers should NOT `await?` on this — the API contract is
|
|
||||||
/// "the topic is done" whether Docker is reachable or not.
|
|
||||||
pub async fn teardown(topic_id: Uuid) {
|
|
||||||
let name = container_name_for(topic_id);
|
|
||||||
let docker = match connect() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
// Dev-machine no-docker path — silently no-op. Prod always
|
|
||||||
// has the socket-proxy sidecar, so this branch is a signal
|
|
||||||
// rather than a warning.
|
|
||||||
eprintln!("research_container::teardown({topic_id}): docker connect failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match stop(&docker, &name).await {
|
|
||||||
Ok(_) => eprintln!("research_container::teardown({topic_id}): removed {name}"),
|
|
||||||
Err(e) => eprintln!("research_container::teardown({topic_id}): stop {name} failed: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Loop container isolation (P2) ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Loops don't have a repo like research does, so their container has ONE
|
|
||||||
// bind mount (per-loop state at /zeroclaw-data) instead of two. Same daemon
|
|
||||||
// image, same network, same env — different name and label so we can tell
|
|
||||||
// research-team containers apart from loop-team ones at a glance.
|
|
||||||
|
|
||||||
/// Deterministic container name for a loop.
|
|
||||||
pub fn loop_container_name_for(loop_id: Uuid) -> String {
|
|
||||||
format!("loop-{loop_id}-team")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn (or reattach to) the per-loop team container. Same idempotent
|
|
||||||
/// pattern as `spawn` — if the container exists it's just (re)started.
|
|
||||||
pub async fn spawn_loop(
|
|
||||||
docker: &Docker,
|
|
||||||
loop_id: Uuid,
|
|
||||||
state_host_path: &Path,
|
|
||||||
mcp_bearer: Option<&str>,
|
|
||||||
) -> Result<SpawnedContainer, String> {
|
|
||||||
let name = loop_container_name_for(loop_id);
|
|
||||||
let gateway_url = format!("http://{name}:42617");
|
|
||||||
|
|
||||||
match docker
|
|
||||||
.inspect_container(&name, None::<InspectContainerOptions>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(info) => {
|
|
||||||
let running = info.state.as_ref().and_then(|s| s.running).unwrap_or(false);
|
|
||||||
if !running {
|
|
||||||
docker
|
|
||||||
.start_container(&name, None::<StartContainerOptions>)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("start existing {name}: {e}"))?;
|
|
||||||
}
|
|
||||||
return Ok(SpawnedContainer { name, gateway_url });
|
|
||||||
}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 404, ..
|
|
||||||
}) => { /* create below */ }
|
|
||||||
Err(e) => return Err(format!("inspect {name}: {e}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::create_dir_all(state_host_path)
|
|
||||||
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
|
|
||||||
// Same pairing bypass as `spawn` — per-loop containers are
|
|
||||||
// ephemeral, on a private docker network, and freshly created.
|
|
||||||
prewrite_daemon_config_with_risk(state_host_path, None, mcp_bearer)?;
|
|
||||||
|
|
||||||
let mounts = vec![Mount {
|
|
||||||
target: Some("/zeroclaw-data".into()),
|
|
||||||
source: Some(state_host_path.to_string_lossy().into_owned()),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(false),
|
|
||||||
..Default::default()
|
|
||||||
}];
|
|
||||||
|
|
||||||
let host_config = HostConfig {
|
|
||||||
mounts: Some(mounts),
|
|
||||||
network_mode: Some(team_network()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = ContainerCreateBody {
|
|
||||||
image: Some(team_image()),
|
|
||||||
// `--verbose` surfaces the daemon's per-request traces to
|
|
||||||
// stderr so `docker logs` shows why an LLM invocation failed
|
|
||||||
// (bad env substitution, provider unreachable, etc.). Without
|
|
||||||
// this, "LLM request failed" comes back as a 500 with no way
|
|
||||||
// to diagnose from outside.
|
|
||||||
cmd: Some(vec![
|
|
||||||
"daemon".into(),
|
|
||||||
"--host".into(),
|
|
||||||
"0.0.0.0".into(),
|
|
||||||
"--verbose".into(),
|
|
||||||
]),
|
|
||||||
env: Some(inherited_env()),
|
|
||||||
host_config: Some(host_config),
|
|
||||||
labels: Some(HashMap::from([
|
|
||||||
("clawmates.role".into(), "loop-team".into()),
|
|
||||||
("clawmates.loop.id".into(), loop_id.to_string()),
|
|
||||||
])),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.create_container(
|
|
||||||
Some(CreateContainerOptions {
|
|
||||||
name: Some(name.clone()),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("create {name}: {e}"))?;
|
|
||||||
|
|
||||||
docker
|
|
||||||
.start_container(&name, None::<StartContainerOptions>)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("start {name}: {e}"))?;
|
|
||||||
|
|
||||||
// Same reason as `spawn` — clawmates_core is Internal=true; without
|
|
||||||
// bridge the loop container can't reach LLM APIs.
|
|
||||||
attach_external_bridge(docker, &name).await;
|
|
||||||
|
|
||||||
Ok(SpawnedContainer { name, gateway_url })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire-and-forget teardown for a loop's runtime — called from
|
|
||||||
/// `disable_loop`, `delete_loop`, and any terminal transition. Same
|
|
||||||
/// non-fatal semantics as `teardown` (Docker unreachable and container
|
|
||||||
/// already-gone both log and return so the API contract is unaffected).
|
|
||||||
pub async fn teardown_loop(loop_id: Uuid) {
|
|
||||||
let name = loop_container_name_for(loop_id);
|
|
||||||
let docker = match connect() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("research_container::teardown_loop({loop_id}): docker connect failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match stop(&docker, &name).await {
|
|
||||||
Ok(_) => eprintln!("research_container::teardown_loop({loop_id}): removed {name}"),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("research_container::teardown_loop({loop_id}): stop {name} failed: {e}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 0046 slice 3b: per-team containers ─────────────────────────────
|
|
||||||
//
|
|
||||||
// A `team-<team_id>-container` runs the same ZeroClaw daemon image
|
|
||||||
// as research-* + loop-* containers but binds the paired research
|
|
||||||
// topic's repo at /workspace/repo (rw, so a coding team can actually
|
|
||||||
// write patches) and injects the team's risk_profile into every
|
|
||||||
// [agents.<name>] binding before boot. That lets a coding team
|
|
||||||
// operate under `coding_readwrite` without loosening the research
|
|
||||||
// team's read-only posture on the sibling container.
|
|
||||||
//
|
|
||||||
// State dir: /var/lib/clawmates-team-state/<team_id>/state — separate
|
|
||||||
// from research (per-topic) and loop (per-loop) dirs so the three
|
|
||||||
// families can't step on each other's config/brain/workspace state.
|
|
||||||
|
|
||||||
/// Deterministic container name for a team-scoped runtime.
|
|
||||||
pub fn team_container_name_for(team_id: Uuid) -> String {
|
|
||||||
format!("team-{team_id}-container")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Root for per-team state dirs on the docker host. Overridable via
|
|
||||||
/// `CLAWMATES_TEAM_STATE_ROOT` for local dev / relocations.
|
|
||||||
pub fn team_state_root(team_id: Uuid) -> std::path::PathBuf {
|
|
||||||
let root = std::env::var("CLAWMATES_TEAM_STATE_ROOT")
|
|
||||||
.unwrap_or_else(|_| "/var/lib/clawmates-team-state".to_string());
|
|
||||||
std::path::PathBuf::from(root)
|
|
||||||
.join(team_id.to_string())
|
|
||||||
.join("state")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as `prewrite_daemon_config` but also rewrites every
|
|
||||||
/// `[agents.<name>]` block's `risk_profile = "..."` line to the given
|
|
||||||
/// override. Idempotent: skips if the config file already exists.
|
|
||||||
///
|
|
||||||
/// The rewrite is line-based so it can't accidentally eat a `[...]`
|
|
||||||
/// array literal (which the earlier regex-based patcher on gw-04
|
|
||||||
/// tripped over): only lines that both (a) live inside an
|
|
||||||
/// `[agents.<name>]` table AND (b) start with the literal
|
|
||||||
/// `risk_profile = "` prefix are touched.
|
|
||||||
fn prewrite_daemon_config_with_risk(
|
|
||||||
state_host_path: &Path,
|
|
||||||
risk_profile_override: Option<&str>,
|
|
||||||
mcp_bearer_override: Option<&str>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
prewrite_daemon_config(state_host_path)?;
|
|
||||||
if risk_profile_override.is_none() && mcp_bearer_override.is_none() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let cfg_path = state_host_path.join(".zeroclaw/config.toml");
|
|
||||||
let src = std::fs::read_to_string(&cfg_path)
|
|
||||||
.map_err(|e| format!("read {}: {e}", cfg_path.display()))?;
|
|
||||||
let mut out = String::with_capacity(src.len());
|
|
||||||
let mut in_agent_block = false;
|
|
||||||
let mut in_mcp_clawmates = false;
|
|
||||||
let mut current_mcp_name: Option<String> = None;
|
|
||||||
let risk_replacement = risk_profile_override.map(|n| format!("risk_profile = \"{n}\"\n"));
|
|
||||||
for line in src.lines() {
|
|
||||||
let trimmed = line.trim_start();
|
|
||||||
if line.starts_with("[agents.") {
|
|
||||||
in_agent_block = true;
|
|
||||||
in_mcp_clawmates = false;
|
|
||||||
current_mcp_name = None;
|
|
||||||
out.push_str(line);
|
|
||||||
out.push('\n');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if line.starts_with("[[mcp.servers]]") {
|
|
||||||
in_agent_block = false;
|
|
||||||
in_mcp_clawmates = false;
|
|
||||||
current_mcp_name = Some(String::new());
|
|
||||||
out.push_str(line);
|
|
||||||
out.push('\n');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if line.starts_with('[') {
|
|
||||||
in_agent_block = line.starts_with("[agents.");
|
|
||||||
in_mcp_clawmates = false;
|
|
||||||
current_mcp_name = None;
|
|
||||||
}
|
|
||||||
// Track name within an [[mcp.servers]] block so we only rewrite
|
|
||||||
// the `clawmates` server's Authorization header, not others.
|
|
||||||
if current_mcp_name.is_some() && trimmed.starts_with("name") {
|
|
||||||
if let Some(v) = trimmed.split('=').nth(1) {
|
|
||||||
let v = v.trim().trim_matches('"');
|
|
||||||
// Both the `clawmates` (door) and `clawmates_skills`
|
|
||||||
// (Slice 3.5b MCP resources server) point at cm-api,
|
|
||||||
// so their bearers get the same workspace-owner
|
|
||||||
// session token rewrite.
|
|
||||||
if v == "clawmates" || v == "clawmates_skills" {
|
|
||||||
in_mcp_clawmates = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if in_mcp_clawmates && trimmed.starts_with("headers") && trimmed.contains("Authorization") {
|
|
||||||
if let Some(bearer) = mcp_bearer_override {
|
|
||||||
out.push_str(&format!(
|
|
||||||
"headers = {{ Authorization = \"Bearer {bearer}\" }}\n"
|
|
||||||
));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if in_agent_block && trimmed.starts_with("risk_profile = \"") {
|
|
||||||
if let Some(r) = &risk_replacement {
|
|
||||||
out.push_str(r);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push_str(line);
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
std::fs::write(&cfg_path, out).map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn (or reattach to) a team-scoped ZeroClaw container.
|
|
||||||
///
|
|
||||||
/// Bind-mounts `repo_host_path` at `/workspace/repo` (RW — coding teams
|
|
||||||
/// write here) plus `state_host_path` at `/zeroclaw-data`. When set,
|
|
||||||
/// `risk_profile` gets stamped into every `[agents.*]` binding in the
|
|
||||||
/// pre-written config so all roles inherit the team's constitution.
|
|
||||||
///
|
|
||||||
/// Idempotent on the container name so a re-fire of the topology
|
|
||||||
/// worker reattaches instead of blowing up.
|
|
||||||
pub async fn spawn_team(
|
|
||||||
docker: &Docker,
|
|
||||||
team_id: Uuid,
|
|
||||||
repo_host_path: &Path,
|
|
||||||
state_host_path: &Path,
|
|
||||||
risk_profile: Option<&str>,
|
|
||||||
mcp_bearer: Option<&str>,
|
|
||||||
) -> Result<SpawnedContainer, String> {
|
|
||||||
let name = team_container_name_for(team_id);
|
|
||||||
let gateway_url = format!("http://{name}:42617");
|
|
||||||
|
|
||||||
match docker
|
|
||||||
.inspect_container(&name, None::<InspectContainerOptions>)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(info) => {
|
|
||||||
let running = info.state.as_ref().and_then(|s| s.running).unwrap_or(false);
|
|
||||||
if !running {
|
|
||||||
docker
|
|
||||||
.start_container(&name, None::<StartContainerOptions>)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("start existing {name}: {e}"))?;
|
|
||||||
}
|
|
||||||
attach_external_bridge(docker, &name).await;
|
|
||||||
return Ok(SpawnedContainer { name, gateway_url });
|
|
||||||
}
|
|
||||||
Err(bollard::errors::Error::DockerResponseServerError {
|
|
||||||
status_code: 404, ..
|
|
||||||
}) => { /* fall through to create */ }
|
|
||||||
Err(e) => return Err(format!("inspect {name}: {e}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::create_dir_all(state_host_path)
|
|
||||||
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
|
|
||||||
prewrite_daemon_config_with_risk(state_host_path, risk_profile, mcp_bearer)?;
|
|
||||||
|
|
||||||
let mut mounts = vec![
|
|
||||||
Mount {
|
|
||||||
target: Some("/workspace/repo".into()),
|
|
||||||
source: Some(repo_host_path.to_string_lossy().into_owned()),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(false),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
Mount {
|
|
||||||
target: Some("/zeroclaw-data".into()),
|
|
||||||
source: Some(state_host_path.to_string_lossy().into_owned()),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(false),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if let Ok(claude_settings_path) = std::env::var("CLAWMATES_CLAUDE_SETTINGS_PATH") {
|
|
||||||
if !claude_settings_path.is_empty() {
|
|
||||||
mounts.push(Mount {
|
|
||||||
target: Some("/root/.claude/settings.json".into()),
|
|
||||||
source: Some(claude_settings_path),
|
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let host_config = HostConfig {
|
|
||||||
mounts: Some(mounts),
|
|
||||||
network_mode: Some(team_network()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let body = ContainerCreateBody {
|
|
||||||
image: Some(team_image()),
|
|
||||||
cmd: Some(vec![
|
|
||||||
"daemon".into(),
|
|
||||||
"--host".into(),
|
|
||||||
"0.0.0.0".into(),
|
|
||||||
"--verbose".into(),
|
|
||||||
]),
|
|
||||||
env: Some(inherited_env()),
|
|
||||||
host_config: Some(host_config),
|
|
||||||
labels: Some(HashMap::from([
|
|
||||||
("clawmates.role".into(), "team".into()),
|
|
||||||
("clawmates.team_id".into(), team_id.to_string()),
|
|
||||||
])),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
docker
|
|
||||||
.create_container(
|
|
||||||
Some(CreateContainerOptions {
|
|
||||||
name: Some(name.clone()),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("create {name}: {e}"))?;
|
|
||||||
docker
|
|
||||||
.start_container(&name, None::<StartContainerOptions>)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("start {name}: {e}"))?;
|
|
||||||
attach_external_bridge(docker, &name).await;
|
|
||||||
|
|
||||||
Ok(SpawnedContainer { name, gateway_url })
|
|
||||||
}
|
|
||||||
@@ -1,898 +0,0 @@
|
|||||||
//! Loop endpoints — CRUD, enable/disable, immediate-run, and the public
|
|
||||||
//! webhook receiver.
|
|
||||||
//!
|
|
||||||
//! GET /api/loops list workspace's loops
|
|
||||||
//! POST /api/loops create
|
|
||||||
//! GET /api/loops/:id detail
|
|
||||||
//! PATCH /api/loops/:id update definition
|
|
||||||
//! DELETE /api/loops/:id delete
|
|
||||||
//! POST /api/loops/:id/run trigger one iteration NOW (bypass schedule)
|
|
||||||
//! POST /api/loops/:id/enable set enabled=true; recomputes next_fire_at
|
|
||||||
//! POST /api/loops/:id/disable set enabled=false
|
|
||||||
//! POST /webhooks/loops/:token public; HMAC-SHA256-verified via
|
|
||||||
//! X-Loop-Signature: sha256=<hex>
|
|
||||||
|
|
||||||
use axum::body::Bytes;
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
|
||||||
use axum::Json;
|
|
||||||
use base64::Engine;
|
|
||||||
use cm_runtime::scheduling::next_occurrence;
|
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Root of per-loop state dirs on the host. Same overridable env pattern
|
|
||||||
/// as research_workspace_root — prod points at the bind-mounted volume
|
|
||||||
/// `/var/lib/clawmates-loops` on gw-04.
|
|
||||||
fn loop_state_root() -> std::path::PathBuf {
|
|
||||||
std::env::var("CLAWMATES_LOOPS_STATE_ROOT")
|
|
||||||
.map(std::path::PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from("/var/lib/clawmates-loops"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort spawn of the per-loop team container before an iteration
|
|
||||||
/// is enqueued. Idempotent — an already-running container is just
|
|
||||||
/// reattached. Failures (docker unreachable, image missing) log and
|
|
||||||
/// return without blocking the run; the topology_worker will fall back
|
|
||||||
/// to the workspace-wide gateway. Records the container name + URL on
|
|
||||||
/// the loop row on first success so subsequent fires skip re-writing.
|
|
||||||
/// Build the task string an iteration will actually run.
|
|
||||||
///
|
|
||||||
/// - Standalone loops (no source research topic bound): returns
|
|
||||||
/// `task_template` verbatim, matching legacy behavior.
|
|
||||||
/// - Loops bound to a research topic: fetches the topic's latest
|
|
||||||
/// research_outcome and prepends a block of the shape:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// RESEARCH ARTIFACT (integration plan you're executing):
|
|
||||||
/// <markdown>
|
|
||||||
/// ITERATION FOCUS: next unconsumed INT-XX in order. If prereqs are
|
|
||||||
/// unmet, work on the smallest unblocking INT-XX. Log
|
|
||||||
/// COMPLETED: INT-<NN> at the end so the loop can advance.
|
|
||||||
/// ORIGINAL TASK:
|
|
||||||
/// <task_template>
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// The topology_worker's completion hook (P3) parses the COMPLETED
|
|
||||||
/// marker to update `consumed_int_ids`.
|
|
||||||
/// One-shot compose + enqueue for a loop iteration. Reads the loop's
|
|
||||||
/// kind from the DB and dispatches: kind='exec' uses
|
|
||||||
/// compose_iteration_task (INT-consumption prepend); kind='research'
|
|
||||||
/// uses compose_research_iteration_task AND sets research_topic_id on
|
|
||||||
/// the topology_run so freeze_research_outcome writes a new outcome
|
|
||||||
/// version at completion. Returns the run id. Standalone exec loops
|
|
||||||
/// (no source topic) still work — the compose helper returns the
|
|
||||||
/// task_template verbatim.
|
|
||||||
pub async fn compose_and_enqueue_iteration(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
graph: &Value,
|
|
||||||
parent_run_id: Option<Uuid>,
|
|
||||||
task_template_override: Option<&str>,
|
|
||||||
) -> Result<Uuid, cm_db::DbError> {
|
|
||||||
// Kind is the source of truth — task_template alone isn't enough
|
|
||||||
// to know whether to write to research_outcomes.
|
|
||||||
let (kind, source_topic, template) =
|
|
||||||
match cm_db::repo::loops::kind_and_binding(pool, loop_id).await? {
|
|
||||||
Some(t) => t,
|
|
||||||
None => return Err(cm_db::DbError::NotFound),
|
|
||||||
};
|
|
||||||
let template_ref = task_template_override.unwrap_or(&template);
|
|
||||||
let iter = cm_db::repo::loops::next_iteration(pool, loop_id).await?;
|
|
||||||
if kind == "research" {
|
|
||||||
// Research-kind requires a bound topic (schema-level constraint
|
|
||||||
// isn't enforced yet — surface the misconfiguration explicitly).
|
|
||||||
let Some(topic_id) = source_topic else {
|
|
||||||
return Err(cm_db::DbError::NotFound);
|
|
||||||
};
|
|
||||||
// Clone + spawn container BEFORE enqueuing so the run has real
|
|
||||||
// repo files + an isolated daemon to hit. Idempotent — the
|
|
||||||
// second iteration reattaches to the existing container. Runs
|
|
||||||
// even when the topic has no repo (harmless no-op).
|
|
||||||
crate::routes::research_setup::prepare_topic_runtime(pool, workspace_id, topic_id).await;
|
|
||||||
// D1 fold — advance the topic's status column when a fresh
|
|
||||||
// research iteration goes out so the canvas's classic state-
|
|
||||||
// machine card reflects reality. Only fire the standby →
|
|
||||||
// processing transition; later iterations already sit in
|
|
||||||
// processing/reviewing/publishing and set_status is a no-op
|
|
||||||
// when the status is already the target.
|
|
||||||
let _ = cm_db::repo::research_topics::set_status_if(
|
|
||||||
pool,
|
|
||||||
topic_id,
|
|
||||||
workspace_id,
|
|
||||||
"standby",
|
|
||||||
"processing",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let task = compose_research_iteration_task(pool, topic_id, template_ref).await;
|
|
||||||
cm_db::repo::loops::enqueue_iteration_with_topic(
|
|
||||||
pool,
|
|
||||||
cm_db::repo::loops::IterationEnqueue {
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
task: &task,
|
|
||||||
graph,
|
|
||||||
iteration: iter,
|
|
||||||
parent_run_id,
|
|
||||||
research_topic_id: Some(topic_id),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
let task = compose_iteration_task(pool, loop_id, template_ref).await;
|
|
||||||
cm_db::repo::loops::enqueue_iteration(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
&task,
|
|
||||||
graph,
|
|
||||||
iter,
|
|
||||||
parent_run_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the coordinator prompt for a kind='research' loop iteration.
|
|
||||||
/// Wraps the topic's description + outcome_kind + prior artifact
|
|
||||||
/// version pointer into an instruction that asks the team to refresh
|
|
||||||
/// the plan (survey new sources, revise existing INTs, add new ones)
|
|
||||||
/// and emit the updated artifact using the same section shape. The
|
|
||||||
/// completion hook's `freeze_research_outcome` will insert a new
|
|
||||||
/// versioned row automatically because the topology_run carries
|
|
||||||
/// research_topic_id.
|
|
||||||
pub async fn compose_research_iteration_task(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
task_template: &str,
|
|
||||||
) -> String {
|
|
||||||
let (title, description, outcome_kind, prior_version) =
|
|
||||||
match cm_db::repo::research_topics::get_any_workspace(pool, topic_id).await {
|
|
||||||
Ok(Some(t)) => {
|
|
||||||
let prior = cm_db::repo::research_outcomes::latest(pool, topic_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None)
|
|
||||||
.map(|o| o.version)
|
|
||||||
.unwrap_or(0);
|
|
||||||
(t.title, t.description, t.outcome_kind, prior)
|
|
||||||
}
|
|
||||||
_ => return task_template.to_string(),
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"RESEARCH LOOP ITERATION\n\
|
|
||||||
=======================\n\
|
|
||||||
Topic: {title}\n\
|
|
||||||
Outcome kind: {outcome_kind}\n\
|
|
||||||
Prior artifact version: v{prior_version} (0 = fresh)\n\n\
|
|
||||||
DESCRIPTION:\n{description}\n\n\
|
|
||||||
AUTONOMY CONTRACT (READ FIRST):\n\
|
|
||||||
- This is a scheduled autonomous run. NO HUMAN WILL ANSWER YOU.\n\
|
|
||||||
- Do NOT ask 'Should I proceed?' or 'Which approach?' — proceed with\n\
|
|
||||||
your best judgment and produce the artifact.\n\
|
|
||||||
- You MUST emit the completed artifact as your final message.\n\
|
|
||||||
Failure to emit = the entire loop iteration is wasted.\n\n\
|
|
||||||
YOUR JOB THIS ITERATION:\n\
|
|
||||||
- Refresh the research — pull in any new papers / findings since v{prior_version}.\n\
|
|
||||||
- Update the artifact using the SAME section structure the outcome_kind\n\
|
|
||||||
requires (e.g. integrations kind = executive summary + INT-XX cards).\n\
|
|
||||||
- Preserve stable ids (INT-01 stays INT-01 across versions). If an item\n\
|
|
||||||
is superseded, mark it {{deprecated: <reason>}} rather than deleting so\n\
|
|
||||||
downstream coding loops that already consumed it don't lose context.\n\
|
|
||||||
- Add NEW items with new ids continuing from the last used number.\n\
|
|
||||||
- Cite what you can verify. When you can't cite a specific paper or\n\
|
|
||||||
benchmark, write `[claim needs verification]` inline and MOVE ON — do\n\
|
|
||||||
not stall the loop asking a human for permission. The next iteration\n\
|
|
||||||
can strengthen citations; a written v{} with rough citations beats a\n\
|
|
||||||
blocked v{} waiting for approval.\n\
|
|
||||||
- Do NOT fabricate concrete paper titles, author names, or DOIs.\n\
|
|
||||||
Vague-but-honest ('a 2024 HNSW improvement paper') beats invented specifics.\n\n\
|
|
||||||
The workspace's final synthesis is captured as research_outcomes v{}. \
|
|
||||||
Downstream on_artifact_update loops will wake up on this write.\n\n\
|
|
||||||
LOOP OPERATOR NOTES:\n{task_template}\n",
|
|
||||||
prior_version + 1,
|
|
||||||
prior_version + 1,
|
|
||||||
prior_version + 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn compose_iteration_task(pool: &PgPool, loop_id: Uuid, task_template: &str) -> String {
|
|
||||||
let ctx = cm_db::repo::loops::source_research_context(pool, loop_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
let Some((topic_id, consumed, current_idx)) = ctx else {
|
|
||||||
return task_template.to_string();
|
|
||||||
};
|
|
||||||
let outcome = match cm_db::repo::research_outcomes::latest(pool, topic_id).await {
|
|
||||||
Ok(Some(o)) => o,
|
|
||||||
_ => return task_template.to_string(),
|
|
||||||
};
|
|
||||||
let consumed_list = if consumed.is_empty() {
|
|
||||||
"(none yet)".to_string()
|
|
||||||
} else {
|
|
||||||
consumed.join(", ")
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"RESEARCH ARTIFACT (integration plan you're executing, v{}):\n\
|
|
||||||
--- BEGIN ARTIFACT ---\n{}\n--- END ARTIFACT ---\n\n\
|
|
||||||
ITERATION FOCUS:\n\
|
|
||||||
- You are on iteration index {}.\n\
|
|
||||||
- Already completed: {}.\n\
|
|
||||||
- Address the NEXT unconsumed INT-XX item in the artifact, in order.\n\
|
|
||||||
- If the next item has unmet prerequisites, work on the smallest\n\
|
|
||||||
unblocking INT-XX instead. When you reorder, emit a line\n\
|
|
||||||
`REORDER: <one-sentence rationale>` at the top of your first\n\
|
|
||||||
substantive turn — the loop indexes these for a review timeline.\n\
|
|
||||||
- Emit `COMPLETED: INT-<NN>` on its own line at the end of the run\n\
|
|
||||||
when the item is done — the loop advances on that marker.\n\
|
|
||||||
- Both markers must appear literally with the colon (no bold, no\n\
|
|
||||||
code fence); the parser is line-based.\n\n\
|
|
||||||
ORIGINAL TASK TEMPLATE:\n{}\n",
|
|
||||||
outcome.version, outcome.body_md, current_idx, consumed_list, task_template
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_loop_container(pool: &PgPool, workspace_id: Uuid, loop_id: Uuid) {
|
|
||||||
let docker = match crate::research_container::connect() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("loops::ensure_loop_container({loop_id}): docker connect failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let state_root = loop_state_root().join(loop_id.to_string()).join("state");
|
|
||||||
let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(
|
|
||||||
pool,
|
|
||||||
cm_domain::WorkspaceId::from(workspace_id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("loops::ensure_loop_container({loop_id}): mint MCP bearer failed: {e}");
|
|
||||||
e
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
let spawned = match crate::research_container::spawn_loop(
|
|
||||||
&docker,
|
|
||||||
loop_id,
|
|
||||||
&state_root,
|
|
||||||
mcp_bearer.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("loops::ensure_loop_container({loop_id}): spawn failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(e) = cm_db::repo::loops::set_zeroclaw_container(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
&spawned.name,
|
|
||||||
&spawned.gateway_url,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("loops::ensure_loop_container({loop_id}): persist failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::{ApiError, AppState, Authed};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CreateLoopRequest {
|
|
||||||
pub title: String,
|
|
||||||
pub description: String,
|
|
||||||
pub graph: Value,
|
|
||||||
pub task_template: String,
|
|
||||||
/// {cron?: '0 */6 * * *', on_completion?: bool, webhook_enabled?: bool}
|
|
||||||
#[serde(default)]
|
|
||||||
pub triggers: Value,
|
|
||||||
/// {kind: 'infinite' | 'iters' | 'until', n?: int}
|
|
||||||
#[serde(default = "default_repeat")]
|
|
||||||
pub repeat_policy: Value,
|
|
||||||
#[serde(default)]
|
|
||||||
pub agents: Vec<AgentSlotInput>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub teams: Vec<Uuid>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub orgs: Vec<Uuid>,
|
|
||||||
/// Optional research topic id. When set, each iteration prepends the
|
|
||||||
/// topic's latest research_outcome markdown + a "focus on next
|
|
||||||
/// unconsumed INT" instruction to the coordinator task. Migration
|
|
||||||
/// 0042 added the pointer column + consumed_int_ids tracking.
|
|
||||||
#[serde(default)]
|
|
||||||
pub source_research_topic_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
fn default_repeat() -> Value {
|
|
||||||
serde_json::json!({"kind": "infinite"})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct AgentSlotInput {
|
|
||||||
pub agent_id: Uuid,
|
|
||||||
#[serde(default)]
|
|
||||||
pub role_slot: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct LoopCreated {
|
|
||||||
pub id: Uuid,
|
|
||||||
/// Set when `triggers.webhook_enabled == true`. The full URL is
|
|
||||||
/// `<origin>/webhooks/loops/<webhook_token>`; the signing key is
|
|
||||||
/// returned exactly once at creation and never surfaced again.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub webhook_token: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub webhook_signing_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_triggers(v: &Value) -> Option<Triggers> {
|
|
||||||
serde_json::from_value(v.clone()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Triggers {
|
|
||||||
#[serde(default)]
|
|
||||||
cron: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
on_completion: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
webhook_enabled: bool,
|
|
||||||
/// NEW — number of iterations to fire back-to-back at loop-create
|
|
||||||
/// time. Enqueue path: fire once immediately, then chain each
|
|
||||||
/// subsequent one via on_completion until the burst quota is
|
|
||||||
/// exhausted (tracked in run metadata). Defaults to 0 for existing
|
|
||||||
/// loops (no auto-fire); new wizards typically set 1 (D1: every
|
|
||||||
/// runnable thing runs at least once).
|
|
||||||
#[serde(default)]
|
|
||||||
initial_burst: u32,
|
|
||||||
/// NEW — when this loop is bound to a source_research_topic and
|
|
||||||
/// that topic gets a fresh research_outcomes row (via
|
|
||||||
/// freeze_research_outcome), enqueue one iteration on this loop.
|
|
||||||
/// Coalesced with any in-flight run (D3: coordinator resolves;
|
|
||||||
/// no race, just one wake per artifact update). Read directly
|
|
||||||
/// from the loops.triggers jsonb by loops_awaiting_topic — no
|
|
||||||
/// need for the Rust parser to hold it after the fact.
|
|
||||||
#[serde(default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
on_artifact_update: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_webhook_material() -> (String, String) {
|
|
||||||
// 24 bytes ≈ 192 bits of entropy each; URL-safe base64 for the token,
|
|
||||||
// standard base64 for the signing key.
|
|
||||||
let mut token_buf = [0u8; 24];
|
|
||||||
let mut key_buf = [0u8; 24];
|
|
||||||
// getrandom is already in the dep tree via base64/hmac/etc; failure
|
|
||||||
// (broken kernel RNG) is fatal enough that unwrapping is fine here.
|
|
||||||
getrandom::getrandom(&mut token_buf).expect("OS RNG");
|
|
||||||
getrandom::getrandom(&mut key_buf).expect("OS RNG");
|
|
||||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token_buf);
|
|
||||||
let key = base64::engine::general_purpose::STANDARD_NO_PAD.encode(key_buf);
|
|
||||||
(token, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_next_fire(triggers: &Value) -> Option<OffsetDateTime> {
|
|
||||||
let t = parse_triggers(triggers)?;
|
|
||||||
let pattern = t.cron?;
|
|
||||||
if pattern.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
next_occurrence(pattern.trim(), OffsetDateTime::now_utc()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_loop(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Json(body): Json<CreateLoopRequest>,
|
|
||||||
) -> Result<(StatusCode, Json<LoopCreated>), ApiError> {
|
|
||||||
if body.title.trim().is_empty() || body.task_template.trim().is_empty() {
|
|
||||||
return Err(ApiError::BadRequest);
|
|
||||||
}
|
|
||||||
// Empty-roster gate: a workspace with zero agents has nothing to staff
|
|
||||||
// the loop with — refuse before any DB writes. Frontend already
|
|
||||||
// disables the create button in this state; this closes the direct-POST
|
|
||||||
// hole so we don't materialize orphan loops that never fire.
|
|
||||||
if cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await? == 0 {
|
|
||||||
return Err(ApiError::Conflict);
|
|
||||||
}
|
|
||||||
let webhook_enabled = parse_triggers(&body.triggers)
|
|
||||||
.map(|t| t.webhook_enabled)
|
|
||||||
.unwrap_or(false);
|
|
||||||
let (webhook_token, webhook_signing_key) = if webhook_enabled {
|
|
||||||
let (t, k) = make_webhook_material();
|
|
||||||
(Some(t), Some(k))
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
let next_fire_at = compute_next_fire(&body.triggers);
|
|
||||||
|
|
||||||
let id = cm_db::repo::loops::create(
|
|
||||||
&state.pool,
|
|
||||||
cm_db::repo::loops::NewLoop {
|
|
||||||
workspace_id: user.workspace_id.as_uuid(),
|
|
||||||
title: body.title.trim(),
|
|
||||||
description: body.description.trim(),
|
|
||||||
graph: &body.graph,
|
|
||||||
task_template: body.task_template.trim(),
|
|
||||||
triggers: &body.triggers,
|
|
||||||
repeat_policy: &body.repeat_policy,
|
|
||||||
enabled: true,
|
|
||||||
next_fire_at,
|
|
||||||
webhook_token: webhook_token.as_deref(),
|
|
||||||
webhook_signing_key: webhook_signing_key.as_deref(),
|
|
||||||
created_by: user.user_id.as_uuid(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
apply_staffing(&state.pool, id, &body.agents, &body.teams, &body.orgs).await?;
|
|
||||||
|
|
||||||
// Bridge to research (option C — snapshot in task_template + save
|
|
||||||
// pointer so a refresh can pull latest artifact into subsequent
|
|
||||||
// iterations). Ownership-checked via research_topics::get so we
|
|
||||||
// can't be tricked into pointing at another workspace's topic.
|
|
||||||
if let Some(topic_id) = body.source_research_topic_id {
|
|
||||||
let topic =
|
|
||||||
cm_db::repo::research_topics::get(&state.pool, topic_id, user.workspace_id.as_uuid())
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
if let Err(e) = cm_db::repo::loops::set_source_research_topic(
|
|
||||||
&state.pool,
|
|
||||||
id,
|
|
||||||
user.workspace_id.as_uuid(),
|
|
||||||
Some(topic.id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("loops::create: bind source research topic failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire the initial burst if the triggers request it. Extracted so
|
|
||||||
// materialize_topic_loops (the wizard-materialized loops path) can
|
|
||||||
// reuse the same logic — previously the burst logic lived only in
|
|
||||||
// this handler and wizard-created loops never fired their first
|
|
||||||
// iteration.
|
|
||||||
fire_initial_burst_if_set(
|
|
||||||
&state.pool,
|
|
||||||
user.workspace_id.as_uuid(),
|
|
||||||
id,
|
|
||||||
&body.triggers,
|
|
||||||
body.task_template.trim(),
|
|
||||||
&body.graph,
|
|
||||||
next_fire_at,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(LoopCreated {
|
|
||||||
id,
|
|
||||||
webhook_token,
|
|
||||||
webhook_signing_key,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire the initial_burst if the loop's triggers request one. On the
|
|
||||||
/// first fire, ensures the per-loop container is spawned and (for
|
|
||||||
/// kind='research' loops) that the topic's repo is cloned and the
|
|
||||||
/// topic container is up. Sets `initial_burst_remaining` to
|
|
||||||
/// `burst - 1` so the completion hook can continue the chain.
|
|
||||||
/// Best-effort: a docker or DB hiccup on the FIRST fire logs but the
|
|
||||||
/// loop row still lives — cron / on_artifact_update / webhook can
|
|
||||||
/// still fire it later.
|
|
||||||
pub async fn fire_initial_burst_if_set(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
loop_id: Uuid,
|
|
||||||
triggers: &Value,
|
|
||||||
task_template: &str,
|
|
||||||
graph: &Value,
|
|
||||||
next_fire_at: Option<OffsetDateTime>,
|
|
||||||
) {
|
|
||||||
let parsed = parse_triggers(triggers);
|
|
||||||
let initial_burst = parsed.as_ref().map(|t| t.initial_burst).unwrap_or(0);
|
|
||||||
let chain_on_completion = parsed.as_ref().map(|t| t.on_completion).unwrap_or(false);
|
|
||||||
if initial_burst == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ensure_loop_container(pool, workspace_id, loop_id).await;
|
|
||||||
match compose_and_enqueue_iteration(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
graph,
|
|
||||||
None,
|
|
||||||
Some(task_template),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(run_id) => {
|
|
||||||
let _ = cm_db::repo::loops::mark_fired(pool, loop_id, run_id, next_fire_at).await;
|
|
||||||
let remaining = initial_burst.saturating_sub(1) as i32;
|
|
||||||
if remaining > 0 || chain_on_completion {
|
|
||||||
let _ =
|
|
||||||
cm_db::repo::loops::set_initial_burst_remaining(pool, loop_id, remaining).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("fire_initial_burst_if_set({loop_id}): enqueue failed: {e:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn apply_staffing(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
agents: &[AgentSlotInput],
|
|
||||||
teams: &[Uuid],
|
|
||||||
orgs: &[Uuid],
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let slots: Vec<cm_db::repo::loops::AgentSlot> = agents
|
|
||||||
.iter()
|
|
||||||
.map(|a| cm_db::repo::loops::AgentSlot {
|
|
||||||
agent_id: a.agent_id,
|
|
||||||
role_slot: a.role_slot.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
cm_db::repo::loops::set_agents(pool, loop_id, &slots).await?;
|
|
||||||
cm_db::repo::loops::set_teams(pool, loop_id, teams).await?;
|
|
||||||
cm_db::repo::loops::set_orgs(pool, loop_id, orgs).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct LoopWithStaffing {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub inner: cm_db::repo::loops::Loop,
|
|
||||||
pub agents: Vec<cm_db::repo::loops::AgentSlot>,
|
|
||||||
pub teams: Vec<Uuid>,
|
|
||||||
pub orgs: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn hydrate_staffing(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
inner: cm_db::repo::loops::Loop,
|
|
||||||
) -> Result<LoopWithStaffing, ApiError> {
|
|
||||||
let id = inner.id;
|
|
||||||
let agents = cm_db::repo::loops::agents(pool, id).await?;
|
|
||||||
let teams = cm_db::repo::loops::teams(pool, id).await?;
|
|
||||||
let orgs = cm_db::repo::loops::orgs(pool, id).await?;
|
|
||||||
Ok(LoopWithStaffing {
|
|
||||||
inner,
|
|
||||||
agents,
|
|
||||||
teams,
|
|
||||||
orgs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_loops(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
) -> Result<Json<Vec<LoopWithStaffing>>, ApiError> {
|
|
||||||
let loops = cm_db::repo::loops::list(&state.pool, user.workspace_id.as_uuid()).await?;
|
|
||||||
let mut out = Vec::with_capacity(loops.len());
|
|
||||||
for l in loops {
|
|
||||||
out.push(hydrate_staffing(&state.pool, l).await?);
|
|
||||||
}
|
|
||||||
Ok(Json(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_loop(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<LoopWithStaffing>, ApiError> {
|
|
||||||
let inner = cm_db::repo::loops::get(&state.pool, id, user.workspace_id.as_uuid())
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
Ok(Json(hydrate_staffing(&state.pool, inner).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct LoopProgress {
|
|
||||||
pub loop_id: Uuid,
|
|
||||||
pub source_topic_id: Uuid,
|
|
||||||
pub source_topic_title: String,
|
|
||||||
pub source_outcome_version: i32,
|
|
||||||
pub consumed_count: usize,
|
|
||||||
pub total_int_count: usize,
|
|
||||||
pub current_int_index: i32,
|
|
||||||
/// Recent coordinator-issued reorders on this loop — newest first,
|
|
||||||
/// capped at 5 so the sidebar card stays compact. Full history is
|
|
||||||
/// on the loop row's reorder_events column.
|
|
||||||
pub recent_reorders: Vec<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /api/loops/progress` — bulk progress read for every loop in the
|
|
||||||
/// workspace that's bound to a research topic. Skips standalone loops
|
|
||||||
/// entirely (empty entry). Parses INT-XX ids from the source outcome's
|
|
||||||
/// markdown to compute the total; consumed count comes straight from
|
|
||||||
/// `consumed_int_ids`. Used by the loops sidebar to render an
|
|
||||||
/// "N/M INTs" pill on each source-bound card.
|
|
||||||
///
|
|
||||||
/// Cost: one query for the loops list + one outcome fetch per unique
|
|
||||||
/// source topic (memoized in the loop below). No N+1 on the topic
|
|
||||||
/// lookup when many loops share a source.
|
|
||||||
pub async fn list_progress(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
) -> Result<Json<Vec<LoopProgress>>, ApiError> {
|
|
||||||
let loops = cm_db::repo::loops::list(&state.pool, user.workspace_id.as_uuid()).await?;
|
|
||||||
let mut by_topic: std::collections::HashMap<Uuid, (String, i32, usize)> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for l in &loops {
|
|
||||||
let ctx = match cm_db::repo::loops::source_research_context(&state.pool, l.id).await {
|
|
||||||
Ok(Some(c)) => c,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let (topic_id, consumed, current_idx) = ctx;
|
|
||||||
let (title, version, total) = match by_topic.get(&topic_id) {
|
|
||||||
Some(cached) => cached.clone(),
|
|
||||||
None => {
|
|
||||||
// Ownership check via get + then count INTs in the latest
|
|
||||||
// outcome. Any failure downgrades to (title, 0, 0) so the
|
|
||||||
// pill still renders — showing 3/0 is better than 500ing
|
|
||||||
// the whole list.
|
|
||||||
let topic = match cm_db::repo::research_topics::get(
|
|
||||||
&state.pool,
|
|
||||||
topic_id,
|
|
||||||
user.workspace_id.as_uuid(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(t)) => t,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let outcome = cm_db::repo::research_outcomes::latest(&state.pool, topic_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
let (version, total) = match &outcome {
|
|
||||||
Some(o) => (o.version, count_int_ids(&o.body_md)),
|
|
||||||
None => (0, 0),
|
|
||||||
};
|
|
||||||
let cached = (topic.title.clone(), version, total);
|
|
||||||
by_topic.insert(topic_id, cached.clone());
|
|
||||||
cached
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let recent = cm_db::repo::loops::recent_reorders(&state.pool, l.id, 5)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
out.push(LoopProgress {
|
|
||||||
loop_id: l.id,
|
|
||||||
source_topic_id: topic_id,
|
|
||||||
source_topic_title: title,
|
|
||||||
source_outcome_version: version,
|
|
||||||
consumed_count: consumed.len(),
|
|
||||||
total_int_count: total,
|
|
||||||
current_int_index: current_idx,
|
|
||||||
recent_reorders: recent,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Json(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count unique INT-<number> ids in a markdown blob. Case-insensitive,
|
|
||||||
/// tolerates prefixes like `### INT-01` and inline references. Same
|
|
||||||
/// permissive matcher used by the completion-marker parser, so what the
|
|
||||||
/// pill counts matches what the completion path can advance against.
|
|
||||||
fn count_int_ids(text: &str) -> usize {
|
|
||||||
let upper = text.to_ascii_uppercase();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(pos) = upper[i..].find("INT-") {
|
|
||||||
let start = i + pos + 4;
|
|
||||||
let end = start
|
|
||||||
+ upper[start..]
|
|
||||||
.chars()
|
|
||||||
.take_while(|c| c.is_ascii_digit())
|
|
||||||
.count();
|
|
||||||
if end > start {
|
|
||||||
seen.insert(upper[start..end].parse::<u32>().ok());
|
|
||||||
}
|
|
||||||
i = end.max(i + pos + 4);
|
|
||||||
}
|
|
||||||
seen.into_iter().flatten().count()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct UpdateLoopRequest {
|
|
||||||
pub title: String,
|
|
||||||
pub description: String,
|
|
||||||
pub graph: Value,
|
|
||||||
pub task_template: String,
|
|
||||||
pub triggers: Value,
|
|
||||||
pub repeat_policy: Value,
|
|
||||||
#[serde(default)]
|
|
||||||
pub agents: Vec<AgentSlotInput>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub teams: Vec<Uuid>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub orgs: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn patch_loop(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
Json(body): Json<UpdateLoopRequest>,
|
|
||||||
) -> Result<StatusCode, ApiError> {
|
|
||||||
if body.title.trim().is_empty() || body.task_template.trim().is_empty() {
|
|
||||||
return Err(ApiError::BadRequest);
|
|
||||||
}
|
|
||||||
cm_db::repo::loops::get(&state.pool, id, user.workspace_id.as_uuid())
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
let next_fire_at = compute_next_fire(&body.triggers);
|
|
||||||
cm_db::repo::loops::update(
|
|
||||||
&state.pool,
|
|
||||||
id,
|
|
||||||
user.workspace_id.as_uuid(),
|
|
||||||
cm_db::repo::loops::UpdateLoop {
|
|
||||||
title: body.title.trim(),
|
|
||||||
description: body.description.trim(),
|
|
||||||
graph: &body.graph,
|
|
||||||
task_template: body.task_template.trim(),
|
|
||||||
triggers: &body.triggers,
|
|
||||||
repeat_policy: &body.repeat_policy,
|
|
||||||
next_fire_at,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
apply_staffing(&state.pool, id, &body.agents, &body.teams, &body.orgs).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_loop(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<StatusCode, ApiError> {
|
|
||||||
cm_db::repo::loops::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
|
|
||||||
// Tear down the per-loop container (P2). Fire-and-forget: the row
|
|
||||||
// is gone, so any Docker failure is a log-line, not an API failure.
|
|
||||||
crate::research_container::teardown_loop(id).await;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn enable_loop(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<StatusCode, ApiError> {
|
|
||||||
cm_db::repo::loops::set_enabled(&state.pool, id, user.workspace_id.as_uuid(), true).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn disable_loop(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<StatusCode, ApiError> {
|
|
||||||
cm_db::repo::loops::set_enabled(&state.pool, id, user.workspace_id.as_uuid(), false).await?;
|
|
||||||
// Stop the per-loop container while disabled — re-enabling later will
|
|
||||||
// spawn a fresh one on the next `run_now` / webhook fire. Keeps
|
|
||||||
// paused loops from holding a docker slot.
|
|
||||||
crate::research_container::teardown_loop(id).await;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct RunTriggered {
|
|
||||||
pub run_id: Uuid,
|
|
||||||
pub iteration: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/loops/:id/run` — enqueue one iteration NOW, bypassing the
|
|
||||||
/// scheduler and any trigger config. Iteration counter continues from
|
|
||||||
/// wherever it was; parent_run_id chains to whatever last_run_id points at.
|
|
||||||
pub async fn run_now(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<RunTriggered>, ApiError> {
|
|
||||||
let l = cm_db::repo::loops::get(&state.pool, id, user.workspace_id.as_uuid())
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
// P2: spawn the per-loop container before enqueue so topology_worker
|
|
||||||
// resolves its gateway URL when it picks up the run. Best-effort;
|
|
||||||
// never blocks the enqueue on Docker being unreachable.
|
|
||||||
ensure_loop_container(&state.pool, l.workspace_id, l.id).await;
|
|
||||||
// Kind-aware — research loops write to research_outcomes.
|
|
||||||
let iter = cm_db::repo::loops::next_iteration(&state.pool, l.id).await?;
|
|
||||||
let run_id = compose_and_enqueue_iteration(
|
|
||||||
&state.pool,
|
|
||||||
l.id,
|
|
||||||
l.workspace_id,
|
|
||||||
&l.graph,
|
|
||||||
l.last_run_id,
|
|
||||||
Some(&l.task_template),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::Internal)?;
|
|
||||||
cm_db::repo::loops::mark_fired(&state.pool, l.id, run_id, l.next_fire_at).await?;
|
|
||||||
Ok(Json(RunTriggered {
|
|
||||||
run_id,
|
|
||||||
iteration: iter,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /webhooks/loops/:token` — public, HMAC-verified. Enqueues one
|
|
||||||
/// iteration on the loop that owns `token`. Returns 202 + `{run_id}` on
|
|
||||||
/// success, 401 on missing/bad signature, 404 on unknown token.
|
|
||||||
pub async fn webhook_receive(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(token): Path<String>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
body: Bytes,
|
|
||||||
) -> (StatusCode, Json<Value>) {
|
|
||||||
let Ok(Some((_id, _ws, key, l))) =
|
|
||||||
cm_db::repo::loops::get_by_webhook_token(&state.pool, &token).await
|
|
||||||
else {
|
|
||||||
return (StatusCode::NOT_FOUND, Json(Value::Null));
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(sig_header) = headers
|
|
||||||
.get("X-Loop-Signature")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
else {
|
|
||||||
return (StatusCode::UNAUTHORIZED, Json(Value::Null));
|
|
||||||
};
|
|
||||||
let Some(provided) = sig_header.strip_prefix("sha256=") else {
|
|
||||||
return (StatusCode::UNAUTHORIZED, Json(Value::Null));
|
|
||||||
};
|
|
||||||
if !verify_hmac(&key, &body, provided) {
|
|
||||||
return (StatusCode::UNAUTHORIZED, Json(Value::Null));
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_loop_container(&state.pool, l.workspace_id, l.id).await;
|
|
||||||
let iter = match cm_db::repo::loops::next_iteration(&state.pool, l.id).await {
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(Value::Null)),
|
|
||||||
};
|
|
||||||
let run_id = match compose_and_enqueue_iteration(
|
|
||||||
&state.pool,
|
|
||||||
l.id,
|
|
||||||
l.workspace_id,
|
|
||||||
&l.graph,
|
|
||||||
l.last_run_id,
|
|
||||||
Some(&l.task_template),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(Value::Null)),
|
|
||||||
};
|
|
||||||
let _ = cm_db::repo::loops::mark_fired(&state.pool, l.id, run_id, None).await;
|
|
||||||
|
|
||||||
(
|
|
||||||
StatusCode::ACCEPTED,
|
|
||||||
Json(serde_json::json!({"run_id": run_id, "iteration": iter})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_hmac(key: &str, body: &[u8], provided_hex: &str) -> bool {
|
|
||||||
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(key.as_bytes()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
mac.update(body);
|
|
||||||
let expected = hex::encode(mac.finalize().into_bytes());
|
|
||||||
if expected.len() != provided_hex.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Constant-time compare.
|
|
||||||
expected
|
|
||||||
.bytes()
|
|
||||||
.zip(provided_hex.bytes())
|
|
||||||
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
|
||||||
== 0
|
|
||||||
}
|
|
||||||
@@ -35,6 +35,9 @@ pub struct CreateMissionRequest {
|
|||||||
pub config: Value,
|
pub config: Value,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub phases: Vec<PhaseSpec>,
|
pub phases: Vec<PhaseSpec>,
|
||||||
|
/// Defaults to "zeroclaw". "local_herdr" requires target_node_id.
|
||||||
|
pub runtime_kind: Option<String>,
|
||||||
|
pub target_node_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_schedule() -> Value {
|
fn default_schedule() -> Value {
|
||||||
@@ -120,6 +123,18 @@ pub async fn create(
|
|||||||
if body.title.trim().is_empty() {
|
if body.title.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
|
// Validate runtime_kind + require target_node when local_herdr.
|
||||||
|
let runtime_kind = body.runtime_kind.as_deref().unwrap_or("zeroclaw");
|
||||||
|
match runtime_kind {
|
||||||
|
"zeroclaw" => {}
|
||||||
|
"local_herdr" => {
|
||||||
|
if body.target_node_id.is_none() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(ApiError::BadRequest),
|
||||||
|
}
|
||||||
|
|
||||||
let new = NewMission {
|
let new = NewMission {
|
||||||
workspace_id: user.workspace_id.as_uuid(),
|
workspace_id: user.workspace_id.as_uuid(),
|
||||||
title: body.title.trim(),
|
title: body.title.trim(),
|
||||||
@@ -130,6 +145,8 @@ pub async fn create(
|
|||||||
schedule: body.schedule,
|
schedule: body.schedule,
|
||||||
description: body.description.as_deref(),
|
description: body.description.as_deref(),
|
||||||
config: body.config,
|
config: body.config,
|
||||||
|
runtime_kind: Some(runtime_kind),
|
||||||
|
target_node_id: body.target_node_id,
|
||||||
phases: body
|
phases: body
|
||||||
.phases
|
.phases
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -221,6 +238,178 @@ pub async fn trigger_security_scan(
|
|||||||
Ok(Json(SecurityScanResponse { findings, tasks }))
|
Ok(Json(SecurityScanResponse { findings, tasks }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RefineResponse {
|
||||||
|
pub original: String,
|
||||||
|
pub refined: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/missions/{id}/refine — generate a coherent, sectioned
|
||||||
|
/// Markdown rewrite of the current description WITHOUT persisting.
|
||||||
|
/// Frontend renders a before/after diff; user hits Accept (PATCH
|
||||||
|
/// /description) or Cancel. Draft-only.
|
||||||
|
pub async fn refine(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<RefineResponse>, ApiError> {
|
||||||
|
let result = crate::mission_refiner::refine(&state.pool, user.workspace_id, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("mission {id}: refine failed: {e}");
|
||||||
|
if e.contains("not found") {
|
||||||
|
ApiError::NotFound
|
||||||
|
} else if e.contains("empty") || e.contains("only allowed on draft") {
|
||||||
|
ApiError::BadRequest
|
||||||
|
} else {
|
||||||
|
ApiError::Internal
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(Json(RefineResponse {
|
||||||
|
original: result.original,
|
||||||
|
refined: result.refined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SetDescriptionRequest {
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/missions/{id}/description — commit a new description.
|
||||||
|
/// Draft-only. Used by the Refine Accept flow (and any future
|
||||||
|
/// direct-edit surface).
|
||||||
|
pub async fn set_description(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<SetDescriptionRequest>,
|
||||||
|
) -> Result<Json<Mission>, ApiError> {
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
if mission.status != "draft" {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
cm_db::repo::missions::set_description(
|
||||||
|
&state.pool,
|
||||||
|
id,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
&body.description,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
Ok(Json(mission))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateMissionRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/missions/{id} — edit title + description. Draft-only.
|
||||||
|
pub async fn update_meta(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<UpdateMissionRequest>,
|
||||||
|
) -> Result<Json<Mission>, ApiError> {
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
if mission.status != "draft" {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
let title = body
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let description = body.description.as_deref();
|
||||||
|
cm_db::repo::missions::update_meta(
|
||||||
|
&state.pool,
|
||||||
|
id,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
Ok(Json(mission))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/missions/{id} — hard-delete. Allowed in any status;
|
||||||
|
/// the operator is expected to Cancel first if a run is in flight
|
||||||
|
/// (cascades will still fire either way).
|
||||||
|
pub async fn delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let deleted =
|
||||||
|
cm_db::repo::missions::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
|
||||||
|
if deleted == 0 {
|
||||||
|
return Err(ApiError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct HerdrDispatchRequest {
|
||||||
|
pub cli: String,
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HerdrDispatchResponse {
|
||||||
|
pub pane_id: String,
|
||||||
|
pub node_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/missions/{id}/herdr-dispatch — manually spawn a Herdr
|
||||||
|
/// pane on the mission's target_node running `cli` with `prompt`.
|
||||||
|
/// Requires mission.runtime_kind = 'local_herdr' + target_node_id set.
|
||||||
|
/// Wizard integration + auto-dispatch land in later phases; this
|
||||||
|
/// exists so Phase 1b's fleet_herdr module can be exercised end-to-end
|
||||||
|
/// against a real node while the rest of the arc builds out.
|
||||||
|
pub async fn herdr_dispatch(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<HerdrDispatchRequest>,
|
||||||
|
) -> Result<Json<HerdrDispatchResponse>, ApiError> {
|
||||||
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
if mission.runtime_kind != "local_herdr" {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
let node_id = mission.target_node_id.ok_or(ApiError::BadRequest)?;
|
||||||
|
let handle = crate::fleet_herdr::dispatch(
|
||||||
|
state.node_hub.clone(),
|
||||||
|
cm_domain::NodeId::from(node_id),
|
||||||
|
id,
|
||||||
|
body.cli.trim(),
|
||||||
|
body.prompt.trim(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("herdr_dispatch mission {id}: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
Ok(Json(HerdrDispatchResponse {
|
||||||
|
pane_id: handle.pane_id,
|
||||||
|
node_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_status(
|
pub async fn set_status(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
@@ -243,7 +432,13 @@ pub async fn set_status(
|
|||||||
|
|
||||||
if prior.status == "draft" && body.status == "running" {
|
if prior.status == "draft" && body.status == "running" {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
crate::mission_orchestrator::on_launch(&state.pool, user.workspace_id, user.user_id, id)
|
crate::mission_orchestrator::on_launch(
|
||||||
|
&state.pool,
|
||||||
|
user.workspace_id,
|
||||||
|
user.user_id,
|
||||||
|
id,
|
||||||
|
Some(state.node_hub.clone()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("mission {id}: on_launch failed: {e}");
|
eprintln!("mission {id}: on_launch failed: {e}");
|
||||||
|
|||||||
@@ -13,17 +13,12 @@ pub mod gateway;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
pub mod loops;
|
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
pub mod planner;
|
pub mod planner;
|
||||||
pub mod probe;
|
|
||||||
pub mod repos;
|
pub mod repos;
|
||||||
pub mod research;
|
|
||||||
pub mod research_pipeline;
|
|
||||||
pub mod research_setup;
|
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
@@ -37,5 +32,4 @@ pub mod teams;
|
|||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod topology;
|
pub mod topology;
|
||||||
pub mod webhooks;
|
pub mod webhooks;
|
||||||
pub mod wizard_repo;
|
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|||||||
@@ -142,6 +142,24 @@ pub async fn sandbox_check(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/nodes/{id}/herdr/session` — full Herdr session snapshot
|
||||||
|
/// for a node (workspaces + tabs + panes + agent states). Used by the
|
||||||
|
/// INFRA Herdr surface to browse per-node Herdr activity.
|
||||||
|
pub async fn herdr_session(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let node_id = NodeId::from(id);
|
||||||
|
nodes::get(&state.pool, node_id, user.workspace_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
match crate::fleet_herdr::snapshot(state.node_hub.clone(), node_id).await {
|
||||||
|
Ok(snap) => Ok(Json(snap)),
|
||||||
|
Err(e) => Ok(Json(json!({ "error": e }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
|
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
|
||||||
pub async fn remove(
|
pub async fn remove(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -293,6 +311,10 @@ struct TermCtrl {
|
|||||||
candidate: Option<String>,
|
candidate: Option<String>,
|
||||||
sdp_mid: Option<String>,
|
sdp_mid: Option<String>,
|
||||||
sdp_mline_index: Option<u16>,
|
sdp_mline_index: Option<u16>,
|
||||||
|
/// When present on `fallback`, spawns this argv in the PTY instead of
|
||||||
|
/// the login shell (used by the Herdr Live Pane).
|
||||||
|
#[serde(default)]
|
||||||
|
command: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The terminal WS is BOTH the WebRTC signaling channel and the fallback data
|
/// The terminal WS is BOTH the WebRTC signaling channel and the fallback data
|
||||||
@@ -335,8 +357,15 @@ async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket)
|
|||||||
let rows = c.rows.unwrap_or(24);
|
let rows = c.rows.unwrap_or(24);
|
||||||
match c.kind.as_str() {
|
match c.kind.as_str() {
|
||||||
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
|
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
|
||||||
// Host shell (no container) — the Infra node terminal.
|
// Host shell by default; `command` override wins.
|
||||||
"fallback" => hub.open_pty(node_id, sid, cols, rows, None, None).await,
|
"fallback" => {
|
||||||
|
let cmd = if c.command.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(c.command.as_slice())
|
||||||
|
};
|
||||||
|
hub.open_pty(node_id, sid, cols, rows, None, None, cmd).await
|
||||||
|
}
|
||||||
"webrtc_offer" => {
|
"webrtc_offer" => {
|
||||||
hub.webrtc_offer(
|
hub.webrtc_offer(
|
||||||
node_id,
|
node_id,
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
//! One-shot end-to-end pipeline probe.
|
|
||||||
//!
|
|
||||||
//! `POST /api/research/probe` bypasses the wizard / topics / loops /
|
|
||||||
//! per-team spawn machinery and drives a single trivial turn against
|
|
||||||
//! the workspace's shared ZeroClaw gateway with a minimal prompt.
|
|
||||||
//! Purpose: distinguish "pipeline is broken" from "the coordinator
|
|
||||||
//! prompt is too big for the current daemon timeouts". If this
|
|
||||||
//! succeeds, every failure we've been chasing is spawn-config or
|
|
||||||
//! prompt-size specific.
|
|
||||||
//!
|
|
||||||
//! Body: `{ "prompt": "…", "agent": "…" }` — both optional; defaults are
|
|
||||||
//! a two-letter reply prompt and the daemon's default agent alias.
|
|
||||||
//! Returns per-step timings + verdict.
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::Json;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::topology_exec::ZeroClawDriveExecutor;
|
|
||||||
use crate::{ApiError, AppState, Authed};
|
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
|
||||||
pub struct ProbeRequest {
|
|
||||||
/// The prompt to send. Defaults to a two-letter reply prompt so
|
|
||||||
/// the daemon returns fast and we can measure baseline latency.
|
|
||||||
#[serde(default)]
|
|
||||||
pub prompt: Option<String>,
|
|
||||||
/// Which agent alias to drive. Defaults to the daemon's
|
|
||||||
/// ZEROCLAW_DEFAULT_AGENT (currently `coordinator`).
|
|
||||||
#[serde(default)]
|
|
||||||
pub agent: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ProbeStep {
|
|
||||||
pub name: &'static str,
|
|
||||||
pub duration_ms: u128,
|
|
||||||
pub status: &'static str,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub detail: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ProbeResponse {
|
|
||||||
pub verdict: &'static str,
|
|
||||||
pub total_duration_ms: u128,
|
|
||||||
pub prompt_len: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub response_preview: Option<String>,
|
|
||||||
pub steps: Vec<ProbeStep>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/research/probe`.
|
|
||||||
pub async fn probe(
|
|
||||||
State(_state): State<AppState>,
|
|
||||||
Authed(_user): Authed,
|
|
||||||
Json(body): Json<ProbeRequest>,
|
|
||||||
) -> Result<Json<ProbeResponse>, ApiError> {
|
|
||||||
let prompt = body.prompt.unwrap_or_else(|| {
|
|
||||||
"Respond with only these two letters (nothing else, no explanation): OK".to_string()
|
|
||||||
});
|
|
||||||
let agent_override = body.agent;
|
|
||||||
let started = Instant::now();
|
|
||||||
let mut steps: Vec<ProbeStep> = Vec::new();
|
|
||||||
|
|
||||||
// ── Step 1: build the executor from env (parses ZEROCLAW_TOKEN,
|
|
||||||
// ZEROCLAW_GATEWAY_URL, ZEROCLAW_AGENT_MAP). Anything wrong with
|
|
||||||
// the workspace config surfaces here.
|
|
||||||
let s1 = Instant::now();
|
|
||||||
let executor = match ZeroClawDriveExecutor::from_env() {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
steps.push(ProbeStep {
|
|
||||||
name: "build_executor",
|
|
||||||
duration_ms: s1.elapsed().as_millis(),
|
|
||||||
status: "fail",
|
|
||||||
detail: Some(e.clone()),
|
|
||||||
});
|
|
||||||
return Ok(Json(ProbeResponse {
|
|
||||||
verdict: "fail",
|
|
||||||
total_duration_ms: started.elapsed().as_millis(),
|
|
||||||
prompt_len: prompt.len(),
|
|
||||||
response_preview: None,
|
|
||||||
steps,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
steps.push(ProbeStep {
|
|
||||||
name: "build_executor",
|
|
||||||
duration_ms: s1.elapsed().as_millis(),
|
|
||||||
status: "ok",
|
|
||||||
detail: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step 2: drive one turn end-to-end (opens ws, sends message,
|
|
||||||
// drains events until terminal). All of "handshake / auth /
|
|
||||||
// daemon spawn claude / claude call / response stream" collapse
|
|
||||||
// into this single measurement because ZeroClawDriveExecutor
|
|
||||||
// doesn't expose finer-grained hooks. But: if this succeeds
|
|
||||||
// within a few seconds, EVERY layer works and the coordinator
|
|
||||||
// failures we've been chasing are prompt-size specific.
|
|
||||||
let agent = agent_override.unwrap_or_else(|| "coordinator".to_string());
|
|
||||||
let s2 = Instant::now();
|
|
||||||
match executor.drive(&agent, &prompt).await {
|
|
||||||
Ok(outcome) => {
|
|
||||||
let out_ms = s2.elapsed().as_millis();
|
|
||||||
steps.push(ProbeStep {
|
|
||||||
name: "drive_turn",
|
|
||||||
duration_ms: out_ms,
|
|
||||||
status: "ok",
|
|
||||||
detail: Some(format!(
|
|
||||||
"tokens={}, output_len={}",
|
|
||||||
outcome.tokens,
|
|
||||||
outcome.output.len()
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
let preview = if outcome.output.len() > 200 {
|
|
||||||
format!("{}…", &outcome.output[..200])
|
|
||||||
} else {
|
|
||||||
outcome.output.clone()
|
|
||||||
};
|
|
||||||
Ok(Json(ProbeResponse {
|
|
||||||
verdict: "ok",
|
|
||||||
total_duration_ms: started.elapsed().as_millis(),
|
|
||||||
prompt_len: prompt.len(),
|
|
||||||
response_preview: Some(preview),
|
|
||||||
steps,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
steps.push(ProbeStep {
|
|
||||||
name: "drive_turn",
|
|
||||||
duration_ms: s2.elapsed().as_millis(),
|
|
||||||
status: "fail",
|
|
||||||
detail: Some(format!("{e}")),
|
|
||||||
});
|
|
||||||
Ok(Json(ProbeResponse {
|
|
||||||
verdict: "fail",
|
|
||||||
total_duration_ms: started.elapsed().as_millis(),
|
|
||||||
prompt_len: prompt.len(),
|
|
||||||
response_preview: None,
|
|
||||||
steps,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,298 +0,0 @@
|
|||||||
//! Pipeline diagnostics for a research topic.
|
|
||||||
//!
|
|
||||||
//! Walks the pipeline stages (staffing, repo, container, runs, outcomes,
|
|
||||||
//! approval) and returns a per-stage report. Read-only — every stage is
|
|
||||||
//! evaluated in isolation and any lookup failure downgrades to warn/skip
|
|
||||||
//! rather than failing the endpoint. Purpose: give users end-to-end
|
|
||||||
//! visibility so silent failures (a run that dies before writing an
|
|
||||||
//! outcome) are surfaced instead of buried in an empty artifact
|
|
||||||
//! download.
|
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::Json;
|
|
||||||
use serde::Serialize;
|
|
||||||
use sqlx::Row;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{ApiError, AppState, Authed};
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct PipelineStage {
|
|
||||||
/// Machine-readable stage id: staffing / repo / container / runs /
|
|
||||||
/// outcomes / approval. Frontend uses this to key the checklist.
|
|
||||||
pub key: String,
|
|
||||||
/// User-facing one-line summary.
|
|
||||||
pub label: String,
|
|
||||||
/// ok | warn | fail | skip — drives the pill color in the UI.
|
|
||||||
pub status: &'static str,
|
|
||||||
/// Optional error text (last-known failure reason from the underlying
|
|
||||||
/// row) so the user can see WHY a stage failed instead of a green tick
|
|
||||||
/// with no artifact behind it.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub detail: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct PipelineState {
|
|
||||||
pub topic_id: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
pub stages: Vec<PipelineStage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ActiveRun {
|
|
||||||
pub id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ActiveRuns {
|
|
||||||
pub topic_id: Uuid,
|
|
||||||
pub runs: Vec<ActiveRun>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /api/research/:id/active-runs` — queued + running topology_run
|
|
||||||
/// ids for this topic, newest first. Feeds the wizard's live-log panel
|
|
||||||
/// (SSE per run at `/api/topology-runs/:id/events`).
|
|
||||||
pub async fn active_runs(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<ActiveRuns>, ApiError> {
|
|
||||||
// Workspace-scope: 404 rather than leak run ids for a topic the
|
|
||||||
// caller can't see.
|
|
||||||
let _topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
let ids =
|
|
||||||
cm_db::repo::topology_runs::active_run_ids_for_research_topic(&state.pool, id).await?;
|
|
||||||
Ok(Json(ActiveRuns {
|
|
||||||
topic_id: id,
|
|
||||||
runs: ids.into_iter().map(|id| ActiveRun { id }).collect(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /api/research/:id/pipeline-state`.
|
|
||||||
pub async fn pipeline_state(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> Result<Json<PipelineState>, ApiError> {
|
|
||||||
let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
let mut stages = Vec::new();
|
|
||||||
|
|
||||||
// 1. staffing.
|
|
||||||
let agents = cm_db::repo::research_topics::agents(&state.pool, id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "staffing".into(),
|
|
||||||
label: format!("{} agent(s) assigned", agents.len()),
|
|
||||||
status: if agents.is_empty() { "fail" } else { "ok" },
|
|
||||||
detail: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. repo — optional. When bound, we check the clone actually landed.
|
|
||||||
if topic.repo_id.is_some() {
|
|
||||||
let cloned = topic
|
|
||||||
.repo_workspace_path
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|p| !p.is_empty());
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "repo".into(),
|
|
||||||
label: if cloned {
|
|
||||||
format!(
|
|
||||||
"Repo cloned at {}",
|
|
||||||
topic.repo_workspace_path.as_deref().unwrap_or("")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"Repo bound but never cloned".into()
|
|
||||||
},
|
|
||||||
status: if cloned { "ok" } else { "fail" },
|
|
||||||
detail: None,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "repo".into(),
|
|
||||||
label: "No repo bound (optional)".into(),
|
|
||||||
status: "skip",
|
|
||||||
detail: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. container — per-topic team runtime.
|
|
||||||
let container_ok =
|
|
||||||
topic.zeroclaw_container_name.is_some() && topic.zeroclaw_gateway_url.is_some();
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "container".into(),
|
|
||||||
label: if container_ok {
|
|
||||||
format!(
|
|
||||||
"Container: {}",
|
|
||||||
topic.zeroclaw_container_name.as_deref().unwrap_or("")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
"Container not spawned (falling back to shared gateway)".into()
|
|
||||||
},
|
|
||||||
status: if container_ok { "ok" } else { "warn" },
|
|
||||||
detail: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. runs — catches the failure with the actual error text.
|
|
||||||
let run_rows = sqlx::query(
|
|
||||||
"SELECT id, status, error, created_at
|
|
||||||
FROM topology_runs
|
|
||||||
WHERE research_topic_id = $1
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let n_runs = run_rows.len();
|
|
||||||
let n_failed = run_rows
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("failed"))
|
|
||||||
.count();
|
|
||||||
let n_running = run_rows
|
|
||||||
.iter()
|
|
||||||
.filter(|r| {
|
|
||||||
matches!(
|
|
||||||
r.try_get::<String, _>("status").ok().as_deref(),
|
|
||||||
Some("running") | Some("queued")
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
let n_completed = run_rows
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("completed"))
|
|
||||||
.count();
|
|
||||||
// 2026-07-16: only surface the error from the MOST RECENT run and
|
|
||||||
// only if that run itself failed. Previously we walked every run
|
|
||||||
// and returned the first non-empty error, so a pre-migration
|
|
||||||
// failed run's stale error kept showing next to a fresh successful
|
|
||||||
// run — reading like "everything is still broken" when it wasn't.
|
|
||||||
let latest_error = run_rows.first().and_then(|r| {
|
|
||||||
let status = r.try_get::<String, _>("status").ok();
|
|
||||||
if status.as_deref() == Some("failed") {
|
|
||||||
r.try_get::<Option<String>, _>("error")
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Status rules:
|
|
||||||
// - 0 runs → skip (nothing to see yet — natural pre-fire state,
|
|
||||||
// NOT a failure)
|
|
||||||
// - any running → waiting (blue/spinner in UI — legitimate in-flight
|
|
||||||
// state)
|
|
||||||
// - all failed → fail (nothing succeeded)
|
|
||||||
// - some failed → warn (mixed history)
|
|
||||||
// - all completed → ok
|
|
||||||
let run_status = if n_runs == 0 {
|
|
||||||
"skip"
|
|
||||||
} else if n_running > 0 {
|
|
||||||
"waiting"
|
|
||||||
} else if n_failed == n_runs {
|
|
||||||
"fail"
|
|
||||||
} else if n_failed > 0 {
|
|
||||||
"warn"
|
|
||||||
} else {
|
|
||||||
"ok"
|
|
||||||
};
|
|
||||||
let run_label = if n_runs == 0 {
|
|
||||||
"No runs yet — pipeline hasn't fired".to_string()
|
|
||||||
} else if n_running > 0 && n_failed == 0 {
|
|
||||||
format!("{n_running} in flight, {n_completed} completed")
|
|
||||||
} else if n_running > 0 {
|
|
||||||
format!("{n_running} in flight, {n_completed} completed, {n_failed} failed")
|
|
||||||
} else {
|
|
||||||
format!("{n_runs} run(s), {n_failed} failed, {n_completed} completed")
|
|
||||||
};
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "runs".into(),
|
|
||||||
label: run_label,
|
|
||||||
// Suppress the "failure" detail line while runs are still in flight —
|
|
||||||
// reporting a prior turn's stale error text next to an actively-running
|
|
||||||
// job reads like the current run failed, which is what triggered the
|
|
||||||
// "everything looks broken" impression.
|
|
||||||
status: run_status,
|
|
||||||
detail: if run_status == "waiting" || run_status == "skip" {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
latest_error
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. outcomes — the artifact rows get_artifact reads. Status is
|
|
||||||
// state-aware: an outcome-less topic with an in-flight run is a
|
|
||||||
// NORMAL waiting state, not a failure. Only flag `fail` when all
|
|
||||||
// runs have terminated AND none produced an outcome — the actual
|
|
||||||
// silent-bug case this diagnostic was designed to catch.
|
|
||||||
let outcome_count: i64 =
|
|
||||||
sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_one(&state.pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
let outcome_status = if outcome_count > 0 {
|
|
||||||
"ok"
|
|
||||||
} else if n_runs == 0 {
|
|
||||||
"skip"
|
|
||||||
} else if n_running > 0 {
|
|
||||||
"waiting"
|
|
||||||
} else if n_failed > 0 {
|
|
||||||
"fail"
|
|
||||||
} else {
|
|
||||||
"warn"
|
|
||||||
};
|
|
||||||
let outcome_label = if outcome_count > 0 {
|
|
||||||
format!("{outcome_count} outcome(s) written")
|
|
||||||
} else if n_running > 0 {
|
|
||||||
"Waiting for the current run to finish…".to_string()
|
|
||||||
} else if n_runs == 0 {
|
|
||||||
"No outcome yet (pipeline hasn't fired)".to_string()
|
|
||||||
} else if n_failed > 0 {
|
|
||||||
"No outcome — all runs failed".to_string()
|
|
||||||
} else {
|
|
||||||
"No outcome yet".to_string()
|
|
||||||
};
|
|
||||||
let outcome_detail = if outcome_status == "fail" {
|
|
||||||
Some("No outcome produced — check the runs stage for the failure reason.".into())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "outcomes".into(),
|
|
||||||
label: outcome_label,
|
|
||||||
status: outcome_status,
|
|
||||||
detail: outcome_detail,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. approval — pending publish-approval, if any.
|
|
||||||
let pending = cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
if let Some(a) = pending {
|
|
||||||
stages.push(PipelineStage {
|
|
||||||
key: "approval".into(),
|
|
||||||
label: format!(
|
|
||||||
"Approval pending (requested {})",
|
|
||||||
a.created_at
|
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
|
||||||
.unwrap_or_default()
|
|
||||||
),
|
|
||||||
status: "warn",
|
|
||||||
detail: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(PipelineState {
|
|
||||||
topic_id: id,
|
|
||||||
status: topic.status,
|
|
||||||
stages,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
//! Research-topic runtime setup + wizard-driven loop materialization.
|
|
||||||
//!
|
|
||||||
//! Extracted from `research.rs` to keep that file under the 1250-line
|
|
||||||
//! budget. Two responsibilities:
|
|
||||||
//!
|
|
||||||
//! 1. Runtime setup — `prepare_topic_runtime` clones the bound repo
|
|
||||||
//! (idempotent) + spawns the per-topic ZeroClaw team container.
|
|
||||||
//! Called from both the one-shot `start_topic` handler and from
|
|
||||||
//! `routes::loops::compose_and_enqueue_iteration` before every
|
|
||||||
//! research-kind loop iteration.
|
|
||||||
//! 2. Wizard loop materialization — `materialize_topic_loops` creates
|
|
||||||
//! the paired research + optional coding loops when the wizard
|
|
||||||
//! picks a schedule mode.
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// A checked-out repo bundle for a research topic — populated by
|
|
||||||
/// `ensure_repo_workspace`; consumed by `build_coordinator_task` to
|
|
||||||
/// give the coordinator a concrete on-disk starting point for the team.
|
|
||||||
pub struct RepoContext {
|
|
||||||
/// Human-readable "owner/name".
|
|
||||||
pub slug: String,
|
|
||||||
/// Absolute path on the API host where the checkout lives.
|
|
||||||
pub path: String,
|
|
||||||
/// Branch we cloned (repo.default_branch → "main" fallback).
|
|
||||||
pub branch: String,
|
|
||||||
/// Line-per-entry preview of the working tree (relative paths).
|
|
||||||
pub tree_preview: String,
|
|
||||||
/// Files shown vs. total, so the prompt is honest about truncation.
|
|
||||||
pub shown: usize,
|
|
||||||
pub total_files: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TopicSchedule {
|
|
||||||
/// "once" | "nightly" | "manual".
|
|
||||||
pub mode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Root directory under which `start_topic` clones per-topic checkouts.
|
|
||||||
/// Overridable via `CLAWMATES_RESEARCH_WORKSPACE_ROOT` for prod deploys
|
|
||||||
/// that want a mounted volume; defaults to a subdir of the system
|
|
||||||
/// tmpdir so dev + tests just work without setup.
|
|
||||||
pub fn research_workspace_root() -> std::path::PathBuf {
|
|
||||||
if let Ok(root) = std::env::var("CLAWMATES_RESEARCH_WORKSPACE_ROOT") {
|
|
||||||
return std::path::PathBuf::from(root);
|
|
||||||
}
|
|
||||||
std::env::temp_dir().join("clawmates-research")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set up the on-disk workspace + container for a research topic —
|
|
||||||
/// clone repo (idempotent) + spawn ZeroClaw team container (idempotent).
|
|
||||||
/// Callable from both the one-shot `start_topic` handler and the
|
|
||||||
/// kind='research' loop iteration path in routes::loops. Fully
|
|
||||||
/// best-effort: any failure (docker unreachable, no clone_url) logs
|
|
||||||
/// and returns, letting the caller enqueue the run against the
|
|
||||||
/// workspace-wide gateway instead.
|
|
||||||
pub async fn prepare_topic_runtime(pool: &PgPool, workspace_id: Uuid, topic_id: Uuid) {
|
|
||||||
let topic = match cm_db::repo::research_topics::get_any_workspace(pool, topic_id).await {
|
|
||||||
Ok(Some(t)) => t,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let Some(repo_id) = topic.repo_id else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let repo =
|
|
||||||
match cm_db::repo::repos::get(pool, repo_id, cm_domain::WorkspaceId::from(workspace_id))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("prepare_topic_runtime({topic_id}): repo fetch failed: {e:?}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let ctx = match ensure_repo_workspace(pool, topic_id, workspace_id, &repo, &topic).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("prepare_topic_runtime({topic_id}): clone failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let repo_path = std::path::PathBuf::from(&ctx.path);
|
|
||||||
let state_root = research_workspace_root()
|
|
||||||
.join(topic_id.to_string())
|
|
||||||
.join("state");
|
|
||||||
let docker = match crate::research_container::connect() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("prepare_topic_runtime({topic_id}): docker connect failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(
|
|
||||||
pool,
|
|
||||||
cm_domain::WorkspaceId::from(workspace_id),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("prepare_topic_runtime({topic_id}): mint MCP bearer failed: {e}");
|
|
||||||
e
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
match crate::research_container::spawn(
|
|
||||||
&docker,
|
|
||||||
topic_id,
|
|
||||||
&repo_path,
|
|
||||||
&state_root,
|
|
||||||
mcp_bearer.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(spawned) => {
|
|
||||||
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
|
|
||||||
pool,
|
|
||||||
topic_id,
|
|
||||||
workspace_id,
|
|
||||||
Some(&spawned.name),
|
|
||||||
Some(&spawned.gateway_url),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("prepare_topic_runtime({topic_id}): persist container failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("prepare_topic_runtime({topic_id}): spawn failed: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clone the bound repo (shallow, single branch) into a per-topic
|
|
||||||
/// workspace and gather a tree preview for the coordinator prompt.
|
|
||||||
/// Persists the clone path on the topic so a re-start reuses it
|
|
||||||
/// instead of re-cloning. Best-effort — callers treat failures as
|
|
||||||
/// "start without repo context" rather than aborting the run.
|
|
||||||
pub async fn ensure_repo_workspace(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
repo: &cm_db::repo::repos::Repo,
|
|
||||||
topic: &cm_db::repo::research_topics::ResearchTopic,
|
|
||||||
) -> Result<RepoContext, String> {
|
|
||||||
let clone_url = repo
|
|
||||||
.clone_url
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| "repo has no clone_url".to_string())?;
|
|
||||||
let branch = repo
|
|
||||||
.default_branch
|
|
||||||
.as_deref()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or("main")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let target = topic.repo_workspace_path.clone().unwrap_or_else(|| {
|
|
||||||
research_workspace_root()
|
|
||||||
.join(topic_id.to_string())
|
|
||||||
.join("repo")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned()
|
|
||||||
});
|
|
||||||
let target_path = std::path::PathBuf::from(&target);
|
|
||||||
|
|
||||||
let should_clone = !target_path.join(".git").exists();
|
|
||||||
if should_clone {
|
|
||||||
if let Some(parent) = target_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir parent: {e}"))?;
|
|
||||||
}
|
|
||||||
let out = tokio::process::Command::new("git")
|
|
||||||
.arg("clone")
|
|
||||||
.arg("--depth")
|
|
||||||
.arg("1")
|
|
||||||
.arg("--single-branch")
|
|
||||||
.arg("--branch")
|
|
||||||
.arg(&branch)
|
|
||||||
.arg(clone_url)
|
|
||||||
.arg(&target_path)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("spawn git clone: {e}"))?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(format!(
|
|
||||||
"git clone exit {:?}: {}",
|
|
||||||
out.status.code(),
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
cm_db::repo::research_topics::set_repo_workspace_path(
|
|
||||||
pool,
|
|
||||||
topic_id,
|
|
||||||
workspace_id,
|
|
||||||
&target,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("persist clone path: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_TREE_LINES: usize = 60;
|
|
||||||
let ls = tokio::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&target_path)
|
|
||||||
.arg("ls-files")
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("spawn git ls-files: {e}"))?;
|
|
||||||
let all = String::from_utf8_lossy(&ls.stdout);
|
|
||||||
let entries: Vec<&str> = all.lines().filter(|l| !l.is_empty()).collect();
|
|
||||||
let shown = entries.len().min(MAX_TREE_LINES);
|
|
||||||
let preview = entries
|
|
||||||
.iter()
|
|
||||||
.take(shown)
|
|
||||||
.map(|e| format!(" {e}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
Ok(RepoContext {
|
|
||||||
slug: format!("{}/{}", repo.owner, repo.name),
|
|
||||||
path: target,
|
|
||||||
branch,
|
|
||||||
tree_preview: if preview.is_empty() {
|
|
||||||
" (empty)".to_string()
|
|
||||||
} else {
|
|
||||||
preview
|
|
||||||
},
|
|
||||||
shown,
|
|
||||||
total_files: entries.len(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a topology graph JSON for a research topic — same shape
|
|
||||||
/// start_topic uses (roster with coordinator promotion, topology-kind
|
|
||||||
/// aware role labeling, cm_topology::build). Called from
|
|
||||||
/// materialize_topic_loops so wizard-created research loops carry a
|
|
||||||
/// valid graph on their topology_run rows; without this the topology
|
|
||||||
/// worker rejects the run with `missing or invalid graph`.
|
|
||||||
///
|
|
||||||
/// Best-effort — returns a minimal fallback (single-node hub) on any
|
|
||||||
/// DB / topology-build failure so the loop still runs (degraded, but
|
|
||||||
/// not silently broken).
|
|
||||||
pub async fn build_topic_graph_json(pool: &PgPool, topic_id: Uuid) -> serde_json::Value {
|
|
||||||
use serde_json::json;
|
|
||||||
let topic = match cm_db::repo::research_topics::get_any_workspace(pool, topic_id).await {
|
|
||||||
Ok(Some(t)) => t,
|
|
||||||
_ => {
|
|
||||||
return json!({ "nodes": [{"id": "hub", "role": "coordinator", "attrs": {}}], "edges": [] })
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let slots = cm_db::repo::research_topics::agents(pool, topic_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if slots.is_empty() {
|
|
||||||
return json!({ "nodes": [{"id": "hub", "role": "coordinator", "attrs": {}}], "edges": [] });
|
|
||||||
}
|
|
||||||
let mut roster: Vec<(cm_db::repo::research_topics::AgentSlot, cm_domain::Agent)> = Vec::new();
|
|
||||||
for s in &slots {
|
|
||||||
if let Ok(agent) =
|
|
||||||
cm_db::repo::agents::get(pool, cm_domain::AgentId::from(s.agent_id)).await
|
|
||||||
{
|
|
||||||
roster.push((s.clone(), agent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if roster.is_empty() {
|
|
||||||
return json!({ "nodes": [{"id": "hub", "role": "coordinator", "attrs": {}}], "edges": [] });
|
|
||||||
}
|
|
||||||
let topo: cm_topology::TopologyKind =
|
|
||||||
serde_json::from_value(json!(topic.topology_kind.as_str()))
|
|
||||||
.unwrap_or(cm_topology::TopologyKind::HubSpoke);
|
|
||||||
let is_pipeline = matches!(topo, cm_topology::TopologyKind::Pipeline);
|
|
||||||
if !is_pipeline {
|
|
||||||
let coord_ix = roster
|
|
||||||
.iter()
|
|
||||||
.position(|(s, _)| {
|
|
||||||
s.role_slot
|
|
||||||
.as_deref()
|
|
||||||
.map(|r| r.to_ascii_lowercase().contains("coordinator"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
if coord_ix != 0 {
|
|
||||||
roster.swap(0, coord_ix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let head_label = if is_pipeline {
|
|
||||||
"stage 1"
|
|
||||||
} else {
|
|
||||||
"coordinator"
|
|
||||||
};
|
|
||||||
let roles: Vec<String> = roster
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, (s, a))| {
|
|
||||||
if i == 0 {
|
|
||||||
head_label.to_string()
|
|
||||||
} else if let Some(r) = &s.role_slot {
|
|
||||||
r.clone()
|
|
||||||
} else if !a.job_title.is_empty() {
|
|
||||||
a.job_title.clone()
|
|
||||||
} else if is_pipeline {
|
|
||||||
format!("stage {}", i + 1)
|
|
||||||
} else {
|
|
||||||
"specialist".to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect();
|
|
||||||
let graph = match cm_topology::build(topo, &role_refs) {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(_) => {
|
|
||||||
return json!({ "nodes": [{"id": "hub", "role": "coordinator", "attrs": {}}], "edges": [] })
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match cm_topology::to_json(&graph)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
|
||||||
{
|
|
||||||
Some(v) => v,
|
|
||||||
None => {
|
|
||||||
json!({ "nodes": [{"id": "hub", "role": "coordinator", "attrs": {}}], "edges": [] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates the paired research + optional coding loops for a topic
|
|
||||||
/// (D1 fold). Fails soft — logs and returns, letting the topic land
|
|
||||||
/// even if loop creation stumbles. Skips the empty-roster gate
|
|
||||||
/// because `create_topic` already verified the workspace has agents.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn materialize_topic_loops(
|
|
||||||
pool: &PgPool,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
created_by: Uuid,
|
|
||||||
topic_id: Uuid,
|
|
||||||
topic_title: &str,
|
|
||||||
mode: &str,
|
|
||||||
also_coding: bool,
|
|
||||||
coding_team_mode: Option<&str>,
|
|
||||||
) {
|
|
||||||
use serde_json::json;
|
|
||||||
// Build a valid topology graph up front — an empty {nodes: [],
|
|
||||||
// edges: []} placeholder was rejected by the topology worker with
|
|
||||||
// "missing or invalid graph".
|
|
||||||
let graph = build_topic_graph_json(pool, topic_id).await;
|
|
||||||
|
|
||||||
let (r_triggers, next_fire_at) = match mode {
|
|
||||||
"nightly" => (
|
|
||||||
json!({ "initial_burst": 1, "cron": "0 3 * * *" }),
|
|
||||||
cm_runtime::scheduling::next_occurrence("0 3 * * *", time::OffsetDateTime::now_utc())
|
|
||||||
.ok(),
|
|
||||||
),
|
|
||||||
"manual" => (json!({ "webhook_enabled": true }), None),
|
|
||||||
_ => (json!({ "initial_burst": 1 }), None),
|
|
||||||
};
|
|
||||||
let r_title = format!("Research · {topic_title}");
|
|
||||||
match cm_db::repo::loops::create(
|
|
||||||
pool,
|
|
||||||
cm_db::repo::loops::NewLoop {
|
|
||||||
workspace_id,
|
|
||||||
title: &r_title,
|
|
||||||
description: "Auto-created by the research wizard. Kind=research; each iteration \
|
|
||||||
appends a new research_outcomes version for the bound topic.",
|
|
||||||
graph: &graph,
|
|
||||||
task_template: "Refresh the topic's research per the outcome kind.",
|
|
||||||
triggers: &r_triggers,
|
|
||||||
repeat_policy: &json!({ "kind": "infinite" }),
|
|
||||||
enabled: true,
|
|
||||||
next_fire_at,
|
|
||||||
webhook_token: None,
|
|
||||||
webhook_signing_key: None,
|
|
||||||
created_by,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(loop_id) => {
|
|
||||||
let _ = cm_db::repo::loops::set_source_research_topic(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
Some(topic_id),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = cm_db::repo::loops::set_kind(pool, loop_id, "research").await;
|
|
||||||
crate::routes::loops::fire_initial_burst_if_set(
|
|
||||||
pool,
|
|
||||||
workspace_id,
|
|
||||||
loop_id,
|
|
||||||
&r_triggers,
|
|
||||||
"Refresh the topic's research per the outcome kind.",
|
|
||||||
&graph,
|
|
||||||
next_fire_at,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("materialize_topic_loops: research loop create failed: {e:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if also_coding {
|
|
||||||
let c_title = format!("Coding · {topic_title}");
|
|
||||||
let c_triggers = json!({ "on_artifact_update": true, "initial_burst": 1 });
|
|
||||||
match cm_db::repo::loops::create(
|
|
||||||
pool,
|
|
||||||
cm_db::repo::loops::NewLoop {
|
|
||||||
workspace_id,
|
|
||||||
title: &c_title,
|
|
||||||
description: "Auto-created by the research wizard. Consumes one INT-XX per \
|
|
||||||
iteration from the paired research topic's artifact.",
|
|
||||||
graph: &graph,
|
|
||||||
task_template: "Execute the next unconsumed INT-XX from the artifact.",
|
|
||||||
triggers: &c_triggers,
|
|
||||||
repeat_policy: &json!({ "kind": "infinite" }),
|
|
||||||
enabled: true,
|
|
||||||
next_fire_at: None,
|
|
||||||
webhook_token: None,
|
|
||||||
webhook_signing_key: None,
|
|
||||||
created_by,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(loop_id) => {
|
|
||||||
let _ = cm_db::repo::loops::set_source_research_topic(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
Some(topic_id),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
// 0045 fold — when the wizard picked "fresh" for the
|
|
||||||
// coding team, provision a dedicated team row with a
|
|
||||||
// coding_readwrite risk profile and bind it. Runtime
|
|
||||||
// spawn hookup (per-team container + config write)
|
|
||||||
// ships in a follow-up slice; the binding here ensures
|
|
||||||
// the loop already carries its intended team by the
|
|
||||||
// time that lands.
|
|
||||||
if coding_team_mode == Some("fresh") {
|
|
||||||
provision_fresh_coding_team(pool, workspace_id, loop_id, topic_title, &graph)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
crate::routes::loops::fire_initial_burst_if_set(
|
|
||||||
pool,
|
|
||||||
workspace_id,
|
|
||||||
loop_id,
|
|
||||||
&c_triggers,
|
|
||||||
"Execute the next unconsumed INT-XX from the artifact.",
|
|
||||||
&graph,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("materialize_topic_loops: coding loop create failed: {e:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a placeholder `teams` row + set the loop's `team_id`. The
|
|
||||||
/// team is deliberately member-less at this stage — the graph is
|
|
||||||
/// carried on the loop itself, and the runtime hookup slice will
|
|
||||||
/// either back-fill members lazily on first spawn or wire the loop's
|
|
||||||
/// existing agents against the team via `add_member`.
|
|
||||||
///
|
|
||||||
/// Best-effort throughout: any failure logs to stderr but the loop
|
|
||||||
/// itself stays intact and functional under the legacy shared-team
|
|
||||||
/// fallback.
|
|
||||||
async fn provision_fresh_coding_team(
|
|
||||||
pool: &PgPool,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
loop_id: Uuid,
|
|
||||||
topic_title: &str,
|
|
||||||
graph: &serde_json::Value,
|
|
||||||
) {
|
|
||||||
let team_id = Uuid::now_v7();
|
|
||||||
let team_name = format!("Coding · {topic_title}");
|
|
||||||
// insert_team_with_lifecycle keeps the topology graph so the
|
|
||||||
// runtime can reproduce the roster without a second lookup.
|
|
||||||
let ws = cm_domain::WorkspaceId::from(workspace_id);
|
|
||||||
if let Err(e) = cm_db::repo::teams::insert_team_with_lifecycle(
|
|
||||||
pool,
|
|
||||||
team_id,
|
|
||||||
ws,
|
|
||||||
&team_name,
|
|
||||||
"pipeline",
|
|
||||||
graph,
|
|
||||||
"permanent",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("provision_fresh_coding_team: insert_team failed for loop {loop_id}: {e:?}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = cm_db::repo::teams::set_team_runtime_config(
|
|
||||||
pool,
|
|
||||||
team_id,
|
|
||||||
ws,
|
|
||||||
&cm_db::repo::teams::TeamRuntimeConfig {
|
|
||||||
risk_profile: Some("coding_readwrite".to_string()),
|
|
||||||
mcp_bundles: vec!["clawmates_door".to_string()],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("provision_fresh_coding_team: set_runtime_config failed: {e:?}");
|
|
||||||
}
|
|
||||||
if let Err(e) = cm_db::repo::teams::set_team_for_loop(pool, loop_id, Some(team_id)).await {
|
|
||||||
eprintln!("provision_fresh_coding_team: set_team_for_loop failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -402,7 +402,7 @@ async fn bridge_node(
|
|||||||
match c.kind.as_str() {
|
match c.kind.as_str() {
|
||||||
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
|
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
|
||||||
"fallback" => {
|
"fallback" => {
|
||||||
hub.open_pty(node_id, sid, cols, rows, Some(&container), Some(&session))
|
hub.open_pty(node_id, sid, cols, rows, Some(&container), Some(&session), None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
"webrtc_offer" => {
|
"webrtc_offer" => {
|
||||||
|
|||||||
@@ -226,39 +226,26 @@ pub struct RunSummary {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub iteration: Option<i32>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub finished_at: Option<String>,
|
pub finished_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query params for `GET /api/topology-runs`. `loop_id` filters to a single
|
/// Query params for `GET /api/topology-runs`.
|
||||||
/// loop's iterations, ordered newest-iteration-first.
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ListRunsQuery {
|
pub struct ListRunsQuery {
|
||||||
#[serde(default)]
|
|
||||||
pub loop_id: Option<Uuid>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/topology-runs` — recent runs for the workspace (compares + durable
|
/// `GET /api/topology-runs` — recent runs for the workspace (compares + durable
|
||||||
/// run jobs), newest first. `?loop_id=X` filters to iterations of one loop,
|
/// run jobs), newest first.
|
||||||
/// ordered by iteration DESC (uses `topology_runs_loop_idx`).
|
|
||||||
pub async fn list_runs(
|
pub async fn list_runs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
Query(q): Query<ListRunsQuery>,
|
Query(q): Query<ListRunsQuery>,
|
||||||
) -> Result<Json<Vec<RunSummary>>, ApiError> {
|
) -> Result<Json<Vec<RunSummary>>, ApiError> {
|
||||||
let limit = q.limit.filter(|n| *n > 0 && *n <= 200).unwrap_or(20);
|
let limit = q.limit.filter(|n| *n > 0 && *n <= 200).unwrap_or(20);
|
||||||
let rows = match q.loop_id {
|
let rows =
|
||||||
Some(loop_id) => {
|
cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, limit).await?;
|
||||||
cm_db::repo::topology_runs::list_by_loop(&state.pool, user.workspace_id, loop_id, limit)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, limit).await?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let out = rows
|
let out = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| RunSummary {
|
.map(|r| RunSummary {
|
||||||
@@ -267,7 +254,6 @@ pub async fn list_runs(
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(),
|
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(),
|
||||||
iteration: r.iteration,
|
|
||||||
finished_at: r.finished_at.and_then(|t| t.format(&Rfc3339).ok()),
|
finished_at: r.finished_at.and_then(|t| t.format(&Rfc3339).ok()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -388,213 +374,3 @@ pub async fn get_run(
|
|||||||
checkpoint: run.checkpoint,
|
checkpoint: run.checkpoint,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase: live container log tail ─────────────────────────────────
|
|
||||||
|
|
||||||
/// Strip ANSI escape sequences from a line so the browser terminal
|
|
||||||
/// renders it cleanly. Cheap and allocation-only when a match hits.
|
|
||||||
fn strip_ansi(input: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(input.len());
|
|
||||||
let bytes = input.as_bytes();
|
|
||||||
let mut i = 0;
|
|
||||||
while i < bytes.len() {
|
|
||||||
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
|
|
||||||
// Skip until final byte in @-~ range.
|
|
||||||
i += 2;
|
|
||||||
while i < bytes.len() && !(bytes[i] >= 0x40 && bytes[i] <= 0x7e) {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
} else {
|
|
||||||
out.push(bytes[i] as char);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Squeeze a zeroclaw daemon log line into `[bracket] action outcome
|
|
||||||
/// · trailing message`. Falls back to the ANSI-stripped raw line when
|
|
||||||
/// the shape isn't recognised so we never lose an interesting line.
|
|
||||||
fn compact_container_log(line: &str) -> Option<String> {
|
|
||||||
let stripped = strip_ansi(line);
|
|
||||||
let trimmed = stripped.trim_end();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Drop pure framing noise: `zeroclaw_scope{...}` continuations
|
|
||||||
// that carry no zc_action.
|
|
||||||
let has_action = trimmed.contains("zc_action=");
|
|
||||||
if !has_action {
|
|
||||||
// Non-daemon lines (bash echoes, container startup banners,
|
|
||||||
// panic backtraces) — keep as-is; those are useful too.
|
|
||||||
if trimmed.contains("zc_") {
|
|
||||||
return None; // structural framing without action, drop
|
|
||||||
}
|
|
||||||
return Some(trimmed.to_string());
|
|
||||||
}
|
|
||||||
let bracket = trimmed
|
|
||||||
.split_once(']')
|
|
||||||
.and_then(|(before, _)| before.strip_prefix('['))
|
|
||||||
.unwrap_or("");
|
|
||||||
let action = trimmed
|
|
||||||
.split("zc_action=")
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|s| s.split_whitespace().next())
|
|
||||||
.unwrap_or("?");
|
|
||||||
let outcome = trimmed
|
|
||||||
.split("zc_outcome=")
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|s| s.split_whitespace().next())
|
|
||||||
.unwrap_or("");
|
|
||||||
let msg = trimmed
|
|
||||||
.rsplit(':')
|
|
||||||
.next()
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let tag = if bracket.is_empty() {
|
|
||||||
"system"
|
|
||||||
} else {
|
|
||||||
bracket
|
|
||||||
};
|
|
||||||
Some(if outcome.is_empty() || outcome == "unknown" {
|
|
||||||
format!("[{tag}] {action} · {msg}")
|
|
||||||
} else {
|
|
||||||
format!("[{tag}] {action} ({outcome}) · {msg}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /api/topology-runs/{id}/container-log` — SSE stream of the
|
|
||||||
/// per-topic team container's daemon log, filtered from the ZeroClaw
|
|
||||||
/// structural noise into `[actor] action (outcome) · message` lines.
|
|
||||||
/// Emits a `line` event per surviving line, plus periodic keep-alives.
|
|
||||||
/// Ends when the container's log stream closes or the client
|
|
||||||
/// disconnects. Auth: workspace-scoped like `run_events_sse`.
|
|
||||||
pub async fn run_container_log_sse(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Path(id): Path<Uuid>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
// All early exits + the live tail funnel through one stream! so
|
|
||||||
// Sse::new sees a single concrete stream type.
|
|
||||||
let pool = state.pool.clone();
|
|
||||||
let ws = user.workspace_id;
|
|
||||||
let stream = async_stream::stream! {
|
|
||||||
use futures::StreamExt;
|
|
||||||
// 1) Workspace scope + resolve the topic id whose container we'll
|
|
||||||
// tail. Two paths:
|
|
||||||
// a) run.research_topic_id set → research pipeline; use it
|
|
||||||
// directly (existing behavior).
|
|
||||||
// b) research_topic_id NULL + run belongs to a loop whose
|
|
||||||
// source_research_topic_id is set → paired coding loop;
|
|
||||||
// the loop reuses the research topic's team container.
|
|
||||||
// Anything else (raw topology runs, pure loop with no paired
|
|
||||||
// topic) errors out with a clear message.
|
|
||||||
if cm_db::repo::topology_runs::status(&pool, id, ws).await.is_err() {
|
|
||||||
yield Ok::<Event, Infallible>(
|
|
||||||
Event::default().event("error").data("run not found"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Precedence — must mirror topology_worker::try_team_gateway_url,
|
|
||||||
// which is what actually spawns the container:
|
|
||||||
// a) run's loop has team_id set → the team runtime spawned
|
|
||||||
// `team-<team_id>-container` (matches spawn_team). This is
|
|
||||||
// the paired-coding-loop path when the wizard picked
|
|
||||||
// "fresh coding team". Loops with a team_id do NOT reuse
|
|
||||||
// the research topic's container.
|
|
||||||
// b) run.research_topic_id set → per-topic research container
|
|
||||||
// `research-<topic_id>-team` (matches spawn).
|
|
||||||
// c) run's loop has source_research_topic_id (legacy paired
|
|
||||||
// flow, no team_id) → same as (b) via the topic.
|
|
||||||
// d) anything else → error with a clear message.
|
|
||||||
let loop_id = cm_db::repo::topology_runs::loop_id_for_run(&pool, id).await.ok().flatten();
|
|
||||||
let team_id = match loop_id {
|
|
||||||
Some(lid) => cm_db::repo::teams::team_for_loop(&pool, lid).await.ok().flatten(),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let direct = cm_db::repo::topology_runs::research_topic_id(&pool, id).await.ok().flatten();
|
|
||||||
let via_loop = if team_id.is_none() && direct.is_none() {
|
|
||||||
match loop_id {
|
|
||||||
Some(lid) => {
|
|
||||||
use sqlx::Row;
|
|
||||||
sqlx::query(
|
|
||||||
"SELECT source_research_topic_id FROM loops WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(lid)
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|r| r.try_get::<Option<Uuid>, _>("source_research_topic_id").ok().flatten())
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
} else { None };
|
|
||||||
let container = if let Some(tid) = team_id {
|
|
||||||
crate::research_container::team_container_name_for(tid)
|
|
||||||
} else {
|
|
||||||
match direct.or(via_loop) {
|
|
||||||
Some(t) => crate::research_container::container_name_for(t),
|
|
||||||
None => {
|
|
||||||
yield Ok::<Event, Infallible>(
|
|
||||||
Event::default().event("error").data(
|
|
||||||
"run has no bound team, research topic, or paired-loop topic; container log unavailable",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2) Docker handle.
|
|
||||||
let docker = match crate::research_container::connect() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
yield Ok(Event::default()
|
|
||||||
.event("error")
|
|
||||||
.data(format!("docker connect failed: {e}")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3) Tail.
|
|
||||||
let opts = bollard::query_parameters::LogsOptionsBuilder::default()
|
|
||||||
.stdout(true)
|
|
||||||
.stderr(true)
|
|
||||||
.follow(true)
|
|
||||||
.tail("200")
|
|
||||||
.timestamps(false)
|
|
||||||
.build();
|
|
||||||
yield Ok(Event::default()
|
|
||||||
.event("info")
|
|
||||||
.data(format!("tailing {container}")));
|
|
||||||
let mut log_stream = docker.logs(&container, Some(opts));
|
|
||||||
// Line-accumulator so partial chunks don't truncate a log line.
|
|
||||||
let mut buf = String::new();
|
|
||||||
while let Some(chunk) = log_stream.next().await {
|
|
||||||
let bytes = match chunk {
|
|
||||||
Ok(bollard::container::LogOutput::StdOut { message })
|
|
||||||
| Ok(bollard::container::LogOutput::StdErr { message })
|
|
||||||
| Ok(bollard::container::LogOutput::Console { message }) => message,
|
|
||||||
Ok(_) => continue,
|
|
||||||
Err(e) => {
|
|
||||||
yield Ok(Event::default().event("error").data(e.to_string()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let s = String::from_utf8_lossy(&bytes);
|
|
||||||
buf.push_str(&s);
|
|
||||||
while let Some(nl) = buf.find('\n') {
|
|
||||||
let line: String = buf.drain(..=nl).collect();
|
|
||||||
if let Some(compact) = compact_container_log(&line) {
|
|
||||||
yield Ok(Event::default().event("line").data(compact));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield Ok(Event::default().event("done").data("stream closed"));
|
|
||||||
};
|
|
||||||
|
|
||||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
//! Wizard-driven clawstor repo materialization.
|
|
||||||
//!
|
|
||||||
//! Bridges the research wizard (frontend) to clawstor's fleet-wide
|
|
||||||
//! `POST /api/v2/repos/{ensure,release}` primitives so a picked repo
|
|
||||||
//! is checked out on every clawstor peer at step-2-next, and released
|
|
||||||
//! if the user backs out.
|
|
||||||
//!
|
|
||||||
//! The clawstor bearer token is server-side only; the frontend never
|
|
||||||
//! sees it. Endpoints require the standard `Authed` extractor and
|
|
||||||
//! resolve the picked `repo_id` against the caller's workspace so a
|
|
||||||
//! user cannot ensure a repo they can't see.
|
|
||||||
//!
|
|
||||||
//! Configured via env:
|
|
||||||
//! CLAWSTOR_URL — aggregator base, e.g. https://quantum.taila4f562.ts.net/clawstor
|
|
||||||
//! CLAWSTOR_TOKEN — bearer token whose namespace scopes the writes
|
|
||||||
//!
|
|
||||||
//! Both missing = disabled (500). Not-configured is a deploy-time
|
|
||||||
//! decision; runtime callers get a plain error.
|
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{ApiError, AppState, Authed};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct RepoBody {
|
|
||||||
pub repo_id: Uuid,
|
|
||||||
/// Git ref (branch, tag, or SHA the remote will accept via
|
|
||||||
/// `git clone --branch`). When omitted, falls back to the repo's
|
|
||||||
/// recorded `default_branch`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub git_ref: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct PeerResult {
|
|
||||||
pub peer: String,
|
|
||||||
pub ok: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub path: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub head_sha: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cached: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub removed: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct FanoutReply {
|
|
||||||
pub url: String,
|
|
||||||
pub git_ref: String,
|
|
||||||
pub workspace: String,
|
|
||||||
pub peers: Vec<PeerResult>,
|
|
||||||
pub all_ok: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/research/wizard/repo/ensure` — materialize the picked
|
|
||||||
/// repo across the clawstor fleet. Returns the aggregator's per-peer
|
|
||||||
/// reply so the wizard can render which nodes succeeded.
|
|
||||||
pub async fn ensure_repo(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Json(body): Json<RepoBody>,
|
|
||||||
) -> Result<Json<FanoutReply>, ApiError> {
|
|
||||||
proxy(&state, &user, body, "ensure").await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/research/wizard/repo/release` — inverse of ensure.
|
|
||||||
/// Called by the wizard on cancel (modal close before submit).
|
|
||||||
pub async fn release_repo(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Authed(user): Authed,
|
|
||||||
Json(body): Json<RepoBody>,
|
|
||||||
) -> Result<Json<FanoutReply>, ApiError> {
|
|
||||||
proxy(&state, &user, body, "release").await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn proxy(
|
|
||||||
state: &AppState,
|
|
||||||
user: &cm_auth::AuthedUser,
|
|
||||||
body: RepoBody,
|
|
||||||
action: &str,
|
|
||||||
) -> Result<Json<FanoutReply>, ApiError> {
|
|
||||||
// Workspace-scoped lookup — a caller can't touch repos outside
|
|
||||||
// their own workspace even if they know the id.
|
|
||||||
let repo = cm_db::repo::repos::get(&state.pool, body.repo_id, user.workspace_id).await?;
|
|
||||||
let url = repo.clone_url.ok_or(ApiError::BadRequest)?;
|
|
||||||
let git_ref = body
|
|
||||||
.git_ref
|
|
||||||
.as_deref()
|
|
||||||
.map(str::to_string)
|
|
||||||
.or(repo.default_branch)
|
|
||||||
.ok_or(ApiError::BadRequest)?;
|
|
||||||
if url.trim().is_empty() || git_ref.trim().is_empty() {
|
|
||||||
return Err(ApiError::BadRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clawstor fan-out is best-effort — the aggregator may not be
|
|
||||||
// deployed in every environment. When it's absent (env unset,
|
|
||||||
// network error, non-JSON HTML from a fallback proxy, non-2xx),
|
|
||||||
// degrade to a "skipped" reply so the wizard doesn't block. Real
|
|
||||||
// fleet materialization happens later at spawn time; ensure was
|
|
||||||
// only a warmup.
|
|
||||||
let skipped = |reason: &str| -> Json<FanoutReply> {
|
|
||||||
eprintln!("wizard_repo::{action}: skipping fleet fan-out ({reason})");
|
|
||||||
Json(FanoutReply {
|
|
||||||
url: url.clone(),
|
|
||||||
git_ref: git_ref.clone(),
|
|
||||||
workspace: String::new(),
|
|
||||||
peers: Vec::new(),
|
|
||||||
all_ok: true,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(base) = std::env::var("CLAWSTOR_URL") else {
|
|
||||||
return Ok(skipped("CLAWSTOR_URL unset"));
|
|
||||||
};
|
|
||||||
let Ok(token) = std::env::var("CLAWSTOR_TOKEN") else {
|
|
||||||
return Ok(skipped("CLAWSTOR_TOKEN unset"));
|
|
||||||
};
|
|
||||||
let endpoint = format!("{}/api/v2/repos/{}", base.trim_end_matches('/'), action);
|
|
||||||
let Ok(client) = reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(360))
|
|
||||||
.build()
|
|
||||||
else {
|
|
||||||
return Ok(skipped("http client build failed"));
|
|
||||||
};
|
|
||||||
let resp = match client
|
|
||||||
.post(&endpoint)
|
|
||||||
.bearer_auth(token)
|
|
||||||
.json(&serde_json::json!({
|
|
||||||
"url": url,
|
|
||||||
"git_ref": git_ref,
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => return Ok(skipped(&format!("send failed: {e}"))),
|
|
||||||
};
|
|
||||||
let status = resp.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Ok(skipped(&format!("aggregator returned {status}")));
|
|
||||||
}
|
|
||||||
match resp.json::<FanoutReply>().await {
|
|
||||||
Ok(reply) => Ok(Json(reply)),
|
|
||||||
Err(e) => Ok(skipped(&format!("non-JSON response: {e}"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,6 +44,36 @@ async fn working_agents(pool: &PgPool, ws: WorkspaceId) -> HashSet<String> {
|
|||||||
rows.into_iter().map(|r| r.get::<String, _>("id")).collect()
|
rows.into_iter().map(|r| r.get::<String, _>("id")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Active missions (status='running') with their assigned team members —
|
||||||
|
/// one row per (mission, agent) pair. The World SSE loop emits each as
|
||||||
|
/// a `mission:<id>` landmark orb + `world.touch` beams from every team
|
||||||
|
/// member. Replaces the retired research/loops landmarks (commit
|
||||||
|
/// fdb8cfe) with the missions-era equivalent.
|
||||||
|
async fn active_missions(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String, String)> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT m.id::text AS mission_id,
|
||||||
|
m.title AS title,
|
||||||
|
tm.claw_id::text AS agent_id
|
||||||
|
FROM missions m
|
||||||
|
JOIN team_members tm ON tm.team_id = m.team_id
|
||||||
|
WHERE m.workspace_id = $1
|
||||||
|
AND m.status = 'running'",
|
||||||
|
)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r.get::<String, _>("mission_id"),
|
||||||
|
r.get::<String, _>("title"),
|
||||||
|
r.get::<String, _>("agent_id"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Currently-running runs in the workspace as (run_id, agent_id) — each is a
|
/// Currently-running runs in the workspace as (run_id, agent_id) — each is a
|
||||||
/// real "this agent is converging on its active work" signal (Gource).
|
/// real "this agent is converging on its active work" signal (Gource).
|
||||||
async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
|
async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
|
||||||
@@ -61,106 +91,6 @@ async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active research topics + their assigned agents. Each returned row is a
|
|
||||||
/// `(topic_id, title, agent_id, repo_workspace_path)` — one row per
|
|
||||||
/// (topic, agent) pair. Emitted from the SSE loop as `repo:<topic_id>`
|
|
||||||
/// project orbs so the World shows a clickable, labeled landmark for
|
|
||||||
/// every in-flight R&D initiative — no need for a file touch to land
|
|
||||||
/// first. `repo_workspace_path` (when non-null) is the on-disk clone
|
|
||||||
/// location; the SSE loop uses it to pre-seed the repo tree.
|
|
||||||
async fn active_research_topics(
|
|
||||||
pool: &PgPool,
|
|
||||||
ws: WorkspaceId,
|
|
||||||
) -> Vec<(String, String, String, Option<String>)> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT t.id::text AS topic_id,
|
|
||||||
t.title AS title,
|
|
||||||
t.repo_workspace_path AS repo_path,
|
|
||||||
ra.agent_id::text AS agent_id
|
|
||||||
FROM research_topics t
|
|
||||||
JOIN research_topic_agents ra ON ra.topic_id = t.id
|
|
||||||
WHERE t.workspace_id = $1
|
|
||||||
AND t.status IN ('processing', 'reviewing', 'publishing')",
|
|
||||||
)
|
|
||||||
.bind(ws.as_uuid())
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
(
|
|
||||||
r.get::<String, _>("topic_id"),
|
|
||||||
r.get::<String, _>("title"),
|
|
||||||
r.get::<String, _>("agent_id"),
|
|
||||||
r.try_get::<Option<String>, _>("repo_path").unwrap_or(None),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cap on pre-seeded file entries per repo. Large repos surface only the
|
|
||||||
/// top N so the SSE payload stays bounded — a subsequent tool call
|
|
||||||
/// exercising a specific path will fill in additional nodes on demand.
|
|
||||||
const REPO_PRESEED_CAP: usize = 200;
|
|
||||||
|
|
||||||
/// Read the top-level file list of a topic's cloned repo via `git ls-files`
|
|
||||||
/// so the SSE loop can pre-seed dir:/file: nodes in the client engine.
|
|
||||||
/// Bounded by `REPO_PRESEED_CAP`. Returns an empty vec on any failure
|
|
||||||
/// (missing clone, git not on PATH, empty repo) — a missing pre-seed
|
|
||||||
/// degrades gracefully to the pre-V3 behavior (tree builds as agents
|
|
||||||
/// touch files).
|
|
||||||
async fn preseed_repo_paths(clone_path: &str) -> Vec<String> {
|
|
||||||
let path = std::path::Path::new(clone_path);
|
|
||||||
if !path.join(".git").exists() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let out = tokio::process::Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(path)
|
|
||||||
.arg("ls-files")
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
let Ok(out) = out else { return Vec::new() };
|
|
||||||
if !out.status.success() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
String::from_utf8_lossy(&out.stdout)
|
|
||||||
.lines()
|
|
||||||
.filter(|l| !l.trim().is_empty())
|
|
||||||
.take(REPO_PRESEED_CAP)
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enabled scheduled loops + their assigned agents. Same shape as
|
|
||||||
/// `active_research_topics` — `(loop_id, title, agent_id)` per (loop, agent).
|
|
||||||
/// Emitted as `loop:<loop_id>` landmark orbs so recurring/scheduled work is
|
|
||||||
/// visible in the World at all times, not just while a run is mid-flight.
|
|
||||||
/// Contrast with research topics (transient statuses processing/reviewing/
|
|
||||||
/// publishing) — loops are persistent landmarks the user can click.
|
|
||||||
async fn active_loops(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String, String)> {
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT l.id::text AS loop_id, l.title AS title, la.agent_id::text AS agent_id
|
|
||||||
FROM loops l
|
|
||||||
JOIN loop_agents la ON la.loop_id = l.id
|
|
||||||
WHERE l.workspace_id = $1
|
|
||||||
AND l.enabled = TRUE",
|
|
||||||
)
|
|
||||||
.bind(ws.as_uuid())
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
(
|
|
||||||
r.get::<String, _>("loop_id"),
|
|
||||||
r.get::<String, _>("title"),
|
|
||||||
r.get::<String, _>("agent_id"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A short human label for a tool's input (for the tool-call target).
|
/// A short human label for a tool's input (for the tool-call target).
|
||||||
fn summarize_input(input: &Value) -> String {
|
fn summarize_input(input: &Value) -> String {
|
||||||
for k in ["target", "path", "url", "query", "name", "file", "command"] {
|
for k in ["target", "path", "url", "query", "name", "file", "command"] {
|
||||||
@@ -461,68 +391,26 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active research topics → landmark project orbs. One `repo:<id>`
|
// Mission landmarks: one `mission:<id>` orb per running mission,
|
||||||
// per topic, labeled with the topic title so users can click it
|
// with `world.touch` beams from every assigned team member. Missions
|
||||||
// and drop into the repo-focus (Gource) view before any files are
|
// outlive individual runs, so the orb gives the World a persistent
|
||||||
// touched. Assigned agents gently converge on their topic's orb
|
// pin for "this is what the team is working on right now" even when
|
||||||
// so the affinity is visible even in idle windows.
|
// no run is claimed. Replaces the retired repo:{topic}/loop:{id}
|
||||||
let research = active_research_topics(&pool, ws).await;
|
// landmarks after commit fdb8cfe.
|
||||||
let mut seen_topics = std::collections::HashSet::new();
|
let missions = active_missions(&pool, ws).await;
|
||||||
for (topic_id, title, agent_id, repo_path) in &research {
|
let mut seen_missions: HashSet<String> = HashSet::new();
|
||||||
let node_id = format!("repo:{topic_id}");
|
for (mission_id, title, agent_id) in &missions {
|
||||||
if seen_topics.insert(topic_id.clone()) {
|
if seen_missions.insert(mission_id.clone()) {
|
||||||
|
let node_id = format!("mission:{mission_id}");
|
||||||
yield sse(
|
yield sse(
|
||||||
"node.activity",
|
"node.activity",
|
||||||
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
|
json!({ "nodeId": node_id, "label": title, "kind": "mission", "heat": 0.75 }),
|
||||||
);
|
|
||||||
// Pre-seed the repo tree (V3). One-shot on first sight
|
|
||||||
// of the topic per SSE client. Each file emits with
|
|
||||||
// heat=0 so the tree is quiet-solid at rest — activity
|
|
||||||
// still hot-swaps as agents touch files. Bounded to
|
|
||||||
// REPO_PRESEED_CAP so payload stays reasonable.
|
|
||||||
if let Some(clone_path) = repo_path {
|
|
||||||
for p in preseed_repo_paths(clone_path).await {
|
|
||||||
let leaf = std::path::Path::new(&p)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or(&p)
|
|
||||||
.to_string();
|
|
||||||
yield sse(
|
|
||||||
"node.activity",
|
|
||||||
json!({
|
|
||||||
"nodeId": format!("file:{p}"),
|
|
||||||
"label": leaf,
|
|
||||||
"kind": "service",
|
|
||||||
"heat": 0.0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield sse(
|
|
||||||
"world.touch",
|
|
||||||
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scheduled loops → landmark orbs, symmetric to research topics.
|
|
||||||
// Persistent landmarks: emitted whenever a loop is enabled, so a
|
|
||||||
// loop between fires still reads as an in-flight project. When
|
|
||||||
// a loop actually runs, the topology_worker journals events
|
|
||||||
// which the run-cursor block below picks up and heats the orb.
|
|
||||||
let loops = active_loops(&pool, ws).await;
|
|
||||||
let mut seen_loops = std::collections::HashSet::new();
|
|
||||||
for (loop_id, title, agent_id) in &loops {
|
|
||||||
let node_id = format!("loop:{loop_id}");
|
|
||||||
if seen_loops.insert(loop_id.clone()) {
|
|
||||||
yield sse(
|
|
||||||
"node.activity",
|
|
||||||
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let node_id = format!("mission:{mission_id}");
|
||||||
yield sse(
|
yield sse(
|
||||||
"world.touch",
|
"world.touch",
|
||||||
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
|
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "mission", "weight": 0.6 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,16 +587,3 @@ pub async fn world_replay(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// THE NORMALIZE SEAM (future) -------------------------------------------------
|
|
||||||
// Translate one durable `run_events` row into zero+ taxonomy events, the Rust
|
|
||||||
// twin of the handoff bridge's normalize(). Wire this into the poll loop above
|
|
||||||
// once the runner emits node targets:
|
|
||||||
// turn.started -> agent.status(working) [+ agent.task.update]
|
|
||||||
// turn.token -> agent.reasoning.delta
|
|
||||||
// tool.invoked -> agent.tool.call [+ world.touch if a nodeId is present]
|
|
||||||
// door.requested -> door.request ; door.resolved -> door.resolve
|
|
||||||
// agent.message -> agent.message ; runner.telemetry -> telemetry
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn normalize(_event_type: &str, _payload: &Value) -> Vec<(&'static str, Value)> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,29 +11,8 @@
|
|||||||
//! prompt; the claw's rich `system_prompt` remains its chat-path identity.
|
//! prompt; the claw's rich `system_prompt` remains its chat-path identity.
|
||||||
//! Injecting per-claw persona into runtime turns is a fast-follow.
|
//! Injecting per-claw persona into runtime turns is a fast-follow.
|
||||||
|
|
||||||
use cm_domain::WorkspaceId;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Mint a long-lived session token for the workspace owner. Used by
|
|
||||||
/// internal service callers (per-team + per-topic + per-loop ZeroClaw
|
|
||||||
/// runtimes hitting our clawmates_door MCP endpoint) without threading
|
|
||||||
/// a real user session through the runtime template.
|
|
||||||
pub async fn mint_workspace_service_token(
|
|
||||||
pool: &PgPool,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let owner = cm_db::repo::users::owner_of_workspace(pool, workspace_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("owner_of_workspace: {e}"))?;
|
|
||||||
let auth = cm_auth::AuthService::new(pool.clone());
|
|
||||||
let token = auth
|
|
||||||
.mint_service_session(owner, time::Duration::days(30))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("mint_service_session: {e}"))?;
|
|
||||||
Ok(token.secret().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The runtime agent alias for a claw id.
|
/// The runtime agent alias for a claw id.
|
||||||
pub fn claw_alias(claw_id: Uuid) -> String {
|
pub fn claw_alias(claw_id: Uuid) -> String {
|
||||||
format!("claw_{}", claw_id.simple())
|
format!("claw_{}", claw_id.simple())
|
||||||
|
|||||||
@@ -61,14 +61,14 @@ pub async fn run(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) -> Result<usiz
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let container = team_container_for_mission(pool, mission_id).await?;
|
let (container, workdir) = exec_target(pool, mission_id).await?;
|
||||||
let mut all_findings: Vec<Finding> = Vec::new();
|
let mut all_findings: Vec<Finding> = Vec::new();
|
||||||
for tool in &tools {
|
for tool in &tools {
|
||||||
let findings = match tool.as_str() {
|
let findings = match tool.as_str() {
|
||||||
"cargo_audit" => run_cargo_audit(&container).await,
|
"cargo_audit" => run_cargo_audit(&container, &workdir).await,
|
||||||
"gitleaks" => run_gitleaks(&container).await,
|
"gitleaks" => run_gitleaks(&container, &workdir).await,
|
||||||
"trivy_fs" => run_trivy_fs(&container).await,
|
"trivy_fs" => run_trivy_fs(&container, &workdir).await,
|
||||||
"semgrep" => run_semgrep(&container).await,
|
"semgrep" => run_semgrep(&container, &workdir).await,
|
||||||
other => {
|
other => {
|
||||||
eprintln!("security_scan: unknown tool `{other}` — skipped");
|
eprintln!("security_scan: unknown tool `{other}` — skipped");
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
@@ -109,9 +109,10 @@ pub async fn run(pool: &PgPool, mission_id: Uuid, phase_id: Uuid) -> Result<usiz
|
|||||||
|
|
||||||
// ── Per-tool runners ────────────────────────────────────────────
|
// ── Per-tool runners ────────────────────────────────────────────
|
||||||
|
|
||||||
async fn run_cargo_audit(container: &str) -> Result<Vec<Finding>, String> {
|
async fn run_cargo_audit(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
|
||||||
let out = docker_exec_json(
|
let out = docker_exec_json(
|
||||||
container,
|
container,
|
||||||
|
workdir,
|
||||||
&[
|
&[
|
||||||
"sh".into(),
|
"sh".into(),
|
||||||
"-c".into(),
|
"-c".into(),
|
||||||
@@ -149,9 +150,10 @@ async fn run_cargo_audit(container: &str) -> Result<Vec<Finding>, String> {
|
|||||||
Ok(findings)
|
Ok(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_gitleaks(container: &str) -> Result<Vec<Finding>, String> {
|
async fn run_gitleaks(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
|
||||||
let out = docker_exec_raw(
|
let out = docker_exec_raw(
|
||||||
container,
|
container,
|
||||||
|
workdir,
|
||||||
&[
|
&[
|
||||||
"sh".into(),
|
"sh".into(),
|
||||||
"-c".into(),
|
"-c".into(),
|
||||||
@@ -183,9 +185,10 @@ async fn run_gitleaks(container: &str) -> Result<Vec<Finding>, String> {
|
|||||||
Ok(findings)
|
Ok(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_trivy_fs(container: &str) -> Result<Vec<Finding>, String> {
|
async fn run_trivy_fs(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
|
||||||
let out = docker_exec_json(
|
let out = docker_exec_json(
|
||||||
container,
|
container,
|
||||||
|
workdir,
|
||||||
&[
|
&[
|
||||||
"sh".into(),
|
"sh".into(),
|
||||||
"-c".into(),
|
"-c".into(),
|
||||||
@@ -225,9 +228,10 @@ async fn run_trivy_fs(container: &str) -> Result<Vec<Finding>, String> {
|
|||||||
Ok(findings)
|
Ok(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_semgrep(container: &str) -> Result<Vec<Finding>, String> {
|
async fn run_semgrep(container: &str, workdir: &std::path::Path) -> Result<Vec<Finding>, String> {
|
||||||
let out = docker_exec_json(
|
let out = docker_exec_json(
|
||||||
container,
|
container,
|
||||||
|
workdir,
|
||||||
&[
|
&[
|
||||||
"sh".into(),
|
"sh".into(),
|
||||||
"-c".into(),
|
"-c".into(),
|
||||||
@@ -279,30 +283,47 @@ async fn load_phase_config(pool: &PgPool, phase_id: Uuid) -> Result<Value, Strin
|
|||||||
.unwrap_or_else(|| json!({})))
|
.unwrap_or_else(|| json!({})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn team_container_for_mission(pool: &PgPool, mission_id: Uuid) -> Result<String, String> {
|
/// Post-task-#23: resolve the (container, working_dir) pair to exec
|
||||||
let row = sqlx::query(
|
/// scans in. Missions run inside the SHARED runtime container
|
||||||
"SELECT t.zeroclaw_container
|
/// (`CLAWMATES_RUNTIME_CONTAINER`, default `clawmates-runtime`) with
|
||||||
FROM missions m
|
/// the working dir mounted at `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`
|
||||||
JOIN teams t ON t.id = m.team_id
|
/// on the host and the same path inside the runtime.
|
||||||
WHERE m.id = $1",
|
///
|
||||||
|
/// A mission MUST have a `repo_id` bound for scans to run — the
|
||||||
|
/// scanners need a source tree. Returning a clear error surfaces
|
||||||
|
/// that gap instead of silently reporting zero findings.
|
||||||
|
async fn exec_target(pool: &PgPool, mission_id: Uuid) -> Result<(String, PathBuf), String> {
|
||||||
|
let repo_id: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT repo_id FROM missions WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(mission_id)
|
.bind(mission_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("resolve container: {e}"))?;
|
.map_err(|e| format!("resolve mission repo: {e}"))?
|
||||||
row.and_then(|r| {
|
.flatten();
|
||||||
r.try_get::<Option<String>, _>("zeroclaw_container")
|
if repo_id.is_none() {
|
||||||
.ok()
|
return Err(
|
||||||
.flatten()
|
"mission has no repo bound — security scan requires a repository under mission.repo_id"
|
||||||
})
|
.into(),
|
||||||
.ok_or_else(|| "mission has no team container yet".to_string())
|
);
|
||||||
|
}
|
||||||
|
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
|
||||||
|
.unwrap_or_else(|_| "clawmates-runtime".to_string());
|
||||||
|
let root = std::env::var("CLAWMATES_MISSIONS_ROOT")
|
||||||
|
.unwrap_or_else(|_| "/var/lib/clawmates-missions".to_string());
|
||||||
|
let workdir = PathBuf::from(root).join(mission_id.to_string()).join("repo");
|
||||||
|
Ok((container, workdir))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn docker_exec_raw(container: &str, cmd: &[String]) -> Result<String, String> {
|
async fn docker_exec_raw(
|
||||||
|
container: &str,
|
||||||
|
workdir: &std::path::Path,
|
||||||
|
cmd: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"exec".to_string(),
|
"exec".to_string(),
|
||||||
"-w".into(),
|
"-w".into(),
|
||||||
"/workspace/repo".into(),
|
workdir.display().to_string(),
|
||||||
container.to_string(),
|
container.to_string(),
|
||||||
];
|
];
|
||||||
args.extend(cmd.iter().cloned());
|
args.extend(cmd.iter().cloned());
|
||||||
@@ -314,8 +335,8 @@ async fn docker_exec_raw(container: &str, cmd: &[String]) -> Result<String, Stri
|
|||||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn docker_exec_json(container: &str, cmd: &[String]) -> Result<Value, String> {
|
async fn docker_exec_json(container: &str, workdir: &std::path::Path, cmd: &[String]) -> Result<Value, String> {
|
||||||
let raw = docker_exec_raw(container, cmd).await?;
|
let raw = docker_exec_raw(container, workdir, cmd).await?;
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(json!({}));
|
return Ok(json!({}));
|
||||||
@@ -333,9 +354,3 @@ fn static_tool_name(s: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unused import silence + shape hint for a future artifact-write
|
|
||||||
// path that dumps the raw JSON outputs into mission_artifacts/security/.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn future_artifact_root(mission_id: Uuid) -> PathBuf {
|
|
||||||
PathBuf::from(format!("/var/lib/clawmates-missions/{mission_id}/security"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,21 +66,20 @@ pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill research team containers whose `running` topology_run has been
|
/// Mark `running` mission-bound topology_runs that have been alive past
|
||||||
/// alive past [`REAP_STUCK_AFTER_SECS`] without journaling a single
|
/// [`REAP_STUCK_AFTER_SECS`] without journaling a single step record as
|
||||||
/// step record. Marks the run `failed` with a diagnostic error so the
|
/// `failed`, with a diagnostic error so the user sees WHY instead of an
|
||||||
/// user sees WHY instead of an infinitely-spinning pipeline.
|
/// infinitely-spinning pipeline.
|
||||||
///
|
///
|
||||||
/// Only reaps runs bound to a research topic — non-research runs (raw
|
/// Only reaps runs bound to a mission — non-mission runs (raw API-driven
|
||||||
/// API-driven topology runs) don't own a container so there's nothing
|
/// topology runs) are left to the existing stale-checkpoint requeuer.
|
||||||
/// to kill; they're left to the existing stale-checkpoint requeuer.
|
|
||||||
async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
|
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
|
||||||
"SELECT id, research_topic_id
|
"SELECT id, mission_id
|
||||||
FROM topology_runs
|
FROM topology_runs
|
||||||
WHERE status = 'running'
|
WHERE status = 'running'
|
||||||
AND research_topic_id IS NOT NULL
|
AND mission_id IS NOT NULL
|
||||||
AND created_at < now() - make_interval(secs => $1::float)
|
AND created_at < now() - make_interval(secs => $1::float)
|
||||||
AND coalesce(jsonb_array_length(coalesce(checkpoint->'records', '[]'::jsonb)), 0) = 0",
|
AND coalesce(jsonb_array_length(coalesce(checkpoint->'records', '[]'::jsonb)), 0) = 0",
|
||||||
)
|
)
|
||||||
@@ -88,36 +87,19 @@ async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort docker cleanup; even if the container is already gone
|
|
||||||
// (crashed, manually killed), we still want to mark the run failed.
|
|
||||||
let docker = crate::research_container::connect().ok();
|
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let id: Uuid = row.get("id");
|
let id: Uuid = row.get("id");
|
||||||
let topic_id: Uuid = row.get("research_topic_id");
|
let mission_id: Uuid = row.get("mission_id");
|
||||||
let container = crate::research_container::container_name_for(topic_id);
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"topology_worker::reaper: reaping stuck run run_id={} topic_id={} container={} (no step records after {}s)",
|
"topology_worker::reaper: reaping stuck run run_id={} mission_id={} (no step records after {}s)",
|
||||||
id, topic_id, container, REAP_STUCK_AFTER_SECS,
|
id, mission_id, REAP_STUCK_AFTER_SECS,
|
||||||
);
|
);
|
||||||
if let Some(d) = &docker {
|
|
||||||
let _ = d
|
|
||||||
.stop_container(
|
|
||||||
&container,
|
|
||||||
None::<bollard::query_parameters::StopContainerOptions>,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let _ = cm_db::repo::topology_runs::fail(
|
let _ = cm_db::repo::topology_runs::fail(
|
||||||
pool,
|
pool,
|
||||||
id,
|
id,
|
||||||
&format!(
|
&format!(
|
||||||
"reaped: no step records after {}s (container {} stopped)",
|
"reaped: no step records after {}s",
|
||||||
REAP_STUCK_AFTER_SECS, container
|
REAP_STUCK_AFTER_SECS
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -150,7 +132,7 @@ async fn run_job(
|
|||||||
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
|
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maybe_transition_research_topic(pool, id).await;
|
maybe_teardown_ephemeral_team(pool, id).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,82 +151,11 @@ async fn run_job(
|
|||||||
.and_then(|c| serde_json::from_value(c).ok())
|
.and_then(|c| serde_json::from_value(c).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// If this run belongs to a research topic OR a loop with a per-team
|
// Missions-era runs drive through the shared, env-derived ZeroClaw
|
||||||
// ZeroClaw container spawned, point the executor at THAT container's
|
// gateway — per-claw provisioning happens ahead of time via
|
||||||
// gateway URL so the run's turns hit its isolated daemon instead of
|
// `RuntimeProvisioner` (see `mission_orchestrator::on_launch`), so
|
||||||
// the workspace-wide one. Falls back to the env-derived executor when
|
// there's no per-run container/gateway resolution left to do here.
|
||||||
// there's no per-topic/loop container (chat sessions, or research/loop
|
let leaf_result = ZeroClawDriveExecutor::from_env();
|
||||||
// runs where spawn failed and we recorded no URL).
|
|
||||||
// 0046 slice 3b — highest-priority resolver: when the run's loop
|
|
||||||
// has a team_id set (wizard picked "fresh coding team"), spawn/
|
|
||||||
// reattach the team-scoped container and route this iteration
|
|
||||||
// through it. The team container inherits the paired research
|
|
||||||
// topic's repo path (needed for coding agents to write patches)
|
|
||||||
// + the team's configured risk_profile.
|
|
||||||
//
|
|
||||||
// Falls through to the legacy per-topic / per-loop URL resolvers
|
|
||||||
// when there's no team binding — safe backward-compat for every
|
|
||||||
// existing loop with team_id = NULL.
|
|
||||||
// Team path is authoritative when the run's loop has team_id set:
|
|
||||||
// if we can't spawn the team container, FAIL the run instead of
|
|
||||||
// silently degrading to the shared runtime. The shared runtime
|
|
||||||
// doesn't bind /workspace/repo, so coding agents would spend their
|
|
||||||
// turns narrating without touching files — a much worse failure
|
|
||||||
// mode than a red run with a clear error.
|
|
||||||
let per_topic_url =
|
|
||||||
match try_team_gateway_url(pool, id, WorkspaceId::from(job.workspace_id)).await {
|
|
||||||
Ok(url) => url,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("topology_worker: team gateway resolution failed for run {id}: {e}");
|
|
||||||
let _ = cm_db::repo::topology_runs::fail(
|
|
||||||
pool,
|
|
||||||
id,
|
|
||||||
&format!("team container unavailable — {e}"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let per_topic_url = if per_topic_url.is_some() {
|
|
||||||
per_topic_url
|
|
||||||
} else {
|
|
||||||
match cm_db::repo::topology_runs::research_topic_id(pool, id).await {
|
|
||||||
Ok(Some(topic_id)) => {
|
|
||||||
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
|
|
||||||
Ok(Some(t)) => t.zeroclaw_gateway_url,
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Loop lookup runs only when the research lookup didn't hit — a run
|
|
||||||
// is bound to at most one of {topic, loop}. This preserves the
|
|
||||||
// existing research fast path unchanged.
|
|
||||||
let per_topic_url = if per_topic_url.is_some() {
|
|
||||||
per_topic_url
|
|
||||||
} else {
|
|
||||||
match cm_db::repo::topology_runs::loop_id_for_run(pool, id).await {
|
|
||||||
Ok(Some(loop_id)) => cm_db::repo::loops::zeroclaw_gateway_url(pool, loop_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(url) = &per_topic_url {
|
|
||||||
// Best-effort readiness gate — a freshly-spawned team container may
|
|
||||||
// still be starting when the worker claims the run. Cap the wait so
|
|
||||||
// a broken image can't hang the worker.
|
|
||||||
if let Err(e) =
|
|
||||||
crate::research_container::wait_ready(url, std::time::Duration::from_secs(30)).await
|
|
||||||
{
|
|
||||||
eprintln!("topology_worker: research team {url} readiness: {e} — proceeding anyway");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let leaf_result = match &per_topic_url {
|
|
||||||
Some(url) => ZeroClawDriveExecutor::from_env_for_gateway(url.clone()),
|
|
||||||
None => ZeroClawDriveExecutor::from_env(),
|
|
||||||
};
|
|
||||||
let leaf = match leaf_result {
|
let leaf = match leaf_result {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -281,9 +192,6 @@ async fn run_job(
|
|||||||
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
|
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
|
||||||
eprintln!("topology_worker: complete({id}) failed: {e}");
|
eprintln!("topology_worker: complete({id}) failed: {e}");
|
||||||
}
|
}
|
||||||
freeze_research_outcome(pool, id, &record.final_output).await;
|
|
||||||
advance_loop_after_completion(pool, id, &record.final_output).await;
|
|
||||||
continue_initial_burst(pool, id).await;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Don't clobber a cancellation (or any already-terminal state) with `failed`.
|
// Don't clobber a cancellation (or any already-terminal state) with `failed`.
|
||||||
@@ -296,231 +204,6 @@ async fn run_job(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maybe_transition_research_topic(pool, id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If this run belongs to a research topic, snapshot the orchestrator's
|
|
||||||
/// final synthesis as a versioned `research_outcomes` row. The frontend
|
|
||||||
/// canvas reads `latest_outcome` for anything past `standby` so reviewers
|
|
||||||
/// see the produced draft rather than the original prompt. Best-effort:
|
|
||||||
/// a failure here logs but doesn't fail the run.
|
|
||||||
async fn freeze_research_outcome(pool: &PgPool, run_id: Uuid, final_output: &str) {
|
|
||||||
let topic_id = match cm_db::repo::topology_runs::research_topic_id(pool, run_id).await {
|
|
||||||
Ok(Some(id)) => id,
|
|
||||||
Ok(None) => return,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("topology_worker: research_topic_id({run_id}) failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if final_output.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) =
|
|
||||||
cm_db::repo::research_outcomes::insert(pool, topic_id, final_output, Some(run_id)).await
|
|
||||||
{
|
|
||||||
eprintln!("topology_worker: research_outcomes::insert({run_id}) failed: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fan-out: any exec-kind loop bound to this topic with the
|
|
||||||
// on_artifact_update trigger enabled wakes up now. Coalesced —
|
|
||||||
// if a loop already has a queued/running run we skip (D3 fallback:
|
|
||||||
// the coordinator sees the fresh artifact on its next iteration
|
|
||||||
// anyway). Best-effort per loop; one loop's Docker/DB hiccup
|
|
||||||
// doesn't affect the others.
|
|
||||||
let awakened = cm_db::repo::loops::loops_awaiting_topic(pool, topic_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
for (loop_id, workspace_id, task_template, graph) in awakened {
|
|
||||||
if cm_db::repo::loops::has_active_run(pool, loop_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
continue; // Coalesce.
|
|
||||||
}
|
|
||||||
// Fan-outs always target kind='exec' (filter enforced in
|
|
||||||
// loops_awaiting_topic). compose_and_enqueue_iteration takes
|
|
||||||
// the exec path and prepends the freshly-inserted artifact.
|
|
||||||
if let Err(e) = crate::routes::loops::compose_and_enqueue_iteration(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
&graph,
|
|
||||||
Some(run_id),
|
|
||||||
Some(&task_template),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("topology_worker: on_artifact_update enqueue({loop_id}) failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// If the just-completed run was a loop iteration with
|
|
||||||
/// initial_burst_remaining > 0, enqueue the next iteration and
|
|
||||||
/// decrement the counter (CAS-safe via take_initial_burst_slot).
|
|
||||||
/// No-op for non-loop runs and for loops whose burst is exhausted.
|
|
||||||
async fn continue_initial_burst(pool: &PgPool, run_id: Uuid) {
|
|
||||||
let loop_id = match cm_db::repo::topology_runs::loop_id_for_run(pool, run_id).await {
|
|
||||||
Ok(Some(id)) => id,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
// If another worker races us, only ONE gets the slot; the other
|
|
||||||
// sees 0 (no-op).
|
|
||||||
let prev = cm_db::repo::loops::take_initial_burst_slot(pool, loop_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
if prev == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Coalesce with a concurrently-in-flight iteration (a webhook
|
|
||||||
// arriving during a burst, say).
|
|
||||||
if cm_db::repo::loops::has_active_run(pool, loop_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fetch the loop so we have the workspace + graph. The kind-aware
|
|
||||||
// dispatcher pulls task_template + kind from the same helper it
|
|
||||||
// uses at first fire, so bursts across an exec + research pair
|
|
||||||
// behave identically.
|
|
||||||
let Ok(Some(l)) = cm_db::repo::loops::get_any_workspace(pool, loop_id).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Err(e) = crate::routes::loops::compose_and_enqueue_iteration(
|
|
||||||
pool,
|
|
||||||
loop_id,
|
|
||||||
l.workspace_id,
|
|
||||||
&l.graph,
|
|
||||||
Some(run_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("topology_worker: continue_initial_burst enqueue failed: {e:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post-terminal hook for loop-bound runs. Parses `COMPLETED: INT-<NN>`
|
|
||||||
/// markers out of the run's final output and advances the loop's
|
|
||||||
/// `consumed_int_ids` + `current_int_index`. Only fires for runs that
|
|
||||||
/// belong to a loop AND that loop is bound to a source research topic
|
|
||||||
/// (the integrations flow). Standalone loops or unbound runs no-op.
|
|
||||||
///
|
|
||||||
/// The marker parser is deliberately forgiving — accepts INT-XX and
|
|
||||||
/// INT-XXX, optionally with surrounding backticks or dashes, so
|
|
||||||
/// coordinator prompts that emit slightly different formats still
|
|
||||||
/// advance the pointer.
|
|
||||||
async fn advance_loop_after_completion(pool: &PgPool, run_id: Uuid, final_output: &str) {
|
|
||||||
let loop_id = match cm_db::repo::topology_runs::loop_id_for_run(pool, run_id).await {
|
|
||||||
Ok(Some(id)) => id,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
let ctx = match cm_db::repo::loops::source_research_context(pool, loop_id).await {
|
|
||||||
Ok(Some(c)) => c,
|
|
||||||
_ => return, // Not a research-bound loop; nothing to advance.
|
|
||||||
};
|
|
||||||
let mut completed = parse_completed_int_ids(final_output);
|
|
||||||
// Drop items already recorded so re-runs don't double-count.
|
|
||||||
let (_topic, already, _idx) = ctx;
|
|
||||||
completed.retain(|id| !already.contains(id));
|
|
||||||
if !completed.is_empty() {
|
|
||||||
if let Err(e) =
|
|
||||||
cm_db::repo::loops::advance_after_completion(pool, loop_id, &completed).await
|
|
||||||
{
|
|
||||||
eprintln!("topology_worker: loops::advance_after_completion({loop_id}) failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reorder rationale — coordinator emits "REORDER: <text>" when it
|
|
||||||
// works on an INT-XX out of order (usually because a prereq was
|
|
||||||
// unmet). Append each occurrence to the loop's reorder_events
|
|
||||||
// array so a mini-timeline UI can surface the history. Iteration
|
|
||||||
// number comes from topology_runs; -1 if the lookup fails (best-
|
|
||||||
// effort — we still record the event with a sentinel).
|
|
||||||
let iteration = cm_db::repo::topology_runs::iteration_for_run(pool, run_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(Some(-1))
|
|
||||||
.unwrap_or(-1);
|
|
||||||
for text in parse_reorder_rationale(final_output) {
|
|
||||||
if let Err(e) =
|
|
||||||
cm_db::repo::loops::append_reorder_event(pool, loop_id, run_id, iteration, &text).await
|
|
||||||
{
|
|
||||||
eprintln!("topology_worker: loops::append_reorder_event({loop_id}) failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract "REORDER: <text>" rationales — one per line the coordinator
|
|
||||||
/// emits when it works out of order. Same permissive line matcher as
|
|
||||||
/// the completed-marker parser (list dashes, backticks, emphasis).
|
|
||||||
/// Returns the text after the colon, trimmed. Skips empty rationales.
|
|
||||||
fn parse_reorder_rationale(text: &str) -> Vec<String> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for line in text.lines() {
|
|
||||||
let normalized = line.trim_start_matches(|c: char| {
|
|
||||||
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
|
||||||
});
|
|
||||||
let upper = normalized.to_ascii_uppercase();
|
|
||||||
if !upper.starts_with("REORDER:") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Preserve original case of the rationale text — only the
|
|
||||||
// marker matched case-insensitively.
|
|
||||||
let colon = normalized.find(':').map(|i| i + 1).unwrap_or(0);
|
|
||||||
let rationale = normalized[colon..].trim();
|
|
||||||
if !rationale.is_empty() {
|
|
||||||
out.push(rationale.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract stable INT-XX ids from a completion line. Matches
|
|
||||||
/// `COMPLETED: INT-01`, `COMPLETED: INT-01, INT-02`, or `- COMPLETED: `INT-01``.
|
|
||||||
/// De-duplicates within a single output.
|
|
||||||
fn parse_completed_int_ids(text: &str) -> Vec<String> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
for line in text.lines() {
|
|
||||||
// Case-insensitive, tolerates surrounding whitespace, list dashes,
|
|
||||||
// markdown emphasis, and backticks.
|
|
||||||
let normalized = line.trim_start_matches(|c: char| {
|
|
||||||
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
|
||||||
});
|
|
||||||
let upper = normalized.to_ascii_uppercase();
|
|
||||||
if !upper.starts_with("COMPLETED:") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for token in upper
|
|
||||||
.trim_start_matches("COMPLETED:")
|
|
||||||
.split(|c: char| c == ',' || c == ';' || c.is_whitespace())
|
|
||||||
{
|
|
||||||
let stripped = token.trim_matches(|c: char| c == '`' || c == '*' || c == '.');
|
|
||||||
if stripped.starts_with("INT-")
|
|
||||||
&& stripped.len() >= 5
|
|
||||||
&& seen.insert(stripped.to_string())
|
|
||||||
{
|
|
||||||
out.push(stripped.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post-terminal hook: if this run belongs to a research topic and it was
|
|
||||||
/// the last sibling in flight, transition the topic `processing → reviewing`.
|
|
||||||
/// Best-effort — a DB hiccup here logs but doesn't fail the run.
|
|
||||||
async fn maybe_transition_research_topic(pool: &PgPool, id: Uuid) {
|
|
||||||
match cm_db::repo::topology_runs::notify_run_completed(pool, id).await {
|
|
||||||
Ok(true) => {
|
|
||||||
// Left intentionally quiet on success; the UI polls the topic
|
|
||||||
// status. Future: emit a run_event so live viewers see it flip.
|
|
||||||
}
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(e) => eprintln!("topology_worker: notify_run_completed({id}) failed: {e}"),
|
|
||||||
}
|
|
||||||
maybe_teardown_ephemeral_team(pool, id).await;
|
maybe_teardown_ephemeral_team(pool, id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,143 +313,3 @@ async fn drive<E: TurnExecutor>(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 0046 slice 3b: resolve the run's team-scoped gateway URL.
|
|
||||||
///
|
|
||||||
/// Returns `Some(url)` when the run belongs to a loop whose team_id
|
|
||||||
/// is set and either the team already has a persisted gateway URL
|
|
||||||
/// or we can spawn one now (the paired research topic's repo path
|
|
||||||
/// must resolve so the team container has something to bind at
|
|
||||||
/// `/workspace/repo`).
|
|
||||||
///
|
|
||||||
/// Any missing prereq returns `None` so the caller falls through to
|
|
||||||
/// the legacy per-topic / per-loop resolvers. Every failure logs to
|
|
||||||
/// stderr and downgrades to `None` — a broken team resolution must
|
|
||||||
/// never brick a run that could otherwise complete on the shared
|
|
||||||
/// research container.
|
|
||||||
/// Resolve the team-scoped ZeroClaw gateway URL for a run.
|
|
||||||
///
|
|
||||||
/// Returns:
|
|
||||||
/// - `Ok(Some(url))` — this run's loop has a `team_id` and the team
|
|
||||||
/// container is spawned (or reattached) and ready to drive.
|
|
||||||
/// - `Ok(None)` — the run has no team binding at all; the caller should
|
|
||||||
/// fall through to the legacy per-topic / per-loop / shared-runtime
|
|
||||||
/// resolvers.
|
|
||||||
/// - `Err(msg)` — the run's loop DOES have a `team_id` but the team
|
|
||||||
/// container couldn't be spawned. The caller MUST fail the run;
|
|
||||||
/// silently degrading to the shared runtime hides real infra breakage
|
|
||||||
/// and leaves the agents narrating instead of touching the repo.
|
|
||||||
async fn try_team_gateway_url(
|
|
||||||
pool: &PgPool,
|
|
||||||
run_id: Uuid,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
let Some(loop_id) = cm_db::repo::topology_runs::loop_id_for_run(pool, run_id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let Some(team_id) = cm_db::repo::teams::team_for_loop(pool, loop_id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reattach fast path — team already has a persisted URL.
|
|
||||||
if let Ok(Some((_container, Some(url)))) =
|
|
||||||
cm_db::repo::teams::team_container_coords(pool, team_id, workspace_id).await
|
|
||||||
{
|
|
||||||
return Ok(Some(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cold path — need to spawn. Repo path comes from the paired
|
|
||||||
// research topic (loops.source_research_topic_id + research_topics.
|
|
||||||
// repo_workspace_path). Without a repo we can't spawn a coding
|
|
||||||
// team container (nothing meaningful to bind at /workspace/repo).
|
|
||||||
let source_topic_id = match cm_db::repo::loops::source_research_context(pool, loop_id).await {
|
|
||||||
Ok(Some((tid, _consumed, _idx))) => tid,
|
|
||||||
Ok(None) => {
|
|
||||||
return Err(format!(
|
|
||||||
"team {team_id} has no paired source_research_topic_id; \
|
|
||||||
coding loops need a research topic to bind /workspace/repo"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(format!("source_research_context({loop_id}): {e}")),
|
|
||||||
};
|
|
||||||
let source_topic = match cm_db::repo::research_topics::get(
|
|
||||||
pool,
|
|
||||||
source_topic_id,
|
|
||||||
workspace_id.as_uuid(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(t)) => t,
|
|
||||||
Ok(None) => {
|
|
||||||
return Err(format!(
|
|
||||||
"source research topic {source_topic_id} not found in workspace"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(format!("research_topics::get({source_topic_id}): {e}")),
|
|
||||||
};
|
|
||||||
let Some(repo_path) = source_topic.repo_workspace_path.clone() else {
|
|
||||||
return Err(format!(
|
|
||||||
"source research topic {source_topic_id} has no repo_workspace_path; \
|
|
||||||
team can't bind /workspace/repo"
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Team's risk_profile → stamped into every [agents.*] binding on
|
|
||||||
// the freshly-written config.toml.
|
|
||||||
let risk_profile = cm_db::repo::teams::get_team_runtime_config(pool, team_id, workspace_id)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|c| c.risk_profile);
|
|
||||||
|
|
||||||
let docker = crate::research_container::connect()
|
|
||||||
.map_err(|e| format!("docker connect failed for team {team_id}: {e}"))?;
|
|
||||||
let state_root = crate::research_container::team_state_root(team_id);
|
|
||||||
|
|
||||||
// Mint a workspace-owner service session so the team runtime's
|
|
||||||
// clawmates_door MCP calls pass cm-auth (the static bearer baked
|
|
||||||
// into the template config isn't a valid auth_sessions row and
|
|
||||||
// gets 401'd, leaving every agent with 0 tools). MCP is best-effort:
|
|
||||||
// if the mint fails we still spawn the container with the stale
|
|
||||||
// bearer — some tools will 401 but the run isn't wholly broken.
|
|
||||||
let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(pool, workspace_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
eprintln!("try_team_gateway_url: mint MCP bearer failed for team {team_id}: {e}");
|
|
||||||
e
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let spawned = crate::research_container::spawn_team(
|
|
||||||
&docker,
|
|
||||||
team_id,
|
|
||||||
std::path::Path::new(&repo_path),
|
|
||||||
&state_root,
|
|
||||||
risk_profile.as_deref(),
|
|
||||||
mcp_bearer.as_deref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("spawn_team({team_id}): {e}"))?;
|
|
||||||
|
|
||||||
// Persist coords so future iterations skip the spawn dance.
|
|
||||||
if let Err(e) = cm_db::repo::teams::set_team_container_coords(
|
|
||||||
pool,
|
|
||||||
team_id,
|
|
||||||
workspace_id,
|
|
||||||
Some(&spawned.name),
|
|
||||||
Some(&spawned.gateway_url),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("try_team_gateway_url: persist coords failed for team {team_id}: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(spawned.gateway_url))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
//! End-to-end coverage for `mission_orchestrator::on_launch` — the
|
||||||
|
//! draft→running transition that materializes a team from a template,
|
||||||
|
//! inserts agents, seeds brains, records template lineage, and binds
|
||||||
|
//! members via team_members. Runs against a real Postgres (cm-testkit).
|
||||||
|
//!
|
||||||
|
//! What this test locks in:
|
||||||
|
//! * Given a mission with `team_template_id` and no `team_id`,
|
||||||
|
//! `on_launch` materializes exactly one team per role in the
|
||||||
|
//! template.
|
||||||
|
//! * The materialized team carries `template_id` + `template_version`
|
||||||
|
//! + `risk_profile` + `mcp_bundles` from the template row.
|
||||||
|
//! * Every role produces one `agents` row + one
|
||||||
|
//! `agent_template_link` row (seeded=true).
|
||||||
|
//! * `team_members` binds every claw to a topology node id.
|
||||||
|
//! * The mission row's `team_id` gets updated.
|
||||||
|
//! * Re-invoking is a no-op (returns the existing team_id, doesn't
|
||||||
|
//! duplicate agents).
|
||||||
|
|
||||||
|
use cm_api::mission_orchestrator;
|
||||||
|
use cm_db::repo::{team_templates, users, workspaces};
|
||||||
|
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::Row;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Missions Test".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
workspaces::insert(pool, &ws).await.unwrap();
|
||||||
|
ws.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_owner(pool: &sqlx::PgPool, ws: WorkspaceId) -> UserId {
|
||||||
|
let user = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws,
|
||||||
|
email: "[email protected]".into(),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
users::insert(pool, &user).await.unwrap();
|
||||||
|
user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
team_templates::upsert_builtin(
|
||||||
|
pool,
|
||||||
|
team_templates::UpsertBuiltin {
|
||||||
|
id,
|
||||||
|
key: "test_backend",
|
||||||
|
name: "Test Backend Team",
|
||||||
|
stack: vec!["rust".into(), "postgres".into()],
|
||||||
|
default_topology: "pipeline",
|
||||||
|
risk_profile: "medium",
|
||||||
|
mcp_bundles: vec!["clawmates_skills".into()],
|
||||||
|
version: 1,
|
||||||
|
description: Some("Fixture template for orchestrator test"),
|
||||||
|
config: json!({}),
|
||||||
|
roles: vec![
|
||||||
|
team_templates::UpsertBuiltinRole {
|
||||||
|
slot: "planner",
|
||||||
|
order_idx: 0,
|
||||||
|
system_prompt: "Plan the feature. Break it into INT-XX items.",
|
||||||
|
skills: vec!["decompose-int-items".into()],
|
||||||
|
brain_seed: Some("# Planner\nBreak features into INT items."),
|
||||||
|
},
|
||||||
|
team_templates::UpsertBuiltinRole {
|
||||||
|
slot: "coder",
|
||||||
|
order_idx: 1,
|
||||||
|
system_prompt: "Implement one INT item at a time.",
|
||||||
|
skills: vec!["write-rust-current-edition".into()],
|
||||||
|
brain_seed: Some("# Coder\nOne INT per commit."),
|
||||||
|
},
|
||||||
|
team_templates::UpsertBuiltinRole {
|
||||||
|
slot: "reviewer",
|
||||||
|
order_idx: 2,
|
||||||
|
system_prompt: "Review each commit before merge.",
|
||||||
|
skills: vec!["code-review-checklist".into()],
|
||||||
|
brain_seed: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_mission(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
ws: WorkspaceId,
|
||||||
|
template_id: Uuid,
|
||||||
|
title: &str,
|
||||||
|
) -> Uuid {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO missions
|
||||||
|
(id, workspace_id, title, template_kind, team_template_id, schedule, status, config)
|
||||||
|
VALUES ($1, $2, $3, 'research_and_code', $4, '{\"kind\":\"one_shot\"}'::jsonb,
|
||||||
|
'draft', '{}'::jsonb)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.bind(title)
|
||||||
|
.bind(template_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_launch_materializes_team_from_template() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = seed_workspace(&pool).await;
|
||||||
|
let owner = seed_owner(&pool, ws).await;
|
||||||
|
let template_id = seed_test_template(&pool).await;
|
||||||
|
let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await;
|
||||||
|
|
||||||
|
let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
||||||
|
.await
|
||||||
|
.expect("on_launch succeeds")
|
||||||
|
.expect("returns a team id");
|
||||||
|
|
||||||
|
// Team row exists with template lineage stamped.
|
||||||
|
let team_row = sqlx::query(
|
||||||
|
"SELECT template_id, template_version, risk_profile, mcp_bundles
|
||||||
|
FROM teams WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(team_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let stamped_template_id: Uuid = team_row.get("template_id");
|
||||||
|
let stamped_version: i32 = team_row.get("template_version");
|
||||||
|
let stamped_risk: String = team_row.get("risk_profile");
|
||||||
|
assert_eq!(stamped_template_id, template_id);
|
||||||
|
assert_eq!(stamped_version, 1);
|
||||||
|
assert_eq!(stamped_risk, "medium");
|
||||||
|
|
||||||
|
// One agent per role — three total.
|
||||||
|
let agent_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT count(*)::bigint FROM team_members WHERE team_id = $1",
|
||||||
|
)
|
||||||
|
.bind(team_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(agent_count, 3, "expected one member per role");
|
||||||
|
|
||||||
|
// Every member has a matching agents row + role_slot binding.
|
||||||
|
let member_slots: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT tm.role FROM team_members tm
|
||||||
|
JOIN agents a ON a.id = tm.claw_id
|
||||||
|
WHERE tm.team_id = $1
|
||||||
|
ORDER BY tm.role",
|
||||||
|
)
|
||||||
|
.bind(team_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(member_slots, vec!["coder", "planner", "reviewer"]);
|
||||||
|
|
||||||
|
// Every claw has an agent_template_link row with seeded=true.
|
||||||
|
let seeded_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT count(*)::bigint FROM agent_template_link atl
|
||||||
|
JOIN team_members tm ON tm.claw_id = atl.agent_id
|
||||||
|
WHERE tm.team_id = $1
|
||||||
|
AND atl.template_id = $2",
|
||||||
|
)
|
||||||
|
.bind(team_id)
|
||||||
|
.bind(template_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seeded_count, 3, "expected every claw to have seeded lineage");
|
||||||
|
|
||||||
|
// Mission row was updated to point at the new team.
|
||||||
|
let bound_team_id: Uuid = sqlx::query_scalar(
|
||||||
|
"SELECT team_id FROM missions WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(mission_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bound_team_id, team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_launch_is_idempotent() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = seed_workspace(&pool).await;
|
||||||
|
let owner = seed_owner(&pool, ws).await;
|
||||||
|
let template_id = seed_test_template(&pool).await;
|
||||||
|
let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await;
|
||||||
|
|
||||||
|
let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(team_a, team_b, "second invocation should return the same team_id");
|
||||||
|
|
||||||
|
// Still exactly three agents — no duplication.
|
||||||
|
let agent_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT count(*)::bigint FROM team_members WHERE team_id = $1",
|
||||||
|
)
|
||||||
|
.bind(team_a)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(agent_count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn on_launch_no_template_returns_none() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = seed_workspace(&pool).await;
|
||||||
|
let owner = seed_owner(&pool, ws).await;
|
||||||
|
|
||||||
|
// Mission with no team_template_id.
|
||||||
|
let mission_id = Uuid::now_v7();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO missions
|
||||||
|
(id, workspace_id, title, template_kind, schedule, status, config)
|
||||||
|
VALUES ($1, $2, 'no template', 'refactor', '{\"kind\":\"one_shot\"}'::jsonb,
|
||||||
|
'draft', '{}'::jsonb)",
|
||||||
|
)
|
||||||
|
.bind(mission_id)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none(), "no template + no team should return None");
|
||||||
|
|
||||||
|
// Mission stays with team_id NULL.
|
||||||
|
let team_id: Option<Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT team_id FROM missions WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(mission_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(team_id.is_none());
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
//! Publish gate is Owner-only. The role check must fire before any DB
|
|
||||||
//! work, so we probe it with a bogus approval id: Members see 403 (role
|
|
||||||
//! guard), Owners see 404 (row missing) — proving ordering + coverage on
|
|
||||||
//! both approve and reject.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use cm_api::AppState;
|
|
||||||
use cm_auth::AuthService;
|
|
||||||
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
|
||||||
use cm_llm::ScriptedProvider;
|
|
||||||
use cm_runtime::{Runtime, RuntimeConfig};
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
struct Server {
|
|
||||||
base: String,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(pool: sqlx::PgPool) -> Server {
|
|
||||||
let runtime = Runtime::new(
|
|
||||||
pool.clone(),
|
|
||||||
Arc::new(ScriptedProvider::from_toml("").unwrap()),
|
|
||||||
RuntimeConfig::basic("scripted", 1024),
|
|
||||||
);
|
|
||||||
let app = cm_api::router(AppState::new(pool, runtime));
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
Server {
|
|
||||||
base: format!("http://{addr}"),
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId, role: Role, email: &str) -> UserId {
|
|
||||||
let user = User {
|
|
||||||
id: UserId::new(),
|
|
||||||
workspace_id: ws,
|
|
||||||
email: email.into(),
|
|
||||||
role,
|
|
||||||
display_name: format!("{role:?}"),
|
|
||||||
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
||||||
};
|
|
||||||
cm_db::repo::users::insert(pool, &user).await.unwrap();
|
|
||||||
AuthService::new(pool.clone())
|
|
||||||
.set_password(user.id, "pw")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login(server: &Server, email: &str) -> String {
|
|
||||||
server
|
|
||||||
.client
|
|
||||||
.post(format!("{}/api/auth/login", server.base))
|
|
||||||
.json(&json!({"email": email, "password": "pw"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.json::<Value>()
|
|
||||||
.await
|
|
||||||
.unwrap()["token"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn publish_decide_is_owner_only() {
|
|
||||||
let pool = cm_testkit::test_pool().await;
|
|
||||||
let server = serve(pool.clone()).await;
|
|
||||||
|
|
||||||
let ws = Workspace {
|
|
||||||
id: WorkspaceId::new(),
|
|
||||||
name: "Acme".into(),
|
|
||||||
plan: "team".into(),
|
|
||||||
};
|
|
||||||
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
|
||||||
|
|
||||||
let owner_email = format!("owner-{}@acme.test", UserId::new());
|
|
||||||
let member_email = format!("member-{}@acme.test", UserId::new());
|
|
||||||
seed_user(&pool, ws.id, Role::Owner, &owner_email).await;
|
|
||||||
seed_user(&pool, ws.id, Role::Member, &member_email).await;
|
|
||||||
|
|
||||||
let owner_tok = login(&server, &owner_email).await;
|
|
||||||
let member_tok = login(&server, &member_email).await;
|
|
||||||
|
|
||||||
// Bogus id: role guard should fire before the DB lookup.
|
|
||||||
let bogus = Uuid::now_v7();
|
|
||||||
|
|
||||||
for path in [
|
|
||||||
format!("/api/research/publish-approvals/{bogus}/approve"),
|
|
||||||
format!("/api/research/publish-approvals/{bogus}/reject"),
|
|
||||||
] {
|
|
||||||
let member_resp = server
|
|
||||||
.client
|
|
||||||
.post(format!("{}{path}", server.base))
|
|
||||||
.bearer_auth(&member_tok)
|
|
||||||
.json(&json!({}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
member_resp.status(),
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"member should be forbidden on {path}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let owner_resp = server
|
|
||||||
.client
|
|
||||||
.post(format!("{}{path}", server.base))
|
|
||||||
.bearer_auth(&owner_tok)
|
|
||||||
.json(&json!({}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
owner_resp.status(),
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"owner should reach DB lookup + get 404 on {path}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
|
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
|
||||||
//! foundation that lets long-horizon topology runs survive worker restarts.
|
//! foundation that lets long-horizon topology runs survive worker restarts.
|
||||||
|
|
||||||
use cm_db::repo::{
|
use cm_db::repo::{teams, topology_runs, users, workspaces};
|
||||||
loops, research_outcomes, research_topics, teams, topology_runs, users, workspaces,
|
|
||||||
};
|
|
||||||
use cm_domain::{
|
use cm_domain::{
|
||||||
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
|
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
|
||||||
};
|
};
|
||||||
@@ -24,33 +22,6 @@ async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId, email: &str) -> UserId
|
|||||||
user.id
|
user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueue a durable topology run with `research_topic_id` set. Kept in the
|
|
||||||
/// test file to avoid a production repo helper for the topic-scoped enqueue
|
|
||||||
/// path (nothing else in the codebase writes this column yet).
|
|
||||||
async fn enqueue_run_with_topic(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
task: &str,
|
|
||||||
topic_id: Uuid,
|
|
||||||
) -> Uuid {
|
|
||||||
let id = Uuid::now_v7();
|
|
||||||
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n","role":"r"}], "edges": []});
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO topology_runs
|
|
||||||
(id, workspace_id, task, kind, status, graph, tier, research_topic_id)
|
|
||||||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5)",
|
|
||||||
id,
|
|
||||||
workspace_id.as_uuid(),
|
|
||||||
task,
|
|
||||||
graph,
|
|
||||||
topic_id,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
||||||
let ws = Workspace {
|
let ws = Workspace {
|
||||||
id: WorkspaceId::new(),
|
id: WorkspaceId::new(),
|
||||||
@@ -176,222 +147,6 @@ async fn cancel_transitions_only_active_runs() {
|
|||||||
assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap());
|
assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
|
|
||||||
let pool = cm_testkit::test_pool().await;
|
|
||||||
let ws = seed_workspace(&pool).await;
|
|
||||||
|
|
||||||
// A loop needs a valid `created_by` user in the same workspace.
|
|
||||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
|
||||||
|
|
||||||
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n1","role":"drafter"}], "edges": []});
|
|
||||||
let triggers = json!({});
|
|
||||||
let repeat = json!({"kind": "infinite"});
|
|
||||||
let loop_id = loops::create(
|
|
||||||
&pool,
|
|
||||||
loops::NewLoop {
|
|
||||||
workspace_id: ws.as_uuid(),
|
|
||||||
title: "L",
|
|
||||||
description: "d",
|
|
||||||
graph: &graph,
|
|
||||||
task_template: "task",
|
|
||||||
triggers: &triggers,
|
|
||||||
repeat_policy: &repeat,
|
|
||||||
enabled: true,
|
|
||||||
next_fire_at: None,
|
|
||||||
webhook_token: None,
|
|
||||||
webhook_signing_key: None,
|
|
||||||
created_by: user_id.as_uuid(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Three iterations of the target loop, plus a naked run + another loop's
|
|
||||||
// iteration that should be filtered out.
|
|
||||||
let it1 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t1", &graph, 1, None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let it2 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t2", &graph, 2, Some(it1))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let it3 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t3", &graph, 3, Some(it2))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
topology_runs::enqueue_run(&pool, Uuid::now_v7(), ws, "naked", &graph)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let other_loop = loops::create(
|
|
||||||
&pool,
|
|
||||||
loops::NewLoop {
|
|
||||||
workspace_id: ws.as_uuid(),
|
|
||||||
title: "L2",
|
|
||||||
description: "d2",
|
|
||||||
graph: &graph,
|
|
||||||
task_template: "task2",
|
|
||||||
triggers: &triggers,
|
|
||||||
repeat_policy: &repeat,
|
|
||||||
enabled: true,
|
|
||||||
next_fire_at: None,
|
|
||||||
webhook_token: None,
|
|
||||||
webhook_signing_key: None,
|
|
||||||
created_by: user_id.as_uuid(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let _other_it =
|
|
||||||
loops::enqueue_iteration(&pool, other_loop, ws.as_uuid(), "other", &graph, 1, None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let rows = topology_runs::list_by_loop(&pool, ws, loop_id, 20)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(rows.len(), 3, "only the target loop's iterations");
|
|
||||||
// Newest iteration first.
|
|
||||||
assert_eq!(rows[0].iteration, Some(3));
|
|
||||||
assert_eq!(rows[0].id, it3);
|
|
||||||
assert_eq!(rows[1].iteration, Some(2));
|
|
||||||
assert_eq!(rows[1].id, it2);
|
|
||||||
assert_eq!(rows[2].iteration, Some(1));
|
|
||||||
assert_eq!(rows[2].id, it1);
|
|
||||||
// Each iteration is still a durable run — finished_at is None until completion.
|
|
||||||
assert!(rows.iter().all(|r| r.finished_at.is_none()));
|
|
||||||
assert!(rows.iter().all(|r| r.kind == "run"));
|
|
||||||
|
|
||||||
// Wrong workspace: nothing.
|
|
||||||
let other_ws = seed_workspace(&pool).await;
|
|
||||||
let cross = topology_runs::list_by_loop(&pool, other_ws, loop_id, 20)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(cross.is_empty(), "loops are scoped by workspace");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn notify_run_completed_transitions_topic_when_no_siblings_in_flight() {
|
|
||||||
let pool = cm_testkit::test_pool().await;
|
|
||||||
let ws = seed_workspace(&pool).await;
|
|
||||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
|
||||||
|
|
||||||
let topic = research_topics::create(
|
|
||||||
&pool,
|
|
||||||
research_topics::NewTopic {
|
|
||||||
workspace_id: ws.as_uuid(),
|
|
||||||
title: "Topic",
|
|
||||||
description: "desc",
|
|
||||||
outcome_kind: "spec",
|
|
||||||
topology_kind: "hub_spoke",
|
|
||||||
repo_id: None,
|
|
||||||
created_by: user_id.as_uuid(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// The auto-transition only fires while the topic is `processing`.
|
|
||||||
research_topics::set_status(&pool, topic, ws.as_uuid(), "processing")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let run_id = enqueue_run_with_topic(&pool, ws, "task", topic).await;
|
|
||||||
// Flip to a terminal state before calling — mirrors the worker order.
|
|
||||||
let result = json!({"final_output": "done"});
|
|
||||||
topology_runs::complete(&pool, run_id, &result)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// A completed run is not enough on its own: `notify_run_completed` only
|
|
||||||
// advances the topic to `reviewing` once at least one outcome exists,
|
|
||||||
// so mirror the worker order and persist the artifact first.
|
|
||||||
research_outcomes::insert(&pool, topic, "# body", Some(run_id))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let transitioned = topology_runs::notify_run_completed(&pool, run_id)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(transitioned, "no siblings in flight → topic transitions");
|
|
||||||
let t = research_topics::get(&pool, topic, ws.as_uuid())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(t.status, "reviewing");
|
|
||||||
|
|
||||||
// Idempotent: a second call after the topic has already left `processing`
|
|
||||||
// is a no-op.
|
|
||||||
let again = topology_runs::notify_run_completed(&pool, run_id)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!again,
|
|
||||||
"second call is a no-op — topic is no longer processing"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn notify_run_completed_leaves_topic_processing_when_siblings_in_flight() {
|
|
||||||
let pool = cm_testkit::test_pool().await;
|
|
||||||
let ws = seed_workspace(&pool).await;
|
|
||||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
|
||||||
|
|
||||||
let topic = research_topics::create(
|
|
||||||
&pool,
|
|
||||||
research_topics::NewTopic {
|
|
||||||
workspace_id: ws.as_uuid(),
|
|
||||||
title: "Topic",
|
|
||||||
description: "desc",
|
|
||||||
outcome_kind: "spec",
|
|
||||||
topology_kind: "hub_spoke",
|
|
||||||
repo_id: None,
|
|
||||||
created_by: user_id.as_uuid(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
research_topics::set_status(&pool, topic, ws.as_uuid(), "processing")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let done = enqueue_run_with_topic(&pool, ws, "first", topic).await;
|
|
||||||
let _still_queued = enqueue_run_with_topic(&pool, ws, "second", topic).await;
|
|
||||||
topology_runs::complete(&pool, done, &json!({"final_output": "x"}))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let transitioned = topology_runs::notify_run_completed(&pool, done)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!transitioned, "sibling still queued → hold");
|
|
||||||
let t = research_topics::get(&pool, topic, ws.as_uuid())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(t.status, "processing");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn notify_run_completed_ignores_runs_with_no_research_topic() {
|
|
||||||
let pool = cm_testkit::test_pool().await;
|
|
||||||
let ws = seed_workspace(&pool).await;
|
|
||||||
|
|
||||||
let id = Uuid::now_v7();
|
|
||||||
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n","role":"r"}], "edges": []});
|
|
||||||
topology_runs::enqueue_run(&pool, id, ws, "task", &graph)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
topology_runs::complete(&pool, id, &json!({}))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let transitioned = topology_runs::notify_run_completed(&pool, id)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
!transitioned,
|
|
||||||
"no research_topic_id → nothing to transition"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed_team(
|
async fn seed_team(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
ws: WorkspaceId,
|
ws: WorkspaceId,
|
||||||
|
|||||||
@@ -1,877 +0,0 @@
|
|||||||
//! Loops — durable recurring topology executions. The row holds the
|
|
||||||
//! definition (graph + task_template + triggers + repeat_policy) and a small
|
|
||||||
//! amount of scheduler state (enabled, next_fire_at, last_run_id).
|
|
||||||
//! Each fire produces a normal `topology_runs` row with loop_id + iteration
|
|
||||||
//! + parent_run_id set, so the run driver picks it up like any other job.
|
|
||||||
//!
|
|
||||||
//! See 0031 migration header for the state semantics and missed-window rule.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::DbError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Loop {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub description: String,
|
|
||||||
pub graph: Value,
|
|
||||||
pub task_template: String,
|
|
||||||
pub triggers: Value,
|
|
||||||
pub repeat_policy: Value,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub next_fire_at: Option<OffsetDateTime>,
|
|
||||||
pub last_run_id: Option<Uuid>,
|
|
||||||
pub webhook_token: Option<String>,
|
|
||||||
pub webhook_signing_key: Option<String>,
|
|
||||||
pub created_by: Uuid,
|
|
||||||
pub created_at: OffsetDateTime,
|
|
||||||
pub updated_at: OffsetDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal fields the scheduler needs when it wakes up.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DueLoop {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub graph: Value,
|
|
||||||
pub task_template: String,
|
|
||||||
pub triggers: Value,
|
|
||||||
pub repeat_policy: Value,
|
|
||||||
pub last_run_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct NewLoop<'a> {
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub title: &'a str,
|
|
||||||
pub description: &'a str,
|
|
||||||
pub graph: &'a Value,
|
|
||||||
pub task_template: &'a str,
|
|
||||||
pub triggers: &'a Value,
|
|
||||||
pub repeat_policy: &'a Value,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub next_fire_at: Option<OffsetDateTime>,
|
|
||||||
pub webhook_token: Option<&'a str>,
|
|
||||||
pub webhook_signing_key: Option<&'a str>,
|
|
||||||
pub created_by: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create(pool: &PgPool, input: NewLoop<'_>) -> Result<Uuid, DbError> {
|
|
||||||
let id = Uuid::now_v7();
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO loops
|
|
||||||
(id, workspace_id, title, description, graph, task_template,
|
|
||||||
triggers, repeat_policy, enabled, next_fire_at,
|
|
||||||
webhook_token, webhook_signing_key, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
|
|
||||||
id,
|
|
||||||
input.workspace_id,
|
|
||||||
input.title,
|
|
||||||
input.description,
|
|
||||||
input.graph,
|
|
||||||
input.task_template,
|
|
||||||
input.triggers,
|
|
||||||
input.repeat_policy,
|
|
||||||
input.enabled,
|
|
||||||
input.next_fire_at,
|
|
||||||
input.webhook_token,
|
|
||||||
input.webhook_signing_key,
|
|
||||||
input.created_by,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<Loop>, DbError> {
|
|
||||||
let rows = sqlx::query_as!(
|
|
||||||
Loop,
|
|
||||||
"SELECT id, workspace_id, title, description, graph, task_template,
|
|
||||||
triggers, repeat_policy, enabled, next_fire_at, last_run_id,
|
|
||||||
webhook_token, webhook_signing_key, created_by, created_at, updated_at
|
|
||||||
FROM loops
|
|
||||||
WHERE workspace_id = $1
|
|
||||||
ORDER BY updated_at DESC",
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kind + source_research_topic_id + task_template — the minimum a
|
|
||||||
/// caller needs to compose the right iteration for a loop without
|
|
||||||
/// hydrating the whole Loop struct. Kind='exec' preserves today's
|
|
||||||
/// behavior; kind='research' builds a research prompt bound to the
|
|
||||||
/// source topic so freeze_research_outcome writes a new outcome
|
|
||||||
/// version.
|
|
||||||
pub async fn kind_and_binding(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
) -> Result<Option<(String, Option<Uuid>, String)>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT kind, source_research_topic_id, task_template
|
|
||||||
FROM loops
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| {
|
|
||||||
(
|
|
||||||
r.get::<String, _>("kind"),
|
|
||||||
r.try_get::<Option<Uuid>, _>("source_research_topic_id")
|
|
||||||
.ok()
|
|
||||||
.flatten(),
|
|
||||||
r.get::<String, _>("task_template"),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cross-workspace fetch used by internal callers (topology_worker
|
|
||||||
/// completion hooks) where the run row is authoritative for the
|
|
||||||
/// workspace binding — no need for a second scoping check. Returns
|
|
||||||
/// None if the loop was deleted between run enqueue and completion.
|
|
||||||
pub async fn get_any_workspace(pool: &PgPool, id: Uuid) -> Result<Option<Loop>, DbError> {
|
|
||||||
// Dynamic query so this callsite doesn't require an offline sqlx
|
|
||||||
// cache regen — used from the completion hook, not on the hot path.
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT id, workspace_id, title, description, graph, task_template,
|
|
||||||
triggers, repeat_policy, enabled, next_fire_at, last_run_id,
|
|
||||||
webhook_token, webhook_signing_key, created_by, created_at, updated_at
|
|
||||||
FROM loops
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| Loop {
|
|
||||||
id: r.get("id"),
|
|
||||||
workspace_id: r.get("workspace_id"),
|
|
||||||
title: r.get("title"),
|
|
||||||
description: r.get("description"),
|
|
||||||
graph: r.get("graph"),
|
|
||||||
task_template: r.get("task_template"),
|
|
||||||
triggers: r.get("triggers"),
|
|
||||||
repeat_policy: r.get("repeat_policy"),
|
|
||||||
enabled: r.get("enabled"),
|
|
||||||
next_fire_at: r.try_get("next_fire_at").ok().flatten(),
|
|
||||||
last_run_id: r.try_get("last_run_id").ok().flatten(),
|
|
||||||
webhook_token: r.try_get("webhook_token").ok().flatten(),
|
|
||||||
webhook_signing_key: r.try_get("webhook_signing_key").ok().flatten(),
|
|
||||||
created_by: r.get("created_by"),
|
|
||||||
created_at: r.get("created_at"),
|
|
||||||
updated_at: r.get("updated_at"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<Loop>, DbError> {
|
|
||||||
let row = sqlx::query_as!(
|
|
||||||
Loop,
|
|
||||||
"SELECT id, workspace_id, title, description, graph, task_template,
|
|
||||||
triggers, repeat_policy, enabled, next_fire_at, last_run_id,
|
|
||||||
webhook_token, webhook_signing_key, created_by, created_at, updated_at
|
|
||||||
FROM loops
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a loop by its webhook token — used only by the webhook receiver,
|
|
||||||
/// which has no session context. Returns the minimal shape needed to enqueue
|
|
||||||
/// an iteration and verify the HMAC signature.
|
|
||||||
pub async fn get_by_webhook_token(
|
|
||||||
pool: &PgPool,
|
|
||||||
token: &str,
|
|
||||||
) -> Result<Option<(Uuid, Uuid, String, DueLoop)>, DbError> {
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT id, workspace_id, graph, task_template, triggers, repeat_policy,
|
|
||||||
last_run_id, webhook_signing_key
|
|
||||||
FROM loops
|
|
||||||
WHERE webhook_token = $1 AND enabled",
|
|
||||||
token,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| {
|
|
||||||
let key = r.webhook_signing_key?;
|
|
||||||
Some((
|
|
||||||
r.id,
|
|
||||||
r.workspace_id,
|
|
||||||
key,
|
|
||||||
DueLoop {
|
|
||||||
id: r.id,
|
|
||||||
workspace_id: r.workspace_id,
|
|
||||||
graph: r.graph,
|
|
||||||
task_template: r.task_template,
|
|
||||||
triggers: r.triggers,
|
|
||||||
repeat_policy: r.repeat_policy,
|
|
||||||
last_run_id: r.last_run_id,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UpdateLoop<'a> {
|
|
||||||
pub title: &'a str,
|
|
||||||
pub description: &'a str,
|
|
||||||
pub graph: &'a Value,
|
|
||||||
pub task_template: &'a str,
|
|
||||||
pub triggers: &'a Value,
|
|
||||||
pub repeat_policy: &'a Value,
|
|
||||||
pub next_fire_at: Option<OffsetDateTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
input: UpdateLoop<'_>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE loops
|
|
||||||
SET title = $3, description = $4, graph = $5, task_template = $6,
|
|
||||||
triggers = $7, repeat_policy = $8, next_fire_at = $9,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
input.title,
|
|
||||||
input.description,
|
|
||||||
input.graph,
|
|
||||||
input.task_template,
|
|
||||||
input.triggers,
|
|
||||||
input.repeat_policy,
|
|
||||||
input.next_fire_at,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_enabled(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE loops SET enabled = $3, updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
enabled,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM loops WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the per-loop team container name + gateway URL after a
|
|
||||||
/// successful `spawn_loop` (P2). Dynamic query so the new columns don't
|
|
||||||
/// need a fresh .sqlx offline cache entry.
|
|
||||||
pub async fn set_zeroclaw_container(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
container: &str,
|
|
||||||
gateway_url: &str,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE loops
|
|
||||||
SET zeroclaw_container = $3,
|
|
||||||
zeroclaw_gateway_url = $4,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(workspace_id)
|
|
||||||
.bind(container)
|
|
||||||
.bind(gateway_url)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append one reorder rationale event to the loop's reorder_events
|
|
||||||
/// jsonb array. Called from the topology_worker completion hook after
|
|
||||||
/// parsing REORDER: markers out of the run output. Each event carries
|
|
||||||
/// the iteration index, run_id, text, and now() timestamp so a
|
|
||||||
/// downstream mini-timeline can show WHEN the plan was adjusted and
|
|
||||||
/// WHY. Idempotent: appending a duplicate text/run_id combo is allowed
|
|
||||||
/// (rare — indicates the parser matched twice on the same line).
|
|
||||||
/// Set a loop's kind. Used by materialize_topic_loops right after
|
|
||||||
/// create() — the create path doesn't take a kind parameter (default
|
|
||||||
/// 'exec' matches every legacy loop), so research-kind loops flip the
|
|
||||||
/// column in a follow-up UPDATE.
|
|
||||||
pub async fn set_kind(pool: &PgPool, loop_id: Uuid, kind: &str) -> Result<(), DbError> {
|
|
||||||
sqlx::query("UPDATE loops SET kind = $2, updated_at = now() WHERE id = $1")
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(kind)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the countdown for the triggers.initial_burst quota. On
|
|
||||||
/// create_loop we set this to `burst - 1` after firing the first
|
|
||||||
/// iteration inline; on each subsequent completion we decrement and,
|
|
||||||
/// while it's > 0, enqueue another iteration. Dynamic query so the
|
|
||||||
/// new column doesn't need an offline sqlx cache regen.
|
|
||||||
pub async fn set_initial_burst_remaining(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
remaining: i32,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query("UPDATE loops SET initial_burst_remaining = $2 WHERE id = $1")
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(remaining)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically decrement initial_burst_remaining, returning the value
|
|
||||||
/// BEFORE decrement. Zero is a no-op (returns 0). Used by the
|
|
||||||
/// completion hook: caller enqueues a new iteration if the returned
|
|
||||||
/// value is > 0. CAS-safe: two workers can't race and both enqueue.
|
|
||||||
pub async fn take_initial_burst_slot(pool: &PgPool, loop_id: Uuid) -> Result<i32, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"UPDATE loops
|
|
||||||
SET initial_burst_remaining = GREATEST(initial_burst_remaining - 1, 0)
|
|
||||||
WHERE id = $1 AND initial_burst_remaining > 0
|
|
||||||
RETURNING initial_burst_remaining + 1 AS prev",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row
|
|
||||||
.and_then(|r| r.try_get::<i32, _>("prev").ok())
|
|
||||||
.unwrap_or(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate loops that should wake up when a research topic gets a
|
|
||||||
/// new outcome. Filters to kind='exec', enabled, and
|
|
||||||
/// triggers.on_artifact_update = true. Called by the completion hook
|
|
||||||
/// after freeze_research_outcome inserts a new row. Returns
|
|
||||||
/// (loop_id, workspace_id, task_template, graph) so the caller can
|
|
||||||
/// enqueue directly without a second fetch.
|
|
||||||
/// Return the kind='research' loop that owns a topic's runs (there
|
|
||||||
/// should be at most one — created by the wizard's
|
|
||||||
/// materialize_topic_loops). Used by the topic detail endpoint to
|
|
||||||
/// tell the canvas that classic Start/Submit buttons should be
|
|
||||||
/// replaced with the loop-managed UI.
|
|
||||||
pub async fn research_loop_for_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
) -> Result<
|
|
||||||
Option<(
|
|
||||||
Uuid,
|
|
||||||
String,
|
|
||||||
bool,
|
|
||||||
Option<time::OffsetDateTime>,
|
|
||||||
Option<Uuid>,
|
|
||||||
Value,
|
|
||||||
)>,
|
|
||||||
DbError,
|
|
||||||
> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT id, title, enabled, next_fire_at, last_run_id, triggers
|
|
||||||
FROM loops
|
|
||||||
WHERE source_research_topic_id = $1
|
|
||||||
AND kind = 'research'
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(topic_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| {
|
|
||||||
(
|
|
||||||
r.get::<Uuid, _>("id"),
|
|
||||||
r.get::<String, _>("title"),
|
|
||||||
r.get::<bool, _>("enabled"),
|
|
||||||
r.try_get::<Option<time::OffsetDateTime>, _>("next_fire_at")
|
|
||||||
.ok()
|
|
||||||
.flatten(),
|
|
||||||
r.try_get::<Option<Uuid>, _>("last_run_id").ok().flatten(),
|
|
||||||
r.get::<Value, _>("triggers"),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn loops_awaiting_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
) -> Result<Vec<(Uuid, Uuid, String, Value)>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT id, workspace_id, task_template, graph
|
|
||||||
FROM loops
|
|
||||||
WHERE source_research_topic_id = $1
|
|
||||||
AND kind = 'exec'
|
|
||||||
AND enabled = true
|
|
||||||
AND (triggers ->> 'on_artifact_update')::boolean = true",
|
|
||||||
)
|
|
||||||
.bind(topic_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
(
|
|
||||||
r.get::<Uuid, _>("id"),
|
|
||||||
r.get::<Uuid, _>("workspace_id"),
|
|
||||||
r.get::<String, _>("task_template"),
|
|
||||||
r.get::<Value, _>("graph"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truthy when the loop currently has a queued or running iteration.
|
|
||||||
/// Used by triggers (on_artifact_update, on_completion chain) to
|
|
||||||
/// coalesce — no point enqueuing another iteration while one is
|
|
||||||
/// already pending.
|
|
||||||
pub async fn has_active_run(pool: &PgPool, loop_id: Uuid) -> Result<bool, DbError> {
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT 1 AS one FROM topology_runs
|
|
||||||
WHERE loop_id = $1 AND status IN ('queued', 'running')
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the reorder_events array for a loop, newest-first, capped at
|
|
||||||
/// `limit`. Used by the progress endpoint to surface a compact recent
|
|
||||||
/// history on the sidebar card. Empty array for standalone loops or
|
|
||||||
/// loops whose coordinator hasn't emitted any REORDER markers yet.
|
|
||||||
pub async fn recent_reorders(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<Value>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> =
|
|
||||||
sqlx::query("SELECT reorder_events FROM loops WHERE id = $1")
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
let arr: Vec<Value> = row
|
|
||||||
.and_then(|r| r.try_get::<Value, _>("reorder_events").ok())
|
|
||||||
.and_then(|v| v.as_array().cloned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
// Appended in chronological order (oldest → newest); reversing then
|
|
||||||
// taking `limit` yields the newest N in newest-first order.
|
|
||||||
let recent: Vec<Value> = arr.into_iter().rev().take(limit as usize).collect();
|
|
||||||
Ok(recent)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn append_reorder_event(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
run_id: Uuid,
|
|
||||||
iteration: i32,
|
|
||||||
text: &str,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
// Build the event server-side so `ts` uses postgres now() (canonical
|
|
||||||
// wall clock; avoids skew if callers had stale local clocks).
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE loops
|
|
||||||
SET reorder_events = reorder_events || jsonb_build_object(
|
|
||||||
'run_id', $2::text,
|
|
||||||
'iteration', $3::int,
|
|
||||||
'text', $4::text,
|
|
||||||
'ts', to_char(now() AT TIME ZONE 'UTC',
|
|
||||||
'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')
|
|
||||||
),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(run_id.to_string())
|
|
||||||
.bind(iteration)
|
|
||||||
.bind(text)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically append `completed` INT-XX ids to the loop's
|
|
||||||
/// `consumed_int_ids` array and bump `current_int_index` by the count
|
|
||||||
/// of NEW ids landed. Existing ids are not re-appended (idempotent on
|
|
||||||
/// re-runs). Called from the topology_worker completion hook after
|
|
||||||
/// parsing "COMPLETED: INT-XX" markers out of the run's final output.
|
|
||||||
pub async fn advance_after_completion(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
completed: &[String],
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
if completed.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Use array set semantics: append only ids not already present.
|
|
||||||
// The subquery computes the new list; length delta feeds the index bump.
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE loops
|
|
||||||
SET consumed_int_ids = (
|
|
||||||
SELECT ARRAY(
|
|
||||||
SELECT DISTINCT unnest(consumed_int_ids || $2::TEXT[])
|
|
||||||
)
|
|
||||||
),
|
|
||||||
current_int_index = current_int_index + (
|
|
||||||
SELECT count(*) FROM unnest($2::TEXT[]) AS n(v)
|
|
||||||
WHERE NOT (consumed_int_ids @> ARRAY[v])
|
|
||||||
),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(completed)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind (or unbind) a loop's source research topic. When set, the loop's
|
|
||||||
/// enqueue path prepends the topic's latest artifact + a "focus on the
|
|
||||||
/// next unconsumed INT" instruction to the coordinator task (option b,
|
|
||||||
/// order-sequential iteration).
|
|
||||||
pub async fn set_source_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
source: Option<Uuid>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE loops
|
|
||||||
SET source_research_topic_id = $3, updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(workspace_id)
|
|
||||||
.bind(source)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a loop's source research topic id + consumed INT ids +
|
|
||||||
/// current index. Used by the enqueue path when building the
|
|
||||||
/// coordinator task string. Missing rows / NULL columns return None
|
|
||||||
/// so the caller can fall back to the plain task_template.
|
|
||||||
pub async fn source_research_context(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
) -> Result<Option<(Uuid, Vec<String>, i32)>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row = sqlx::query(
|
|
||||||
"SELECT source_research_topic_id, consumed_int_ids, current_int_index
|
|
||||||
FROM loops
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| {
|
|
||||||
let topic = r
|
|
||||||
.try_get::<Option<Uuid>, _>("source_research_topic_id")
|
|
||||||
.ok()
|
|
||||||
.flatten()?;
|
|
||||||
let consumed = r
|
|
||||||
.try_get::<Vec<String>, _>("consumed_int_ids")
|
|
||||||
.unwrap_or_default();
|
|
||||||
let idx = r.try_get::<i32, _>("current_int_index").unwrap_or(0);
|
|
||||||
Some((topic, consumed, idx))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the per-loop gateway URL (or None if the loop hasn't spawned a
|
|
||||||
/// container yet). Used by `topology_worker` to prefer the isolated
|
|
||||||
/// daemon over the workspace-wide one.
|
|
||||||
pub async fn zeroclaw_gateway_url(pool: &PgPool, loop_id: Uuid) -> Result<Option<String>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row = sqlx::query("SELECT zeroclaw_gateway_url FROM loops WHERE id = $1")
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| {
|
|
||||||
r.try_get::<Option<String>, _>("zeroclaw_gateway_url")
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Staffing ---------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Loops attach agents, teams, and/or orgs. The three join tables are
|
|
||||||
// parallel; a loop can mix modes (e.g. one team + a couple of specialist
|
|
||||||
// agents). Callers use the `set_*` replace-all shape so PATCH is a single
|
|
||||||
// transactional swap — simpler than diffing and cheap for the list sizes
|
|
||||||
// this UI generates.
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct AgentSlot {
|
|
||||||
pub agent_id: Uuid,
|
|
||||||
pub role_slot: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_agents(pool: &PgPool, loop_id: Uuid, slots: &[AgentSlot]) -> Result<(), DbError> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
sqlx::query!("DELETE FROM loop_agents WHERE loop_id = $1", loop_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
for s in slots {
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO loop_agents (loop_id, agent_id, role_slot)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (loop_id, agent_id) DO UPDATE
|
|
||||||
SET role_slot = EXCLUDED.role_slot",
|
|
||||||
loop_id,
|
|
||||||
s.agent_id,
|
|
||||||
s.role_slot,
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn agents(pool: &PgPool, loop_id: Uuid) -> Result<Vec<AgentSlot>, DbError> {
|
|
||||||
let rows = sqlx::query!(
|
|
||||||
"SELECT agent_id, role_slot FROM loop_agents WHERE loop_id = $1",
|
|
||||||
loop_id,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| AgentSlot {
|
|
||||||
agent_id: r.agent_id,
|
|
||||||
role_slot: r.role_slot,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_teams(pool: &PgPool, loop_id: Uuid, ids: &[Uuid]) -> Result<(), DbError> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
sqlx::query!("DELETE FROM loop_teams WHERE loop_id = $1", loop_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
for id in ids {
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO loop_teams (loop_id, team_id) VALUES ($1, $2)
|
|
||||||
ON CONFLICT (loop_id, team_id) DO NOTHING",
|
|
||||||
loop_id,
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn teams(pool: &PgPool, loop_id: Uuid) -> Result<Vec<Uuid>, DbError> {
|
|
||||||
let rows = sqlx::query!("SELECT team_id FROM loop_teams WHERE loop_id = $1", loop_id,)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows.into_iter().map(|r| r.team_id).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_orgs(pool: &PgPool, loop_id: Uuid, ids: &[Uuid]) -> Result<(), DbError> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
sqlx::query!("DELETE FROM loop_orgs WHERE loop_id = $1", loop_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
for id in ids {
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO loop_orgs (loop_id, org_id) VALUES ($1, $2)
|
|
||||||
ON CONFLICT (loop_id, org_id) DO NOTHING",
|
|
||||||
loop_id,
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn orgs(pool: &PgPool, loop_id: Uuid) -> Result<Vec<Uuid>, DbError> {
|
|
||||||
let rows = sqlx::query!("SELECT org_id FROM loop_orgs WHERE loop_id = $1", loop_id,)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows.into_iter().map(|r| r.org_id).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loops the scheduler tick should fire NOW. Only reads what the enqueue
|
|
||||||
/// path needs, so the tick stays cheap even when the workspace has hundreds
|
|
||||||
/// of loops.
|
|
||||||
pub async fn due(pool: &PgPool) -> Result<Vec<DueLoop>, DbError> {
|
|
||||||
let rows = sqlx::query!(
|
|
||||||
"SELECT id, workspace_id, graph, task_template, triggers, repeat_policy,
|
|
||||||
last_run_id
|
|
||||||
FROM loops
|
|
||||||
WHERE enabled AND next_fire_at IS NOT NULL AND next_fire_at <= now()",
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| DueLoop {
|
|
||||||
id: r.id,
|
|
||||||
workspace_id: r.workspace_id,
|
|
||||||
graph: r.graph,
|
|
||||||
task_template: r.task_template,
|
|
||||||
triggers: r.triggers,
|
|
||||||
repeat_policy: r.repeat_policy,
|
|
||||||
last_run_id: r.last_run_id,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Post-fire bookkeeping: bump last_run_id + advance next_fire_at (NULL when
|
|
||||||
/// the loop has no cron trigger). Called by the scheduler after a successful
|
|
||||||
/// enqueue_iteration.
|
|
||||||
pub async fn mark_fired(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
run_id: Uuid,
|
|
||||||
next_fire_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE loops
|
|
||||||
SET last_run_id = $2, next_fire_at = $3, updated_at = now()
|
|
||||||
WHERE id = $1",
|
|
||||||
id,
|
|
||||||
run_id,
|
|
||||||
next_fire_at,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Next iteration number for a loop (1 if it has never fired).
|
|
||||||
pub async fn next_iteration(pool: &PgPool, loop_id: Uuid) -> Result<i32, DbError> {
|
|
||||||
let n: Option<i32> = sqlx::query_scalar!(
|
|
||||||
"SELECT MAX(iteration) FROM topology_runs WHERE loop_id = $1",
|
|
||||||
loop_id,
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(n.unwrap_or(0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enqueue an iteration as a normal `topology_runs` row. The scheduler,
|
|
||||||
/// on-completion hook, and webhook receiver all funnel through here so the
|
|
||||||
/// invariants (loop_id + iteration + parent_run_id all set together) stay
|
|
||||||
/// in one place.
|
|
||||||
pub async fn enqueue_iteration(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
task: &str,
|
|
||||||
graph: &Value,
|
|
||||||
iteration: i32,
|
|
||||||
parent_run_id: Option<Uuid>,
|
|
||||||
) -> Result<Uuid, DbError> {
|
|
||||||
enqueue_iteration_with_topic(
|
|
||||||
pool,
|
|
||||||
IterationEnqueue {
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
task,
|
|
||||||
graph,
|
|
||||||
iteration,
|
|
||||||
parent_run_id,
|
|
||||||
research_topic_id: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Batched arguments for `enqueue_iteration_with_topic`. Bundled so the
|
|
||||||
/// signature stays under clippy's 7-arg ceiling — the columns are
|
|
||||||
/// all conceptually one "run to enqueue for a loop", not free-floating
|
|
||||||
/// parameters.
|
|
||||||
pub struct IterationEnqueue<'a> {
|
|
||||||
pub loop_id: Uuid,
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub task: &'a str,
|
|
||||||
pub graph: &'a Value,
|
|
||||||
pub iteration: i32,
|
|
||||||
pub parent_run_id: Option<Uuid>,
|
|
||||||
pub research_topic_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Variant of `enqueue_iteration` that also sets `research_topic_id` on
|
|
||||||
/// the topology_runs row. Used by kind='research' loops so
|
|
||||||
/// `freeze_research_outcome` writes a new outcome version each
|
|
||||||
/// iteration, and by any future flow that binds a run to both a loop
|
|
||||||
/// and a research topic.
|
|
||||||
pub async fn enqueue_iteration_with_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
args: IterationEnqueue<'_>,
|
|
||||||
) -> Result<Uuid, DbError> {
|
|
||||||
let IterationEnqueue {
|
|
||||||
loop_id,
|
|
||||||
workspace_id,
|
|
||||||
task,
|
|
||||||
graph,
|
|
||||||
iteration,
|
|
||||||
parent_run_id,
|
|
||||||
research_topic_id,
|
|
||||||
} = args;
|
|
||||||
let run_id = Uuid::now_v7();
|
|
||||||
// Dynamic query so the new column combination (loop_id +
|
|
||||||
// research_topic_id on the same row) doesn't require an offline
|
|
||||||
// sqlx cache regen — the enqueue path only runs on user actions,
|
|
||||||
// not the tight worker loop.
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO topology_runs
|
|
||||||
(id, workspace_id, task, kind, status, graph, tier,
|
|
||||||
loop_id, iteration, parent_run_id, research_topic_id)
|
|
||||||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5, $6, $7, $8)",
|
|
||||||
)
|
|
||||||
.bind(run_id)
|
|
||||||
.bind(workspace_id)
|
|
||||||
.bind(task)
|
|
||||||
.bind(graph)
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(iteration)
|
|
||||||
.bind(parent_run_id)
|
|
||||||
.bind(research_topic_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(run_id)
|
|
||||||
}
|
|
||||||
@@ -30,6 +30,10 @@ pub struct Mission {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub config: Value,
|
pub config: Value,
|
||||||
|
/// 'zeroclaw' (default, headless) | 'local_herdr' (attended, fleet node)
|
||||||
|
pub runtime_kind: String,
|
||||||
|
/// FK → nodes(id); only relevant when runtime_kind = 'local_herdr'
|
||||||
|
pub target_node_id: Option<Uuid>,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
@@ -106,6 +110,9 @@ pub struct NewMission<'a> {
|
|||||||
pub schedule: Value,
|
pub schedule: Value,
|
||||||
pub description: Option<&'a str>,
|
pub description: Option<&'a str>,
|
||||||
pub config: Value,
|
pub config: Value,
|
||||||
|
/// Defaults to 'zeroclaw' when None.
|
||||||
|
pub runtime_kind: Option<&'a str>,
|
||||||
|
pub target_node_id: Option<Uuid>,
|
||||||
pub phases: Vec<NewMissionPhase>,
|
pub phases: Vec<NewMissionPhase>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +134,10 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO missions
|
"INSERT INTO missions
|
||||||
(id, workspace_id, title, template_kind, team_id,
|
(id, workspace_id, title, template_kind, team_id,
|
||||||
team_template_id, repo_id, schedule, status, description, config)
|
team_template_id, repo_id, schedule, status, description, config,
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10)",
|
runtime_kind, target_node_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10,
|
||||||
|
COALESCE($11,'zeroclaw'),$12)",
|
||||||
)
|
)
|
||||||
.bind(mission_id)
|
.bind(mission_id)
|
||||||
.bind(m.workspace_id)
|
.bind(m.workspace_id)
|
||||||
@@ -140,6 +149,8 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
|
|||||||
.bind(&m.schedule)
|
.bind(&m.schedule)
|
||||||
.bind(m.description)
|
.bind(m.description)
|
||||||
.bind(&m.config)
|
.bind(&m.config)
|
||||||
|
.bind(m.runtime_kind)
|
||||||
|
.bind(m.target_node_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -169,7 +180,8 @@ pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<M
|
|||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, workspace_id, title, template_kind, team_id,
|
"SELECT id, workspace_id, title, template_kind, team_id,
|
||||||
team_template_id, repo_id, schedule, status,
|
team_template_id, repo_id, schedule, status,
|
||||||
description, config, created_at, updated_at, completed_at
|
description, config, runtime_kind, target_node_id,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
FROM missions WHERE id = $1 AND workspace_id = $2",
|
FROM missions WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -188,6 +200,8 @@ pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<M
|
|||||||
status: r.get("status"),
|
status: r.get("status"),
|
||||||
description: r.get("description"),
|
description: r.get("description"),
|
||||||
config: r.get("config"),
|
config: r.get("config"),
|
||||||
|
runtime_kind: r.get("runtime_kind"),
|
||||||
|
target_node_id: r.get("target_node_id"),
|
||||||
created_at: r.get("created_at"),
|
created_at: r.get("created_at"),
|
||||||
updated_at: r.get("updated_at"),
|
updated_at: r.get("updated_at"),
|
||||||
completed_at: r.get("completed_at"),
|
completed_at: r.get("completed_at"),
|
||||||
@@ -204,7 +218,8 @@ pub async fn list_by_workspace(
|
|||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, workspace_id, title, template_kind, team_id,
|
"SELECT id, workspace_id, title, template_kind, team_id,
|
||||||
team_template_id, repo_id, schedule, status,
|
team_template_id, repo_id, schedule, status,
|
||||||
description, config, created_at, updated_at, completed_at
|
description, config, runtime_kind, target_node_id,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
FROM missions WHERE workspace_id = $1
|
FROM missions WHERE workspace_id = $1
|
||||||
ORDER BY created_at DESC LIMIT $2",
|
ORDER BY created_at DESC LIMIT $2",
|
||||||
)
|
)
|
||||||
@@ -226,6 +241,8 @@ pub async fn list_by_workspace(
|
|||||||
status: r.get("status"),
|
status: r.get("status"),
|
||||||
description: r.get("description"),
|
description: r.get("description"),
|
||||||
config: r.get("config"),
|
config: r.get("config"),
|
||||||
|
runtime_kind: r.get("runtime_kind"),
|
||||||
|
target_node_id: r.get("target_node_id"),
|
||||||
created_at: r.get("created_at"),
|
created_at: r.get("created_at"),
|
||||||
updated_at: r.get("updated_at"),
|
updated_at: r.get("updated_at"),
|
||||||
completed_at: r.get("completed_at"),
|
completed_at: r.get("completed_at"),
|
||||||
@@ -233,6 +250,66 @@ pub async fn list_by_workspace(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_description(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE missions
|
||||||
|
SET description = $3, updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(description)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch title + description in one shot. Either field `None` = leave
|
||||||
|
/// as-is (uses COALESCE so partial edits don't clobber the other).
|
||||||
|
pub async fn update_meta(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
title: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE missions
|
||||||
|
SET title = COALESCE($3, title),
|
||||||
|
description = COALESCE($4, description),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(title)
|
||||||
|
.bind(description)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard-delete a mission. Cascades via FKs on mission_phases /
|
||||||
|
/// mission_tasks / mission_artifacts / benchmark_snapshots (all
|
||||||
|
/// declared ON DELETE CASCADE in 0047).
|
||||||
|
pub async fn delete(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<u64, DbError> {
|
||||||
|
let r = sqlx::query("DELETE FROM missions WHERE id = $1 AND workspace_id = $2")
|
||||||
|
.bind(id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(r.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_status(
|
pub async fn set_status(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod files;
|
|||||||
pub mod fleet_beszel;
|
pub mod fleet_beszel;
|
||||||
pub mod fleet_tailscale;
|
pub mod fleet_tailscale;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
pub mod loops;
|
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod node_metrics;
|
pub mod node_metrics;
|
||||||
@@ -21,9 +20,6 @@ pub mod orgs;
|
|||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
pub mod repo_connections;
|
pub mod repo_connections;
|
||||||
pub mod repos;
|
pub mod repos;
|
||||||
pub mod research_outcomes;
|
|
||||||
pub mod research_publish_approvals;
|
|
||||||
pub mod research_topics;
|
|
||||||
pub mod routine_runs;
|
pub mod routine_runs;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod run_events;
|
pub mod run_events;
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
//! Persisted research artifacts — one row per run's final synthesis. When
|
|
||||||
//! `topology_worker` completes a run tagged with a `research_topic_id`, it
|
|
||||||
//! extracts the orchestrator's `RunRecord.final_output` and calls
|
|
||||||
//! [`insert`] here. The frontend canvas then renders the latest outcome
|
|
||||||
//! instead of the topic description when the topic has moved past
|
|
||||||
//! `standby`, so reviewers see the actual draft.
|
|
||||||
//!
|
|
||||||
//! Version is per-topic and monotonically increasing so reject-with-
|
|
||||||
//! revision loops accumulate history rather than clobber prior drafts.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::DbError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Outcome {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub topic_id: Uuid,
|
|
||||||
pub version: i32,
|
|
||||||
pub body_md: String,
|
|
||||||
pub produced_by_run_id: Option<Uuid>,
|
|
||||||
// RFC3339 on the wire so `new Date(...)` in the browser parses it
|
|
||||||
// instead of choking on the `time` crate's default `[y, ordinal,
|
|
||||||
// ...]` array format (surfaced as "Invalid Date" in the Draft
|
|
||||||
// header).
|
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
|
||||||
pub created_at: OffsetDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a new outcome. Version is derived server-side as `max(version) + 1`
|
|
||||||
/// for the topic (starting at 1) so callers never need to know the current
|
|
||||||
/// count. Returns the persisted row.
|
|
||||||
pub async fn insert(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
body_md: &str,
|
|
||||||
produced_by_run_id: Option<Uuid>,
|
|
||||||
) -> Result<Outcome, DbError> {
|
|
||||||
let id = Uuid::now_v7();
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"INSERT INTO research_outcomes (id, topic_id, version, body_md, produced_by_run_id)
|
|
||||||
SELECT $1, $2, coalesce(max(version), 0) + 1, $3, $4
|
|
||||||
FROM research_outcomes
|
|
||||||
WHERE topic_id = $2
|
|
||||||
RETURNING id, topic_id, version, body_md, produced_by_run_id, created_at",
|
|
||||||
id,
|
|
||||||
topic_id,
|
|
||||||
body_md,
|
|
||||||
produced_by_run_id,
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(Outcome {
|
|
||||||
id: row.id,
|
|
||||||
topic_id: row.topic_id,
|
|
||||||
version: row.version,
|
|
||||||
body_md: row.body_md,
|
|
||||||
produced_by_run_id: row.produced_by_run_id,
|
|
||||||
created_at: row.created_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Newest outcome for a topic, or `None` if no run has completed yet.
|
|
||||||
pub async fn latest(pool: &PgPool, topic_id: Uuid) -> Result<Option<Outcome>, DbError> {
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT id, topic_id, version, body_md, produced_by_run_id, created_at
|
|
||||||
FROM research_outcomes
|
|
||||||
WHERE topic_id = $1
|
|
||||||
ORDER BY version DESC
|
|
||||||
LIMIT 1",
|
|
||||||
topic_id,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| Outcome {
|
|
||||||
id: r.id,
|
|
||||||
topic_id: r.topic_id,
|
|
||||||
version: r.version,
|
|
||||||
body_md: r.body_md,
|
|
||||||
produced_by_run_id: r.produced_by_run_id,
|
|
||||||
created_at: r.created_at,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
//! Publish approval gate for research topics — see 0032 migration header.
|
|
||||||
//! Small table with a small state machine (pending → approved | rejected).
|
|
||||||
//! One pending row per topic at a time; enforced at the route layer by
|
|
||||||
//! looking up `pending_for_topic` before create.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::DbError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PublishApproval {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub topic_id: Uuid,
|
|
||||||
pub requested_by: Uuid,
|
|
||||||
pub status: String,
|
|
||||||
pub decided_by: Option<Uuid>,
|
|
||||||
pub decided_at: Option<OffsetDateTime>,
|
|
||||||
pub created_at: OffsetDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create(
|
|
||||||
pool: &PgPool,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
topic_id: Uuid,
|
|
||||||
requested_by: Uuid,
|
|
||||||
) -> Result<Uuid, DbError> {
|
|
||||||
let id = Uuid::now_v7();
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO research_publish_approvals
|
|
||||||
(id, workspace_id, topic_id, requested_by, status)
|
|
||||||
VALUES ($1, $2, $3, $4, 'pending')",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
topic_id,
|
|
||||||
requested_by,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pending approval for a topic, if any. The route layer uses this to
|
|
||||||
/// short-circuit before writing a duplicate request.
|
|
||||||
pub async fn pending_for_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
) -> Result<Option<PublishApproval>, DbError> {
|
|
||||||
let row = sqlx::query_as!(
|
|
||||||
PublishApproval,
|
|
||||||
"SELECT id, workspace_id, topic_id, requested_by, status,
|
|
||||||
decided_by, decided_at, created_at
|
|
||||||
FROM research_publish_approvals
|
|
||||||
WHERE topic_id = $1 AND status = 'pending'
|
|
||||||
LIMIT 1",
|
|
||||||
topic_id,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
) -> Result<Option<PublishApproval>, DbError> {
|
|
||||||
let row = sqlx::query_as!(
|
|
||||||
PublishApproval,
|
|
||||||
"SELECT id, workspace_id, topic_id, requested_by, status,
|
|
||||||
decided_by, decided_at, created_at
|
|
||||||
FROM research_publish_approvals
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_pending(
|
|
||||||
pool: &PgPool,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
) -> Result<Vec<PublishApproval>, DbError> {
|
|
||||||
let rows = sqlx::query_as!(
|
|
||||||
PublishApproval,
|
|
||||||
"SELECT id, workspace_id, topic_id, requested_by, status,
|
|
||||||
decided_by, decided_at, created_at
|
|
||||||
FROM research_publish_approvals
|
|
||||||
WHERE workspace_id = $1 AND status = 'pending'
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically flip a pending row to approved/rejected. Returns whether the
|
|
||||||
/// caller was the one who won the race — false when the row was already
|
|
||||||
/// decided (idempotent). Optional `notes` are stashed on the row so a
|
|
||||||
/// subsequent `start_topic` can pick them up as revision guidance
|
|
||||||
/// (R2 — reject-with-revision).
|
|
||||||
pub async fn decide(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
decided_by: Uuid,
|
|
||||||
approve: bool,
|
|
||||||
notes: Option<&str>,
|
|
||||||
) -> Result<bool, DbError> {
|
|
||||||
let new_status = if approve { "approved" } else { "rejected" };
|
|
||||||
// Dynamic sqlx::query so the new `notes` column doesn't need a fresh
|
|
||||||
// .sqlx offline cache entry — the value is bound at runtime.
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE research_publish_approvals
|
|
||||||
SET status = $4, decided_by = $3, decided_at = now(), notes = $5
|
|
||||||
WHERE id = $1 AND workspace_id = $2 AND status = 'pending'",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(workspace_id)
|
|
||||||
.bind(decided_by)
|
|
||||||
.bind(new_status)
|
|
||||||
.bind(notes)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(result.rows_affected() > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-recent rejected-approval notes for a topic, or None. Used by
|
|
||||||
/// `start_topic` to prepend a reviewer's revision guidance to the next
|
|
||||||
/// coordinator task. Only returns non-empty strings; a rejection with
|
|
||||||
/// no notes reads the same as no rejection at all.
|
|
||||||
pub async fn latest_rejection_notes(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
) -> Result<Option<String>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT notes
|
|
||||||
FROM research_publish_approvals
|
|
||||||
WHERE topic_id = $1 AND status = 'rejected' AND notes IS NOT NULL
|
|
||||||
ORDER BY decided_at DESC NULLS LAST
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(topic_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row
|
|
||||||
.and_then(|r| r.try_get::<Option<String>, _>("notes").ok().flatten())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty()))
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
//! Research topics — user-scoped inquiry containers that group agents around
|
|
||||||
//! a shared question and drive them toward a named outcome (spec, prod_plan,
|
|
||||||
//! roadmap, paper). The status column is a small state machine; see the
|
|
||||||
//! 0030 migration header for the transitions. Runs are owned via
|
|
||||||
//! `topology_runs.research_topic_id`, so all durable execution state lives
|
|
||||||
//! there — this repo only manages the container + status + agent binding.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::DbError;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ResearchTopic {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub title: String,
|
|
||||||
pub description: String,
|
|
||||||
pub outcome_kind: String,
|
|
||||||
pub status: String,
|
|
||||||
pub created_by: Uuid,
|
|
||||||
pub created_at: OffsetDateTime,
|
|
||||||
pub updated_at: OffsetDateTime,
|
|
||||||
pub published_at: Option<OffsetDateTime>,
|
|
||||||
/// Topology shape start_topic builds when firing this topic. Options:
|
|
||||||
/// hub_spoke, pipeline, hierarchical, star_moe. Defaults to hub_spoke.
|
|
||||||
pub topology_kind: String,
|
|
||||||
/// The workspace repo the wizard bound to this topic (optional). When
|
|
||||||
/// set, `start_topic` clones it and feeds the coordinator prompt with
|
|
||||||
/// the checkout path + a file-tree overview so the agents can reason
|
|
||||||
/// about the actual code.
|
|
||||||
pub repo_id: Option<Uuid>,
|
|
||||||
/// Absolute path on the API host where `start_topic` cloned the bound
|
|
||||||
/// repo. Written once on the first successful clone; subsequent starts
|
|
||||||
/// reuse it. Null until then.
|
|
||||||
pub repo_workspace_path: Option<String>,
|
|
||||||
/// Docker container name of the per-topic ZeroClaw team runtime, e.g.
|
|
||||||
/// "research-<topic_id>-team". Set by `research_container::spawn`;
|
|
||||||
/// cleared by teardown. Also used to look up the container for stop.
|
|
||||||
pub zeroclaw_container_name: Option<String>,
|
|
||||||
/// Reachable URL of the per-topic team's gateway, e.g.
|
|
||||||
/// `http://research-<topic_id>-team:42617`. Persisted so
|
|
||||||
/// topology_worker can point ZeroClawDriveExecutor at the isolated
|
|
||||||
/// endpoint for THIS topic's runs instead of the global env one.
|
|
||||||
pub zeroclaw_gateway_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct AgentSlot {
|
|
||||||
pub agent_id: Uuid,
|
|
||||||
pub role_slot: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fields the wizard collected for a new research topic. Grouped into a
|
|
||||||
/// struct so `create` stays under the 7-argument clippy ceiling and future
|
|
||||||
/// wizard additions (repo commit-branch, etc.) don't cascade into every
|
|
||||||
/// call site.
|
|
||||||
pub struct NewTopic<'a> {
|
|
||||||
pub workspace_id: Uuid,
|
|
||||||
pub title: &'a str,
|
|
||||||
pub description: &'a str,
|
|
||||||
pub outcome_kind: &'a str,
|
|
||||||
pub topology_kind: &'a str,
|
|
||||||
pub repo_id: Option<Uuid>,
|
|
||||||
pub created_by: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a topic in `standby`. Returns the new row's id.
|
|
||||||
pub async fn create(pool: &PgPool, input: NewTopic<'_>) -> Result<Uuid, DbError> {
|
|
||||||
let id = Uuid::now_v7();
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO research_topics
|
|
||||||
(id, workspace_id, title, description, outcome_kind, topology_kind, repo_id, status, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'standby', $8)",
|
|
||||||
id,
|
|
||||||
input.workspace_id,
|
|
||||||
input.title,
|
|
||||||
input.description,
|
|
||||||
input.outcome_kind,
|
|
||||||
input.topology_kind,
|
|
||||||
input.repo_id,
|
|
||||||
input.created_by,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the per-topic ZeroClaw container coordinates. Called from
|
|
||||||
/// `research_container::spawn` after `docker start` succeeds. Pass `None`
|
|
||||||
/// on both to clear the fields during teardown.
|
|
||||||
pub async fn set_zeroclaw_container(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
container_name: Option<&str>,
|
|
||||||
gateway_url: Option<&str>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE research_topics
|
|
||||||
SET zeroclaw_container_name = $3,
|
|
||||||
zeroclaw_gateway_url = $4,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
container_name,
|
|
||||||
gateway_url,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the clone path for a topic's bound repo. Set once, on the first
|
|
||||||
/// successful clone; a re-start reads it back and skips re-cloning.
|
|
||||||
pub async fn set_repo_workspace_path(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE research_topics
|
|
||||||
SET repo_workspace_path = $3, updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
path,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Workspace's topics, newest-updated first.
|
|
||||||
pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic>, DbError> {
|
|
||||||
let rows = sqlx::query_as!(
|
|
||||||
ResearchTopic,
|
|
||||||
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
|
||||||
created_by, created_at, updated_at, published_at, topology_kind,
|
|
||||||
repo_id, repo_workspace_path,
|
|
||||||
zeroclaw_container_name, zeroclaw_gateway_url
|
|
||||||
FROM research_topics
|
|
||||||
WHERE workspace_id = $1
|
|
||||||
ORDER BY updated_at DESC",
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Conditional set_status — advance ONLY if the current status
|
|
||||||
/// matches `from`. Used by the fold hook that bumps standby →
|
|
||||||
/// processing when a research loop's first iteration goes out
|
|
||||||
/// without racing with later hooks that may have already advanced
|
|
||||||
/// the topic further. Returns silently on no match; the caller
|
|
||||||
/// treats it as best-effort.
|
|
||||||
pub async fn set_status_if(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
from: &str,
|
|
||||||
to: &str,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE research_topics
|
|
||||||
SET status = $4,
|
|
||||||
published_at = CASE
|
|
||||||
WHEN $4 = 'publishing' AND published_at IS NULL THEN now()
|
|
||||||
ELSE published_at
|
|
||||||
END,
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2 AND status = $3",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(workspace_id)
|
|
||||||
.bind(from)
|
|
||||||
.bind(to)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cross-workspace fetch used by internal callers (topology_worker
|
|
||||||
/// completion hooks, kind='research' loop iteration builders) where
|
|
||||||
/// the caller already has an authoritative workspace binding from the
|
|
||||||
/// linked loop row. Skip the workspace scope filter to avoid a second
|
|
||||||
/// hop.
|
|
||||||
pub async fn get_any_workspace(pool: &PgPool, id: Uuid) -> Result<Option<ResearchTopic>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
|
||||||
created_by, created_at, updated_at, published_at, topology_kind,
|
|
||||||
repo_id, repo_workspace_path,
|
|
||||||
zeroclaw_container_name, zeroclaw_gateway_url
|
|
||||||
FROM research_topics
|
|
||||||
WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| ResearchTopic {
|
|
||||||
id: r.get("id"),
|
|
||||||
workspace_id: r.get("workspace_id"),
|
|
||||||
title: r.get("title"),
|
|
||||||
description: r.get("description"),
|
|
||||||
outcome_kind: r.get("outcome_kind"),
|
|
||||||
status: r.get("status"),
|
|
||||||
created_by: r.get("created_by"),
|
|
||||||
created_at: r.get("created_at"),
|
|
||||||
updated_at: r.get("updated_at"),
|
|
||||||
published_at: r.try_get("published_at").ok().flatten(),
|
|
||||||
topology_kind: r.get("topology_kind"),
|
|
||||||
repo_id: r.try_get("repo_id").ok().flatten(),
|
|
||||||
repo_workspace_path: r.try_get("repo_workspace_path").ok().flatten(),
|
|
||||||
zeroclaw_container_name: r.try_get("zeroclaw_container_name").ok().flatten(),
|
|
||||||
zeroclaw_gateway_url: r.try_get("zeroclaw_gateway_url").ok().flatten(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
) -> Result<Option<ResearchTopic>, DbError> {
|
|
||||||
let row = sqlx::query_as!(
|
|
||||||
ResearchTopic,
|
|
||||||
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
|
||||||
created_by, created_at, updated_at, published_at, topology_kind,
|
|
||||||
repo_id, repo_workspace_path,
|
|
||||||
zeroclaw_container_name, zeroclaw_gateway_url
|
|
||||||
FROM research_topics
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hard-delete a topic and cascade every dependent row. FK cascades on
|
|
||||||
/// research_topic_agents, research_publish_approvals, and research_outcomes
|
|
||||||
/// clean themselves up; topology_runs.research_topic_id is SET NULL so
|
|
||||||
/// historical runs survive with the back-ref cleared.
|
|
||||||
pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM research_topics WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-status fields; the state machine transitions are their own endpoints.
|
|
||||||
pub async fn update_fields(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
title: &str,
|
|
||||||
description: &str,
|
|
||||||
outcome_kind: &str,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE research_topics
|
|
||||||
SET title = $3, description = $4, outcome_kind = $5, updated_at = now()
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
outcome_kind,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State-machine transition. Caller enforces which transitions are valid;
|
|
||||||
/// this is the single write path so we can bump `updated_at` (and
|
|
||||||
/// `published_at` on landing in `publishing`).
|
|
||||||
pub async fn set_status(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
status: &str,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"UPDATE research_topics
|
|
||||||
SET status = $3,
|
|
||||||
updated_at = now(),
|
|
||||||
published_at = CASE
|
|
||||||
WHEN $3 = 'publishing' AND published_at IS NULL THEN now()
|
|
||||||
ELSE published_at
|
|
||||||
END
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
id,
|
|
||||||
workspace_id,
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn attach_agent(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
agent_id: Uuid,
|
|
||||||
role_slot: Option<&str>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO research_topic_agents (topic_id, agent_id, role_slot)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (topic_id, agent_id) DO UPDATE
|
|
||||||
SET role_slot = EXCLUDED.role_slot",
|
|
||||||
topic_id,
|
|
||||||
agent_id,
|
|
||||||
role_slot,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn detach_agent(pool: &PgPool, topic_id: Uuid, agent_id: Uuid) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"DELETE FROM research_topic_agents WHERE topic_id = $1 AND agent_id = $2",
|
|
||||||
topic_id,
|
|
||||||
agent_id,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn agents(pool: &PgPool, topic_id: Uuid) -> Result<Vec<AgentSlot>, DbError> {
|
|
||||||
let rows = sqlx::query!(
|
|
||||||
"SELECT agent_id, role_slot FROM research_topic_agents WHERE topic_id = $1",
|
|
||||||
topic_id,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| AgentSlot {
|
|
||||||
agent_id: r.agent_id,
|
|
||||||
role_slot: r.role_slot,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
@@ -318,110 +318,3 @@ pub async fn set_team_runtime_config(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the team attached to a loop (via loops.team_id, added in
|
|
||||||
/// 0045). Returns `None` when the loop has no team bound — the runtime
|
|
||||||
/// then uses whatever fallback rules apply (paired research topic's
|
|
||||||
/// team, or the template default).
|
|
||||||
pub async fn team_for_loop(pool: &PgPool, loop_id: Uuid) -> Result<Option<Uuid>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query("SELECT team_id FROM loops WHERE id = $1")
|
|
||||||
.bind(loop_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("team_id").ok().flatten()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Symmetric to `team_for_loop` but for research topics.
|
|
||||||
pub async fn team_for_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
) -> Result<Option<Uuid>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> =
|
|
||||||
sqlx::query("SELECT team_id FROM research_topics WHERE id = $1")
|
|
||||||
.bind(topic_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("team_id").ok().flatten()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind a team to a loop (or clear the binding by passing None).
|
|
||||||
pub async fn set_team_for_loop(
|
|
||||||
pool: &PgPool,
|
|
||||||
loop_id: Uuid,
|
|
||||||
team_id: Option<Uuid>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query("UPDATE loops SET team_id = $2 WHERE id = $1")
|
|
||||||
.bind(loop_id)
|
|
||||||
.bind(team_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind a team to a research topic (or clear).
|
|
||||||
pub async fn set_team_for_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_id: Uuid,
|
|
||||||
team_id: Option<Uuid>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query("UPDATE research_topics SET team_id = $2 WHERE id = $1")
|
|
||||||
.bind(topic_id)
|
|
||||||
.bind(team_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 0046: read the team's per-container coordinates. Both fields NULL
|
|
||||||
/// means the team has never spawned; the runtime provisions on first
|
|
||||||
/// iteration.
|
|
||||||
pub async fn team_container_coords(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
) -> Result<Option<(Option<String>, Option<String>)>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT zeroclaw_container, zeroclaw_gateway_url FROM teams
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(workspace_id.as_uuid())
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| {
|
|
||||||
(
|
|
||||||
r.try_get::<Option<String>, _>("zeroclaw_container")
|
|
||||||
.ok()
|
|
||||||
.flatten(),
|
|
||||||
r.try_get::<Option<String>, _>("zeroclaw_gateway_url")
|
|
||||||
.ok()
|
|
||||||
.flatten(),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 0046: set (or clear) the team's per-container coordinates once
|
|
||||||
/// spawn_team lands them.
|
|
||||||
pub async fn set_team_container_coords(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
container: Option<&str>,
|
|
||||||
gateway_url: Option<&str>,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE teams
|
|
||||||
SET zeroclaw_container = $3,
|
|
||||||
zeroclaw_gateway_url = $4
|
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(workspace_id.as_uuid())
|
|
||||||
.bind(container)
|
|
||||||
.bind(gateway_url)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::DbError;
|
use crate::DbError;
|
||||||
|
|
||||||
/// A row summary for the recent-runs list. `iteration` and `finished_at`
|
/// A row summary for the recent-runs list. `finished_at` is populated for
|
||||||
/// are populated for loop iterations and for terminal runs respectively;
|
/// terminal runs; `None` for compares or still-in-flight runs.
|
||||||
/// `None` for compares or still-in-flight runs.
|
|
||||||
pub struct TopologyRunSummary {
|
pub struct TopologyRunSummary {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub task: String,
|
pub task: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
pub iteration: Option<i32>,
|
|
||||||
pub finished_at: Option<OffsetDateTime>,
|
pub finished_at: Option<OffsetDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,170 +145,6 @@ pub async fn enqueue_run_for_team(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count `queued` + `running` runs whose `research_topic_id` matches. The
|
|
||||||
/// research canvas polls this so it can show a spinner "the pipeline is
|
|
||||||
/// running" and suppress the manual "Submit for review" escape hatch
|
|
||||||
/// while any run is still in flight.
|
|
||||||
pub async fn active_runs_for_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
research_topic_id: Uuid,
|
|
||||||
) -> Result<i64, DbError> {
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT count(*) AS n
|
|
||||||
FROM topology_runs
|
|
||||||
WHERE research_topic_id = $1
|
|
||||||
AND status IN ('queued', 'running')",
|
|
||||||
research_topic_id,
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.n.unwrap_or(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live-run panel companion to `active_runs_for_research_topic`: return
|
|
||||||
/// the actual run ids (queued + running) so the UI can subscribe to
|
|
||||||
/// their SSE event streams. Ordered newest first — the freshest run is
|
|
||||||
/// the one the user just kicked off.
|
|
||||||
/// Batch run-count feeder for the research topic list. Returns a
|
|
||||||
/// (topic_id, in_flight, failed) tuple per topic in `topic_ids`,
|
|
||||||
/// omitting topics with zero runs. Used to render the errored-state
|
|
||||||
/// icon + "rerun" affordance on cards in the left sidebar.
|
|
||||||
///
|
|
||||||
/// `failed` counts runs that terminated in `failed` since the topic's
|
|
||||||
/// most recent successful run (or all-time if none have succeeded).
|
|
||||||
/// That way an old failure on a topic that later succeeded doesn't
|
|
||||||
/// keep the card flagged as broken.
|
|
||||||
pub async fn run_counts_by_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
topic_ids: &[Uuid],
|
|
||||||
) -> Result<Vec<(Uuid, i64, i64)>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
if topic_ids.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"WITH last_success AS (
|
|
||||||
SELECT research_topic_id, max(created_at) AS ts
|
|
||||||
FROM topology_runs
|
|
||||||
WHERE research_topic_id = ANY($1)
|
|
||||||
AND status = 'completed'
|
|
||||||
GROUP BY research_topic_id
|
|
||||||
)
|
|
||||||
SELECT r.research_topic_id AS topic_id,
|
|
||||||
count(*) FILTER (WHERE r.status IN ('queued','running')) AS in_flight,
|
|
||||||
count(*) FILTER (
|
|
||||||
WHERE r.status = 'failed'
|
|
||||||
AND r.created_at > coalesce(ls.ts, 'epoch'::timestamptz)
|
|
||||||
) AS failed
|
|
||||||
FROM topology_runs r
|
|
||||||
LEFT JOIN last_success ls
|
|
||||||
ON ls.research_topic_id = r.research_topic_id
|
|
||||||
WHERE r.research_topic_id = ANY($1)
|
|
||||||
GROUP BY r.research_topic_id",
|
|
||||||
)
|
|
||||||
.bind(topic_ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
(
|
|
||||||
r.get::<Uuid, _>("topic_id"),
|
|
||||||
r.get::<i64, _>("in_flight"),
|
|
||||||
r.get::<i64, _>("failed"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn active_run_ids_for_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
research_topic_id: Uuid,
|
|
||||||
) -> Result<Vec<Uuid>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
// Dynamic query (not `sqlx::query!`) so cm-db builds air-gapped
|
|
||||||
// without a fresh `cargo sqlx prepare` round-trip. Schema shape is
|
|
||||||
// identical to `active_runs_for_research_topic` above.
|
|
||||||
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"SELECT id
|
|
||||||
FROM topology_runs
|
|
||||||
WHERE research_topic_id = $1
|
|
||||||
AND status IN ('queued', 'running')
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.bind(research_topic_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows.into_iter().map(|r| r.get::<Uuid, _>("id")).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The research topic this run belongs to, if any. Used by the topology
|
|
||||||
/// worker's `freeze_research_outcome` post-hook to snapshot the run's
|
|
||||||
/// final synthesis into `research_outcomes`.
|
|
||||||
pub async fn research_topic_id(pool: &PgPool, id: Uuid) -> Result<Option<Uuid>, DbError> {
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"SELECT research_topic_id FROM topology_runs WHERE id = $1",
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| r.research_topic_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The loop this run belongs to, if any. Mirror of `research_topic_id`.
|
|
||||||
/// Used by `topology_worker` to look up the per-loop gateway URL so a
|
|
||||||
/// loop's runs land on its isolated daemon (P2). Non-loop runs return
|
|
||||||
/// None.
|
|
||||||
pub async fn loop_id_for_run(pool: &PgPool, id: Uuid) -> Result<Option<Uuid>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> =
|
|
||||||
sqlx::query("SELECT loop_id FROM topology_runs WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("loop_id").ok().flatten()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The iteration counter for a loop-bound run. Returns None for chat /
|
|
||||||
/// research runs (iteration column is nullable). Used by the reorder
|
|
||||||
/// rationale hook so the mini-timeline can order events by iteration.
|
|
||||||
pub async fn iteration_for_run(pool: &PgPool, id: Uuid) -> Result<Option<i32>, DbError> {
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> =
|
|
||||||
sqlx::query("SELECT iteration FROM topology_runs WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.and_then(|r| r.try_get::<Option<i32>, _>("iteration").ok().flatten()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enqueue a durable run bound to a research topic. `research_topic_id` is
|
|
||||||
/// stored so `notify_run_completed` can flip the owning topic
|
|
||||||
/// `processing → reviewing` when its last run terminates (see
|
|
||||||
/// `topology_worker::maybe_transition_research_topic`).
|
|
||||||
pub async fn enqueue_run_for_research_topic(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
task: &str,
|
|
||||||
graph: &Value,
|
|
||||||
research_topic_id: Uuid,
|
|
||||||
) -> Result<(), DbError> {
|
|
||||||
sqlx::query!(
|
|
||||||
"INSERT INTO topology_runs
|
|
||||||
(id, workspace_id, task, kind, status, graph, tier, research_topic_id)
|
|
||||||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5)",
|
|
||||||
id,
|
|
||||||
workspace_id.as_uuid(),
|
|
||||||
task,
|
|
||||||
graph,
|
|
||||||
research_topic_id,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of `check_ephemeral_teardown` when this run's terminal completion
|
/// Result of `check_ephemeral_teardown` when this run's terminal completion
|
||||||
/// should tear down its team.
|
/// should tear down its team.
|
||||||
pub struct EphemeralTeardown {
|
pub struct EphemeralTeardown {
|
||||||
@@ -423,54 +257,6 @@ pub async fn touch(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If this run belongs to a research topic AND no siblings of that topic
|
|
||||||
/// are still queued or running, transition the topic `processing → reviewing`.
|
|
||||||
/// Guarded by `status = 'processing'` so a repeat call (e.g. a retry) is a
|
|
||||||
/// no-op; a topic already reviewing/publishing/published stays put.
|
|
||||||
/// Returns `true` when the topic was transitioned.
|
|
||||||
pub async fn notify_run_completed(pool: &PgPool, id: Uuid) -> Result<bool, DbError> {
|
|
||||||
// One statement: subquery locates the topic id, subquery counts siblings
|
|
||||||
// still in flight (excluding *this* run — it's about to be flipped to
|
|
||||||
// completed/failed by the caller, but ordering isn't guaranteed here).
|
|
||||||
//
|
|
||||||
// Only advance the topic when it has AT LEAST ONE outcome — otherwise a
|
|
||||||
// failed run with no synthesis would push the topic into `reviewing`,
|
|
||||||
// the UI would offer "Request publish", the user would click Approve, and
|
|
||||||
// decide_publish would 409 on the "no outcome" guard. Stays in
|
|
||||||
// `processing` when zero outcomes exist so the loop's next iteration
|
|
||||||
// still has a chance to produce one.
|
|
||||||
// Dynamic query — the added EXISTS clause on research_outcomes
|
|
||||||
// doesn't have an entry in the offline sqlx cache, so we bind
|
|
||||||
// values by hand instead of using the `query!` macro.
|
|
||||||
use sqlx::Row;
|
|
||||||
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
|
|
||||||
"UPDATE research_topics t
|
|
||||||
SET status = 'reviewing', updated_at = now()
|
|
||||||
WHERE t.id = (
|
|
||||||
SELECT research_topic_id FROM topology_runs
|
|
||||||
WHERE id = $1 AND research_topic_id IS NOT NULL
|
|
||||||
)
|
|
||||||
AND t.status = 'processing'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM research_outcomes
|
|
||||||
WHERE topic_id = t.id
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM topology_runs
|
|
||||||
WHERE research_topic_id = t.id
|
|
||||||
AND id <> $1
|
|
||||||
AND status IN ('queued', 'running')
|
|
||||||
)
|
|
||||||
RETURNING t.id",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row
|
|
||||||
.map(|r| r.try_get::<Uuid, _>("id").is_ok())
|
|
||||||
.unwrap_or(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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!(
|
||||||
@@ -559,7 +345,7 @@ pub async fn list_recent(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<TopologyRunSummary>, DbError> {
|
) -> Result<Vec<TopologyRunSummary>, DbError> {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query!(
|
||||||
"SELECT id, task, status, kind, created_at, iteration, finished_at
|
"SELECT id, task, status, kind, created_at, finished_at
|
||||||
FROM topology_runs
|
FROM topology_runs
|
||||||
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
workspace_id.as_uuid(),
|
workspace_id.as_uuid(),
|
||||||
@@ -575,41 +361,6 @@ pub async fn list_recent(
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
iteration: r.iteration,
|
|
||||||
finished_at: r.finished_at,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Iterations of a loop, newest first. Uses the partial index
|
|
||||||
/// `topology_runs_loop_idx` on `(loop_id, iteration DESC)`.
|
|
||||||
pub async fn list_by_loop(
|
|
||||||
pool: &PgPool,
|
|
||||||
workspace_id: WorkspaceId,
|
|
||||||
loop_id: Uuid,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<TopologyRunSummary>, DbError> {
|
|
||||||
let rows = sqlx::query!(
|
|
||||||
"SELECT id, task, status, kind, created_at, iteration, finished_at
|
|
||||||
FROM topology_runs
|
|
||||||
WHERE workspace_id = $1 AND loop_id = $2
|
|
||||||
ORDER BY iteration DESC NULLS LAST, created_at DESC
|
|
||||||
LIMIT $3",
|
|
||||||
workspace_id.as_uuid(),
|
|
||||||
loop_id,
|
|
||||||
limit,
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| TopologyRunSummary {
|
|
||||||
id: r.id,
|
|
||||||
task: r.task,
|
|
||||||
status: r.status,
|
|
||||||
kind: r.kind,
|
|
||||||
created_at: r.created_at,
|
|
||||||
iteration: r.iteration,
|
|
||||||
finished_at: r.finished_at,
|
finished_at: r.finished_at,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
mod brain;
|
mod brain;
|
||||||
mod events;
|
mod events;
|
||||||
pub mod loops;
|
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod sandboxes;
|
mod sandboxes;
|
||||||
@@ -13,7 +12,6 @@ mod terminals;
|
|||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub use events::{RunEventBody, RunEventEnvelope};
|
pub use events::{RunEventBody, RunEventEnvelope};
|
||||||
pub use loops::spawn_loop_scheduler;
|
|
||||||
pub use outbox::{drain_once, spawn_drainer, EmailSender, LettreSender, SmtpConfig};
|
pub use outbox::{drain_once, spawn_drainer, EmailSender, LettreSender, SmtpConfig};
|
||||||
pub use runtime::{
|
pub use runtime::{
|
||||||
judge_model, ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun,
|
judge_model, ProviderRegistry, Runtime, RuntimeConfig, RuntimeError, StartedRun,
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
//! Loop scheduler: periodically wakes, finds loops whose `next_fire_at`
|
|
||||||
//! has arrived, enqueues one topology_runs row per fire (with loop_id +
|
|
||||||
//! iteration + parent_run_id set), and computes the next fire time from
|
|
||||||
//! the cron trigger.
|
|
||||||
//!
|
|
||||||
//! Missed-window rule: `next_fire_at` is always computed strictly AFTER
|
|
||||||
//! `now()`, so a scheduler that woke up late (restart, long stall) fires
|
|
||||||
//! ONCE and skips whatever windows were in the backlog. This matches the
|
|
||||||
//! "fire once and move on" behavior we chose in the spec.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use tokio::time::interval;
|
|
||||||
|
|
||||||
use crate::scheduling::next_occurrence;
|
|
||||||
|
|
||||||
/// The subset of `triggers` JSONB the scheduler needs to make decisions.
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct Triggers {
|
|
||||||
/// Cron pattern (5-field). None => scheduler doesn't participate.
|
|
||||||
#[serde(default)]
|
|
||||||
cron: Option<String>,
|
|
||||||
/// Enqueue next iteration when the previous one hits `run_completed`.
|
|
||||||
/// Handled by the run driver on completion (see runtime::spawn_drive);
|
|
||||||
/// the scheduler doesn't own this branch, we surface it here just so
|
|
||||||
/// mark_fired() below knows whether to null out next_fire_at.
|
|
||||||
#[serde(default)]
|
|
||||||
on_completion: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
webhook_enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The subset of `repeat_policy` JSONB the scheduler needs.
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct RepeatPolicy {
|
|
||||||
/// `infinite` | `iters` | `until`. Anything unknown = `infinite`.
|
|
||||||
#[serde(default = "default_kind")]
|
|
||||||
kind: String,
|
|
||||||
/// `iters.n` stopping condition.
|
|
||||||
#[serde(default)]
|
|
||||||
n: Option<i32>,
|
|
||||||
}
|
|
||||||
fn default_kind() -> String {
|
|
||||||
"infinite".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the tick every `interval_duration` until the process exits.
|
|
||||||
pub fn spawn_loop_scheduler(pool: sqlx::PgPool, interval_duration: Duration) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut tick = interval(interval_duration);
|
|
||||||
// First tick fires immediately; second waits the full interval. That's
|
|
||||||
// fine — the query is a bounded partial-index scan.
|
|
||||||
loop {
|
|
||||||
tick.tick().await;
|
|
||||||
if let Err(e) = fire_due(&pool).await {
|
|
||||||
eprintln!("loop scheduler tick failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One tick: find due loops, fire each. Errors from one loop don't stop the
|
|
||||||
/// others.
|
|
||||||
async fn fire_due(pool: &sqlx::PgPool) -> Result<(), sqlx::Error> {
|
|
||||||
let due = cm_db::repo::loops::due(pool).await.map_err(sqlx_err)?;
|
|
||||||
for l in due {
|
|
||||||
if let Err(e) = fire_one(pool, &l).await {
|
|
||||||
eprintln!("loop {} fire failed: {e}", l.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fire_one(pool: &sqlx::PgPool, l: &cm_db::repo::loops::DueLoop) -> Result<(), sqlx::Error> {
|
|
||||||
let triggers: Triggers = serde_json::from_value(l.triggers.clone()).unwrap_or_default();
|
|
||||||
let policy: RepeatPolicy = serde_json::from_value(l.repeat_policy.clone()).unwrap_or_default();
|
|
||||||
|
|
||||||
let iter = cm_db::repo::loops::next_iteration(pool, l.id)
|
|
||||||
.await
|
|
||||||
.map_err(sqlx_err)?;
|
|
||||||
|
|
||||||
// Repeat cap. `iters` stops after N total iterations; `infinite` and
|
|
||||||
// `until` don't check here (until is enforced by the on-completion path
|
|
||||||
// which inspects the run's terminal event; scope for the scheduler stops
|
|
||||||
// at cron time-based firing).
|
|
||||||
if policy.kind == "iters" {
|
|
||||||
if let Some(cap) = policy.n {
|
|
||||||
if iter > cap {
|
|
||||||
// Silently disable the loop so we don't tick it forever.
|
|
||||||
let _ = cm_db::repo::loops::set_enabled(pool, l.id, l.workspace_id, false).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let run_id = cm_db::repo::loops::enqueue_iteration(
|
|
||||||
pool,
|
|
||||||
l.id,
|
|
||||||
l.workspace_id,
|
|
||||||
&l.task_template,
|
|
||||||
&l.graph,
|
|
||||||
iter,
|
|
||||||
l.last_run_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(sqlx_err)?;
|
|
||||||
|
|
||||||
// Advance next_fire_at strictly AFTER now(). If there's no cron trigger
|
|
||||||
// (e.g. webhook-only or on-completion-only), null it out so the partial
|
|
||||||
// index stops matching this loop for the scheduler.
|
|
||||||
let next = match triggers.cron.as_deref() {
|
|
||||||
Some(pattern) if !pattern.is_empty() => {
|
|
||||||
match next_occurrence(pattern, OffsetDateTime::now_utc()) {
|
|
||||||
Ok(t) => Some(t),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("loop {} invalid cron '{}': {e}", l.id, pattern);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
// Also null it out when the cron trigger vanished but on_completion or
|
|
||||||
// webhook_enabled is still on — those paths will re-fire independently.
|
|
||||||
let _ = (triggers.on_completion, triggers.webhook_enabled);
|
|
||||||
|
|
||||||
cm_db::repo::loops::mark_fired(pool, l.id, run_id, next)
|
|
||||||
.await
|
|
||||||
.map_err(sqlx_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sqlx_err(e: cm_db::DbError) -> sqlx::Error {
|
|
||||||
match e {
|
|
||||||
cm_db::DbError::Other(e) => e,
|
|
||||||
cm_db::DbError::NotFound => sqlx::Error::RowNotFound,
|
|
||||||
cm_db::DbError::Conflict(_) => sqlx::Error::PoolTimedOut,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,14 @@ CLAWMATES_BOOTSTRAP_CREDITS=1250
|
|||||||
# and provide the key here (uncomment):
|
# and provide the key here (uncomment):
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
|
# --- Gemini (mission Refine + agent/team Level-Up) --------------------------
|
||||||
|
# Both features call Gemini via generativelanguage.googleapis.com. Without a
|
||||||
|
# key, the Refine button and Level-Up buttons return 500.
|
||||||
|
# Model overrides default to gemini-2.5-flash (cheap, JSON-mode-native).
|
||||||
|
# GEMINI_API_KEY=AIza...
|
||||||
|
# CLAWMATES_REFINER_MODEL=gemini-2.5-flash
|
||||||
|
# CLAWMATES_LEVEL_UP_MODEL=gemini-2.5-flash
|
||||||
|
|
||||||
# --- Auth mode (optional) ---------------------------------------------------
|
# --- Auth mode (optional) ---------------------------------------------------
|
||||||
# clawmates.toml defaults to mode = "local". For Clerk, set mode = "clerk"
|
# clawmates.toml defaults to mode = "local". For Clerk, set mode = "clerk"
|
||||||
# and auth.issuer_url there, then supply the frontend keys (see docs/clerk.md):
|
# and auth.issuer_url there, then supply the frontend keys (see docs/clerk.md):
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ volumes:
|
|||||||
# the unix-socket equivalent of the K8s sidecar topology.
|
# the unix-socket equivalent of the K8s sidecar topology.
|
||||||
broker_run: {}
|
broker_run: {}
|
||||||
broker_key: {}
|
broker_key: {}
|
||||||
|
# Per-mission repo checkouts + generated PDFs live on the host at
|
||||||
|
# /var/lib/clawmates-missions so the (separately-managed)
|
||||||
|
# clawmates-runtime container can bind-mount the SAME host path and
|
||||||
|
# see the same tree the server wrote to. A named docker volume
|
||||||
|
# would work too but would need volume-name knowledge in the
|
||||||
|
# runtime's spawn command.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -118,9 +124,15 @@ services:
|
|||||||
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
|
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
|
||||||
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
|
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
|
||||||
DOCKER_HOST: tcp://socket-proxy:2375
|
DOCKER_HOST: tcp://socket-proxy:2375
|
||||||
|
CLAWMATES_MISSIONS_ROOT: /var/lib/clawmates-missions
|
||||||
|
CLAWMATES_RUNTIME_CONTAINER: ${CLAWMATES_RUNTIME_CONTAINER:-clawmates-runtime}
|
||||||
volumes:
|
volumes:
|
||||||
- ./clawmates.toml:/etc/clawmates/clawmates.toml:ro
|
- ./clawmates.toml:/etc/clawmates/clawmates.toml:ro
|
||||||
- broker_run:/run/clawmates
|
- broker_run:/run/clawmates
|
||||||
|
# Per-mission repo checkouts. Bind-mounted host path so the
|
||||||
|
# separately-managed clawmates-runtime container can see the
|
||||||
|
# same trees at the same path when it exec's for scans/benches.
|
||||||
|
- /var/lib/clawmates-missions:/var/lib/clawmates-missions
|
||||||
networks: [edge, core, engine_net]
|
networks: [edge, core, engine_net]
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
Launchd user agent for Herdr's headless server. Install as:
|
||||||
|
cp dev.herdr.plist ~/Library/LaunchAgents/
|
||||||
|
launchctl load -w ~/Library/LaunchAgents/dev.herdr.plist
|
||||||
|
|
||||||
|
Reads $HOME/.local/bin/herdr — fleet_herdr and the node daemon's
|
||||||
|
PtyTarget::Command resolver both expect that path.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>dev.herdr.server</string>
|
||||||
|
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>__HERDR_BIN__</string>
|
||||||
|
<string>server</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<dict>
|
||||||
|
<key>SuccessfulExit</key>
|
||||||
|
<false/>
|
||||||
|
<key>Crashed</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- 5 second throttle so a bad binary doesn't hot-loop. -->
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>5</integer>
|
||||||
|
|
||||||
|
<!-- Herdr writes its own logs at ~/.config/herdr/herdr-server.log;
|
||||||
|
launchd captures anything on stdout/stderr as a safety net. -->
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/herdr-launchd.out.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/herdr-launchd.err.log</string>
|
||||||
|
|
||||||
|
<!-- Herdr's install script writes to $HOME/.local/bin; make sure
|
||||||
|
the daemon's PATH includes it so the binary is resolvable. -->
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>__HOME__/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Herdr headless server (terminal multiplexer for coding agents)
|
||||||
|
Documentation=https://herdr.dev/docs/
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Reads $HOME/.local/bin/herdr; the resolver in fleet_herdr expects this path.
|
||||||
|
ExecStart=%h/.local/bin/herdr server
|
||||||
|
# Herdr's socket lives under ~/.config/herdr/herdr.sock — the daemon
|
||||||
|
# creates its own state dir, no need to pre-provision anything.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# 24 hours between restart bursts so a crashing binary doesn't hot-loop.
|
||||||
|
StartLimitIntervalSec=86400
|
||||||
|
StartLimitBurst=8
|
||||||
|
# Herdr owns its own logs (~/.config/herdr/herdr-server.log); systemd
|
||||||
|
# journal captures anything that escapes.
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
# WantedBy=default.target for user units — no wants for multi-user.target.
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Idempotent install of a persistent Herdr daemon for the current user.
|
||||||
|
# Autodetects Linux (systemd --user) vs macOS (launchd LaunchAgent).
|
||||||
|
#
|
||||||
|
# Usage (locally):
|
||||||
|
# ./install.sh
|
||||||
|
# Usage (remote):
|
||||||
|
# scp -r deploy/fleet/herdr-persistence <host>:/tmp/
|
||||||
|
# ssh <host> 'bash -lc "/tmp/herdr-persistence/install.sh"'
|
||||||
|
#
|
||||||
|
# Preconditions: `herdr` is installed at $HOME/.local/bin/herdr
|
||||||
|
# (via `curl -fsSL https://herdr.dev/install.sh | sh`).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
herdr="$HOME/.local/bin/herdr"
|
||||||
|
|
||||||
|
if [ ! -x "$herdr" ]; then
|
||||||
|
echo "herdr binary not found at $herdr — run the herdr install script first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
os="$(uname -s)"
|
||||||
|
case "$os" in
|
||||||
|
Linux)
|
||||||
|
unit_dir="$HOME/.config/systemd/user"
|
||||||
|
unit="$unit_dir/herdr.service"
|
||||||
|
mkdir -p "$unit_dir"
|
||||||
|
cp "$here/herdr.service" "$unit"
|
||||||
|
# Enable lingering so the user's systemd manager runs without a
|
||||||
|
# login session — otherwise the daemon dies at logout.
|
||||||
|
if command -v loginctl >/dev/null 2>&1; then
|
||||||
|
sudo loginctl enable-linger "$USER" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now herdr.service
|
||||||
|
systemctl --user status --no-pager herdr.service | head -8
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
plist_dir="$HOME/Library/LaunchAgents"
|
||||||
|
plist="$plist_dir/dev.herdr.server.plist"
|
||||||
|
mkdir -p "$plist_dir"
|
||||||
|
sed \
|
||||||
|
-e "s|__HERDR_BIN__|$herdr|g" \
|
||||||
|
-e "s|__HOME__|$HOME|g" \
|
||||||
|
"$here/dev.herdr.plist" > "$plist"
|
||||||
|
# Unload first so a re-install replaces cleanly.
|
||||||
|
launchctl unload "$plist" 2>/dev/null || true
|
||||||
|
launchctl load -w "$plist"
|
||||||
|
sleep 1
|
||||||
|
launchctl list | grep dev.herdr.server || echo "(agent not in list yet)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported OS: $os" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "herdr daemon persistence installed. Verify with: herdr status server"
|
||||||
@@ -219,7 +219,14 @@ export type TermMode = "connecting" | "direct" | "relayed" | "local";
|
|||||||
* ticket + builds the wss URL): direct DataChannel browser↔node, with the
|
* ticket + builds the wss URL): direct DataChannel browser↔node, with the
|
||||||
* gateway WS relay as automatic fallback. Endpoint-agnostic — works for the node
|
* gateway WS relay as automatic fallback. Endpoint-agnostic — works for the node
|
||||||
* host shell and the node-placed agent container alike. */
|
* host shell and the node-placed agent container alike. */
|
||||||
export function webrtcConnector(getUrl: () => Promise<string | null>, onMode?: (m: TermMode) => void): TermConnector {
|
export function webrtcConnector(
|
||||||
|
getUrl: () => Promise<string | null>,
|
||||||
|
onMode?: (m: TermMode) => void,
|
||||||
|
/** When set, the fallback frame carries this argv so the node spawns
|
||||||
|
* the given command in the PTY instead of the login shell. Used by
|
||||||
|
* the Herdr Live Pane to attach directly to `herdr`. */
|
||||||
|
commandOverride?: string[],
|
||||||
|
): TermConnector {
|
||||||
return ({ term, onClosed }) =>
|
return ({ term, onClosed }) =>
|
||||||
new Promise<TermTransport | null>((resolve) => {
|
new Promise<TermTransport | null>((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -284,7 +291,16 @@ export function webrtcConnector(getUrl: () => Promise<string | null>, onMode?: (
|
|||||||
}
|
}
|
||||||
dc = null;
|
dc = null;
|
||||||
pc = null;
|
pc = null;
|
||||||
ws?.send(JSON.stringify({ type: "fallback", cols: term.cols, rows: term.rows }));
|
ws?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "fallback",
|
||||||
|
cols: term.cols,
|
||||||
|
rows: term.rows,
|
||||||
|
...(commandOverride && commandOverride.length > 0
|
||||||
|
? { command: commandOverride }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
flush();
|
flush();
|
||||||
};
|
};
|
||||||
const startWebrtc = () => {
|
const startWebrtc = () => {
|
||||||
@@ -388,6 +404,27 @@ export function nodeWebrtcConnector(nodeId: string, onMode?: (m: TermMode) => vo
|
|||||||
}, onMode);
|
}, onMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attach xterm to `herdr` running on the node — bypasses the login
|
||||||
|
* shell, drops the browser straight into the node's Herdr TUI so the
|
||||||
|
* operator sees every mission pane on that node. */
|
||||||
|
export function nodeHerdrConnector(
|
||||||
|
nodeId: string,
|
||||||
|
onMode?: (m: TermMode) => void,
|
||||||
|
): TermConnector {
|
||||||
|
return webrtcConnector(
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`/api/nodes/${nodeId}/terminal/ticket`, { method: "POST" });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const { ticket } = (await res.json()) as { ticket?: string };
|
||||||
|
if (!ticket) return null;
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${location.host}/api/nodes/${nodeId}/terminal/ws?token=${encodeURIComponent(ticket)}`;
|
||||||
|
},
|
||||||
|
onMode,
|
||||||
|
["herdr"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The agent terminal: mint the ticket, then pick the transport from the
|
/** The agent terminal: mint the ticket, then pick the transport from the
|
||||||
* response — a node-placed container → WebRTC (LAN speed, signaling over the
|
* response — a node-placed container → WebRTC (LAN speed, signaling over the
|
||||||
* agent WS); a gateway-local container → plain WS (today's path). */
|
* agent WS); a gateway-local container → plain WS (today's path). */
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Activity, Brain, Camera } from "lucide-react";
|
import { Activity, Brain, Camera, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import { proposeForAgent } from "@/lib/api/level-up";
|
||||||
|
import { LevelUpDrawer } from "./LevelUpDrawer";
|
||||||
|
|
||||||
import type { DemoAgent } from "@/lib/dashboard-demo";
|
import type { DemoAgent } from "@/lib/dashboard-demo";
|
||||||
import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive";
|
import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive";
|
||||||
@@ -205,6 +208,22 @@ export function ClawCommandCenter({
|
|||||||
const tele = useAgentTelemetry(agent.id);
|
const tele = useAgentTelemetry(agent.id);
|
||||||
const doors = tele?.doorsPending ?? 0;
|
const doors = tele?.doorsPending ?? 0;
|
||||||
|
|
||||||
|
const [levelUpBusy, setLevelUpBusy] = useState(false);
|
||||||
|
const [levelUpOpenId, setLevelUpOpenId] = useState<string | null>(null);
|
||||||
|
const [levelUpError, setLevelUpError] = useState<string | null>(null);
|
||||||
|
const proposeLevelUp = async () => {
|
||||||
|
setLevelUpBusy(true);
|
||||||
|
setLevelUpError(null);
|
||||||
|
try {
|
||||||
|
const { proposal_id } = await proposeForAgent(agent.id);
|
||||||
|
setLevelUpOpenId(proposal_id);
|
||||||
|
} catch (e) {
|
||||||
|
setLevelUpError(e instanceof Error ? e.message : "level-up failed");
|
||||||
|
} finally {
|
||||||
|
setLevelUpBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#08080a" }}>
|
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#08080a" }}>
|
||||||
{/* Identity header strip — compact. */}
|
{/* Identity header strip — compact. */}
|
||||||
@@ -220,7 +239,41 @@ export function ClawCommandCenter({
|
|||||||
<div style={{ fontFamily: mono, fontSize: 11.5, letterSpacing: ".06em", color: "#ff8a7a", marginTop: 2 }}>{agent.role}<span style={{ color: "#5a5a62" }}> · part of {teamName}</span></div>
|
<div style={{ fontFamily: mono, fontSize: 11.5, letterSpacing: ".06em", color: "#ff8a7a", marginTop: 2 }}>{agent.role}<span style={{ color: "#5a5a62" }}> · part of {teamName}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={proposeLevelUp}
|
||||||
|
disabled={levelUpBusy}
|
||||||
|
title="Ask the proposer to suggest improvements to this claw's identity, skills, and brain"
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(201,160,255,.45)",
|
||||||
|
background: "rgba(201,160,255,.1)",
|
||||||
|
color: "#c9a0ff",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: ".08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
opacity: levelUpBusy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={12} />
|
||||||
|
{levelUpBusy ? "Proposing…" : "Level up"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{levelUpError && (
|
||||||
|
<div style={{ padding: "6px 22px", color: "#ff8a7a", fontSize: 11 }}>{levelUpError}</div>
|
||||||
|
)}
|
||||||
|
{levelUpOpenId && (
|
||||||
|
<LevelUpDrawer
|
||||||
|
proposalId={levelUpOpenId}
|
||||||
|
onClose={() => setLevelUpOpenId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metric grid — 2-column, wraps to 3 rows for the 5 tiles.
|
{/* Metric grid — 2-column, wraps to 3 rows for the 5 tiles.
|
||||||
Activity now lives here (top-of-fold quick-glance); the beefier
|
Activity now lives here (top-of-fold quick-glance); the beefier
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { WorldCanvas } from "../world/WorldCanvas";
|
|||||||
import { ObservePanel } from "../observe/ObservePanel";
|
import { ObservePanel } from "../observe/ObservePanel";
|
||||||
import { StructureTree, orgNode, type TreeItem } from "./StructureTree";
|
import { StructureTree, orgNode, type TreeItem } from "./StructureTree";
|
||||||
import { MissionsList } from "./MissionsList";
|
import { MissionsList } from "./MissionsList";
|
||||||
|
import { LevelUpInbox } from "./LevelUpInbox";
|
||||||
import { MissionCanvas } from "./MissionCanvas";
|
import { MissionCanvas } from "./MissionCanvas";
|
||||||
import { RepoList } from "./RepoList";
|
import { RepoList } from "./RepoList";
|
||||||
import { RepoCanvas } from "./RepoCanvas";
|
import { RepoCanvas } from "./RepoCanvas";
|
||||||
@@ -720,15 +721,36 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isMissions ? (
|
{isMissions ? (
|
||||||
<MissionsList
|
<div style={{ display: "flex", flexDirection: "column", minHeight: 0, flex: 1 }}>
|
||||||
selectedId={missionsSel}
|
<div style={{ flex: "1 1 0", minHeight: 0, overflow: "auto" }}>
|
||||||
onSelect={setMissionsSel}
|
<MissionsList
|
||||||
refreshKey={missionsRefresh}
|
selectedId={missionsSel}
|
||||||
onCreated={(id) => {
|
onSelect={setMissionsSel}
|
||||||
setMissionsSel(id);
|
refreshKey={missionsRefresh}
|
||||||
setMissionsRefresh((n) => n + 1);
|
onCreated={(id) => {
|
||||||
}}
|
setMissionsSel(id);
|
||||||
/>
|
setMissionsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
onDeleted={(ids) => {
|
||||||
|
if (missionsSel && ids.includes(missionsSel)) {
|
||||||
|
setMissionsSel(null);
|
||||||
|
}
|
||||||
|
setMissionsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "none",
|
||||||
|
borderTop: "1px solid rgba(255,255,255,.06)",
|
||||||
|
padding: "10px 12px",
|
||||||
|
maxHeight: "38%",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LevelUpInbox />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : isRepos ? (
|
) : isRepos ? (
|
||||||
<RepoList
|
<RepoList
|
||||||
selectedId={repoSel}
|
selectedId={repoSel}
|
||||||
@@ -812,6 +834,14 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
selectedId={missionsSel}
|
selectedId={missionsSel}
|
||||||
refreshKey={missionsRefresh}
|
refreshKey={missionsRefresh}
|
||||||
onChanged={() => setMissionsRefresh((n) => n + 1)}
|
onChanged={() => setMissionsRefresh((n) => n + 1)}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setMissionsSel(id);
|
||||||
|
setMissionsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
setMissionsSel(null);
|
||||||
|
setMissionsRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : isRepos ? (
|
) : isRepos ? (
|
||||||
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
|
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// INFRA → Herdr sessions: browse the Herdr session snapshot from every
|
||||||
|
// online node. One card per node with per-workspace agent-state pills
|
||||||
|
// + a "Open in browser" button that spawns an xterm attached to that
|
||||||
|
// node's Herdr TUI (same LivePane mechanism the MissionCanvas uses).
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { RefreshCw, Sparkles, TerminalSquare } from "lucide-react";
|
||||||
|
|
||||||
|
import type { FleetNode } from "./fleet/FleetPanels";
|
||||||
|
import {
|
||||||
|
nodeHerdrConnector,
|
||||||
|
useResilientTerminal,
|
||||||
|
type TermMode,
|
||||||
|
} from "@/components/computer/apps/terminal/core";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
|
type AgentStatus = "working" | "blocked" | "done" | "idle" | "unknown";
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
workspace_id: string;
|
||||||
|
label?: string;
|
||||||
|
number?: number;
|
||||||
|
agent_status?: AgentStatus;
|
||||||
|
pane_count?: number;
|
||||||
|
tab_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Snapshot {
|
||||||
|
workspaces?: Workspace[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<AgentStatus, string> = {
|
||||||
|
working: "#5ec8d8",
|
||||||
|
blocked: "#e8b465",
|
||||||
|
done: "#5fd08a",
|
||||||
|
idle: "#8a8a92",
|
||||||
|
unknown: "#6a6a72",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HerdrSessions() {
|
||||||
|
const [nodes, setNodes] = useState<FleetNode[]>([]);
|
||||||
|
const [snapshots, setSnapshots] = useState<Record<string, Snapshot | "loading" | "error">>({});
|
||||||
|
const [openNode, setOpenNode] = useState<string | null>(null);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/nodes");
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = (await r.json()) as { nodes?: FleetNode[] };
|
||||||
|
if (alive) setNodes((data.nodes ?? []).filter((n) => n.status === "online"));
|
||||||
|
} catch {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [tick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
nodes.map(async (n) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/nodes/${n.id}/herdr/session`);
|
||||||
|
if (!r.ok) return [n.id, "error" as const] as const;
|
||||||
|
const raw = (await r.json()) as {
|
||||||
|
error?: string;
|
||||||
|
result?: { workspaces?: Workspace[] };
|
||||||
|
workspaces?: Workspace[];
|
||||||
|
};
|
||||||
|
if (raw.error) return [n.id, { error: raw.error }] as const;
|
||||||
|
const workspaces = raw.result?.workspaces ?? raw.workspaces ?? [];
|
||||||
|
return [n.id, { workspaces }] as const;
|
||||||
|
} catch {
|
||||||
|
return [n.id, "error" as const] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!alive) return;
|
||||||
|
const next: Record<string, Snapshot | "loading" | "error"> = {};
|
||||||
|
for (const [id, s] of results) next[id] = s;
|
||||||
|
setSnapshots(next);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [nodes, tick]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setTick((n) => n + 1), []);
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, color: "#8a8a92" }}>
|
||||||
|
No online nodes. Connect a host from the Local hardware panel first.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "auto", padding: "24px 28px" }}>
|
||||||
|
<div style={{ maxWidth: 1000, margin: "0 auto" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
|
||||||
|
<Sparkles size={16} style={{ color: "#c9a0ff" }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: "#c9a0ff",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Herdr sessions · {nodes.length} online node{nodes.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
title="Refresh"
|
||||||
|
aria-label="Refresh"
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.12)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#9a9aa2",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: "#8a8a92", margin: "6px 0 22px", lineHeight: 1.5 }}>
|
||||||
|
Every online fleet node is running a persistent Herdr daemon. This view
|
||||||
|
shows their live workspaces + agent states. Click Open to render the
|
||||||
|
node's Herdr TUI in your browser (WebRTC direct where possible).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 14 }}>
|
||||||
|
{nodes.map((n) => {
|
||||||
|
const snap = snapshots[n.id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "#0f0f13",
|
||||||
|
border: "1px solid rgba(255,255,255,.08)",
|
||||||
|
padding: 14,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
minHeight: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#f3f3f5",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.hostname ?? n.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenNode(openNode === n.id ? null : n.id)}
|
||||||
|
title="Open Herdr TUI in browser"
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(201,160,255,.4)",
|
||||||
|
background: openNode === n.id ? "rgba(201,160,255,.15)" : "transparent",
|
||||||
|
color: "#c9a0ff",
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalSquare size={11} />
|
||||||
|
{openNode === n.id ? "Close" : "Open"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72" }}>
|
||||||
|
{n.localIp ?? n.tailscaleIp ?? ""}
|
||||||
|
</div>
|
||||||
|
<SessionSummary snap={snap} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openNode && (
|
||||||
|
<div style={{ marginTop: 22 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
color: "#c9a0ff",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nodes.find((n) => n.id === openNode)?.hostname ??
|
||||||
|
nodes.find((n) => n.id === openNode)?.name ??
|
||||||
|
openNode}{" "}
|
||||||
|
· Herdr TUI
|
||||||
|
</div>
|
||||||
|
<HerdrTerminal nodeId={openNode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionSummary({ snap }: { snap: Snapshot | "loading" | "error" | undefined }) {
|
||||||
|
if (snap === undefined) return <div style={hint}>Loading…</div>;
|
||||||
|
if (snap === "loading") return <div style={hint}>Loading…</div>;
|
||||||
|
if (snap === "error") return <div style={{ ...hint, color: "#ff8a7a" }}>Snapshot failed</div>;
|
||||||
|
if (typeof snap === "object" && snap.error)
|
||||||
|
return <div style={{ ...hint, color: "#ff8a7a" }}>{snap.error}</div>;
|
||||||
|
const workspaces = (snap as Snapshot).workspaces ?? [];
|
||||||
|
if (workspaces.length === 0)
|
||||||
|
return <div style={hint}>No active workspaces on this node.</div>;
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5, marginTop: 4 }}>
|
||||||
|
{workspaces.slice(0, 8).map((w) => {
|
||||||
|
const status = (w.agent_status ?? "unknown") as AgentStatus;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={w.workspace_id}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: STATUS_COLOR[status] ?? "#6a6a72",
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#cfcfd5", flex: 1, minWidth: 0 }}>
|
||||||
|
{w.label ?? `w${w.number}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
color: STATUS_COLOR[status] ?? "#6a6a72",
|
||||||
|
letterSpacing: ".08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#6a6a72", fontFamily: mono, fontSize: 10 }}>
|
||||||
|
{w.pane_count ?? 0}p
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{workspaces.length > 8 && (
|
||||||
|
<div style={{ ...hint, marginTop: 2 }}>
|
||||||
|
+{workspaces.length - 8} more workspaces
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HerdrTerminal({ nodeId }: { nodeId: string }) {
|
||||||
|
const [mode, setMode] = useState<TermMode>("connecting");
|
||||||
|
const connector = useMemo(() => nodeHerdrConnector(nodeId, setMode), [nodeId]);
|
||||||
|
const { hostRef } = useResilientTerminal({ connect: connector, autoFocus: true }, [nodeId]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: "65vh",
|
||||||
|
minHeight: 460,
|
||||||
|
background: "#0a0a0d",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 5,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color:
|
||||||
|
mode === "direct" ? "#5fd08a" : mode === "relayed" ? "#8a8a92" : "#e8b465",
|
||||||
|
background:
|
||||||
|
mode === "direct" ? "rgba(95,208,138,.12)" : "rgba(255,255,255,.05)",
|
||||||
|
border: `1px solid ${
|
||||||
|
mode === "direct" ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.1)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "direct" ? "direct" : mode === "relayed" ? "relayed" : "connecting…"}
|
||||||
|
</div>
|
||||||
|
<div ref={hostRef} style={{ position: "absolute", inset: 0, padding: 8 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint: React.CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6a6a72",
|
||||||
|
marginTop: 4,
|
||||||
|
};
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
// the infra categories, and a console placeholder (the bottom-split slot mirrors
|
// the infra categories, and a console placeholder (the bottom-split slot mirrors
|
||||||
// the agent chat). Placeholders for now; whittled into real fleet/host UI later.
|
// the agent chat). Placeholders for now; whittled into real fleet/host UI later.
|
||||||
|
|
||||||
import { Box, ChevronDown, Cloud, HardDrive, Network, Server, Terminal, type LucideIcon } from "lucide-react";
|
import { Box, ChevronDown, Cloud, HardDrive, Network, Server, Sparkles, Terminal, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import { FleetOverview, LocalHardware } from "./fleet/FleetPanels";
|
import { FleetOverview, LocalHardware } from "./fleet/FleetPanels";
|
||||||
|
import { HerdrSessions } from "./HerdrSessions";
|
||||||
|
|
||||||
const mono = "'Geist Mono', ui-monospace, monospace";
|
const mono = "'Geist Mono', ui-monospace, monospace";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export interface InfraCat {
|
|||||||
|
|
||||||
export const INFRA_CATS: InfraCat[] = [
|
export const INFRA_CATS: InfraCat[] = [
|
||||||
{ id: "fleet", icon: Network, label: "Fleet", desc: "An overview of every machine connected to your fleet." },
|
{ id: "fleet", icon: Network, label: "Fleet", desc: "An overview of every machine connected to your fleet." },
|
||||||
|
{ id: "herdr", icon: Sparkles, label: "Herdr sessions", desc: "Live Herdr workspaces + panes across every online node." },
|
||||||
{ id: "local", icon: HardDrive, label: "Local hardware", desc: "Run agents on your own machines — Macs, Linux boxes, edge devices." },
|
{ id: "local", icon: HardDrive, label: "Local hardware", desc: "Run agents on your own machines — Macs, Linux boxes, edge devices." },
|
||||||
{ id: "containers", icon: Box, label: "Containers", desc: "Deploy agent runtimes as Docker / OCI containers." },
|
{ id: "containers", icon: Box, label: "Containers", desc: "Deploy agent runtimes as Docker / OCI containers." },
|
||||||
{ id: "vms", icon: Server, label: "Virtual machines", desc: "Provision agents on VMs across your fleet." },
|
{ id: "vms", icon: Server, label: "Virtual machines", desc: "Provision agents on VMs across your fleet." },
|
||||||
@@ -63,6 +65,7 @@ export function InfraSidebar({ selected, onSelect }: { selected: string | null;
|
|||||||
/** Center content for the selected infra category. */
|
/** Center content for the selected infra category. */
|
||||||
export function InfraStage({ selected }: { selected: string | null }) {
|
export function InfraStage({ selected }: { selected: string | null }) {
|
||||||
if (selected === "fleet") return <FleetOverview />;
|
if (selected === "fleet") return <FleetOverview />;
|
||||||
|
if (selected === "herdr") return <HerdrSessions />;
|
||||||
if (selected === "local") return <LocalHardware />;
|
if (selected === "local") return <LocalHardware />;
|
||||||
return (
|
return (
|
||||||
<div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}>
|
<div style={{ height: "100%", overflow: "auto", padding: "28px 32px" }}>
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// LevelUpDrawer — review one pending proposal, pick which suggested
|
||||||
|
// items to apply, then apply or reject. Loads by proposal_id so it
|
||||||
|
// can be opened both from the global inbox and from the "just
|
||||||
|
// proposed" flow on ClawCommandCenter / TeamObserver.
|
||||||
|
//
|
||||||
|
// Item-kind → renderer table sits at the bottom. Each kind gets a
|
||||||
|
// compact card that shows the payload without hiding surprises.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
applyProposal,
|
||||||
|
getProposal,
|
||||||
|
rejectProposal,
|
||||||
|
type LevelUpProposal,
|
||||||
|
type SuggestedItem,
|
||||||
|
type SuggestedItemKind,
|
||||||
|
} from "@/lib/api/level-up";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<SuggestedItemKind, string> = {
|
||||||
|
identity_refinement: "Identity refinement",
|
||||||
|
skill_add: "Skill add",
|
||||||
|
skill_candidate: "New skill candidate",
|
||||||
|
brain_consolidation: "Brain consolidation",
|
||||||
|
roster_change: "Roster change",
|
||||||
|
mcp_bundle_change: "MCP bundle change",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_COLOR: Record<SuggestedItemKind, string> = {
|
||||||
|
identity_refinement: "#7cd6e0",
|
||||||
|
skill_add: "#7fd0a0",
|
||||||
|
skill_candidate: "#f0c264",
|
||||||
|
brain_consolidation: "#c9a0ff",
|
||||||
|
roster_change: "#ff8a7a",
|
||||||
|
mcp_bundle_change: "#ffb44a",
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_APPLICABLE: SuggestedItemKind[] = [
|
||||||
|
"identity_refinement",
|
||||||
|
"skill_add",
|
||||||
|
"skill_candidate",
|
||||||
|
"brain_consolidation",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LevelUpDrawer({
|
||||||
|
proposalId,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
proposalId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [proposal, setProposal] = useState<LevelUpProposal | null>(null);
|
||||||
|
const [approved, setApproved] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const p = await getProposal(proposalId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setProposal(p);
|
||||||
|
const preselect = new Set<string>();
|
||||||
|
(p.payload.suggested_items ?? []).forEach((it) => {
|
||||||
|
if (AUTO_APPLICABLE.includes(it.kind)) preselect.add(it.id);
|
||||||
|
});
|
||||||
|
setApproved(preselect);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "load failed");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [proposalId]);
|
||||||
|
|
||||||
|
const toggle = useCallback((id: string) => {
|
||||||
|
setApproved((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doApply = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await applyProposal(proposalId, Array.from(approved));
|
||||||
|
onChanged?.();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "apply failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [proposalId, approved, onChanged, onClose]);
|
||||||
|
|
||||||
|
const doReject = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await rejectProposal(proposalId);
|
||||||
|
onChanged?.();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "reject failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [proposalId, onChanged, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.55)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(560px, 100vw)",
|
||||||
|
height: "100vh",
|
||||||
|
background: "#141419",
|
||||||
|
borderLeft: "1px solid rgba(255,255,255,.08)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 18px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: "#c9a0ff",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Level-up proposal
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "4px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!proposal && !error && (
|
||||||
|
<div style={{ padding: 20, color: "#5ec8d8", fontFamily: mono }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: 20, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{proposal && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
padding: "14px 18px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 12, fontSize: 11, color: "#8a8a92" }}>
|
||||||
|
<span>
|
||||||
|
status:{" "}
|
||||||
|
<b style={{ color: proposal.status === "pending" ? "#f0c264" : "#7fd0a0" }}>
|
||||||
|
{proposal.status}
|
||||||
|
</b>
|
||||||
|
</span>
|
||||||
|
{proposal.model && (
|
||||||
|
<span>
|
||||||
|
model: <b style={{ color: "#cfcfd5" }}>{proposal.model}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ marginLeft: "auto" }}>
|
||||||
|
{new Date(proposal.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{proposal.payload.summary ? (
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.55 }}>
|
||||||
|
{proposal.payload.summary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{(proposal.payload.suggested_items ?? []).length === 0 && (
|
||||||
|
<div style={{ color: "#8a8a92", fontSize: 12 }}>
|
||||||
|
Proposer returned no items — nothing to apply.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(proposal.payload.suggested_items ?? []).map((item) => {
|
||||||
|
const isApproved = approved.has(item.id);
|
||||||
|
const canApply =
|
||||||
|
proposal.status === "pending" && AUTO_APPLICABLE.includes(item.kind);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${
|
||||||
|
isApproved ? "rgba(127,208,160,.4)" : "rgba(255,255,255,.08)"
|
||||||
|
}`,
|
||||||
|
background: isApproved ? "rgba(127,208,160,.06)" : "rgba(255,255,255,.02)",
|
||||||
|
cursor: canApply ? "pointer" : "default",
|
||||||
|
opacity: canApply ? 1 : 0.65,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isApproved}
|
||||||
|
disabled={!canApply}
|
||||||
|
onChange={() => toggle(item.id)}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
color: KIND_COLOR[item.kind] ?? "#a0a0a8",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{KIND_LABEL[item.kind] ?? item.kind}
|
||||||
|
</span>
|
||||||
|
{!canApply && proposal.status === "pending" && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#8a8a92",
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
manual — needs team wizard
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{proposal.applied_items.includes(item.id) && (
|
||||||
|
<span style={{ fontSize: 10, color: "#7fd0a0" }}>applied</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.title ? (
|
||||||
|
<div style={{ fontSize: 13, color: "#f3f3f5", marginBottom: 4 }}>
|
||||||
|
{String(item.title)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.rationale ? (
|
||||||
|
<div style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
|
||||||
|
{String(item.rationale)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<PerKindDetails item={item} />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderTop: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={doReject}
|
||||||
|
disabled={busy || proposal.status !== "pending"}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,138,122,.4)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#ff8a7a",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: proposal.status === "pending" ? "pointer" : "not-allowed",
|
||||||
|
opacity: busy || proposal.status !== "pending" ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={doApply}
|
||||||
|
disabled={busy || proposal.status !== "pending" || approved.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(127,208,160,.5)",
|
||||||
|
background: "rgba(127,208,160,.12)",
|
||||||
|
color: "#7fd0a0",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor:
|
||||||
|
proposal.status === "pending" && approved.size > 0
|
||||||
|
? "pointer"
|
||||||
|
: "not-allowed",
|
||||||
|
opacity:
|
||||||
|
busy || proposal.status !== "pending" || approved.size === 0 ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "Applying…" : `Apply ${approved.size}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PerKindDetails({ item }: { item: SuggestedItem }) {
|
||||||
|
// Show a compact view of the load-bearing fields per kind. Keeps
|
||||||
|
// surprises visible without dumping the whole json.
|
||||||
|
const preview = (() => {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "identity_refinement":
|
||||||
|
return item.new_system_prompt ?? item.diff;
|
||||||
|
case "skill_add":
|
||||||
|
return item.skill_name ?? item.skill_id;
|
||||||
|
case "skill_candidate":
|
||||||
|
return item.skill_body ?? item.markdown;
|
||||||
|
case "brain_consolidation":
|
||||||
|
return item.new_agent_md ?? item.consolidated_agent_md;
|
||||||
|
case "roster_change":
|
||||||
|
case "mcp_bundle_change":
|
||||||
|
return item.diff ?? item.description;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (preview === undefined || preview === null) return null;
|
||||||
|
const text = typeof preview === "string" ? preview : JSON.stringify(preview, null, 2);
|
||||||
|
const clipped = text.length > 320 ? text.slice(0, 320) + "…" : text;
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 0,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: "rgba(0,0,0,.35)",
|
||||||
|
color: "#cfcfd5",
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: mono,
|
||||||
|
maxHeight: 160,
|
||||||
|
overflow: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clipped}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// LevelUpInbox — pending proposals list. Small enough to inline into
|
||||||
|
// any tier tab; opens LevelUpDrawer on click. Auto-refreshes on
|
||||||
|
// proposal apply/reject.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import {
|
||||||
|
listPendingProposals,
|
||||||
|
type LevelUpProposal,
|
||||||
|
} from "@/lib/api/level-up";
|
||||||
|
import { LevelUpDrawer } from "./LevelUpDrawer";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
export function LevelUpInbox() {
|
||||||
|
const [rows, setRows] = useState<LevelUpProposal[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await listPendingProposals();
|
||||||
|
setRows(r);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "load failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Sparkles size={13} style={{ color: "#c9a0ff" }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: "#c9a0ff",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pending level-up proposals ({rows.length})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: loading ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: 8, color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && rows.length === 0 && (
|
||||||
|
<div style={{ padding: 8, color: "#6a6a72", fontSize: 12 }}>
|
||||||
|
No pending proposals. Run Level-Up on a claw or team to generate one.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{rows.map((p) => {
|
||||||
|
const itemCount = (p.payload.suggested_items ?? []).length;
|
||||||
|
const scope = p.agent_id ? "claw" : p.team_id ? "team" : "?";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenId(p.id)}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.08)",
|
||||||
|
background: "rgba(255,255,255,.02)",
|
||||||
|
color: "#cfcfd5",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: scope === "team" ? "#7cd6e0" : "#7fd0a0",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0, color: "#f3f3f5" }}>
|
||||||
|
{p.payload.summary
|
||||||
|
? String(p.payload.summary).slice(0, 96)
|
||||||
|
: `Proposal ${p.id.slice(0, 8)}…`}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#a0a0a8", fontFamily: mono, fontSize: 10 }}>
|
||||||
|
{itemCount} item{itemCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#6a6a72", fontSize: 10 }}>
|
||||||
|
{new Date(p.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{openId && (
|
||||||
|
<LevelUpDrawer
|
||||||
|
proposalId={openId}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// MarkdownBlock — tiny zero-dep Markdown renderer. Handles the subset
|
||||||
|
// the refiner emits: h1/h2/h3 headings, - / * bullets, 1. numbered
|
||||||
|
// lists, `**bold**`, `` `code` ``, blank-line-separated paragraphs.
|
||||||
|
// Not a general-purpose renderer — deliberately small to avoid a
|
||||||
|
// react-markdown dep for one canvas surface.
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { kind: "h1" | "h2" | "h3"; text: string }
|
||||||
|
| { kind: "p"; text: string }
|
||||||
|
| { kind: "ul"; items: string[] }
|
||||||
|
| { kind: "ol"; items: string[] };
|
||||||
|
|
||||||
|
function parse(md: string): Block[] {
|
||||||
|
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Headings
|
||||||
|
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
|
||||||
|
if (h) {
|
||||||
|
const level = h[1].length as 1 | 2 | 3;
|
||||||
|
blocks.push({
|
||||||
|
kind: (`h${level}` as "h1" | "h2" | "h3"),
|
||||||
|
text: h[2],
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Bullet list
|
||||||
|
if (/^[-*]\s+/.test(trimmed)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
|
||||||
|
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ kind: "ul", items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Numbered list
|
||||||
|
if (/^\d+\.\s+/.test(trimmed)) {
|
||||||
|
const items: string[] = [];
|
||||||
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||||
|
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
blocks.push({ kind: "ol", items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Paragraph — greedily accumulate until blank line or block boundary
|
||||||
|
const paraLines: string[] = [];
|
||||||
|
while (
|
||||||
|
i < lines.length &&
|
||||||
|
lines[i].trim() &&
|
||||||
|
!/^(#{1,3})\s+/.test(lines[i].trim()) &&
|
||||||
|
!/^[-*]\s+/.test(lines[i].trim()) &&
|
||||||
|
!/^\d+\.\s+/.test(lines[i].trim())
|
||||||
|
) {
|
||||||
|
paraLines.push(lines[i].trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (paraLines.length) blocks.push({ kind: "p", text: paraLines.join(" ") });
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline: **bold**, `code`. Simple sequential scan.
|
||||||
|
function renderInline(text: string): React.ReactNode[] {
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let key = 0;
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
if (m.index > last) out.push(text.slice(last, m.index));
|
||||||
|
const tok = m[0];
|
||||||
|
if (tok.startsWith("**")) {
|
||||||
|
out.push(
|
||||||
|
<strong key={key++} style={{ color: "#f3f3f5" }}>
|
||||||
|
{tok.slice(2, -2)}
|
||||||
|
</strong>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out.push(
|
||||||
|
<code
|
||||||
|
key={key++}
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11.5,
|
||||||
|
padding: "1px 5px",
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "rgba(255,255,255,.06)",
|
||||||
|
color: "#ffb44a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tok.slice(1, -1)}
|
||||||
|
</code>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last = m.index + tok.length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push(text.slice(last));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownBlock({ source }: { source: string }) {
|
||||||
|
const blocks = React.useMemo(() => parse(source), [source]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
color: "#cfcfd5",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{blocks.map((b, idx) => {
|
||||||
|
if (b.kind === "h1")
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
margin: "8px 0 2px",
|
||||||
|
fontSize: 17,
|
||||||
|
color: "#f3f3f5",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: ".01em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(b.text)}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
if (b.kind === "h2")
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
margin: "10px 0 -2px",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#7cd6e0",
|
||||||
|
fontFamily: mono,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(b.text)}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
if (b.kind === "h3")
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
margin: "6px 0 -4px",
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontFamily: mono,
|
||||||
|
letterSpacing: ".10em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInline(b.text)}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
if (b.kind === "p")
|
||||||
|
return (
|
||||||
|
<p key={idx} style={{ margin: 0 }}>
|
||||||
|
{renderInline(b.text)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (b.kind === "ul")
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: 18,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.items.map((it, i) => (
|
||||||
|
<li key={i}>{renderInline(it)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
if (b.kind === "ol")
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: 20,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.items.map((it, i) => (
|
||||||
|
<li key={i}>{renderInline(it)}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,18 +9,33 @@
|
|||||||
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
|
// This replaces ResearchCanvas + LoopsCanvas after Slice 9's cutover.
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FileText, Play, RefreshCw } from "lucide-react";
|
import { FileText, Pencil, Play, Plus, RefreshCw, Sparkles, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteMission,
|
||||||
getMission,
|
getMission,
|
||||||
|
refineMission,
|
||||||
|
setMissionDescription,
|
||||||
setMissionStatus,
|
setMissionStatus,
|
||||||
|
triggerBenchmark,
|
||||||
|
triggerSecurityScan,
|
||||||
|
updateMission,
|
||||||
type MissionDetail,
|
type MissionDetail,
|
||||||
type MissionStatus,
|
type MissionStatus,
|
||||||
type PhaseKind,
|
type PhaseKind,
|
||||||
type PhaseStatus,
|
type PhaseStatus,
|
||||||
|
type RefineResult,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
type TemplateKind,
|
type TemplateKind,
|
||||||
} from "@/lib/api/missions";
|
} from "@/lib/api/missions";
|
||||||
|
import { MarkdownBlock } from "./MarkdownBlock";
|
||||||
|
import { MissionWizard } from "./MissionWizard";
|
||||||
|
import {
|
||||||
|
nodeHerdrConnector,
|
||||||
|
useResilientTerminal,
|
||||||
|
type TermMode,
|
||||||
|
} from "@/components/computer/apps/terminal/core";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
@@ -61,22 +76,34 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
|
|||||||
custom: "Custom",
|
custom: "Custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks";
|
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks" | "pane";
|
||||||
|
|
||||||
export function MissionCanvas({
|
export function MissionCanvas({
|
||||||
selectedId,
|
selectedId,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
onSelect,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [mission, setMission] = useState<MissionDetail | null>(null);
|
const [mission, setMission] = useState<MissionDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<Tab>("overview");
|
const [tab, setTab] = useState<Tab>("overview");
|
||||||
const [launching, setLaunching] = useState(false);
|
const [launching, setLaunching] = useState(false);
|
||||||
|
const [refining, setRefining] = useState(false);
|
||||||
|
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
const [phaseBusy, setPhaseBusy] = useState<string | null>(null);
|
||||||
|
const [pdfPreviewId, setPdfPreviewId] = useState<string | null>(null);
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -100,6 +127,91 @@ export function MissionCanvas({
|
|||||||
void load();
|
void load();
|
||||||
}, [load, refreshKey]);
|
}, [load, refreshKey]);
|
||||||
|
|
||||||
|
const refine = useCallback(async () => {
|
||||||
|
if (!mission) return;
|
||||||
|
setRefining(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await refineMission(mission.id);
|
||||||
|
setRefineDiff(result);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "refine failed");
|
||||||
|
} finally {
|
||||||
|
setRefining(false);
|
||||||
|
}
|
||||||
|
}, [mission]);
|
||||||
|
|
||||||
|
const acceptRefine = useCallback(async () => {
|
||||||
|
if (!mission || !refineDiff) return;
|
||||||
|
setAccepting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setMissionDescription(mission.id, refineDiff.refined);
|
||||||
|
setRefineDiff(null);
|
||||||
|
onChanged();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "accept failed");
|
||||||
|
} finally {
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
}, [mission, refineDiff, onChanged, load]);
|
||||||
|
|
||||||
|
const undoRefine = useCallback(async () => {
|
||||||
|
if (!mission || !refineDiff) return;
|
||||||
|
setAccepting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setMissionDescription(mission.id, refineDiff.original);
|
||||||
|
setRefineDiff(null);
|
||||||
|
onChanged();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "undo failed");
|
||||||
|
} finally {
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
}, [mission, refineDiff, onChanged, load]);
|
||||||
|
|
||||||
|
const runSecurityScan = useCallback(
|
||||||
|
async (phaseId: string) => {
|
||||||
|
if (!mission) return;
|
||||||
|
setPhaseBusy(`sec:${phaseId}`);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await triggerSecurityScan(mission.id, phaseId);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "security scan failed");
|
||||||
|
} finally {
|
||||||
|
setPhaseBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mission, load],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runBenchmark = useCallback(
|
||||||
|
async (phaseId: string, slot: "baseline" | "after") => {
|
||||||
|
if (!mission) return;
|
||||||
|
setPhaseBusy(`bench:${phaseId}:${slot}`);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await triggerBenchmark(
|
||||||
|
mission.id,
|
||||||
|
phaseId,
|
||||||
|
slot,
|
||||||
|
slot === "after" ? Math.max(1, mission.benchmarks.length) : undefined,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "benchmark failed");
|
||||||
|
} finally {
|
||||||
|
setPhaseBusy(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mission, load],
|
||||||
|
);
|
||||||
|
|
||||||
const launch = useCallback(async () => {
|
const launch = useCallback(async () => {
|
||||||
if (!mission) return;
|
if (!mission) return;
|
||||||
setLaunching(true);
|
setLaunching(true);
|
||||||
@@ -198,6 +310,77 @@ export function MissionCanvas({
|
|||||||
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
|
{TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
<div style={{ marginLeft: "auto", display: "flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWizardOpen(true)}
|
||||||
|
title="Create a new mission"
|
||||||
|
aria-label="Add mission"
|
||||||
|
style={iconBtn}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
{mission.status === "draft" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
title="Edit title + description"
|
||||||
|
aria-label="Edit mission"
|
||||||
|
style={iconBtn}
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (deleteBusy) return;
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Delete mission "${mission.title}"? This cascades to its phases, tasks, artifacts, and benchmark snapshots.`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
await deleteMission(mission.id);
|
||||||
|
onDeleted?.();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "delete failed");
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteBusy}
|
||||||
|
title="Delete this mission"
|
||||||
|
aria-label="Delete mission"
|
||||||
|
style={{
|
||||||
|
...iconBtn,
|
||||||
|
color: "#ff8a7a",
|
||||||
|
borderColor: "rgba(255,138,122,.35)",
|
||||||
|
opacity: deleteBusy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
{mission.status === "draft" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refine}
|
||||||
|
disabled={refining || !mission.description?.trim()}
|
||||||
|
title={
|
||||||
|
mission.description?.trim()
|
||||||
|
? "Refine the description into a sectioned brief"
|
||||||
|
: "Add a description first"
|
||||||
|
}
|
||||||
|
aria-label="Refine"
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
opacity: refining || !mission.description?.trim() ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={13} style={{ marginRight: 4 }} />
|
||||||
|
{refining ? "Refining…" : "Refine"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={load}
|
onClick={load}
|
||||||
@@ -225,12 +408,52 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
|
<h1 style={{ margin: 0, fontSize: 20, color: "#f3f3f5" }}>{mission.title}</h1>
|
||||||
{mission.description && (
|
{mission.description && (
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "#cfcfd5", lineHeight: 1.5 }}>
|
<div style={{ marginTop: 4 }}>
|
||||||
{mission.description}
|
<MarkdownBlock source={mission.description} />
|
||||||
</p>
|
</div>
|
||||||
|
)}
|
||||||
|
{refineDiff && (
|
||||||
|
<RefineDiffModal
|
||||||
|
original={refineDiff.original}
|
||||||
|
refined={refineDiff.refined}
|
||||||
|
busy={accepting}
|
||||||
|
onAccept={acceptRefine}
|
||||||
|
onCancel={() => setRefineDiff(null)}
|
||||||
|
onUndoAfterAccept={undoRefine}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{wizardOpen && (
|
||||||
|
<MissionWizard
|
||||||
|
onClose={() => setWizardOpen(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setWizardOpen(false);
|
||||||
|
onSelect?.(id);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editOpen && (
|
||||||
|
<EditMissionModal
|
||||||
|
mission={mission}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditOpen(false);
|
||||||
|
onChanged();
|
||||||
|
await load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
|
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
|
||||||
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
|
{(
|
||||||
|
[
|
||||||
|
"overview",
|
||||||
|
"phases",
|
||||||
|
"tasks",
|
||||||
|
"artifacts",
|
||||||
|
"benchmarks",
|
||||||
|
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
|
||||||
|
] as Tab[]
|
||||||
|
).map((t) => {
|
||||||
const active = tab === t;
|
const active = tab === t;
|
||||||
const badge =
|
const badge =
|
||||||
t === "tasks"
|
t === "tasks"
|
||||||
@@ -360,6 +583,53 @@ export function MissionCanvas({
|
|||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{mission.status === "running" || mission.status === "completed" ? (
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||||
|
{p.kind === "security_scan" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runSecurityScan(p.id)}
|
||||||
|
disabled={phaseBusy === `sec:${p.id}`}
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
opacity: phaseBusy === `sec:${p.id}` ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phaseBusy === `sec:${p.id}` ? "Scanning…" : "Run scan"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{p.kind === "benchmark" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBenchmark(p.id, "baseline")}
|
||||||
|
disabled={phaseBusy === `bench:${p.id}:baseline`}
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
opacity:
|
||||||
|
phaseBusy === `bench:${p.id}:baseline` ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phaseBusy === `bench:${p.id}:baseline`
|
||||||
|
? "Running…"
|
||||||
|
: "Baseline"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runBenchmark(p.id, "after")}
|
||||||
|
disabled={phaseBusy === `bench:${p.id}:after`}
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
opacity:
|
||||||
|
phaseBusy === `bench:${p.id}:after` ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phaseBusy === `bench:${p.id}:after` ? "Running…" : "After"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -440,58 +710,93 @@ export function MissionCanvas({
|
|||||||
{mission.artifacts.length === 0 ? (
|
{mission.artifacts.length === 0 ? (
|
||||||
<Empty label="no artifacts yet — phases produce them as they run" />
|
<Empty label="no artifacts yet — phases produce them as they run" />
|
||||||
) : (
|
) : (
|
||||||
mission.artifacts.map((a) => (
|
mission.artifacts.map((a) => {
|
||||||
<div
|
const isPreviewing = pdfPreviewId === a.id;
|
||||||
key={a.id}
|
return (
|
||||||
style={{
|
<div
|
||||||
padding: 11,
|
key={a.id}
|
||||||
borderRadius: 10,
|
style={{ display: "flex", flexDirection: "column", gap: 8 }}
|
||||||
border: "1px solid rgba(255,255,255,.07)",
|
>
|
||||||
background: "#101014",
|
<div
|
||||||
display: "flex",
|
style={{
|
||||||
alignItems: "center",
|
padding: 11,
|
||||||
gap: 10,
|
borderRadius: 10,
|
||||||
}}
|
border: "1px solid rgba(255,255,255,.07)",
|
||||||
>
|
background: "#101014",
|
||||||
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
|
display: "flex",
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
alignItems: "center",
|
||||||
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
|
gap: 10,
|
||||||
{a.title ?? a.path.split("/").pop() ?? a.path}
|
}}
|
||||||
</div>
|
>
|
||||||
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
|
<FileText size={16} style={{ color: "#7cd6e0", flex: "none" }} />
|
||||||
{a.kind} · {a.path}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 500 }}>
|
||||||
|
{a.title ?? a.path.split("/").pop() ?? a.path}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92" }}>
|
||||||
|
{a.kind} · {a.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{a.rendered_pdf_path ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPdfPreviewId(isPreviewing ? null : a.id)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
padding: "5px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPreviewing ? "Hide" : "Preview"}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={a.rendered_pdf_path}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
...secondaryBtn,
|
||||||
|
padding: "5px 10px",
|
||||||
|
fontSize: 11,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : a.render_pdf_status !== "skip" ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
color:
|
||||||
|
a.render_pdf_status === "failed"
|
||||||
|
? "#ff8a7a"
|
||||||
|
: "#8a8a92",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
pdf: {a.render_pdf_status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{isPreviewing && a.rendered_pdf_path && (
|
||||||
|
<iframe
|
||||||
|
src={a.rendered_pdf_path}
|
||||||
|
title={a.title ?? a.path}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 640,
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#0a0a0d",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{a.rendered_pdf_path ? (
|
);
|
||||||
<a
|
})
|
||||||
href={a.rendered_pdf_path}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{
|
|
||||||
...secondaryBtn,
|
|
||||||
padding: "5px 10px",
|
|
||||||
fontSize: 11,
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open PDF
|
|
||||||
</a>
|
|
||||||
) : a.render_pdf_status !== "skip" ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: mono,
|
|
||||||
fontSize: 10.5,
|
|
||||||
color:
|
|
||||||
a.render_pdf_status === "failed"
|
|
||||||
? "#ff8a7a"
|
|
||||||
: "#8a8a92",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
pdf: {a.render_pdf_status}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -628,6 +933,13 @@ export function MissionCanvas({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
|
||||||
|
<LivePane
|
||||||
|
nodeId={mission.target_node_id}
|
||||||
|
visible={tab === "pane"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -680,6 +992,495 @@ function Empty({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LivePane({
|
||||||
|
nodeId,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
nodeId: string | null;
|
||||||
|
visible: boolean;
|
||||||
|
}) {
|
||||||
|
if (!nodeId) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 40,
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#8a8a92",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This mission has no target node.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <LivePaneInner nodeId={nodeId} visible={visible} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LivePaneInner({
|
||||||
|
nodeId,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
nodeId: string;
|
||||||
|
visible: boolean;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<TermMode>("connecting");
|
||||||
|
const { hostRef, refit } = useResilientTerminal(
|
||||||
|
{
|
||||||
|
connect: nodeHerdrConnector(nodeId, setMode),
|
||||||
|
autoFocus: false,
|
||||||
|
visible: () => visible,
|
||||||
|
},
|
||||||
|
[nodeId],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) refit();
|
||||||
|
}, [visible, refit]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: "70vh",
|
||||||
|
minHeight: 480,
|
||||||
|
background: "#0a0a0d",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 5,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color:
|
||||||
|
mode === "direct"
|
||||||
|
? "#5fd08a"
|
||||||
|
: mode === "relayed"
|
||||||
|
? "#8a8a92"
|
||||||
|
: "#e8b465",
|
||||||
|
background:
|
||||||
|
mode === "direct"
|
||||||
|
? "rgba(95,208,138,.12)"
|
||||||
|
: "rgba(255,255,255,.05)",
|
||||||
|
border: `1px solid ${
|
||||||
|
mode === "direct" ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.1)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "direct" ? "direct" : mode === "relayed" ? "relayed" : "connecting…"}
|
||||||
|
</div>
|
||||||
|
<div ref={hostRef} style={{ position: "absolute", inset: 0, padding: 8 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditMissionModal({
|
||||||
|
mission,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
mission: MissionDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(mission.title);
|
||||||
|
const [description, setDescription] = useState(mission.description ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dirty =
|
||||||
|
title.trim() !== mission.title || description !== (mission.description ?? "");
|
||||||
|
const save = async () => {
|
||||||
|
if (!dirty) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("title is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateMission(mission.id, {
|
||||||
|
title: title.trim() !== mission.title ? title.trim() : undefined,
|
||||||
|
description:
|
||||||
|
description !== (mission.description ?? "") ? description : undefined,
|
||||||
|
});
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "save failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.55)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 900,
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(620px, 100%)",
|
||||||
|
background: "#141419",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid rgba(255,255,255,.08)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: "#7cd6e0",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit mission
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 18, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "#0a0a0d",
|
||||||
|
color: "#f3f3f5",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Description (Markdown; use Refine to structure)
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
rows={12}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "#0a0a0d",
|
||||||
|
color: "#f3f3f5",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
resize: "vertical",
|
||||||
|
minHeight: 160,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: "#ff8a7a", fontSize: 12 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderTop: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(127,208,160,.5)",
|
||||||
|
background: "rgba(127,208,160,.12)",
|
||||||
|
color: "#7fd0a0",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: dirty ? "pointer" : "not-allowed",
|
||||||
|
opacity: busy || !dirty ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefineDiffModal({
|
||||||
|
original,
|
||||||
|
refined,
|
||||||
|
busy,
|
||||||
|
onAccept,
|
||||||
|
onCancel,
|
||||||
|
onUndoAfterAccept,
|
||||||
|
}: {
|
||||||
|
original: string;
|
||||||
|
refined: string;
|
||||||
|
busy: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onUndoAfterAccept: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 900,
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "min(1100px, 100%)",
|
||||||
|
height: "min(720px, calc(100vh - 48px))",
|
||||||
|
background: "#141419",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid rgba(255,255,255,.08)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 18px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color: "#ffb44a",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refine — review before/after
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onUndoAfterAccept}
|
||||||
|
disabled={busy}
|
||||||
|
title="Restore the original description (drops the refinement)"
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore original
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: "5px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,255,255,.1)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "#a0a0a8",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAccept}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: "5px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(127,208,160,.5)",
|
||||||
|
background: "rgba(127,208,160,.12)",
|
||||||
|
color: "#7fd0a0",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "Applying…" : "Accept"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 1,
|
||||||
|
background: "rgba(255,255,255,.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiffPane
|
||||||
|
title="Before"
|
||||||
|
color="#8a8a92"
|
||||||
|
body={original}
|
||||||
|
renderAsMarkdown={false}
|
||||||
|
/>
|
||||||
|
<DiffPane
|
||||||
|
title="After"
|
||||||
|
color="#7fd0a0"
|
||||||
|
body={refined}
|
||||||
|
renderAsMarkdown
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffPane({
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
body,
|
||||||
|
renderAsMarkdown,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
body: string;
|
||||||
|
renderAsMarkdown: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#0e0e12",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.05)",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".14em",
|
||||||
|
color,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 14 }}>
|
||||||
|
{renderAsMarkdown ? (
|
||||||
|
<MarkdownBlock source={body} />
|
||||||
|
) : (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#cfcfd5",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const iconBtn: React.CSSProperties = {
|
const iconBtn: React.CSSProperties = {
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ export function MissionWizard({
|
|||||||
}, []);
|
}, []);
|
||||||
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
|
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
|
||||||
const [cron, setCron] = useState("0 */6 * * *");
|
const [cron, setCron] = useState("0 */6 * * *");
|
||||||
|
const [runtimeKind, setRuntimeKind] = useState<"zeroclaw" | "local_herdr">("zeroclaw");
|
||||||
|
const [targetNodeId, setTargetNodeId] = useState<string>("");
|
||||||
|
const [onlineNodes, setOnlineNodes] = useState<
|
||||||
|
Array<{ id: string; name: string }>
|
||||||
|
>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/nodes");
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = (await r.json()) as {
|
||||||
|
nodes?: Array<{ id: string; name: string; status: string }>;
|
||||||
|
};
|
||||||
|
setOnlineNodes(
|
||||||
|
(data.nodes ?? [])
|
||||||
|
.filter((n) => n.status === "online")
|
||||||
|
.map((n) => ({ id: n.id, name: n.name })),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — user can still pick zeroclaw runtime without nodes.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -73,7 +96,8 @@ export function MissionWizard({
|
|||||||
title.trim().length > 0 &&
|
title.trim().length > 0 &&
|
||||||
(!preset.requiresRepo || repo !== null)) ||
|
(!preset.requiresRepo || repo !== null)) ||
|
||||||
step === 3 ||
|
step === 3 ||
|
||||||
step === 4;
|
(step === 4 &&
|
||||||
|
(runtimeKind === "zeroclaw" || targetNodeId !== ""));
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -89,6 +113,9 @@ export function MissionWizard({
|
|||||||
schedule,
|
schedule,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
phases: preset.phases,
|
phases: preset.phases,
|
||||||
|
runtime_kind: runtimeKind,
|
||||||
|
target_node_id:
|
||||||
|
runtimeKind === "local_herdr" ? targetNodeId : undefined,
|
||||||
});
|
});
|
||||||
onCreated(created.id);
|
onCreated(created.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -372,7 +399,63 @@ export function MissionWizard({
|
|||||||
|
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
<span style={labelStyle}>Schedule</span>
|
<span style={labelStyle}>Runtime</span>
|
||||||
|
<label style={radioRowStyle(runtimeKind === "zeroclaw")}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={runtimeKind === "zeroclaw"}
|
||||||
|
onChange={() => {
|
||||||
|
setRuntimeKind("zeroclaw");
|
||||||
|
setTargetNodeId("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
|
||||||
|
Hosted (ZeroClaw)
|
||||||
|
</div>
|
||||||
|
<div style={hintStyle}>
|
||||||
|
Runs headlessly in the shared ZeroClaw daemon. Default. No
|
||||||
|
fleet node required.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label style={radioRowStyle(runtimeKind === "local_herdr")}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={runtimeKind === "local_herdr"}
|
||||||
|
onChange={() => setRuntimeKind("local_herdr")}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
|
||||||
|
On a fleet node (Herdr)
|
||||||
|
</div>
|
||||||
|
<div style={hintStyle}>
|
||||||
|
Executes in a Herdr pane on a fleet node, using that
|
||||||
|
node's local CLI (claude / kimi / codex). Operator-visible,
|
||||||
|
live pane view in the mission canvas.
|
||||||
|
</div>
|
||||||
|
{runtimeKind === "local_herdr" && (
|
||||||
|
<select
|
||||||
|
value={targetNodeId}
|
||||||
|
onChange={(e) => setTargetNodeId(e.target.value)}
|
||||||
|
style={{ ...fieldStyle, marginTop: 6 }}
|
||||||
|
>
|
||||||
|
<option value="">Pick a node…</option>
|
||||||
|
{onlineNodes.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{n.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{runtimeKind === "local_herdr" && onlineNodes.length === 0 && (
|
||||||
|
<div style={{ ...hintStyle, color: "#ff8a7a", marginTop: 4 }}>
|
||||||
|
No online nodes. Connect one from the INFRA tier first.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<span style={{ ...labelStyle, marginTop: 6 }}>Schedule</span>
|
||||||
<label style={radioRowStyle(scheduleKind === "one_shot")}>
|
<label style={radioRowStyle(scheduleKind === "one_shot")}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -426,6 +509,14 @@ export function MissionWizard({
|
|||||||
: "auto-provision from prompt"
|
: "auto-provision from prompt"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
k="Runtime"
|
||||||
|
v={
|
||||||
|
runtimeKind === "local_herdr"
|
||||||
|
? `Herdr on ${onlineNodes.find((n) => n.id === targetNodeId)?.name ?? targetNodeId}`
|
||||||
|
: "Hosted (ZeroClaw)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
k="Schedule"
|
k="Schedule"
|
||||||
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}
|
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
// UX without breaking existing workflows.
|
// UX without breaking existing workflows.
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Plus, RotateCw } from "lucide-react";
|
import { Plus, RotateCw, Trash2, Wrench } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteMission,
|
||||||
listMissions,
|
listMissions,
|
||||||
type Mission,
|
type Mission,
|
||||||
type MissionStatus,
|
type MissionStatus,
|
||||||
@@ -41,16 +42,23 @@ export function MissionsList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
onDeleted,
|
||||||
}: {
|
}: {
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onCreated: (id: string) => void;
|
onCreated: (id: string) => void;
|
||||||
|
/** Called after bulk delete completes; parent clears selection if the
|
||||||
|
* currently-open mission was among the deleted rows. */
|
||||||
|
onDeleted?: (deletedIds: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [missions, setMissions] = useState<Mission[]>([]);
|
const [missions, setMissions] = useState<Mission[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -97,6 +105,77 @@ export function MissionsList({
|
|||||||
MISSIONS · {missions.length}
|
MISSIONS · {missions.length}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectMode((v) => {
|
||||||
|
if (v) setSelectedIds(new Set());
|
||||||
|
return !v;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title={selectMode ? "Exit select mode" : "Select missions to manage"}
|
||||||
|
aria-label="Toggle select mode"
|
||||||
|
style={{
|
||||||
|
...iconBtn,
|
||||||
|
color: selectMode ? "#ff8a7a" : "#9a9aa2",
|
||||||
|
borderColor: selectMode
|
||||||
|
? "rgba(255,138,122,.5)"
|
||||||
|
: "rgba(255,255,255,.12)",
|
||||||
|
background: selectMode ? "rgba(255,138,122,.08)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wrench size={13} />
|
||||||
|
</button>
|
||||||
|
{selectMode && selectedIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (bulkBusy) return;
|
||||||
|
const ids = Array.from(selectedIds);
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Delete ${ids.length} mission${ids.length === 1 ? "" : "s"}? Cascades to phases, tasks, artifacts, and benchmark snapshots.`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setBulkBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await deleteMission(id);
|
||||||
|
} catch (e) {
|
||||||
|
failures.push(id);
|
||||||
|
console.error(`delete ${id}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBulkBusy(false);
|
||||||
|
const deletedIds = ids.filter((id) => !failures.includes(id));
|
||||||
|
setSelectedIds(new Set(failures));
|
||||||
|
if (failures.length === 0) setSelectMode(false);
|
||||||
|
else
|
||||||
|
setError(`${failures.length} of ${ids.length} deletes failed`);
|
||||||
|
onDeleted?.(deletedIds);
|
||||||
|
await load();
|
||||||
|
}}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
title={`Delete ${selectedIds.size} selected`}
|
||||||
|
aria-label="Delete selected missions"
|
||||||
|
style={{
|
||||||
|
...iconBtn,
|
||||||
|
color: "#ff8a7a",
|
||||||
|
borderColor: "rgba(255,138,122,.5)",
|
||||||
|
background: "rgba(255,138,122,.1)",
|
||||||
|
opacity: bulkBusy ? 0.5 : 1,
|
||||||
|
width: "auto",
|
||||||
|
padding: "0 8px",
|
||||||
|
gap: 4,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
{selectedIds.size}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={load}
|
onClick={load}
|
||||||
@@ -162,13 +241,25 @@ export function MissionsList({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
missions.map((m) => {
|
missions.map((m) => {
|
||||||
const active = selectedId === m.id;
|
const active = !selectMode && selectedId === m.id;
|
||||||
|
const picked = selectMode && selectedIds.has(m.id);
|
||||||
const badge = TEMPLATE_BADGE[m.template_kind] ?? TEMPLATE_BADGE.custom;
|
const badge = TEMPLATE_BADGE[m.template_kind] ?? TEMPLATE_BADGE.custom;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(m.id)}
|
onClick={() => {
|
||||||
|
if (selectMode) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(m.id)) next.delete(m.id);
|
||||||
|
else next.add(m.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onSelect(m.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
@@ -177,13 +268,43 @@ export function MissionsList({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
padding: "10px 11px",
|
padding: "10px 11px",
|
||||||
borderRadius: 9,
|
borderRadius: 9,
|
||||||
border: `1px solid ${active ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.07)"}`,
|
border: `1px solid ${
|
||||||
background: active ? "rgba(94,200,216,.08)" : "#101013",
|
picked
|
||||||
|
? "rgba(255,138,122,.55)"
|
||||||
|
: active
|
||||||
|
? "rgba(94,200,216,.5)"
|
||||||
|
: "rgba(255,255,255,.07)"
|
||||||
|
}`,
|
||||||
|
background: picked
|
||||||
|
? "rgba(255,138,122,.1)"
|
||||||
|
: active
|
||||||
|
? "rgba(94,200,216,.08)"
|
||||||
|
: "#101013",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: "#eaeaee",
|
color: "#eaeaee",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
{selectMode && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${picked ? "#ff8a7a" : "rgba(255,255,255,.25)"}`,
|
||||||
|
background: picked ? "#ff8a7a" : "transparent",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "#101013",
|
||||||
|
fontSize: 10,
|
||||||
|
flex: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{picked ? "✓" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 7,
|
width: 7,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user