refactor(costs): decouple cost recording from Dolt database
Replace bd create --ephemeral wisp with simple file append to ~/.gt/costs.jsonl. This ensures the stop hook never fails due to: - Dolt server not running (connection refused) - Dolt connection stale (invalid connection) - Database temporarily unavailable The costs.jsonl approach: - Stop hook appends JSON line (fire-and-forget, ~0ms) - gt costs --today reads from log file - gt costs digest aggregates log entries into permanent beads This is Option 1 from gt-99ls5z design bead. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,28 +63,29 @@ The infrastructure remains in place and will work once cost data is available.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt costs # Live costs from running sessions
|
gt costs # Live costs from running sessions
|
||||||
gt costs --today # Today's costs from wisps (not yet digested)
|
gt costs --today # Today's costs from log file (not yet digested)
|
||||||
gt costs --week # This week's costs from digest beads + today's wisps
|
gt costs --week # This week's costs from digest beads + today's log
|
||||||
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
|
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
|
||||||
gt costs --by-rig # Breakdown by rig
|
gt costs --by-rig # Breakdown by rig
|
||||||
gt costs --json # Output as JSON
|
gt costs --json # Output as JSON
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
gt costs record # Record session cost as ephemeral wisp (Stop hook)
|
gt costs record # Record session cost to local log file (Stop hook)
|
||||||
gt costs digest # Aggregate wisps into daily digest bead (Deacon patrol)`,
|
gt costs digest # Aggregate log entries into daily digest bead (Deacon patrol)`,
|
||||||
RunE: runCosts,
|
RunE: runCosts,
|
||||||
}
|
}
|
||||||
|
|
||||||
var costsRecordCmd = &cobra.Command{
|
var costsRecordCmd = &cobra.Command{
|
||||||
Use: "record",
|
Use: "record",
|
||||||
Short: "Record session cost as an ephemeral wisp (called by Stop hook)",
|
Short: "Record session cost to local log file (called by Stop hook)",
|
||||||
Long: `Record the final cost of a session as an ephemeral wisp.
|
Long: `Record the final cost of a session to a local log file.
|
||||||
|
|
||||||
This command is intended to be called from a Claude Code Stop hook.
|
This command is intended to be called from a Claude Code Stop hook.
|
||||||
It captures the final cost from the tmux session and creates an ephemeral
|
It captures the final cost from the tmux session and appends it to
|
||||||
event that is NOT exported to JSONL (avoiding log-in-database pollution).
|
~/.gt/costs.jsonl. This is a simple append operation that never fails
|
||||||
|
due to database availability.
|
||||||
|
|
||||||
Session cost wisps are aggregated daily by 'gt costs digest' into a single
|
Session costs are aggregated daily by 'gt costs digest' into a single
|
||||||
permanent "Cost Report YYYY-MM-DD" bead for audit purposes.
|
permanent "Cost Report YYYY-MM-DD" bead for audit purposes.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -95,12 +96,12 @@ Examples:
|
|||||||
|
|
||||||
var costsDigestCmd = &cobra.Command{
|
var costsDigestCmd = &cobra.Command{
|
||||||
Use: "digest",
|
Use: "digest",
|
||||||
Short: "Aggregate session cost wisps into a daily digest bead",
|
Short: "Aggregate session cost log entries into a daily digest bead",
|
||||||
Long: `Aggregate ephemeral session cost wisps into a permanent daily digest.
|
Long: `Aggregate session cost log entries into a permanent daily digest.
|
||||||
|
|
||||||
This command is intended to be run by Deacon patrol (daily) or manually.
|
This command is intended to be run by Deacon patrol (daily) or manually.
|
||||||
It queries session.ended wisps for a target date, creates a single aggregate
|
It reads entries from ~/.gt/costs.jsonl for a target date, creates a single
|
||||||
"Cost Report YYYY-MM-DD" bead, then deletes the source wisps.
|
aggregate "Cost Report YYYY-MM-DD" bead, then removes the source entries.
|
||||||
|
|
||||||
The resulting digest bead is permanent (exported to JSONL, synced via git)
|
The resulting digest bead is permanent (exported to JSONL, synced via git)
|
||||||
and provides an audit trail without log-in-database pollution.
|
and provides an audit trail without log-in-database pollution.
|
||||||
@@ -114,18 +115,18 @@ Examples:
|
|||||||
|
|
||||||
var costsMigrateCmd = &cobra.Command{
|
var costsMigrateCmd = &cobra.Command{
|
||||||
Use: "migrate",
|
Use: "migrate",
|
||||||
Short: "Migrate legacy session.ended beads to the new wisp architecture",
|
Short: "Migrate legacy session.ended beads to the new log-file architecture",
|
||||||
Long: `Migrate legacy session.ended event beads to the new cost tracking system.
|
Long: `Migrate legacy session.ended event beads to the new cost tracking system.
|
||||||
|
|
||||||
This command handles the transition from the old architecture (where each
|
This command handles the transition from the old architecture (where each
|
||||||
session.ended event was a permanent bead) to the new wisp-based system.
|
session.ended event was a permanent bead) to the new log-file-based system.
|
||||||
|
|
||||||
The migration:
|
The migration:
|
||||||
1. Finds all open session.ended event beads (should be none if auto-close worked)
|
1. Finds all open session.ended event beads (should be none if auto-close worked)
|
||||||
2. Closes them with reason "migrated to wisp architecture"
|
2. Closes them with reason "migrated to log-file architecture"
|
||||||
|
|
||||||
Legacy beads remain in the database for historical queries but won't interfere
|
Legacy beads remain in the database for historical queries but won't interfere
|
||||||
with the new wisp-based cost tracking.
|
with the new log-file-based cost tracking.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt costs migrate # Migrate legacy beads
|
gt costs migrate # Migrate legacy beads
|
||||||
@@ -738,8 +739,29 @@ func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCostsRecord captures the final cost from a session and records it as a bead event.
|
// CostLogEntry represents a single entry in the costs.jsonl log file.
|
||||||
// This is called by the Claude Code Stop hook.
|
type CostLogEntry struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Rig string `json:"rig,omitempty"`
|
||||||
|
Worker string `json:"worker,omitempty"`
|
||||||
|
CostUSD float64 `json:"cost_usd"`
|
||||||
|
EndedAt time.Time `json:"ended_at"`
|
||||||
|
WorkItem string `json:"work_item,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCostsLogPath returns the path to the costs log file (~/.gt/costs.jsonl).
|
||||||
|
func getCostsLogPath() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "/tmp/gt-costs.jsonl" // Fallback
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".gt", "costs.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCostsRecord captures the final cost from a session and appends it to a local log file.
|
||||||
|
// This is called by the Claude Code Stop hook. It's designed to never fail due to
|
||||||
|
// database availability - it's a simple file append operation.
|
||||||
func runCostsRecord(cmd *cobra.Command, args []string) error {
|
func runCostsRecord(cmd *cobra.Command, args []string) error {
|
||||||
// Get session from flag or try to detect from environment
|
// Get session from flag or try to detect from environment
|
||||||
session := recordSession
|
session := recordSession
|
||||||
@@ -773,92 +795,47 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
// Parse session name
|
// Parse session name
|
||||||
role, rig, worker := parseSessionName(session)
|
role, rig, worker := parseSessionName(session)
|
||||||
|
|
||||||
// Build agent path for actor field
|
// Build log entry
|
||||||
agentPath := buildAgentPath(role, rig, worker)
|
entry := CostLogEntry{
|
||||||
|
SessionID: session,
|
||||||
// Build event title
|
Role: role,
|
||||||
title := fmt.Sprintf("Session ended: %s", session)
|
Rig: rig,
|
||||||
if recordWorkItem != "" {
|
Worker: worker,
|
||||||
title = fmt.Sprintf("Session: %s completed %s", session, recordWorkItem)
|
CostUSD: cost,
|
||||||
|
EndedAt: time.Now(),
|
||||||
|
WorkItem: recordWorkItem,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build payload JSON
|
// Marshal to JSON
|
||||||
payload := map[string]interface{}{
|
entryJSON, err := json.Marshal(entry)
|
||||||
"cost_usd": cost,
|
|
||||||
"session_id": session,
|
|
||||||
"role": role,
|
|
||||||
"ended_at": time.Now().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if rig != "" {
|
|
||||||
payload["rig"] = rig
|
|
||||||
}
|
|
||||||
if worker != "" {
|
|
||||||
payload["worker"] = worker
|
|
||||||
}
|
|
||||||
payloadJSON, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshaling payload: %w", err)
|
return fmt.Errorf("marshaling cost entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build bd create command for ephemeral wisp
|
// Append to log file
|
||||||
// Using --ephemeral creates a wisp that:
|
logPath := getCostsLogPath()
|
||||||
// - Is stored locally only (not exported to JSONL)
|
|
||||||
// - Won't pollute git history with O(sessions/day) events
|
// Ensure directory exists
|
||||||
// - Will be aggregated into daily digests by 'gt costs digest'
|
logDir := filepath.Dir(logPath)
|
||||||
bdArgs := []string{
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||||
"create",
|
return fmt.Errorf("creating log directory: %w", err)
|
||||||
"--ephemeral",
|
|
||||||
"--type=event",
|
|
||||||
"--title=" + title,
|
|
||||||
"--event-category=session.ended",
|
|
||||||
"--event-actor=" + agentPath,
|
|
||||||
"--event-payload=" + string(payloadJSON),
|
|
||||||
"--silent",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add work item as event target if specified
|
// Open file for append (create if doesn't exist)
|
||||||
if recordWorkItem != "" {
|
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
bdArgs = append(bdArgs, "--event-target="+recordWorkItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: We intentionally don't use --rig flag here because it causes
|
|
||||||
// event fields (event_kind, actor, payload) to not be stored properly.
|
|
||||||
// The bd command will auto-detect the correct rig from cwd.
|
|
||||||
|
|
||||||
// Find town root so bd can find the .beads database.
|
|
||||||
// The stop hook may run from a role subdirectory (e.g., mayor/) that
|
|
||||||
// doesn't have its own .beads, so we need to run bd from town root.
|
|
||||||
townRoot, err := workspace.FindFromCwd()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("finding town root: %w", err)
|
return fmt.Errorf("opening costs log: %w", err)
|
||||||
}
|
|
||||||
if townRoot == "" {
|
|
||||||
return fmt.Errorf("not in a Gas Town workspace")
|
|
||||||
}
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
// Execute bd create from town root
|
// Write entry with newline
|
||||||
bdCmd := exec.Command("bd", bdArgs...)
|
if _, err := f.Write(append(entryJSON, '\n')); err != nil {
|
||||||
bdCmd.Dir = townRoot
|
return fmt.Errorf("writing to costs log: %w", err)
|
||||||
output, err := bdCmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
wispID := strings.TrimSpace(string(output))
|
|
||||||
|
|
||||||
// Auto-close session cost wisps immediately after creation.
|
|
||||||
// These are informational records that don't need to stay open.
|
|
||||||
// The wisp data is preserved and queryable until digested.
|
|
||||||
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
|
|
||||||
closeCmd.Dir = townRoot
|
|
||||||
if closeErr := closeCmd.Run(); closeErr != nil {
|
|
||||||
// Non-fatal: wisp was created, just couldn't auto-close
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not auto-close session cost wisp %s: %v\n", wispID, closeErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output confirmation (silent if cost is zero and no work item)
|
// Output confirmation (silent if cost is zero and no work item)
|
||||||
if cost > 0 || recordWorkItem != "" {
|
if cost > 0 || recordWorkItem != "" {
|
||||||
fmt.Printf("%s Recorded $%.2f for %s (wisp: %s)", style.Success.Render("✓"), cost, session, wispID)
|
fmt.Printf("%s Recorded $%.2f for %s", style.Success.Render("✓"), cost, session)
|
||||||
if recordWorkItem != "" {
|
if recordWorkItem != "" {
|
||||||
fmt.Printf(" (work: %s)", recordWorkItem)
|
fmt.Printf(" (work: %s)", recordWorkItem)
|
||||||
}
|
}
|
||||||
@@ -928,44 +905,6 @@ func detectCurrentTmuxSession() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAgentPath builds the agent path from role, rig, and worker.
|
|
||||||
// Examples: "mayor", "gastown/witness", "gastown/polecats/toast"
|
|
||||||
func buildAgentPath(role, rig, worker string) string {
|
|
||||||
switch role {
|
|
||||||
case constants.RoleMayor, constants.RoleDeacon:
|
|
||||||
return role
|
|
||||||
case constants.RoleWitness, constants.RoleRefinery:
|
|
||||||
if rig != "" {
|
|
||||||
return rig + "/" + role
|
|
||||||
}
|
|
||||||
return role
|
|
||||||
case constants.RolePolecat:
|
|
||||||
if rig != "" && worker != "" {
|
|
||||||
return rig + "/polecats/" + worker
|
|
||||||
}
|
|
||||||
if rig != "" {
|
|
||||||
return rig + "/polecat"
|
|
||||||
}
|
|
||||||
return "polecat/" + worker
|
|
||||||
case constants.RoleCrew:
|
|
||||||
if rig != "" && worker != "" {
|
|
||||||
return rig + "/crew/" + worker
|
|
||||||
}
|
|
||||||
if rig != "" {
|
|
||||||
return rig + "/crew"
|
|
||||||
}
|
|
||||||
return "crew/" + worker
|
|
||||||
default:
|
|
||||||
if rig != "" && worker != "" {
|
|
||||||
return rig + "/" + worker
|
|
||||||
}
|
|
||||||
if rig != "" {
|
|
||||||
return rig
|
|
||||||
}
|
|
||||||
return worker
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CostDigest represents the aggregated daily cost report.
|
// CostDigest represents the aggregated daily cost report.
|
||||||
type CostDigest struct {
|
type CostDigest struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
@@ -976,21 +915,7 @@ type CostDigest struct {
|
|||||||
ByRig map[string]float64 `json:"by_rig,omitempty"`
|
ByRig map[string]float64 `json:"by_rig,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WispListOutput represents the JSON output from bd mol wisp list.
|
// runCostsDigest aggregates session cost entries into a daily digest bead.
|
||||||
type WispListOutput struct {
|
|
||||||
Wisps []WispItem `json:"wisps"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WispItem represents a single wisp from bd mol wisp list.
|
|
||||||
type WispItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCostsDigest aggregates session cost wisps into a daily digest bead.
|
|
||||||
func runCostsDigest(cmd *cobra.Command, args []string) error {
|
func runCostsDigest(cmd *cobra.Command, args []string) error {
|
||||||
// Determine target date
|
// Determine target date
|
||||||
var targetDate time.Time
|
var targetDate time.Time
|
||||||
@@ -1016,7 +941,7 @@ func runCostsDigest(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(wisps) == 0 {
|
if len(wisps) == 0 {
|
||||||
fmt.Printf("%s No session cost wisps found for %s\n", style.Dim.Render("○"), dateStr)
|
fmt.Printf("%s No session cost entries found for %s\n", style.Dim.Render("○"), dateStr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,105 +985,70 @@ func runCostsDigest(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("creating digest bead: %w", err)
|
return fmt.Errorf("creating digest bead: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete source wisps (they're ephemeral, use bd mol burn)
|
// Delete source entries from log file
|
||||||
deletedCount, deleteErr := deleteSessionCostWisps(targetDate)
|
deletedCount, deleteErr := deleteSessionCostEntries(targetDate)
|
||||||
if deleteErr != nil {
|
if deleteErr != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: failed to delete some source wisps: %v\n", deleteErr)
|
fmt.Fprintf(os.Stderr, "warning: failed to delete some source entries: %v\n", deleteErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s Created Cost Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
|
fmt.Printf("%s Created Cost Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
|
||||||
fmt.Printf(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
|
fmt.Printf(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
|
||||||
if deletedCount > 0 {
|
if deletedCount > 0 {
|
||||||
fmt.Printf(" Deleted %d source wisps\n", deletedCount)
|
fmt.Printf(" Removed %d entries from costs log\n", deletedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// querySessionCostWisps queries ephemeral session.ended events for a target date.
|
// querySessionCostWisps reads session cost entries from the local log file for a target date.
|
||||||
func querySessionCostWisps(targetDate time.Time) ([]CostEntry, error) {
|
func querySessionCostWisps(targetDate time.Time) ([]CostEntry, error) {
|
||||||
// List all wisps including closed ones
|
logPath := getCostsLogPath()
|
||||||
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
|
||||||
listOutput, err := listCmd.Output()
|
// Read log file
|
||||||
|
data, err := os.ReadFile(logPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No wisps database or command failed
|
if os.IsNotExist(err) {
|
||||||
if costsVerbose {
|
return nil, nil // No log file yet
|
||||||
fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err)
|
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, fmt.Errorf("reading costs log: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var wispList WispListOutput
|
|
||||||
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing wisp list: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wispList.Count == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch all wisp IDs into a single bd show call to avoid N+1 queries
|
|
||||||
showArgs := []string{"show", "--json"}
|
|
||||||
for _, wisp := range wispList.Wisps {
|
|
||||||
showArgs = append(showArgs, wisp.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
showCmd := exec.Command("bd", showArgs...)
|
|
||||||
showOutput, err := showCmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("showing wisps: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var events []SessionEvent
|
|
||||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing wisp details: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionCostWisps []CostEntry
|
|
||||||
targetDay := targetDate.Format("2006-01-02")
|
targetDay := targetDate.Format("2006-01-02")
|
||||||
|
var entries []CostEntry
|
||||||
|
|
||||||
for _, event := range events {
|
// Parse each line as a CostLogEntry
|
||||||
// Filter for session.ended events only
|
lines := strings.Split(string(data), "\n")
|
||||||
if event.EventKind != "session.ended" {
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse payload
|
var logEntry CostLogEntry
|
||||||
var payload SessionPayload
|
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||||
if event.Payload != "" {
|
if costsVerbose {
|
||||||
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
|
fmt.Fprintf(os.Stderr, "[costs] failed to parse log entry: %v\n", err)
|
||||||
if costsVerbose {
|
|
||||||
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for event %s: %v\n", event.ID, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ended_at and filter by target date
|
|
||||||
endedAt := event.CreatedAt
|
|
||||||
if payload.EndedAt != "" {
|
|
||||||
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
|
|
||||||
endedAt = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this event is from the target date
|
|
||||||
if endedAt.Format("2006-01-02") != targetDay {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCostWisps = append(sessionCostWisps, CostEntry{
|
// Filter by target date
|
||||||
SessionID: payload.SessionID,
|
if logEntry.EndedAt.Format("2006-01-02") != targetDay {
|
||||||
Role: payload.Role,
|
continue
|
||||||
Rig: payload.Rig,
|
}
|
||||||
Worker: payload.Worker,
|
|
||||||
CostUSD: payload.CostUSD,
|
entries = append(entries, CostEntry{
|
||||||
EndedAt: endedAt,
|
SessionID: logEntry.SessionID,
|
||||||
WorkItem: event.Target,
|
Role: logEntry.Role,
|
||||||
|
Rig: logEntry.Rig,
|
||||||
|
Worker: logEntry.Worker,
|
||||||
|
CostUSD: logEntry.CostUSD,
|
||||||
|
EndedAt: logEntry.EndedAt,
|
||||||
|
WorkItem: logEntry.WorkItem,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionCostWisps, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createCostDigestBead creates a permanent bead for the daily cost digest.
|
// createCostDigestBead creates a permanent bead for the daily cost digest.
|
||||||
@@ -1228,96 +1118,63 @@ func createCostDigestBead(digest CostDigest) (string, error) {
|
|||||||
return digestID, nil
|
return digestID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteSessionCostWisps deletes ephemeral session.ended wisps for a target date.
|
// deleteSessionCostEntries removes entries for a target date from the costs log file.
|
||||||
func deleteSessionCostWisps(targetDate time.Time) (int, error) {
|
// It rewrites the file without the entries for that date.
|
||||||
// List all wisps
|
func deleteSessionCostEntries(targetDate time.Time) (int, error) {
|
||||||
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
logPath := getCostsLogPath()
|
||||||
listOutput, err := listCmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
if costsVerbose {
|
|
||||||
fmt.Fprintf(os.Stderr, "[costs] wisp list failed in deletion: %v\n", err)
|
|
||||||
}
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var wispList WispListOutput
|
// Read log file
|
||||||
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
data, err := os.ReadFile(logPath)
|
||||||
return 0, fmt.Errorf("parsing wisp list: %w", err)
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil // No log file
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("reading costs log: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
targetDay := targetDate.Format("2006-01-02")
|
targetDay := targetDate.Format("2006-01-02")
|
||||||
|
var keepLines []string
|
||||||
|
deletedCount := 0
|
||||||
|
|
||||||
// Collect all wisp IDs that match our criteria
|
// Filter out entries for target date
|
||||||
var wispIDsToDelete []string
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
for _, wisp := range wispList.Wisps {
|
line = strings.TrimSpace(line)
|
||||||
// Get full wisp details to check if it's a session.ended event
|
if line == "" {
|
||||||
showCmd := exec.Command("bd", "show", wisp.ID, "--json")
|
|
||||||
showOutput, err := showCmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
if costsVerbose {
|
|
||||||
fmt.Fprintf(os.Stderr, "[costs] bd show failed for wisp %s: %v\n", wisp.ID, err)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var events []SessionEvent
|
var logEntry CostLogEntry
|
||||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||||
if costsVerbose {
|
// Keep unparseable lines (shouldn't happen but be safe)
|
||||||
fmt.Fprintf(os.Stderr, "[costs] JSON unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
keepLines = append(keepLines, line)
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(events) == 0 {
|
// Remove entries from target date
|
||||||
|
if logEntry.EndedAt.Format("2006-01-02") == targetDay {
|
||||||
|
deletedCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
event := events[0]
|
keepLines = append(keepLines, line)
|
||||||
|
|
||||||
// Only delete session.ended wisps
|
|
||||||
if event.EventKind != "session.ended" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse payload to get ended_at for date filtering
|
|
||||||
var payload SessionPayload
|
|
||||||
if event.Payload != "" {
|
|
||||||
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
|
|
||||||
if costsVerbose {
|
|
||||||
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endedAt := event.CreatedAt
|
|
||||||
if payload.EndedAt != "" {
|
|
||||||
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
|
|
||||||
endedAt = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only delete wisps from the target date
|
|
||||||
if endedAt.Format("2006-01-02") != targetDay {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
wispIDsToDelete = append(wispIDsToDelete, wisp.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wispIDsToDelete) == 0 {
|
if deletedCount == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch delete all wisps in a single subprocess call
|
// Rewrite file without deleted entries
|
||||||
burnArgs := append([]string{"mol", "burn", "--force"}, wispIDsToDelete...)
|
newContent := strings.Join(keepLines, "\n")
|
||||||
burnCmd := exec.Command("bd", burnArgs...)
|
if len(keepLines) > 0 {
|
||||||
if burnErr := burnCmd.Run(); burnErr != nil {
|
newContent += "\n"
|
||||||
return 0, fmt.Errorf("batch burn failed: %w", burnErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(wispIDsToDelete), nil
|
if err := os.WriteFile(logPath, []byte(newContent), 0644); err != nil {
|
||||||
|
return 0, fmt.Errorf("rewriting costs log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCostsMigrate migrates legacy session.ended beads to the new architecture.
|
// runCostsMigrate migrates legacy session.ended beads to the new architecture.
|
||||||
@@ -1399,7 +1256,7 @@ func runCostsMigrate(cmd *cobra.Command, args []string) error {
|
|||||||
// Close all open session.ended events
|
// Close all open session.ended events
|
||||||
closedMigrated := 0
|
closedMigrated := 0
|
||||||
for _, event := range openEvents {
|
for _, event := range openEvents {
|
||||||
closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to wisp architecture")
|
closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to log-file architecture")
|
||||||
if err := closeCmd.Run(); err != nil {
|
if err := closeCmd.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
|
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
|
||||||
continue
|
continue
|
||||||
@@ -1409,7 +1266,7 @@ func runCostsMigrate(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
fmt.Printf("\n%s Migrated %d session.ended events (closed)\n", style.Success.Render("✓"), closedMigrated)
|
fmt.Printf("\n%s Migrated %d session.ended events (closed)\n", style.Success.Render("✓"), closedMigrated)
|
||||||
fmt.Println(style.Dim.Render("Legacy beads preserved for historical queries."))
|
fmt.Println(style.Dim.Render("Legacy beads preserved for historical queries."))
|
||||||
fmt.Println(style.Dim.Render("New session costs will use ephemeral wisps + daily digests."))
|
fmt.Println(style.Dim.Render("New session costs will use ~/.gt/costs.jsonl + daily digests."))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user