CI: remove k8s stages, fix the Docker-level pipeline green
Survey + fixes so the pipeline passes at the Docker level (no k8s).
- Remove k8s: drop the `sandbox-k8s` job (kind/Calico/--features k8s-tests) and the
"Helm chart lints" gate step. release.yml was already k8s-clean.
- Rust job:
- `cargo fmt --all` — fix pre-existing formatting drift (fmt --check was failing).
- clippy -D warnings: fix 3 lib warnings (cm-brain sort_by_key→Reverse, cm-api
fleet.rs doc list indentation, node_rules map_or→is_none_or).
- Regenerate the .sqlx offline cache (was missing the cm-runtime run_loop test
query → offline compile failed). DB-backed tests use testcontainers at runtime.
- Set SQLX_OFFLINE=true on the rust + e2e jobs so query! macros compile against
the committed cache deterministically (no DB needed at compile time).
- Frontend job:
- Fix the 1 ESLint error (useAgentTelemetry: no setState-synchronously-in-effect;
tag the slice with agentId + derive null on mismatch).
- Fix 2 stale panel-params tests (`terminal` is a valid app id now; assert the
current APP_IDS + use a genuinely-unknown id for the reject case).
Verified locally: fmt clean, clippy --all-targets -D warnings clean (offline),
frontend lint 0 errors, tsc clean, 86/86 frontend tests pass, build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a36b2c87ac
commit
3554a3aaf2
@@ -16,12 +16,14 @@ jobs:
|
|||||||
run: ./ci/check-no-placeholders.sh
|
run: ./ci/check-no-placeholders.sh
|
||||||
- name: Compose config validates
|
- name: Compose config validates
|
||||||
run: POSTGRES_PASSWORD=ci docker compose -f deploy/compose/docker-compose.yml config -q
|
run: POSTGRES_PASSWORD=ci docker compose -f deploy/compose/docker-compose.yml config -q
|
||||||
- name: Helm chart lints and renders the safety topology
|
|
||||||
run: ./ci/check-helm.sh
|
|
||||||
|
|
||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: gates
|
needs: gates
|
||||||
|
# Compile sqlx query! macros against the committed .sqlx cache (no DB needed);
|
||||||
|
# DB-backed tests spin up their own postgres via testcontainers at runtime.
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -38,24 +40,6 @@ jobs:
|
|||||||
- name: Air-gapped installer verify path
|
- name: Air-gapped installer verify path
|
||||||
run: ./ci/test-install.sh
|
run: ./ci/test-install.sh
|
||||||
|
|
||||||
sandbox-k8s:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: gates
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Create kind cluster
|
|
||||||
uses: helm/kind-action@v1
|
|
||||||
with:
|
|
||||||
cluster_name: clawmates-test
|
|
||||||
- name: K8s sandbox kernel assertions
|
|
||||||
run: cargo test -p cm-sandbox --features k8s-tests --test k8s_security
|
|
||||||
- name: Calico cluster (NetworkPolicy enforcement)
|
|
||||||
run: ./scripts/netpol-cluster.sh up
|
|
||||||
- name: Egress default-deny enforced in the kernel
|
|
||||||
run: cargo test -p cm-sandbox --features k8s-tests --test k8s_security calico
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: gates
|
needs: gates
|
||||||
@@ -83,6 +67,8 @@ jobs:
|
|||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [rust, frontend]
|
needs: [rust, frontend]
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT id FROM workspaces ORDER BY created_at, id LIMIT 1",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": []
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "242775b597148fcd51492982be9438891bc67e85925da8f5f0e4c2729e20cf99"
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "INSERT INTO users (id, workspace_id, email, role, display_name, auth_subject)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (auth_subject) WHERE auth_subject IS NOT NULL\n DO UPDATE SET role = EXCLUDED.role\n RETURNING id, workspace_id",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "workspace_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Uuid",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "3b52f6b5c493c72e79a88d223acafa4f02c582a9293ad2f72782e60136e6c287"
|
|
||||||
}
|
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT tokens_in, tokens_out, credits FROM usage_events\n WHERE workspace_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "tokens_in",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "tokens_out",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "credits",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "47c1cee8591250819d22e87058c6011bebb77eaf93c1cfa2535db42221d8ecf3"
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ use tokio_tungstenite::tungstenite::Message;
|
|||||||
|
|
||||||
mod rtc;
|
mod rtc;
|
||||||
|
|
||||||
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
const B64: base64::engine::general_purpose::GeneralPurpose =
|
||||||
|
base64::engine::general_purpose::STANDARD;
|
||||||
|
|
||||||
/// A live host-shell PTY the gateway opened (keyed by session id).
|
/// A live host-shell PTY the gateway opened (keyed by session id).
|
||||||
struct Pty {
|
struct Pty {
|
||||||
@@ -257,13 +258,17 @@ async fn handle_frame(
|
|||||||
"verify" => {
|
"verify" => {
|
||||||
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||||
let (ok, output) = verify().await;
|
let (ok, output) = verify().await;
|
||||||
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
|
let _ = out.send(
|
||||||
|
json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"sb_check" => {
|
"sb_check" => {
|
||||||
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||||
let (ok, output) = sandbox_check().await;
|
let (ok, output) = sandbox_check().await;
|
||||||
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
|
let _ = out.send(
|
||||||
|
json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Agent-sandbox container ops: drive the REAL DockerDriver so the
|
// Agent-sandbox container ops: drive the REAL DockerDriver so the
|
||||||
@@ -272,7 +277,9 @@ async fn handle_frame(
|
|||||||
op @ ("sb_provision" | "sb_exec" | "sb_destroy" | "sb_health" | "sb_list") => {
|
op @ ("sb_provision" | "sb_exec" | "sb_destroy" | "sb_health" | "sb_list") => {
|
||||||
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||||
let (ok, output) = sb_op(op, &v).await;
|
let (ok, output) = sb_op(op, &v).await;
|
||||||
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
|
let _ = out.send(
|
||||||
|
json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"pty_open" => {
|
"pty_open" => {
|
||||||
@@ -280,7 +287,16 @@ async fn handle_frame(
|
|||||||
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
|
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
|
||||||
let rows = v.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16;
|
let rows = v.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16;
|
||||||
eprintln!("[pty] received pty_open sid={sid}");
|
eprintln!("[pty] received pty_open sid={sid}");
|
||||||
if let Err(e) = open_pty(sid, cols, rows, PtyTarget::from_frame(&v), out.clone(), ptys.clone()).await {
|
if let Err(e) = open_pty(
|
||||||
|
sid,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
PtyTarget::from_frame(&v),
|
||||||
|
out.clone(),
|
||||||
|
ptys.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
eprintln!("[pty] open_pty FAILED sid={sid}: {e}");
|
eprintln!("[pty] open_pty FAILED sid={sid}: {e}");
|
||||||
let _ = out.send(json!({ "t": "pty_out", "sid": sid, "data": B64.encode(format!("\r\n\x1b[31m[clawmates] could not start shell: {e}\x1b[0m\r\n").as_bytes()) }).to_string());
|
let _ = out.send(json!({ "t": "pty_out", "sid": sid, "data": B64.encode(format!("\r\n\x1b[31m[clawmates] could not start shell: {e}\x1b[0m\r\n").as_bytes()) }).to_string());
|
||||||
let _ = out.send(json!({ "t": "pty_exit", "sid": sid, "error": e }).to_string());
|
let _ = out.send(json!({ "t": "pty_exit", "sid": sid, "error": e }).to_string());
|
||||||
@@ -288,7 +304,11 @@ async fn handle_frame(
|
|||||||
}
|
}
|
||||||
"pty_in" => {
|
"pty_in" => {
|
||||||
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
||||||
if let Some(bytes) = v.get("data").and_then(Value::as_str).and_then(|d| B64.decode(d).ok()) {
|
if let Some(bytes) = v
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|d| B64.decode(d).ok())
|
||||||
|
{
|
||||||
if let Some(p) = ptys.lock().await.get_mut(&sid) {
|
if let Some(p) = ptys.lock().await.get_mut(&sid) {
|
||||||
let _ = p.writer.write_all(&bytes);
|
let _ = p.writer.write_all(&bytes);
|
||||||
let _ = p.writer.flush();
|
let _ = p.writer.flush();
|
||||||
@@ -300,7 +320,12 @@ async fn handle_frame(
|
|||||||
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
|
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
|
||||||
let rows = v.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16;
|
let rows = v.get("rows").and_then(Value::as_u64).unwrap_or(24) as u16;
|
||||||
if let Some(p) = ptys.lock().await.get(&sid) {
|
if let Some(p) = ptys.lock().await.get(&sid) {
|
||||||
let _ = p.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
|
let _ = p.master.resize(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"pty_close" => {
|
"pty_close" => {
|
||||||
@@ -314,8 +339,20 @@ async fn handle_frame(
|
|||||||
// as pty_resize/pty_close keyed by the same sid.
|
// as pty_resize/pty_close keyed by the same sid.
|
||||||
"webrtc_offer" => {
|
"webrtc_offer" => {
|
||||||
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
||||||
let sdp = v.get("sdp").and_then(Value::as_str).unwrap_or("").to_owned();
|
let sdp = v
|
||||||
rtc::handle_offer(sid, sdp, PtyTarget::from_frame(&v), out.clone(), ptys.clone(), peers.clone()).await;
|
.get("sdp")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned();
|
||||||
|
rtc::handle_offer(
|
||||||
|
sid,
|
||||||
|
sdp,
|
||||||
|
PtyTarget::from_frame(&v),
|
||||||
|
out.clone(),
|
||||||
|
ptys.clone(),
|
||||||
|
peers.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
"webrtc_ice" => {
|
"webrtc_ice" => {
|
||||||
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
|
||||||
@@ -341,7 +378,12 @@ type PtyParts = (
|
|||||||
/// Spawn `cmd` in a fresh PTY, returning handles for resize/read/write/child.
|
/// Spawn `cmd` in a fresh PTY, returning handles for resize/read/write/child.
|
||||||
fn spawn_pty(cmd: CommandBuilder, cols: u16, rows: u16) -> Result<PtyParts, String> {
|
fn spawn_pty(cmd: CommandBuilder, cols: u16, rows: u16) -> Result<PtyParts, String> {
|
||||||
let pair = native_pty_system()
|
let pair = native_pty_system()
|
||||||
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
|
.openpty(PtySize {
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
|
let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
|
||||||
drop(pair.slave);
|
drop(pair.slave);
|
||||||
@@ -357,9 +399,23 @@ fn spawn_terminal_pty(cols: u16, rows: u16) -> Result<PtyParts, String> {
|
|||||||
|
|
||||||
/// `docker exec -it` a tmux session inside an agent's container — the node-placed
|
/// `docker exec -it` a tmux session inside an agent's container — the node-placed
|
||||||
/// agent terminal, which shares the container's node-local `~/drives`.
|
/// agent terminal, which shares the container's node-local `~/drives`.
|
||||||
fn spawn_container_pty(container: &str, session: &str, cols: u16, rows: u16) -> Result<PtyParts, String> {
|
fn spawn_container_pty(
|
||||||
|
container: &str,
|
||||||
|
session: &str,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<PtyParts, String> {
|
||||||
let mut c = CommandBuilder::new("docker");
|
let mut c = CommandBuilder::new("docker");
|
||||||
for a in ["exec", "-it", container, "tmux", "new-session", "-A", "-s", session] {
|
for a in [
|
||||||
|
"exec",
|
||||||
|
"-it",
|
||||||
|
container,
|
||||||
|
"tmux",
|
||||||
|
"new-session",
|
||||||
|
"-A",
|
||||||
|
"-s",
|
||||||
|
session,
|
||||||
|
] {
|
||||||
c.arg(a);
|
c.arg(a);
|
||||||
}
|
}
|
||||||
spawn_pty(c, cols, rows)
|
spawn_pty(c, cols, rows)
|
||||||
@@ -379,7 +435,11 @@ impl PtyTarget {
|
|||||||
match v.get("container").and_then(Value::as_str) {
|
match v.get("container").and_then(Value::as_str) {
|
||||||
Some(c) if !c.is_empty() => PtyTarget::Container {
|
Some(c) if !c.is_empty() => PtyTarget::Container {
|
||||||
container: c.to_owned(),
|
container: c.to_owned(),
|
||||||
session: v.get("session").and_then(Value::as_str).unwrap_or("main").to_owned(),
|
session: v
|
||||||
|
.get("session")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("main")
|
||||||
|
.to_owned(),
|
||||||
},
|
},
|
||||||
_ => PtyTarget::Host,
|
_ => PtyTarget::Host,
|
||||||
}
|
}
|
||||||
@@ -388,7 +448,9 @@ impl PtyTarget {
|
|||||||
pub(crate) fn spawn(&self, cols: u16, rows: u16) -> Result<PtyParts, String> {
|
pub(crate) fn spawn(&self, cols: u16, rows: u16) -> Result<PtyParts, String> {
|
||||||
match self {
|
match self {
|
||||||
PtyTarget::Host => spawn_terminal_pty(cols, rows),
|
PtyTarget::Host => spawn_terminal_pty(cols, rows),
|
||||||
PtyTarget::Container { container, session } => spawn_container_pty(container, session, cols, rows),
|
PtyTarget::Container { container, session } => {
|
||||||
|
spawn_container_pty(container, session, cols, rows)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,9 +465,19 @@ async fn open_pty(
|
|||||||
ptys: Ptys,
|
ptys: Ptys,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (master, mut reader, writer, child) = target.spawn(cols, rows)?;
|
let (master, mut reader, writer, child) = target.spawn(cols, rows)?;
|
||||||
ptys.lock().await.insert(sid, Pty { master, writer, child });
|
ptys.lock().await.insert(
|
||||||
|
sid,
|
||||||
|
Pty {
|
||||||
|
master,
|
||||||
|
writer,
|
||||||
|
child,
|
||||||
|
},
|
||||||
|
);
|
||||||
let label = match &target {
|
let label = match &target {
|
||||||
PtyTarget::Host => format!("host shell ({})", if has_tmux() { "tmux" } else { "login shell" }),
|
PtyTarget::Host => format!(
|
||||||
|
"host shell ({})",
|
||||||
|
if has_tmux() { "tmux" } else { "login shell" }
|
||||||
|
),
|
||||||
PtyTarget::Container { container, .. } => format!("container {container}"),
|
PtyTarget::Container { container, .. } => format!("container {container}"),
|
||||||
};
|
};
|
||||||
eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}");
|
eprintln!("[pty] open sid={sid} cols={cols} rows={rows} target={label}");
|
||||||
@@ -413,7 +485,9 @@ async fn open_pty(
|
|||||||
// the relay works and the shell is the problem (vs. a dead relay → nothing).
|
// the relay works and the shell is the problem (vs. a dead relay → nothing).
|
||||||
let host = System::host_name().unwrap_or_else(|| "node".into());
|
let host = System::host_name().unwrap_or_else(|| "node".into());
|
||||||
let banner = format!("\r\n\x1b[2m[clawmates] {label} on {host} — starting…\x1b[0m\r\n");
|
let banner = format!("\r\n\x1b[2m[clawmates] {label} on {host} — starting…\x1b[0m\r\n");
|
||||||
let _ = out.send(json!({ "t": "pty_out", "sid": sid, "data": B64.encode(banner.as_bytes()) }).to_string());
|
let _ = out.send(
|
||||||
|
json!({ "t": "pty_out", "sid": sid, "data": B64.encode(banner.as_bytes()) }).to_string(),
|
||||||
|
);
|
||||||
// Blocking PTY reads on a thread → base64 pty_out frames into the out channel.
|
// Blocking PTY reads on a thread → base64 pty_out frames into the out channel.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut buf = [0u8; 8192];
|
let mut buf = [0u8; 8192];
|
||||||
@@ -434,7 +508,10 @@ async fn open_pty(
|
|||||||
}
|
}
|
||||||
total += n;
|
total += n;
|
||||||
let data = B64.encode(&buf[..n]);
|
let data = B64.encode(&buf[..n]);
|
||||||
if out.send(json!({ "t": "pty_out", "sid": sid, "data": data }).to_string()).is_err() {
|
if out
|
||||||
|
.send(json!({ "t": "pty_out", "sid": sid, "data": data }).to_string())
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
eprintln!("[pty] sid={sid} out channel closed");
|
eprintln!("[pty] sid={sid} out channel closed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -479,10 +556,26 @@ async fn sandbox_check() -> (bool, String) {
|
|||||||
.await;
|
.await;
|
||||||
let out = tokio::process::Command::new("docker")
|
let out = tokio::process::Command::new("docker")
|
||||||
.args([
|
.args([
|
||||||
"run", "--rm", "--cap-drop=ALL", "--security-opt", "no-new-privileges",
|
"run",
|
||||||
"--network", "none", "--read-only", "--tmpfs", "/tmp", "--user", "10001:10001",
|
"--rm",
|
||||||
"--memory", "256m", "--pids-limit", "128", "alpine:latest",
|
"--cap-drop=ALL",
|
||||||
"sh", "-c", "echo sandbox-ok; id; uname -sm",
|
"--security-opt",
|
||||||
|
"no-new-privileges",
|
||||||
|
"--network",
|
||||||
|
"none",
|
||||||
|
"--read-only",
|
||||||
|
"--tmpfs",
|
||||||
|
"/tmp",
|
||||||
|
"--user",
|
||||||
|
"10001:10001",
|
||||||
|
"--memory",
|
||||||
|
"256m",
|
||||||
|
"--pids-limit",
|
||||||
|
"128",
|
||||||
|
"alpine:latest",
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"echo sandbox-ok; id; uname -sm",
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
@@ -499,8 +592,16 @@ async fn sandbox_check() -> (bool, String) {
|
|||||||
|
|
||||||
fn handle_of(v: &Value) -> SandboxHandle {
|
fn handle_of(v: &Value) -> SandboxHandle {
|
||||||
SandboxHandle {
|
SandboxHandle {
|
||||||
id: v.get("cid").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
id: v
|
||||||
name: v.get("cname").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
.get("cid")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
|
name: v
|
||||||
|
.get("cname")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,12 +614,18 @@ async fn sb_op(op: &str, v: &Value) -> (bool, String) {
|
|||||||
};
|
};
|
||||||
match op {
|
match op {
|
||||||
"sb_provision" => {
|
"sb_provision" => {
|
||||||
let spec: SandboxSpec = match v.get("spec").and_then(|s| serde_json::from_value(s.clone()).ok()) {
|
let spec: SandboxSpec = match v
|
||||||
|
.get("spec")
|
||||||
|
.and_then(|s| serde_json::from_value(s.clone()).ok())
|
||||||
|
{
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return (false, "invalid spec".to_owned()),
|
None => return (false, "invalid spec".to_owned()),
|
||||||
};
|
};
|
||||||
// Pull the agent image if the node doesn't have it yet (best effort).
|
// Pull the agent image if the node doesn't have it yet (best effort).
|
||||||
let _ = tokio::process::Command::new("docker").args(["pull", "-q", &spec.image]).output().await;
|
let _ = tokio::process::Command::new("docker")
|
||||||
|
.args(["pull", "-q", &spec.image])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
match driver.provision(&spec).await {
|
match driver.provision(&spec).await {
|
||||||
Ok(h) => (true, json!({ "id": h.id, "name": h.name }).to_string()),
|
Ok(h) => (true, json!({ "id": h.id, "name": h.name }).to_string()),
|
||||||
Err(e) => (false, format!("provision: {e}")),
|
Err(e) => (false, format!("provision: {e}")),
|
||||||
@@ -529,13 +636,18 @@ async fn sb_op(op: &str, v: &Value) -> (bool, String) {
|
|||||||
let cmd: Vec<String> = v
|
let cmd: Vec<String> = v
|
||||||
.get("cmd")
|
.get("cmd")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect())
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.as_str().map(str::to_owned))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
|
let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
|
||||||
match driver.exec(&handle, &refs).await {
|
match driver.exec(&handle, &refs).await {
|
||||||
Ok(r) => (
|
Ok(r) => (
|
||||||
r.exit_code == 0,
|
r.exit_code == 0,
|
||||||
json!({ "exit": r.exit_code, "stdout": r.stdout, "stderr": r.stderr }).to_string(),
|
json!({ "exit": r.exit_code, "stdout": r.stdout, "stderr": r.stderr })
|
||||||
|
.to_string(),
|
||||||
),
|
),
|
||||||
Err(e) => (false, format!("exec: {e}")),
|
Err(e) => (false, format!("exec: {e}")),
|
||||||
}
|
}
|
||||||
@@ -553,7 +665,11 @@ async fn sb_op(op: &str, v: &Value) -> (bool, String) {
|
|||||||
match driver.list_managed(kind).await {
|
match driver.list_managed(kind).await {
|
||||||
Ok(list) => (
|
Ok(list) => (
|
||||||
true,
|
true,
|
||||||
json!(list.iter().map(|m| json!({ "id": m.id, "created_unix": m.created_unix })).collect::<Vec<_>>()).to_string(),
|
json!(list
|
||||||
|
.iter()
|
||||||
|
.map(|m| json!({ "id": m.id, "created_unix": m.created_unix }))
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
.to_string(),
|
||||||
),
|
),
|
||||||
Err(e) => (false, format!("list: {e}")),
|
Err(e) => (false, format!("list: {e}")),
|
||||||
}
|
}
|
||||||
@@ -657,7 +773,11 @@ fn selftest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
eprintln!("\n--- selftest: tmux={}, {} bytes of output ---", has_tmux(), total);
|
eprintln!(
|
||||||
|
"\n--- selftest: tmux={}, {} bytes of output ---",
|
||||||
|
has_tmux(),
|
||||||
|
total
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is tmux on PATH?
|
/// Is tmux on PATH?
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ async fn build_peer(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let target = Arc::new(target);
|
let target = Arc::new(target);
|
||||||
let mut m = MediaEngine::default();
|
let mut m = MediaEngine::default();
|
||||||
let registry = register_default_interceptors(Registry::new(), &mut m).map_err(|e| e.to_string())?;
|
let registry =
|
||||||
|
register_default_interceptors(Registry::new(), &mut m).map_err(|e| e.to_string())?;
|
||||||
let api = APIBuilder::new()
|
let api = APIBuilder::new()
|
||||||
.with_media_engine(m)
|
.with_media_engine(m)
|
||||||
.with_interceptor_registry(registry)
|
.with_interceptor_registry(registry)
|
||||||
@@ -74,7 +75,11 @@ async fn build_peer(
|
|||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pc = Arc::new(api.new_peer_connection(config).await.map_err(|e| e.to_string())?);
|
let pc = Arc::new(
|
||||||
|
api.new_peer_connection(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
|
||||||
// Trickle our local ICE candidates back to the browser over the control channel.
|
// Trickle our local ICE candidates back to the browser over the control channel.
|
||||||
let out_ice = out.clone();
|
let out_ice = out.clone();
|
||||||
@@ -142,7 +147,9 @@ async fn build_peer(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let answer = pc.create_answer(None).await.map_err(|e| e.to_string())?;
|
let answer = pc.create_answer(None).await.map_err(|e| e.to_string())?;
|
||||||
pc.set_local_description(answer.clone()).await.map_err(|e| e.to_string())?;
|
pc.set_local_description(answer.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let _ = out.send(json!({ "t": "webrtc_answer", "sid": sid, "sdp": answer.sdp }).to_string());
|
let _ = out.send(json!({ "t": "webrtc_answer", "sid": sid, "sdp": answer.sdp }).to_string());
|
||||||
|
|
||||||
peers.lock().await.insert(sid, pc);
|
peers.lock().await.insert(sid, pc);
|
||||||
@@ -161,7 +168,14 @@ async fn bridge_pty(sid: u64, dc: Arc<RTCDataChannel>, ptys: Ptys, target: Arc<P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ptys.lock().await.insert(sid, Pty { master, writer, child });
|
ptys.lock().await.insert(
|
||||||
|
sid,
|
||||||
|
Pty {
|
||||||
|
master,
|
||||||
|
writer,
|
||||||
|
child,
|
||||||
|
},
|
||||||
|
);
|
||||||
eprintln!("[rtc] sid={sid} DataChannel open → PTY bridged (direct)");
|
eprintln!("[rtc] sid={sid} DataChannel open → PTY bridged (direct)");
|
||||||
|
|
||||||
// PTY output → DataChannel. Blocking reads on a thread feed an async sender.
|
// PTY output → DataChannel. Blocking reads on a thread feed an async sender.
|
||||||
|
|||||||
@@ -266,7 +266,11 @@ async fn run() -> Result<(), String> {
|
|||||||
// Durable topology run jobs: claim queued runs, drive + checkpoint per step,
|
// Durable topology run jobs: claim queued runs, drive + checkpoint per step,
|
||||||
// resume stale ones after a crash. Long-horizon topologies run here, not in
|
// resume stale ones after a crash. Long-horizon topologies run here, not in
|
||||||
// the HTTP request.
|
// the HTTP request.
|
||||||
cm_api::topology_worker::spawn(pool.clone(), runtime.clone(), std::time::Duration::from_secs(3));
|
cm_api::topology_worker::spawn(
|
||||||
|
pool.clone(),
|
||||||
|
runtime.clone(),
|
||||||
|
std::time::Duration::from_secs(3),
|
||||||
|
);
|
||||||
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
|
// Outbound-email delivery: drains the §15-gated `outbox` over SMTP. Inert
|
||||||
// until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist.
|
// until CLAWMATES_SMTP_* is set, so it ships safely before credentials exist.
|
||||||
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
|
cm_runtime::spawn_drainer(pool.clone(), std::time::Duration::from_secs(10));
|
||||||
|
|||||||
+75
-23
@@ -21,7 +21,8 @@ use serde_json::{json, Value};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||||
|
|
||||||
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
const B64: base64::engine::general_purpose::GeneralPurpose =
|
||||||
|
base64::engine::general_purpose::STANDARD;
|
||||||
|
|
||||||
/// Monotonic per-connection id. A reconnecting daemon gets a fresh epoch so a
|
/// Monotonic per-connection id. A reconnecting daemon gets a fresh epoch so a
|
||||||
/// stale channel's teardown can't clobber the newer connection's online status.
|
/// stale channel's teardown can't clobber the newer connection's online status.
|
||||||
@@ -97,15 +98,19 @@ impl NodeHub {
|
|||||||
/// output. The gateway never sends arbitrary shell — only typed ops the
|
/// output. The gateway never sends arbitrary shell — only typed ops the
|
||||||
/// daemon vets and runs itself (verify today; container ops later).
|
/// daemon vets and runs itself (verify today; container ops later).
|
||||||
pub async fn verify(&self, id: NodeId) -> Result<ExecOutput, String> {
|
pub async fn verify(&self, id: NodeId) -> Result<ExecOutput, String> {
|
||||||
self.request(id, |req_id| json!({ "t": "verify", "id": req_id }).to_string())
|
self.request(id, |req_id| {
|
||||||
.await
|
json!({ "t": "verify", "id": req_id }).to_string()
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provision + run + tear down a fully hardened throwaway container on the
|
/// Provision + run + tear down a fully hardened throwaway container on the
|
||||||
/// node (readiness check that it can host agent workloads).
|
/// node (readiness check that it can host agent workloads).
|
||||||
pub async fn sandbox_check(&self, id: NodeId) -> Result<ExecOutput, String> {
|
pub async fn sandbox_check(&self, id: NodeId) -> Result<ExecOutput, String> {
|
||||||
self.request(id, |req_id| json!({ "t": "sb_check", "id": req_id }).to_string())
|
self.request(id, |req_id| {
|
||||||
.await
|
json!({ "t": "sb_check", "id": req_id }).to_string()
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync liveness check (no await) — used by placement.
|
/// Sync liveness check (no await) — used by placement.
|
||||||
@@ -147,8 +152,8 @@ impl NodeHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allocate a terminal session: a sid + a byte stream (WS-relay PTY output)
|
/// Allocate a terminal session: a sid, a byte stream (WS-relay PTY output),
|
||||||
/// + a text stream (WebRTC answer/ICE). The PTY is NOT opened yet — the
|
/// and a text stream (WebRTC answer/ICE). The PTY is NOT opened yet — the
|
||||||
/// browser picks the transport (WebRTC direct, or `open_pty` fallback).
|
/// browser picks the transport (WebRTC direct, or `open_pty` fallback).
|
||||||
pub async fn open_session(
|
pub async fn open_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -250,9 +255,9 @@ impl NodeHub {
|
|||||||
|
|
||||||
pub async fn terminal_resize(&self, id: NodeId, sid: u64, cols: u16, rows: u16) {
|
pub async fn terminal_resize(&self, id: NodeId, sid: u64, cols: u16, rows: u16) {
|
||||||
if let Some(conn) = self.get(id).await {
|
if let Some(conn) = self.get(id).await {
|
||||||
let _ = conn
|
let _ = conn.tx.send(
|
||||||
.tx
|
json!({ "t": "pty_resize", "sid": sid, "cols": cols, "rows": rows }).to_string(),
|
||||||
.send(json!({ "t": "pty_resize", "sid": sid, "cols": cols, "rows": rows }).to_string());
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +265,12 @@ impl NodeHub {
|
|||||||
if let Some(conn) = self.get(id).await {
|
if let Some(conn) = self.get(id).await {
|
||||||
conn.pty_sinks.lock().await.remove(&sid);
|
conn.pty_sinks.lock().await.remove(&sid);
|
||||||
conn.signal_sinks.lock().await.remove(&sid);
|
conn.signal_sinks.lock().await.remove(&sid);
|
||||||
let _ = conn.tx.send(json!({ "t": "pty_close", "sid": sid }).to_string());
|
let _ = conn
|
||||||
let _ = conn.tx.send(json!({ "t": "webrtc_close", "sid": sid }).to_string());
|
.tx
|
||||||
|
.send(json!({ "t": "pty_close", "sid": sid }).to_string());
|
||||||
|
let _ = conn
|
||||||
|
.tx
|
||||||
|
.send(json!({ "t": "webrtc_close", "sid": sid }).to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,32 +515,71 @@ impl SandboxDriver for RemoteDriver {
|
|||||||
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> {
|
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> {
|
||||||
let v = self.call("sb_provision", json!({ "spec": spec })).await?;
|
let v = self.call("sb_provision", json!({ "spec": spec })).await?;
|
||||||
Ok(SandboxHandle {
|
Ok(SandboxHandle {
|
||||||
id: v.get("id").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
id: v
|
||||||
name: v.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
|
name: v
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError> {
|
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError> {
|
||||||
let v = self
|
let v = self
|
||||||
.call("sb_exec", json!({ "cid": handle.id, "cname": handle.name, "cmd": cmd }))
|
.call(
|
||||||
|
"sb_exec",
|
||||||
|
json!({ "cid": handle.id, "cname": handle.name, "cmd": cmd }),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(ExecResult {
|
Ok(ExecResult {
|
||||||
exit_code: v.get("exit").and_then(Value::as_i64).unwrap_or(-1),
|
exit_code: v.get("exit").and_then(Value::as_i64).unwrap_or(-1),
|
||||||
stdout: v.get("stdout").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
stdout: v
|
||||||
stderr: v.get("stderr").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
.get("stdout")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
|
stderr: v
|
||||||
|
.get("stderr")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn attach_pty(&self, _h: &SandboxHandle, _cmd: &[&str], _c: u16, _r: u16, _e: &[String]) -> Result<PtySession, SandboxError> {
|
async fn attach_pty(
|
||||||
Err(SandboxError::Engine("interactive PTY is not supported on remote nodes yet".into()))
|
&self,
|
||||||
|
_h: &SandboxHandle,
|
||||||
|
_cmd: &[&str],
|
||||||
|
_c: u16,
|
||||||
|
_r: u16,
|
||||||
|
_e: &[String],
|
||||||
|
) -> Result<PtySession, SandboxError> {
|
||||||
|
Err(SandboxError::Engine(
|
||||||
|
"interactive PTY is not supported on remote nodes yet".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
async fn resize_pty(&self, _exec_id: &str, _c: u16, _r: u16) -> Result<(), SandboxError> {
|
async fn resize_pty(&self, _exec_id: &str, _c: u16, _r: u16) -> Result<(), SandboxError> {
|
||||||
Err(SandboxError::Engine("pty resize is not supported on remote nodes".into()))
|
Err(SandboxError::Engine(
|
||||||
|
"pty resize is not supported on remote nodes".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
|
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
|
||||||
self.call("sb_destroy", json!({ "cid": handle.id, "cname": handle.name })).await?;
|
self.call(
|
||||||
|
"sb_destroy",
|
||||||
|
json!({ "cid": handle.id, "cname": handle.name }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn health(&self, handle: &SandboxHandle) -> Result<bool, SandboxError> {
|
async fn health(&self, handle: &SandboxHandle) -> Result<bool, SandboxError> {
|
||||||
let v = self.call("sb_health", json!({ "cid": handle.id, "cname": handle.name })).await?;
|
let v = self
|
||||||
|
.call(
|
||||||
|
"sb_health",
|
||||||
|
json!({ "cid": handle.id, "cname": handle.name }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(v.get("alive").and_then(Value::as_bool).unwrap_or(false))
|
Ok(v.get("alive").and_then(Value::as_bool).unwrap_or(false))
|
||||||
}
|
}
|
||||||
async fn list_managed(&self, kind: &str) -> Result<Vec<ManagedSandbox>, SandboxError> {
|
async fn list_managed(&self, kind: &str) -> Result<Vec<ManagedSandbox>, SandboxError> {
|
||||||
@@ -540,7 +588,11 @@ impl SandboxDriver for RemoteDriver {
|
|||||||
.map(|a| {
|
.map(|a| {
|
||||||
a.iter()
|
a.iter()
|
||||||
.map(|m| ManagedSandbox {
|
.map(|m| ManagedSandbox {
|
||||||
id: m.get("id").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
id: m
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
created_unix: m.get("created_unix").and_then(Value::as_i64).unwrap_or(0),
|
created_unix: m.get("created_unix").and_then(Value::as_i64).unwrap_or(0),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
+44
-16
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
pub mod beszel;
|
pub mod beszel;
|
||||||
pub mod cleanup_sweeper;
|
pub mod cleanup_sweeper;
|
||||||
pub mod node_rules;
|
|
||||||
mod error;
|
mod error;
|
||||||
mod extract;
|
mod extract;
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
|
pub mod node_rules;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
mod recursive_exec;
|
mod recursive_exec;
|
||||||
mod routes;
|
mod routes;
|
||||||
@@ -122,10 +122,22 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/nodes/live", get(routes::nodes::live))
|
.route("/api/nodes/live", get(routes::nodes::live))
|
||||||
.route("/api/nodes/agent", get(routes::nodes::agent_ws))
|
.route("/api/nodes/agent", get(routes::nodes::agent_ws))
|
||||||
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
|
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
|
||||||
.route("/api/nodes/{id}/sandbox-check", post(routes::nodes::sandbox_check))
|
.route(
|
||||||
.route("/api/nodes/{id}/terminal/ticket", post(routes::nodes::terminal_ticket))
|
"/api/nodes/{id}/sandbox-check",
|
||||||
.route("/api/nodes/{id}/terminal/ws", get(routes::nodes::terminal_ws))
|
post(routes::nodes::sandbox_check),
|
||||||
.route("/api/nodes/{id}/metrics", get(routes::beszel::node_metrics_get))
|
)
|
||||||
|
.route(
|
||||||
|
"/api/nodes/{id}/terminal/ticket",
|
||||||
|
post(routes::nodes::terminal_ticket),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/nodes/{id}/terminal/ws",
|
||||||
|
get(routes::nodes::terminal_ws),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/nodes/{id}/metrics",
|
||||||
|
get(routes::beszel::node_metrics_get),
|
||||||
|
)
|
||||||
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
||||||
.route(
|
.route(
|
||||||
"/api/fleet/beszel",
|
"/api/fleet/beszel",
|
||||||
@@ -147,7 +159,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.post(routes::tailscale::connect)
|
.post(routes::tailscale::connect)
|
||||||
.delete(routes::tailscale::disconnect),
|
.delete(routes::tailscale::disconnect),
|
||||||
)
|
)
|
||||||
.route("/api/fleet/tailscale/devices", get(routes::tailscale::devices))
|
.route(
|
||||||
|
"/api/fleet/tailscale/devices",
|
||||||
|
get(routes::tailscale::devices),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/fleet/placement",
|
"/api/fleet/placement",
|
||||||
get(routes::tailscale::placement_get).put(routes::tailscale::placement_set),
|
get(routes::tailscale::placement_get).put(routes::tailscale::placement_set),
|
||||||
@@ -172,7 +187,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/claws/{id}/compartments",
|
"/api/claws/{id}/compartments",
|
||||||
get(routes::claws::compartments),
|
get(routes::claws::compartments),
|
||||||
)
|
)
|
||||||
.route("/api/claws/{id}/brain", get(routes::claws::brain).patch(routes::claws::edit_brain))
|
.route(
|
||||||
|
"/api/claws/{id}/brain",
|
||||||
|
get(routes::claws::brain).patch(routes::claws::edit_brain),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/claws/{id}/brain/push",
|
"/api/claws/{id}/brain/push",
|
||||||
axum::routing::post(routes::claws::push_brain),
|
axum::routing::post(routes::claws::push_brain),
|
||||||
@@ -182,7 +200,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
axum::routing::post(routes::claws::pull_brain),
|
axum::routing::post(routes::claws::pull_brain),
|
||||||
)
|
)
|
||||||
.route("/api/brainhub/search", get(routes::claws::brainhub_search))
|
.route("/api/brainhub/search", get(routes::claws::brainhub_search))
|
||||||
.route("/api/brainhub/preview", get(routes::claws::brainhub_preview))
|
.route(
|
||||||
|
"/api/brainhub/preview",
|
||||||
|
get(routes::claws::brainhub_preview),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/brainhub/enhance",
|
"/api/brainhub/enhance",
|
||||||
axum::routing::post(routes::claws::enhance_brain),
|
axum::routing::post(routes::claws::enhance_brain),
|
||||||
@@ -207,10 +228,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/claws/{id}/brain/apply",
|
"/api/claws/{id}/brain/apply",
|
||||||
axum::routing::post(routes::claws::apply_brain),
|
axum::routing::post(routes::claws::apply_brain),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/api/terminal/{id}/ticket", post(routes::terminal::ticket))
|
||||||
"/api/terminal/{id}/ticket",
|
|
||||||
post(routes::terminal::ticket),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/terminal/{id}/tabs",
|
"/api/terminal/{id}/tabs",
|
||||||
get(routes::terminal::get_tabs).put(routes::terminal::save_tabs),
|
get(routes::terminal::get_tabs).put(routes::terminal::save_tabs),
|
||||||
@@ -290,10 +308,15 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/teams",
|
"/api/teams",
|
||||||
get(routes::teams::list_teams).post(routes::teams::create_team),
|
get(routes::teams::list_teams).post(routes::teams::create_team),
|
||||||
)
|
)
|
||||||
.route("/api/teams/from-claws", post(routes::teams::create_team_from_claws))
|
.route(
|
||||||
|
"/api/teams/from-claws",
|
||||||
|
post(routes::teams::create_team_from_claws),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/teams/{id}",
|
"/api/teams/{id}",
|
||||||
get(routes::teams::get_team).patch(routes::teams::patch_team).delete(routes::teams::delete_team),
|
get(routes::teams::get_team)
|
||||||
|
.patch(routes::teams::patch_team)
|
||||||
|
.delete(routes::teams::delete_team),
|
||||||
)
|
)
|
||||||
.route("/api/teams/{id}/run", post(routes::teams::run_team))
|
.route("/api/teams/{id}/run", post(routes::teams::run_team))
|
||||||
.route(
|
.route(
|
||||||
@@ -302,7 +325,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/companies/{id}",
|
"/api/companies/{id}",
|
||||||
get(routes::companies::get_company).patch(routes::companies::patch_company).delete(routes::companies::delete_company),
|
get(routes::companies::get_company)
|
||||||
|
.patch(routes::companies::patch_company)
|
||||||
|
.delete(routes::companies::delete_company),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/companies/{id}/run",
|
"/api/companies/{id}/run",
|
||||||
@@ -312,7 +337,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/orgs",
|
"/api/orgs",
|
||||||
get(routes::orgs::list_orgs).post(routes::orgs::create_org),
|
get(routes::orgs::list_orgs).post(routes::orgs::create_org),
|
||||||
)
|
)
|
||||||
.route("/api/orgs/{id}", get(routes::orgs::get_org).delete(routes::orgs::delete_org))
|
.route(
|
||||||
|
"/api/orgs/{id}",
|
||||||
|
get(routes::orgs::get_org).delete(routes::orgs::delete_org),
|
||||||
|
)
|
||||||
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
||||||
.route("/api/structure/stats", get(routes::structure::stats))
|
.route("/api/structure/stats", get(routes::structure::stats))
|
||||||
.route("/api/structure/{level}/{id}", get(routes::structure::node))
|
.route("/api/structure/{level}/{id}", get(routes::structure::node))
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ pub fn spawn_evaluator(pool: PgPool, interval: Duration) {
|
|||||||
for rule in &rules {
|
for rule in &rules {
|
||||||
for node in evals.iter().filter(|e| {
|
for node in evals.iter().filter(|e| {
|
||||||
e.workspace_id == rule.workspace_id
|
e.workspace_id == rule.workspace_id
|
||||||
&& rule.node_id.map_or(true, |n| n == e.node_id)
|
&& rule.node_id.is_none_or(|n| n == e.node_id)
|
||||||
}) {
|
}) {
|
||||||
let key = (rule.id, node.node_id);
|
let key = (rule.id, node.node_id);
|
||||||
let held = node
|
let held = node
|
||||||
@@ -76,10 +76,11 @@ pub fn spawn_evaluator(pool: PgPool, interval: Duration) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let since = *true_since.entry(key).or_insert(now);
|
let since = *true_since.entry(key).or_insert(now);
|
||||||
let sustained =
|
let sustained = now.duration_since(since)
|
||||||
now.duration_since(since) >= Duration::from_secs(rule.for_seconds.max(0) as u64);
|
>= Duration::from_secs(rule.for_seconds.max(0) as u64);
|
||||||
let cooled = last_fired.get(&key).is_none_or(|&t| {
|
let cooled = last_fired.get(&key).is_none_or(|&t| {
|
||||||
now.duration_since(t) >= Duration::from_secs(rule.for_seconds.max(30) as u64)
|
now.duration_since(t)
|
||||||
|
>= Duration::from_secs(rule.for_seconds.max(30) as u64)
|
||||||
});
|
});
|
||||||
if sustained && cooled {
|
if sustained && cooled {
|
||||||
fire(&pool, &rule.action, node).await;
|
fire(&pool, &rule.action, node).await;
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ async fn plan_of(state: &AppState, workspace_id: WorkspaceId) -> Result<String,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reject creating another agent if the workspace is at its plan cap.
|
/// Reject creating another agent if the workspace is at its plan cap.
|
||||||
pub async fn enforce_new_agent(state: &AppState, workspace_id: WorkspaceId) -> Result<(), ApiError> {
|
pub async fn enforce_new_agent(
|
||||||
|
state: &AppState,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
let plan = plan_of(state, workspace_id).await?;
|
let plan = plan_of(state, workspace_id).await?;
|
||||||
let quota = plan_quota(&plan);
|
let quota = plan_quota(&plan);
|
||||||
let used = cm_db::repo::agents::count_active(&state.pool, workspace_id).await?;
|
let used = cm_db::repo::agents::count_active(&state.pool, workspace_id).await?;
|
||||||
@@ -65,7 +68,8 @@ pub async fn enforce_new_container(
|
|||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let plan = plan_of(state, workspace_id).await?;
|
let plan = plan_of(state, workspace_id).await?;
|
||||||
let quota = plan_quota(&plan);
|
let quota = plan_quota(&plan);
|
||||||
let used = cm_db::repo::agent_containers::count_for_workspace(&state.pool, workspace_id).await?;
|
let used =
|
||||||
|
cm_db::repo::agent_containers::count_for_workspace(&state.pool, workspace_id).await?;
|
||||||
if used >= quota.max_live_containers {
|
if used >= quota.max_live_containers {
|
||||||
return Err(ApiError::Quota(format!(
|
return Err(ApiError::Quota(format!(
|
||||||
"live container limit reached ({} on the {plan} plan) — close a terminal/agent or upgrade",
|
"live container limit reached ({} on the {plan} plan) — close a terminal/agent or upgrade",
|
||||||
|
|||||||
@@ -34,12 +34,23 @@ pub async fn connect(
|
|||||||
json!({ "ok": false, "error": "hubUrl, username and password are required" }),
|
json!({ "ok": false, "error": "hubUrl, username and password are required" }),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let conn = BeszelConn { hub_url: hub.clone(), username: username.clone(), password: req.password.clone() };
|
let conn = BeszelConn {
|
||||||
|
hub_url: hub.clone(),
|
||||||
|
username: username.clone(),
|
||||||
|
password: req.password.clone(),
|
||||||
|
};
|
||||||
// Verify the credentials authenticate before persisting.
|
// Verify the credentials authenticate before persisting.
|
||||||
if let Err(e) = beszel::authenticate(&reqwest::Client::new(), &conn).await {
|
if let Err(e) = beszel::authenticate(&reqwest::Client::new(), &conn).await {
|
||||||
return Ok(Json(json!({ "ok": false, "error": e })));
|
return Ok(Json(json!({ "ok": false, "error": e })));
|
||||||
}
|
}
|
||||||
fleet_beszel::set(&state.pool, user.workspace_id, &hub, &username, &req.password).await?;
|
fleet_beszel::set(
|
||||||
|
&state.pool,
|
||||||
|
user.workspace_id,
|
||||||
|
&hub,
|
||||||
|
&username,
|
||||||
|
&req.password,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(Json(json!({ "ok": true, "hubUrl": hub })))
|
Ok(Json(json!({ "ok": true, "hubUrl": hub })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +60,9 @@ pub async fn status(
|
|||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let conn = fleet_beszel::get(&state.pool, user.workspace_id).await?;
|
let conn = fleet_beszel::get(&state.pool, user.workspace_id).await?;
|
||||||
Ok(Json(json!({ "connected": conn.is_some(), "hubUrl": conn.map(|c| c.hub_url) })))
|
Ok(Json(
|
||||||
|
json!({ "connected": conn.is_some(), "hubUrl": conn.map(|c| c.hub_url) }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/fleet/beszel` — disconnect.
|
/// `DELETE /api/fleet/beszel` — disconnect.
|
||||||
@@ -74,9 +87,10 @@ pub async fn node_metrics_get(
|
|||||||
.ok_or(ApiError::NotFound)?;
|
.ok_or(ApiError::NotFound)?;
|
||||||
let latest = node_metrics::latest(&state.pool, node_id).await?;
|
let latest = node_metrics::latest(&state.pool, node_id).await?;
|
||||||
let mut history = json!([]);
|
let mut history = json!([]);
|
||||||
if let (Some(latest_v), Some(conn)) =
|
if let (Some(latest_v), Some(conn)) = (
|
||||||
(&latest, fleet_beszel::get(&state.pool, user.workspace_id).await?)
|
&latest,
|
||||||
{
|
fleet_beszel::get(&state.pool, user.workspace_id).await?,
|
||||||
|
) {
|
||||||
if let Some(sysid) = latest_v.get("beszelSystemId").and_then(Value::as_str) {
|
if let Some(sysid) = latest_v.get("beszelSystemId").and_then(Value::as_str) {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
if let Ok(token) = beszel::authenticate(&client, &conn).await {
|
if let Ok(token) = beszel::authenticate(&client, &conn).await {
|
||||||
@@ -91,7 +105,9 @@ pub async fn node_metrics_get(
|
|||||||
|
|
||||||
// ── Fleet automation rules ──────────────────────────────────────────────────
|
// ── Fleet automation rules ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const METRICS: [&str; 6] = ["cpu_pct", "mem_pct", "disk_pct", "gpu_pct", "temp_max", "load1"];
|
const METRICS: [&str; 6] = [
|
||||||
|
"cpu_pct", "mem_pct", "disk_pct", "gpu_pct", "temp_max", "load1",
|
||||||
|
];
|
||||||
const OPS: [&str; 4] = [">", "<", ">=", "<="];
|
const OPS: [&str; 4] = [">", "<", ">=", "<="];
|
||||||
|
|
||||||
fn rule_json(r: &node_rules::NodeRule) -> Value {
|
fn rule_json(r: &node_rules::NodeRule) -> Value {
|
||||||
@@ -115,7 +131,9 @@ pub async fn rules_list(
|
|||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let rules = node_rules::list(&state.pool, user.workspace_id).await?;
|
let rules = node_rules::list(&state.pool, user.workspace_id).await?;
|
||||||
Ok(Json(json!({ "rules": rules.iter().map(rule_json).collect::<Vec<_>>() })))
|
Ok(Json(
|
||||||
|
json!({ "rules": rules.iter().map(rule_json).collect::<Vec<_>>() }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -138,7 +156,9 @@ pub async fn rules_create(
|
|||||||
Json(req): Json<RuleReq>,
|
Json(req): Json<RuleReq>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
if !METRICS.contains(&req.metric.as_str()) || !OPS.contains(&req.op.as_str()) {
|
if !METRICS.contains(&req.metric.as_str()) || !OPS.contains(&req.op.as_str()) {
|
||||||
return Ok(Json(json!({ "ok": false, "error": "invalid metric or operator" })));
|
return Ok(Json(
|
||||||
|
json!({ "ok": false, "error": "invalid metric or operator" }),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let node_id = match req.node_id.as_deref() {
|
let node_id = match req.node_id.as_deref() {
|
||||||
Some(s) if !s.is_empty() && s != "all" => {
|
Some(s) if !s.is_empty() && s != "all" => {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use axum::extract::{Path, Query, State};
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use std::convert::Infallible;
|
|
||||||
use cm_db::repo::audit::Actor;
|
use cm_db::repo::audit::Actor;
|
||||||
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
|
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
use crate::runtime_provision::provider_alias_for;
|
use crate::runtime_provision::provider_alias_for;
|
||||||
use crate::{ApiError, AppState, Authed};
|
use crate::{ApiError, AppState, Authed};
|
||||||
@@ -241,7 +241,14 @@ pub async fn edit_brain(
|
|||||||
// system_prompt stays authoritative in Postgres.
|
// system_prompt stays authoritative in Postgres.
|
||||||
if let Some(sp) = req.system_prompt.as_ref() {
|
if let Some(sp) = req.system_prompt.as_ref() {
|
||||||
let _ = cm_db::repo::agents::update_profile(
|
let _ = cm_db::repo::agents::update_profile(
|
||||||
&state.pool, agent.id, None, None, Some(sp.trim()), None, None, None,
|
&state.pool,
|
||||||
|
agent.id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(sp.trim()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -292,7 +299,10 @@ pub struct EnhanceRequest {
|
|||||||
/// fences, and trailing commentary (balanced-brace scan from the first `{`).
|
/// fences, and trailing commentary (balanced-brace scan from the first `{`).
|
||||||
pub(crate) fn extract_json(s: &str) -> Option<Value> {
|
pub(crate) fn extract_json(s: &str) -> Option<Value> {
|
||||||
let t = s.trim();
|
let t = s.trim();
|
||||||
let t = t.strip_prefix("```json").or_else(|| t.strip_prefix("```")).unwrap_or(t);
|
let t = t
|
||||||
|
.strip_prefix("```json")
|
||||||
|
.or_else(|| t.strip_prefix("```"))
|
||||||
|
.unwrap_or(t);
|
||||||
let t = t.strip_suffix("```").unwrap_or(t).trim();
|
let t = t.strip_suffix("```").unwrap_or(t).trim();
|
||||||
if let Ok(v) = serde_json::from_str::<Value>(t) {
|
if let Ok(v) = serde_json::from_str::<Value>(t) {
|
||||||
return Some(v);
|
return Some(v);
|
||||||
@@ -448,54 +458,110 @@ pub(crate) async fn enhance_and_publish(
|
|||||||
reference: &str,
|
reference: &str,
|
||||||
role_context: &str,
|
role_context: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
|
let safe: String = reference
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c == '/' || c == ':' { '_' } else { c })
|
||||||
|
.collect();
|
||||||
let path = brain_dir().join(format!("scaffold_{safe}.h5"));
|
let path = brain_dir().join(format!("scaffold_{safe}.h5"));
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let pulled = cm_brain::hub::pull(reference, &path).await.map_err(|e| e.to_string())?;
|
let pulled = cm_brain::hub::pull(reference, &path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let (sp, agent_md, persona, skills) = {
|
let (sp, agent_md, persona, skills) = {
|
||||||
let b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
|
let b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
|
||||||
(
|
(
|
||||||
b.system_prompt().unwrap_or_default(),
|
b.system_prompt().unwrap_or_default(),
|
||||||
b.agent_md().unwrap_or_default(),
|
b.agent_md().unwrap_or_default(),
|
||||||
b.personality().unwrap_or_default(),
|
b.personality().unwrap_or_default(),
|
||||||
b.skills().into_iter().map(|(n, bd)| format!("## {n}\n{bd}")).collect::<Vec<_>>().join("\n\n"),
|
b.skills()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, bd)| format!("## {n}\n{bd}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n"),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let user_prompt = format!(
|
let user_prompt = format!(
|
||||||
"ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
|
"ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}"
|
||||||
);
|
);
|
||||||
let raw = runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await?;
|
let raw = runtime
|
||||||
|
.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true)
|
||||||
|
.await?;
|
||||||
let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?;
|
let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?;
|
||||||
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
|
let enh = v.get("enhanced").cloned().unwrap_or(Value::Null);
|
||||||
let field = |k: &str| enh.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
let field = |k: &str| {
|
||||||
{
|
enh.get(k)
|
||||||
let mut b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
|
.and_then(|x| x.as_str())
|
||||||
if !field("system_prompt").trim().is_empty() { let _ = b.set_system_prompt(&field("system_prompt")); }
|
.unwrap_or("")
|
||||||
if !field("agent_md").trim().is_empty() { let _ = b.set_agent_md(&field("agent_md")); }
|
.to_string()
|
||||||
if !field("persona").trim().is_empty() { let _ = b.set_personality(&field("persona")); }
|
|
||||||
if !field("skills_md").trim().is_empty() { let _ = b.set_skills_md(&field("skills_md")); }
|
|
||||||
}
|
|
||||||
let owner = cm_brain::hub::whoami().await.unwrap_or_else(|_| "me".to_string());
|
|
||||||
let on = pulled.meta.reference.rsplit_once(':').map(|(o, _)| o).unwrap_or(&pulled.meta.reference);
|
|
||||||
let name = on.rsplit_once('/').map(|(_, n)| n.to_string()).unwrap_or_else(|| on.to_string());
|
|
||||||
let cur_ver = pulled.meta.reference.rsplit_once(':').map(|(_, vv)| vv.to_string()).unwrap_or_else(|| "1.0.0".to_string());
|
|
||||||
let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver));
|
|
||||||
let result = match cm_brain::hub::push(&new_ref, &path, "Refined by Master Planner (Opus 4.8)", &[]).await {
|
|
||||||
Ok(()) => new_ref,
|
|
||||||
Err(_) => reference.to_string(),
|
|
||||||
};
|
};
|
||||||
|
{
|
||||||
|
let mut b =
|
||||||
|
cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?;
|
||||||
|
if !field("system_prompt").trim().is_empty() {
|
||||||
|
let _ = b.set_system_prompt(&field("system_prompt"));
|
||||||
|
}
|
||||||
|
if !field("agent_md").trim().is_empty() {
|
||||||
|
let _ = b.set_agent_md(&field("agent_md"));
|
||||||
|
}
|
||||||
|
if !field("persona").trim().is_empty() {
|
||||||
|
let _ = b.set_personality(&field("persona"));
|
||||||
|
}
|
||||||
|
if !field("skills_md").trim().is_empty() {
|
||||||
|
let _ = b.set_skills_md(&field("skills_md"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let owner = cm_brain::hub::whoami()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "me".to_string());
|
||||||
|
let on = pulled
|
||||||
|
.meta
|
||||||
|
.reference
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(o, _)| o)
|
||||||
|
.unwrap_or(&pulled.meta.reference);
|
||||||
|
let name = on
|
||||||
|
.rsplit_once('/')
|
||||||
|
.map(|(_, n)| n.to_string())
|
||||||
|
.unwrap_or_else(|| on.to_string());
|
||||||
|
let cur_ver = pulled
|
||||||
|
.meta
|
||||||
|
.reference
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(_, vv)| vv.to_string())
|
||||||
|
.unwrap_or_else(|| "1.0.0".to_string());
|
||||||
|
let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver));
|
||||||
|
let result =
|
||||||
|
match cm_brain::hub::push(&new_ref, &path, "Refined by Master Planner (Opus 4.8)", &[])
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => new_ref,
|
||||||
|
Err(_) => reference.to_string(),
|
||||||
|
};
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a brain reference to a freshly-created claw (merge + set its
|
/// Attach a brain reference to a freshly-created claw (merge + set its
|
||||||
/// authoritative system prompt). Used by the Master Planner scaffold.
|
/// authoritative system prompt). Used by the Master Planner scaffold.
|
||||||
pub(crate) async fn apply_reference_to_claw(state: &AppState, id: AgentId, reference: &str) -> Result<(), String> {
|
pub(crate) async fn apply_reference_to_claw(
|
||||||
|
state: &AppState,
|
||||||
|
id: AgentId,
|
||||||
|
reference: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
let path = brain_dir().join(format!("claw_{id}.h5"));
|
let path = brain_dir().join(format!("claw_{id}.h5"));
|
||||||
let pulled = cm_brain::hub::pull_merge(reference, &path).await.map_err(|e| e.to_string())?;
|
let pulled = cm_brain::hub::pull_merge(reference, &path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !pulled.system_prompt.trim().is_empty() {
|
if !pulled.system_prompt.trim().is_empty() {
|
||||||
let _ = cm_db::repo::agents::update_profile(
|
let _ = cm_db::repo::agents::update_profile(
|
||||||
&state.pool, id, None, None, Some(pulled.system_prompt.trim()), None, None, None,
|
&state.pool,
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(pulled.system_prompt.trim()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -539,14 +605,20 @@ pub async fn pull_brain(
|
|||||||
let agent = Agent {
|
let agent = Agent {
|
||||||
id,
|
id,
|
||||||
workspace_id: user.workspace_id,
|
workspace_id: user.workspace_id,
|
||||||
name: body.name.filter(|s| !s.trim().is_empty()).unwrap_or(pulled.name),
|
name: body
|
||||||
|
.name
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(pulled.name),
|
||||||
job_title: body
|
job_title: body
|
||||||
.job_title
|
.job_title
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| "Pulled from ClawBrainHub".into()),
|
.unwrap_or_else(|| "Pulled from ClawBrainHub".into()),
|
||||||
system_prompt: pulled.system_prompt,
|
system_prompt: pulled.system_prompt,
|
||||||
avatar: String::new(),
|
avatar: String::new(),
|
||||||
accent: body.accent.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "#ff6f61".into()),
|
accent: body
|
||||||
|
.accent
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "#ff6f61".into()),
|
||||||
wallpaper: String::new(),
|
wallpaper: String::new(),
|
||||||
managed_by: user.user_id,
|
managed_by: user.user_id,
|
||||||
status: AgentStatus::Online,
|
status: AgentStatus::Online,
|
||||||
@@ -627,7 +699,9 @@ pub async fn brainhub_search(
|
|||||||
Authed(_user): Authed,
|
Authed(_user): Authed,
|
||||||
Query(query): Query<BrainSearchQuery>,
|
Query(query): Query<BrainSearchQuery>,
|
||||||
) -> Result<Json<Vec<cm_brain::hub::BrainListing>>, ApiError> {
|
) -> Result<Json<Vec<cm_brain::hub::BrainListing>>, ApiError> {
|
||||||
Ok(Json(cm_brain::hub::list(&query.q).await.unwrap_or_default()))
|
Ok(Json(
|
||||||
|
cm_brain::hub::list(&query.q).await.unwrap_or_default(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/brainhub/preview?ref=owner/name` — overview of a brain's contents
|
/// `GET /api/brainhub/preview?ref=owner/name` — overview of a brain's contents
|
||||||
@@ -647,10 +721,13 @@ pub async fn brainhub_preview(
|
|||||||
if reference.is_empty() || !reference.contains('/') {
|
if reference.is_empty() || !reference.contains('/') {
|
||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
cm_brain::hub::preview(reference).await.map(Json).map_err(|e| {
|
cm_brain::hub::preview(reference)
|
||||||
eprintln!("cm-api: brain preview failed for {reference}: {e}");
|
.await
|
||||||
ApiError::BadRequest
|
.map(Json)
|
||||||
})
|
.map_err(|e| {
|
||||||
|
eprintln!("cm-api: brain preview failed for {reference}: {e}");
|
||||||
|
ApiError::BadRequest
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/claws/{id}/brain/apply` — pull a brain and inject its contents
|
/// `POST /api/claws/{id}/brain/apply` — pull a brain and inject its contents
|
||||||
@@ -673,10 +750,12 @@ pub async fn apply_brain(
|
|||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
let path = brain_dir().join(format!("claw_{id}.h5"));
|
let path = brain_dir().join(format!("claw_{id}.h5"));
|
||||||
let pulled = cm_brain::hub::pull_merge(reference, &path).await.map_err(|e| {
|
let pulled = cm_brain::hub::pull_merge(reference, &path)
|
||||||
eprintln!("cm-api: brain apply failed for {reference}: {e}");
|
.await
|
||||||
ApiError::BadRequest
|
.map_err(|e| {
|
||||||
})?;
|
eprintln!("cm-api: brain apply failed for {reference}: {e}");
|
||||||
|
ApiError::BadRequest
|
||||||
|
})?;
|
||||||
// The assembled identity becomes the agent's authoritative system prompt
|
// The assembled identity becomes the agent's authoritative system prompt
|
||||||
// (safe replace — the chat path is raw‑API for every provider).
|
// (safe replace — the chat path is raw‑API for every provider).
|
||||||
if !pulled.system_prompt.trim().is_empty() {
|
if !pulled.system_prompt.trim().is_empty() {
|
||||||
@@ -749,13 +828,25 @@ pub async fn brain_rollback(
|
|||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
workspace_agent(&state, &user, id).await?;
|
workspace_agent(&state, &user, id).await?;
|
||||||
let path = brain_dir().join(format!("claw_{id}.h5"));
|
let path = brain_dir().join(format!("claw_{id}.h5"));
|
||||||
let b = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()).map_err(|_| ApiError::Internal)?;
|
let b = cm_brain::ClawBrain::open_or_create(&path, &id.to_string())
|
||||||
b.rollback(body.revision).map_err(|_| ApiError::BadRequest)?;
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
b.rollback(body.revision)
|
||||||
|
.map_err(|_| ApiError::BadRequest)?;
|
||||||
// Re-open the rolled-back brain and restore its identity as the live prompt.
|
// Re-open the rolled-back brain and restore its identity as the live prompt.
|
||||||
if let Ok(reb) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
|
if let Ok(reb) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) {
|
||||||
let sp = reb.assembled_identity();
|
let sp = reb.assembled_identity();
|
||||||
if !sp.trim().is_empty() {
|
if !sp.trim().is_empty() {
|
||||||
let _ = cm_db::repo::agents::update_profile(&state.pool, id, None, None, Some(sp.trim()), None, None, None).await;
|
let _ = cm_db::repo::agents::update_profile(
|
||||||
|
&state.pool,
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(sp.trim()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cm_db::repo::audit::append(
|
cm_db::repo::audit::append(
|
||||||
|
|||||||
@@ -136,7 +136,12 @@ pub async fn patch_company(
|
|||||||
.collect();
|
.collect();
|
||||||
let n = by_node.len();
|
let n = by_node.len();
|
||||||
let roles: Vec<String> = (0..n)
|
let roles: Vec<String> = (0..n)
|
||||||
.map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "team".into()))
|
.map(|i| {
|
||||||
|
by_node
|
||||||
|
.get(&format!("n{i}"))
|
||||||
|
.map(|(_, r)| r.clone())
|
||||||
|
.unwrap_or_else(|| "team".into())
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
|
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
|
||||||
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
|
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
|
||||||
@@ -147,7 +152,14 @@ pub async fn patch_company(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
|
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
|
||||||
cm_db::repo::companies::set_topology(&state.pool, company.id, user.workspace_id, kind.as_str(), &graph_json).await?;
|
cm_db::repo::companies::set_topology(
|
||||||
|
&state.pool,
|
||||||
|
company.id,
|
||||||
|
user.workspace_id,
|
||||||
|
kind.as_str(),
|
||||||
|
&graph_json,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ fn walk_files<'a>(
|
|||||||
};
|
};
|
||||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let Ok(ft) = entry.file_type().await else { continue };
|
let Ok(ft) = entry.file_type().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if ft.is_dir() {
|
if ft.is_dir() {
|
||||||
walk_files(path, base.clone(), out).await;
|
walk_files(path, base.clone(), out).await;
|
||||||
} else if ft.is_file() {
|
} else if ft.is_file() {
|
||||||
@@ -54,7 +56,12 @@ async fn reconcile_drive(
|
|||||||
};
|
};
|
||||||
let dir = root.join(format!("{ws}/{}/{scope}", drive.as_str()));
|
let dir = root.join(format!("{ws}/{}/{scope}", drive.as_str()));
|
||||||
let mut disk = HashMap::new();
|
let mut disk = HashMap::new();
|
||||||
walk_files(dir, root.join(format!("{ws}/{}/{scope}", drive.as_str())), &mut disk).await;
|
walk_files(
|
||||||
|
dir,
|
||||||
|
root.join(format!("{ws}/{}/{scope}", drive.as_str())),
|
||||||
|
&mut disk,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let db = match cm_db::repo::files::list(pool, ws, drive, agent).await {
|
let db = match cm_db::repo::files::list(pool, ws, drive, agent).await {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@@ -171,7 +178,14 @@ pub async fn shared_files(
|
|||||||
) -> Result<Json<Vec<FileNode>>, ApiError> {
|
) -> Result<Json<Vec<FileNode>>, ApiError> {
|
||||||
let agent = workspace_agent(&state, &user, query.claw_id).await?;
|
let agent = workspace_agent(&state, &user, query.claw_id).await?;
|
||||||
if let Some(root) = &state.file_root {
|
if let Some(root) = &state.file_root {
|
||||||
reconcile_drive(&state.pool, root, user.workspace_id, FileDrive::Shared, agent.id).await;
|
reconcile_drive(
|
||||||
|
&state.pool,
|
||||||
|
root,
|
||||||
|
user.workspace_id,
|
||||||
|
FileDrive::Shared,
|
||||||
|
agent.id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
let nodes =
|
let nodes =
|
||||||
cm_db::repo::files::list(&state.pool, user.workspace_id, FileDrive::Shared, agent.id)
|
cm_db::repo::files::list(&state.pool, user.workspace_id, FileDrive::Shared, agent.id)
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ pub mod browser;
|
|||||||
pub mod claw_chat;
|
pub mod claw_chat;
|
||||||
pub mod claws;
|
pub mod claws;
|
||||||
pub mod companies;
|
pub mod companies;
|
||||||
pub mod planner;
|
|
||||||
pub mod webhooks;
|
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
@@ -16,6 +14,7 @@ pub mod identity;
|
|||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
|
pub mod planner;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
@@ -26,4 +25,5 @@ pub mod team;
|
|||||||
pub mod teams;
|
pub mod teams;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod topology;
|
pub mod topology;
|
||||||
|
pub mod webhooks;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ pub async fn list(
|
|||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let rows = nodes::list(&state.pool, user.workspace_id).await?;
|
let rows = nodes::list(&state.pool, user.workspace_id).await?;
|
||||||
Ok(Json(json!({ "nodes": rows.iter().map(node_json).collect::<Vec<_>>() })))
|
Ok(Json(
|
||||||
|
json!({ "nodes": rows.iter().map(node_json).collect::<Vec<_>>() }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/nodes/live` — SSE stream of the node list + health (2s poll).
|
/// `GET /api/nodes/live` — SSE stream of the node list + health (2s poll).
|
||||||
@@ -116,7 +118,9 @@ pub async fn exec_test(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ApiError::NotFound)?;
|
.ok_or(ApiError::NotFound)?;
|
||||||
match state.node_hub.verify(node_id).await {
|
match state.node_hub.verify(node_id).await {
|
||||||
Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output, "node": node.name }))),
|
Ok(out) => Ok(Json(
|
||||||
|
json!({ "ok": out.ok, "output": out.output, "node": node.name }),
|
||||||
|
)),
|
||||||
Err(e) => Ok(Json(json!({ "ok": false, "output": e, "node": node.name }))),
|
Err(e) => Ok(Json(json!({ "ok": false, "output": e, "node": node.name }))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +188,9 @@ pub async fn terminal_ticket(
|
|||||||
if !state.node_hub.is_online(node_id).await {
|
if !state.node_hub.is_online(node_id).await {
|
||||||
return Ok(Json(json!({ "error": "node is offline" })));
|
return Ok(Json(json!({ "error": "node is offline" })));
|
||||||
}
|
}
|
||||||
Ok(Json(json!({ "ticket": state.node_hub.mint_ticket(node_id).await })))
|
Ok(Json(
|
||||||
|
json!({ "ticket": state.node_hub.mint_ticket(node_id).await }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/nodes/{id}/terminal/ws?ticket=…` — bridge a browser xterm to a host
|
/// `GET /api/nodes/{id}/terminal/ws?ticket=…` — bridge a browser xterm to a host
|
||||||
@@ -261,8 +267,14 @@ async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket)
|
|||||||
// Host shell (no container) — the Infra node terminal.
|
// Host shell (no container) — the Infra node terminal.
|
||||||
"fallback" => hub.open_pty(node_id, sid, cols, rows, None, None).await,
|
"fallback" => hub.open_pty(node_id, sid, cols, rows, None, None).await,
|
||||||
"webrtc_offer" => {
|
"webrtc_offer" => {
|
||||||
hub.webrtc_offer(node_id, sid, c.sdp.as_deref().unwrap_or(""), None, None)
|
hub.webrtc_offer(
|
||||||
.await
|
node_id,
|
||||||
|
sid,
|
||||||
|
c.sdp.as_deref().unwrap_or(""),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
"webrtc_ice" => {
|
"webrtc_ice" => {
|
||||||
hub.webrtc_ice(
|
hub.webrtc_ice(
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ brain_query to a concrete domain brain keyword (e.g. 'rust-2024', 'react-native'
|
|||||||
const SCHEDULED_NOTE: &str = "\n\nMODE: Scheduled. Include a schedule in the proposal. Recurring: \
|
const SCHEDULED_NOTE: &str = "\n\nMODE: Scheduled. Include a schedule in the proposal. Recurring: \
|
||||||
{\"cron\":\"<5-field>\",\"prompt\":\"<mission run each cycle>\"}. One-time: \
|
{\"cron\":\"<5-field>\",\"prompt\":\"<mission run each cycle>\"}. One-time: \
|
||||||
{\"one_shot_at\":\"<RFC3339 UTC datetime>\",\"prompt\":\"<mission>\"}.";
|
{\"one_shot_at\":\"<RFC3339 UTC datetime>\",\"prompt\":\"<mission>\"}.";
|
||||||
const TRIGGERED_NOTE: &str = "\n\nMODE: Triggered. The team will be fired by a webhook on demand. Set the \
|
const TRIGGERED_NOTE: &str =
|
||||||
|
"\n\nMODE: Triggered. The team will be fired by a webhook on demand. Set the \
|
||||||
schedule field to {\"prompt\":\"<the default task the webhook runs>\"} (NO cron / one_shot_at).";
|
schedule field to {\"prompt\":\"<the default task the webhook runs>\"} (NO cron / one_shot_at).";
|
||||||
const SWARM_SYSTEM: &str = "You are the planner for a self-verifying agent SWARM (Opus plans + verifies, a worker \
|
const SWARM_SYSTEM: &str = "You are the planner for a self-verifying agent SWARM (Opus plans + verifies, a worker \
|
||||||
swarm executes, the loop repeats until every output passes). The user describes a job; you turn it into a swarm \
|
swarm executes, the loop repeats until every output passes). The user describes a job; you turn it into a swarm \
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ pub async fn connect(
|
|||||||
let api_key = req.api_key.trim();
|
let api_key = req.api_key.trim();
|
||||||
let tailnet = req.tailnet.trim();
|
let tailnet = req.tailnet.trim();
|
||||||
if api_key.is_empty() || tailnet.is_empty() {
|
if api_key.is_empty() || tailnet.is_empty() {
|
||||||
return Ok(Json(json!({ "ok": false, "error": "apiKey and tailnet are required" })));
|
return Ok(Json(
|
||||||
|
json!({ "ok": false, "error": "apiKey and tailnet are required" }),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
fleet_tailscale::set(&state.pool, user.workspace_id, api_key, tailnet).await?;
|
fleet_tailscale::set(&state.pool, user.workspace_id, api_key, tailnet).await?;
|
||||||
Ok(Json(json!({ "ok": true, "tailnet": tailnet })))
|
Ok(Json(json!({ "ok": true, "tailnet": tailnet })))
|
||||||
@@ -74,7 +76,9 @@ pub async fn status(
|
|||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let conn = fleet_tailscale::get(&state.pool, user.workspace_id).await?;
|
let conn = fleet_tailscale::get(&state.pool, user.workspace_id).await?;
|
||||||
Ok(Json(json!({ "connected": conn.is_some(), "tailnet": conn.map(|(_, t)| t) })))
|
Ok(Json(
|
||||||
|
json!({ "connected": conn.is_some(), "tailnet": conn.map(|(_, t)| t) }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELETE /api/fleet/tailscale` — disconnect Tailscale.
|
/// `DELETE /api/fleet/tailscale` — disconnect Tailscale.
|
||||||
@@ -92,11 +96,16 @@ pub async fn devices(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
let Some((api_key, tailnet)) = fleet_tailscale::get(&state.pool, user.workspace_id).await? else {
|
let Some((api_key, tailnet)) = fleet_tailscale::get(&state.pool, user.workspace_id).await?
|
||||||
|
else {
|
||||||
return Ok(Json(json!({ "connected": false, "devices": [] })));
|
return Ok(Json(json!({ "connected": false, "devices": [] })));
|
||||||
};
|
};
|
||||||
let url = format!("https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices");
|
let url = format!("https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices");
|
||||||
let resp = reqwest::Client::new().get(&url).bearer_auth(&api_key).send().await;
|
let resp = reqwest::Client::new()
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&api_key)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
let body = match resp {
|
let body = match resp {
|
||||||
Ok(r) if r.status().is_success() => r.json::<Value>().await.unwrap_or_else(|_| json!({})),
|
Ok(r) if r.status().is_success() => r.json::<Value>().await.unwrap_or_else(|_| json!({})),
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
@@ -127,5 +136,7 @@ pub async fn devices(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
Ok(Json(json!({ "connected": true, "tailnet": tailnet, "devices": devices })))
|
Ok(Json(
|
||||||
|
json!({ "connected": true, "tailnet": tailnet, "devices": devices }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,13 @@ pub(crate) async fn build_team(
|
|||||||
};
|
};
|
||||||
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
|
cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?;
|
||||||
let claw_id = agent.id.as_uuid();
|
let claw_id = agent.id.as_uuid();
|
||||||
provisioner.provision_claw(claw_id, &m.model).await.map_err(|e| {
|
provisioner
|
||||||
eprintln!("teams: provision claw {claw_id} failed: {e}");
|
.provision_claw(claw_id, &m.model)
|
||||||
ApiError::Internal
|
.await
|
||||||
})?;
|
.map_err(|e| {
|
||||||
|
eprintln!("teams: provision claw {claw_id} failed: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?;
|
cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?;
|
||||||
claw_ids.push(claw_id);
|
claw_ids.push(claw_id);
|
||||||
}
|
}
|
||||||
@@ -98,10 +101,19 @@ pub(crate) async fn build_team(
|
|||||||
|
|
||||||
let team_id = Uuid::now_v7();
|
let team_id = Uuid::now_v7();
|
||||||
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
|
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
|
||||||
cm_db::repo::teams::insert_team(&state.pool, team_id, workspace_id, name, kind.as_str(), &graph_json).await?;
|
cm_db::repo::teams::insert_team(
|
||||||
|
&state.pool,
|
||||||
|
team_id,
|
||||||
|
workspace_id,
|
||||||
|
name,
|
||||||
|
kind.as_str(),
|
||||||
|
&graph_json,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
for (i, node) in graph.nodes.iter().enumerate() {
|
for (i, node) in graph.nodes.iter().enumerate() {
|
||||||
if let Some(cid) = claw_ids.get(i) {
|
if let Some(cid) = claw_ids.get(i) {
|
||||||
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role).await?;
|
cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok((team_id, claw_ids))
|
Ok((team_id, claw_ids))
|
||||||
@@ -122,7 +134,12 @@ pub async fn create_team(
|
|||||||
&body.members,
|
&body.members,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((StatusCode::CREATED, Json(TeamCreated { team_id: team_id.to_string() })))
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(TeamCreated {
|
||||||
|
team_id: team_id.to_string(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -146,12 +163,17 @@ pub async fn create_team_from_claws(
|
|||||||
if body.claw_ids.is_empty() {
|
if body.claw_ids.is_empty() {
|
||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
let kind = parse_kind(if body.kind.is_empty() { "hub_spoke" } else { &body.kind })?;
|
let kind = parse_kind(if body.kind.is_empty() {
|
||||||
|
"hub_spoke"
|
||||||
|
} else {
|
||||||
|
&body.kind
|
||||||
|
})?;
|
||||||
|
|
||||||
// Resolve + authorize each claw, collecting its role for the topology.
|
// Resolve + authorize each claw, collecting its role for the topology.
|
||||||
let mut roles: Vec<String> = Vec::with_capacity(body.claw_ids.len());
|
let mut roles: Vec<String> = Vec::with_capacity(body.claw_ids.len());
|
||||||
for cid in &body.claw_ids {
|
for cid in &body.claw_ids {
|
||||||
let agent = crate::routes::claws::workspace_agent(&state, &user, AgentId::from(*cid)).await?;
|
let agent =
|
||||||
|
crate::routes::claws::workspace_agent(&state, &user, AgentId::from(*cid)).await?;
|
||||||
roles.push(if agent.job_title.is_empty() {
|
roles.push(if agent.job_title.is_empty() {
|
||||||
"claw".into()
|
"claw".into()
|
||||||
} else {
|
} else {
|
||||||
@@ -291,7 +313,12 @@ pub async fn patch_team(
|
|||||||
.collect();
|
.collect();
|
||||||
let n = by_node.len();
|
let n = by_node.len();
|
||||||
let roles: Vec<String> = (0..n)
|
let roles: Vec<String> = (0..n)
|
||||||
.map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "claw".into()))
|
.map(|i| {
|
||||||
|
by_node
|
||||||
|
.get(&format!("n{i}"))
|
||||||
|
.map(|(_, r)| r.clone())
|
||||||
|
.unwrap_or_else(|| "claw".into())
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
|
let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect();
|
||||||
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
|
let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?;
|
||||||
@@ -302,7 +329,14 @@ pub async fn patch_team(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
|
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
|
||||||
cm_db::repo::teams::set_topology(&state.pool, team.id, user.workspace_id, kind.as_str(), &graph_json).await?;
|
cm_db::repo::teams::set_topology(
|
||||||
|
&state.pool,
|
||||||
|
team.id,
|
||||||
|
user.workspace_id,
|
||||||
|
kind.as_str(),
|
||||||
|
&graph_json,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,11 +197,21 @@ pub async fn run_swarm(
|
|||||||
"task_count": req.task_count,
|
"task_count": req.task_count,
|
||||||
"worker_model": req.worker_model,
|
"worker_model": req.worker_model,
|
||||||
});
|
});
|
||||||
cm_db::repo::topology_runs::enqueue_run_tier(&state.pool, id, user.workspace_id, &req.goal, &config, "swarm")
|
cm_db::repo::topology_runs::enqueue_run_tier(
|
||||||
.await?;
|
&state.pool,
|
||||||
|
id,
|
||||||
|
user.workspace_id,
|
||||||
|
&req.goal,
|
||||||
|
&config,
|
||||||
|
"swarm",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::ACCEPTED,
|
||||||
Json(RunAccepted { run_id: id.to_string(), status: "queued".into() }),
|
Json(RunAccepted {
|
||||||
|
run_id: id.to_string(),
|
||||||
|
status: "queued".into(),
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ pub async fn create_webhook(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Internal)?;
|
.map_err(|_| ApiError::Internal)?;
|
||||||
Ok(Json(json!({ "token": token.to_string(), "url": format!("/api/hooks/{token}") })))
|
Ok(Json(
|
||||||
|
json!({ "token": token.to_string(), "url": format!("/api/hooks/{token}") }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
|
|||||||
@@ -74,33 +74,60 @@ fn summarize_input(input: &Value) -> String {
|
|||||||
/// Normalize one durable `run_events` row into taxonomy events — the Rust twin of
|
/// Normalize one durable `run_events` row into taxonomy events — the Rust twin of
|
||||||
/// the handoff bridge's normalize(). The runner already journals these, so the
|
/// the handoff bridge's normalize(). The runner already journals these, so the
|
||||||
/// live view shows REAL reasoning, tool-convergence and doors with no runner edit.
|
/// live view shows REAL reasoning, tool-convergence and doors with no runner edit.
|
||||||
fn normalize_run_event(agent_id: &str, event_type: &str, payload: &Value) -> Vec<(&'static str, Value)> {
|
fn normalize_run_event(
|
||||||
|
agent_id: &str,
|
||||||
|
event_type: &str,
|
||||||
|
payload: &Value,
|
||||||
|
) -> Vec<(&'static str, Value)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
match event_type {
|
match event_type {
|
||||||
"text_delta" => {
|
"text_delta" => {
|
||||||
if let Some(delta) = payload.get("delta").and_then(|v| v.as_str()) {
|
if let Some(delta) = payload.get("delta").and_then(|v| v.as_str()) {
|
||||||
out.push(("agent.reasoning.delta", json!({ "agentId": agent_id, "text": delta })));
|
out.push((
|
||||||
|
"agent.reasoning.delta",
|
||||||
|
json!({ "agentId": agent_id, "text": delta }),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"step_started" => {
|
"step_started" => {
|
||||||
let tool = payload.get("tool").and_then(|v| v.as_str()).unwrap_or("tool");
|
let tool = payload
|
||||||
|
.get("tool")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("tool");
|
||||||
let node_id = format!("tool:{tool}");
|
let node_id = format!("tool:{tool}");
|
||||||
let target = payload.get("input").map(summarize_input).unwrap_or_default();
|
let target = payload
|
||||||
|
.get("input")
|
||||||
|
.map(summarize_input)
|
||||||
|
.unwrap_or_default();
|
||||||
// File/project I/O gets an explosive burst (high touch weight).
|
// File/project I/O gets an explosive burst (high touch weight).
|
||||||
let lower = tool.to_lowercase();
|
let lower = tool.to_lowercase();
|
||||||
let file_op = ["file", "read", "write", "drive", "vault", "obsidian", "edit", "fs", "save"]
|
let file_op = [
|
||||||
.iter()
|
"file", "read", "write", "drive", "vault", "obsidian", "edit", "fs", "save",
|
||||||
.any(|k| lower.contains(k));
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|k| lower.contains(k));
|
||||||
let weight = if file_op { 1.0 } else { 0.4 };
|
let weight = if file_op { 1.0 } else { 0.4 };
|
||||||
out.push(("agent.tool.call", json!({ "agentId": agent_id, "tool": tool, "target": target })));
|
out.push((
|
||||||
|
"agent.tool.call",
|
||||||
|
json!({ "agentId": agent_id, "tool": tool, "target": target }),
|
||||||
|
));
|
||||||
out.push(("node.activity", json!({ "nodeId": node_id, "label": tool, "kind": "service", "heat": if file_op { 1.0 } else { 0.9 } })));
|
out.push(("node.activity", json!({ "nodeId": node_id, "label": tool, "kind": "service", "heat": if file_op { 1.0 } else { 0.9 } })));
|
||||||
// the agent converges on the tool it's using (the Gource beam)
|
// the agent converges on the tool it's using (the Gource beam)
|
||||||
out.push(("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": weight })));
|
out.push(("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": weight })));
|
||||||
}
|
}
|
||||||
"approval_required" => {
|
"approval_required" => {
|
||||||
let action = payload.get("action_type").and_then(|v| v.as_str()).unwrap_or("action");
|
let action = payload
|
||||||
let category = payload.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
.get("action_type")
|
||||||
let door_id = payload.get("approval_id").and_then(|v| v.as_str()).unwrap_or("");
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("action");
|
||||||
|
let category = payload
|
||||||
|
.get("category")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let door_id = payload
|
||||||
|
.get("approval_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
out.push((
|
out.push((
|
||||||
"door.request",
|
"door.request",
|
||||||
json!({ "doorId": door_id, "agentId": agent_id, "action": action, "target": category, "summary": action }),
|
json!({ "doorId": door_id, "agentId": agent_id, "action": action, "target": category, "summary": action }),
|
||||||
@@ -134,7 +161,10 @@ struct AgentTele {
|
|||||||
/// Per-agent telemetry for every agent in the workspace, in 4 grouped queries
|
/// Per-agent telemetry for every agent in the workspace, in 4 grouped queries
|
||||||
/// (not N×4): tokens over the last minute, credits over the last hour, active
|
/// (not N×4): tokens over the last minute, credits over the last hour, active
|
||||||
/// routines, and pending approvals — all keyed by agent id.
|
/// routines, and pending approvals — all keyed by agent id.
|
||||||
async fn agent_telemetry(pool: &PgPool, ws: WorkspaceId) -> std::collections::HashMap<String, AgentTele> {
|
async fn agent_telemetry(
|
||||||
|
pool: &PgPool,
|
||||||
|
ws: WorkspaceId,
|
||||||
|
) -> std::collections::HashMap<String, AgentTele> {
|
||||||
let mut m: std::collections::HashMap<String, AgentTele> = std::collections::HashMap::new();
|
let mut m: std::collections::HashMap<String, AgentTele> = std::collections::HashMap::new();
|
||||||
let id_of = |r: &sqlx::postgres::PgRow| r.get::<uuid::Uuid, _>("agent_id").to_string();
|
let id_of = |r: &sqlx::postgres::PgRow| r.get::<uuid::Uuid, _>("agent_id").to_string();
|
||||||
|
|
||||||
@@ -347,7 +377,9 @@ pub async fn world_replay(
|
|||||||
events.push(json!({ "t": started, "type": "node.activity", "data": { "nodeId": node_id, "label": "run", "kind": "event", "heat": 0.85 }}));
|
events.push(json!({ "t": started, "type": "node.activity", "data": { "nodeId": node_id, "label": "run", "kind": "event", "heat": 0.85 }}));
|
||||||
events.push(json!({ "t": started, "type": "world.touch", "data": { "agentId": agent_id, "nodeId": node_id, "kind": "event" }}));
|
events.push(json!({ "t": started, "type": "world.touch", "data": { "agentId": agent_id, "nodeId": node_id, "kind": "event" }}));
|
||||||
}
|
}
|
||||||
Ok(Json(json!({ "events": events, "hours": hours, "count": rows.len() })))
|
Ok(Json(
|
||||||
|
json!({ "events": events, "hours": hours, "count": rows.len() }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// THE NORMALIZE SEAM (future) -------------------------------------------------
|
// THE NORMALIZE SEAM (future) -------------------------------------------------
|
||||||
|
|||||||
+112
-23
@@ -36,7 +36,11 @@ task must be self-contained and instruct the worker to cite resolvable source UR
|
|||||||
ONLY: {\"tasks\":[\"task 1\",\"task 2\", ...]}. Aim for the requested count if one is given, else pick a sensible number.";
|
ONLY: {\"tasks\":[\"task 1\",\"task 2\", ...]}. Aim for the requested count if one is given, else pick a sensible number.";
|
||||||
|
|
||||||
fn checklist_lines(checklist: &[String]) -> String {
|
fn checklist_lines(checklist: &[String]) -> String {
|
||||||
checklist.iter().map(|c| format!("- {c}")).collect::<Vec<_>>().join("\n")
|
checklist
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("- {c}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_system(checklist: &[String]) -> String {
|
fn worker_system(checklist: &[String]) -> String {
|
||||||
@@ -69,42 +73,94 @@ fn resolve_worker_model(requested: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn step(node_id: impl Into<String>, role: impl Into<String>, phase: StepPhase, output: impl Into<String>) -> StepRecord {
|
fn step(
|
||||||
StepRecord { node_id: node_id.into(), role: role.into(), phase, output: output.into(), gated: Vec::new(), tokens: 0 }
|
node_id: impl Into<String>,
|
||||||
|
role: impl Into<String>,
|
||||||
|
phase: StepPhase,
|
||||||
|
output: impl Into<String>,
|
||||||
|
) -> StepRecord {
|
||||||
|
StepRecord {
|
||||||
|
node_id: node_id.into(),
|
||||||
|
role: role.into(),
|
||||||
|
phase,
|
||||||
|
output: output.into(),
|
||||||
|
gated: Vec::new(),
|
||||||
|
tokens: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ckpt(pool: &PgPool, id: Uuid, records: &[StepRecord], totals: &RunMetrics) {
|
async fn ckpt(pool: &PgPool, id: Uuid, records: &[StepRecord], totals: &RunMetrics) {
|
||||||
let prog = RunProgress { completed: records.len(), outputs: Vec::new(), records: records.to_vec(), totals: *totals };
|
let prog = RunProgress {
|
||||||
|
completed: records.len(),
|
||||||
|
outputs: Vec::new(),
|
||||||
|
records: records.to_vec(),
|
||||||
|
totals: *totals,
|
||||||
|
};
|
||||||
if let Ok(v) = serde_json::to_value(&prog) {
|
if let Ok(v) = serde_json::to_value(&prog) {
|
||||||
let _ = cm_db::repo::topology_runs::checkpoint(pool, id, &v, records.len() as i64).await;
|
let _ = cm_db::repo::topology_runs::checkpoint(pool, id, &v, records.len() as i64).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a swarm job to completion, journaling every worker output + verdict.
|
/// Run a swarm job to completion, journaling every worker output + verdict.
|
||||||
pub async fn run_swarm_job(pool: &PgPool, runtime: &Runtime, id: Uuid, job: SwarmJob, goal: &str) -> Result<RunRecord, String> {
|
pub async fn run_swarm_job(
|
||||||
|
pool: &PgPool,
|
||||||
|
runtime: &Runtime,
|
||||||
|
id: Uuid,
|
||||||
|
job: SwarmJob,
|
||||||
|
goal: &str,
|
||||||
|
) -> Result<RunRecord, String> {
|
||||||
let mut records: Vec<StepRecord> = Vec::new();
|
let mut records: Vec<StepRecord> = Vec::new();
|
||||||
let totals = RunMetrics::default();
|
let totals = RunMetrics::default();
|
||||||
let checklist = if job.checklist.is_empty() {
|
let checklist = if job.checklist.is_empty() {
|
||||||
vec!["output is accurate and complete".to_string(), "every claim cites a resolvable source URL".to_string()]
|
vec![
|
||||||
|
"output is accurate and complete".to_string(),
|
||||||
|
"every claim cites a resolvable source URL".to_string(),
|
||||||
|
]
|
||||||
} else {
|
} else {
|
||||||
job.checklist.clone()
|
job.checklist.clone()
|
||||||
};
|
};
|
||||||
let worker_model = resolve_worker_model(&job.worker_model);
|
let worker_model = resolve_worker_model(&job.worker_model);
|
||||||
|
|
||||||
// 1) PLAN — Opus decomposes the goal into worker tasks.
|
// 1) PLAN — Opus decomposes the goal into worker tasks.
|
||||||
records.push(step("planner", "planner:opus", StepPhase::Plan, format!("Planning tasks for: {goal}")));
|
records.push(step(
|
||||||
|
"planner",
|
||||||
|
"planner:opus",
|
||||||
|
StepPhase::Plan,
|
||||||
|
format!("Planning tasks for: {goal}"),
|
||||||
|
));
|
||||||
ckpt(pool, id, &records, &totals).await;
|
ckpt(pool, id, &records, &totals).await;
|
||||||
let want = job.task_count.map(|n| format!("\n\nDesired number of tasks: {n}.")).unwrap_or_default();
|
let want = job
|
||||||
let plan_user = format!("GOAL:\n{goal}\n\nCHECKLIST each task's output must satisfy:\n{}{want}", checklist_lines(&checklist));
|
.task_count
|
||||||
let plan_raw = runtime.complete(PLAN_SYSTEM, &plan_user, "claude-opus-4-8", 4000, false).await?;
|
.map(|n| format!("\n\nDesired number of tasks: {n}."))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let plan_user = format!(
|
||||||
|
"GOAL:\n{goal}\n\nCHECKLIST each task's output must satisfy:\n{}{want}",
|
||||||
|
checklist_lines(&checklist)
|
||||||
|
);
|
||||||
|
let plan_raw = runtime
|
||||||
|
.complete(PLAN_SYSTEM, &plan_user, "claude-opus-4-8", 4000, false)
|
||||||
|
.await?;
|
||||||
let tasks: Vec<String> = extract_json(&plan_raw)
|
let tasks: Vec<String> = extract_json(&plan_raw)
|
||||||
.and_then(|v| v.get("tasks").and_then(|t| t.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()))
|
.and_then(|v| {
|
||||||
|
v.get("tasks").and_then(|t| t.as_array()).map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if tasks.is_empty() {
|
if tasks.is_empty() {
|
||||||
return Err("planner produced no tasks".to_string());
|
return Err("planner produced no tasks".to_string());
|
||||||
}
|
}
|
||||||
records.push(step("planner", "planner:opus", StepPhase::Plan,
|
records.push(step(
|
||||||
format!("Decomposed into {} tasks. Workers: {worker_model}. Verifier: claude-opus-4-8.", tasks.len())));
|
"planner",
|
||||||
|
"planner:opus",
|
||||||
|
StepPhase::Plan,
|
||||||
|
format!(
|
||||||
|
"Decomposed into {} tasks. Workers: {worker_model}. Verifier: claude-opus-4-8.",
|
||||||
|
tasks.len()
|
||||||
|
),
|
||||||
|
));
|
||||||
ckpt(pool, id, &records, &totals).await;
|
ckpt(pool, id, &records, &totals).await;
|
||||||
|
|
||||||
// 2) LOOP — run pending tasks, verify each, requeue failures until clean.
|
// 2) LOOP — run pending tasks, verify each, requeue failures until clean.
|
||||||
@@ -125,37 +181,65 @@ pub async fn run_swarm_job(pool: &PgPool, runtime: &Runtime, id: Uuid, job: Swar
|
|||||||
.complete(&wsys, task, &worker_model, 4000, true)
|
.complete(&wsys, task, &worker_model, 4000, true)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| format!("worker error: {e}"));
|
.unwrap_or_else(|e| format!("worker error: {e}"));
|
||||||
records.push(step(format!("task-{idx}"), format!("worker:{worker_model}"), StepPhase::Work, out.clone()));
|
records.push(step(
|
||||||
|
format!("task-{idx}"),
|
||||||
|
format!("worker:{worker_model}"),
|
||||||
|
StepPhase::Work,
|
||||||
|
out.clone(),
|
||||||
|
));
|
||||||
ckpt(pool, id, &records, &totals).await;
|
ckpt(pool, id, &records, &totals).await;
|
||||||
|
|
||||||
let vuser = format!("TASK:\n{task}\n\nWORKER OUTPUT:\n{out}");
|
let vuser = format!("TASK:\n{task}\n\nWORKER OUTPUT:\n{out}");
|
||||||
let v_raw = runtime.complete(&vsys, &vuser, "claude-opus-4-8", 1200, true).await.unwrap_or_default();
|
let v_raw = runtime
|
||||||
|
.complete(&vsys, &vuser, "claude-opus-4-8", 1200, true)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let v = extract_json(&v_raw);
|
let v = extract_json(&v_raw);
|
||||||
let passed = v.as_ref().and_then(|x| x.get("passed").and_then(|p| p.as_bool())).unwrap_or(false);
|
let passed = v
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| x.get("passed").and_then(|p| p.as_bool()))
|
||||||
|
.unwrap_or(false);
|
||||||
let reason = v
|
let reason = v
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|x| x.get("reason").and_then(|r| r.as_str()))
|
.and_then(|x| x.get("reason").and_then(|r| r.as_str()))
|
||||||
.unwrap_or("no verifier response")
|
.unwrap_or("no verifier response")
|
||||||
.to_string();
|
.to_string();
|
||||||
records.push(step(format!("verify-{idx}"), "verifier:opus", StepPhase::Aggregate,
|
records.push(step(
|
||||||
format!("{} — {reason}", if passed { "✓ PASS" } else { "✗ REJECT" })));
|
format!("verify-{idx}"),
|
||||||
|
"verifier:opus",
|
||||||
|
StepPhase::Aggregate,
|
||||||
|
format!("{} — {reason}", if passed { "✓ PASS" } else { "✗ REJECT" }),
|
||||||
|
));
|
||||||
ckpt(pool, id, &records, &totals).await;
|
ckpt(pool, id, &records, &totals).await;
|
||||||
|
|
||||||
if passed {
|
if passed {
|
||||||
results.insert(*idx, out);
|
results.insert(*idx, out);
|
||||||
} else {
|
} else {
|
||||||
rejected += 1;
|
rejected += 1;
|
||||||
still.push((*idx, format!("{task}\n\n(Your previous attempt was REJECTED: {reason}. Correct it.)")));
|
still.push((
|
||||||
|
*idx,
|
||||||
|
format!(
|
||||||
|
"{task}\n\n(Your previous attempt was REJECTED: {reason}. Correct it.)"
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
records.push(step("verifier", "verifier:opus", StepPhase::Aggregate,
|
records.push(step(
|
||||||
format!("Verify pass {pass}: checked {count}, rejected {rejected}.")));
|
"verifier",
|
||||||
|
"verifier:opus",
|
||||||
|
StepPhase::Aggregate,
|
||||||
|
format!("Verify pass {pass}: checked {count}, rejected {rejected}."),
|
||||||
|
));
|
||||||
ckpt(pool, id, &records, &totals).await;
|
ckpt(pool, id, &records, &totals).await;
|
||||||
pending = still;
|
pending = still;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Assemble the report.
|
// 3) Assemble the report.
|
||||||
let mut report = format!("# Swarm result — {goal}\n\n{} of {} tasks verified clean.\n", results.len(), tasks.len());
|
let mut report = format!(
|
||||||
|
"# Swarm result — {goal}\n\n{} of {} tasks verified clean.\n",
|
||||||
|
results.len(),
|
||||||
|
tasks.len()
|
||||||
|
);
|
||||||
for (idx, task) in tasks.iter().enumerate() {
|
for (idx, task) in tasks.iter().enumerate() {
|
||||||
report.push_str(&format!("\n## Task {}\n", idx + 1));
|
report.push_str(&format!("\n## Task {}\n", idx + 1));
|
||||||
match results.get(&idx) {
|
match results.get(&idx) {
|
||||||
@@ -165,5 +249,10 @@ pub async fn run_swarm_job(pool: &PgPool, runtime: &Runtime, id: Uuid, job: Swar
|
|||||||
report.push('\n');
|
report.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(RunRecord { kind: TopologyKind::Swarm, steps: records, final_output: report, totals })
|
Ok(RunRecord {
|
||||||
|
kind: TopologyKind::Swarm,
|
||||||
|
steps: records,
|
||||||
|
final_output: report,
|
||||||
|
totals,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drive one claimed job to a terminal state, persisting checkpoints as it goes.
|
/// Drive one claimed job to a terminal state, persisting checkpoints as it goes.
|
||||||
async fn run_job(pool: &PgPool, runtime: &cm_runtime::Runtime, job: cm_db::repo::topology_runs::ClaimedTopologyRun) {
|
async fn run_job(
|
||||||
|
pool: &PgPool,
|
||||||
|
runtime: &cm_runtime::Runtime,
|
||||||
|
job: cm_db::repo::topology_runs::ClaimedTopologyRun,
|
||||||
|
) {
|
||||||
let id = job.id;
|
let id = job.id;
|
||||||
|
|
||||||
// Swarm runs aren't graph topologies — the `graph` JSONB holds the swarm
|
// Swarm runs aren't graph topologies — the `graph` JSONB holds the swarm
|
||||||
|
|||||||
@@ -140,10 +140,11 @@ impl AuthService {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// A concurrent winner may have already provisioned this identity.
|
// A concurrent winner may have already provisioned this identity.
|
||||||
if let Some(row) = sqlx::query("SELECT id, workspace_id, role FROM users WHERE auth_subject = $1")
|
if let Some(row) =
|
||||||
.bind(&claims.sub)
|
sqlx::query("SELECT id, workspace_id, role FROM users WHERE auth_subject = $1")
|
||||||
.fetch_optional(&mut *tx)
|
.bind(&claims.sub)
|
||||||
.await?
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
let role_db: String = row.get("role");
|
let role_db: String = row.get("role");
|
||||||
|
|||||||
+126
-29
@@ -77,7 +77,11 @@ pub async fn pull(reference: &str, dest: &Path) -> Result<PulledBrain, BrainErro
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
r
|
r
|
||||||
};
|
};
|
||||||
Ok(PulledBrain { meta, name, system_prompt })
|
Ok(PulledBrain {
|
||||||
|
meta,
|
||||||
|
name,
|
||||||
|
system_prompt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -102,7 +106,15 @@ pub async fn list(query: &str) -> Result<Vec<BrainListing>, BrainError> {
|
|||||||
search_one(&client, q).await?
|
search_one(&client, q).await?
|
||||||
} else {
|
} else {
|
||||||
const SEEDS: &[&str] = &[
|
const SEEDS: &[&str] = &[
|
||||||
"assistant", "agent", "code", "react", "data", "research", "write", "general", "support",
|
"assistant",
|
||||||
|
"agent",
|
||||||
|
"code",
|
||||||
|
"react",
|
||||||
|
"data",
|
||||||
|
"research",
|
||||||
|
"write",
|
||||||
|
"general",
|
||||||
|
"support",
|
||||||
];
|
];
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -137,7 +149,10 @@ async fn filter_loadable(raw: Vec<BrainListing>) -> Vec<BrainListing> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search_one(client: &BrainRegistryClient, q: &str) -> Result<Vec<BrainListing>, BrainError> {
|
async fn search_one(
|
||||||
|
client: &BrainRegistryClient,
|
||||||
|
q: &str,
|
||||||
|
) -> Result<Vec<BrainListing>, BrainError> {
|
||||||
let entries = client.list(Some(q), None, None).await.map_err(be)?;
|
let entries = client.list(Some(q), None, None).await.map_err(be)?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for e in entries {
|
for e in entries {
|
||||||
@@ -191,8 +206,15 @@ fn section_text(s: Option<String>) -> SectionText {
|
|||||||
|
|
||||||
/// Pull a brain and summarize its sections without applying it to any agent.
|
/// Pull a brain and summarize its sections without applying it to any agent.
|
||||||
pub async fn preview(reference: &str) -> Result<BrainPreview, BrainError> {
|
pub async fn preview(reference: &str) -> Result<BrainPreview, BrainError> {
|
||||||
let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect();
|
let safe: String = reference
|
||||||
let tmp = std::env::temp_dir().join(format!("cm-brain-preview-{}-{}.brain", std::process::id(), safe));
|
.chars()
|
||||||
|
.map(|c| if c == '/' || c == ':' { '_' } else { c })
|
||||||
|
.collect();
|
||||||
|
let tmp = std::env::temp_dir().join(format!(
|
||||||
|
"cm-brain-preview-{}-{}.brain",
|
||||||
|
std::process::id(),
|
||||||
|
safe
|
||||||
|
));
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
let pulled = pull(reference, &tmp).await?;
|
let pulled = pull(reference, &tmp).await?;
|
||||||
let b = ClawBrain::open_or_create(&tmp, reference)?;
|
let b = ClawBrain::open_or_create(&tmp, reference)?;
|
||||||
@@ -207,7 +229,10 @@ pub async fn preview(reference: &str) -> Result<BrainPreview, BrainError> {
|
|||||||
memory_count: b.memory_count(),
|
memory_count: b.memory_count(),
|
||||||
memory_recent: b.recent_memory(4).into_iter().map(|(_, t)| t).collect(),
|
memory_recent: b.recent_memory(4).into_iter().map(|(_, t)| t).collect(),
|
||||||
runtime: b.runtime().map(|s| !s.trim().is_empty()).unwrap_or(false),
|
runtime: b.runtime().map(|s| !s.trim().is_empty()).unwrap_or(false),
|
||||||
provenance: b.provenance().map(|s| !s.trim().is_empty()).unwrap_or(false),
|
provenance: b
|
||||||
|
.provenance()
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false),
|
||||||
};
|
};
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
Ok(pv)
|
Ok(pv)
|
||||||
@@ -225,13 +250,27 @@ pub async fn pull_merge(reference: &str, dest_brain: &Path) -> Result<PulledBrai
|
|||||||
let assembled = {
|
let assembled = {
|
||||||
let src = ClawBrain::open_or_create(&tmp, reference)?;
|
let src = ClawBrain::open_or_create(&tmp, reference)?;
|
||||||
let mut dst = ClawBrain::open_or_create(dest_brain, reference)?;
|
let mut dst = ClawBrain::open_or_create(dest_brain, reference)?;
|
||||||
if let Some(s) = src.system_prompt() { dst.set_system_prompt(&s)?; }
|
if let Some(s) = src.system_prompt() {
|
||||||
if let Some(a) = src.agent_md() { dst.set_agent_md(&a)?; }
|
dst.set_system_prompt(&s)?;
|
||||||
if let Some(p) = src.personality() { dst.set_personality(&p)?; }
|
}
|
||||||
for (n, b) in src.skills() { dst.set_skill(&n, &b)?; }
|
if let Some(a) = src.agent_md() {
|
||||||
for (n, st) in src.tools() { dst.set_tool(&n, &st)?; }
|
dst.set_agent_md(&a)?;
|
||||||
if let Some(rt) = src.runtime() { let _ = dst.set_runtime(&rt); }
|
}
|
||||||
for (_, text) in src.recent_memory(200) { let _ = dst.remember("memory", &text, "brain-apply"); }
|
if let Some(p) = src.personality() {
|
||||||
|
dst.set_personality(&p)?;
|
||||||
|
}
|
||||||
|
for (n, b) in src.skills() {
|
||||||
|
dst.set_skill(&n, &b)?;
|
||||||
|
}
|
||||||
|
for (n, st) in src.tools() {
|
||||||
|
dst.set_tool(&n, &st)?;
|
||||||
|
}
|
||||||
|
if let Some(rt) = src.runtime() {
|
||||||
|
let _ = dst.set_runtime(&rt);
|
||||||
|
}
|
||||||
|
for (_, text) in src.recent_memory(200) {
|
||||||
|
let _ = dst.remember("memory", &text, "brain-apply");
|
||||||
|
}
|
||||||
dst.assembled_identity()
|
dst.assembled_identity()
|
||||||
};
|
};
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
@@ -249,7 +288,11 @@ pub async fn push(
|
|||||||
description: &str,
|
description: &str,
|
||||||
tags: &[String],
|
tags: &[String],
|
||||||
) -> Result<(), BrainError> {
|
) -> Result<(), BrainError> {
|
||||||
if std::env::var("BRAINHUB_API_KEY").unwrap_or_default().trim().is_empty() {
|
if std::env::var("BRAINHUB_API_KEY")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
return Err(BrainError::Backend(
|
return Err(BrainError::Backend(
|
||||||
"BRAINHUB_API_KEY is not set — cannot push to ClawBrainHub".into(),
|
"BRAINHUB_API_KEY is not set — cannot push to ClawBrainHub".into(),
|
||||||
));
|
));
|
||||||
@@ -271,7 +314,11 @@ pub async fn whoami() -> Result<String, BrainError> {
|
|||||||
/// Delete a brain (all versions) from the registry. Requires `BRAINHUB_API_KEY`
|
/// Delete a brain (all versions) from the registry. Requires `BRAINHUB_API_KEY`
|
||||||
/// and ownership of the namespace.
|
/// and ownership of the namespace.
|
||||||
pub async fn delete(reference: &str) -> Result<(), BrainError> {
|
pub async fn delete(reference: &str) -> Result<(), BrainError> {
|
||||||
if std::env::var("BRAINHUB_API_KEY").unwrap_or_default().trim().is_empty() {
|
if std::env::var("BRAINHUB_API_KEY")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
return Err(BrainError::Backend("BRAINHUB_API_KEY not set".into()));
|
return Err(BrainError::Backend("BRAINHUB_API_KEY not set".into()));
|
||||||
}
|
}
|
||||||
let client = BrainRegistryClient::new(config());
|
let client = BrainRegistryClient::new(config());
|
||||||
@@ -377,8 +424,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "hits live clawbrainhub.com"]
|
#[ignore = "hits live clawbrainhub.com"]
|
||||||
async fn live_pull_general_assistant() {
|
async fn live_pull_general_assistant() {
|
||||||
let dest =
|
let dest = std::env::temp_dir().join(format!("cm-brain-pull-{}.brain", std::process::id()));
|
||||||
std::env::temp_dir().join(format!("cm-brain-pull-{}.brain", std::process::id()));
|
|
||||||
let _ = std::fs::remove_file(&dest);
|
let _ = std::fs::remove_file(&dest);
|
||||||
let pulled = pull("redclawsystems/general-assistant", &dest)
|
let pulled = pull("redclawsystems/general-assistant", &dest)
|
||||||
.await
|
.await
|
||||||
@@ -400,19 +446,32 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "hits live clawbrainhub.com"]
|
#[ignore = "hits live clawbrainhub.com"]
|
||||||
async fn live_pull_merge_into_existing() {
|
async fn live_pull_merge_into_existing() {
|
||||||
let dest = std::env::temp_dir().join(format!("cm-brain-merge-{}.brain", std::process::id()));
|
let dest =
|
||||||
|
std::env::temp_dir().join(format!("cm-brain-merge-{}.brain", std::process::id()));
|
||||||
let _ = std::fs::remove_file(&dest);
|
let _ = std::fs::remove_file(&dest);
|
||||||
{
|
{
|
||||||
let mut b = ClawBrain::open_or_create(&dest, "x").unwrap();
|
let mut b = ClawBrain::open_or_create(&dest, "x").unwrap();
|
||||||
b.set_skill("existing", "keep me").unwrap();
|
b.set_skill("existing", "keep me").unwrap();
|
||||||
}
|
}
|
||||||
let pulled = pull_merge("redclawsystems/general-assistant", &dest).await.expect("merge");
|
let pulled = pull_merge("redclawsystems/general-assistant", &dest)
|
||||||
assert!(!pulled.system_prompt.is_empty(), "assembled identity returned");
|
.await
|
||||||
|
.expect("merge");
|
||||||
|
assert!(
|
||||||
|
!pulled.system_prompt.is_empty(),
|
||||||
|
"assembled identity returned"
|
||||||
|
);
|
||||||
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
|
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
|
||||||
let names: Vec<String> = b.skills().into_iter().map(|(n, _)| n).collect();
|
let names: Vec<String> = b.skills().into_iter().map(|(n, _)| n).collect();
|
||||||
assert!(names.iter().any(|n| n == "existing"), "existing skill preserved; got {names:?}");
|
assert!(
|
||||||
|
names.iter().any(|n| n == "existing"),
|
||||||
|
"existing skill preserved; got {names:?}"
|
||||||
|
);
|
||||||
assert!(names.len() > 1, "merged skills added; got {names:?}");
|
assert!(names.len() > 1, "merged skills added; got {names:?}");
|
||||||
eprintln!("merged → {} skills, {} memories", names.len(), b.memory_count());
|
eprintln!(
|
||||||
|
"merged → {} skills, {} memories",
|
||||||
|
names.len(),
|
||||||
|
b.memory_count()
|
||||||
|
);
|
||||||
let _ = std::fs::remove_file(&dest);
|
let _ = std::fs::remove_file(&dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,9 +479,16 @@ mod tests {
|
|||||||
#[ignore = "hits live clawbrainhub.com"]
|
#[ignore = "hits live clawbrainhub.com"]
|
||||||
async fn live_list() {
|
async fn live_list() {
|
||||||
let items = list("").await.expect("list");
|
let items = list("").await.expect("list");
|
||||||
eprintln!("registry browse returned {} brains: {:?}", items.len(), items.iter().map(|b| &b.reference).collect::<Vec<_>>());
|
eprintln!(
|
||||||
|
"registry browse returned {} brains: {:?}",
|
||||||
|
items.len(),
|
||||||
|
items.iter().map(|b| &b.reference).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
assert!(!items.is_empty(), "seed-browse should surface brains");
|
assert!(!items.is_empty(), "seed-browse should surface brains");
|
||||||
assert!(items.iter().any(|b| b.reference == "omar/react-native"), "react-native should appear");
|
assert!(
|
||||||
|
items.iter().any(|b| b.reference == "omar/react-native"),
|
||||||
|
"react-native should appear"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -436,8 +502,20 @@ mod tests {
|
|||||||
let sp = b.system_prompt().unwrap_or_default();
|
let sp = b.system_prompt().unwrap_or_default();
|
||||||
let am = b.agent_md().unwrap_or_default();
|
let am = b.agent_md().unwrap_or_default();
|
||||||
let pe = b.personality().unwrap_or_default();
|
let pe = b.personality().unwrap_or_default();
|
||||||
let sk = b.skills().into_iter().map(|(n, bd)| format!("## {n}\n{bd}")).collect::<Vec<_>>().join("\n\n");
|
let sk = b
|
||||||
eprintln!("{r}: system_prompt={} agent_md={} persona={} skills_md={} chars (skills={})", sp.len(), am.len(), pe.len(), sk.len(), b.skills().len());
|
.skills()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, bd)| format!("## {n}\n{bd}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
eprintln!(
|
||||||
|
"{r}: system_prompt={} agent_md={} persona={} skills_md={} chars (skills={})",
|
||||||
|
sp.len(),
|
||||||
|
am.len(),
|
||||||
|
pe.len(),
|
||||||
|
sk.len(),
|
||||||
|
b.skills().len()
|
||||||
|
);
|
||||||
let payload = format!("BRAIN: {r}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{am}\n\n=== PERSONA ===\n{pe}\n\n=== SKILLS ===\n{sk}");
|
let payload = format!("BRAIN: {r}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{am}\n\n=== PERSONA ===\n{pe}\n\n=== SKILLS ===\n{sk}");
|
||||||
std::fs::write("/tmp/brain-dump.txt", &payload).unwrap();
|
std::fs::write("/tmp/brain-dump.txt", &payload).unwrap();
|
||||||
eprintln!("wrote /tmp/brain-dump.txt ({} chars)", payload.len());
|
eprintln!("wrote /tmp/brain-dump.txt ({} chars)", payload.len());
|
||||||
@@ -447,7 +525,20 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "hits live clawbrainhub.com — read-only scan"]
|
#[ignore = "hits live clawbrainhub.com — read-only scan"]
|
||||||
async fn live_scan_empty_brains() {
|
async fn live_scan_empty_brains() {
|
||||||
let queries = ["", "omar", "assistant", "default", "test", "claw", "agent", "brain", "react", "general", "data", "code"];
|
let queries = [
|
||||||
|
"",
|
||||||
|
"omar",
|
||||||
|
"assistant",
|
||||||
|
"default",
|
||||||
|
"test",
|
||||||
|
"claw",
|
||||||
|
"agent",
|
||||||
|
"brain",
|
||||||
|
"react",
|
||||||
|
"general",
|
||||||
|
"data",
|
||||||
|
"code",
|
||||||
|
];
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for q in queries {
|
for q in queries {
|
||||||
for it in list(q).await.unwrap_or_default() {
|
for it in list(q).await.unwrap_or_default() {
|
||||||
@@ -472,9 +563,15 @@ mod tests {
|
|||||||
async fn live_apply_react_native_hdf5() {
|
async fn live_apply_react_native_hdf5() {
|
||||||
let dest = std::env::temp_dir().join(format!("cm-brain-rn-{}.brain", std::process::id()));
|
let dest = std::env::temp_dir().join(format!("cm-brain-rn-{}.brain", std::process::id()));
|
||||||
let _ = std::fs::remove_file(&dest);
|
let _ = std::fs::remove_file(&dest);
|
||||||
let pulled = pull_merge("omar/react-native", &dest).await.expect("merge HDF5 brain");
|
let pulled = pull_merge("omar/react-native", &dest)
|
||||||
|
.await
|
||||||
|
.expect("merge HDF5 brain");
|
||||||
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
|
let b = ClawBrain::open_or_create(&dest, "x").unwrap();
|
||||||
eprintln!("react-native → system_prompt {} chars, {} skills", pulled.system_prompt.len(), b.skills().len());
|
eprintln!(
|
||||||
|
"react-native → system_prompt {} chars, {} skills",
|
||||||
|
pulled.system_prompt.len(),
|
||||||
|
b.skills().len()
|
||||||
|
);
|
||||||
let _ = std::fs::remove_file(&dest);
|
let _ = std::fs::remove_file(&dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-31
@@ -12,8 +12,8 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use claw_brain::{index_entry, keyword_search, BrainHandle};
|
|
||||||
pub use claw_brain::RevisionInfo;
|
pub use claw_brain::RevisionInfo;
|
||||||
|
use claw_brain::{index_entry, keyword_search, BrainHandle};
|
||||||
|
|
||||||
pub mod hub;
|
pub mod hub;
|
||||||
|
|
||||||
@@ -99,7 +99,10 @@ impl ClawBrain {
|
|||||||
self.h.flush().map_err(be)
|
self.h.flush().map_err(be)
|
||||||
}
|
}
|
||||||
fn get(&self, key: &str) -> Option<String> {
|
fn get(&self, key: &str) -> Option<String> {
|
||||||
self.h.read(key).ok().and_then(|b| String::from_utf8(b).ok())
|
self.h
|
||||||
|
.read(key)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| String::from_utf8(b).ok())
|
||||||
}
|
}
|
||||||
fn del(&self, key: &str) -> Result<bool, BrainError> {
|
fn del(&self, key: &str) -> Result<bool, BrainError> {
|
||||||
if self.h.read(key).is_err() {
|
if self.h.read(key).is_err() {
|
||||||
@@ -126,7 +129,9 @@ impl ClawBrain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── identity ──────────────────────────────────────────────────────────────
|
// ── identity ──────────────────────────────────────────────────────────────
|
||||||
pub fn set_system_prompt(&mut self, s: &str) -> Result<(), BrainError> { self.put(K_SYSTEM_PROMPT, s) }
|
pub fn set_system_prompt(&mut self, s: &str) -> Result<(), BrainError> {
|
||||||
|
self.put(K_SYSTEM_PROMPT, s)
|
||||||
|
}
|
||||||
/// System prompt, falling back to the brain-pack `soul_md` (pulled brains
|
/// System prompt, falling back to the brain-pack `soul_md` (pulled brains
|
||||||
/// often carry the identity there with an empty `system_prompt`).
|
/// often carry the identity there with an empty `system_prompt`).
|
||||||
pub fn system_prompt(&self) -> Option<String> {
|
pub fn system_prompt(&self) -> Option<String> {
|
||||||
@@ -135,11 +140,19 @@ impl ClawBrain {
|
|||||||
_ => self.get(K_SOUL).filter(|s| !s.trim().is_empty()),
|
_ => self.get(K_SOUL).filter(|s| !s.trim().is_empty()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn set_personality(&mut self, s: &str) -> Result<(), BrainError> { self.put(K_PERSONA, s) }
|
pub fn set_personality(&mut self, s: &str) -> Result<(), BrainError> {
|
||||||
pub fn personality(&self) -> Option<String> { self.get(K_PERSONA).filter(|s| !s.trim().is_empty()) }
|
self.put(K_PERSONA, s)
|
||||||
|
}
|
||||||
|
pub fn personality(&self) -> Option<String> {
|
||||||
|
self.get(K_PERSONA).filter(|s| !s.trim().is_empty())
|
||||||
|
}
|
||||||
/// AGENTS.md — "how I operate" (workflow/rules), prepended to the prompt.
|
/// AGENTS.md — "how I operate" (workflow/rules), prepended to the prompt.
|
||||||
pub fn set_agent_md(&mut self, s: &str) -> Result<(), BrainError> { self.put(K_AGENT_MD, s) }
|
pub fn set_agent_md(&mut self, s: &str) -> Result<(), BrainError> {
|
||||||
pub fn agent_md(&self) -> Option<String> { self.get(K_AGENT_MD).filter(|s| !s.trim().is_empty()) }
|
self.put(K_AGENT_MD, s)
|
||||||
|
}
|
||||||
|
pub fn agent_md(&self) -> Option<String> {
|
||||||
|
self.get(K_AGENT_MD).filter(|s| !s.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Assemble the agent's full system prompt from the canonical identity files
|
/// Assemble the agent's full system prompt from the canonical identity files
|
||||||
/// (mirrors ZeroClaw's personality render): `system_prompt`‖`soul_md`, then
|
/// (mirrors ZeroClaw's personality render): `system_prompt`‖`soul_md`, then
|
||||||
@@ -150,12 +163,16 @@ impl ClawBrain {
|
|||||||
out.push_str(&sp);
|
out.push_str(&sp);
|
||||||
}
|
}
|
||||||
if let Some(a) = self.agent_md() {
|
if let Some(a) = self.agent_md() {
|
||||||
if !out.is_empty() { out.push_str("\n\n"); }
|
if !out.is_empty() {
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
out.push_str("## How I operate\n");
|
out.push_str("## How I operate\n");
|
||||||
out.push_str(&a);
|
out.push_str(&a);
|
||||||
}
|
}
|
||||||
if let Some(p) = self.personality() {
|
if let Some(p) = self.personality() {
|
||||||
if !out.is_empty() { out.push_str("\n\n"); }
|
if !out.is_empty() {
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
out.push_str("## Personality\n");
|
out.push_str("## Personality\n");
|
||||||
out.push_str(&p);
|
out.push_str(&p);
|
||||||
}
|
}
|
||||||
@@ -163,11 +180,17 @@ impl ClawBrain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── skills ────────────────────────────────────────────────────────────────
|
// ── skills ────────────────────────────────────────────────────────────────
|
||||||
pub fn set_skill(&mut self, name: &str, body: &str) -> Result<(), BrainError> { self.put(&format!("{P_SKILL}{name}"), body) }
|
pub fn set_skill(&mut self, name: &str, body: &str) -> Result<(), BrainError> {
|
||||||
|
self.put(&format!("{P_SKILL}{name}"), body)
|
||||||
|
}
|
||||||
/// Set the brain-pack narrative skills doc (`skills/skills_md`); `skills()`
|
/// Set the brain-pack narrative skills doc (`skills/skills_md`); `skills()`
|
||||||
/// parses its `## <name>` sections.
|
/// parses its `## <name>` sections.
|
||||||
pub fn set_skills_md(&mut self, md: &str) -> Result<(), BrainError> { self.put(K_SKILLS_MD, md) }
|
pub fn set_skills_md(&mut self, md: &str) -> Result<(), BrainError> {
|
||||||
pub fn remove_skill(&mut self, name: &str) -> Result<bool, BrainError> { self.del(&format!("{P_SKILL}{name}")) }
|
self.put(K_SKILLS_MD, md)
|
||||||
|
}
|
||||||
|
pub fn remove_skill(&mut self, name: &str) -> Result<bool, BrainError> {
|
||||||
|
self.del(&format!("{P_SKILL}{name}"))
|
||||||
|
}
|
||||||
/// All skills as `(name, body)` — both per-skill keys and, if present, the
|
/// All skills as `(name, body)` — both per-skill keys and, if present, the
|
||||||
/// brain-pack narrative `skills/skills_md` parsed into `## <name>` sections.
|
/// brain-pack narrative `skills/skills_md` parsed into `## <name>` sections.
|
||||||
pub fn skills(&self) -> Vec<(String, String)> {
|
pub fn skills(&self) -> Vec<(String, String)> {
|
||||||
@@ -187,19 +210,38 @@ impl ClawBrain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── tools / doors (value = state, e.g. "gated" | "blocked") ────────────────
|
// ── tools / doors (value = state, e.g. "gated" | "blocked") ────────────────
|
||||||
pub fn set_tool(&mut self, name: &str, state: &str) -> Result<(), BrainError> { self.put(&format!("{P_TOOL}{name}"), state) }
|
pub fn set_tool(&mut self, name: &str, state: &str) -> Result<(), BrainError> {
|
||||||
pub fn remove_tool(&mut self, name: &str) -> Result<bool, BrainError> { self.del(&format!("{P_TOOL}{name}")) }
|
self.put(&format!("{P_TOOL}{name}"), state)
|
||||||
pub fn tools(&self) -> Vec<(String, String)> { self.list(P_TOOL) }
|
}
|
||||||
|
pub fn remove_tool(&mut self, name: &str) -> Result<bool, BrainError> {
|
||||||
|
self.del(&format!("{P_TOOL}{name}"))
|
||||||
|
}
|
||||||
|
pub fn tools(&self) -> Vec<(String, String)> {
|
||||||
|
self.list(P_TOOL)
|
||||||
|
}
|
||||||
|
|
||||||
// ── runtime + provenance (opaque JSON blobs) ───────────────────────────────
|
// ── runtime + provenance (opaque JSON blobs) ───────────────────────────────
|
||||||
pub fn set_runtime(&mut self, json: &str) -> Result<(), BrainError> { self.put(K_RUNTIME, json) }
|
pub fn set_runtime(&mut self, json: &str) -> Result<(), BrainError> {
|
||||||
pub fn runtime(&self) -> Option<String> { self.get(K_RUNTIME) }
|
self.put(K_RUNTIME, json)
|
||||||
pub fn set_provenance(&mut self, json: &str) -> Result<(), BrainError> { self.put(K_PROVENANCE, json) }
|
}
|
||||||
pub fn provenance(&self) -> Option<String> { self.get(K_PROVENANCE) }
|
pub fn runtime(&self) -> Option<String> {
|
||||||
|
self.get(K_RUNTIME)
|
||||||
|
}
|
||||||
|
pub fn set_provenance(&mut self, json: &str) -> Result<(), BrainError> {
|
||||||
|
self.put(K_PROVENANCE, json)
|
||||||
|
}
|
||||||
|
pub fn provenance(&self) -> Option<String> {
|
||||||
|
self.get(K_PROVENANCE)
|
||||||
|
}
|
||||||
|
|
||||||
// ── conversational memory ──────────────────────────────────────────────────
|
// ── conversational memory ──────────────────────────────────────────────────
|
||||||
/// Append a turn to the brain's memory (keyword-indexed, recallable later).
|
/// Append a turn to the brain's memory (keyword-indexed, recallable later).
|
||||||
pub fn remember(&mut self, role: &str, text: &str, _session_id: &str) -> Result<(), BrainError> {
|
pub fn remember(
|
||||||
|
&mut self,
|
||||||
|
role: &str,
|
||||||
|
text: &str,
|
||||||
|
_session_id: &str,
|
||||||
|
) -> Result<(), BrainError> {
|
||||||
let key = format!("{P_MEMORY}{:020}", now_nanos());
|
let key = format!("{P_MEMORY}{:020}", now_nanos());
|
||||||
let chunk = format!("{role}: {text}");
|
let chunk = format!("{role}: {text}");
|
||||||
self.h.write(&key, chunk.into_bytes()).map_err(be)?;
|
self.h.write(&key, chunk.into_bytes()).map_err(be)?;
|
||||||
@@ -227,9 +269,13 @@ impl ClawBrain {
|
|||||||
.h
|
.h
|
||||||
.keys()
|
.keys()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|key| key.strip_prefix(P_MEMORY).and_then(|s| s.parse::<u128>().ok()).map(|n| (n, key)))
|
.filter_map(|key| {
|
||||||
|
key.strip_prefix(P_MEMORY)
|
||||||
|
.and_then(|s| s.parse::<u128>().ok())
|
||||||
|
.map(|n| (n, key))
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
keys.sort_by(|a, b| b.0.cmp(&a.0));
|
keys.sort_by_key(|k| std::cmp::Reverse(k.0));
|
||||||
keys.into_iter()
|
keys.into_iter()
|
||||||
.take(k)
|
.take(k)
|
||||||
.filter_map(|(n, key)| self.get(&key).map(|t| (n as f64 / 1e9, t)))
|
.filter_map(|(n, key)| self.get(&key).map(|t| (n as f64 / 1e9, t)))
|
||||||
@@ -237,7 +283,11 @@ impl ClawBrain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn memory_count(&self) -> usize {
|
pub fn memory_count(&self) -> usize {
|
||||||
self.h.keys().iter().filter(|k| k.starts_with(P_MEMORY)).count()
|
self.h
|
||||||
|
.keys()
|
||||||
|
.iter()
|
||||||
|
.filter(|k| k.starts_with(P_MEMORY))
|
||||||
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render identity + skills as Markdown (for ZeroClaw workspace hydration).
|
/// Render identity + skills as Markdown (for ZeroClaw workspace hydration).
|
||||||
@@ -301,23 +351,42 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&p);
|
let _ = std::fs::remove_file(&p);
|
||||||
{
|
{
|
||||||
let mut b = ClawBrain::open_or_create(&p, "agent-x").expect("create");
|
let mut b = ClawBrain::open_or_create(&p, "agent-x").expect("create");
|
||||||
b.set_system_prompt("You are Atlas, a meticulous planner.").unwrap();
|
b.set_system_prompt("You are Atlas, a meticulous planner.")
|
||||||
|
.unwrap();
|
||||||
b.set_personality("calm, precise, terse").unwrap();
|
b.set_personality("calm, precise, terse").unwrap();
|
||||||
b.set_skill("python", "Write idiomatic, tested Python.").unwrap();
|
b.set_skill("python", "Write idiomatic, tested Python.")
|
||||||
|
.unwrap();
|
||||||
b.set_tool("browser", "gated").unwrap();
|
b.set_tool("browser", "gated").unwrap();
|
||||||
b.set_runtime(r#"{"model":"claude","risk":"toolfree"}"#).unwrap();
|
b.set_runtime(r#"{"model":"claude","risk":"toolfree"}"#)
|
||||||
b.remember("user", "My favorite color is teal.", "s1").unwrap();
|
.unwrap();
|
||||||
|
b.remember("user", "My favorite color is teal.", "s1")
|
||||||
|
.unwrap();
|
||||||
b.remember("user", "I live in Boston.", "s1").unwrap();
|
b.remember("user", "I live in Boston.", "s1").unwrap();
|
||||||
}
|
}
|
||||||
let b = ClawBrain::open_or_create(&p, "agent-x").expect("open");
|
let b = ClawBrain::open_or_create(&p, "agent-x").expect("open");
|
||||||
assert_eq!(b.system_prompt().as_deref(), Some("You are Atlas, a meticulous planner."));
|
assert_eq!(
|
||||||
|
b.system_prompt().as_deref(),
|
||||||
|
Some("You are Atlas, a meticulous planner.")
|
||||||
|
);
|
||||||
assert_eq!(b.personality().as_deref(), Some("calm, precise, terse"));
|
assert_eq!(b.personality().as_deref(), Some("calm, precise, terse"));
|
||||||
assert_eq!(b.skills(), vec![("python".to_string(), "Write idiomatic, tested Python.".to_string())]);
|
assert_eq!(
|
||||||
assert_eq!(b.tools(), vec![("browser".to_string(), "gated".to_string())]);
|
b.skills(),
|
||||||
|
vec![(
|
||||||
|
"python".to_string(),
|
||||||
|
"Write idiomatic, tested Python.".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
b.tools(),
|
||||||
|
vec![("browser".to_string(), "gated".to_string())]
|
||||||
|
);
|
||||||
assert!(b.runtime().unwrap().contains("toolfree"));
|
assert!(b.runtime().unwrap().contains("toolfree"));
|
||||||
assert_eq!(b.memory_count(), 2);
|
assert_eq!(b.memory_count(), 2);
|
||||||
let hits = b.recall("what is my favorite color", 3);
|
let hits = b.recall("what is my favorite color", 3);
|
||||||
assert!(hits.iter().any(|h| h.contains("teal")), "recall should surface teal; got {hits:?}");
|
assert!(
|
||||||
|
hits.iter().any(|h| h.contains("teal")),
|
||||||
|
"recall should surface teal; got {hits:?}"
|
||||||
|
);
|
||||||
let _ = std::fs::remove_file(&p);
|
let _ = std::fs::remove_file(&p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,11 +114,7 @@ pub async fn reset_sessions(pool: &PgPool, kind: &str) -> Result<(), DbError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Session-less containers untouched for longer than `idle_secs` (0 = all).
|
/// Session-less containers untouched for longer than `idle_secs` (0 = all).
|
||||||
pub async fn idle(
|
pub async fn idle(pool: &PgPool, kind: &str, idle_secs: i64) -> Result<Vec<ManagedRow>, DbError> {
|
||||||
pool: &PgPool,
|
|
||||||
kind: &str,
|
|
||||||
idle_secs: i64,
|
|
||||||
) -> Result<Vec<ManagedRow>, DbError> {
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT agent_id, node_id, container_id, name FROM agent_containers
|
"SELECT agent_id, node_id, container_id, name FROM agent_containers
|
||||||
WHERE kind = $1 AND session_count <= 0
|
WHERE kind = $1 AND session_count <= 0
|
||||||
@@ -133,10 +129,12 @@ pub async fn idle(
|
|||||||
|
|
||||||
/// Every recorded container of a kind (for reconcile + shutdown).
|
/// Every recorded container of a kind (for reconcile + shutdown).
|
||||||
pub async fn all(pool: &PgPool, kind: &str) -> Result<Vec<ManagedRow>, DbError> {
|
pub async fn all(pool: &PgPool, kind: &str) -> Result<Vec<ManagedRow>, DbError> {
|
||||||
let rows = sqlx::query("SELECT agent_id, node_id, container_id, name FROM agent_containers WHERE kind = $1")
|
let rows = sqlx::query(
|
||||||
.bind(kind)
|
"SELECT agent_id, node_id, container_id, name FROM agent_containers WHERE kind = $1",
|
||||||
.fetch_all(pool)
|
)
|
||||||
.await?;
|
.bind(kind)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
Ok(rows.into_iter().map(map_managed).collect())
|
Ok(rows.into_iter().map(map_managed).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,14 +296,18 @@ pub async fn hard_purge(pool: &PgPool, agent_id: AgentId) -> Result<PurgeCounts,
|
|||||||
.bind(aid)
|
.bind(aid)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE agent_id = $1)")
|
sqlx::query(
|
||||||
.bind(aid)
|
"DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE agent_id = $1)",
|
||||||
.execute(&mut *tx)
|
)
|
||||||
.await?;
|
.bind(aid)
|
||||||
sqlx::query("DELETE FROM agent_runs WHERE session_id IN (SELECT id FROM sessions WHERE agent_id = $1)")
|
.execute(&mut *tx)
|
||||||
.bind(aid)
|
.await?;
|
||||||
.execute(&mut *tx)
|
sqlx::query(
|
||||||
.await?;
|
"DELETE FROM agent_runs WHERE session_id IN (SELECT id FROM sessions WHERE agent_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(aid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
c.sessions = sqlx::query("DELETE FROM sessions WHERE agent_id = $1")
|
c.sessions = sqlx::query("DELETE FROM sessions WHERE agent_id = $1")
|
||||||
.bind(aid)
|
.bind(aid)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -325,10 +329,22 @@ pub async fn hard_purge(pool: &PgPool, agent_id: AgentId) -> Result<PurgeCounts,
|
|||||||
.rows_affected();
|
.rows_affected();
|
||||||
|
|
||||||
// Inter-agent threads, queued mail, oauth flows.
|
// Inter-agent threads, queued mail, oauth flows.
|
||||||
sqlx::query("DELETE FROM thread_messages WHERE from_agent = $1").bind(aid).execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM thread_messages WHERE from_agent = $1")
|
||||||
sqlx::query("DELETE FROM thread_participants WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
|
.bind(aid)
|
||||||
sqlx::query("DELETE FROM outbox WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
|
.execute(&mut *tx)
|
||||||
sqlx::query("DELETE FROM oauth_states WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM thread_participants WHERE agent_id = $1")
|
||||||
|
.bind(aid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM outbox WHERE agent_id = $1")
|
||||||
|
.bind(aid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM oauth_states WHERE agent_id = $1")
|
||||||
|
.bind(aid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
c.connections = sqlx::query("DELETE FROM app_connections WHERE agent_id = $1")
|
c.connections = sqlx::query("DELETE FROM app_connections WHERE agent_id = $1")
|
||||||
.bind(aid)
|
.bind(aid)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -339,7 +355,10 @@ pub async fn hard_purge(pool: &PgPool, agent_id: AgentId) -> Result<PurgeCounts,
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
sqlx::query("DELETE FROM usage_events WHERE agent_id = $1").bind(aid).execute(&mut *tx).await?;
|
sqlx::query("DELETE FROM usage_events WHERE agent_id = $1")
|
||||||
|
.bind(aid)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Finally the agent itself (cascades the rest).
|
// Finally the agent itself (cascades the rest).
|
||||||
let n = sqlx::query("DELETE FROM agents WHERE id = $1")
|
let n = sqlx::query("DELETE FROM agents WHERE id = $1")
|
||||||
|
|||||||
@@ -70,13 +70,15 @@ pub async fn set_topology(
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
graph: &Value,
|
graph: &Value,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let res = sqlx::query("UPDATE companies SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2")
|
let res = sqlx::query(
|
||||||
.bind(id)
|
"UPDATE companies SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2",
|
||||||
.bind(workspace_id.as_uuid())
|
)
|
||||||
.bind(kind)
|
.bind(id)
|
||||||
.bind(graph)
|
.bind(workspace_id.as_uuid())
|
||||||
.execute(pool)
|
.bind(kind)
|
||||||
.await?;
|
.bind(graph)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
if res.rows_affected() == 0 {
|
if res.rows_affected() == 0 {
|
||||||
return Err(DbError::NotFound);
|
return Err(DbError::NotFound);
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,11 @@ pub async fn set_topology(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a company and its node→team bindings (teams themselves remain).
|
/// Delete a company and its node→team bindings (teams themselves remain).
|
||||||
pub async fn delete_company(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
|
pub async fn delete_company(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
sqlx::query("DELETE FROM company_teams WHERE company_id = $1")
|
sqlx::query("DELETE FROM company_teams WHERE company_id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -39,15 +39,13 @@ pub async fn set(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a workspace's stored Beszel connection, if any.
|
/// Get a workspace's stored Beszel connection, if any.
|
||||||
pub async fn get(
|
pub async fn get(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Option<BeszelConn>, DbError> {
|
||||||
pool: &PgPool,
|
let row = sqlx::query(
|
||||||
workspace_id: WorkspaceId,
|
"SELECT hub_url, username, password FROM workspace_beszel WHERE workspace_id = $1",
|
||||||
) -> Result<Option<BeszelConn>, DbError> {
|
)
|
||||||
let row =
|
.bind(workspace_id.as_uuid())
|
||||||
sqlx::query("SELECT hub_url, username, password FROM workspace_beszel WHERE workspace_id = $1")
|
.fetch_optional(pool)
|
||||||
.bind(workspace_id.as_uuid())
|
.await?;
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| BeszelConn {
|
Ok(row.map(|r| BeszelConn {
|
||||||
hub_url: r.get("hub_url"),
|
hub_url: r.get("hub_url"),
|
||||||
username: r.get("username"),
|
username: r.get("username"),
|
||||||
@@ -57,9 +55,10 @@ pub async fn get(
|
|||||||
|
|
||||||
/// Every workspace with a Beszel hub connected (for the background poll task).
|
/// Every workspace with a Beszel hub connected (for the background poll task).
|
||||||
pub async fn all(pool: &PgPool) -> Result<Vec<(WorkspaceId, BeszelConn)>, DbError> {
|
pub async fn all(pool: &PgPool) -> Result<Vec<(WorkspaceId, BeszelConn)>, DbError> {
|
||||||
let rows = sqlx::query("SELECT workspace_id, hub_url, username, password FROM workspace_beszel")
|
let rows =
|
||||||
.fetch_all(pool)
|
sqlx::query("SELECT workspace_id, hub_url, username, password FROM workspace_beszel")
|
||||||
.await?;
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ pub async fn get(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
) -> Result<Option<(String, String)>, DbError> {
|
) -> Result<Option<(String, String)>, DbError> {
|
||||||
let row = sqlx::query("SELECT api_key, tailnet FROM workspace_tailscale WHERE workspace_id = $1")
|
let row =
|
||||||
.bind(workspace_id.as_uuid())
|
sqlx::query("SELECT api_key, tailnet FROM workspace_tailscale WHERE workspace_id = $1")
|
||||||
.fetch_optional(pool)
|
.bind(workspace_id.as_uuid())
|
||||||
.await?;
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
Ok(row.map(|r| (r.get("api_key"), r.get("tailnet"))))
|
Ok(row.map(|r| (r.get("api_key"), r.get("tailnet"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,10 @@ pub async fn latest(pool: &PgPool, node_id: NodeId) -> Result<Option<Value>, DbE
|
|||||||
Ok(row.map(|r| {
|
Ok(row.map(|r| {
|
||||||
let mut data: Value = r.get("data");
|
let mut data: Value = r.get("data");
|
||||||
if let Some(obj) = data.as_object_mut() {
|
if let Some(obj) = data.as_object_mut() {
|
||||||
obj.insert("updatedAt".into(), serde_json::json!(r.get::<i64, _>("updated")));
|
obj.insert(
|
||||||
|
"updatedAt".into(),
|
||||||
|
serde_json::json!(r.get::<i64, _>("updated")),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
data
|
data
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -69,13 +69,14 @@ pub async fn set_topology(
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
graph: &Value,
|
graph: &Value,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let res = sqlx::query("UPDATE teams SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2")
|
let res =
|
||||||
.bind(id)
|
sqlx::query("UPDATE teams SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2")
|
||||||
.bind(workspace_id.as_uuid())
|
.bind(id)
|
||||||
.bind(kind)
|
.bind(workspace_id.as_uuid())
|
||||||
.bind(graph)
|
.bind(kind)
|
||||||
.execute(pool)
|
.bind(graph)
|
||||||
.await?;
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
if res.rows_affected() == 0 {
|
if res.rows_affected() == 0 {
|
||||||
return Err(DbError::NotFound);
|
return Err(DbError::NotFound);
|
||||||
}
|
}
|
||||||
@@ -83,7 +84,11 @@ pub async fn set_topology(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a team and its node→claw bindings.
|
/// Delete a team and its node→claw bindings.
|
||||||
pub async fn delete_team(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
|
pub async fn delete_team(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
sqlx::query("DELETE FROM team_members WHERE team_id = $1")
|
sqlx::query("DELETE FROM team_members WHERE team_id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ impl SandboxManager {
|
|||||||
return self.node_id.clone();
|
return self.node_id.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.node_provider.as_ref().and_then(|p| p.driver(&node)).is_some() {
|
if self
|
||||||
|
.node_provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.driver(&node))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
self.node_id.clone()
|
self.node_id.clone()
|
||||||
@@ -260,7 +265,8 @@ impl SandboxManager {
|
|||||||
if let Err(e) = driver.destroy(&handle).await {
|
if let Err(e) = driver.destroy(&handle).await {
|
||||||
eprintln!("sandbox release: failed to remove {}: {e}", handle.id);
|
eprintln!("sandbox release: failed to remove {}: {e}", handle.id);
|
||||||
}
|
}
|
||||||
let _ = cm_db::repo::agent_containers::delete(&self.db, agent_id, self.kind()).await;
|
let _ =
|
||||||
|
cm_db::repo::agent_containers::delete(&self.db, agent_id, self.kind()).await;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|||||||
@@ -123,7 +123,12 @@ impl TerminalManager {
|
|||||||
return self.node_id.clone();
|
return self.node_id.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.node_provider.as_ref().and_then(|p| p.driver(&node)).is_some() {
|
if self
|
||||||
|
.node_provider
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.driver(&node))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
self.node_id.clone()
|
self.node_id.clone()
|
||||||
@@ -136,7 +141,8 @@ impl TerminalManager {
|
|||||||
/// of an existing container, else the computed placement. For the ticket's
|
/// of an existing container, else the computed placement. For the ticket's
|
||||||
/// `node` hint (the browser uses the WebRTC-vs-WS transport accordingly).
|
/// `node` hint (the browser uses the WebRTC-vs-WS transport accordingly).
|
||||||
pub async fn placement_node_for(&self, agent_id: AgentId) -> String {
|
pub async fn placement_node_for(&self, agent_id: AgentId) -> String {
|
||||||
if let Ok(Some(row)) = cm_db::repo::agent_containers::get(&self.pool, agent_id, KIND).await {
|
if let Ok(Some(row)) = cm_db::repo::agent_containers::get(&self.pool, agent_id, KIND).await
|
||||||
|
{
|
||||||
return row.node_id;
|
return row.node_id;
|
||||||
}
|
}
|
||||||
self.placement_node(agent_id).await
|
self.placement_node(agent_id).await
|
||||||
@@ -226,8 +232,7 @@ impl TerminalManager {
|
|||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
agent_id: AgentId,
|
agent_id: AgentId,
|
||||||
) -> Result<(SandboxHandle, String), String> {
|
) -> Result<(SandboxHandle, String), String> {
|
||||||
if let Ok(Some(row)) =
|
if let Ok(Some(row)) = cm_db::repo::agent_containers::get(&self.pool, agent_id, KIND).await
|
||||||
cm_db::repo::agent_containers::get(&self.pool, agent_id, KIND).await
|
|
||||||
{
|
{
|
||||||
let driver = self.driver_for(&row.node_id);
|
let driver = self.driver_for(&row.node_id);
|
||||||
let handle = SandboxHandle {
|
let handle = SandboxHandle {
|
||||||
@@ -332,19 +337,16 @@ impl TerminalManager {
|
|||||||
|
|
||||||
/// Reap idle terminals (no live session, untouched past `idle_ttl`).
|
/// Reap idle terminals (no live session, untouched past `idle_ttl`).
|
||||||
async fn reap_idle(&self, idle_ttl: Duration) -> usize {
|
async fn reap_idle(&self, idle_ttl: Duration) -> usize {
|
||||||
let rows = match cm_db::repo::agent_containers::idle(
|
let rows =
|
||||||
&self.pool,
|
match cm_db::repo::agent_containers::idle(&self.pool, KIND, idle_ttl.as_secs() as i64)
|
||||||
KIND,
|
.await
|
||||||
idle_ttl.as_secs() as i64,
|
{
|
||||||
)
|
Ok(r) => r,
|
||||||
.await
|
Err(e) => {
|
||||||
{
|
eprintln!("terminal reaper: idle query failed: {e}");
|
||||||
Ok(r) => r,
|
return 0;
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("terminal reaper: idle query failed: {e}");
|
};
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut reaped = 0;
|
let mut reaped = 0;
|
||||||
for r in rows {
|
for r in rows {
|
||||||
let handle = SandboxHandle {
|
let handle = SandboxHandle {
|
||||||
@@ -362,7 +364,11 @@ impl TerminalManager {
|
|||||||
/// when `min_age` > 0, that are older than it). Registry-tracked terminals
|
/// when `min_age` > 0, that are older than it). Registry-tracked terminals
|
||||||
/// are left alone.
|
/// are left alone.
|
||||||
async fn reap_orphans(&self, min_age: Duration) -> usize {
|
async fn reap_orphans(&self, min_age: Duration) -> usize {
|
||||||
let managed = match self.driver.list_managed(SandboxKind::Terminal.label()).await {
|
let managed = match self
|
||||||
|
.driver
|
||||||
|
.list_managed(SandboxKind::Terminal.label())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("terminal reaper: list failed: {e}");
|
eprintln!("terminal reaper: list failed: {e}");
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ impl Tool for WebSearch {
|
|||||||
fn descriptor(&self) -> ToolDescriptor {
|
fn descriptor(&self) -> ToolDescriptor {
|
||||||
ToolDescriptor {
|
ToolDescriptor {
|
||||||
name: "web.search".into(),
|
name: "web.search".into(),
|
||||||
description: "Search the web for current information (research papers, news, docs) and return a \
|
description:
|
||||||
|
"Search the web for current information (research papers, news, docs) and return a \
|
||||||
grounded summary with source URLs."
|
grounded summary with source URLs."
|
||||||
.into(),
|
.into(),
|
||||||
input_schema: json!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": { "query": { "type": "string", "description": "What to search the web for" } },
|
"properties": { "query": { "type": "string", "description": "What to search the web for" } },
|
||||||
@@ -38,7 +39,12 @@ grounded summary with source URLs."
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<Value, String> {
|
async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<Value, String> {
|
||||||
let query = input.get("query").and_then(|q| q.as_str()).unwrap_or("").trim().to_string();
|
let query = input
|
||||||
|
.get("query")
|
||||||
|
.and_then(|q| q.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Err("query is required".into());
|
return Err("query is required".into());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ async fn shell_exec_runs_in_the_agent_sandbox_with_persistent_home() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let driver = DockerDriver::connect().expect("docker reachable");
|
let driver = DockerDriver::connect().expect("docker reachable");
|
||||||
let sandboxes = Arc::new(SandboxManager::new(Arc::new(driver), pool.clone(), "local", IMAGE));
|
let sandboxes = Arc::new(SandboxManager::new(
|
||||||
|
Arc::new(driver),
|
||||||
|
pool.clone(),
|
||||||
|
"local",
|
||||||
|
IMAGE,
|
||||||
|
));
|
||||||
let rt = Runtime::new(
|
let rt = Runtime::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
//! same API via `DOCKER_HOST`.
|
//! same API via `DOCKER_HOST`.
|
||||||
|
|
||||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||||
use bollard::models::{
|
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, MountVolumeOptions};
|
||||||
ContainerCreateBody, HostConfig, Mount, MountTypeEnum, MountVolumeOptions,
|
|
||||||
};
|
|
||||||
use bollard::query_parameters::{
|
use bollard::query_parameters::{
|
||||||
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
||||||
};
|
};
|
||||||
@@ -236,7 +234,11 @@ impl SandboxDriver for DockerDriver {
|
|||||||
&handle.id,
|
&handle.id,
|
||||||
CreateExecOptions {
|
CreateExecOptions {
|
||||||
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
|
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
|
||||||
env: if env.is_empty() { None } else { Some(env.to_vec()) },
|
env: if env.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(env.to_vec())
|
||||||
|
},
|
||||||
attach_stdin: Some(true),
|
attach_stdin: Some(true),
|
||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
@@ -276,9 +278,9 @@ impl SandboxDriver for DockerDriver {
|
|||||||
input,
|
input,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
StartExecResults::Detached => {
|
StartExecResults::Detached => Err(SandboxError::Engine(
|
||||||
Err(SandboxError::Engine("pty exec detached unexpectedly".into()))
|
"pty exec detached unexpectedly".into(),
|
||||||
}
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ impl SandboxKind {
|
|||||||
/// the window size can be resized.
|
/// the window size can be resized.
|
||||||
pub struct PtySession {
|
pub struct PtySession {
|
||||||
pub exec_id: String,
|
pub exec_id: String,
|
||||||
pub output: std::pin::Pin<Box<dyn futures::Stream<Item = Result<Vec<u8>, crate::SandboxError>> + Send>>,
|
pub output:
|
||||||
|
std::pin::Pin<Box<dyn futures::Stream<Item = Result<Vec<u8>, crate::SandboxError>> + Send>>,
|
||||||
pub input: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
|
pub input: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,16 @@ impl Scheduler {
|
|||||||
// Reschedule first: a firing failure must not stall the clock. A
|
// Reschedule first: a firing failure must not stall the clock. A
|
||||||
// one-shot routine (Scheduled mode, a specific date/time) fires once
|
// one-shot routine (Scheduled mode, a specific date/time) fires once
|
||||||
// and never reschedules.
|
// and never reschedules.
|
||||||
let one_shot = routine.action.get("one_shot").and_then(|v| v.as_bool()).unwrap_or(false);
|
let one_shot = routine
|
||||||
let next = if one_shot { None } else { next_occurrence(&routine.schedule_cron, now).ok() };
|
.action
|
||||||
|
.get("one_shot")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let next = if one_shot {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
next_occurrence(&routine.schedule_cron, now).ok()
|
||||||
|
};
|
||||||
routines::set_next_run(&self.pool, routine.id, next).await?;
|
routines::set_next_run(&self.pool, routine.id, next).await?;
|
||||||
|
|
||||||
let agent_id = cm_domain::AgentId::from(routine.agent_id);
|
let agent_id = cm_domain::AgentId::from(routine.agent_id);
|
||||||
|
|||||||
@@ -15,16 +15,28 @@ fn id(i: usize) -> String {
|
|||||||
|
|
||||||
fn star(n: usize, kind: EdgeKind) -> Vec<Edge> {
|
fn star(n: usize, kind: EdgeKind) -> Vec<Edge> {
|
||||||
(1..n)
|
(1..n)
|
||||||
.map(|i| Edge { from: id(0), to: id(i), kind })
|
.map(|i| Edge {
|
||||||
|
from: id(0),
|
||||||
|
to: id(i),
|
||||||
|
kind,
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chain(n: usize, close: bool) -> Vec<Edge> {
|
fn chain(n: usize, close: bool) -> Vec<Edge> {
|
||||||
let mut edges: Vec<Edge> = (0..n.saturating_sub(1))
|
let mut edges: Vec<Edge> = (0..n.saturating_sub(1))
|
||||||
.map(|i| Edge { from: id(i), to: id(i + 1), kind: EdgeKind::PipesTo })
|
.map(|i| Edge {
|
||||||
|
from: id(i),
|
||||||
|
to: id(i + 1),
|
||||||
|
kind: EdgeKind::PipesTo,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if close && n > 1 {
|
if close && n > 1 {
|
||||||
edges.push(Edge { from: id(n - 1), to: id(0), kind: EdgeKind::PipesTo });
|
edges.push(Edge {
|
||||||
|
from: id(n - 1),
|
||||||
|
to: id(0),
|
||||||
|
kind: EdgeKind::PipesTo,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
edges
|
edges
|
||||||
}
|
}
|
||||||
@@ -33,7 +45,11 @@ fn complete(n: usize, kind: EdgeKind) -> Vec<Edge> {
|
|||||||
let mut edges = Vec::new();
|
let mut edges = Vec::new();
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
for j in (i + 1)..n {
|
for j in (i + 1)..n {
|
||||||
edges.push(Edge { from: id(i), to: id(j), kind });
|
edges.push(Edge {
|
||||||
|
from: id(i),
|
||||||
|
to: id(j),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
edges
|
edges
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ pub fn metrics(g: &TopologyGraph) -> GraphMetrics {
|
|||||||
} else {
|
} else {
|
||||||
undirected_edges as f64 / (n as f64 * (n as f64 - 1.0) / 2.0)
|
undirected_edges as f64 / (n as f64 * (n as f64 - 1.0) / 2.0)
|
||||||
};
|
};
|
||||||
let avg_degree = if n == 0 { 0.0 } else { sum_deg as f64 / n as f64 };
|
let avg_degree = if n == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
sum_deg as f64 / n as f64
|
||||||
|
};
|
||||||
let hub_dominance = if sum_deg == 0 {
|
let hub_dominance = if sum_deg == 0 {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
@@ -92,7 +96,11 @@ pub fn metrics(g: &TopologyGraph) -> GraphMetrics {
|
|||||||
let possible = k * (k - 1) / 2;
|
let possible = k * (k - 1) / 2;
|
||||||
clustering_sum += links as f64 / possible as f64;
|
clustering_sum += links as f64 / possible as f64;
|
||||||
}
|
}
|
||||||
let clustering = if n == 0 { 0.0 } else { clustering_sum / n as f64 };
|
let clustering = if n == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
clustering_sum / n as f64
|
||||||
|
};
|
||||||
|
|
||||||
let (components, diameter) = components_and_diameter(&adj);
|
let (components, diameter) = components_and_diameter(&adj);
|
||||||
|
|
||||||
@@ -188,13 +196,21 @@ pub fn classify(g: &TopologyGraph) -> Classification {
|
|||||||
let is_tree = connected && m.undirected_edges + 1 == n && n >= 2;
|
let is_tree = connected && m.undirected_edges + 1 == n && n >= 2;
|
||||||
let is_path = is_tree && deg1 == 2 && deg2 == n.saturating_sub(2);
|
let is_path = is_tree && deg1 == 2 && deg2 == n.saturating_sub(2);
|
||||||
let is_star = is_tree && n >= 3 && m.max_degree == n - 1;
|
let is_star = is_tree && n >= 3 && m.max_degree == n - 1;
|
||||||
let is_cycle = connected && n >= 3 && degrees.iter().all(|&d| d == 2) && m.undirected_edges == n;
|
let is_cycle =
|
||||||
|
connected && n >= 3 && degrees.iter().all(|&d| d == 2) && m.undirected_edges == n;
|
||||||
|
|
||||||
let swarm_fit = (1.0 - (m.density - 0.45).abs() * 2.0).clamp(0.0, 1.0) * 0.7;
|
let swarm_fit = (1.0 - (m.density - 0.45).abs() * 2.0).clamp(0.0, 1.0) * 0.7;
|
||||||
|
|
||||||
let scores: [(TopologyKind, f64); 12] = [
|
let scores: [(TopologyKind, f64); 12] = [
|
||||||
(Mesh, m.density),
|
(Mesh, m.density),
|
||||||
(Flat, if m.undirected_edges == 0 { 1.0 } else { (1.0 - m.density) * 0.4 }),
|
(
|
||||||
|
Flat,
|
||||||
|
if m.undirected_edges == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(1.0 - m.density) * 0.4
|
||||||
|
},
|
||||||
|
),
|
||||||
(Pipeline, if is_path { 0.95 } else { 0.0 }),
|
(Pipeline, if is_path { 0.95 } else { 0.0 }),
|
||||||
(Ring, if is_cycle { 0.95 } else { 0.0 }),
|
(Ring, if is_cycle { 0.95 } else { 0.0 }),
|
||||||
(HubSpoke, if is_star { 0.90 } else { m.hub_dominance * 0.5 }),
|
(HubSpoke, if is_star { 0.90 } else { m.hub_dominance * 0.5 }),
|
||||||
@@ -251,26 +267,45 @@ mod tests {
|
|||||||
let g = graph(
|
let g = graph(
|
||||||
TopologyKind::Hierarchical,
|
TopologyKind::Hierarchical,
|
||||||
&["r", "a", "b", "a1", "a2", "b1", "b2"],
|
&["r", "a", "b", "a1", "a2", "b1", "b2"],
|
||||||
&[("r", "a"), ("r", "b"), ("a", "a1"), ("a", "a2"), ("b", "b1"), ("b", "b2")],
|
&[
|
||||||
|
("r", "a"),
|
||||||
|
("r", "b"),
|
||||||
|
("a", "a1"),
|
||||||
|
("a", "a2"),
|
||||||
|
("b", "b1"),
|
||||||
|
("b", "b2"),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert_eq!(classify(&g).primary, TopologyKind::Hierarchical);
|
assert_eq!(classify(&g).primary, TopologyKind::Hierarchical);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn line_is_pipeline() {
|
fn line_is_pipeline() {
|
||||||
let g = graph(TopologyKind::Pipeline, &["a", "b", "c", "d"], &[("a", "b"), ("b", "c"), ("c", "d")]);
|
let g = graph(
|
||||||
|
TopologyKind::Pipeline,
|
||||||
|
&["a", "b", "c", "d"],
|
||||||
|
&[("a", "b"), ("b", "c"), ("c", "d")],
|
||||||
|
);
|
||||||
assert_eq!(classify(&g).primary, TopologyKind::Pipeline);
|
assert_eq!(classify(&g).primary, TopologyKind::Pipeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cycle_is_ring() {
|
fn cycle_is_ring() {
|
||||||
let g = graph(TopologyKind::Ring, &["a", "b", "c", "d"], &[("a", "b"), ("b", "c"), ("c", "d"), ("d", "a")]);
|
let g = graph(
|
||||||
|
TopologyKind::Ring,
|
||||||
|
&["a", "b", "c", "d"],
|
||||||
|
&[("a", "b"), ("b", "c"), ("c", "d"), ("d", "a")],
|
||||||
|
);
|
||||||
assert_eq!(classify(&g).primary, TopologyKind::Ring);
|
assert_eq!(classify(&g).primary, TopologyKind::Ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn star_is_hub_spoke() {
|
fn star_is_hub_spoke() {
|
||||||
let g = graph(TopologyKind::HubSpoke, &["h", "s1", "s2", "s3", "s4"], &[("h", "s1"), ("h", "s2"), ("h", "s3"), ("h", "s4")]);
|
let g = graph(
|
||||||
|
TopologyKind::HubSpoke,
|
||||||
|
&["h", "s1", "s2", "s3", "s4"],
|
||||||
|
&[("h", "s1"), ("h", "s2"), ("h", "s3"), ("h", "s4")],
|
||||||
|
);
|
||||||
assert_eq!(classify(&g).primary, TopologyKind::HubSpoke);
|
assert_eq!(classify(&g).primary, TopologyKind::HubSpoke);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +332,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn metrics_are_sane_for_a_path() {
|
fn metrics_are_sane_for_a_path() {
|
||||||
let g = graph(TopologyKind::Pipeline, &["a", "b", "c"], &[("a", "b"), ("b", "c")]);
|
let g = graph(
|
||||||
|
TopologyKind::Pipeline,
|
||||||
|
&["a", "b", "c"],
|
||||||
|
&[("a", "b"), ("b", "c")],
|
||||||
|
);
|
||||||
let m = metrics(&g);
|
let m = metrics(&g);
|
||||||
assert_eq!(m.order, 3);
|
assert_eq!(m.order, 3);
|
||||||
assert_eq!(m.undirected_edges, 2);
|
assert_eq!(m.undirected_edges, 2);
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ impl TopologyGraph {
|
|||||||
.collect();
|
.collect();
|
||||||
let mut adj = vec![HashSet::new(); self.nodes.len()];
|
let mut adj = vec![HashSet::new(); self.nodes.len()];
|
||||||
for e in &self.edges {
|
for e in &self.edges {
|
||||||
if let (Some(&a), Some(&b)) = (index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
|
if let (Some(&a), Some(&b)) =
|
||||||
|
(index_of.get(e.from.as_str()), index_of.get(e.to.as_str()))
|
||||||
{
|
{
|
||||||
if a != b {
|
if a != b {
|
||||||
adj[a].insert(b);
|
adj[a].insert(b);
|
||||||
|
|||||||
@@ -187,18 +187,19 @@ export function useLiveState<T extends TaxonomyType, S>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The per-agent telemetry slice for the command-center metric band. Re-subscribes
|
/** The per-agent telemetry slice for the command-center metric band. Re-subscribes
|
||||||
* when the agent changes so the replayed last-known value paints immediately. */
|
* when the agent changes so the replayed last-known value paints immediately. The
|
||||||
|
* slice is tagged with its agentId and the getter returns null on mismatch — so we
|
||||||
|
* never show a stale agent's metrics, without resetting state inside the effect. */
|
||||||
export function useAgentTelemetry(agentId: string | null): TaxonomyPayload<"telemetry"> | null {
|
export function useAgentTelemetry(agentId: string | null): TaxonomyPayload<"telemetry"> | null {
|
||||||
const client = useClawmatesLive();
|
const client = useClawmatesLive();
|
||||||
const [t, setT] = useState<TaxonomyPayload<"telemetry"> | null>(null);
|
const [slice, setSlice] = useState<{ agentId: string; data: TaxonomyPayload<"telemetry"> } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setT(null);
|
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
return client.on("telemetry", (d) => {
|
return client.on("telemetry", (d) => {
|
||||||
if (d.agentId === agentId) setT(d);
|
if (d.agentId === agentId) setSlice({ agentId, data: d });
|
||||||
});
|
});
|
||||||
}, [client, agentId]);
|
}, [client, agentId]);
|
||||||
return t;
|
return slice && slice.agentId === agentId ? slice.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- synthetic generator (no backend) — ports the demo loop so the World/Observe
|
// --- synthetic generator (no backend) — ports the demo loop so the World/Observe
|
||||||
|
|||||||
@@ -9,17 +9,24 @@ import {
|
|||||||
} from "./panel-params";
|
} from "./panel-params";
|
||||||
|
|
||||||
describe("panelParsers.app", () => {
|
describe("panelParsers.app", () => {
|
||||||
it("accepts every Computer-panel app id from spec §12", () => {
|
it("accepts every Computer-panel app id (agent + infra apps)", () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
"home",
|
"home",
|
||||||
"browser",
|
"browser",
|
||||||
"slack",
|
"slack",
|
||||||
|
"terminal",
|
||||||
|
"obsidian",
|
||||||
"chat",
|
"chat",
|
||||||
"skills",
|
"skills",
|
||||||
"files",
|
"files",
|
||||||
"routines",
|
"routines",
|
||||||
"settings",
|
"settings",
|
||||||
"apps",
|
"apps",
|
||||||
|
"aws",
|
||||||
|
"gcp",
|
||||||
|
"azure",
|
||||||
|
"hosts",
|
||||||
|
"status",
|
||||||
];
|
];
|
||||||
expect([...APP_IDS]).toEqual(expected);
|
expect([...APP_IDS]).toEqual(expected);
|
||||||
for (const id of expected) {
|
for (const id of expected) {
|
||||||
@@ -28,7 +35,7 @@ describe("panelParsers.app", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown app ids", () => {
|
it("rejects unknown app ids", () => {
|
||||||
expect(panelParsers.app.parse("terminal")).toBeNull();
|
expect(panelParsers.app.parse("bogus")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user