f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
942 lines
27 KiB
Go
942 lines
27 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// JSONLSource is the in-memory payload JSONLSourceSet stores in SourceRef.
|
|
type JSONLSource struct {
|
|
Root string
|
|
Path string
|
|
RelPath string
|
|
}
|
|
|
|
// JSONLSourceSetOptions configures the reusable JSONL source helper.
|
|
type JSONLSourceSetOptions struct {
|
|
// Recursive enables traversal and changed-path classification below each
|
|
// configured root. When false, only direct child files are sources.
|
|
Recursive bool
|
|
// Extensions defaults to .jsonl. Matching is case-sensitive to mirror
|
|
// legacy parser discovery.
|
|
Extensions []string
|
|
// Hash includes a full content hash in SourceFingerprint. Providers should
|
|
// leave this false unless size/mtime freshness is insufficient.
|
|
Hash bool
|
|
// FollowSymlinkDirs treats symlinks to directories as directories while
|
|
// discovering recursive roots. Providers should enable it only when legacy
|
|
// discovery followed symlinked session directories; targets may be outside
|
|
// the configured root, so provider IncludePath filters should constrain the
|
|
// accepted source shape when that matters.
|
|
FollowSymlinkDirs bool
|
|
// FollowSymlinkFiles treats symlinks to regular files as sources. Providers
|
|
// should enable it when legacy discovery accepted matching symlinked files
|
|
// and the parser reads through the symlink target.
|
|
FollowSymlinkFiles bool
|
|
// DescendPath is a directory predicate for recursive discovery. It is also
|
|
// applied to source ancestors during direct source classification so
|
|
// changed-path events cannot accept paths discovery would have pruned.
|
|
DescendPath func(root, path string) bool
|
|
// IncludePath is a path-only source predicate. It runs before Include and is
|
|
// also used for deleted/renamed changed paths where os.FileInfo is
|
|
// unavailable.
|
|
IncludePath func(root, path string) bool
|
|
// Include is a source predicate for existing files. It is not called for
|
|
// deleted/renamed changed paths.
|
|
Include func(path string, info os.FileInfo) bool
|
|
// Key must be stable across process restarts and unique within a provider
|
|
// when every physical source should be parsed. If duplicates exist,
|
|
// discovery keeps the first configured root/traversal result.
|
|
Key func(root, path string) string
|
|
// FingerprintKey is the persisted lookup and freshness identity. When unset,
|
|
// it falls back to the display path (the source's relative-or-absolute path).
|
|
FingerprintKey func(root, path string) string
|
|
// ProjectHint is display metadata only.
|
|
ProjectHint func(root, path string) string
|
|
// SessionIDFromPath returns the raw session ID used by FindSource fallback
|
|
// lookups. It should not include the provider ID prefix.
|
|
SessionIDFromPath func(root, path string) string
|
|
// LookupIDValid reports whether a raw session ID is shaped like an ID this
|
|
// provider could resolve, gating the FindSource discovery fallback. It
|
|
// defaults to IsValidSessionID. Providers whose SessionIDFromPath emits
|
|
// composite IDs (for example subagent IDs containing separators that
|
|
// IsValidSessionID rejects) supply their own validator so those lookups are
|
|
// not dropped before the comparison loop.
|
|
LookupIDValid func(rawID string) bool
|
|
// RawSessionIDForLookup normalizes a raw session ID before the FindSource
|
|
// discovery comparison. Providers whose stored IDs carry a suffix the
|
|
// discovered filename stem lacks (for example iFlow subagent IDs) reduce it
|
|
// to the base ID here so the comparison still matches. It runs after
|
|
// ProviderFindRequestWithRawSessionID and before the LookupIDValid gate.
|
|
RawSessionIDForLookup func(rawID string) string
|
|
// RawSessionIDSourceFiles reconstructs candidate file paths from a raw
|
|
// session ID for providers whose IDs encode the on-disk layout rather than
|
|
// being a discoverable filename stem. FindSource resolves each candidate
|
|
// through the same path->SourceRef machinery as a stored path and returns
|
|
// the first that exists, before falling through to the discovery scan. The
|
|
// closure iterates the provided roots itself and applies its own ID
|
|
// validation.
|
|
RawSessionIDSourceFiles func(roots []string, rawID string) []string
|
|
// StoredPathFallbackRoot resolves the configured root for a stored source
|
|
// path that is not under any current root, returning false to decline. It
|
|
// lets a provider honor a DB-recorded file_path whose root was removed or
|
|
// was a custom location by reconstructing the implicit root so the path
|
|
// still resolves to a SourceRef. FindSource consults it after the in-root
|
|
// path lookup misses.
|
|
StoredPathFallbackRoot func(storedPath string) (string, bool)
|
|
// ParseFile parses one discovered source file into zero or more sessions
|
|
// plus the IDs of any sessions to exclude. Empty results with no exclusions
|
|
// is a clean no-session. It is what makes JSONLSourceSet a full SourceSet
|
|
// (its Parse method); leave it nil for discovery-only embedders that supply
|
|
// their own Parse. ctx and req.Machine are supplied by SourceSetProvider.
|
|
ParseFile jsonlParseFileFunc
|
|
// ForceReplace marks every non-empty parse outcome from ParseFile as a full
|
|
// replacement of the source's existing sessions, for providers whose
|
|
// transcripts are rewritten wholesale rather than appended.
|
|
ForceReplace bool
|
|
// CompanionFiles returns the sidecar files that belong to a transcript
|
|
// source, given the transcript's path. The base folds each existing
|
|
// companion's basename into the watch plan globs, its size/mtime (and hash
|
|
// when Hash is set) into the SourceFingerprint, and maps a changed companion
|
|
// path back to its owning transcript in SourcesForChangedPath. It reuses the
|
|
// sibling-metadata helpers rather than introducing a separate mechanism, so
|
|
// providers describe companions once as transcript->companions and the base
|
|
// drives watch, freshness, and changed-path mapping from that single hook.
|
|
CompanionFiles func(transcriptPath string) []string
|
|
}
|
|
|
|
// JSONLSourceSet discovers, watches, locates, and fingerprints JSONL-like
|
|
// transcript files. With a ParseFile option it is also a full SourceSet;
|
|
// without one it is a discovery helper that providers compose as a named field
|
|
// and forward the methods they support. Missing or unreadable roots and
|
|
// subdirectories are treated as empty, matching legacy discovery's lenient
|
|
// local-filesystem behavior.
|
|
type JSONLSourceSet struct {
|
|
provider AgentType
|
|
roots []string
|
|
options JSONLSourceSetOptions
|
|
extensions []string
|
|
}
|
|
|
|
// NewJSONLSourceSet builds a JSONL source set for a provider's roots from
|
|
// functional options. Every option has a zero-value default, so callers state
|
|
// only what differs.
|
|
func NewJSONLSourceSet(
|
|
provider AgentType,
|
|
roots []string,
|
|
opts ...JSONLOption,
|
|
) JSONLSourceSet {
|
|
var options JSONLSourceSetOptions
|
|
for _, opt := range opts {
|
|
opt(&options)
|
|
}
|
|
return jsonlSourceSetFromOptions(provider, roots, options)
|
|
}
|
|
|
|
// jsonlSourceSetFromOptions is the shared constructor used by NewJSONLSourceSet
|
|
// and NewDirectoryJSONLSourceSet once options have been resolved.
|
|
func jsonlSourceSetFromOptions(
|
|
provider AgentType,
|
|
roots []string,
|
|
options JSONLSourceSetOptions,
|
|
) JSONLSourceSet {
|
|
return JSONLSourceSet{
|
|
provider: provider,
|
|
roots: cleanJSONLRoots(roots),
|
|
options: options,
|
|
extensions: normalizeJSONLExtensions(options.Extensions),
|
|
}
|
|
}
|
|
|
|
// Discover returns stable, deduped source references for configured roots.
|
|
func (s JSONLSourceSet) Discover(ctx context.Context) ([]SourceRef, error) {
|
|
var sources []SourceRef
|
|
seen := make(map[string]struct{})
|
|
for _, root := range s.roots {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(root)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
if err := s.discoverDir(ctx, root, root, &sources, seen); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sortJSONLSources(sources)
|
|
return sources, nil
|
|
}
|
|
|
|
// WatchPlan returns one watch root for each configured JSONL root. When a
|
|
// CompanionFiles hook is configured, each discovered transcript's companion
|
|
// basenames are added to every root's include globs so sidecar events are not
|
|
// filtered out before SourcesForChangedPath can map them back.
|
|
func (s JSONLSourceSet) WatchPlan(ctx context.Context) (WatchPlan, error) {
|
|
roots := make([]WatchRoot, 0, len(s.roots))
|
|
globs := s.includeGlobs()
|
|
companionGlobs, err := s.companionGlobs(ctx)
|
|
if err != nil {
|
|
return WatchPlan{}, err
|
|
}
|
|
for _, root := range s.roots {
|
|
includeGlobs := append([]string(nil), globs...)
|
|
includeGlobs = append(includeGlobs, companionGlobs...)
|
|
roots = append(roots, WatchRoot{
|
|
Path: root,
|
|
Recursive: s.options.Recursive,
|
|
IncludeGlobs: includeGlobs,
|
|
DebounceKey: string(s.provider) + ":jsonl:" + root,
|
|
})
|
|
}
|
|
return WatchPlan{Roots: roots}, nil
|
|
}
|
|
|
|
// companionGlobs enumerates the distinct sidecar basenames across all
|
|
// discovered transcripts so they can be added to every root's include globs.
|
|
func (s JSONLSourceSet) companionGlobs(ctx context.Context) ([]string, error) {
|
|
if s.options.CompanionFiles == nil {
|
|
return nil, nil
|
|
}
|
|
sources, err := s.Discover(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seen := make(map[string]struct{})
|
|
var globs []string
|
|
for _, source := range sources {
|
|
src, ok := source.Opaque.(JSONLSource)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, companion := range s.options.CompanionFiles(src.Path) {
|
|
base := filepath.Base(companion)
|
|
if base == "" || base == "." {
|
|
continue
|
|
}
|
|
if _, ok := seen[base]; ok {
|
|
continue
|
|
}
|
|
seen[base] = struct{}{}
|
|
globs = append(globs, base)
|
|
}
|
|
}
|
|
sort.Strings(globs)
|
|
return globs, nil
|
|
}
|
|
|
|
// SourcesForChangedPath maps a filesystem event path back to JSONL sources.
|
|
func (s JSONLSourceSet) SourcesForChangedPath(
|
|
ctx context.Context,
|
|
req ChangedPathRequest,
|
|
) ([]SourceRef, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
source, ok, err := s.sourceForPath(ctx, req.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
// The changed path is not itself a source. A configured CompanionFiles
|
|
// hook lets an existing sidecar map back to its owning transcript; this
|
|
// runs before the missing-path fallback because a present companion file
|
|
// is not eligible for the tombstone path.
|
|
source, ok, err = s.sourceForCompanionPath(ctx, req.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
if !jsonlMissingPathFallbackAllowed(req) {
|
|
return nil, nil
|
|
}
|
|
source, ok, err = s.sourceForMissingPath(ctx, req.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
}
|
|
}
|
|
if req.WatchRoot != "" {
|
|
root := filepath.Clean(req.WatchRoot)
|
|
src := source.Opaque.(JSONLSource)
|
|
if !samePath(root, src.Root) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
return []SourceRef{source}, nil
|
|
}
|
|
|
|
// FindSource resolves persisted source hints or a raw filename-stem session ID.
|
|
func (s JSONLSourceSet) FindSource(
|
|
ctx context.Context,
|
|
req FindSourceRequest,
|
|
) (SourceRef, bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
for _, stored := range []string{req.StoredFilePath, req.FingerprintKey} {
|
|
if stored == "" {
|
|
continue
|
|
}
|
|
source, ok, err := s.sourceForPath(ctx, stored)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
if ok {
|
|
return source, true, nil
|
|
}
|
|
if s.options.StoredPathFallbackRoot != nil {
|
|
if root, ok := s.options.StoredPathFallbackRoot(stored); ok {
|
|
if source, ok := s.sourceRefFromPath(
|
|
root, filepath.Clean(stored),
|
|
); ok {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if s.options.RawSessionIDForLookup != nil && req.RawSessionID != "" {
|
|
req.RawSessionID = s.options.RawSessionIDForLookup(req.RawSessionID)
|
|
}
|
|
if req.RawSessionID != "" && s.options.RawSessionIDSourceFiles != nil {
|
|
for _, candidate := range s.options.RawSessionIDSourceFiles(
|
|
s.roots, req.RawSessionID,
|
|
) {
|
|
source, ok, err := s.sourceForPath(ctx, candidate)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
if ok {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
}
|
|
validRawID := req.RawSessionID != "" && s.lookupIDValid(req.RawSessionID)
|
|
if req.FingerprintKey == "" && !validRawID {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
sources, err := s.Discover(ctx)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
for _, source := range sources {
|
|
if req.FingerprintKey != "" && source.FingerprintKey == req.FingerprintKey {
|
|
return source, true, nil
|
|
}
|
|
if !validRawID {
|
|
continue
|
|
}
|
|
src := source.Opaque.(JSONLSource)
|
|
if s.sessionID(src.Root, src.Path) == req.RawSessionID {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
return SourceRef{}, false, nil
|
|
}
|
|
|
|
// Fingerprint returns the filesystem freshness identity for a JSONL source.
|
|
func (s JSONLSourceSet) Fingerprint(
|
|
ctx context.Context,
|
|
source SourceRef,
|
|
) (SourceFingerprint, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
path, ok, err := s.pathFromSource(ctx, source)
|
|
if err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
if !ok {
|
|
return SourceFingerprint{}, fmt.Errorf("jsonl source path unavailable")
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
if info.IsDir() {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", path)
|
|
}
|
|
inode, device := sourceFileIdentity(info)
|
|
fingerprint := SourceFingerprint{
|
|
Key: firstNonEmptyJSONLString(
|
|
source.FingerprintKey,
|
|
source.Key,
|
|
path,
|
|
),
|
|
Size: info.Size(),
|
|
MTimeNS: info.ModTime().UnixNano(),
|
|
Inode: inode,
|
|
Device: device,
|
|
}
|
|
if s.options.Hash {
|
|
hash, err := hashJSONLSourceFile(path)
|
|
if err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
fingerprint.Hash = hash
|
|
}
|
|
if err := s.foldCompanionFingerprint(path, &fingerprint); err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
return fingerprint, nil
|
|
}
|
|
|
|
// foldCompanionFingerprint folds each existing companion file's size and mtime
|
|
// into the transcript fingerprint, and when content hashing is enabled mixes the
|
|
// companion contents into the hash. It reuses the sibling-metadata helpers so a
|
|
// companion change is reflected in the source's freshness identity. Missing
|
|
// companions are ignored, matching sibling-metadata behavior.
|
|
func (s JSONLSourceSet) foldCompanionFingerprint(
|
|
transcriptPath string,
|
|
fingerprint *SourceFingerprint,
|
|
) error {
|
|
if s.options.CompanionFiles == nil {
|
|
return nil
|
|
}
|
|
companions := s.options.CompanionFiles(transcriptPath)
|
|
if len(companions) == 0 {
|
|
return nil
|
|
}
|
|
var hasher interface {
|
|
Write([]byte) (int, error)
|
|
Sum([]byte) []byte
|
|
}
|
|
if s.options.Hash {
|
|
h := sha256.New()
|
|
// Seed with the transcript's existing content hash so companion mixing
|
|
// stays anchored to the transcript while preserving its contribution.
|
|
_, _ = io.WriteString(h, fingerprint.Hash)
|
|
hasher = h
|
|
}
|
|
folded := false
|
|
for _, companion := range companions {
|
|
info, err := siblingMetadataFileInfo(companion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info == nil {
|
|
continue
|
|
}
|
|
fingerprint.Size += info.Size()
|
|
if mtime := info.ModTime().UnixNano(); mtime > fingerprint.MTimeNS {
|
|
fingerprint.MTimeNS = mtime
|
|
}
|
|
if hasher != nil {
|
|
if err := addSiblingMetadataFingerprintPart(
|
|
hasher, "companion", companion, info,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
folded = true
|
|
}
|
|
if hasher != nil && folded {
|
|
fingerprint.Hash = fmt.Sprintf("%x", hasher.Sum(nil))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Parse resolves the request's source to a file and parses it via the ParseFile
|
|
// option, making JSONLSourceSet a full SourceSet. It mirrors the single-file
|
|
// base's parse semantics: empty results with no exclusions is a clean
|
|
// no-session skip. SourceSetProvider resolves req.Machine before calling in.
|
|
func (s JSONLSourceSet) Parse(
|
|
ctx context.Context,
|
|
req ParseRequest,
|
|
) (ParseOutcome, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
if s.options.ParseFile == nil {
|
|
return ParseOutcome{}, fmt.Errorf(
|
|
"%s: JSONLSourceSet has no ParseFile configured", s.provider,
|
|
)
|
|
}
|
|
path, ok, err := s.pathFromSource(ctx, req.Source)
|
|
if err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
if !ok {
|
|
return ParseOutcome{}, fmt.Errorf(
|
|
"%s source path unavailable", s.provider,
|
|
)
|
|
}
|
|
results, excluded, err := s.options.ParseFile(ctx, path, req)
|
|
if err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
if len(results) == 0 && len(excluded) == 0 {
|
|
return ParseOutcome{
|
|
ResultSetComplete: true,
|
|
SkipReason: SkipNoSession,
|
|
}, nil
|
|
}
|
|
out := make([]ParseResultOutcome, 0, len(results))
|
|
for i := range results {
|
|
out = append(out, ParseResultOutcome{
|
|
Result: results[i],
|
|
DataVersion: DataVersionCurrent,
|
|
})
|
|
}
|
|
return ParseOutcome{
|
|
Results: out,
|
|
ExcludedSessionIDs: excluded,
|
|
ResultSetComplete: true,
|
|
ForceReplace: s.options.ForceReplace,
|
|
}, nil
|
|
}
|
|
|
|
var (
|
|
_ SourceSet = JSONLSourceSet{}
|
|
_ SourceSet = DirectoryJSONLSourceSet{}
|
|
)
|
|
|
|
func (s JSONLSourceSet) discoverDir(
|
|
ctx context.Context,
|
|
root string,
|
|
dir string,
|
|
sources *[]SourceRef,
|
|
seen map[string]struct{},
|
|
) error {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
return nil
|
|
}
|
|
for _, entry := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
path := filepath.Join(dir, entry.Name())
|
|
if s.shouldDescend(entry, dir) {
|
|
if s.options.Recursive && s.descendPathIncluded(root, path) {
|
|
if err := s.discoverDir(
|
|
ctx, root, path, sources, seen,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
info, err := s.sourceFileInfo(entry, path)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
continue
|
|
}
|
|
source, ok := s.sourceRef(root, path, info)
|
|
if !ok {
|
|
continue
|
|
}
|
|
addJSONLSource(source, sources, seen)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s JSONLSourceSet) shouldDescend(entry os.DirEntry, dir string) bool {
|
|
if entry.IsDir() {
|
|
return true
|
|
}
|
|
return s.options.FollowSymlinkDirs && isDirOrSymlink(entry, dir)
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourceFileInfo(
|
|
entry os.DirEntry,
|
|
path string,
|
|
) (os.FileInfo, error) {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !s.options.FollowSymlinkFiles || info.Mode()&os.ModeSymlink == 0 {
|
|
return info, nil
|
|
}
|
|
return os.Stat(path)
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourceForPath(
|
|
ctx context.Context,
|
|
path string,
|
|
) (SourceRef, bool, error) {
|
|
path = filepath.Clean(path)
|
|
info, err := s.sourcePathInfo(path)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
for _, root := range s.roots {
|
|
if !s.pathAllowedByRoot(root, path) {
|
|
continue
|
|
}
|
|
if !s.sourcePathAllowedByDescendPath(root, path) {
|
|
continue
|
|
}
|
|
if !s.pathIncluded(root, path) {
|
|
continue
|
|
}
|
|
source, ok := s.sourceRef(root, path, info)
|
|
if !ok {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
return s.discoveredSourceForCandidate(ctx, source)
|
|
}
|
|
return SourceRef{}, false, nil
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourcePathInfo(path string) (os.FileInfo, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !s.options.FollowSymlinkFiles || info.Mode()&os.ModeSymlink == 0 {
|
|
return info, nil
|
|
}
|
|
return os.Stat(path)
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourceForMissingPath(
|
|
ctx context.Context,
|
|
path string,
|
|
) (SourceRef, bool, error) {
|
|
path = filepath.Clean(path)
|
|
for _, root := range s.roots {
|
|
if !s.pathAllowedByRoot(root, path) {
|
|
continue
|
|
}
|
|
if !s.sourcePathAllowedByDescendPath(root, path) {
|
|
continue
|
|
}
|
|
if !s.matchesExtension(path) || !s.pathIncluded(root, path) {
|
|
continue
|
|
}
|
|
source, ok := s.sourceRefFromPath(root, path)
|
|
if !ok {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
return s.discoveredSourceForCandidate(ctx, source)
|
|
}
|
|
return SourceRef{}, false, nil
|
|
}
|
|
|
|
// sourceForCompanionPath resolves a changed sidecar path back to the transcript
|
|
// source that owns it. It scans discovered transcripts and returns the one whose
|
|
// CompanionFiles list contains the changed path, so a companion write triggers a
|
|
// re-parse of its transcript.
|
|
func (s JSONLSourceSet) sourceForCompanionPath(
|
|
ctx context.Context,
|
|
path string,
|
|
) (SourceRef, bool, error) {
|
|
if s.options.CompanionFiles == nil {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
path = filepath.Clean(path)
|
|
sources, err := s.Discover(ctx)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
for _, source := range sources {
|
|
src, ok := source.Opaque.(JSONLSource)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, companion := range s.options.CompanionFiles(src.Path) {
|
|
if samePath(companion, path) {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
}
|
|
return SourceRef{}, false, nil
|
|
}
|
|
|
|
func jsonlMissingPathFallbackAllowed(req ChangedPathRequest) bool {
|
|
if req.Path == "" {
|
|
return false
|
|
}
|
|
if _, err := os.Lstat(req.Path); err == nil {
|
|
return false
|
|
} else if os.IsNotExist(err) {
|
|
return true
|
|
}
|
|
switch strings.ToLower(req.EventKind) {
|
|
case "remove", "removed", "delete", "deleted", "rename", "renamed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s JSONLSourceSet) pathAllowedByRoot(root, path string) bool {
|
|
if s.options.Recursive {
|
|
return pathUnderRoot(root, path)
|
|
}
|
|
return samePath(filepath.Dir(path), root)
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourceRef(
|
|
root string,
|
|
path string,
|
|
info os.FileInfo,
|
|
) (SourceRef, bool) {
|
|
if !s.matchesExtension(path) {
|
|
return SourceRef{}, false
|
|
}
|
|
if !s.pathIncluded(root, path) {
|
|
return SourceRef{}, false
|
|
}
|
|
if s.options.Include != nil && !s.options.Include(path, info) {
|
|
return SourceRef{}, false
|
|
}
|
|
return s.sourceRefFromPath(root, path)
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourceRefFromPath(
|
|
root string,
|
|
path string,
|
|
) (SourceRef, bool) {
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return SourceRef{}, false
|
|
}
|
|
// RelPath is a forward-slash key by convention, matching how the rest of
|
|
// the parser builds source keys and display paths. filepath.Rel returns
|
|
// OS-native separators, so normalize here; this is a no-op on Unix and
|
|
// keeps RelPath stable for Windows callers and tests.
|
|
rel = filepath.ToSlash(rel)
|
|
displayPath := path
|
|
fingerprintKey := firstNonEmptyJSONLString(
|
|
callPathFunc(s.options.FingerprintKey, root, path),
|
|
displayPath,
|
|
)
|
|
key := firstNonEmptyJSONLString(
|
|
callPathFunc(s.options.Key, root, path),
|
|
displayPath,
|
|
)
|
|
return SourceRef{
|
|
Provider: s.provider,
|
|
Key: key,
|
|
DisplayPath: displayPath,
|
|
FingerprintKey: fingerprintKey,
|
|
ProjectHint: callPathFunc(s.options.ProjectHint, root, path),
|
|
Opaque: JSONLSource{
|
|
Root: root,
|
|
Path: path,
|
|
RelPath: rel,
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func (s JSONLSourceSet) discoveredSourceForCandidate(
|
|
ctx context.Context,
|
|
candidate SourceRef,
|
|
) (SourceRef, bool, error) {
|
|
discovered, err := s.Discover(ctx)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
for _, source := range discovered {
|
|
if source.Provider == candidate.Provider && source.Key == candidate.Key {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
return candidate, true, nil
|
|
}
|
|
|
|
func (s JSONLSourceSet) pathIncluded(root, path string) bool {
|
|
return s.options.IncludePath == nil || s.options.IncludePath(root, path)
|
|
}
|
|
|
|
func (s JSONLSourceSet) descendPathIncluded(root, path string) bool {
|
|
return s.options.DescendPath == nil || s.options.DescendPath(root, path)
|
|
}
|
|
|
|
func (s JSONLSourceSet) sourcePathAllowedByDescendPath(root, path string) bool {
|
|
if s.options.DescendPath == nil {
|
|
return true
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
dir := filepath.Dir(rel)
|
|
if dir == "." {
|
|
return true
|
|
}
|
|
current := root
|
|
for part := range strings.SplitSeq(dir, string(filepath.Separator)) {
|
|
if part == "" || part == "." || part == ".." {
|
|
return false
|
|
}
|
|
current = filepath.Join(current, part)
|
|
if !s.descendPathIncluded(root, current) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s JSONLSourceSet) matchesExtension(path string) bool {
|
|
ext := filepath.Ext(path)
|
|
return slices.Contains(s.extensions, ext)
|
|
}
|
|
|
|
func (s JSONLSourceSet) includeGlobs() []string {
|
|
globs := make([]string, 0, len(s.extensions))
|
|
for _, ext := range s.extensions {
|
|
globs = append(globs, "*"+ext)
|
|
}
|
|
return globs
|
|
}
|
|
|
|
func (s JSONLSourceSet) sessionID(root, path string) string {
|
|
if s.options.SessionIDFromPath != nil {
|
|
return s.options.SessionIDFromPath(root, path)
|
|
}
|
|
return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
}
|
|
|
|
func (s JSONLSourceSet) lookupIDValid(rawID string) bool {
|
|
if s.options.LookupIDValid != nil {
|
|
return s.options.LookupIDValid(rawID)
|
|
}
|
|
return IsValidSessionID(rawID)
|
|
}
|
|
|
|
func (s JSONLSourceSet) pathFromSource(
|
|
ctx context.Context,
|
|
source SourceRef,
|
|
) (string, bool, error) {
|
|
switch src := source.Opaque.(type) {
|
|
case JSONLSource:
|
|
if src.Path != "" {
|
|
return src.Path, true, nil
|
|
}
|
|
case *JSONLSource:
|
|
if src != nil && src.Path != "" {
|
|
return src.Path, true, nil
|
|
}
|
|
}
|
|
for _, candidate := range []string{
|
|
source.DisplayPath,
|
|
source.FingerprintKey,
|
|
source.Key,
|
|
} {
|
|
if ref, ok, err := s.sourceForPath(ctx, candidate); err != nil {
|
|
return "", false, err
|
|
} else if ok {
|
|
src := ref.Opaque.(JSONLSource)
|
|
return src.Path, true, nil
|
|
}
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func cleanJSONLRoots(roots []string) []string {
|
|
cleaned := make([]string, 0, len(roots))
|
|
for _, root := range roots {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
// Preserve s3:// roots verbatim: filepath.Clean collapses the "//" in the
|
|
// scheme to "s3:/", which breaks the s3:// prefix checks that route
|
|
// discovery to the object store instead of the local filesystem.
|
|
if strings.HasPrefix(root, "s3://") {
|
|
cleaned = append(cleaned, root)
|
|
continue
|
|
}
|
|
cleaned = append(cleaned, filepath.Clean(root))
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func normalizeJSONLExtensions(exts []string) []string {
|
|
if len(exts) == 0 {
|
|
return []string{".jsonl"}
|
|
}
|
|
seen := make(map[string]struct{}, len(exts))
|
|
normalized := make([]string, 0, len(exts))
|
|
for _, ext := range exts {
|
|
if ext == "" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(ext, ".") {
|
|
ext = "." + ext
|
|
}
|
|
if _, ok := seen[ext]; ok {
|
|
continue
|
|
}
|
|
seen[ext] = struct{}{}
|
|
normalized = append(normalized, ext)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return []string{".jsonl"}
|
|
}
|
|
sort.Strings(normalized)
|
|
return normalized
|
|
}
|
|
|
|
func addJSONLSource(
|
|
source SourceRef,
|
|
sources *[]SourceRef,
|
|
seen map[string]struct{},
|
|
) bool {
|
|
key := string(source.Provider) + "\x00" + source.Key
|
|
if _, ok := seen[key]; ok {
|
|
return false
|
|
}
|
|
seen[key] = struct{}{}
|
|
*sources = append(*sources, source)
|
|
return true
|
|
}
|
|
|
|
func sortJSONLSources(sources []SourceRef) {
|
|
sort.Slice(sources, func(i, j int) bool {
|
|
if sources[i].DisplayPath != sources[j].DisplayPath {
|
|
return sources[i].DisplayPath < sources[j].DisplayPath
|
|
}
|
|
return sources[i].Key < sources[j].Key
|
|
})
|
|
}
|
|
|
|
func callPathFunc(fn func(root, path string) string, root, path string) string {
|
|
if fn == nil {
|
|
return ""
|
|
}
|
|
return fn(root, path)
|
|
}
|
|
|
|
func samePath(a, b string) bool {
|
|
return filepath.Clean(a) == filepath.Clean(b)
|
|
}
|
|
|
|
func firstNonEmptyJSONLString(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hashJSONLSourceFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", fmt.Errorf("hash %s: %w", path, err)
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
}
|