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

614 lines
19 KiB
Go

package parser
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// multi_session_container.go provides a reusable source-set, provider, and
// factory for agents whose physical source is a *container* of many sessions
// surfaced to the engine as virtual per-member paths. Shelley (one SQLite DB ->
// many conversations) and Aider (one history file -> many runs) are the first
// two providers built on it; Zed, Kiro, OpenCode, and the other multi-session
// containers can follow.
//
// All agent-specific behavior is supplied through functional options (with*()),
// so a new special case is added as a new option rather than by widening a
// constructor or growing an interface.
// multiSessionSource is the engine-visible Opaque payload for a container
// source. MemberID == "" means the source is the whole container (fan out every
// member on parse); a non-empty MemberID identifies a single member.
type multiSessionSource struct {
Root string
Path string
Container string
MemberID string
}
// multiSessionMatch is what a classifier or member lookup resolves to: the
// canonical source path (a container path or a virtual member path) plus the
// physical container and, for a member, its ID. ProjectHint is surfaced on the
// SourceRef for providers that attribute a project at discovery time.
type multiSessionMatch struct {
Path string
Container string
MemberID string
ProjectHint string
}
type multiSessionConfig struct {
// discoverContainers returns the physical container paths under one root;
// each becomes a whole-container source that fans out on parse.
discoverContainers func(root string) []string
// discoverSources returns fully-formed matches under one root, for providers
// that surface individual members (or a mix of members and containers) at
// discovery time rather than one source per container. Mutually exclusive
// with discoverContainers.
discoverSources func(root string) []multiSessionMatch
// watchRoots returns the provider WatchPlan roots for the configured roots.
watchRoots func(roots []string) []WatchRoot
// classifyPath maps a stored or changed path to its container/member.
// allowMissing relaxes existence checks so a deleted container (or a sibling
// such as a SQLite WAL file) still classifies for changed-path tombstones.
classifyPath func(root, path string, allowMissing bool) (multiSessionMatch, bool)
// findMember resolves a raw session ID to its member match under one root.
findMember func(root, rawID string) (multiSessionMatch, bool)
// storedPathFallback resolves a stored path that classifyPath could not
// match directly (for example a canonical remote-sync path that must be
// mapped back onto a local container). Optional.
storedPathFallback func(root, path string) (multiSessionMatch, bool)
// fingerprint returns the source freshness fingerprint (Size/MTime/Hash);
// the base supplies the Key.
fingerprint func(src multiSessionSource) (SourceFingerprint, error)
// parseContainer parses every member of a container into one result each.
// The full ParseRequest is passed so a closure can read req.Machine and
// per-request hints such as req.Source.ProjectHint.
parseContainer func(src multiSessionSource, req ParseRequest) ([]ParseResult, error)
// parseMember parses a single member; a nil result is a clean no-session.
parseMember func(src multiSessionSource, req ParseRequest) (*ParseResult, error)
// memberPresent reports whether a source still exists for RequireFreshSource
// lookups. Optional; the default treats every source as present.
memberPresent func(src multiSessionSource) bool
// freshStoredMember reports whether a stored member source still resolves to
// the requested raw session ID under RequireFreshSource. Providers with
// positional member IDs (Aider's run index) set this so a stored path whose
// index now points at a different run is rejected and re-resolved by raw ID.
// Optional; when nil only memberPresent gates freshness.
freshStoredMember func(src multiSessionSource, rawID string) bool
// stampContainerHash stamps the request fingerprint hash onto every fanned
// out container result (used when all members share the container's content
// hash). Member parses are always stamped.
stampContainerHash bool
}
type MultiSessionOption func(*multiSessionConfig)
func multiSessionContainerSourceCapabilities(
compositeFingerprint CapabilitySupport,
storedSourceHints CapabilitySupport,
) SourceCapabilities {
return SourceCapabilities{
DiscoverSources: CapabilitySupported,
WatchSources: CapabilitySupported,
ClassifyChangedPath: CapabilitySupported,
StoredSourceHints: storedSourceHints,
FindSource: CapabilitySupported,
CompositeFingerprint: compositeFingerprint,
IncrementalAppend: CapabilityNotApplicable,
MultiSessionSource: CapabilitySupported,
PerSessionErrors: CapabilityNotApplicable,
ExcludedSessions: CapabilityNotApplicable,
ForceReplaceOnParse: CapabilitySupported,
}
}
func WithContainerDiscovery(fn func(root string) []string) MultiSessionOption {
return func(c *multiSessionConfig) { c.discoverContainers = fn }
}
func WithSourceDiscovery(
fn func(root string) []multiSessionMatch,
) MultiSessionOption {
return func(c *multiSessionConfig) { c.discoverSources = fn }
}
func WithWatchRoots(fn func(roots []string) []WatchRoot) MultiSessionOption {
return func(c *multiSessionConfig) { c.watchRoots = fn }
}
func WithChangedPathClassifier(
fn func(root, path string, allowMissing bool) (multiSessionMatch, bool),
) MultiSessionOption {
return func(c *multiSessionConfig) { c.classifyPath = fn }
}
func WithMemberLookup(
fn func(root, rawID string) (multiSessionMatch, bool),
) MultiSessionOption {
return func(c *multiSessionConfig) { c.findMember = fn }
}
func WithStoredPathFallback(
fn func(root, path string) (multiSessionMatch, bool),
) MultiSessionOption {
return func(c *multiSessionConfig) { c.storedPathFallback = fn }
}
func WithFingerprint(
fn func(src multiSessionSource) (SourceFingerprint, error),
) MultiSessionOption {
return func(c *multiSessionConfig) { c.fingerprint = fn }
}
func WithContainerParse(
fn func(src multiSessionSource, req ParseRequest) ([]ParseResult, error),
) MultiSessionOption {
return func(c *multiSessionConfig) { c.parseContainer = fn }
}
func WithMemberParse(
fn func(src multiSessionSource, req ParseRequest) (*ParseResult, error),
) MultiSessionOption {
return func(c *multiSessionConfig) { c.parseMember = fn }
}
func WithMemberPresence(fn func(src multiSessionSource) bool) MultiSessionOption {
return func(c *multiSessionConfig) { c.memberPresent = fn }
}
func WithFreshStoredMember(
fn func(src multiSessionSource, rawID string) bool,
) MultiSessionOption {
return func(c *multiSessionConfig) { c.freshStoredMember = fn }
}
func WithContainerHashStamping() MultiSessionOption {
return func(c *multiSessionConfig) { c.stampContainerHash = true }
}
func NewMultiSessionContainerSourceSet(
agent AgentType,
roots []string,
opts ...MultiSessionOption,
) multiSessionContainerSourceSet {
cfg := multiSessionConfig{}
for _, opt := range opts {
opt(&cfg)
}
switch {
case cfg.discoverContainers == nil && cfg.discoverSources == nil:
panic("multi-session container: missing WithContainerDiscovery or WithSourceDiscovery")
case cfg.watchRoots == nil:
panic("multi-session container: missing WithWatchRoots")
case cfg.classifyPath == nil:
panic("multi-session container: missing WithChangedPathClassifier")
case cfg.findMember == nil:
panic("multi-session container: missing WithMemberLookup")
case cfg.fingerprint == nil:
panic("multi-session container: missing WithFingerprint")
case cfg.parseContainer == nil:
panic("multi-session container: missing WithContainerParse")
case cfg.parseMember == nil:
panic("multi-session container: missing WithMemberParse")
}
return multiSessionContainerSourceSet{
agent: agent,
roots: cleanJSONLRoots(roots),
cfg: cfg,
}
}
type multiSessionContainerSourceSet struct {
agent AgentType
roots []string
cfg multiSessionConfig
}
func (s multiSessionContainerSourceSet) 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
}
for _, match := range s.discoverMatches(root) {
if match.Path == "" {
continue
}
addJSONLSource(s.sourceRef(root, match), &sources, seen)
}
}
sortJSONLSources(sources)
return sources, nil
}
// discoverMatches yields the discovery matches for one root: either the
// member-level matches from WithSourceDiscovery, or one whole-container match
// per path from WithContainerDiscovery.
func (s multiSessionContainerSourceSet) discoverMatches(
root string,
) []multiSessionMatch {
if s.cfg.discoverSources != nil {
return s.cfg.discoverSources(root)
}
containers := s.cfg.discoverContainers(root)
out := make([]multiSessionMatch, 0, len(containers))
for _, container := range containers {
if container == "" {
continue
}
out = append(out, multiSessionMatch{Path: container, Container: container})
}
return out
}
func (s multiSessionContainerSourceSet) WatchPlan(
context.Context,
) (WatchPlan, error) {
return WatchPlan{Roots: s.cfg.watchRoots(s.roots)}, nil
}
func (s multiSessionContainerSourceSet) SourcesForChangedPath(
ctx context.Context,
req ChangedPathRequest,
) ([]SourceRef, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, root := range s.roots {
match, ok := s.cfg.classifyPath(root, req.Path, true)
if !ok {
continue
}
tombstones := s.changedPathTombstones(root, match, req.StoredSourcePaths)
sources := make([]SourceRef, 0, 1+len(tombstones))
if req.EventKind != "remove" ||
len(tombstones) == 0 ||
IsRegularFile(match.Container) {
sources = append(sources, s.sourceRef(root, match))
}
sources = append(sources, tombstones...)
return sources, nil
}
return nil, nil
}
// changedPathTombstones emits a per-member source for every stored member that
// belongs to the changed container, is no longer present, yet whose container
// file still exists. The whole-container source re-writes the surviving
// members; without these, a member row deleted from a still-present container
// is never force-replaced and a stale session lingers, diverging from the
// db-backed providers that drop it. A vanished shared container still yields no
// tombstones, preserving the archive when the whole source file disappears; a
// vanished one-file-per-member container emits the stored member tombstone so
// the deleted row is force-replaced instead of lingering forever.
func (s multiSessionContainerSourceSet) changedPathTombstones(
root string,
changed multiSessionMatch,
storedPaths []string,
) []SourceRef {
if changed.MemberID != "" || changed.Container == "" {
return nil
}
containerExists := IsRegularFile(changed.Container)
var tombstones []SourceRef
seen := make(map[string]struct{})
for _, stored := range storedPaths {
match, ok := s.cfg.classifyPath(root, stored, true)
if !ok || match.MemberID == "" {
continue
}
if !samePath(match.Container, changed.Container) {
continue
}
if !containerExists {
if !multiSessionMatchOwnsContainer(match) {
continue
}
if current, ok := s.cfg.findMember(root, match.MemberID); ok &&
!samePath(current.Container, match.Container) {
if _, dup := seen[current.Path]; dup {
continue
}
seen[current.Path] = struct{}{}
tombstones = append(tombstones, s.sourceRef(root, current))
continue
}
} else if s.memberPresent(match.toSource(root)) {
continue
}
if _, dup := seen[match.Path]; dup {
continue
}
seen[match.Path] = struct{}{}
tombstones = append(tombstones, s.sourceRef(root, match))
}
return tombstones
}
func multiSessionMatchOwnsContainer(
match multiSessionMatch,
) bool {
return multiSessionMemberOwnsContainer(match.MemberID, match.Container)
}
func (s multiSessionContainerSourceSet) 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 {
match, ok := s.cfg.classifyPath(root, path, false)
if !ok {
continue
}
memberSrc := match.toSource(root)
if req.RequireFreshSource && !s.memberPresent(memberSrc) {
continue
}
if s.staleStoredMember(memberSrc, req) {
continue
}
return s.sourceRef(root, match), true, nil
}
if s.cfg.storedPathFallback != nil {
for _, root := range s.roots {
if match, ok := s.cfg.storedPathFallback(root, path); ok {
if s.staleStoredMember(match.toSource(root), req) {
continue
}
return s.sourceRef(root, match), true, nil
}
}
}
}
if req.RawSessionID == "" {
return SourceRef{}, false, nil
}
for _, root := range s.roots {
if err := ctx.Err(); err != nil {
return SourceRef{}, false, err
}
if match, ok := s.cfg.findMember(root, req.RawSessionID); ok {
return s.sourceRef(root, match), true, nil
}
}
return SourceRef{}, false, nil
}
func (s multiSessionContainerSourceSet) 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", s.agent)
}
fingerprint, err := s.cfg.fingerprint(src)
if err != nil {
if errors.Is(err, os.ErrNotExist) &&
multiSessionSourceOwnsContainer(src) {
fingerprint = SourceFingerprint{}
} else {
return SourceFingerprint{}, err
}
}
fingerprint.Key = firstNonEmptyJSONLString(
source.FingerprintKey, source.Key, src.Path,
)
return fingerprint, nil
}
func (s multiSessionContainerSourceSet) parse(
src multiSessionSource, req ParseRequest,
) (ParseOutcome, error) {
fingerprintHash := req.Fingerprint.Hash
if src.MemberID != "" {
result, err := s.cfg.parseMember(src, req)
if err != nil {
return ParseOutcome{}, err
}
if result == nil {
return s.skipOutcome(src), nil
}
if fingerprintHash != "" {
result.Session.File.Hash = fingerprintHash
}
return ParseOutcome{
Results: []ParseResultOutcome{{
Result: *result,
DataVersion: DataVersionCurrent,
}},
ResultSetComplete: true,
ForceReplace: true,
}, nil
}
results, err := s.cfg.parseContainer(src, req)
if err != nil {
return ParseOutcome{}, err
}
if len(results) == 0 {
return s.skipOutcome(src), nil
}
out := make([]ParseResultOutcome, 0, len(results))
for i := range results {
if fingerprintHash != "" && s.cfg.stampContainerHash {
results[i].Session.File.Hash = fingerprintHash
}
out = append(out, ParseResultOutcome{
Result: results[i],
DataVersion: DataVersionCurrent,
})
}
return ParseOutcome{
Results: out,
ResultSetComplete: true,
ForceReplace: true,
}, nil
}
// skipOutcome builds the "no session" outcome for a container/member that
// produced no results. When the backing container file is gone, whole-container
// sources preserve the stored sessions because the archive survives a vanished
// source file. Member tombstones for one-file-per-member layouts still
// force-replace so a deleted session row does not linger forever.
func (s multiSessionContainerSourceSet) skipOutcome(src multiSessionSource) ParseOutcome {
if src.Container != "" && !IsRegularFile(src.Container) {
if multiSessionSourceOwnsContainer(src) {
return ParseOutcome{
ResultSetComplete: true,
ForceReplace: true,
SkipReason: SkipNoSession,
}
}
return ParseOutcome{
ResultSetComplete: true,
SkipReason: SkipNoSession,
}
}
return ParseOutcome{
ResultSetComplete: true,
ForceReplace: true,
SkipReason: SkipNoSession,
}
}
func multiSessionSourceOwnsContainer(src multiSessionSource) bool {
return multiSessionMemberOwnsContainer(src.MemberID, src.Container)
}
func multiSessionMemberOwnsContainer(memberID, container string) bool {
if memberID == "" {
return false
}
base := filepath.Base(container)
if memberID == base {
return true
}
return isVisualStudioCopilotVS2026SessionID(memberID) &&
isVisualStudioCopilotVS2026SessionID(base) &&
strings.EqualFold(memberID, base)
}
func (s multiSessionContainerSourceSet) memberPresent(src multiSessionSource) bool {
if s.cfg.memberPresent == nil {
return true
}
return s.cfg.memberPresent(src)
}
// staleStoredMember reports whether a RequireFreshSource lookup must reject a
// stored member source because it no longer resolves to the requested raw
// session ID. It only fires when the provider supplies freshStoredMember and a
// raw ID is available, so providers with stable member IDs are unaffected.
func (s multiSessionContainerSourceSet) staleStoredMember(
src multiSessionSource, req FindSourceRequest,
) bool {
if !req.RequireFreshSource || req.RawSessionID == "" {
return false
}
if s.cfg.freshStoredMember == nil {
return false
}
return !s.cfg.freshStoredMember(src, req.RawSessionID)
}
func (s multiSessionContainerSourceSet) sourceRef(
root string, match multiSessionMatch,
) SourceRef {
return SourceRef{
Provider: s.agent,
Key: match.Path,
DisplayPath: match.Path,
FingerprintKey: match.Path,
ProjectHint: match.ProjectHint,
Opaque: match.toSource(root),
}
}
func (m multiSessionMatch) toSource(root string) multiSessionSource {
return multiSessionSource{
Root: root,
Path: m.Path,
Container: m.Container,
MemberID: m.MemberID,
}
}
func (s multiSessionContainerSourceSet) sourceFromRef(
source SourceRef,
) (multiSessionSource, bool) {
switch src := source.Opaque.(type) {
case multiSessionSource:
return src, src.Container != "" && src.Path != ""
case *multiSessionSource:
if src != nil && src.Container != "" && src.Path != "" {
return *src, true
}
}
for _, candidate := range []string{
source.DisplayPath, source.FingerprintKey, source.Key,
} {
if candidate == "" {
continue
}
for _, root := range s.roots {
if match, ok := s.cfg.classifyPath(root, candidate, false); ok {
return match.toSource(root), true
}
}
}
return multiSessionSource{}, false
}
var _ SourceSet = multiSessionContainerSourceSet{}
// Parse resolves the request's source and parses it: a member source yields one
// result, a container source fans out every member. It satisfies the SourceSet
// interface; SourceSetProvider applies the request/config machine fallback
// before calling in, so req.Machine is already resolved here.
func (s multiSessionContainerSourceSet) Parse(
ctx context.Context,
req ParseRequest,
) (ParseOutcome, error) {
if err := ctx.Err(); err != nil {
return ParseOutcome{}, err
}
src, ok := s.sourceFromRef(req.Source)
if !ok {
return ParseOutcome{}, fmt.Errorf("%s source path unavailable", s.agent)
}
return s.parse(src, req)
}
// NewMultiSessionProviderFactory builds a ProviderFactory for a multi-session
// container provider. It is a thin adapter over the generic SourceSetFactory;
// the build closure constructs the agent's configured source set.
func NewMultiSessionProviderFactory(
def AgentDef,
caps Capabilities,
build func(cfg ProviderConfig) multiSessionContainerSourceSet,
) ProviderFactory {
return NewSourceSetFactory(
def, caps,
func(cfg ProviderConfig) SourceSet { return build(cfg) },
)
}