Skip to content

Commit ef5bc88

Browse files
feat(parser): migrate claw providers
OpenClaw and QClaw share a Claw-style source layout where each agent directory owns a sessions folder and active JSONL files compete with archived JSONL variants for the same logical session. Moving them behind concrete provider facades keeps that active-over-archive and newest-archive policy explicit without broadening the generic JSONL source helpers around variable archive suffixes. The providers preserve colon-delimited agent/session lookup, selected-source change classification, symlinked agent directories, stale stored-path remapping, source fingerprinting, and existing parse normalization. fix(parser): promote claw archives on removal Claw providers choose a single source per logical session, so live-sync removal events need to account for source promotion. When an active file or newest archive disappears, another archive may become the selected source even though the changed path is no longer the source to parse. This keeps write events strict about the selected path, while remove and rename-style missing-path events can remap a valid stale Claw path to the newly selected source for the same raw session ID. test(parser): opt openclaw qclaw into provider shadow OpenClaw and QClaw now have concrete facade providers on this branch, so their migration modes should enter shadow comparison rather than staying legacy-only and additive. Earlier provider opt-ins remain inherited; later provider branches still own their own 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 claw shadow parity OpenClaw and QClaw are shadow-compared on this branch, so add source-level migration coverage that compares provider observation with their legacy parsers. The paired test follows the shared provider implementation and keeps the agent/session raw ID shape and planned data-version behavior visible during review. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesClawLegacyParsers|Test(OpenClaw|QClaw)Provider|TestParse(OpenClaw|QClaw)' -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 claw providers into provider OpenClaw and QClaw should no longer keep exported discover/find/parse entrypoints beside the provider facade. Folding discovery, raw-ID lookup, archive selection, and parsing into the concrete providers makes this branch a real migration instead of another shim around the legacy path. The sync engine now relies on provider changed-path handling for this family, so the provider migration mode can become authoritative and the shadow-only comparison test is removed. Validation: go test -tags "fts5" ./internal/parser -run 'TestClawProvidersOwnLegacyEntrypoints|TestOpenClaw|TestQClaw|TestClawProvider|TestParseOpenClaw|TestParseQClaw|TestDiscoverOpenClaw|TestDiscoverQClaw|TestFindOpenClaw|TestFindQClaw' -count=1 -v; go test -tags "fts5" ./internal/sync -run 'TestEngine_ClassifyPathsQClaw|TestProviderMigration|TestObserveProvider|TestProviderProcess' -count=1 -v; go fmt ./...; go test -tags "fts5" ./internal/parser ./internal/sync ./cmd/agentsview -count=1; go vet ./...; git diff --check
1 parent 26dec1c commit ef5bc88

11 files changed

Lines changed: 1183 additions & 630 deletions

File tree

internal/parser/claw_provider.go

Lines changed: 682 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
package parser
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestOpenClawProviderFactoryReplacesLegacyAdapter(t *testing.T) {
15+
assertClawProviderReplacesLegacyAdapter(t, AgentOpenClaw)
16+
}
17+
18+
func TestQClawProviderFactoryReplacesLegacyAdapter(t *testing.T) {
19+
assertClawProviderReplacesLegacyAdapter(t, AgentQClaw)
20+
}
21+
22+
func TestClawProvidersOwnLegacyEntrypoints(t *testing.T) {
23+
for _, tt := range []struct {
24+
parserFile string
25+
providerFile string
26+
symbols []string
27+
calls []string
28+
}{
29+
{
30+
parserFile: "openclaw.go",
31+
providerFile: "claw_provider.go",
32+
symbols: []string{
33+
"func DiscoverOpenClawSessions",
34+
"func FindOpenClawSourceFile",
35+
"func ParseOpenClawSession",
36+
},
37+
calls: []string{
38+
"DiscoverOpenClawSessions(",
39+
"FindOpenClawSourceFile(",
40+
"ParseOpenClawSession(",
41+
},
42+
},
43+
{
44+
parserFile: "qclaw.go",
45+
providerFile: "claw_provider.go",
46+
symbols: []string{
47+
"func DiscoverQClawSessions",
48+
"func FindQClawSourceFile",
49+
"func ParseQClawSession",
50+
},
51+
calls: []string{
52+
"DiscoverQClawSessions(",
53+
"FindQClawSourceFile(",
54+
"ParseQClawSession(",
55+
},
56+
},
57+
} {
58+
parserSource, err := os.ReadFile(tt.parserFile)
59+
require.NoError(t, err)
60+
providerSource, err := os.ReadFile(tt.providerFile)
61+
require.NoError(t, err)
62+
63+
for _, symbol := range tt.symbols {
64+
assert.NotContains(t, string(parserSource), symbol)
65+
}
66+
67+
providerText := string(providerSource)
68+
for _, call := range tt.calls {
69+
assert.NotContains(
70+
t,
71+
strings.ReplaceAll(providerText, "func "+call, ""),
72+
call,
73+
)
74+
}
75+
}
76+
}
77+
78+
func TestOpenClawProviderSourceMethods(t *testing.T) {
79+
spec := openClawProviderTestSpec()
80+
assertClawProviderSourceMethods(t, spec)
81+
}
82+
83+
func TestQClawProviderSourceMethods(t *testing.T) {
84+
spec := qClawProviderTestSpec()
85+
assertClawProviderSourceMethods(t, spec)
86+
}
87+
88+
func TestOpenClawProviderDiscoversSymlinkedAgentDirectory(t *testing.T) {
89+
spec := openClawProviderTestSpec()
90+
assertClawProviderDiscoversSymlinkedAgentDirectory(t, spec)
91+
}
92+
93+
func TestQClawProviderDiscoversSymlinkedAgentDirectory(t *testing.T) {
94+
spec := qClawProviderTestSpec()
95+
assertClawProviderDiscoversSymlinkedAgentDirectory(t, spec)
96+
}
97+
98+
func TestOpenClawProviderParse(t *testing.T) {
99+
spec := openClawProviderTestSpec()
100+
assertClawProviderParse(t, spec)
101+
}
102+
103+
func TestQClawProviderParse(t *testing.T) {
104+
spec := qClawProviderTestSpec()
105+
assertClawProviderParse(t, spec)
106+
}
107+
108+
type clawProviderTestSpec struct {
109+
agent AgentType
110+
prefix string
111+
sessionFile func(string) bool
112+
fixture func(string, string) string
113+
}
114+
115+
func openClawProviderTestSpec() clawProviderTestSpec {
116+
return clawProviderTestSpec{
117+
agent: AgentOpenClaw,
118+
prefix: "openclaw",
119+
sessionFile: IsOpenClawSessionFile,
120+
fixture: func(sessionID string, firstMessage string) string {
121+
return clawProviderFixture(sessionID, firstMessage)
122+
},
123+
}
124+
}
125+
126+
func qClawProviderTestSpec() clawProviderTestSpec {
127+
return clawProviderTestSpec{
128+
agent: AgentQClaw,
129+
prefix: "qclaw",
130+
sessionFile: IsQClawSessionFile,
131+
fixture: func(sessionID string, firstMessage string) string {
132+
return clawProviderFixture(sessionID, firstMessage)
133+
},
134+
}
135+
}
136+
137+
func assertClawProviderReplacesLegacyAdapter(t *testing.T, agent AgentType) {
138+
t.Helper()
139+
140+
factory, ok := ProviderFactoryByType(agent)
141+
require.True(t, ok)
142+
require.NotNil(t, factory)
143+
144+
provider, ok := NewProvider(agent, ProviderConfig{
145+
Roots: []string{t.TempDir()},
146+
Machine: "devbox",
147+
})
148+
require.True(t, ok)
149+
require.NotNil(t, provider)
150+
}
151+
152+
func assertClawProviderSourceMethods(t *testing.T, spec clawProviderTestSpec) {
153+
t.Helper()
154+
155+
root := t.TempDir()
156+
activePath := filepath.Join(root, "main", "sessions", "abc-123.jsonl")
157+
activeArchivePath := filepath.Join(
158+
root, "main", "sessions",
159+
"abc-123.jsonl.deleted.2026-01-01T00-00-00.000Z",
160+
)
161+
oldArchivePath := filepath.Join(
162+
root, "main", "sessions",
163+
"def-456.jsonl.deleted.2026-01-01T00-00-00.000Z",
164+
)
165+
newArchivePath := filepath.Join(
166+
root, "main", "sessions",
167+
"def-456.jsonl.reset.2026-03-01T00-00-00.000Z",
168+
)
169+
writeSourceFile(t, activePath, spec.fixture("abc-123", "active question"))
170+
writeSourceFile(t, activeArchivePath, spec.fixture("abc-123", "archived active"))
171+
writeSourceFile(t, oldArchivePath, spec.fixture("def-456", "old archive"))
172+
writeSourceFile(t, newArchivePath, spec.fixture("def-456", "new archive"))
173+
writeSourceFile(t, filepath.Join(root, "main", "sessions", "notes.jsonl.tmp"), "{}\n")
174+
writeSourceFile(t, filepath.Join(root, "bad agent", "sessions", "skip.jsonl"), "{}\n")
175+
176+
provider, ok := NewProvider(spec.agent, ProviderConfig{
177+
Roots: []string{root},
178+
Machine: "devbox",
179+
})
180+
require.True(t, ok)
181+
182+
plan, err := provider.WatchPlan(context.Background())
183+
require.NoError(t, err)
184+
require.Len(t, plan.Roots, 1)
185+
assert.Equal(t, root, plan.Roots[0].Path)
186+
assert.True(t, plan.Roots[0].Recursive)
187+
assert.Equal(t, []string{"*.jsonl", "*.jsonl.*"}, plan.Roots[0].IncludeGlobs)
188+
189+
discovered, err := provider.Discover(context.Background())
190+
require.NoError(t, err)
191+
require.Len(t, discovered, 2)
192+
assert.Equal(t, activePath, discovered[0].DisplayPath)
193+
assert.Equal(t, "main", discovered[0].ProjectHint)
194+
assert.Equal(t, newArchivePath, discovered[1].DisplayPath)
195+
assert.Equal(t, "main", discovered[1].ProjectHint)
196+
197+
found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
198+
FullSessionID: "host~" + spec.prefix + ":main:abc-123",
199+
})
200+
require.NoError(t, err)
201+
require.True(t, ok)
202+
assert.Equal(t, activePath, found.DisplayPath)
203+
204+
found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{
205+
RawSessionID: "main:def-456",
206+
})
207+
require.NoError(t, err)
208+
require.True(t, ok)
209+
assert.Equal(t, newArchivePath, found.DisplayPath)
210+
211+
found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{
212+
StoredFilePath: activeArchivePath,
213+
})
214+
require.NoError(t, err)
215+
require.True(t, ok)
216+
assert.Equal(t, activePath, found.DisplayPath)
217+
218+
fingerprint, err := provider.Fingerprint(context.Background(), found)
219+
require.NoError(t, err)
220+
assert.Equal(t, activePath, fingerprint.Key)
221+
assert.Positive(t, fingerprint.Size)
222+
assert.Positive(t, fingerprint.MTimeNS)
223+
224+
changed, err := provider.SourcesForChangedPath(
225+
context.Background(),
226+
ChangedPathRequest{Path: newArchivePath, EventKind: "write", WatchRoot: root},
227+
)
228+
require.NoError(t, err)
229+
require.Len(t, changed, 1)
230+
assert.Equal(t, newArchivePath, changed[0].DisplayPath)
231+
232+
changed, err = provider.SourcesForChangedPath(
233+
context.Background(),
234+
ChangedPathRequest{Path: activeArchivePath, EventKind: "write", WatchRoot: root},
235+
)
236+
require.NoError(t, err)
237+
assert.Empty(t, changed)
238+
239+
require.NoError(t, os.Remove(activePath))
240+
changed, err = provider.SourcesForChangedPath(
241+
context.Background(),
242+
ChangedPathRequest{Path: activePath, EventKind: "remove", WatchRoot: root},
243+
)
244+
require.NoError(t, err)
245+
require.Len(t, changed, 1)
246+
assert.Equal(t, activeArchivePath, changed[0].DisplayPath)
247+
248+
require.NoError(t, os.Remove(newArchivePath))
249+
changed, err = provider.SourcesForChangedPath(
250+
context.Background(),
251+
ChangedPathRequest{Path: newArchivePath, EventKind: "remove", WatchRoot: root},
252+
)
253+
require.NoError(t, err)
254+
require.Len(t, changed, 1)
255+
assert.Equal(t, oldArchivePath, changed[0].DisplayPath)
256+
257+
changed, err = provider.SourcesForChangedPath(
258+
context.Background(),
259+
ChangedPathRequest{
260+
Path: oldArchivePath,
261+
EventKind: "write",
262+
WatchRoot: filepath.Join(root, "..", "other-root"),
263+
},
264+
)
265+
require.NoError(t, err)
266+
assert.Empty(t, changed)
267+
268+
assert.True(t, spec.sessionFile(filepath.Base(activeArchivePath)))
269+
}
270+
271+
func assertClawProviderDiscoversSymlinkedAgentDirectory(
272+
t *testing.T,
273+
spec clawProviderTestSpec,
274+
) {
275+
t.Helper()
276+
277+
root := t.TempDir()
278+
targetRoot := t.TempDir()
279+
targetAgent := filepath.Join(targetRoot, "main")
280+
sourceAgent := filepath.Join(root, "main")
281+
sourcePath := filepath.Join(sourceAgent, "sessions", "abc-123.jsonl")
282+
writeSourceFile(
283+
t,
284+
filepath.Join(targetAgent, "sessions", "abc-123.jsonl"),
285+
spec.fixture("abc-123", "from symlink"),
286+
)
287+
if err := os.Symlink(targetAgent, sourceAgent); err != nil {
288+
t.Skipf("symlink not supported: %v", err)
289+
}
290+
291+
provider, ok := NewProvider(spec.agent, ProviderConfig{
292+
Roots: []string{root},
293+
Machine: "devbox",
294+
})
295+
require.True(t, ok)
296+
297+
discovered, err := provider.Discover(context.Background())
298+
require.NoError(t, err)
299+
require.Len(t, discovered, 1)
300+
assert.Equal(t, sourcePath, discovered[0].DisplayPath)
301+
302+
found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
303+
FullSessionID: "host~" + spec.prefix + ":main:abc-123",
304+
})
305+
require.NoError(t, err)
306+
require.True(t, ok)
307+
assert.Equal(t, sourcePath, found.DisplayPath)
308+
}
309+
310+
func assertClawProviderParse(t *testing.T, spec clawProviderTestSpec) {
311+
t.Helper()
312+
313+
root := t.TempDir()
314+
sourcePath := filepath.Join(root, "main", "sessions", "abc-123.jsonl")
315+
writeSourceFile(t, sourcePath, spec.fixture("abc-123", "provider question"))
316+
317+
provider, ok := NewProvider(spec.agent, ProviderConfig{
318+
Roots: []string{root},
319+
Machine: "devbox",
320+
})
321+
require.True(t, ok)
322+
sources, err := provider.Discover(context.Background())
323+
require.NoError(t, err)
324+
require.Len(t, sources, 1)
325+
326+
outcome, err := provider.Parse(context.Background(), ParseRequest{
327+
Source: sources[0],
328+
Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"},
329+
})
330+
require.NoError(t, err)
331+
require.True(t, outcome.ResultSetComplete)
332+
require.Len(t, outcome.Results, 1)
333+
assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion)
334+
assert.Equal(t, spec.prefix+":main:abc-123", outcome.Results[0].Result.Session.ID)
335+
assert.Equal(t, "project", outcome.Results[0].Result.Session.Project)
336+
assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine)
337+
assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash)
338+
assert.Len(t, outcome.Results[0].Result.Messages, 2)
339+
}
340+
341+
func clawProviderFixture(sessionID string, firstMessage string) string {
342+
return `{"type":"session","version":3,"id":"` + sessionID + `","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}` + "\n" +
343+
`{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"` + firstMessage + `"}],"timestamp":"2026-02-25T10:00:01Z"}}` + "\n" +
344+
`{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"Done."}],"timestamp":"2026-02-25T10:00:02Z"}}` + "\n"
345+
}

0 commit comments

Comments
 (0)