Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2481a2b
fix(daemon/remote): publish bundle extractions by swap, not destroy-t…
beardthelion Aug 28, 2026
3975806
fix(daemon/remote): refuse link ids that can name a staging directory
beardthelion Aug 28, 2026
07ba489
fix(daemon/remote): survive an interrupted extract and a second daemon
beardthelion Aug 28, 2026
1889963
fix(dictation): keep the previous engine install if promotion fails
beardthelion Aug 28, 2026
9ef9eee
fix(daemon/remote): do not let startup recovery take a live extract's…
beardthelion Aug 28, 2026
101030f
fix(daemon/remote): stop recovery from destroying the tree it should …
beardthelion Aug 28, 2026
2c835b2
fix(dictation): restore an install left aside by an interrupted promo…
beardthelion Aug 28, 2026
a821800
fix(dictation): restore an interrupted model promotion, and the newes…
beardthelion Aug 30, 2026
4726e3a
fix(daemon/remote): order bundle recovery by the extract, not by mtimes
beardthelion Aug 30, 2026
d0dcbc2
fix(dictation): find holders by prefix rather than by glob pattern
beardthelion Aug 30, 2026
f1ff276
test(daemon/remote): cover bundle recovery end to end
beardthelion Aug 30, 2026
85400b1
test(dictation): keep the awkward-path names to ones Windows can hold
beardthelion Sep 1, 2026
f2a8df9
fix(daemon/remote): keep a retained backup across the next recovery pass
beardthelion Sep 1, 2026
7d526e7
test(dictation): cover a stamped holder winning over an unstamped one
beardthelion Sep 1, 2026
4cf33bf
fix(daemon/remote,dictation): order crash recovery by a sequence, not…
beardthelion Sep 1, 2026
9fd085f
fix(daemon/remote): read only the names the staging allocator owns
beardthelion Sep 1, 2026
589a3b5
fix(dictation,daemon/remote): stop a failed cleanup from stranding an…
beardthelion Sep 1, 2026
7e862a9
fix(dictation): reap a holder only for a destination that is actually…
beardthelion Sep 1, 2026
9cc278c
test: name the mode the allocator sets, not the one MkdirTemp used to
beardthelion Sep 1, 2026
0d1662a
test(daemon/remote,dictation): route every filesystem step through on…
beardthelion Sep 3, 2026
75dbb76
fix(daemon/remote): prove which transaction owns a staged backup, and…
beardthelion Sep 3, 2026
fb9924b
fix(dictation): hold one lock across a destination's whole install li…
beardthelion Sep 3, 2026
0488a99
fix(daemon/remote): let the allocator read only the names it wrote
beardthelion Sep 3, 2026
ba34710
fix(dictation): attribute a holder by its marker, not by its name
beardthelion Sep 3, 2026
6050a4b
fix(daemon/remote): decide recovery from proof on disk, not from what…
beardthelion Sep 3, 2026
ca2a54f
fix(dictation): apply the caller's own usability test to every candidate
beardthelion Sep 3, 2026
a488f23
test(daemon/remote,dictation): run the crash states as a table
beardthelion Sep 3, 2026
d3cede4
feat(cli): let an operator see and reclaim the copies recovery keeps
beardthelion Sep 3, 2026
f2a7a42
test(daemon/remote,dictation): fail every recovery step, on either pa…
beardthelion Sep 3, 2026
59f9ae8
fix(daemon/remote): treat an unreadable work-tree probe as a work tree
beardthelion Sep 3, 2026
fe7bd44
fix(daemon/remote,dictation,cli): close the gaps a full review of the…
beardthelion Sep 3, 2026
b17ec19
test(daemon/remote,dictation,cli): make the guards a mutation run wal…
beardthelion Sep 3, 2026
b38346a
fix(daemon/remote): bound the wait for an extract already running in …
beardthelion Sep 3, 2026
2618dde
fix(daemon/remote,dictation,cli): unwrap not-exist, refuse aliasing l…
beardthelion Sep 3, 2026
c5a1448
fix(daemon/remote): group an unprobeable candidate and keep release f…
beardthelion Sep 10, 2026
d863b55
fix(dictation): make discovery, usability, and the engine name carry …
beardthelion Sep 10, 2026
f4cbe7b
fix(daemon/remote,dictation,cli,tui): close what an adversarial pass …
beardthelion Sep 10, 2026
9c98bf9
fix(dictation): propagate an unlistable install root out of the engin…
beardthelion Sep 11, 2026
7061428
fix(dictation): bound the install tag by the widest sibling name (#993)
beardthelion Sep 11, 2026
06bb09a
test(dictation): skip the compat-link case where symlinks cannot be m…
beardthelion Sep 11, 2026
86ce343
fix(dictation): route the badge's legacy lookup through compatEngineD…
beardthelion Sep 11, 2026
f2e3159
test(dictation): plant the host's binary names in the badge probe cas…
beardthelion Sep 11, 2026
e391305
Merge remote-tracking branch 'upstream/main' into fix/remote-bundle-e…
beardthelion Oct 16, 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 @@ -490,6 +490,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 @@ -1382,6 +1384,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 @@ -101,6 +101,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
250 changes: 250 additions & 0 deletions internal/cli/kept_backups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
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) }
}
code, warning := keptRemovalOutcome(remove())
if code != exitSuccess {
return writeAppError(stderr, warning, code)
}
if _, err := fmt.Fprintf(stdout, "Removed %s from %s\n", name, root); err != nil {
return exitCrash
}
if warning != "" {
if _, err := fmt.Fprintln(stderr, "zero kept-backups: "+warning); err != nil {
return exitCrash
}
}
return exitSuccess
}

// keptRemovalOutcome decides what a removal's error means for the operator. A
// lock that could not be released after the copy was already gone is a cleanup
// failure, not a removal that did not happen: reporting it as a crash sends the
// operator to retry a name that no longer exists, and the retry then says
// nothing attributes it, which reads as a different fault entirely. The copy is
// gone either way, so the command succeeds and says what else went wrong.
func keptRemovalOutcome(err error) (code int, warning string) {
switch {
case err == nil:
return exitSuccess, ""
case errors.Is(err, dictation.ErrInstalledNotReleased), errors.Is(err, remote.ErrCommittedNotReleased):
return exitSuccess, redaction.ErrorMessage(err, redaction.Options{})
default:
return exitCrash, redaction.ErrorMessage(err, redaction.Options{})
}
}

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