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
590 lines
15 KiB
Go
590 lines
15 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
func newDevinProviderFactory(def AgentDef) ProviderFactory {
|
|
return devinProviderFactory{def: cloneAgentDef(def)}
|
|
}
|
|
|
|
type devinProviderFactory struct {
|
|
def AgentDef
|
|
}
|
|
|
|
func (f devinProviderFactory) Definition() AgentDef {
|
|
return cloneAgentDef(f.def)
|
|
}
|
|
|
|
func (f devinProviderFactory) Capabilities() Capabilities {
|
|
return devinProviderCapabilities()
|
|
}
|
|
|
|
func (f devinProviderFactory) NewProvider(cfg ProviderConfig) Provider {
|
|
cfg = cfg.Clone()
|
|
return &devinProvider{
|
|
ProviderBase: ProviderBase{
|
|
Def: cloneAgentDef(f.def),
|
|
Caps: devinProviderCapabilities(),
|
|
Config: cfg,
|
|
},
|
|
sources: newDevinSourceSet(cfg.Roots),
|
|
}
|
|
}
|
|
|
|
type devinProvider struct {
|
|
ProviderBase
|
|
sources devinSourceSet
|
|
}
|
|
|
|
func (p *devinProvider) Discover(ctx context.Context) ([]SourceRef, error) {
|
|
return p.sources.Discover(ctx)
|
|
}
|
|
|
|
func (p *devinProvider) WatchPlan(ctx context.Context) (WatchPlan, error) {
|
|
return p.sources.WatchPlan(ctx)
|
|
}
|
|
|
|
func (p *devinProvider) SourcesForChangedPath(
|
|
ctx context.Context,
|
|
req ChangedPathRequest,
|
|
) ([]SourceRef, error) {
|
|
return p.sources.SourcesForChangedPath(ctx, req)
|
|
}
|
|
|
|
func (p *devinProvider) FindSource(
|
|
ctx context.Context,
|
|
req FindSourceRequest,
|
|
) (SourceRef, bool, error) {
|
|
req = ProviderFindRequestWithRawSessionID(p.Def, req)
|
|
return p.sources.FindSource(ctx, req)
|
|
}
|
|
|
|
func (p *devinProvider) Fingerprint(
|
|
ctx context.Context,
|
|
source SourceRef,
|
|
) (SourceFingerprint, error) {
|
|
return p.sources.Fingerprint(ctx, source)
|
|
}
|
|
|
|
func (p *devinProvider) Parse(
|
|
ctx context.Context,
|
|
req ParseRequest,
|
|
) (ParseOutcome, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
src, ok := p.sources.sourceFromRef(req.Source)
|
|
if !ok {
|
|
return ParseOutcome{}, fmt.Errorf("%s source path unavailable", AgentDevin)
|
|
}
|
|
if _, err := os.Stat(src.DBPath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return ParseOutcome{
|
|
ResultSetComplete: true,
|
|
SkipReason: SkipNoSession,
|
|
}, nil
|
|
}
|
|
return ParseOutcome{}, fmt.Errorf("stat %s: %w", src.DBPath, err)
|
|
}
|
|
machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine)
|
|
sess, msgs, err := parseDevinSession(src.DBPath, src.SessionID, machine)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ParseOutcome{
|
|
ResultSetComplete: true,
|
|
ForceReplace: true,
|
|
SkipReason: SkipNoSession,
|
|
}, nil
|
|
}
|
|
var transcriptErr *devinTranscriptError
|
|
if errors.As(err, &transcriptErr) {
|
|
return ParseOutcome{}, transcriptErr
|
|
}
|
|
if err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
if sess == nil {
|
|
return ParseOutcome{
|
|
ResultSetComplete: true,
|
|
ForceReplace: 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,
|
|
ForceReplace: true,
|
|
}, nil
|
|
}
|
|
|
|
type devinSource struct {
|
|
Root string
|
|
DBPath string
|
|
SessionID string
|
|
}
|
|
|
|
type devinSourceSet struct {
|
|
roots []string
|
|
}
|
|
|
|
func newDevinSourceSet(roots []string) devinSourceSet {
|
|
return devinSourceSet{roots: cleanJSONLRoots(roots)}
|
|
}
|
|
|
|
func (s devinSourceSet) 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
|
|
}
|
|
dbPath := devinDBPath(root)
|
|
if dbPath == "" {
|
|
continue
|
|
}
|
|
metas, err := ListDevinSessionMeta(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, meta := range metas {
|
|
addJSONLSource(s.newSourceRefWithMTime(root, dbPath, meta.RawSessionID, meta.FileMtime), &sources, seen)
|
|
}
|
|
}
|
|
sortJSONLSources(sources)
|
|
return sources, nil
|
|
}
|
|
|
|
func (s devinSourceSet) WatchPlan(context.Context) (WatchPlan, error) {
|
|
roots := make([]WatchRoot, 0, len(s.roots)*2)
|
|
for _, root := range s.roots {
|
|
cliRoot := filepath.Join(root, "cli")
|
|
roots = append(roots,
|
|
WatchRoot{
|
|
Path: cliRoot,
|
|
Recursive: false,
|
|
IncludeGlobs: []string{devinDBFilename, devinDBFilename + "-*"},
|
|
DebounceKey: string(AgentDevin) + ":db:" + root,
|
|
},
|
|
WatchRoot{
|
|
Path: filepath.Join(cliRoot, "transcripts"),
|
|
Recursive: false,
|
|
IncludeGlobs: []string{"*.json"},
|
|
DebounceKey: string(AgentDevin) + ":transcripts:" + root,
|
|
},
|
|
)
|
|
}
|
|
return WatchPlan{Roots: roots}, nil
|
|
}
|
|
|
|
func (s devinSourceSet) SourcesForChangedPath(
|
|
ctx context.Context,
|
|
req ChangedPathRequest,
|
|
) ([]SourceRef, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, root := range s.roots {
|
|
if req.WatchRoot != "" && !samePath(req.WatchRoot, filepath.Join(root, "cli")) && !samePath(req.WatchRoot, filepath.Join(root, "cli", "transcripts")) {
|
|
continue
|
|
}
|
|
if ref, ok := s.sourceRef(root, req.Path, true); ok {
|
|
return []SourceRef{ref}, nil
|
|
}
|
|
if dbPath, ok := s.dbPathForEvent(root, req.Path); ok {
|
|
metas, err := ListDevinSessionMeta(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sources := make([]SourceRef, 0, len(metas))
|
|
seen := make(map[string]struct{}, len(metas))
|
|
for _, meta := range metas {
|
|
addJSONLSource(s.newSourceRefWithMTime(root, dbPath, meta.RawSessionID, meta.FileMtime), &sources, seen)
|
|
}
|
|
for _, path := range req.StoredSourcePaths {
|
|
ref, ok := s.sourceRef(root, path, true)
|
|
if !ok {
|
|
continue
|
|
}
|
|
src := ref.Opaque.(devinSource)
|
|
if !samePath(src.DBPath, dbPath) {
|
|
continue
|
|
}
|
|
addJSONLSource(ref, &sources, seen)
|
|
}
|
|
sortJSONLSources(sources)
|
|
return sources, nil
|
|
}
|
|
if sessionID, ok := s.transcriptSessionIDForEvent(root, req.Path); ok {
|
|
if ref, ok, err := s.findByRawSessionID(root, sessionID, false); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return []SourceRef{ref}, nil
|
|
}
|
|
for _, path := range req.StoredSourcePaths {
|
|
ref, ok := s.sourceRef(root, path, true)
|
|
if !ok {
|
|
continue
|
|
}
|
|
src := ref.Opaque.(devinSource)
|
|
if src.SessionID == sessionID {
|
|
return []SourceRef{ref}, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s devinSourceSet) 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 {
|
|
ref, ok := s.sourceRef(root, path, true)
|
|
if !ok {
|
|
continue
|
|
}
|
|
src := ref.Opaque.(devinSource)
|
|
if req.RawSessionID != "" && src.SessionID != req.RawSessionID {
|
|
continue
|
|
}
|
|
if req.RequireFreshSource {
|
|
fresh, err := s.sourceExists(src)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
if !fresh {
|
|
continue
|
|
}
|
|
}
|
|
return ref, true, nil
|
|
}
|
|
}
|
|
if req.RawSessionID == "" {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
for _, root := range s.roots {
|
|
ref, ok, err := s.findByRawSessionID(root, req.RawSessionID, req.RequireFreshSource)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
if ok {
|
|
return ref, true, nil
|
|
}
|
|
}
|
|
return SourceRef{}, false, nil
|
|
}
|
|
|
|
func (s devinSourceSet) Fingerprint(
|
|
ctx context.Context,
|
|
source SourceRef,
|
|
) (SourceFingerprint, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
src, ok := s.sourceFromRef(source)
|
|
if !ok {
|
|
return SourceFingerprint{}, fmt.Errorf("%s source path unavailable", AgentDevin)
|
|
}
|
|
key := firstNonEmptyJSONLString(source.FingerprintKey, source.Key, src.virtualPath())
|
|
dbInfo, err := os.Stat(src.DBPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return SourceFingerprint{Key: key}, nil
|
|
}
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", src.DBPath, err)
|
|
}
|
|
meta, err := getDevinSessionMeta(src.DBPath, src.SessionID)
|
|
if err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
if meta == nil {
|
|
return SourceFingerprint{Key: key}, nil
|
|
}
|
|
fingerprint := SourceFingerprint{Key: key}
|
|
transcriptPath := filepath.Join(filepath.Dir(src.DBPath), "transcripts", src.SessionID+".json")
|
|
transcriptInfo, err := os.Stat(transcriptPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return SourceFingerprint{}, newDevinTranscriptError("stat", err)
|
|
}
|
|
fingerprint.MTimeNS = maxDevinFingerprintMTime(meta, dbInfo, transcriptInfo)
|
|
hash, err := devinFingerprintHash(meta, src.DBPath, transcriptPath, transcriptInfo)
|
|
if err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
fingerprint.Hash = hash
|
|
if transcriptInfo != nil {
|
|
fingerprint.Size = transcriptInfo.Size()
|
|
}
|
|
return fingerprint, nil
|
|
}
|
|
|
|
func maxDevinFingerprintMTime(
|
|
meta *DevinSessionMeta,
|
|
dbInfo os.FileInfo,
|
|
transcriptInfo os.FileInfo,
|
|
) int64 {
|
|
var mtime int64
|
|
if meta != nil {
|
|
mtime = meta.FileMtime
|
|
if created := meta.CreatedAt.UnixNano(); created > mtime {
|
|
mtime = created
|
|
}
|
|
if lastActivity := meta.LastActivity.UnixNano(); lastActivity > mtime {
|
|
mtime = lastActivity
|
|
}
|
|
}
|
|
if dbInfo != nil {
|
|
if dbMTime := dbInfo.ModTime().UnixNano(); dbMTime > mtime {
|
|
mtime = dbMTime
|
|
}
|
|
}
|
|
if transcriptInfo != nil {
|
|
if transcriptMTime := transcriptInfo.ModTime().UnixNano(); transcriptMTime > mtime {
|
|
mtime = transcriptMTime
|
|
}
|
|
}
|
|
return mtime
|
|
}
|
|
|
|
func devinFingerprintHash(
|
|
meta *DevinSessionMeta,
|
|
dbPath string,
|
|
transcriptPath string,
|
|
transcriptInfo os.FileInfo,
|
|
) (string, error) {
|
|
h := sha256.New()
|
|
if _, err := fmt.Fprintf(
|
|
h,
|
|
"session\x00%s\x00created\x00%d\x00last_activity\x00%d\x00updated\x00%d\x00title\x00%s\x00cwd\x00%s\x00model\x00%s\x00",
|
|
meta.RawSessionID,
|
|
meta.CreatedAt.UnixMilli(),
|
|
meta.LastActivity.UnixMilli(),
|
|
meta.UpdatedAt.UnixMilli(),
|
|
meta.Title,
|
|
meta.CWD,
|
|
meta.Model,
|
|
); err != nil {
|
|
return "", err
|
|
}
|
|
if transcriptInfo == nil {
|
|
if err := devinAppendMessageNodesFingerprint(h, dbPath, meta.RawSessionID); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
}
|
|
if _, err := fmt.Fprintf(
|
|
h,
|
|
"transcript_size\x00%d\x00transcript_mtime\x00%d\x00",
|
|
transcriptInfo.Size(),
|
|
transcriptInfo.ModTime().UnixNano(),
|
|
); err != nil {
|
|
return "", err
|
|
}
|
|
f, err := os.Open(transcriptPath)
|
|
if err != nil {
|
|
return "", newDevinTranscriptError("open", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", newDevinTranscriptError("hash", err)
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
}
|
|
|
|
func devinAppendMessageNodesFingerprint(h io.Writer, dbPath, rawSessionID string) error {
|
|
nodes, err := listDevinMessageNodes(dbPath, rawSessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var maxCreatedAtMS int64
|
|
for _, node := range nodes {
|
|
if node.CreatedAtMS > maxCreatedAtMS {
|
|
maxCreatedAtMS = node.CreatedAtMS
|
|
}
|
|
}
|
|
if _, err := fmt.Fprintf(h, "message_nodes\x00count\x00%d\x00max_created\x00%d\x00", len(nodes), maxCreatedAtMS); err != nil {
|
|
return err
|
|
}
|
|
for _, node := range nodes {
|
|
if _, err := fmt.Fprintf(
|
|
h,
|
|
"row\x00%d\x00node\x00%d\x00parent_valid\x00%t\x00parent\x00%d\x00created\x00%d\x00chat_len\x00%d\x00",
|
|
node.RowID,
|
|
node.NodeID,
|
|
node.ParentNodeID.Valid,
|
|
node.ParentNodeID.Int64,
|
|
node.CreatedAtMS,
|
|
len(node.ChatMessage),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.WriteString(h, node.ChatMessage); err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.WriteString(h, "\x00"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s devinSourceSet) sourceFromRef(source SourceRef) (devinSource, bool) {
|
|
switch src := source.Opaque.(type) {
|
|
case devinSource:
|
|
return src, src.DBPath != "" && src.SessionID != ""
|
|
case *devinSource:
|
|
if src != nil && src.DBPath != "" && src.SessionID != "" {
|
|
return *src, true
|
|
}
|
|
}
|
|
for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} {
|
|
for _, root := range s.roots {
|
|
if ref, ok := s.sourceRef(root, candidate, true); ok {
|
|
return ref.Opaque.(devinSource), true
|
|
}
|
|
}
|
|
}
|
|
return devinSource{}, false
|
|
}
|
|
|
|
func (s devinSourceSet) sourceRef(root, path string, allowMissing bool) (SourceRef, bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
dbPath, sessionID, ok := ParseVirtualSourcePathForBase(path, devinDBFilename)
|
|
if !ok || sessionID == "" {
|
|
return SourceRef{}, false
|
|
}
|
|
if !samePath(dbPath, filepath.Join(root, "cli", devinDBFilename)) {
|
|
return SourceRef{}, false
|
|
}
|
|
if !allowMissing && !IsRegularFile(dbPath) {
|
|
return SourceRef{}, false
|
|
}
|
|
return s.newSourceRef(root, dbPath, sessionID), true
|
|
}
|
|
|
|
func (s devinSourceSet) dbPathForEvent(root, path string) (string, bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
cliRoot := filepath.Join(root, "cli")
|
|
rel, ok := relUnder(cliRoot, path)
|
|
if !ok || strings.Contains(rel, string(filepath.Separator)) {
|
|
return "", false
|
|
}
|
|
if rel == devinDBFilename || rel == devinDBFilename+"-wal" || rel == devinDBFilename+"-shm" {
|
|
return filepath.Join(cliRoot, devinDBFilename), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (s devinSourceSet) transcriptSessionIDForEvent(root, path string) (string, bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
rel, ok := relUnder(filepath.Join(root, "cli", "transcripts"), path)
|
|
if !ok || strings.Contains(rel, string(filepath.Separator)) || filepath.Ext(rel) != ".json" {
|
|
return "", false
|
|
}
|
|
return strings.TrimSuffix(rel, ".json"), true
|
|
}
|
|
|
|
func (s devinSourceSet) sourceExists(src devinSource) (bool, error) {
|
|
if !IsRegularFile(src.DBPath) {
|
|
return false, nil
|
|
}
|
|
meta, err := getDevinSessionMeta(src.DBPath, src.SessionID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return meta != nil, nil
|
|
}
|
|
|
|
func (s devinSourceSet) findByRawSessionID(root, rawID string, requireFresh bool) (SourceRef, bool, error) {
|
|
if root == "" || rawID == "" {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
dbPath := devinDBPath(root)
|
|
if dbPath == "" {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
meta, err := getDevinSessionMeta(dbPath, rawID)
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
if meta == nil {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
ref := s.newSourceRefWithMTime(root, dbPath, rawID, meta.FileMtime)
|
|
if requireFresh {
|
|
fresh, err := s.sourceExists(ref.Opaque.(devinSource))
|
|
if err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
if !fresh {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
}
|
|
return ref, true, nil
|
|
}
|
|
|
|
func (s devinSourceSet) newSourceRef(root, dbPath, sessionID string) SourceRef {
|
|
return s.newSourceRefWithMTime(root, dbPath, sessionID, 0)
|
|
}
|
|
|
|
func (s devinSourceSet) newSourceRefWithMTime(root, dbPath, sessionID string, mtimeNS int64) SourceRef {
|
|
virtualPath := VirtualSourcePath(dbPath, sessionID)
|
|
return SourceRef{
|
|
Provider: AgentDevin,
|
|
Key: virtualPath,
|
|
DisplayPath: virtualPath,
|
|
FingerprintKey: virtualPath,
|
|
DiscoveryMTimeNS: mtimeNS,
|
|
Opaque: devinSource{
|
|
Root: root,
|
|
DBPath: dbPath,
|
|
SessionID: sessionID,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s devinSource) virtualPath() string {
|
|
return VirtualSourcePath(s.DBPath, s.SessionID)
|
|
}
|
|
|
|
func devinProviderCapabilities() Capabilities {
|
|
return Capabilities{
|
|
Source: dbBackedSourceCapabilities(CapabilityNotApplicable),
|
|
Content: ContentCapabilities{
|
|
FirstMessage: CapabilitySupported,
|
|
SessionName: CapabilitySupported,
|
|
Cwd: CapabilitySupported,
|
|
Thinking: CapabilitySupported,
|
|
ToolCalls: CapabilitySupported,
|
|
ToolResults: CapabilitySupported,
|
|
PerMessageTokenUsage: CapabilitySupported,
|
|
Model: CapabilitySupported,
|
|
},
|
|
}
|
|
}
|