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

533 lines
14 KiB
Go

package parser
import (
"context"
"crypto/sha256"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strings"
)
var _ Provider = (*geminiProvider)(nil)
type geminiProviderFactory struct {
def AgentDef
}
func newGeminiProviderFactory(def AgentDef) ProviderFactory {
return geminiProviderFactory{def: cloneAgentDef(def)}
}
func (f geminiProviderFactory) Definition() AgentDef {
return cloneAgentDef(f.def)
}
func (f geminiProviderFactory) Capabilities() Capabilities {
return geminiProviderCapabilities()
}
func (f geminiProviderFactory) NewProvider(cfg ProviderConfig) Provider {
cfg = cfg.Clone()
return &geminiProvider{
ProviderBase: ProviderBase{
Def: cloneAgentDef(f.def),
Caps: geminiProviderCapabilities(),
Config: cfg,
},
sources: newGeminiSourceSet(cfg.Roots),
}
}
type geminiProvider struct {
ProviderBase
sources geminiSourceSet
}
func (p *geminiProvider) Discover(ctx context.Context) ([]SourceRef, error) {
return p.sources.Discover(ctx)
}
func (p *geminiProvider) WatchPlan(ctx context.Context) (WatchPlan, error) {
return p.sources.WatchPlan(ctx)
}
func (p *geminiProvider) SourcesForChangedPath(
ctx context.Context,
req ChangedPathRequest,
) ([]SourceRef, error) {
return p.sources.SourcesForChangedPath(ctx, req)
}
func (p *geminiProvider) FindSource(
ctx context.Context,
req FindSourceRequest,
) (SourceRef, bool, error) {
req = ProviderFindRequestWithRawSessionID(p.Def, req)
return p.sources.FindSource(ctx, req)
}
func (p *geminiProvider) Fingerprint(
ctx context.Context,
source SourceRef,
) (SourceFingerprint, error) {
return p.sources.Fingerprint(ctx, source)
}
func (p *geminiProvider) Parse(
ctx context.Context,
req ParseRequest,
) (ParseOutcome, error) {
if err := ctx.Err(); err != nil {
return ParseOutcome{}, err
}
path, ok := p.sources.pathFromSource(req.Source)
if !ok {
return ParseOutcome{}, fmt.Errorf("gemini source path unavailable")
}
machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine)
sess, msgs, err := p.parseSession(path, req.Source.ProjectHint, machine)
if err != nil {
return ParseOutcome{}, err
}
if sess == nil {
return ParseOutcome{
ResultSetComplete: true,
SkipReason: SkipNoSession,
}, nil
}
if req.Fingerprint.Hash != "" {
sess.File.Hash = req.Fingerprint.Hash
}
return ParseOutcome{
Results: []ParseResultOutcome{{
Result: ParseResult{
Session: *sess,
Messages: msgs,
},
DataVersion: DataVersionCurrent,
}},
ResultSetComplete: true,
}, nil
}
type geminiSource struct {
Root string
Path string
}
type geminiSourceSet struct {
roots []string
}
func newGeminiSourceSet(roots []string) geminiSourceSet {
return geminiSourceSet{roots: cleanJSONLRoots(roots)}
}
func (s geminiSourceSet) Discover(ctx context.Context) ([]SourceRef, error) {
var sources []SourceRef
seen := make(map[string]struct{})
for _, root := range s.roots {
rootSources, err := s.discoverRoot(ctx, root)
if err != nil {
return nil, err
}
for _, source := range rootSources {
addJSONLSource(source, &sources, seen)
}
}
sortJSONLSources(sources)
return sources, nil
}
func (s geminiSourceSet) discoverRoot(
ctx context.Context,
root string,
) ([]SourceRef, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
sources := make([]SourceRef, 0)
seen := make(map[string]struct{})
// Build the project map once per root. It depends only on root, and
// BuildGeminiProjectMap re-reads and SHA-256-hashes projects.json on every
// call, so resolving it per source path made discovery scale with session
// count (the dominant cost on large archives).
projectMap := buildGeminiProjectMap(root)
for _, path := range s.discoverSessionPaths(root) {
source, ok := s.sourceRefForPathWithProjectMap(root, path, true, projectMap)
if !ok {
continue
}
addJSONLSource(source, &sources, seen)
}
sortJSONLSources(sources)
return sources, nil
}
// discoverSessionPaths finds all Gemini session file paths under the Gemini
// directory (<root>/tmp/<dir>/chats/session-*.json[l]). <dir> is either a
// SHA-256 project hash (old layout) or a project name (new layout); symlinked
// hash directories are followed (matching the watcher). Project resolution is
// applied by sourceRef via BuildGeminiProjectMap/ResolveGeminiProject, so this
// helper only enumerates source paths.
func (s geminiSourceSet) discoverSessionPaths(root string) []string {
if root == "" {
return nil
}
tmpDir := filepath.Join(root, "tmp")
hashDirs, err := os.ReadDir(tmpDir)
if err != nil {
return nil
}
var paths []string
for _, hd := range hashDirs {
if !isDirOrSymlink(hd, tmpDir) {
continue
}
chatsDir := filepath.Join(tmpDir, hd.Name(), geminiChatsDir)
entries, err := os.ReadDir(chatsDir)
if err != nil {
continue
}
for _, sf := range entries {
if sf.IsDir() {
continue
}
name := sf.Name()
if !isGeminiSessionFilename(name) {
continue
}
paths = append(paths, filepath.Join(chatsDir, name))
}
}
return paths
}
func (s geminiSourceSet) WatchPlan(context.Context) (WatchPlan, error) {
roots := make([]WatchRoot, 0, len(s.roots))
for _, root := range s.roots {
tmp := filepath.Join(root, "tmp")
roots = append(roots, WatchRoot{
Path: tmp,
Recursive: true,
IncludeGlobs: []string{"session-*.json", "session-*.jsonl"},
DebounceKey: string(AgentGemini) + ":tmp:" + tmp,
})
roots = append(roots, WatchRoot{
Path: root,
Recursive: false,
IncludeGlobs: []string{"projects.json", "trustedFolders.json"},
DebounceKey: string(AgentGemini) + ":projects:" + root,
})
}
return WatchPlan{Roots: roots}, nil
}
func (s geminiSourceSet) SourcesForChangedPath(
ctx context.Context,
req ChangedPathRequest,
) ([]SourceRef, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, root := range s.roots {
if geminiProjectMetadataPath(root, req.Path) {
return s.discoverRoot(ctx, root)
}
source, ok := s.sourceRef(root, req.Path)
if ok {
return []SourceRef{source}, nil
}
if jsonlMissingPathFallbackAllowed(req) {
source, ok = s.sourceRefForPath(root, req.Path, false)
if ok {
return []SourceRef{source}, nil
}
}
}
return nil, nil
}
func (s geminiSourceSet) FindSource(
ctx context.Context,
req FindSourceRequest,
) (SourceRef, bool, error) {
if err := ctx.Err(); err != nil {
return SourceRef{}, false, err
}
for _, path := range []string{req.StoredFilePath, req.FingerprintKey} {
if path == "" {
continue
}
for _, root := range s.roots {
if source, ok := s.sourceRef(root, path); ok {
return source, true, nil
}
}
}
if req.RawSessionID == "" {
return SourceRef{}, false, nil
}
for _, root := range s.roots {
path := s.findSourceFile(root, req.RawSessionID)
if path == "" {
continue
}
if source, ok := s.sourceRef(root, path); ok {
return source, true, nil
}
}
return SourceRef{}, false, nil
}
// findSourceFile locates a Gemini session file by its session UUID under root,
// searching all project hash directories. The session filename embeds the first
// eight characters of the UUID, so candidates are pre-filtered on that prefix
// before confirming the recorded sessionId matches.
func (s geminiSourceSet) findSourceFile(root, sessionID string) string {
if root == "" || !IsValidSessionID(sessionID) ||
len(sessionID) < 8 {
return ""
}
tmpDir := filepath.Join(root, "tmp")
hashDirs, err := os.ReadDir(tmpDir)
if err != nil {
return ""
}
for _, hd := range hashDirs {
if !isDirOrSymlink(hd, tmpDir) {
continue
}
chatsDir := filepath.Join(tmpDir, hd.Name(), geminiChatsDir)
entries, err := os.ReadDir(chatsDir)
if err != nil {
continue
}
for _, sf := range entries {
if sf.IsDir() {
continue
}
name := sf.Name()
if !isGeminiSessionFilename(name) {
continue
}
if strings.Contains(name, sessionID[:8]) {
path := filepath.Join(chatsDir, name)
if confirmGeminiSessionID(path, sessionID) {
return path
}
}
}
}
return ""
}
// confirmGeminiSessionID reads the sessionId field from a Gemini file to
// confirm it matches the expected ID.
func confirmGeminiSessionID(path, sessionID string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return GeminiSessionID(data) == sessionID
}
func (s geminiSourceSet) Fingerprint(
ctx context.Context,
source SourceRef,
) (SourceFingerprint, error) {
if err := ctx.Err(); err != nil {
return SourceFingerprint{}, err
}
root, path, ok := s.rootPathFromSource(source)
if !ok {
return SourceFingerprint{}, fmt.Errorf("gemini 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)
}
fingerprint := SourceFingerprint{
Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path),
Size: info.Size(),
MTimeNS: info.ModTime().UnixNano(),
}
h := sha256.New()
if err := addGeminiFingerprintPart(h, "session", path, info); err != nil {
return SourceFingerprint{}, err
}
for _, metadataPath := range geminiProjectMetadataPaths(root) {
metadataInfo, err := os.Stat(metadataPath)
if err != nil || metadataInfo.IsDir() {
continue
}
fingerprint.Size += metadataInfo.Size()
if mtime := metadataInfo.ModTime().UnixNano(); mtime > fingerprint.MTimeNS {
fingerprint.MTimeNS = mtime
}
if err := addGeminiFingerprintPart(h, "project", metadataPath, metadataInfo); err != nil {
return SourceFingerprint{}, err
}
}
fingerprint.Hash = fmt.Sprintf("%x", h.Sum(nil))
return fingerprint, nil
}
func (s geminiSourceSet) pathFromSource(source SourceRef) (string, bool) {
_, path, ok := s.rootPathFromSource(source)
return path, ok
}
func (s geminiSourceSet) rootPathFromSource(source SourceRef) (string, string, bool) {
switch src := source.Opaque.(type) {
case geminiSource:
return src.Root, src.Path, src.Path != ""
case *geminiSource:
if src != nil && src.Path != "" {
return src.Root, src.Path, true
}
}
for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} {
for _, root := range s.roots {
if ref, ok := s.sourceRef(root, candidate); ok {
src := ref.Opaque.(geminiSource)
return src.Root, src.Path, true
}
}
}
return "", "", false
}
func (s geminiSourceSet) sourceRef(root, path string) (SourceRef, bool) {
return s.sourceRefForPath(root, path, true)
}
func (s geminiSourceSet) sourceRefForPath(
root, path string,
requireRegular bool,
) (SourceRef, bool) {
return s.sourceRefForPathWithProjectMap(root, path, requireRegular, nil)
}
// sourceRefForPathWithProjectMap builds a SourceRef using a caller-supplied
// project map. Discovery builds the map once per root and passes it for every
// source; single-path callers pass nil and the map is built for that one path.
func (s geminiSourceSet) sourceRefForPathWithProjectMap(
root, path string,
requireRegular bool,
projectMap map[string]string,
) (SourceRef, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
rel, ok := relUnder(root, path)
if !ok || (requireRegular && !IsRegularFile(path)) {
return SourceRef{}, false
}
sepParts := strings.Split(filepath.ToSlash(rel), "/")
if len(sepParts) != 4 ||
sepParts[0] != "tmp" ||
sepParts[2] != geminiChatsDir ||
!isGeminiSessionFilename(sepParts[3]) {
return SourceRef{}, false
}
if projectMap == nil {
projectMap = buildGeminiProjectMap(root)
}
project := ResolveGeminiProject(sepParts[1], projectMap)
return SourceRef{
Provider: AgentGemini,
Key: path,
DisplayPath: path,
FingerprintKey: path,
ProjectHint: project,
Opaque: geminiSource{
Root: root,
Path: path,
},
}, true
}
// buildGeminiProjectMap indirects BuildGeminiProjectMap so discovery can build
// the project map once per root and tests can observe how often it runs.
var buildGeminiProjectMap = BuildGeminiProjectMap
func geminiProjectMetadataPaths(root string) []string {
return []string{
filepath.Join(root, "projects.json"),
filepath.Join(root, "trustedFolders.json"),
}
}
func geminiProjectMetadataPath(root, path string) bool {
root = filepath.Clean(root)
path = filepath.Clean(path)
rel, ok := relUnder(root, path)
if !ok {
return false
}
rel = filepath.ToSlash(rel)
return rel == "projects.json" || rel == "trustedFolders.json"
}
func addGeminiFingerprintPart(
h hash.Hash,
label string,
path string,
info os.FileInfo,
) error {
if _, err := fmt.Fprintf(
h,
"%s\x00%s\x00%d\x00%d\x00",
label,
path,
info.Size(),
info.ModTime().UnixNano(),
); err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("hash %s: %w", path, err)
}
return nil
}
func geminiProviderCapabilities() Capabilities {
return Capabilities{
Source: SourceCapabilities{
DiscoverSources: CapabilitySupported,
WatchSources: CapabilitySupported,
ClassifyChangedPath: CapabilitySupported,
FindSource: CapabilitySupported,
CompositeFingerprint: CapabilitySupported,
IncrementalAppend: CapabilityNotApplicable,
MultiSessionSource: CapabilityNotApplicable,
PerSessionErrors: CapabilityNotApplicable,
ExcludedSessions: CapabilityNotApplicable,
ForceReplaceOnParse: CapabilityNotApplicable,
},
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
Thinking: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
PerMessageTokenUsage: CapabilitySupported,
Model: CapabilitySupported,
},
}
}