slice 8.5: per-agent + per-team level-up endpoints
ci / gates (push) Successful in 4s
ci / frontend (push) Successful in 40s
ci / rust (push) Successful in 3m30s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m30s

Level-up analyzes an agent's brain + recent run outcomes (or a
whole team's aggregate state), calls Gemini 2.5 Flash for structured
JSON proposals, and persists them as pending level_up_proposals
rows. Reviewer approves a subset via /apply; the applier commits
only those items.

Migration 0052 adds level_up_proposals (id, workspace_id, agent_id
XOR team_id via CHECK constraint, status, payload JSONB,
applied_items[], model, created_by, approved_by, created_at,
applied_at) + workspace/pending/agent/team indexes.

Rust surface:
  - cm_db::repo::level_up::{insert, get, list_pending, mark_applied,
    mark_rejected}
  - cm_api::level_up::{propose_agent, propose_team, apply}
    Item kinds handled by apply():
      identity_refinement    → UPDATE agents.system_prompt
      skill_add              → agent_skills_ext INSERT
      skill_candidate        → workspace-scoped skills INSERT
                              (deterministic id per (workspace, name))
      brain_consolidation    → set_agent_md on the brain (unlike
                              brain_seed::ingest, this overwrites)
      roster_change / mcp_bundle_change — logged as
                              "not auto-applied, human runs
                              team-wizard" (structural changes need
                              human review of side effects).

API:
  - POST /api/claws/{id}/level-up   → { proposal_id }
  - POST /api/teams/{id}/level-up   → { proposal_id }
  - GET  /api/level-up-proposals    → pending list
  - GET  /api/level-up-proposals/{id}
  - POST /api/level-up-proposals/{id}/apply  { approved_item_ids }
  - POST /api/level-up-proposals/{id}/reject

Uses Gemini 2.5 Flash with response_mime_type: "application/json"
so the model returns structured JSON directly (no ```json fence
stripping needed). Configurable via CLAWMATES_LEVEL_UP_MODEL.

Follow-ups:
  - Frontend diff-review UI (pick items, approve/reject)
  - roster_change / mcp_bundle_change appliers (currently manual)
  - Anthropic + OpenAI proposer variants
  - Promote workspace-scoped skills to builtin via a curator flow

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 16:36:29 -07:00
co-authored by Claude Opus 4.7
parent 58963d5083
commit 9b5e63cbb7
7 changed files with 918 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
//! Level-up proposer + applier — Slice 8.5.
//!
//! Two entry points:
//! propose_agent(agent_id) — reads agent's brain + last N runs,
//! asks the LLM to propose brain consolidation / identity
//! refinement / skill add / skill candidate items. Persists as
//! a pending `level_up_proposals` row.
//! propose_team(team_id) — aggregates each team agent's context +
//! recent mission outcomes; LLM proposes roster changes + MCP
//! bundle changes on top of per-role items.
//!
//! apply(proposal_id, approved_item_ids) commits only the ids the
//! reviewer picked. Rejected proposals move to status='rejected';
//! partial approvals move to status='partial'.
//!
//! Uses Gemini 2.5 Flash as the default proposer model — cheap,
//! JSON-mode-native, plenty of room for structured output. Configurable
//! via CLAWMATES_LEVEL_UP_MODEL.
use serde_json::{json, Value};
use sqlx::PgPool;
use sqlx::Row;
use uuid::Uuid;
const DEFAULT_MODEL: &str = "gemini-2.5-flash";
fn model_name() -> String {
std::env::var("CLAWMATES_LEVEL_UP_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
/// Analyze an agent + insert a pending proposal. Returns the proposal id.
pub async fn propose_agent(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
created_by: cm_domain::UserId,
agent_id: Uuid,
) -> Result<Uuid, String> {
let agent = cm_db::repo::agents::get(pool, cm_domain::AgentId::from(agent_id))
.await
.map_err(|e| format!("load agent: {e}"))?;
if agent.workspace_id != workspace_id {
return Err("agent not in workspace".into());
}
let brain_summary = load_brain_summary(agent_id).await;
let recent_runs = recent_run_summary(pool, agent_id, 10).await?;
let link = cm_db::repo::agent_template_link::get(pool, agent_id)
.await
.ok()
.flatten();
let payload = call_llm_for_agent(
&agent.name,
&agent.job_title,
&agent.system_prompt,
&brain_summary,
&recent_runs,
link.as_ref(),
)
.await?;
let model = model_name();
let id = cm_db::repo::level_up::insert(
pool,
cm_db::repo::level_up::NewProposal {
workspace_id: workspace_id.as_uuid(),
agent_id: Some(agent_id),
team_id: None,
payload: &payload,
model: Some(&model),
created_by: Some(created_by.as_uuid()),
},
)
.await
.map_err(|e| format!("insert proposal: {e}"))?;
Ok(id)
}
/// Analyze a team + insert a pending proposal. Returns the proposal id.
pub async fn propose_team(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
created_by: cm_domain::UserId,
team_id: Uuid,
) -> Result<Uuid, String> {
let members = sqlx::query(
"SELECT a.id, a.name, a.job_title, a.system_prompt, m.role_slot
FROM team_members m
JOIN agents a ON a.id = m.claw_id
WHERE m.team_id = $1
ORDER BY m.role_slot",
)
.bind(team_id)
.fetch_all(pool)
.await
.map_err(|e| format!("load members: {e}"))?;
if members.is_empty() {
return Err("team has no members".into());
}
let mut member_summaries: Vec<Value> = Vec::new();
for r in &members {
let id: Uuid = r.get("id");
let name: String = r.get("name");
let role: String = r.get("role_slot");
let brain = load_brain_summary(id).await;
let runs = recent_run_summary(pool, id, 3).await.unwrap_or_default();
member_summaries.push(json!({
"agent_id": id,
"name": name,
"role_slot": role,
"brain": brain,
"recent_runs": runs,
}));
}
let payload = call_llm_for_team(&member_summaries).await?;
let model = model_name();
let id = cm_db::repo::level_up::insert(
pool,
cm_db::repo::level_up::NewProposal {
workspace_id: workspace_id.as_uuid(),
agent_id: None,
team_id: Some(team_id),
payload: &payload,
model: Some(&model),
created_by: Some(created_by.as_uuid()),
},
)
.await
.map_err(|e| format!("insert team proposal: {e}"))?;
Ok(id)
}
/// Apply the reviewer-approved subset of a proposal. Item kinds
/// (from payload.suggested_items[].kind) each map to a small applier:
/// identity_refinement → agents.set_system_prompt (via patch)
/// skill_add → agent_skills_ext INSERT
/// skill_candidate → skills_catalog::upsert workspace-scoped
/// brain_consolidation → set_agent_md on the brain
/// roster_change → not automated in this slice (logs a
/// reminder — human runs the team-wizard)
/// mcp_bundle_change → not automated in this slice (same)
pub async fn apply(
pool: &PgPool,
workspace_id: cm_domain::WorkspaceId,
approved_by: cm_domain::UserId,
proposal_id: Uuid,
approved_item_ids: &[String],
) -> Result<(), String> {
let proposal = cm_db::repo::level_up::get(pool, proposal_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load proposal: {e}"))?
.ok_or_else(|| "proposal not found".to_string())?;
if proposal.status != "pending" {
return Err(format!("proposal already {}", proposal.status));
}
let items = proposal
.payload
.get("suggested_items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut actually_applied: Vec<String> = Vec::new();
for item in items {
let Some(item_id) = item.get("id").and_then(|v| v.as_str()) else {
continue;
};
if !approved_item_ids.iter().any(|s| s == item_id) {
continue;
}
let kind = item.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let applied = match kind {
"identity_refinement" => apply_identity(pool, &proposal, &item).await,
"skill_add" => apply_skill_add(pool, &proposal, &item).await,
"skill_candidate" => apply_skill_candidate(pool, &proposal, &item).await,
"brain_consolidation" => apply_brain_consolidation(&proposal, &item).await,
"roster_change" | "mcp_bundle_change" => {
eprintln!(
"level_up: {kind} item {item_id} — not auto-applied, human runs team-wizard"
);
Ok(())
}
other => {
eprintln!("level_up: unknown item kind `{other}` — skipping {item_id}");
Ok(())
}
};
match applied {
Ok(()) => actually_applied.push(item_id.to_string()),
Err(e) => eprintln!("level_up: apply {item_id} failed: {e}"),
}
}
let partial = actually_applied.len() != approved_item_ids.len();
cm_db::repo::level_up::mark_applied(
pool,
proposal_id,
workspace_id.as_uuid(),
approved_by.as_uuid(),
&actually_applied,
partial,
)
.await
.map_err(|e| format!("mark applied: {e}"))?;
Ok(())
}
// ── Appliers ───────────────────────────────────────────────────
async fn apply_identity(
pool: &PgPool,
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let Some(agent_id) = proposal.agent_id else {
return Err("identity_refinement requires an agent proposal".into());
};
let Some(new_prompt) = item.get("new_system_prompt").and_then(|v| v.as_str()) else {
return Err("missing new_system_prompt".into());
};
sqlx::query("UPDATE agents SET system_prompt = $1 WHERE id = $2")
.bind(new_prompt)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("update agent: {e}"))?;
Ok(())
}
async fn apply_skill_add(
pool: &PgPool,
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let Some(agent_id) = proposal.agent_id else {
return Err("skill_add on team proposal — use per-agent override".into());
};
let Some(skill_id_str) = item.get("skill_id").and_then(|v| v.as_str()) else {
return Err("missing skill_id".into());
};
let skill_id = Uuid::parse_str(skill_id_str).map_err(|e| format!("parse skill_id: {e}"))?;
let pin = item
.get("pin_in_context")
.and_then(|v| v.as_bool())
.unwrap_or(false);
cm_db::repo::skills_catalog::set_agent_skill(
pool,
agent_id,
skill_id,
true,
pin,
proposal.approved_by,
item.get("rationale").and_then(|v| v.as_str()),
)
.await
.map_err(|e| format!("set agent skill: {e}"))?;
Ok(())
}
async fn apply_skill_candidate(
pool: &PgPool,
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let draft = item
.get("draft")
.ok_or_else(|| "missing draft".to_string())?;
let name = draft
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| "draft.name missing".to_string())?;
let description = draft
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("(no description)");
let body = draft.get("body").and_then(|v| v.as_str()).unwrap_or("");
let when_to_use = draft.get("when_to_use").and_then(|v| v.as_str());
let tags: Vec<String> = draft
.get("tags")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
// Workspace-scoped custom skill. Deterministic id per
// (workspace, name) so re-approving the same draft updates in
// place rather than duplicating.
let id = workspace_skill_id(proposal.workspace_id, name);
sqlx::query(
"INSERT INTO skills
(id, name, title, author, description, when_to_use, tags,
source_kind, workspace_id, current_version, body)
VALUES ($1,$2,$2,'level_up',$3,$4,$5,'promoted_from_brain',$6,1,$7)
ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description,
when_to_use = EXCLUDED.when_to_use,
tags = EXCLUDED.tags,
body = EXCLUDED.body,
updated_at = now()",
)
.bind(id)
.bind(name)
.bind(description)
.bind(when_to_use)
.bind(&tags)
.bind(proposal.workspace_id)
.bind(body)
.execute(pool)
.await
.map_err(|e| format!("upsert skill draft: {e}"))?;
Ok(())
}
async fn apply_brain_consolidation(
proposal: &cm_db::repo::level_up::LevelUpProposal,
item: &Value,
) -> Result<(), String> {
let Some(agent_id) = proposal.agent_id else {
return Err("brain_consolidation requires agent proposal".into());
};
let Some(new_agent_md) = item.get("brain_md_diff").and_then(|v| v.as_str()) else {
return Err("missing brain_md_diff".into());
};
// Overwrites agent_md — level-up is the sanctioned path, unlike
// brain_seed::ingest which skips if agent_md is populated.
let agent_id_owned = agent_id;
let md_owned = new_agent_md.to_string();
tokio::task::spawn_blocking(move || -> Result<(), String> {
use cm_brain::ClawBrain;
let dir = std::env::var("CLAWMATES_BRAIN_DIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/data/brains"));
let path = dir.join(format!("claw_{agent_id_owned}.h5"));
let mut brain = ClawBrain::open_or_create(&path, &agent_id_owned.to_string())
.map_err(|e| format!("open brain: {e}"))?;
brain
.set_agent_md(&md_owned)
.map_err(|e| format!("set agent_md: {e}"))?;
brain
.commit(Some("level_up: brain consolidation"))
.map_err(|e| format!("commit: {e}"))?;
Ok(())
})
.await
.map_err(|e| format!("brain task join: {e}"))?
}
// ── Helpers ────────────────────────────────────────────────────
async fn load_brain_summary(agent_id: Uuid) -> Value {
tokio::task::spawn_blocking(move || {
use cm_brain::ClawBrain;
let dir = std::env::var("CLAWMATES_BRAIN_DIR")
.ok()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/data/brains"));
let path = dir.join(format!("claw_{agent_id}.h5"));
let Ok(brain) = ClawBrain::open_or_create(&path, &agent_id.to_string()) else {
return json!({ "available": false });
};
let agent_md = brain.agent_md().unwrap_or_default();
let skills: Vec<Value> = brain
.skills()
.into_iter()
.map(|(n, b)| json!({ "name": n, "body_excerpt": excerpt(&b, 400) }))
.collect();
json!({
"available": true,
"agent_md_excerpt": excerpt(&agent_md, 2000),
"agent_md_bytes": agent_md.len(),
"skills": skills,
})
})
.await
.unwrap_or_else(|_| json!({ "available": false }))
}
async fn recent_run_summary(pool: &PgPool, agent_id: Uuid, limit: i64) -> Result<Value, String> {
// Runs the agent participated in — via team_members.claw_id +
// team_id + mission_id. Cheap best-effort join; missing → empty.
let rows = sqlx::query(
"SELECT tr.id, tr.status, tr.error, tr.created_at
FROM topology_runs tr
WHERE tr.mission_id IN (
SELECT m.id FROM missions m
JOIN team_members tm ON tm.team_id = m.team_id
WHERE tm.claw_id = $1
)
ORDER BY tr.created_at DESC LIMIT $2",
)
.bind(agent_id)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| format!("load runs: {e}"))?;
let items: Vec<Value> = rows
.into_iter()
.map(|r| {
let id: Uuid = r.get("id");
let status: String = r.get("status");
let err: Option<String> = r.get("error");
json!({
"run_id": id,
"status": status,
"error": err,
})
})
.collect();
Ok(json!(items))
}
async fn call_llm_for_agent(
name: &str,
role: &str,
system_prompt: &str,
brain: &Value,
runs: &Value,
link: Option<&cm_db::repo::agent_template_link::AgentTemplateLink>,
) -> Result<Value, String> {
let template_note = link
.map(|l| {
format!(
"Template lineage: template {} v{} role {}.",
l.template_id, l.template_version, l.role_slot
)
})
.unwrap_or_else(|| "No template lineage (LLM-derived or manual).".to_string());
let system = r#"You review an AI agent's history and propose targeted improvements.
Return ONLY JSON matching this schema:
{
"kind": "agent",
"current": { "system_prompt": "<current>", "skills": [] },
"suggested_items": [
// 0..5 items, each with `id`, `kind`, `rationale`.
// kind ∈ {identity_refinement, skill_candidate, skill_add, brain_consolidation}
// identity_refinement: extra `new_system_prompt`
// skill_candidate: extra `draft: { name, description, when_to_use, body, tags[] }`
// skill_add: extra `skill_id`
// brain_consolidation: extra `brain_md_diff` (full replacement text)
]
}
Propose changes only when there's evidence — a clear pattern in the
brain or a failure in recent runs. Do not propose changes purely for
the sake of proposing."#;
let user = json!({
"name": name,
"role_slot": role,
"system_prompt": system_prompt,
"brain": brain,
"recent_runs": runs,
"template_lineage_note": template_note,
})
.to_string();
call_gemini_json(system, &user).await
}
async fn call_llm_for_team(members: &[Value]) -> Result<Value, String> {
let system = r#"You review an AI team's roster + recent history and propose
targeted improvements. Return ONLY JSON:
{
"kind": "team",
"suggested_items": [
// 0..8 items. Same shapes as agent, plus:
// roster_change: { op: "add"|"drop"|"rename", slot, rationale }
// mcp_bundle_change:{ op: "add"|"drop", bundle, rationale }
// Per-agent items should carry an extra `agent_id` field.
]
}
Prefer removing unused roles over adding new ones. Prefer tightening
prompts over adding skills. Only add skills when a clear
"the team keeps getting stuck on <X>" pattern appears."#;
let user = json!({ "members": members }).to_string();
call_gemini_json(system, &user).await
}
async fn call_gemini_json(system: &str, user: &str) -> Result<Value, String> {
let api_key =
std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?;
let model = model_name();
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
model, api_key
);
let body = json!({
"system_instruction": { "parts": [{ "text": system }] },
"contents": [{ "role": "user", "parts": [{ "text": user }] }],
"generationConfig": {
"temperature": 0.2,
"response_mime_type": "application/json",
"maxOutputTokens": 8192,
}
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("gemini call: {e}"))?;
if !resp.status().is_success() {
let code = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("gemini {code}: {}", &body[..body.len().min(500)]));
}
let json: Value = resp.json().await.map_err(|e| format!("gemini json: {e}"))?;
let text = json
.pointer("/candidates/0/content/parts/0/text")
.and_then(|v| v.as_str())
.ok_or_else(|| "gemini response missing text".to_string())?;
serde_json::from_str(text).map_err(|e| format!("parse suggestion json: {e}"))
}
fn excerpt(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
fn workspace_skill_id(workspace_id: Uuid, name: &str) -> Uuid {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(b"clawmates.workspace.skill\x00");
h.update(workspace_id.as_bytes());
h.update(name.as_bytes());
let d = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&d[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Uuid::from_bytes(bytes)
}
+26
View File
@@ -7,6 +7,7 @@ pub mod cleanup_sweeper;
mod error; mod error;
mod extract; mod extract;
pub mod fleet; pub mod fleet;
pub mod level_up;
mod mcp_door; mod mcp_door;
mod mcp_skills; mod mcp_skills;
pub mod mission_orchestrator; pub mod mission_orchestrator;
@@ -446,6 +447,31 @@ pub fn router(state: AppState) -> Router {
"/api/missions/{id}/security-scan", "/api/missions/{id}/security-scan",
post(routes::missions::trigger_security_scan), post(routes::missions::trigger_security_scan),
) )
// Level-up (Slice 8.5)
.route(
"/api/level-up-proposals",
get(routes::level_up::list_pending),
)
.route(
"/api/level-up-proposals/{id}",
get(routes::level_up::get_proposal),
)
.route(
"/api/level-up-proposals/{id}/apply",
post(routes::level_up::apply_proposal),
)
.route(
"/api/level-up-proposals/{id}/reject",
post(routes::level_up::reject_proposal),
)
.route(
"/api/claws/{id}/level-up",
post(routes::level_up::propose_for_agent),
)
.route(
"/api/teams/{id}/level-up",
post(routes::level_up::propose_for_team),
)
.route( .route(
"/api/research", "/api/research",
get(routes::research::list_topics).post(routes::research::create_topic), get(routes::research::list_topics).post(routes::research::create_topic),
+109
View File
@@ -0,0 +1,109 @@
//! `/api/level-up-proposals/*` + trigger endpoints — Slice 8.5.
use axum::extract::{Path, State};
use axum::response::Json;
use serde::Deserialize;
use uuid::Uuid;
use cm_db::repo::level_up::LevelUpProposal;
use crate::{ApiError, AppState, Authed};
pub async fn list_pending(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<LevelUpProposal>>, ApiError> {
let rows =
cm_db::repo::level_up::list_pending(&state.pool, user.workspace_id.as_uuid()).await?;
Ok(Json(rows))
}
pub async fn get_proposal(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<LevelUpProposal>, ApiError> {
let p = cm_db::repo::level_up::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(p))
}
/// POST /api/claws/{id}/level-up — LLM proposes agent improvements.
/// Returns the new proposal id; reviewer approves via the apply route.
pub async fn propose_for_agent(
State(state): State<AppState>,
Authed(user): Authed,
Path(agent_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let id = crate::level_up::propose_agent(&state.pool, user.workspace_id, user.user_id, agent_id)
.await
.map_err(|e| {
eprintln!("level_up: propose_agent {agent_id} failed: {e}");
ApiError::Internal
})?;
Ok(Json(serde_json::json!({ "proposal_id": id })))
}
/// POST /api/teams/{id}/level-up — LLM proposes team improvements.
pub async fn propose_for_team(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let id = crate::level_up::propose_team(&state.pool, user.workspace_id, user.user_id, team_id)
.await
.map_err(|e| {
eprintln!("level_up: propose_team {team_id} failed: {e}");
ApiError::Internal
})?;
Ok(Json(serde_json::json!({ "proposal_id": id })))
}
#[derive(Debug, Deserialize)]
pub struct ApplyRequest {
/// Ids from payload.suggested_items[] that the reviewer approved.
pub approved_item_ids: Vec<String>,
}
pub async fn apply_proposal(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<ApplyRequest>,
) -> Result<Json<LevelUpProposal>, ApiError> {
crate::level_up::apply(
&state.pool,
user.workspace_id,
user.user_id,
id,
&body.approved_item_ids,
)
.await
.map_err(|e| {
eprintln!("level_up::apply {id} failed: {e}");
ApiError::Internal
})?;
let p = cm_db::repo::level_up::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(p))
}
pub async fn reject_proposal(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<LevelUpProposal>, ApiError> {
cm_db::repo::level_up::mark_rejected(
&state.pool,
id,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
)
.await?;
let p = cm_db::repo::level_up::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(p))
}
+1
View File
@@ -12,6 +12,7 @@ pub mod files;
pub mod gateway; pub mod gateway;
pub mod health; pub mod health;
pub mod identity; pub mod identity;
pub mod level_up;
pub mod loops; pub mod loops;
pub mod missions; pub mod missions;
pub mod nodes; pub mod nodes;
+159
View File
@@ -0,0 +1,159 @@
//! Level-up proposals — Slice 8.5.
//!
//! A proposal is a diff of "current state → suggested state" for an
//! agent or a team. Reviewers approve a subset of items; the applier
//! commits only those. All shape lives in `payload` JSONB — this
//! module is pure storage.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::Row;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LevelUpProposal {
pub id: Uuid,
pub workspace_id: Uuid,
pub agent_id: Option<Uuid>,
pub team_id: Option<Uuid>,
pub status: String,
pub payload: Value,
pub applied_items: Vec<String>,
pub model: Option<String>,
pub created_by: Option<Uuid>,
pub approved_by: Option<Uuid>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub applied_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone)]
pub struct NewProposal<'a> {
pub workspace_id: Uuid,
pub agent_id: Option<Uuid>,
pub team_id: Option<Uuid>,
pub payload: &'a Value,
pub model: Option<&'a str>,
pub created_by: Option<Uuid>,
}
pub async fn insert(pool: &PgPool, p: NewProposal<'_>) -> Result<Uuid, DbError> {
let id = Uuid::now_v7();
sqlx::query(
"INSERT INTO level_up_proposals
(id, workspace_id, agent_id, team_id, status, payload, model, created_by)
VALUES ($1,$2,$3,$4,'pending',$5,$6,$7)",
)
.bind(id)
.bind(p.workspace_id)
.bind(p.agent_id)
.bind(p.team_id)
.bind(p.payload)
.bind(p.model)
.bind(p.created_by)
.execute(pool)
.await?;
Ok(id)
}
pub async fn get(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
) -> Result<Option<LevelUpProposal>, DbError> {
let row = sqlx::query(
"SELECT id, workspace_id, agent_id, team_id, status, payload,
applied_items, model, created_by, approved_by,
created_at, applied_at
FROM level_up_proposals WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.fetch_optional(pool)
.await?;
Ok(row.map(row_to_proposal))
}
pub async fn list_pending(
pool: &PgPool,
workspace_id: Uuid,
) -> Result<Vec<LevelUpProposal>, DbError> {
let rows = sqlx::query(
"SELECT id, workspace_id, agent_id, team_id, status, payload,
applied_items, model, created_by, approved_by,
created_at, applied_at
FROM level_up_proposals
WHERE workspace_id = $1 AND status = 'pending'
ORDER BY created_at DESC",
)
.bind(workspace_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(row_to_proposal).collect())
}
pub async fn mark_applied(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
approved_by: Uuid,
applied_items: &[String],
partial: bool,
) -> Result<(), DbError> {
let status = if partial { "partial" } else { "applied" };
sqlx::query(
"UPDATE level_up_proposals
SET status = $3, applied_items = $4, approved_by = $5,
applied_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(status)
.bind(applied_items)
.bind(approved_by)
.execute(pool)
.await?;
Ok(())
}
pub async fn mark_rejected(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
approved_by: Uuid,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE level_up_proposals
SET status = 'rejected', approved_by = $3, applied_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(workspace_id)
.bind(approved_by)
.execute(pool)
.await?;
Ok(())
}
fn row_to_proposal(r: sqlx::postgres::PgRow) -> LevelUpProposal {
LevelUpProposal {
id: r.get("id"),
workspace_id: r.get("workspace_id"),
agent_id: r.get("agent_id"),
team_id: r.get("team_id"),
status: r.get("status"),
payload: r.get("payload"),
applied_items: r.get("applied_items"),
model: r.get("model"),
created_by: r.get("created_by"),
approved_by: r.get("approved_by"),
created_at: r.get("created_at"),
applied_at: r.get("applied_at"),
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod credits;
pub mod files; pub mod files;
pub mod fleet_beszel; pub mod fleet_beszel;
pub mod fleet_tailscale; pub mod fleet_tailscale;
pub mod level_up;
pub mod loops; pub mod loops;
pub mod messages; pub mod messages;
pub mod missions; pub mod missions;
+74
View File
@@ -0,0 +1,74 @@
-- Slice 8.5 — level-up proposals.
--
-- A proposal is an LLM-generated diff of "current state → suggested
-- state" for either a specific agent or a whole team. Human approves
-- (all or per-item) via a diff-review UI; the applier commits the
-- approved subset.
--
-- Proposal payload (JSONB) shape — kept schemaless because proposal
-- kinds evolve. Documented shapes:
--
-- agent proposal:
-- {
-- "kind": "agent",
-- "current": { "system_prompt": "…", "skills": ["…"] },
-- "suggested_items": [
-- { "id": "u1", "kind": "brain_consolidation",
-- "rationale": "…", "brain_md_diff": "…" },
-- { "id": "u2", "kind": "identity_refinement",
--- "rationale": "…", "new_system_prompt": "…" },
-- { "id": "u3", "kind": "skill_add", "skill_id": "…",
-- "rationale": "…" },
-- { "id": "u4", "kind": "skill_candidate",
-- "rationale": "…", "draft": { "name": "…",
-- "description": "…",
-- "when_to_use": "…",
-- "body": "…",
-- "tags": ["…"] } }
-- ]
-- }
--
-- team proposal — same shape plus:
-- { "id": "u5", "kind": "roster_change",
-- "op": "add"|"drop"|"rename", "slot": "…", "rationale": "…" }
-- { "id": "u6", "kind": "mcp_bundle_change",
-- "op": "add"|"drop", "bundle": "…", "rationale": "…" }
--
-- `applied_items` is the array of `id`s that the reviewer approved;
-- the applier only touches those.
CREATE TABLE level_up_proposals (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
-- Exactly one of these is set; enforced by the CHECK below.
agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
-- 'pending' | 'applied' | 'rejected' | 'partial' (applied a subset)
status TEXT NOT NULL DEFAULT 'pending',
-- Full proposal JSONB (see header comment for shape).
payload JSONB NOT NULL,
-- Ids from payload.suggested_items[] that reviewer approved.
applied_items TEXT[] NOT NULL DEFAULT '{}',
-- Model used for the LLM analysis pass (audit trail).
model TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
applied_at TIMESTAMPTZ,
CONSTRAINT level_up_target_one CHECK (
(agent_id IS NOT NULL AND team_id IS NULL) OR
(agent_id IS NULL AND team_id IS NOT NULL)
)
);
CREATE INDEX level_up_workspace_idx
ON level_up_proposals (workspace_id, created_at DESC);
CREATE INDEX level_up_pending_idx
ON level_up_proposals (status, created_at DESC)
WHERE status = 'pending';
CREATE INDEX level_up_agent_idx
ON level_up_proposals (agent_id, created_at DESC)
WHERE agent_id IS NOT NULL;
CREATE INDEX level_up_team_idx
ON level_up_proposals (team_id, created_at DESC)
WHERE team_id IS NOT NULL;