A finding you have to remember to go and look at is a finding you do not see. This closes the loop: `fathom insights` reports findings for other programs, a Claude Code Stop hook runs it when a turn ends, and /insights shows the detail on request. The CLI reports as text, --json, or --jsonl, and --new returns only what has not been reported before. The server owns the reported-findings state, because two sessions can end at the same moment and a read-then-write would have both report the same thing. Two rules follow from running on every turn: - It must be fast. --hook asks a running server for an analysis already computed rather than doing one, and never falls back to analysing directly, which could take seconds. Measured at ~20ms whether or not a server is up. - It must never disrupt the session it observes. Every path exits 0 and prints at most one line. A Stop hook exiting 2 tells Claude Code to keep going rather than stop, so a crash here would hijack the session it was supposed to quietly watch. Surfacing is a higher bar than appearing in the tab, and biased towards silence. A tab is consulted; a notification arrives whether or not you wanted it, and the costs are asymmetric -- a missed finding waits patiently, a wrong nudge trains you to ignore all of them. Medium severity and $0.50 minimum, not already reported unless it has recurred since, and at most one nudge every two hours. A local model, if one is running, can veto a finding as not worth interrupting for. It can never promote one that did not clear the bar, is never consulted about findings below it, and a model failure keeps the finding rather than silencing it. Findings it rejects are recorded, so it is not asked the same question every turn. Also fixes a latent hazard this would have made routine. OpenShard runs tail recovery, which truncates, so a CLI opening a store the server is mid-append to could cut off the chunk being written -- the reader destroying the writer's data. Added a read-only open that skips recovery and opens O_RDONLY, and pointed every read-only command at it, including `fathom query`, which had the same problem already. The nudge's cost figure counted each finding separately, so a set of them could claim more money than the window ever spent: $12.52 against a window costing $7.94. Now counted once per trace. claude/ ships the skill and the hook snippet with an installer. The snippet is printed rather than applied, because a script should not blindly edit a settings file that may already have hooks in it, and a mangled settings.json silently disables everything in it.
280 lines
7.4 KiB
Go
280 lines
7.4 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"hash/crc32"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// A shard file is a sequence of framed chunks:
|
|
//
|
|
// [ length uint32 ][ crc32 uint32 ][ chunk body ]
|
|
//
|
|
// There is no footer and no header block to keep current. Appending is a write
|
|
// at the end of the file, and nothing already written is ever revised. That
|
|
// property is what makes crash recovery a truncation rather than a repair: if
|
|
// the process dies mid-append, the tail is a chunk whose length runs past EOF
|
|
// or whose CRC does not match, and dropping it leaves every earlier chunk
|
|
// intact and correct.
|
|
const (
|
|
frameHeaderSize = 8
|
|
|
|
// maxChunkBytes bounds the allocation a corrupt length prefix can request.
|
|
// Real chunks are well under a megabyte; this is three orders of magnitude
|
|
// of headroom before it rejects anything legitimate.
|
|
maxChunkBytes = 1 << 30
|
|
)
|
|
|
|
// Shard is one day's file for one table, plus its blob sidecar.
|
|
type Shard struct {
|
|
Table string
|
|
Date string // YYYY-MM-DD
|
|
|
|
path string
|
|
mu sync.Mutex
|
|
f *os.File
|
|
blob *BlobFile
|
|
readOnly bool
|
|
}
|
|
|
|
// OpenShard opens or creates a shard, recovering a torn tail if one is present.
|
|
func OpenShard(dir, table, date string) (*Shard, error) {
|
|
return openShard(dir, table, date, false)
|
|
}
|
|
|
|
// OpenShardReadOnly opens a shard without creating, recovering, or writing.
|
|
//
|
|
// This exists because recovery truncates. A second process opening a shard the
|
|
// server is actively appending to would run recoverTail against a chunk still
|
|
// being written and cut it off — the reader destroying the writer's data. Any
|
|
// process that only reads, which is every CLI command, must take this path.
|
|
//
|
|
// Reading a file under concurrent append is otherwise safe: chunks are framed
|
|
// with a length and a CRC, so a partially written tail fails one of those
|
|
// checks and iteration stops cleanly at the last complete chunk.
|
|
func OpenShardReadOnly(dir, table, date string) (*Shard, error) {
|
|
return openShard(dir, table, date, true)
|
|
}
|
|
|
|
func openShard(dir, table, date string, readOnly bool) (*Shard, error) {
|
|
tdir := filepath.Join(dir, table)
|
|
path := filepath.Join(tdir, date+".fathom")
|
|
|
|
if readOnly {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blob, err := OpenBlobFileReadOnly(filepath.Join(tdir, date+".blob"))
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return &Shard{Table: table, Date: date, path: path, f: f, blob: blob, readOnly: true}, nil
|
|
}
|
|
|
|
if err := os.MkdirAll(tdir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := recoverTail(path); err != nil {
|
|
return nil, fmt.Errorf("recovering %s: %w", path, err)
|
|
}
|
|
|
|
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blob, err := OpenBlobFile(filepath.Join(tdir, date+".blob"))
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return &Shard{Table: table, Date: date, path: path, f: f, blob: blob}, nil
|
|
}
|
|
|
|
// recoverTail truncates the file to the end of the last chunk that is both
|
|
// fully present and passes its checksum.
|
|
//
|
|
// This runs on every open, not only after a detected crash, because there is no
|
|
// reliable way to know a crash happened. It is a full scan of the frame headers
|
|
// — not the bodies — so the cost is a seek per chunk, which for a day's file is
|
|
// negligible next to the safety of never handing a half-written chunk to the
|
|
// decoder.
|
|
func recoverTail(path string) error {
|
|
f, err := os.OpenFile(path, os.O_RDWR, 0o644)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
size := st.Size()
|
|
|
|
var good int64
|
|
var hdr [frameHeaderSize]byte
|
|
for {
|
|
if good+frameHeaderSize > size {
|
|
break
|
|
}
|
|
if _, err := f.ReadAt(hdr[:], good); err != nil {
|
|
break
|
|
}
|
|
n := binary.LittleEndian.Uint32(hdr[0:4])
|
|
want := binary.LittleEndian.Uint32(hdr[4:8])
|
|
if n == 0 || n > maxChunkBytes {
|
|
break
|
|
}
|
|
end := good + frameHeaderSize + int64(n)
|
|
if end > size {
|
|
break // chunk body never finished landing
|
|
}
|
|
body := make([]byte, n)
|
|
if _, err := f.ReadAt(body, good+frameHeaderSize); err != nil {
|
|
break
|
|
}
|
|
if crc32.ChecksumIEEE(body) != want {
|
|
break // body landed partially, or the disk lied
|
|
}
|
|
good = end
|
|
}
|
|
|
|
if good < size {
|
|
return f.Truncate(good)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppendChunk frames and appends a chunk, then fsyncs.
|
|
//
|
|
// The fsync is per chunk, not per span, which is the entire reason chunks
|
|
// exist: ingest buffers spans and pays one sync per flush.
|
|
func (s *Shard) AppendChunk(c *Chunk) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.readOnly {
|
|
return fmt.Errorf("shard %s/%s is open read-only", s.Table, s.Date)
|
|
}
|
|
|
|
var body bytesBuffer
|
|
if err := c.Encode(&body); err != nil {
|
|
return err
|
|
}
|
|
if len(body.b) > maxChunkBytes {
|
|
return fmt.Errorf("chunk of %d bytes exceeds the %d cap", len(body.b), maxChunkBytes)
|
|
}
|
|
|
|
var hdr [frameHeaderSize]byte
|
|
binary.LittleEndian.PutUint32(hdr[0:4], uint32(len(body.b)))
|
|
binary.LittleEndian.PutUint32(hdr[4:8], crc32.ChecksumIEEE(body.b))
|
|
|
|
// One write, so a frame header never lands without at least an attempt at
|
|
// its body. Recovery handles the partial case regardless.
|
|
frame := append(hdr[:], body.b...)
|
|
if _, err := s.f.Write(frame); err != nil {
|
|
return err
|
|
}
|
|
if err := s.blob.Sync(); err != nil {
|
|
return err
|
|
}
|
|
return s.f.Sync()
|
|
}
|
|
|
|
// Chunks iterates the shard's chunks in write order, stopping at the first
|
|
// unreadable one. Recovery at open means that should not happen, but a shard
|
|
// being appended to concurrently can present a partial tail to a reader.
|
|
func (s *Shard) Chunks(fn func(*Chunk) error) error {
|
|
f, err := os.Open(s.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return iterChunks(f, fn)
|
|
}
|
|
|
|
func iterChunks(f *os.File, fn func(*Chunk) error) error {
|
|
var off int64
|
|
var hdr [frameHeaderSize]byte
|
|
for {
|
|
if _, err := f.ReadAt(hdr[:], off); err != nil {
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
return nil // short tail: stop cleanly
|
|
}
|
|
n := binary.LittleEndian.Uint32(hdr[0:4])
|
|
want := binary.LittleEndian.Uint32(hdr[4:8])
|
|
if n == 0 || n > maxChunkBytes {
|
|
return nil
|
|
}
|
|
body := make([]byte, n)
|
|
if _, err := f.ReadAt(body, off+frameHeaderSize); err != nil {
|
|
return nil
|
|
}
|
|
if crc32.ChecksumIEEE(body) != want {
|
|
return nil
|
|
}
|
|
c, err := DecodeChunk(body)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := fn(c); err != nil {
|
|
return err
|
|
}
|
|
off += frameHeaderSize + int64(n)
|
|
}
|
|
}
|
|
|
|
// Blob exposes the shard's blob sidecar for lazy text fetches.
|
|
func (s *Shard) Blob() *BlobFile { return s.blob }
|
|
|
|
func (s *Shard) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
err1 := s.blob.Close()
|
|
err2 := s.f.Close()
|
|
if err1 != nil {
|
|
return err1
|
|
}
|
|
return err2
|
|
}
|
|
|
|
// Size reports the shard's total on-disk footprint, chunks plus blobs, which is
|
|
// what size-based retention budgets against.
|
|
func (s *Shard) Size() int64 {
|
|
var total int64
|
|
if st, err := os.Stat(s.path); err == nil {
|
|
total += st.Size()
|
|
}
|
|
total += s.blob.Size()
|
|
return total
|
|
}
|
|
|
|
// bytesBuffer is a minimal append-only sink. bytes.Buffer would do, but Encode
|
|
// writes many small pieces and this avoids its interface indirection on the
|
|
// hottest path in ingest.
|
|
type bytesBuffer struct{ b []byte }
|
|
|
|
func (w *bytesBuffer) Write(p []byte) (int, error) {
|
|
w.b = append(w.b, p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (w *bytesBuffer) WriteString(s string) (int, error) {
|
|
w.b = append(w.b, s...)
|
|
return len(s), nil
|
|
}
|