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

332 lines
9.5 KiB
Go

package parser
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
// Cowork stores each session as a Claude-format transcript
// (.claude/projects/**/<id>.jsonl) with a sibling local_<uuid>.json metadata
// file, plus per-subagent transcripts. It is a single-file provider whose parse
// can yield multiple sessions (the main conversation and its subagents) and
// drive removals via excluded session IDs. All behavior is wired into the
// shared single-file base via options.
func newCoworkProviderFactory(def AgentDef) ProviderFactory {
return NewSingleFileProviderFactory(
def,
coworkProviderCapabilities(),
func(cfg ProviderConfig) singleFileSourceSet {
return NewSingleFileSourceSet(
AgentCowork,
cfg.Roots,
WithFileDiscovery(coworkDiscoverFiles),
WithFileWatchRoots(coworkWatchRoots),
WithFileChangedPathClassifier(coworkClassifyPath),
WithFileLookup(coworkFindFile),
WithFileFingerprint(coworkFingerprintSource),
WithFileParse(coworkParseFile),
// Parse removes stale subagents via exclusions, so an empty
// result set is still a complete (not skipped) parse.
WithAlwaysCompleteResultSet(),
)
},
)
}
func coworkDiscoverFiles(root string) []singleFileMatch {
var out []singleFileMatch
walkCoworkSessions(root, func(transcript string) {
if match, ok := coworkTranscriptMatch(root, transcript); ok {
out = append(out, match)
}
})
return out
}
func coworkWatchRoots(roots []string) []WatchRoot {
out := make([]WatchRoot, 0, len(roots))
for _, root := range roots {
out = append(out, WatchRoot{
Path: root,
Recursive: true,
IncludeGlobs: []string{"local_*.json", "*.jsonl"},
DebounceKey: string(AgentCowork) + ":metadata:" + root,
})
}
return out
}
// coworkClassifyPath maps a stored or changed path to its session transcript. A
// transcript path classifies directly; a metadata path resolves to the
// session's main transcript so a title rename is picked up. Under allowMissing a
// metadata path whose transcript was deleted still resolves via on-disk
// scanning.
func coworkClassifyPath(
root, path string, allowMissing bool,
) (singleFileMatch, bool) {
transcript, ok := classifyCoworkPath(root, path)
if !ok && allowMissing {
transcript, ok = coworkTranscriptForMetadataPath(root, path)
}
if !ok {
return singleFileMatch{}, false
}
return coworkTranscriptMatch(root, transcript)
}
func coworkFindFile(root, rawID string) (singleFileMatch, bool) {
path := coworkFindSourceFile(root, rawID)
if path == "" {
return singleFileMatch{}, false
}
return coworkTranscriptMatch(root, path)
}
// coworkTranscriptMatch validates a transcript path under root and builds a
// match carrying the project hint read from the session's metadata. It
// reproduces the legacy sourceRef checks.
func coworkTranscriptMatch(root, path string) (singleFileMatch, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
if _, ok := relUnder(root, path); !ok {
return singleFileMatch{}, false
}
metaPath := coworkMetaPathForTranscript(path)
if metaPath == "" {
return singleFileMatch{}, false
}
if !isCoworkTranscriptPath(root, path) {
return singleFileMatch{}, false
}
return singleFileMatch{
Path: path,
ProjectHint: coworkProjectName(readCoworkMeta(metaPath)),
}, true
}
func coworkFingerprintSource(
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,
)
}
hash, err := hashJSONLSourceFile(src.Path)
if err != nil {
return SourceFingerprint{}, err
}
return SourceFingerprint{
Size: info.Size(),
MTimeNS: CoworkSessionMtime(src.Path, info.ModTime().UnixNano()),
Hash: hash,
}, nil
}
func coworkParseFile(
src singleFileSource, req ParseRequest,
) ([]ParseResult, []string, error) {
results, excluded, err := parseCoworkSession(src.Path, req.Machine)
if err != nil {
return nil, nil, err
}
if req.Fingerprint.Hash != "" {
for i := range results {
results[i].Session.File.Hash = req.Fingerprint.Hash
}
}
return results, excluded, nil
}
func isCoworkTranscriptPath(root, path string) bool {
rel, ok := relUnder(root, path)
if !ok || filepath.Ext(path) != ".jsonl" {
return false
}
sep := string(filepath.Separator)
parts := strings.Split(rel, sep)
n := len(parts)
base := strings.TrimSuffix(filepath.Base(path), ".jsonl")
if n >= 5 && parts[n-4] == ".claude" && parts[n-3] == "projects" {
return IsValidSessionID(base)
}
if !strings.Contains(sep+rel, sep+".claude"+sep+"projects"+sep) ||
!slices.Contains(parts, "subagents") {
return false
}
return strings.HasPrefix(base, "agent-")
}
func coworkTranscriptForMetadataPath(root, path string) (string, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
rel, ok := relUnder(root, path)
if !ok || !isCoworkMetaFileName(filepath.Base(rel)) {
return "", false
}
sessionDir := strings.TrimSuffix(path, ".json")
resolvedSessionDir, err := filepath.EvalSymlinks(sessionDir)
if err != nil {
return "", false
}
projectsDir := filepath.Join(sessionDir, ".claude", "projects")
entries, err := os.ReadDir(projectsDir)
if err != nil {
return "", false
}
var found string
for _, entry := range entries {
if !isDirOrSymlink(entry, projectsDir) {
continue
}
projectDir := filepath.Join(projectsDir, entry.Name())
files, err := os.ReadDir(projectDir)
if err != nil {
continue
}
for _, file := range files {
if file.IsDir() {
continue
}
name := file.Name()
if !strings.HasSuffix(name, ".jsonl") {
continue
}
stem := strings.TrimSuffix(name, ".jsonl")
if !IsValidSessionID(stem) || strings.HasPrefix(stem, "agent-") {
continue
}
candidate := filepath.Join(projectDir, name)
if !validCoworkMainTranscriptCandidate(resolvedSessionDir, candidate) {
continue
}
if found != "" {
return "", false
}
found = candidate
}
}
return found, found != ""
}
func validCoworkMainTranscriptCandidate(resolvedSessionDir, candidate string) bool {
if !IsRegularFile(candidate) {
return false
}
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return false
}
return isContainedIn(resolved, resolvedSessionDir)
}
// coworkFindSourceFile locates a cowork transcript by its raw session ID
// (the cliSessionId or "agent-<id>" subagent id, with the "cowork:" prefix
// already stripped).
func coworkFindSourceFile(root, sessionID string) string {
if !IsValidSessionID(sessionID) {
return ""
}
target := sessionID + ".jsonl"
var found string
walkCoworkSessions(root, func(transcript string) {
if found == "" && filepath.Base(transcript) == target {
found = transcript
}
})
return found
}
// classifyCoworkPath reports whether a changed path under a cowork root is a
// cowork session transcript (main or subagent) or its sibling metadata file,
// and returns the transcript file that should be (re)parsed. Metadata changes
// (e.g. a title rename) resolve to the session's main transcript so the rename
// is picked up.
func classifyCoworkPath(root, path string) (string, bool) {
rel, ok := relUnder(root, path)
if !ok {
return "", false
}
sep := string(filepath.Separator)
parts := strings.Split(rel, sep)
n := len(parts)
base := parts[n-1]
if strings.HasSuffix(base, ".jsonl") {
// Must live under a .claude/projects/ subtree.
marker := sep + ".claude" + sep + "projects" + sep
if !strings.Contains(sep+rel, marker) {
return "", false
}
stem := strings.TrimSuffix(base, ".jsonl")
if strings.HasPrefix(stem, "agent-") {
// Subagent transcript: <enc>/<cli>/subagents/**/agent-*.jsonl.
if slices.Contains(parts, "subagents") {
return path, true
}
return "", false
}
// Main transcript: <enc>/<cliSessionId>.jsonl directly under projects.
if n >= 5 && parts[n-4] == ".claude" && parts[n-3] == "projects" &&
IsValidSessionID(stem) {
return path, true
}
return "", false
}
// Metadata: <orgId>/<workspaceId>/local_<uuid>.json
if isCoworkMetaFileName(base) {
meta := readCoworkMeta(path)
if meta.CliSessionID == "" {
return "", false
}
sessionDir := strings.TrimSuffix(path, ".json")
if main, _ := resolveCoworkSession(
sessionDir, meta.CliSessionID,
); main != "" {
return main, true
}
}
return "", false
}
func coworkProviderCapabilities() Capabilities {
return Capabilities{
Source: SourceCapabilities{
DiscoverSources: CapabilitySupported,
WatchSources: CapabilitySupported,
ClassifyChangedPath: CapabilitySupported,
FindSource: CapabilitySupported,
CompositeFingerprint: CapabilitySupported,
IncrementalAppend: CapabilityNotApplicable,
MultiSessionSource: CapabilitySupported,
PerSessionErrors: CapabilityNotApplicable,
ExcludedSessions: CapabilitySupported,
ForceReplaceOnParse: CapabilityNotApplicable,
},
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
SessionName: CapabilitySupported,
Cwd: CapabilitySupported,
GitBranch: CapabilitySupported,
Relationships: CapabilitySupported,
Subagents: CapabilitySupported,
Thinking: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
PerMessageTokenUsage: CapabilitySupported,
TerminationStatus: CapabilitySupported,
MalformedLineCount: CapabilitySupported,
Model: CapabilitySupported,
StopReason: CapabilitySupported,
},
}
}