Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8412c4d
fix(daemon/remote): publish bundle extractions by swap, not destroy-t…
beardthelion Aug 28, 2026
efa6adc
fix(daemon/remote): refuse link ids that can name a staging directory
beardthelion Aug 28, 2026
cce928e
fix(daemon/remote): survive an interrupted extract and a second daemon
beardthelion Aug 28, 2026
6936d99
fix(dictation): keep the previous engine install if promotion fails
beardthelion Aug 28, 2026
f0fce95
fix(daemon/remote): do not let startup recovery take a live extract's…
beardthelion Aug 28, 2026
af745d0
fix(daemon/remote): stop recovery from destroying the tree it should …
beardthelion Aug 28, 2026
db658d7
fix(dictation): restore an install left aside by an interrupted promo…
beardthelion Aug 28, 2026
23da589
fix(dictation): restore an interrupted model promotion, and the newes…
beardthelion Aug 30, 2026
1179915
fix(daemon/remote): order bundle recovery by the extract, not by mtimes
beardthelion Aug 30, 2026
46d12d8
fix(dictation): find holders by prefix rather than by glob pattern
beardthelion Aug 30, 2026
572dc1f
test(daemon/remote): cover bundle recovery end to end
beardthelion Aug 30, 2026
3974d58
test(dictation): keep the awkward-path names to ones Windows can hold
beardthelion Sep 1, 2026
61f5816
fix(daemon/remote): keep a retained backup across the next recovery pass
beardthelion Sep 1, 2026
2aac6e4
test(dictation): cover a stamped holder winning over an unstamped one
beardthelion Sep 1, 2026
aa68c04
fix(daemon/remote,dictation): order crash recovery by a sequence, not…
beardthelion Sep 1, 2026
1580a59
fix(daemon/remote): read only the names the staging allocator owns
beardthelion Sep 1, 2026
e17255e
fix(dictation,daemon/remote): stop a failed cleanup from stranding an…
beardthelion Sep 1, 2026
b57cacb
fix(dictation): reap a holder only for a destination that is actually…
beardthelion Sep 1, 2026
2f41eed
test: name the mode the allocator sets, not the one MkdirTemp used to
beardthelion Sep 1, 2026
6d65b71
test(daemon/remote,dictation): route every filesystem step through on…
beardthelion Sep 3, 2026
efc472e
fix(daemon/remote): prove which transaction owns a staged backup, and…
beardthelion Sep 3, 2026
60351fc
fix(dictation): hold one lock across a destination's whole install li…
beardthelion Sep 3, 2026
3ad135b
fix(daemon/remote): let the allocator read only the names it wrote
beardthelion Sep 3, 2026
064b4e1
fix(dictation): attribute a holder by its marker, not by its name
beardthelion Sep 3, 2026
f21290f
fix(daemon/remote): decide recovery from proof on disk, not from what…
beardthelion Sep 3, 2026
9af200c
fix(dictation): apply the caller's own usability test to every candidate
beardthelion Sep 3, 2026
062c88d
test(daemon/remote,dictation): run the crash states as a table
beardthelion Sep 3, 2026
0750b3f
feat(cli): let an operator see and reclaim the copies recovery keeps
beardthelion Sep 3, 2026
053857e
test(daemon/remote,dictation): fail every recovery step, on either pa…
beardthelion Sep 3, 2026
dbbdba4
fix(daemon/remote): treat an unreadable work-tree probe as a work tree
beardthelion Sep 3, 2026
d45d828
fix(daemon/remote,dictation,cli): close the gaps a full review of the…
beardthelion Sep 3, 2026
90503c7
test(daemon/remote,dictation,cli): make the guards a mutation run wal…
beardthelion Sep 3, 2026
29fe455
fix(daemon/remote): bound the wait for an extract already running in …
beardthelion Sep 3, 2026
942710a
fix(daemon/remote,dictation,cli): unwrap not-exist, refuse aliasing l…
beardthelion Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runCron(args[1:], stdout, stderr, deps)
case "repo-info", "repoinfo":
return runRepoInfo(args[1:], stdout, stderr, deps)
case "kept-backups":
return runKeptBackups(args[1:], stdout, stderr, deps)
case "serve":
return runServe(args[1:], stdout, stderr, deps)
case "acp":
Expand Down Expand Up @@ -1368,6 +1370,7 @@ Commands:
usage Summarize token usage and estimated cost
cron Schedule agent jobs (foreground, file-backed)
repo-info Characterize the current repository (local git only)
kept-backups List and remove copies a recovery pass retained
serve Run Zero protocol servers
acp Serve the Agent Client Protocol over stdio (editor backend)
help Show this help
Expand Down
1 change: 1 addition & 0 deletions internal/cli/completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ var completionRoot = completionNode{
{names: []string{"usage"}, children: leafNodes("report")},
{names: []string{"cron"}, children: leafNodes("add", "list", "rm", "pause", "resume", "run")},
{names: []string{"repo-info", "repoinfo"}},
{names: []string{"kept-backups"}, children: leafNodes("list", "remove")},
{names: []string{"serve"}},
{names: []string{"acp"}},
{names: []string{"help"}},
Expand Down
227 changes: 227 additions & 0 deletions internal/cli/kept_backups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package cli

import (
"errors"
"fmt"
"io"
"io/fs"
"path/filepath"

"github.com/Gitlawb/zero/internal/daemon/remote"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/redaction"
)

// runKeptBackups implements `zero kept-backups`, the only way a copy recovery
// retained ever leaves the disk. Recovery moves a copy it will not restore and
// cannot prove superseded under a Kept prefix its own scan never enumerates, and
// nothing reclaims one on its own, so without this command retention is one-way.
//
// zero kept-backups list [--bundle-dir <dir>] list what is retained
// zero kept-backups remove <name> [--bundle-dir <dir>] remove one by name
func runKeptBackups(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
rest, bundleDir, err := splitBundleDirFlag(args)
if err != nil {
if _, werr := fmt.Fprintf(stderr, "zero kept-backups: %s\n\n", err); werr != nil {
return exitCrash
}
writeKeptBackupsUsage(stderr)
return exitUsage
}
if len(rest) == 0 {
writeKeptBackupsUsage(stderr)
return exitUsage
}
switch rest[0] {
case "list", "ls":
return keptBackupsList(rest[1:], bundleDir, stdout, stderr, deps)
case "remove", "rm":
return keptBackupsRemove(rest[1:], bundleDir, stdout, stderr, deps)
case "-h", "--help", "help":
// Explicit help is a success path, matching the other subcommands: usage
// to stdout, exit 0. Only the error paths below write it to stderr.
writeKeptBackupsUsage(stdout)
return exitSuccess
default:
if _, err := fmt.Fprintf(stderr, "zero kept-backups: unknown subcommand %q\n\n", rest[0]); err != nil {
return exitCrash
}
writeKeptBackupsUsage(stderr)
return exitUsage
}
}

// splitBundleDirFlag pulls --bundle-dir out of the argument list wherever it
// appears, so it can sit before or after the subcommand and its name argument.
// The daemon has no config key for the bundle dir; it is a serve-remote flag, so
// the operator has to be able to name the same directory here.
func splitBundleDirFlag(args []string) (rest []string, bundleDir string, err error) {
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--bundle-dir":
if i+1 >= len(args) {
return nil, "", errors.New("--bundle-dir needs a directory")
}
bundleDir = args[i+1]
i++
case len(arg) > len("--bundle-dir=") && arg[:len("--bundle-dir=")] == "--bundle-dir=":
bundleDir = arg[len("--bundle-dir="):]
default:
rest = append(rest, arg)
}
}
return rest, bundleDir, nil
}

// sttKeptRoot is where the dictation installs live: the same tree the rest of the
// TUI downloads into, derived from userConfigPath rather than the default config
// dir so an overridden config root does not leave this command reading a
// directory nothing writes to.
func sttKeptRoot(deps appDeps) (string, error) {
path, err := deps.userConfigPath()
if err != nil {
return "", err
}
if path == "" {
return "", errors.New("no user config path, so the dictation install root cannot be resolved")
}
return filepath.Join(filepath.Dir(path), "stt"), nil
}

func keptBackupsList(args []string, bundleDir string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) > 0 {
if _, err := fmt.Fprintf(stderr, "zero kept-backups list: unexpected argument %q\n", args[0]); err != nil {
return exitCrash
}
return exitUsage
}
root, err := sttKeptRoot(deps)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
sttBackups, err := dictation.ListKeptBackups(root)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
found, err := writeKeptBackups(stdout, "stt", sttKeptLines(sttBackups))
if err != nil {
return exitCrash
}
if bundleDir != "" {
bundleBackups, err := remote.ListKeptBackups(bundleDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
bundleFound, err := writeKeptBackups(stdout, "bundle", bundleKeptLines(bundleBackups))
if err != nil {
return exitCrash
}
found = bundleFound || found
}
if !found {
if _, err := fmt.Fprintln(stdout, "No kept backups."); err != nil {
return exitCrash
}
}
return exitSuccess
}

// keptBackupLine is one retained copy as this command prints it. The two sites
// return their own KeptBackup types, and flattening them here is what keeps the
// output one format rather than two that drift.
type keptBackupLine struct {
name string
dest string
seq int64
bytes int64
owned bool
}

func sttKeptLines(backups []dictation.KeptBackup) []keptBackupLine {
lines := make([]keptBackupLine, 0, len(backups))
for _, b := range backups {
lines = append(lines, keptBackupLine{name: filepath.Base(b.Path), dest: b.Dest, seq: b.Seq, bytes: b.Bytes, owned: b.Owned})
}
return lines
}

func bundleKeptLines(backups []remote.KeptBackup) []keptBackupLine {
lines := make([]keptBackupLine, 0, len(backups))
for _, b := range backups {
lines = append(lines, keptBackupLine{name: filepath.Base(b.Path), dest: b.Dest, seq: b.Seq, bytes: b.Bytes, owned: b.Owned})
}
return lines
}

// writeKeptBackups prints one line per retained copy. The name comes first after
// the site because it is exactly what `remove` takes; the destination is the
// install or link the copy was set aside for, and an entry nothing on disk
// attributes says so instead of borrowing a destination from its own name.
func writeKeptBackups(stdout io.Writer, site string, lines []keptBackupLine) (bool, error) {
for _, line := range lines {
dest := line.dest
if dest == "" {
dest = "-"
}
suffix := ""
if !line.owned {
suffix = " unowned"
}
if _, err := fmt.Fprintf(stdout, "%s %s dest=%s seq=%d bytes=%d%s\n", site, line.name, dest, line.seq, line.bytes, suffix); err != nil {
// A listing cut short is not a listing. Reporting success here tells
// an operator they have seen every retained copy when they have not,
// and this command is the only place those copies are visible.
return false, err
}
}
return len(lines) > 0, nil
}

func keptBackupsRemove(args []string, bundleDir string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) != 1 {
if _, err := fmt.Fprintln(stderr, "usage: zero kept-backups remove <name> [--bundle-dir <dir>]"); err != nil {
return exitCrash
}
return exitUsage
}
name := args[0]
root := bundleDir
remove := func() error { return remote.RemoveKeptBackup(bundleDir, name) }
if bundleDir == "" {
sttRoot, err := sttKeptRoot(deps)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
root = sttRoot
remove = func() error { return dictation.RemoveKeptBackup(sttRoot, name) }
}
if err := remove(); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Removed %s from %s\n", name, root); err != nil {
return exitCrash
}
return exitSuccess
}

func writeKeptBackupsUsage(w io.Writer) {
_, _ = fmt.Fprint(w, `Usage:
zero kept-backups list [--bundle-dir <dir>] List retained copies
zero kept-backups remove <name> [--bundle-dir <dir>] Remove one by name

Recovery never deletes a copy it cannot prove was superseded; it moves that copy
under a kept- name and leaves it there. Nothing reclaims one on its own, so this
command is how retained copies leave the disk.

Without --bundle-dir both subcommands work on the dictation install root. With
it, remove works on that daemon bundle dir instead, and list adds the bundle dir
to the dictation listing, so each line names the site it came from. Weigh the two differently: a
dictation kept backup is the only offline copy of an engine or a model, while a
bundle kept backup is a work tree the client that sent it can upload again.

Entries marked unowned carry the kept- name with nothing on disk attributing
them. They are reported so they can be found, and remove refuses them; check
what they hold and remove those by hand.
`)
}
Loading
Loading