f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
257 lines
7.7 KiB
Go
257 lines
7.7 KiB
Go
// ABOUTME: CLI subcommand that returns token usage data for a
|
|
// ABOUTME: session, syncing on-demand if no server is running.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
// Exit codes for the token-use subcommand.
|
|
const (
|
|
tokenUseExitOK = 0
|
|
tokenUseExitErr = 1
|
|
tokenUseExitNotFound = 2
|
|
tokenUseExitNoTokenData = 3
|
|
tokenUseResolveMatchLimit = 2
|
|
)
|
|
|
|
// resolveRawSessionID translates a user-supplied session ID into
|
|
// the canonical form stored in sessions.id. Callers may pass
|
|
// either a canonical ID ("codex:<uuid>") or a bare raw ID as
|
|
// emitted by the underlying agent — including raw IDs that
|
|
// themselves contain colons (Kimi: "<project-hash>:<session-uuid>",
|
|
// OpenClaw: "<agentId>:<sessionId>", legacy Kiro IDE).
|
|
//
|
|
// Resolution order (short-circuit only on host-prefixed IDs, which
|
|
// are unambiguously remote; any other input — even one that begins
|
|
// with a registered prefix — flows through DB and disk probes
|
|
// because the first colon-delimited component can legitimately be
|
|
// part of a raw ID):
|
|
//
|
|
// 1. Host-prefixed input -> returned unchanged.
|
|
// 2. DB lookup: exact row (if any) sorts ahead of suffix matches
|
|
// in SQL; suffix matches come back in most-recent order. If
|
|
// multiple suffix matches exist without an exact row, the
|
|
// most recent wins and an ambiguity warning is emitted.
|
|
// 3. Canonical provider probe: when input begins with a registered
|
|
// agent prefix, strip the prefix and ask that agent's source lookup
|
|
// so a truly canonical-but-unsynced ID still resolves.
|
|
// 4. Raw provider probe: ask every file-backed agent plus Devin for a
|
|
// raw-ID source lookup; the first hit yields "<prefix><input>".
|
|
// 5. No match anywhere: returned unchanged with known=false.
|
|
//
|
|
// known reports whether resolution found evidence for the ID.
|
|
// When false, the caller should skip on-demand sync because it
|
|
// cannot produce meaningful output.
|
|
func resolveRawSessionID(
|
|
ctx context.Context,
|
|
database *db.DB,
|
|
agentDirs map[parser.AgentType][]string,
|
|
input string,
|
|
) (resolved string, known bool) {
|
|
if host, _ := parser.StripHostPrefix(input); host != "" {
|
|
return input, true
|
|
}
|
|
|
|
matches, err := database.FindSessionIDsByRawSuffix(
|
|
ctx, input, tokenUseResolveMatchLimit,
|
|
)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr,
|
|
"warning: session id lookup failed: %v\n", err)
|
|
}
|
|
if len(matches) > 0 {
|
|
if matches[0] == input {
|
|
return input, true
|
|
}
|
|
if len(matches) > 1 {
|
|
fmt.Fprintf(os.Stderr,
|
|
"warning: ambiguous session id %q matches "+
|
|
"multiple sessions, using most recent (%s)\n",
|
|
input, matches[0],
|
|
)
|
|
}
|
|
return matches[0], true
|
|
}
|
|
|
|
// Canonical disk probe: if the input starts with a known
|
|
// agent prefix, trust that interpretation first and strip
|
|
// before resolving the source (which rejects IDs with
|
|
// colons via IsValidSessionID).
|
|
for _, def := range parser.Registry {
|
|
if def.IDPrefix == "" ||
|
|
!agentHasDiskSourceLookup(def) {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(input, def.IDPrefix) {
|
|
continue
|
|
}
|
|
bareID := strings.TrimPrefix(input, def.IDPrefix)
|
|
for _, dir := range agentDirs[def.Type] {
|
|
if findAgentSourceFile(def, dir, bareID) != "" {
|
|
return input, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Raw disk probe: treat input as a raw agent ID. Agents
|
|
// whose raw IDs cannot contain ':' (most of them) reject
|
|
// the input via IsValidSessionID; agents that accept
|
|
// colon-bearing raw IDs (Kimi, OpenClaw, Kiro IDE) may
|
|
// match.
|
|
for _, def := range parser.Registry {
|
|
if !agentHasDiskSourceLookup(def) {
|
|
continue
|
|
}
|
|
for _, dir := range agentDirs[def.Type] {
|
|
if findAgentSourceFile(def, dir, input) != "" {
|
|
return def.IDPrefix + input, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return input, false
|
|
}
|
|
|
|
// agentHasDiskSourceLookup reports whether a session source can be located by
|
|
// raw ID via the provider facade's FindSource path. This covers file-backed
|
|
// agents plus Devin's provider-owned virtual session paths.
|
|
func agentHasDiskSourceLookup(def parser.AgentDef) bool {
|
|
if !def.FileBased && def.Type != parser.AgentDevin {
|
|
return false
|
|
}
|
|
if parser.ProviderMigrationModes()[def.Type] !=
|
|
parser.ProviderMigrationProviderAuthoritative {
|
|
return false
|
|
}
|
|
_, ok := parser.ProviderFactoryByType(def.Type)
|
|
return ok
|
|
}
|
|
|
|
// findAgentSourceFile resolves a raw agent session ID to an on-disk source path
|
|
// under dir via the provider's FindSource (RawSessionID lookup). Returns ""
|
|
// when no source resolves or the agent has no on-disk lookup.
|
|
func findAgentSourceFile(def parser.AgentDef, dir, rawID string) string {
|
|
factory, ok := parser.ProviderFactoryByType(def.Type)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
provider := factory.NewProvider(parser.ProviderConfig{Roots: []string{dir}})
|
|
source, found, err := provider.FindSource(
|
|
context.Background(),
|
|
parser.FindSourceRequest{RawSessionID: rawID},
|
|
)
|
|
if err != nil || !found {
|
|
return ""
|
|
}
|
|
if path, ok := providerSourcePath(source); ok {
|
|
return path
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// providerSourcePath extracts the on-disk path a provider SourceRef points to,
|
|
// preferring the display path and falling back to the fingerprint key or key.
|
|
func providerSourcePath(source parser.SourceRef) (string, bool) {
|
|
for _, candidate := range []string{
|
|
source.DisplayPath,
|
|
source.FingerprintKey,
|
|
source.Key,
|
|
} {
|
|
if candidate != "" {
|
|
return candidate, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// usageExitCode classifies a SessionUsage into an exit code: 2 when
|
|
// the session is not in the DB, 0 when token data OR cost is present,
|
|
// 3 when the session exists but has neither. Cost-only sessions
|
|
// (e.g. Hermes) return 0 so callers do not discard useful cost.
|
|
func usageExitCode(u *db.SessionUsage) int {
|
|
if u == nil {
|
|
return tokenUseExitNotFound
|
|
}
|
|
if u.HasTokenData || u.HasCost {
|
|
return tokenUseExitOK
|
|
}
|
|
return tokenUseExitNoTokenData
|
|
}
|
|
|
|
// sessionUsageOutput is the JSON shape emitted by `session usage`
|
|
// and the deprecated `token-use`. It is a strict superset of the
|
|
// historical token-use output (same fields, plus cost). The shape
|
|
// is experimental and may change.
|
|
type sessionUsageOutput struct {
|
|
db.SessionUsage
|
|
ServerRunning bool `json:"server_running"`
|
|
}
|
|
|
|
// startupWaitTimeout is how long CLI subcommands wait for a
|
|
// starting server to become ready before falling back to
|
|
// on-demand sync or direct DB access.
|
|
const startupWaitTimeout = 30 * time.Second
|
|
|
|
func runTokenUse(args []string) {
|
|
if len(args) != 1 {
|
|
fmt.Fprintln(os.Stderr,
|
|
"usage: agentsview token-use <session-id>")
|
|
os.Exit(tokenUseExitErr)
|
|
}
|
|
fmt.Fprintln(os.Stderr,
|
|
"note: 'token-use' is deprecated; use 'session usage <id>' instead")
|
|
|
|
out, code, err := sessionUsageData(args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(tokenUseExitErr)
|
|
}
|
|
if out != nil {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
if encErr := enc.Encode(out); encErr != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", encErr)
|
|
os.Exit(tokenUseExitErr)
|
|
}
|
|
}
|
|
os.Exit(code)
|
|
}
|
|
|
|
func sessionUsageData(sessionID string) (*sessionUsageOutput, int, error) {
|
|
appCfg, err := config.LoadMinimal()
|
|
if err != nil {
|
|
return nil, tokenUseExitErr, fmt.Errorf("loading config: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(appCfg.DataDir, 0o755); err != nil {
|
|
return nil, tokenUseExitErr,
|
|
fmt.Errorf("creating data dir: %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
backend, cleanup, err := resolveArchiveQueryBackendWithConfig(
|
|
ctx,
|
|
appCfg,
|
|
archiveQueryPolicy{
|
|
AutoStart: true,
|
|
ReadOnlyDaemon: archiveQueryRejectReadOnlyDaemon,
|
|
DirectReadOnlyAction: "refresh session usage directly",
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, tokenUseExitErr, err
|
|
}
|
|
defer closeArchiveQueryBackend(cleanup)
|
|
return backend.SessionUsage(ctx, sessionID)
|
|
}
|