Skip to content

Commit f47f17d

Browse files
mxfeinbergclaude
andauthored
pg push service (#547)
## Problem I track token usage across AI tools with agentsview, and lately I've been running more remote agent tools on machines other than my primary dev box. Each of those machines records its own sessions locally. The Postgres support (`pg push` / `pg serve`) already lets me review everything together, but keeping the shared database current meant either remembering to run `pg push` by hand on each machine or wiring up a bespoke cron job per host. I wanted a touchless way to sync data from multiple machines into one place so remote agent token usage shows up in my primary dashboard automatically, without maintaining custom cron entries everywhere. I also made some updates to hermes specific code because although tokens were being passed through, the associated costs were not being aggregated properly. ## pg push service A long-running auto-push daemon and a first-class way to install it as an OS service, so a recorder machine keeps the shared Postgres current on its own. **`agentsview pg push --watch`** start a foreground daemon that pushes to Postgres shortly after sessions change, with a periodic floor as a safety net: - A file watcher (reusing the same machinery as `serve`) coalesces change events over a debounce window (`--debounce`, default 30s) and triggers a local sync + incremental push. - A periodic floor (`--interval`, default 15m) pushes regardless of file events and covers any directories the watch budget couldn't cover. - Pushes are serialized by a single goroutine and connect to Postgres lazily, reconnecting on error. A transiently unreachable database is logged and retried on the next trigger rather than crashing the daemon. - A single-instance lock prevents two watchers from racing on the push watermarks. On shutdown (SIGINT/SIGTERM) it performs one bounded final flush. - It reads the same `[pg]` config and project filters as `pg push`, and honors `result_content_blocked_categories` so the push path no longer diverges from `serve`. **`agentsview pg service install|uninstall|status|start|stop|logs`** installs and manages the daemon as a per-user OS service: - launchd LaunchAgent on macOS, `systemd --user` unit on Linux, behind a small platform abstraction with pure (golden-tested) unit-file rendering. - `install` validates that the Postgres DSN is resolvable before creating the service, and surfaces the systemd linger requirement so the service keeps running on headless boxes after logout. - `status` reports the manager state plus the last successful push time; `logs -f` tails the daemon log and survives log rotation. The daemon reads the DSN from the existing config file (no credentials are copied into unit files), so protecting `config.toml` is sufficient. ## Notes - Platform support targets macOS (launchd) and Linux (`systemd --user`); the daemon command itself is cross-platform Go. - The generated unit always pins `AGENTSVIEW_DATA_DIR` to the install-time data dir, so the service resolves the same config regardless of the environment it starts in. - The watch daemon's initial resync is push-oriented and skips the serve-side `Vacuum`/`BackfillSignals` steps, since signal recomputation happens on the serve side. - A windows service was not added because I don't currently have access to a windows machine that I could use for testing. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 47b30a1 commit f47f17d

29 files changed

Lines changed: 2803 additions & 61 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,7 @@ html/
6363

6464
# Antigravity CLI session dir created when AGY runs inside this repo
6565
.antigravitycli/
66+
67+
# graphify knowledge-graph output (local analysis)
68+
graphify-out/
6669
.kata.local.toml

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,45 @@ agentsview pg push # push local data to PG
283283
agentsview pg serve # serve web UI from PG (read-only)
284284
```
285285

286+
### Automatic push (background service)
287+
288+
To keep a shared PostgreSQL database current without running `pg push` by
289+
hand, run the auto-push daemon. It watches your session directories and
290+
pushes shortly after new sessions are recorded, with a periodic floor as a
291+
safety net:
292+
293+
```bash
294+
agentsview pg push --watch # foreground, Ctrl-C to stop
295+
agentsview pg push --watch --debounce 1m # custom coalesce window
296+
agentsview pg push --watch --interval 5m # custom floor interval
297+
```
298+
299+
The daemon reads the same `[pg]` config as `pg push`, so the PostgreSQL
300+
DSN must be set in your config file (or an environment variable it
301+
expands). Protect the config file, since it holds credentials:
302+
303+
```bash
304+
chmod 600 ~/.agentsview/config.toml
305+
```
306+
307+
To run it unattended as an OS service (launchd on macOS,
308+
`systemd --user` on Linux):
309+
310+
```bash
311+
agentsview pg service install # generate the unit, enable + start it
312+
agentsview pg service status # show manager status
313+
agentsview pg service logs -f # follow the service log
314+
agentsview pg service uninstall # stop and remove
315+
```
316+
317+
**Linux headless machines:** systemd `--user` services stop at logout and
318+
do not start at boot unless lingering is enabled for your user. `install`
319+
detects this and prints the command; you can also run it yourself:
320+
321+
```bash
322+
loginctl enable-linger "$USER"
323+
```
324+
286325
See [PostgreSQL docs](https://agentsview.io/postgresql/) for setup and
287326
configuration.
288327

cmd/agentsview/cli.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ func newPGCommand() *cobra.Command {
350350
cmd.AddCommand(newPGPushCommand())
351351
cmd.AddCommand(newPGStatusCommand())
352352
cmd.AddCommand(newPGServeCommand())
353+
cmd.AddCommand(newPGServiceCommand())
353354
return cmd
354355
}
355356

@@ -361,13 +362,24 @@ func newPGPushCommand() *cobra.Command {
361362
SilenceUsage: true,
362363
Args: cobra.NoArgs,
363364
Run: func(cmd *cobra.Command, args []string) {
365+
if cfg.Watch {
366+
runPGPushWatch(cfg)
367+
return
368+
}
369+
if cmd.Flags().Changed("debounce") || cmd.Flags().Changed("interval") {
370+
fmt.Fprintln(os.Stderr,
371+
"warning: --debounce and --interval have no effect without --watch")
372+
}
364373
runPGPush(cfg)
365374
},
366375
}
367376
cmd.Flags().BoolVar(&cfg.Full, "full", false, "Force full local resync and PG push")
368377
cmd.Flags().StringVar(&cfg.ProjectsFlag, "projects", "", "Comma-separated list of projects to push (inclusive)")
369378
cmd.Flags().StringVar(&cfg.ExcludeProjects, "exclude-projects", "", "Comma-separated list of projects to exclude from push")
370379
cmd.Flags().BoolVar(&cfg.AllProjects, "all-projects", false, "Ignore configured project filters for this run")
380+
cmd.Flags().BoolVar(&cfg.Watch, "watch", false, "Run continuously, pushing on change plus a periodic floor")
381+
cmd.Flags().DurationVar(&cfg.Debounce, "debounce", defaultWatchDebounce, "Coalesce window after a change before pushing (--watch only)")
382+
cmd.Flags().DurationVar(&cfg.Interval, "interval", defaultWatchInterval, "Periodic floor push interval (--watch only)")
371383
return cmd
372384
}
373385

cmd/agentsview/main.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,11 @@ func runServe(cfg config.Config) {
253253
fmt.Printf("Database: %s\n", cfg.DBPath)
254254

255255
if engine != nil {
256-
stopWatcher, unwatchedDirs := startFileWatcher(cfg, engine)
256+
stopWatcher, unwatchedDirs := startFileWatcher(
257+
cfg, engine, func(paths []string) {
258+
engine.SyncPaths(paths)
259+
},
260+
)
257261
defer stopWatcher()
258262
if len(unwatchedDirs) > 0 {
259263
go startUnwatchedPoll(engine)
@@ -281,7 +285,13 @@ func mustLoadConfig(cmd *cobra.Command) config.Config {
281285
const maxLogSize = 10 * 1024 * 1024 // 10 MB
282286

283287
func setupLogFile(dataDir string) {
284-
logPath := filepath.Join(dataDir, "debug.log")
288+
setupLogFileNamed(dataDir, "debug.log")
289+
}
290+
291+
// setupLogFileNamed redirects the standard logger to the named file
292+
// in dataDir, truncating it first if it exceeds maxLogSize.
293+
func setupLogFileNamed(dataDir, name string) {
294+
logPath := filepath.Join(dataDir, name)
285295
truncateLogFile(logPath, maxLogSize)
286296
f, err := os.OpenFile(
287297
logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644,
@@ -443,12 +453,9 @@ func printSyncProgress(p sync.Progress) {
443453
}
444454

445455
func startFileWatcher(
446-
cfg config.Config, engine *sync.Engine,
456+
cfg config.Config, engine *sync.Engine, onChange func(paths []string),
447457
) (stopWatcher func(), unwatchedDirs []string) {
448458
t := time.Now()
449-
onChange := func(paths []string) {
450-
engine.SyncPaths(paths)
451-
}
452459
watcher, err := sync.NewWatcher(watcherDebounce, onChange, cfg.WatchExcludePatterns)
453460
if err != nil {
454461
log.Printf(

cmd/agentsview/pg.go

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,12 @@ type PGPushConfig struct {
2424
ProjectsFlag string
2525
ExcludeProjects string
2626
AllProjects bool
27+
Watch bool
28+
Debounce time.Duration
29+
Interval time.Duration
2730
}
2831

2932
func runPGPush(cfg PGPushConfig) {
30-
if cfg.ProjectsFlag != "" && cfg.ExcludeProjects != "" {
31-
fatal("pg push: --projects and --exclude-projects " +
32-
"are mutually exclusive")
33-
}
34-
if cfg.AllProjects &&
35-
(cfg.ProjectsFlag != "" || cfg.ExcludeProjects != "") {
36-
fatal("pg push: --all-projects cannot be combined " +
37-
"with --projects or --exclude-projects")
38-
}
39-
4033
appCfg, err := config.LoadMinimal()
4134
if err != nil {
4235
log.Fatalf("loading config: %v", err)
@@ -54,28 +47,9 @@ func runPGPush(cfg PGPushConfig) {
5447
fatal("pg push: url not configured")
5548
}
5649

57-
// CLI flags override config values entirely. When either
58-
// flag is set, clear both config-derived lists so a CLI
59-
// include can override a config exclude (and vice versa).
60-
// --all-projects clears both lists for an unfiltered push.
61-
projects := pgCfg.Projects
62-
excludeProjects := pgCfg.ExcludeProjects
63-
if cfg.AllProjects {
64-
projects = nil
65-
excludeProjects = nil
66-
}
67-
if cfg.ProjectsFlag != "" {
68-
projects = splitProjectList(cfg.ProjectsFlag)
69-
excludeProjects = nil
70-
}
71-
if cfg.ExcludeProjects != "" {
72-
excludeProjects = splitProjectList(cfg.ExcludeProjects)
73-
projects = nil
74-
}
75-
76-
if len(projects) > 0 && len(excludeProjects) > 0 {
77-
fatal("pg push: projects and exclude_projects " +
78-
"are mutually exclusive")
50+
projects, excludeProjects, err := resolvePushProjects(pgCfg, cfg)
51+
if err != nil {
52+
fatal("pg push: %v", err)
7953
}
8054

8155
applyClassifierConfig(appCfg)
@@ -363,6 +337,47 @@ func runPGServe(appCfg config.Config, basePath string) {
363337
}
364338
}
365339

340+
// resolvePushProjects merges configured project filters with CLI
341+
// flag overrides. A CLI include or exclude flag fully replaces the
342+
// configured lists; --all-projects clears both. Include and exclude
343+
// are mutually exclusive.
344+
func resolvePushProjects(
345+
pgCfg config.PGConfig, cfg PGPushConfig,
346+
) (projects, exclude []string, err error) {
347+
if cfg.ProjectsFlag != "" && cfg.ExcludeProjects != "" {
348+
return nil, nil, fmt.Errorf(
349+
"--projects and --exclude-projects are mutually exclusive",
350+
)
351+
}
352+
if cfg.AllProjects &&
353+
(cfg.ProjectsFlag != "" || cfg.ExcludeProjects != "") {
354+
return nil, nil, fmt.Errorf(
355+
"--all-projects cannot be combined with " +
356+
"--projects or --exclude-projects",
357+
)
358+
}
359+
projects = pgCfg.Projects
360+
exclude = pgCfg.ExcludeProjects
361+
if cfg.AllProjects {
362+
projects = nil
363+
exclude = nil
364+
}
365+
if cfg.ProjectsFlag != "" {
366+
projects = splitProjectList(cfg.ProjectsFlag)
367+
exclude = nil
368+
}
369+
if cfg.ExcludeProjects != "" {
370+
exclude = splitProjectList(cfg.ExcludeProjects)
371+
projects = nil
372+
}
373+
if len(projects) > 0 && len(exclude) > 0 {
374+
return nil, nil, fmt.Errorf(
375+
"projects and exclude_projects are mutually exclusive",
376+
)
377+
}
378+
return projects, exclude, nil
379+
}
380+
366381
// splitProjectList splits a comma-separated string into trimmed,
367382
// non-empty project names.
368383
func splitProjectList(s string) []string {

0 commit comments

Comments
 (0)