slice 9 cleanup: drop legacy research/loops backend + tables

Retires the legacy research/loops backend after the missions arc
(slices 1-9) fully replaced it. Frontend cutover was 4663348; this
commit finishes the job on the backend + database.

Migration:
  - 0053_drop_legacy_research_loops.sql — drops the 8 legacy tables
    (research_topics, research_topic_agents, research_outcomes,
    research_publish_approvals, loops, loop_agents, loop_orgs,
    loop_teams) and the 3 topology_runs FK columns
    (research_topic_id, loop_id, iteration). parent_run_id stays;
    recursive_exec still uses it.

Files deleted (11):
  - crates/cm-api/src/routes/{research,loops,research_setup,
    research_pipeline,wizard_repo,probe}.rs
  - crates/cm-api/src/research_container.rs
  - crates/cm-db/src/repo/{research_topics,research_outcomes,
    research_publish_approvals,loops}.rs
  - crates/cm-runtime/src/loops.rs
  - crates/cm-api/tests/research_publish_role.rs

Files edited:
  - crates/cm-api/src/lib.rs — dropped 20 legacy route registrations
    (all /api/research/* + /api/loops/* + /webhooks/loops + probe)
    and module decls
  - crates/cm-api/src/topology_worker.rs — deleted legacy dispatch
    (freeze_research_outcome, advance_loop_after_completion,
    continue_initial_burst, maybe_transition_research_topic,
    parse_reorder_rationale, per-topic/loop gateway resolver).
    reap_stuck_runs now keys on mission_id (not topic_id).
    Executor path unconditionally uses ZeroClawDriveExecutor::from_env
    — mission_orchestrator provisions each claw as an agent inside
    the shared runtime via RuntimeProvisioner, so per-team gateway
    resolution is no longer applicable.
  - crates/cm-api/src/routes/topology.rs — deleted container-log SSE
    endpoint (research/loop-specific), dropped loop_id filter and
    iteration field from ListRunsQuery/RunSummary
  - crates/cm-api/src/routes/world.rs — removed
    active_research_topics/active_loops/preseed_repo_paths;
    World SSE no longer emits repo:{topic}/loop:{id} landmark orbs
    (follow-up task #21 tracks adding mission:{id} equivalents)
  - crates/cm-api/src/runtime_provision.rs — removed now-unused
    mint_workspace_service_token
  - crates/cm-db/src/repo/topology_runs.rs — removed 9 legacy
    helpers (research_topic_id lookup, loop_id_for_run,
    iteration_for_run, active_runs_for_research_topic, etc.)
  - crates/cm-db/src/repo/teams.rs — removed 4 dead helpers
    (team_for_loop, team_for_research_topic + setters)
  - crates/cm-api/tests/topology_jobs.rs — removed loop/topic
    tests, dropped enqueue_run_with_topic helper
  - crates/bins/clawmates-server/src/main.rs — removed
    spawn_loop_scheduler call
  - crates/cm-api/src/routes/mod.rs, crates/cm-db/src/repo/mod.rs,
    crates/cm-runtime/src/lib.rs — module decls stripped

sqlx cache: regenerated against post-migration schema
  (71 files changed, ~+70 / -8896 net)

Test/build: SQLX_OFFLINE=true cargo check --workspace clean;
cargo test --workspace --no-run clean.

Follow-up (task #21): World view lost the in-flight-work landmarks
when repo:{topic} / loop:{id} orbs disappeared. Add mission:{id}
orbs as the missions-era replacement.
This commit is contained in:
Omar Sobh
2026-07-19 18:37:24 -07:00
parent 56201a6985
commit fdb8cfeecc
71 changed files with 70 additions and 8896 deletions
@@ -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"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM loop_orgs WHERE loop_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "01cdcd2bbc04ebfefb5ba2a69bee88ddb732a88117f63841bfd17d07a4b969af"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM loop_agents WHERE loop_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "3f74f01c6411409c49ad66edba8a059ca8a72daae3570a6e99d59de7c3b6dc82"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
} }
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM loop_teams WHERE loop_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "c73f821ea00857bcc06add6478a4f999bbf8bdecb96e9fb79cdeafd1d22a2a2f"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
-1
View File
@@ -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));
+3 -90
View File
@@ -16,7 +16,6 @@ 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;
@@ -477,91 +476,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",
@@ -582,10 +499,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),
-787
View File
@@ -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 })
}
-898
View File
@@ -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
}
-6
View File
@@ -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;
-147
View File
@@ -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,
}))
}
-506
View File
@@ -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:?}");
}
}
+4 -228
View File
@@ -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())
}
-153
View File
@@ -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}"))),
}
}
-165
View File
@@ -61,106 +61,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,71 +361,6 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
} }
} }
// Active research topics → landmark project orbs. One `repo:<id>`
// per topic, labeled with the topic title so users can click it
// and drop into the repo-focus (Gource) view before any files are
// touched. Assigned agents gently converge on their topic's orb
// so the affinity is visible even in idle windows.
let research = active_research_topics(&pool, ws).await;
let mut seen_topics = std::collections::HashSet::new();
for (topic_id, title, agent_id, repo_path) in &research {
let node_id = format!("repo:{topic_id}");
if seen_topics.insert(topic_id.clone()) {
yield sse(
"node.activity",
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
);
// 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 }),
);
}
yield sse(
"world.touch",
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
);
}
// Real convergence: each running agent beams toward its active-run node. // Real convergence: each running agent beams toward its active-run node.
for (run_id, agent_id) in &runs { for (run_id, agent_id) in &runs {
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]); let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
-21
View File
@@ -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())
+19 -476
View File
@@ -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))
}
@@ -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}"
);
}
}
+1 -246
View File
@@ -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,
-877
View File
@@ -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)
}
-4
View File
@@ -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()))
}
-357
View File
@@ -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())
}
-55
View File
@@ -318,61 +318,6 @@ 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 /// 0046: read the team's per-container coordinates. Both fields NULL
/// means the team has never spawned; the runtime provisions on first /// means the team has never spawned; the runtime provisions on first
/// iteration. /// iteration.
+3 -252
View File
@@ -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())
-2
View File
@@ -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,
-141
View File
@@ -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,
}
}
@@ -0,0 +1,38 @@
-- Slice 9 cleanup: retire the legacy research/loops surface.
--
-- The missions arc (Slices 1–9) fully replaced these tables. The
-- one-time backfill in 0047 copied every row into missions; no new
-- writes have hit these tables since the UI cutover in 4663348.
--
-- Drop order matters: children first, then parents. `research_topics`
-- has children `research_topic_agents`, `research_outcomes`,
-- `research_publish_approvals`. `loops` has children `loop_agents`,
-- `loop_orgs`, `loop_teams`.
--
-- topology_runs.research_topic_id / .loop_id / .iteration go too —
-- Slice 5 added mission_id + mission_phase_id which now own
-- attribution. `parent_run_id` (also from 0031) stays; recursive_exec
-- still uses it.
BEGIN;
ALTER TABLE topology_runs
DROP COLUMN IF EXISTS research_topic_id,
DROP COLUMN IF EXISTS loop_id,
DROP COLUMN IF EXISTS iteration;
-- Loop-side children.
DROP TABLE IF EXISTS loop_agents;
DROP TABLE IF EXISTS loop_orgs;
DROP TABLE IF EXISTS loop_teams;
-- Research-side children.
DROP TABLE IF EXISTS research_publish_approvals;
DROP TABLE IF EXISTS research_outcomes;
DROP TABLE IF EXISTS research_topic_agents;
-- Parents.
DROP TABLE IF EXISTS loops;
DROP TABLE IF EXISTS research_topics;
COMMIT;