research: spawn per-topic ZeroClaw team container on start (commit 1/3)
Commit 1 of the path-B (real per-topic isolation) plan. The
container spawns and its coordinates persist — nothing talks to
it yet; commit 2 wires ZeroClawDriveExecutor to prefer the topic's
URL when populated. This split keeps each landing verifiable.
Backend
- Migration 0038: research_topics gets zeroclaw_container_name +
zeroclaw_gateway_url columns. Both nullable so a topic can exist
before a spawn and teardown just NULLs them out.
- cm-db: ResearchTopic struct extended; get/list SELECTs updated;
new set_zeroclaw_container(id, workspace_id, name, url) helper
used both for spawn (Some/Some) and teardown (None/None).
- cm-api: bollard added as a workspace dep (matches cm-sandbox's
version). New research_container module:
· connect() → uses DOCKER_HOST when set (prod's socket-proxy
at tcp://socket-proxy:2375) else the local socket. Same
pattern cm-sandbox already uses.
· container_name_for(topic_id) → "research-<uuid>-team"
(deterministic so a re-start reattaches to the same
container instead of orphaning it).
· inherited_env() → propagates ZEROCLAW_*, OPENAI_*,
ANTHROPIC_*, GEMINI_*, GROQ_* from the parent server env
(provider config + tokens), stripping the server's own
ZEROCLAW_GATEWAY_URL/WORKSPACE so the team runtime doesn't
loop back on itself. Appends ZEROCLAW_GATEWAY_PORT=42617
and ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace for the
team's own listener.
· spawn(docker, topic_id, repo_host_path, state_host_path):
- inspect: if the container already exists, start it if
stopped and return its coordinates (idempotent restart).
- else create with:
image = CLAWMATES_RESEARCH_TEAM_IMAGE or
clawmates-runtime:latest
cmd = [daemon, --host, 0.0.0.0]
env = inherited_env()
mounts = repo_host_path → /workspace/repo (rw)
state_host_path → /zeroclaw-data (rw)
network = CLAWMATES_RESEARCH_TEAM_NETWORK or
clawmates_core
labels = clawmates.role=research-team,
clawmates.research.topic_id=<uuid>
- creates state_host_path first so bind doesn't ENOENT.
· stop(docker, name) → stop + remove. Idempotent on 404/304.
- start_topic wires spawn after the clone completes:
· state root = CLAWMATES_RESEARCH_WORKSPACE_ROOT / <topic> /
state
· on success, persists (name, url) on the topic row so commit
2 can look them up when constructing the executor
· every failure (docker connect, docker create/start, DB
persist) is best-effort: logs and continues. A missing team
container leaves the topic pointing at the workspace-wide
gateway URL (env), preserving prior behavior.
Deploy prerequisites (not in this commit)
- The compose stack's clawmates_server service needs bind-mounts
of CLAWMATES_RESEARCH_WORKSPACE_ROOT (e.g.
/var/lib/clawmates-research:/var/lib/clawmates-research) so
paths the server writes to are visible on the host and the
spawned team container mounts the same underlying data.
- socket-proxy's ACL must allow POST + DELETE on /containers
(already the case in prod per the audited compose file).
This commit is contained in:
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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 FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,16 @@
|
|||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "repo_workspace_path",
|
"name": "repo_workspace_path",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "zeroclaw_container_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "zeroclaw_gateway_url",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -87,8 +97,10 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "9a823772908b51bb76d5e162e9dde8265fd9cdf106828ada234b75ab106ad21e"
|
"hash": "40584d4cb37a3e98c260b113c807a5f7e0e6775c922e60d4321b790a1bad8d27"
|
||||||
}
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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 FROM research_topics\n WHERE id = $1 AND workspace_id = $2",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,16 @@
|
|||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "repo_workspace_path",
|
"name": "repo_workspace_path",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 13,
|
||||||
|
"name": "zeroclaw_container_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "zeroclaw_gateway_url",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -88,8 +98,10 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "a40940823a2d1e3105140d59c513fd6c7b27430c4350bdce82ce4427c9954ee7"
|
"hash": "ba4f424960acc864d6df32e793cec83c173bfea181f19c6b7b9ce223d0c2ded0"
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,10 @@ sha2 = "0.10"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
# Docker API — matches cm-sandbox's version so the workspace resolves cleanly.
|
||||||
|
# Used by research_container to spawn/stop per-topic team runtimes via the
|
||||||
|
# same socket-proxy the server already talks to.
|
||||||
|
bollard = "0.19"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod mcp_door;
|
|||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
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 swarm;
|
pub mod swarm;
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
const PREFIXES: &[&str] = &["ZEROCLAW_", "OPENAI_", "ANTHROPIC_", "GEMINI_", "GROQ_"];
|
||||||
|
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,
|
||||||
|
) -> 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()))?;
|
||||||
|
|
||||||
|
let 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()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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()]),
|
||||||
|
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}"))?;
|
||||||
|
|
||||||
|
Ok(SpawnedContainer { name, gateway_url })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -604,6 +604,45 @@ pub async fn start_topic(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Spawn the per-topic ZeroClaw team runtime (or reattach if one from a
|
||||||
|
// prior start already exists). Best-effort in this pass — a failure
|
||||||
|
// leaves the topic pointing at the workspace-wide gateway (env), which
|
||||||
|
// still works but skips the isolation. Commit 2 wires the executor to
|
||||||
|
// prefer the topic's URL when populated.
|
||||||
|
let state_root = research_workspace_root().join(id.to_string()).join("state");
|
||||||
|
let repo_path_for_container = repo_context
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| std::path::PathBuf::from(&c.path));
|
||||||
|
if let Some(repo_path) = repo_path_for_container {
|
||||||
|
match crate::research_container::connect() {
|
||||||
|
Ok(docker) => {
|
||||||
|
match crate::research_container::spawn(&docker, id, &repo_path, &state_root).await {
|
||||||
|
Ok(spawned) => {
|
||||||
|
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
|
||||||
|
&state.pool,
|
||||||
|
id,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
Some(&spawned.name),
|
||||||
|
Some(&spawned.gateway_url),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"research::start_topic({id}): persist container coords failed: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("research::start_topic({id}): spawn team container failed: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"research::start_topic({id}): docker connect failed: {e} — skipping team container"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
// Coordinator = first slot whose role_slot mentions "coordinator" (case-
|
// Coordinator = first slot whose role_slot mentions "coordinator" (case-
|
||||||
// insensitive), else the first slot. The coordinator becomes the hub of
|
// insensitive), else the first slot. The coordinator becomes the hub of
|
||||||
// the hub_spoke graph, so it can talk to every other agent per-turn.
|
// the hub_spoke graph, so it can talk to every other agent per-turn.
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ pub struct ResearchTopic {
|
|||||||
/// repo. Written once on the first successful clone; subsequent starts
|
/// repo. Written once on the first successful clone; subsequent starts
|
||||||
/// reuse it. Null until then.
|
/// reuse it. Null until then.
|
||||||
pub repo_workspace_path: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -79,6 +88,32 @@ pub async fn create(pool: &PgPool, input: NewTopic<'_>) -> Result<Uuid, DbError>
|
|||||||
Ok(id)
|
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
|
/// 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.
|
/// successful clone; a re-start reads it back and skips re-cloning.
|
||||||
pub async fn set_repo_workspace_path(
|
pub async fn set_repo_workspace_path(
|
||||||
@@ -106,7 +141,8 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
|
|||||||
ResearchTopic,
|
ResearchTopic,
|
||||||
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
||||||
created_by, created_at, updated_at, published_at, topology_kind,
|
created_by, created_at, updated_at, published_at, topology_kind,
|
||||||
repo_id, repo_workspace_path
|
repo_id, repo_workspace_path,
|
||||||
|
zeroclaw_container_name, zeroclaw_gateway_url
|
||||||
FROM research_topics
|
FROM research_topics
|
||||||
WHERE workspace_id = $1
|
WHERE workspace_id = $1
|
||||||
ORDER BY updated_at DESC",
|
ORDER BY updated_at DESC",
|
||||||
@@ -126,7 +162,8 @@ pub async fn get(
|
|||||||
ResearchTopic,
|
ResearchTopic,
|
||||||
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
"SELECT id, workspace_id, title, description, outcome_kind, status,
|
||||||
created_by, created_at, updated_at, published_at, topology_kind,
|
created_by, created_at, updated_at, published_at, topology_kind,
|
||||||
repo_id, repo_workspace_path
|
repo_id, repo_workspace_path,
|
||||||
|
zeroclaw_container_name, zeroclaw_gateway_url
|
||||||
FROM research_topics
|
FROM research_topics
|
||||||
WHERE id = $1 AND workspace_id = $2",
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Per-topic ZeroClaw container coordinates. When `start_topic` fires, it
|
||||||
|
-- spawns an isolated clawmates-runtime container for the team via the
|
||||||
|
-- socket-proxy the server already talks to, mounts the topic's cloned
|
||||||
|
-- repo into it, and records the container name + reachable gateway URL
|
||||||
|
-- here. The topology_worker looks these up when constructing
|
||||||
|
-- ZeroClawDriveExecutor so each run's turns hit ITS team's daemon, not
|
||||||
|
-- the shared workspace one.
|
||||||
|
--
|
||||||
|
-- Both nullable so a topic can exist before the container spawns and so
|
||||||
|
-- teardown just NULLs them out.
|
||||||
|
|
||||||
|
ALTER TABLE research_topics
|
||||||
|
ADD COLUMN zeroclaw_container_name TEXT,
|
||||||
|
ADD COLUMN zeroclaw_gateway_url TEXT;
|
||||||
Reference in New Issue
Block a user