research: extract setup helpers into research_setup.rs (fixes file-size gate)
research.rs hit 1446 lines with the wizard-fold + regression-fix
commits, tripping the CI file-size gate (limit 1250). Pure extraction
into a sibling module; no behavior change.
Moved to routes/research_setup.rs:
- RepoContext (now pub — used by build_coordinator_task)
- TopicSchedule (now pub — request body sub-struct)
- research_workspace_root (now pub)
- prepare_topic_runtime (already pub — used by loops iteration path)
- ensure_repo_workspace (now pub — used by start_topic + prepare)
- materialize_topic_loops (now pub — used by create_topic)
research.rs re-imports them via `use crate::routes::research_setup::{...}`
so the calling code reads identically.
New line counts:
research.rs 1127 lines (was 1446, limit 1250)
research_setup.rs 321 lines (new)
Also updated the loop-iteration callsite in routes/loops.rs to point
at the new path (crate::routes::research_setup::prepare_topic_runtime).
No API changes; migrations unaffected.
This commit is contained in:
@@ -90,7 +90,7 @@ pub async fn compose_and_enqueue_iteration(
|
|||||||
// repo files + an isolated daemon to hit. Idempotent — the
|
// repo files + an isolated daemon to hit. Idempotent — the
|
||||||
// second iteration reattaches to the existing container. Runs
|
// second iteration reattaches to the existing container. Runs
|
||||||
// even when the topic has no repo (harmless no-op).
|
// even when the topic has no repo (harmless no-op).
|
||||||
crate::routes::research::prepare_topic_runtime(pool, workspace_id, topic_id).await;
|
crate::routes::research_setup::prepare_topic_runtime(pool, workspace_id, topic_id).await;
|
||||||
let task = compose_research_iteration_task(pool, topic_id, template_ref).await;
|
let task = compose_research_iteration_task(pool, topic_id, template_ref).await;
|
||||||
cm_db::repo::loops::enqueue_iteration_with_topic(
|
cm_db::repo::loops::enqueue_iteration_with_topic(
|
||||||
pool,
|
pool,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod planner;
|
|||||||
pub mod repos;
|
pub mod repos;
|
||||||
pub mod research;
|
pub mod research;
|
||||||
pub mod research_pipeline;
|
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;
|
||||||
|
|||||||
@@ -149,12 +149,6 @@ pub struct CreateTopicRequest {
|
|||||||
pub create_paired_coding_loop: bool,
|
pub create_paired_coding_loop: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TopicSchedule {
|
|
||||||
/// "once" | "nightly" | "manual".
|
|
||||||
pub mode: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// The wizard sends a denormalized display object for its own UI. Only
|
// The wizard sends a denormalized display object for its own UI. Only
|
||||||
// repo_id is authoritative; the rest is present so serde deserializes the
|
// repo_id is authoritative; the rest is present so serde deserializes the
|
||||||
// full body cleanly (and future callers can piggyback additional
|
// full body cleanly (and future callers can piggyback additional
|
||||||
@@ -301,195 +295,14 @@ the description."
|
|||||||
format!("{framing}{body}")
|
format!("{framing}{body}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A shallow-cloned repo attached to a research run. Populated by
|
// RepoContext, research_workspace_root, prepare_topic_runtime,
|
||||||
/// `ensure_repo_workspace`; consumed by `build_coordinator_task` to give
|
// ensure_repo_workspace, TopicSchedule, and materialize_topic_loops
|
||||||
/// the coordinator a concrete on-disk starting point for the team.
|
// moved to `research_setup.rs` to keep this file under the 1250-line
|
||||||
struct RepoContext {
|
// budget. Import re-uses below.
|
||||||
/// Human-readable "owner/name".
|
use crate::routes::research_setup::{
|
||||||
slug: String,
|
ensure_repo_workspace, materialize_topic_loops, research_workspace_root, RepoContext,
|
||||||
/// Absolute path on the API host where the checkout lives.
|
TopicSchedule,
|
||||||
path: String,
|
|
||||||
/// Branch we cloned (repo.default_branch → "main" fallback).
|
|
||||||
branch: String,
|
|
||||||
/// Line-per-entry preview of the working tree (relative paths).
|
|
||||||
tree_preview: String,
|
|
||||||
/// Files shown vs. total, so the prompt is honest about truncation.
|
|
||||||
shown: usize,
|
|
||||||
total_files: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
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: &sqlx::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,
|
|
||||||
};
|
};
|
||||||
// Repo binding is optional; without it we just skip clone + spawn.
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match crate::research_container::spawn(&docker, topic_id, &repo_path, &state_root).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.
|
|
||||||
async fn ensure_repo_workspace(
|
|
||||||
pool: &sqlx::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}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview: `git ls-files` first N entries. Bounded so the prompt stays
|
|
||||||
// small even for large repos; a subsequent tool call can list more.
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AgentSlotInput {
|
pub struct AgentSlotInput {
|
||||||
@@ -586,140 +399,6 @@ pub async fn create_topic(
|
|||||||
Ok((StatusCode::CREATED, Json(TopicCreated { id })))
|
Ok((StatusCode::CREATED, Json(TopicCreated { id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
|
||||||
async fn materialize_topic_loops(
|
|
||||||
pool: &sqlx::PgPool,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
created_by: Uuid,
|
|
||||||
topic_id: Uuid,
|
|
||||||
topic_title: &str,
|
|
||||||
mode: &str,
|
|
||||||
also_coding: bool,
|
|
||||||
) {
|
|
||||||
use serde_json::json;
|
|
||||||
// Empty graph — research loop iterations build the coordinator
|
|
||||||
// task on the fly from the topic config (compose_research_iteration_task);
|
|
||||||
// graph is a placeholder the run driver requires.
|
|
||||||
let graph = json!({ "nodes": [], "edges": [] });
|
|
||||||
|
|
||||||
// Research loop triggers by mode.
|
|
||||||
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}");
|
|
||||||
let r_loop = 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;
|
|
||||||
match r_loop {
|
|
||||||
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;
|
|
||||||
// Fire the initial burst NOW (this is what create_loop's
|
|
||||||
// handler does after inserting the row; materialize_topic_loops
|
|
||||||
// bypasses that handler). Without this call the loop lands
|
|
||||||
// in the DB but its initial_burst=1 never fires and the
|
|
||||||
// diagnostic shows "0 runs".
|
|
||||||
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:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional coding loop — kind='exec', wakes on artifact updates.
|
|
||||||
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;
|
|
||||||
// Fire the coding loop's initial burst too. It's
|
|
||||||
// typically 1 (single wake to consume the first
|
|
||||||
// artifact) and on_artifact_update handles subsequent
|
|
||||||
// waves via the fan-out hook.
|
|
||||||
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:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TopicListItem {
|
pub struct TopicListItem {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::research_container::spawn(&docker, topic_id, &repo_path, &state_root).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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
use serde_json::json;
|
||||||
|
let graph = json!({ "nodes": [], "edges": [] });
|
||||||
|
|
||||||
|
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;
|
||||||
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user