Skip to content

Commit c4a723f

Browse files
committed
ingest: fix tailer schema/idempotency/timestamp bugs; add Cowork coverage + reimport recovery
The passive tailer (Tier 1a) has been silently non-functional since it was written: every usage_events row in the live DB had source='hook', zero from source='tailer', even though the tailer was running and reading every tracked file to EOF. Three separate bugs, found while investigating why desktop-app Cowork sessions weren't showing up: 1. internal/ingest/parser.go read usage/session_id/message_id/model off the top level of each JSON line. Real Claude Code transcripts nest usage/id/ model under a "message" object with camelCase "sessionId" as a sibling — the same shape cmd/clusage-cli/hook.go already parses correctly, with a comment noting the original parser was written against a mock schema that never matched a real transcript. parser_test.go and tailer_test.go were fixtured against the same wrong flat schema, which is exactly what let this hide: the parser and its tests agreed with each other, just not with reality. 2. The tailer's INSERT lacked the idempotent-duplicate handling server.go's handleLog already has for hook re-posts. Once (1) was fixed, every message the hook got to first collided on the UNIQUE(session_id, message_id) constraint and was logged as an ERROR plus a permanent parse_errors row, on every restart's catch-up poll. Moved the constraint-violation check into store.IsUniqueConstraintViolation (shared by both ingest paths) and made the tailer treat a collision as idempotent re-delivery: debug-log, skip, still advance the offset. 3. The tailer's INSERT passed occurred_at as a raw time.Time instead of store.FormatTime(...), the one documented invariant every other insert site in the codebase already follows. modernc.org/sqlite serializes an unconverted time.Time via Go's default String() ("2026-07-09 23:02:59.894 +0000 UTC", sometimes with a monotonic-clock "m=+..." suffix from a bare time.Now()), which SQLite's strftime() silently fails to parse (NULL, not an error). The dashboard's session/weekly burn-down charts bucket events with strftime('%s', occurred_at), so every tailer-sourced row broke that query for its bucket — the "converting NULL to int64" errors and the "No active window" dashboard symptom. Fixed the insert; TestTailerStores QueryableTimestamp pins the failure mode directly against strftime. Cowork ("local agent mode") session coverage: Each Cowork session runs against its own private, sandboxed .claude home nested several directories under %APPDATA%\Claude\local-agent-mode-sessions, completely separate from the user's real ~/.claude/projects that the primary tailer watches — same JSONL schema, just invisible to it. Added claude.cowork_sessions_dir (default resolved via APPDATA) and a second Tailer instance rooted there; its own poll's recursive filepath.Walk discovers new sessions automatically as they're created, no hook needed. cmd/trayapp/main.go's tailerGroup aggregates CaughtUp()/Stop() across both tailers so /healthz and graceful shutdown treat them uniformly (shutdown previously only knew about the first tailer, a latent bug in the same area this change touches). Bulk reimport recovery tool: A tailer's byte offset records how far a file was *read*, not whether the lines in that range actually produced a usage_event — bug (1) above left every pre-existing session's offset sitting at EOF for content that was never successfully recorded, with no per-line marker to say which lines those were. There's no way to selectively repair "just the missing ones" without already knowing which files/sessions are affected. Added: - store.DeleteAllTailerOffsets: wipes every persisted offset. - ingest.Tailer.Reimport: wipes offsets (in-memory + DB) and re-walks the tailer's root from byte 0. - POST /admin/reimport: runs Reimport in the background (202 immediately, 409 if one's already in flight), for every tailer via tailerGroup. - clusage-cli reimport [--wait] [--wait-timeout 5m]: triggers it, with --wait polling /healthz's tailer_caught_up. Deliberately slow and wasteful — re-reads every tracked transcript byte-for-byte and leans entirely on UNIQUE(session_id, message_id) dedup to discard anything already recorded. Recovery-only, not routine maintenance. Verified live: recovered 2,426 tailer-sourced events with timestamps predating the fix, with zero duplication. Tests added at every layer (parser, tailer, store, server) including TestTailerReimportRecoversPastOffset, which reproduces the exact bug class Reimport exists to fix: an offset already at EOF with one message genuinely missing, verifying recovery without duplicating what's already there. Docs: docs/data-sources.md (Cowork second root, recovery section), docs/container-cli.md (reimport subcommand), docs/configuration.md (cowork_sessions_dir).
1 parent b2107f2 commit c4a723f

15 files changed

Lines changed: 783 additions & 68 deletions

File tree

cmd/clusage-cli/main.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ func main() {
3737
cmdConsumption()
3838
case "release":
3939
cmdRelease()
40+
case "reimport":
41+
cmdReimport()
4042
case "--version":
4143
fmt.Printf("clusage-cli v%s\n", Version)
4244
case "--help":
@@ -56,6 +58,7 @@ Usage:
5658
clusage-cli slack [--format json|release-bool|fraction]
5759
clusage-cli consumption [--period 24h] [--format json|summary]
5860
clusage-cli release --released-at TS --job-tag TAG --estimated-cost N --slack-at-release N [--window-kind session|weekly]
61+
clusage-cli reimport [--wait] [--wait-timeout 5m]
5962
6063
`, Version)
6164
}
@@ -333,6 +336,76 @@ func cmdRelease() {
333336
}
334337
}
335338

339+
// cmdReimport triggers a full recovery re-walk of every transcript file the
340+
// trayapp's tailer(s) track, bypassing persisted byte offsets entirely.
341+
//
342+
// This is a rare-recovery tool, not routine maintenance: it exists for
343+
// cases like suspected data loss, a parser bug that silently under-counted
344+
// past events, or a restored/corrupted database, where it's unknown which
345+
// specific sessions are affected. It deliberately re-reads every tracked
346+
// transcript byte-for-byte and leans entirely on the server's
347+
// UNIQUE(session_id, message_id) dedup to skip anything already correctly
348+
// recorded — slow and wasteful by design, in exchange for not requiring
349+
// anyone to name the affected files in advance.
350+
func cmdReimport() {
351+
fs := flag.NewFlagSet("reimport", flag.ExitOnError)
352+
wait := fs.Bool("wait", false, "block until the tailer reports caught up (or --wait-timeout elapses)")
353+
waitTimeout := fs.Duration("wait-timeout", 10*time.Minute, "max time to wait with --wait")
354+
fs.Parse(os.Args[2:])
355+
356+
client := &http.Client{Timeout: parseTimeout()}
357+
resp, err := client.Post(fmt.Sprintf("%s/admin/reimport", hostURL()), "application/json", bytes.NewReader(nil))
358+
if err != nil {
359+
fmt.Fprintf(os.Stderr, "error: connection refused\n")
360+
os.Exit(3)
361+
}
362+
body, _ := io.ReadAll(resp.Body)
363+
resp.Body.Close()
364+
365+
switch resp.StatusCode {
366+
case http.StatusAccepted:
367+
fmt.Println(string(body))
368+
case http.StatusConflict:
369+
fmt.Fprintf(os.Stderr, "error: %s\n", string(body))
370+
os.Exit(4)
371+
case http.StatusServiceUnavailable:
372+
fmt.Fprintf(os.Stderr, "error: %s\n", string(body))
373+
os.Exit(5)
374+
default:
375+
fmt.Fprintf(os.Stderr, "error: %d %s\n", resp.StatusCode, string(body))
376+
os.Exit(5)
377+
}
378+
379+
if !*wait {
380+
os.Exit(0)
381+
}
382+
383+
fmt.Println("waiting for tailer to catch up...")
384+
deadline := time.Now().Add(*waitTimeout)
385+
pollClient := &http.Client{Timeout: 5 * time.Second}
386+
for time.Now().Before(deadline) {
387+
time.Sleep(2 * time.Second)
388+
hresp, err := pollClient.Get(fmt.Sprintf("%s/healthz", hostURL()))
389+
if err != nil {
390+
continue // transient; keep polling until the deadline
391+
}
392+
var health struct {
393+
TailerCaughtUp bool `json:"tailer_caught_up"`
394+
}
395+
decodeErr := json.NewDecoder(hresp.Body).Decode(&health)
396+
hresp.Body.Close()
397+
if decodeErr != nil {
398+
continue
399+
}
400+
if health.TailerCaughtUp {
401+
fmt.Println("caught up")
402+
os.Exit(0)
403+
}
404+
}
405+
fmt.Fprintf(os.Stderr, "error: still not caught up after %s\n", *waitTimeout)
406+
os.Exit(5)
407+
}
408+
336409
func parseTimeout() time.Duration {
337410
var ms int64 = 2000
338411
if timeoutMs != "" {

cmd/trayapp/main.go

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,40 @@ type pauseToggle struct{ c *slack.Calculator }
3131

3232
func (p pauseToggle) Toggle() { p.c.SetPaused(!p.c.IsPaused()) }
3333

34+
// tailerGroup collects every running Tailer (the primary ~/.claude/projects
35+
// root plus, when resolved, the Cowork sessions root) so both /healthz
36+
// reporting and shutdown treat them uniformly instead of the shutdown path
37+
// only knowing about the first one.
38+
type tailerGroup []*ingest.Tailer
39+
40+
// CaughtUp reports /healthz status: caught up only when every tailer is.
41+
func (g tailerGroup) CaughtUp() bool {
42+
for _, t := range g {
43+
if !t.CaughtUp() {
44+
return false
45+
}
46+
}
47+
return true
48+
}
49+
50+
// Stop stops every tailer in the group, waiting for each in turn.
51+
func (g tailerGroup) Stop() {
52+
for _, t := range g {
53+
t.Stop()
54+
}
55+
}
56+
57+
// Reimport triggers a full recovery re-walk on every tailer in the group.
58+
// Runs synchronously (each Tailer.Reimport call blocks until its own
59+
// re-walk finishes) — the HTTP handler wrapping this is expected to call
60+
// it in its own goroutine so a slow, deliberately-inefficient recovery
61+
// pass doesn't block the request.
62+
func (g tailerGroup) Reimport() {
63+
for _, t := range g {
64+
t.Reimport()
65+
}
66+
}
67+
3468
const Version = "0.0.1"
3569

3670
const (
@@ -108,12 +142,25 @@ func main() {
108142

109143
tailer := ingest.NewTailer(cfg.Claude.ProjectsDir, db, priceTable)
110144
tailer.Start()
111-
srv.SetTailer(tailer)
145+
tailers := tailerGroup{tailer}
146+
147+
// Cowork ("local agent mode") sessions each get their own private
148+
// .claude home nested under CoworkSessionsDir rather than the user's
149+
// real ~/.claude, so the primary tailer above never sees them. A second
150+
// tailer rooted one level up recursively finds every session's nested
151+
// projects/ dir as it's created — same JSONL schema, no hook needed.
152+
if cfg.Claude.CoworkSessionsDir != "" {
153+
coworkTailer := ingest.NewTailer(cfg.Claude.CoworkSessionsDir, db, priceTable)
154+
coworkTailer.Start()
155+
tailers = append(tailers, coworkTailer)
156+
}
157+
srv.SetTailer(tailers)
158+
srv.SetReimporter(tailers)
112159

113160
// stop signals every background loop (retention pruner, windows ticker)
114-
// to exit; wg lets shutdown wait for them. The tailer has its own
115-
// stopChan + doneChan and is stopped via tailer.Stop() so we don't
116-
// double-track it on the WaitGroup.
161+
// to exit; wg lets shutdown wait for them. Each tailer has its own
162+
// stopChan + doneChan and is stopped via tailers.Stop() so we don't
163+
// double-track them on the WaitGroup.
117164
stop := make(chan struct{})
118165
var wg sync.WaitGroup
119166

@@ -205,15 +252,15 @@ waitLoop:
205252
cancelHTTP()
206253

207254
// Phase 2: stop background goroutines (retention pruner, windows
208-
// ticker, tailer, tray UI). tailer.Stop is invoked inside the
255+
// ticker, tailer, tray UI). tailers.Stop is invoked inside the
209256
// goroutine so a stuck tailer is also bounded by the 10s timeout
210257
// — otherwise a hung tailer blocks process exit indefinitely.
211258
close(stop)
212259
cancelTray()
213260

214261
bgDone := make(chan struct{})
215262
go func() {
216-
tailer.Stop()
263+
tailers.Stop()
217264
wg.Wait()
218265
close(bgDone)
219266
}()

docs/configuration.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ http:
2121

2222
claude:
2323
projects_dir: "~/.claude/projects" # %USERPROFILE%\.claude\projects on Windows
24+
# Root of the desktop app's Cowork ("local agent mode") session tree.
25+
# Each Cowork session nests its own private .claude home several levels
26+
# under this root, so a second tailer walks it recursively. Default
27+
# resolves to %APPDATA%\Claude\local-agent-mode-sessions on Windows;
28+
# empty (e.g. APPDATA unset) disables this second tailer.
29+
cowork_sessions_dir: "%APPDATA%\\Claude\\local-agent-mode-sessions"
2430

2531
# Price table used to compute cost_usd_equivalent when the source did not
2632
# report it. See docs/data-model.md "Cost source" and "Price table
@@ -112,10 +118,11 @@ suppress it. See `docs/no-active-session.md` for the wiring.
112118
## Path placeholders
113119

114120
`%APPDATA%`, `%LOCALAPPDATA%`, `%USERPROFILE%`, and `%HOME%` placeholders
115-
are expanded inside `database.path`, `claude.projects_dir`, and
116-
`pricing.table_path` at load time. When the underlying environment
117-
variable is unset (typical on Linux), the loader falls back to the user's
118-
home directory so cross-platform configs stay testable.
121+
are expanded inside `database.path`, `claude.projects_dir`,
122+
`claude.cowork_sessions_dir`, and `pricing.table_path` at load time. When
123+
the underlying environment variable is unset (typical on Linux), the
124+
loader falls back to the user's home directory so cross-platform configs
125+
stay testable.
119126

120127
`claude.projects_dir` additionally expands a leading `~/` to the user's
121128
home directory.

docs/container-cli.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,38 @@ fi
8585

8686
GET `/healthz`. Exits `0` if healthy.
8787

88+
### `clusage-cli reimport`
89+
90+
POST `/admin/reimport`, triggering a full recovery re-walk of every
91+
transcript file the trayapp's tailer(s) track, ignoring persisted byte
92+
offsets entirely.
93+
94+
**Recovery-only — not routine maintenance.** A byte offset records how far
95+
a file was *read*, not whether the lines in that range actually produced a
96+
`usage_event`. A parser bug (or any other silent extraction failure) can
97+
leave an offset sitting at EOF for content that was never successfully
98+
recorded, with no per-line marker to say which lines those were. There is
99+
no way to selectively repair "just the missing ones" without already
100+
knowing which files/sessions are affected — so this re-reads *everything*
101+
from byte 0 and relies entirely on the `UNIQUE(session_id, message_id)`
102+
constraint to skip whatever was already correctly recorded (the same
103+
idempotency contract described in `docs/data-sources.md`). Deliberately
104+
slow and wasteful: acceptable as a one-time cost for suspected data loss
105+
(a parser bug, a restored/corrupted database) but not something to run on
106+
a schedule.
107+
108+
```
109+
clusage-cli reimport [--wait] [--wait-timeout 5m]
110+
```
111+
112+
Without `--wait`, the command returns as soon as the trayapp accepts the
113+
request (202) — the re-walk itself runs in the background. With `--wait`,
114+
it polls `GET /healthz`'s `tailer_caught_up` field every 2 seconds until
115+
the re-walk finishes or `--wait-timeout` (default 10m) elapses.
116+
117+
A second `reimport` call while one is already running gets `409` (exit
118+
code `4`) rather than piling on a redundant concurrent re-walk.
119+
88120
## Configuration
89121

90122
Environment variables:

docs/data-sources.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,49 @@ workflow is whichever requires less change to existing images.
7474
- **Container path coverage.** See above; the unshared-container case is common enough
7575
that it shapes the install flow, not just an edge case.
7676

77+
### Second root: Cowork ("local agent mode") sessions
78+
79+
The desktop app's Cowork feature runs Claude Code sessions in a sandboxed
80+
VM/background mode. Each Cowork session gets its own **private, nested
81+
`.claude` home** several directories under
82+
`%APPDATA%\Claude\local-agent-mode-sessions\<workspace>\<id>\local_<uuid>\`
83+
— its own `.claude/projects/<encoded-cwd>/<session>.jsonl`, distinct from
84+
the user's real `~/.claude/projects`. The schema is identical (same
85+
`usage` block), but the primary tailer above never reaches these files
86+
because they aren't under its root at all.
87+
88+
A second `ingest.Tailer` instance is started against
89+
`claude.cowork_sessions_dir` (default
90+
`%APPDATA%\Claude\local-agent-mode-sessions`) alongside the primary one.
91+
Because the tailer's poll pass does a full recursive `filepath.Walk`, this
92+
second root picks up newly-created Cowork sessions automatically — no
93+
per-session registration needed. Both tailers share the same DB and
94+
dedup key, so there's no risk of double-counting. This is still Tier 1a:
95+
no hook, no perturbation, just a second recursion root.
96+
97+
Regular desktop-app Claude Code (the embedded pane, as opposed to Cowork)
98+
writes straight into the normal `~/.claude/projects`, indistinguishable
99+
from a terminal session — it needs no special handling.
100+
101+
### Recovering from a silent extraction failure
102+
103+
The tailer's per-file byte offset only records how far a file was *read*,
104+
not whether the lines in that range actually produced a `usage_event`. A
105+
parser bug — silently returning "no usage block" for a schema it
106+
misreads — can leave an offset sitting at EOF for content that was never
107+
successfully recorded, and a normal poll has nothing new to read past that
108+
offset, so it can't self-heal. There's also no per-line "was this
109+
ingested" marker, so a *selective* repair isn't possible without already
110+
knowing which files/sessions are affected.
111+
112+
`clusage-cli reimport` (see `docs/container-cli.md`) is the recovery tool
113+
for this: it wipes every persisted tailer offset and re-walks every root
114+
from byte 0, leaning entirely on the `UNIQUE(session_id, message_id)`
115+
dedup to discard whatever's already correctly recorded. Slow and wasteful
116+
by design — it re-reads everything — but that's the acceptable cost of
117+
guaranteed recovery without naming files in advance. Not routine
118+
maintenance; use it once after fixing a bug like this, not on a schedule.
119+
77120
### Schema fragility mitigation
78121

79122
The parser should:

internal/config/config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ type Config struct {
2323

2424
Claude struct {
2525
ProjectsDir string `yaml:"projects_dir"`
26+
// CoworkSessionsDir is the root of the desktop app's Cowork
27+
// ("local agent mode") session tree. Each Cowork session nests its
28+
// own private .claude home several levels under this root
29+
// (<workspace>/<id>/local_<uuid>/.claude/projects/<encoded-cwd>/
30+
// <session>.jsonl), so the tailer walks it recursively rather than
31+
// treating it as a projects dir directly. Empty disables it (e.g.
32+
// non-Windows, where APPDATA isn't set).
33+
CoworkSessionsDir string `yaml:"cowork_sessions_dir"`
2634
} `yaml:"claude"`
2735

2836
Pricing struct {
@@ -71,6 +79,7 @@ func Load(path string) (*Config, error) {
7179
cfg.HTTP.Port = 27812
7280
cfg.HTTP.Bind = []string{"127.0.0.1"}
7381
cfg.Claude.ProjectsDir = expandHome("~/.claude/projects")
82+
cfg.Claude.CoworkSessionsDir = defaultCoworkSessionsDir()
7483
// Empty means "use the resolution chain" (executable dir / app config
7584
// dir override, else the embedded built-in table). A non-empty value is
7685
// an explicit override. See ingest.ResolvePriceTable.
@@ -107,6 +116,7 @@ func Load(path string) (*Config, error) {
107116
// Resolve env-style placeholders in path/dir fields.
108117
cfg.Database.Path = expandPlaceholders(cfg.Database.Path)
109118
cfg.Claude.ProjectsDir = expandPlaceholders(cfg.Claude.ProjectsDir)
119+
cfg.Claude.CoworkSessionsDir = expandPlaceholders(cfg.Claude.CoworkSessionsDir)
110120
cfg.Pricing.TablePath = expandPlaceholders(cfg.Pricing.TablePath)
111121

112122
return &cfg, nil
@@ -146,6 +156,21 @@ func expandPlaceholders(s string) string {
146156
return s
147157
}
148158

159+
// defaultCoworkSessionsDir returns the root of the Claude desktop app's
160+
// Cowork ("local agent mode") session tree on Windows. Each Cowork session
161+
// runs against its own private, sandboxed .claude home nested several
162+
// levels under this root rather than the user's real ~/.claude — the
163+
// tailer walks it recursively to reach those nested projects/ dirs (see
164+
// docs/data-sources.md, Tier 1a). Returns "" when APPDATA isn't set (e.g.
165+
// non-Windows), which disables this second root without erroring.
166+
func defaultCoworkSessionsDir() string {
167+
appData := os.Getenv("APPDATA")
168+
if appData == "" {
169+
return ""
170+
}
171+
return filepath.Join(appData, "Claude", "local-agent-mode-sessions")
172+
}
173+
149174
// expandHome expands a leading ~/ to the user's home directory.
150175
func expandHome(path string) string {
151176
if !strings.HasPrefix(path, "~/") {

0 commit comments

Comments
 (0)