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
1041 lines
36 KiB
Go
1041 lines
36 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
// ParseDiffOptions configures a report-only re-parse comparison.
|
|
type ParseDiffOptions struct {
|
|
// Agents restricts the run; empty means every provider-authoritative
|
|
// agent whose registered factory can Discover() and re-parse a source.
|
|
// That includes shared-SQLite DB-backed providers that fan a database
|
|
// out to one virtual source per session (Kiro, OpenCode, Forge,
|
|
// Devin, Piebald, Warp). Only import-only agents, which have no
|
|
// source to re-parse, are rejected with an error.
|
|
Agents []parser.AgentType
|
|
// Limit caps the number of source files parsed, newest mtime first
|
|
// across all agents. 0 means no limit.
|
|
Limit int
|
|
// Progress, when non-nil, is called as (filesDone, filesTotal) from
|
|
// the result collector.
|
|
Progress func(done, total int)
|
|
}
|
|
|
|
// NewDiffEngine creates an engine for report-only parse-diff runs. It
|
|
// forces Ephemeral so nothing is persisted (no skip cache, no sync
|
|
// state) and arms the engine's force-parse mode so every discovered
|
|
// file is fully re-parsed regardless of stored size/mtime/data_version
|
|
// state.
|
|
func NewDiffEngine(database *db.DB, cfg EngineConfig) *Engine {
|
|
cfg.Ephemeral = true
|
|
e := NewEngine(database, cfg)
|
|
e.forceParse = true
|
|
return e
|
|
}
|
|
|
|
// ParseDiff re-parses session source files with the current binary,
|
|
// runs the result through the same normalization sync applies, and
|
|
// compares it against the stored rows. It writes nothing: no sessions,
|
|
// no skip cache, no sync state. It holds the engine's sync mutex for
|
|
// the duration.
|
|
func (e *Engine) ParseDiff(ctx context.Context, opts ParseDiffOptions) (*ParseDiffReport, error) {
|
|
e.syncMu.Lock()
|
|
defer e.syncMu.Unlock()
|
|
|
|
resolved, err := e.resolveParseDiffAgents(opts.Agents)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolvedSet := make(map[parser.AgentType]bool, len(resolved))
|
|
|
|
report := &ParseDiffReport{
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
DataVersion: db.CurrentDataVersion(),
|
|
FieldCounts: map[string]int{},
|
|
// Non-nil so a clean run serializes "sessions": [] rather than
|
|
// null, which jq pipelines and typed consumers expect.
|
|
Sessions: []SessionDiff{},
|
|
}
|
|
for _, def := range resolved {
|
|
resolvedSet[def.Type] = true
|
|
report.Agents = append(report.Agents, string(def.Type))
|
|
}
|
|
|
|
// Discovery mirrors syncAllLocked's file phase: provider discovery over
|
|
// the configured dirs per agent, then dedupe and the legacy-Kiro shadow
|
|
// filter. Provider discovery already enumerates shared-SQLite sources
|
|
// (Kiro's data.sqlite3, db-mode OpenCode's opencode.db) per session, so
|
|
// no separate db-source synthesis is needed.
|
|
var files []parser.DiscoveredFile
|
|
for _, def := range resolved {
|
|
providerFiles, err := e.parseDiffProviderSources(ctx, def.Type)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, providerFiles...)
|
|
}
|
|
files = dedupeDiscoveredFiles(files)
|
|
files = e.filterShadowedLegacyKiroFiles(files)
|
|
|
|
// Newest first by source mtime (composite stats for virtual
|
|
// paths), tie-broken by path so the --limit sample is stable.
|
|
files, cutPaths, limited := sortAndLimitParseDiffFiles(
|
|
files, opts.Limit,
|
|
)
|
|
report.FilesLimited = limited
|
|
report.FilesExamined = len(files)
|
|
|
|
// One full snapshot of the archive (empty range = every session,
|
|
// including trashed rows, with full columns and the raw
|
|
// session_name) indexed by ID and by base source path.
|
|
storedSessions, err := e.db.ListSessionsModifiedBetween(
|
|
ctx, "", "", nil, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse-diff: list stored sessions: %w", err)
|
|
}
|
|
storedByID := make(map[string]*db.Session, len(storedSessions))
|
|
storedByPath := make(map[string][]*db.Session)
|
|
for i := range storedSessions {
|
|
s := &storedSessions[i]
|
|
storedByID[s.ID] = s
|
|
if s.FilePath != nil && *s.FilePath != "" {
|
|
base := parseDiffSourceKey(*s.FilePath)
|
|
storedByPath[base] = append(storedByPath[base], s)
|
|
}
|
|
}
|
|
|
|
fileAgents := make(map[string]parser.AgentType, len(files))
|
|
for _, f := range files {
|
|
fileAgents[f.Path] = f.Agent
|
|
}
|
|
|
|
// The worktree resolver caches are not thread-safe; everything
|
|
// below runs in this single collector goroutine.
|
|
resolver := e.loadWorktreeProjectResolver()
|
|
visited := make(map[string]bool)
|
|
var presencePaths []string
|
|
|
|
total := len(files)
|
|
if opts.Progress != nil {
|
|
opts.Progress(0, total)
|
|
}
|
|
// A local cancel lets the error-return paths stop the worker pool
|
|
// instead of parsing every remaining file just to drain it.
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
results := e.startWorkers(runCtx, files)
|
|
for i := range total {
|
|
var r syncJob
|
|
select {
|
|
case <-runCtx.Done():
|
|
cancel()
|
|
drainResults(results, total-i)
|
|
return nil, ctx.Err()
|
|
case r = <-results:
|
|
}
|
|
if r.err != nil && runCtx.Err() != nil {
|
|
// Workers emit ctx.Err() for files skipped after
|
|
// cancellation.
|
|
cancel()
|
|
drainResults(results, total-i-1)
|
|
return nil, ctx.Err()
|
|
}
|
|
if r.incremental != nil {
|
|
cancel()
|
|
drainResults(results, total-i-1)
|
|
return nil, fmt.Errorf(
|
|
"parse-diff: internal error: incremental parse of %s "+
|
|
"despite force-parse mode", r.path,
|
|
)
|
|
}
|
|
if err := e.parseDiffCollectFile(
|
|
ctx, report, r, fileAgents, storedByID, storedByPath,
|
|
visited, resolver, &presencePaths,
|
|
); err != nil {
|
|
cancel()
|
|
drainResults(results, total-i-1)
|
|
return nil, err
|
|
}
|
|
if opts.Progress != nil {
|
|
opts.Progress(i+1, total)
|
|
}
|
|
}
|
|
|
|
// Presence sweep after all files: stored sessions under a
|
|
// successfully parsed file that no parse result or exclusion
|
|
// accounted for were silently dropped by the current parser.
|
|
e.parseDiffPresenceSweep(report, presencePaths, storedByPath, visited)
|
|
|
|
// Final sweep: stored sessions never visited by any file.
|
|
e.parseDiffSweepStored(
|
|
report, storedSessions, resolvedSet,
|
|
len(opts.Agents) == 0, cutPaths, visited,
|
|
)
|
|
// Raced and incremental-skew sessions were compared against a fresh
|
|
// parse just like the others, so they count toward Examined; they are
|
|
// simply not counted as drift. Keeping them in Examined also keeps
|
|
// VacuousResync honest: a run with comparable (raced or skew) sessions
|
|
// is not "all pending resync".
|
|
report.Totals.Examined = report.Totals.Identical +
|
|
report.Totals.Changed + report.Totals.PendingResync +
|
|
report.Totals.Raced + report.Totals.IncrementalSkew
|
|
|
|
sort.Slice(report.Sessions, func(i, j int) bool {
|
|
a, b := report.Sessions[i], report.Sessions[j]
|
|
if a.Class != b.Class {
|
|
return a.Class < b.Class
|
|
}
|
|
if a.Agent != b.Agent {
|
|
return a.Agent < b.Agent
|
|
}
|
|
if a.FilePath != b.FilePath {
|
|
return a.FilePath < b.FilePath
|
|
}
|
|
return a.SessionID < b.SessionID
|
|
})
|
|
return report, nil
|
|
}
|
|
|
|
// parseDiffProviderSources discovers an agent's sources through the provider
|
|
// facade. It is scoped to a single agent type so parse-diff
|
|
// respects the requested agent set.
|
|
func (e *Engine) parseDiffProviderSources(
|
|
ctx context.Context,
|
|
agentType parser.AgentType,
|
|
) ([]parser.DiscoveredFile, error) {
|
|
factory, ok := e.providerFactories[agentType]
|
|
if !ok || factory == nil {
|
|
return nil, nil
|
|
}
|
|
roots := e.agentDirs[agentType]
|
|
if len(roots) == 0 {
|
|
return nil, nil
|
|
}
|
|
provider := factory.NewProvider(parser.ProviderConfig{
|
|
Roots: roots,
|
|
Machine: e.machine,
|
|
})
|
|
sources, err := provider.Discover(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"parse-diff %s provider discovery: %w",
|
|
agentType, err,
|
|
)
|
|
}
|
|
def := provider.Definition()
|
|
var files []parser.DiscoveredFile
|
|
for _, source := range sources {
|
|
sourcePath := providerDiscoveredPath(source)
|
|
if sourcePath == "" {
|
|
continue
|
|
}
|
|
agent := source.Provider
|
|
if agent == "" {
|
|
agent = def.Type
|
|
}
|
|
sourceCopy := source
|
|
discovered := parser.DiscoveredFile{
|
|
Path: sourcePath,
|
|
Project: source.ProjectHint,
|
|
Agent: agent,
|
|
ProviderSource: &sourceCopy,
|
|
ProviderProcess: true,
|
|
}
|
|
// Thread S3 discovery metadata onto the file exactly as the main sync
|
|
// discovery path does. Without this, s3:// sources carry a zero mtime
|
|
// and parse-diff ordering falls back to a network stat (or treats them
|
|
// as oldest if that stat fails), skewing --limit selection.
|
|
if s3, ok := source.Opaque.(parser.S3DiscoveredSource); ok {
|
|
discovered.Machine = s3.Machine
|
|
discovered.SourceSize = s3.Size
|
|
discovered.SourceMtime = s3.MtimeNS
|
|
discovered.SourceFingerprint = s3.Fingerprint
|
|
if discovered.Project == "" {
|
|
discovered.Project = s3.Project
|
|
}
|
|
}
|
|
files = append(files, discovered)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func (e *Engine) parseDiffAgentDiscoverable(def parser.AgentDef) bool {
|
|
// A provider-authoritative agent with a registered factory exposes a
|
|
// working Discover()/Parse() the report-only run can re-parse, whether it
|
|
// reads literal per-session files (Claude, Codex) or a shared SQLite store
|
|
// its provider fans out to one virtual source per session (Kiro,
|
|
// OpenCode, and the DB-backed Forge/Devin/Piebald/Warp providers).
|
|
// FileBased is not consulted here: it distinguishes literal-file agents
|
|
// from DB-backed ones for the watcher and sync paths, but both kinds
|
|
// satisfy the same provider Discover() contract parse-diff needs.
|
|
// Import-only agents are not provider-authoritative, so the switch still
|
|
// rejects them.
|
|
switch e.providerMigrationModes[def.Type] {
|
|
case parser.ProviderMigrationProviderAuthoritative:
|
|
factory, ok := e.providerFactories[def.Type]
|
|
return ok && factory != nil
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// resolveParseDiffAgents validates the requested agent set against
|
|
// the registry and returns the matching defs in registry order. Only
|
|
// agents with parse-diff support can be re-parsed.
|
|
func (e *Engine) resolveParseDiffAgents(
|
|
requested []parser.AgentType,
|
|
) ([]parser.AgentDef, error) {
|
|
var allowed []parser.AgentDef
|
|
allowedSet := make(map[parser.AgentType]bool)
|
|
var names []string
|
|
for _, def := range parser.Registry {
|
|
if e.parseDiffAgentDiscoverable(def) {
|
|
allowed = append(allowed, def)
|
|
allowedSet[def.Type] = true
|
|
names = append(names, string(def.Type))
|
|
}
|
|
}
|
|
if len(requested) == 0 {
|
|
return allowed, nil
|
|
}
|
|
|
|
supported := strings.Join(names, ", ")
|
|
reqSet := make(map[parser.AgentType]bool, len(requested))
|
|
for _, t := range requested {
|
|
if allowedSet[t] {
|
|
reqSet[t] = true
|
|
continue
|
|
}
|
|
if _, known := parser.AgentByType(t); known {
|
|
return nil, fmt.Errorf(
|
|
"agent %q is not supported by parse-diff; "+
|
|
"supported agents: %s", t, supported,
|
|
)
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"unknown agent %q; supported agents: %s", t, supported,
|
|
)
|
|
}
|
|
out := make([]parser.AgentDef, 0, len(reqSet))
|
|
for _, def := range allowed {
|
|
if reqSet[def.Type] {
|
|
out = append(out, def)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// sortAndLimitParseDiffFiles orders files newest-first by source
|
|
// mtime (tie-break: path ascending) and applies the file cap. It
|
|
// returns the kept files and the base paths of files cut by the
|
|
// limit, used by the final sweep's "not sampled" reason.
|
|
func sortAndLimitParseDiffFiles(
|
|
files []parser.DiscoveredFile, limit int,
|
|
) ([]parser.DiscoveredFile, map[string]bool, bool) {
|
|
mtimes := make(map[string]int64, len(files))
|
|
for _, f := range files {
|
|
if m, ok := parseDiffDiscoveryMtime(f); ok {
|
|
mtimes[f.Path] = m
|
|
continue
|
|
}
|
|
m, err := discoveredFileMtime(f)
|
|
if err != nil {
|
|
m = 0
|
|
}
|
|
mtimes[f.Path] = m
|
|
}
|
|
sort.SliceStable(files, func(i, j int) bool {
|
|
mi, mj := mtimes[files[i].Path], mtimes[files[j].Path]
|
|
if mi != mj {
|
|
return mi > mj
|
|
}
|
|
return files[i].Path < files[j].Path
|
|
})
|
|
|
|
cutPaths := map[string]bool{}
|
|
limited := false
|
|
if limit > 0 && len(files) > limit {
|
|
limited = true
|
|
for _, f := range files[limit:] {
|
|
cutPaths[parseDiffSourceKey(f.Path)] = true
|
|
}
|
|
files = files[:limit]
|
|
}
|
|
return files, cutPaths, limited
|
|
}
|
|
|
|
// parseDiffDiscoveryMtime returns a per-source mtime captured at provider
|
|
// discovery when the source carries one. The DB-backed providers (Forge,
|
|
// Piebald, Warp) fan a shared SQLite store out to one virtual
|
|
// "<db>#<sessionID>" source per session; those paths have no on-disk existence,
|
|
// so discoveredFileMtime's os.Stat fallback fails and yields 0. With every
|
|
// virtual source pinned to 0, the --limit sampler would order them
|
|
// lexicographically by path and could cut the newest sessions. The provider
|
|
// stamps SourceRef.DiscoveryMTimeNS with each session's real mtime during
|
|
// Discover, so use it directly for ordering here. Ordering only: raced-guard
|
|
// reliability (parseDiffSourceReliableForRaced) and skip-cache/data-version
|
|
// freshness are unaffected.
|
|
func parseDiffDiscoveryMtime(f parser.DiscoveredFile) (int64, bool) {
|
|
if f.ProviderSource == nil || f.ProviderSource.DiscoveryMTimeNS == 0 {
|
|
return 0, false
|
|
}
|
|
return f.ProviderSource.DiscoveryMTimeNS, true
|
|
}
|
|
|
|
func parseDiffSourceKey(path string) string {
|
|
if isPerSessionDBVirtualSource(path) {
|
|
return path
|
|
}
|
|
return stripVirtualSourceSuffix(path)
|
|
}
|
|
|
|
// perSessionDBVirtualSourceBases lists the shared-SQLite database filenames
|
|
// whose providers discover one virtual source per session (opencode.db#id,
|
|
// sessions.db#id, warp.sqlite#id, ...). parse-diff keys these by their full
|
|
// virtual path, so a
|
|
// --limit sample or a per-session parse failure stays scoped to the single
|
|
// session it names instead of fanning out across every sibling row in the same
|
|
// physical database. Contrast Kiro/Zed/Shelley, whose providers discover the
|
|
// database as one source; those key by the stripped database path.
|
|
var perSessionDBVirtualSourceBases = []string{
|
|
"opencode.db", "kilo.db", "mimocode.db", "sessions.db",
|
|
parser.WarpDBFilename, parser.ForgeDBFilename, parser.PiebaldDBFilename,
|
|
parser.WindsurfStateDBName,
|
|
}
|
|
|
|
func isPerSessionDBVirtualSource(path string) bool {
|
|
for _, base := range perSessionDBVirtualSourceBases {
|
|
if _, _, ok := parser.ParseVirtualSourcePathForBase(path, base); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stripVirtualSourceSuffix maps a stored file_path to its on-disk base file by
|
|
// removing the "#rawID" suffix that Kiro, Zed, OpenCode, Kilo, MiMoCode,
|
|
// IcodeMate, Shelley, Forge, Piebald, and Warp SQLite-backed sessions append to
|
|
// their shared database path, the "#conversationID" suffix Visual Studio
|
|
// Copilot appends to its shared trace file, and the "#runIdx" suffix aider
|
|
// appends to its shared history file.
|
|
func stripVirtualSourceSuffix(path string) string {
|
|
if dbPath, _, ok := parser.ParseVirtualSourcePath(path); ok {
|
|
return dbPath
|
|
}
|
|
if tracePath, _, ok := parser.SplitVisualStudioCopilotVirtualPath(path); ok {
|
|
return tracePath
|
|
}
|
|
if historyPath, _, ok := parser.ParseAiderVirtualPath(path); ok {
|
|
return historyPath
|
|
}
|
|
if dbPath, _, ok := parseKiroSQLiteVirtualPath(path); ok {
|
|
return dbPath
|
|
}
|
|
if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, "threads.db"); ok {
|
|
return dbPath
|
|
}
|
|
for _, base := range perSessionDBVirtualSourceBases {
|
|
if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, base); ok {
|
|
return dbPath
|
|
}
|
|
}
|
|
if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, "icodemate.db"); ok {
|
|
return dbPath
|
|
}
|
|
if dbPath, _, ok := parser.ParseVirtualSourcePathForBase(path, shelleyDBFile); ok {
|
|
return dbPath
|
|
}
|
|
return path
|
|
}
|
|
|
|
// parseDiffSourceReliableForRaced reports whether a session's live source
|
|
// mtime can be compared against the stored file_mtime on an apples-to-apples
|
|
// basis, gating the live-write skew (raced) reclassification.
|
|
//
|
|
// The raced guard reclassifies a would-be DiffChanged as DiffRaced when the
|
|
// live source mtime is newer than the stored per-session file_mtime. The live
|
|
// mtime it consults is resolved by parseDiffLiveMtime at collect time, using
|
|
// only mtime sources that are safe per-session race signals. For agents with
|
|
// session-specific sibling files, it recomputes the same effective basis a real
|
|
// sync would persist; for Codex, it deliberately uses the transcript mtime
|
|
// instead of the global session_index.jsonl mtime so unrelated title/index
|
|
// writes cannot mask transcript parser drift.
|
|
//
|
|
// Even so, two source shapes are deliberately held OUT of the raced guard so
|
|
// it fails CLOSED rather than risk masking genuine parser drift:
|
|
//
|
|
// - Virtual-path sources (Aider "#runIdx", Kiro/Zed/OpenCode/Kilo/MiMoCode
|
|
// "#rawID", Shelley, Forge/Devin/Piebald/Warp "#sessionID", Visual
|
|
// Studio Copilot "#conversationID"). Many sessions fan out of ONE
|
|
// physical source, so the source mtime is NOT a per-session signal. A
|
|
// shared DB's rows can carry advanced updated_at values, Devin's
|
|
// authoritative mtime is a provider-composite across DB metadata and
|
|
// transcript state, and Aider's File.Mtime is the whole
|
|
// .aider.chat.history.md file's mtime shared by every run in it --
|
|
// appending a new run bumps it for all runs -- so a newer mtime does not
|
|
// mean THIS session's parsed content was torn. Including them would mask
|
|
// genuine drift on untouched siblings; instead they keep their real
|
|
// changed/unchanged verdict (see
|
|
// TestParseDiffDBBackedSourceNotMaskedAsRaced).
|
|
// - Agents that are not plain file-based (no on-disk literal file at all).
|
|
//
|
|
// Detecting either makes the source unreliable, so the caller skips the raced
|
|
// guard entirely. This never masks genuine drift for those agents, while plain
|
|
// file-based agents reading a literal file still get the real race protection.
|
|
func (e *Engine) parseDiffSourceReliableForRaced(
|
|
agent parser.AgentType, sourcePath string,
|
|
) bool {
|
|
// A virtual path carries a recognized "#..." suffix; stripping changes
|
|
// the string only for such paths. The stored file_mtime for these is a
|
|
// per-row/composite value, not trusted as a mid-run race signal. This is
|
|
// the gate that holds the DB-backed providers (Forge/Piebald/Warp and the
|
|
// shared-SQLite families) out of the raced guard now that
|
|
// parseDiffAgentDiscoverable admits them: their virtual paths strip, so
|
|
// they fail closed here and keep their real changed/unchanged verdict.
|
|
if stripVirtualSourceSuffix(sourcePath) != sourcePath {
|
|
return false
|
|
}
|
|
// Only a literal-file agent reads a source whose os.Stat mtime is a
|
|
// per-session race signal that populated file_mtime. FileBased is exactly
|
|
// that predicate, so it is required here even though the discoverability
|
|
// gate no longer consults it: a DB-backed agent (FileBased == false) is
|
|
// never reliable for the raced guard. Its real sources are virtual and were
|
|
// gated out above; requiring FileBased also fails closed on the impossible
|
|
// non-virtual DB path rather than trusting a shared store's composite mtime.
|
|
def, ok := parser.AgentByType(agent)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return def.FileBased && e.parseDiffAgentDiscoverable(def)
|
|
}
|
|
|
|
// parseDiffLiveMtime resolves a session's live source mtime for the raced
|
|
// guard, re-stat'd at collect time (after the worker finished reading) and
|
|
// recomputing the agent-aware mtime that is safe to use as a per-session race
|
|
// signal. Computing it now -- rather than trusting the parser's pre-read
|
|
// File.Mtime -- catches a sibling-file write that landed after the parser's own
|
|
// stat but before classification when that sibling is session-specific:
|
|
//
|
|
// - Codex deliberately uses the transcript mtime only. Its
|
|
// session_index.jsonl is global to every Codex session, so an unrelated
|
|
// title/index write must not mask transcript-derived parser drift.
|
|
// - OpenHands folds base_state.json/TASKS.json/events/* (OpenHandsSnapshot).
|
|
// - Copilot folds workspace.yaml (copilotEffectiveMtime).
|
|
//
|
|
// Every other reliable agent is handled by discoveredFileMtime, which already
|
|
// recomputes their effective mtime at stat time (Cowork/CommandCode/Vibe/
|
|
// Reasonix/Antigravity/...). Only literal-file sources reach here -- virtual
|
|
// and DB-backed sources are gated out by parseDiffSourceReliableForRaced -- so
|
|
// the OpenCode-format storage children (virtual "#rawID" paths) never apply.
|
|
func parseDiffLiveMtime(
|
|
agent parser.AgentType, path string,
|
|
) (int64, error) {
|
|
switch agent {
|
|
case parser.AgentCodex:
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return info.ModTime().UnixNano(), nil
|
|
case parser.AgentOpenHands:
|
|
snapshot, err := parser.OpenHandsSnapshot(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return snapshot.Mtime, nil
|
|
case parser.AgentCopilot:
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return copilotEffectiveMtime(path, info), nil
|
|
}
|
|
return discoveredFileMtime(parser.DiscoveredFile{
|
|
Path: path, Agent: agent,
|
|
})
|
|
}
|
|
|
|
// parseDiffCodexTranscriptChangedSinceStored reports whether the Codex
|
|
// transcript differs from the archived source snapshot on a size basis Codex
|
|
// has historically stored. Full parses store the raw file size, while
|
|
// incremental parses can store only the parser-consumed JSONL boundary when a
|
|
// partial line trails the file. Codex rows persist file_mtime on an
|
|
// index-folded basis so title changes can invalidate sync caches, but
|
|
// parse-diff's raced guard uses transcript-only live mtimes to avoid letting the
|
|
// global session_index.jsonl mask unrelated parser drift. When the index-folded
|
|
// stored mtime is newer than a later transcript append, the mtime comparison
|
|
// alone cannot prove the write; the live raw size or parser-consumed JSONL
|
|
// boundary can.
|
|
func parseDiffCodexTranscriptChangedSinceStored(
|
|
stored *db.Session, parsed parser.ParsedSession,
|
|
) bool {
|
|
if stored == nil || parsed.Agent != parser.AgentCodex {
|
|
return false
|
|
}
|
|
if stored.FileSize == nil {
|
|
return false
|
|
}
|
|
|
|
storedSize := *stored.FileSize
|
|
info, err := os.Stat(parsed.File.Path)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
if info.Size() == storedSize {
|
|
return false
|
|
}
|
|
|
|
consumedSize, err := parser.CodexTranscriptConsumedSize(parsed.File.Path)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return consumedSize != storedSize
|
|
}
|
|
|
|
// parseDiffCollectFile folds one worker result into the report.
|
|
func (e *Engine) parseDiffCollectFile(
|
|
ctx context.Context,
|
|
report *ParseDiffReport,
|
|
job syncJob,
|
|
fileAgents map[string]parser.AgentType,
|
|
storedByID map[string]*db.Session,
|
|
storedByPath map[string][]*db.Session,
|
|
visited map[string]bool,
|
|
resolver worktreeProjectResolver,
|
|
presencePaths *[]string,
|
|
) error {
|
|
base := parseDiffSourceKey(job.path)
|
|
|
|
if job.err != nil {
|
|
storedHere := storedByPath[base]
|
|
if len(storedHere) == 0 {
|
|
report.Sessions = append(report.Sessions, SessionDiff{
|
|
Agent: string(fileAgents[job.path]),
|
|
FilePath: job.path,
|
|
Class: DiffParseError,
|
|
Reason: job.err.Error(),
|
|
})
|
|
report.Totals.ParseErrors++
|
|
return nil
|
|
}
|
|
for _, s := range storedHere {
|
|
visited[s.ID] = true
|
|
report.Sessions = append(report.Sessions, SessionDiff{
|
|
SessionID: s.ID,
|
|
Agent: s.Agent,
|
|
FilePath: job.path,
|
|
Class: DiffParseError,
|
|
Reason: job.err.Error(),
|
|
StoredDataVersion: s.DataVersion,
|
|
})
|
|
report.Totals.ParseErrors++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Count how many parsed sessions in this job map to each source path. A
|
|
// source shared by more than one session -- e.g. Hermes' state.db, which
|
|
// fans out to every session in the archive under one literal path -- has
|
|
// an mtime that is not a per-session signal: touching it for one session
|
|
// would race-mask genuine drift on its siblings. Those are skipped by the
|
|
// raced guard below (count > 1) and keep their real changed/unchanged
|
|
// verdict, failing closed the same way virtual fan-out sources do.
|
|
sourceSessionCount := make(map[string]int, len(job.results))
|
|
for _, pr := range job.results {
|
|
sourceSessionCount[pr.Session.File.Path]++
|
|
}
|
|
|
|
for _, pr := range job.results {
|
|
pw := pendingWrite{
|
|
sess: pr.Session,
|
|
msgs: pr.Messages,
|
|
usageEvents: pr.UsageEvents,
|
|
needsRetry: job.needsRetryForSession(pr.Session.ID),
|
|
}
|
|
prepared, msgs, verdict := e.prepareSessionWrite(pw, resolver)
|
|
id := prepared.ID
|
|
if verdict != sessionWriteOK {
|
|
// prepareSessionWrite returns a zero session on veto;
|
|
// reconstruct the final ID the way applyRemoteRewrites
|
|
// would have.
|
|
id = e.idPrefix + pw.sess.ID
|
|
}
|
|
stored := storedByID[id]
|
|
if stored != nil {
|
|
visited[stored.ID] = true
|
|
}
|
|
|
|
var fields []FieldDiff
|
|
compare := verdict == sessionWriteOK && !pw.needsRetry &&
|
|
stored != nil && stored.DeletedAt == nil
|
|
if compare {
|
|
events, _ := toDBUsageEvents(id, pw.usageEvents)
|
|
var err error
|
|
fields, err = e.compareStoredSession(
|
|
ctx, stored, prepared, msgs, events,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
realDiffs := 0
|
|
for _, f := range fields {
|
|
if !f.Informational {
|
|
realDiffs++
|
|
}
|
|
}
|
|
|
|
// Only consult the skew guard when there is a real change to
|
|
// reclassify; an identical or pending-resync session is unaffected
|
|
// by a mid-run write. A nil stored row leaves storedMtime nil.
|
|
//
|
|
// The live mtime is parseDiffLiveMtime, re-computed NOW after
|
|
// the worker finished reading, using only mtimes that are safe
|
|
// per-session race signals. Re-stat'ing at collect time catches
|
|
// session-specific sibling-file writes such as OpenHands'
|
|
// events/*.json and Copilot's workspace.yaml that landed after
|
|
// the parser's own stat but before classification; Codex uses
|
|
// the transcript mtime only because its session_index.jsonl is a
|
|
// global file shared by every Codex session. For other agents,
|
|
// the value is floored by the parsed File.Mtime so a source
|
|
// rewritten with an older mtime mid-run still reads as raced;
|
|
// Codex skips that floor because the parsed File.Mtime also folds
|
|
// in the shared index. If that same index-folded stored mtime hides
|
|
// a newer transcript mtime, Codex falls back to the transcript
|
|
// hash/size fingerprint to prove the source file changed. The
|
|
// guard runs only for
|
|
// reliable, literal-file sources (see
|
|
// parseDiffSourceReliableForRaced); virtual and DB-backed sources
|
|
// are gated out and keep their real changed/unchanged verdict. A
|
|
// re-stat error -- the source was replaced/removed mid-run -- is
|
|
// treated as unreadable -> raced, matching the conservative
|
|
// re-stat policy.
|
|
raced := false
|
|
if realDiffs > 0 && compare &&
|
|
sourceSessionCount[pw.sess.File.Path] == 1 &&
|
|
e.parseDiffSourceReliableForRaced(pw.sess.Agent, pw.sess.File.Path) {
|
|
var storedMtime *int64
|
|
if stored != nil {
|
|
storedMtime = stored.FileMtime
|
|
}
|
|
liveMtime, err := parseDiffLiveMtime(
|
|
pw.sess.Agent, pw.sess.File.Path,
|
|
)
|
|
liveOK := err == nil
|
|
if liveOK && pw.sess.Agent != parser.AgentCodex &&
|
|
pw.sess.File.Mtime > liveMtime {
|
|
liveMtime = pw.sess.File.Mtime
|
|
}
|
|
raced = parseDiffSourceRaced(
|
|
storedMtime, liveMtime, liveOK,
|
|
)
|
|
if !raced && liveOK &&
|
|
parseDiffCodexTranscriptChangedSinceStored(stored, pw.sess) {
|
|
raced = true
|
|
}
|
|
}
|
|
|
|
// Incremental-append skew: the stored row was last written by the
|
|
// incremental-append path (last_write_incremental), so a full
|
|
// re-parse legitimately differs on the ordinal-shape / per-message
|
|
// metadata surface. Only meaningful when there is a real change to
|
|
// reclassify, and only when the session was actually compared (a nil
|
|
// stored row cannot carry the flag). Ranked below raced -- if the
|
|
// source also demonstrably moved past its snapshot, the mtime race
|
|
// is the stronger, directly provable diagnosis -- so this is gated
|
|
// on !raced. Unlike the raced guard this needs no agent/source
|
|
// reliability check: the flag is per-session ground truth recorded
|
|
// by the exact write path being diagnosed, so it generalizes to
|
|
// whatever provider took the incremental path rather than a
|
|
// hardcoded agent list.
|
|
//
|
|
// The reclassification is confined to the incremental-artifact
|
|
// fields (diffsConfinedToIncrementalArtifacts): a marker alone is
|
|
// not enough, because it is session-level while a regression is
|
|
// field-level. A non-informational diff outside that allow-list
|
|
// (first_message, message_content, usage totals, tool_calls, ...) is
|
|
// genuine parser drift that must stay DiffChanged even on an
|
|
// incrementally written row, so the marker cannot mask it.
|
|
incrementalSkew := realDiffs > 0 && compare && !raced &&
|
|
stored != nil && stored.LastWriteIncremental &&
|
|
diffsConfinedToIncrementalArtifacts(fields)
|
|
|
|
class, reason := classifyParseDiffSession(
|
|
pw.needsRetry,
|
|
verdict == sessionWriteOK,
|
|
stored != nil,
|
|
stored != nil && stored.DeletedAt != nil,
|
|
stored != nil && stored.DataVersion < db.CurrentDataVersion(),
|
|
realDiffs,
|
|
raced,
|
|
incrementalSkew,
|
|
)
|
|
|
|
entry := SessionDiff{
|
|
SessionID: id,
|
|
Agent: string(pw.sess.Agent),
|
|
FilePath: pw.sess.File.Path,
|
|
Class: class,
|
|
Reason: reason,
|
|
Fields: fields,
|
|
}
|
|
if stored != nil {
|
|
entry.StoredDataVersion = stored.DataVersion
|
|
}
|
|
|
|
switch class {
|
|
case DiffNeedsRetry:
|
|
report.Totals.NeedsRetry++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffExcluded:
|
|
report.Totals.ExcludedByParser++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffNewOnDisk:
|
|
report.Totals.NewOnDisk++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffPendingResync:
|
|
// Field diffs are attached for drill-down but never
|
|
// counted as parser drift (FieldCounts excluded).
|
|
report.Totals.PendingResync++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffChanged:
|
|
report.Totals.Changed++
|
|
for _, f := range fields {
|
|
if !f.Informational {
|
|
report.FieldCounts[f.Field]++
|
|
}
|
|
}
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffRaced:
|
|
// Inconclusive (live-write skew): listed for visibility with
|
|
// its field diffs attached for drill-down, but excluded from
|
|
// FieldCounts and from HasFailures so --fail-on-change stays
|
|
// trustworthy.
|
|
report.Totals.Raced++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffIncrementalSkew:
|
|
// Inconclusive (incremental-vs-full skew): listed for
|
|
// visibility with its field diffs attached for drill-down, but
|
|
// excluded from FieldCounts and from HasFailures so
|
|
// --fail-on-change stays trustworthy, exactly like DiffRaced.
|
|
report.Totals.IncrementalSkew++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffSkipped:
|
|
// A re-parsed but trashed session: counted with the rest
|
|
// of the skipped (not-re-parsed) trashed rows.
|
|
report.Totals.Skipped++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
case DiffIdentical:
|
|
report.Totals.Identical++
|
|
if len(fields) > 0 {
|
|
// Informational-only: counted identical, but
|
|
// listed so the explanation is visible.
|
|
report.Totals.InformationalOnly++
|
|
report.Sessions = append(report.Sessions, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Per-session parse failures inside a shared SQLite store: the db
|
|
// itself opened fine, so job.err is nil, but individual sessions
|
|
// could not be parsed. Each becomes a DiffParseError; matching the
|
|
// stored row by its virtual source path marks it visited so the
|
|
// presence sweep does not double-report it as "not emitted".
|
|
for _, se := range job.sessionErrs {
|
|
entry := SessionDiff{
|
|
Agent: string(fileAgents[job.path]),
|
|
FilePath: se.virtualPath,
|
|
Class: DiffParseError,
|
|
Reason: se.err.Error(),
|
|
}
|
|
if entry.FilePath == "" {
|
|
entry.FilePath = job.path
|
|
}
|
|
for _, s := range storedByPath[base] {
|
|
if derefString(s.FilePath) == se.virtualPath {
|
|
visited[s.ID] = true
|
|
entry.SessionID = s.ID
|
|
entry.Agent = s.Agent
|
|
entry.StoredDataVersion = s.DataVersion
|
|
break
|
|
}
|
|
}
|
|
report.Sessions = append(report.Sessions, entry)
|
|
report.Totals.ParseErrors++
|
|
}
|
|
|
|
for _, exID := range e.applyIDPrefixToSessionIDs(
|
|
job.excludedSessionIDs,
|
|
) {
|
|
stored := storedByID[exID]
|
|
if stored == nil {
|
|
continue
|
|
}
|
|
visited[stored.ID] = true
|
|
report.Sessions = append(report.Sessions, SessionDiff{
|
|
SessionID: stored.ID,
|
|
Agent: stored.Agent,
|
|
FilePath: job.path,
|
|
Class: DiffExcluded,
|
|
Reason: "parser exclusion (would delete)",
|
|
StoredDataVersion: stored.DataVersion,
|
|
})
|
|
report.Totals.ExcludedByParser++
|
|
}
|
|
|
|
// Legacy source-wide needsRetry output and incomplete provider
|
|
// result sets are transient and low fidelity; missing sessions
|
|
// there are expected, not parser drift. Provider per-session
|
|
// retry state is handled above and should not hide unrelated
|
|
// missing sessions from the same complete source.
|
|
if !job.suppressesPresenceSweepForRetry() && !job.suppressPresenceSweep {
|
|
*presencePaths = append(*presencePaths, base)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseDiffPresenceSweep flags stored sessions whose source file
|
|
// parsed cleanly but no longer emits them. Runs after every file has
|
|
// been collected so a session re-emitted from a different file (by
|
|
// ID) is never falsely reported missing.
|
|
func (e *Engine) parseDiffPresenceSweep(
|
|
report *ParseDiffReport,
|
|
presencePaths []string,
|
|
storedByPath map[string][]*db.Session,
|
|
visited map[string]bool,
|
|
) {
|
|
for _, base := range presencePaths {
|
|
for _, s := range storedByPath[base] {
|
|
if visited[s.ID] {
|
|
continue
|
|
}
|
|
if s.DeletedAt != nil {
|
|
// Trashed rows are intentionally absent; the final
|
|
// sweep reports them as skipped/trashed.
|
|
continue
|
|
}
|
|
visited[s.ID] = true
|
|
diff := SessionDiff{
|
|
SessionID: s.ID,
|
|
Agent: s.Agent,
|
|
FilePath: derefString(s.FilePath),
|
|
Class: DiffChanged,
|
|
StoredDataVersion: s.DataVersion,
|
|
Fields: []FieldDiff{{
|
|
Field: FieldPresence,
|
|
Stored: "stored",
|
|
Parsed: "(not emitted)",
|
|
}},
|
|
}
|
|
// A row below the current data version is pipeline
|
|
// history, not drift: incomplete writes (data_version 0)
|
|
// and orphan-copied rows from earlier resyncs commonly
|
|
// survive in the archive under IDs the current parser no
|
|
// longer derives. Only a current-version row that vanished
|
|
// from its file's parse output is a real presence change.
|
|
if s.DataVersion < db.CurrentDataVersion() {
|
|
diff.Class = DiffPendingResync
|
|
diff.Reason = "stale row; parser no longer emits this ID"
|
|
report.Sessions = append(report.Sessions, diff)
|
|
report.Totals.PendingResync++
|
|
continue
|
|
}
|
|
report.Sessions = append(report.Sessions, diff)
|
|
report.Totals.Changed++
|
|
report.FieldCounts[FieldPresence]++
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseDiffSweepStored classifies every stored session that no
|
|
// re-parsed file accounted for. With an explicit agent filter the
|
|
// sweep is restricted to those agents; an unrestricted run accounts
|
|
// for every stored session, including import-only and
|
|
// database-backed agents.
|
|
func (e *Engine) parseDiffSweepStored(
|
|
report *ParseDiffReport,
|
|
storedSessions []db.Session,
|
|
resolvedSet map[parser.AgentType]bool,
|
|
unrestricted bool,
|
|
cutPaths map[string]bool,
|
|
visited map[string]bool,
|
|
) {
|
|
for i := range storedSessions {
|
|
s := &storedSessions[i]
|
|
if visited[s.ID] {
|
|
continue
|
|
}
|
|
def, defOK := parser.AgentByPrefix(s.ID)
|
|
if !unrestricted && (!defOK || !resolvedSet[def.Type]) {
|
|
continue
|
|
}
|
|
host, _ := parser.StripHostPrefix(s.ID)
|
|
|
|
var reason string
|
|
switch {
|
|
case host != "":
|
|
reason = "remote session"
|
|
case s.DeletedAt != nil:
|
|
reason = "trashed"
|
|
case defOK && !eParseDiffAgentHasProviderSource(def) &&
|
|
def.EnvVar == "" && def.ConfigKey == "":
|
|
reason = "import-only agent"
|
|
case defOK && !def.FileBased && !e.parseDiffAgentDiscoverable(def):
|
|
// A DB-backed agent parse-diff cannot re-parse (no provider
|
|
// factory). Provider-authoritative DB-backed agents
|
|
// (Forge/Devin/Piebald/Warp) are discoverable, so they fall
|
|
// through to the source-based reasons below (not sampled /
|
|
// source missing / not discovered) just like literal-file
|
|
// agents.
|
|
reason = "database-backed agent"
|
|
case s.FilePath == nil || *s.FilePath == "":
|
|
reason = "source missing"
|
|
default:
|
|
sourceKey := parseDiffSourceKey(*s.FilePath)
|
|
sourcePath := stripVirtualSourceSuffix(*s.FilePath)
|
|
switch {
|
|
case cutPaths[sourceKey]:
|
|
reason = "not sampled (--limit)"
|
|
case !statExists(sourcePath):
|
|
reason = "source missing"
|
|
default:
|
|
reason = "not discovered"
|
|
}
|
|
}
|
|
|
|
report.Sessions = append(report.Sessions, SessionDiff{
|
|
SessionID: s.ID,
|
|
Agent: s.Agent,
|
|
FilePath: derefString(s.FilePath),
|
|
Class: DiffSkipped,
|
|
Reason: reason,
|
|
StoredDataVersion: s.DataVersion,
|
|
})
|
|
report.Totals.Skipped++
|
|
}
|
|
}
|
|
|
|
func eParseDiffAgentHasProviderSource(def parser.AgentDef) bool {
|
|
switch parser.ProviderMigrationModes()[def.Type] {
|
|
case parser.ProviderMigrationProviderAuthoritative:
|
|
_, ok := parser.ProviderFactoryByType(def.Type)
|
|
return ok
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func statExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|