From 031798b4b438f77ccb1e31dea30d074ad4a90264 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 21:07:44 -0400 Subject: [PATCH] feat(parser): migrate cowork provider Cowork stores Claude-shaped transcripts behind local-agent metadata, so the provider boundary needs to preserve that metadata-to-transcript relationship instead of treating the files as plain Claude JSONL sources. The concrete provider keeps shallow metadata watching, metadata change classification, subagent transcript discovery, raw/full ID lookup, composite mtime freshness, and hash propagation explicit for the sync path. fix(parser): cover cowork nested watch events Cowork metadata and transcripts live below org/workspace/session directories, so a shallow root watch could not deliver the paths the provider claimed to classify. Deleted metadata also lost the JSON needed to resolve the transcript, leaving stale provider state after remove or rename events. Make the watch plan recursive for Cowork source globs, recover deleted metadata from the local session directory shape, cover removed metadata/main/subagent paths, and move Cowork into shadow comparison as its branch-local migration step. Validation: go test -tags "fts5" ./internal/parser -run 'Test(CoworkProvider|ProviderMigrationModes)' -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check fix(parser): reject ambiguous cowork metadata removal Deleted Cowork metadata can only be recovered from the local session directory shape. If that directory contains multiple main transcripts, choosing the first filesystem match would attach the event to an arbitrary source and leave the real stale source unresolved. Refuse ambiguous deleted-metadata recovery unless exactly one main transcript is present, and cover the multi-transcript case. The regular single-transcript metadata removal path remains supported. Validation: go test -tags "fts5" ./internal/parser -run 'Test(CoworkProvider|ProviderMigrationModes)' -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check fix(parser): validate cowork deleted metadata candidates Cowork metadata deletion recovery scans project directories after the metadata file is gone, so it cannot rely on the normal metadata-guided resolution path. It still needs the same transcript validity rules as normal discovery: regular files only, and symlink targets must stay inside the local session directory. Apply that validation before selecting or counting fallback candidates so symlink escapes are ignored and broken symlinks do not create false ambiguity. Validation: go test -tags "fts5" ./internal/parser -run 'TestCoworkProvider|TestResolveCoworkSessionRejectsSymlinkEscape|TestClassifyCoworkPath|TestParseCowork' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; git diff --check test(sync): compare cowork shadow parity Cowork is a sidecar-backed Claude transcript provider, so add source-level migration coverage that compares provider observation with ParseCoworkSession. The fixture includes local-agent metadata plus the nested Claude transcript and verifies session, messages, usage, excluded IDs, and data-version planning parity while preserving provider-computed hashes. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesCoworkLegacyParser|TestCoworkProvider|TestParseCowork|TestClassifyCoworkPath' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; git diff --check refactor(parser): fold cowork into provider Move Cowork source discovery, lookup, parse, and changed-path classification onto the concrete coworkProvider and delete the package-level DiscoverCoworkSessions, FindCoworkSourceFile, ParseCoworkSession, and ClassifyCoworkPath free functions. Discovery and find-source bodies now live as provider-owned helpers (discoverTranscriptPaths, coworkFindSourceFile), parseSession is a receiver method, and the metadata-to-transcript classifier moves onto SourcesForChangedPath as classifyCoworkPath so a sibling local_.json change still resolves to the session's main transcript. Make Cowork provider-authoritative and drop its legacy sync dispatch: the classifyOnePath cowork block, the processFile case arm, and the processCowork method. The sibling-meta composite freshness is preserved on the provider's Fingerprint, which already folds CoworkSessionMtime (the max of transcript and metadata mtime) into the freshness identity so a title-only rename triggers a reparse through processProviderFile. CoworkSessionMtime stays exported and the engine's skip-cache and SourceMtime watcher-fallback blocks keep calling it, mirroring how the commandcode fold retained commandCodeEffectiveInfo. Replace the legacy free-function tests with provider API coverage plus a guard asserting the four entrypoints stay gone, drop the shadow-baseline comparison test, relocate the shared writeProviderShadowSourceFile helper into provider_shadow_support_test.go, and remove cowork_provider.go from the pending-shim scan list. test(sync): drop obsolete cowork shadow-legacy tests Folding cowork into its provider removes its legacy processFile arm, so the two shadow-compare tests that built fixtures via the deleted parser.ParseCoworkSession and asserted a legacy result coexisting with the shadow provider can no longer pass: a non-authoritative cowork file now falls through to the unknown-agent default. The shadow machinery keeps coverage through provider_shadow_test.go and the cached-skip not-comparable case. fix(sync): skip fresh cowork provider sources Cowork moved behind the provider-authoritative sync path, but the migrated path still fingerprinted and parsed unchanged transcripts before checking the stored file metadata. That dropped the cheap DB freshness gate the legacy Cowork path relied on and made full syncs rewrite fresh sessions unnecessarily.\n\nRestore that gate for Cowork before provider fingerprinting, using the same transcript size plus CoworkSessionMtime identity stored in the database. Per-file force parses still bypass the gate so metadata-driven refreshes and explicit reparses continue to reach the provider.\n\nValidation: go test -tags "fts5" ./internal/sync -run 'TestProcessFileProviderAuthoritative(SkipsFreshCoworkBeforeFingerprint|ForceParseBypassesFreshCoworkSkip)|TestSyncAllSinceCoworkMetaUpdateTriggersResync|TestSyncPathsCoworkReplacesUpdatedMessageOrdinal' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go vet ./...; git diff --check --- internal/parser/cowork.go | 98 +---- internal/parser/cowork_provider.go | 331 ++++++++++++++++ internal/parser/cowork_provider_test.go | 377 +++++++++++++++++++ internal/parser/cowork_test.go | 142 +++++-- internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/provider_shim_scan_test.go | 1 - internal/parser/types.go | 18 +- internal/parser/types_test.go | 6 +- internal/sync/engine.go | 102 ++--- internal/sync/provider_shadow_caller_test.go | 345 +++++++---------- 11 files changed, 1019 insertions(+), 405 deletions(-) create mode 100644 internal/parser/cowork_provider.go create mode 100644 internal/parser/cowork_provider_test.go diff --git a/internal/parser/cowork.go b/internal/parser/cowork.go index 4a32299d3..db91856a9 100644 --- a/internal/parser/cowork.go +++ b/internal/parser/cowork.go @@ -7,7 +7,6 @@ import ( "encoding/json" "os" "path/filepath" - "slices" "sort" "strings" "time" @@ -246,89 +245,6 @@ func walkCoworkSessions(root string, fn func(transcriptPath string)) { ) } -// DiscoverCoworkSessions finds all cowork session transcripts under root, -// including subagent transcripts. -func DiscoverCoworkSessions(root string) []DiscoveredFile { - var files []DiscoveredFile - walkCoworkSessions(root, func(transcript string) { - files = append(files, DiscoveredFile{ - Path: transcript, - Agent: AgentCowork, - }) - }) - return files -} - -// FindCoworkSourceFile locates a cowork transcript by its raw session ID -// (the cliSessionId or "agent-" subagent id, with the "cowork:" prefix -// already stripped). -func FindCoworkSourceFile(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: //subagents/**/agent-*.jsonl. - if slices.Contains(parts, "subagents") { - return path, true - } - return "", false - } - // Main transcript: /.jsonl directly under projects. - if n >= 5 && parts[n-4] == ".claude" && parts[n-3] == "projects" && - IsValidSessionID(stem) { - return path, true - } - return "", false - } - - // Metadata: //local_.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 -} - // relUnder returns the path of child relative to dir when child is // strictly contained within dir, mirroring the engine's isUnder helper so // the parser can classify paths without importing sync internals. @@ -377,13 +293,13 @@ func extractCoworkAITitle(transcriptPath string) string { return title } -// ParseCoworkSession parses a cowork session transcript. It reuses the -// Claude Code parser on the transcript and then rewrites the results into -// the cowork namespace: agent type, "cowork:"-prefixed IDs, the session -// title, and metadata-derived timestamps for transcripts that carry none. -// Returns parsed results plus session IDs the parser intentionally -// excluded (prefixed), matching the Claude transcript parser. -func ParseCoworkSession( +// parseSession parses a cowork session transcript. It reuses the Claude +// Code parser on the transcript and then rewrites the results into the +// cowork namespace: agent type, "cowork:"-prefixed IDs, the session title, +// and metadata-derived timestamps for transcripts that carry none. Returns +// parsed results plus session IDs the parser intentionally excluded +// (prefixed), matching ParseClaudeSessionWithExclusions. +func parseCoworkSession( transcriptPath, machine string, ) ([]ParseResult, []string, error) { metaPath := coworkMetaPathForTranscript(transcriptPath) diff --git a/internal/parser/cowork_provider.go b/internal/parser/cowork_provider.go new file mode 100644 index 000000000..35c0c129b --- /dev/null +++ b/internal/parser/cowork_provider.go @@ -0,0 +1,331 @@ +package parser + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +// Cowork stores each session as a Claude-format transcript +// (.claude/projects/**/.jsonl) with a sibling local_.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-" 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: //subagents/**/agent-*.jsonl. + if slices.Contains(parts, "subagents") { + return path, true + } + return "", false + } + // Main transcript: /.jsonl directly under projects. + if n >= 5 && parts[n-4] == ".claude" && parts[n-3] == "projects" && + IsValidSessionID(stem) { + return path, true + } + return "", false + } + + // Metadata: //local_.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, + }, + } +} diff --git a/internal/parser/cowork_provider_test.go b/internal/parser/cowork_provider_test.go new file mode 100644 index 000000000..7756f2c85 --- /dev/null +++ b/internal/parser/cowork_provider_test.go @@ -0,0 +1,377 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCoworkProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentCowork) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestCoworkProviderSourceMethods(t *testing.T) { + root := t.TempDir() + cli := "c0000000-0000-4000-8000-000000000101" + metaPath, transcript := writeCoworkSession(t, root, coworkFixture{ + org: "org", + workspace: "ws", + sessionUUID: "50000000-0000-4000-8000-000000000101", + cliSessionID: cli, + encodedProject: "-Users-dev-code-demo", + title: "Provider title", + folders: []string{"/Users/dev/code/demo"}, + transcriptLines: coworkTranscriptLines(cli), + }) + subagentPath := filepath.Join( + filepath.Dir(transcript), + cli, + "subagents", + "tasks", + "agent-worker.jsonl", + ) + writeSourceFile(t, subagentPath, strings.Join(coworkTranscriptLines(cli), "\n")+"\n") + writeSourceFile( + t, + filepath.Join(filepath.Dir(transcript), cli, "subagents", "not-agent.jsonl"), + strings.Join(coworkTranscriptLines(cli), "\n")+"\n", + ) + writeSourceFile( + t, + filepath.Join(root, "org", "ws", "cowork-clientdata-cache.json"), + "{}\n", + ) + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"local_*.json", "*.jsonl"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.ElementsMatch(t, []string{transcript, subagentPath}, []string{ + discovered[0].DisplayPath, + discovered[1].DisplayPath, + }) + for _, source := range discovered { + assert.Equal(t, AgentCowork, source.Provider) + assert.Equal(t, "demo", source.ProjectHint) + assert.Equal(t, source.DisplayPath, source.FingerprintKey) + } + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "remote~cowork:" + cli, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, transcript, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "agent-worker", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, subagentPath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: transcript, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, transcript, found.DisplayPath) + + transcriptInfo, err := os.Stat(transcript) + require.NoError(t, err) + newer := transcriptInfo.ModTime().Add(time.Hour) + require.NoError(t, os.Chtimes(metaPath, newer, newer)) + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, transcript, fingerprint.Key) + assert.Equal(t, transcriptInfo.Size(), fingerprint.Size) + assert.Equal(t, newer.UnixNano(), fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + for _, tc := range []struct { + name string + path string + want string + }{ + {name: "main transcript", path: transcript, want: transcript}, + {name: "subagent transcript", path: subagentPath, want: subagentPath}, + {name: "metadata", path: metaPath, want: transcript}, + } { + t.Run(tc.name, func(t *testing.T) { + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: tc.path, + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, tc.want, changed[0].DisplayPath) + }) + } + + require.NoError(t, os.Remove(metaPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, transcript, changed[0].DisplayPath) + + require.NoError(t, os.Remove(transcript)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: transcript, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, transcript, changed[0].DisplayPath) + + require.NoError(t, os.Remove(subagentPath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: subagentPath, EventKind: "rename", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, subagentPath, changed[0].DisplayPath) + + ignored, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "org", "ws", "cowork-clientdata-cache.json"), + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, ignored) + + wrongRoot, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: transcript, + EventKind: "write", + WatchRoot: filepath.Join(root, "..", "other-root"), + }, + ) + require.NoError(t, err) + assert.Empty(t, wrongRoot) +} + +func TestCoworkProviderParse(t *testing.T) { + root := t.TempDir() + cli := "c0000000-0000-4000-8000-000000000102" + _, transcript := writeCoworkSession(t, root, coworkFixture{ + org: "org", + workspace: "ws", + sessionUUID: "50000000-0000-4000-8000-000000000102", + cliSessionID: cli, + encodedProject: "-sessions-demo", + title: "Parse title", + transcriptLines: coworkTranscriptLines(cli), + }) + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + fingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.False(t, outcome.ForceReplace) + require.Empty(t, outcome.ExcludedSessionIDs) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "cowork:"+cli, result.Result.Session.ID) + assert.Equal(t, AgentCowork, result.Result.Session.Agent) + assert.Equal(t, "cowork", result.Result.Session.Project) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, transcript, result.Result.Session.File.Path) + assert.Equal(t, fingerprint.Hash, result.Result.Session.File.Hash) + assert.Equal(t, "Parse title", result.Result.Session.SessionName) + assert.Equal(t, "hello there", result.Result.Session.FirstMessage) + assert.Len(t, result.Result.Messages, 2) +} + +func TestCoworkProviderMetadataRemovalRejectsAmbiguousMainTranscripts(t *testing.T) { + root := t.TempDir() + cli := "c0000000-0000-4000-8000-000000000104" + metaPath, transcript := writeCoworkSession(t, root, coworkFixture{ + org: "org", + workspace: "ws", + sessionUUID: "50000000-0000-4000-8000-000000000104", + cliSessionID: cli, + encodedProject: "-sessions-demo", + transcriptLines: coworkTranscriptLines(cli), + }) + otherPath := filepath.Join( + filepath.Dir(filepath.Dir(transcript)), + "-sessions-other", + "c0000000-0000-4000-8000-000000000105.jsonl", + ) + writeSourceFile( + t, + otherPath, + strings.Join(coworkTranscriptLines("c0000000-0000-4000-8000-000000000105"), "\n")+"\n", + ) + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + require.NoError(t, os.Remove(metaPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func TestCoworkProviderMetadataRemovalIgnoresSymlinkEscape(t *testing.T) { + root := t.TempDir() + cli := "c0000000-0000-4000-8000-000000000106" + metaPath, _ := writeCoworkSession(t, root, coworkFixture{ + org: "org", + workspace: "ws", + sessionUUID: "50000000-0000-4000-8000-000000000106", + cliSessionID: cli, + encodedProject: "-sessions-demo", + transcriptLines: coworkTranscriptLines(cli), + }) + sessionDir := strings.TrimSuffix(metaPath, ".json") + projectsDir := filepath.Join(sessionDir, ".claude", "projects") + outside := filepath.Join(root, "outside") + require.NoError(t, os.MkdirAll(outside, 0o755)) + writeSourceFile( + t, + filepath.Join(outside, "c0000000-0000-4000-8000-000000000107.jsonl"), + strings.Join(coworkTranscriptLines("c0000000-0000-4000-8000-000000000107"), "\n")+"\n", + ) + if err := os.Symlink(outside, filepath.Join(projectsDir, "-sessions-escape")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + require.NoError(t, os.Remove(metaPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, cli+".jsonl", filepath.Base(changed[0].DisplayPath)) +} + +func TestCoworkProviderMetadataRemovalIgnoresBrokenSymlinkAmbiguity(t *testing.T) { + root := t.TempDir() + cli := "c0000000-0000-4000-8000-000000000108" + metaPath, _ := writeCoworkSession(t, root, coworkFixture{ + org: "org", + workspace: "ws", + sessionUUID: "50000000-0000-4000-8000-000000000108", + cliSessionID: cli, + encodedProject: "-sessions-demo", + transcriptLines: coworkTranscriptLines(cli), + }) + sessionDir := strings.TrimSuffix(metaPath, ".json") + projectsDir := filepath.Join(sessionDir, ".claude", "projects") + brokenDir := filepath.Join(projectsDir, "-sessions-broken") + require.NoError(t, os.MkdirAll(brokenDir, 0o755)) + if err := os.Symlink( + filepath.Join(root, "missing.jsonl"), + filepath.Join(brokenDir, "c0000000-0000-4000-8000-000000000109.jsonl"), + ); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + require.NoError(t, os.Remove(metaPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: metaPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, cli+".jsonl", filepath.Base(changed[0].DisplayPath)) +} + +func TestCoworkProviderFullSessionIDPrefixLookup(t *testing.T) { + root := t.TempDir() + cli := "c0000000-0000-4000-8000-000000000103" + _, transcript := writeCoworkSession(t, root, coworkFixture{ + org: "org", + workspace: "ws", + sessionUUID: "50000000-0000-4000-8000-000000000103", + cliSessionID: cli, + encodedProject: "-sessions-demo", + transcriptLines: coworkTranscriptLines(cli), + }) + + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) + + for _, id := range []string{"cowork:" + cli, "remote~cowork:" + cli} { + t.Run(strings.ReplaceAll(id, ":", "_"), func(t *testing.T) { + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: id, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, transcript, found.DisplayPath) + }) + } +} diff --git a/internal/parser/cowork_test.go b/internal/parser/cowork_test.go index eaf92b6bf..365bf2d87 100644 --- a/internal/parser/cowork_test.go +++ b/internal/parser/cowork_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "encoding/json" "os" "path/filepath" @@ -13,6 +14,54 @@ import ( "github.com/stretchr/testify/require" ) +// coworkProviderForRoot constructs a cowork provider rooted at root. +func coworkProviderForRoot(t *testing.T, root, machine string) Provider { + t.Helper() + provider, ok := NewProvider(AgentCowork, ProviderConfig{ + Roots: []string{root}, + Machine: machine, + }) + require.True(t, ok) + return provider +} + +// coworkDiscoveredPaths returns the transcript paths the provider discovers +// under root. +func coworkDiscoveredPaths(t *testing.T, root string) []string { + t.Helper() + sources, err := coworkProviderForRoot(t, root, "").Discover(context.Background()) + require.NoError(t, err) + paths := make([]string, len(sources)) + for i, source := range sources { + paths[i] = source.DisplayPath + } + return paths +} + +// coworkParseTranscript finds and parses a single cowork transcript through +// the provider, returning the parse results and any excluded session IDs. +func coworkParseTranscript( + t *testing.T, root, transcript, machine string, +) ([]ParseResult, []string) { + t.Helper() + provider := coworkProviderForRoot(t, root, machine) + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: transcript, + }) + require.NoError(t, err) + require.True(t, ok, "find source for %s", transcript) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Machine: machine, + }) + require.NoError(t, err) + results := make([]ParseResult, len(outcome.Results)) + for i, out := range outcome.Results { + results[i] = out.Result + } + return results, outcome.ExcludedSessionIDs +} + // All identifiers, titles, and content below are synthetic fixtures. // coworkFixture describes one cowork session to materialize on disk. @@ -91,7 +140,7 @@ func coworkTranscriptLines(cli string) []string { } } -func TestDiscoverCoworkSessions(t *testing.T) { +func TestCoworkProviderDiscoversSessions(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000001" _, transcript := writeCoworkSession(t, root, coworkFixture{ @@ -104,13 +153,12 @@ func TestDiscoverCoworkSessions(t *testing.T) { transcriptLines: coworkTranscriptLines(cli), }) - got := DiscoverCoworkSessions(root) - require.Len(t, got, 1, "discovered files") - assert.Equal(t, transcript, got[0].Path, "Path") - assert.Equal(t, AgentCowork, got[0].Agent, "Agent") + got := coworkDiscoveredPaths(t, root) + require.Len(t, got, 1, "discovered sources") + assert.Equal(t, transcript, got[0], "DisplayPath") } -func TestDiscoverCoworkSessionsIgnoresNoise(t *testing.T) { +func TestCoworkProviderDiscoverIgnoresNoise(t *testing.T) { root := t.TempDir() wsDir := filepath.Join(root, "org", "ws") require.NoError(t, os.MkdirAll(wsDir, 0o755), "mkdir ws") @@ -145,10 +193,10 @@ func TestDiscoverCoworkSessionsIgnoresNoise(t *testing.T) { "write transcript-less meta", ) - assert.Empty(t, DiscoverCoworkSessions(root)) + assert.Empty(t, coworkDiscoveredPaths(t, root)) } -func TestParseCoworkSession(t *testing.T) { +func TestCoworkProviderParsesSession(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000002" _, transcript := writeCoworkSession(t, root, coworkFixture{ @@ -163,8 +211,7 @@ func TestParseCoworkSession(t *testing.T) { transcriptLines: coworkTranscriptLines(cli), }) - results, excluded, err := ParseCoworkSession(transcript, "host-1") - require.NoError(t, err, "parse") + results, excluded := coworkParseTranscript(t, root, transcript, "host-1") require.Empty(t, excluded, "excluded") require.Len(t, results, 1, "results") @@ -185,7 +232,7 @@ func TestParseCoworkSession(t *testing.T) { assert.Equal(t, 12, sess.PeakContextTokens, "PeakContextTokens (input+cacheRead)") } -func TestParseCoworkSessionTitleFallsBackToAITitle(t *testing.T) { +func TestCoworkProviderParseTitleFallsBackToAITitle(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000003" _, transcript := writeCoworkSession(t, root, coworkFixture{ @@ -198,14 +245,13 @@ func TestParseCoworkSessionTitleFallsBackToAITitle(t *testing.T) { transcriptLines: coworkTranscriptLines(cli), }) - results, _, err := ParseCoworkSession(transcript, "host-1") - require.NoError(t, err, "parse") + results, _ := coworkParseTranscript(t, root, transcript, "host-1") require.Len(t, results, 1, "results") assert.Equal(t, "Auto title", results[0].Session.SessionName, "falls back to ai-title event") } -func TestParseCoworkSessionProjectFromSelectedFolder(t *testing.T) { +func TestCoworkProviderParseProjectFromSelectedFolder(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000004" _, transcript := writeCoworkSession(t, root, coworkFixture{ @@ -219,14 +265,13 @@ func TestParseCoworkSessionProjectFromSelectedFolder(t *testing.T) { transcriptLines: coworkTranscriptLines(cli), }) - results, _, err := ParseCoworkSession(transcript, "host-1") - require.NoError(t, err, "parse") + results, _ := coworkParseTranscript(t, root, transcript, "host-1") require.Len(t, results, 1, "results") assert.Equal(t, "my_app", results[0].Session.Project, "project derived from userSelectedFolders") } -func TestFindCoworkSourceFile(t *testing.T) { +func TestCoworkProviderFindsSourceFile(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000005" _, transcript := writeCoworkSession(t, root, coworkFixture{ @@ -239,11 +284,22 @@ func TestFindCoworkSourceFile(t *testing.T) { transcriptLines: coworkTranscriptLines(cli), }) - assert.Equal(t, transcript, FindCoworkSourceFile(root, cli), "found") - assert.Empty(t, FindCoworkSourceFile(root, "nonexistent-id"), "missing") + provider := coworkProviderForRoot(t, root, "") + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: cli, + }) + require.NoError(t, err) + require.True(t, ok, "found") + assert.Equal(t, transcript, found.DisplayPath) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "nonexistent-id", + }) + require.NoError(t, err) + assert.False(t, ok, "missing") } -func TestClassifyCoworkPath(t *testing.T) { +func TestCoworkProviderClassifiesChangedPath(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000006" metaPath, transcript := writeCoworkSession(t, root, coworkFixture{ @@ -256,20 +312,34 @@ func TestClassifyCoworkPath(t *testing.T) { transcriptLines: coworkTranscriptLines(cli), }) + provider := coworkProviderForRoot(t, root, "") + classify := func(path string) (string, bool) { + sources, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: path, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + if len(sources) == 0 { + return "", false + } + require.Len(t, sources, 1) + return sources[0].DisplayPath, true + } + // A transcript change classifies to itself. - got, ok := ClassifyCoworkPath(root, transcript) + got, ok := classify(transcript) require.True(t, ok, "transcript classified") assert.Equal(t, transcript, got, "transcript path") // A metadata change resolves to the session's transcript. - got, ok = ClassifyCoworkPath(root, metaPath) + got, ok = classify(metaPath) require.True(t, ok, "metadata classified") assert.Equal(t, transcript, got, "metadata resolves to transcript") // Unrelated and outside-root paths are ignored. - _, ok = ClassifyCoworkPath(root, filepath.Join(root, "org", "ws", "artifacts.json")) + _, ok = classify(filepath.Join(root, "org", "ws", "artifacts.json")) assert.False(t, ok, "cache file ignored") - _, ok = ClassifyCoworkPath(root, "/some/other/place.jsonl") + _, ok = classify("/some/other/place.jsonl") assert.False(t, ok, "outside root ignored") } @@ -310,7 +380,7 @@ func TestCoworkSessionMtime(t *testing.T) { "transcript mtime when metadata missing") } -func TestDiscoverCoworkSessionsIncludesSubagents(t *testing.T) { +func TestCoworkProviderDiscoverIncludesSubagents(t *testing.T) { root := t.TempDir() cli := "c0000000-0000-4000-8000-000000000008" enc := "-sessions-demo" @@ -344,29 +414,27 @@ func TestDiscoverCoworkSessionsIncludesSubagents(t *testing.T) { "write subagent", ) - got := DiscoverCoworkSessions(root) - paths := make([]string, len(got)) - for i, f := range got { - paths[i] = f.Path - assert.Equal(t, AgentCowork, f.Agent, "Agent") - } + paths := coworkDiscoveredPaths(t, root) assert.Contains(t, paths, transcript, "main transcript discovered") assert.Contains(t, paths, subPath, "subagent transcript discovered") // The subagent parses into a cowork-namespaced subagent session whose // parent is the main session. - results, _, err := ParseCoworkSession(subPath, "host-1") - require.NoError(t, err, "parse subagent") + results, _ := coworkParseTranscript(t, root, subPath, "host-1") require.Len(t, results, 1, "results") sub := results[0].Session assert.Equal(t, "cowork:agent-0000000000000001", sub.ID, "subagent ID") assert.Equal(t, "cowork:"+cli, sub.ParentSessionID, "parent prefixed") assert.Equal(t, RelSubagent, sub.RelationshipType, "RelSubagent") - // FindCoworkSourceFile resolves the subagent by its raw ID too. - assert.Equal(t, subPath, - FindCoworkSourceFile(root, "agent-0000000000000001"), - "find subagent source") + // The provider resolves the subagent by its raw ID too. + provider := coworkProviderForRoot(t, root, "") + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "agent-0000000000000001", + }) + require.NoError(t, err) + require.True(t, ok, "find subagent source") + assert.Equal(t, subPath, found.DisplayPath) } func TestResolveCoworkSessionRejectsSymlinkEscape(t *testing.T) { diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 6489b09d3..d3740085f 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -358,6 +358,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newClaudeProviderFactory(def) case AgentCommandCode: return newCommandCodeProviderFactory(def) + case AgentCowork: + return newCoworkProviderFactory(def) case AgentCortex: return newCortexProviderFactory(def) case AgentCursor: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index d70763ccb..2a8ba596c 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -18,7 +18,7 @@ const ( var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentClaude: ProviderMigrationProviderAuthoritative, - AgentCowork: ProviderMigrationLegacyOnly, + AgentCowork: ProviderMigrationProviderAuthoritative, AgentCodex: ProviderMigrationLegacyOnly, AgentCopilot: ProviderMigrationLegacyOnly, AgentGemini: ProviderMigrationLegacyOnly, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 607545b60..f4d3cb8e8 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -51,7 +51,6 @@ var pendingShimProviderFiles = map[string]bool{ "antigravity_provider.go": true, "codex_provider.go": true, "copilot_provider.go": true, - "cowork_provider.go": true, "db_backed_provider.go": true, "gemini_provider.go": true, "kiro_ide_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 6ca765379..238674cb3 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -105,16 +105,14 @@ var Registry = []AgentDef{ FileBased: true, }, { - Type: AgentCowork, - DisplayName: "Claude Cowork", - EnvVar: "COWORK_DIR", - ConfigKey: "cowork_dirs", - DefaultDirs: coworkDefaultDirs(), - IDPrefix: "cowork:", - FileBased: true, - ShallowWatch: true, - DiscoverFunc: DiscoverCoworkSessions, - FindSourceFunc: FindCoworkSourceFile, + Type: AgentCowork, + DisplayName: "Claude Cowork", + EnvVar: "COWORK_DIR", + ConfigKey: "cowork_dirs", + DefaultDirs: coworkDefaultDirs(), + IDPrefix: "cowork:", + FileBased: true, + ShallowWatch: true, }, { Type: AgentCodex, diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index 18c04d68d..6dff16319 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -519,8 +519,10 @@ func TestCoworkRegistryEntry(t *testing.T) { def, ok := AgentByType(AgentCowork) require.True(t, ok, "AgentCowork missing from Registry") require.True(t, def.FileBased, "Cowork FileBased") - require.NotNil(t, def.DiscoverFunc, "Cowork DiscoverFunc") - require.NotNil(t, def.FindSourceFunc, "Cowork FindSourceFunc") + // Cowork is a migrated, provider-authoritative agent: source discovery + // and lookup live on the concrete provider, not on legacy AgentDef hooks. + require.Nil(t, def.DiscoverFunc, "Cowork DiscoverFunc") + require.Nil(t, def.FindSourceFunc, "Cowork FindSourceFunc") assert.Equal(t, "COWORK_DIR", def.EnvVar) assert.Equal(t, "cowork_dirs", def.ConfigKey) assert.Equal(t, "cowork:", def.IDPrefix) diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 2c350ccca..e04fb7d10 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -956,23 +956,6 @@ func (e *Engine) classifyOnePath( // shapes, so the legacy block was removed when Claude was folded // onto its provider. - // Cowork: ///local_/.claude/ - // projects//.jsonl (transcript), or the sibling - // local_.json metadata file (resolves to its transcript). - for _, coworkDir := range e.agentDirs[parser.AgentCowork] { - if coworkDir == "" { - continue - } - if transcript, ok := parser.ClassifyCoworkPath( - coworkDir, path, - ); ok { - return parser.DiscoveredFile{ - Path: transcript, - Agent: parser.AgentCowork, - }, true - } - } - // Codex: either ////.jsonl // or /.jsonl for archived sessions. for _, codexDir := range e.agentDirs[parser.AgentCodex] { @@ -4120,8 +4103,6 @@ func (e *Engine) processFile( var res processResult switch file.Agent { - case parser.AgentCowork: - res = e.processCowork(file, info) case parser.AgentCodex: res = e.processCodex(file, info) case parser.AgentCopilot: @@ -4237,6 +4218,12 @@ func (e *Engine) processProviderFile( mtime: mtime, }, true } + if freshMtime, fresh := e.providerCoworkSourceFresh(source, file); fresh { + return processResult{ + skip: true, + mtime: freshMtime, + }, true + } fingerprint, err := provider.Fingerprint(ctx, source) if err != nil { @@ -4838,50 +4825,6 @@ func (f fakeSnapshotInfo) ModTime() time.Time { func (f fakeSnapshotInfo) IsDir() bool { return false } func (f fakeSnapshotInfo) Sys() any { return nil } -// processCowork parses a Claude Desktop "cowork" (local agent mode) -// session. The transcript is a standard Claude Code JSONL file nested -// inside the cowork session directory, so the work is delegated to the -// Claude parser and rewritten into the cowork namespace by -// parser.ParseCoworkSession. Cowork session IDs are "cowork:"-prefixed, so -// the skip check keys off file_path rather than the bare filename stem. -func (e *Engine) processCowork( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - - // The session title lives in the sibling metadata file, so a rename - // changes only that file. Skip on the composite (transcript+metadata) - // mtime so renames are re-parsed instead of skipped as unchanged. - compositeMtime := parser.CoworkSessionMtime( - file.Path, info.ModTime().UnixNano(), - ) - fi := fakeSnapshotInfo{fSize: info.Size(), fMtime: compositeMtime} - if e.shouldSkipByPath(file.Path, fi) { - return processResult{skip: true} - } - - results, excludedIDs, err := parser.ParseCoworkSession( - file.Path, e.machine, - ) - if err != nil { - return processResult{err: err} - } - - inode, device := getFileIdentity(info) - hash, hashErr := ComputeFileHash(file.Path) - for i := range results { - results[i].Session.File.Inode = inode - results[i].Session.File.Device = device - if hashErr == nil { - results[i].Session.File.Hash = hash - } - } - - return processResult{ - results: results, - excludedSessionIDs: excludedIDs, - } -} - // providerSingleSessionFresh reports whether a single-session JSONL // provider's source (Claude) maps to a stored session that is already // up to date: the source size and mtime match what is stored, the row @@ -4942,6 +4885,39 @@ func (e *Engine) providerSingleSessionFresh( !parser.NeedsProjectReparse(sess.Project) } +func (e *Engine) providerCoworkSourceFresh( + source parser.SourceRef, + file parser.DiscoveredFile, +) (int64, bool) { + if e.forceParse || file.ForceParse || file.Agent != parser.AgentCowork { + return 0, false + } + path := providerDiscoveredPath(source) + if path == "" { + return 0, false + } + lookupPath := path + if e.pathRewriter != nil { + lookupPath = e.pathRewriter(path) + } + info, err := os.Stat(lookupPath) + if err != nil { + info, err = os.Stat(path) + if err != nil { + return 0, false + } + } + mtime := parser.CoworkSessionMtime(path, info.ModTime().UnixNano()) + effectiveInfo := fakeSnapshotInfo{ + fSize: info.Size(), + fMtime: mtime, + } + if !e.shouldSkipByPath(path, effectiveInfo) { + return 0, false + } + return mtime, true +} + // stampProviderFileIdentity copies the source file's inode and device onto // every parsed result for an incremental-append provider (Claude). The // legacy process arm stamped this identity from the source stat so the diff --git a/internal/sync/provider_shadow_caller_test.go b/internal/sync/provider_shadow_caller_test.go index 3d4466c37..a389eac76 100644 --- a/internal/sync/provider_shadow_caller_test.go +++ b/internal/sync/provider_shadow_caller_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,107 +17,6 @@ import ( "go.kenn.io/agentsview/internal/testjsonl" ) -// The generic shadow-compare/legacy-coexistence mechanism is exercised through -// the Cowork agent, which remains legacy-only and reuses the Claude transcript -// format. Claude itself is now provider-authoritative, so it no longer has a -// legacy processFile arm to observe in shadow. -func TestProcessFileShadowObservesProviderWithoutReplacingLegacy(t *testing.T) { - root := t.TempDir() - sourcePath := filepath.Join(root, "-Users-dev-code-demo", "shadow-caller.jsonl") - require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0o755)) - require.NoError(t, os.WriteFile( - sourcePath, - []byte(testjsonl.JoinJSONL( - testjsonl.ClaudeUserJSON( - "compare through the caller", - "2026-06-01T10:00:00Z", - "/Users/dev/code/demo", - ), - testjsonl.ClaudeAssistantJSON( - "provider stayed shadow-only", - "2026-06-01T10:01:00Z", - ), - )), - 0o644, - )) - - legacyResults, legacyExcluded, err := parser.ParseCoworkSession( - sourcePath, "devbox", - ) - require.NoError(t, err) - require.Len(t, legacyResults, 1) - require.Empty(t, legacyExcluded) - info, err := os.Stat(sourcePath) - require.NoError(t, err) - providerResult := legacyResults[0] - providerResult.Session.File.Inode, providerResult.Session.File.Device = getFileIdentity(info) - hash, err := ComputeFileHash(sourcePath) - require.NoError(t, err) - providerResult.Session.File.Hash = hash - - source := parser.SourceRef{ - Provider: parser.AgentCowork, - Key: sourcePath, - DisplayPath: sourcePath, - FingerprintKey: sourcePath, - ProjectHint: "demo", - } - provider := &shadowCallerProvider{ - shadowTestProvider: shadowTestProvider{ - ProviderBase: parser.ProviderBase{ - Def: parser.AgentDef{ - Type: parser.AgentCowork, - DisplayName: "Claude Cowork", - }, - }, - fingerprint: parser.SourceFingerprint{ - Key: sourcePath, - Size: info.Size(), - MTimeNS: info.ModTime().UnixNano(), - }, - outcome: parser.ParseOutcome{ - Results: []parser.ParseResultOutcome{{ - Result: providerResult, - DataVersion: parser.DataVersionCurrent, - }}, - ResultSetComplete: true, - }, - }, - source: source, - } - var comparisons []ProviderShadowComparison - engine := NewEngine(dbtest.OpenTestDB(t), EngineConfig{ - AgentDirs: map[parser.AgentType][]string{ - parser.AgentCowork: {root}, - }, - Machine: "devbox", - ProviderFactories: []parser.ProviderFactory{ - shadowCallerFactory{provider: provider}, - }, - ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ - parser.AgentCowork: parser.ProviderMigrationShadowCompare, - }, - ProviderShadowRecorder: func(comparison ProviderShadowComparison) { - comparisons = append(comparisons, comparison) - }, - }) - - result := engine.processFile(context.Background(), parser.DiscoveredFile{ - Path: sourcePath, - Agent: parser.AgentCowork, - }) - - require.NoError(t, result.err) - require.Len(t, result.results, 1) - assert.Equal(t, "cowork:shadow-caller", result.results[0].Session.ID) - assert.Equal(t, parser.AgentCowork, result.results[0].Session.Agent) - require.Len(t, comparisons, 1) - assert.NoError(t, comparisons[0].Err) - assert.Empty(t, comparisons[0].Mismatches) - assert.Equal(t, sourcePath, comparisons[0].File.Path) - assert.Equal(t, []string{"fingerprint", "parse"}, provider.calls) -} - func TestClassifyProviderChangedPathPassesStoredHintsToShadowProvider( t *testing.T, ) { @@ -241,105 +141,6 @@ func TestClassifyProviderChangedPathRunsAlongsideLegacyClassifier( assert.Equal(t, sourcePath, files[0].ProviderSource.DisplayPath) } -func TestProcessFileShadowUsesChangedPathProviderSource(t *testing.T) { - root := t.TempDir() - sourcePath := filepath.Join(root, "-Users-dev-code-demo", "shadow-provider-source.jsonl") - require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0o755)) - require.NoError(t, os.WriteFile( - sourcePath, - []byte(testjsonl.JoinJSONL( - testjsonl.ClaudeUserJSON( - "provider source should win", - "2026-06-01T10:00:00Z", - "/Users/dev/code/demo", - ), - testjsonl.ClaudeAssistantJSON( - "force parse should propagate", - "2026-06-01T10:01:00Z", - ), - )), - 0o644, - )) - - legacyResults, legacyExcluded, err := parser.ParseCoworkSession( - sourcePath, "devbox", - ) - require.NoError(t, err) - require.Len(t, legacyResults, 1) - require.Empty(t, legacyExcluded) - info, err := os.Stat(sourcePath) - require.NoError(t, err) - providerResult := legacyResults[0] - providerResult.Session.File.Inode, providerResult.Session.File.Device = getFileIdentity(info) - hash, err := ComputeFileHash(sourcePath) - require.NoError(t, err) - providerResult.Session.File.Hash = hash - - changedSource := parser.SourceRef{ - Provider: parser.AgentCowork, - Key: "changed-path-source", - DisplayPath: sourcePath, - FingerprintKey: sourcePath, - ProjectHint: "demo", - } - findFound := false - provider := &shadowCallerProvider{ - shadowTestProvider: shadowTestProvider{ - ProviderBase: parser.ProviderBase{ - Def: parser.AgentDef{ - Type: parser.AgentCowork, - DisplayName: "Claude Cowork", - }, - }, - fingerprint: parser.SourceFingerprint{ - Key: sourcePath, - Size: info.Size(), - MTimeNS: info.ModTime().UnixNano(), - }, - outcome: parser.ParseOutcome{ - Results: []parser.ParseResultOutcome{{ - Result: providerResult, - DataVersion: parser.DataVersionCurrent, - }}, - ResultSetComplete: true, - }, - }, - findFound: &findFound, - } - var comparisons []ProviderShadowComparison - engine := NewEngine(dbtest.OpenTestDB(t), EngineConfig{ - AgentDirs: map[parser.AgentType][]string{ - parser.AgentCowork: {root}, - }, - Machine: "devbox", - ProviderFactories: []parser.ProviderFactory{ - shadowCallerFactory{provider: provider}, - }, - ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ - parser.AgentCowork: parser.ProviderMigrationShadowCompare, - }, - ProviderShadowRecorder: func(comparison ProviderShadowComparison) { - comparisons = append(comparisons, comparison) - }, - }) - - result := engine.processFile(context.Background(), parser.DiscoveredFile{ - Path: sourcePath, - Agent: parser.AgentCowork, - ForceParse: true, - ProviderSource: &changedSource, - }) - - require.NoError(t, result.err) - require.Len(t, comparisons, 1) - assert.NoError(t, comparisons[0].Err) - assert.Empty(t, comparisons[0].Mismatches) - assert.Equal(t, changedSource, comparisons[0].Source) - assert.Equal(t, changedSource, provider.parseRequest.Source) - assert.True(t, provider.parseRequest.ForceParse) - assert.Empty(t, provider.findRequest) -} - func TestClassifyProviderChangedPathMarksAuthoritativeProviderProcess( t *testing.T, ) { @@ -710,6 +511,7 @@ func TestProcessFileProviderAuthoritativeSkipsFreshClaudeBeforeFingerprint(t *te FingerprintKey: sourcePath, ProjectHint: "demo", } + provider := &shadowCallerProvider{ shadowTestProvider: shadowTestProvider{ ProviderBase: parser.ProviderBase{ @@ -765,6 +567,106 @@ func TestProcessFileProviderAuthoritativeSkipsFreshClaudeBeforeFingerprint(t *te assert.Equal(t, sourcePath, provider.findRequest.StoredFilePath) } +func TestProcessFileProviderAuthoritativeSkipsFreshCoworkBeforeFingerprint(t *testing.T) { + root := t.TempDir() + database := dbtest.OpenTestDB(t) + sourcePath, sourceMtime := writeFreshCoworkProviderSource( + t, root, database, "fresh-session", + ) + provider := &shadowCallerProvider{ + shadowTestProvider: shadowTestProvider{ + ProviderBase: parser.ProviderBase{ + Def: parser.AgentDef{ + Type: parser.AgentCowork, + DisplayName: "Claude Cowork", + }, + }, + }, + source: parser.SourceRef{ + Provider: parser.AgentCowork, + Key: sourcePath, + DisplayPath: sourcePath, + FingerprintKey: sourcePath, + }, + } + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentCowork: {root}, + }, + Machine: "devbox", + ProviderFactories: []parser.ProviderFactory{ + shadowCallerFactory{provider: provider}, + }, + ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ + parser.AgentCowork: parser.ProviderMigrationProviderAuthoritative, + }, + }) + + result := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: sourcePath, + Agent: parser.AgentCowork, + }) + + require.NoError(t, result.err) + assert.True(t, result.skip) + assert.Equal(t, sourceMtime, result.mtime) + assert.Empty(t, provider.calls) +} + +func TestProcessFileProviderAuthoritativeForceParseBypassesFreshCoworkSkip(t *testing.T) { + root := t.TempDir() + database := dbtest.OpenTestDB(t) + sourcePath, sourceMtime := writeFreshCoworkProviderSource( + t, root, database, "force-session", + ) + provider := &shadowCallerProvider{ + shadowTestProvider: shadowTestProvider{ + ProviderBase: parser.ProviderBase{ + Def: parser.AgentDef{ + Type: parser.AgentCowork, + DisplayName: "Claude Cowork", + }, + }, + fingerprint: parser.SourceFingerprint{ + Key: sourcePath, + MTimeNS: sourceMtime, + }, + outcome: parser.ParseOutcome{ + ResultSetComplete: true, + }, + }, + source: parser.SourceRef{ + Provider: parser.AgentCowork, + Key: sourcePath, + DisplayPath: sourcePath, + FingerprintKey: sourcePath, + }, + } + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentCowork: {root}, + }, + Machine: "devbox", + ProviderFactories: []parser.ProviderFactory{ + shadowCallerFactory{provider: provider}, + }, + ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ + parser.AgentCowork: parser.ProviderMigrationProviderAuthoritative, + }, + }) + + result := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: sourcePath, + Agent: parser.AgentCowork, + ForceParse: true, + }) + + require.NoError(t, result.err) + assert.False(t, result.skip) + assert.Equal(t, []string{"fingerprint", "parse"}, provider.calls) + assert.True(t, provider.parseRequest.ForceParse) +} + func TestProcessFileProviderAuthoritativeKeepsRetryStatePerResult(t *testing.T) { root := t.TempDir() sourcePath := filepath.Join(root, "multi-provider-owned.jsonl") @@ -1312,3 +1214,46 @@ func (f shadowCallerFactory) Capabilities() parser.Capabilities { func (f shadowCallerFactory) NewProvider(parser.ProviderConfig) parser.Provider { return f.provider } + +func writeFreshCoworkProviderSource( + t *testing.T, + root string, + database *db.DB, + rawSessionID string, +) (string, int64) { + t.Helper() + + sessionDir := filepath.Join(root, "org", "workspace", "local_fresh") + projectDir := filepath.Join(sessionDir, ".claude", "projects", "-demo") + require.NoError(t, os.MkdirAll(projectDir, 0o755)) + metaPath := sessionDir + ".json" + sourcePath := filepath.Join(projectDir, rawSessionID+".jsonl") + require.NoError(t, os.WriteFile(metaPath, []byte(`{"title":"Fresh"}`), 0o644)) + require.NoError(t, os.WriteFile(sourcePath, []byte("{}\n"), 0o644)) + + transcriptTime := time.Unix(1_781_475_210, 0) + metaTime := transcriptTime.Add(time.Second) + require.NoError(t, os.Chtimes(sourcePath, transcriptTime, transcriptTime)) + require.NoError(t, os.Chtimes(metaPath, metaTime, metaTime)) + info, err := os.Stat(sourcePath) + require.NoError(t, err) + sourceSize := info.Size() + sourceMtime := parser.CoworkSessionMtime(sourcePath, info.ModTime().UnixNano()) + require.Equal(t, metaTime.UnixNano(), sourceMtime) + + fullSessionID := "cowork:" + rawSessionID + require.NoError(t, database.UpsertSession(db.Session{ + ID: fullSessionID, + Project: "cowork-project", + Machine: "devbox", + Agent: string(parser.AgentCowork), + FilePath: &sourcePath, + FileSize: &sourceSize, + FileMtime: &sourceMtime, + })) + require.NoError(t, database.SetSessionDataVersion( + fullSessionID, db.CurrentDataVersion(), + )) + + return sourcePath, sourceMtime +}