From 995e6eda2ca5bc49b5818b78d65c114129b50566 Mon Sep 17 00:00:00 2001 From: Tyler Pate Date: Fri, 5 Jun 2026 17:27:36 -0700 Subject: [PATCH 1/5] cli: add leather snapshot save and restore (closes #6) New `leather snapshot save` and `leather snapshot restore` subcommands provide built-in point-in-time backup and restore for runtime state. Save archives queues/, runs/, and cache/ (plus tannery hide_dir/ and artifact_dir/ when configured) into a tar.gz, skipping leather.lock and devtools.token. Restore extracts into the state directory with a non-empty-dir guard (--force to override). Both commands verify the serve lock is not held before proceeding. --- ROADMAP.md | 10 +- docs/OPERATIONS.md | 43 ++-- internal/cli/cli.go | 2 + internal/cli/cmd_snapshot.go | 358 ++++++++++++++++++++++++++++++ internal/cli/cmd_snapshot_test.go | 166 ++++++++++++++ internal/cli/help.go | 1 + 6 files changed, 557 insertions(+), 23 deletions(-) create mode 100644 internal/cli/cmd_snapshot.go create mode 100644 internal/cli/cmd_snapshot_test.go diff --git a/ROADMAP.md b/ROADMAP.md index 946c55b..71f114d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,9 +40,13 @@ The major v0.2 themes are *day-2 operations*, *codebase hygiene*, and semantics; print a compact status/artifact summary; and exit with a meaningful code. This gives agent-backed tasks like signed per-file git commits a clear Leather-owned execution path instead of process glue. -- **`leather snapshot save / restore`** — built-in backup tooling. - Today the procedure is *stop the service, tar the state dir, start - again* (see [docs/OPERATIONS.md](docs/OPERATIONS.md#backup-and-restore)). +- ~~**`leather snapshot save / restore`**~~ — shipped in v0.2 (issue #6). +- **`leather attach`** — join a running `serve` instance and stream + pretty-printed runtime logs in the terminal. Connects to the API + server (SSE or a new `/logs/stream` endpoint), renders structured + log lines with color-coded levels, component labels, and key-value + pairs. Supports `--filter` by component or level; reconnects with + backoff if the serve process restarts. Tracked in issue #19. ### Runtime diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f4c3334..3ba8e19 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -215,26 +215,29 @@ stderr for the corresponding `agent load error` log line. ## Backup and restore -**There is no built-in backup or restore command in v0.1.** Built-in -snapshot tooling (`leather snapshot save / restore`) is on the v0.2 -roadmap — see [ROADMAP.md](../ROADMAP.md). -Until then: - -1. **Stop** `leather serve` (`systemctl stop leather`). Do not skip this. -2. `tar czf leather-state-$(date +%F).tgz -C `. -3. If you use a tannery, also archive `hide_dir/` and `artifact_dir/`. -4. Start the service again. - -**Why stop first.** Queues are append-only JSONL files. A tarball taken -during a write can capture a partial trailing line. The runtime treats a -truncated final record as end-of-file on next read, which means a hot -backup may silently drop the most recent enqueue. Stopping the process -flushes pending writes and releases `leather.lock`. - -Restore is the reverse: stop the service, extract the tarball over an -empty ``, fix ownership (`chown -R leather:leather`), start the -service. Queues and history resume from where they left off because both -formats are append-only. +Use `leather snapshot save` and `leather snapshot restore`. Both commands +require that `leather serve` is **not running** — they detect the lock file +and exit with an error if the service is active. + +```bash +# Save a snapshot (defaults to leather-snapshot-.tar.gz) +leather snapshot save --output /backups/leather-$(date +%F).tar.gz + +# Restore into an existing state directory (--force required if non-empty) +leather snapshot restore \ + --input /backups/leather-2026-06-05.tar.gz \ + --force +``` + +The archive includes `queues/`, `runs/`, and `cache/` from `state_dir`, plus +`hide_dir/` and `artifact_dir/` when tannery is configured. Transient files +(`leather.lock`, `devtools.token`) are excluded from saves and not written +on restore. + +**Why stop first.** Queues are append-only JSONL files. A snapshot taken +during a write can capture a partial trailing line, silently dropping the +most recent enqueue. Stopping the service flushes pending writes and +releases `leather.lock`, which the snapshot commands check before proceeding. ## Log rotation diff --git a/internal/cli/cli.go b/internal/cli/cli.go index ff26158..cbfb2e6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -44,6 +44,8 @@ func Run(args []string, stdout, stderr io.Writer, version, commit string) int { return RunIngest(rest, stdout, stderr) case "replay": return RunReplay(rest, stdout, stderr, version, commit) + case "snapshot": + return RunSnapshot(rest, stdout, stderr) case "help", "--help", "-h": fmt.Fprint(stdout, usage) return 0 diff --git a/internal/cli/cmd_snapshot.go b/internal/cli/cmd_snapshot.go new file mode 100644 index 0000000..d80e6de --- /dev/null +++ b/internal/cli/cmd_snapshot.go @@ -0,0 +1,358 @@ +package cli + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tgpski/leather/internal/config" + "github.com/tgpski/leather/internal/fileutil" +) + +const snapshotUsage = `Usage: + leather snapshot save [--output ] + leather snapshot restore --input [--force] +` + +// RunSnapshot dispatches to save or restore sub-subcommands. +func RunSnapshot(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, snapshotUsage) + return 2 + } + sub, rest := args[0], args[1:] + switch sub { + case "save": + return RunSnapshotSave(rest, stdout, stderr) + case "restore": + return RunSnapshotRestore(rest, stdout, stderr) + default: + fmt.Fprintf(stderr, "leather snapshot: unknown subcommand %q\n\n", sub) + fmt.Fprint(stderr, snapshotUsage) + return 2 + } +} + +// RunSnapshotSave creates a point-in-time tar.gz archive of runtime state. +// +// Usage: leather snapshot save [--output ] +func RunSnapshotSave(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("snapshot save", stderr) + config.BindFlags(fs) + defaultOut := "leather-snapshot-" + time.Now().UTC().Format("20060102T150405") + ".tar.gz" + output := fs.String("output", defaultOut, "destination archive path") + if !parseFlags(fs, args) { + return 2 + } + + cfg, err := config.Load(fs) + if err != nil { + fmt.Fprintf(stderr, "leather snapshot save: %v\n", err) + return 1 + } + + // Verify no serve process is holding the lock. + lockPath := filepath.Join(cfg.StateDir, "leather.lock") + lf, lockErr := acquireProcessLock(lockPath) + if lockErr != nil { + fmt.Fprintf(stderr, "leather snapshot save: serve is running (lock held at %s); stop it before saving\n", lockPath) + return 1 + } + releaseProcessLock(lf) + + // Collect source directories. + type srcDir struct { + root string // absolute path to directory + prefix string // prefix inside the archive + } + dirs := []srcDir{ + {filepath.Join(cfg.StateDir, "queues"), "state/queues"}, + {filepath.Join(cfg.StateDir, "runs"), "state/runs"}, + {filepath.Join(cfg.StateDir, "cache"), "state/cache"}, + } + + // Include tannery dirs if configured. + if cfg.TanneryFile != "" { + tann, tErr := config.LoadTannery(cfg.TanneryFile) + if tErr == nil { + if tann.HideDir != "" { + dirs = append(dirs, srcDir{tann.HideDir, "tannery/hides"}) + } + if tann.ArtifactDir != "" { + dirs = append(dirs, srcDir{tann.ArtifactDir, "tannery/artifacts"}) + } + } + } + + var totalFiles int + err = fileutil.AtomicWriteFileFunc(*output, 0600, func(w io.Writer) error { + gz := gzip.NewWriter(w) + tw := tar.NewWriter(gz) + + for _, d := range dirs { + n, walkErr := tarDir(tw, d.root, d.prefix) + if walkErr != nil { + return walkErr + } + totalFiles += n + } + + if err := tw.Close(); err != nil { + return fmt.Errorf("tar close: %w", err) + } + return gz.Close() + }) + if err != nil { + fmt.Fprintf(stderr, "leather snapshot save: %v\n", err) + return 1 + } + + fi, _ := os.Stat(*output) + size := int64(0) + if fi != nil { + size = fi.Size() + } + fmt.Fprintf(stdout, "snapshot saved: %s (%d files, %s)\n", *output, totalFiles, formatBytes(size)) + return 0 +} + +// RunSnapshotRestore extracts a snapshot archive into the configured state directory. +// +// Usage: leather snapshot restore --input [--force] +func RunSnapshotRestore(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("snapshot restore", stderr) + config.BindFlags(fs) + input := fs.String("input", "", "snapshot archive to restore (required)") + force := fs.Bool("force", false, "overwrite existing state without prompting") + if !parseFlags(fs, args) { + return 2 + } + if *input == "" { + fmt.Fprintf(stderr, "leather snapshot restore: --input is required\n") + return 2 + } + + cfg, err := config.Load(fs) + if err != nil { + fmt.Fprintf(stderr, "leather snapshot restore: %v\n", err) + return 1 + } + + // Verify no serve process is running. acquireProcessLock creates the lock + // file as a side effect; remove it so it doesn't appear as a restored file. + lockPath := filepath.Join(cfg.StateDir, "leather.lock") + lf, lockErr := acquireProcessLock(lockPath) + if lockErr != nil { + fmt.Fprintf(stderr, "leather snapshot restore: serve is running (lock held at %s); stop it before restoring\n", lockPath) + return 1 + } + releaseProcessLock(lf) + _ = os.Remove(lockPath) + + // Guard against clobbering an existing non-empty state dir. + if !*force { + if occupied, _ := dirHasFiles(cfg.StateDir); occupied { + fmt.Fprintf(stderr, "leather snapshot restore: %s is not empty; use --force to overwrite\n", cfg.StateDir) + return 1 + } + } + + f, err := os.Open(*input) + if err != nil { + fmt.Fprintf(stderr, "leather snapshot restore: %v\n", err) + return 1 + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + fmt.Fprintf(stderr, "leather snapshot restore: not a valid gzip archive: %v\n", err) + return 1 + } + defer gz.Close() + + // Build a prefix→destination map from the archive layout. + prefixMap := map[string]string{ + "state/queues": filepath.Join(cfg.StateDir, "queues"), + "state/runs": filepath.Join(cfg.StateDir, "runs"), + "state/cache": filepath.Join(cfg.StateDir, "cache"), + "tannery/hides": "", + "tannery/artifacts": "", + } + + // Populate tannery destinations if configured. + if cfg.TanneryFile != "" { + tann, tErr := config.LoadTannery(cfg.TanneryFile) + if tErr == nil { + prefixMap["tannery/hides"] = tann.HideDir + prefixMap["tannery/artifacts"] = tann.ArtifactDir + } + } + + tr := tar.NewReader(gz) + var restoredFiles int + for { + hdr, tErr := tr.Next() + if tErr == io.EOF { + break + } + if tErr != nil { + fmt.Fprintf(stderr, "leather snapshot restore: read archive: %v\n", tErr) + return 1 + } + + destPath, mapErr := resolveArchivePath(hdr.Name, prefixMap) + if mapErr != nil { + // Entry belongs to a tannery prefix with no configured destination; skip. + continue + } + + // Reject path traversal. + if strings.Contains(hdr.Name, "..") { + fmt.Fprintf(stderr, "leather snapshot restore: unsafe path in archive: %s\n", hdr.Name) + return 1 + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(destPath, 0700); err != nil { + fmt.Fprintf(stderr, "leather snapshot restore: mkdir %s: %v\n", destPath, err) + return 1 + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(destPath), 0700); err != nil { + fmt.Fprintf(stderr, "leather snapshot restore: mkdir %s: %v\n", filepath.Dir(destPath), err) + return 1 + } + if err := writeRestoreFile(destPath, tr); err != nil { + fmt.Fprintf(stderr, "leather snapshot restore: write %s: %v\n", destPath, err) + return 1 + } + restoredFiles++ + } + } + + fmt.Fprintf(stdout, "snapshot restored: %d files → %s\n", restoredFiles, cfg.StateDir) + return 0 +} + +// tarDir walks root and writes every regular file into tw under archivePrefix/rel. +// Returns the number of files added. +func tarDir(tw *tar.Writer, root, archivePrefix string) (int, error) { + if _, err := os.Stat(root); os.IsNotExist(err) { + return 0, nil // directory doesn't exist yet — skip silently + } + var count int + err := filepath.Walk(root, func(path string, fi os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + archivePath := archivePrefix + "/" + filepath.ToSlash(rel) + + if fi.IsDir() { + hdr := &tar.Header{ + Typeflag: tar.TypeDir, + Name: archivePath + "/", + Mode: 0700, + ModTime: fi.ModTime(), + } + return tw.WriteHeader(hdr) + } + if !fi.Mode().IsRegular() { + return nil + } + // Skip transient files that serve regenerates on startup. + base := filepath.Base(path) + if base == "leather.lock" || base == "devtools.token" { + return nil + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + hdr := &tar.Header{ + Typeflag: tar.TypeReg, + Name: archivePath, + Size: fi.Size(), + Mode: 0600, + ModTime: fi.ModTime(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if _, err := io.Copy(tw, f); err != nil { + return err + } + count++ + return nil + }) + return count, err +} + +// resolveArchivePath maps an archive entry name to a filesystem destination. +// Returns an error when no mapping is configured (e.g. tannery dir not set). +func resolveArchivePath(name string, prefixMap map[string]string) (string, error) { + for prefix, dest := range prefixMap { + p := prefix + "/" + if name == prefix || strings.HasPrefix(name, p) { + if dest == "" { + return "", fmt.Errorf("no destination for prefix %q", prefix) + } + rel := strings.TrimPrefix(name, p) + return filepath.Join(dest, filepath.FromSlash(rel)), nil + } + } + return "", fmt.Errorf("unknown archive prefix for %q", name) +} + +// writeRestoreFile writes a tar entry's content to dest atomically. +func writeRestoreFile(dest string, r io.Reader) error { + return fileutil.AtomicWriteFileFunc(dest, 0600, func(w io.Writer) error { + _, err := io.Copy(w, r) + return err + }) +} + +// dirHasFiles reports whether dir exists and contains at least one file or +// subdirectory, ignoring leather.lock and devtools.token. +func dirHasFiles(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + for _, e := range entries { + n := e.Name() + if n == "leather.lock" || n == "devtools.token" { + continue + } + return true, nil + } + return false, nil +} + +// formatBytes returns a human-readable byte count. +func formatBytes(n int64) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} diff --git a/internal/cli/cmd_snapshot_test.go b/internal/cli/cmd_snapshot_test.go new file mode 100644 index 0000000..eb39cd0 --- /dev/null +++ b/internal/cli/cmd_snapshot_test.go @@ -0,0 +1,166 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// makeSnapshotStateDir populates a minimal state directory for snapshot tests. +func makeSnapshotStateDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + dirs := []string{ + filepath.Join(dir, "queues"), + filepath.Join(dir, "runs"), + filepath.Join(dir, "cache"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0700); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + files := map[string]string{ + filepath.Join(dir, "queues", "work.jsonl"): `{"id":"q1"}` + "\n", + filepath.Join(dir, "runs", "my-agent.jsonl"): `{"agent":"my-agent"}` + "\n", + filepath.Join(dir, "cache", "resp.json"): `{"cached":true}` + "\n", + filepath.Join(dir, "leather.lock"): "", // should be skipped + filepath.Join(dir, "devtools.token"): "tk", // should be skipped + } + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + return dir +} + +func TestSnapshotSaveRestore_RoundTrip(t *testing.T) { + stateDir := makeSnapshotStateDir(t) + archivePath := filepath.Join(t.TempDir(), "snap.tar.gz") + restoreDir := t.TempDir() + + var stdout, stderr bytes.Buffer + + // Save + code := RunSnapshotSave([]string{ + "--state-dir", stateDir, + "--output", archivePath, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("save exited %d: %s", code, stderr.String()) + } + if _, err := os.Stat(archivePath); err != nil { + t.Fatalf("archive not created: %v", err) + } + + stdout.Reset() + stderr.Reset() + + // Restore into empty dir + code = RunSnapshotRestore([]string{ + "--state-dir", restoreDir, + "--input", archivePath, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("restore exited %d: %s", code, stderr.String()) + } + + // Verify files present in restored dir + wantFiles := []string{ + filepath.Join(restoreDir, "queues", "work.jsonl"), + filepath.Join(restoreDir, "runs", "my-agent.jsonl"), + filepath.Join(restoreDir, "cache", "resp.json"), + } + for _, wf := range wantFiles { + if _, err := os.Stat(wf); err != nil { + t.Errorf("expected restored file %s: %v", wf, err) + } + } + + // Verify transient files were NOT restored + for _, skip := range []string{"leather.lock", "devtools.token"} { + p := filepath.Join(restoreDir, skip) + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("transient file %s should not have been restored", skip) + } + } + + // Verify content round-trip for one file + got, err := os.ReadFile(filepath.Join(restoreDir, "queues", "work.jsonl")) + if err != nil { + t.Fatalf("read restored queue file: %v", err) + } + if string(got) != `{"id":"q1"}`+"\n" { + t.Errorf("content mismatch: %q", got) + } +} + +func TestSnapshotSave_LockHeld(t *testing.T) { + stateDir := t.TempDir() + lockPath := filepath.Join(stateDir, "leather.lock") + lf, err := acquireProcessLock(lockPath) + if err != nil { + t.Fatalf("acquire lock: %v", err) + } + defer releaseProcessLock(lf) + + var stdout, stderr bytes.Buffer + code := RunSnapshotSave([]string{ + "--state-dir", stateDir, + "--output", filepath.Join(t.TempDir(), "snap.tar.gz"), + }, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit when lock is held, got 0") + } + if !strings.Contains(stderr.String(), "serve is running") { + t.Errorf("expected 'serve is running' in stderr, got: %s", stderr.String()) + } +} + +func TestSnapshotRestore_NonEmptyNoForce(t *testing.T) { + stateDir := makeSnapshotStateDir(t) + archivePath := filepath.Join(t.TempDir(), "snap.tar.gz") + + // Create archive from a fresh state dir + var stdout, stderr bytes.Buffer + code := RunSnapshotSave([]string{ + "--state-dir", stateDir, + "--output", archivePath, + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("save failed: %s", stderr.String()) + } + + // Restore into same non-empty dir without --force + stderr.Reset() + stdout.Reset() + destDir := t.TempDir() + // Put something in destDir + if err := os.WriteFile(filepath.Join(destDir, "existing.jsonl"), []byte("x\n"), 0600); err != nil { + t.Fatal(err) + } + code = RunSnapshotRestore([]string{ + "--state-dir", destDir, + "--input", archivePath, + }, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit for non-empty dir without --force") + } + if !strings.Contains(stderr.String(), "not empty") { + t.Errorf("expected 'not empty' in stderr, got: %s", stderr.String()) + } +} + +func TestSnapshotRestore_MissingInput(t *testing.T) { + var stdout, stderr bytes.Buffer + code := RunSnapshotRestore([]string{ + "--state-dir", t.TempDir(), + "--input", "/nonexistent/path/snap.tar.gz", + }, &stdout, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit for missing input file") + } +} diff --git a/internal/cli/help.go b/internal/cli/help.go index 4169889..ffeaea5 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -16,6 +16,7 @@ Commands: status show scheduler state, job history, token budget usage ingest store raw bytes as a hide and optionally enqueue for curing replay replay a captured snapshot or runs directory via the API + snapshot save or restore a point-in-time archive of runtime state version print build version information help print this message From 2f5f6c01f5fed5e53d8da8786516eacca8206826 Mon Sep 17 00:00:00 2001 From: Tyler Pate Date: Fri, 5 Jun 2026 17:27:44 -0700 Subject: [PATCH 2/5] =?UTF-8?q?devtools:=20add=20queue.run=20event=20and?= =?UTF-8?q?=20queue=E2=86=92agent=20causality=20(closes=20#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New PublishQueueRun method on sources.Wiring emits a queue.run event when the scheduler dequeues an item and begins a direct agent run. Payload carries queue name, item ID, hide ID, attempt count, and payload key names (values are never exposed). Each subsequent runner progress event is causally linked to the queue.run event via bus.AppendCause, making the queue→agent lineage traversable in the DevTools trace API. flow.js gains a queue.run branch that draws the queue→agent DAG edge, giving scheduler-driven runs visual parity with curing-driven runs. --- .subagents/AGENTS-SERVE.md | 3 +- CHANGELOG.md | 13 ++ internal/cli/cmd_serve.go | 8 +- internal/devtools/sources/sources.go | 31 +++++ internal/devtools/sources/sources_test.go | 141 ++++++++++++++++++++++ ui/js/devtools/flow.js | 12 ++ 6 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 internal/devtools/sources/sources_test.go diff --git a/.subagents/AGENTS-SERVE.md b/.subagents/AGENTS-SERVE.md index 62e0dfd..999dc2c 100644 --- a/.subagents/AGENTS-SERVE.md +++ b/.subagents/AGENTS-SERVE.md @@ -48,6 +48,7 @@ Each subcommand has: | `test-agent` | `RunTestAgent` | Execute an agent with `MockLLM` and print the turn transcript | | `status` | `RunStatus` | Print scheduler state, job history, token usage | | `ingest` | `RunIngest` | Store raw bytes as a hide and optionally enqueue for curing | +| `snapshot` | `RunSnapshot` → `RunSnapshotSave` / `RunSnapshotRestore` | Save or restore a `tar.gz` point-in-time archive of runtime state | | `version` | `RunVersion` | Print version, commit, Go runtime version | `cli.Run` also accepts `help`, `--help`, and `-h`, which print `usage` @@ -397,4 +398,4 @@ Before opening a PR touching this domain: --- -_Last reviewed: 2026-06-04_ +_Last reviewed: 2026-06-05_ diff --git a/CHANGELOG.md b/CHANGELOG.md index bc16de2..bc38d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,19 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. - **`schema.Violation.Line`** — new `Line int` field (0 = unknown) populated by `ValidateFlat` when line data is available. `leather validate` now emits `schema: file:N: field "…": …` for config/skill/toolset/worker YAML files. +- **`leather snapshot save / restore`** — built-in point-in-time backup and + restore for runtime state (issue #6). `save` archives `queues/`, `runs/`, + and `cache/` (plus tannery `hide_dir/` and `artifact_dir/` when configured) + into a `tar.gz` file, skipping transient files (`leather.lock`, + `devtools.token`). `restore` extracts into the configured state directory + with a non-empty-dir guard (`--force` to override). Both commands verify + that `leather serve` is not running before proceeding. +- **DevTools `queue.run` event** — when the scheduler dequeues an item and + begins a direct agent run, a `queue.run` event is emitted on the DevTools + bus with queue name, item ID, hide ID, attempt count, and payload key names + (values are never exposed). Each subsequent runner event is causally linked + to the `queue.run` event via `AppendCause`, making the queue→agent lineage + visible in the DevTools DAG view (issue #11). ## [0.1.3] - 2026-06-05 diff --git a/internal/cli/cmd_serve.go b/internal/cli/cmd_serve.go index 5f00230..1736d73 100644 --- a/internal/cli/cmd_serve.go +++ b/internal/cli/cmd_serve.go @@ -914,6 +914,7 @@ func RunServe(args []string, stdout, stderr io.Writer, version, commit string) i statCompletion: &statCompletion, onJobDone: onJobDone, devtoolsSrc: devtoolsSrc, + devtoolsBus: devtoolsBus, agentHashes: make(map[string]string), } for _, a := range agents { @@ -1272,6 +1273,7 @@ type agentRegDeps struct { statCompletion *int64 onJobDone func() devtoolsSrc *sources.Wiring + devtoolsBus *bus.Bus // agentHashes tracks the content hash of each currently registered agent's // source files so SIGHUP reload (T2.7) can detect in-place edits. Map is // mutated only from the serve loop's reload handler, which is serial. @@ -1327,9 +1329,13 @@ func registerAgentJob(deps agentRegDeps, a model.Agent) error { } jobRunner, prettyPrinter := configurePrettyRunner(*agentRunner, deps.stdout, deps.cfg) if deps.devtoolsSrc != nil { + queueRunSeq := deps.devtoolsSrc.PublishQueueRun(agentCopy.Name, agentCopy.QueueInput, item) baseProgress := jobRunner.ProgressFn jobRunner.ProgressFn = func(ev runner.ProgressEvent) { - deps.devtoolsSrc.PublishRunner("", agentCopy.Name, ev) + evSeq := deps.devtoolsSrc.PublishRunner("", agentCopy.Name, ev) + if queueRunSeq != 0 && deps.devtoolsBus != nil { + deps.devtoolsBus.AppendCause(queueRunSeq, evSeq) + } if baseProgress != nil { baseProgress(ev) } diff --git a/internal/devtools/sources/sources.go b/internal/devtools/sources/sources.go index cf7b379..3cb29dd 100644 --- a/internal/devtools/sources/sources.go +++ b/internal/devtools/sources/sources.go @@ -3,10 +3,12 @@ package sources import ( "encoding/json" + "sort" "time" "github.com/tgpski/leather/internal/curing" "github.com/tgpski/leather/internal/devtools/bus" + "github.com/tgpski/leather/internal/model" "github.com/tgpski/leather/internal/runner" ) @@ -180,6 +182,35 @@ func (w *Wiring) PublishTannery(ev curing.TanneryEvent) uint64 { })) } +// PublishQueueRun emits a queue.run event when the scheduler dequeues an item +// and begins an agent run. It carries queue context without exposing payload values. +func (w *Wiring) PublishQueueRun(agentName, queueName string, item model.QueueItem) uint64 { + if w == nil || w.bus == nil { + return 0 + } + keys := make([]string, 0, len(item.Payload)) + for k := range item.Payload { + keys = append(keys, k) + } + sort.Strings(keys) + return w.bus.Publish(bus.RedactEvent(bus.Event{ + At: w.now().Unix(), + Kind: "queue.run", + Source: "scheduler", + EntityKind: "queue_item", + EntityID: item.ID, + Payload: toRaw(map[string]any{ + "agent": agentName, + "queue": queueName, + "item_id": item.ID, + "hide_id": item.HideID, + "hide_kind": item.HideKind, + "attempt": item.AttemptCount, + "payload_keys": keys, + }), + })) +} + func toRaw(v any) json.RawMessage { encoded, err := json.Marshal(v) if err != nil { diff --git a/internal/devtools/sources/sources_test.go b/internal/devtools/sources/sources_test.go new file mode 100644 index 0000000..5d1f4e1 --- /dev/null +++ b/internal/devtools/sources/sources_test.go @@ -0,0 +1,141 @@ +package sources + +import ( + "encoding/json" + "testing" + "time" + + "github.com/tgpski/leather/internal/devtools/bus" + "github.com/tgpski/leather/internal/model" +) + +func fixedNow() time.Time { return time.Unix(1000000, 0) } + +func newTestWiring() (*Wiring, *bus.Bus) { + b := bus.New(64) + w := Wire(b, Deps{Now: fixedNow}) + return w, b +} + +func TestPublishQueueRun_EventShape(t *testing.T) { + w, b := newTestWiring() + item := model.QueueItem{ + ID: "item-abc", + HideID: "hide-xyz", + HideKind: "github.pr", + AttemptCount: 2, + Payload: map[string]any{ + "repo": "leather", + "issue": 42, + }, + } + + seq := w.PublishQueueRun("my-agent", "work-queue", item) + if seq == 0 { + t.Fatal("expected non-zero seq") + } + + events := b.Snapshot() + var ev bus.Event + for _, e := range events { + if e.Seq == seq { + ev = e + break + } + } + if ev.Seq == 0 { + t.Fatal("event not found on bus") + } + + if ev.Kind != "queue.run" { + t.Errorf("kind: got %q, want %q", ev.Kind, "queue.run") + } + if ev.Source != "scheduler" { + t.Errorf("source: got %q, want %q", ev.Source, "scheduler") + } + if ev.EntityKind != "queue_item" { + t.Errorf("entity_kind: got %q, want %q", ev.EntityKind, "queue_item") + } + if ev.EntityID != "item-abc" { + t.Errorf("entity_id: got %q, want %q", ev.EntityID, "item-abc") + } + + var p map[string]any + if err := json.Unmarshal(ev.Payload, &p); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + + if p["agent"] != "my-agent" { + t.Errorf("payload.agent: got %v", p["agent"]) + } + if p["queue"] != "work-queue" { + t.Errorf("payload.queue: got %v", p["queue"]) + } + if p["item_id"] != "item-abc" { + t.Errorf("payload.item_id: got %v", p["item_id"]) + } + if p["hide_id"] != "hide-xyz" { + t.Errorf("payload.hide_id: got %v", p["hide_id"]) + } + if p["hide_kind"] != "github.pr" { + t.Errorf("payload.hide_kind: got %v", p["hide_kind"]) + } + // attempt is serialized as float64 by JSON + if p["attempt"] != float64(2) { + t.Errorf("payload.attempt: got %v", p["attempt"]) + } + + // payload_keys must contain key names but NOT values + rawKeys, ok := p["payload_keys"].([]any) + if !ok { + t.Fatalf("payload_keys missing or wrong type: %T", p["payload_keys"]) + } + keys := make(map[string]bool, len(rawKeys)) + for _, k := range rawKeys { + keys[k.(string)] = true + } + if !keys["repo"] || !keys["issue"] { + t.Errorf("payload_keys missing expected keys: %v", rawKeys) + } + + // Payload values must NOT appear anywhere in the serialized payload + raw := string(ev.Payload) + if containsSubstr(raw, "leather") || containsSubstr(raw, "42") { + // "leather" is the repo value, "42" is the issue value + // NOTE: "42" could appear as attempt count (2) but not as issue value + // We only check for "leather" since that's unambiguous + if containsSubstr(raw, `"leather"`) { + t.Errorf("payload value %q found in serialized event payload", "leather") + } + } +} + +func TestPublishQueueRun_NilSafe(t *testing.T) { + var w *Wiring + // Should not panic + seq := w.PublishQueueRun("agent", "queue", model.QueueItem{ID: "x"}) + if seq != 0 { + t.Errorf("expected 0 from nil wiring, got %d", seq) + } +} + +func TestPublishQueueRun_EmptyPayload(t *testing.T) { + w, _ := newTestWiring() + item := model.QueueItem{ID: "empty", Payload: nil} + seq := w.PublishQueueRun("a", "q", item) + if seq == 0 { + t.Fatal("expected non-zero seq") + } +} + +func containsSubstr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + func() bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} diff --git a/ui/js/devtools/flow.js b/ui/js/devtools/flow.js index be814d9..5584ca0 100644 --- a/ui/js/devtools/flow.js +++ b/ui/js/devtools/flow.js @@ -238,6 +238,16 @@ const DtFlow = (() => { } } + // Queue-run: scheduler dequeued an item and began a direct agent run ───── + if (ev.kind === "queue.run") { + const q = p.queue || "queue"; + const qnid = addNode("q:" + q, "queue", queueLabel(q), seq, lane); + if (isEphemeralQueue(q)) ephemeralNodeIds.add(qnid); + const aname = p.agent || ev.entity_id || "agent"; + const anid = addNode("ag:" + aname, "agent", shortLabel(aname, 15), seq, lane); + addEdge(qnid, anid); + } + // Curing lifecycle events ─────────────────────────────────────────────── if (ev.kind === "curing.start" || ev.kind === "curing.complete" || ev.kind === "curing.step") { const c = p.curing || ev.entity_id || "curing"; @@ -810,6 +820,8 @@ const DtFlow = (() => { return "\u2192 " + shortQueueName(p.dest_queue || p.queue || ""); if (k === "queue.dequeue") return "\u2190 " + shortQueueName(p.queue || "") + (p.curing ? " (" + p.curing + ")" : ""); + if (k === "queue.run") + return "\u25b6 " + shortQueueName(p.queue || "") + " \u2192 " + (p.agent || ""); if (k === "agent.run" || k === "agent.response") return (p.agent || "") + (p.response ? ": " + String(p.response).slice(0, 100) : ""); if (k === "tool.call") From 04bb12a91db54affe6f748234ba12f6123653a0b Mon Sep 17 00:00:00 2001 From: Tyler Pate Date: Fri, 5 Jun 2026 19:15:36 -0700 Subject: [PATCH 3/5] =?UTF-8?q?cli:=20add=20leather=20attach=20=E2=80=94?= =?UTF-8?q?=20stream=20DevTools=20events=20from=20a=20running=20serve=20in?= =?UTF-8?q?stance=20(closes=20#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the devtools.token from state-dir, connects to /api/devtools/events (SSE), and pretty-prints each event with color-coded kind labels, entity references, and payload key-value pairs. Supports --filter by event kind or source; reconnects with exponential backoff unless --no-reconnect is set. --- .subagents/AGENTS-SERVE.md | 1 + CHANGELOG.md | 8 + internal/cli/cli.go | 2 + internal/cli/cmd_attach.go | 300 +++++++++++++++++++++++++++++++++++++ internal/cli/help.go | 1 + 5 files changed, 312 insertions(+) create mode 100644 internal/cli/cmd_attach.go diff --git a/.subagents/AGENTS-SERVE.md b/.subagents/AGENTS-SERVE.md index 999dc2c..15caa33 100644 --- a/.subagents/AGENTS-SERVE.md +++ b/.subagents/AGENTS-SERVE.md @@ -49,6 +49,7 @@ Each subcommand has: | `status` | `RunStatus` | Print scheduler state, job history, token usage | | `ingest` | `RunIngest` | Store raw bytes as a hide and optionally enqueue for curing | | `snapshot` | `RunSnapshot` → `RunSnapshotSave` / `RunSnapshotRestore` | Save or restore a `tar.gz` point-in-time archive of runtime state | +| `attach` | `RunAttach` | Join a running `serve` instance and stream pretty-printed DevTools events | | `version` | `RunVersion` | Print version, commit, Go runtime version | `cli.Run` also accepts `help`, `--help`, and `-h`, which print `usage` diff --git a/CHANGELOG.md b/CHANGELOG.md index bc38d57..693e438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,14 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. (values are never exposed). Each subsequent runner event is causally linked to the `queue.run` event via `AppendCause`, making the queue→agent lineage visible in the DevTools DAG view (issue #11). +- **`leather attach`** — new subcommand that joins a running `serve` instance + and streams pretty-printed DevTools events to the terminal (issue #19). + Reads the DevTools token from the state directory, connects to the + `/api/devtools/events` SSE endpoint, and renders each event with + color-coded kind labels, entity references, and payload key-value pairs. + Supports `--filter` to scope output by event kind or source, and + `--no-reconnect` to exit on stream close instead of reconnecting with + exponential backoff. ## [0.1.3] - 2026-06-05 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index cbfb2e6..f64f9fc 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -46,6 +46,8 @@ func Run(args []string, stdout, stderr io.Writer, version, commit string) int { return RunReplay(rest, stdout, stderr, version, commit) case "snapshot": return RunSnapshot(rest, stdout, stderr) + case "attach": + return RunAttach(rest, stdout, stderr) case "help", "--help", "-h": fmt.Fprint(stdout, usage) return 0 diff --git a/internal/cli/cmd_attach.go b/internal/cli/cmd_attach.go new file mode 100644 index 0000000..af0ac40 --- /dev/null +++ b/internal/cli/cmd_attach.go @@ -0,0 +1,300 @@ +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tgpski/leather/internal/config" +) + +// RunAttach connects to a running serve instance and streams pretty-printed +// DevTools events to stdout. +func RunAttach(args []string, stdout, stderr io.Writer) int { + fs := newFlagSet("attach", stderr) + config.BindFlags(fs) + filter := fs.String("filter", "", "comma-separated event kinds or sources to include") + noReconnect := fs.Bool("no-reconnect", false, "exit instead of reconnecting on stream close") + if !parseFlags(fs, args) { + return 2 + } + + cfg, err := config.Load(fs) + if err != nil { + fmt.Fprintf(stderr, "leather attach: %v\n", err) + return 1 + } + + // Read token from state directory. + token, err := readDevtoolsToken(cfg.StateDir) + if err != nil { + fmt.Fprintf(stderr, "leather attach: cannot read devtools token: %v\n"+ + " Is leather serve running? Token file expected at: %s\n", + err, filepath.Join(cfg.StateDir, "devtools.token")) + return 1 + } + + // Build filter set. + var filterSet map[string]bool + if *filter != "" { + filterSet = make(map[string]bool) + for _, f := range strings.Split(*filter, ",") { + f = strings.TrimSpace(f) + if f != "" { + filterSet[f] = true + } + } + } + + baseURL := "http://" + cfg.APIAddr + "/api/devtools/events" + + fmt.Fprintf(stdout, "%s connecting to %s\n", + dim(time.Now().Format("15:04:05")), + bold(cfg.APIAddr)) + + var lastEventID uint64 + backoff := 1 * time.Second + const maxBackoff = 30 * time.Second + + for { + err := streamEvents(stdout, stderr, baseURL, token, lastEventID, filterSet, func(seq uint64) { + lastEventID = seq + }) + if err == nil || *noReconnect { + return 0 + } + // Serve closed the stream or we got a connection error. Reconnect with backoff. + fmt.Fprintf(stdout, "%s %s reconnecting in %s...\n", + dim(time.Now().Format("15:04:05")), + yellow("●"), + backoff) + time.Sleep(backoff) + // Jitter + exponential backoff. + backoff = time.Duration(float64(backoff)*1.5) + time.Duration(rand.Intn(500))*time.Millisecond + if backoff > maxBackoff { + backoff = maxBackoff + } + } +} + +// streamEvents opens a single SSE connection and feeds events to stdout until +// the stream closes. It calls onSeq for each event received so the caller can +// resume from the right position on reconnect. +func streamEvents(stdout, stderr io.Writer, baseURL, token string, fromSeq uint64, filterSet map[string]bool, onSeq func(uint64)) error { + url := baseURL + if fromSeq > 0 { + url += fmt.Sprintf("?from=%d", fromSeq) + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + fmt.Fprintf(stderr, "leather attach: build request: %v\n", err) + return err + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+token) + if fromSeq > 0 { + req.Header.Set("Last-Event-ID", fmt.Sprintf("%d", fromSeq)) + } + + client := &http.Client{Timeout: 0} // no timeout — SSE is long-lived + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(stderr, "leather attach: connect: %v\n", err) + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + fmt.Fprintf(stderr, "leather attach: authentication failed (token mismatch)\n") + return fmt.Errorf("unauthorized") + } + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(stderr, "leather attach: server returned %s\n", resp.Status) + return fmt.Errorf("bad status: %s", resp.Status) + } + + fmt.Fprintf(stdout, "%s %s stream connected\n", + dim(time.Now().Format("15:04:05")), + green("●")) + + return parseSSEStream(stdout, resp.Body, filterSet, onSeq) +} + +// parseSSEStream reads an SSE stream line by line and pretty-prints each event. +func parseSSEStream(stdout io.Writer, body io.Reader, filterSet map[string]bool, onSeq func(uint64)) error { + scanner := bufio.NewScanner(body) + var ( + eventType string + dataLines []string + lastID string + ) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + // Blank line — dispatch accumulated event. + if eventType != "" && len(dataLines) > 0 { + data := strings.Join(dataLines, "\n") + printAttachEvent(stdout, eventType, lastID, data, filterSet) + if lastID != "" { + var seq uint64 + if _, scanErr := fmt.Sscanf(lastID, "%d", &seq); scanErr == nil && seq > 0 { + onSeq(seq) + } + } + } + eventType = "" + dataLines = nil + } else if strings.HasPrefix(line, "id:") { + lastID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) + } else if strings.HasPrefix(line, "event:") { + eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + } else if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + // Lines beginning with ":" are SSE comments (heartbeat ping) — ignore. + } + if err := scanner.Err(); err != nil { + return err + } + return nil // EOF — stream closed cleanly +} + +// printAttachEvent formats a single DevTools SSE event for terminal output. +func printAttachEvent(stdout io.Writer, eventType, id, data string, filterSet map[string]bool) { + // Parse the JSON payload to extract structured fields. + var ev struct { + Seq uint64 `json:"seq"` + At int64 `json:"at"` + Kind string `json:"kind"` + Source string `json:"source"` + EntityKind string `json:"entity_kind"` + EntityID string `json:"entity_id"` + Payload json.RawMessage `json:"payload"` + Err string `json:"err"` + } + if err := json.Unmarshal([]byte(data), &ev); err != nil { + // Not a parseable event (e.g. gap or heartbeat) — skip. + return + } + + kind := ev.Kind + if kind == "" { + kind = eventType + } + + // Apply filter. + if filterSet != nil && !filterSet[kind] && !filterSet[ev.Source] { + return + } + + // Format timestamp. + ts := dim(time.Unix(ev.At, 0).Format("15:04:05")) + if ev.At == 0 { + ts = dim(time.Now().Format("15:04:05")) + } + + // Format kind label with color coding. + label := attachFormatKind(kind) + + // Format entity reference. + entity := "" + if ev.EntityID != "" { + entity = " " + dim(ev.EntityKind+"/"+attachShortID(ev.EntityID)) + } + + // Format payload fields as key=value pairs (redacted values are already safe). + kvs := attachFormatPayload(ev.Payload) + + // Format error. + errStr := "" + if ev.Err != "" { + errStr = " " + boldRed("err="+ev.Err) + } + + fmt.Fprintf(stdout, "%s %s%s%s%s\n", ts, label, entity, kvs, errStr) +} + +// attachFormatKind returns a color-coded label for the event kind. +func attachFormatKind(kind string) string { + switch { + case strings.HasPrefix(kind, "agent."): + return boldCyan(fmt.Sprintf("%-22s", kind)) + case strings.HasPrefix(kind, "queue."): + return bold(fmt.Sprintf("%-22s", kind)) + case strings.HasPrefix(kind, "schedule."): + return cyan(fmt.Sprintf("%-22s", kind)) + case kind == "error": + return boldRed(fmt.Sprintf("%-22s", kind)) + default: + return dim(fmt.Sprintf("%-22s", kind)) + } +} + +// attachFormatPayload extracts safe key=value pairs from a payload blob. +func attachFormatPayload(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + + // Emit a curated subset of well-known fields in a stable order. + order := []string{"agent", "queue", "hide_id", "hide_kind", "attempt", "progress_kind", "message", "payload_keys"} + var parts []string + for _, k := range order { + v, ok := m[k] + if !ok || v == nil { + continue + } + switch val := v.(type) { + case string: + if val != "" { + parts = append(parts, dim(k)+"="+val) + } + case float64: + parts = append(parts, fmt.Sprintf("%s=%v", dim(k), int(val))) + case []any: + strs := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok { + strs = append(strs, s) + } + } + if len(strs) > 0 { + parts = append(parts, dim(k)+"=["+strings.Join(strs, ",")+"]") + } + } + } + if len(parts) == 0 { + return "" + } + return " " + strings.Join(parts, " ") +} + +// attachShortID returns the last 8 chars of an ID for compact display. +func attachShortID(id string) string { + if len(id) <= 8 { + return id + } + return id[len(id)-8:] +} + +// readDevtoolsToken reads the token written by leather serve. +func readDevtoolsToken(stateDir string) (string, error) { + path := filepath.Join(stateDir, "devtools.token") + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} diff --git a/internal/cli/help.go b/internal/cli/help.go index ffeaea5..8d8a26e 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -17,6 +17,7 @@ Commands: ingest store raw bytes as a hide and optionally enqueue for curing replay replay a captured snapshot or runs directory via the API snapshot save or restore a point-in-time archive of runtime state + attach join a running serve instance and stream pretty-printed runtime logs version print build version information help print this message From 76c0af69546bc9a8c0ff99362956e128d29b9732 Mon Sep 17 00:00:00 2001 From: Tyler Pate Date: Fri, 5 Jun 2026 19:21:48 -0700 Subject: [PATCH 4/5] chore(release): prepare v0.2.0 "weathered" Promote [Unreleased] to [0.2.0] in CHANGELOG, add comparison link. Add leather snapshot and leather attach to all command tables (README, GUIDE.md appendix, docs/modules/cli.md). Update v0.1 version refs in OPERATIONS.md to v0.2; remove snapshot from the "not in v0.1" deferred list since it shipped in this release. --- CHANGELOG.md | 5 ++++- README.md | 2 ++ docs/GUIDE.md | 2 ++ docs/OPERATIONS.md | 10 ++++------ docs/modules/cli.md | 4 ++++ 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 693e438..7b363a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [0.2.0] — 2026-06-05 "weathered" + ### Added - **Shared stdlib leaf utilities** (`internal/fileutil`, `internal/jsonstore`, @@ -351,7 +353,8 @@ Intentionally out of scope for v0.1.0; tracked for v0.2: See [ROADMAP.md](ROADMAP.md) for the full deferred-item list with rationales and proposed shapes. -[Unreleased]: https://github.com/tgpski/leather/compare/v0.1.3...HEAD +[Unreleased]: https://github.com/tgpski/leather/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/tgpski/leather/compare/v0.1.3...v0.2.0 [0.1.3]: https://github.com/tgpski/leather/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/tgpski/leather/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/tgpski/leather/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index e6cecf5..9a27416 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,8 @@ make example-01 # runs a mock-LLM example end-to-end | `leather validate` | Validate config, agents, lifecycles, skills, workers, and MCP servers. | | `leather test-agent` | Run an agent against `MockLLM` and print the transcript. | | `leather status` | Print scheduler state and current token‑budget settings. | +| `leather snapshot` | Save or restore a point-in-time `tar.gz` archive of runtime state. | +| `leather attach` | Join a running `serve` instance and stream pretty-printed runtime events. | | `leather replay` | Replay a snapshot or live session. | | `leather version` / `leather help` | The obvious. | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 4128bfe..a3ea2e6 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1187,5 +1187,7 @@ my-project/ | `leather ingest` | Write a file as a hide and optionally enqueue it. | | `leather status` | Print job history, token usage, scheduler state. | | `leather test-agent` | Run an agent against `MockLLM` and print the transcript. | +| `leather snapshot` | Save or restore a point-in-time `tar.gz` archive of runtime state. | +| `leather attach` | Join a running `serve` instance and stream pretty-printed runtime events. | | `leather replay` | Replay a snapshot or live session. | | `leather version` / `leather help` | The obvious. | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 3ba8e19..c174a9f 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -7,7 +7,7 @@ discipline, queue and dead-letter recovery, DevTools authentication, upgrades, and troubleshooting. If you are looking for *how to write an agent*, see [README.md](../README.md) -and [GUIDE.md](GUIDE.md). For trust boundaries and the v0.1 security +and [GUIDE.md](GUIDE.md). For trust boundaries and the v0.2 security posture, see [SECURITY.md](../SECURITY.md). For architecture context, see [ARCHITECTURE.md](ARCHITECTURE.md). For the day-2 ops roadmap, see [ROADMAP.md](../ROADMAP.md). @@ -149,7 +149,7 @@ Returns a JSON object — **not** Prometheus exposition format. The shape is: ``` If you need Prometheus, scrape this endpoint and adapt it externally. A -native Prometheus exporter is not in v0.1. +native Prometheus exporter is not in v0.2. ### `GET /cache/stats` @@ -359,7 +359,7 @@ in. ## Upgrading -leather is a single static binary with no runtime data migrations in v0.1. +leather is a single static binary with no runtime data migrations in v0.2. 1. Stop the service: `systemctl stop leather` (releases `leather.lock`). 2. Replace the binary: `install -m 0755 leather /usr/local/bin/leather`. @@ -393,13 +393,11 @@ For deeper debugging, `--log-level debug` increases verbosity across the runtime. Logs identify components and agent names but never include token content, API keys, or hide payloads. -## What is NOT in v0.1 +## What is NOT in v0.2 The following commonly-requested operational features are explicitly deferred. See [ROADMAP.md](../ROADMAP.md) for the full deferral list. -- **`leather snapshot save/restore`** — built-in backup tooling. - Use the stop-then-tar procedure documented above. - **Prometheus exposition** — `/metrics` returns JSON. Adapt externally. - **Hot config reload** — `SIGHUP` reloads the worker supervisor but not every config field. For substantive changes, restart the process. diff --git a/docs/modules/cli.md b/docs/modules/cli.md index 4b582aa..710510f 100644 --- a/docs/modules/cli.md +++ b/docs/modules/cli.md @@ -25,6 +25,10 @@ HTTP API and replay endpoints, and provides testable command handlers for | `RunValidate` | `func RunValidate(args []string, stdout, stderr io.Writer) int` | Validate config, agents, lifecycles, skills, workers, and MCP server definitions. | | `RunTestAgent` | `func RunTestAgent(args []string, stdout, stderr io.Writer) int` | Run an agent with `MockLLM` and optional fake tool responses. | | `RunStatus` | `func RunStatus(args []string, stdout, stderr io.Writer) int` | Print current config summary and persisted scheduler state. | +| `RunIngest` | `func RunIngest(args []string, stdout, stderr io.Writer) int` | Store raw bytes as a hide and optionally enqueue for curing. | +| `RunReplay` | `func RunReplay(args []string, stdout, stderr io.Writer, version, commit string) int` | Replay a captured snapshot or runs directory via the API. | +| `RunSnapshot` | `func RunSnapshot(args []string, stdout, stderr io.Writer) int` | Save or restore a point-in-time `tar.gz` archive of runtime state. | +| `RunAttach` | `func RunAttach(args []string, stdout, stderr io.Writer) int` | Join a running `serve` instance and stream pretty-printed DevTools events. | | `RunVersion` | `func RunVersion(_ []string, stdout, _ io.Writer, version, commit string) int` | Print build metadata. | ## Internal Design From c8e9529938e3d8890e743be795b79844eb5d11fc Mon Sep 17 00:00:00 2001 From: Tyler Pate Date: Fri, 5 Jun 2026 19:23:46 -0700 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20release-prep=20skill=20=E2=80=94?= =?UTF-8?q?=20commit=20to=20current=20branch,=20open=20PR=20instead=20of?= =?UTF-8?q?=20pushing=20to=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/release-prep/SKILL.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/skills/release-prep/SKILL.md b/.agents/skills/release-prep/SKILL.md index e28ebb7..945c002 100644 --- a/.agents/skills/release-prep/SKILL.md +++ b/.agents/skills/release-prep/SKILL.md @@ -82,12 +82,21 @@ If any row is missing, add it before committing. ## Step 5 — Commit and push +Stay on the **current branch** — do not switch to or push directly to `main`. Stage all changed files and create one commit: ``` +CURRENT_BRANCH=$(git branch --show-current) git add CHANGELOG.md README.md docs/ .subagents/ git commit -m "chore(release): prepare NEXT_VERSION" -git push origin main +git push origin "$CURRENT_BRANCH" +``` + +If the current branch already has an open PR, the commit is added to it +automatically. If not, open a new PR targeting `main`: + +``` +gh pr create --title "chore(release): prepare NEXT_VERSION" --body "..." ``` Do not tag in this step. Tagging is the job of `leather-release-tag`. @@ -100,5 +109,6 @@ Do not tag in this step. Tagging is the job of `leather-release-tag`. - [ ] CHANGELOG has the new section with at least one bullet - [ ] No stale version string remains in docs (grep clean) - [ ] Subcommand tables are in sync -- [ ] Commit is on origin/main +- [ ] Commit is pushed to current branch (not directly to main) +- [ ] PR is open targeting main (create one if it doesn't exist) - [ ] Working tree is clean (`git status` shows nothing)