Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

305 lines
8.8 KiB
Go

package parser
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Vibe stores each session in <root>/session_<YYYYMMDD>_<HHMMSS>_<uuid>/, with a
// messages.jsonl transcript and a sibling meta.json. It is a single-file
// provider: one transcript parses into one session, with a composite fingerprint
// folding in meta.json and a fallback-ID exclusion when meta.json later supplies
// a different session_id. All behavior is wired into the shared single-file base
// via options.
func newVibeProviderFactory(def AgentDef) ProviderFactory {
return NewSingleFileProviderFactory(
def,
vibeProviderCapabilities(),
func(cfg ProviderConfig) singleFileSourceSet {
return NewSingleFileSourceSet(
AgentVibe,
cfg.Roots,
WithFileDiscovery(vibeDiscoverFiles),
WithFileWatchRoots(vibeWatchRoots),
WithFileChangedPathClassifier(vibeClassifyPath),
WithFileLookup(vibeFindFile),
WithFileFingerprint(vibeFingerprintSource),
WithFileParse(vibeParseFile),
)
},
)
}
func vibeDiscoverFiles(root string) []singleFileMatch {
var out []singleFileMatch
for _, path := range discoverVibeSessionPaths(root) {
if match, ok := vibeStrictMatch(root, path); ok {
out = append(out, match)
}
}
return out
}
// discoverVibeSessionPaths finds all Vibe messages.jsonl paths under root.
// Symlinked session directories are followed (matching the watcher), but only
// session_-prefixed directories that hold a regular messages.jsonl qualify.
func discoverVibeSessionPaths(root string) []string {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var paths []string
for _, entry := range entries {
if !isDirOrSymlink(entry, root) {
continue
}
if !isVibeSessionDirName(entry.Name()) {
continue
}
messagesPath := filepath.Join(root, entry.Name(), "messages.jsonl")
if isVibeMessagesFile(messagesPath) {
paths = append(paths, messagesPath)
}
}
return paths
}
func vibeWatchRoots(roots []string) []WatchRoot {
out := make([]WatchRoot, 0, len(roots))
for _, root := range roots {
out = append(out, WatchRoot{
Path: root,
Recursive: true,
IncludeGlobs: []string{"messages.jsonl", "meta.json"},
DebounceKey: string(AgentVibe) + ":sessions:" + root,
})
}
return out
}
// vibeClassifyPath maps a messages.jsonl or meta.json event path to its session
// transcript. Under allowMissing a transcript that does not (yet) exist still
// classifies via the session directory name, so a metadata-only event or a
// deletion still resolves.
func vibeClassifyPath(
root, path string, allowMissing bool,
) (singleFileMatch, bool) {
rel, ok := vibeRelPath(root, path)
if !ok {
return singleFileMatch{}, false
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || !isVibeSessionDirName(parts[0]) {
return singleFileMatch{}, false
}
messagesPath := filepath.Join(filepath.Clean(root), parts[0], "messages.jsonl")
switch parts[1] {
case "messages.jsonl":
if allowMissing {
return vibeMatchFromSessionDir(parts[0], messagesPath)
}
return vibeStrictMatch(root, messagesPath)
case "meta.json":
if allowMissing && !isVibeMessagesFile(messagesPath) {
return vibeMatchFromSessionDir(parts[0], messagesPath)
}
return vibeStrictMatch(root, messagesPath)
default:
return singleFileMatch{}, false
}
}
// vibeStrictMatch requires the messages.jsonl to exist as a regular file under a
// session directory before classifying it.
func vibeStrictMatch(root, path string) (singleFileMatch, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
if !isVibeMessagesFile(path) {
return singleFileMatch{}, false
}
rel, ok := vibeRelPath(root, path)
if !ok {
return singleFileMatch{}, false
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || !isVibeSessionDirName(parts[0]) ||
parts[1] != "messages.jsonl" {
return singleFileMatch{}, false
}
return vibeMatchFromSessionDir(parts[0], path)
}
func vibeMatchFromSessionDir(sessionDir, path string) (singleFileMatch, bool) {
if !isVibeSessionDirName(sessionDir) {
return singleFileMatch{}, false
}
return singleFileMatch{Path: path, ProjectHint: sessionDir}, true
}
func vibeFindFile(root, rawID string) (singleFileMatch, bool) {
path := findVibeSourceFile(root, rawID)
if path == "" {
return singleFileMatch{}, false
}
return vibeStrictMatch(root, path)
}
// findVibeSourceFile locates a Vibe session by ID under root. The ID is the
// session_id from meta.json (a uuid), which usually differs from the session
// directory name, so a direct directory-name path is tried before scanning
// meta.json files.
func findVibeSourceFile(root, sessionID string) string {
if messagesPath := filepath.Join(
root, sessionID, "messages.jsonl",
); isVibeMessagesFile(messagesPath) {
return messagesPath
}
entries, err := os.ReadDir(root)
if err != nil {
return ""
}
for _, entry := range entries {
if !isDirOrSymlink(entry, root) ||
!strings.HasPrefix(entry.Name(), "session_") {
continue
}
messagesPath := filepath.Join(root, entry.Name(), "messages.jsonl")
if !isVibeMessagesFile(messagesPath) {
continue
}
metaPath := filepath.Join(root, entry.Name(), "meta.json")
if meta, err := parseVibeMetadata(metaPath); err == nil &&
meta.SessionID == sessionID {
return messagesPath
}
}
return ""
}
// isVibeMessagesFile reports whether path is an existing regular file.
func isVibeMessagesFile(path string) bool {
info, err := os.Stat(path)
if err != nil || info == nil {
return false
}
return !info.IsDir()
}
func vibeFingerprintSource(src singleFileSource) (SourceFingerprint, error) {
info, err := os.Stat(src.Path)
if err != nil {
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.Path, err)
}
if info.IsDir() {
return SourceFingerprint{}, fmt.Errorf(
"stat %s: source is a directory", src.Path,
)
}
size := info.Size()
mtime := info.ModTime().UnixNano()
metaPath := vibeMetaPath(src.Path)
if metaInfo, err := os.Stat(metaPath); err == nil {
size += metaInfo.Size()
if metaMTime := metaInfo.ModTime().UnixNano(); metaMTime > mtime {
mtime = metaMTime
}
}
hash, err := hashJSONLSourceFile(src.Path)
if err != nil {
return SourceFingerprint{}, err
}
return SourceFingerprint{
Size: size,
MTimeNS: mtime,
Hash: hash,
}, nil
}
func vibeParseFile(
src singleFileSource, req ParseRequest,
) ([]ParseResult, []string, error) {
sess, msgs, usageEvents, err := parseVibeSession(src.Path, "", req.Machine)
if err != nil {
return nil, nil, err
}
if sess == nil {
return nil, nil, nil
}
if req.Fingerprint.Size > 0 {
sess.File.Size = req.Fingerprint.Size
}
if req.Fingerprint.MTimeNS > 0 {
sess.File.Mtime = req.Fingerprint.MTimeNS
}
if req.Fingerprint.Hash != "" {
sess.File.Hash = req.Fingerprint.Hash
}
excluded := vibeProviderExcludedSessionIDs(src.Path, sess.ID)
return []ParseResult{{
Session: *sess,
Messages: msgs,
UsageEvents: usageEvents,
}}, excluded, nil
}
func vibeRelPath(root, path string) (string, bool) {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil || rel == "." || rel == "" {
return "", false
}
if strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return "", false
}
for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
if part == "" || part == "." || part == ".." {
return "", false
}
}
return rel, true
}
func isVibeSessionDirName(name string) bool {
return strings.HasPrefix(name, "session_") && strings.Contains(name, "_")
}
func vibeMetaPath(messagesPath string) string {
return filepath.Join(filepath.Dir(messagesPath), "meta.json")
}
func vibeProviderExcludedSessionIDs(path, currentID string) []string {
fallbackID := string(AgentVibe) + ":" + filepath.Base(filepath.Dir(path))
if currentID == "" || currentID == fallbackID {
return nil
}
return []string{fallbackID}
}
func vibeProviderCapabilities() Capabilities {
return Capabilities{
Source: SourceCapabilities{
DiscoverSources: CapabilitySupported,
WatchSources: CapabilitySupported,
ClassifyChangedPath: CapabilitySupported,
FindSource: CapabilitySupported,
CompositeFingerprint: CapabilitySupported,
MultiSessionSource: CapabilityNotApplicable,
PerSessionErrors: CapabilityNotApplicable,
ExcludedSessions: CapabilitySupported,
ForceReplaceOnParse: CapabilityNotApplicable,
},
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
SessionName: CapabilitySupported,
Cwd: CapabilitySupported,
GitBranch: CapabilitySupported,
Relationships: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
AggregateUsageEvents: CapabilitySupported,
Model: CapabilitySupported,
},
}
}