diff options
31 files changed, 2096 insertions, 385 deletions
diff --git a/constrain/AGENTS.md b/constrain/AGENTS.md index abec095..871fc6c 100644 --- a/constrain/AGENTS.md +++ b/constrain/AGENTS.md @@ -2,10 +2,14 @@ Files are read with the `vr` tool — run `vr -doc` once for full usage. +`vr` stores the trace in the repo's private local jj sidecar named `vr_log`. +Do not set `VR_LOG`, pass `-log`, or create a working-tree `vr-log.jsonl`. + - `vr read FILE:START-END` — line-numbered read; prefer ranges over whole files - `vr grep PATTERN [PATH]` — regex search; scope with a path - `vr read -r REV FILE`, `vr grep -r REV PATTERN PATH` — at another jj change - `vr note -f FILE:10-42 -t struct TEXT...` — record observations as you read Navigate top-down: grep for the symbol, read the enclosing range, note what -you learn. Reads and notes are keyed to the current jj change automatically. +you learn. Every entry records the full current jj change and commit IDs; the +sidecar append does not modify the project's working-copy change. diff --git a/constrain/README.txt b/constrain/README.txt index 4eee9a1..4da2733 100644 --- a/constrain/README.txt +++ b/constrain/README.txt @@ -11,7 +11,6 @@ vr-only-guard.sh PreToolUse hook: blocks cat/rg/sed/... and the agent at `vr -doc`, so agents converge even with zero instructions vr-only.rules codex execpolicy rules (allow vr, forbid readers) -codex-config.toml optional: centralize VR_LOG for codex AGENTS.md how-to-navigate-with-vr guidance for the repo ── one-time setup ────────────────────────────────────────────────────────── @@ -20,9 +19,8 @@ put both on PATH: go build -o ~/.local/bin/vr . (repo root) (vr is what agents call; vrsite is how you read the trace) ── running a constrained CLAUDE investigation ────────────────────────────── -No repo mutation needed; from the target repo dir: +From the target jj repo dir: - VR_LOG=/abs/path/trace.jsonl \ claude -p --settings <vr-repo>/constrain/claude-headless-settings.json \ --allowedTools 'Bash(vr)' 'Bash(vr:*)' \ < prompt.txt > report.md @@ -40,29 +38,35 @@ No repo mutation needed; from the target repo dir: ── running a constrained CODEX investigation ─────────────────────────────── cp vr-only.rules ~/.codex/rules/ # activate (GLOBAL: constrains # every codex session while there) - VR_LOG=/abs/path/trace.jsonl \ codex exec -s danger-full-access "$(cat prompt.txt)" > report.md rm ~/.codex/rules/vr-only.rules # deactivate when done - - danger-full-access is required: workspace-write blocks .git/ writes, - which kills jj's working-copy snapshot and with it `vr read`. + - danger-full-access is required: workspace-write blocks jj metadata writes, + including the sidecar rewrite and `vr read`'s working-copy snapshot. - enforcement is pre-exec by codex's execpolicy engine, even through `zsh -lc` wrappers; validate rules with: codex execpolicy check --rules vr-only.rules -- cat foo.txt ── shared trace + rendering ──────────────────────────────────────────────── - - VR_LOG must point at a FILE path whose parent exists. If the path is a - directory (or becomes one), agents improvise their own log files and you - will be merging jsonl afterwards. Ask me how I know. - - Multiple agents may share one log: note ids are per-session, appends are - line-atomic. Same file = one merged timeline for free. + - vr creates one anonymous sidecar change directly under jj's root() on the + first append. The repo-local alias vr_log names it; git.private-commits + protects it from accidental pushes. No path or environment setup exists. + - Multiple agents in the repo share that sidecar. Rewrites are serialized, + note ids are assigned under the same lock, and the project @ is untouched. + - The sidecar has no bookmark and is local-only. It does not survive a fresh + clone; include the jj repository in backups when the trace matters. - In the prompt, tell the agent to leave pinned notes (vr note -f FILE:START-END -t kind "...") — that is the payload. - - Read the trace afterwards — serve it, and write notes of your own back - into the same log: - vrsite -log trace.jsonl -repo <repo> -title "..." + - Read traces afterwards from one process. Bare `vrsite` scans below ~ at + startup and when Refresh is pressed, then offers every repo with a sidecar: + vrsite + Its explicit buttons can start an empty private sidecar in a discovered jj + repo or initialize colocated jj in a discovered Git repo. The project index + is only in memory; those two requested metadata changes are the only writes. + - To bypass the dashboard and serve one repository directly: + vrsite -repo <repo> -title "..." or take a static copy to hand around (one change, no server features): - vrsite -log trace.jsonl -repo <repo> -out site -title "..." + vrsite -repo <repo> -out site -title "..." `vrsite -h` explains both, and what a log needs to be worth reading. ── known holes (accepted) ────────────────────────────────────────────────── diff --git a/constrain/claude-headless-settings.json b/constrain/claude-headless-settings.json index f125ed2..30aff9b 100644 --- a/constrain/claude-headless-settings.json +++ b/constrain/claude-headless-settings.json @@ -10,7 +10,7 @@ "hooks": [ { "type": "command", - "command": "/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger/constrain/vr-only-guard.sh" + "command": "/home/goblin/00-projects/01-tools/vr-agent-logger/constrain/vr-only-guard.sh" } ] } diff --git a/constrain/codex-config.toml b/constrain/codex-config.toml deleted file mode 100644 index fa5902f..0000000 --- a/constrain/codex-config.toml +++ /dev/null @@ -1,12 +0,0 @@ -# Optional: merge into ~/.codex/config.toml (or pass per-run with -c). -# Enforcement lives in vr-only.rules (~/.codex/rules/); this only centralizes -# the log and, if you want, tightens approvals for everything unmatched. - -# approval_policy = "untrusted" - -[shell_environment_policy] -inherit = "all" - -[shell_environment_policy.set] -# one shared vr log for every repo codex touches -VR_LOG = "/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger/vr-log.jsonl" @@ -1,4 +1,4 @@ -vr — read code and take notes; every call is logged. +vr — read code and take notes; every call is logged in a private jj sidecar. read a file (line-numbered, relative to a jj revision): vr read FILE whole file @@ -19,5 +19,10 @@ vr query -op note shows both the session-local id and the log-wide id used by -reply and vrsite /note/N permalinks. REV is any jj revset; default @ (the working copy). -Every command takes -log FILE for where to append the log -(default ./vr-log.jsonl, or $VR_LOG); use an absolute path across repos. +The repo-local revset alias vr_log names the log change. vr creates it lazily +as an anonymous child of root(); it never appears in the project working copy. +Every new entry records the full target change id and exact target commit id. +An existing repo-root vr-log.jsonl is read and imported on the first append; +vr leaves that legacy working-tree file for you to remove after migration. +The sidecar has no bookmark and is deliberately local-only: a fresh clone will +not contain it, so include the jj repository in backups when notes matter. @@ -1,9 +1,9 @@ // vr — file reader / note taker for coding agents, logging every call. // -// Each operation appends one JSON line to a log file recording who read what: -// session id, agent, model, reasoning effort, working dir, jj change id, file -// and line range. Reads and greps are relative to a jj revision (default @, the -// working copy) — the tool assumes it runs inside a jj repo. +// Each operation appends one JSON line to a private jj sidecar recording who read what: +// session id, agent, model, reasoning effort, working dir, full jj change and +// commit ids, file and line range. Reads and greps are relative to a jj +// revision (default @, the working copy) — the tool assumes a jj repo. // // Agent identity comes from the caller's environment (Claude Code exports // CLAUDE_CODE_SESSION_ID, AI_AGENT, CLAUDE_EFFORT into tool subprocesses); when @@ -18,6 +18,7 @@ import ( "bytes" _ "embed" "encoding/json" + "errors" "flag" "fmt" "os" @@ -27,6 +28,8 @@ import ( "strconv" "strings" "time" + + "0x4200.cafe/vr/sidecar" ) type Entry struct { @@ -39,6 +42,7 @@ type Entry struct { Dir string `json:"dir"` Rev string `json:"rev,omitempty"` Change string `json:"change,omitempty"` + Commit string `json:"commit,omitempty"` File string `json:"file,omitempty"` Start int `json:"start,omitempty"` End int `json:"end,omitempty"` @@ -51,6 +55,7 @@ type Entry struct { } var gSession, gAgent, gModel, gReasoning, gDir string +var gStore *sidecar.Store //go:embed doc.txt var doc string @@ -90,20 +95,20 @@ func usage() { fmt.Fprint(os.Stderr, `vr — file reader / note taker for coding agents; logs every call. usage: - vr read [-log FILE] [-r REV] FILE[:START[-END]] + vr read [-r REV] FILE[:START[-END]] print a file line-numbered, whole or a line range, at a jj revision - vr grep [-log FILE] [-r REV] PATTERN [PATH] + vr grep [-r REV] PATTERN [PATH] search the working copy (rg) or another revision (jj file list/show); scope non-@ greps with PATH, they read every file through jj - vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT... + vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT... record a note about a file range or a kind of thing (struct, enum, const, ...), optionally replying to a log-wide note number; ids count up from 1 per session - vr query [-log FILE] [-session S] [-op OP] [-file SUBSTR] [-n N] + vr query [-session S] [-op OP] [-file SUBSTR] [-n N] print logged entries, oldest first -REV is any jj revset, default @ (working copy). The log is JSON lines, -default ./vr-log.jsonl, overridable with -log or $VR_LOG. +REV is any jj revset, default @ (working copy). The JSONL log lives in the +private jj sidecar named by the repo-local revset alias vr_log. "vr -doc" prints the short agent-facing usage doc; "vr -doc-query" the query one. `) os.Exit(2) @@ -114,13 +119,6 @@ func fatal(format string, a ...any) { os.Exit(1) } -func defaultLog() string { - if v := os.Getenv("VR_LOG"); v != "" { - return v - } - return "vr-log.jsonl" -} - // ── agent / session / model detection ───────────────────────────────────── func detect() { @@ -257,8 +255,14 @@ func jjOut(args ...string) string { // snapshots, and this keeps `vr note`/`vr grep` working in sandboxes that // block .git writes (codex workspace-write). read/grep -r snapshot anyway // via their own jj file show/list calls. -func changeID(rev string) string { - return strings.TrimSpace(jjOut("log", "-r", rev, "--no-graph", "--ignore-working-copy", "-T", "change_id")) +func revisionIDs(rev string) (change, commit string) { + out := jjOut("log", "-r", "exactly(("+rev+"), 1)", "--no-graph", "--ignore-working-copy", + "-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"") + lines := strings.Fields(out) + if len(lines) != 2 { + fatal("revision %q did not resolve to one change and commit", rev) + } + return lines[0], lines[1] } // parseFileRange splits "file.go:10-20" / "file.go:10" / "file.go". @@ -283,11 +287,10 @@ func parseFileRange(s string) (file string, start, end int) { func cmdRead(args []string) { fs := flag.NewFlagSet("read", flag.ExitOnError) - logPath := fs.String("log", defaultLog(), "log file to append to") rev := fs.String("r", "@", "jj revision to read at") fs.Parse(args) if fs.NArg() != 1 { - fatal("usage: vr read [-log FILE] [-r REV] FILE[:START[-END]]") + fatal("usage: vr read [-r REV] FILE[:START[-END]]") } file, start, end := parseFileRange(fs.Arg(0)) @@ -308,16 +311,16 @@ func cmdRead(args []string) { for i := start; i <= end; i++ { fmt.Printf("%d\t%s\n", i, lines[i-1]) } - writeLog(*logPath, Entry{Op: "read", Rev: *rev, Change: changeID(*rev), File: file, Start: start, End: end}) + change, commit := revisionIDs(*rev) + writeLog(Entry{Op: "read", Rev: *rev, Change: change, Commit: commit, File: file, Start: start, End: end}) } func cmdGrep(args []string) { fs := flag.NewFlagSet("grep", flag.ExitOnError) - logPath := fs.String("log", defaultLog(), "log file to append to") rev := fs.String("r", "@", "jj revision to search at") fs.Parse(args) if fs.NArg() < 1 || fs.NArg() > 2 { - fatal("usage: vr grep [-log FILE] [-r REV] PATTERN [PATH]") + fatal("usage: vr grep [-r REV] PATTERN [PATH]") } pattern, path := fs.Arg(0), fs.Arg(1) @@ -356,12 +359,12 @@ func cmdGrep(args []string) { } } } - writeLog(*logPath, Entry{Op: "grep", Rev: *rev, Change: changeID(*rev), Pattern: pattern, File: path, Matches: matches}) + change, commit := revisionIDs(*rev) + writeLog(Entry{Op: "grep", Rev: *rev, Change: change, Commit: commit, Pattern: pattern, File: path, Matches: matches}) } func cmdNote(args []string) { fs := flag.NewFlagSet("note", flag.ExitOnError) - logPath := fs.String("log", defaultLog(), "log file to append to") rev := fs.String("r", "@", "jj revision the note refers to") file := fs.String("f", "", "FILE[:START[-END]] the note is about") kind := fs.String("t", "", "kind of thing the note is about (struct, enum, const, ...)") @@ -369,21 +372,23 @@ func cmdNote(args []string) { fs.Parse(args) text := strings.Join(fs.Args(), " ") if text == "" { - fatal("usage: vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") + fatal("usage: vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") } if *reply < 0 { fatal("reply note must be positive") } - if *reply > 0 && !hasNote(readLog(*logPath), *reply) { - fatal("no log-wide note #%d in %s", *reply, *logPath) - } - - e := Entry{Op: "note", Rev: *rev, Change: changeID(*rev), ReplyTo: *reply, Type: *kind, Text: text} + change, commit := revisionIDs(*rev) + e := Entry{Op: "note", Rev: *rev, Change: change, Commit: commit, ReplyTo: *reply, Type: *kind, Text: text} if *file != "" { e.File, e.Start, e.End = parseFileRange(*file) } - e.NoteID = nextNoteID(*logPath) - writeLog(*logPath, e) + writeLogWith(func(entries []Entry) (Entry, error) { + if *reply > 0 && !hasNote(entries, *reply) { + return Entry{}, fmt.Errorf("no log-wide note #%d in %s", *reply, store()) + } + e.NoteID = nextNoteID(entries) + return e, nil + }) fmt.Printf("note %d\n", e.NoteID) } @@ -402,9 +407,9 @@ func hasNote(entries []Entry, id int) bool { return false } -func nextNoteID(logPath string) int { +func nextNoteID(entries []Entry) int { max := 0 - for _, e := range readLog(logPath) { + for _, e := range entries { if e.Op == "note" && e.Session == gSession && e.NoteID > max { max = e.NoteID } @@ -414,14 +419,13 @@ func nextNoteID(logPath string) int { func cmdQuery(args []string) { fs := flag.NewFlagSet("query", flag.ExitOnError) - logPath := fs.String("log", defaultLog(), "log file to read") session := fs.String("session", "", "filter: session id substring") op := fs.String("op", "", "filter: read | grep | note") file := fs.String("file", "", "filter: file path substring") n := fs.Int("n", 0, "show only the last N entries") fs.Parse(args) - entries := readLog(*logPath) + entries := readLog() type result struct { Entry logNoteID int @@ -493,13 +497,9 @@ func fileRange(e Entry) string { return s } -// ── log file ────────────────────────────────────────────────────────────── +// ── jj sidecar log ──────────────────────────────────────────────────────── -func readLog(path string) []Entry { - b, err := os.ReadFile(path) - if err != nil { - return nil - } +func parseLog(b []byte) []Entry { var entries []Entry for _, line := range strings.Split(string(b), "\n") { if line == "" { @@ -513,16 +513,43 @@ func readLog(path string) []Entry { return entries } -func writeLog(path string, e Entry) { - e.Time = time.Now().Format(time.RFC3339) - e.Session, e.Agent, e.Model, e.Reasoning, e.Dir = gSession, gAgent, gModel, gReasoning, gDir - b, _ := json.Marshal(e) - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) +func store() *sidecar.Store { + if gStore == nil { + var err error + gStore, err = sidecar.Open(gDir) + if err != nil { + fatal("open jj sidecar: %v", err) + } + } + return gStore +} + +func readLog() []Entry { + b, err := store().Read() + if errors.Is(err, sidecar.ErrNoLog) { + return nil + } if err != nil { - fatal("open log: %v", err) + fatal("read %s: %v", store(), err) } - defer f.Close() - if _, err := f.Write(append(b, '\n')); err != nil { - fatal("write log: %v", err) + return parseLog(b) +} + +func writeLog(e Entry) { + writeLogWith(func([]Entry) (Entry, error) { return e, nil }) +} + +func writeLogWith(build func([]Entry) (Entry, error)) { + err := store().Append(func(current []byte) ([]byte, error) { + e, err := build(parseLog(current)) + if err != nil { + return nil, err + } + e.Time = time.Now().Format(time.RFC3339) + e.Session, e.Agent, e.Model, e.Reasoning, e.Dir = gSession, gAgent, gModel, gReasoning, gDir + return json.Marshal(e) + }) + if err != nil { + fatal("write %s: %v", store(), err) } } diff --git a/query-doc.txt b/query-doc.txt index 7c0cfdc..8d4f58a 100644 --- a/query-doc.txt +++ b/query-doc.txt @@ -1,12 +1,14 @@ -vr query — inspect the read/note log (one line per entry, oldest first). +vr query — inspect this jj repo's read/note sidecar (oldest first). vr query everything vr query -n 20 last 20 entries vr query -op note only notes (or: read, grep) vr query -file accounts_db entries touching a file path substring vr query -session fa7cb1bf one session (id substring is enough) - vr query -log FILE read another log (default ./vr-log.jsonl, or $VR_LOG) -Filters combine. The log itself is plain JSON lines — jq works on it too. +Filters combine. The sidecar content is JSON lines and can also be read with: + jj file show -r 'exactly(vr_log, 1)' -- vr-log.jsonl +Its append history is: + jj evolog -r 'exactly(vr_log, 1)' --reversed -p --git Note rows show the session-local id first and the log-wide id used by `vr note -reply N` and vrsite permalinks in brackets. diff --git a/sidecar/store.go b/sidecar/store.go new file mode 100644 index 0000000..76e626f --- /dev/null +++ b/sidecar/store.go @@ -0,0 +1,329 @@ +// Package sidecar stores vr's append-only JSONL log in a private jj change. +// +// The change is an anonymous direct child of root(), so it is visible and +// durable in the jj repository without becoming part of project history or the +// working copy. A repo-local revset alias names it and git.private-commits keeps +// accidental --change pushes from publishing it. +package sidecar + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +const ( + Alias = "vr_log" + LogFile = "vr-log.jsonl" + Description = "private: vr log" +) + +var ErrNoLog = errors.New("vr log has not been initialized") + +// Store is a repository's vr log. Open is read-only with respect to jj; the +// sidecar change and its config are created lazily by Append. +type Store struct { + root string + lockPath string +} + +// Open finds the jj workspace containing dir. +func Open(dir string) (*Store, error) { + root, err := jjOutput(dir, "workspace", "root") + if err != nil { + return nil, err + } + root = strings.TrimSpace(root) + configPath, err := jjOutput(root, "config", "path", "--repo") + if err != nil { + return nil, err + } + return &Store{ + root: root, + lockPath: filepath.Join(filepath.Dir(strings.TrimSpace(configPath)), "vr-agent-logger.lock"), + }, nil +} + +func (s *Store) Root() string { return s.root } + +func (s *Store) String() string { + return fmt.Sprintf("jj sidecar %s:%s", Alias, LogFile) +} + +// Read returns the current log. Before the first sidecar write it falls back +// to a repo-root legacy log, which makes migration transparent. +func (s *Store) Read() ([]byte, error) { + var data []byte + err := s.withLock(syscall.LOCK_SH, func() error { + rev, found, _, err := s.resolveExisting() + if err != nil { + return err + } + if found { + data, err = s.readRevision(rev) + return err + } + data, err = os.ReadFile(filepath.Join(s.root, LogFile)) + if errors.Is(err, os.ErrNotExist) { + return ErrNoLog + } + return err + }) + return data, err +} + +// Generation is a cheap identity for the current contents, used by vrsite to +// notice appends. A sidecar rewrite gets a new commit id. +func (s *Store) Generation() (string, error) { + var generation string + err := s.withLock(syscall.LOCK_SH, func() error { + rev, found, _, err := s.resolveExisting() + if err != nil { + return err + } + if found { + generation, err = s.logValue(rev, "commit_id") + return err + } + info, err := os.Stat(filepath.Join(s.root, LogFile)) + if errors.Is(err, os.ErrNotExist) { + generation = "empty" + return nil + } + if err != nil { + return err + } + generation = fmt.Sprintf("legacy:%d:%d", info.Size(), info.ModTime().UnixNano()) + return nil + }) + return generation, err +} + +// Append serializes sidecar rewrites across vr processes. makePayload runs +// while holding that lock and receives the complete current log, allowing note +// numbering and reply validation to be atomic with the append. +func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error { + return s.withLock(syscall.LOCK_EX, func() error { + rev, err := s.ensure() + if err != nil { + return err + } + sidecarData, err := s.readRevision(rev) + if err != nil { + return err + } + current := sidecarData + if len(current) == 0 { + legacy, legacyErr := os.ReadFile(filepath.Join(s.root, LogFile)) + if legacyErr == nil { + current = legacy + } else if !errors.Is(legacyErr, os.ErrNotExist) { + return legacyErr + } + } + payload, err := makePayload(current) + if err != nil { + return err + } + if len(payload) == 0 { + return errors.New("refusing to append an empty vr log payload") + } + + var appendData []byte + if len(sidecarData) == 0 && len(current) > 0 { + appendData = append(appendData, current...) + } + if len(current) > 0 && current[len(current)-1] != '\n' { + appendData = append(appendData, '\n') + } + appendData = append(appendData, payload...) + if appendData[len(appendData)-1] != '\n' { + appendData = append(appendData, '\n') + } + return s.runAppend(appendData) + }) +} + +func (s *Store) withLock(kind int, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(s.lockPath), 0o755); err != nil { + return err + } + f, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), kind); err != nil { + return err + } + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck + return fn() +} + +func (s *Store) ensure() (string, error) { + rev, found, configured, err := s.resolveExisting() + if err != nil { + return "", err + } + if !found { + if _, err := s.jj("new", "--no-edit", "root()", "-m", Description); err != nil { + return "", err + } + ids, err := s.descriptionMatches() + if err != nil { + return "", err + } + if len(ids) != 1 { + return "", fmt.Errorf("created %q sidecar but found %d matching root children", Description, len(ids)) + } + rev = ids[0] + } + if !configured { + if _, err := s.jj("config", "set", "--repo", "revset-aliases."+Alias, strconv.Quote(rev)); err != nil { + return "", err + } + } + private, err := s.jj("config", "get", "git.private-commits") + if err != nil { + return "", err + } + if !containsRevsetName(private, Alias) { + expr := fmt.Sprintf("(%s) | %s", strings.TrimSpace(private), Alias) + if _, err := s.jj("config", "set", "--repo", "git.private-commits", strconv.Quote(expr)); err != nil { + return "", err + } + } + return rev, nil +} + +func (s *Store) resolveExisting() (rev string, found, configured bool, err error) { + configuredValue, configErr := s.jj("config", "get", "revset-aliases."+Alias) + if configErr == nil { + rev, err = s.logValue("exactly("+Alias+", 1)", "change_id") + if err != nil { + return "", false, true, fmt.Errorf("repo-local revset alias %s is invalid: %w", Alias, err) + } + valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", Alias, strconv.Quote(Description)) + if _, err := s.logValue(valid, "change_id"); err != nil { + return "", false, true, fmt.Errorf("repo-local revset alias %s=%q does not name vr's sidecar: %w", + Alias, strings.TrimSpace(configuredValue), err) + } + return rev, true, true, nil + } + + ids, err := s.descriptionMatches() + if err != nil { + return "", false, false, err + } + switch len(ids) { + case 0: + return "", false, false, nil + case 1: + return ids[0], true, false, nil + default: + return "", false, false, fmt.Errorf("found %d root children described %q; cannot choose a vr sidecar", len(ids), Description) + } +} + +func (s *Store) descriptionMatches() ([]string, error) { + revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(Description)) + out, err := s.jj("log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") + if err != nil { + return nil, err + } + var ids []string + for _, id := range strings.Fields(out) { + ids = append(ids, id) + } + return ids, nil +} + +func (s *Store) logValue(revset, template string) (string, error) { + out, err := s.jj("log", "-G", "-r", revset, "-T", template) + if err != nil { + return "", err + } + value := strings.TrimSpace(out) + if value == "" { + return "", fmt.Errorf("revision %q produced no %s", revset, template) + } + return value, nil +} + +func (s *Store) readRevision(rev string) ([]byte, error) { + listed, err := s.jj("file", "list", "-r", rev, "--", LogFile) + if err != nil { + return nil, err + } + if strings.TrimSpace(listed) == "" { + return nil, nil + } + out, err := s.jj("file", "show", "-r", rev, "--", LogFile) + return []byte(out), err +} + +func (s *Store) runAppend(data []byte) error { + f, err := os.CreateTemp(filepath.Dir(s.lockPath), "vr-log-append-*") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) //nolint:errcheck + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + _, err = s.jj("--config", "snapshot.auto-track=\"all()\"", + "--config", "snapshot.max-new-file-size=\"1GiB\"", + "run", "--clean", "--root", "-r", "exactly("+Alias+", 1)", "--", + "sh", "-c", "cat \"$1\" >> "+LogFile, "sh", tmp) + return err +} + +func (s *Store) jj(args ...string) (string, error) { + return jjOutput(s.root, args...) +} + +func jjOutput(dir string, args ...string) (string, error) { + full := append([]string{"--ignore-working-copy", "--no-pager"}, args...) + cmd := exec.Command("jj", full...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + message := strings.TrimSpace(string(out)) + if ee, ok := err.(*exec.ExitError); ok { + message = strings.TrimSpace(string(ee.Stderr)) + } + if message != "" { + return "", fmt.Errorf("jj %s: %w: %s", strings.Join(args, " "), err, message) + } + return "", fmt.Errorf("jj %s: %w", strings.Join(args, " "), err) + } + return string(out), nil +} + +func containsRevsetName(expr, name string) bool { + for i := 0; i+len(name) <= len(expr); i++ { + if expr[i:i+len(name)] != name { + continue + } + before := i == 0 || !isNameByte(expr[i-1]) + after := i+len(name) == len(expr) || !isNameByte(expr[i+len(name)]) + if before && after { + return true + } + } + return false +} + +func isNameByte(b byte) bool { + return b == '_' || b == '-' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' +} diff --git a/sidecar/store_test.go b/sidecar/store_test.go new file mode 100644 index 0000000..85f50e1 --- /dev/null +++ b/sidecar/store_test.go @@ -0,0 +1,164 @@ +package sidecar + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestAppendCreatesPrivateUnrelatedSidecar(t *testing.T) { + repo := testRepo(t) + // Store must be independent of a user's restrictive auto-track setting. + testJJ(t, repo, "config", "set", "--repo", "snapshot.auto-track", `"none()"`) + testJJ(t, repo, "config", "set", "--repo", "git.private-commits", `"description(glob:'secret:*')"`) + before := testJJ(t, repo, "status") + store, err := Open(repo) + if err != nil { + t.Fatal(err) + } + + if err := store.Append(func(current []byte) ([]byte, error) { + if len(current) != 0 { + t.Fatalf("new store contained %q", current) + } + return []byte("one\n"), nil + }); err != nil { + t.Fatal(err) + } + if err := store.Append(func(current []byte) ([]byte, error) { + if string(current) != "one\n" { + t.Fatalf("second append saw %q", current) + } + return []byte("two\n"), nil + }); err != nil { + t.Fatal(err) + } + + got, err := store.Read() + if err != nil { + t.Fatal(err) + } + if string(got) != "one\ntwo\n" { + t.Fatalf("sidecar log = %q", got) + } + if after := testJJ(t, repo, "status"); after != before { + t.Fatalf("working copy changed:\n--- before\n%s--- after\n%s", before, after) + } + if _, err := os.Stat(filepath.Join(repo, LogFile)); !os.IsNotExist(err) { + t.Fatalf("log materialized in the project working copy: %v", err) + } + + alias := strings.TrimSpace(testJJ(t, repo, "config", "get", "revset-aliases."+Alias)) + if alias == "" { + t.Fatal("repo-local sidecar alias was not configured") + } + private := testJJ(t, repo, "config", "get", "git.private-commits") + if !containsRevsetName(private, Alias) { + t.Fatalf("private commits = %q, missing %s", private, Alias) + } + if !strings.Contains(private, "secret:*") { + t.Fatalf("private commits = %q, previous setting was overwritten", private) + } + parent := strings.TrimSpace(testJJ(t, repo, "log", "-G", "-r", "exactly("+Alias+", 1)", + "-T", "parents.map(|p| p.commit_id()).join(\"\")")) + if parent != strings.Repeat("0", 40) { + t.Fatalf("sidecar parent = %q, want virtual root", parent) + } +} + +func TestLegacyLogIsReadThenImportedOnFirstAppend(t *testing.T) { + repo := testRepo(t) + legacy := []byte("legacy\n") + if err := os.WriteFile(filepath.Join(repo, LogFile), legacy, 0o644); err != nil { + t.Fatal(err) + } + store, err := Open(repo) + if err != nil { + t.Fatal(err) + } + if got, err := store.Read(); err != nil || string(got) != string(legacy) { + t.Fatalf("pre-migration read = %q, %v", got, err) + } + if err := store.Append(func(current []byte) ([]byte, error) { + if string(current) != string(legacy) { + t.Fatalf("migration callback saw %q", current) + } + return []byte("sidecar\n"), nil + }); err != nil { + t.Fatal(err) + } + if got, err := store.Read(); err != nil || string(got) != "legacy\nsidecar\n" { + t.Fatalf("migrated log = %q, %v", got, err) + } +} + +func TestConcurrentAppendsAreSerialized(t *testing.T) { + repo := testRepo(t) + store, err := Open(repo) + if err != nil { + t.Fatal(err) + } + const writers = 6 + errCh := make(chan error, writers) + var wg sync.WaitGroup + for range writers { + wg.Add(1) + go func() { + defer wg.Done() + errCh <- store.Append(func(current []byte) ([]byte, error) { + id := len(strings.Fields(string(current))) + 1 + return []byte(fmt.Sprintf("%d\n", id)), nil + }) + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatal(err) + } + } + got, err := store.Read() + if err != nil { + t.Fatal(err) + } + if string(got) != "1\n2\n3\n4\n5\n6\n" { + t.Fatalf("serialized appends = %q", got) + } +} + +func testRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("jj"); err != nil { + t.Skip("jj not installed") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("JJ_USER", "test") + t.Setenv("JJ_EMAIL", "[email protected]") + repo := filepath.Join(home, "repo") + if err := os.Mkdir(repo, 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("jj", "git", "init") + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("jj git init: %v\n%s", err, out) + } + return repo +} + +func testJJ(t *testing.T, repo string, args ...string) string { + t.Helper() + cmd := exec.Command("jj", append([]string{"--ignore-working-copy", "--no-pager"}, args...)...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("jj %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} diff --git a/vr-log.jsonl b/vr-log.jsonl deleted file mode 100644 index a2f3e10..0000000 --- a/vr-log.jsonl +++ /dev/null @@ -1,120 +0,0 @@ -{"time":"2026-07-24T11:39:36-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","start":44,"end":48} -{"time":"2026-07-24T11:39:37-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","pattern":"func cmd\\w+","matches":4} -{"time":"2026-07-24T11:39:37-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","start":107,"end":135,"note_id":1,"type":"func","text":"detect() prefers direct env (AI_AGENT, CLAUDE_CODE_SESSION_ID) and only walks the process tree as fallback"} -{"time":"2026-07-24T11:39:43-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":".gitignore","start":1,"end":1} -{"time":"2026-07-24T11:42:14-03:00","op":"note","agent":"claude","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":1,"type":"test","text":"fallback: agent from process tree, session unavailable on macOS"} -{"time":"2026-07-24T11:42:35-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","pattern":"pub enum LoadHint","matches":1} -{"time":"2026-07-24T11:42:35-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","pattern":"ancient","matches":60} -{"time":"2026-07-24T11:42:46-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":620,"end":665} -{"time":"2026-07-24T11:42:46-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":330,"end":350} -{"time":"2026-07-24T11:42:53-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"vpmuoxxzvkrt","change":"vpmuoxxzvkrtxsntvukovztyqlpwrtzo","file":"svm/src/transaction_processor.rs","pattern":"sysvar_cache","matches":65} -{"time":"2026-07-24T11:42:53-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"vpmuoxxzvkrt","change":"vpmuoxxzvkrtxsntvukovztyqlpwrtzo","file":"svm/src/transaction_processor.rs","start":1,"end":15} -{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":630,"end":648,"note_id":2,"type":"enum","text":"LoadHint is less a hint than a caller contract: FixedMaxRoot promises max_root won't move during the load, which lets do_load assert! strictly on the fast path instead of tolerating races. The name undersells it — choosing wrong isn't just slower, it changes which invariants get enforced. Doc comment grammar is rough ('unlike previously did allow')."} -{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":650,"end":656,"note_id":3,"type":"enum","text":"PopulateReadCache is a bool dressed as a two-variant enum — good call-site ergonomics, .0 avoids the classic 'what does true mean here' problem in do_load args."} -{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":337,"end":350,"note_id":4,"type":"const","text":"The ancient-slot offset constant has sign-dependent semantics (positive shrinks the modern window, negative extends it past an epoch) documented only by ASCII diagrams; easy to misread when tuning. A signed offset with two diagrams is doing the job of two named constants."} -{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"vpmuoxxzvkrt","change":"vpmuoxxzvkrtxsntvukovztyqlpwrtzo","file":"svm/src/transaction_processor.rs","start":205,"end":205,"note_id":5,"type":"struct","text":"sysvar_cache: RwLock\u003cSysvarCache\u003e — this change (svm: cache sysvar read lock per transaction batch, #11604) hoists the read-guard acquisition out of the per-transaction loop; sound since the cache is only written at epoch/slot boundaries, and it trims RwLock traffic on the hot path."} -{"time":"2026-07-24T11:49:52-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":6,"type":"test","text":"detect: claude branch"} -{"time":"2026-07-24T11:49:52-03:00","op":"note","session":"test-thread-123","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":1,"type":"test","text":"detect: codex branch"} -{"time":"2026-07-24T11:49:52-03:00","op":"note","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":2,"type":"test","text":"detect: nested, tree decides"} -{"time":"2026-07-24T11:50:24-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":7,"type":"test","text":"detect: nested, tree decides (fixed)"} -{"time":"2026-07-24T11:50:24-03:00","op":"note","agent":"claude","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":3,"type":"test","text":"detect: pure walk (fixed)"} -{"time":"2026-07-24T11:50:43-03:00","op":"read","session":"019f949b-5ccc-79a3-9d30-de99668a2bcb","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","start":1,"end":13} -{"time":"2026-07-24T11:50:44-03:00","op":"note","session":"019f949b-5ccc-79a3-9d30-de99668a2bcb","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":1,"type":"test","text":"logged from a real codex session"} -{"time":"2026-07-24T12:05:43-03:00","op":"read","session":"019f94a8-f7f2-7bd2-815e-887e06b21048","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/private/tmp/claude-501/-Users-goblin-00-projects-0x4200-cafe-vr-agent-logger/fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb/scratchpad/constrained-demo","rev":"@","change":"nwrpvwutzoorsyxtzsrtwkqpwllsxnok","file":"hello.txt","start":1,"end":3} -{"time":"2026-07-26T21:20:43-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"CHANGELOG.md","pattern":"(?i)alpenglow|certificate|reward|shred limit|snapshot|xdp","matches":37} -{"time":"2026-07-26T21:20:48-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"CHANGELOG.md","start":1,"end":175} -{"time":"2026-07-26T21:20:54-03:00","op":"note","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"CHANGELOG.md","start":10,"end":41,"note_id":1,"type":"architecture","text":"4.2 visible surface spans three distinct constraint classes: persisted RPC/blockstore compatibility is intentionally narrowed (legacy Rewards decode removed), validator ingress/egress bounds change (half-epoch future-shred horizon and XDP-by-default operational requirements), and CLI exposes Alpenglow vote accounts with a declared JSON break. This public summary is narrower than the working-copy code footprint, so review must trace latent consensus/runtime migrations separately from release-noted behavior."} -{"time":"2026-07-26T21:21:00-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","pattern":"alpenglow|certificat|reward|block_component|shred","matches":34} -{"time":"2026-07-26T21:21:00-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"feature-set/src/lib.rs","pattern":"alpenglow|certificat|reward|block_component|shred","matches":38} -{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":35,"end":175} -{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"feature-set/src/lib.rs","start":15,"end":175} -{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":1300,"end":1370} -{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":1535,"end":1570} -{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":2420,"end":2450} -{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":2615,"end":2640} -{"time":"2026-07-26T21:21:22-03:00","op":"note","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":54,"end":55,"note_id":2,"type":"constraint","text":"BEFORE -\u003e AFTER: Alpenglow remains gated by its existing feature, while fast leader handover is split into a new independent feature bit. This preserves reachability of baseline Alpenglow after activation and makes the timing/leadership transition opt-in; every consumer must therefore tolerate all reachable states {legacy, Alpenglow without fast handover, Alpenglow with fast handover}. The snapshot does not itself encode a dependency that fast handover implies Alpenglow, so callers must nest the second behavior under the first."} -{"time":"2026-07-26T21:21:22-03:00","op":"note","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"feature-set/src/lib.rs","start":20,"end":92,"note_id":3,"type":"finding","text":"The parent FeatureSnapshot still carried stake_minimum_delegation_for_rewards and remove_simple_vote_from_cost_model, while @ removes them. These removals are safe only if the features are guaranteed active on every supported cluster/replay starting point; otherwise old-path runtime economics/cost behavior becomes unreachable. This is a migration/replay support constraint, not merely struct cleanup."} -{"time":"2026-07-26T21:21:29-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src","pattern":"pub (struct|enum)|fn (handle|process|try_|on_|finalize|root|vote)|Certificate|Finalized|Notar|Skip|BlockId","matches":529} -{"time":"2026-07-26T21:21:29-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor-messages/src","pattern":"pub (struct|enum)|Certificate|Finalized|Notar|Skip|BlockId|WIRE","matches":239} -{"time":"2026-07-26T21:21:39-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src","pattern":"fast_leader|handover|LeaderWindowInfo|window","matches":166} -{"time":"2026-07-26T21:21:40-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"votor/src","pattern":"fast_leader|handover|LeaderWindowInfo|window","matches":138} -{"time":"2026-07-26T21:21:46-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src/consensus_pool_service.rs","start":1,"end":130} -{"time":"2026-07-26T21:21:46-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src/consensus_pool_service.rs","start":300,"end":630} -{"time":"2026-07-26T21:21:46-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"votor/src/consensus_pool_service.rs","start":300,"end":465} -{"time":"2026-07-26T21:22:07-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src","pattern":"has_existing_shreds_for_slot|already produced shreds|LeaderWindowInfo|parent_block|start_slot","matches":225} -{"time":"2026-07-26T21:22:09-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"core/src","pattern":"has_existing_shreds_for_slot|already produced shreds|LeaderWindowInfo|parent_block|start_slot","matches":92} -{"time":"2026-07-26T21:22:16-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":340,"end":650} -{"time":"2026-07-26T21:22:16-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":760,"end":970} -{"time":"2026-07-26T21:22:16-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"core/src/block_creation_loop.rs","start":240,"end":455} -{"time":"2026-07-26T21:22:25-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","start":4250,"end":4400} -{"time":"2026-07-26T21:22:25-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","pattern":"alpenglow_fast_leader_handover|optimistic_parent","matches":28} -{"time":"2026-07-26T21:22:25-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","pattern":"alpenglow_fast_leader_handover|optimistic_parent","matches":35} -{"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","start":1580,"end":1630} -{"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":630,"end":755} -{"time":"2026-07-30T15:22:34-03:00","op":"note","session":"web:goblin","agent":"goblin","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"vsrmxqunvnxxzylkqlpkzpkmonulkzwn","file":"vr-log.jsonl","start":9,"end":9,"note_id":1,"text":"example note"} -{"time":"2026-08-01T18:01:25-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":".","pattern":"type (Entry|Note|PageData|note)|func .*note|Op.*note|case \"note\"|/notes|frag-note|note-form|permalink","matches":83} -{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":1,"end":491} -{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":1,"end":540} -{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main.go","start":1,"end":229} -{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/render.go","start":1,"end":460} -{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":1,"end":294} -{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":1,"end":756} -{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":350,"end":460} -{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"doc.txt","start":1,"end":19} -{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":1,"end":190} -{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":190,"end":380} -{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":380,"end":480} -{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":1,"end":220} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":63} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-note.tmpl","start":1,"end":41} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-notes.tmpl","start":1,"end":52} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-file.tmpl","start":1,"end":20} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/layout.tmpl","start":1,"end":88} -{"time":"2026-08-01T18:01:39-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","pattern":"note-card|note-|comment|textarea|form|focus","matches":27} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":280,"end":440} -{"time":"2026-08-01T18:01:39-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/app.js","pattern":"note|textarea|reply|form","matches":32} -{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/app.js","start":1,"end":426} -{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":250,"end":490} -{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/render.go","start":1,"end":230} -{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/static.go","start":160,"end":247} -{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main_test.go","start":1,"end":57} -{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":1,"end":240} -{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/static.go","start":1,"end":165} -{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown.go","start":1,"end":173} -{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/permalink_test.go","start":1,"end":198} -{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":187,"end":270} -{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":1,"end":190} -{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-index.tmpl","start":1,"end":56} -{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-change.tmpl","start":1,"end":37} -{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-session.tmpl","start":1,"end":70} -{"time":"2026-08-01T18:02:43-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite","pattern":"newTemplates|Funcs|dict|md|NoteHref","matches":70} -{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":400,"end":775} -{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":103,"end":160,"note_id":1,"type":"invariant","text":"Public note permalinks use a log-wide ID counted over note entries, while note_id remains session-local; reply edges should therefore target the log-wide ID."} -{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":417,"end":465,"note_id":2,"type":"flow","text":"The focused /note/{id} handler starts from the whole-log model before choosing source context, so it is the natural place to render cross-change reply and mention backlinks."} -{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":26,"note_id":3,"type":"ui","text":"Every note surface shares the note card template; a relation indicator added here will cover lists, file asides, conversations, and focused threads consistently."} -{"time":"2026-08-01T18:08:23-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","pattern":"ReplyTo|reply_to|newNoteLinkParser"} -{"time":"2026-08-01T18:08:23-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"doc.txt","start":8,"end":19} -{"time":"2026-08-01T18:08:23-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"query-doc.txt","start":1,"end":10} -{"time":"2026-08-01T18:11:16-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":490,"end":515} -{"time":"2026-08-01T18:11:16-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","pattern":"@media","matches":2} -{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown_test.go","start":1,"end":80} -{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":240,"end":400} -{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":617,"end":700} -{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/permalink_test.go","start":1,"end":60} -{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":35,"end":130} -{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main_test.go","start":1,"end":38} -{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown_test.go","start":55,"end":105} -{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":32,"end":110} -{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":350,"end":475} -{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":20,"end":190} -{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":300,"end":435} -{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown.go","start":70,"end":175} -{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":190,"end":285} -{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":417,"end":605} -{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":105} -{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-note.tmpl","start":1,"end":54} -{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":338,"end":408,"note_id":4,"type":"graph","text":"Reply edges come from reply_to while mention edges come only from local Markdown note links; recursive expansion uses a global seen set so arbitrary-depth DAG joins and cycles terminate as references."} -{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":524,"end":562,"note_id":5,"type":"validation","text":"Browser replies resolve their target against the whole-log model before append, while preserving the change currently being read and leaving mentions as ordinary note text."} -{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":40,"end":76,"note_id":6,"type":"ui","text":"The focused thread recursively renders reply and mention edges at arbitrary depth, with one normal POST reply form under each full card and compact references for repeated DAG nodes."} -{"time":"2026-08-01T18:20:12-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":60,"end":94,"note_id":7,"reply_to":20,"type":"test","text":"The model test covers a four-edge reply chain plus a note reached through both a reply and a mention, proving arbitrary recursion and DAG join handling."} -{"time":"2026-08-01T18:20:28-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main.go","start":1,"end":130} diff --git a/vrsite/app_test.go b/vrsite/app_test.go index 403903a..1ee7209 100644 --- a/vrsite/app_test.go +++ b/vrsite/app_test.go @@ -13,6 +13,8 @@ import ( "strings" "testing" "time" + + "0x4200.cafe/vr/sidecar" ) func logLines(lines ...string) string { return strings.Join(lines, "\n") + "\n" } @@ -369,7 +371,7 @@ func testServer(t *testing.T) (*server, string, string) { t.Fatal(err) } dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") - sv := newServer(logPath, repo, "test", "tester", dark, light, testThemes(dark, light)) + sv := newServer(newFileLog(logPath), repo, "test", "tester", dark, light, testThemes(dark, light)) return sv, logPath, change } @@ -586,6 +588,54 @@ func TestServerWritesOnlyTheLog(t *testing.T) { } } +func TestServerWritesBrowserNotesToJJSidecar(t *testing.T) { + repo, change := testRepo(t) + t.Setenv("HOME", repo) + t.Setenv("JJ_USER", "test") + t.Setenv("JJ_EMAIL", "[email protected]") + _, commit, err := resolveRevision(repo, change) + if err != nil { + t.Fatal(err) + } + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + sv := newServer(log, repo, "test", "tester", dark, light, testThemes(dark, light)) + before, err := jjRun(repo, "status") + if err != nil { + t.Fatal(err) + } + form := url.Values{"text": {"sidecar browser note"}, "file": {"hello.txt"}, "change": {change}} + r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + sv.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("POST /notes = %d: %s", w.Code, w.Body) + } + after, err := jjRun(repo, "status") + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("project working copy changed:\n--- before\n%s--- after\n%s", before, after) + } + if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) { + t.Fatalf("sidecar materialized in project working copy: %v", err) + } + b, err := log.Read() + if err != nil { + t.Fatal(err) + } + entries := parseLog(b) + last := entries[len(entries)-1] + if last.Text != "sidecar browser note" || last.Commit != commit || len(last.Commit) != 40 { + t.Fatalf("sidecar note = %+v", last) + } +} + var agoRe = regexp.MustCompile(`\d+[smhd] ago|just now`) func TestServerHoldsNoStateOfItsOwn(t *testing.T) { @@ -598,7 +648,7 @@ func TestServerHoldsNoStateOfItsOwn(t *testing.T) { h.ServeHTTP(httptest.NewRecorder(), r) // a second server that has only ever seen the log file - fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler() + fresh := newServer(newFileLog(logPath), sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler() for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt", "/files", "/tree", "/change/" + change} { was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO") diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 696041c..74db8d2 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -22,6 +22,26 @@ setMode(root.dataset.mode || "trace"); const typing = e => /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable; +// The project hub is fully server-derived; filtering is deliberately local so +// choosing a project does not create another piece of server state. +const projectFind = document.getElementById("projectfind"); +if (projectFind) { + const apply = () => { + const q = projectFind.value.trim().toLowerCase(); + for (const card of document.querySelectorAll(".project-card")) + card.hidden = !!q && !(card.dataset.project || "").toLowerCase().includes(q); + }; + projectFind.addEventListener("input", apply); + projectFind.addEventListener("search", apply); + projectFind.addEventListener("keydown", e => { + if (e.key === "Escape") { projectFind.value = ""; apply(); projectFind.blur(); } + if (e.key === "Enter") { + const first = document.querySelector(".project-card:not([hidden]) a.project-action"); + if (first) first.click(); + } + }); +} + document.addEventListener("keydown", e => { if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return; if (e.key === "t") setMode(root.dataset.mode === "notes" ? "trace" : "notes"); diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl index 9418087..60d2a2b 100644 --- a/vrsite/assets/style.css.tmpl +++ b/vrsite/assets/style.css.tmpl @@ -537,6 +537,31 @@ table.changed .st { color: var(--muted); font-size: 11px; width: 7ch; } table.changed .st-added { color: var(--accent); } table.changed .st-deleted { color: var(--note); } +/* ── project hub ───────────────────────────────────────────── */ +.projects-main { padding-bottom: 20vh; } +.projects-main > section { max-width: 1200px; } +.project-refresh { margin: 10px 0; } +.project-refresh button, .project-card button, .project-action { + display: inline-block; background: var(--panel); color: var(--fg); + border: 1px solid var(--border); border-radius: 5px; + font: inherit; font-size: 12px; padding: 5px 10px; cursor: pointer; +} +.project-refresh button:hover, .project-card button:hover, .project-action:hover { + background: var(--hover); text-decoration: none; +} +.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; } +.project-card { min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); } +.project-card > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.project-card h3 { min-width: 0; margin: 0; font: 600 14px/1.3 ui-monospace, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; } +.project-path { margin: 8px 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-state { flex: none; padding: 1px 6px; border: 1px solid var(--border); border-radius: 99px; color: var(--muted); font-size: 10px; text-transform: uppercase; } +.project-state.active { border-color: var(--accent); color: var(--accent); } +.project-state.git { color: var(--note); } +.project-card form, .project-card .project-action { margin-top: 10px; } +.project-message { max-width: 1168px; } +.ok { margin: 12px 16px; padding: 8px 10px; border: 1px solid var(--accent); border-radius: 6px; color: var(--accent); } +.project-card[hidden] { display: none; } + /* ── agent coverage colors ────────────────────────────────── A bitmask is its agents' colors averaged. The blend is left to the browser so these rules hold for any theme: a theme only has to name diff --git a/vrsite/go.mod b/vrsite/go.mod index e4c19c8..2175fcd 100644 --- a/vrsite/go.mod +++ b/vrsite/go.mod @@ -3,6 +3,7 @@ module 0x4200.cafe/vrsite go 1.26.5 require ( + 0x4200.cafe/vr v0.0.0 github.com/BurntSushi/toml v1.6.0 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-bash v0.25.1 @@ -18,3 +19,5 @@ require ( ) require github.com/mattn/go-pointer v0.0.1 // indirect + +replace 0x4200.cafe/vr => .. diff --git a/vrsite/jj.go b/vrsite/jj.go index bb95d83..0b5cbdf 100644 --- a/vrsite/jj.go +++ b/vrsite/jj.go @@ -1,9 +1,8 @@ // jj.go — every read of the repository goes through here. // -// Two rules hold everywhere: the repo is never modified (no edit, no new, no -// checkout; --ignore-working-copy on every call so we never even snapshot), -// and nothing is written to disk — templates are passed inline with -T, so a -// query leaves no trace behind, it just renders once and is sent to the page. +// These helpers never modify project history or its working copy +// (--ignore-working-copy means they never even snapshot it). Sidecar mutation +// is isolated in the shared sidecar package. package main import ( @@ -72,21 +71,27 @@ func checkRevset(r string) error { } func resolveChange(repo, rev string) (string, error) { + change, _, err := resolveRevision(repo, rev) + return change, err +} + +func resolveRevision(repo, rev string) (change, commit string, err error) { if rev == "" { rev = "@" } if err := checkRevset(rev); err != nil { - return "", err + return "", "", err } - out, err := jjRun(repo, "log", "-r", rev, "--no-graph", "-n", "1", "-T", "change_id") + out, err := jjRun(repo, "log", "-r", "exactly(("+rev+"), 1)", "--no-graph", + "-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"") if err != nil { - return "", err + return "", "", err } - id := strings.TrimSpace(string(out)) - if id == "" { - return "", fmt.Errorf("revset %q matches no change", rev) + ids := strings.Fields(string(out)) + if len(ids) != 2 { + return "", "", fmt.Errorf("revset %q did not resolve to one change and commit", rev) } - return id, nil + return ids[0], ids[1], nil } // changeBrief resolves a revset to the one change it names, with everything a diff --git a/vrsite/log.go b/vrsite/log.go new file mode 100644 index 0000000..a0a4f3e --- /dev/null +++ b/vrsite/log.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "sync" +) + +// logStore is implemented by the production jj sidecar and by the file-backed +// adapter used in tests. Append's callback makes session note numbering atomic +// with the content update. +type logStore interface { + Read() ([]byte, error) + Generation() (string, error) + Append(func(current []byte) ([]byte, error)) error + String() string +} + +type fileLog struct { + path string + mu sync.Mutex +} + +func newFileLog(path string) *fileLog { return &fileLog{path: path} } + +func (l *fileLog) String() string { return l.path } + +func (l *fileLog) Read() ([]byte, error) { return os.ReadFile(l.path) } + +func (l *fileLog) Generation() (string, error) { + info, err := os.Stat(l.path) + if err != nil { + return "", err + } + return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano()), nil +} + +func (l *fileLog) Append(makePayload func(current []byte) ([]byte, error)) error { + l.mu.Lock() + defer l.mu.Unlock() + current, err := os.ReadFile(l.path) + if err != nil && !os.IsNotExist(err) { + return err + } + payload, err := makePayload(current) + if err != nil { + return err + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(payload) + return err +} diff --git a/vrsite/main.go b/vrsite/main.go index 69c90a8..e43a8eb 100644 --- a/vrsite/main.go +++ b/vrsite/main.go @@ -7,8 +7,8 @@ // both the ones agents left and the ones you write in the browser. // // It runs as a live web app (default) or writes a static export (-out). -// Either way the log is the only state: notes are appended to it, and the -// repository is only ever read, never modified. +// Either way the log is the only state: notes are appended to a private jj +// sidecar, never to the project working copy. // // Theme comes from a Zed theme JSON; per-agent colors come from the theme's // player (collaborator) palette. Every other theme this machine has — Zed's @@ -25,6 +25,8 @@ import ( "path/filepath" "strings" "time" + + "0x4200.cafe/vr/sidecar" ) //go:embed templates assets @@ -43,30 +45,33 @@ tree with per-agent read coverage, syntax-highlighted sources, the notes agents left, the conversation each note was written in, and jj queries over the repo. usage: - vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME] - serve it, at http://127.0.0.1:4200 by default - vrsite -log FILE -repo DIR -out DIR [-watch] + vrsite [-scan-root DIR] [-addr HOST:PORT] [-user NAME] + serve every discovered project from one dashboard; scans ~ by default + vrsite -repo DIR [-addr HOST:PORT] [-user NAME] + serve one repository directly, preserving the original single-project mode + vrsite [-repo DIR] -out DIR [-watch] write a static copy instead — no server, so no comments, no live activity, no jj queries; the focus toggle, filters and file finder still work, and it holds the one change the log points at starting on a new project: - 1. the project must be a jj repo — in an existing git one: - jj git init --colocate - 2. give the agents vr and one log for the repo: - export VR_LOG=$PWD/vr-log.jsonl - and tell them to read with it; constrain/AGENTS.md in the vr repo is a - drop-in brief that does, and constrain/ can also enforce it - 3. let them work, then: - vrsite -log $PWD/vr-log.jsonl -repo $PWD + 1. open the dashboard; it discovers jj and Git repositories below ~ + 2. initialize colocated jj or start the private sidecar from its explicit + button — no project registry or scan result is written to disk + 3. give the agents vr and tell them to read with it; constrain/AGENTS.md in + the vr repo is a drop-in brief that does, and constrain/ can enforce it + 4. let them work, then refresh the dashboard when a new repo appears A log belongs to one repository. Entries carry the jj change id they were recorded at, and vrsite reads the code at those changes, so a log from another repo has nothing to show here. Point -repo at the repository the agents ran in. -Notes written in the browser are appended to the same log, under -user, at the -change being read. Nothing else is ever written: the repository is only read, -and the app keeps no state of its own. +Notes written in the browser are appended to the same private jj sidecar, +under -user, at the exact change and commit being read. The project working +copy is never changed, and the app keeps no state of its own. + +The sidecar has no bookmark and is local-only. It is not included in a fresh +clone, so back up the jj repository or export the site when the trace matters. Session transcripts (the conversation behind a note) are looked up by session id under -transcripts; Claude Code and Codex are found automatically. @@ -80,8 +85,12 @@ flags: ` func main() { - logPath := flag.String("log", "", "vr trace log (jsonl)") - repo := flag.String("repo", "", "jj repo the trace was recorded in") + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + repo := flag.String("repo", "", "serve one jj repo directly (with -out, default: current directory)") + scanRoot := flag.String("scan-root", home, "live dashboard root to scan for jj and Git repositories") addr := flag.String("addr", "127.0.0.1:4200", "address to serve on") who := flag.String("user", "", "name your browser notes are logged under (default: $USER)") out := flag.String("out", "", "write a static site here instead of serving") @@ -99,42 +108,54 @@ func main() { flag.PrintDefaults() } flag.Parse() - if *logPath == "" || *repo == "" { - missing := "-log and -repo are required" - if *logPath != "" { - missing = "-repo is required: the repository the log was recorded in" - } else if *repo != "" { - missing = "-log is required: the jsonl file vr appends to" - } - fmt.Fprintf(os.Stderr, "vrsite: %s\n\n", missing) - flag.Usage() - os.Exit(2) - } - repoAbs, err := filepath.Abs(*repo) - if err != nil { - fatal("%v", err) - } if *debounce < 0 { fatal("debounce must not be negative") } theme := loadTheme(*themePath, *themeName, "dark") light := loadTheme(*lightPath, *lightName, "light") themes := newThemeSet(theme, light) + if *out == "" && *watch { + fatal("-watch is for -out builds; the served app already follows the log") + } - if *out == "" { - if *watch { - fatal("-watch is for -out builds; the served app already follows the log") + // Explicit -repo and every static export retain the original one-repository + // path. A bare live invocation is the new, stateless project dashboard. + if *repo == "" && *out == "" { + hubTitle := *title + if hubTitle == "vr trace" { + hubTitle = "vr projects" } - serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) + hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes) + if err != nil { + fatal("discover projects: %v", err) + } + serveProjects(*addr, hub) + return + } + + repoPath := *repo + if repoPath == "" { + repoPath = "." + } + repoAbs, err := filepath.Abs(repoPath) + if err != nil { + fatal("%v", err) + } + log, err := sidecar.Open(repoAbs) + if err != nil { + fatal("open jj sidecar: %v", err) + } + if *out == "" { + serve(*addr, log, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes) return } - build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light, themes) } + build := func() { buildSite(log, repoAbs, *out, *title, theme, light, themes) } build() if !*watch { return } - fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", *logPath, *debounce) - watchLog(*logPath, *debounce, build) + fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", log, *debounce) + watchLog(log, *debounce, build) } func username(flagValue string) string { @@ -152,27 +173,14 @@ func username(flagValue string) string { // ── watching the log (static export only) ───────────────────────────────── -type logState struct { - size int64 - modTime time.Time -} - -func statLog(path string) (logState, error) { - info, err := os.Stat(path) - if err != nil { - return logState{}, err - } - return logState{size: info.Size(), modTime: info.ModTime()}, nil -} - -func watchLog(path string, debounce time.Duration, rebuild func()) { - watchLogUntil(path, debounce, time.Second, nil, rebuild) +func watchLog(log logStore, debounce time.Duration, rebuild func()) { + watchLogUntil(log, debounce, time.Second, nil, rebuild) } -func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) { - state, err := statLog(path) +func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) { + state, err := log.Generation() if err != nil { - fatal("watch %s: %v", path, err) + fatal("watch %s: %v", log, err) } ticker := time.NewTicker(poll) @@ -192,10 +200,10 @@ func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct return case <-ticker.C: - next, err := statLog(path) + next, err := log.Generation() if err != nil { if msg := err.Error(); msg != lastStatErr { - fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", path, err) + fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", log, err) lastStatErr = msg } continue diff --git a/vrsite/main_test.go b/vrsite/main_test.go index 7e715b9..b7c93c3 100644 --- a/vrsite/main_test.go +++ b/vrsite/main_test.go @@ -18,7 +18,7 @@ func TestWatchLogDebouncesChanges(t *testing.T) { rebuilt := make(chan struct{}, 2) go func() { defer close(stopped) - watchLogUntil(logPath, 60*time.Millisecond, 5*time.Millisecond, done, func() { + watchLogUntil(newFileLog(logPath), 60*time.Millisecond, 5*time.Millisecond, done, func() { rebuilt <- struct{}{} }) }() diff --git a/vrsite/model.go b/vrsite/model.go index b580b49..10f3f1d 100644 --- a/vrsite/model.go +++ b/vrsite/model.go @@ -2,14 +2,13 @@ // // Everything the site shows is derived here from the vr log alone: agents and // their colors, per-file coverage bitmasks, notes, and live session activity. -// The repo is never written to; the log is the only state. +// The private jj sidecar is the only state. package main import ( "encoding/json" "fmt" "html/template" - "os" "sort" "strings" "time" @@ -29,6 +28,7 @@ type Entry struct { Dir string `json:"dir,omitempty"` Rev string `json:"rev,omitempty"` Change string `json:"change,omitempty"` + Commit string `json:"commit,omitempty"` File string `json:"file,omitempty"` Start int `json:"start,omitempty"` End int `json:"end,omitempty"` @@ -47,16 +47,16 @@ type Agent struct { } type Note struct { - ID int // position in the log, for anchors - NoteID int // the agent's own per-session number - Agent *Agent - Session, Model, Reason string - File, Kind, Text, Change, Rev string - Start, End int - ReplyTo int - Replies, Mentions []*Note // notes that point back to this one - At time.Time - When, Ago string + ID int // position in the log, for anchors + NoteID int // the agent's own per-session number + Agent *Agent + Session, Model, Reason string + File, Kind, Text, Change, Commit, Rev string + Start, End int + ReplyTo int + Replies, Mentions []*Note // notes that point back to this one + At time.Time + When, Ago string } // ThreadNode is the tree-shaped rendering of the note DAG. A note reached by @@ -164,7 +164,7 @@ func buildModel(title string, entries []Entry, only string) *Site { at := parseTime(e.Time) n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session, Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change, - Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo, + Commit: e.Commit, Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo, Kind: e.Type, Text: e.Text, At: at, When: stamp(at), Ago: ago(at)} s.Notes = append(s.Notes, n) @@ -547,7 +547,7 @@ func describe(e Entry) string { return e.Op } -// ── log file ────────────────────────────────────────────────────────────── +// ── sidecar JSONL ───────────────────────────────────────────────────────── func parseLog(b []byte) []Entry { var entries []Entry @@ -563,28 +563,20 @@ func parseLog(b []byte) []Entry { return entries } -func readLog(path string) []Entry { - b, err := os.ReadFile(path) +func readLog(log logStore) []Entry { + b, err := log.Read() if err != nil { fatal("%v", err) } return parseLog(b) } -// appendEntry adds one line to the log. O_APPEND plus a single write keeps it -// interleaving safely with vr processes writing the same file. -func appendEntry(path string, e Entry) error { - b, err := json.Marshal(e) - if err != nil { - return err - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - _, err = f.Write(append(b, '\n')) - return err +func appendEntry(log logStore, e *Entry) error { + return log.Append(func(current []byte) ([]byte, error) { + e.NoteID = nextNoteID(parseLog(current), e.Session) + b, err := json.Marshal(e) + return append(b, '\n'), err + }) } // ── time ────────────────────────────────────────────────────────────────── diff --git a/vrsite/page.go b/vrsite/page.go index cca0c58..1570d45 100644 --- a/vrsite/page.go +++ b/vrsite/page.go @@ -17,7 +17,7 @@ type page struct { Site *Site Title string Kind string // index | file | files | notes | note | jj | change | session - Root string // static: relative prefix; live: "/" + Root string // static: relative prefix; live: route prefix ending in "/" Live bool Rev string // revision being browsed ("" = the change the log points at) ChangeID string // resolved change id for Rev — what pages name and link @@ -63,6 +63,7 @@ type page struct { // JJView is one rendered jj query: what was asked, and what came back. type JJView struct { + Root string Kind string // log | op Revset string AtOp string @@ -95,7 +96,7 @@ func (p *page) paramsWith(scope string) string { func (p *page) ScopeHref(scope string) string { path := p.Path if path == "" { - path = "/" + path = p.liveRoot() } return path + p.paramsWith(scope) } @@ -118,7 +119,21 @@ func (p *page) JumpPath() string { return p.Path } } - return "/files" + return p.liveRoot() + "files" +} + +func (p *page) liveRoot() string { + root := p.Root + if root == "" { + return "/" + } + if !strings.HasPrefix(root, "/") { + root = "/" + root + } + if !strings.HasSuffix(root, "/") { + root += "/" + } + return root } // Href maps a logical destination to a URL for this page's mode. Kinds the @@ -129,29 +144,38 @@ func (p *page) Href(kind string, arg ...string) string { a = arg[0] } if p.Live { + root := p.liveRoot() switch kind { case "": - return "/" + p.params() + return root + p.params() case "notes": - return "/notes" + p.params() + return root + "notes" + p.params() + case "notes-list": + return root + "notes/list" + p.params() case "files": - return "/files" + p.params() + return root + "files" + p.params() case "tree": - return "/tree" + p.params() + return root + "tree" + p.params() case "jj": - return "/jj" + return root + "jj" + case "jj-log": + return root + "jj/log" + case "jj-op": + return root + "jj/op" + case "activity": + return root + "activity" case "code": - return "/code/" + escPath(a) + p.params() + return root + "code/" + escPath(a) + p.params() case "note": - return "/note/" + a + return root + "note/" + a case "change": - return "/change/" + a + return root + "change/" + a case "session": - return "/session/" + escPath(a) + return root + "session/" + escPath(a) case "asset": - return "/" + a + return root + a } - return "/" + return root } switch kind { case "": diff --git a/vrsite/projects.go b/vrsite/projects.go new file mode 100644 index 0000000..5808a92 --- /dev/null +++ b/vrsite/projects.go @@ -0,0 +1,698 @@ +// projects.go — the stateless, multi-repository front door for vrsite. +// +// The hub is an index and dispatcher, not another database. It reconstructs +// its project list from .jj/.git markers and vr sidecars at startup or on an +// explicit refresh. Per-project pages are still served by server, and the only +// writes are the two actions named by their buttons: initialize jj metadata or +// create the normal private vr sidecar/config. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "html/template" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "0x4200.cafe/vr/sidecar" +) + +type projectKind string + +const ( + projectActive projectKind = "active" + projectJJ projectKind = "jj" + projectGit projectKind = "git" +) + +type Project struct { + ID, Key, Name, Path, Relative string + Kind projectKind + Workspaces []string + Entries, Notes, Agents int + Updated, Err string +} + +type projectRuntime struct { + repo string + server *server + handler http.Handler +} + +type projectHub struct { + scanRoot, title, user string + transcriptRoots []string + theme, light *Theme + themes *themeSet + tpl *template.Template + + mu sync.RWMutex + projects map[string]*Project + order []*Project + runtimes map[string]*projectRuntime + scanned time.Time + scanErr string + + refreshMu sync.Mutex + actionMu sync.Mutex +} + +type projectsPage struct { + Title, ScanRoot, Scanned, Message, Err string + Active, Ready, Git []*Project + Theme, Light *Theme +} + +func newProjectHub(scanRoot, title, user string, transcriptRoots []string, theme, light *Theme, themes *themeSet) (*projectHub, error) { + abs, err := filepath.Abs(scanRoot) + if err != nil { + return nil, err + } + tpl, err := template.New("projects.tmpl").ParseFS(tfs, "templates/projects.tmpl") + if err != nil { + return nil, err + } + h := &projectHub{ + scanRoot: abs, title: title, user: user, transcriptRoots: transcriptRoots, + theme: theme, light: light, themes: themes, tpl: tpl, + projects: map[string]*Project{}, runtimes: map[string]*projectRuntime{}, + } + if err := h.refresh(); err != nil { + return nil, err + } + return h, nil +} + +func (h *projectHub) refresh() error { + h.refreshMu.Lock() + defer h.refreshMu.Unlock() + projects, scanErr, err := discoverProjects(h.scanRoot) + if err != nil { + return err + } + byID := make(map[string]*Project, len(projects)) + for _, p := range projects { + byID[p.ID] = p + } + h.mu.Lock() + h.projects, h.order = byID, projects + h.runtimes = map[string]*projectRuntime{} // derived cache; rebuild lazily + h.scanned, h.scanErr = time.Now(), scanErr + h.mu.Unlock() + return nil +} + +type repoCandidate struct { + path string + git bool +} + +func discoverProjects(root string) ([]*Project, string, error) { + var candidates []repoCandidate + var walkErrs []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + walkErrs = append(walkErrs, fmt.Sprintf("%s: %v", path, err)) + if d != nil && d.IsDir() { + return filepath.SkipDir + } + return nil + } + if !d.IsDir() { + return nil + } + if path != root && skipScanDir(d.Name()) { + return filepath.SkipDir + } + if markerExists(filepath.Join(path, ".jj")) { + candidates = append(candidates, repoCandidate{path: path}) + return filepath.SkipDir + } + if markerExists(filepath.Join(path, ".git")) { + candidates = append(candidates, repoCandidate{path: path, git: true}) + return filepath.SkipDir + } + return nil + }) + if err != nil { + return nil, "", err + } + + // A jj repository can have several workspaces. The repo-local config path is + // stable across them and gives the dashboard one project, not duplicate logs. + groups := map[string]*Project{} + for _, c := range candidates { + if c.git { + key := filepath.Clean(c.path) + p := &Project{ID: projectID("git", key), Key: key, Path: key, Kind: projectGit} + nameProject(p, root) + groups["git\x00"+key] = p + continue + } + out, err := jjRun(c.path, "config", "path", "--repo") + if err != nil { + walkErrs = append(walkErrs, fmt.Sprintf("%s: %v", c.path, err)) + continue + } + key := filepath.Clean(strings.TrimSpace(string(out))) + gkey := "jj\x00" + key + p := groups[gkey] + if p == nil { + p = &Project{ID: projectID("jj", key), Key: key, Path: c.path, Kind: projectJJ} + groups[gkey] = p + } + p.Workspaces = append(p.Workspaces, c.path) + if preferredWorkspace(c.path, p.Path) { + p.Path = c.path + } + } + + projects := make([]*Project, 0, len(groups)) + for _, p := range groups { + if p.Kind != projectGit { + sort.Strings(p.Workspaces) + active, err := hasVRSidecar(p.Path) + if err != nil { + p.Err = err.Error() + } else if active { + p.Kind = projectActive + loadProjectStats(p) + } + } + nameProject(p, root) + projects = append(projects, p) + } + sort.Slice(projects, func(i, j int) bool { + if projects[i].Kind != projects[j].Kind { + return projects[i].Kind < projects[j].Kind + } + return strings.ToLower(projects[i].Relative) < strings.ToLower(projects[j].Relative) + }) + return projects, strings.Join(walkErrs, "; "), nil +} + +func skipScanDir(name string) bool { + switch name { + case ".git", ".jj", ".cache", ".cargo", ".local", ".npm", ".rustup", ".var", + "node_modules", "target", "vendor": + return true + } + return false +} + +func markerExists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func preferredWorkspace(candidate, current string) bool { + return current == "" || len(candidate) < len(current) || len(candidate) == len(current) && candidate < current +} + +func projectID(kind, key string) string { + sum := sha256.Sum256([]byte(kind + "\x00" + key)) + return fmt.Sprintf("%x", sum[:8]) +} + +func nameProject(p *Project, root string) { + p.Name = filepath.Base(p.Path) + rel, err := filepath.Rel(root, p.Path) + if err != nil || strings.HasPrefix(rel, "..") { + p.Relative = p.Path + } else if rel == "." { + p.Relative = "~" + } else { + p.Relative = "~/" + filepath.ToSlash(rel) + } +} + +func sidecarChanges(repo string) ([]string, error) { + revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(sidecar.Description)) + out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"") + if err != nil { + return nil, err + } + return strings.Fields(string(out)), nil +} + +func hasVRSidecar(repo string) (bool, error) { + ids, err := sidecarChanges(repo) + if err != nil { + return false, err + } + switch len(ids) { + case 0: + return false, nil + case 1: + return true, nil + default: + return false, fmt.Errorf("found %d private vr sidecars", len(ids)) + } +} + +func loadProjectStats(p *Project) { + log, err := sidecar.Open(p.Path) + if err != nil { + p.Err = err.Error() + return + } + b, err := log.Read() + if err != nil && !errors.Is(err, sidecar.ErrNoLog) { + p.Err = err.Error() + return + } + entries := parseLog(b) + p.Entries = len(entries) + agents := map[string]bool{} + var latest time.Time + for _, e := range entries { + if e.Op == "note" { + p.Notes++ + } + if e.Agent != "" { + agents[e.Agent] = true + } + if at := parseTime(e.Time); at.After(latest) { + latest = at + } + } + p.Agents = len(agents) + if latest.IsZero() { + p.Updated = "no entries yet" + } else { + p.Updated = ago(latest) + } +} + +func (h *projectHub) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", h.dashboard) + mux.HandleFunc("POST /projects/refresh", h.refreshAction) + mux.HandleFunc("POST /projects/{id}/start", h.startAction) + mux.HandleFunc("POST /git/{id}/init", h.initGitAction) + mux.HandleFunc("GET /p/{id}", h.projectSlash) + mux.HandleFunc("/p/{id}/{rest...}", h.projectRoute) + mux.HandleFunc("GET /style.css", h.styleCSS) + mux.HandleFunc("GET /themes.css", h.themeAsset) + mux.HandleFunc("GET /themes.js", h.themeAsset) + mux.HandleFunc("GET /app.js", h.asset) + mux.HandleFunc("GET /htmx.min.js", h.asset) + return mux +} + +func (h *projectHub) dashboard(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + p := projectsPage{Title: h.title, ScanRoot: h.scanRoot, Scanned: h.scanned.Format("2006-01-02 15:04:05"), + Message: r.URL.Query().Get("message"), Err: r.URL.Query().Get("error"), Theme: h.theme, Light: h.light} + if p.Err == "" { + p.Err = h.scanErr + } + for _, project := range h.order { + switch project.Kind { + case projectActive: + p.Active = append(p.Active, project) + case projectJJ: + p.Ready = append(p.Ready, project) + case projectGit: + p.Git = append(p.Git, project) + } + } + h.mu.RUnlock() + var b bytes.Buffer + if err := h.tpl.ExecuteTemplate(&b, "projects", p); err != nil { + http.Error(w, "render: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + b.WriteTo(w) +} + +func (h *projectHub) refreshAction(w http.ResponseWriter, r *http.Request) { + if !localPost(w, r) { + return + } + if err := h.refresh(); err != nil { + homeRedirect(w, r, "", err) + return + } + homeRedirect(w, r, "projects refreshed", nil) +} + +func (h *projectHub) startAction(w http.ResponseWriter, r *http.Request) { + if !localPost(w, r) { + return + } + p := h.project(r.PathValue("id")) + if p == nil || p.Kind != projectJJ { + http.NotFound(w, r) + return + } + h.actionMu.Lock() + err := ensureVRSidecar(p.Path) + h.actionMu.Unlock() + if err != nil { + homeRedirect(w, r, "", err) + return + } + if err := h.refresh(); err != nil { + homeRedirect(w, r, "", err) + return + } + if active := h.projectByKey(p.Key, projectActive); active != nil { + http.Redirect(w, r, "/p/"+active.ID+"/", http.StatusSeeOther) + return + } + homeRedirect(w, r, "sidecar created", nil) +} + +func (h *projectHub) initGitAction(w http.ResponseWriter, r *http.Request) { + if !localPost(w, r) { + return + } + p := h.project(r.PathValue("id")) + if p == nil || p.Kind != projectGit { + http.NotFound(w, r) + return + } + h.actionMu.Lock() + err := initColocated(p.Path) + h.actionMu.Unlock() + if err != nil { + homeRedirect(w, r, "", err) + return + } + if err := h.refresh(); err != nil { + homeRedirect(w, r, "", err) + return + } + homeRedirect(w, r, "jj initialized; the repository is ready for a vr sidecar", nil) +} + +func localPost(w http.ResponseWriter, r *http.Request) bool { + if o := r.Header.Get("Origin"); o != "" { + if u, err := url.Parse(o); err != nil || u.Host != r.Host { + http.Error(w, "cross-origin post refused", http.StatusForbidden) + return false + } + } + return true +} + +func homeRedirect(w http.ResponseWriter, r *http.Request, message string, err error) { + q := url.Values{} + if message != "" { + q.Set("message", message) + } + if err != nil { + q.Set("error", err.Error()) + } + to := "/" + if encoded := q.Encode(); encoded != "" { + to += "?" + encoded + } + http.Redirect(w, r, to, http.StatusSeeOther) +} + +func (h *projectHub) project(id string) *Project { + h.mu.RLock() + defer h.mu.RUnlock() + return h.projects[id] +} + +func (h *projectHub) projectByKey(key string, kind projectKind) *Project { + h.mu.RLock() + defer h.mu.RUnlock() + for _, p := range h.order { + if p.Key == key && p.Kind == kind { + return p + } + } + return nil +} + +func (h *projectHub) projectSlash(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if p := h.project(id); p == nil || p.Kind != projectActive { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/p/"+id+"/", http.StatusPermanentRedirect) +} + +func (h *projectHub) projectRoute(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + runtime, err := h.runtime(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + rest := r.PathValue("rest") + clone := r.Clone(r.Context()) + u := *r.URL + u.Path, u.RawPath = "/"+rest, "" + clone.URL = &u + clone.RequestURI = u.RequestURI() + runtime.handler.ServeHTTP(w, clone) +} + +func (h *projectHub) runtime(id string) (*projectRuntime, error) { + h.mu.RLock() + if rt := h.runtimes[id]; rt != nil { + h.mu.RUnlock() + return rt, nil + } + p := h.projects[id] + h.mu.RUnlock() + if p == nil || p.Kind != projectActive { + return nil, fmt.Errorf("project not found") + } + log, err := sidecar.Open(p.Path) + if err != nil { + return nil, err + } + title := p.Name + if h.title != "" && h.title != "vr projects" { + title += " — " + h.title + } + sv := newServerAt(log, p.Path, title, h.user, "/p/"+id+"/", h.theme, h.light, h.themes) + sv.tx = newTranscripts(h.transcriptRoots) + rt := &projectRuntime{repo: p.Path, server: sv, handler: sv.handler()} + h.mu.Lock() + if existing := h.runtimes[id]; existing != nil { + rt = existing + } else { + h.runtimes[id] = rt + } + h.mu.Unlock() + return rt, nil +} + +func ensureVRSidecar(repo string) error { + config, err := jjRun(repo, "config", "path", "--repo") + if err != nil { + return err + } + lockPath := filepath.Join(filepath.Dir(strings.TrimSpace(string(config))), "vr-agent-logger.lock") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return err + } + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck + + configuredValue, configErr := jjRun(repo, "config", "get", "revset-aliases."+sidecar.Alias) + configured := configErr == nil + var id string + if configured { + resolved, err := jjLogValue(repo, "exactly("+sidecar.Alias+", 1)", "change_id") + if err != nil { + return fmt.Errorf("repo-local revset alias %s is invalid: %w", sidecar.Alias, err) + } + valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", sidecar.Alias, strconv.Quote(sidecar.Description)) + if _, err := jjLogValue(repo, valid, "change_id"); err != nil { + return fmt.Errorf("repo-local revset alias %s=%q does not name vr's sidecar: %w", + sidecar.Alias, strings.TrimSpace(string(configuredValue)), err) + } + id = resolved + } else { + ids, err := sidecarChanges(repo) + if err != nil { + return err + } + switch len(ids) { + case 0: + case 1: + id = ids[0] + default: + return fmt.Errorf("found %d root children described %q; cannot choose a vr sidecar", len(ids), sidecar.Description) + } + } + if id == "" { + if err := jjMutate(repo, "new", "--no-edit", "root()", "-m", sidecar.Description); err != nil { + return err + } + ids, err := sidecarChanges(repo) + if err != nil { + return err + } + if len(ids) != 1 { + return fmt.Errorf("created %q sidecar but found %d matching root children", sidecar.Description, len(ids)) + } + id = ids[0] + } + if !configured { + if err := jjMutate(repo, "config", "set", "--repo", "revset-aliases."+sidecar.Alias, strconv.Quote(id)); err != nil { + return err + } + } + private, err := jjRun(repo, "config", "get", "git.private-commits") + if err != nil { + return err + } + expr := strings.TrimSpace(string(private)) + if !mentionsRevset(expr, sidecar.Alias) { + expr = "(" + expr + ") | " + sidecar.Alias + if err := jjMutate(repo, "config", "set", "--repo", "git.private-commits", strconv.Quote(expr)); err != nil { + return err + } + } + return nil +} + +func jjLogValue(repo, revset, template string) (string, error) { + out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", template) + if err != nil { + return "", err + } + value := strings.TrimSpace(string(out)) + if value == "" { + return "", fmt.Errorf("revision %q produced no %s", revset, template) + } + return value, nil +} + +func mentionsRevset(expr, name string) bool { + for i := 0; i+len(name) <= len(expr); i++ { + if expr[i:i+len(name)] != name { + continue + } + before := i == 0 || !revsetNameByte(expr[i-1]) + after := i+len(name) == len(expr) || !revsetNameByte(expr[i+len(name)]) + if before && after { + return true + } + } + return false +} + +func revsetNameByte(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-' +} + +func jjMutate(repo string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), jjTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "jj", append([]string{"--ignore-working-copy", "--no-pager"}, args...)...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("jj %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out))) + } + return nil +} + +func initColocated(repo string) error { + if !markerExists(filepath.Join(repo, ".git")) { + return fmt.Errorf("%s is no longer a git repository", repo) + } + if markerExists(filepath.Join(repo, ".jj")) { + return fmt.Errorf("%s already has jj metadata", repo) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "jj", "git", "init", "--colocate") + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("jj git init --colocate: %s", strings.TrimSpace(string(out))) + } + return nil +} + +func (h *projectHub) styleCSS(w http.ResponseWriter, _ *http.Request) { + site := buildModel(h.title, nil, "") + site.Theme, site.Light = h.theme, h.light + var b bytes.Buffer + if err := renderCSS(&b, site); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + b.WriteTo(w) +} + +func (h *projectHub) themeAsset(w http.ResponseWriter, r *http.Request) { + body, ctype := h.themes.JS, "text/javascript; charset=utf-8" + if strings.HasSuffix(r.URL.Path, ".css") { + body, ctype = h.themes.CSS, "text/css; charset=utf-8" + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Cache-Control", "max-age=300") + w.Write(body) +} + +func (h *projectHub) asset(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/") + b, err := tfs.ReadFile("assets/" + name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "max-age=300") + w.Write(b) +} + +func serveProjects(addr string, hub *projectHub) { + hub.mu.RLock() + active, ready, git := 0, 0, 0 + for _, p := range hub.order { + switch p.Kind { + case projectActive: + active++ + case projectJJ: + ready++ + case projectGit: + git++ + } + } + hub.mu.RUnlock() + fmt.Printf("vrsite: http://%s — %d projects, %d jj repos ready to start, %d git repos under %s\n", + addr, active, ready, git, hub.scanRoot) + fmt.Println("vrsite: project discovery is in memory; refresh rescans the filesystem") + if err := http.ListenAndServe(addr, hub.handler()); err != nil { + fatal("%v", err) + } +} diff --git a/vrsite/projects_test.go b/vrsite/projects_test.go new file mode 100644 index 0000000..79fc6bc --- /dev/null +++ b/vrsite/projects_test.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "0x4200.cafe/vr/sidecar" +) + +func projectTestEnv(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + t.Setenv("JJ_USER", "test") + t.Setenv("JJ_EMAIL", "[email protected]") +} + +func runAt(t *testing.T, dir, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s in %s: %v\n%s", name, strings.Join(args, " "), dir, err, out) + } + return string(out) +} + +func initJJAt(t *testing.T, dir string) (change, commit string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + runAt(t, dir, "jj", "git", "init") + if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("one\ntwo\n"), 0o644); err != nil { + t.Fatal(err) + } + runAt(t, dir, "jj", "describe", "-m", "test project") + change, commit, err := resolveRevision(dir, "@") + if err != nil { + t.Fatal(err) + } + return change, commit +} + +func addTestSidecar(t *testing.T, repo, change, commit string) { + t.Helper() + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + line, err := json.Marshal(Entry{ + Time: "2026-08-02T12:00:00Z", Op: "note", Session: "s1", Agent: "codex", + Dir: repo, Rev: "@", Change: change, Commit: commit, File: "hello.txt", + Start: 1, End: 1, NoteID: 1, Text: "project note", + }) + if err != nil { + t.Fatal(err) + } + if err := log.Append(func([]byte) ([]byte, error) { return []byte(line), nil }); err != nil { + t.Fatal(err) + } +} + +func initGitAt(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + runAt(t, dir, "git", "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("git only\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func testProjectHub(t *testing.T, home string) *projectHub { + t.Helper() + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + h, err := newProjectHub(home, "vr projects", "tester", nil, dark, light, testThemes(dark, light)) + if err != nil { + t.Fatal(err) + } + return h +} + +func projectOfKind(t *testing.T, h *projectHub, kind projectKind, name string) *Project { + t.Helper() + h.mu.RLock() + defer h.mu.RUnlock() + for _, p := range h.order { + if p.Kind == kind && p.Name == name { + return p + } + } + t.Fatalf("no %s project named %s", kind, name) + return nil +} + +func TestDiscoverProjectsClassifiesAndDeduplicates(t *testing.T) { + if _, err := exec.LookPath("jj"); err != nil { + t.Skip("jj not installed") + } + home := t.TempDir() + projectTestEnv(t, home) + active := filepath.Join(home, "src", "active") + change, commit := initJJAt(t, active) + addTestSidecar(t, active, change, commit) + workspace := filepath.Join(home, "src", "active-workspace") + runAt(t, active, "jj", "workspace", "add", workspace) + initJJAt(t, filepath.Join(home, "src", "ready")) + initGitAt(t, filepath.Join(home, "src", "git-only")) + + projects, scanErr, err := discoverProjects(home) + if err != nil || scanErr != "" { + t.Fatalf("discover = %v, %q", err, scanErr) + } + if len(projects) != 3 { + t.Fatalf("projects = %d, want 3: %+v", len(projects), projects) + } + counts := map[projectKind]int{} + for _, p := range projects { + counts[p.Kind]++ + if p.Kind == projectActive { + if p.Entries != 1 || p.Notes != 1 || p.Agents != 1 || len(p.Workspaces) != 2 { + t.Fatalf("active project = %+v", p) + } + } + } + if counts[projectActive] != 1 || counts[projectJJ] != 1 || counts[projectGit] != 1 { + t.Fatalf("classification = %+v", counts) + } +} + +func TestProjectHubRoutesAnExistingLogUnderPrefix(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "alpha") + change, commit := initJJAt(t, repo) + addTestSidecar(t, repo, change, commit) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectActive, "alpha") + h := hub.handler() + + for _, tc := range []struct{ path, want string }{ + {"/", "alpha"}, + {"/", `id="themepick"`}, + {"/", `data-js="/themes.js"`}, + {"/", `l.href = "/themes.css"`}, + {"/p/" + p.ID + "/", "project note"}, + {"/p/" + p.ID + "/code/hello.txt", "two"}, + {"/p/" + p.ID + "/style.css", ".project-grid"}, + {"/p/" + p.ID + "/jj", "/p/" + p.ID + "/jj/log"}, + } { + w := get(t, h, tc.path, false) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), tc.want) { + t.Errorf("GET %s = %d, want body containing %q\n%s", tc.path, w.Code, tc.want, w.Body.String()) + } + } + body := get(t, h, "/p/"+p.ID+"/", false).Body.String() + for _, want := range []string{"/p/" + p.ID + "/style.css", "/p/" + p.ID + "/activity", ">projects</a>"} { + if !strings.Contains(body, want) { + t.Errorf("prefixed page lacks %q", want) + } + } + + form := url.Values{"text": {"written through the project route"}, "change": {change}} + r := httptest.NewRequest("POST", "/p/"+p.ID+"/notes", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/p/"+p.ID+"/notes" { + t.Fatalf("prefixed POST = %d, location %q: %s", w.Code, w.Header().Get("Location"), w.Body.String()) + } + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + b, err := log.Read() + if err != nil { + t.Fatal(err) + } + entries := parseLog(b) + if last := entries[len(entries)-1]; last.Text != "written through the project route" || last.Change != change { + t.Fatalf("prefixed POST recorded %+v", last) + } +} + +func TestProjectHubStartsAnEmptyPrivateSidecar(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "ready") + initJJAt(t, repo) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectJJ, "ready") + beforeStatus, _ := jjRun(repo, "status") + _, beforeCommit, err := resolveRevision(repo, "@") + if err != nil { + t.Fatal(err) + } + + r := httptest.NewRequest("POST", "/projects/"+p.ID+"/start", nil) + w := httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/p/") { + t.Fatalf("start = %d, location %q: %s", w.Code, w.Header().Get("Location"), w.Body.String()) + } + opened := get(t, hub.handler(), w.Header().Get("Location"), false) + if opened.Code != http.StatusOK || !strings.Contains(opened.Body.String(), "ready") { + t.Fatalf("new empty project page = %d: %s", opened.Code, opened.Body.String()) + } + active, err := hasVRSidecar(repo) + if err != nil || !active { + t.Fatalf("sidecar = %v, %v", active, err) + } + afterStatus, _ := jjRun(repo, "status") + _, afterCommit, err := resolveRevision(repo, "@") + if err != nil { + t.Fatal(err) + } + if string(afterStatus) != string(beforeStatus) || afterCommit != beforeCommit { + t.Fatal("starting the sidecar changed the project working copy") + } + log, err := sidecar.Open(repo) + if err != nil { + t.Fatal(err) + } + if b, err := log.Read(); err != nil || len(b) != 0 { + t.Fatalf("new sidecar log = %q, %v", b, err) + } + if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) { + t.Fatalf("sidecar materialized in the project: %v", err) + } + private, err := jjRun(repo, "config", "get", "git.private-commits") + if err != nil || !mentionsRevset(string(private), sidecar.Alias) { + t.Fatalf("private commits = %q, %v", private, err) + } +} + +func TestEnsureVRSidecarRefusesAConflictingAlias(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "conflict") + initJJAt(t, repo) + runAt(t, repo, "jj", "config", "set", "--repo", "revset-aliases."+sidecar.Alias, `"root()"`) + + err := ensureVRSidecar(repo) + if err == nil || !strings.Contains(err.Error(), "does not name vr's sidecar") { + t.Fatalf("ensure with conflicting alias = %v", err) + } + if active, err := hasVRSidecar(repo); err != nil || active { + t.Fatalf("conflicting alias created a sidecar: %v, %v", active, err) + } + alias := strings.TrimSpace(runAt(t, repo, "jj", "config", "get", "revset-aliases."+sidecar.Alias)) + if alias != "root()" { + t.Fatalf("conflicting alias was overwritten: %q", alias) + } +} + +func TestProjectHubInitializesGitThenRefreshes(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "git-only") + initGitAt(t, repo) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectGit, "git-only") + + r := httptest.NewRequest("POST", "/git/"+p.ID+"/init", nil) + w := httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("init = %d: %s", w.Code, w.Body.String()) + } + if !markerExists(filepath.Join(repo, ".jj")) { + t.Fatal("colocated jj metadata was not created") + } + projectOfKind(t, hub, projectJJ, "git-only") + if got := string(mustRead(t, filepath.Join(repo, "README.md"))); got != "git only\n" { + t.Fatalf("working file changed: %q", got) + } + + late := filepath.Join(home, "code", "appeared-later") + initJJAt(t, late) + r = httptest.NewRequest("POST", "/projects/refresh", nil) + w = httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("refresh = %d", w.Code) + } + projectOfKind(t, hub, projectJJ, "appeared-later") +} + +func TestProjectHubRefusesCrossOriginMutation(t *testing.T) { + home := t.TempDir() + projectTestEnv(t, home) + repo := filepath.Join(home, "code", "ready") + initJJAt(t, repo) + hub := testProjectHub(t, home) + p := projectOfKind(t, hub, projectJJ, "ready") + r := httptest.NewRequest("POST", "/projects/"+p.ID+"/start", nil) + r.Header.Set("Origin", "https://elsewhere.example") + w := httptest.NewRecorder() + hub.handler().ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("cross-origin start = %d", w.Code) + } + if active, err := hasVRSidecar(repo); err != nil || active { + t.Fatalf("cross-origin request created sidecar: %v, %v", active, err) + } +} diff --git a/vrsite/serve.go b/vrsite/serve.go index 31217af..746abac 100644 --- a/vrsite/serve.go +++ b/vrsite/serve.go @@ -3,9 +3,9 @@ // The server owns nothing. Three rules keep it that way, and app_test.go holds // each of them: // -// - The log is the only thing written. Notes typed in the browser are -// appended to it exactly as vr appends them; the repository is only ever -// read (see jj.go), and no file of the app's own is ever created. +// - The log sidecar is the only thing written. Notes typed in the browser are +// appended to it exactly as vr appends them; project history and the main +// working copy are only ever read (see jj.go). // - Memory is a cache, never a source. The model, the per-change // projections, file lists and rendered pages are all derived from the log // and the repo, so a restart changes nothing anyone can see. @@ -27,7 +27,6 @@ import ( "html/template" "net/http" "net/url" - "os" "strconv" "strings" "sync" @@ -43,18 +42,18 @@ const liveWindow = 5 * time.Minute const renderCacheMax = 64 type server struct { - logPath, repo, title, user, session string - tpl *template.Template - hl *highlighter - theme, light *Theme - themes *themeSet - tx *transcripts + log logStore + repo, title, user, session, base string + tpl *template.Template + hl *highlighter + theme, light *Theme + themes *themeSet + tx *transcripts mu sync.Mutex site *Site entries []Entry - off int64 - state logState + state string gen int def string // resolved change the log points at models map[string]*Site // scope key -> the log projected onto it @@ -65,12 +64,17 @@ type server struct { // scopeAll is the key of the unscoped model — the whole log, every change. const scopeAll = "*" -func newServer(logPath, repo, title, user string, theme, light *Theme, themes *themeSet) *server { +func newServer(log logStore, repo, title, user string, theme, light *Theme, themes *themeSet) *server { + return newServerAt(log, repo, title, user, "/", theme, light, themes) +} + +func newServerAt(log logStore, repo, title, user, base string, theme, light *Theme, themes *themeSet) *server { if themes == nil { themes = &themeSet{} } + base = (&page{Root: base}).liveRoot() hl := newHighlighter(theme, light) - sv := &server{logPath: logPath, repo: repo, title: title, user: user, + sv := &server{log: log, repo: repo, title: title, user: user, base: base, session: "web:" + user, theme: theme, light: light, themes: themes, tpl: newTemplates(hl), models: map[string]*Site{}, trees: map[string][]*File{}, rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())} @@ -79,11 +83,10 @@ func newServer(logPath, repo, title, user string, theme, light *Theme, themes *t return sv } -// reload re-reads the log if it changed. It appends from the last offset when -// the file only grew — the common case, since vr and the web app both append. +// reload re-reads the log when the sidecar commit id changes. func (sv *server) reload(force bool) { - st, err := statLog(sv.logPath) - if err == nil && st == sv.state && !force { + state, err := sv.log.Generation() + if err == nil && state == sv.state && !force { return } if err != nil { @@ -92,22 +95,17 @@ func (sv *server) reload(force bool) { } return } - if st.size < sv.off { - sv.off, sv.entries = 0, nil // truncated or rewritten - } - f, err := os.Open(sv.logPath) + b, err := sv.log.Read() if err != nil { + if sv.site == nil { + sv.state = state + sv.site = sv.dress(buildModel(sv.title, nil, "")) + sv.gen++ + } return } - defer f.Close() - buf := make([]byte, st.size-sv.off) - n, _ := f.ReadAt(buf, sv.off) - buf = buf[:n] - if i := bytes.LastIndexByte(buf, '\n'); i >= 0 { // ignore a partial last line - sv.off += int64(i + 1) - sv.entries = append(sv.entries, parseLog(buf[:i+1])...) - } - sv.state = st + sv.entries = parseLog(b) + sv.state = state sv.site = sv.dress(buildModel(sv.title, sv.entries, "")) sv.gen++ sv.def = "" @@ -257,8 +255,9 @@ func (sv *server) fileAt(site *Site, gen int, commit, change, path string) *File func (sv *server) newPage(r *http.Request, kind, title string) (*page, *Site, error) { full, _, def := sv.snap() q := r.URL.Query() - p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true, - User: sv.user, ChangeID: def, Default: def, Path: r.URL.Path, Scope: "change"} + path := sv.base + strings.TrimPrefix(r.URL.Path, "/") + p := &page{Site: full, Kind: kind, Title: title, Root: sv.base, Live: true, + User: sv.user, ChangeID: def, Default: def, Path: path, Scope: "change"} rev := strings.TrimSpace(q.Get("rev")) target := rev if target == "" { @@ -540,7 +539,7 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { } target = fallback } - change, err := resolveChange(sv.repo, target) + change, commit, err := resolveRevision(sv.repo, target) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -556,14 +555,13 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { e := Entry{ Time: time.Now().Format(time.RFC3339), Op: "note", Session: sv.session, Agent: sv.user, Dir: sv.repo, - Rev: rev, Change: change, File: strings.TrimSpace(r.FormValue("file")), + Rev: rev, Change: change, Commit: commit, File: strings.TrimSpace(r.FormValue("file")), Start: start, End: end, ReplyTo: replyTo, Type: strings.TrimSpace(r.FormValue("kind")), Text: text, } sv.mu.Lock() - e.NoteID = nextNoteID(sv.entries, sv.session) - err = appendEntry(sv.logPath, e) + err = appendEntry(sv.log, &e) if err == nil { sv.reload(true) } @@ -583,12 +581,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) { if !htmxReq(r) { back := r.FormValue("back") if back == "" { - back = "/notes" + back = sv.base + "notes" } http.Redirect(w, r, back, http.StatusSeeOther) return } - p := &page{Site: site, Kind: "file", Root: "/", Live: true, User: sv.user, Facets: site.Facets()} + p := &page{Site: site, Kind: "file", Root: sv.base, Live: true, User: sv.user, Facets: site.Facets()} if e.File != "" { p.File = site.Files[e.File] } @@ -609,7 +607,7 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) { if !compact { limit = 20 } - p := &page{Site: site, Live: true, Compact: compact, Window: "5 min", + p := &page{Site: site, Root: sv.base, Live: true, Compact: compact, Window: "5 min", Sessions: site.Sessions(time.Now(), liveWindow, limit)} sv.frag(w, "activity", p) } @@ -638,7 +636,7 @@ const defaultLimit = 50 func (sv *server) jjView(r *http.Request) *JJView { q := r.URL.Query() - v := &JJView{Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")), + v := &JJView{Root: sv.base, Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")), AtOp: strings.TrimSpace(q.Get("at_op")), Limit: atoiDefault(q.Get("limit"), defaultLimit)} if v.Limit < 1 || v.Limit > 1000 { v.Limit = defaultLimit @@ -678,7 +676,7 @@ func (sv *server) jjLog(w http.ResponseWriter, r *http.Request) { func (sv *server) jjOp(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - v := &JJView{Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)} + v := &JJView{Root: sv.base, Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)} if v.Limit < 1 || v.Limit > 1000 { v.Limit = defaultLimit } @@ -751,15 +749,15 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) { w.Write(b) } -func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { - sv := newServer(logPath, repo, title, user, theme, light, themes) +func serve(addr string, log logStore, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) { + sv := newServer(log, repo, title, user, theme, light, themes) sv.tx = newTranscripts(roots) site, _, def := sv.snap() fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n", addr, len(site.Notes), len(site.Agents), repo, short(def)) fmt.Printf("vrsite: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n", theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix")) - fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath) + fmt.Printf("vrsite: comments are appended to %s; the project working copy is never written to\n", log) fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", ")) if err := http.ListenAndServe(addr, sv.handler()); err != nil { fatal("%v", err) diff --git a/vrsite/static.go b/vrsite/static.go index b299975..0a8eec0 100644 --- a/vrsite/static.go +++ b/vrsite/static.go @@ -32,9 +32,9 @@ func run(dir string, name string, args ...string) []byte { return out } -func buildSite(logPath, repoAbs, out, title string, theme, light *Theme, themes *themeSet) { +func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, themes *themeSet) { start := time.Now() - entries := readLog(logPath) + entries := readLog(log) ranked := buildModel(title, entries, "").Ranked if len(ranked) == 0 { fatal("log has no jj change ids") diff --git a/vrsite/templates/frag-jj.tmpl b/vrsite/templates/frag-jj.tmpl index b31af53..61040dc 100644 --- a/vrsite/templates/frag-jj.tmpl +++ b/vrsite/templates/frag-jj.tmpl @@ -8,7 +8,7 @@ {{else if .Op}} <div class="jjrow"> <span class="graph">{{.Graph}}</span> - <a class="id" href="/jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a> + <a class="id" href="{{$.Root}}jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a> <span class="when">{{.When}}</span> <span class="who">{{.Author}}</span> <span class="desc">{{.Desc}}</span> @@ -17,17 +17,17 @@ {{else}} <div class="jjrow"> <span class="graph">{{.Graph}}</span> - <a class="id" href="/change/{{.ID}}" title="{{.ID}}">{{.Short}}</a> + <a class="id" href="{{$.Root}}change/{{.ID}}" title="{{.ID}}">{{.Short}}</a> <code class="commit">{{.CommitShort}}</code> <span class="when">{{.When}}</span> <span class="who">{{.Author}}</span> {{if .Refs}}<span class="refs">{{.Refs}}</span>{{end}} {{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}} - <a class="desc" href="/files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a> + <a class="desc" href="{{$.Root}}files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a> </div> {{end}} {{end}} </div> {{else if not .Err}}<p class="meta hint">no revisions matched</p>{{end}} -{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="/jj?kind=op">back to the op log</a></p>{{end}} +{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="{{.Root}}jj?kind=op">back to the op log</a></p>{{end}} {{end}} diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl index 268b5b6..e83730a 100644 --- a/vrsite/templates/layout.tmpl +++ b/vrsite/templates/layout.tmpl @@ -33,12 +33,13 @@ </header> {{template "changebar" .}} {{if .Live}} - <div id="activity" class="activity" hx-get="/activity?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div> + <div id="activity" class="activity" hx-get="{{.Href "activity"}}?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div> {{end}} <input id="filefind" class="find" type="search" placeholder="find file…" aria-label="find file" autocomplete="off" spellcheck="false"> <div id="tree"><p class="meta hint">loading tree…</p></div> <footer> <span> + {{if and .Live (ne .Root "/")}}<a href="/">projects</a> · {{end}} <a href="{{.Href "notes"}}">notes ({{len .Site.Notes}})</a> · <a href="{{.Href "files"}}">files</a>{{if .Live}} · <a href="{{.Href "jj"}}">jj</a>{{end}} diff --git a/vrsite/templates/page-change.tmpl b/vrsite/templates/page-change.tmpl index 08310e5..8641da7 100644 --- a/vrsite/templates/page-change.tmpl +++ b/vrsite/templates/page-change.tmpl @@ -1,5 +1,6 @@ {{define "page-change"}} {{$c := .Change}} +{{$p := .}} <header class="filehead"> <h1>{{$c.Row.Short}} <span class="meta">{{$c.Row.Flags}}</span></h1> <p class="meta"> @@ -14,7 +15,7 @@ {{if $c.Parents}} <p class="meta">parents: - {{range $c.Parents}}<a href="/change/{{.ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}} + {{range $c.Parents}}<a href="{{$p.Href "change" .ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}} </p> {{end}} @@ -24,7 +25,7 @@ <tbody> {{range $c.Files}} <tr><td class="st st-{{.Status}}">{{.Status}}</td> - <td><a href="/code/{{.Path}}?rev={{$c.Row.ID}}">{{.Path}}</a></td></tr> + <td><a href="{{$p.Href "code" .Path}}">{{.Path}}</a></td></tr> {{end}} </tbody> </table> diff --git a/vrsite/templates/page-index.tmpl b/vrsite/templates/page-index.tmpl index bd377ac..93cad6b 100644 --- a/vrsite/templates/page-index.tmpl +++ b/vrsite/templates/page-index.tmpl @@ -12,7 +12,7 @@ {{if .Live}} <section> <h2>Live <span class="meta">— agents reading this codebase</span></h2> - <div id="activity-full" hx-get="/activity" hx-trigger="load, every 3s" hx-swap="innerHTML"> + <div id="activity-full" hx-get="{{.Href "activity"}}" hx-trigger="load, every 3s" hx-swap="innerHTML"> <p class="meta hint">…</p> </div> </section> diff --git a/vrsite/templates/page-jj.tmpl b/vrsite/templates/page-jj.tmpl index 5c1381c..24df596 100644 --- a/vrsite/templates/page-jj.tmpl +++ b/vrsite/templates/page-jj.tmpl @@ -8,12 +8,12 @@ <section class="jjpage"> <form class="jjform" method="get" action="{{.Href "jj"}}" - hx-get="/jj/log" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy"> + hx-get="{{.Href "jj-log"}}" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy"> <input type="hidden" name="kind" value="log"> <input type="search" name="revset" value="{{.JJ.Revset}}" placeholder="revset — e.g. @ | ancestors(trunk(), 10) | description(glob:'*fix*')" autocomplete="off" spellcheck="false"> <input type="number" name="limit" value="{{.JJ.Limit}}" min="1" max="1000" aria-label="limit" title="limit"> <button type="submit">jj log</button> - <button type="button" hx-get="/jj/op" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button> + <button type="button" hx-get="{{.Href "jj-op"}}" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button> <span id="jjbusy" class="busy">·</span> </form> <p class="meta hint"> diff --git a/vrsite/templates/page-notes.tmpl b/vrsite/templates/page-notes.tmpl index 242c137..ebd5865 100644 --- a/vrsite/templates/page-notes.tmpl +++ b/vrsite/templates/page-notes.tmpl @@ -16,7 +16,7 @@ {{if .Live}}<a href="{{.ScopeHref "all"}}">{{.Elsewhere}} more at other changes</a> {{else}}{{.Elsewhere}} more at other changes are not in this export{{end}}{{end}} {{end}} - {{if .Live}} · anything you add here is appended to the log at this change — the repo is never touched{{end}} + {{if .Live}} · anything you add here is appended to the private log sidecar at this change — the project working copy is never touched{{end}} </p> </header> @@ -29,7 +29,7 @@ {{end}} <form class="filters" id="notefilters" method="get" action="{{.Href "notes"}}" - {{if .Live}}hx-get="/notes/list" hx-target="#notelist" hx-swap="innerHTML" + {{if .Live}}hx-get="{{.Href "notes-list"}}" hx-target="#notelist" hx-swap="innerHTML" hx-trigger="input delay:250ms, change, search" hx-indicator="#notesbusy"{{end}}> <input type="search" name="text" value="{{.Filter.Text}}" placeholder="search note text…" autocomplete="off"> <input type="search" name="file" value="{{.Filter.File}}" placeholder="file path…" list="filelist" autocomplete="off"> diff --git a/vrsite/templates/projects.tmpl b/vrsite/templates/projects.tmpl new file mode 100644 index 0000000..3e3c670 --- /dev/null +++ b/vrsite/templates/projects.tmpl @@ -0,0 +1,112 @@ +{{define "projects"}}<!doctype html> +<html lang="en" data-live="1"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{.Title}}</title> +<link rel="stylesheet" href="/style.css"> +<script> +// Match project pages: wear the saved theme before first paint, and load the +// installed-theme stylesheet now only when that saved choice requires it. +{const d = document.documentElement, t = localStorage.getItem("vrsite-theme"); + if (t && t !== "system") d.dataset.theme = t; + if (t && !/^(system|dark|light)$/.test(t)) { + const l = document.createElement("link"); + l.rel = "stylesheet"; l.id = "themescss"; l.href = "/themes.css"; + document.head.append(l); + } + d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";} +</script> +<script src="/app.js" defer></script> +</head> +<body class="projects"> +<nav aria-label="projects" id="nav"> + <header><a class="brand" href="/">{{.Title}}</a></header> + <p class="meta">one process · every local vr sidecar</p> + <input id="projectfind" class="find" type="search" placeholder="find project…" aria-label="find project" autocomplete="off" spellcheck="false"> + <form method="post" action="/projects/refresh" class="project-refresh"> + <button type="submit">refresh repositories</button> + </form> + <p class="meta">scans <code>{{.ScanRoot}}</code></p> + <p class="meta">last refresh {{.Scanned}}</p> + <footer> + <span>selection and scan results stay in memory</span> + <div class="themepick" id="themepick" + data-js="/themes.js" data-css="/themes.css" + data-dark="{{.Theme.Name}}" data-light="{{.Light.Name}}" + data-darkid="{{.Theme.ID}}" data-lightid="{{.Light.ID}}"> + <button type="button" id="themebtn" aria-haspopup="listbox" aria-expanded="false" + title="theme — every one installed here (T)">theme</button> + <div class="thememenu" id="thememenu" hidden> + <input id="themefind" type="search" placeholder="filter themes…" aria-label="filter themes" + role="combobox" aria-controls="themelist" aria-autocomplete="list" + autocomplete="off" spellcheck="false"> + <ul id="themelist" role="listbox" aria-label="themes"></ul> + <p class="meta hint">↑↓ preview · enter keeps · esc puts it back</p> + </div> + </div> + </footer> +</nav> + +<main class="projects-main"> + <header class="filehead"> + <h1>Projects</h1> + <p class="meta">Choose a vr log, start one in a jj repository, or colocate jj with an existing Git repository.</p> + </header> + {{if .Message}}<p class="ok project-message">{{.Message}}</p>{{end}} + {{if .Err}}<p class="err project-message">{{.Err}}</p>{{end}} + + <section> + <h2>Logs <span class="meta">— {{len .Active}} repositories with a private sidecar</span></h2> + <div class="project-grid"> + {{range .Active}} + <article class="project-card" data-project="{{.Name}} {{.Relative}}"> + <header><h3><a href="/p/{{.ID}}/">{{.Name}}</a></h3><span class="project-state active">log</span></header> + <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p> + <p class="meta">{{.Entries}} entries · {{.Notes}} notes · {{.Agents}} agents · {{.Updated}}</p> + {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} jj workspaces share this log</p>{{end}} + {{if .Err}}<p class="err">{{.Err}}</p>{{end}} + <a class="project-action" href="/p/{{.ID}}/">open project</a> + </article> + {{end}} + {{if not .Active}}<p class="meta hint">no vr sidecars found yet</p>{{end}} + </div> + </section> + + <section> + <h2>Ready to start <span class="meta">— {{len .Ready}} jj repositories without a vr sidecar</span></h2> + <div class="project-grid"> + {{range .Ready}} + <article class="project-card" data-project="{{.Name}} {{.Relative}}"> + <header><h3>{{.Name}}</h3><span class="project-state">jj</span></header> + <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p> + {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} workspaces</p>{{end}} + {{if .Err}}<p class="err">{{.Err}}</p>{{end}} + <form method="post" action="/projects/{{.ID}}/start"> + <button type="submit">start private sidecar</button> + </form> + </article> + {{end}} + {{if not .Ready}}<p class="meta hint">every detected jj repository already has a sidecar</p>{{end}} + </div> + </section> + + <section> + <h2>Git repositories <span class="meta">— {{len .Git}} without jj metadata</span></h2> + <p class="meta">Initializing adds colocated <code>.jj</code> metadata; it does not create a sidecar until you choose “start private sidecar”.</p> + <div class="project-grid"> + {{range .Git}} + <article class="project-card" data-project="{{.Name}} {{.Relative}}"> + <header><h3>{{.Name}}</h3><span class="project-state git">git</span></header> + <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p> + <form method="post" action="/git/{{.ID}}/init"> + <button type="submit">initialize colocated jj</button> + </form> + </article> + {{end}} + {{if not .Git}}<p class="meta hint">no Git-only repositories detected</p>{{end}} + </div> + </section> +</main> +</body> +</html>{{end}} |
