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:
|
||||
gt costs # Live costs from running sessions
|
||||
gt costs --today # Today's costs from wisps (not yet digested)
|
||||
gt costs --week # This week's costs from digest beads + today's wisps
|
||||
gt costs --today # Today's costs from log file (not yet digested)
|
||||
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-rig # Breakdown by rig
|
||||
gt costs --json # Output as JSON
|
||||
|
||||
Subcommands:
|
||||
gt costs record # Record session cost as ephemeral wisp (Stop hook)
|
||||
gt costs digest # Aggregate wisps into daily digest bead (Deacon patrol)`,
|
||||
gt costs record # Record session cost to local log file (Stop hook)
|
||||
gt costs digest # Aggregate log entries into daily digest bead (Deacon patrol)`,
|
||||
RunE: runCosts,
|
||||
}
|
||||
|
||||
var costsRecordCmd = &cobra.Command{
|
||||
Use: "record",
|
||||
Short: "Record session cost as an ephemeral wisp (called by Stop hook)",
|
||||
Long: `Record the final cost of a session as an ephemeral wisp.
|
||||
Short: "Record session cost to local log file (called by Stop hook)",
|
||||
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.
|
||||
It captures the final cost from the tmux session and creates an ephemeral
|
||||
event that is NOT exported to JSONL (avoiding log-in-database pollution).
|
||||
It captures the final cost from the tmux session and appends it to
|
||||
~/.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.
|
||||
|
||||
Examples:
|
||||
@@ -95,12 +96,12 @@ Examples:
|
||||
|
||||
var costsDigestCmd = &cobra.Command{
|
||||
Use: "digest",
|
||||
Short: "Aggregate session cost wisps into a daily digest bead",
|
||||
Long: `Aggregate ephemeral session cost wisps into a permanent daily digest.
|
||||
Short: "Aggregate session cost log entries into a daily digest bead",
|
||||
Long: `Aggregate session cost log entries into a permanent daily digest.
|
||||
|
||||
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
|
||||
"Cost Report YYYY-MM-DD" bead, then deletes the source wisps.
|
||||
It reads entries from ~/.gt/costs.jsonl for a target date, creates a single
|
||||
aggregate "Cost Report YYYY-MM-DD" bead, then removes the source entries.
|
||||
|
||||
The resulting digest bead is permanent (exported to JSONL, synced via git)
|
||||
and provides an audit trail without log-in-database pollution.
|
||||
@@ -114,18 +115,18 @@ Examples:
|
||||
|
||||
var costsMigrateCmd = &cobra.Command{
|
||||
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.
|
||||
|
||||
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:
|
||||
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
|
||||
with the new wisp-based cost tracking.
|
||||
with the new log-file-based cost tracking.
|
||||
|
||||
Examples:
|
||||
gt costs migrate # Migrate legacy beads
|
||||
@@ -738,8 +739,29 @@ func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCostsRecord captures the final cost from a session and records it as a bead event.
|
||||
// This is called by the Claude Code Stop hook.
|
||||
// CostLogEntry represents a single entry in the costs.jsonl log file.
|
||||
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 {
|
||||
// Get session from flag or try to detect from environment
|
||||
session := recordSession
|
||||
@@ -773,92 +795,47 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
||||
// Parse session name
|
||||
role, rig, worker := parseSessionName(session)
|
||||
|
||||
// Build agent path for actor field
|
||||
agentPath := buildAgentPath(role, rig, worker)
|
||||
|
||||
// Build event title
|
||||
title := fmt.Sprintf("Session ended: %s", session)
|
||||
if recordWorkItem != "" {
|
||||
title = fmt.Sprintf("Session: %s completed %s", session, recordWorkItem)
|
||||
// Build log entry
|
||||
entry := CostLogEntry{
|
||||
SessionID: session,
|
||||
Role: role,
|
||||
Rig: rig,
|
||||
Worker: worker,
|
||||
CostUSD: cost,
|
||||
EndedAt: time.Now(),
|
||||
WorkItem: recordWorkItem,
|
||||
}
|
||||
|
||||
// Build payload JSON
|
||||
payload := map[string]interface{}{
|
||||
"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)
|
||||
// Marshal to JSON
|
||||
entryJSON, err := json.Marshal(entry)
|
||||
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
|
||||
// Using --ephemeral creates a wisp that:
|
||||
// - Is stored locally only (not exported to JSONL)
|
||||
// - Won't pollute git history with O(sessions/day) events
|
||||
// - Will be aggregated into daily digests by 'gt costs digest'
|
||||
bdArgs := []string{
|
||||
"create",
|
||||
"--ephemeral",
|
||||
"--type=event",
|
||||
"--title=" + title,
|
||||
"--event-category=session.ended",
|
||||
"--event-actor=" + agentPath,
|
||||
"--event-payload=" + string(payloadJSON),
|
||||
"--silent",
|
||||
// Append to log file
|
||||
logPath := getCostsLogPath()
|
||||
|
||||
// Ensure directory exists
|
||||
logDir := filepath.Dir(logPath)
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating log directory: %w", err)
|
||||
}
|
||||
|
||||
// Add work item as event target if specified
|
||||
if recordWorkItem != "" {
|
||||
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()
|
||||
// Open file for append (create if doesn't exist)
|
||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding town root: %w", err)
|
||||
}
|
||||
if townRoot == "" {
|
||||
return fmt.Errorf("not in a Gas Town workspace")
|
||||
return fmt.Errorf("opening costs log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Execute bd create from town root
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
bdCmd.Dir = townRoot
|
||||
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)
|
||||
// Write entry with newline
|
||||
if _, err := f.Write(append(entryJSON, '\n')); err != nil {
|
||||
return fmt.Errorf("writing to costs log: %w", err)
|
||||
}
|
||||
|
||||
// Output confirmation (silent if cost is zero and no work item)
|
||||
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 != "" {
|
||||
fmt.Printf(" (work: %s)", recordWorkItem)
|
||||
}
|
||||
@@ -928,44 +905,6 @@ func detectCurrentTmuxSession() string {
|
||||
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.
|
||||
type CostDigest struct {
|
||||
Date string `json:"date"`
|
||||
@@ -976,21 +915,7 @@ type CostDigest struct {
|
||||
ByRig map[string]float64 `json:"by_rig,omitempty"`
|
||||
}
|
||||
|
||||
// WispListOutput represents the JSON output from bd mol wisp list.
|
||||
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.
|
||||
// runCostsDigest aggregates session cost entries into a daily digest bead.
|
||||
func runCostsDigest(cmd *cobra.Command, args []string) error {
|
||||
// Determine target date
|
||||
var targetDate time.Time
|
||||
@@ -1016,7 +941,7 @@ func runCostsDigest(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1060,105 +985,70 @@ func runCostsDigest(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("creating digest bead: %w", err)
|
||||
}
|
||||
|
||||
// Delete source wisps (they're ephemeral, use bd mol burn)
|
||||
deletedCount, deleteErr := deleteSessionCostWisps(targetDate)
|
||||
// Delete source entries from log file
|
||||
deletedCount, deleteErr := deleteSessionCostEntries(targetDate)
|
||||
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(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
|
||||
if deletedCount > 0 {
|
||||
fmt.Printf(" Deleted %d source wisps\n", deletedCount)
|
||||
fmt.Printf(" Removed %d entries from costs log\n", deletedCount)
|
||||
}
|
||||
|
||||
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) {
|
||||
// List all wisps including closed ones
|
||||
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
||||
listOutput, err := listCmd.Output()
|
||||
logPath := getCostsLogPath()
|
||||
|
||||
// Read log file
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
// No wisps database or command failed
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No log file yet
|
||||
}
|
||||
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")
|
||||
var entries []CostEntry
|
||||
|
||||
for _, event := range events {
|
||||
// Filter for session.ended events only
|
||||
if event.EventKind != "session.ended" {
|
||||
// Parse each line as a CostLogEntry
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
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 event %s: %v\n", event.ID, err)
|
||||
}
|
||||
continue
|
||||
var logEntry CostLogEntry
|
||||
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] failed to parse log entry: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
sessionCostWisps = append(sessionCostWisps, CostEntry{
|
||||
SessionID: payload.SessionID,
|
||||
Role: payload.Role,
|
||||
Rig: payload.Rig,
|
||||
Worker: payload.Worker,
|
||||
CostUSD: payload.CostUSD,
|
||||
EndedAt: endedAt,
|
||||
WorkItem: event.Target,
|
||||
// Filter by target date
|
||||
if logEntry.EndedAt.Format("2006-01-02") != targetDay {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, CostEntry{
|
||||
SessionID: logEntry.SessionID,
|
||||
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.
|
||||
@@ -1228,96 +1118,63 @@ func createCostDigestBead(digest CostDigest) (string, error) {
|
||||
return digestID, nil
|
||||
}
|
||||
|
||||
// deleteSessionCostWisps deletes ephemeral session.ended wisps for a target date.
|
||||
func deleteSessionCostWisps(targetDate time.Time) (int, error) {
|
||||
// List all wisps
|
||||
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
|
||||
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
|
||||
}
|
||||
// deleteSessionCostEntries removes entries for a target date from the costs log file.
|
||||
// It rewrites the file without the entries for that date.
|
||||
func deleteSessionCostEntries(targetDate time.Time) (int, error) {
|
||||
logPath := getCostsLogPath()
|
||||
|
||||
var wispList WispListOutput
|
||||
if err := json.Unmarshal(listOutput, &wispList); err != nil {
|
||||
return 0, fmt.Errorf("parsing wisp list: %w", err)
|
||||
// Read log file
|
||||
data, err := os.ReadFile(logPath)
|
||||
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")
|
||||
var keepLines []string
|
||||
deletedCount := 0
|
||||
|
||||
// Collect all wisp IDs that match our criteria
|
||||
var wispIDsToDelete []string
|
||||
|
||||
for _, wisp := range wispList.Wisps {
|
||||
// Get full wisp details to check if it's a session.ended event
|
||||
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)
|
||||
}
|
||||
// Filter out entries for target date
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var events []SessionEvent
|
||||
if err := json.Unmarshal(showOutput, &events); err != nil {
|
||||
if costsVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[costs] JSON unmarshal failed for wisp %s: %v\n", wisp.ID, err)
|
||||
}
|
||||
var logEntry CostLogEntry
|
||||
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||
// Keep unparseable lines (shouldn't happen but be safe)
|
||||
keepLines = append(keepLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
// Remove entries from target date
|
||||
if logEntry.EndedAt.Format("2006-01-02") == targetDay {
|
||||
deletedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
event := events[0]
|
||||
|
||||
// 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)
|
||||
keepLines = append(keepLines, line)
|
||||
}
|
||||
|
||||
if len(wispIDsToDelete) == 0 {
|
||||
if deletedCount == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Batch delete all wisps in a single subprocess call
|
||||
burnArgs := append([]string{"mol", "burn", "--force"}, wispIDsToDelete...)
|
||||
burnCmd := exec.Command("bd", burnArgs...)
|
||||
if burnErr := burnCmd.Run(); burnErr != nil {
|
||||
return 0, fmt.Errorf("batch burn failed: %w", burnErr)
|
||||
// Rewrite file without deleted entries
|
||||
newContent := strings.Join(keepLines, "\n")
|
||||
if len(keepLines) > 0 {
|
||||
newContent += "\n"
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -1399,7 +1256,7 @@ func runCostsMigrate(cmd *cobra.Command, args []string) error {
|
||||
// Close all open session.ended events
|
||||
closedMigrated := 0
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
|
||||
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.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user