Skip to main content

ai_crew_sync/
admin.rs

1//! Operator-facing commands. These bypass MCP entirely and talk to Postgres
2//! directly, so they are the only way to mint credentials.
3
4use anyhow::{Context, bail};
5use sqlx::PgPool;
6use uuid::Uuid;
7
8use crate::auth::{generate_token, hash_token, token_prefix};
9
10async fn team_id(pool: &PgPool, slug: &str) -> anyhow::Result<Uuid> {
11    let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE slug = $1")
12        .bind(slug)
13        .fetch_optional(pool)
14        .await?;
15    row.map(|r| r.0)
16        .with_context(|| format!("no team with slug '{slug}'"))
17}
18
19pub async fn team_create(pool: &PgPool, slug: &str, name: Option<String>) -> anyhow::Result<()> {
20    let slug = slug.trim().to_lowercase();
21    if slug.is_empty() {
22        bail!("team slug cannot be empty");
23    }
24    let name = name.unwrap_or_else(|| slug.clone());
25    sqlx::query("INSERT INTO teams (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING")
26        .bind(&slug)
27        .bind(&name)
28        .execute(pool)
29        .await?;
30    println!("team '{slug}' ready");
31    Ok(())
32}
33
34pub async fn team_list(pool: &PgPool) -> anyhow::Result<()> {
35    let rows: Vec<(String, String, i64)> = sqlx::query_as(
36        r#"
37        SELECT t.slug, t.name, (SELECT count(*) FROM agents a WHERE a.team_id = t.id)
38        FROM teams t ORDER BY t.slug
39        "#,
40    )
41    .fetch_all(pool)
42    .await?;
43    if rows.is_empty() {
44        println!("(no teams yet — create one with `team create --slug <slug>`)");
45    }
46    for (slug, name, agents) in rows {
47        println!("{slug:<20} {name:<30} {agents} agent(s)");
48    }
49    Ok(())
50}
51
52fn human_bytes(n: i64) -> String {
53    const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
54    let mut value = n as f64;
55    let mut unit = 0;
56    while value >= 1024.0 && unit < UNITS.len() - 1 {
57        value /= 1024.0;
58        unit += 1;
59    }
60    if unit == 0 {
61        format!("{n} B")
62    } else {
63        format!("{value:.1} {}", UNITS[unit])
64    }
65}
66
67/// Set or clear a team's attachment quota. `None` clears it (unlimited).
68pub async fn team_quota(pool: &PgPool, team: &str, bytes: Option<i64>) -> anyhow::Result<()> {
69    let id = team_id(pool, team).await?;
70    if let Some(b) = bytes
71        && b <= 0
72    {
73        anyhow::bail!("a quota must be positive; omit --bytes to clear it");
74    }
75    sqlx::query("UPDATE teams SET attachment_bytes_limit = $1 WHERE id = $2")
76        .bind(bytes)
77        .bind(id)
78        .execute(pool)
79        .await?;
80    match bytes {
81        Some(b) => println!("team '{team}' attachment quota set to {}", human_bytes(b)),
82        None => println!("team '{team}' attachment quota cleared (unlimited)"),
83    }
84    Ok(())
85}
86
87/// Report what a team is storing. Counts and bytes only — never content, so
88/// this is safe to run for a team you are not on.
89pub async fn team_usage(pool: &PgPool, team: &str) -> anyhow::Result<()> {
90    let id = team_id(pool, team).await?;
91    let u = crate::store::quota::usage(pool, id).await?;
92
93    let quota = match u.attachment_bytes_limit {
94        Some(limit) => format!(
95            "{} of {} ({:.1}%)",
96            human_bytes(u.attachment_bytes),
97            human_bytes(limit),
98            u.percent_used().unwrap_or(0.0)
99        ),
100        None => format!("{} (no quota set)", human_bytes(u.attachment_bytes)),
101    };
102
103    println!("team '{team}'");
104    println!(
105        "  attachments     {quota} across {} file(s)",
106        u.attachment_count
107    );
108    println!("  messages        {}", u.messages);
109    println!("  note revisions  {}", u.note_revisions);
110    println!("  task events     {}", u.task_events);
111    if let Some(oldest) = u.oldest_message {
112        let days = (chrono::Utc::now() - oldest).num_days();
113        println!("  oldest message  {days} day(s) ago");
114    }
115    if let Some(pct) = u.percent_used()
116        && pct >= 80.0
117    {
118        println!();
119        println!("  ⚠ {pct:.0}% of the attachment quota is in use — raise it with");
120        println!("    `team quota --team {team} --bytes N`, or free space with `team prune`.");
121    }
122    Ok(())
123}
124
125/// Trim history older than `days`. Dry run by default at the call site.
126pub async fn team_prune(pool: &PgPool, team: &str, days: i64, apply: bool) -> anyhow::Result<()> {
127    let id = team_id(pool, team).await?;
128    let report = crate::store::quota::prune(pool, id, days, !apply).await?;
129
130    let verb = if report.dry_run {
131        "would delete"
132    } else {
133        "deleted"
134    };
135    println!("team '{team}', anything older than {days} day(s):");
136    println!("  {verb} {} message(s)", report.messages);
137    println!("  {verb} {} note revision(s)", report.note_revisions);
138    println!("  {verb} {} task event(s)", report.task_events);
139    println!(
140        "  {verb} attachments worth {}",
141        human_bytes(report.attachments_freed_bytes)
142    );
143    if report.dry_run {
144        println!();
145        println!("dry run — nothing was deleted. Re-run with --apply to do it.");
146        println!("Notes and tasks themselves are never pruned, only their history.");
147    }
148    Ok(())
149}
150
151pub async fn agent_add(
152    pool: &PgPool,
153    team: &str,
154    name: &str,
155    display_name: Option<String>,
156    issue_token: bool,
157) -> anyhow::Result<()> {
158    let tid = team_id(pool, team).await?;
159    let name = name.trim().to_lowercase();
160    if name.is_empty() {
161        bail!("agent name cannot be empty");
162    }
163
164    sqlx::query(
165        r#"
166        INSERT INTO agents (team_id, name, display_name)
167        VALUES ($1, $2, $3)
168        ON CONFLICT (team_id, name) DO UPDATE
169            SET display_name = COALESCE(EXCLUDED.display_name, agents.display_name),
170                disabled_at = NULL
171        "#,
172    )
173    .bind(tid)
174    .bind(&name)
175    .bind(display_name)
176    .execute(pool)
177    .await?;
178    println!("agent '{name}' ready in team '{team}'");
179
180    if issue_token {
181        token_issue(pool, team, &name, None).await?;
182    }
183    Ok(())
184}
185
186pub async fn agent_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
187    let tid = team_id(pool, team).await?;
188    let rows: Vec<(String, Option<String>, bool, i64)> = sqlx::query_as(
189        r#"
190        SELECT a.name,
191               a.display_name,
192               (a.disabled_at IS NOT NULL) AS disabled,
193               (SELECT count(*) FROM api_tokens t
194                 WHERE t.agent_id = a.id AND t.revoked_at IS NULL)
195        FROM agents a WHERE a.team_id = $1 ORDER BY a.name
196        "#,
197    )
198    .bind(tid)
199    .fetch_all(pool)
200    .await?;
201    for (name, display, disabled, tokens) in rows {
202        let flag = if disabled { " [disabled]" } else { "" };
203        println!(
204            "{name:<24} {:<28} {tokens} active token(s){flag}",
205            display.unwrap_or_default()
206        );
207    }
208    Ok(())
209}
210
211pub async fn agent_disable(pool: &PgPool, team: &str, name: &str) -> anyhow::Result<()> {
212    let tid = team_id(pool, team).await?;
213    let res = sqlx::query("UPDATE agents SET disabled_at = now() WHERE team_id = $1 AND name = $2")
214        .bind(tid)
215        .bind(name.trim())
216        .execute(pool)
217        .await?;
218    if res.rows_affected() == 0 {
219        bail!("no agent '{name}' in team '{team}'");
220    }
221    println!("agent '{name}' disabled; its tokens no longer authenticate");
222    Ok(())
223}
224
225pub async fn token_issue(
226    pool: &PgPool,
227    team: &str,
228    agent: &str,
229    label: Option<String>,
230) -> anyhow::Result<()> {
231    let tid = team_id(pool, team).await?;
232    let row: Option<(Uuid,)> =
233        sqlx::query_as("SELECT id FROM agents WHERE team_id = $1 AND name = $2")
234            .bind(tid)
235            .bind(agent.trim())
236            .fetch_optional(pool)
237            .await?;
238    let Some((agent_id,)) = row else {
239        bail!("no agent '{agent}' in team '{team}' — add it with `agent add` first");
240    };
241
242    let raw = generate_token();
243    sqlx::query(
244        "INSERT INTO api_tokens (agent_id, token_hash, prefix, label) VALUES ($1, $2, $3, $4)",
245    )
246    .bind(agent_id)
247    .bind(hash_token(&raw))
248    .bind(token_prefix(&raw))
249    .bind(label)
250    .execute(pool)
251    .await?;
252
253    println!();
254    println!("Token for {agent}@{team} — shown once, store it now:");
255    println!();
256    println!("  {raw}");
257    println!();
258    Ok(())
259}
260
261pub async fn token_list(pool: &PgPool, team: &str) -> anyhow::Result<()> {
262    let tid = team_id(pool, team).await?;
263    let rows: Vec<(
264        Uuid,
265        String,
266        String,
267        Option<String>,
268        Option<chrono::DateTime<chrono::Utc>>,
269        bool,
270    )> = sqlx::query_as(
271        r#"
272        SELECT t.id, a.name, t.prefix, t.label, t.last_used_at,
273               (t.revoked_at IS NOT NULL) AS revoked
274        FROM api_tokens t
275        JOIN agents a ON a.id = t.agent_id
276        WHERE a.team_id = $1
277        ORDER BY a.name, t.created_at
278        "#,
279    )
280    .bind(tid)
281    .fetch_all(pool)
282    .await?;
283    for (id, agent, prefix, label, last_used, revoked) in rows {
284        let used = last_used
285            .map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
286            .unwrap_or_else(|| "never".into());
287        let flag = if revoked { " [revoked]" } else { "" };
288        println!(
289            "{id}  {agent:<20} {prefix}…  last used {used}  {}{flag}",
290            label.unwrap_or_default()
291        );
292    }
293    Ok(())
294}
295
296pub async fn token_revoke(pool: &PgPool, id: Uuid) -> anyhow::Result<()> {
297    let res = sqlx::query("UPDATE api_tokens SET revoked_at = now() WHERE id = $1")
298        .bind(id)
299        .execute(pool)
300        .await?;
301    if res.rows_affected() == 0 {
302        bail!("no token with id {id}");
303    }
304    println!("token {id} revoked");
305    Ok(())
306}
307
308/// Print the exact `.mcp.json` block a teammate drops into their repo.
309pub fn print_mcp_config(url: &str, token: &str) {
310    let cfg = serde_json::json!({
311        "mcpServers": {
312            "ai-crew-sync": {
313                "type": "http",
314                "url": url,
315                "headers": { "Authorization": format!("Bearer {token}") }
316            }
317        }
318    });
319    println!("{}", serde_json::to_string_pretty(&cfg).unwrap());
320}