Skip to content

Commit a73e4d1

Browse files
feat(parser): migrate qwenpaw provider
QwenPaw stores rewritten JSON session snapshots under workspace-scoped sessions directories, with a second one-level subdirectory namespace for console sessions. Moving it behind a concrete provider keeps those raw ID shapes explicit while continuing to use the shared file-source mechanics where they fit. The provider preserves workspace project hints, root and console source lookup, hidden/deeper layout rejection, symlinked workspace discovery, content-hash fingerprinting, and force-replace parse semantics for rewritten session files. fix(parser): prune qwenpaw source traversal QwenPaw legacy discovery only walks valid workspace directories, the sessions directory, and one real non-hidden namespace below sessions. The provider migration reused the generic recursive JSONL walker, which preserved emitted source filtering but still allowed traversal and event classification through deeper or symlinked session namespaces. Add a shared traversal predicate to the JSONL source helper so providers can keep discovery and changed-path classification aligned when their source layouts are narrower than an unbounded recursive scan. test(parser): opt qwenpaw into provider shadow QwenPaw now has a concrete facade provider on this branch, so its migration mode should enter shadow comparison instead of remaining legacy-only and additive. Earlier provider opt-ins stay inherited and later branches own their modes. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare qwenpaw shadow parity QwenPaw is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseQwenPawSession. The test covers both root session files and console subdirectory session files so the path-derived workspace/session IDs and planned data-version behavior stay visible during review. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesQwenPawLegacyParser|TestQwenPawProvider|TestParseQwenPaw|TestSyncSingleSession_QwenPaw|TestWriteBatchQwenPaw' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... refactor(parser): fold qwenpaw into provider Move QwenPaw parse and source-lookup ownership onto the concrete qwenPawProvider and delete the package-level legacy entrypoints (DiscoverQwenPawSessions, FindQwenPawSourceFile, ParseQwenPawSession) plus their unexported discovery/traversal helpers. ParseQwenPawSession becomes the provider parseSession method, and the rawID resolution from FindQwenPawSourceFile moves onto the provider as sourceFileForRawID with its traversal guard. The provider's existing JSONLSourceSet already reproduces workspace/sessions/console discovery, symlink handling, and hidden-subdir pruning, so the legacy DiscoverQwenPawSessions free function is dropped entirely. Route QwenPaw sync classification and processing through the provider-neutral runtime by removing its legacy engine dispatch: the classifyOnePath workspace/sessions block, the processFile case arm, and the processQwenPaw method. The provider's SourcesForChangedPath and forceReplace-on-parse capability preserve changed-path remapping and the full-rewrite write semantics the legacy path provided. Make QwenPaw provider-authoritative in the migration manifest, drop its AgentDef DiscoverFunc/FindSourceFunc hooks, remove it from the pending shim scan list, and replace the shadow-baseline test with provider API coverage plus a guard that the legacy symbols stay gone. To preserve single-session resync parity, FindSource now resolves a DB-stored file_path that points outside any configured QWENPAW_DIR by synthesizing a source from the path's implicit <root>/<workspace>/sessions/ layout, recovering the workspace as ProjectHint so a reparse keeps the canonical qwenpaw:<workspace>:<stem> ID instead of orphaning it under an empty workspace. fix(parser): thread ctx through qwenpaw source lookups
1 parent ef5bc88 commit a73e4d1

11 files changed

Lines changed: 1161 additions & 318 deletions

internal/parser/jsonl_source_set_test.go

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

internal/parser/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory {
369369
return newQClawProviderFactory(def)
370370
case AgentQwen:
371371
return newQwenProviderFactory(def)
372+
case AgentQwenPaw:
373+
return newQwenPawProviderFactory(def)
372374
case AgentWorkBuddy:
373375
return newWorkBuddyProviderFactory(def)
374376
case AgentZencoder:

internal/parser/provider_migration.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{
5454
AgentAntigravityCLI: ProviderMigrationLegacyOnly,
5555
AgentVibe: ProviderMigrationLegacyOnly,
5656
AgentZed: ProviderMigrationLegacyOnly,
57-
AgentQwenPaw: ProviderMigrationLegacyOnly,
57+
AgentQwenPaw: ProviderMigrationProviderAuthoritative,
5858
AgentGptme: ProviderMigrationProviderAuthoritative,
5959
AgentShelley: ProviderMigrationLegacyOnly,
6060
AgentAider: ProviderMigrationLegacyOnly,

internal/parser/provider_shim_scan_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ var pendingShimProviderFiles = map[string]bool{
5757
"opencode_provider.go": true,
5858
"openhands_provider.go": true,
5959
"positron_provider.go": true,
60-
"qwenpaw_provider.go": true,
6160
"shelley_provider.go": true,
6261
"vibe_provider.go": true,
6362
"visualstudio_copilot_provider.go": true,

internal/parser/qwenpaw.go

Lines changed: 4 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -7,190 +7,12 @@ import (
77
"fmt"
88
"os"
99
"path/filepath"
10-
"sort"
1110
"strings"
1211
"time"
1312

1413
"github.com/tidwall/gjson"
1514
)
1615

17-
// DiscoverQwenPawSessions walks <root>/<workspace>/sessions/*.json and
18-
// <root>/<workspace>/sessions/console/*.json. Each QwenPaw runtime
19-
// hosts multiple agent workspaces (e.g. "default", "fund_manager")
20-
// and each workspace persists one JSON file per active session under
21-
// sessions/. Hidden subdirectories (e.g. ".weixin-legacy") and the
22-
// legacy dialog/*.jsonl layout are skipped.
23-
func DiscoverQwenPawSessions(root string) []DiscoveredFile {
24-
if root == "" {
25-
return nil
26-
}
27-
workspaceEntries, err := os.ReadDir(root)
28-
if err != nil {
29-
return nil
30-
}
31-
var files []DiscoveredFile
32-
for _, wsEntry := range workspaceEntries {
33-
if !isDirOrSymlink(wsEntry, root) {
34-
continue
35-
}
36-
workspace := wsEntry.Name()
37-
if !IsValidQwenPawIDPart(workspace) {
38-
continue
39-
}
40-
files = append(files,
41-
discoverQwenPawSessionsDir(
42-
filepath.Join(root, workspace, "sessions"),
43-
workspace,
44-
)...,
45-
)
46-
}
47-
sort.Slice(files, func(i, j int) bool {
48-
return files[i].Path < files[j].Path
49-
})
50-
return files
51-
}
52-
53-
// discoverQwenPawSessionsDir collects *.json from a sessions/ root
54-
// and one level of non-hidden subdirectories (e.g. console/).
55-
func discoverQwenPawSessionsDir(
56-
sessionsDir, workspace string,
57-
) []DiscoveredFile {
58-
entries, err := os.ReadDir(sessionsDir)
59-
if err != nil {
60-
return nil
61-
}
62-
var files []DiscoveredFile
63-
for _, entry := range entries {
64-
if entry.IsDir() {
65-
name := entry.Name()
66-
if strings.HasPrefix(name, ".") || !IsValidQwenPawIDPart(name) {
67-
continue
68-
}
69-
subDir := filepath.Join(sessionsDir, name)
70-
files = append(files,
71-
discoverQwenPawSessionsFiles(subDir, workspace)...,
72-
)
73-
continue
74-
}
75-
stem, ok := strings.CutSuffix(entry.Name(), ".json")
76-
if !ok || !IsValidQwenPawIDPart(stem) {
77-
continue
78-
}
79-
files = append(files, DiscoveredFile{
80-
Path: filepath.Join(sessionsDir, entry.Name()),
81-
Project: workspace,
82-
Agent: AgentQwenPaw,
83-
})
84-
}
85-
return files
86-
}
87-
88-
// discoverQwenPawSessionsFiles collects *.json from a single
89-
// directory without recursing further.
90-
func discoverQwenPawSessionsFiles(
91-
dir, workspace string,
92-
) []DiscoveredFile {
93-
entries, err := os.ReadDir(dir)
94-
if err != nil {
95-
return nil
96-
}
97-
var files []DiscoveredFile
98-
for _, entry := range entries {
99-
if entry.IsDir() {
100-
continue
101-
}
102-
stem, ok := strings.CutSuffix(entry.Name(), ".json")
103-
if !ok || !IsValidQwenPawIDPart(stem) {
104-
continue
105-
}
106-
files = append(files, DiscoveredFile{
107-
Path: filepath.Join(dir, entry.Name()),
108-
Project: workspace,
109-
Agent: AgentQwenPaw,
110-
})
111-
}
112-
return files
113-
}
114-
115-
// FindQwenPawSourceFile resolves a rawID to a sessions JSON file.
116-
//
117-
// Raw ID shapes:
118-
//
119-
// - qwenpaw:<workspace>:<stem> -> <root>/<workspace>/sessions/<stem>.json
120-
// - qwenpaw:<workspace>:<subdir>:<stem> -> <root>/<workspace>/sessions/<subdir>/<stem>.json
121-
//
122-
// The subdir segment disambiguates the sessions/console/ layout
123-
// from the sessions/ root so two files with the same stem cannot
124-
// collide.
125-
//
126-
// Returns "" when the rawID is malformed, references a traversal
127-
// component (".", ".."), escapes the resolved sessions directory,
128-
// or the file does not exist.
129-
func FindQwenPawSourceFile(root, rawID string) string {
130-
if root == "" {
131-
return ""
132-
}
133-
workspace, rest, ok := strings.Cut(rawID, ":")
134-
if !ok {
135-
return ""
136-
}
137-
if !IsValidQwenPawIDPart(workspace) {
138-
return ""
139-
}
140-
var candidate string
141-
if subdir, stem, found := strings.Cut(rest, ":"); found {
142-
if !IsValidQwenPawIDPart(subdir) ||
143-
!IsValidQwenPawIDPart(stem) {
144-
return ""
145-
}
146-
candidate = filepath.Join(
147-
root, workspace, "sessions", subdir, stem+".json",
148-
)
149-
} else {
150-
if !IsValidQwenPawIDPart(rest) {
151-
return ""
152-
}
153-
candidate = filepath.Join(
154-
root, workspace, "sessions", rest+".json",
155-
)
156-
}
157-
if !isUnderQwenPawRoot(root, candidate) {
158-
return ""
159-
}
160-
if _, err := os.Stat(candidate); err == nil {
161-
return candidate
162-
}
163-
return ""
164-
}
165-
166-
// isUnderQwenPawRoot reports whether candidate resolves to a path
167-
// inside <root>/<workspace>/sessions/. Both sides are cleaned and
168-
// converted to absolute form so that "." / ".." segments in the
169-
// candidate cannot escape the QwenPaw root.
170-
func isUnderQwenPawRoot(root, candidate string) bool {
171-
absRoot, err := filepath.Abs(filepath.Clean(root))
172-
if err != nil {
173-
return false
174-
}
175-
absCand, err := filepath.Abs(filepath.Clean(candidate))
176-
if err != nil {
177-
return false
178-
}
179-
rel, err := filepath.Rel(absRoot, absCand)
180-
if err != nil {
181-
return false
182-
}
183-
rel = filepath.ToSlash(rel)
184-
if rel == "." || rel == ".." || strings.HasPrefix(rel, "../") {
185-
return false
186-
}
187-
parts := strings.Split(rel, "/")
188-
if len(parts) < 2 || parts[1] != "sessions" {
189-
return false
190-
}
191-
return true
192-
}
193-
19416
// IsValidQwenPawIDPart accepts workspace names and session file
19517
// stems. QwenPaw emits channel-scoped filenames containing dots,
19618
// at-signs, and double dashes (e.g. "<userId>@im.wechat_wechat--..."),
@@ -202,8 +24,8 @@ func isUnderQwenPawRoot(root, candidate string) bool {
20224
// part is joined into a session ID:
20325
//
20426
// - ":" joins ID parts in qwenpawSessionID. A stem "foo:bar" would
205-
// produce qwenpaw:<workspace>:foo:bar, which FindQwenPawSourceFile
206-
// reparses as the sessions/foo/bar.json subdir layout.
27+
// produce qwenpaw:<workspace>:foo:bar, which source lookup reparses
28+
// as the sessions/foo/bar.json subdir layout.
20729
// - "~" is the remote-host separator (see StripHostPrefix). A part
20830
// containing it would be split off as a bogus host prefix.
20931
// - "?", "#", and "%" are URL delimiters. Session IDs are
@@ -261,7 +83,7 @@ func qwenpawSessionID(path, project, stem string) (string, error) {
26183
return "qwenpaw:" + project + ":" + parent + ":" + stem, nil
26284
}
26385

264-
// ParseQwenPawSession parses a QwenPaw sessions/<name>.json file.
86+
// parseSession parses a QwenPaw sessions/<name>.json file.
26587
//
26688
// The on-disk shape is:
26789
//
@@ -286,7 +108,7 @@ func qwenpawSessionID(path, project, stem string) (string, error) {
286108
// of Anthropic's user-side tool_result). They map to RoleUser +
287109
// IsSystem so they remain distinguishable from real user turns
288110
// without inflating UserMessageCount.
289-
func ParseQwenPawSession(
111+
func parseQwenPawSession(
290112
path, project, machine string,
291113
) (*ParsedSession, []ParsedMessage, error) {
292114
raw, err := os.ReadFile(path)

0 commit comments

Comments
 (0)