Skip to content

Commit 5d4d2fa

Browse files
committed
feat(data): first-class Data mode for project inventory and reclassification
Centralize archive-wide project governance in a dedicated Data mode so folder mappings are explicit, reviewable, and applied atomically without relying on Activity filters or ambiguous worktree terminology. Follow-up fixes: - reconcile reclassified identity aggregates - bound reclassification identity updates - refresh project filters after reclassification - reload grouped session counts after deletion - keep project identity views current - preserve rule drafts during refresh - count all matched reclassification projects - preserve mappings across publication scopes - preserve identity across publication scopes - preserve PostgreSQL publication provenance
1 parent 3205da8 commit 5d4d2fa

177 files changed

Lines changed: 22416 additions & 1442 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/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

docs/screenshots/tests/screenshots.spec.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,18 +1160,20 @@ test.describe('Settings', () => {
11601160
});
11611161

11621162
test('worktree project mappings section', async ({ page }) => {
1163-
await openSettings(page);
1164-
const worktreeSection = await openSettingsPanel(
1165-
page,
1166-
'Worktree mappings'
1167-
);
1168-
await worktreeSection.scrollIntoViewIfNeeded();
1169-
const mappingPath = worktreeSection.getByRole('textbox').first();
1163+
// Mapping rules moved from Settings to the Data page's Rules view.
1164+
await page.goto('/data?view=rules');
1165+
1166+
const rulesView = page.locator('section.rules-view');
1167+
await expect(rulesView).toBeVisible({ timeout: 5_000 });
1168+
await rulesView.scrollIntoViewIfNeeded();
1169+
const mappingPath = rulesView.getByRole('textbox', {
1170+
name: 'Path prefix',
1171+
});
11701172
await mappingPath.fill('~/code/project.worktrees');
11711173
await expect(mappingPath).toHaveValue('~/code/project.worktrees');
11721174
await page.waitForTimeout(500);
11731175

1174-
await snapEl(worktreeSection, 'worktree-mappings');
1176+
await snapEl(rulesView, 'worktree-mappings');
11751177
});
11761178
});
11771179

0 commit comments

Comments
 (0)