From b47af04c0f16f87dba8c9918f280e6ee82f3d2d8 Mon Sep 17 00:00:00 2001 From: Daniel Netzer Date: Tue, 27 Oct 2026 19:14:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20unblock=20from=20the=20browser=20?= =?UTF-8?q?=E2=80=94=20why=20it's=20blocked=20=C2=B7=20what=20you=20can=20?= =?UTF-8?q?do,=20bulk-retire=20attempts=20for=20closed=20Issues,=20loop=20?= =?UTF-8?q?panel=20for=20any=20loop=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lab/internal/cli/ui_actions_parity_test.go | 414 +++++++++++++++ lab/internal/molecule/next_actions.go | 339 ++++++++++++ lab/internal/molecule/next_actions_test.go | 306 +++++++++++ lab/internal/ui/actions.go | 457 ++++++++++++++++ lab/internal/ui/actions_test.go | 549 ++++++++++++++++++++ lab/internal/ui/bulk_retire.go | 223 ++++++++ lab/internal/ui/bulk_retire_test.go | 300 +++++++++++ lab/internal/ui/drive.go | 92 +++- lab/internal/ui/gates.go | 9 + lab/internal/ui/issues.go | 39 +- lab/internal/ui/loop_view_test.go | 175 +++++++ lab/internal/ui/projection.go | 13 +- lab/internal/ui/retire.go | 4 +- lab/internal/ui/served_actions_test.go | 110 ++++ lab/internal/ui/server.go | 60 ++- lab/internal/ui/web/index.html | 3 +- lab/internal/ui/web/js/core-gates.js | 8 +- lab/internal/ui/web/js/loop.js | 147 +++++- lab/internal/ui/web/js/molecule-decision.js | 142 ++++- 19 files changed, 3344 insertions(+), 46 deletions(-) create mode 100644 lab/internal/cli/ui_actions_parity_test.go create mode 100644 lab/internal/molecule/next_actions.go create mode 100644 lab/internal/molecule/next_actions_test.go create mode 100644 lab/internal/ui/actions.go create mode 100644 lab/internal/ui/actions_test.go create mode 100644 lab/internal/ui/bulk_retire.go create mode 100644 lab/internal/ui/bulk_retire_test.go create mode 100644 lab/internal/ui/loop_view_test.go create mode 100644 lab/internal/ui/served_actions_test.go diff --git a/lab/internal/cli/ui_actions_parity_test.go b/lab/internal/cli/ui_actions_parity_test.go new file mode 100644 index 0000000..1bae70c --- /dev/null +++ b/lab/internal/cli/ui_actions_parity_test.go @@ -0,0 +1,414 @@ +package cli + +import ( + "context" + "encoding/json" + "io/fs" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" + "github.com/DanielNetzer/forgecell/lab/internal/resources" + "github.com/DanielNetzer/forgecell/lab/internal/ui" +) + +// The Lab UI's "Approve the requested scope change" builds its engine request +// with ui.ActionEngineRequest. On the same Lab state it records exactly the +// revision forgecell amend --add-scope PATH --reason TEXT records: the same plan +// digest. The ledger is restored between the two runs, so both start from the +// same latest plan. +func TestUIScopeChangeActionRecordsTheCLIRevisionDigest(t *testing.T) { + m := gsSeedMolecule(t) + before := gsRead(t, m.ledger()) + req, ok := ui.ActionEngineRequest(ui.ActionScopeChange, m.digest, []string{"src/new.go"}, "Helper needed") + if !ok || req.ExpectParent != m.digest || req.Run { + t.Fatalf("request %+v %t", req, ok) + } + r, err := molecule.AmendFromLatest(context.Background(), m.lab, m.record.ID, req) + if err != nil { + t.Fatal(err) + } + fromUI := r.Readiness.Plans[len(r.Readiness.Plans)-1] + for _, e := range r.Readiness.Events { + if e.Kind == "approved" && e.Digest == fromUI.Digest { + t.Fatal("the UI request approved its own revision") + } + } + if err = os.WriteFile(m.ledger(), before, 0600); err != nil { + t.Fatal(err) + } + code, _, errOut := gsRun([]string{"amend", m.record.ID, "--lab", m.lab, "--add-scope", "src/new.go", "--reason", "Helper needed"}) + if code != 0 { + t.Fatalf("CLI amend failed: %d %s", code, errOut) + } + cli := gsLoadRecord(t, m.ledger()) + fromCLI := cli.Readiness.Plans[len(cli.Readiness.Plans)-1] + if len(cli.Readiness.Plans) != 2 || fromCLI.Digest != fromUI.Digest || fromCLI.Plan.ParentDigest != m.digest { + t.Fatalf("CLI digest %s, UI digest %s (%d plans)", fromCLI.Digest, fromUI.Digest, len(cli.Readiness.Plans)) + } +} + +// Every UI action that records a revision sends the request the CLI flags +// build, plus only the digest the operator was shown: --accept-protected-failures, +// --add-scope PATH --reason TEXT for each path, and --continuation checks-only. +// None of them sets --run, so none approves. +func TestUIActionEngineRequestsAreTheCLIRequests(t *testing.T) { + digest := "5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e" + for kind, cli := range map[string]molecule.AmendRequest{ + ui.ActionAcceptFailures: {AcceptProtectedFailures: true}, + ui.ActionScopeChange: {AddScope: []molecule.ScopeAddition{{Path: "src/a.go", Reason: "Needed"}, {Path: "src/b.go", Reason: "Needed"}}}, + ui.ActionRerunChecks: {Continuation: "checks-only"}, + } { + got, ok := ui.ActionEngineRequest(kind, digest, []string{"src/a.go", "src/b.go"}, "Needed") + if !ok || got.ExpectParent != digest || got.Run { + t.Fatalf("%s: %+v %t", kind, got, ok) + } + if kind != ui.ActionScopeChange && len(got.AddScope) != 0 { + t.Fatalf("%s carried scope paths: %+v", kind, got) + } + got.ExpectParent = "" + if !reflect.DeepEqual(got, cli) { + t.Fatalf("%s: %+v, want the CLI's %+v", kind, got, cli) + } + } + for _, kind := range []string{ui.ActionRecover, ui.ActionFreshIntake, ui.ActionRetire, "approve"} { + if _, ok := ui.ActionEngineRequest(kind, digest, nil, ""); ok { + t.Fatalf("%s built an amend request", kind) + } + } +} + +// parityLab is every file under a Lab except the coding checkouts and the +// advisory loop-lock files, so one engine path can be undone before the other +// runs from exactly the same state. +type parityLab struct { + files map[string][]byte + dirs map[string]bool +} + +func paritySkip(rel string) bool { + return rel == "workspaces" || strings.HasPrefix(rel, "workspaces/") || strings.HasSuffix(rel, ".loop-lock") +} + +func paritySave(t *testing.T, lab string) parityLab { + t.Helper() + s := parityLab{files: map[string][]byte{}, dirs: map[string]bool{}} + err := filepath.WalkDir(lab, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(lab, path) + if err != nil || rel == "." { + return err + } + rel = filepath.ToSlash(rel) + switch { + case paritySkip(rel): + if d.IsDir() { + return filepath.SkipDir + } + case d.IsDir(): + s.dirs[rel] = true + default: + raw, e := os.ReadFile(path) + if e != nil { + return e + } + s.files[rel] = raw + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return s +} + +// parityRestore removes what was created since s and rewrites every saved file. +func parityRestore(t *testing.T, lab string, s parityLab) { + t.Helper() + err := filepath.WalkDir(lab, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(lab, path) + if err != nil || rel == "." { + return err + } + rel = filepath.ToSlash(rel) + switch { + case paritySkip(rel): + if d.IsDir() { + return filepath.SkipDir + } + case d.IsDir() && !s.dirs[rel]: + if e := os.RemoveAll(path); e != nil { + return e + } + return filepath.SkipDir + case !d.IsDir(): + if _, saved := s.files[rel]; !saved { + return os.Remove(path) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + for rel, raw := range s.files { + gsWrite(t, filepath.Join(lab, filepath.FromSlash(rel)), string(raw), 0600) + } +} + +func parityLatest(t *testing.T, m gsMolecule) readiness.Proposal { + t.Helper() + r := gsLoadRecord(t, m.ledger()) + if r.Readiness == nil || len(r.Readiness.Plans) == 0 { + t.Fatal("no plan") + } + return r.Readiness.Plans[len(r.Readiness.Plans)-1] +} + +func parityIssue(context.Context, string, int64, string) (molecule.Issue, error) { + return molecule.Issue{Number: 12, Repo: "acme/widgets", Title: "Change code", Body: "Write changed", URL: "https://github.com/acme/widgets/issues/12", State: "OPEN"}, nil +} + +const parityCompleted = `{"schemaVersion":"v1","outcome":"completed","reason":"Implemented and tested","paths":[]}` + +// paritySeed is gsSeedWith with extra repository files, its own harness script +// and analysis additions: a real Molecule waiting at the scope gate. +func paritySeed(t *testing.T, files map[string]string, harness string, extend func(a *readiness.Analysis, evidence map[string]string)) gsMolecule { + t.Helper() + root := t.TempDir() + repo := filepath.Join(root, "repo") + gsWrite(t, filepath.Join(repo, "code.txt"), "base", 0600) + gsWrite(t, filepath.Join(repo, "package.json"), `{"scripts":{"test":"test x = x"}}`, 0600) + for path, body := range files { + gsWrite(t, filepath.Join(repo, filepath.FromSlash(path)), body, 0600) + } + harnessPath := filepath.Join(root, "harness") + issuePath := filepath.Join(root, "issue.json") + tools := gsEnv(t, "\"issue view\"*) cat '"+issuePath+"'; exit 0;;\n") + gsGit(t, repo, "init", "-q", "-b", "main") + gsGit(t, repo, "config", "user.name", "Test") + gsGit(t, repo, "config", "user.email", "test@example.test") + gsGit(t, repo, "add", ".") + gsGit(t, repo, "commit", "-qm", "base") + gsGit(t, repo, "remote", "add", "origin", "https://github.com/acme/widgets.git") + lab := filepath.Join(root, "lab") + gsWrite(t, harnessPath, harness, 0700) + gsWrite(t, issuePath, gsIssueJSON("Write changed"), 0600) + t.Chdir(repo) + command, err := json.Marshal([]string{harnessPath}) + if err != nil { + t.Fatal(err) + } + gsApproveFormula(t, lab, gsMoleculeYAML(string(command), gsScopeAtom, gsReviewAtom)) + record, err := molecule.Run(context.Background(), molecule.Options{LabDir: lab, SourceRoot: repo, Issue: "12", TargetBranch: "main", ReadIssue: parityIssue, + Analyze: func(_ context.Context, _ formula.Binding, _ molecule.Issue, s readiness.RepositorySnapshot) (readiness.Analysis, error) { + evidence := map[string]string{} + for _, e := range s.Evidence { + evidence[e.Path] = e.ID + } + id := evidence["package.json"] + a := readiness.Analysis{Summary: "Change code", Scope: []readiness.ScopedPath{{Path: "code.txt", Reason: "Implementation", Evidence: []string{"issue"}}, {Path: "request.json", Reason: "Fixture harness trace", Evidence: []string{id}}}, Acceptance: []readiness.Criterion{{Description: "Code contains changed", Evidence: []string{"issue"}}}, Checks: []readiness.Check{{ID: "check", Category: "candidate", Argv: []string{"/bin/sh", "-c", "test \"$(cat code.txt)\" = changed"}, Dir: ".", TimeoutMS: 5000, Required: true, Definitions: []string{id}, Reason: "Fixture acceptance", Evidence: []string{"issue", id}}}} + if extend != nil { + extend(&a, evidence) + } + return a, nil + }}) + if err != nil || record.Readiness == nil || len(record.Readiness.Plans) != 1 || record.Readiness.Phase != "scope-waiting" { + t.Fatalf("scope-waiting Molecule was not seeded: %v %+v", err, record) + } + return gsMolecule{root: root, repo: repo, lab: lab, harnessPath: harnessPath, issuePath: issuePath, record: record, digest: record.Readiness.Plans[0].Digest, tools: tools} +} + +// parityApprove approves the seeded plan and runs it, through the engine the +// CLI's run --approve calls. +func parityApprove(t *testing.T, m gsMolecule, guard *resources.Guard) molecule.Record { + t.Helper() + r, err := molecule.Run(context.Background(), molecule.Options{LabDir: m.lab, SourceRoot: m.repo, Issue: "12", TargetBranch: "main", ReadIssue: parityIssue, Approve: m.digest, Resources: guard}) + if err != nil { + t.Fatalf("approved run: %v", err) + } + return r +} + +func parityOffered(t *testing.T, m gsMolecule, r molecule.Record, kind string) molecule.NextAction { + t.Helper() + for _, a := range molecule.NextActions(context.Background(), m.lab, r, true, time.Minute) { + if a.Kind == kind { + return a + } + } + t.Fatalf("%s is not offered", kind) + return molecule.NextAction{} +} + +// parityGo answers `go test` from the pinned test source the check runs +// against: the candidate's says "new" and passes, the base version the +// protected view restores says "old" and fails TestX, by design. No Go +// toolchain runs and nothing depends on time. +const parityGo = `#!/bin/sh +[ "$1" = test ] || exit 2 +pkg=example.test/lab/pin +json=no +for a in "$@"; do + [ "$a" = -json ] && json=yes +done +if grep -q new pin/pin_test.go; then + if [ $json = yes ]; then + printf '{"Action":"run","Package":"%s","Test":"TestX"}\n{"Action":"pass","Package":"%s","Test":"TestX"}\n{"Action":"pass","Package":"%s"}\n' "$pkg" "$pkg" "$pkg" + else + printf 'ok \t%s\t0.01s\n' "$pkg" + fi + exit 0 +fi +if [ $json = yes ]; then + printf '{"Action":"run","Package":"%s","Test":"TestX"}\n{"Action":"fail","Package":"%s","Test":"TestX"}\n{"Action":"fail","Package":"%s"}\n' "$pkg" "$pkg" "$pkg" +else + printf -- '--- FAIL: TestX\nFAIL\t%s\t0.01s\n' "$pkg" +fi +exit 1 +` + +// Accept by-design test failures: on a Molecule whose new code passed and +// whose protected regression run failed only the pinned test, the UI's engine +// request records exactly the revision forgecell amend +// --accept-protected-failures records from the same restored Lab, and that +// revision declares exactly the tests the UI preview listed. +func TestUIAcceptFailuresActionRecordsTheCLIRevisionDigest(t *testing.T) { + const pinned = "lab/pin/pin_test.go" + harness := "#!/bin/sh\ncat > request.json\nprintf changed > code.txt\nprintf 'package pin\\n\\n// pinned: new\\n' > " + pinned + "\nprintf '%s' '" + parityCompleted + "'\n" + m := paritySeed(t, map[string]string{"lab/go.mod": "module example.test/lab\n", pinned: "package pin\n\n// pinned: old\n"}, harness, func(a *readiness.Analysis, evidence map[string]string) { + manifest := evidence["lab/go.mod"] + a.Scope = append(a.Scope, readiness.ScopedPath{Path: pinned, Reason: "The pinned behaviour changes by design", Evidence: []string{"issue"}}) + a.Checks = append(a.Checks, readiness.Check{ID: "go-test-lab", Category: "regression", Argv: []string{"go", "test", "-json", "./..."}, Dir: "lab", TimeoutMS: 60000, Required: true, Definitions: []string{manifest}, Reason: "Module tests", Evidence: []string{"issue", manifest}}) + }) + bin := t.TempDir() + gsWrite(t, filepath.Join(bin, "go"), parityGo, 0700) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if p, err := exec.LookPath("go"); err != nil || p != filepath.Join(bin, "go") { + t.Fatalf("the fake go is not first on PATH: %s %v", p, err) + } + r := parityApprove(t, m, nil) + if r.Verification == nil || r.Verification.Protected == nil || r.Verification.Protected.RequiredChecksPassed { + t.Fatalf("fixture did not fail only in the protected view: %+v", r.Verification) + } + a := parityOffered(t, m, r, molecule.NextAcceptProtectedFailures) + if a.Digest != m.digest || len(a.Deltas) != 1 || a.Deltas[0].Test != "TestX" || a.Deltas[0].ScopePath != pinned { + t.Fatalf("offered %+v", a) + } + saved := paritySave(t, m.lab) + req, _ := ui.ActionEngineRequest(ui.ActionAcceptFailures, a.Digest, nil, "") + if _, err := molecule.AmendFromLatest(context.Background(), m.lab, m.record.ID, req); err != nil { + t.Fatal(err) + } + fromUI := parityLatest(t, m) + parityRestore(t, m.lab, saved) + if code, _, errOut := gsRun([]string{"amend", m.record.ID, "--lab", m.lab, "--accept-protected-failures"}); code != 0 { + t.Fatalf("CLI amend failed: %d %s", code, errOut) + } + fromCLI := parityLatest(t, m) + if fromCLI.Digest != fromUI.Digest || fromCLI.Plan.ParentDigest != m.digest || fromCLI.Plan.Revision != 2 { + t.Fatalf("CLI digest %s, UI digest %s", fromCLI.Digest, fromUI.Digest) + } + // The digest the page binds is the list the CLI's revision declares. + if ui.DeltasDigest(fromCLI.Plan.ExpectedProtectedDeltas) != ui.DeltasDigest(a.Deltas) { + t.Fatalf("declared %+v, previewed %+v", fromCLI.Plan.ExpectedProtectedDeltas, a.Deltas) + } +} + +// Approve the requested scope change: on a Molecule whose coding harness asked +// for one more path, the UI's request with the offered paths records exactly +// the revision forgecell amend --add-scope PATH --reason TEXT records. +func TestUIScopeChangeOutcomeActionRecordsTheCLIRevisionDigest(t *testing.T) { + harness := "#!/bin/sh\ncat > request.json\nprintf partial > code.txt\nprintf '%s' '{\"schemaVersion\":\"v1\",\"outcome\":\"scope-change\",\"reason\":\"Helper needed\",\"paths\":[\"src/new.go\"]}'\n" + m := paritySeed(t, nil, harness, nil) + r := parityApprove(t, m, nil) + a := parityOffered(t, m, r, molecule.NextApproveScopeChange) + if !reflect.DeepEqual(a.Paths, []string{"src/new.go"}) || a.Reason != "Helper needed" || a.Digest != m.digest { + t.Fatalf("offered %+v", a) + } + saved := paritySave(t, m.lab) + req, _ := ui.ActionEngineRequest(ui.ActionScopeChange, a.Digest, a.Paths, a.Reason) + if _, err := molecule.AmendFromLatest(context.Background(), m.lab, m.record.ID, req); err != nil { + t.Fatal(err) + } + fromUI := parityLatest(t, m) + parityRestore(t, m.lab, saved) + if code, _, errOut := gsRun([]string{"amend", m.record.ID, "--lab", m.lab, "--add-scope", "src/new.go", "--reason", "Helper needed"}); code != 0 { + t.Fatalf("CLI amend failed: %d %s", code, errOut) + } + if fromCLI := parityLatest(t, m); fromCLI.Digest != fromUI.Digest || fromCLI.Plan.ParentDigest != m.digest { + t.Fatalf("CLI digest %s, UI digest %s", fromCLI.Digest, fromUI.Digest) + } +} + +// Re-run checks on the same code, from a Molecule stopped below the resource +// floor before its checks: the UI recovers first and then amends checks-only, +// exactly as forgecell recover --plan DIGEST --confirm-stopped and amend +// --continuation checks-only do, from the same restored Lab; and from the +// recovered state, the plain checks-only action records the CLI's revision too. +func TestUIRerunChecksActionRecordsTheCLIRevisionDigest(t *testing.T) { + m := paritySeed(t, nil, "#!/bin/sh\ncat > request.json\nprintf changed > code.txt\nprintf '%s' '"+parityCompleted+"'\n", nil) + low := resources.Readings{Volumes: []resources.Volume{{Path: "/lab", Free: resources.GiB, Total: 100 * resources.GiB}}, Memory: 8 * resources.GiB, MemoryKnown: true} + guard := &resources.Guard{Floors: resources.DefaultFloors().Clamp(), Reader: resources.ReaderFunc(func() resources.Readings { return low }), Sleep: func(context.Context, time.Duration) bool { return true }} + r := parityApprove(t, m, guard) + if r.Status != "blocked" || len(r.VerificationAttempts) != 0 { + t.Fatalf("fixture %s with %d verification attempts", r.Status, len(r.VerificationAttempts)) + } + a := parityOffered(t, m, r, molecule.NextRerunChecks) + if !a.RecoverFirst || a.Digest != m.digest { + t.Fatalf("offered %+v", a) + } + pending := paritySave(t, m.lab) + // The UI path: recover with the confirmation, then the checks-only request. + if _, err := molecule.Recover(context.Background(), m.lab, m.record.ID, a.Digest, true); err != nil { + t.Fatal(err) + } + recovered := paritySave(t, m.lab) + if b := parityOffered(t, m, gsLoadRecord(t, m.ledger()), molecule.NextRerunChecks); b.RecoverFirst || b.Digest != a.Digest { + t.Fatalf("after recovery %+v", b) + } + req, _ := ui.ActionEngineRequest(ui.ActionRerunChecks, a.Digest, nil, "") + if _, err := molecule.AmendFromLatest(context.Background(), m.lab, m.record.ID, req); err != nil { + t.Fatal(err) + } + fromUI := parityLatest(t, m) + if fromUI.Plan.Continuation != "checks-only" || fromUI.Plan.ParentDigest != m.digest { + t.Fatalf("UI revision %+v", fromUI.Plan) + } + // The CLI path from the same check-pending Lab. + parityRestore(t, m.lab, pending) + if code, _, errOut := gsRun([]string{"recover", m.record.ID, "--lab", m.lab, "--plan", a.Digest, "--confirm-stopped"}); code != 0 { + t.Fatalf("CLI recover failed: %d %s", code, errOut) + } + if code, _, errOut := gsRun([]string{"amend", m.record.ID, "--lab", m.lab, "--continuation", "checks-only"}); code != 0 { + t.Fatalf("CLI amend failed: %d %s", code, errOut) + } + if fromCLI := parityLatest(t, m); fromCLI.Digest != fromUI.Digest { + t.Fatalf("recover first: CLI digest %s, UI digest %s", fromCLI.Digest, fromUI.Digest) + } + // Plain checks-only from the recovered Lab: CLI first, then the UI request. + parityRestore(t, m.lab, recovered) + if code, _, errOut := gsRun([]string{"amend", m.record.ID, "--lab", m.lab, "--continuation", "checks-only"}); code != 0 { + t.Fatalf("CLI amend failed: %d %s", code, errOut) + } + fromCLI := parityLatest(t, m) + parityRestore(t, m.lab, recovered) + if _, err := molecule.AmendFromLatest(context.Background(), m.lab, m.record.ID, req); err != nil { + t.Fatal(err) + } + if again := parityLatest(t, m); again.Digest != fromCLI.Digest || again.Digest != fromUI.Digest { + t.Fatalf("checks-only: CLI digest %s, UI digest %s", fromCLI.Digest, again.Digest) + } +} diff --git a/lab/internal/molecule/next_actions.go b/lab/internal/molecule/next_actions.go new file mode 100644 index 0000000..c07da5d --- /dev/null +++ b/lab/internal/molecule/next_actions.go @@ -0,0 +1,339 @@ +package molecule + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/readiness" + "github.com/DanielNetzer/forgecell/lab/internal/verification" +) + +// Next actions are the ways out of a blocked, failed, interrupted or +// scope-change Molecule that an engine call accepts today. NextActions derives +// them from the ledger with the same preconditions as the engine calls behind +// them (AmendFromLatest, Recover, Retire and intake); it adds no engine +// semantics, records nothing and never approves. The engine checks every +// precondition again under its locks, so an action offered here can still be +// refused when the Lab changed in between, and never the other way round. +const ( + // NextAcceptProtectedFailures is amend --accept-protected-failures. + NextAcceptProtectedFailures = "accept-protected-failures" + // NextApproveScopeChange is amend --add-scope PATH --reason TEXT for each path + // the coding harness requested. + NextApproveScopeChange = "approve-scope-change" + // NextRerunChecks is amend --continuation checks-only, after recover + // --confirm-stopped when RecoverFirst is set. + NextRerunChecks = "rerun-checks" + // NextRecover is recover --plan DIGEST --confirm-stopped for an interrupted + // Molecule whose owner is gone, the loop's recovery proposal. + NextRecover = "recover" + // NextFreshIntake is a fresh intake of the Issue (forgecell run ISSUE). + NextFreshIntake = "fresh-intake" + // NextRetire is retire --reason TEXT. + NextRetire = "retire" +) + +// MaxNextActionPaths bounds the scope paths one scope-change action offers; a +// request for more is not offered, never cut short. +const MaxNextActionPaths = 8 + +// maxNextActionQuestions bounds the open questions a fresh intake carries. +const maxNextActionQuestions = 8 + +// NextAction is one action an engine call accepts today. Digest is the latest +// plan digest the action binds as its parent; it is empty for retire and +// fresh intake. Paths and Reason are the coding harness's scope-change request, +// Deltas the tests accept-protected-failures would declare (set only by a +// preview), and Questions the open questions of a stopped intake. Every text is +// ledger content: a caller redacts and bounds it before showing it. +type NextAction struct { + Kind string + Digest string + RecoverFirst bool + Paths []string + Reason string + Deltas []readiness.ProtectedDelta + Questions []string +} + +// NextActions lists the actions the engine accepts for r today, in a fixed +// order. preview also derives, read-only through git, the exact deltas +// accept-protected-failures would declare; an action whose derivation the +// engine would refuse is then left out. recoveryTimeout is the loop's +// AnalysisTimeout: a ledger written more recently is not proposed for recovery. +func NextActions(ctx context.Context, lab string, r Record, preview bool, recoveryTimeout time.Duration) []NextAction { + if refuseRetired(r) != nil || !safeLoopID(r.ID) { + return nil + } + lab, err := filepath.Abs(lab) + if err != nil { + return nil + } + digest, hasPlan := latestReviewDigest(r) + if r.Status == "running" || r.Status == "allocating" { + // Only recovery applies to a ledger left running. + if hasPlan && recoverable(lab, r, recoveryTimeout) { + return []NextAction{{Kind: NextRecover, Digest: digest}} + } + return nil + } + var out []NextAction + approvalLock := pathPresent(filepath.Join(lab, "ledgers", r.ID+".lock")) + if hasPlan && !approvalLock && amendable(r) { + latest := r.Readiness.Plans[len(r.Readiness.Plans)-1] + phase := statePhase(r) + retained := phase == "failed" || phase == "review-waiting" || phase == "interrupted" + protected := false + if retained && checksOnlyEligible(r) && protectedFailurePrecheck(r, latest) == nil { + protected = true + a := NextAction{Kind: NextAcceptProtectedFailures, Digest: digest} + ok := true + if preview { + a.Deltas, err = protectedFailureDeltas(ctx, r, latest) + ok = err == nil + } + if ok { + out = append(out, a) + } + } + if phase == "scope-change" { + if a, ok := scopeChangeAction(r, latest); ok { + out = append(out, a) + } + } + if phase != "review-waiting" && retained && !protected && checksOnlyEligible(r) && environmentalFailure(r, digest) { + out = append(out, NextAction{Kind: NextRerunChecks, Digest: digest}) + } + } + // A check-pending Molecule that stopped before its checks ran (a resource + // floor, or the slot) is recovered first, as forgecell recover --plan DIGEST + // --confirm-stopped does, and then continues checks-only. + if hasPlan && statePhase(r) == "check-pending" && r.Status == "blocked" && checkPendingRecoverable(lab, r, approvalLock) { + out = append(out, NextAction{Kind: NextRerunChecks, Digest: digest, RecoverFirst: true}) + } + if a, ok := freshIntakeAction(r); ok { + out = append(out, a) + } + if retireAllowed(lab, r, approvalLock) { + out = append(out, NextAction{Kind: NextRetire}) + } + return out +} + +// latestReviewDigest is the latest plan digest when it is a SHA-256 digest. +func latestReviewDigest(r Record) (string, bool) { + if r.Readiness == nil || len(r.Readiness.Plans) == 0 { + return "", false + } + d := r.Readiness.Plans[len(r.Readiness.Plans)-1].Digest + if len(d) != 64 || strings.Trim(d, "0123456789abcdef") != "" { + return "", false + } + return d, true +} + +// pathPresent reports a path that exists or cannot be checked, so an +// uncertain lock always counts as held. +func pathPresent(path string) bool { + _, err := os.Lstat(path) + return !errors.Is(err, os.ErrNotExist) +} + +// amendable holds the preconditions AmendFromLatest and amendLocked check from +// the ledger alone: a coding checkout, frozen repository evidence, a valid +// verification history with no live check process and a reconciled harness +// outcome. +func amendable(r Record) bool { + if r.Workspace.Path == "" || (r.CheckoutStatus != "" && r.CheckoutStatus != "available") || r.RepositoryEvidence == nil { + return false + } + if _, err := resolveExecutionRoles(r); err != nil { + return false + } + return ValidateVerificationHistory(r) == nil && confirmVerificationProcessesStopped(r) == nil && retainedWorkCertain(r) == nil +} + +// checksOnlyEligible is amendLocked's checks-only rule as the ledger shows it: +// captured completed coding output with no violation. +func checksOnlyEligible(r Record) bool { + return r.Capture != nil && len(r.Capture.Violations) == 0 && completedCoding(r) +} + +// protectedFailurePrecheck is the part of protectedFailureDeltas that reads +// only the ledger and the retained verification result: the latest +// verification of the latest plan and the retained tree failed only in the +// protected view, on required go test checks whose failures parse cleanly. +func protectedFailurePrecheck(r Record, latest readiness.Proposal) error { + if len(r.VerificationAttempts) == 0 || r.Capture == nil || !reviewRevision.MatchString(r.Capture.Tree) { + return errors.New("no verification of the retained tree") + } + tree := r.Capture.Tree + a := r.VerificationAttempts[len(r.VerificationAttempts)-1] + if a.Status != "failed" || a.Result == nil || a.SourceTree != tree || a.PlanDigest != latest.Digest { + return errors.New("the latest verification is not a failure of the retained tree under the latest plan") + } + result, err := readVerificationResult(r, *a.Result) + if err != nil { + return err + } + if result.SchemaVersion != "v1" || result.SourceTree != tree { + return errors.New("the latest verification result does not match the retained tree") + } + if err = candidateViewPassed(latest.Plan, result, tree); err != nil { + return err + } + failing, err := failingProtectedChecks(latest.Plan, result) + if err != nil { + return err + } + for _, f := range failing { + if _, err = verification.ProtectedFailuresForDeltas(f.obs); err != nil { + return err + } + } + return nil +} + +// scopeChangeAction is the scope-change request of the latest coding attempt: +// exact, distinct, valid paths not yet in scope, each of which buildRevision +// (the --add-scope path, which only reads the ledger) accepts. +func scopeChangeAction(r Record, latest readiness.Proposal) (NextAction, bool) { + if len(r.HarnessAttempts) == 0 { + return NextAction{}, false + } + a := r.HarnessAttempts[len(r.HarnessAttempts)-1] + if a.Coding == nil || a.Coding.Outcome != "scope-change" || a.PlanDigest != latest.Digest || len(a.Coding.Paths) == 0 || len(a.Coding.Paths) > MaxNextActionPaths { + return NextAction{}, false + } + reason := strings.TrimSpace(a.Coding.Reason) + if readiness.ValidateOperatorText(reason, maxAmendTextBytes) != nil { + // The operator edits the reason; a probe reason checks only the paths. + reason = "" + } + probe := AmendRequest{} + seen := map[string]bool{} + for _, p := range a.Coding.Paths { + if readiness.ValidatePath(p) != nil || seen[p] { + return NextAction{}, false + } + seen[p] = true + probe.AddScope = append(probe.AddScope, ScopeAddition{Path: p, Reason: "Requested by the coding harness"}) + } + if probe.validate() != nil { + return NextAction{}, false + } + if _, err := buildRevision(r, latest.Plan, probe); err != nil { + return NextAction{}, false + } + return NextAction{Kind: NextApproveScopeChange, Digest: latest.Digest, Paths: append([]string(nil), a.Coding.Paths...), Reason: reason}, true +} + +// environmentalFailure reports a verification that failed for reasons other +// than the retained code: it never started under the latest plan, it was +// interrupted, the Lab ran below a resource floor, a required check timed +// out, or every check passed and a later step (review, harness or provider) +// failed. A check that failed on its own is not environmental. +func environmentalFailure(r Record, digest string) bool { + var last *VerificationAttempt + for i := range r.VerificationAttempts { + if r.VerificationAttempts[i].PlanDigest == digest { + last = &r.VerificationAttempts[i] + } + } + if last == nil || last.Status != "failed" { + // Never started, interrupted, or passed with a later step failing. + return true + } + v := r.Verification + if v == nil { + return true + } + if strings.HasPrefix(v.Error, "resources-exhausted") { + return true + } + for _, view := range []*verification.Result{v, v.Protected} { + if view == nil { + continue + } + for _, obs := range view.Checks { + if obs.Command.Required && !obs.Result.OK && obs.Result.TimedOut { + return true + } + } + } + return v.RequiredChecksPassed && v.Error == "" && (v.Protected == nil || v.Protected.RequiredChecksPassed) +} + +// checkPendingRecoverable holds Recover's preconditions for a stopped +// check-pending Molecule: completed coding output, a reconciled harness +// attempt with durable stopped-process evidence, a valid verification history +// and no approval or recovery lock left behind. +func checkPendingRecoverable(lab string, r Record, approvalLock bool) bool { + if approvalLock || pathPresent(filepath.Join(lab, "ledgers", r.ID+".recovery-lock")) || !amendable(r) || !checksOnlyEligible(r) { + return false + } + a := r.HarnessAttempts[len(r.HarnessAttempts)-1] + return a.Evidence != nil && a.Result.Reconciled +} + +// recoverable is the loop's recovery proposal for a ledger left running: a +// plan and a reconciled harness attempt to name, not written for longer than +// timeout, and no live owner of its approval lock. +func recoverable(lab string, r Record, timeout time.Duration) bool { + if pathPresent(filepath.Join(lab, "ledgers", r.ID+".recovery-lock")) { + return false + } + if ValidateVerificationHistory(r) != nil { + return false + } + _, ok := currentRecoveryProposal(lab, r.ID, timeout) + return ok +} + +// freshIntakeAction is a fresh intake for an intake that stopped on open +// questions or after its supplemental evidence passes were exhausted. +func freshIntakeAction(r Record) (NextAction, bool) { + if r.Readiness != nil || (r.Status != "blocked" && r.Status != "failed") || r.Issue.Number < 1 { + return NextAction{}, false + } + if r.Analysis != nil && len(r.Analysis.Questions) > 0 { + a := NextAction{Kind: NextFreshIntake} + for i, q := range r.Analysis.Questions { + if i == maxNextActionQuestions { + break + } + a.Questions = append(a.Questions, q.Question) + } + return a, true + } + if n := len(r.Notes); n > 0 && strings.HasPrefix(r.Notes[n-1], "Supplemental evidence passes exhausted") { + return NextAction{Kind: NextFreshIntake}, true + } + return NextAction{}, false +} + +// retireAllowed holds Retire's preconditions that a read can predict: not +// running, allocating or queued, no approval lock, no Lab delivery lock and a +// delivery receipt that is absent or published. +func retireAllowed(lab string, r Record, approvalLock bool) bool { + if approvalLock || r.Status == "running" || r.Status == "allocating" || pathPresent(filepath.Join(lab, ".delivery-lock")) { + return false + } + state, found, err := retireReceiptState(lab, r.ID) + return err == nil && (!found || state == "published") +} + +// RetireDeliveryInProgress reports a Molecule whose delivery Retire would +// refuse: the Lab's delivery lock exists, or its receipt is unreadable, +// prepared or committed. +func RetireDeliveryInProgress(lab, id string) bool { + if !safeLoopID(id) || pathPresent(filepath.Join(lab, ".delivery-lock")) { + return true + } + state, found, err := retireReceiptState(lab, id) + return err != nil || (found && state != "published") +} diff --git a/lab/internal/molecule/next_actions_test.go b/lab/internal/molecule/next_actions_test.go new file mode 100644 index 0000000..72ac564 --- /dev/null +++ b/lab/internal/molecule/next_actions_test.go @@ -0,0 +1,306 @@ +package molecule + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/readiness" + "github.com/DanielNetzer/forgecell/lab/internal/resources" +) + +func nextKinds(actions []NextAction) []string { + out := []string{} + for _, a := range actions { + out = append(out, a.Kind) + } + return out +} + +func nextAction(actions []NextAction, kind string) (NextAction, bool) { + for _, a := range actions { + if a.Kind == kind { + return a, true + } + } + return NextAction{}, false +} + +func approvedDigest(r Record, digest string) bool { + for _, e := range r.Readiness.Events { + if e.Kind == "approved" && e.Digest == digest { + return true + } + } + return false +} + +// A by-design protected failure offers exactly accept-protected-failures and +// retire. The preview names the tests the engine then declares, the amend with +// the shown parent records them without approving, and the old digest is stale. +func TestNextActionsOfferAcceptProtectedFailuresWithTheDeclaredTests(t *testing.T) { + o, r := acceptFixture(t, "by-design") + actions := NextActions(context.Background(), o.LabDir, r, true, time.Minute) + if got := nextKinds(actions); !reflect.DeepEqual(got, []string{NextAcceptProtectedFailures, NextRetire}) { + t.Fatalf("actions %v", got) + } + a, _ := nextAction(actions, NextAcceptProtectedFailures) + parent := latestPlan(r) + if a.Digest != parent.Digest || len(a.Deltas) != 1 || a.Deltas[0].Package != acceptPackage || a.Deltas[0].Test != "TestX" || a.Deltas[0].ScopePath != acceptTest || a.Deltas[0].CheckID != acceptCheck { + t.Fatalf("action %+v", a) + } + before := ledgerBytes(t, r) + if again := NextActions(context.Background(), o.LabDir, r, true, time.Minute); !reflect.DeepEqual(again, actions) || string(before) != string(ledgerBytes(t, r)) { + t.Fatal("deriving the actions changed the ledger or is not repeatable") + } + amended, err := AmendFromLatest(context.Background(), o.LabDir, r.ID, AmendRequest{AcceptProtectedFailures: true, ExpectParent: a.Digest}) + if err != nil { + t.Fatal(err) + } + got := latestPlan(amended) + if got.Plan.ParentDigest != a.Digest || !reflect.DeepEqual(got.Plan.ExpectedProtectedDeltas, a.Deltas) || approvedDigest(amended, got.Digest) || amended.Readiness.Phase != "scope-waiting" { + t.Fatalf("revision %+v deltas %+v", got.Plan.ParentDigest, got.Plan.ExpectedProtectedDeltas) + } + // The new revision waits for its own approval: nothing more is offered but retire. + if got := nextKinds(NextActions(context.Background(), o.LabDir, amended, true, time.Minute)); !reflect.DeepEqual(got, []string{NextRetire}) { + t.Fatalf("after the amend: %v", got) + } + if _, err = AmendFromLatest(context.Background(), o.LabDir, r.ID, AmendRequest{AcceptProtectedFailures: true, ExpectParent: a.Digest}); !errors.Is(err, ErrStalePlan) { + t.Fatalf("stale parent: %v", err) + } +} + +// A protected failure the engine would refuse to declare is never offered, and +// neither is a re-run of checks for a check that failed on its own code. +func TestNextActionsLeaveOutAnAcceptanceTheEngineRefuses(t *testing.T) { + for _, mode := range []string{"candidate-fails", "other-package", "panic", "pass"} { + t.Run(mode, func(t *testing.T) { + o, r := acceptFixture(t, mode) + if got := nextKinds(NextActions(context.Background(), o.LabDir, r, true, time.Minute)); !reflect.DeepEqual(got, []string{NextRetire}) { + t.Fatalf("actions %v", got) + } + if _, err := AmendFromLatest(context.Background(), o.LabDir, r.ID, AmendRequest{AcceptProtectedFailures: true}); err == nil { + t.Fatal("the engine accepted what was not offered") + } + }) + } +} + +// A coding scope-change request offers exactly its paths and reason; the --add-scope +// amend with the shown parent records them and never approves. Invalid, +// too many or outdated requests are not offered. +func TestNextActionsScopeChangeOffersTheRequestedPaths(t *testing.T) { + t.Parallel() + o, _ := fixture(t) + writeStub(filepath.Join(filepath.Dir(o.SourceRoot), "harness"), []byte("#!/bin/sh\ncat >/dev/null\nprintf partial > code.txt\nprintf '%s' '{\"schemaVersion\":\"v1\",\"outcome\":\"scope-change\",\"reason\":\"helper needed\",\"paths\":[\"helper.go\"]}'\n"), 0700) + r, err := runApprovedFixture(t, o) + if err != nil { + t.Fatal(err) + } + if statePhase(r) != "scope-change" { + t.Fatalf("phase %s", statePhase(r)) + } + actions := NextActions(context.Background(), o.LabDir, r, true, time.Minute) + if got := nextKinds(actions); !reflect.DeepEqual(got, []string{NextApproveScopeChange, NextRetire}) { + t.Fatalf("actions %v", got) + } + a, _ := nextAction(actions, NextApproveScopeChange) + if a.Digest != latestPlan(r).Digest || !reflect.DeepEqual(a.Paths, []string{"helper.go"}) || a.Reason != "helper needed" { + t.Fatalf("action %+v", a) + } + for name, mutate := range map[string]func(*HarnessAttempt){ + "traversal": func(h *HarnessAttempt) { h.Coding.Paths = []string{"../x.go"} }, + "duplicate": func(h *HarnessAttempt) { h.Coding.Paths = []string{"a.go", "a.go"} }, + "too many": func(h *HarnessAttempt) { h.Coding.Paths = strings.Split("a b c d e f g h i", " ") }, + "no paths": func(h *HarnessAttempt) { h.Coding.Paths = nil }, + "older plan": func(h *HarnessAttempt) { h.PlanDigest = strings.Repeat("0", 64) }, + "not requested": func(h *HarnessAttempt) { h.Coding.Outcome = "blocked" }, + } { + changed := r + changed.HarnessAttempts = append([]HarnessAttempt(nil), r.HarnessAttempts...) + coding := *changed.HarnessAttempts[0].Coding + changed.HarnessAttempts[0].Coding = &coding + mutate(&changed.HarnessAttempts[0]) + if _, ok := nextAction(NextActions(context.Background(), o.LabDir, changed, true, time.Minute), NextApproveScopeChange); ok { + t.Errorf("%s: scope change offered", name) + } + } + amended, err := AmendFromLatest(context.Background(), o.LabDir, r.ID, AmendRequest{AddScope: []ScopeAddition{{Path: "helper.go", Reason: a.Reason}}, ExpectParent: a.Digest}) + if err != nil { + t.Fatal(err) + } + got := latestPlan(amended) + scope := got.Plan.Analysis.Scope + if got.Plan.ParentDigest != a.Digest || scope[len(scope)-1].Path != "helper.go" || approvedDigest(amended, got.Digest) || amended.Readiness.Phase != "scope-waiting" { + t.Fatalf("revision parent %s scope %+v phase %s", got.Plan.ParentDigest, scope, amended.Readiness.Phase) + } + if _, ok := nextAction(NextActions(context.Background(), o.LabDir, amended, true, time.Minute), NextApproveScopeChange); ok { + t.Fatal("the scope change is still offered after its revision") + } +} + +// A Molecule stopped below the soft resource floor before its checks started is +// offered a re-run of checks after recovery, as forgecell recover --confirm-stopped +// then amend --continuation checks-only do; the checks-only revision runs the +// checks on the same code without coding again. +func TestNextActionsRerunChecksFromAResourceRefusedCheckPendingMolecule(t *testing.T) { + t.Parallel() + o, _ := fixture(t) + low := resources.Readings{Volumes: []resources.Volume{{Path: "/lab", Free: resources.GiB, Total: 100 * resources.GiB}}, Memory: 8 * resources.GiB, MemoryKnown: true} + o.Resources = &resources.Guard{Floors: resources.DefaultFloors().Clamp(), Reader: resources.ReaderFunc(func() resources.Readings { return low }), Sleep: func(context.Context, time.Duration) bool { return true }} + r, err := runApprovedFixture(t, o) + if err != nil { + t.Fatal(err) + } + if r.Status != "blocked" || statePhase(r) != "check-pending" || len(r.VerificationAttempts) != 0 { + t.Fatalf("fixture %s %s", r.Status, statePhase(r)) + } + actions := NextActions(context.Background(), o.LabDir, r, true, time.Minute) + if got := nextKinds(actions); !reflect.DeepEqual(got, []string{NextRerunChecks, NextRetire}) { + t.Fatalf("actions %v", got) + } + a, _ := nextAction(actions, NextRerunChecks) + if !a.RecoverFirst || a.Digest != latestPlan(r).Digest { + t.Fatalf("action %+v", a) + } + // The checks-only amend alone is refused here; recovery comes first. + if _, err = AmendFromLatest(context.Background(), o.LabDir, r.ID, AmendRequest{Continuation: "checks-only", ExpectParent: a.Digest}); err == nil { + t.Fatal("checks-only was accepted before recovery") + } + recovered, err := Recover(context.Background(), o.LabDir, r.ID, a.Digest, true) + if err != nil { + t.Fatal(err) + } + after := NextActions(context.Background(), o.LabDir, recovered, true, time.Minute) + if b, ok := nextAction(after, NextRerunChecks); !ok || b.RecoverFirst || b.Digest != a.Digest { + t.Fatalf("after recovery %+v", after) + } + amended, err := AmendFromLatest(context.Background(), o.LabDir, r.ID, AmendRequest{Continuation: "checks-only", ExpectParent: a.Digest}) + if err != nil { + t.Fatal(err) + } + got := latestPlan(amended) + if got.Plan.Continuation != "checks-only" || got.Plan.ParentDigest != a.Digest || approvedDigest(amended, got.Digest) { + t.Fatalf("revision %+v", got.Plan) + } + o.Resources, o.Approve = nil, got.Digest + done, err := Run(context.Background(), o) + if err != nil { + t.Fatal(err) + } + if done.Verification == nil || !done.Verification.RequiredChecksPassed || len(done.HarnessAttempts) != 1 { + t.Fatalf("checks-only run: %s %+v attempts %d", statePhase(done), done.Verification, len(done.HarnessAttempts)) + } +} + +// An abandoned running Molecule is offered recovery only, and only as the loop's +// recovery proposal would name it with evidence Recover accepts. A ledger the +// loop names but whose harness evidence Recover refuses, one with no +// reconciled attempt, and one written within the timeout are offered nothing. +func TestNextActionsRecoverOnlyForAnAbandonedMolecule(t *testing.T) { + t.Parallel() + o, _ := fixture(t) + r, err := runApprovedFixture(t, o) + if err != nil { + t.Fatal(err) + } + old := time.Now().Add(-24 * time.Hour) + r.Status = "running" + if err = save(r); err != nil { + t.Fatal(err) + } + if err = os.Chtimes(filepath.Join(o.LabDir, "ledgers", r.ID+".json"), old, old); err != nil { + t.Fatal(err) + } + actions := NextActions(context.Background(), o.LabDir, r, true, triggersTimeout) + if got := nextKinds(actions); !reflect.DeepEqual(got, []string{NextRecover}) || actions[0].Digest != latestPlan(r).Digest { + t.Fatalf("actions %+v", actions) + } + // A ledger written within the timeout may still be live: nothing is offered. + if got := NextActions(context.Background(), o.LabDir, r, true, 48*time.Hour); len(got) != 0 { + t.Fatalf("a possibly live Molecule offered %v", nextKinds(got)) + } + for _, attempt := range []string{"tree", "unreconciled", ""} { + t.Run(attempt, func(t *testing.T) { + lo, _ := loopFixture(t, policyScopeAtom, defaultReviewAtom, "") + crashed := triggersInterrupted(t, lo, attempt) + if got := NextActions(context.Background(), lo.LabDir, crashed, true, triggersTimeout); len(got) != 0 { + t.Fatalf("actions %v", nextKinds(got)) + } + if _, err := Recover(context.Background(), lo.LabDir, crashed.ID, latestPlan(crashed).Digest, true); err == nil { + t.Fatal("Recover accepted what was not offered") + } + }) + } +} + +// A stopped intake offers a fresh intake with its bounded open questions; a +// retired Molecule offers nothing, and a held delivery lock or an unpublished +// receipt keeps retire out. +func TestNextActionsFreshIntakeAndRetire(t *testing.T) { + t.Parallel() + lab := resolvedTempDir(t) + if err := os.MkdirAll(filepath.Join(lab, "ledgers"), 0700); err != nil { + t.Fatal(err) + } + questions := []readiness.Question{} + for i := 0; i < maxNextActionQuestions+2; i++ { + questions = append(questions, readiness.Question{Question: "Which flag?"}) + } + r := Record{ID: "mol-12-abc", Status: "blocked", Issue: Issue{Number: 12, Repo: "acme/widgets"}, Analysis: &readiness.Analysis{Questions: questions}} + actions := NextActions(context.Background(), lab, r, true, time.Minute) + if got := nextKinds(actions); !reflect.DeepEqual(got, []string{NextFreshIntake, NextRetire}) { + t.Fatalf("actions %v", got) + } + if len(actions[0].Questions) != maxNextActionQuestions || actions[0].Digest != "" { + t.Fatalf("fresh intake %+v", actions[0]) + } + exhausted := r + exhausted.Analysis, exhausted.Notes = nil, []string{"Supplemental evidence passes exhausted after 2 passes."} + if got := nextKinds(NextActions(context.Background(), lab, exhausted, true, time.Minute)); !reflect.DeepEqual(got, []string{NextFreshIntake, NextRetire}) { + t.Fatalf("exhausted: %v", got) + } + other := exhausted + other.Notes = []string{"Something else"} + if got := nextKinds(NextActions(context.Background(), lab, other, true, time.Minute)); !reflect.DeepEqual(got, []string{NextRetire}) { + t.Fatalf("other block: %v", got) + } + retired := r + retired.Status, retired.Retired = StatusRetired, &Retirement{At: "2026-10-26T10:00:00Z", Reason: "stale", PriorStatus: "blocked"} + if got := NextActions(context.Background(), lab, retired, true, time.Minute); len(got) != 0 { + t.Fatalf("retired: %v", nextKinds(got)) + } + if RetireDeliveryInProgress(lab, r.ID) { + t.Fatal("no delivery, yet in progress") + } + deliveries := filepath.Join(lab, "deliveries") + if err := os.MkdirAll(deliveries, 0700); err != nil { + t.Fatal(err) + } + for state, inProgress := range map[string]bool{"published": false, "prepared": true, "committed": true, "bogus": true} { + if err := os.WriteFile(filepath.Join(deliveries, r.ID+".json"), []byte(`{"state":"`+state+`"}`), 0600); err != nil { + t.Fatal(err) + } + _, offered := nextAction(NextActions(context.Background(), lab, r, true, time.Minute), NextRetire) + if RetireDeliveryInProgress(lab, r.ID) != inProgress || offered == inProgress { + t.Errorf("receipt %s: in progress %t, retire offered %t", state, RetireDeliveryInProgress(lab, r.ID), offered) + } + } + if err := os.Remove(filepath.Join(deliveries, r.ID+".json")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(lab, ".delivery-lock"), 0700); err != nil { + t.Fatal(err) + } + if _, offered := nextAction(NextActions(context.Background(), lab, r, true, time.Minute), NextRetire); offered || !RetireDeliveryInProgress(lab, r.ID) { + t.Fatal("retire offered under the Lab delivery lock") + } + if !RetireDeliveryInProgress(lab, "../x") { + t.Fatal("an unsafe id is not refused") + } +} diff --git a/lab/internal/ui/actions.go b/lab/internal/ui/actions.go new file mode 100644 index 0000000..eec9ad6 --- /dev/null +++ b/lab/internal/ui/actions.go @@ -0,0 +1,457 @@ +package ui + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "reflect" + "strings" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" +) + +// "Why it's blocked · What you can do" (#243). GET /api/molecules/{id}/actions +// lists the next actions molecule.NextActions derives from the ledger, with +// the blocking reason bounded and redacted; it previews, read-only through +// git, the exact tests accept-protected-failures would declare. Each action is +// a POST with the gate write protection (cookie, tab header, exact Origin, +// Fetch Metadata and one strict JSON object of at most 4 KiB), taking the +// exact plan digest it was shown: +// +// POST /api/molecules/{id}/actions/accept-failures {"digest","deltas"} +// → molecule.AmendFromLatest{AcceptProtectedFailures} (amend --accept-protected-failures) +// POST /api/molecules/{id}/actions/scope-change {"digest","paths","reason"} +// → molecule.AmendFromLatest{AddScope} (amend --add-scope PATH --reason TEXT) +// POST /api/molecules/{id}/actions/rerun-checks {"digest","confirmStopped"} +// → molecule.Recover first when the action says so, then +// molecule.AmendFromLatest{Continuation: checks-only} +// POST /api/molecules/{id}/actions/recover {"digest","confirmStopped"} +// → molecule.Recover(confirmedStopped) +// +// None of them approves: a revision they record waits for its own exact +// approval through the scope gate. An action the engine would refuse today is +// refused before the engine, the engine refuses a stale digest under its lock, +// and committed comes only from the ledger read-back. Accept-failures also +// sends the DeltasDigest of the tests it listed and is refused as stale when +// the tests derived again differ; scope-change sends the ledger's exact paths. +// When re-run checks recovered first and the amend after it fails, the refusal +// is recovered-not-amended: the recovery stays recorded. Fresh intake reuses POST +// /api/runs and retire the retire route; demo refuses every action. + +// UnblockAction is the action name /api/lab lists for the next-action routes. +const UnblockAction = "unblock Molecule" + +// Action kinds, the last path segment of each POST route. +const ( + ActionAcceptFailures = "accept-failures" + ActionScopeChange = "scope-change" + ActionRerunChecks = "rerun-checks" + ActionRecover = "recover" + ActionFreshIntake = "fresh-intake" + ActionRetire = "retire" +) + +// actionKinds maps each POST route's kind to its engine next action. +var actionKinds = map[string]string{ + ActionAcceptFailures: molecule.NextAcceptProtectedFailures, + ActionScopeChange: molecule.NextApproveScopeChange, + ActionRerunChecks: molecule.NextRerunChecks, + ActionRecover: molecule.NextRecover, +} + +// maxActionText bounds a shown path, test name or question in runes. +const maxActionText = 300 + +// ActionsView is GET /api/molecules/{id}/actions. +type ActionsView struct { + MoleculeID string `json:"moleculeId"` + // Blocking is why the Molecule stopped, bounded and redacted; "" when nothing + // blocks it. + Blocking string `json:"blocking,omitempty"` + Actions []NextActionView `json:"actions"` + // NotOffered are actions the engine would accept that this page cannot offer + // exactly, each with the plain reason. + NotOffered []NotOfferedView `json:"notOffered"` +} + +// NotOfferedView is one action left out, with why, in plain words. +type NotOfferedView struct { + Kind string `json:"kind"` + Label string `json:"label"` + Reason string `json:"reason"` +} + +// scopePathsNotShown is why a scope change whose paths cannot be shown exactly +// is not offered: the POST sends the paths it showed, and they must be the +// ledger's own. +const scopePathsNotShown = "A requested path cannot be shown exactly as the coding agent wrote it, so this page does not offer to approve it; review the request in the Molecule's ledger." + +// NextActionView is one offered action. Digest is the plan digest its POST must +// send back; ConfirmStopped means the POST needs the operator's explicit +// "I confirm it is stopped". Paths are the ledger's exact paths, which the +// POST sends back unchanged. DeltasDigest binds accept-failures to exactly the +// tests listed: its POST sends it back. +type NextActionView struct { + Kind string `json:"kind"` + Label string `json:"label"` + Copy string `json:"copy"` + Digest string `json:"digest,omitempty"` + RecoverFirst bool `json:"recoverFirst,omitempty"` + ConfirmStopped bool `json:"confirmStopped,omitempty"` + Paths []string `json:"paths,omitempty"` + Reason string `json:"reason,omitempty"` + Deltas []DeltaView `json:"deltas,omitempty"` + DeltasDigest string `json:"deltasDigest,omitempty"` + Questions []string `json:"questions,omitempty"` + Issue int64 `json:"issue,omitempty"` + IssueURL string `json:"issueUrl,omitempty"` +} + +// DeltasDigest is the SHA-256 of the exact protected deltas an +// accept-failures preview lists, in order: package, test, check and declaring +// file of each, as the engine derives them. +func DeltasDigest(deltas []readiness.ProtectedDelta) string { + raw, err := json.Marshal(deltas) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// shownExactly reports paths each of which is shown as itself, so the POST can +// send back the ledger's exact path. +func shownExactly(x redactor, paths []string) bool { + for _, p := range paths { + if actionText(x, p) != p { + return false + } + } + return true +} + +// DeltaView is one test accept-protected-failures declares: its package, test, +// the check it failed on and the in-scope test file that declares it. +type DeltaView struct { + Package string `json:"package"` + Test string `json:"test"` + Check string `json:"check"` + File string `json:"file"` +} + +var actionLabels = map[string][2]string{ + ActionAcceptFailures: {"Accept by-design test failures", "Only protected regression tests failed, and the new code passed every check. Accepting records a new version of the plan that expects exactly these failures; you approve it separately."}, + ActionScopeChange: {"Approve the requested scope change", "The coding agent asked to change more files. This records a new version of the plan with these paths; you approve it separately."}, + ActionRerunChecks: {"Re-run checks on the same code", "Coding finished and its output is kept, but the checks did not complete for reasons outside the code. This records a checks-only version of the plan; you approve it separately."}, + ActionRecover: {"Recover this Molecule", "The process that ran it is gone. Recovery records the interruption and keeps the work; it never retries coding."}, + ActionFreshIntake: {"Start a fresh intake", "Intake stopped before a plan. Answer the questions in the Issue on GitHub, then start a fresh intake."}, + ActionRetire: {"Retire", "Retire it from the Danger zone below if it is no longer needed."}, +} + +func deltaViews(x redactor, deltas []readiness.ProtectedDelta) []DeltaView { + out := make([]DeltaView, 0, len(deltas)) + for _, d := range deltas { + out = append(out, DeltaView{Package: actionText(x, d.Package), Test: actionText(x, d.Test), Check: actionText(x, d.CheckID), File: actionText(x, d.ScopePath)}) + } + return out +} + +func actionText(x redactor, s string) string { + return harness.ReviewDisplayText(x.text(redactSecrets(s)), maxActionText) +} + +// analysisTimeout is the Molecule's Formula, whose AnalysisTimeout is the +// loop's recovery bound; an unreadable snapshot gives the default bound. +func analysisTimeout(r molecule.Record) (f formula.Formula) { + if parsed, err := formula.Parse([]byte(r.FormulaSnapshot.YAML)); err == nil { + f = parsed + } + return f +} + +// nextActions is the engine's next actions for record, as this Lab UI offers +// them: none while a gate job of this UI runs for it, recovery only for work no +// one here runs, and a fresh intake only with a source checkout. +func (h *handler) nextActions(ctx context.Context, record molecule.Record, preview bool) []molecule.NextAction { + if h.jobs.busy(record.ID) { + return nil + } + var out []molecule.NextAction + for _, a := range h.deriveActions(ctx, h.opts.LabDir, record, preview, molecule.AnalysisTimeout(analysisTimeout(record))) { + switch { + case a.Kind == molecule.NextRecover && h.runner(record, false) != RunnerOther: + case a.Kind == molecule.NextFreshIntake && h.opts.SourceRoot == "": + default: + out = append(out, a) + } + } + return out +} + +func uiKind(kind string) string { + for k, v := range actionKinds { + if v == kind { + return k + } + } + switch kind { + case molecule.NextFreshIntake: + return ActionFreshIntake + case molecule.NextRetire: + return ActionRetire + } + return "" +} + +func (h *handler) actionsView(ctx context.Context, record molecule.Record) ActionsView { + x := newRedactor(h.opts.LabDir, record) + v := ActionsView{MoleculeID: record.ID, Actions: []NextActionView{}, NotOffered: []NotOfferedView{}} + if Lane(record.Status, phase(record), nil) == LaneBlocked { + v.Blocking = blockedReason(record) + } + for _, a := range h.nextActions(ctx, record, true) { + kind := uiKind(a.Kind) + if kind == "" { + continue + } + text := actionLabels[kind] + av := NextActionView{Kind: kind, Label: text[0], Copy: text[1], Digest: a.Digest, RecoverFirst: a.RecoverFirst} + switch kind { + case ActionRecover: + av.ConfirmStopped = true + case ActionRerunChecks: + av.ConfirmStopped = a.RecoverFirst + if a.RecoverFirst { + av.Copy = "Checks never ran: the Lab stopped before them, for example below a resource floor. Forgecell recovers the Molecule first, then records a checks-only version of the plan; you approve it separately." + } + case ActionScopeChange: + if !shownExactly(x, a.Paths) { + v.NotOffered = append(v.NotOffered, NotOfferedView{Kind: kind, Label: text[0], Reason: scopePathsNotShown}) + continue + } + av.Paths = append(av.Paths, a.Paths...) + av.Reason = actionText(x, a.Reason) + case ActionAcceptFailures: + av.Deltas, av.DeltasDigest = deltaViews(x, a.Deltas), DeltasDigest(a.Deltas) + case ActionFreshIntake: + for _, q := range a.Questions { + av.Questions = append(av.Questions, actionText(x, q)) + } + av.Issue, av.IssueURL = record.Issue.Number, issueURL(record.Issue) + } + v.Actions = append(v.Actions, av) + } + return v +} + +// actionRequest is every action POST. Deltas is the DeltasDigest of the tests +// accept-failures listed, and only that action takes it. +type actionRequest struct { + Digest string `json:"digest"` + Deltas string `json:"deltas"` + Paths []string `json:"paths"` + Reason string `json:"reason"` + ConfirmStopped bool `json:"confirmStopped"` +} + +// ActionEngineRequest is the engine amend request of an action that records a +// revision, exactly the request forgecell amend builds for the same flags, +// with ExpectParent set to the shown digest: --accept-protected-failures, +// --add-scope PATH --reason TEXT for each path, or --continuation checks-only. +func ActionEngineRequest(kind, digest string, paths []string, reason string) (molecule.AmendRequest, bool) { + req := molecule.AmendRequest{ExpectParent: digest} + switch kind { + case ActionAcceptFailures: + req.AcceptProtectedFailures = true + case ActionScopeChange: + for _, p := range paths { + req.AddScope = append(req.AddScope, molecule.ScopeAddition{Path: p, Reason: reason}) + } + case ActionRerunChecks: + req.Continuation = "checks-only" + default: + return req, false + } + return req, true +} + +// valid checks the request's shape for kind before anything is read: only +// the fields that kind takes, bounded. +func (q actionRequest) valid(kind string) bool { + if !sha256Hex.MatchString(q.Digest) { + return false + } + if (kind == ActionAcceptFailures) != (q.Deltas != "") || (q.Deltas != "" && !sha256Hex.MatchString(q.Deltas)) { + return false + } + switch kind { + case ActionScopeChange: + if len(q.Paths) == 0 || len(q.Paths) > molecule.MaxNextActionPaths || q.ConfirmStopped || readiness.ValidateOperatorText(q.Reason, maxUIAmendText) != nil { + return false + } + for _, p := range q.Paths { + if readiness.ValidatePath(p) != nil { + return false + } + } + return true + case ActionRecover: + return len(q.Paths) == 0 && q.Reason == "" + default: + return len(q.Paths) == 0 && q.Reason == "" && (kind == ActionRerunChecks || !q.ConfirmStopped) + } +} + +// ActionResultView is every accepted action response. Committed is true only +// when the ledger read back records the action: a revision whose parent is the +// submitted digest waiting for approval, or for recovery the interruption. +type ActionResultView struct { + MoleculeID string `json:"moleculeId"` + Kind string `json:"kind"` + Committed bool `json:"committed"` + Recovered bool `json:"recovered,omitempty"` + Revision int `json:"revision,omitempty"` + Digest string `json:"digest,omitempty"` + Changes string `json:"changes,omitempty"` + Deltas []DeltaView `json:"deltas,omitempty"` + Canonical MoleculeState `json:"canonical"` +} + +// moleculeAction serves one POST action after the write protection passed and +// the ledger was read. +func (h *handler) moleculeAction(w http.ResponseWriter, r *http.Request, id, kind string) { + var req actionRequest + if decodeGate(w, r, &req) != nil || !req.valid(kind) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "send one small JSON object with exactly the action's fields and the plan digest shown") + return + } + if kind != ActionScopeChange { + req.Paths = nil + } + // One amendment or recovery at a time from this UI, so the action checked + // here is the one the engine performs. + h.amendMu.Lock() + defer h.amendMu.Unlock() + record, err := h.records(h.opts.LabDir, id) + switch { + case err == nil: + case errors.Is(err, os.ErrNotExist): + h.refuseGate(w, http.StatusNotFound, "missing", "no such Molecule") + return + default: + h.refuseGate(w, http.StatusUnprocessableEntity, "refused", "the ledger was refused: it is invalid, unsafe, oversized or not this Lab's") + return + } + if h.refuseRetiredAmend(w, record) { + return + } + if h.jobs.busy(record.ID) { + h.refuseGate(w, http.StatusConflict, "busy", "a gate job is still running for this Molecule; nothing was recorded") + return + } + ctx, cancel := context.WithTimeout(h.jobs.ctx, amendTimeout) + defer cancel() + // Accept-failures derives its tests again, read-only, so the POST binds the + // exact list it was shown, not only the plan digest. + var offered *molecule.NextAction + for _, a := range h.nextActions(ctx, record, kind == ActionAcceptFailures) { + if a.Kind == actionKinds[kind] { + a := a + offered = &a + break + } + } + if offered == nil || (kind == ActionScopeChange && !shownExactly(newRedactor(h.opts.LabDir, record), offered.Paths)) { + h.refuseGate(w, http.StatusConflict, "not-offered", "the engine would refuse this action in the Molecule's current state; nothing was recorded") + return + } + if offered.Digest != req.Digest || (kind == ActionScopeChange && !reflect.DeepEqual(offered.Paths, req.Paths)) { + h.refuseGate(w, http.StatusConflict, "stale", amendStaleMessage) + return + } + if kind == ActionAcceptFailures && (len(offered.Deltas) == 0 || DeltasDigest(offered.Deltas) != req.Deltas) { + h.refuseGate(w, http.StatusConflict, "stale", "the failing tests changed since they were shown: a newer verification failed differently; reload the Molecule and review the tests listed now; nothing was recorded") + return + } + if (kind == ActionRecover || offered.RecoverFirst) && !req.ConfirmStopped { + h.refuseGate(w, http.StatusBadRequest, "confirm-stopped", "confirm that every Lab process for this Molecule has stopped; nothing was recorded") + return + } + v := ActionResultView{MoleculeID: record.ID, Kind: kind} + if kind == ActionRecover || offered.RecoverFirst { + if _, err := guardedRecover(ctx, h.recover, h.opts.LabDir, record.ID, req.Digest); err != nil { + h.amendRefused(w, err) + return + } + v.Recovered = h.recovered(record.ID, req.Digest) + if kind == ActionRecover { + v.Committed = v.Recovered + v.Canonical = h.moleculeState(record.ID) + h.json(w, http.StatusOK, v) + return + } + } + engineReq, _ := ActionEngineRequest(kind, req.Digest, req.Paths, req.Reason) + if _, err := guardedAmend(ctx, h.amend, h.opts.LabDir, record.ID, engineReq); err != nil { + if offered.RecoverFirst { + // The recovery is recorded already; only the amend after it failed. + h.refuseGate(w, http.StatusConflict, "recovered-not-amended", "recovery was recorded, but no new plan version exists: the checks-only amend after it was refused; reload the Molecule") + return + } + h.amendRefused(w, err) + return + } + read := h.amendView(record.ID, req.Digest) + v.Committed, v.Revision, v.Digest, v.Canonical = read.Committed, read.Revision, read.Digest, read.Canonical + if after, err := h.records(h.opts.LabDir, record.ID); v.Committed && err == nil && len(after.Readiness.Plans) > 1 { + plans := after.Readiness.Plans + next, parent := plans[len(plans)-1].Plan, plans[len(plans)-2].Plan + v.Changes = harness.ReviewDisplayText(molecule.AmendDiffSummary(parent, next), maxReasonRunes) + if kind == ActionAcceptFailures { + v.Deltas = deltaViews(newRedactor(h.opts.LabDir, after), next.ExpectedProtectedDeltas) + } + } + h.json(w, http.StatusOK, v) +} + +// recovered reads the ledger back: the Molecule is no longer running and its +// latest plan, still digest, is interrupted. +func (h *handler) recovered(id, digest string) bool { + r, err := h.records(h.opts.LabDir, id) + if err != nil || active(r.Status) || r.Readiness == nil || len(r.Readiness.Plans) == 0 { + return false + } + p := phase(r) + return r.Readiness.Plans[len(r.Readiness.Plans)-1].Digest == digest && (p == "interrupted" || p == "scope-violation") +} + +// guardedRecover runs the engine's recovery with the operator's confirmation; +// a panic becomes a refusal. +func guardedRecover(ctx context.Context, run func(context.Context, string, string, string, bool) (molecule.Record, error), lab, id, digest string) (r molecule.Record, err error) { + defer func() { + if recover() != nil { + err = errGatePanic + } + }() + return run(ctx, lab, id, digest, true) +} + +// actionRoute reports a POST action path, its Molecule id and its kind. +func actionRoute(path string) (id, kind string, ok bool) { + rest, found := strings.CutPrefix(path, "/api/molecules/") + if !found { + return "", "", false + } + id, kind, found = strings.Cut(rest, "/actions/") + if !found || strings.Contains(id, "/") || actionKinds[kind] == "" { + return "", "", false + } + return id, kind, true +} diff --git a/lab/internal/ui/actions_test.go b/lab/internal/ui/actions_test.go new file mode 100644 index 0000000..02b5d44 --- /dev/null +++ b/lab/internal/ui/actions_test.go @@ -0,0 +1,549 @@ +package ui + +import ( + "context" + "errors" + "net/http" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" +) + +// actionSeams replaces the engine calls behind the next actions: the derived +// actions are fixed, and recover and amend record each call in order and write +// the ledger as the engine would, so the read-back can see it. No git, harness +// or check process starts. +type actionSeams struct { + mu sync.Mutex + offered []molecule.NextAction + previews []bool + log []string + amends []molecule.AmendRequest + recovers []bool + recoverErr error + amendErr error +} + +func (s *actionSeams) calls() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.log...) +} + +func installActions(t *testing.T, h http.Handler, s *actionSeams) *actionSeams { + t.Helper() + hh := h.(*handler) + hh.deriveActions = func(_ context.Context, _ string, _ molecule.Record, preview bool, _ time.Duration) []molecule.NextAction { + s.mu.Lock() + defer s.mu.Unlock() + s.previews = append(s.previews, preview) + return append([]molecule.NextAction(nil), s.offered...) + } + hh.recover = func(_ context.Context, lab, id, digest string, confirmed bool) (molecule.Record, error) { + s.mu.Lock() + s.log, s.recovers = append(s.log, "recover"), append(s.recovers, confirmed) + err := s.recoverErr + s.mu.Unlock() + if err != nil { + return molecule.Record{}, err + } + r, err := molecule.ReadRecord(lab, id) + if err != nil { + return r, err + } + r.Status, r.Readiness.Phase = "blocked", "interrupted" + return r, saveRecord(lab, r) + } + hh.amend = func(_ context.Context, lab, id string, req molecule.AmendRequest) (molecule.Record, error) { + s.mu.Lock() + s.log, s.amends = append(s.log, "amend"), append(s.amends, req) + err := s.amendErr + s.mu.Unlock() + if err != nil { + return molecule.Record{}, err + } + r, err := molecule.ReadRecord(lab, id) + if err != nil { + return r, err + } + latest := r.Readiness.Plans[len(r.Readiness.Plans)-1] + if req.ExpectParent != latest.Digest { + return r, molecule.ErrStalePlan + } + next := latest.Plan + next.Revision, next.ParentDigest = latest.Plan.Revision+1, latest.Digest + if req.Continuation != "" || req.AcceptProtectedFailures { + next.Continuation = "checks-only" + } + r.Readiness.Plans = append(r.Readiness.Plans, readiness.Proposal{Plan: next, Digest: successorDigest}) + r.Status, r.Readiness.Phase = "waiting", "scope-waiting" + return r, saveRecord(lab, r) + } + return s +} + +// blockedFixture is gateFixture after a failed verification: blocked, with its +// one plan as the parent the actions bind. +func blockedFixture(t *testing.T) (string, molecule.Record, string) { + t.Helper() + lab, r, digest := gateFixture(t) + r.Status, r.Readiness.Phase = "failed", "failed" + writeRecord(t, lab, r) + return lab, r, digest +} + +func actionBody(digest string) string { return `{"digest":"` + digest + `"}` } + +// testDeltas are the tests the accept-failures fixtures offer; acceptBody binds +// them by their DeltasDigest, as the served page does. +var testDeltas = []readiness.ProtectedDelta{{CheckID: "go-test-lab", Package: "example.test/lab/pin", Test: "TestX", ScopePath: "lab/pin/pin_test.go"}} + +func acceptBody(digest string, deltas []readiness.ProtectedDelta) string { + return `{"digest":"` + digest + `","deltas":"` + DeltasDigest(deltas) + `"}` +} + +func acceptOffer(digest string) molecule.NextAction { + return molecule.NextAction{Kind: molecule.NextAcceptProtectedFailures, Digest: digest, Deltas: testDeltas} +} + +func actionPath(id, kind string) string { return "/api/molecules/" + id + "/actions/" + kind } + +// The engine wiring: the actions are molecule.NextActions and recover is +// molecule.Recover, and /api/lab lists both new actions with Request changes last. +func TestUIActionsAreWiredToTheEngine(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}).(*handler) + if reflect.ValueOf(h.deriveActions).Pointer() != reflect.ValueOf(molecule.NextActions).Pointer() || reflect.ValueOf(h.recover).Pointer() != reflect.ValueOf(molecule.Recover).Pointer() { + t.Fatal("the next actions are not wired to molecule.NextActions and molecule.Recover") + } + for _, source := range []string{"", "/src/checkout"} { + lab := viewOf[LabView](t, do(t, testHandler(t, Options{LabDir: t.TempDir(), SourceRoot: source}), authed("/api/lab")), http.StatusOK) + if !slices.Contains(lab.Actions, UnblockAction) || !slices.Contains(lab.Actions, BulkRetireAction) || lab.Actions[len(lab.Actions)-1] != AmendAction { + t.Errorf("source %q: actions %v", source, lab.Actions) + } + } + for path, want := range map[string][2]string{ + "/api/molecules/mol-1/actions/accept-failures": {"mol-1", ActionAcceptFailures}, + "/api/molecules/mol-1/actions/scope-change": {"mol-1", ActionScopeChange}, + "/api/molecules/mol-1/actions/rerun-checks": {"mol-1", ActionRerunChecks}, + "/api/molecules/mol-1/actions/recover": {"mol-1", ActionRecover}, + } { + if id, kind, ok := actionRoute(path); !ok || id != want[0] || kind != want[1] { + t.Errorf("%s routed to %q %q %t", path, id, kind, ok) + } + } + for _, path := range []string{"/api/molecules/mol-1/actions/approve", "/api/molecules/mol-1/actions/retire", "/api/molecules/mol-1/actions/fresh-intake", "/api/molecules/a/b/actions/recover", "/api/molecules/mol-1/actions/", "/api/molecules/mol-1/actions"} { + if _, _, ok := actionRoute(path); ok { + t.Errorf("%s routed", path) + } + } +} + +// GET /actions lists exactly what the engine offers, with its digest, the exact +// tests accept-failures declares, the scope paths and reason, the stopped +// confirmation where recovery runs, and a fresh intake only with a source +// checkout. It previews; nothing is written. +func TestUIActionsViewListsTheEngineActions(t *testing.T) { + lab, r, digest := blockedFixture(t) + r.Analysis = &readiness.Analysis{} + writeRecord(t, lab, r) + offered := []molecule.NextAction{ + acceptOffer(digest), + {Kind: molecule.NextApproveScopeChange, Digest: digest, Paths: []string{"lab/x/new.go"}, Reason: "helper needed"}, + {Kind: molecule.NextRerunChecks, Digest: digest, RecoverFirst: true}, + {Kind: molecule.NextRecover, Digest: digest}, + {Kind: molecule.NextFreshIntake, Questions: []string{"Which flag?"}}, + {Kind: molecule.NextRetire}, + } + for _, source := range []string{"", "/src/checkout"} { + h := testHandler(t, Options{LabDir: lab, SourceRoot: source}) + s := installActions(t, h, &actionSeams{offered: offered}) + before := snapshot(t, lab) + v := viewOf[ActionsView](t, do(t, h, authed("/api/molecules/"+r.ID+"/actions")), http.StatusOK) + want := []string{ActionAcceptFailures, ActionScopeChange, ActionRerunChecks, ActionRecover, ActionRetire} + if source != "" { + want = []string{ActionAcceptFailures, ActionScopeChange, ActionRerunChecks, ActionRecover, ActionFreshIntake, ActionRetire} + } + var kinds []string + for _, a := range v.Actions { + kinds = append(kinds, a.Kind) + if a.Label == "" || a.Copy == "" || strings.Contains(a.Copy, "CLI") { + t.Errorf("%s: label %q copy %q", a.Kind, a.Label, a.Copy) + } + } + if !reflect.DeepEqual(kinds, want) || v.MoleculeID != r.ID || v.Blocking == "" || v.NotOffered == nil || len(v.NotOffered) != 0 { + t.Fatalf("source %q: kinds %v blocking %q not offered %v", source, kinds, v.Blocking, v.NotOffered) + } + accept, scope, rerun, recover := v.Actions[0], v.Actions[1], v.Actions[2], v.Actions[3] + if accept.Digest != digest || accept.DeltasDigest != DeltasDigest(testDeltas) || !reflect.DeepEqual(accept.Deltas, []DeltaView{{Package: "example.test/lab/pin", Test: "TestX", Check: "go-test-lab", File: "lab/pin/pin_test.go"}}) || accept.ConfirmStopped { + t.Errorf("accept %+v", accept) + } + if !reflect.DeepEqual(scope.Paths, []string{"lab/x/new.go"}) || scope.Reason != "helper needed" || scope.ConfirmStopped { + t.Errorf("scope %+v", scope) + } + if !rerun.RecoverFirst || !rerun.ConfirmStopped || !recover.ConfirmStopped { + t.Errorf("rerun %+v recover %+v", rerun, recover) + } + if source != "" { + if fresh := v.Actions[4]; fresh.Issue != r.Issue.Number || fresh.IssueURL != r.Issue.URL || !reflect.DeepEqual(fresh.Questions, []string{"Which flag?"}) || fresh.Digest != "" { + t.Errorf("fresh %+v", fresh) + } + } + if !reflect.DeepEqual(s.previews, []bool{true}) || len(s.calls()) != 0 { + t.Fatalf("previews %v calls %v", s.previews, s.calls()) + } + if after := snapshot(t, lab); after != before { + t.Fatal("reading the actions changed the Lab") + } + } + // Sample data has no actions view. + if w := do(t, testHandler(t, Options{Demo: true}), authed("/api/molecules/demo-101/actions")); w.Code != http.StatusNotFound { + t.Fatalf("demo actions view: %d", w.Code) + } +} + +// Accept by-design failures and Approve the requested scope change each send the +// CLI's engine request with the shown digest, never approve, and show committed +// only from the read-back of a successor of exactly that digest. +func TestUIActionsRecordTheRevisionWithoutApproving(t *testing.T) { + for _, c := range []struct { + kind, body string + offered molecule.NextAction + want molecule.AmendRequest + }{ + {ActionAcceptFailures, `,"deltas":"` + DeltasDigest(testDeltas) + `"`, molecule.NextAction{Kind: molecule.NextAcceptProtectedFailures, Deltas: testDeltas}, molecule.AmendRequest{AcceptProtectedFailures: true}}, + {ActionScopeChange, `,"paths":["lab/x/new.go"],"reason":"Edited by the operator"`, molecule.NextAction{Kind: molecule.NextApproveScopeChange, Paths: []string{"lab/x/new.go"}, Reason: "helper needed"}, molecule.AmendRequest{AddScope: []molecule.ScopeAddition{{Path: "lab/x/new.go", Reason: "Edited by the operator"}}}}, + {ActionRerunChecks, "", molecule.NextAction{Kind: molecule.NextRerunChecks}, molecule.AmendRequest{Continuation: "checks-only"}}, + } { + t.Run(c.kind, func(t *testing.T) { + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + engine := refuseEngine(t, h) + c.offered.Digest = digest + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{c.offered}}) + body := `{"digest":"` + digest + `"` + c.body + `}` + v := viewOf[ActionResultView](t, do(t, h, gatePost(actionPath(r.ID, c.kind), body)), http.StatusOK) + c.want.ExpectParent = digest + if !reflect.DeepEqual(s.calls(), []string{"amend"}) || !reflect.DeepEqual(s.amends[0], c.want) { + t.Fatalf("engine calls %v %+v, want %+v", s.calls(), s.amends, c.want) + } + if !v.Committed || v.Recovered || v.Revision != 2 || v.Digest != successorDigest || v.Kind != c.kind || v.Canonical.Phase != "scope-waiting" { + t.Fatalf("read-back %+v", v) + } + // Only accept-failures derives its tests again, to bind the list shown. + if !reflect.DeepEqual(s.previews, []bool{c.kind == ActionAcceptFailures}) { + t.Fatalf("previews %v", s.previews) + } + if scope, _, _ := engine.counts(); scope != 0 || h.(*handler).jobs.busy(r.ID) { + t.Fatal("an action approved or started a job") + } + // The successor waits for its own approval through the scope gate. + if d := detailOf(t, h, r.ID); d.View.Decision.Primary == nil || d.View.Decision.Primary.Digest != successorDigest { + t.Fatalf("after the action the decision is %+v", d.View.Decision) + } + }) + } + // An engine call that records nothing is never shown as committed. + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{acceptOffer(digest)}}) + h.(*handler).amend = func(context.Context, string, string, molecule.AmendRequest) (molecule.Record, error) { + return molecule.Record{}, nil + } + if v := viewOf[ActionResultView](t, do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), acceptBody(digest, testDeltas))), http.StatusOK); v.Committed || v.Digest != "" { + t.Fatalf("nothing read back, yet %+v", v) + } + if len(s.calls()) != 0 { + t.Fatalf("calls %v", s.calls()) + } +} + +// Re-run checks on a resource-refused check-pending Molecule recovers first, as +// forgecell recover --confirm-stopped does, then records the checks-only +// revision; it needs the stopped confirmation. Recover alone records the +// interruption and is committed only when the ledger reads back interrupted. +func TestUIActionsRecoverFirstThenChecksOnly(t *testing.T) { + lab, r, digest := gateFixture(t) + r.Status, r.Readiness.Phase = "blocked", "check-pending" + writeRecord(t, lab, r) + h := testHandler(t, Options{LabDir: lab}) + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{{Kind: molecule.NextRerunChecks, Digest: digest, RecoverFirst: true}}}) + before := snapshot(t, lab) + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionRerunChecks), actionBody(digest))), http.StatusBadRequest, "confirm-stopped") + if len(s.calls()) != 0 || snapshot(t, lab) != before { + t.Fatalf("an unconfirmed recovery reached the engine: %v", s.calls()) + } + v := viewOf[ActionResultView](t, do(t, h, gatePost(actionPath(r.ID, ActionRerunChecks), `{"digest":"`+digest+`","confirmStopped":true}`)), http.StatusOK) + if !reflect.DeepEqual(s.calls(), []string{"recover", "amend"}) || !reflect.DeepEqual(s.recovers, []bool{true}) { + t.Fatalf("engine calls %v", s.calls()) + } + if want := (molecule.AmendRequest{Continuation: "checks-only", ExpectParent: digest}); !reflect.DeepEqual(s.amends[0], want) { + t.Fatalf("amend %+v", s.amends[0]) + } + if !v.Recovered || !v.Committed || v.Digest != successorDigest { + t.Fatalf("read-back %+v", v) + } + + // Recover alone. + lab2, r2, digest2 := gateFixture(t) + r2.Status = "running" + writeRecord(t, lab2, r2) + h2 := testHandler(t, Options{LabDir: lab2}) + s2 := installActions(t, h2, &actionSeams{offered: []molecule.NextAction{{Kind: molecule.NextRecover, Digest: digest2}}}) + refusalOf(t, do(t, h2, gatePost(actionPath(r2.ID, ActionRecover), actionBody(digest2))), http.StatusBadRequest, "confirm-stopped") + v = viewOf[ActionResultView](t, do(t, h2, gatePost(actionPath(r2.ID, ActionRecover), `{"digest":"`+digest2+`","confirmStopped":true}`)), http.StatusOK) + if !reflect.DeepEqual(s2.calls(), []string{"recover"}) || !v.Committed || !v.Recovered || v.Digest != "" { + t.Fatalf("recover calls %v view %+v", s2.calls(), v) + } + // A refused recovery stops before the amend and is served as a fixed refusal. + lab3, r3, digest3 := gateFixture(t) + r3.Status, r3.Readiness.Phase = "blocked", "check-pending" + writeRecord(t, lab3, r3) + h3 := testHandler(t, Options{LabDir: lab3}) + leak := errors.New("Molecule is locked at " + lab3) + s3 := installActions(t, h3, &actionSeams{offered: []molecule.NextAction{{Kind: molecule.NextRerunChecks, Digest: digest3, RecoverFirst: true}}, recoverErr: leak}) + w := do(t, h3, gatePost(actionPath(r3.ID, ActionRerunChecks), `{"digest":"`+digest3+`","confirmStopped":true}`)) + refusalOf(t, w, http.StatusConflict, "locked") + if !reflect.DeepEqual(s3.calls(), []string{"recover"}) || strings.Contains(w.Body.String(), lab3) { + t.Fatalf("calls %v body %s", s3.calls(), w.Body) + } +} + +// Every action route has the gate write protection and a strict body; an action +// the engine would refuse, a stale digest or changed scope paths, a retired +// Molecule and sample data never reach the engine, and nothing changes. +func TestUIActionsRefusalsNeverReachTheEngine(t *testing.T) { + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{ + acceptOffer(digest), + {Kind: molecule.NextApproveScopeChange, Digest: digest, Paths: []string{"lab/x/new.go"}}, + {Kind: molecule.NextRerunChecks, Digest: digest}, + {Kind: molecule.NextRecover, Digest: digest}, + }}) + engine := refuseEngine(t, h) + before := snapshot(t, lab) + scopeBody := `{"digest":"` + digest + `","paths":["lab/x/new.go"],"reason":"r"}` + bodies := map[string]string{ActionAcceptFailures: acceptBody(digest, testDeltas), ActionScopeChange: scopeBody, ActionRerunChecks: actionBody(digest), ActionRecover: `{"digest":"` + digest + `","confirmStopped":true}`} + for kind, body := range bodies { + for name, forged := range forgedWrites(actionPath(r.ID, kind), body) { + if w := do(t, h, forged); w.Code != http.StatusForbidden { + t.Errorf("%s %s: %d %s", kind, name, w.Code, w.Body) + } + } + get := authed(actionPath(r.ID, kind)) + get.header = map[string]string{sessionHeader: theSession} + if w := do(t, h, get); w.Code == http.StatusOK { + t.Errorf("GET %s served %d", kind, w.Code) + } + } + for name, c := range map[string]struct{ kind, body string }{ + "approve field": {ActionAcceptFailures, `{"digest":"` + digest + `","approve":true}`}, + "run field": {ActionScopeChange, `{"digest":"` + digest + `","paths":["lab/x/new.go"],"reason":"r","run":true}`}, + "short digest": {ActionAcceptFailures, `{"digest":"abc"}`}, + "no digest": {ActionRerunChecks, `{}`}, + "accept with paths": {ActionAcceptFailures, `{"digest":"` + digest + `","paths":["a.go"]}`}, + "accept confirmed": {ActionAcceptFailures, `{"digest":"` + digest + `","confirmStopped":true}`}, + "recover with reason": {ActionRecover, `{"digest":"` + digest + `","reason":"r","confirmStopped":true}`}, + "scope no paths": {ActionScopeChange, `{"digest":"` + digest + `","reason":"r"}`}, + "scope no reason": {ActionScopeChange, `{"digest":"` + digest + `","paths":["lab/x/new.go"],"reason":""}`}, + "scope multi-line": {ActionScopeChange, `{"digest":"` + digest + `","paths":["lab/x/new.go"],"reason":"a\nb"}`}, + "scope traversal": {ActionScopeChange, `{"digest":"` + digest + `","paths":["../x.go"],"reason":"r"}`}, + "scope absolute": {ActionScopeChange, `{"digest":"` + digest + `","paths":["/etc/x"],"reason":"r"}`}, + "scope too many": {ActionScopeChange, `{"digest":"` + digest + `","paths":["a","b","c","d","e","f","g","h","i"],"reason":"r"}`}, + "trailing data": {ActionAcceptFailures, acceptBody(digest, testDeltas) + `{}`}, + "oversized": {ActionAcceptFailures, `{"digest":"` + digest + `"` + strings.Repeat(" ", maxGateBody) + `}`}, + "accept no deltas": {ActionAcceptFailures, actionBody(digest)}, + "accept short deltas": {ActionAcceptFailures, `{"digest":"` + digest + `","deltas":"abc"}`}, + "rerun with deltas": {ActionRerunChecks, `{"digest":"` + digest + `","deltas":"` + DeltasDigest(testDeltas) + `"}`}, + "scope with deltas": {ActionScopeChange, `{"digest":"` + digest + `","deltas":"` + DeltasDigest(testDeltas) + `","paths":["lab/x/new.go"],"reason":"r"}`}, + } { + if w := do(t, h, gatePost(actionPath(r.ID, c.kind), c.body)); w.Code != http.StatusBadRequest { + t.Errorf("%s: %d %s", name, w.Code, w.Body) + } + } + // A stale digest, a test list other than the one offered, or scope paths + // other than the ones offered. + other := strings.Repeat("0", 64) + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), acceptBody(other, testDeltas))), http.StatusConflict, "stale") + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), `{"digest":"`+digest+`","deltas":"`+other+`"}`)), http.StatusConflict, "stale") + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionScopeChange), `{"digest":"`+digest+`","paths":["lab/x/other.go"],"reason":"r"}`)), http.StatusConflict, "stale") + // An unknown action, Molecule or id is not served. + if w := do(t, h, gatePost("/api/molecules/"+r.ID+"/actions/approve", actionBody(digest))); w.Code != http.StatusMethodNotAllowed { + t.Errorf("unknown action: %d", w.Code) + } + if w := do(t, h, gatePost(actionPath("absent", ActionAcceptFailures), acceptBody(digest, testDeltas))); w.Code != http.StatusNotFound { + t.Errorf("absent Molecule: %d", w.Code) + } + if w := do(t, h, gatePost(actionPath("bad.id", ActionAcceptFailures), acceptBody(digest, testDeltas))); w.Code != http.StatusBadRequest { + t.Errorf("invalid id: %d", w.Code) + } + if after := snapshot(t, lab); after != before { + t.Fatalf("refused requests changed the Lab:\n%s\n---\n%s", before, after) + } + if len(s.calls()) != 0 || engine.calls() != 0 { + t.Fatalf("refused requests reached the engine: %v, %d gate calls", s.calls(), engine.calls()) + } + + // An action the engine would refuse now is not offered, so it is refused. + s.mu.Lock() + s.offered = []molecule.NextAction{{Kind: molecule.NextRetire}} + s.mu.Unlock() + for kind, body := range bodies { + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, kind), body)), http.StatusConflict, "not-offered") + } + // A retired Molecule is refused before the engine. + retired := r + retired.Status = molecule.StatusRetired + retired.Retired = &molecule.Retirement{At: "2026-10-26T20:00:00Z", Reason: "stale", PriorStatus: "failed"} + writeRecord(t, lab, retired) + if w := do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), acceptBody(digest, testDeltas))); w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), `"category":"retired"`) { + t.Fatalf("retired: %d %s", w.Code, w.Body) + } + if len(s.calls()) != 0 { + t.Fatalf("refused requests reached the engine: %v", s.calls()) + } + + // Sample data refuses every action. + demo := testHandler(t, Options{Demo: true}) + ds := installActions(t, demo, &actionSeams{offered: []molecule.NextAction{acceptOffer(digest)}}) + for kind, body := range bodies { + refusalOf(t, do(t, demo, gatePost(actionPath("demo-101", kind), body)), http.StatusNotFound, "sample") + } + if len(ds.calls()) != 0 || len(ds.previews) != 0 { + t.Fatal("sample data reached the engine") + } +} + +// Accept by-design failures is bound to the exact tests shown. A newer +// verification attempt under the same plan digest that failed other tests +// changes the derived list, so the POST carrying the shown list's digest is +// refused as stale before the engine; the list shown now is accepted. +func TestUIActionsAcceptFailuresBindsTheTestListShown(t *testing.T) { + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{acceptOffer(digest)}}) + shown := viewOf[ActionsView](t, do(t, h, authed("/api/molecules/"+r.ID+"/actions")), http.StatusOK).Actions[0] + if shown.Kind != ActionAcceptFailures || shown.DeltasDigest != DeltasDigest(testDeltas) { + t.Fatalf("shown %+v", shown) + } + // A new verification attempt of the same plan fails TestY in another file. + newer := []readiness.ProtectedDelta{{CheckID: "go-test-lab", Package: "example.test/lab/pin", Test: "TestY", ScopePath: "lab/pin/other_test.go"}} + s.mu.Lock() + s.offered = []molecule.NextAction{{Kind: molecule.NextAcceptProtectedFailures, Digest: digest, Deltas: newer}} + s.mu.Unlock() + before := snapshot(t, lab) + w := do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), `{"digest":"`+digest+`","deltas":"`+shown.DeltasDigest+`"}`)) + refusalOf(t, w, http.StatusConflict, "stale") + if !strings.Contains(w.Body.String(), "failing tests changed") || len(s.calls()) != 0 || snapshot(t, lab) != before { + t.Fatalf("a stale test list reached the engine: %v %s", s.calls(), w.Body) + } + // The POST derived the tests again (a preview) rather than trusting the page. + if !s.previews[len(s.previews)-1] { + t.Fatalf("previews %v", s.previews) + } + // No derivable test list is never accepted either. + s.mu.Lock() + s.offered = []molecule.NextAction{{Kind: molecule.NextAcceptProtectedFailures, Digest: digest}} + s.mu.Unlock() + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), acceptBody(digest, nil))), http.StatusConflict, "stale") + // The list shown now records the revision. + s.mu.Lock() + s.offered = []molecule.NextAction{{Kind: molecule.NextAcceptProtectedFailures, Digest: digest, Deltas: newer}} + s.mu.Unlock() + if v := viewOf[ActionResultView](t, do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), acceptBody(digest, newer))), http.StatusOK); !v.Committed || !reflect.DeepEqual(s.calls(), []string{"amend"}) { + t.Fatalf("current list: %+v %v", v, s.calls()) + } +} + +// When re-run checks recovers first and the checks-only amend after it fails, +// the refusal says the recovery was recorded and that no new plan version +// exists, never that nothing was recorded; the ledger keeps the recovery. +func TestUIActionsRecoveredButNotAmended(t *testing.T) { + for _, amendErr := range []error{molecule.ErrStalePlan, errors.New("checks-only requires unchanged scope at /Users/someone/lab"), context.DeadlineExceeded} { + lab, r, digest := gateFixture(t) + r.Status, r.Readiness.Phase = "blocked", "check-pending" + writeRecord(t, lab, r) + h := testHandler(t, Options{LabDir: lab}) + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{{Kind: molecule.NextRerunChecks, Digest: digest, RecoverFirst: true}}, amendErr: amendErr}) + w := do(t, h, gatePost(actionPath(r.ID, ActionRerunChecks), `{"digest":"`+digest+`","confirmStopped":true}`)) + refusalOf(t, w, http.StatusConflict, "recovered-not-amended") + body := w.Body.String() + if !strings.Contains(body, "recovery was recorded") || !strings.Contains(body, "no new plan version exists") || !strings.Contains(body, "reload") || strings.Contains(body, "nothing was recorded") || strings.Contains(body, "/Users/") { + t.Fatalf("%v: %s", amendErr, body) + } + if !strings.Contains(body, categoryReasons["recovered-not-amended"]) { + t.Fatalf("%v: no reason: %s", amendErr, body) + } + if !reflect.DeepEqual(s.calls(), []string{"recover", "amend"}) { + t.Fatalf("calls %v", s.calls()) + } + if after, err := molecule.ReadRecord(lab, r.ID); err != nil || after.Readiness.Phase != "interrupted" || len(after.Readiness.Plans) != 1 { + t.Fatalf("the recovery was not kept: %v %+v", err, after.Readiness) + } + } + // Without recovering first, an amend refusal keeps its own category. + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + installActions(t, h, &actionSeams{offered: []molecule.NextAction{{Kind: molecule.NextRerunChecks, Digest: digest}}, amendErr: molecule.ErrStalePlan}) + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionRerunChecks), actionBody(digest))), http.StatusConflict, "stale") +} + +// Scope-change shows and posts the ledger's exact paths. A path the page +// cannot show as itself (here one the redactor rewrites) is not offered, with a +// plain reason, and its POST is refused before the engine. +func TestUIActionsScopeChangeUsesTheExactLedgerPaths(t *testing.T) { + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + exact := []string{"lab/x/new.go", "lab/x/new_test.go"} + s := installActions(t, h, &actionSeams{offered: []molecule.NextAction{{Kind: molecule.NextApproveScopeChange, Digest: digest, Paths: exact, Reason: "helper needed"}, {Kind: molecule.NextRetire}}}) + v := viewOf[ActionsView](t, do(t, h, authed("/api/molecules/"+r.ID+"/actions")), http.StatusOK) + if len(v.Actions) != 2 || !reflect.DeepEqual(v.Actions[0].Paths, exact) || len(v.NotOffered) != 0 { + t.Fatalf("view %+v", v) + } + secret := "lab/ghp_" + strings.Repeat("A", 24) + ".go" + if readiness.ValidatePath(secret) != nil { + t.Fatal("the fixture path is not a valid repository path") + } + s.mu.Lock() + s.offered = []molecule.NextAction{{Kind: molecule.NextApproveScopeChange, Digest: digest, Paths: []string{"lab/x/new.go", secret}, Reason: "helper needed"}, {Kind: molecule.NextRetire}} + s.mu.Unlock() + w := do(t, h, authed("/api/molecules/"+r.ID+"/actions")) + v = viewOf[ActionsView](t, w, http.StatusOK) + if len(v.Actions) != 1 || v.Actions[0].Kind != ActionRetire || len(v.NotOffered) != 1 || v.NotOffered[0].Kind != ActionScopeChange || v.NotOffered[0].Reason != scopePathsNotShown || strings.Contains(w.Body.String(), "ghp_") { + t.Fatalf("view %+v", v) + } + before := snapshot(t, lab) + body := `{"digest":"` + digest + `","paths":["lab/x/new.go","` + secret + `"],"reason":"r"}` + refusalOf(t, do(t, h, gatePost(actionPath(r.ID, ActionScopeChange), body)), http.StatusConflict, "not-offered") + if len(s.calls()) != 0 || snapshot(t, lab) != before { + t.Fatalf("calls %v", s.calls()) + } +} + +// A revision recorded after the UI checked the shown digest but before the engine +// call is refused by the engine under its lock and served as stale; engine text +// is never served. +func TestUIActionsEngineRefusalsAreFixedCategories(t *testing.T) { + for err, category := range map[error]string{ + molecule.ErrStalePlan: "stale", + errors.New("--accept-protected-failures refused: the candidate view failed at /Users/someone/lab"): "refused", + errors.New("Molecule is locked: another approval, amend or recover holds it"): "locked", + context.DeadlineExceeded: "interrupted", + } { + lab, r, digest := blockedFixture(t) + h := testHandler(t, Options{LabDir: lab}) + installActions(t, h, &actionSeams{offered: []molecule.NextAction{acceptOffer(digest)}, amendErr: err}) + w := do(t, h, gatePost(actionPath(r.ID, ActionAcceptFailures), acceptBody(digest, testDeltas))) + code := map[string]int{"stale": http.StatusConflict, "refused": http.StatusUnprocessableEntity, "locked": http.StatusConflict, "interrupted": http.StatusServiceUnavailable}[category] + refusalOf(t, w, code, category) + if strings.Contains(w.Body.String(), "/Users/") || strings.Contains(w.Body.String(), `"committed":true`) { + t.Fatalf("%v: %s", err, w.Body) + } + } +} diff --git a/lab/internal/ui/bulk_retire.go b/lab/internal/ui/bulk_retire.go new file mode 100644 index 0000000..c8d4eb9 --- /dev/null +++ b/lab/internal/ui/bulk_retire.go @@ -0,0 +1,223 @@ +package ui + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "sort" + "strings" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" +) + +// Retire attempts for closed Issues (#243): +// +// GET /api/retire-closed → the exact list, its count and digest +// POST /api/retire-closed {"count","digest"} → molecule.Retire for each listed Molecule +// +// The list holds every Molecule of the active Formula's repository that is not +// retired, not running, allocating or queued, has no gate job of this UI and no +// delivery in progress, and whose Issue GitHub reports closed. Issue state comes +// from the read-only sync cache of /api/issues (#242) and must be current and +// complete; otherwise both routes refuse. The POST has the gate write +// protection and binds the exact list it was shown: the count and the SHA-256 of +// its Molecule ids, recomputed before any retire. Each Molecule is retired +// through the same engine call as the retire route, with a fixed reason, and +// reported on its own with the ledger read-back; a refusal never stops the rest +// and is never silent. The whole retire runs under one deadline: past it, each +// remaining Molecule is reported not attempted. + +// BulkRetireAction is the action name /api/lab lists for the bulk retire route. +const BulkRetireAction = "retire closed Issues" + +// BulkRetireReason is the fixed retire reason of every bulk retirement. +const BulkRetireReason = "Issue closed; superseded attempt" + +// maxBulkRetire bounds the Molecules one bulk retire lists and retires; more are +// left for the next one and reported as truncated. +const maxBulkRetire = 200 + +// RetireClosedItem is one listed Molecule. +type RetireClosedItem struct { + ID string `json:"id"` + Issue int64 `json:"issue"` + Title string `json:"title"` + Lane string `json:"lane"` +} + +// RetireClosedView is GET /api/retire-closed. Available is false when the Issue +// state is unavailable, stale or incomplete; Reason then says why and nothing is +// listed. +type RetireClosedView struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + Count int `json:"count"` + Truncated bool `json:"truncated"` + Digest string `json:"digest,omitempty"` + Molecules []RetireClosedItem `json:"molecules"` +} + +// RetireClosedResult is one Molecule's outcome. Committed comes only from the +// ledger read-back recording the retirement. +type RetireClosedResult struct { + ID string `json:"id"` + Committed bool `json:"committed"` + Category string `json:"category,omitempty"` + Error string `json:"error,omitempty"` +} + +// RetireClosedReport is every accepted POST /api/retire-closed. +// NotAttempted counts the Molecules left when the overall deadline passed; +// each still has its own result. +type RetireClosedReport struct { + Attempted int `json:"attempted"` + Retired int `json:"retired"` + Refused int `json:"refused"` + NotAttempted int `json:"notAttempted"` + Truncated bool `json:"truncated"` + Results []RetireClosedResult `json:"results"` +} + +// bulkRetireTimeout bounds one bulk retire inside the server's write timeout. +const bulkRetireTimeout = 40 * time.Second + +// retireClosedContext is one bulk retire's overall deadline; tests replace it +// through h.closedDeadline so no test waits on the wall clock. +func (h *handler) retireClosedContext() (context.Context, context.CancelFunc) { + if h.closedDeadline != nil { + return h.closedDeadline() + } + return context.WithTimeout(h.jobs.ctx, bulkRetireTimeout) +} + +type retireClosedRequest struct { + Count int `json:"count"` + Digest string `json:"digest"` +} + +var errClosedState = errors.New("closed Issue state unavailable") + +// closedStateReasons are the fixed reasons the list is unavailable. +const ( + closedNoRepository = "this Lab's Formula names no GitHub repository, so Issue state cannot be read" + closedUnavailable = "GitHub Issue state could not be read just now; nothing can be retired as closed until it is current" + closedIncomplete = "the Lab's ledgers or the Issue states could not all be read; nothing is listed" +) + +func listDigest(ids []string) string { + sum := sha256.Sum256([]byte(strings.Join(ids, "\n"))) + return hex.EncodeToString(sum[:]) +} + +// retireClosedList computes the exact list from the ledgers and the current +// Issue state. +func (h *handler) retireClosedList(r *http.Request) (RetireClosedView, error) { + v := RetireClosedView{Molecules: []RetireClosedItem{}} + repo := h.syncRepository() + if repo == "" { + v.Reason = closedNoRepository + return v, errClosedState + } + work := h.syncWork(repo) + res := h.forge.get(r.Context(), work.want, false) + if !res.Current() || res.Snapshot == nil { + v.Reason = closedUnavailable + return v, errClosedState + } + if res.Snapshot.StatesTruncated || !work.list.Complete || work.list.Truncated { + v.Reason = closedIncomplete + return v, errClosedState + } + var items []RetireClosedItem + for _, s := range work.list.Molecules { + if !strings.EqualFold(s.Repository, repo) || s.Lane == LaneRetired || active(s.Status) || !moleculeID.MatchString(s.ID) { + continue + } + issue := res.Snapshot.Issue(s.Number) + if issue == nil || !issue.Found || issue.State != forgesync.IssueClosed { + continue + } + if h.jobs.busy(s.ID) || molecule.RetireDeliveryInProgress(h.opts.LabDir, s.ID) { + continue + } + items = append(items, RetireClosedItem{ID: s.ID, Issue: s.Number, Title: bounded(s.Title), Lane: s.Lane}) + } + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + if len(items) > maxBulkRetire { + items, v.Truncated = items[:maxBulkRetire], true + } + ids := make([]string, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ID) + } + v.Available, v.Count, v.Molecules = true, len(items), append(v.Molecules, items...) + v.Digest = listDigest(ids) + return v, nil +} + +func (h *handler) retireClosedStatus(w http.ResponseWriter, r *http.Request) { + if h.opts.Demo { + h.refuseGate(w, http.StatusNotFound, "sample", "sample data cannot be retired") + return + } + v, _ := h.retireClosedList(r) + h.json(w, http.StatusOK, v) +} + +// retireClosed serves the POST after the write protection and the demo check. +func (h *handler) retireClosed(w http.ResponseWriter, r *http.Request, req retireClosedRequest) { + if !sha256Hex.MatchString(req.Digest) || req.Count < 1 || req.Count > maxBulkRetire { + h.refuseGate(w, http.StatusBadRequest, "invalid", "give the exact count and list digest shown") + return + } + list, err := h.retireClosedList(r) + if err != nil { + h.refuseGate(w, http.StatusConflict, "issue-state", list.Reason+"; nothing was retired") + return + } + if list.Count != req.Count || list.Digest != req.Digest { + h.refuseGate(w, http.StatusConflict, "stale", "the list of Molecules for closed Issues changed since it was shown; nothing was retired") + return + } + report := RetireClosedReport{Truncated: list.Truncated, Results: []RetireClosedResult{}} + ctx, cancel := h.retireClosedContext() + defer cancel() + for _, it := range list.Molecules { + res := RetireClosedResult{ID: it.ID} + // Past the overall deadline every remaining Molecule is reported, never + // dropped. + if ctx.Err() != nil { + res.Category, res.Error = "not-attempted", "the bulk retire reached its time bound first; it was not attempted and is still listed" + report.NotAttempted++ + report.Results = append(report.Results, res) + continue + } + report.Attempted++ + if h.jobs.busy(it.ID) { + res.Category, res.Error = "busy", "a gate job is running for this Molecule; it was not retired" + } else if _, err := h.retire(h.opts.LabDir, it.ID, BulkRetireReason); err != nil { + res.Category, res.Error = "refused", "the engine refused the request" + for _, rr := range retireRefusals { + if errors.Is(err, rr.err) { + res.Category, res.Error = rr.category, rr.message + break + } + } + } else if record, err := h.records(h.opts.LabDir, it.ID); err == nil && record.Status == molecule.StatusRetired && record.Retired != nil { + res.Committed = true + } else { + res.Category, res.Error = "unreadable", "the ledger read back does not record the retirement" + } + if res.Committed { + report.Retired++ + } else { + report.Refused++ + } + report.Results = append(report.Results, res) + } + h.json(w, http.StatusOK, report) +} diff --git a/lab/internal/ui/bulk_retire_test.go b/lab/internal/ui/bulk_retire_test.go new file mode 100644 index 0000000..7429042 --- /dev/null +++ b/lab/internal/ui/bulk_retire_test.go @@ -0,0 +1,300 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" +) + +// closedLab is a fixture Lab of attempts for Issues 20-27, and closedFetcher is +// GitHub's side of it: Issues 20, 21, 23, 24 and 25 closed, 22 open, 26 and 27 +// not read. Only mol-20-blocked and mol-21-waiting are attempts for closed +// Issues that bulk retire may retire: 22's Issue is open, 23 is running, 24 is +// retired already, 25 has a delivery in progress, 26's state is unknown and 27 +// belongs to another repository. +func closedLab(t *testing.T) string { + t.Helper() + lab, base := labFixture(t) + os.Remove(filepath.Join(lab, "ledgers", base.ID+".json")) + add := func(id string, n int64, status, phase string) molecule.Record { + r := base + r.ID, r.Status, r.Issue.Number = id, status, n + r.Issue.URL = fmt.Sprintf("https://github.com/o/r/issues/%d", n) + r.Readiness = &readiness.State{SchemaVersion: "v1", Phase: phase} + return r + } + for _, r := range []molecule.Record{ + add("mol-20-blocked", 20, "blocked", "failed"), + add("mol-21-waiting", 21, "waiting", "scope-waiting"), + add("mol-22-open", 22, "blocked", "failed"), + add("mol-23-running", 23, "running", "coding"), + add("mol-25-delivery", 25, "completed", "review-waiting"), + add("mol-26-unknown", 26, "blocked", "failed"), + } { + writeRecord(t, lab, r) + } + retired := add("mol-24-retired", 24, molecule.StatusRetired, "failed") + retired.Retired = &molecule.Retirement{At: "2026-10-26T10:00:00Z", Reason: "stale", PriorStatus: "blocked"} + writeRecord(t, lab, retired) + elsewhere := add("mol-27-elsewhere", 27, "blocked", "failed") + elsewhere.Issue.Repo, elsewhere.Workspace.Repo, elsewhere.Issue.URL = "x/y", "x/y", "https://github.com/x/y/issues/27" + writeRecord(t, lab, elsewhere) + writeReceipt(t, lab, "mol-25-delivery", "committed", "") + return lab +} + +func closedFetcher() *syncFetcher { + f := &syncFetcher{issues: map[int64]forgesync.Issue{}, prs: map[int64]forgesync.PR{}} + f.listing.Issues = []forgesync.Issue{openSyncIssue(22)} + f.listing.Total = 1 + for _, n := range []int64{20, 21, 23, 24, 25, 27} { + closed := openSyncIssue(n) + closed.State, closed.StateReason = forgesync.IssueClosed, forgesync.ReasonCompleted + f.issues[n] = closed + } + return f +} + +var closedWant = []string{"mol-20-blocked", "mol-21-waiting"} + +func closedBody(v RetireClosedView) string { + return fmt.Sprintf(`{"count":%d,"digest":%q}`, v.Count, v.Digest) +} + +// The list holds exactly the closed-Issue, non-running, no-delivery attempts; +// the confirmation retires each of them through the engine with the fixed +// reason and reports each one committed from the ledger read-back. The list is +// then empty, and nothing else in the Lab was retired. +func TestUIBulkRetireRetiresExactlyTheClosedIssueAttempts(t *testing.T) { + lab := closedLab(t) + h, _ := syncHandler(t, Options{LabDir: lab}, closedFetcher()) + v := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + var ids []string + for _, m := range v.Molecules { + ids = append(ids, m.ID) + } + if !v.Available || v.Truncated || v.Count != 2 || !reflect.DeepEqual(ids, closedWant) || v.Digest != listDigest(closedWant) { + t.Fatalf("list %+v", v) + } + if v.Molecules[0].Issue != 20 || v.Molecules[0].Lane != LaneBlocked || v.Molecules[1].Lane != LaneDecision { + t.Fatalf("items %+v", v.Molecules) + } + var reasons []string + retire := h.(*handler).retire + h.(*handler).retire = func(lab, id, reason string) (molecule.Record, error) { + reasons = append(reasons, reason) + return retire(lab, id, reason) + } + report := viewOf[RetireClosedReport](t, do(t, h, gatePost("/api/retire-closed", closedBody(v))), http.StatusOK) + if report.Attempted != 2 || report.Retired != 2 || report.Refused != 0 || len(report.Results) != 2 { + t.Fatalf("report %+v", report) + } + for i, res := range report.Results { + if res.ID != closedWant[i] || !res.Committed || res.Category != "" { + t.Fatalf("result %+v", res) + } + } + if !reflect.DeepEqual(reasons, []string{BulkRetireReason, BulkRetireReason}) { + t.Fatalf("reasons %q", reasons) + } + for _, id := range []string{"mol-20-blocked", "mol-21-waiting", "mol-22-open", "mol-23-running", "mol-24-retired", "mol-25-delivery", "mol-26-unknown", "mol-27-elsewhere"} { + r, err := molecule.ReadRecord(lab, id) + if err != nil { + t.Fatal(err) + } + want := id == "mol-20-blocked" || id == "mol-21-waiting" || id == "mol-24-retired" + if (r.Status == molecule.StatusRetired) != want { + t.Errorf("%s: status %s", id, r.Status) + } + if (id == "mol-20-blocked" || id == "mol-21-waiting") && (r.Retired == nil || r.Retired.Reason != BulkRetireReason) { + t.Errorf("%s: retirement %+v", id, r.Retired) + } + } + if again := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK); !again.Available || again.Count != 0 || len(again.Molecules) != 0 { + t.Fatalf("after: %+v", again) + } +} + +// Each Molecule's result is reported on its own: an engine refusal is a fixed +// category, an engine call the ledger does not record is not committed, and a +// refusal never stops the rest. +func TestUIBulkRetireReportsEachResult(t *testing.T) { + lab := closedLab(t) + h, _ := syncHandler(t, Options{LabDir: lab}, closedFetcher()) + v := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + h.(*handler).retire = func(lab, id, reason string) (molecule.Record, error) { + if id == "mol-20-blocked" { + return molecule.Record{}, fmt.Errorf("%w at %s", molecule.ErrRetireLocked, lab) + } + return molecule.Record{}, nil + } + w := do(t, h, gatePost("/api/retire-closed", closedBody(v))) + report := viewOf[RetireClosedReport](t, w, http.StatusOK) + if report.Attempted != 2 || report.Retired != 0 || report.Refused != 2 { + t.Fatalf("report %+v", report) + } + if r := report.Results[0]; r.ID != "mol-20-blocked" || r.Committed || r.Category != "locked" || r.Error == "" { + t.Fatalf("refused %+v", r) + } + if r := report.Results[1]; r.ID != "mol-21-waiting" || r.Committed || r.Category != "unreadable" { + t.Fatalf("unrecorded %+v", r) + } + if strings.Contains(w.Body.String(), lab) { + t.Fatalf("the report names the Lab path: %s", w.Body) + } +} + +// The bulk retire runs under one overall deadline. When it passes, each +// remaining Molecule is reported not attempted, never dropped, and is neither +// retired nor sent to the engine; it is listed again afterwards. The deadline +// is a seam: the test expires it after the first retire, so no wall clock runs. +func TestUIBulkRetireReportsNotAttemptedPastTheDeadline(t *testing.T) { + lab := closedLab(t) + h, _ := syncHandler(t, Options{LabDir: lab}, closedFetcher()) + v := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + hh := h.(*handler) + hh.closedDeadline = func() (context.Context, context.CancelFunc) { return ctx, func() {} } + var retired []string + retire := hh.retire + hh.retire = func(lab, id, reason string) (molecule.Record, error) { + retired = append(retired, id) + defer cancel() + return retire(lab, id, reason) + } + report := viewOf[RetireClosedReport](t, do(t, h, gatePost("/api/retire-closed", closedBody(v))), http.StatusOK) + if report.Attempted != 1 || report.Retired != 1 || report.Refused != 0 || report.NotAttempted != 1 || len(report.Results) != 2 { + t.Fatalf("report %+v", report) + } + if r := report.Results[0]; r.ID != closedWant[0] || !r.Committed { + t.Fatalf("first %+v", r) + } + if r := report.Results[1]; r.ID != closedWant[1] || r.Committed || r.Category != "not-attempted" || r.Error == "" { + t.Fatalf("second %+v", r) + } + if !reflect.DeepEqual(retired, closedWant[:1]) { + t.Fatalf("engine calls %v", retired) + } + if r, err := molecule.ReadRecord(lab, closedWant[1]); err != nil || r.Status == molecule.StatusRetired { + t.Fatalf("a not-attempted Molecule was retired: %v %s", err, r.Status) + } + if again := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK); again.Count != 1 || again.Molecules[0].ID != closedWant[1] { + t.Fatalf("after: %+v", again) + } + // An already expired deadline attempts nothing and reports every Molecule. + expired, stop := context.WithCancel(context.Background()) + stop() + hh.closedDeadline = func() (context.Context, context.CancelFunc) { return expired, func() {} } + v = viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + report = viewOf[RetireClosedReport](t, do(t, h, gatePost("/api/retire-closed", closedBody(v))), http.StatusOK) + if report.Attempted != 0 || report.NotAttempted != 1 || len(report.Results) != 1 || report.Results[0].Category != "not-attempted" || len(retired) != 1 { + t.Fatalf("expired %+v after %v", report, retired) + } +} + +// Without current, complete Issue state nothing is listed and the confirmation +// is refused; a list that changed since it was shown is refused as stale; and +// nothing is retired in any of these. +func TestUIBulkRetireRefusesWithoutCurrentIssueState(t *testing.T) { + lab := closedLab(t) + f := closedFetcher() + h, clk := syncHandler(t, Options{LabDir: lab}, f) + calls := countRetire(h, nil) + good := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + before := snapshot(t, lab) + + // A changed list: a count or digest other than the current one. + for _, body := range []string{fmt.Sprintf(`{"count":1,"digest":%q}`, good.Digest), fmt.Sprintf(`{"count":2,"digest":%q}`, listDigest([]string{"mol-20-blocked"}))} { + refusalOf(t, do(t, h, gatePost("/api/retire-closed", body)), http.StatusConflict, "stale") + } + // GitHub fails: the last good snapshot is no longer current. + f.mu.Lock() + f.err = errors.New("gh: rate limited at " + lab) + f.mu.Unlock() + clk.add(3 * forgesync.TTL) + v := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + if v.Available || v.Reason != closedUnavailable || len(v.Molecules) != 0 || v.Digest != "" { + t.Fatalf("unavailable list %+v", v) + } + refusalOf(t, do(t, h, gatePost("/api/retire-closed", closedBody(good))), http.StatusConflict, "issue-state") + // No repository. + h.(*handler).forge.repository = func(string) string { return "" } + if v := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK); v.Available || v.Reason != closedNoRepository { + t.Fatalf("no repository %+v", v) + } + refusalOf(t, do(t, h, gatePost("/api/retire-closed", closedBody(good))), http.StatusConflict, "issue-state") + if *calls != 0 { + t.Fatalf("a refused bulk retire reached the engine %d times", *calls) + } + if after := snapshot(t, lab); after != before { + t.Fatal("a refused bulk retire changed the Lab") + } + + // Truncated Issue states are incomplete, so nothing is listed. + lab2 := closedLab(t) + h2, _ := syncHandler(t, Options{LabDir: lab2}, &truncatedFetcher{closedFetcher()}) + if v := viewOf[RetireClosedView](t, do(t, h2, authed("/api/retire-closed")), http.StatusOK); v.Available || v.Reason != closedIncomplete { + t.Fatalf("truncated states %+v", v) + } +} + +type truncatedFetcher struct{ *syncFetcher } + +func (f *truncatedFetcher) States(ctx context.Context, repo string, issues, prs []int64) (forgesync.States, error) { + s, err := f.syncFetcher.States(ctx, repo, issues, prs) + s.Truncated = true + return s, err +} + +// The POST has the write protection and a strict body, and sample data +// refuses both routes; none reaches the engine. +func TestUIBulkRetireGuardsAndDemo(t *testing.T) { + lab := closedLab(t) + h, _ := syncHandler(t, Options{LabDir: lab}, closedFetcher()) + calls := countRetire(h, nil) + v := viewOf[RetireClosedView](t, do(t, h, authed("/api/retire-closed")), http.StatusOK) + before := snapshot(t, lab) + for name, forged := range forgedWrites("/api/retire-closed", closedBody(v)) { + if w := do(t, h, forged); w.Code != http.StatusForbidden { + t.Errorf("%s: %d %s", name, w.Code, w.Body) + } + } + for name, body := range map[string]string{ + "unknown field": fmt.Sprintf(`{"count":2,"digest":%q,"reason":"mine"}`, v.Digest), + "ids field": fmt.Sprintf(`{"count":2,"digest":%q,"ids":["mol-22-open"]}`, v.Digest), + "zero count": fmt.Sprintf(`{"count":0,"digest":%q}`, v.Digest), + "huge count": fmt.Sprintf(`{"count":%d,"digest":%q}`, maxBulkRetire+1, v.Digest), + "short digest": `{"count":2,"digest":"abc"}`, + "two objects": closedBody(v) + `{}`, + "oversized": `{"count":2` + strings.Repeat(" ", maxGateBody) + `}`, + } { + if w := do(t, h, gatePost("/api/retire-closed", body)); w.Code != http.StatusBadRequest { + t.Errorf("%s: %d %s", name, w.Code, w.Body) + } + } + if *calls != 0 { + t.Fatalf("refused requests reached the engine %d times", *calls) + } + if after := snapshot(t, lab); after != before { + t.Fatal("refused requests changed the Lab") + } + f := closedFetcher() + demo, _ := syncHandler(t, Options{Demo: true}, f) + demoCalls := countRetire(demo, nil) + refusalOf(t, do(t, demo, authed("/api/retire-closed")), http.StatusNotFound, "sample") + refusalOf(t, do(t, demo, gatePost("/api/retire-closed", closedBody(v))), http.StatusNotFound, "sample") + if *demoCalls != 0 || f.calls() != 0 { + t.Fatal("sample data reached the engine or GitHub") + } +} diff --git a/lab/internal/ui/drive.go b/lab/internal/ui/drive.go index 49e57bf..32a327d 100644 --- a/lab/internal/ui/drive.go +++ b/lab/internal/ui/drive.go @@ -3,6 +3,7 @@ package ui import ( "context" "errors" + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" "github.com/DanielNetzer/forgecell/lab/internal/intake" "github.com/DanielNetzer/forgecell/lab/internal/molecule" "github.com/DanielNetzer/forgecell/lab/internal/resources" @@ -28,6 +29,7 @@ import ( // GET /api/loop → this UI's loop, its last report and any Lab loop // POST /api/molecules/{id}/stop {} → cancels this UI's engine call for it // POST /api/molecules/{id}/retire {"reason"} → molecule.Retire (retire.go) +// POST /api/retire-closed {"count","digest"} → molecule.Retire for each closed-Issue attempt (bulk_retire.go) // // A started run counts only once a ledger for its Issue appears, and a stop only // once the ledger no longer reports the Molecule running; the job's own outcome @@ -69,6 +71,8 @@ func driveRoute(path string) (id, action string, ok bool) { return "", "loop/start", true case "/api/loop/stop": return "", "loop/stop", true + case "/api/retire-closed": + return "", "retire-closed", true } rest, found := strings.CutPrefix(path, "/api/molecules/") if !found { @@ -101,7 +105,7 @@ func (h *handler) drive(w http.ResponseWriter, r *http.Request, id, action strin return } if h.opts.Demo { - if action == "retire" { + if action == "retire" || action == "retire-closed" { h.refuseGate(w, http.StatusNotFound, "sample", "sample data cannot be retired; nothing was written") return } @@ -111,6 +115,7 @@ func (h *handler) drive(w http.ResponseWriter, r *http.Request, id, action strin var run runRequest var loop loopRequest var retire retireRequest + var closed retireClosedRequest var body any = &emptyRequest{} switch action { case "run": @@ -119,6 +124,8 @@ func (h *handler) drive(w http.ResponseWriter, r *http.Request, id, action strin body = &loop case "retire": body = &retire + case "retire-closed": + body = &closed } if decodeGate(w, r, body) != nil { h.refuseGate(w, http.StatusBadRequest, "invalid", "send one small JSON object with exactly the action's fields") @@ -133,6 +140,8 @@ func (h *handler) drive(w http.ResponseWriter, r *http.Request, id, action strin h.stopLoop(w) case "retire": h.retireMolecule(w, id, retire) + case "retire-closed": + h.retireClosed(w, r, closed) default: h.stopMolecule(w, id) } @@ -705,6 +714,10 @@ type LoopView struct { Report *LoopReportView `json:"report,omitempty"` // LabLoop is any loop running in this Lab, whoever started it. LabLoop LabLoopView `json:"labLoop"` + // DefaultSelect and DefaultSelectNote are the Start loop form's first + // selector and its plain explanation. + DefaultSelect string `json:"defaultSelect"` + DefaultSelectNote string `json:"defaultSelectNote"` } // LabLoopView is the Lab's loop marker as molecule.ReadLoopStatus read it. @@ -730,6 +743,81 @@ type LabLoopView struct { // named, by number with their phase (planning). Intake []LabLoopIntakeView `json:"intake"` CanStop bool `json:"canStop"` + // OwnerText names who started the loop and where to stop it, in plain words. + OwnerText string `json:"ownerText,omitempty"` +} + +// ownerText is the plain words naming a running loop's owner, its selector and +// where it is stopped. +func ownerText(v LabLoopView) string { + with := "" + if v.Select != "" { + with = " with " + v.Select + } + switch v.Owner { + case LoopOwnerThisUI: + return "Started from this Lab UI" + with + case LoopOwnerAnotherUI: + return "Started from another Lab UI" + with + " · stop it there" + case LoopOwnerTerminal: + return "Started from the command line" + with + " · stop it there" + } + return "" +} + +// The Start loop form's default selector is milestone:: the +// milestone with the most open Issues of the active Formula's repository, ties +// going to the milestone whose lowest open Issue number is lowest, read from +// the sync engine's open-Issue listing (#242) and never fetched for this. It is +// oldest only when the listing is current, complete and names no milestone, +// and "" when the listing is unknown, stale or truncated: the form then asks +// for a milestone rather than guessing one. +const ( + // DefaultOldestNote explains the oldest selector wherever it is offered. + DefaultOldestNote = "Oldest: oldest open Issue, any milestone." + noMilestoneNote = DefaultOldestNote + " This repository has no open milestone with open Issues." + unknownNote = "The active milestone is unknown until GitHub Issue state is current: open the Issues view, or enter a milestone. " + DefaultOldestNote +) + +// activeMilestoneSelect is the default selector and its plain explanation for +// one current open-Issue listing; a nil snapshot gives "" and unknownNote. +func activeMilestoneSelect(snap *forgesync.Snapshot) (string, string) { + if snap == nil || snap.Listing.Truncated { + return "", unknownNote + } + type count struct { + n int + lowest int64 + } + counts := map[string]*count{} + for _, issue := range snap.Listing.Issues { + if issue.Milestone == "" || (issue.State != "" && issue.State != forgesync.IssueOpen) { + continue + } + c := counts[issue.Milestone] + if c == nil { + c = &count{lowest: issue.Number} + counts[issue.Milestone] = c + } + c.n++ + if issue.Number < c.lowest { + c.lowest = issue.Number + } + } + best, bestCount := "", (*count)(nil) + for title, c := range counts { + selector := intake.SelectMilestone + ":" + title + if _, err := intake.ParseSelector(selector); err != nil || len(selector) > maxSelectBytes || !validTitle(selector) { + continue + } + if bestCount == nil || c.n > bestCount.n || (c.n == bestCount.n && (c.lowest < bestCount.lowest || (c.lowest == bestCount.lowest && title < best))) { + best, bestCount = title, c + } + } + if bestCount == nil { + return intake.SelectOldest, noMilestoneNote + } + return intake.SelectMilestone + ":" + best, "Milestone " + best + ": the open milestone with the most open Issues (" + strconv.Itoa(bestCount.n) + "). " + DefaultOldestNote } // LabLoopIntakeView is an Issue in flight by number. @@ -796,6 +884,7 @@ func labLoopView(lab molecule.LoopMarkerState, ownToken string, stoppable bool) } } v.InFlightTruncated = v.InFlightTruncated || s.InFlightTruncated + v.OwnerText = ownerText(v) return v } @@ -843,6 +932,7 @@ func (h *handler) loopView() LoopView { own = l.token } v := LoopView{State: l.state, Category: l.category, Error: l.message, Reason: l.reason, Select: l.selector, MaxConcurrent: l.max, Limit: maxLoopConcurrent, LabLoop: labLoopView(lab, own, l.running && !l.drain)} + v.DefaultSelect, v.DefaultSelectNote = activeMilestoneSelect(h.forge.currentSnapshot(h.syncRepository())) if v.State == "" { v.State = "idle" } diff --git a/lab/internal/ui/gates.go b/lab/internal/ui/gates.go index d218032..d047115 100644 --- a/lab/internal/ui/gates.go +++ b/lab/internal/ui/gates.go @@ -34,6 +34,11 @@ import ( // POST /api/molecules/{id}/amend {"digest","addAcceptance","addScope"} // → molecule.AmendFromLatest (no --run); the new revision is approved separately // +// The next actions of a blocked Molecule (actions.go) are the same kind of POST +// and never approve either: +// +// POST /api/molecules/{id}/actions/{accept-failures|scope-change|rerun-checks|recover} +// // The engine validates every digest, lock and receipt; the UI only refuses // early what the engine would refuse anyway. There is no merge, deployment or // permission route. A job's own outcome never shows a decision as committed: @@ -399,6 +404,10 @@ var categoryReasons = map[string]string{ "duplicate": "Add a path or an acceptance note the plan does not list yet, or approve the plan as it is", "scope-refused": "Choose an exact path inside a component the plan's evidence covers, or run forgecell amend to see the engine's own reason", "checkout": "Refresh intake with forgecell run so the Molecule has a coding checkout, then review the new plan", + "not-offered": "Reload this Molecule and choose one of the actions it offers now; the engine would refuse this one", + "confirm-stopped": "Tick I confirm it is stopped once no Lab process works on this Molecule, then submit again", + "issue-state": "Refresh the Issues view so GitHub Issue state is current, then list the closed Issues again", + "recovered-not-amended": "Reload this Molecule: it is recovered now, and What you can do lists the actions the engine accepts next", } // engineReasons are exact next steps for engine errors the UI recognises, diff --git a/lab/internal/ui/issues.go b/lab/internal/ui/issues.go index ee79905..81a200f 100644 --- a/lab/internal/ui/issues.go +++ b/lab/internal/ui/issues.go @@ -1,6 +1,8 @@ package ui import ( + "context" + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" "github.com/DanielNetzer/forgecell/lab/internal/formula" "github.com/DanielNetzer/forgecell/lab/internal/molecule" @@ -24,13 +26,46 @@ type issueSync struct { repository func(lab string) string receiptsMu sync.Mutex receipts map[string]publishedEntry + // now is the cache's clock; last is the latest answer get returned, which + // the loop view reads without starting a sync. + now func() time.Time + lastMu sync.Mutex + last forgesync.Result } func newIssueSync(fetcher forgesync.Fetcher, now func() time.Time) *issueSync { if fetcher == nil { fetcher = forgesync.NewGH() } - return &issueSync{cache: forgesync.NewCache(fetcher, now), repository: func(lab string) string { return formula.ActiveIdentity(lab).Repository }, receipts: map[string]publishedEntry{}} + if now == nil { + now = time.Now + } + return &issueSync{cache: forgesync.NewCache(fetcher, now), repository: func(lab string) string { return formula.ActiveIdentity(lab).Repository }, receipts: map[string]publishedEntry{}, now: now} +} + +// get is the cache's answer for want, remembered for currentSnapshot. +func (s *issueSync) get(ctx context.Context, want forgesync.Want, refresh bool) forgesync.Result { + res := s.cache.Get(ctx, want, refresh) + s.lastMu.Lock() + s.last = res + s.lastMu.Unlock() + return res +} + +// currentSnapshot is the latest remembered sync of repo while it is still +// current (a successful sync at most twice the TTL old), or nil. It never +// starts a sync. +func (s *issueSync) currentSnapshot(repo string) *forgesync.Snapshot { + s.lastMu.Lock() + res := s.last + s.lastMu.Unlock() + if res.Snapshot == nil || res.Reason != "" || repo == "" || !strings.EqualFold(res.Snapshot.Repository, repo) { + return nil + } + if s.now().Sub(res.Snapshot.FetchedAt) > 2*forgesync.TTL { + return nil + } + return res.Snapshot } // published is the pull request number and commit of one published receipt. @@ -237,7 +272,7 @@ func (h *handler) issues(w http.ResponseWriter, r *http.Request) { return } work := h.syncWork(repo) - res := h.forge.cache.Get(r.Context(), work.want, refresh(r)) + res := h.forge.get(r.Context(), work.want, refresh(r)) out.Freshness = freshness(res) queue := molecule.ReadQueueInventory(h.opts.LabDir) out.Complete = queue.Complete && work.list.Complete diff --git a/lab/internal/ui/loop_view_test.go b/lab/internal/ui/loop_view_test.go new file mode 100644 index 0000000..fd0e337 --- /dev/null +++ b/lab/internal/ui/loop_view_test.go @@ -0,0 +1,175 @@ +package ui + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" + "github.com/DanielNetzer/forgecell/lab/internal/intake" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" +) + +// Each owner of a running Lab loop is named in plain words with its selector, +// and only this UI's own loop says nothing about stopping it elsewhere. A +// marker that is not running names no owner. +func TestUILoopOwnerTextNamesEachOwner(t *testing.T) { + started := time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC) + status := func(starter, token string) molecule.LoopMarkerState { + return molecule.LoopMarkerState{Running: true, State: molecule.LoopMarkerRunning, Held: true, Status: &molecule.LoopStatus{Token: token, Starter: starter, StartedAt: started, Selector: "milestone:v1", MaxConcurrent: 2, InFlight: []string{"mol-7-a"}, Intake: []molecule.LoopIntake{{Issue: 243, Phase: molecule.LoopIntakePlanning}}}} + } + for name, c := range map[string]struct { + lab molecule.LoopMarkerState + own string + text string + canStop bool + }{ + "this UI": {status(molecule.LoopStarterUI, "tok"), "tok", "Started from this Lab UI with milestone:v1", true}, + "another UI": {status(molecule.LoopStarterUI, "other"), "tok", "Started from another Lab UI with milestone:v1 · stop it there", false}, + "terminal": {status(molecule.LoopStarterCLI, "cli"), "", "Started from the command line with milestone:v1 · stop it there", false}, + "not running": {molecule.LoopMarkerState{State: molecule.LoopMarkerNotRunning}, "", "", false}, + } { + v := labLoopView(c.lab, c.own, true) + if v.OwnerText != c.text || v.CanStop != c.canStop { + t.Errorf("%s: owner text %q can stop %t", name, v.OwnerText, v.CanStop) + } + if c.lab.Running && (v.StartedAt != "2026-10-27T09:00:00Z" || v.MaxConcurrent != 2 || len(v.InFlight) != 1 || len(v.Intake) != 1 || v.Intake[0].Issue != 243 || v.Intake[0].Phase != "planning") { + t.Errorf("%s: %+v", name, v) + } + } + // A loop already stopping cannot be stopped again. + if v := labLoopView(status(molecule.LoopStarterUI, "tok"), "tok", false); v.CanStop { + t.Error("a draining loop is stoppable") + } +} + +// GET /api/loop names a terminal loop's owner and where to stop it, carries +// the Start loop form's default selector and its explanation, and a start +// beside it is refused with the engine's plain reason. +func TestUILoopStatusNamesATerminalLoopAndTheDefaultSelector(t *testing.T) { + lab, _ := labFixture(t) + h, d := driveHandler(t, lab, nil, &fakeDrive{}) + idle := viewOf[LoopView](t, do(t, h, authed("/api/loop")), http.StatusOK) + // No Issue state was read, so the active milestone is unknown: no selector + // is guessed, oldest least of all. + if idle.DefaultSelect != "" || idle.DefaultSelectNote != unknownNote || !strings.Contains(idle.DefaultSelectNote, "oldest open Issue, any milestone") || idle.LabLoop.OwnerText != "" { + t.Fatalf("idle %+v", idle) + } + selector, err := intake.ParseSelector("milestone:v1") + if err != nil { + t.Fatal(err) + } + cli := molecule.NewLoopMarker(molecule.LoopStarterCLI, 0) + if err = cli.Acquire(lab, selector, 2); err != nil { + t.Fatal(err) + } + defer cli.Release() + cli.TrackIntake(243) + v := viewOf[LoopView](t, do(t, h, authed("/api/loop")), http.StatusOK) + if v.LabLoop.OwnerText != "Started from the command line with milestone:v1 · stop it there" || v.LabLoop.CanStop || len(v.LabLoop.Intake) != 1 || v.LabLoop.Intake[0].Issue != 243 { + t.Fatalf("terminal loop %+v", v.LabLoop) + } + w := do(t, h, gatePost("/api/loop/start", `{"select":"oldest","maxConcurrent":1}`)) + refusalOf(t, w, http.StatusConflict, "busy") + if !strings.Contains(w.Body.String(), "started in a terminal") || d.calls() != 0 { + t.Fatalf("refusal %s after %d engine calls", w.Body, d.calls()) + } +} + +// The default selector is milestone:: the milestone with the +// most open Issues in the sync's open-Issue listing, ties to the one with the +// lowest open Issue number. It is oldest only when the listing names no +// milestone, and "" (unknown, nothing guessed) without a complete listing. +func TestUILoopDefaultSelectorIsTheActiveMilestone(t *testing.T) { + listing := func(truncated bool, milestones map[int64]string) *forgesync.Snapshot { + s := &forgesync.Snapshot{Listing: forgesync.Listing{Truncated: truncated}} + for n := int64(1); n <= 12; n++ { + i := openSyncIssue(n) + i.Milestone = milestones[n] + s.Listing.Issues = append(s.Listing.Issues, i) + } + return s + } + for name, c := range map[string]struct { + snap *forgesync.Snapshot + select_, has string + }{ + "unknown": {nil, "", "The active milestone is unknown"}, + "truncated": {listing(true, map[int64]string{1: "v1"}), "", "The active milestone is unknown"}, + "no milestone": {listing(false, nil), "oldest", "no open milestone"}, + "most open": {listing(false, map[int64]string{1: "v2", 4: "v1", 5: "v1", 6: "v1", 7: "v2"}), "milestone:v1", "Milestone v1: the open milestone with the most open Issues (3)."}, + "tie": {listing(false, map[int64]string{2: "v1", 9: "v1", 3: "v2", 10: "v2"}), "milestone:v1", "(2)"}, + "tie other way": {listing(false, map[int64]string{8: "v1", 9: "v1", 3: "v2", 10: "v2"}), "milestone:v2", "(2)"}, + "unusable title": {listing(false, map[int64]string{1: "bad\ntitle", 2: "bad\ntitle", 3: "v3"}), "milestone:v3", "(1)"}, + } { + got, note := activeMilestoneSelect(c.snap) + if got != c.select_ || !strings.Contains(note, c.has) || !strings.Contains(note, "Oldest: oldest open Issue, any milestone.") { + t.Errorf("%s: %q %q", name, got, note) + } + } + + // Served: /api/loop reads only the sync the Issues view already made, while + // it is current; it never reads GitHub itself. + f := newSyncFetcher() + for i := range f.listing.Issues { + if f.listing.Issues[i].Number <= 3 { + f.listing.Issues[i].Milestone = "v1" + } + } + h, clk := syncHandler(t, Options{LabDir: syncLab(t)}, f) + if v := viewOf[LoopView](t, do(t, h, authed("/api/loop")), http.StatusOK); v.DefaultSelect != "" || f.calls() != 0 { + t.Fatalf("before any sync: %q after %d GitHub reads", v.DefaultSelect, f.calls()) + } + issueRows(t, h, "/api/issues") + calls := f.calls() + if v := viewOf[LoopView](t, do(t, h, authed("/api/loop")), http.StatusOK); v.DefaultSelect != "milestone:v1" || !strings.Contains(v.DefaultSelectNote, "(3)") || f.calls() != calls { + t.Fatalf("after the sync: %q %q", v.DefaultSelect, v.DefaultSelectNote) + } + clk.add(3 * forgesync.TTL) + if v := viewOf[LoopView](t, do(t, h, authed("/api/loop")), http.StatusOK); v.DefaultSelect != "" || f.calls() != calls { + t.Fatalf("stale sync: %q", v.DefaultSelect) + } +} + +// The served loop module reads any Lab loop: the pill counts its in-flight +// Molecules and planning intakes, offers Stop only when canStop and otherwise +// names the owner; the panel renders "issue #N · planning", the owner text, +// the default selector's explanation and the engine's refusal text. +func TestUIServedJSLoopStatus(t *testing.T) { + js := servedJS(t) + for _, want := range []string{ + "const lab = (loop && loop.labLoop) || {};", + "const phase = own !== 'idle' ? own : lab.running === true ? 'running' : 'idle';", + "const canStop = phase === 'running' && lab.canStop === true;", + "} else if (canStop) stop.hidden = false;", + "const owner = lab.running === true && typeof lab.ownerText === 'string' ? lab.ownerText : '';", + "const elsewhere = phase === 'running' && !canStop && owner ? ' · ' + owner : '';", + "+ inFlight + ' in flight') + elsewhere);", + "'issue #' + notRecorded(i.issue) + ' · planning'", + "el('strong', null, notRecorded(lab.ownerText)", + "if (loop.state === 'idle') return labLoopText(loop.labLoop);", + "'Oldest: oldest open Issue, any milestone.'", + "setText(c.selectNote, loop.defaultSelectNote)", + "reasonText(loop)", + // The server's default selector fills the form until the operator chooses. + "mode: 'milestone', modeChosen: false, defaultSelect: null,", + "applyDefaultSelect(loop);", + "if (!c || drive.modeChosen || !loop || typeof loop.defaultSelect !== 'string' || loop.defaultSelect === drive.defaultSelect) return;", + "if (loop.defaultSelect === 'oldest') { showMode('oldest'); return; }", + "showMode('milestone');\n c.value.value = title;", + "oninput: () => { drive.modeChosen = true; }", + "' not attempted (time bound reached)'", + } { + if !strings.Contains(js, want) { + t.Errorf("loop.js lacks %q", want) + } + } + if choose := moduleFunction(t, js, "chooseMode"); !strings.Contains(choose, "drive.modeChosen = true;") { + t.Error("choosing a mode does not keep the operator's choice") + } + pill := moduleFunction(t, js, "renderLoopPill") + if strings.Contains(pill, "phase === 'running') stop.hidden = false") { + t.Error("the pill offers Stop for any running loop, not only when canStop") + } +} diff --git a/lab/internal/ui/projection.go b/lab/internal/ui/projection.go index 08767c6..0db7203 100644 --- a/lab/internal/ui/projection.go +++ b/lab/internal/ui/projection.go @@ -168,10 +168,13 @@ func nextAction(lane, activity, status, phase, current string, g GateView, deliv } switch lane { case LaneDecision: - if g.State == GateScope || g.State == GateDelivery { - return NextView{"Needs your decision" + at, "This Molecule waits at a human gate (phase " + recorded(phase) + "). Review it and decide under Gates and decisions, or with the forgecell CLI. The engine checks every decision again."} + if g.State == GateScope { + return NextView{"Needs your decision" + at, "This Molecule waits at a human gate (phase " + recorded(phase) + "). Review the plan, then click Approve plan & start coding below; decide under Gates and decisions. The engine checks every decision again."} } - return NextView{"Waiting at a human gate" + at, "This Molecule waits at a human gate (phase " + recorded(phase) + ") that this Lab UI does not perform. Inspect it with forgecell ledger and decide with the forgecell CLI. The engine checks every decision again."} + if g.State == GateDelivery { + return NextView{"Needs your decision" + at, "This Molecule waits at a human gate (phase " + recorded(phase) + "). Click Preview delivery below, then Publish draft PR; decide under Gates and decisions. The engine checks every decision again."} + } + return NextView{"Waiting at a human gate" + at, "This Molecule waits at a human gate (phase " + recorded(phase) + "). What you can do lists the actions the engine accepts now. The engine checks every decision again."} case LaneRunning: if activity == ActivityQueued { return NextView{"Queued for checks" + at, "Another Molecule in this Lab holds the verification slot. Checks start, and their time budgets begin, when the slot is free. This view updates from the ledger."} @@ -185,7 +188,7 @@ func nextAction(lane, activity, status, phase, current string, g GateView, deliv return NextView{"Retired", "This Molecule was retired: it is finished history and every record is kept. The loop never resumes it and may start a fresh Molecule for its Issue."} } if g.State == GateDelivery { - return NextView{"Ready for a draft PR" + at, strings.TrimSpace("This Molecule finished with status " + recorded(status) + ". Preview and publish a draft PR under Gates and decisions, or with the forgecell CLI. " + label)} + return NextView{"Ready for a draft PR" + at, strings.TrimSpace("This Molecule finished with status " + recorded(status) + ". Click Preview delivery below, then Publish draft PR under Gates and decisions. " + label)} } if label == "" { label = "No published delivery receipt. This Molecule is in no lane; it appears under All and Other." @@ -1118,7 +1121,7 @@ func decisionView(r molecule.Record, lane, activity string, g GateView, plan *Pl d.Copy = harness.ReviewDisplayText(hint, maxReasonRunes) + ", then start this Issue again." } case LaneDecision: - d.Title, d.Copy = "Waiting at a gate", "This page does not perform this gate. Decide with the forgecell CLI; the engine checks every decision again." + d.Title, d.Copy = "Waiting at a gate", "Choose a button under What you can do when one is offered; the engine checks every decision again." case LaneRetired: d.Title, d.Copy = "Retired", "Every record is kept. The loop never resumes it and may start a fresh Molecule for its Issue." } diff --git a/lab/internal/ui/retire.go b/lab/internal/ui/retire.go index 27821f4..91b8773 100644 --- a/lab/internal/ui/retire.go +++ b/lab/internal/ui/retire.go @@ -12,7 +12,9 @@ import ( // synchronous: the engine only appends the retired event under the Molecule // lock. Committed comes only from the ledger read-back recording that event. // Engine error text is never served: each refusal maps to a fixed category, -// message and reason. There is no bulk retire. +// message and reason. The one bulk retire, of the attempts for closed Issues, +// is bulk_retire.go: it calls the same engine retire for each Molecule and maps +// each refusal through retireRefusals. // RetireAction is the action name /api/lab lists for the retire route. const RetireAction = "retire Molecule" diff --git a/lab/internal/ui/served_actions_test.go b/lab/internal/ui/served_actions_test.go new file mode 100644 index 0000000..5615595 --- /dev/null +++ b/lab/internal/ui/served_actions_test.go @@ -0,0 +1,110 @@ +package ui + +import ( + "net/http" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/readiness" +) + +// Every gate's main action is a primary button, Retire sits in the separate +// Danger zone behind its own open step, a reason and Confirm retire, and the +// "What you can do" actions are primary buttons posting the shown digest. +func TestUIServedJSGateActionsArePrimaryButtons(t *testing.T) { + js := servedJS(t) + for _, want := range []string{ + "c.approve = submitButton({ class: 'btn btn-primary btn-decision'", + "c.preview = el('button', { type: 'button', class: 'btn btn-primary btn-decision'", + "c.publish = submitButton({ class: 'btn btn-primary btn-decision'", + // Retire: open, then a reason and Confirm retire, in the Danger zone. + "onclick: () => openConfirm('retire') }, 'Retire this Molecule…')", + "c.retireSubmit = submitButton({ class: 'btn btn-danger', dataset: { gate: 'retire' } }, 'Confirm retire');", + "field('mv-retire-reason', 'Reason', c.reasonInput)", + "el('details', { class: 'danger-zone', id: 'mv-danger', hidden: true }", + "fill($('#mv-danger-slot'), c.runnerNote, c.danger);", + // What you can do. + "'Why it’s blocked · What you can do'", + "const button = submitButton({ class: 'btn btn-primary btn-decision', dataset: { action: a.kind } }", + "if (a.kind === 'retire') return el('p', { class: 'drive-help' }, label + ': ' + copy);", + "'/api/molecules/' + encodeURIComponent(d.id) + '/actions/' + encodeURIComponent(a.kind)", + "let body = { digest: a.digest };", + "if (a.kind === 'fresh-intake') { path = '/api/runs'; body = { issue: a.issue }; }", + "'I confirm it is stopped: no Lab process works on this Molecule'", + // Accept-failures binds the exact tests listed; scope-change posts the + // ledger's paths; an action not offered is named with its reason. + "if (a.kind === 'accept-failures') body.deltas = a.deltasDigest;", + "body.paths = a.paths;", + "' is not offered: ' + plainText(a.reason, '')", + "res = await postJSON(path, JSON.stringify(body));", + "'The response was lost or ambiguous. The Molecule is read back; nothing is resubmitted.'", + "It waits for your approval: click Approve plan & start coding. Nothing was approved.", + // Bulk retire: the exact count and list, then Confirm retire. + "'Confirm retire ' + items.length", + "JSON.stringify({ count: list.count, digest: list.digest })", + "'Each is retired with the reason “Issue closed; superseded attempt”. Every record is kept.'", + } { + if !strings.Contains(js, want) { + t.Errorf("the served JS lacks %q", want) + } + } + // The actions panel never posts an approval, and Retire is never a button + // beside a primary action. + item := moduleFunction(t, js, "actionItem") + if strings.Contains(item, "btn-danger") || strings.Contains(item, "/scope") { + t.Error("an action in What you can do is a danger button or approves") + } + submit := moduleFunction(t, js, "submitAction") + if strings.Contains(submit, "approve") || strings.Contains(submit, "/scope") { + t.Error("submitAction approves") + } +} + +// The served page and scripts no longer send the user to the CLI for a +// decision: the overview names the button, the panel and the Danger zone have +// their own blocks, and Next action names the button to click. +func TestUIDecisionCopyNamesTheButton(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}) + page := do(t, h, authed("/")).Body.String() + js := servedJS(t) + for _, text := range []string{page, js} { + for _, gone := range []string{"Decide with the forgecell CLI", "or with the forgecell CLI", "decide with the forgecell CLI"} { + if strings.Contains(text, gone) { + t.Errorf("served copy still says %q", gone) + } + } + } + for _, want := range []string{``, `
`, "Open one and click its button"} { + if !strings.Contains(page, want) { + t.Errorf("index.html lacks %q", want) + } + } + if strings.Index(page, `id="mv-actions"`) > strings.Index(page, `id="mv-danger-slot"`) { + t.Error("the Danger zone is not below What you can do") + } + + lab, r, digest := gateFixture(t) + hl := testHandler(t, Options{LabDir: lab}) + d := detailOf(t, hl, r.ID) + if !strings.Contains(d.Next.Copy, "click Approve plan & start coding below") || strings.Contains(d.Next.Copy, "CLI") || d.View.Decision.Primary == nil || d.View.Decision.Primary.Label != "Approve plan & start coding" || d.View.Decision.Primary.Digest != digest { + t.Fatalf("scope gate next %+v decision %+v", d.Next, d.View.Decision) + } + // Ready for a draft PR names Preview delivery and Publish draft PR. + review := r + review.Status, review.Readiness = "completed", &readiness.State{SchemaVersion: "v1", Phase: "review-waiting", Plans: r.Readiness.Plans} + writeRecord(t, lab, review) + var dv Detail + decode(t, do(t, hl, authed("/api/molecules/"+r.ID)), &dv) + if dv.Gate.State == GateDelivery && (!strings.Contains(dv.Next.Copy, "Click Preview delivery below") || strings.Contains(dv.Next.Copy, "CLI")) { + t.Fatalf("delivery gate next %+v", dv.Next) + } + // A gate this page does not perform points at What you can do, not the CLI. + for _, copy := range []string{decisionView(review, LaneDecision, "", GateView{}, nil).Copy} { + if strings.Contains(copy, "CLI") || !strings.Contains(copy, "What you can do") { + t.Errorf("decision copy %q", copy) + } + } + if w := do(t, hl, authed("/api/molecules/"+r.ID+"/actions")); w.Code != http.StatusOK { + t.Fatalf("actions view: %d", w.Code) + } +} diff --git a/lab/internal/ui/server.go b/lab/internal/ui/server.go index 741123b..692de22 100644 --- a/lab/internal/ui/server.go +++ b/lab/internal/ui/server.go @@ -3,12 +3,18 @@ // writes are the human gates in gates.go (scope approval and draft publication), // Request changes on a waiting plan (amend.go, which records a revision and never // approves it), the drive actions in drive.go (start intake, start or stop the -// loop, stop a Molecule this UI runs) and retiring one Molecule (retire.go), each -// a POST that needs the session cookie, the exact Origin and the per-tab session -// header, and each performed by the engine, which validates digests, locks and -// receipts. The one read that runs a process is a Molecule's diff size -// (diffsize.go): git diff --numstat through the engine, bounded and cached by -// tree. It never merges, deploys, recovers or changes permissions. +// loop, stop a Molecule this UI runs), retiring one Molecule (retire.go), the +// next actions of a blocked Molecule (actions.go: accept by-design failures, +// approve a requested scope change, re-run checks and recover, each recording a +// revision or an interruption and never approving) and retiring the attempts +// for closed Issues (bulk_retire.go), each a POST that needs the session cookie, +// the exact Origin and the per-tab session header, and each performed by the +// engine, which validates digests, locks and receipts. The reads that run a +// process are a Molecule's diff size (diffsize.go), git diff --numstat through +// the engine, bounded and cached by tree, and its next actions, whose +// accept-failures preview reads the retained tree through git. It never +// merges, deploys or changes permissions, and recovers only on an explicit +// confirmation. package ui import ( @@ -197,6 +203,14 @@ type handler struct { // forge is the read-only GitHub sync behind /api/issues and /api/sync; tests // replace its fetcher and clock. forge *issueSync + // recover and deriveActions are the engine calls behind the next actions + // (actions.go): forgecell recover and molecule.NextActions. Tests may replace + // them. + recover func(ctx context.Context, lab, id, digest string, confirmedStopped bool) (molecule.Record, error) + deriveActions func(ctx context.Context, lab string, r molecule.Record, preview bool, timeout time.Duration) []molecule.NextAction + // closedDeadline, when a test sets it, is the bulk retire's overall deadline + // in place of bulkRetireTimeout (bulk_retire.go). + closedDeadline func() (context.Context, context.CancelFunc) } // staticFiles are the only embedded files served, with their content types. @@ -303,7 +317,7 @@ func NewHandler(o Options, token, host string) (http.Handler, error) { if err != nil { return nil, err } - return &handler{opts: o, token: token, exchanged: make(chan struct{}), session: session, tab: tab, host: host, origin: "http://" + host, cookie: "forgecell_ui_" + port, static: static, records: molecule.ReadRecord, receipts: delivery.ReadReceiptStatus, cache: newListCache(), runScope: molecule.Run, preview: delivery.Preview, publish: delivery.Publish, jobs: newGateJobs(), runIssue: molecule.RunIntake, runLoop: molecule.RunLoop, loop: &driveLoop{}, retire: molecule.Retire, listIssues: intake.ReadSelectedOpenIssues, amend: molecule.AmendFromLatest, numstat: molecule.DiffNumstat, diffs: newDiffCache(), forge: newIssueSync(nil, nil)}, nil + return &handler{opts: o, token: token, exchanged: make(chan struct{}), session: session, tab: tab, host: host, origin: "http://" + host, cookie: "forgecell_ui_" + port, static: static, records: molecule.ReadRecord, receipts: delivery.ReadReceiptStatus, cache: newListCache(), runScope: molecule.Run, preview: delivery.Preview, publish: delivery.Publish, jobs: newGateJobs(), runIssue: molecule.RunIntake, runLoop: molecule.RunLoop, loop: &driveLoop{}, retire: molecule.Retire, listIssues: intake.ReadSelectedOpenIssues, amend: molecule.AmendFromLatest, numstat: molecule.DiffNumstat, diffs: newDiffCache(), forge: newIssueSync(nil, nil), recover: molecule.Recover, deriveActions: molecule.NextActions}, nil } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -336,8 +350,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // GET serves every read; POST is served only on the gate and drive routes. gateID, gateAction, gate := gateRoute(r.URL.Path) driveID, driveAction, drive := driveRoute(r.URL.Path) - if r.Method != http.MethodGet && (r.Method != http.MethodPost || !(gate || drive)) { - if gate || drive { + actionID, actionKind, action := actionRoute(r.URL.Path) + if r.Method != http.MethodGet && (r.Method != http.MethodPost || !(gate || drive || action)) { + if gate || drive || action { header.Set("Allow", http.MethodPost) } else { header.Set("Allow", http.MethodGet) @@ -370,6 +385,18 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.drive(w, r, driveID, driveAction) return } + if action { + if !moleculeID.MatchString(actionID) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "invalid Molecule id") + return + } + if h.opts.Demo { + h.refuseGate(w, http.StatusNotFound, "sample", "sample data has no actions; nothing was written") + return + } + h.moleculeAction(w, r, actionID, actionKind) + return + } if h.refuseRetiredScope(w, gateID, gateAction) { return } @@ -391,6 +418,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.issues(w, r) case path == "/api/sync": h.syncStates(w, r) + case path == "/api/retire-closed": + h.retireClosedStatus(w, r) case strings.HasPrefix(path, "/api/molecules/"): h.molecule(w, r, strings.TrimPrefix(path, "/api/molecules/")) case staticFiles[path] != "": @@ -491,10 +520,11 @@ func (h *handler) lab(w http.ResponseWriter) { return } id := formula.ActiveIdentity(h.opts.LabDir) - // Retiring and Request changes need no source checkout; starting work does. - actions := []string{RetireAction, AmendAction} + // Retiring, the next actions and Request changes need no source checkout; + // starting work does. + actions := []string{RetireAction, UnblockAction, BulkRetireAction, AmendAction} if h.opts.SourceRoot != "" { - actions = append(append([]string{}, DriveActions...), AmendAction) + actions = append(append([]string{}, DriveActions...), UnblockAction, BulkRetireAction, AmendAction) } h.json(w, http.StatusOK, LabView{Gates: Gates, Actions: actions, Name: filepath.Base(h.opts.LabDir), Version: h.opts.Version, Repository: id.Repository, Binding: id.Binding, Model: id.Model, Formula: id, Unavailable: unavailable, Operator: operatorView(h.opts.Operator)}) } @@ -541,7 +571,7 @@ func (h *handler) molecule(w http.ResponseWriter, r *http.Request, rest string) h.refuse(w, http.StatusBadRequest, "invalid Molecule id") return } - if view != "" && view != "evidence" && view != "ledger" && view != "job" && view != "stats" { + if view != "" && view != "evidence" && view != "ledger" && view != "job" && view != "stats" && view != "actions" { h.refuse(w, http.StatusNotFound, "not found") return } @@ -585,6 +615,10 @@ func (h *handler) molecule(w http.ResponseWriter, r *http.Request, rest string) h.json(w, http.StatusOK, text) case "stats": h.json(w, http.StatusOK, h.stats(record)) + case "actions": + ctx, cancel := context.WithTimeout(r.Context(), previewTimeout) + defer cancel() + h.json(w, http.StatusOK, h.actionsView(ctx, record)) default: d := project(h.opts.LabDir, record, h.receipts, withYAML) if d.View != nil { diff --git a/lab/internal/ui/web/index.html b/lab/internal/ui/web/index.html index 8f3bb8d..e8525f8 100644 --- a/lab/internal/ui/web/index.html +++ b/lab/internal/ui/web/index.html @@ -193,6 +193,7 @@

What Forgecell will do

@@ -210,7 +211,7 @@

Lab overview

FORMULA SUGGESTIONSUnavailable

Suggestion review is not connected in this slice.

- +
diff --git a/lab/internal/ui/web/js/core-gates.js b/lab/internal/ui/web/js/core-gates.js index 2ebf00b..4000a70 100644 --- a/lab/internal/ui/web/js/core-gates.js +++ b/lab/internal/ui/web/js/core-gates.js @@ -7,9 +7,15 @@ import { el, state, ctx, notRecorded, SessionError, tabSession, api, failed, keepFocus } from './core.js'; export async function gatePost(id, action, body) { + return postJSON('/api/molecules/' + encodeURIComponent(id) + action, body); +} + +// postJSON is one write: the tab session header, same-origin credentials and a +// JSON body. A 403 means this tab lost its session. +export async function postJSON(path, body) { const session = tabSession(); if (!session || state.sessionLost) throw new SessionError('no session'); - const res = await fetch('/api/molecules/' + encodeURIComponent(id) + action, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'X-Forgecell-Session': session, 'Content-Type': 'application/json' }, body: body }); + const res = await fetch(path, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'X-Forgecell-Session': session, 'Content-Type': 'application/json' }, body: body }); if (res.status === 403) throw new SessionError('forbidden'); return res; } diff --git a/lab/internal/ui/web/js/loop.js b/lab/internal/ui/web/js/loop.js index 376a6aa..e45549d 100644 --- a/lab/internal/ui/web/js/loop.js +++ b/lab/internal/ui/web/js/loop.js @@ -5,13 +5,13 @@ // state is read back and never resubmitted. A run shows as started only once // the server's ledger read-back finds its Molecule; the loop's report comes // from the engine. -import { $, el, fill, state, ctx, notRecorded, SessionError, tabSession, api, failed, setText } from './core.js'; +import { $, el, fill, state, ctx, notRecorded, SessionError, tabSession, api, failed, setText, setAttr } from './core.js'; import { reasonText, refusalText } from './core-gates.js'; // where names the form (run or loop) whose status shows the pending note and // whose inline error shows the last message; it changes only what is displayed // where. announce moves focus to that error once, when a message is set. -const drive = { panel: null, controls: null, loaded: false, pending: false, where: 'run', message: '', announce: false, mode: 'oldest', runIssue: null, run: null, loop: null, timer: null, token: 0 }; +const drive = { panel: null, controls: null, loaded: false, pending: false, where: 'run', message: '', announce: false, mode: 'milestone', modeChosen: false, defaultSelect: null, runIssue: null, run: null, loop: null, timer: null, token: 0, closed: null, closedReport: null, closedConfirm: false }; // driveMessage shows text as the inline error of the form drive.where names, // and moves focus to it. @@ -32,7 +32,7 @@ async function drivePost(path, body) { async function submitDrive(path, payload) { if (drive.pending) return null; drive.pending = true; - drive.where = path === '/api/runs' ? 'run' : 'loop'; + drive.where = path === '/api/runs' ? 'run' : path === '/api/retire-closed' ? 'closed' : 'loop'; drive.message = ''; renderDrive(); let res = null, body = {}; @@ -71,7 +71,7 @@ async function readDrive() { if (token !== drive.token) return; drive.loop = loop; drive.run = run; - const busy = (loop && (loop.state === 'running' || loop.state === 'draining')) || (run && run.state === 'running'); + const busy = (loop && (loop.state === 'running' || loop.state === 'draining' || (loop.labLoop && loop.labLoop.running))) || (run && run.state === 'running'); if (busy && !state.sessionLost) drive.timer = setTimeout(readDrive, 2000); renderDrive(); } @@ -99,6 +99,67 @@ async function stopLoop() { if (loop) { drive.loop = loop; readDrive(); } } +// ---- Retire attempts for closed Issues --------------------------------------------- +// List reads the exact list; Retire opens the confirmation that names the +// count; Confirm retire posts that count and the list's digest, so a list that +// changed meanwhile is refused. Each Molecule's result is shown on its own. +async function listClosed() { + if (drive.pending) return; + drive.where = 'closed'; + drive.closedConfirm = false; + drive.closedReport = null; + try { + const res = await api('/api/retire-closed'); + drive.closed = res.ok ? await res.json() : null; + if (!res.ok) drive.message = 'The list could not be read; nothing was retired.'; + } catch (error) { failed(error); } + renderDrive(); +} + +async function retireClosed() { + const list = drive.closed; + if (!list || !list.available || !drive.closedConfirm) return; + const report = await submitDrive('/api/retire-closed', JSON.stringify({ count: list.count, digest: list.digest })); + drive.closedConfirm = false; + if (report) { drive.closedReport = report; drive.closed = null; } + renderDrive(); +} + +function closedText() { + const list = drive.closed, report = drive.closedReport; + if (report) { + const results = Array.isArray(report.results) ? report.results : []; + return el('div', { class: 'gate-job' }, + el('strong', null, 'Retired ' + notRecorded(report.retired) + ' of ' + notRecorded(report.attempted) + (report.refused ? ' · ' + report.refused + ' refused' : '') + (report.notAttempted ? ' · ' + report.notAttempted + ' not attempted (time bound reached)' : '')), + el('ul', { class: 'diagnostics' }, results.map((r) => el('li', null, notRecorded(r.id) + ' · ' + (r.committed ? 'retired' : 'not retired: ' + (r.category ? r.category + ': ' : '') + notRecorded(r.error))))), + report.truncated ? el('p', { class: 'scope-note' }, 'More attempts remain; list them again.') : null); + } + if (!list) return null; + if (!list.available) return el('p', { class: 'scope-note' }, 'Unavailable: ' + notRecorded(list.reason) + '.'); + const items = Array.isArray(list.molecules) ? list.molecules : []; + if (!items.length) return el('p', { class: 'scope-note' }, 'No attempt for a closed Issue needs retiring.'); + return el('div', { class: 'gate-job' }, + el('strong', null, items.length + ' attempt(s) for closed Issues' + (list.truncated ? ' (first ' + items.length + ')' : '')), + el('ul', { class: 'diagnostics' }, items.map((m) => el('li', null, '#' + notRecorded(m.issue) + ' · ' + notRecorded(m.id) + ' · ' + notRecorded(m.lane)))), + drive.closedConfirm + ? el('div', { class: 'confirm-actions' }, + el('button', { type: 'button', class: 'btn btn-danger', dataset: { drive: 'closed-confirm' }, onclick: retireClosed }, 'Confirm retire ' + items.length), + el('button', { type: 'button', class: 'btn btn-secondary', dataset: { drive: 'closed-cancel' }, onclick: () => { drive.closedConfirm = false; renderDrive(); } }, 'Cancel')) + : el('button', { type: 'button', class: 'btn btn-danger', dataset: { drive: 'closed-retire' }, onclick: () => { drive.closedConfirm = true; renderDrive(); } }, 'Retire ' + items.length + '…'), + el('p', { class: 'drive-help' }, 'Each is retired with the reason “Issue closed; superseded attempt”. Every record is kept.')); +} + +// labLoopText names the Lab's loop: who started it, its selector, how many at +// once, when it started and what it has in flight. +function labLoopText(lab) { + if (!lab || !lab.running) return null; + const inFlight = (Array.isArray(lab.inFlight) ? lab.inFlight : []).concat((Array.isArray(lab.intake) ? lab.intake : []).map((i) => 'issue #' + notRecorded(i.issue) + ' · planning')); + return el('div', { class: 'gate-job' }, + el('strong', null, notRecorded(lab.ownerText) + (lab.maxConcurrent ? ' · ' + lab.maxConcurrent + ' at once' : '') + (lab.startedAt ? ' · started ' + lab.startedAt : '')), + inFlight.length ? el('ul', { class: 'diagnostics' }, inFlight.map((x) => el('li', null, x))) : el('p', { class: 'scope-note' }, 'Nothing in flight.'), + lab.inFlightTruncated ? el('p', { class: 'scope-note' }, 'More are in flight than shown.') : null); +} + // The Run-the-loop selector: Oldest, or a milestone title or label name from // the value field, as the same selector string the loop takes; the server // parses and validates it again. @@ -128,7 +189,29 @@ function submitRun(e) { startRun(drive.controls.issue.value); } +// chooseMode is the operator's choice: from then on the server's default +// selector never replaces it. function chooseMode(mode) { + if (!LOOP_MODES[mode]) return; + drive.modeChosen = true; + showMode(mode); +} + +// applyDefaultSelect shows the server's default selector (milestone:, oldest when the repository has no open milestone, or "" when +// the active milestone is unknown: Milestone with an empty title) until the +// operator chooses or types a selector. +function applyDefaultSelect(loop) { + const c = drive.controls; + if (!c || drive.modeChosen || !loop || typeof loop.defaultSelect !== 'string' || loop.defaultSelect === drive.defaultSelect) return; + drive.defaultSelect = loop.defaultSelect; + if (loop.defaultSelect === 'oldest') { showMode('oldest'); return; } + const title = loop.defaultSelect.startsWith('milestone:') ? loop.defaultSelect.slice('milestone:'.length) : ''; + showMode('milestone'); + c.value.value = title; +} + +function showMode(mode) { const c = drive.controls, m = LOOP_MODES[mode]; if (!c || !m) return; drive.mode = mode; @@ -145,7 +228,7 @@ function chooseMode(mode) { function ensureDrive() { if (drive.panel) return; const modes = Object.entries(LOOP_MODES).map(([mode, m]) => el('button', { type: 'button', class: 'seg', 'aria-pressed': String(mode === drive.mode), dataset: { drive: 'mode-' + mode }, onclick: () => chooseMode(mode) }, m.label)); - const value = el('input', { type: 'text', id: 'drive-value', name: 'value', maxlength: '200', autocomplete: 'off', spellcheck: 'false', placeholder: 'v1…', 'aria-describedby': 'drive-loop-error', dataset: { drive: 'value' } }); + const value = el('input', { type: 'text', id: 'drive-value', name: 'value', maxlength: '200', autocomplete: 'off', spellcheck: 'false', placeholder: 'v1…', 'aria-describedby': 'drive-loop-error', dataset: { drive: 'value' }, oninput: () => { drive.modeChosen = true; } }); const valueLabel = el('label', { class: 'field-label', for: 'drive-value' }, 'Milestone title'); const valueField = el('div', { class: 'drive-field' }, valueLabel, value); const max = el('input', { type: 'number', id: 'drive-max', name: 'maxConcurrent', min: '1', max: '8', step: '1', value: '1', inputmode: 'numeric', autocomplete: 'off', 'aria-describedby': 'drive-loop-error', dataset: { drive: 'max' } }); @@ -164,13 +247,22 @@ function ensureDrive() { const run = el('button', { type: 'submit', class: 'btn btn-secondary', dataset: { drive: 'run' } }, 'Start intake'); const runError = el('p', { class: 'drive-error', id: 'drive-run-error', tabindex: '-1', hidden: true }); const runStatus = el('div', { class: 'drive-status', role: 'status' }); - drive.controls = { modes: modes, value: value, valueLabel: valueLabel, valueField: valueField, max: max, start: start, loopError: loopError, loopStatus: loopStatus, issue: issue, run: run, runError: runError, runStatus: runStatus }; + const selectNote = el('p', { class: 'drive-help', dataset: { drive: 'select-note' } }, 'Oldest: oldest open Issue, any milestone.'); + const closedList = el('button', { type: 'button', class: 'btn btn-secondary', dataset: { drive: 'closed-list' }, onclick: listClosed }, 'List attempts for closed Issues'); + const closedError = el('p', { class: 'drive-error', id: 'drive-closed-error', tabindex: '-1', hidden: true }); + const closedStatus = el('div', { class: 'drive-status', role: 'status' }); + const closedForm = el('div', { class: 'drive-panel drive-form drive-closed', id: 'drive-closed', 'aria-labelledby': 'drive-closed-title', hidden: true }, + el('h2', { class: 'drive-title', id: 'drive-closed-title' }, 'Retire attempts for closed Issues'), + el('p', { class: 'drive-help' }, 'Lists every attempt that is not running, has no delivery in progress and whose Issue GitHub reports closed.'), + closedList, closedError, closedStatus); + drive.controls = { modes: modes, value: value, valueLabel: valueLabel, valueField: valueField, max: max, start: start, loopError: loopError, loopStatus: loopStatus, issue: issue, run: run, runError: runError, runStatus: runStatus, selectNote: selectNote, closedForm: closedForm, closedList: closedList, closedError: closedError, closedStatus: closedStatus }; drive.panel = el('div', { class: 'drive-forms', id: 'drive-panel' }, el('form', { class: 'drive-panel drive-form', id: 'drive-loop', tabindex: '-1', 'aria-labelledby': 'drive-loop-title', novalidate: true, onsubmit: submitLoop }, el('div', null, el('h2', { class: 'drive-title', id: 'drive-loop-title' }, 'Run the loop'), el('p', { class: 'drive-help' }, 'Plans each eligible Issue, then waits for your approval.')), el('div', { class: 'segmented', role: 'group', 'aria-label': 'Which Issues' }, modes), + selectNote, valueField, el('div', { class: 'drive-at-once' }, el('label', { class: 'field-label', for: 'drive-max' }, 'At once'), @@ -183,9 +275,10 @@ function ensureDrive() { el('div', { class: 'drive-field' }, el('label', { class: 'field-label', for: 'drive-issue' }, 'Issue number'), issue), run), runError, runStatus, - el('p', { class: 'drive-help' }, 'Intake plans one Issue and stops at the scope gate for your approval.'))); + el('p', { class: 'drive-help' }, 'Intake plans one Issue and stops at the scope gate for your approval.')), + closedForm); fill($('#drive-slot'), drive.panel); - chooseMode(drive.mode); + showMode(drive.mode); } function runText(run) { @@ -204,7 +297,9 @@ function runText(run) { } function loopText(loop) { - if (!loop || loop.state === 'idle') return null; + if (!loop) return null; + // A loop started elsewhere in the Lab is named with its owner and where to stop it. + if (loop.state === 'idle') return labLoopText(loop.labLoop); const r = loop.report; const items = r && Array.isArray(r.items) ? r.items : []; let status = ''; @@ -212,6 +307,7 @@ function loopText(loop) { else if (loop.state === 'running') status = 'Running. Molecules appear in the Inbox as their ledgers are written.'; else if (r) status = 'Final pass: ' + items.length + ' eligible Issue(s)' + (r.truncated ? ', list truncated' : '') + ' after ' + notRecorded(r.passes) + ' pass(es).'; return el('div', { class: 'gate-job' }, + labLoopText(loop.labLoop), el('strong', null, 'Loop · ' + notRecorded(loop.state) + (loop.select ? ' · select ' + loop.select : '') + (loop.maxConcurrent ? ' · ' + loop.maxConcurrent + ' at once' : '')), status ? el('p', { class: 'scope-note' }, status) : null, loop.error ? el('p', { class: 'scope-note' }, (loop.category ? loop.category + ': ' : '') + loop.error + '.' + reasonText(loop)) : null, @@ -231,7 +327,11 @@ function renderDrive() { if (loop && Number.isInteger(loop.maxConcurrentLimit)) c.max.max = String(loop.maxConcurrentLimit); // The message is the inline error of the form it belongs to, linked from its // inputs by aria-describedby; focus moves to it once when it is set. - for (const [where, error] of [['run', c.runError], ['loop', c.loopError]]) { + if (loop && typeof loop.defaultSelectNote === 'string' && loop.defaultSelectNote) setText(c.selectNote, loop.defaultSelectNote); + applyDefaultSelect(loop); + c.closedForm.hidden = !(state.lab && Array.isArray(state.lab.actions) && state.lab.actions.includes('retire closed Issues')); + c.closedList.disabled = drive.pending; + for (const [where, error] of [['run', c.runError], ['loop', c.loopError], ['closed', c.closedError]]) { const text = drive.where === where ? drive.message : ''; setText(error, text); error.hidden = !text; @@ -240,28 +340,39 @@ function renderDrive() { const pending = (where) => drive.pending && drive.where === where ? el('p', { class: 'scope-note' }, 'Submitting…') : null; fill(c.runStatus, pending('run'), runText(drive.run)); fill(c.loopStatus, pending('loop'), loopText(loop)); + fill(c.closedStatus, pending('closed'), closedText()); renderLoopPill(); } -// The loop pill reads the loop this UI runs: idle with a Start loop link to the -// Run-the-loop form, or running with N in flight and Stop behind a disclosure -// and a confirmation that names the loop. Stop reuses stopLoop unchanged. +// The loop pill reads any loop in this Lab (/api/loop's labLoop): idle with a +// Start loop link to the Run-the-loop form, or running with N in flight. Stop +// sits behind a disclosure and a confirmation that names the loop, and only +// when canStop; otherwise the pill's text names the owner and where to stop +// it. Stop reuses stopLoop unchanged. function renderLoopPill() { const pill = $('#loop-pill'); if (!drive.panel) { pill.hidden = true; return; } pill.hidden = false; const loop = drive.loop; - const phase = loop && (loop.state === 'running' || loop.state === 'draining') ? loop.state : 'idle'; - const inFlight = (state.list && state.list.counts && state.list.counts.running) || 0; + const lab = (loop && loop.labLoop) || {}; + const own = loop && (loop.state === 'running' || loop.state === 'draining') ? loop.state : 'idle'; + const phase = own !== 'idle' ? own : lab.running === true ? 'running' : 'idle'; + const listed = (Array.isArray(lab.inFlight) ? lab.inFlight.length : 0) + (Array.isArray(lab.intake) ? lab.intake.length : 0); + const inFlight = lab.running === true ? listed : (state.list && state.list.counts && state.list.counts.running) || 0; if (pill.dataset.loop !== phase) pill.dataset.loop = phase; - setText($('#loop-pill-text'), phase === 'idle' ? 'Loop is idle' : (phase === 'draining' ? 'Loop stopping · ' : 'Loop running · ') + inFlight + ' in flight'); + const canStop = phase === 'running' && lab.canStop === true; + const owner = lab.running === true && typeof lab.ownerText === 'string' ? lab.ownerText : ''; + // A loop this UI cannot stop is named in plain text, with where to stop it. + const elsewhere = phase === 'running' && !canStop && owner ? ' · ' + owner : ''; + setText($('#loop-pill-text'), (phase === 'idle' ? 'Loop is idle' : (phase === 'draining' ? 'Loop stopping · ' : 'Loop running · ') + inFlight + ' in flight') + elsewhere); + setAttr(pill, 'title', owner); $('#loop-start-link').hidden = phase !== 'idle'; const stop = $('#loop-stop'); - if (phase !== 'running' && !stop.hidden) { + if (!canStop && !stop.hidden) { if (stop.contains(document.activeElement)) pill.focus({ preventScroll: true }); stop.open = false; stop.hidden = true; - } else if (phase === 'running') stop.hidden = false; + } else if (canStop) stop.hidden = false; setText($('#loop-stop-question'), 'Stop the loop' + (loop && loop.select ? ' on “' + loop.select + '”' : '') + '?'); const confirm = $('#loop-stop-confirm'); confirm.disabled = drive.pending; diff --git a/lab/internal/ui/web/js/molecule-decision.js b/lab/internal/ui/web/js/molecule-decision.js index ceb9fda..ef53320 100644 --- a/lab/internal/ui/web/js/molecule-decision.js +++ b/lab/internal/ui/web/js/molecule-decision.js @@ -4,8 +4,8 @@ // anymore? block, each behind a confirmation that names the Molecule. Controls // are built once and patched in place, so a poll never rebuilds a form being // typed in, and the one live region changes only when its text does. -import { $, el, fill, state, ctx, newGate, RETIRED, PULL_URL, notRecorded, api, failed, setText, setAttr, plainText, count, counted, wholeNumber, money, whenText, dateTitle, minutesText, STEPS, show } from './core.js'; -import { submitGate, gateMessage, fetchJob, readBack, rerenderGate, jobText } from './core-gates.js'; +import { $, el, fill, state, ctx, newGate, RETIRED, PULL_URL, ISSUE_URL, notRecorded, api, failed, setText, setAttr, plainText, count, counted, wholeNumber, money, whenText, dateTitle, minutesText, STEPS, show, SessionError } from './core.js'; +import { submitGate, gateMessage, fetchJob, readBack, rerenderGate, jobText, refusalText, postJSON } from './core-gates.js'; // mv is the Molecule view's state, shared by its sub-modules. export const mv = { controls: null, steps: null, from: 'work', planKey: null, statsKey: null, statsToken: 0, stats: null, lastDigest: null, replaced: false, timelineId: null, timelineDirty: false }; @@ -169,7 +169,7 @@ export function ensureMoleculeView() { c.retireQuestion = el('p', { class: 'confirm-question', id: 'mv-retire-question' }); c.reasonInput = el('input', { type: 'text', id: 'mv-retire-reason', name: 'reason', maxlength: '200', autocomplete: 'off', placeholder: 'Replaced by a fresh Molecule…', 'aria-describedby': 'mv-retire-error', dataset: { gate: 'reason' }, oninput: (e) => { state.gate.reason = e.target.value; } }); c.retireError = el('p', { class: 'drive-error', id: 'mv-retire-error', tabindex: '-1', hidden: true }); - c.retireSubmit = submitButton({ class: 'btn btn-danger', dataset: { gate: 'retire' } }, 'Retire Molecule'); + c.retireSubmit = submitButton({ class: 'btn btn-danger', dataset: { gate: 'retire' } }, 'Confirm retire'); c.retireForm = el('form', { class: 'confirm-form', id: 'mv-retire-form', 'aria-labelledby': 'mv-retire-question', novalidate: true, hidden: true, onsubmit: submitRetire }, c.retireQuestion, field('mv-retire-reason', 'Reason', c.reasonInput), c.retireError, el('div', { class: 'confirm-actions' }, c.retireSubmit, el('button', { type: 'button', class: 'btn btn-secondary', dataset: { gate: 'retire-cancel' }, onclick: () => closeConfirm('retire', true) }, 'Cancel'))); @@ -363,7 +363,7 @@ export function renderDecision(d) { c.retireOpen.hidden = !canRetire; if (!canRetire && !c.retireForm.hidden) closeConfirm('retire', false); c.retireSubmit.disabled = gate.pending || running; - setText(c.retireSubmit, gate.pending && gate.where === 'retire' ? 'Retiring…' : 'Retire Molecule'); + setText(c.retireSubmit, gate.pending && gate.where === 'retire' ? 'Retiring…' : 'Confirm retire'); setText(c.retireQuestion, 'Retire ' + moleculeName(d) + '?'); fillKeyed(c.retireStatus, JSON.stringify([d.lane, d.retired, gate.retire]), () => retireText(d, gate)); c.danger.hidden = c.stopBlock.hidden && c.retireBlock.hidden; @@ -381,6 +381,140 @@ export function renderDecision(d) { if (gate.message && gate.announce) { gate.announce = false; target.focus(); } // The one live region of the view carries the outcomes read back. setText($('#mv-live'), [c.job, c.amendStatus, c.stopStatus, c.retireStatus].map((n) => n.textContent).filter(Boolean).join(' ')); + loadActions(d); +} + +// ---- Why it's blocked · What you can do (#243) ---------------------------------- +// The panel lists the actions the engine accepts now, from the server's +// /actions read. Each is a primary button posting the exact plan digest it was +// shown; none approves, and its outcome shows as committed only from the +// server's ledger read-back. Retire stays in the Danger zone below. +const acts = { id: null, key: null, token: 0, data: null, pending: false, message: '', result: null, reason: {}, confirm: {} }; + +function actionsKey(d) { return [d.id, d.status, d.phase, d.lane, latestDigest(d)].join(':'); } + +export async function loadActions(d) { + if (acts.id !== d.id) { acts.id = d.id; acts.result = null; acts.message = ''; acts.reason = {}; acts.confirm = {}; } + if (d.sample || (state.lab && state.lab.readOnly) || !hasAction('unblock Molecule')) { acts.key = null; acts.data = null; renderActions(d); return; } + const key = actionsKey(d); + if (key === acts.key) { renderActions(d); return; } + acts.key = key; + const token = ++acts.token; + try { + const res = await api('/api/molecules/' + encodeURIComponent(d.id) + '/actions'); + if (token !== acts.token) return; + const data = res.ok ? await res.json() : null; + if (token !== acts.token) return; + acts.data = data ? { id: d.id, view: data } : null; + } catch (error) { failed(error); } + if (state.detail && state.detail.id === d.id) renderActions(state.detail); +} + +function renderActions(d) { + const node = $('#mv-actions'); + const data = acts.data && acts.data.id === d.id ? acts.data.view : null; + const list = data && Array.isArray(data.actions) ? data.actions.filter((a) => a && typeof a.kind === 'string') : []; + const blocking = data ? plainText(data.blocking, '') : ''; + // An action this page cannot offer exactly is named with its plain reason. + const notOffered = data && Array.isArray(data.notOffered) ? data.notOffered.filter((a) => a && typeof a.kind === 'string') : []; + const shown = Boolean(list.length || notOffered.length || blocking || acts.result || acts.message); + node.hidden = !shown; + fillKeyed(node, shown ? JSON.stringify([d.id, data, acts.result, acts.message, acts.pending]) : '', () => [ + el('h2', { class: 'decision-title', id: 'mv-actions-title' }, 'Why it’s blocked · What you can do'), + blocking ? el('p', { class: 'decision-copy' }, blocking) : null, + list.length ? null : el('p', { class: 'drive-help' }, 'The engine accepts no action for this Molecule now.'), + ...list.map((a) => actionItem(d, a)), + ...notOffered.map((a) => el('p', { class: 'drive-help', dataset: { action: 'not-offered' } }, plainText(a.label, 'Action') + ' is not offered: ' + plainText(a.reason, ''))), + acts.message ? el('p', { class: 'drive-error', role: 'alert' }, acts.message) : null, + acts.result ? actionResultText(acts.result) : null, + ]); +} + +// actionItem is one action: its plain words, what it shows, and a primary +// button directly under them. Retire only points at the Danger zone. +function actionItem(d, a) { + const label = plainText(a.label, 'Action'), copy = plainText(a.copy, ''); + if (a.kind === 'retire') return el('p', { class: 'drive-help' }, label + ': ' + copy); + const parts = [el('h3', { class: 'amend-title' }, label), copy ? el('p', { class: 'drive-help' }, copy) : null]; + if (a.kind === 'accept-failures') { + const deltas = Array.isArray(a.deltas) ? a.deltas : []; + parts.push(el('p', { class: 'scope-note' }, counted(deltas.length, 'test is', 'tests are') + ' declared as expected failures:')); + parts.push(el('ul', { class: 'diagnostics' }, deltas.slice(0, 64).map((x) => el('li', null, el('code', null, notRecorded(x.package) + ' ' + notRecorded(x.test)), ' · check ' + notRecorded(x.check) + ' · declared in ', el('code', null, notRecorded(x.file)))))); + } + if (a.kind === 'scope-change') { + const paths = Array.isArray(a.paths) ? a.paths : []; + parts.push(el('ul', { class: 'diagnostics' }, paths.map((p) => el('li', null, el('code', null, notRecorded(p)))))); + const id = 'mv-action-reason'; + const input = el('input', { type: 'text', id: id, maxlength: '1000', autocomplete: 'off', dataset: { action: 'reason' }, oninput: (e) => { acts.reason[a.kind] = e.target.value; } }); + input.value = acts.reason[a.kind] !== undefined ? acts.reason[a.kind] : plainText(a.reason, ''); + parts.push(field(id, 'Why these files need to change (the coding agent’s reason; edit it if needed)', input)); + } + if (a.kind === 'fresh-intake') { + const questions = Array.isArray(a.questions) ? a.questions : []; + if (questions.length) parts.push(el('ul', { class: 'diagnostics' }, questions.map((q) => el('li', null, plainText(q, ''))))); + if (typeof a.issueUrl === 'string' && ISSUE_URL.test(a.issueUrl)) parts.push(el('a', { class: 'mv-issue-link', href: a.issueUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Edit the Issue on GitHub ↗')); + } + if (a.confirmStopped) { + const id = 'mv-action-confirm-' + a.kind; + const box = el('input', { type: 'checkbox', id: id, dataset: { action: 'confirm' }, onchange: (e) => { acts.confirm[a.kind] = e.target.checked; } }); + box.checked = acts.confirm[a.kind] === true; + parts.push(el('label', { class: 'accept-label', for: id }, box, el('span', null, 'I confirm it is stopped: no Lab process works on this Molecule'))); + } + const button = submitButton({ class: 'btn btn-primary btn-decision', dataset: { action: a.kind } }, acts.pending ? 'Working…' : label); + button.disabled = acts.pending; + parts.push(button); + return el('form', { class: 'decision-form mv-action', novalidate: true, onsubmit: (e) => { e.preventDefault(); submitAction(d, a); } }, ...parts); +} + +// submitAction posts once with the shown digest; after an ambiguous response +// it reads the Molecule back and never resubmits. +async function submitAction(d, a) { + if (acts.pending) return; + let path = '/api/molecules/' + encodeURIComponent(d.id) + '/actions/' + encodeURIComponent(a.kind); + let body = { digest: a.digest }; + if (a.kind === 'fresh-intake') { path = '/api/runs'; body = { issue: a.issue }; } + // Accept-failures binds the exact tests listed: the server derives them again. + if (a.kind === 'accept-failures') body.deltas = a.deltasDigest; + if (a.kind === 'scope-change') { + const reason = String(acts.reason[a.kind] !== undefined ? acts.reason[a.kind] : plainText(a.reason, '')).trim(); + if (!reason || /[\r\n]/.test(reason)) { acts.message = 'Say in one line why these files need to change.'; renderActions(d); return; } + body.paths = a.paths; + body.reason = reason; + } + if (a.confirmStopped) { + if (acts.confirm[a.kind] !== true) { acts.message = 'Tick I confirm it is stopped once no Lab process works on this Molecule.'; renderActions(d); return; } + body.confirmStopped = true; + } + acts.pending = true; + acts.message = ''; + renderActions(d); + let res = null, out = {}; + try { + res = await postJSON(path, JSON.stringify(body)); + out = await res.json().catch(() => ({})); + } catch (error) { + if (error instanceof SessionError) { acts.pending = false; failed(error); renderActions(d); return; } + res = null; + } + acts.pending = false; + if (!res || res.status >= 500) acts.message = 'The response was lost or ambiguous. The Molecule is read back; nothing is resubmitted.'; + else if (!res.ok) acts.message = refusalText(out); + else acts.result = { kind: a.kind, body: out }; + acts.key = null; + if (state.detail && state.detail.id === d.id) renderActions(state.detail); + ctx.loadDetail(d.id, false); +} + +function actionResultText(r) { + const b = r.body || {}; + if (r.kind === 'fresh-intake') return el('p', { class: 'scope-note' }, b.committed ? 'Started: the ledger records a fresh Molecule for this Issue.' : 'Intake was submitted. Not shown as started until a ledger for this Issue appears.'); + if (r.kind === 'recover') return el('p', { class: 'scope-note' }, b.committed ? 'Recovered: the ledger records the interruption and keeps the work. Choose the next action above.' : 'Not committed: the ledger read back does not record the recovery.'); + if (!b.committed) return el('p', { class: 'scope-note' }, (b.recovered ? 'Recovered, but ' : '') + 'Not committed: the ledger read back shows no new version of this plan. Nothing was approved.'); + const digest = typeof b.digest === 'string' && /^[0-9a-f]{64}$/.test(b.digest) ? b.digest.slice(0, 12) : 'not recorded'; + const deltas = Array.isArray(b.deltas) ? b.deltas : []; + return el('div', { class: 'gate-job' }, + el('p', { class: 'scope-note' }, 'Version ' + count(b.revision) + ' of the plan is recorded (digest ', el('code', null, digest), '…' + (b.changes ? '; ' + plainText(b.changes, '') : '') + '). It waits for your approval: click Approve plan & start coding. Nothing was approved.'), + deltas.length ? el('ul', { class: 'diagnostics' }, deltas.slice(0, 64).map((x) => el('li', null, el('code', null, notRecorded(x.package) + ' ' + notRecorded(x.test)), ' · declared in ', el('code', null, notRecorded(x.file))))) : null); } function amendText(a) {