Skip to content

Commit cad0508

Browse files
authored
feat(data): first-class Data mode for project inventory and reclassification (#1166)
## Summary Adds a top-level Data mode for reviewing project identity across an archive and managing folder-to-project mappings. It replaces the mapping editor in Settings with an inventory, a project workspace, and centralized rules that can be previewed and applied atomically. ## Behavior and correctness - Keys inventory rows by opaque project identity, so sanitized-label collisions remain distinct and every deep link and reclassification targets the exact project. - Presents observed folders in a compact workspace suited to projects with many source directories. Unknown identities appear as Unclassified, and filesystem roots are not suggested as automatic mappings. - Binds reclassification previews to the normalized draft, collision state, mapping revision, and exact affected-session snapshot. Changed state returns a conflict instead of applying a stale preview. - Reconciles identity evidence when sessions move between projects while preserving trustworthy historical Git evidence for missing sources and orphaned sessions. - Emits project/session changes after mutations and refreshes Data, Sessions, and Activity metadata across clients. Background rule refreshes update the list without discarding unsaved edits. - Keeps mapping mutations local to writable SQLite archives. PostgreSQL and DuckDB expose the same inventory and governance data as read-only views. ## Publication and storage Filtered PostgreSQL and DuckDB publication keeps mappings, snapshots, provenance, and tombstones within each configured project scope. Incremental watch work is bounded by changed sessions, project moves publish in both directions, placeholder snapshots are excluded, and mirrored sessions retain their source machine, file path, and exact database generation. The SQLite data version advances from 76 to 77 to reparse project-identity source snapshots while preserving live and orphaned archive data through non-destructive migration. PostgreSQL gains source-scoped publication metadata and session provenance fields. DuckDB schema v8 adds source archive and mapping provenance; existing disposable mirrors rebuild rather than migrate in place. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent b0318ad commit cad0508

184 files changed

Lines changed: 23459 additions & 1499 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ jobs:
118118
run: |
119119
bash scripts/install_test.sh
120120
bash scripts/retry_test.sh
121+
bash scripts/e2e-server_test.sh
121122
bash scripts/check_desktop_release_health_test.sh
122123
bash scripts/check_desktop_release_health_from_event_test.sh
123124
bash desktop/scripts/test-repair-appimage-diricon.sh
@@ -366,3 +367,9 @@ jobs:
366367
env:
367368
E2E_PREBUILT_FIXTURE: /tmp/testfixture
368369
E2E_PREBUILT_SERVER: /tmp/agentsview
370+
371+
- name: Run DuckDB E2E tests
372+
run: make e2e-duckdb
373+
env:
374+
E2E_PREBUILT_FIXTURE: /tmp/testfixture
375+
E2E_PREBUILT_SERVER: /tmp/agentsview

.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ docs/.env*.local
6767
testdata/ssh/test_key
6868

6969
# Local data
70-
data/
71-
sessions/
72-
html/
70+
/data/
71+
/sessions/
72+
/html/
7373
.superset/
7474
.github/hooks/
7575
.superpowers/

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,8 @@ e2e:
378378
# Run focused Playwright smoke tests against duckdb serve.
379379
e2e-duckdb:
380380
cd frontend && AGENTSVIEW_E2E_BACKEND=duckdb npx playwright test \
381-
e2e/duckdb-backend.spec.ts e2e/session-list.spec.ts --project=chromium
381+
e2e/duckdb-backend.spec.ts e2e/data-mode.spec.ts \
382+
e2e/session-list.spec.ts --project=chromium
382383

383384
# Vet
384385
vet: pricing-snapshot ensure-embed-dir

cmd/testfixture/main.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"go.kenn.io/agentsview/internal/db"
1414
duckdbsync "go.kenn.io/agentsview/internal/duckdb"
15+
"go.kenn.io/agentsview/internal/export"
1516
"go.kenn.io/agentsview/internal/money"
1617
)
1718

@@ -119,6 +120,12 @@ func main() {
119120
log.Fatalf("creating recent-edits fixture: %v", err)
120121
}
121122

123+
if err := createProjectReclassificationFixture(
124+
database, base.Add(120*time.Hour),
125+
); err != nil {
126+
log.Fatalf("creating project-reclassification fixture: %v", err)
127+
}
128+
122129
fmt.Printf("Fixture DB written to %s\n", *out)
123130
if *duckDBOut != "" {
124131
if err := writeDuckDBMirror(database, *duckDBOut); err != nil {
@@ -128,6 +135,80 @@ func main() {
128135
}
129136
}
130137

138+
func createProjectReclassificationFixture(
139+
database *db.DB, start time.Time,
140+
) error {
141+
const (
142+
machine = "remote-example-host"
143+
project = "wrong_branch_label"
144+
worktreeRoot = "/srv/worktrees/github.com/example-org/sample-service/example-worktree"
145+
model = "claude-sonnet-4-20250514"
146+
)
147+
cwds := []struct {
148+
suffix string
149+
cwd string
150+
}{
151+
{suffix: "root", cwd: worktreeRoot},
152+
{suffix: "nested", cwd: worktreeRoot + "/cmd/server"},
153+
}
154+
ctx := context.Background()
155+
for index, item := range cwds {
156+
sessionID := "test-session-project-reclassification-" + item.suffix
157+
startedAt := start.Add(time.Duration(index) * time.Hour)
158+
endedAt := startedAt.Add(12 * time.Minute)
159+
firstMessage := "Inspect the sample service worktree."
160+
session := db.Session{
161+
ID: sessionID,
162+
Project: project,
163+
Machine: machine,
164+
Agent: "claude",
165+
StartedAt: new(startedAt.Format(time.RFC3339Nano)),
166+
EndedAt: new(endedAt.Format(time.RFC3339Nano)),
167+
MessageCount: 2,
168+
UserMessageCount: 1,
169+
FirstMessage: new(firstMessage),
170+
Cwd: item.cwd,
171+
}
172+
if err := database.UpsertSession(session); err != nil {
173+
return fmt.Errorf(
174+
"upserting project-reclassification session: %w", err,
175+
)
176+
}
177+
if err := database.InsertMessages(generateMessages(
178+
sessionID, session.MessageCount, startedAt, model,
179+
)); err != nil {
180+
return fmt.Errorf(
181+
"inserting project-reclassification messages: %w", err,
182+
)
183+
}
184+
if err := database.UpsertProjectIdentityObservation(
185+
ctx,
186+
export.ProjectIdentityObservation{
187+
SessionID: sessionID,
188+
Project: project,
189+
Machine: machine,
190+
RootPath: worktreeRoot,
191+
RepositoryPath: "/srv/worktrees/github.com/example-org/sample-service",
192+
WorktreeName: "example-worktree",
193+
WorktreeRootPath: worktreeRoot,
194+
WorktreeRelationship: export.WorktreeLinked,
195+
CheckoutState: export.CheckoutBranch,
196+
GitBranch: "example-worktree",
197+
ObservedAt: startedAt,
198+
},
199+
); err != nil {
200+
return fmt.Errorf(
201+
"upserting project-reclassification identity: %w", err,
202+
)
203+
}
204+
fmt.Printf(
205+
" %s: %d messages (project reclassification)\n",
206+
sessionID, session.MessageCount,
207+
)
208+
}
209+
return nil
210+
}
211+
131212
func writeDuckDBMirror(database *db.DB, path string) error {
132213
if err := os.Remove(path); err != nil &&
133214
!errors.Is(err, os.ErrNotExist) {

cmd/testfixture/main_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"go.kenn.io/agentsview/internal/db"
13+
)
14+
15+
func TestCreateProjectReclassificationFixture(t *testing.T) {
16+
database, err := db.Open(filepath.Join(t.TempDir(), "sessions.db"))
17+
require.NoError(t, err)
18+
t.Cleanup(func() { require.NoError(t, database.Close()) })
19+
20+
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
21+
require.NoError(t, createProjectReclassificationFixture(database, base))
22+
23+
const (
24+
machine = "remote-example-host"
25+
wrongProject = "wrong_branch_label"
26+
worktreeRoot = "/srv/worktrees/github.com/example-org/sample-service/example-worktree"
27+
)
28+
wantCwds := map[string]string{
29+
"test-session-project-reclassification-root": worktreeRoot,
30+
"test-session-project-reclassification-nested": worktreeRoot + "/cmd/server",
31+
}
32+
for sessionID, wantCwd := range wantCwds {
33+
session, getErr := database.GetSession(context.Background(), sessionID)
34+
require.NoError(t, getErr)
35+
require.NotNil(t, session)
36+
assert.Equal(t, machine, session.Machine)
37+
assert.Equal(t, wrongProject, session.Project)
38+
assert.Equal(t, wantCwd, session.Cwd)
39+
}
40+
41+
snapshots, err := database.ListSessionProjectIdentitySnapshots(
42+
context.Background(),
43+
)
44+
require.NoError(t, err)
45+
require.Len(t, snapshots, 2)
46+
for _, snapshot := range snapshots {
47+
assert.Equal(t, machine, snapshot.Machine)
48+
assert.Equal(t, wrongProject, snapshot.Project)
49+
assert.Equal(t, worktreeRoot, snapshot.RootPath)
50+
assert.Equal(t, worktreeRoot, snapshot.WorktreeRootPath)
51+
assert.NotEmpty(t, snapshot.Key)
52+
}
53+
}

docs/activity.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ that total across usage rows in proportion to their catalog-price estimates. The
106106
per-model costs are therefore estimated attributions, not provider-reported
107107
model charges, but they still sum to the displayed total.
108108

109+
## Create A Project Mapping
110+
111+
Worktree layouts the parser does not recognize can surface a branch or
112+
worktree directory name as a project. Each row in the **Project** breakdown
113+
links to that project on the [Data page](/data/), where the mapping editor lists
114+
the project's observed session folders, previews the full-archive impact of a
115+
folder-path → project rule, and applies a
116+
[worktree project mapping](/configuration/#worktree-project-mappings) rule in
117+
one atomic step. Cleaning always evaluates the complete archive; the current
118+
Activity range and filters do not carry over.
119+
109120
## Activity Insight
110121

111122
At the bottom of the page, **Activity Insight** shows an existing global

docs/configuration.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -720,27 +720,43 @@ manual sync, and the periodic directory scan.
720720

721721
### Worktree Project Mappings
722722

723-
The parser infers a session's project from its `cwd`, which works for standard
724-
layouts but not custom worktree conventions like
725-
`~/code/{project}.worktrees/feat/<branch>/` — those sessions otherwise group
726-
under `<branch>` rather than `{project}`. As of 0.29.0, you can register manual
727-
**path-prefix → project** rules from the **Worktree Project Mappings** section
728-
in Settings:
729-
730-
![Worktree Project Mappings settings section](/assets/generated/screenshots/worktree-mappings.png)
723+
The parser infers a session's project from its `cwd`. It recognizes common
724+
worktree manager layouts, including the generic
725+
`worktrees/github.com/<owner>/<repository>/<worktree>` convention, where the
726+
repository segment becomes the project. Layouts it does not recognize — such
727+
as `~/code/{project}.worktrees/feat/<branch>/` — otherwise group sessions
728+
under `<branch>` rather than `{project}`. For those, register manual
729+
**path-prefix → project** rules from the **Rules** view on the
730+
[Data page](/data/#rules), or let the
731+
[mapping editor](/data/#create-a-project-mapping) create one from a project's
732+
observed session folders:
733+
734+
![Worktree mapping rules on the Data page](/assets/generated/screenshots/worktree-mappings.png)
731735

732736
- Mappings are explicit; there is no auto-discovery.
737+
- Each rule is scoped to one machine. The machine selector manages rules for
738+
the local machine and for any remotely synced machine. Rules live in the
739+
writable archive that ingests that machine's sessions, which may be the
740+
source machine's local SQLite archive or a separate collector archive.
733741
- Each rule applies whenever a session's `cwd` falls under the configured
734742
prefix, on both new sessions as they sync and (via the **Apply** button)
735-
already-imported sessions.
743+
already-imported sessions. Prefixes match on directory boundaries, so
744+
`/worktrees/service` does not match `/worktrees/service-old`.
745+
- Enabled mappings run after parser inference, so an explicit rule always wins
746+
when the two disagree.
736747
- The default `explicit` layout maps every matching path to the project name
737748
stored on the rule. The `repo_dot_worktrees` layout derives the project from
738749
the first path segment under the prefix when it is named `<repo>.worktrees`,
739750
so a path like `/code/agentsview.worktrees/feature/frontend` resolves to
740751
project `agentsview`.
741-
- Rules are stored in a `worktree_project_mappings` SQLite table scoped to the
742-
host machine, so a mapping created on one machine does not leak into another
743-
machine's view of synced sessions.
752+
- Rules created from the Data mapping editor record the mislabeled
753+
project they corrected, shown as the rule's **original label**. The value is
754+
informational and set once; to manually revert a reclassification, edit the
755+
rule's target back to that original label and apply again.
756+
- Disabling or deleting a rule does not rewrite sessions by itself. Sessions
757+
whose source files still exist revert to parser-derived names on a later
758+
reparse or full resync, while orphaned sessions keep their stored
759+
classification.
744760
- Excluded, trashed, and skipped session files are left alone.
745761

746762
Mappings only mutate the session's `project` field; the rest of the session

docs/data.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
title: Data
3+
description: Project inventory and worktree mapping rules
4+
---
5+
6+
The **Data** page is where you inspect and clean project classification across
7+
the whole archive. It is the home of the worktree mapping rules that previously
8+
lived in the Settings **Worktree mappings** section.
9+
10+
Open it from the **Data** tab in the header, or follow a project link from the
11+
[Activity breakdown](/activity/#breakdowns). Deep links are stable:
12+
`/data?project_key=<key>` selects a project, and `/data?view=rules` opens the
13+
[Rules view](#rules).
14+
15+
## Project Inventory
16+
17+
The default view lists every project in the archive with its session, machine,
18+
agent, and working-directory counts plus first and last activity timestamps. A
19+
summary strip totals the projects, sessions, and the sessions currently governed
20+
by classification rules.
21+
22+
- The table is sortable by any column and filterable by project name.
23+
- Projects targeted by enabled rules carry a rule badge; projects recorded as a
24+
rule's original label carry an original-label badge.
25+
- Sessions whose stored project label is empty are grouped under a single
26+
"unknown" row.
27+
- Activity bounds come from session timestamps only; rows without any recorded
28+
timestamps show a no-activity state.
29+
30+
Selecting a row opens the project workspace. Unknown `project_key` deep links
31+
show the full inventory with a non-blocking notice.
32+
33+
## Create A Project Mapping
34+
35+
The workspace creates [worktree project
36+
mappings](/configuration/#worktree-project-mappings) directly:
37+
38+
- **Observed folders** lists every session folder associated with the selected
39+
project. Each folder stays visible instead of being hidden behind a worktree
40+
selector.
41+
- Selecting a folder opens one mapping row: **Folder path → Project**. The
42+
suggested folder path covers that group's working directories and remains
43+
editable, so it can be shortened to cover sibling folders when appropriate.
44+
- The **Project** typeahead suggests known projects and accepts a new name.
45+
When the server normalizes the name (for example `sample-service` becomes
46+
`sample_service`), the editor shows the stored form before you apply.
47+
- The **full archive impact** preview is live and authoritative: it counts
48+
matching sessions across all dates for that machine. A prefix that touches
49+
more than one existing project shows a warning with per-project counts —
50+
usually a sign the prefix is too broad. A prefix matching zero sessions
51+
cannot be applied.
52+
53+
**Save and apply mapping** saves the rule and rewrites the matching sessions in
54+
one atomic step, then reloads the inventory. If the applied rule renamed the
55+
selected project, the selection follows the new name. If mappings changed
56+
between preview and apply, the apply is rejected and a fresh preview is
57+
required.
58+
59+
## Rules
60+
61+
The **Rules** toggle shows the worktree mapping rules for one machine at a time,
62+
with the same add, edit, apply, and delete controls that Settings previously
63+
offered — see
64+
[Worktree Project Mappings](/configuration/#worktree-project-mappings) for the
65+
full rule semantics. Each rule row also shows its **governed sessions** count
66+
(how many sessions the rule currently classifies) and the **original label**
67+
recorded when the rule was created through the mapping editor. Rule
68+
targets link back to the corresponding inventory row.
69+
70+
## Read-Only Servers
71+
72+
On a read-only server (`pg serve` or `duckdb serve`) the inventory, the
73+
candidate evidence, and the Rules table remain fully readable, but the editor
74+
and rule mutations are replaced by a notice: classification rules are managed
75+
from the writable archive that ingests the machine's sessions.

docs/internal/session-format-sources.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,10 @@ Grok section and remove the explicit registry exception in the coverage test.
11671167
context/last-turn/total statistics, without per-message cache or cost data.
11681168
Agentsview emits one aggregate usage event and catalog-prices it when model
11691169
identity is available.
1170+
- **Project identity:** Metadata records `session_id`, `git_branch`, and
1171+
`environment.working_directory`. Agentsview recovers those independent
1172+
fields even when another optional metadata field is malformed, so a partial
1173+
parse cannot replace repository classification with generic fallbacks.
11701174
- **Agentsview:** `internal/parser/vibe.go` and
11711175
`internal/parser/vibe_provider.go`.
11721176

docs/screenshots/extract-db.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ DELETE FROM session_project_identity_snapshots;
290290
DELETE FROM project_identity_observation_changes;
291291
DELETE FROM session_project_identity_snapshot_changes;
292292
DELETE FROM worktree_project_mappings;
293+
-- The mapping delete above journals tombstones, so clear that journal last.
294+
DELETE FROM worktree_project_mapping_changes;
293295
294296
-- Keep generated screenshots independent of the source machine's hostname.
295297
-- The PostgreSQL fixture relabels a subset as work-desktop after push so the

0 commit comments

Comments
 (0)