forked from molecule-ai/molecule-core
Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
5.5 KiB
Go
172 lines
5.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// BudgetHandler exposes per-workspace budget read/write endpoints.
|
|
// Routes (all behind WorkspaceAuth middleware):
|
|
//
|
|
// GET /workspaces/:id/budget — current budget_limit, monthly_spend, budget_remaining
|
|
// PATCH /workspaces/:id/budget — set or clear budget_limit
|
|
type BudgetHandler struct{}
|
|
|
|
func NewBudgetHandler() *BudgetHandler { return &BudgetHandler{} }
|
|
|
|
// budgetResponse is the canonical JSON shape for both GET and PATCH responses.
|
|
type budgetResponse struct {
|
|
// BudgetLimit is the monthly spend ceiling in USD cents (null = no limit).
|
|
// budget_limit=500 means $5.00/month.
|
|
BudgetLimit *int64 `json:"budget_limit"`
|
|
// MonthlySpend is the agent's self-reported accumulated LLM API spend
|
|
// for the current month (USD cents). Incremented via heartbeat.
|
|
MonthlySpend int64 `json:"monthly_spend"`
|
|
// BudgetRemaining is null when BudgetLimit is null, otherwise
|
|
// max(0, budget_limit - monthly_spend). Can be negative — we store the
|
|
// actual value so callers can see how far over-budget a workspace is.
|
|
BudgetRemaining *int64 `json:"budget_remaining"`
|
|
}
|
|
|
|
// GetBudget handles GET /workspaces/:id/budget.
|
|
// Returns the workspace's current budget ceiling, accumulated spend, and
|
|
// computed remaining headroom. Both budget_limit and budget_remaining are
|
|
// null when no limit has been configured for the workspace.
|
|
func (h *BudgetHandler) GetBudget(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
var budgetLimit sql.NullInt64
|
|
var monthlySpend int64
|
|
err := db.DB.QueryRowContext(ctx,
|
|
`SELECT budget_limit, COALESCE(monthly_spend, 0)
|
|
FROM workspaces
|
|
WHERE id = $1 AND status != 'removed'`,
|
|
workspaceID,
|
|
).Scan(&budgetLimit, &monthlySpend)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("GetBudget: query failed for %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
|
|
resp := budgetResponse{
|
|
MonthlySpend: monthlySpend,
|
|
}
|
|
if budgetLimit.Valid {
|
|
limit := budgetLimit.Int64
|
|
resp.BudgetLimit = &limit
|
|
remaining := limit - monthlySpend
|
|
resp.BudgetRemaining = &remaining
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// patchBudgetRequest is the expected JSON body for PATCH /workspaces/:id/budget.
|
|
// budget_limit=null removes the ceiling; a positive integer sets it (USD cents).
|
|
type patchBudgetRequest struct {
|
|
// BudgetLimit pointer so JSON null → nil, absent → parse error (required field).
|
|
BudgetLimit *int64 `json:"budget_limit"`
|
|
}
|
|
|
|
// PatchBudget handles PATCH /workspaces/:id/budget.
|
|
// Accepts {"budget_limit": <int64>} to set a new ceiling, or
|
|
// {"budget_limit": null} to remove an existing ceiling.
|
|
// Returns the updated budget state in the same shape as GetBudget.
|
|
func (h *BudgetHandler) PatchBudget(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
// We need to distinguish between "field absent" and "field = null",
|
|
// so we unmarshal into a raw map first.
|
|
var raw map[string]interface{}
|
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
budgetLimitRaw, ok := raw["budget_limit"]
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit field is required"})
|
|
return
|
|
}
|
|
|
|
// Validate and convert the value. JSON numbers decode as float64.
|
|
var budgetArg interface{} // nil → SQL NULL, int64 → new ceiling
|
|
if budgetLimitRaw != nil {
|
|
switch v := budgetLimitRaw.(type) {
|
|
case float64:
|
|
if v < 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit must be >= 0 (USD cents)"})
|
|
return
|
|
}
|
|
cv := int64(v)
|
|
budgetArg = cv
|
|
case int64:
|
|
if v < 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit must be >= 0 (USD cents)"})
|
|
return
|
|
}
|
|
budgetArg = v
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "budget_limit must be an integer (USD cents) or null"})
|
|
return
|
|
}
|
|
}
|
|
// budgetArg == nil means "clear the ceiling"
|
|
|
|
// Existence check — return 404 for non-existent / removed workspaces.
|
|
var exists bool
|
|
if err := db.DB.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1 AND status != 'removed')`,
|
|
workspaceID,
|
|
).Scan(&exists); err != nil || !exists {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return
|
|
}
|
|
|
|
if _, err := db.DB.ExecContext(ctx,
|
|
`UPDATE workspaces SET budget_limit = $2, updated_at = now() WHERE id = $1`,
|
|
workspaceID, budgetArg,
|
|
); err != nil {
|
|
log.Printf("PatchBudget: update failed for %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
|
|
return
|
|
}
|
|
|
|
// Re-read the current state so the response reflects exactly what is in
|
|
// the DB, including the monthly_spend the agent has already accumulated.
|
|
var newLimit sql.NullInt64
|
|
var monthlySpend int64
|
|
if err := db.DB.QueryRowContext(ctx,
|
|
`SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1`,
|
|
workspaceID,
|
|
).Scan(&newLimit, &monthlySpend); err != nil {
|
|
log.Printf("PatchBudget: re-read failed for %s: %v", workspaceID, err)
|
|
// Still success — just omit the echo.
|
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
|
return
|
|
}
|
|
|
|
resp := budgetResponse{
|
|
MonthlySpend: monthlySpend,
|
|
}
|
|
if newLimit.Valid {
|
|
limit := newLimit.Int64
|
|
resp.BudgetLimit = &limit
|
|
remaining := limit - monthlySpend
|
|
resp.BudgetRemaining = &remaining
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|