Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/agentsview/archive_query_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ func (b localArchiveQueryBackend) SessionUsage(
if known && !b.skipFreshData {
engine := sync.NewEngine(b.database, sync.EngineConfig{
AgentDirs: b.cfg.AgentDirs,
IncludeCwdPrefixes: b.cfg.SyncIncludeCwdPrefixes,
Machine: "local",
BlockedResultCategories: b.cfg.ResultContentBlockedCategories,
})
Expand Down
1 change: 1 addition & 0 deletions cmd/agentsview/archive_write_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ func (b *localArchiveWriteBackend) PGPushWatch(

engine := syncpkg.NewEngine(b.database, syncpkg.EngineConfig{
AgentDirs: b.appCfg.AgentDirs,
IncludeCwdPrefixes: b.appCfg.SyncIncludeCwdPrefixes,
Machine: "local",
BlockedResultCategories: b.appCfg.ResultContentBlockedCategories,
})
Expand Down
7 changes: 7 additions & 0 deletions cmd/agentsview/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,13 @@ func writeRootHelp(w io.Writer, root *cobra.Command) {
fmt.Fprintln(w, " Example:")
fmt.Fprintln(w, " watch_exclude_patterns = [\".git\", \"node_modules\", \".next\", \"dist\"]")
fmt.Fprintln(w)
fmt.Fprintln(w, "Session cwd filter:")
fmt.Fprintln(w, " Add \"sync_include_cwd_prefixes\" to ~/.agentsview/config.toml to")
fmt.Fprintln(w, " ingest only sessions whose working directory is under one of the")
fmt.Fprintln(w, " listed paths. Sessions without a recorded cwd are skipped while")
fmt.Fprintln(w, " the filter is set. Applies to local sync only. Example:")
fmt.Fprintln(w, " sync_include_cwd_prefixes = [\"/home/me/work\"]")
fmt.Fprintln(w)
fmt.Fprintln(w, "Multiple directories:")
fmt.Fprintln(w, " Add arrays to ~/.agentsview/config.toml to scan multiple locations:")
fmt.Fprintln(w, " claude_project_dirs = [\"/path/one\", \"/path/two\"]")
Expand Down
1 change: 1 addition & 0 deletions cmd/agentsview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ func runServe(cfg config.Config, opts serveOptions) {
if !cfg.NoSync {
engine = sync.NewEngine(database, sync.EngineConfig{
AgentDirs: cfg.AgentDirs,
IncludeCwdPrefixes: cfg.SyncIncludeCwdPrefixes,
Machine: "local",
BlockedResultCategories: cfg.ResultContentBlockedCategories,
Emitter: emitter,
Expand Down
1 change: 1 addition & 0 deletions cmd/agentsview/parse_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func doParseDiff(cfg ParseDiffConfig) (failed bool) {

engine := sync.NewDiffEngine(database, sync.EngineConfig{
AgentDirs: appCfg.AgentDirs,
IncludeCwdPrefixes: appCfg.SyncIncludeCwdPrefixes,
Machine: "local",
BlockedResultCategories: appCfg.ResultContentBlockedCategories,
})
Expand Down
5 changes: 3 additions & 2 deletions cmd/agentsview/session_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ func syncService(
return nil, nil, fmt.Errorf("opening db: %w", err)
}
engine := sync.NewEngine(d, sync.EngineConfig{
AgentDirs: cfg.AgentDirs,
Machine: "local",
AgentDirs: cfg.AgentDirs,
IncludeCwdPrefixes: cfg.SyncIncludeCwdPrefixes,
Machine: "local",
})
// Close the engine before the DB so pending debounced signal
// recomputes flush while the DB is still open.
Expand Down
1 change: 1 addition & 0 deletions cmd/agentsview/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ func runLocalSync(

engine := sync.NewEngine(database, sync.EngineConfig{
AgentDirs: appCfg.AgentDirs,
IncludeCwdPrefixes: appCfg.SyncIncludeCwdPrefixes,
Machine: "local",
BlockedResultCategories: appCfg.ResultContentBlockedCategories,
})
Expand Down
10 changes: 6 additions & 4 deletions cmd/agentsview/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,9 @@ func ensureFreshData(

if database.NeedsResync() {
engine := sync.NewEngine(database, sync.EngineConfig{
AgentDirs: appCfg.AgentDirs,
Machine: "local",
AgentDirs: appCfg.AgentDirs,
IncludeCwdPrefixes: appCfg.SyncIncludeCwdPrefixes,
Machine: "local",
})
defer engine.Close()
fmt.Fprintln(os.Stderr,
Expand All @@ -332,8 +333,9 @@ func ensureFreshData(
}

engine := sync.NewEngine(database, sync.EngineConfig{
AgentDirs: appCfg.AgentDirs,
Machine: "local",
AgentDirs: appCfg.AgentDirs,
IncludeCwdPrefixes: appCfg.SyncIncludeCwdPrefixes,
Machine: "local",
})
defer engine.Close()

Expand Down
36 changes: 36 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,42 @@ state while they hold the start lock, so `agentsview serve status` can show the
starting PID, elapsed time, current phase, progress detail, and log path before
the HTTP server is ready.

### Restricting Ingestion by Working Directory

By default every discovered session is ingested. To limit the archive to
sessions from specific workspaces — for example on a machine shared across
multiple clients where transcripts from one workspace should never appear
alongside another — set `sync_include_cwd_prefixes` in
`~/.agentsview/config.toml`:

```toml
sync_include_cwd_prefixes = [
"/home/me/work/client-a",
"/home/me/oss",
]
```

When the list is non-empty, a session is ingested only if its recorded
working directory equals one of the prefixes or lives underneath one.
Prefixes and session directories are lexically cleaned before matching:
trailing separators are ignored and `..` components are resolved, so
`/home/me/oss/../other` does not match a `/home/me/oss` prefix. Matching
is path-boundary aware (`/home/me/oss` matches `/home/me/oss/repo` but
not `/home/me/oss-other`), case-sensitive, and uses the local operating
system's path separator — on Linux and macOS a backslash is an ordinary
filename character, not a directory boundary. Use absolute paths; `~` is
not expanded.

Notes:

- Sessions without a recorded working directory (a few agents do not store
one) are skipped while the filter is set.
- The filter gates ingestion only. Sessions already in the archive are
preserved (the SQLite database is a persistent archive); remove unwanted
existing sessions explicitly with `agentsview prune`.
- Remote-host sync is unaffected: the prefixes describe local paths, so they
are not applied to sessions pulled from `[[remote_hosts]]` entries.

### Large Watch Trees

The recursive watcher has a hard budget of 8192 directories per process. If a
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,14 @@ type Config struct {

ResultContentBlockedCategories []string `json:"result_content_blocked_categories,omitempty" toml:"result_content_blocked_categories"`

// SyncIncludeCwdPrefixes, when non-empty, restricts local session
// ingestion to sessions whose working directory equals one of the
// prefixes or lives underneath one. Sessions without a recorded
// cwd are skipped while the filter is active. Config-file only;
// remote sync is unaffected because the prefixes describe local
// paths.
SyncIncludeCwdPrefixes []string `json:"-" toml:"sync_include_cwd_prefixes"`

// EventsCoalesceInterval is the minimum wall-clock time between
// SSE data_changed broadcasts to connected clients. Emits that
// arrive within this window after a prior broadcast are coalesced
Expand Down Expand Up @@ -944,6 +952,7 @@ func (c *Config) applyConfigTOML(data string) error {
PublicOrigins []string `toml:"public_origins"`
Proxy ProxyConfig `toml:"proxy"`
WatchExcludePatterns []string `toml:"watch_exclude_patterns"`
SyncIncludeCwdPrefixes []string `toml:"sync_include_cwd_prefixes"`
ResultContentBlockedCategories []string `toml:"result_content_blocked_categories"`
Terminal TerminalConfig `toml:"terminal"`
AuthToken string `toml:"auth_token"`
Expand Down Expand Up @@ -1005,6 +1014,9 @@ func (c *Config) applyConfigTOML(data string) error {
if file.WatchExcludePatterns != nil {
c.WatchExcludePatterns = file.WatchExcludePatterns
}
if file.SyncIncludeCwdPrefixes != nil {
c.SyncIncludeCwdPrefixes = file.SyncIncludeCwdPrefixes
}
if file.ResultContentBlockedCategories != nil {
c.ResultContentBlockedCategories = file.ResultContentBlockedCategories
}
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1733,3 +1733,26 @@ func TestValidateRemoteHosts(t *testing.T) {
})
}
}

func TestLoadFile_SyncIncludeCwdPrefixes(t *testing.T) {
f := newConfigFixture(t)
f.WriteConfigText(t, `sync_include_cwd_prefixes = ["/home/me/work", "/home/me/oss"]
`)

cfg := f.LoadMinimal(t)

assert.Equal(t,
[]string{"/home/me/work", "/home/me/oss"},
cfg.SyncIncludeCwdPrefixes,
)
}

func TestLoadFile_SyncIncludeCwdPrefixesDefaultsEmpty(t *testing.T) {
f := newConfigFixture(t)
f.WriteConfigText(t, `host = "127.0.0.1"
`)

cfg := f.LoadMinimal(t)

assert.Empty(t, cfg.SyncIncludeCwdPrefixes)
}
1 change: 1 addition & 0 deletions internal/server/huma_routes_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ func (s *Server) syncEngineForLocal(local *db.DB) *syncpkg.Engine {
}
s.onDemandEngine = syncpkg.NewEngine(local, syncpkg.EngineConfig{
AgentDirs: s.cfg.AgentDirs,
IncludeCwdPrefixes: s.cfg.SyncIncludeCwdPrefixes,
Machine: "local",
BlockedResultCategories: s.cfg.ResultContentBlockedCategories,
Emitter: emitter,
Expand Down
114 changes: 114 additions & 0 deletions internal/sync/cwd_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package sync

import (
"os"
"path/filepath"
"strings"

"go.kenn.io/agentsview/internal/parser"
)

// cwdPrefixFilter gates session ingestion on the session working
// directory. An empty filter allows everything. A non-empty filter
// allows only sessions whose cwd equals a configured prefix or lives
// underneath one; sessions without a recorded cwd are rejected
// because they cannot be attributed to any workspace.
//
// Prefixes and cwds are lexically cleaned before matching and the
// path boundary is the local OS separator. The filter only ever sees
// local paths (remote sync does not apply it), so local filesystem
// semantics are the correct ones: on POSIX a backslash is an ordinary
// filename character, not a boundary, and a cwd like "/a/b/../c" is
// resolved to "/a/c" rather than prefix-matching "/a/b".
type cwdPrefixFilter struct {
prefixes []string
}

// newCwdPrefixFilter normalizes the configured prefixes: entries are
// trimmed, blank entries are dropped, and each remaining entry is
// cleaned with filepath.Clean so "/a/b/" and "/a/b" behave
// identically and ".." components cannot linger in a prefix.
func newCwdPrefixFilter(prefixes []string) cwdPrefixFilter {
normalized := make([]string, 0, len(prefixes))
for _, p := range prefixes {
p = strings.TrimSpace(p)
if p == "" {
continue
}
normalized = append(normalized, filepath.Clean(p))
}
return cwdPrefixFilter{prefixes: normalized}
}

func (f cwdPrefixFilter) empty() bool {
return len(f.prefixes) == 0
}

// allows reports whether a session with the given cwd may be
// ingested. Matching is path-boundary aware: prefix "/a/b" matches
// "/a/b" and "/a/b/c" but not "/a/bc".
func (f cwdPrefixFilter) allows(cwd string) bool {
if f.empty() {
return true
}
if cwd == "" {
return false
}
cwd = filepath.Clean(cwd)
sep := string(os.PathSeparator)
for _, p := range f.prefixes {
if cwd == p {
return true
}
prefix := p
if !strings.HasSuffix(prefix, sep) {
prefix += sep
}
if strings.HasPrefix(cwd, prefix) {
return true
}
}
return false
}

// sourceAllowsParserExclusions reports whether a source's parser
// exclusions (including engine stale-row cleanup) may delete archived
// rows. When the cwd allow-list is active, a source proves it is
// inside the list by producing at least one allowed session or
// incremental update; a source with no allowed output is frozen — its
// exclusions would erase archived sessions whose replacement writes
// the filter vetoes, which the ingestion-only contract forbids.
// Zero-result exclusion carriers (e.g. a file that parses to no live
// session) have no cwd to judge, so they are frozen too.
func (e *Engine) sourceAllowsParserExclusions(res processResult) bool {
if e.cwdFilter.empty() {
return true
}
if res.incremental != nil && e.cwdFilter.allows(res.incremental.cwd) {
return true
}
for _, pr := range res.results {
if e.cwdFilter.allows(pr.Session.Cwd) {
return true
}
}
return false
}

// splitResultsByCwdFilter returns the parsed sessions the cwd
// allow-list admits and the number it vetoes. With no filter
// configured it returns the input untouched.
func (e *Engine) splitResultsByCwdFilter(
results []parser.ParseResult,
) ([]parser.ParseResult, int) {
if e.cwdFilter.empty() || len(results) == 0 {
return results, 0
}
allowed := make([]parser.ParseResult, 0, len(results))
for _, pr := range results {
if e.cwdFilter.allows(pr.Session.Cwd) {
allowed = append(allowed, pr)
}
}
return allowed, len(results) - len(allowed)
}
Loading