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:
co-authored by
Claude Fable 5
parent
91327e3618
commit
a8efada690
@@ -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"
|
||||||
+15
@@ -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"
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT tokens_in, tokens_out, credits FROM usage_events\n WHERE workspace_id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "tokens_in",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "tokens_out",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "credits",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "47c1cee8591250819d22e87058c6011bebb77eaf93c1cfa2535db42221d8ecf3"
|
||||||
|
}
|
||||||
+19
@@ -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"
|
||||||
|
}
|
||||||
+34
@@ -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"
|
||||||
|
}
|
||||||
+23
@@ -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"
|
||||||
|
}
|
||||||
+28
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,59 +1,171 @@
|
|||||||
"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">
|
||||||
|
{/* Step progress (§9). */}
|
||||||
|
<div className="flex gap-1.5" aria-label={`Step ${stepIndex + 1} of 3`}>
|
||||||
|
{WIZARD_STEPS.map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className={`h-1 flex-1 rounded-full ${
|
||||||
|
i <= stepIndex ? "bg-accent" : "bg-surface-warm-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "identity" && (
|
||||||
|
<>
|
||||||
|
<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">
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
Name *
|
Name *
|
||||||
|
<span className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
name="name"
|
value={draft.name}
|
||||||
required
|
required
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
aria-label="Name"
|
||||||
placeholder="Scout"
|
placeholder="Scout"
|
||||||
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
|
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>
|
||||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
Job title *
|
Job title *
|
||||||
<input
|
<input
|
||||||
name="job_title"
|
value={draft.jobTitle}
|
||||||
required
|
onChange={(e) => setDraft({ ...draft, jobTitle: e.target.value })}
|
||||||
|
aria-label="Job title"
|
||||||
placeholder="Research Analyst"
|
placeholder="Research Analyst"
|
||||||
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
|
className="rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
@@ -61,44 +173,128 @@ export function CreateClawForm() {
|
|||||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
Job description
|
Job description
|
||||||
<textarea
|
<textarea
|
||||||
name="system_prompt"
|
value={draft.systemPrompt}
|
||||||
rows={4}
|
onChange={(e) =>
|
||||||
placeholder="Describe how this claw should think and work..."
|
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"
|
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>
|
<span className="text-xxs">Becomes part of its system prompt.</span>
|
||||||
</label>
|
</label>
|
||||||
<div role="radiogroup" aria-label="Accent color" className="flex gap-2">
|
|
||||||
{ACCENTS.map((color) => (
|
|
||||||
<button
|
<button
|
||||||
key={color}
|
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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "access" && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium">Who can this claw talk to?</p>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Other Claws"
|
||||||
|
className="rounded-(--radius) border border-border bg-subtle p-2 text-xs"
|
||||||
|
>
|
||||||
|
<p className="pb-1 text-muted-foreground">Other Claws</p>
|
||||||
|
{(["any", "specific"] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={accent === color}
|
aria-checked={draft.agentsMode === mode}
|
||||||
aria-label={`Accent ${color}`}
|
onClick={() => setDraft({ ...draft, agentsMode: mode })}
|
||||||
onClick={() => setAccent(color)}
|
className="flex w-full items-center gap-2 px-1 py-1 text-left hover:bg-surface-warm"
|
||||||
style={{ backgroundColor: color }}
|
>
|
||||||
className={`size-6 rounded-full ${
|
<span aria-hidden>
|
||||||
accent === color ? "ring-2 ring-foreground ring-offset-2 ring-offset-background" : ""
|
{draft.agentsMode === mode ? "◉" : "○"}
|
||||||
}`}
|
</span>
|
||||||
/>
|
{mode === "any"
|
||||||
|
? "Any Claw on the team"
|
||||||
|
: "Specific claws — pick them later in Settings"}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{failed && (
|
<div className="flex justify-between">
|
||||||
<p role="alert" className="text-xs text-accent">
|
|
||||||
Could not create the claw. Check the fields and try again.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={pending}
|
onClick={() => setParams({ step: "identity" })}
|
||||||
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"
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
{pending ? "Creating…" : "Create claw"}
|
‹ Back
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xxs text-muted-foreground">
|
<button
|
||||||
Creating a claw brings it online immediately.
|
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's
|
||||||
|
Computer. Outbound posts always need your approval.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
<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 & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\//);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
Executable
+59
@@ -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
|
||||||
Executable
+21
@@ -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"
|
||||||
Reference in New Issue
Block a user