From 65d636914fb0447e3685326633303856d262637a Mon Sep 17 00:00:00 2001 From: Daniel Netzer Date: Wed, 4 Nov 2026 09:55:41 +0200 Subject: [PATCH] loop --watch: print only what changed after the first pass (#376) --- docs/install.md | 3 +- lab/internal/cli/cli.go | 2 +- lab/internal/cli/loop.go | 176 +++++++++++++--- lab/internal/cli/loop_watch_quiet_test.go | 242 ++++++++++++++++++++++ 4 files changed, 392 insertions(+), 31 deletions(-) create mode 100644 lab/internal/cli/loop_watch_quiet_test.go diff --git a/docs/install.md b/docs/install.md index eefe020..232de17 100644 --- a/docs/install.md +++ b/docs/install.md @@ -161,7 +161,8 @@ forgecell deliver MOLECULE_ID --lab DIR --base BRANCH -- FILE... # preview, th `loop --max-concurrent N` sets how many Molecules it advances at once (default 1) and `--once` makes a single pass; `loop --watch` repeats a pass every `--interval` (default 5m), and `--learn` also runs the pending learn requests after each pass, like -`learn --pending`. `retire MOLECULE_ID --reason TEXT` retires a stale Molecule and +`learn --pending`. After its first pass, a watch prints only what changed, plus a +one-line summary. `retire MOLECULE_ID --reason TEXT` retires a stale Molecule and keeps every record; `recover MOLECULE_ID --plan DIGEST --confirm-stopped` recovers an interrupted one; `checks` reads a pull request's required CI checks at an exact commit for a Molecule's delivery and exits nonzero unless they passed. diff --git a/lab/internal/cli/cli.go b/lab/internal/cli/cli.go index 6791439..9819b43 100644 --- a/lab/internal/cli/cli.go +++ b/lab/internal/cli/cli.go @@ -48,7 +48,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer fail := func(err error) int { fmt.Fprintln(stderr, "forgecell:", err); return 1 } harness.SetRunningVersion(version) if len(args) == 0 || args[0] == "--help" || args[0] == "help" { - fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run --approve DIGEST)\namend --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --parent DIGEST --rebase-formula [--lab DIR]\nrecover --plan DIGEST --confirm-stopped [--lab DIR]\nretire --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.") + fmt.Fprintln(out, "Forgecell Lab — Go migration preview\n\n"+demoUsage+"\ninit [--lab DIR] [--harness ID | --rebind ID] [--meta-harness ID | --meta-command JSON_ARGV] [--harness-timeout-ms MS] [--max-timeout-ms MS] [--json] [--review ID | --approve ID | --dismiss ID]\ninit --model ID [--role analysis|coding|meta|review] [--lab DIR] [--json]\ninit --refresh-baseline [--lab DIR] [--json]\ninit --enable-review [--lab DIR] [--json]\ndoctor [--lab DIR] [--harness ID] [--json] [--verbose]\ndoctor --clean [--yes] [--lab DIR]\nrun [--lab DIR] [--formula ID] [--base COMMIT] [--target BRANCH] [--approve DIGEST] [--json]\namend [--add-scope PATH --reason TEXT]... [--add-acceptance TEXT]... [--note TEXT] [--continuation checks-only] [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --accept-protected-failures [--add-acceptance TEXT]... [--note TEXT] [--lab DIR] (declare the tests that failed by design in the latest protected regression run as expectedProtectedDeltas; continues checks-only; takes no --run: review the printed list, then approve the digest with run --approve DIGEST)\namend --parent DIGEST --plan FILE [--accept-existing-tree TREE] [--run] [--lab DIR]\namend --parent DIGEST --rebase-formula [--lab DIR]\nrecover --plan DIGEST --confirm-stopped [--lab DIR]\nretire --reason TEXT [--lab DIR] (mark a stale Molecule retired: one-line reason of at most 200 characters; every record is kept, the loop never resumes it and may start a fresh Molecule for its Issue)\nloop [--lab DIR] [--select oldest|label:L|milestone:M] [--max-concurrent N] [--once]\nloop [--lab DIR] --watch [--interval DURATION] [--learn]\nledger [--lab DIR]\nissues --repo OWNER/REPO [--page N --page-size N | --select ISSUE_REF] [--lab DIR] [--json]\nlearn [--lab DIR] [--json]\nlearn --pending [--lab DIR] [--json]\nsuggestion [--lab DIR] [--approve | --dismiss | --link-evaluation DIR | --baseline-ledger ID --candidate-ledger ID --comparability-basis TEXT] [--evaluation-parent DIR] [--json]\nrollback\ndev install [--from CHECKOUT] [--activate] [--allow-dirty]\nevaluate --inputs PLAN --out NEW_DIRECTORY [--source REPO] [--approve DIGEST]\nevaluate --cohort FILE --out DIRECTORY [--source REPO] [--approve DIGEST] [--parallel N] [--recheck]\nevaluate replay --repo OWNER/REPO --pr N [--pr M ...] --source CHECKOUT --out NEW_DIRECTORY\ndeliver --lab DIR --base BRANCH [--approve DIGEST --title TITLE] -- FILE...\nchecks --lab DIR --molecule ID --repo OWNER/REPO --pr NUMBER --commit SHA --required NAME [--required NAME]\n"+findingsUsage+"\nlabs [--json]\n"+pluginHelp+"\nui [--lab DIR] [--lab-name NAME] [--port N] [--no-open] [--demo]\n--version\n--protocol\n\nThe default Lab is keyed by the checkout's GitHub origin (OWNER/REPO), not its path; --lab DIR always wins and --lab-name NAME selects a named Lab for the repository on any command that takes a Lab. labs lists every Lab, read-only.\n\ndev install is developer-only: it builds the checkout's lab/cmd/forgecell with the local Go toolchain and then runs the result, so the checkout is trusted code. Without --activate the build is only staged under releases/ and current is unchanged. A tree with uncommitted, untracked or ignored Go source changes is refused unless --allow-dirty is given. --protocol prints the adapter protocol version.\n\nui serves the Lab UI on 127.0.0.1 behind a per-launch token and opens it in the browser unless --no-open is given. Its writes go through the engine and are bound to the digest you saw; it never merges or deploys. With no ready Lab it serves Set up your Lab; --demo shows synthetic sample data. It stops on SIGINT or SIGTERM.\n\n"+demoHelp+"\n\nRuns analyze a ticket before coding and wait for exact scope approval. Required checks run independently before final review.\n\nloop starts intake for eligible open Issues of the Formula's repository, oldest first, and approves a scope gate only where the Formula declares a policy that approves that exact plan. It stops each Molecule at any human gate, open question or failure and reports why; it never delivers, merges or approves a human gate. --once makes a single pass. --watch repeats single passes every --interval (default 5m, at least 1m) until SIGINT or SIGTERM, which stop it between passes after in-flight Molecules finish; it cannot be combined with --once. After its first pass, a watch prints only what changed, plus a one-line summary. --learn, which is off by default, runs the pending learn requests after each pass like learn --pending; suggestions stay pending for your decision. For an interrupted Molecule whose owner is gone, the loop saves a recovery proposal (the exact forgecell recover command) under the Lab's recovery-proposals directory and shows it in its report and in doctor; it never runs recover or amend.\n\nTicket readiness is a development preview; existing Formulas require an explicitly reviewed scope gate.") return 0 } if args[0] == "--version" || args[0] == "-v" { diff --git a/lab/internal/cli/loop.go b/lab/internal/cli/loop.go index 53e1f1c..935cbf5 100644 --- a/lab/internal/cli/loop.go +++ b/lab/internal/cli/loop.go @@ -155,21 +155,39 @@ func watchLoop(ctx context.Context, options molecule.LoopOptions, interval time. options.Work = context.WithoutCancel(ctx) code, failures := 0, 0 errored := false + // last is what the last completed pass rendered; until one completed, a pass + // prints its full report. A skip repeated with the same reason prints one line. + var last *watchView + skipped, lastSkip := false, "" for n := 1; ctx.Err() == nil; n++ { - fmt.Fprintf(out, "Watch pass %d\n", n) // Below a soft floor the pass is skipped, with its reason in the pass report; // the next pass checks again after the interval. if guard := options.Template.Resources; guard != nil { if level, reason := guard.Check(); level != resources.OK { - fmt.Fprintf(out, "Pass skipped: %s\n", reason) + if skipped && reason == lastSkip { + fmt.Fprintf(out, "Watch pass %d: skipped (same reason)\n", n) + } else { + fmt.Fprintf(out, "Watch pass %d\nPass skipped: %s\n", n, reason) + } + skipped, lastSkip = true, reason if !watchWait(ctx, interval) { break } continue } } + skipped = false report, err := runLoop(ctx, options) - renderLoop(out, report, options.Select, options.MaxConcurrent) + view := newWatchView(report) + if err != nil || last == nil { + fmt.Fprintf(out, "Watch pass %d\n", n) + renderLoop(out, report, options.Select, options.MaxConcurrent) + } else { + renderWatchChanges(out, n, *last, view, report) + } + if err == nil { + last = &view + } renderLearnNotices(out, stderr, lab) wait := interval if err != nil { @@ -290,32 +308,7 @@ func renderLoop(out io.Writer, report molecule.LoopReport, selector intake.Selec fmt.Fprintf(out, "Warning: %s\n", note) } for _, item := range pass.Items { - id := item.MoleculeID - if id == "" { - id = "none" - } - actions := "" - if item.Started { - actions += " · started intake" - } - if item.Approved { - actions += " · approved by policy" - } - if item.Key != "" { - fmt.Fprintf(out, "%s %s · %s · Molecule %s%s\n %s\n", item.Repository, flatten(item.Key), item.State, id, actions, item.Reason) - } else { - fmt.Fprintf(out, "%s#%d · %s · Molecule %s%s\n %s\n", item.Repository, item.Issue, item.State, id, actions, item.Reason) - } - if p := item.Proposal; p != nil { - saved := "saved" - if p.Existing { - saved = "already proposed" - } - fmt.Fprintf(out, " Recovery proposal (%s; the loop never runs it): %s\n %s\n", saved, flatten(p.Path), p.Command) - for _, step := range p.Followup { - fmt.Fprintf(out, " then: %s\n", step) - } - } + io.WriteString(out, loopItemText(item)) } if pass.Stopped != "" { fmt.Fprintf(out, "Pass %d stopped: %s\n", n+1, flatten(pass.Stopped)) @@ -325,3 +318,128 @@ func renderLoop(out io.Writer, report molecule.LoopReport, selector intake.Selec fmt.Fprintf(out, "%d Issue(s) made no progress in the final pass and need attention\n", n) } } + +// loopItemText is the lines renderLoop prints for one item: the item line, its +// reason, and any recovery proposal with its follow-ups. +func loopItemText(item molecule.LoopItem) string { + var b strings.Builder + id := item.MoleculeID + if id == "" { + id = "none" + } + actions := "" + if item.Started { + actions += " · started intake" + } + if item.Approved { + actions += " · approved by policy" + } + if item.Key != "" { + fmt.Fprintf(&b, "%s %s · %s · Molecule %s%s\n %s\n", item.Repository, flatten(item.Key), item.State, id, actions, item.Reason) + } else { + fmt.Fprintf(&b, "%s#%d · %s · Molecule %s%s\n %s\n", item.Repository, item.Issue, item.State, id, actions, item.Reason) + } + if p := item.Proposal; p != nil { + saved := "saved" + if p.Existing { + saved = "already proposed" + } + fmt.Fprintf(&b, " Recovery proposal (%s; the loop never runs it): %s\n %s\n", saved, flatten(p.Path), p.Command) + for _, step := range p.Followup { + fmt.Fprintf(&b, " then: %s\n", step) + } + } + return b.String() +} + +// watchView is what one completed watch pass rendered, so the next pass prints +// only what changed. It holds no more than that pass's report already did. +type watchView struct { + order []string + labels map[string]string + items map[string]string + warnings []string + stops []watchStop +} + +type watchStop struct{ reason, line string } + +func newWatchView(report molecule.LoopReport) watchView { + v := watchView{labels: map[string]string{}, items: map[string]string{}} + if report.Repository == "" { + // renderLoop prints nothing for such a report. + return v + } + for n, pass := range report.Passes { + for _, note := range pass.Notes { + v.warnings = append(v.warnings, fmt.Sprintf("Warning: %s\n", note)) + } + for _, item := range pass.Items { + // The kind prefix keeps a keyed item from colliding with a numbered one. + key, label := fmt.Sprintf("n\x00%s\x00%d", item.Repository, item.Issue), fmt.Sprintf("%s#%d", item.Repository, item.Issue) + if item.Key != "" { + key, label = "k\x00"+item.Repository+"\x00"+item.Key, item.Repository+" "+flatten(item.Key) + } + if _, seen := v.items[key]; !seen { + v.order = append(v.order, key) + } + v.labels[key], v.items[key] = label, loopItemText(item) + } + if pass.Stopped != "" { + reason := flatten(pass.Stopped) + v.stops = append(v.stops, watchStop{reason, fmt.Sprintf("Pass %d stopped: %s\n", n+1, reason)}) + } + } + return v +} + +// renderWatchChanges prints a later watch pass: one summary line in place of the +// pass and loop headers, then each new warning, each new or changed item, one +// line per item no longer eligible, and each new stop line. Every printed group +// counts as one change. +func renderWatchChanges(out io.Writer, n int, prev, cur watchView, report molecule.LoopReport) { + var body strings.Builder + changes := 0 + seenWarning := map[string]bool{} + for _, w := range prev.warnings { + seenWarning[w] = true + } + for _, w := range cur.warnings { + if !seenWarning[w] { + seenWarning[w] = true + body.WriteString(w) + changes++ + } + } + for _, key := range cur.order { + if text := cur.items[key]; prev.items[key] != text { + body.WriteString(text) + changes++ + } + } + for _, key := range prev.order { + if _, ok := cur.items[key]; !ok { + fmt.Fprintf(&body, "%s · no longer eligible\n", prev.labels[key]) + changes++ + } + } + seenStop := map[string]bool{} + for _, s := range prev.stops { + seenStop[s.reason] = true + } + for _, s := range cur.stops { + if !seenStop[s.reason] { + seenStop[s.reason] = true + body.WriteString(s.line) + changes++ + } + } + summary := "no change" + if changes > 0 { + summary = fmt.Sprintf("%d change(s)", changes) + } + if j := report.NonProgress(); j > 0 { + summary += fmt.Sprintf(" · %d Issue(s) need attention", j) + } + fmt.Fprintf(out, "Watch pass %d: %s\n%s", n, summary, body.String()) +} diff --git a/lab/internal/cli/loop_watch_quiet_test.go b/lab/internal/cli/loop_watch_quiet_test.go new file mode 100644 index 0000000..8d4bb5e --- /dev/null +++ b/lab/internal/cli/loop_watch_quiet_test.go @@ -0,0 +1,242 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/resources" +) + +// quietStep is one watch pass: the report and error the loop returns, or a +// resource reading below a soft floor that skips the pass. +type quietStep struct { + report molecule.LoopReport + err error + skip string // "disk" or "memory" skips the pass with that reason +} + +// quietWatch drives watchLoop through steps with the watchWait seam and a fake +// guard, so no test sleeps. It returns the exit code, stdout and stderr. +func quietWatch(t *testing.T, steps []quietStep) (int, string, string) { + t.Helper() + stubLearn(t) + step := 0 + stubLoop(t, func(context.Context, molecule.LoopOptions) (molecule.LoopReport, error) { + return steps[step].report, steps[step].err + }) + previous := watchWait + watchWait = func(context.Context, time.Duration) bool { + step++ + return step < len(steps) + } + t.Cleanup(func() { watchWait = previous }) + guard := &resources.Guard{Floors: resources.DefaultFloors().Clamp(), Reader: resources.ReaderFunc(func() resources.Readings { + r := resources.Readings{Volumes: []resources.Volume{{Path: "/lab", Free: 50 * resources.GiB, Total: 100 * resources.GiB}}, Memory: 8 * resources.GiB, MemoryKnown: true} + switch steps[step].skip { + case "disk": + r.Volumes[0].Free = resources.GiB + case "memory": + r.Memory = 2 * resources.GiB + } + return r + })} + var out, stderr bytes.Buffer + lab := t.TempDir() + options := molecule.LoopOptions{Template: molecule.Options{LabDir: lab, Resources: guard}, MaxConcurrent: 1} + code := watchLoop(context.Background(), options, time.Minute, false, lab, &out, &stderr) + return code, out.String(), stderr.String() +} + +func quietItem(issue int64, state, reason string) molecule.LoopItem { + return molecule.LoopItem{Repository: "acme/widgets", Issue: issue, MoleculeID: "mol-x", State: state, Reason: reason} +} + +func quietReport(items ...molecule.LoopItem) molecule.LoopReport { + return molecule.LoopReport{Repository: "acme/widgets", Passes: []molecule.LoopPass{{Items: items}}} +} + +// fullReport is what renderLoop prints for report, as a one-shot loop shows it. +func fullReport(report molecule.LoopReport) string { + var b bytes.Buffer + renderLoop(&b, report, molecule.LoopOptions{}.Select, 1) + return b.String() +} + +func TestWatchQuietFirstPassIsFullAndAnIdenticalPassIsOneLine(t *testing.T) { + report := quietReport(quietItem(1, molecule.LoopWaiting, "waiting at the scope gate"), quietItem(2, molecule.LoopStalled, "approval never applied")) + report.Passes[0].Notes = []string{"the selector matched no milestone"} + code, out, stderr := quietWatch(t, []quietStep{{report: report}, {report: report}, {report: report}}) + want := "Watch pass 1\n" + fullReport(report) + + "Watch pass 2: no change · 1 Issue(s) need attention\n" + + "Watch pass 3: no change · 1 Issue(s) need attention\n" + if out != want { + t.Fatalf("output:\n%s\nwant:\n%s", out, want) + } + if !strings.Contains(fullReport(report), "Warning: the selector matched no milestone\n") || !strings.Contains(fullReport(report), "acme/widgets#2 · stalled") { + t.Fatalf("pass 1 is not the full report:\n%s", out) + } + if code != 3 || stderr != "" { + t.Fatalf("exit %d, stderr %q", code, stderr) + } + + clean := quietReport(quietItem(1, molecule.LoopWaiting, "waiting at the scope gate")) + _, out, _ = quietWatch(t, []quietStep{{report: clean}, {report: clean}}) + if lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n"); lines[len(lines)-1] != "Watch pass 2: no change" || strings.Count(out, "Watch pass 2") != 1 || strings.Count(out, "Lab loop for") != 1 { + t.Fatalf("an unchanged pass is not exactly one line:\n%s", out) + } +} + +func TestWatchQuietPrintsOnlyTheChangedItem(t *testing.T) { + first := quietReport(quietItem(1, molecule.LoopWaiting, "waiting at the scope gate"), quietItem(2, molecule.LoopWaiting, "waiting at the delivery gate")) + changed := molecule.LoopItem{Repository: "acme/widgets", Issue: 2, MoleculeID: "mol-x", State: molecule.LoopStalled, Reason: "interrupted", Proposal: &molecule.LoopProposal{Path: "p", Command: "forgecell recover mol-x", Followup: []string{"forgecell amend mol-x"}}} + second := quietReport(quietItem(1, molecule.LoopWaiting, "waiting at the scope gate"), changed) + _, out, _ := quietWatch(t, []quietStep{{report: first}, {report: second}}) + want := "Watch pass 1\n" + fullReport(first) + + "Watch pass 2: 1 change(s) · 1 Issue(s) need attention\n" + + "acme/widgets#2 · stalled · Molecule mol-x\n interrupted\n" + + " Recovery proposal (saved; the loop never runs it): p\n forgecell recover mol-x\n then: forgecell amend mol-x\n" + if out != want { + t.Fatalf("output:\n%s\nwant:\n%s", out, want) + } +} + +func TestWatchQuietReportsNewAndDroppedItems(t *testing.T) { + keyed := molecule.LoopItem{Repository: "acme/widgets", Key: "PROJ-7\x1b[2J", State: molecule.LoopWaiting, Reason: "waiting"} + first := quietReport(quietItem(1, molecule.LoopWaiting, "a"), quietItem(2, molecule.LoopWaiting, "b"), keyed) + second := quietReport(quietItem(2, molecule.LoopWaiting, "b"), quietItem(3, molecule.LoopWaiting, "c")) + _, out, _ := quietWatch(t, []quietStep{{report: first}, {report: second}}) + tail := out[len("Watch pass 1\n"+fullReport(first)):] + want := "Watch pass 2: 3 change(s)\n" + + "acme/widgets#3 · waiting · Molecule mol-x\n c\n" + + "acme/widgets#1 · no longer eligible\n" + + "acme/widgets " + flatten(keyed.Key) + " · no longer eligible\n" + if tail != want { + t.Fatalf("pass 2:\n%s\nwant:\n%s", tail, want) + } + if strings.Contains(out, "\x1b") { + t.Fatalf("a hostile key reached the output raw: %q", out) + } +} + +func TestWatchQuietDoesNotRepeatWarningsOrStops(t *testing.T) { + report := func(notes []string, stopped string) molecule.LoopReport { + r := quietReport(quietItem(1, molecule.LoopWaiting, "a")) + r.Passes[0].Notes, r.Passes[0].Stopped = notes, stopped + return r + } + _, out, _ := quietWatch(t, []quietStep{ + {report: report([]string{"old warning"}, "harness login needed")}, + {report: report([]string{"old warning"}, "harness login needed")}, + {report: report([]string{"old warning", "new warning"}, "another stop")}, + }) + tail := out[strings.Index(out, "Watch pass 2"):] + want := "Watch pass 2: no change\n" + + "Watch pass 3: 2 change(s)\n" + + "Warning: new warning\n" + + "Pass 1 stopped: another stop\n" + if tail != want { + t.Fatalf("later passes:\n%s\nwant:\n%s", tail, want) + } +} + +func TestWatchQuietShortensARepeatedSkip(t *testing.T) { + report := quietReport(quietItem(1, molecule.LoopWaiting, "a")) + _, out, _ := quietWatch(t, []quietStep{{skip: "disk"}, {skip: "disk"}, {skip: "memory"}, {report: report}, {skip: "memory"}, {skip: "memory"}, {report: report}}) + lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") + full := strings.Split(strings.TrimSuffix(fullReport(report), "\n"), "\n") + want := []string{"Watch pass 1", "Pass skipped: resources: low disk", "Watch pass 2: skipped (same reason)", "Watch pass 3", "Pass skipped: resources: low memory", "Watch pass 4"} + want = append(want, full...) + want = append(want, "Watch pass 5", "Pass skipped: resources: low memory", "Watch pass 6: skipped (same reason)", "Watch pass 7: no change") + if len(lines) != len(want) { + t.Fatalf("output:\n%s", out) + } + for i := range want { + if !strings.HasPrefix(lines[i], want[i]) { + t.Fatalf("line %d %q, want %q:\n%s", i+1, lines[i], want[i], out) + } + } +} + +func TestWatchQuietAPassErrorKeepsTheComparison(t *testing.T) { + first := quietReport(quietItem(1, molecule.LoopWaiting, "a")) + partial := quietReport(quietItem(1, molecule.LoopStalled, "listing cut short")) + transient := molecule.TransientLoopError{Err: errors.New("gh: network unreachable")} + code, out, stderr := quietWatch(t, []quietStep{{report: first}, {report: partial, err: transient}, {report: first}}) + want := "Watch pass 1\n" + fullReport(first) + + "Watch pass 2\n" + fullReport(partial) + + "Watch pass 3: no change\n" + if out != want { + t.Fatalf("output:\n%s\nwant:\n%s", out, want) + } + if code != 0 || strings.Count(stderr, "network unreachable") != 1 { + t.Fatalf("exit %d, stderr %q", code, stderr) + } + + // A watch whose first pass failed has nothing to compare with yet. + _, out, _ = quietWatch(t, []quietStep{{report: partial, err: transient}, {report: first}}) + if want := "Watch pass 1\n" + fullReport(partial) + "Watch pass 2\n" + fullReport(first); out != want { + t.Fatalf("output:\n%s\nwant:\n%s", out, want) + } +} + +func TestWatchQuietExitCodesAreUnchanged(t *testing.T) { + failed := quietReport(molecule.LoopItem{Repository: "acme/widgets", Issue: 1, State: molecule.LoopStopped, Failed: true, Reason: "x"}) + stalled := quietReport(quietItem(1, molecule.LoopStalled, "x")) + clean := quietReport() + transient := molecule.TransientLoopError{Err: errors.New("gh: network unreachable")} + for name, c := range map[string]struct { + steps []quietStep + want int + }{ + "failed then unchanged": {[]quietStep{{report: failed}, {report: failed}}, 2}, + "stalled then unchanged": {[]quietStep{{report: stalled}, {report: stalled}}, 3}, + "stalled then clean": {[]quietStep{{report: stalled}, {report: clean}}, 0}, + "ends in a transient error": {[]quietStep{{report: clean}, {report: clean, err: transient}}, 1}, + "recovers after an error": {[]quietStep{{report: stalled}, {err: transient}, {report: stalled}}, 3}, + "a skip keeps the final code": {[]quietStep{{report: failed}, {skip: "disk"}}, 2}, + } { + t.Run(name, func(t *testing.T) { + if code, _, _ := quietWatch(t, c.steps); code != c.want { + t.Fatalf("exit %d, want %d", code, c.want) + } + }) + } + t.Run("a loop-level error", func(t *testing.T) { + code, _, stderr := quietWatch(t, []quietStep{{report: clean}, {report: clean, err: errors.New("GitHub data unavailable")}, {report: clean}}) + if code != 1 || !strings.Contains(stderr, "GitHub data unavailable") { + t.Fatalf("exit %d, stderr %q", code, stderr) + } + }) +} + +func TestWatchQuietLeavesTheOneShotReportByteIdentical(t *testing.T) { + stubLearn(t) + report := molecule.LoopReport{Repository: "acme/widgets", Passes: []molecule.LoopPass{{ + Notes: []string{"a note"}, + Items: []molecule.LoopItem{ + {Repository: "acme/widgets", Issue: 11, MoleculeID: "mol-11-a", Started: true, Approved: true, State: molecule.LoopWaiting, Reason: "waiting at the scope gate"}, + {Repository: "acme/widgets", Issue: 12, State: molecule.LoopStalled, Reason: "interrupted", Proposal: &molecule.LoopProposal{Path: "p", Command: "forgecell recover mol-12", Followup: []string{"step one"}, Existing: true}}, + {Repository: "acme/widgets", Key: "PROJ-1", MoleculeID: "mol-k", State: molecule.LoopWaiting, Reason: "keyed"}, + }, + Stopped: "the harness needs a login", + }}} + stubLoop(t, func(context.Context, molecule.LoopOptions) (molecule.LoopReport, error) { return report, nil }) + code, out, _ := runLoopCLI("--lab", t.TempDir(), "--once") + want := "Lab loop for acme/widgets · select oldest · max concurrent 1\n" + + "Pass 1: 3 eligible Issue(s)\n" + + "Warning: a note\n" + + "acme/widgets#11 · waiting · Molecule mol-11-a · started intake · approved by policy\n waiting at the scope gate\n" + + "acme/widgets#12 · stalled · Molecule none\n interrupted\n" + + " Recovery proposal (already proposed; the loop never runs it): p\n forgecell recover mol-12\n then: step one\n" + + "acme/widgets PROJ-1 · waiting · Molecule mol-k\n keyed\n" + + "Pass 1 stopped: the harness needs a login\n" + + "1 Issue(s) made no progress in the final pass and need attention\n" + if code != 3 || out != want { + t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want) + } +}