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

550 lines
17 KiB
Go

// ABOUTME: Reads Claude/Codex session JSONL directly from an S3-compatible
// ABOUTME: object store (AWS S3, MinIO, Aliyun OSS, R2, ...) — pure Go, no cgo.
package parser
import (
"context"
"fmt"
"io"
"net"
"os"
"slices"
"sort"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// S3Object carries the durable source metadata needed for sync skip checks.
type S3Object struct {
URI string
Size int64
LastModified time.Time
Fingerprint string
}
var (
listS3Objects = listS3
fetchS3Object = fetchS3ObjectDefault
statS3Object = statS3ObjectDefault
)
// s3Client builds an S3-compatible client from standard env vars:
//
// AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
// AWS_S3_ENDPOINT — host of an S3-compatible endpoint (e.g.
// "oss-cn-shenzhen.aliyuncs.com"); empty = AWS S3.
// An "http://" prefix selects insecure transport for
// loopback endpoints, or with explicit unsafe opt-in.
//
// Returning an error here means an s3:// source simply yields nothing,
// so a misconfigured store never aborts the local sync.
func s3Client() (*minio.Client, error) {
endpoint, secure, err := s3EndpointConfig(os.Getenv("AWS_S3_ENDPOINT"))
if err != nil {
return nil, err
}
return minio.New(endpoint, &minio.Options{
Creds: s3Credentials(),
Secure: secure,
Region: os.Getenv("AWS_REGION"),
})
}
func s3EndpointConfig(raw string) (endpoint string, secure bool, err error) {
endpoint = strings.TrimSpace(raw)
secure = true
switch {
case endpoint == "":
return "s3.amazonaws.com", true, nil
case strings.HasPrefix(endpoint, "http://"):
secure, endpoint = false, strings.TrimPrefix(endpoint, "http://")
case strings.HasPrefix(endpoint, "https://"):
endpoint = strings.TrimPrefix(endpoint, "https://")
case strings.Contains(endpoint, "://"):
return "", false, fmt.Errorf("unsupported S3 endpoint scheme: %q", raw)
}
if !secure && !isLoopbackS3Endpoint(endpoint) &&
!allowUnsafeS3Endpoint() {
return "", false, fmt.Errorf(
"insecure S3 endpoint %q is only allowed for loopback hosts; "+
"set AGENTSVIEW_ALLOW_INSECURE_S3_ENDPOINT=true to override",
raw,
)
}
return endpoint, secure, nil
}
func allowUnsafeS3Endpoint() bool {
switch strings.ToLower(os.Getenv("AGENTSVIEW_ALLOW_INSECURE_S3_ENDPOINT")) {
case "1", "true", "yes":
return true
default:
return false
}
}
func isLoopbackS3Endpoint(endpoint string) bool {
host := endpoint
if before, _, ok := strings.Cut(host, "/"); ok {
host = before
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(strings.ToLower(host), "[]")
if host == "localhost" {
return true
}
if before, _, ok := strings.Cut(host, "%"); ok {
host = before
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func s3Credentials() *credentials.Credentials {
return credentials.NewStaticV4(
os.Getenv("AWS_ACCESS_KEY_ID"),
os.Getenv("AWS_SECRET_ACCESS_KEY"),
os.Getenv("AWS_SESSION_TOKEN"),
)
}
// parseS3URI splits s3://bucket/key into (bucket, key).
func parseS3URI(uri string) (bucket, key string) {
rest := strings.TrimPrefix(uri, "s3://")
if before, after, ok := strings.Cut(rest, "/"); ok {
return before, after
}
return rest, ""
}
// listS3 lists every object under an s3://bucket/prefix, returning each
// object's full s3:// URI plus source metadata.
func listS3(uri string) ([]S3Object, error) {
cl, err := s3Client()
if err != nil {
return nil, err
}
bucket, prefix := parseS3URI(uri)
if prefix != "" {
prefix = strings.TrimSuffix(prefix, "/") + "/"
}
var out []S3Object
for o := range cl.ListObjects(context.Background(), bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
}) {
if o.Err != nil {
return nil, o.Err
}
out = append(out, S3Object{
URI: "s3://" + bucket + "/" + o.Key,
Size: o.Size,
LastModified: o.LastModified,
Fingerprint: s3ObjectFingerprint("s3://"+bucket+"/"+o.Key, o),
})
}
return out, nil
}
// FetchS3Object opens one s3://bucket/key object for streaming reads. The
// caller owns the returned reader and must close it. minio fetches lazily,
// so transport errors surface on the first Read rather than here.
func FetchS3Object(uri string) (io.ReadCloser, error) {
return fetchS3Object(uri)
}
func fetchS3ObjectDefault(uri string) (io.ReadCloser, error) {
cl, err := s3Client()
if err != nil {
return nil, err
}
bucket, key := parseS3URI(uri)
return cl.GetObject(context.Background(), bucket, key, minio.GetObjectOptions{})
}
// StatS3Object returns durable object metadata for an s3:// URI.
func StatS3Object(uri string) (S3Object, error) {
return statS3Object(uri)
}
// StatClaudeS3Session returns metadata for a Claude transcript plus matching
// tool-result sidecars that can change parsed content without changing JSONL.
func StatClaudeS3Session(uri string) (S3Object, error) {
obj, err := statS3Object(uri)
if err != nil {
return S3Object{}, err
}
return foldClaudeS3SidecarMetadata(obj, func(root string) []S3Object {
objects, err := listS3Objects(root)
if err != nil {
return nil
}
return objects
}), nil
}
// StatCodexS3Session returns metadata for a Codex rollout object.
func StatCodexS3Session(uri string) (S3Object, error) {
return statS3Object(uri)
}
func statS3ObjectDefault(uri string) (S3Object, error) {
cl, err := s3Client()
if err != nil {
return S3Object{}, err
}
bucket, key := parseS3URI(uri)
info, err := cl.StatObject(
context.Background(), bucket, key, minio.StatObjectOptions{},
)
if err != nil {
return S3Object{}, err
}
return S3Object{
URI: uri,
Size: info.Size,
LastModified: info.LastModified,
Fingerprint: s3ObjectFingerprint(uri, info),
}, nil
}
func s3RelativePath(root, uri string) (string, bool) {
prefix := strings.TrimSuffix(root, "/") + "/"
rel := strings.TrimPrefix(uri, prefix)
return rel, rel != uri
}
// s3MachineFromRoot derives the source machine from an s3:// session root laid
// out as .../<machine>/raw/<provider>, i.e. the path segment immediately
// preceding the "raw/<provider>" boundary. provider is the agent's path segment
// (string(Agent)), so the rule generalizes to any agent that adopts the same
// layout rather than being limited to Claude/Codex. Returns "" when not found,
// so callers fall back to the agentsview host machine name. This mirrors the
// host prefix that SSH remote sync attaches to pulled sessions.
func s3MachineFromRoot(root, provider string) string {
// segs[0] is the bucket, so "raw" must be at index >= 2 for the
// preceding segment to be a machine directory rather than the bucket.
segs := strings.Split(strings.TrimPrefix(root, "s3://"), "/")
for i := len(segs) - 2; i > 1; i-- {
if segs[i] == "raw" && segs[i+1] == provider {
return segs[i-1]
}
}
return ""
}
func foldClaudeS3SidecarMetadata(
obj S3Object, list func(root string) []S3Object,
) S3Object {
for _, root := range claudeS3SidecarRoots(obj.URI) {
for _, sidecar := range list(root) {
obj = foldS3ObjectMetadata(obj, sidecar)
}
}
return obj
}
func claudeS3SidecarRoots(uri string) []string {
sessionPath := strings.TrimSuffix(uri, ".jsonl")
if sessionPath == "" || sessionPath == uri {
return nil
}
roots := []string{sessionPath + "/tool-results"}
if strings.HasPrefix(pathBase(sessionPath), "agent-") {
if idx := strings.LastIndex(sessionPath, "/subagents/"); idx > 0 {
roots = append(roots, sessionPath[:idx]+"/tool-results")
}
}
return roots
}
func pathBase(p string) string {
if idx := strings.LastIndex(p, "/"); idx >= 0 {
return p[idx+1:]
}
return p
}
func foldS3ObjectMetadata(obj, extra S3Object) S3Object {
obj.Size += extra.Size
if extra.LastModified.After(obj.LastModified) {
obj.LastModified = extra.LastModified
}
obj.Fingerprint = combineS3Fingerprints(
obj.Fingerprint, extra.Fingerprint,
)
return obj
}
func s3ObjectFingerprint(uri string, info minio.ObjectInfo) string {
parts := []string{
"etag=" + strings.Trim(info.ETag, `"`),
"version=" + info.VersionID,
"crc32=" + info.ChecksumCRC32,
"crc32c=" + info.ChecksumCRC32C,
"sha1=" + info.ChecksumSHA1,
"sha256=" + info.ChecksumSHA256,
"crc64nvme=" + info.ChecksumCRC64NVME,
"md5=" + info.ChecksumMD5,
"sha512=" + info.ChecksumSHA512,
"xxhash64=" + info.ChecksumXXHash64,
"xxhash3=" + info.ChecksumXXHash3,
"xxhash128=" + info.ChecksumXXHash128,
}
nonEmpty := parts[:0]
for _, part := range parts {
if !strings.HasSuffix(part, "=") {
nonEmpty = append(nonEmpty, part)
}
}
if len(nonEmpty) == 0 {
return ""
}
sort.Strings(nonEmpty)
return combineS3Fingerprints(uri + "\x00" + strings.Join(nonEmpty, "\x00"))
}
func combineS3Fingerprints(values ...string) string {
const prefix = "s3-meta:"
const sep = "\x1e"
var entries []string
for _, value := range values {
if value == "" {
continue
}
value = strings.TrimPrefix(value, prefix)
for entry := range strings.SplitSeq(value, sep) {
if entry != "" {
entries = append(entries, entry)
}
}
}
if len(entries) == 0 {
return ""
}
sort.Strings(entries)
entries = slices.Compact(entries)
return prefix + strings.Join(entries, sep)
}
// S3DiscoveredSource is the Opaque payload an S3-aware source set attaches to a
// discovered s3:// SourceRef. It carries the durable object metadata the sync
// engine threads back into the DiscoveredFile so S3 freshness, dedup, mtime
// cutoff, and machine-ID namespacing operate on a provider-discovered S3 source
// exactly as they did when discovery emitted these fields directly. Providers
// read local files and cannot Fingerprint an s3:// URI, so the engine routes
// s3:// objects to the dedicated S3 sync path: it re-stats and fetches the
// object itself, then parses the fetched temp file through provider.Parse via a
// MaterializedFileSource. The metadata threaded here is what lets the
// incremental cutoff and skip checks run without performing that fetch.
type S3DiscoveredSource struct {
URI string
Project string
Machine string
Size int64
MtimeNS int64
Fingerprint string
}
// s3SourceRefFromDiscoveredFile builds the SourceRef for an s3:// session object
// enumerated by a source set's discovery. The s3 URI is the stable identity
// across Key, DisplayPath, and FingerprintKey, and the durable object metadata
// rides in the Opaque payload for the engine to thread into the DiscoveredFile.
func s3SourceRefFromDiscoveredFile(file DiscoveredFile) SourceRef {
return SourceRef{
Provider: file.Agent,
Key: file.Path,
DisplayPath: file.Path,
FingerprintKey: file.Path,
ProjectHint: file.Project,
Opaque: S3DiscoveredSource{
URI: file.Path,
Project: file.Project,
Machine: file.Machine,
Size: file.SourceSize,
MtimeNS: file.SourceMtime,
Fingerprint: file.SourceFingerprint,
},
}
}
// s3SessionScanner configures the shared S3 discovery scan over a session root
// laid out as .../<machine>/raw/<provider>. The scan lists every object under
// the root, derives the source machine from that layout, and emits a
// DiscoveredFile for each object Keep accepts. Keep and Project receive both the
// raw relative path and its pre-split segments so a provider expresses its
// selection and project rules without re-splitting. Sidecars, when set, returns
// the companion objects whose size/mtime/fingerprint fold into the session's
// freshness identity (Claude tool-results); providers without sidecars leave it
// nil, and providers that derive the project from session content leave Project
// nil.
type s3SessionScanner struct {
Agent AgentType
Keep func(rel string, segs []string) bool
Project func(rel string, segs []string) string
Sidecars func(uri string, all []S3Object) []S3Object
}
// s3PrefixScan is the shared S3 discovery body for the
// .../<machine>/raw/<provider> layout. discoverClaudeS3 and discoverCodexS3 are
// thin configurations of it, and any JSONL provider whose sessions land under
// the same layout can reuse it by supplying its own Keep/Project predicates.
func s3PrefixScan(root string, scan s3SessionScanner) []DiscoveredFile {
objects, err := listS3Objects(root)
if err != nil {
return nil
}
machine := s3MachineFromRoot(root, string(scan.Agent))
var out []DiscoveredFile
for _, obj := range objects {
rel, ok := s3RelativePath(root, obj.URI)
if !ok {
continue
}
segs := strings.Split(rel, "/")
if !scan.Keep(rel, segs) {
continue
}
source := obj
if scan.Sidecars != nil {
for _, sidecar := range scan.Sidecars(obj.URI, objects) {
source = foldS3ObjectMetadata(source, sidecar)
}
}
project := ""
if scan.Project != nil {
project = scan.Project(rel, segs)
}
out = append(out, DiscoveredFile{
Path: obj.URI,
Project: project,
Agent: scan.Agent,
Machine: machine,
SourceSize: source.Size,
SourceMtime: source.LastModified.UnixNano(),
SourceFingerprint: source.Fingerprint,
})
}
return out
}
// discoverClaudeS3 lists Claude session JSONL under an s3:// projects root,
// mirroring DiscoverClaudeProjects' selection rules:
// - top-level <project>/<uuid>.jsonl (skip names starting "agent-")
// - subagents .../subagents/.../agent-*.jsonl
//
// Project is the first path segment under the root (e.g. "-home-user-proj").
func discoverClaudeS3(root string) []DiscoveredFile {
return s3PrefixScan(root, s3SessionScanner{
Agent: AgentClaude,
Keep: keepClaudeS3Session,
Project: func(_ string, segs []string) string { return segs[0] },
Sidecars: claudeS3SidecarObjects,
})
}
// keepClaudeS3Session selects Claude transcript objects: a top-level
// <project>/<uuid>.jsonl (excluding agent-* names and any subagents path), or a
// subagent under .../subagents/.../agent-*.jsonl.
func keepClaudeS3Session(rel string, segs []string) bool {
if !strings.HasSuffix(rel, ".jsonl") || len(segs) < 2 {
return false
}
base := segs[len(segs)-1]
if len(segs) >= 4 && segs[2] == "subagents" {
return strings.HasPrefix(base, "agent-")
}
return len(segs) == 2 && !strings.HasPrefix(base, "agent-") &&
!slices.Contains(segs, "subagents")
}
// claudeS3SidecarObjects returns the tool-results sidecar objects (under the
// session's tool-results prefix, plus the parent session's for subagents) whose
// metadata folds into the transcript's freshness identity. It filters the bulk
// listing rather than re-listing per prefix, since the scan already holds every
// object under the root.
func claudeS3SidecarObjects(uri string, all []S3Object) []S3Object {
var matched []S3Object
for _, sidecarRoot := range claudeS3SidecarRoots(uri) {
prefix := strings.TrimSuffix(sidecarRoot, "/") + "/"
for _, candidate := range all {
if strings.HasPrefix(candidate.URI, prefix) {
matched = append(matched, candidate)
}
}
}
return matched
}
// discoverCodexS3 lists Codex rollout-*.jsonl under an s3:// sessions root
// (any depth — Codex nests under 2026/MM/DD/). Project is derived from
// session content, so it is left empty here, as in the local path.
func discoverCodexS3(root string) []DiscoveredFile {
return s3PrefixScan(root, s3SessionScanner{
Agent: AgentCodex,
Keep: func(_ string, segs []string) bool {
return isCodexSessionFilename(segs[len(segs)-1])
},
})
}
// CodexS3SessionIndexURI returns the session_index.jsonl URI adjacent to the
// configured Codex sessions root represented by a rollout URI.
func CodexS3SessionIndexURI(sessionURI string) (string, bool) {
if !strings.HasPrefix(sessionURI, "s3://") {
return "", false
}
trimmed := strings.TrimPrefix(sessionURI, "s3://")
parts := strings.Split(trimmed, "/")
if len(parts) < 2 || !isCodexSessionFilename(parts[len(parts)-1]) {
return "", false
}
for i := len(parts) - 3; i >= 1; i-- {
if parts[i] == "raw" && parts[i+1] == "codex" {
rootEnd := i + 2
if rootEnd < len(parts)-1 &&
(parts[rootEnd] == "sessions" ||
parts[rootEnd] == "archived_sessions") {
return s3URIWithLast(parts[:rootEnd], CodexSessionIndexFilename), true
}
return s3URIWithLast(parts[:i+1], CodexSessionIndexFilename), true
}
}
for i := len(parts) - 2; i >= 1; i-- {
if parts[i] == "sessions" || parts[i] == "archived_sessions" {
return s3URIWithLast(parts[:i], CodexSessionIndexFilename), true
}
}
sessionRootEnd := len(parts) - 1
if len(parts) >= 5 &&
IsDigits(parts[len(parts)-4]) &&
IsDigits(parts[len(parts)-3]) &&
IsDigits(parts[len(parts)-2]) {
sessionRootEnd = len(parts) - 4
}
if sessionRootEnd <= 0 {
return "", false
}
parent := parts[:sessionRootEnd]
if len(parent) > 1 {
parent = parent[:len(parent)-1]
}
return s3URIWithLast(parent, CodexSessionIndexFilename), true
}
func s3URIWithLast(parts []string, last string) string {
out := make([]string, 0, len(parts)+1)
out = append(out, parts...)
out = append(out, last)
return "s3://" + strings.Join(out, "/")
}