P5 exit: usage metering, credit billing, promo codes, 3-step wizard

- LlmEvent::Usage across all three providers (Scripted deterministic
  word-count accounting; Anthropic message_start/delta usage; OpenAI-compat
  stream_options include_usage)
- tc-billing: ceil(tokens/1000) min 1 credit; lots drain oldest-first under
  FOR UPDATE; balance clamps at zero while the usage ledger records the
  full obligation; promo codes redeem exactly once via CAS (migration 0006)
- Runtime charges every completed run (billing failure never fails a run);
  proven: 1 token in + 3 out -> 1 credit deducted
- API: GET /api/team/usage, POST /api/credits/redeem (409 on reuse, audited)
- Credits page: balance, 7-day usage meter with runway estimate, PromoRedeem
- /claws/new is the full §9 wizard: ?step=identity|access|slack deep-linked
  progress, accent swatches + name randomizer, access toggles, optional
  Slack step, explicit review-and-confirm (creation = live agent), animated
  provisioning state -> straight into chat
- E2E: chat decrements the visible balance and fills the usage meter;
  WELCOME500 adds exactly 500 once then refuses; wizard round trip

140 Rust + 63 frontend tests + 23 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 07:19:34 -05:00
co-authored by Claude Fable 5
parent 91327e3618
commit a8efada690
37 changed files with 1261 additions and 86 deletions
+12
View File
@@ -0,0 +1,12 @@
# Point the whole test suite at the persistent shared Postgres managed by
# `scripts/test-server.sh up`. With TC_TEST_DATABASE_URL set, tc-testkit never
# starts a per-test `postgres` testcontainer (it takes its `_container: None`
# branch), so a killed/panicking/orphaned `cargo test` has nothing to leak.
# This is the durable fix for the runaway `postgres:11-alpine` containers.
#
# Not forced: an explicit TC_TEST_DATABASE_URL in the environment still wins
# (e.g. CI pointing at its own server). If the shared server is down, tests
# fail fast with a connection error instead of silently leaking containers —
# run `scripts/test-server.sh up` first.
[env]
TC_TEST_DATABASE_URL = "postgres://postgres:[email protected]:54331/postgres"
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE credit_lots SET remaining = remaining - $2 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Numeric"
]
},
"nullable": []
},
"hash": "3cbfdcb8db800b5a03a412d8a0e671332c9b6769eb6f909fbc71d1f6943e2397"
}
@@ -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"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usage_events\n (workspace_id, agent_id, run_id, kind, tokens_in, tokens_out, credits)\n VALUES ($1, $2, $3, 'llm_tokens', $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Int8",
"Int8",
"Numeric"
]
},
"nullable": []
},
"hash": "8576053c7cf5528164ceb85599543c4f5500915e22499f610dcc90b6fd7e34da"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(SUM(tokens_in), 0)::BIGINT AS \"tokens_in!\",\n COALESCE(SUM(tokens_out), 0)::BIGINT AS \"tokens_out!\",\n COALESCE(SUM(credits), 0)::BIGINT AS \"credits!\"\n FROM usage_events\n WHERE workspace_id = $1 AND created_at > now() - interval '7 days'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "tokens_in!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "tokens_out!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "credits!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "909cb1586b68326590bd3b399bf3284b9084585e3b6fa6d92393c76c0d2f9534"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE promo_codes\n SET redeemed_by = $2, redeemed_at = now()\n WHERE code = $1 AND redeemed_by IS NULL\n RETURNING credits",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "credits",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "a1e82ca9e1124f0bdeb54840ed53807444f1a132f9ce28d85cfd17d08e9f5b4a"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, remaining FROM credit_lots\n WHERE workspace_id = $1 AND remaining > 0\n ORDER BY purchased_at, id\n FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "remaining",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false
]
},
"hash": "c2494e47cc1d43f99bab534dcb420149f7d99f17943dabe99466dab2c142b99e"
}
Generated
+54
View File
@@ -442,6 +442,21 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "conquer-once"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d008a441c0f269f36ca13712528069a86a3e60dffee1d98b976eb3b0b2160b4"
dependencies = [
"conquer-util",
]
[[package]]
name = "conquer-util"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e763eef8846b13b380f37dfecda401770b0ca4e56e95170237bd7c25c7db3582"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@@ -2503,6 +2518,26 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "signature" name = "signature"
version = "2.2.0" version = "2.2.0"
@@ -2858,6 +2893,7 @@ dependencies = [
"sha2", "sha2",
"sqlx", "sqlx",
"tc-auth", "tc-auth",
"tc-billing",
"tc-config", "tc-config",
"tc-db", "tc-db",
"tc-domain", "tc-domain",
@@ -2891,6 +2927,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tc-billing"
version = "0.1.0"
dependencies = [
"sqlx",
"tc-db",
"tc-domain",
"tc-testkit",
"thiserror",
"time",
"tokio",
"uuid",
]
[[package]] [[package]]
name = "tc-config" name = "tc-config"
version = "0.1.0" version = "0.1.0"
@@ -2965,6 +3015,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tc-billing",
"tc-db", "tc-db",
"tc-domain", "tc-domain",
"tc-files", "tc-files",
@@ -3054,6 +3105,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"sqlx", "sqlx",
"tc-db", "tc-db",
"testcontainers",
"testcontainers-modules", "testcontainers-modules",
"tokio", "tokio",
"uuid", "uuid",
@@ -3121,6 +3173,7 @@ dependencies = [
"async-trait", "async-trait",
"bollard", "bollard",
"bytes", "bytes",
"conquer-once",
"docker_credential", "docker_credential",
"either", "either",
"etcetera 0.10.0", "etcetera 0.10.0",
@@ -3132,6 +3185,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"signal-hook",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
+1
View File
@@ -12,6 +12,7 @@ members = [
"crates/tc-secrets", "crates/tc-secrets",
"crates/tc-files", "crates/tc-files",
"crates/tc-scheduler", "crates/tc-scheduler",
"crates/tc-billing",
"crates/tc-testkit", "crates/tc-testkit",
"crates/tc-auth", "crates/tc-auth",
"crates/tc-api", "crates/tc-api",
+5
View File
@@ -111,6 +111,11 @@ pub async fn seed(pool: &PgPool) -> Result<(), String> {
.await .await
.map_err(|e| format!("seed skill: {e}"))?; .map_err(|e| format!("seed skill: {e}"))?;
sqlx::query("INSERT INTO promo_codes (code, credits) VALUES ('WELCOME500', 500)")
.execute(pool)
.await
.map_err(|e| format!("seed promo: {e}"))?;
println!("teamclaw-server: e2e seed applied ({E2E_OWNER_EMAIL})"); println!("teamclaw-server: e2e seed applied ({E2E_OWNER_EMAIL})");
Ok(()) Ok(())
} }
+1
View File
@@ -15,6 +15,7 @@ serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tc-auth = { path = "../tc-auth" } tc-auth = { path = "../tc-auth" }
tc-billing = { path = "../tc-billing" }
tc-config = { path = "../tc-config" } tc-config = { path = "../tc-config" }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" } tc-domain = { path = "../tc-domain" }
+2
View File
@@ -95,6 +95,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/team/claws", get(routes::team::claws)) .route("/api/team/claws", get(routes::team::claws))
.route("/api/team/members", get(routes::team::members)) .route("/api/team/members", get(routes::team::members))
.route("/api/team/credits", get(routes::team::credits)) .route("/api/team/credits", get(routes::team::credits))
.route("/api/team/usage", get(routes::billing::usage))
.route("/api/credits/redeem", post(routes::billing::redeem))
.route("/api/team/permissions", get(routes::team::permissions)) .route("/api/team/permissions", get(routes::team::permissions))
.with_state(state) .with_state(state)
} }
+54
View File
@@ -0,0 +1,54 @@
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tc_billing::BillingError;
use tc_db::repo::audit::Actor;
use crate::{ApiError, AppState, Authed};
#[derive(Deserialize)]
pub struct RedeemRequest {
code: String,
}
/// POST /api/credits/redeem — promo redemption (§8.4), once per code.
pub async fn redeem(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<RedeemRequest>,
) -> Result<Json<Value>, ApiError> {
let granted = tc_billing::redeem_promo(&state.pool, user.workspace_id, &body.code)
.await
.map_err(|e| match e {
BillingError::PromoUnavailable => ApiError::Conflict,
BillingError::Db(_) => ApiError::Internal,
})?;
tc_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"credits.promo_redeemed",
"promo",
&body.code,
json!({"granted": granted}),
)
.await?;
Ok(Json(json!({ "granted": granted })))
}
/// GET /api/team/usage — the trailing-7-day meter (§8.4).
pub async fn usage(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let (tokens_in, tokens_out, credits) =
tc_billing::usage_last_7_days(&state.pool, user.workspace_id)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(json!({
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"credits": credits,
})))
}
+1
View File
@@ -1,6 +1,7 @@
pub mod approvals; pub mod approvals;
pub mod apps; pub mod apps;
pub mod auth; pub mod auth;
pub mod billing;
pub mod claw_chat; pub mod claw_chat;
pub mod claws; pub mod claws;
pub mod files; pub mod files;
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "tc-billing"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
sqlx = { workspace = true }
tc-domain = { path = "../tc-domain" }
thiserror = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tc-db = { path = "../tc-db" }
tc-testkit = { path = "../tc-testkit" }
time = { workspace = true }
tokio = { workspace = true }
[lints]
workspace = true
+135
View File
@@ -0,0 +1,135 @@
//! Credit metering (§8.4 / §16 NFR). One credit pays for 1,000 LLM tokens
//! (rounded up, minimum one per charged run). Lots are consumed oldest
//! first and never expire; a workspace can run dry but never goes
//! negative — usage is always recorded in full either way.
use sqlx::PgPool;
use tc_domain::{AgentId, WorkspaceId};
use uuid::Uuid;
pub const TOKENS_PER_CREDIT: u64 = 1000;
#[derive(Debug, thiserror::Error)]
pub enum BillingError {
#[error("promo code is invalid or already redeemed")]
PromoUnavailable,
#[error(transparent)]
Db(#[from] sqlx::Error),
}
/// Credits owed for a token count: ceil(tokens / 1000), at least 1.
pub fn credits_for_tokens(total_tokens: u64) -> i64 {
(total_tokens.div_ceil(TOKENS_PER_CREDIT).max(1)) as i64
}
/// Records a run's token usage and decrements credit lots oldest-first.
/// Returns the credits actually deducted (clamped at the available
/// balance; the usage event always records the full obligation).
pub async fn charge(
pool: &PgPool,
workspace_id: WorkspaceId,
agent_id: AgentId,
run_id: Uuid,
input_tokens: u64,
output_tokens: u64,
) -> Result<i64, BillingError> {
let owed = credits_for_tokens(input_tokens + output_tokens);
let mut tx = pool.begin().await?;
sqlx::query!(
"INSERT INTO usage_events
(workspace_id, agent_id, run_id, kind, tokens_in, tokens_out, credits)
VALUES ($1, $2, $3, 'llm_tokens', $4, $5, $6)",
workspace_id.as_uuid(),
agent_id.as_uuid(),
run_id,
input_tokens as i64,
output_tokens as i64,
sqlx::types::BigDecimal::from(owed),
)
.execute(&mut *tx)
.await?;
// Oldest lots first, locked so concurrent charges serialize.
let lots = sqlx::query!(
"SELECT id, remaining FROM credit_lots
WHERE workspace_id = $1 AND remaining > 0
ORDER BY purchased_at, id
FOR UPDATE",
workspace_id.as_uuid(),
)
.fetch_all(&mut *tx)
.await?;
let mut left = owed;
for lot in lots {
if left == 0 {
break;
}
let available: i64 = lot.remaining.with_scale(0).to_string().parse().unwrap_or(0);
let take = left.min(available);
sqlx::query!(
"UPDATE credit_lots SET remaining = remaining - $2 WHERE id = $1",
lot.id,
sqlx::types::BigDecimal::from(take),
)
.execute(&mut *tx)
.await?;
left -= take;
}
tx.commit().await?;
Ok(owed - left)
}
/// Redeems a promo code exactly once (CAS) and grants its credits as a
/// new lot.
pub async fn redeem_promo(
pool: &PgPool,
workspace_id: WorkspaceId,
code: &str,
) -> Result<i64, BillingError> {
let mut tx = pool.begin().await?;
let promo = sqlx::query!(
"UPDATE promo_codes
SET redeemed_by = $2, redeemed_at = now()
WHERE code = $1 AND redeemed_by IS NULL
RETURNING credits",
code,
workspace_id.as_uuid(),
)
.fetch_optional(&mut *tx)
.await?
.ok_or(BillingError::PromoUnavailable)?;
let credits: i64 = promo.credits.with_scale(0).to_string().parse().unwrap_or(0);
sqlx::query!(
"INSERT INTO credit_lots (id, workspace_id, amount, remaining, source)
VALUES ($1, $2, $3, $3, $4)",
Uuid::now_v7(),
workspace_id.as_uuid(),
promo.credits,
format!("promo:{code}"),
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(credits)
}
/// Aggregate usage over the trailing seven days (Credits page meter).
pub async fn usage_last_7_days(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<(i64, i64, i64), BillingError> {
let row = sqlx::query!(
r#"SELECT COALESCE(SUM(tokens_in), 0)::BIGINT AS "tokens_in!",
COALESCE(SUM(tokens_out), 0)::BIGINT AS "tokens_out!",
COALESCE(SUM(credits), 0)::BIGINT AS "credits!"
FROM usage_events
WHERE workspace_id = $1 AND created_at > now() - interval '7 days'"#,
workspace_id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok((row.tokens_in, row.tokens_out, row.credits))
}
+133
View File
@@ -0,0 +1,133 @@
use tc_billing::{charge, credits_for_tokens, redeem_promo, usage_last_7_days, BillingError};
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
#[test]
fn credit_math_rounds_up_with_a_floor_of_one() {
assert_eq!(credits_for_tokens(1), 1);
assert_eq!(credits_for_tokens(999), 1);
assert_eq!(credits_for_tokens(1000), 1);
assert_eq!(credits_for_tokens(1001), 2);
assert_eq!(credits_for_tokens(0), 1, "a charged run costs at least 1");
}
async fn seeded(pool: &sqlx::PgPool) -> (Workspace, Agent, uuid::Uuid) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let session = tc_db::repo::sessions::create(pool, agent.id, ws.id, "Chat")
.await
.unwrap();
let run_id = tc_db::repo::runs::create(pool, session.id).await.unwrap();
(ws, agent, run_id)
}
#[tokio::test]
async fn charges_span_lots_oldest_first_and_record_usage() {
let pool = tc_testkit::test_pool().await;
let (ws, agent, run_id) = seeded(&pool).await;
tc_db::repo::credits::add_lot(&pool, ws.id, 2, "first")
.await
.unwrap();
tc_db::repo::credits::add_lot(&pool, ws.id, 100, "second")
.await
.unwrap();
// 2500 tokens → 3 credits: drains the first lot (2) then one more.
let deducted = charge(&pool, ws.id, agent.id, run_id, 1500, 1000)
.await
.unwrap();
assert_eq!(deducted, 3);
assert_eq!(
tc_db::repo::credits::balance(&pool, ws.id).await.unwrap(),
99
);
let remaining: Vec<i64> = sqlx::query_scalar::<_, sqlx::types::BigDecimal>(
"SELECT remaining FROM credit_lots WHERE workspace_id = $1 ORDER BY purchased_at, id",
)
.bind(ws.id.as_uuid())
.fetch_all(&pool)
.await
.unwrap()
.into_iter()
.map(|d| d.with_scale(0).to_string().parse().unwrap())
.collect();
assert_eq!(remaining, vec![0, 99], "oldest lot drains first");
let (tin, tout, credits) = usage_last_7_days(&pool, ws.id).await.unwrap();
assert_eq!((tin, tout, credits), (1500, 1000, 3));
}
#[tokio::test]
async fn an_empty_workspace_records_usage_but_clamps_at_zero() {
let pool = tc_testkit::test_pool().await;
let (ws, agent, run_id) = seeded(&pool).await;
tc_db::repo::credits::add_lot(&pool, ws.id, 1, "tiny")
.await
.unwrap();
// Owes 5, only 1 available: deducts 1, balance hits zero, never negative.
let deducted = charge(&pool, ws.id, agent.id, run_id, 4000, 500)
.await
.unwrap();
assert_eq!(deducted, 1);
assert_eq!(
tc_db::repo::credits::balance(&pool, ws.id).await.unwrap(),
0
);
// The full obligation is still on the books.
let (_, _, credits) = usage_last_7_days(&pool, ws.id).await.unwrap();
assert_eq!(credits, 5);
}
#[tokio::test]
async fn promo_codes_redeem_exactly_once() {
let pool = tc_testkit::test_pool().await;
let (ws, _, _) = seeded(&pool).await;
sqlx::query("INSERT INTO promo_codes (code, credits) VALUES ('WELCOME500', 500)")
.execute(&pool)
.await
.unwrap();
let granted = redeem_promo(&pool, ws.id, "WELCOME500").await.unwrap();
assert_eq!(granted, 500);
assert_eq!(
tc_db::repo::credits::balance(&pool, ws.id).await.unwrap(),
500
);
let again = redeem_promo(&pool, ws.id, "WELCOME500").await;
assert!(matches!(again, Err(BillingError::PromoUnavailable)));
let unknown = redeem_promo(&pool, ws.id, "NOPE").await;
assert!(matches!(unknown, Err(BillingError::PromoUnavailable)));
}
+11
View File
@@ -113,6 +113,7 @@ impl LlmProvider for AnthropicProvider {
// tool_use input arrives as accumulated partial JSON between // tool_use input arrives as accumulated partial JSON between
// content_block_start and content_block_stop. // content_block_start and content_block_stop.
let mut pending_tool: Option<(String, String, String)> = None; // id, name, json let mut pending_tool: Option<(String, String, String)> = None; // id, name, json
let mut input_tokens: u32 = 0;
while let Some(event) = sse.next().await { while let Some(event) = sse.next().await {
let event = event.map_err(|e| LlmError::Transport(e.to_string()))?; let event = event.map_err(|e| LlmError::Transport(e.to_string()))?;
let data: Value = serde_json::from_str(&event.data) let data: Value = serde_json::from_str(&event.data)
@@ -155,7 +156,17 @@ impl LlmProvider for AnthropicProvider {
yield LlmEvent::ToolUse { id, name, input }; yield LlmEvent::ToolUse { id, name, input };
} }
} }
"message_start" => {
input_tokens =
data["message"]["usage"]["input_tokens"].as_u64().unwrap_or(0) as u32;
}
"message_delta" => { "message_delta" => {
if let Some(out) = data["usage"]["output_tokens"].as_u64() {
yield LlmEvent::Usage {
input_tokens,
output_tokens: out as u32,
};
}
if let Some(reason) = data["delta"]["stop_reason"].as_str() { if let Some(reason) = data["delta"]["stop_reason"].as_str() {
yield LlmEvent::Stop(stop_reason(reason)); yield LlmEvent::Stop(stop_reason(reason));
} }
+7
View File
@@ -97,6 +97,7 @@ impl LlmProvider for OpenAiCompatProvider {
"max_tokens": request.max_tokens, "max_tokens": request.max_tokens,
"messages": OpenAiCompatProvider::wire_messages(&request), "messages": OpenAiCompatProvider::wire_messages(&request),
"stream": true, "stream": true,
"stream_options": {"include_usage": true},
}); });
if !tools.is_empty() { if !tools.is_empty() {
body["tools"] = Value::Array(tools); body["tools"] = Value::Array(tools);
@@ -159,6 +160,12 @@ impl LlmProvider for OpenAiCompatProvider {
if let Some(reason) = choice["finish_reason"].as_str() { if let Some(reason) = choice["finish_reason"].as_str() {
finish = Some(stop_reason(reason)); finish = Some(stop_reason(reason));
} }
if let Some(usage) = data["usage"].as_object() {
yield LlmEvent::Usage {
input_tokens: usage["prompt_tokens"].as_u64().unwrap_or(0) as u32,
output_tokens: usage["completion_tokens"].as_u64().unwrap_or(0) as u32,
};
}
} }
for (id, name, args) in pending.drain(..) { for (id, name, args) in pending.drain(..) {
if name.is_empty() { if name.is_empty() {
+5
View File
@@ -83,6 +83,11 @@ pub enum LlmEvent {
name: String, name: String,
input: Value, input: Value,
}, },
/// Token accounting for this provider call (drives credit metering).
Usage {
input_tokens: u32,
output_tokens: u32,
},
Stop(StopReason), Stop(StopReason),
} }
+26
View File
@@ -163,6 +163,32 @@ impl LlmProvider for ScriptedProvider {
} }
} }
// Deterministic accounting: one "token" per whitespace word in and
// out, so billing tests can predict exact charges.
let input_tokens = request
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
ContentPart::Text { text } => Some(text.split_whitespace().count()),
_ => None,
})
.sum::<usize>() as u32;
let output_tokens = events
.iter()
.filter_map(|e| match e {
Ok(LlmEvent::TextDelta(t)) => Some(t.split_whitespace().count()),
_ => None,
})
.sum::<usize>() as u32;
let stop_index = events.len().saturating_sub(1);
events.insert(
stop_index,
Ok(LlmEvent::Usage {
input_tokens,
output_tokens,
}),
);
Ok(Box::pin(stream::iter(events))) Ok(Box::pin(stream::iter(events)))
} }
} }
+1
View File
@@ -14,6 +14,7 @@ futures = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
tc-billing = { path = "../tc-billing" }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" } tc-domain = { path = "../tc-domain" }
tc-files = { path = "../tc-files" } tc-files = { path = "../tc-files" }
+28
View File
@@ -95,6 +95,11 @@ struct LoopState {
/// tainted, every later gated decision carries these sources. /// tainted, every later gated decision carries these sources.
#[serde(default)] #[serde(default)]
taint: Vec<String>, taint: Vec<String>,
/// Token accounting across every provider leg of this run.
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
} }
enum Outcome { enum Outcome {
@@ -210,6 +215,8 @@ impl Runtime {
result_parts: Vec::new(), result_parts: Vec::new(),
pending_tools: Vec::new(), pending_tools: Vec::new(),
taint: Vec::new(), taint: Vec::new(),
input_tokens: 0,
output_tokens: 0,
}; };
self.spawn_drive(run_id, state, true); self.spawn_drive(run_id, state, true);
@@ -556,6 +563,13 @@ impl Runtime {
LlmEvent::ToolUse { id, name, input } => { LlmEvent::ToolUse { id, name, input } => {
state.pending_tools.push(PendingTool { id, name, input }); state.pending_tools.push(PendingTool { id, name, input });
} }
LlmEvent::Usage {
input_tokens,
output_tokens,
} => {
state.input_tokens += u64::from(input_tokens);
state.output_tokens += u64::from(output_tokens);
}
LlmEvent::Stop(reason) => stop = reason, LlmEvent::Stop(reason) => stop = reason,
} }
} }
@@ -571,6 +585,20 @@ impl Runtime {
json!({"text": state.full_text}), json!({"text": state.full_text}),
) )
.await?; .await?;
// Meter the run (§8.4). Billing failures never fail the run — the
// usage ledger is the recovery path.
if let Err(error) = tc_billing::charge(
&self.inner.pool,
state.workspace_id,
state.agent_id,
run_id,
state.input_tokens,
state.output_tokens,
)
.await
{
eprintln!("billing charge failed for run {run_id}: {error}");
}
runs::set_state( runs::set_state(
&self.inner.pool, &self.inner.pool,
run_id, run_id,
+44
View File
@@ -229,3 +229,47 @@ events = [ { type = "text", text = "I could not use that tool." } ]
let run = tc_db::repo::runs::get(&pool, started.run_id).await.unwrap(); let run = tc_db::repo::runs::get(&pool, started.run_id).await.unwrap();
assert_eq!(run.state, RunState::Completed); assert_eq!(run.state, RunState::Completed);
} }
#[tokio::test]
async fn completed_runs_are_metered_and_decrement_credits() {
let pool = tc_testkit::test_pool().await;
let agent = seeded(&pool).await;
tc_db::repo::credits::add_lot(&pool, agent.workspace_id, 10, "seed")
.await
.unwrap();
let rt = runtime(pool.clone());
let session = tc_db::repo::sessions::create(&pool, agent.id, agent.workspace_id, "Bill")
.await
.unwrap();
let started = rt.send_message(session.id, "ping").await.unwrap();
drain(started.events).await;
// Scripted accounting: 1 word in, "I received: ping" = 3 words out →
// 4 tokens → 1 credit.
let mut metered = false;
for _ in 0..100 {
let row = sqlx::query!(
r#"SELECT tokens_in, tokens_out, credits FROM usage_events
WHERE workspace_id = $1"#,
agent.workspace_id.as_uuid(),
)
.fetch_optional(&pool)
.await
.unwrap();
if let Some(usage) = row {
assert_eq!(usage.tokens_in, 1);
assert_eq!(usage.tokens_out, 3);
assert_eq!(
tc_db::repo::credits::balance(&pool, agent.workspace_id)
.await
.unwrap(),
9
);
metered = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(metered, "usage event never recorded");
}
+6
View File
@@ -10,6 +10,12 @@ publish.workspace = true
sqlx = { workspace = true } sqlx = { workspace = true }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
testcontainers-modules = { workspace = true } testcontainers-modules = { workspace = true }
# Direct dep solely to turn on the `watchdog` feature (modules doesn't expose
# it as a passthrough). Features are additive across the graph, so this enables
# the reaper on the same testcontainers crate modules uses. It removes any
# container started by the fallback path on SIGINT/SIGTERM/SIGQUIT — a safety
# net for the rare local run that doesn't have TC_TEST_DATABASE_URL set.
testcontainers = { version = "0.25", features = ["watchdog"] }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
+7
View File
@@ -11,6 +11,7 @@ use sqlx::PgPool;
use testcontainers_modules::postgres::Postgres; use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner; use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::ContainerAsync; use testcontainers_modules::testcontainers::ContainerAsync;
use testcontainers_modules::testcontainers::ImageExt;
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
use uuid::Uuid; use uuid::Uuid;
@@ -34,7 +35,13 @@ async fn server() -> &'static PgServer {
_container: None, _container: None,
}; };
} }
// Pin the tag: `Postgres::default()` resolves to the EOL
// `postgres:11-alpine` in testcontainers-modules 0.13. Match prod
// (and `scripts/test.sh`) on 16-alpine. The shared-server path
// (`TC_TEST_DATABASE_URL`, above) is preferred and starts no
// container at all — see scripts/test.sh.
let container = Postgres::default() let container = Postgres::default()
.with_tag("16-alpine")
.start() .start()
.await .await
.expect("start postgres testcontainer"); .expect("start postgres testcontainer");
+44 -3
View File
@@ -1,25 +1,66 @@
import { z } from "zod";
import { PromoRedeem } from "@/components/global/PromoRedeem";
import { apiFetch } from "@/lib/api/http";
import { fetchCredits } from "@/lib/api/team"; import { fetchCredits } from "@/lib/api/team";
// Credits page (§8.4): available balance; purchase and usage land in P5. const UsageSchema = z.object({
tokens_in: z.number(),
tokens_out: z.number(),
credits: z.number(),
});
// Credits page (§8.4): balance, 7-day usage meter, promo redemption.
export default async function CreditsPage() { export default async function CreditsPage() {
const credits = await fetchCredits(); const [credits, usage] = await Promise.all([
fetchCredits(),
apiFetch(UsageSchema, "/api/team/usage"),
]);
const burn = usage.credits;
// Rough runway: at the current 7-day burn, how long does the balance last?
const runwayDays =
burn > 0 ? Math.floor((credits.available / burn) * 7) : null;
return ( return (
<section className="mx-auto max-w-3xl px-8 py-12"> <section className="mx-auto max-w-3xl px-8 py-12">
<h1 className="text-2xl font-semibold tracking-tight">Credits</h1> <h1 className="text-2xl font-semibold tracking-tight">Credits</h1>
<p className="pt-1 text-sm text-muted-foreground"> <p className="pt-1 text-sm text-muted-foreground">
Manage balance, subscriptions, and usage Manage balance, subscriptions, and usage
</p> </p>
<div className="mt-8 rounded-(--radius) border border-border bg-surface-warm p-6 shadow-(--shadow-card)"> <div className="mt-8 rounded-(--radius) border border-border bg-surface-warm p-6 shadow-(--shadow-card)">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Available credits Available credits
</p> </p>
<p className="pt-2 font-mono text-xxxl font-semibold"> <p
data-testid="credit-balance"
className="pt-2 font-mono text-xxxl font-semibold"
>
{credits.available.toLocaleString("en-US")} {credits.available.toLocaleString("en-US")}
</p> </p>
<p className="pt-2 text-xs text-muted-foreground"> <p className="pt-2 text-xs text-muted-foreground">
All credits never expire. All credits never expire.
</p> </p>
</div> </div>
<div className="mt-4 rounded-(--radius) border border-border bg-surface-warm p-6">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Usage · last 7 days
</p>
<p className="pt-2 text-sm">
{usage.credits.toLocaleString("en-US")} credits ·{" "}
{(usage.tokens_in + usage.tokens_out).toLocaleString("en-US")} tokens
({usage.tokens_in.toLocaleString("en-US")} in /{" "}
{usage.tokens_out.toLocaleString("en-US")} out)
</p>
{runwayDays !== null && (
<p className="pt-1 text-xs text-muted-foreground">
~{runwayDays} days of runway at this pace.
</p>
)}
</div>
<PromoRedeem />
</section> </section>
); );
} }
@@ -0,0 +1,58 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
/** Promo redemption (§8.4): one redemption per code, server-enforced. */
export function PromoRedeem() {
const router = useRouter();
const [message, setMessage] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setMessage(null);
const code = new FormData(event.currentTarget).get("code");
const res = await fetch("/api/credits/redeem", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
setPending(false);
if (res.ok) {
const { granted } = (await res.json()) as { granted: number };
setMessage(`Added ${granted.toLocaleString("en-US")} credits.`);
router.refresh();
} else {
setMessage("That code is invalid or already redeemed.");
}
}
return (
<form
onSubmit={redeem}
className="mt-4 flex items-center gap-2 rounded-(--radius) border border-border bg-surface-warm p-4"
>
<input
name="code"
required
aria-label="Promo code"
placeholder="PROMOCODE"
className="min-w-0 flex-1 rounded-(--radius) border border-input bg-background px-3 py-1.5 font-mono text-xs uppercase outline-none focus:border-accent"
/>
<button
type="submit"
disabled={pending}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light disabled:opacity-50"
>
Redeem
</button>
{message && (
<p role="status" className="text-xs text-muted-foreground">
{message}
</p>
)}
</form>
);
}
+273 -77
View File
@@ -1,104 +1,300 @@
"use client"; "use client";
// The 3-step creation wizard (§9): identity → access → slack, driven by
// ?step= (deep-linkable). Completing creation yields a LIVE agent, so the
// final step is an explicit review-and-confirm.
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react"; import { useState } from "react";
import { useQueryStates } from "nuqs";
import { panelParsers, WIZARD_STEPS } from "@/lib/url/panel-params";
import { Avatar } from "@/components/ui/Avatar";
const ACCENTS = ["#f96565", "#65a8f9", "#65f9a8", "#f9d965", "#c465f9"]; const ACCENTS = ["#f96565", "#65a8f9", "#65f9a8", "#f9d965", "#c465f9"];
const NAMES = [
"Scout", "Drafter", "Ledger", "Atlas", "Quill", "Beacon", "Harbor", "Vesper",
];
const TIPS = [
"Provisioning workspace…",
"Wiring up the Computer…",
"Teaching it your team's ways…",
];
interface Draft {
name: string;
jobTitle: string;
systemPrompt: string;
accent: string;
agentsMode: "any" | "specific";
}
/** Minimal claw creation (full §9 wizard with provisioning lands in P5).
* Completing this form creates a LIVE agent. */
export function CreateClawForm() { export function CreateClawForm() {
const router = useRouter(); const router = useRouter();
const [pending, setPending] = useState(false); const [{ step }, setParams] = useQueryStates(panelParsers, { shallow: true });
const [failed, setFailed] = useState(false); const [draft, setDraft] = useState<Draft>({
const [accent, setAccent] = useState(ACCENTS[0]); name: "",
jobTitle: "",
systemPrompt: "",
accent: ACCENTS[0],
agentsMode: "any",
});
const [confirming, setConfirming] = useState(false);
const [provisioning, setProvisioning] = useState(false);
const [tip, setTip] = useState(0);
async function handleSubmit(event: FormEvent<HTMLFormElement>) { const stepIndex = WIZARD_STEPS.indexOf(step);
event.preventDefault();
setPending(true); function randomName() {
setFailed(false); const pool = NAMES.filter((n) => n !== draft.name);
const data = new FormData(event.currentTarget); setDraft({ ...draft, name: pool[Math.floor(Math.random() * pool.length)] });
}
async function create() {
setProvisioning(true);
const ticker = setInterval(() => setTip((t) => (t + 1) % TIPS.length), 900);
const res = await fetch("/api/claws", { const res = await fetch("/api/claws", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: data.get("name"), name: draft.name,
job_title: data.get("job_title"), job_title: draft.jobTitle,
system_prompt: data.get("system_prompt") ?? "", system_prompt: draft.systemPrompt,
accent, accent: draft.accent,
}), }),
}); });
if (res.ok) { if (!res.ok) {
const claw = (await res.json()) as { id: string }; clearInterval(ticker);
router.push(`/claws/${claw.id}`); setProvisioning(false);
router.refresh();
return; return;
} }
setPending(false); const claw = (await res.json()) as { id: string };
setFailed(true); if (draft.agentsMode === "specific") {
await fetch(`/api/claws/${claw.id}/access`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
humans: { mode: "entire_team" },
agents: { mode: "specific", ids: [] },
}),
});
}
clearInterval(ticker);
router.push(`/claws/${claw.id}`);
router.refresh();
}
if (provisioning) {
return (
<div className="flex flex-col items-center gap-4 py-8">
<div className="motion-safe:animate-[avatar-breathe_1.6s_ease-in-out_infinite]">
<Avatar name={draft.name || "C"} accent={draft.accent} size="lg" />
</div>
<p className="text-sm font-medium">Bringing {draft.name} online</p>
<p
key={tip}
role="status"
className="text-xs text-muted-foreground motion-safe:animate-[tip-slide_var(--duration-normal)_var(--ease-app)]"
>
{TIPS[tip]}
</p>
</div>
);
} }
return ( return (
<form onSubmit={handleSubmit} className="flex w-full max-w-md flex-col gap-4"> <div className="flex w-full max-w-md flex-col gap-4">
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> {/* Step progress (§9). */}
Name * <div className="flex gap-1.5" aria-label={`Step ${stepIndex + 1} of 3`}>
<input {WIZARD_STEPS.map((s, i) => (
name="name" <span
required key={s}
placeholder="Scout" className={`h-1 flex-1 rounded-full ${
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent" i <= stepIndex ? "bg-accent" : "bg-surface-warm-muted"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Job title *
<input
name="job_title"
required
placeholder="Research Analyst"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Job description
<textarea
name="system_prompt"
rows={4}
placeholder="Describe how this claw should think and work..."
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
<span className="text-xxs">Becomes part of its system prompt.</span>
</label>
<div role="radiogroup" aria-label="Accent color" className="flex gap-2">
{ACCENTS.map((color) => (
<button
key={color}
type="button"
role="radio"
aria-checked={accent === color}
aria-label={`Accent ${color}`}
onClick={() => setAccent(color)}
style={{ backgroundColor: color }}
className={`size-6 rounded-full ${
accent === color ? "ring-2 ring-foreground ring-offset-2 ring-offset-background" : ""
}`} }`}
/> />
))} ))}
</div> </div>
{failed && (
<p role="alert" className="text-xs text-accent"> {step === "identity" && (
Could not create the claw. Check the fields and try again. <>
</p> <div className="flex items-center justify-center gap-3">
<Avatar name={draft.name || "?"} accent={draft.accent} size="lg" />
<div role="radiogroup" aria-label="Accent color" className="flex gap-2">
{ACCENTS.map((color) => (
<button
key={color}
type="button"
role="radio"
aria-checked={draft.accent === color}
aria-label={`Accent ${color}`}
onClick={() => setDraft({ ...draft, accent: color })}
style={{ backgroundColor: color }}
className={`size-5 rounded-full ${
draft.accent === color
? "ring-2 ring-foreground ring-offset-2 ring-offset-background"
: ""
}`}
/>
))}
</div>
</div>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Name *
<span className="flex gap-2">
<input
value={draft.name}
required
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
aria-label="Name"
placeholder="Scout"
className="min-w-0 flex-1 rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
<button
type="button"
aria-label="Random name"
onClick={randomName}
className="rounded-(--radius) border border-border px-2 text-sm text-muted-foreground hover:text-foreground"
>
</button>
</span>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Job title *
<input
value={draft.jobTitle}
onChange={(e) => setDraft({ ...draft, jobTitle: e.target.value })}
aria-label="Job title"
placeholder="Research Analyst"
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Job description
<textarea
value={draft.systemPrompt}
onChange={(e) =>
setDraft({ ...draft, systemPrompt: e.target.value })
}
rows={3}
aria-label="Job description"
placeholder="Describe how this claw should think..."
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
/>
<span className="text-xxs">Becomes part of its system prompt.</span>
</label>
<button
type="button"
disabled={!draft.name || !draft.jobTitle}
onClick={() => setParams({ step: "access" })}
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background hover:bg-coral-light disabled:opacity-40"
>
Continue
</button>
</>
)} )}
<button
type="submit" {step === "access" && (
disabled={pending} <>
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light disabled:opacity-50" <p className="text-sm font-medium">Who can this claw talk to?</p>
> <div
{pending ? "Creating…" : "Create claw"} role="radiogroup"
</button> aria-label="Other Claws"
<p className="text-xxs text-muted-foreground"> className="rounded-(--radius) border border-border bg-subtle p-2 text-xs"
Creating a claw brings it online immediately. >
</p> <p className="pb-1 text-muted-foreground">Other Claws</p>
</form> {(["any", "specific"] as const).map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={draft.agentsMode === mode}
onClick={() => setDraft({ ...draft, agentsMode: mode })}
className="flex w-full items-center gap-2 px-1 py-1 text-left hover:bg-surface-warm"
>
<span aria-hidden>
{draft.agentsMode === mode ? "◉" : "○"}
</span>
{mode === "any"
? "Any Claw on the team"
: "Specific claws — pick them later in Settings"}
</button>
))}
</div>
<div className="flex justify-between">
<button
type="button"
onClick={() => setParams({ step: "identity" })}
className="text-xs text-muted-foreground hover:text-foreground"
>
Back
</button>
<button
type="button"
onClick={() => setParams({ step: "slack" })}
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background hover:bg-coral-light"
>
Continue
</button>
</div>
</>
)}
{step === "slack" && !confirming && (
<>
<p className="text-sm font-medium">Bring {draft.name} into Slack?</p>
<p className="text-xs text-muted-foreground">
Optional you can connect Slack any time from the claw&apos;s
Computer. Outbound posts always need your approval.
</p>
<div className="flex justify-between">
<button
type="button"
onClick={() => setParams({ step: "access" })}
className="text-xs text-muted-foreground hover:text-foreground"
>
Back
</button>
<button
type="button"
onClick={() => setConfirming(true)}
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light"
>
Review &amp; create
</button>
</div>
</>
)}
{step === "slack" && confirming && (
<div className="rounded-(--radius) border border-accent/40 bg-surface-warm p-4">
<p className="text-sm font-medium">Create {draft.name}?</p>
<p className="pt-1 text-xs text-muted-foreground">
{draft.jobTitle} ·{" "}
{draft.agentsMode === "any"
? "reachable by any claw"
: "reachable by specific claws only"}
. This claw goes live immediately.
</p>
<div className="flex justify-end gap-2 pt-3">
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-(--radius-button) border border-border px-4 py-1.5 text-xs text-muted-foreground hover:text-foreground"
>
Back
</button>
<button
type="button"
onClick={create}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background shadow-(--shadow-cta) hover:bg-coral-light"
>
Create claw
</button>
</div>
</div>
)}
</div>
); );
} }
+18 -6
View File
@@ -117,16 +117,28 @@ test("multiple sessions hold separate transcripts", async ({ page }) => {
await expect(page.getByText("I received: first session message")).toBeVisible(); await expect(page.getByText("I received: first session message")).toBeVisible();
}); });
test("creating a claw from the rail goes straight to its chat", async ({ test("the 3-step wizard creates a live claw and lands in its chat", async ({
page, page,
}) => { }) => {
await signIn(page); await signIn(page);
await page.getByRole("link", { name: "New claw" }).click(); await page.getByRole("link", { name: "New claw" }).click();
await page.getByLabel("Name *").fill("Drafter");
await page.getByLabel("Job title *").fill("Writer"); // Step 1: identity (accent swatch + name + title).
await page await page.getByRole("radio", { name: "Accent #65a8f9" }).click();
.getByLabel(/Job description/) await page.getByLabel("Name", { exact: true }).fill("Drafter");
.fill("You draft crisp documents."); await page.getByLabel("Job title").fill("Writer");
await page.getByLabel("Job description").fill("You draft crisp documents.");
await page.getByRole("button", { name: "Continue →" }).click();
// Step 2: access.
await expect(page).toHaveURL(/step=access/);
await page.getByRole("radio", { name: /Any Claw on the team/ }).click();
await page.getByRole("button", { name: "Continue →" }).click();
// Step 3: slack (optional) → explicit confirm before the agent goes live.
await expect(page).toHaveURL(/step=slack/);
await page.getByRole("button", { name: "Review & create" }).click();
await expect(page.getByText("Create Drafter?")).toBeVisible();
await page.getByRole("button", { name: "Create claw" }).click(); await page.getByRole("button", { name: "Create claw" }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//); await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
+68
View File
@@ -0,0 +1,68 @@
import { expect, test, type Page } from "@playwright/test";
// P5 exit criterion (spec §17): the full admin + billing loop — credit
// decrement matches token usage, and promo redemption grants credits.
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
}
async function readBalance(page: Page): Promise<number> {
await page.getByRole("link", { name: "Credits" }).click();
await expect(page.getByText("Available credits")).toBeVisible();
const text = await page.getByTestId("credit-balance").innerText();
return Number(text.replace(/,/g, ""));
}
test("chat usage decrements credits and shows in the 7-day meter", async ({
page,
}) => {
await signIn(page);
const before = await readBalance(page);
await page.getByRole("link", { name: /Scout/ }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
// Fresh session: older sessions carry scenario markers in history that
// would steer the scripted provider away from the plain echo.
const before_url = page.url();
await page.getByRole("button", { name: "New" }).click();
await page.waitForURL((url) => url.toString() !== before_url);
const box = page.getByLabel("Message Scout");
await box.fill("bill me for this message");
await box.press("Enter");
await expect(
page.getByText("I received: bill me for this message"),
).toBeVisible();
await expect
.poll(async () => readBalance(page), { timeout: 15000 })
.toBeLessThan(before);
await expect(page.getByText(/Usage · last 7 days/i)).toBeVisible();
await expect(page.getByText(/tokens \(/)).toBeVisible();
});
test("a promo code redeems exactly once", async ({ page }) => {
await signIn(page);
const before = await readBalance(page);
await page.getByLabel("Promo code").fill("WELCOME500");
await page.getByRole("button", { name: "Redeem" }).click();
await expect(page.getByText("Added 500 credits.")).toBeVisible();
await expect
.poll(async () => readBalance(page))
.toBe(before + 500);
// Second redemption refuses.
await page.getByLabel("Promo code").fill("WELCOME500");
await page.getByRole("button", { name: "Redeem" }).click();
await expect(
page.getByText("That code is invalid or already redeemed."),
).toBeVisible();
});
+9
View File
@@ -0,0 +1,9 @@
-- Promo codes (§8.4): single-redemption credit grants.
CREATE TABLE promo_codes (
code TEXT PRIMARY KEY,
credits NUMERIC NOT NULL CHECK (credits > 0),
redeemed_by UUID REFERENCES workspaces (id),
redeemed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Manages the persistent shared Postgres that the whole test suite points at via
# TC_TEST_DATABASE_URL (wired up in .cargo/config.toml). One long-lived server
# means tc-testkit takes its `_container: None` branch and NEVER starts a
# per-test testcontainer — so a killed/panicking/orphaned `cargo test` has no
# container to leak. `--restart unless-stopped` keeps it up across Docker
# restarts so direct `cargo test` always has a server to connect to.
#
# Usage: scripts/test-server.sh {up|down|status|clean}
# up start the server if not already running (idempotent)
# down remove the server
# status show its state
# clean drop leftover test_<uuid> databases (reclaim space without a restart)
set -euo pipefail
CONTAINER="${TC_TEST_PG_CONTAINER:-teamclaw-test-pg}"
PORT="${TC_TEST_PG_PORT:-54331}"
case "${1:-up}" in
up)
if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "$CONTAINER already up on :$PORT"
exit 0
fi
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER" --restart unless-stopped \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
-p "${PORT}:5432" \
postgres:16-alpine \
-c fsync=off -c full_page_writes=off -c max_connections=300 >/dev/null
until docker exec "$CONTAINER" pg_isready -U postgres -q >/dev/null 2>&1; do
sleep 0.5
done
echo "$CONTAINER up on :$PORT"
;;
down)
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
echo "$CONTAINER removed"
;;
status)
docker ps -a --filter "name=^/${CONTAINER}$" \
--format '{{.Names}} | {{.Status}} | {{.Ports}}' || true
;;
clean)
# Drop every transient test database to reclaim space (tc-testkit creates
# test_<uuid> per test and does not drop them). Safe to run anytime.
docker exec "$CONTAINER" psql -U postgres -tAc \
"SELECT datname FROM pg_database WHERE datname LIKE 'test\_%'" \
| while IFS= read -r db; do
[ -n "$db" ] && docker exec "$CONTAINER" dropdb -U postgres --force "$db" || true
done
echo "dropped leftover test_* databases"
;;
*)
echo "usage: $0 {up|down|status|clean}" >&2
exit 2
;;
esac
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Convenience wrapper: make sure the shared test Postgres is up, then run the
# suite. TC_TEST_DATABASE_URL is supplied automatically by .cargo/config.toml,
# so `cargo test` — with OR without this wrapper — points at the shared server
# and spawns zero testcontainers. This wrapper just guarantees the server is up
# and trims leftover test_<uuid> databases before running.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
"$ROOT/scripts/test-server.sh" up
"$ROOT/scripts/test-server.sh" clean
cd "$ROOT"
status=0
if command -v cargo-nextest >/dev/null 2>&1; then
cargo nextest run "$@" || status=$?
else
cargo test "$@" || status=$?
fi
exit "$status"