forked from kenn-io/agentsview
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb.go
More file actions
1891 lines (1766 loc) · 53.2 KB
/
Copy pathdb.go
File metadata and controls
1891 lines (1766 loc) · 53.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package db
import (
"context"
"crypto/rand"
"database/sql"
_ "embed"
"errors"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
_ "github.com/mattn/go-sqlite3"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/parser"
)
// dataVersion tracks parser changes that require a full
// re-sync. Increment this when parsing logic changes in ways
// that affect stored data (e.g. new fields extracted, content
// formatting changes). Old databases with a lower user_version
// trigger a non-destructive re-sync (mtime reset + skip cache
// clear) so existing session data is preserved.
//
// Bumped to 47: the Visual Studio Copilot trace parser now
// persists per-chat model and token usage from gen_ai.usage
// attributes. Existing Visual Studio Copilot rows need re-parsing
// so Usage reports include those sessions.
//
// Bumped to 45: the Codex parser now imports renamed session
// titles from session_index.jsonl. Existing Codex rows need
// re-parsing so their titles reflect later renames.
//
// Bumped to 44: the VSCode Copilot parser now extracts per-turn
// token usage (promptTokens/outputTokens) and the resolved model from
// result.metadata into usage events, session output totals, and peak
// context, so existing VSCode Copilot rows need re-parsing to gain
// usage and cost.
//
// Bumped to 43: the Pi parser now persists cwd from the
// session header. Existing Pi rows need re-parsing so their cwd column
// is populated.
//
// Bumped to 42: the Claude parser now infers subagent parent
// relationships from Claude Code companion directories
// (<session>/subagents/agent-*.jsonl) and resolves externalized
// tool-results content from <session>/tool-results. Existing Claude
// rows need re-parsing so companion subagents are linked and
// persisted tool outputs replace preview placeholders.
//
// Bumped to 41: the Cursor parser now stores structured tool-call
// input JSON for text transcripts and normalizes ApplyPatch calls as
// edits. Existing Cursor rows need re-parsing so archived ApplyPatch
// calls render with the new patch-aware UI.
//
// (40: the Codex parser now suppresses the parent history
// that `codex fork` replays at the top of a forked rollout, which was
// double counted as the fork's own messages and token usage, and kept
// the fork's own session id instead of letting the replayed parent
// session_meta overwrite it. Existing forked rows persist the
// double-counted totals under the parent's identity, so they need
// re-parsing to be rewritten with post-fork activity only. Resync's
// orphan copy also skips stale Codex rows whose file_path was
// reparsed under a different session id, so the old parent-ID row
// does not survive the rebuild when the parent's own file is gone
// (see CopyOrphanedDataFromExcluding.)
//
// (39: the Antigravity wire-walk hardened its output
// invariants (issue #648): model-name candidates must be printable,
// collected strings replace NUL bytes with U+FFFD, nanos values
// outside the protobuf Timestamp range no longer match
// timestamp-shaped fields, token blocks whose output+reasoning sum
// breaches the plausibility cap are rejected, and parses truncate
// at a total-fields allocation budget. Existing Antigravity rows
// may hold content/model/usage values the parser no longer
// produces and need re-parsing.)
//
// (38: two Antigravity parsing changes. (a) The Antigravity
// CLI parser extracts generatorMetadata token usage from agy-reader
// trajectory sidecars: usage events for legacy .pb sessions (and .db
// sessions without gen_metadata) and per-message model/token
// attribution on sidecar transcripts, so existing Antigravity CLI rows
// need re-parsing to gain usage data. (b) The gen_metadata model-name
// heuristic now rejects non-printable candidates: field 21/19
// sometimes carries a nested protobuf message whose low bytes are
// valid UTF-8, and the raw fragment (including NUL bytes) was
// persisted as messages.model, so existing Antigravity rows need
// re-parsing to clear the corrupt model values.)
//
// (37: Antigravity and Antigravity CLI parsers now extract
// per-generation model names and token usage (input, output,
// reasoning) from the gen_metadata table into per-message token
// fields, session totals, and usage events. Existing Antigravity
// rows need re-parsing so usage and cost reports include older
// sessions.)
//
// (36: the Antigravity CLI .pb branch dropped its sidecar
// mtime gate: a trajectory.json older than the .pb was rejected in
// favor of low-fidelity history fallbacks, but the encrypted .pb has no
// richer decode, .pb files are no longer produced, and their sidecars
// are final. Existing .pb rows whose sidecar was previously rejected
// need re-parsing to pick up the full-fidelity transcript.)
//
// (35: Antigravity CLI parser changed persisted data in two
// ways: (a) project inference (GitHub #579) now resolves a workspace
// for sessions whose history.jsonl rows lack a conversationId, changing
// stored session.Project, and (b) .db sessions now prefer the
// agy-reader trajectory.json sidecar (structured tool calls/results and
// thinking) over the heuristic SQLite decode. Existing Antigravity CLI
// rows would otherwise be skipped while file size/mtime and
// data_version look current, so they need a non-destructive resync to
// pick up inferred projects and sidecar-fidelity transcripts.)
//
// (34: added session_name column to sessions; existing rows
// need re-parsing so the parser can populate agent-provided session
// names (Claude /rename and native titles from other agents) into the
// new session_name field.)
//
// (33: Claude parser now skips content-free /usage probe
// sessions (the only user turn is the /usage command), and the Codex
// parser drops the initial user prompt when Codex re-emits it verbatim
// while continuing a task across turns. Existing rows need re-parsing
// so /usage probe sessions are dropped from the archive and Codex
// code-review sessions are recounted to a single user turn and
// re-flagged as automated.)
//
// (32: Antigravity DB parsers now filter internal protocol strings
// from visible message content, remove raw step headers, prefer
// prompt-like user text, and merge matching Antigravity CLI history
// prompts when DB decoding drops short user turns. Existing Antigravity
// DB rows need re-parsing so previously indexed noisy or assistant-only
// transcripts are rewritten.)
//
// (31: Copilot shutdown usage events use positional DedupKey to
// handle multi-segment sessions correctly.)
//
// (30: Hermes parser no longer treats cost_status
// "included" as a confident $0 when cost_source is "none"/empty (its
// default for models it does not price, e.g. gpt-5.5). Such rows now
// leave cost_usd nil so they are catalog-priced. Existing Hermes rows
// need re-parsing so their usage cost reflects the catalog instead of a
// baked-in $0.)
//
// (29: secret findings now record tool_result_event
// coordinates by the persisted slice position (matching
// tool_result_events.event_index) instead of the parser's raw event
// index. Existing rows need re-scanning so stored findings normalize
// and `secrets list --reveal` can re-read the source.)
//
// (28: Gemini parser now persists normalized
// (Anthropic-style) per-message token_usage JSON instead of the raw
// tokens object, and rolls thoughts tokens into OutputTokens so
// per-message and session output totals match the cost JSON.
// Existing Gemini rows need re-parsing so usage and cost reports
// reflect the new shape and include thoughts tokens.)
//
// (27: Piebald parser now persists normalized per-message
// token_usage JSON. Existing Piebald rows need re-parsing so Usage
// reports can include older Piebald sessions.)
//
// (26: Claude parser now (a) links Task / Agent tool
// calls to child subagent sessions via toolUseResult.agentId
// when queue/progress mappings are absent, populating
// tool_calls.subagent_session_id, and (b) merges additive
// same-message.id assistant chunks instead of keeping only the
// last entry, preserving sibling tool_use blocks and
// progressively-built text. Existing rows need re-parsing so
// these linkages and merged content show up.)
//
// (25: Codex parser now also links codex_app subagents
// via collab_agent_spawn_end event_msgs, wait_agent function
// calls, and agent_path subagent notifications. Existing rows
// need re-parsing so codex_app subagent linkage works.)
//
// (24: Codex parser now annotates spawn_agent tool calls
// with subagent_session_id once the spawned agent id is known.
// Existing rows need re-parsing so inline subagent expansion can
// resolve child sessions from persisted tool call metadata.)
//
// (23: split termination_status into awaiting_user vs
// clean (Claude end_turn / Codex task_complete vs other clean
// stops); Codex parser now classifies based on task lifecycle
// events. Existing rows need re-parsing so the new awaiting_user
// value populates correctly.)
//
// (22: added termination_status column to sessions; existing
// rows need re-parsing so the Claude classifier can populate
// the new column.)
//
// (21: Copilot parser now reads workspace.yaml to use the
// LLM-generated session name as first_message. Existing
// directory-format sessions where workspace.yaml.mtime <=
// events.jsonl.mtime would be permanently skipped without this
// bump, leaving first_message as the raw first user message.)
//
// (20: Claude parser now surfaces queued_command attachment
// entries (user messages typed mid-tool-call) as real user
// messages with source_subtype="queued_command".)
//
// (19: Copilot parser now filters synthetic skill context
// user messages.)
//
// (18: Claude parser now skips /clear and /effort
// command envelopes when computing first_message, so sessions
// that opened with one of those commands show the next real
// user message in the sidebar instead of the command text.
// Re-parsing rewrites first_message with the new logic.)
//
// (46: Cursor and Codex parsers now infer skill_name from
// read-like SKILL.md tool calls. Covers Read/ReadFile tool
// calls and Codex/Cursor shell reads across the Cursor JSONL
// and plain-text transcript paths, with ~ expansion, relative
// paths resolved against the tool-call workdir or session cwd,
// glob/space handling, and grep/rg pattern-vs-file
// classification, so historical skill usage is backfilled on
// re-parse.)
//
// (17: Codex <skill> template filtering.)
// (16: <turn_aborted> system messages.)
const dataVersion = 47
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
const (
walJournalSizeLimitBytes = 256 * 1024 * 1024
walCheckpointThreshold = 512 * 1024 * 1024
walCheckpointInterval = 5 * time.Minute
walCheckpointAttempts = 3
walCheckpointRetryDelay = 250 * time.Millisecond
)
// ErrWALCheckpointBusy reports that a truncate checkpoint could not reset
// the WAL because another connection still had pages pinned.
var ErrWALCheckpointBusy = errors.New("wal checkpoint busy")
// ClassifierHashKey is the shared SQLite stats / PG sync_metadata key
// under which the current is_automated classifier hash is stored.
// Exported so the postgres package and the classifier rebuild CLI
// reference one definition instead of repeating the literal.
const ClassifierHashKey = "is_automated_classifier_hash"
//go:embed schema.sql
var schemaSQL string
// messagesADTriggerDDL is the AFTER DELETE trigger that mirrors row
// removals into the FTS5 shadow tables. ReplaceSessionMessages drops
// this trigger inside its transaction (replacing N per-row FTS deletes
// with a single bulk INSERT...SELECT) and then re-runs this DDL to
// restore it before commit. Keeping the statement in one place keeps
// the two installation sites byte-identical.
const messagesADTriggerDDL = `
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
END;
`
const schemaFTS = `
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content='messages',
content_rowid='id',
tokenize='porter unicode61'
);
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
` + messagesADTriggerDDL + `
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
`
// DB manages a write connection and a read-only pool.
// The reader and writer fields use atomic.Pointer so that
// concurrent HTTP handler goroutines can safely read while
// Reopen/CloseConnections swap the underlying *sql.DB.
type DB struct {
path string
writer atomic.Pointer[sql.DB]
reader atomic.Pointer[sql.DB]
mu sync.Mutex // serializes writes
connMu sync.RWMutex
retired []*sql.DB // old pools kept open for in-flight reads
dataStale bool // set by Open when user_version < dataVersion
cursorMu sync.RWMutex
cursorSecret []byte
customPricing map[string]config.CustomModelRate
checkpointMu sync.Mutex
checkpointStop chan struct{}
checkpointDone chan struct{}
}
// Reader exposes guarded read-only query operations. It intentionally does
// not expose the underlying *sql.DB so callers cannot retain a raw pool across
// Reopen.
type Reader interface {
Exec(query string, args ...any) (sql.Result, error)
Query(query string, args ...any) (*sql.Rows, error)
QueryContext(
ctx context.Context, query string, args ...any,
) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
QueryRowContext(
ctx context.Context, query string, args ...any,
) *sql.Row
}
type readerHandle struct {
owner *DB
}
func (r *readerHandle) current() *sql.DB {
return r.owner.reader.Load()
}
func (r *readerHandle) Exec(
query string, args ...any,
) (sql.Result, error) {
r.owner.connMu.RLock()
defer r.owner.connMu.RUnlock()
return r.current().Exec(query, args...)
}
func (r *readerHandle) Query(
query string, args ...any,
) (*sql.Rows, error) {
r.owner.connMu.RLock()
defer r.owner.connMu.RUnlock()
return r.current().Query(query, args...)
}
func (r *readerHandle) QueryContext(
ctx context.Context, query string, args ...any,
) (*sql.Rows, error) {
r.owner.connMu.RLock()
defer r.owner.connMu.RUnlock()
return r.current().QueryContext(ctx, query, args...)
}
func (r *readerHandle) QueryRow(
query string, args ...any,
) *sql.Row {
r.owner.connMu.RLock()
defer r.owner.connMu.RUnlock()
return r.current().QueryRow(query, args...)
}
func (r *readerHandle) QueryRowContext(
ctx context.Context, query string, args ...any,
) *sql.Row {
r.owner.connMu.RLock()
defer r.owner.connMu.RUnlock()
return r.current().QueryRowContext(ctx, query, args...)
}
// getReader returns a guarded facade for the current read-only connection pool.
func (db *DB) getReader() *readerHandle { return &readerHandle{owner: db} }
func (db *DB) rawReader() *sql.DB { return db.reader.Load() }
// getWriter returns the current write connection.
func (db *DB) getWriter() *sql.DB { return db.writer.Load() }
// Path returns the file path of the database.
func (db *DB) Path() string {
return db.path
}
// ReadOnly returns false for the local SQLite store.
func (db *DB) ReadOnly() bool { return false }
func (db *DB) SetCustomPricing(p map[string]config.CustomModelRate) {
db.customPricing = p
}
// SetCursorSecret updates the secret key used for cursor signing.
func (db *DB) SetCursorSecret(secret []byte) {
db.cursorMu.Lock()
defer db.cursorMu.Unlock()
db.cursorSecret = append([]byte(nil), secret...)
}
// makeDSN builds a SQLite connection string with shared pragmas.
func makeDSN(path string, readOnly bool) string {
params := url.Values{}
params.Set("_journal_mode", "WAL")
params.Set("_busy_timeout", "5000")
params.Set("_foreign_keys", "ON")
params.Set("_mmap_size", "268435456")
params.Set("_cache_size", "-64000")
if readOnly {
params.Set("mode", "ro")
} else {
params.Set("_synchronous", "NORMAL")
}
return path + "?" + params.Encode()
}
// Open creates or opens a SQLite database at the given path.
// It configures WAL mode, mmap, and returns a DB with separate
// writer and reader connections.
//
// If an existing database has an outdated schema (missing
// columns), it is deleted and recreated from scratch.
// If the schema is current but the data version is stale,
// the database is preserved and file mtimes are reset to
// trigger a re-sync on the next cycle.
func Open(path string) (*DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating db directory: %w", err)
}
schemaStale, dataStale, err := probeDatabase(path)
if err != nil {
return nil, fmt.Errorf("checking schema: %w", err)
}
if schemaStale {
if err := dropDatabase(path); err != nil {
return nil, fmt.Errorf(
"rebuilding database: %w", err,
)
}
}
d, err := openAndInit(path)
if err != nil {
return nil, err
}
if err := d.migrateColumns(); err != nil {
d.Close()
return nil, fmt.Errorf("migrating columns: %w", err)
}
if dataStale && !schemaStale {
d.dataStale = true
log.Printf(
"data version outdated; full resync required",
)
} else {
// Only stamp user_version when data is current.
// When data is stale, preserve the old version so
// the "needs resync" state survives process restarts
// until ResyncAll completes successfully.
if err := d.setDataVersion(); err != nil {
d.Close()
return nil, fmt.Errorf(
"setting data version: %w", err,
)
}
}
return d, nil
}
// probeDatabase checks an existing database for schema and
// data staleness. Returns (schemaStale, dataStale, err).
// schemaStale means required columns are missing and the DB
// must be dropped and recreated. dataStale means the schema
// is fine but user_version < dataVersion, requiring a
// non-destructive re-sync.
func probeDatabase(
path string,
) (schemaStale, dataStale bool, err error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return false, false, nil
}
return false, false, fmt.Errorf(
"checking database file: %w", err,
)
}
conn, err := sql.Open("sqlite3", makeDSN(path, true))
if err != nil {
return false, false, fmt.Errorf(
"probing schema: %w", err,
)
}
defer conn.Close()
schema, err := needsSchemaRebuild(conn)
if err != nil {
return false, false, err
}
if schema {
return true, false, nil
}
data, err := needsDataResync(conn)
if err != nil {
return false, false, err
}
return false, data, nil
}
// needsSchemaRebuild probes for required columns that may be
// missing in databases created by older releases. If any are
// absent, the DB must be dropped and recreated.
func needsSchemaRebuild(conn *sql.DB) (bool, error) {
probes := []struct {
table string
column string
}{
{"sessions", "parent_session_id"},
{"insights", "date_from"},
{"tool_calls", "tool_use_id"},
{"sessions", "user_message_count"},
{"sessions", "relationship_type"},
{"tool_calls", "subagent_session_id"},
}
for _, p := range probes {
var count int
err := conn.QueryRow(fmt.Sprintf(
"SELECT count(*) FROM pragma_table_info('%s')"+
" WHERE name = '%s'",
p.table, p.column,
)).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"probing schema (%s.%s): %w",
p.table, p.column, err,
)
}
if count == 0 {
return true, nil
}
}
return false, nil
}
// needsDataResync checks whether user_version is behind the
// current dataVersion, indicating parser changes that require
// re-processing existing files.
func needsDataResync(conn *sql.DB) (bool, error) {
var version int
err := conn.QueryRow(
"PRAGMA user_version",
).Scan(&version)
if err != nil {
return false, fmt.Errorf(
"probing data version: %w", err,
)
}
return version < dataVersion, nil
}
// migrateColumns adds columns introduced by this branch to
// databases created by older releases. Each migration is
// idempotent — it only runs when the column is missing.
func (db *DB) migrateColumns() error {
db.mu.Lock()
defer db.mu.Unlock()
w := db.getWriter()
migrations := []struct {
table string
column string
ddl string
}{
{
"sessions", "display_name",
"ALTER TABLE sessions ADD COLUMN display_name TEXT",
},
{
"sessions", "session_name",
"ALTER TABLE sessions ADD COLUMN session_name TEXT",
},
{
"sessions", "deleted_at",
"ALTER TABLE sessions ADD COLUMN deleted_at TEXT",
},
{
"messages", "is_system",
"ALTER TABLE messages ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0",
},
{
"messages", "model",
"ALTER TABLE messages ADD COLUMN model TEXT NOT NULL DEFAULT ''",
},
{
"messages", "token_usage",
"ALTER TABLE messages ADD COLUMN token_usage TEXT NOT NULL DEFAULT ''",
},
{
"messages", "context_tokens",
"ALTER TABLE messages ADD COLUMN context_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"messages", "output_tokens",
"ALTER TABLE messages ADD COLUMN output_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"messages", "has_context_tokens",
"ALTER TABLE messages ADD COLUMN has_context_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"messages", "has_output_tokens",
"ALTER TABLE messages ADD COLUMN has_output_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"messages", "claude_message_id",
"ALTER TABLE messages ADD COLUMN claude_message_id TEXT NOT NULL DEFAULT ''",
},
{
"messages", "claude_request_id",
"ALTER TABLE messages ADD COLUMN claude_request_id TEXT NOT NULL DEFAULT ''",
},
{
"messages", "source_type",
"ALTER TABLE messages ADD COLUMN source_type TEXT NOT NULL DEFAULT ''",
},
{
"messages", "source_subtype",
"ALTER TABLE messages ADD COLUMN source_subtype TEXT NOT NULL DEFAULT ''",
},
{
"messages", "source_uuid",
"ALTER TABLE messages ADD COLUMN source_uuid TEXT NOT NULL DEFAULT ''",
},
{
"messages", "source_parent_uuid",
"ALTER TABLE messages ADD COLUMN source_parent_uuid TEXT NOT NULL DEFAULT ''",
},
{
"messages", "is_sidechain",
"ALTER TABLE messages ADD COLUMN is_sidechain INTEGER NOT NULL DEFAULT 0",
},
{
"messages", "is_compact_boundary",
"ALTER TABLE messages ADD COLUMN is_compact_boundary INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "total_output_tokens",
"ALTER TABLE sessions ADD COLUMN total_output_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "peak_context_tokens",
"ALTER TABLE sessions ADD COLUMN peak_context_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "has_total_output_tokens",
"ALTER TABLE sessions ADD COLUMN has_total_output_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "has_peak_context_tokens",
"ALTER TABLE sessions ADD COLUMN has_peak_context_tokens INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "local_modified_at",
"ALTER TABLE sessions ADD COLUMN local_modified_at TEXT",
},
{
"sessions", "is_automated",
"ALTER TABLE sessions ADD COLUMN is_automated INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "tool_failure_signal_count",
"ALTER TABLE sessions ADD COLUMN tool_failure_signal_count INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "tool_retry_count",
"ALTER TABLE sessions ADD COLUMN tool_retry_count INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "edit_churn_count",
"ALTER TABLE sessions ADD COLUMN edit_churn_count INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "consecutive_failure_max",
"ALTER TABLE sessions ADD COLUMN consecutive_failure_max INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "outcome",
"ALTER TABLE sessions ADD COLUMN outcome TEXT NOT NULL DEFAULT 'unknown'",
},
{
"sessions", "outcome_confidence",
"ALTER TABLE sessions ADD COLUMN outcome_confidence TEXT NOT NULL DEFAULT 'low'",
},
{
"sessions", "ended_with_role",
"ALTER TABLE sessions ADD COLUMN ended_with_role TEXT NOT NULL DEFAULT ''",
},
{
"sessions", "final_failure_streak",
"ALTER TABLE sessions ADD COLUMN final_failure_streak INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "signals_pending_since",
"ALTER TABLE sessions ADD COLUMN signals_pending_since TEXT",
},
{
"sessions", "compaction_count",
"ALTER TABLE sessions ADD COLUMN compaction_count INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "context_pressure_max",
"ALTER TABLE sessions ADD COLUMN context_pressure_max REAL",
},
{
"sessions", "health_score",
"ALTER TABLE sessions ADD COLUMN health_score INTEGER",
},
{
"sessions", "health_grade",
"ALTER TABLE sessions ADD COLUMN health_grade TEXT",
},
{
"sessions", "has_tool_calls",
"ALTER TABLE sessions ADD COLUMN has_tool_calls INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "has_context_data",
"ALTER TABLE sessions ADD COLUMN has_context_data INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "data_version",
"ALTER TABLE sessions ADD COLUMN data_version INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "mid_task_compaction_count",
"ALTER TABLE sessions ADD COLUMN mid_task_compaction_count INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "cwd",
"ALTER TABLE sessions ADD COLUMN cwd TEXT NOT NULL DEFAULT ''",
},
{
"sessions", "git_branch",
"ALTER TABLE sessions ADD COLUMN git_branch TEXT NOT NULL DEFAULT ''",
},
{
"sessions", "source_session_id",
"ALTER TABLE sessions ADD COLUMN source_session_id TEXT NOT NULL DEFAULT ''",
},
{
"sessions", "source_version",
"ALTER TABLE sessions ADD COLUMN source_version TEXT NOT NULL DEFAULT ''",
},
{
"sessions", "parser_malformed_lines",
"ALTER TABLE sessions ADD COLUMN parser_malformed_lines INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "is_truncated",
"ALTER TABLE sessions ADD COLUMN is_truncated INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "file_inode",
"ALTER TABLE sessions ADD COLUMN file_inode INTEGER",
},
{
"sessions", "file_device",
"ALTER TABLE sessions ADD COLUMN file_device INTEGER",
},
{
"messages", "thinking_text",
"ALTER TABLE messages ADD COLUMN thinking_text TEXT NOT NULL DEFAULT ''",
},
{
"sessions", "termination_status",
"ALTER TABLE sessions ADD COLUMN termination_status TEXT",
},
{
"sessions", "secret_leak_count",
"ALTER TABLE sessions ADD COLUMN secret_leak_count INTEGER NOT NULL DEFAULT 0",
},
{
"sessions", "secrets_rules_version",
"ALTER TABLE sessions ADD COLUMN secrets_rules_version TEXT NOT NULL DEFAULT ''",
},
}
for _, m := range migrations {
var count int
err := w.QueryRow(fmt.Sprintf(
"SELECT count(*) FROM pragma_table_info('%s')"+
" WHERE name = '%s'",
m.table, m.column,
)).Scan(&count)
if err != nil {
return fmt.Errorf(
"probing %s.%s: %w",
m.table, m.column, err,
)
}
if count == 0 {
if _, err := w.Exec(m.ddl); err != nil {
return fmt.Errorf(
"adding %s.%s: %w",
m.table, m.column, err,
)
}
log.Printf(
"migration: added column %s.%s",
m.table, m.column,
)
}
}
if err := db.createPartialIndexesLocked(w); err != nil {
return err
}
if err := db.backfillIsAutomatedLocked(w); err != nil {
return err
}
if _, err := w.Exec(
`CREATE INDEX IF NOT EXISTS idx_sessions_termination_status
ON sessions(termination_status)`,
); err != nil {
return fmt.Errorf(
"creating idx_sessions_termination_status: %w", err,
)
}
if _, err := w.Exec(`
CREATE TABLE IF NOT EXISTS remote_skipped_files (
host TEXT NOT NULL,
path TEXT NOT NULL,
file_mtime INTEGER NOT NULL,
PRIMARY KEY (host, path)
)`,
); err != nil {
return fmt.Errorf(
"creating remote_skipped_files: %w", err,
)
}
if _, err := w.Exec(`
CREATE TABLE IF NOT EXISTS worktree_project_mappings (
id INTEGER PRIMARY KEY,
machine TEXT NOT NULL,
path_prefix TEXT NOT NULL,
project TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(machine, path_prefix)
);
CREATE INDEX IF NOT EXISTS idx_worktree_project_mappings_match
ON worktree_project_mappings(machine, enabled, path_prefix);
CREATE INDEX IF NOT EXISTS idx_worktree_project_mappings_project
ON worktree_project_mappings(machine, project);
`); err != nil {
return fmt.Errorf(
"creating worktree_project_mappings: %w", err,
)
}
if err := db.ensureUsageEventsSchemaLocked(w); err != nil {
return err
}
runRepair, err := db.shouldRunTokenCoverageRepairLocked(w)
if err != nil {
return err
}
if !runRepair {
return nil
}
if err := db.backfillTokenCoverageFlagsLocked(w); err != nil {
return err
}
if err := db.markTokenCoverageRepairDoneLocked(w); err != nil {
return err
}
return nil
}
// createPartialIndexesLocked creates partial indexes that are not
// covered by the initial schema DDL. Idempotent via IF NOT EXISTS.
func (db *DB) createPartialIndexesLocked(w *sql.DB) error {
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_sessions_cwd
ON sessions(cwd) WHERE cwd != ''`,
`CREATE INDEX IF NOT EXISTS idx_messages_compact_boundary
ON messages(session_id, ordinal) WHERE is_compact_boundary = 1`,
`CREATE INDEX IF NOT EXISTS idx_messages_sidechain
ON messages(session_id) WHERE is_sidechain = 1`,
`CREATE INDEX IF NOT EXISTS idx_messages_source_uuid
ON messages(source_uuid) WHERE source_uuid != ''`,
`CREATE INDEX IF NOT EXISTS idx_messages_usage_covering
ON messages(timestamp, session_id, ordinal, model,
claude_message_id, claude_request_id, token_usage)
WHERE token_usage != ''
AND model != ''
AND model != '<synthetic>'`,
`CREATE INDEX IF NOT EXISTS idx_sessions_has_secret
ON sessions(secret_leak_count) WHERE secret_leak_count > 0`,
}
for _, ddl := range indexes {
if _, err := w.Exec(ddl); err != nil {
return fmt.Errorf("creating index: %w", err)
}
}
if _, err := w.Exec(
`DROP INDEX IF EXISTS idx_messages_usage_timestamp`,
); err != nil {
return fmt.Errorf("dropping legacy usage index: %w", err)
}
return nil
}
// backfillIsAutomatedLocked verifies is_automated for all
// sessions, correcting both false negatives (new patterns or
// stale imported rows) and stale false positives (patterns
// tightened since last run). The stored classifier hash records
// which classifier wrote the current audit, but it is not a
// complete integrity marker: rows can be copied from older DBs
// or stale remote machines after the hash was stamped.
func (db *DB) backfillIsAutomatedLocked(w *sql.DB) error {
current := ClassifierHash()
var stored string
err := w.QueryRow(
`SELECT value FROM stats WHERE key = ?`,
ClassifierHashKey,
).Scan(&stored)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf(
"probing classifier hash: %w", err,
)
}
rows, err := w.Query(
`SELECT
s.id,
s.first_message,
s.user_message_count,
s.is_automated,
(
SELECT m.content
FROM messages m
WHERE m.session_id = s.id
AND m.role = 'user'
AND m.is_system = 0
AND TRIM(m.content) <> ''
ORDER BY m.ordinal
LIMIT 1
) AS first_user_message
FROM sessions s`,
)
if err != nil {
return fmt.Errorf(
"querying automated backfill candidates: %w", err,
)
}
defer rows.Close()
var setIDs, clearIDs []string
for rows.Next() {
var id string
var fm sql.NullString
var firstUser sql.NullString
var umc int
var rowAutomated bool
if err := rows.Scan(
&id, &fm, &umc, &rowAutomated, &firstUser,
); err != nil {
return fmt.Errorf(
"scanning backfill candidate: %w", err,
)
}
want := isAutomatedFromTextCandidates(
umc, firstUser, fm,
)
if want && !rowAutomated {
setIDs = append(setIDs, id)
} else if !want && rowAutomated {
clearIDs = append(clearIDs, id)
}
}
if err := rows.Err(); err != nil {
return err
}
if err := batchUpdateAutomated(
w, setIDs, 1,
); err != nil {
return err
}
if err := batchUpdateAutomated(
w, clearIDs, 0,
); err != nil {
return err
}