diff --git a/lab/internal/cli/ui.go b/lab/internal/cli/ui.go index bf8705a..ca81af1 100644 --- a/lab/internal/cli/ui.go +++ b/lab/internal/cli/ui.go @@ -183,6 +183,19 @@ func runUIChoosing(ctx context.Context, args []string, defaultLab string, choice options.Resources = guard } options.SourceRoot = cwd + // A Formula rebind from the UI saves the launcher init --rebind would, and + // only one that resolves to a build this user owns under the managed + // Forgecell home; otherwise the Formula view says the launcher is unknown. + launcher := initLauncher() + if launcher == "" { + launcher, _ = os.Executable() + } + if root, e := resolvedCheckoutRoot(); e == nil { + if home, e := forgecellHome(root); e == nil { + options.Home = home + options.Launcher = ui.ValidateLauncher(home, launcher) + } + } // The avatar's name; demo mode never reads git. options.Operator = operatorName(ctx, cwd) } diff --git a/lab/internal/formula/dev_launcher_test.go b/lab/internal/formula/dev_launcher_test.go new file mode 100644 index 0000000..98a623e --- /dev/null +++ b/lab/internal/formula/dev_launcher_test.go @@ -0,0 +1,156 @@ +package formula + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const devRoot = "/home/op/.forgecell/releases/" + +// withCommitTimes replaces the recorded dev-build commit times with times, keyed +// by launcher; a launcher missing from times records none. +func withCommitTimes(t *testing.T, times map[string]int64) { + t.Helper() + previous := devCommitTime + devCommitTime = func(launcher string) int64 { return times[launcher] } + t.Cleanup(func() { devCommitTime = previous }) +} + +func TestDevBuildRebindNewerIsAcceptedOlderIsRefusedWithReason(t *testing.T) { + early, late := devRoot+"0.16.9-dev.ffff/forgecell", devRoot+"0.16.9-dev.0000/forgecell" + untimed, dirty := devRoot+"0.16.9-dev.abcd/forgecell", devRoot+"0.16.9-dev.abcd.dirty.12345678/forgecell" + twin := devRoot + "0.16.9-dev.1234/forgecell" + withCommitTimes(t, map[string]int64{early: 1_700_000_000, late: 1_700_000_100, twin: 1_700_000_000}) + for name, c := range map[string]struct { + current, next, order string + }{ + "newer dev build of a newer version": {devRoot + "0.16.8-dev.ffff/forgecell", devRoot + "0.16.9-dev.0000/forgecell", LauncherNewer}, + "dev build after its release": {devRoot + "0.16.8/forgecell", devRoot + "0.16.9-dev.abcd/forgecell", LauncherNewer}, + "release after its dev build": {devRoot + "0.16.9-dev.abcd/forgecell", devRoot + "0.16.9/forgecell", LauncherNewer}, + "older dev build": {devRoot + "0.16.9-dev.0000/forgecell", devRoot + "0.16.8-dev.ffff/forgecell", LauncherOlder}, + "dev build of the current release": {devRoot + "0.16.9/forgecell", devRoot + "0.16.9-dev.abcd/forgecell", LauncherOlder}, + "same version, later commit, lower hash": {early, late, LauncherNewer}, + "same version, earlier commit, higher hash": {late, early, LauncherOlder}, + "same version, the next records no time": {early, untimed, LauncherUnordered}, + "same version, the current records no time": {untimed, late, LauncherUnordered}, + "same version, neither records a time": {untimed, dirty, LauncherUnordered}, + "same version, equal commit times": {early, twin, LauncherUnordered}, + "identical": {early, early, LauncherSame}, + "other installation": {devRoot + "0.16.8/forgecell", "/opt/forgecell/releases/0.16.9/forgecell", LauncherUnrelated}, + "not a release": {"/usr/local/bin/forgecell", devRoot + "0.16.9/forgecell", LauncherUnrelated}, + "other installation, even with recorded times": {early, "/opt/forgecell/releases/0.16.9-dev.0000/forgecell", LauncherUnrelated}, + } { + order, reason := CompareLaunchers(c.current, c.next) + if order != c.order { + t.Errorf("%s: order %s, want %s (%s)", name, order, c.order, reason) + } + if reason == "" || strings.Contains(reason, "/home/op") || strings.Contains(reason, "/opt/") || strings.Contains(reason, "/usr/") { + t.Errorf("%s: reason must be plain and path-free: %q", name, reason) + } + // The rebase check agrees: only a newer build is a launcher upgrade. + if IsReleaseLauncher(c.current) && IsReleaseLauncher(c.next) { + if got := launcherUpgrade(c.current, c.next); got != (c.order == LauncherNewer) { + t.Errorf("%s: launcherUpgrade %v, want %v", name, got, c.order == LauncherNewer) + } + } + } + if _, reason := CompareLaunchers(early, untimed); !strings.Contains(reason, "records no commit time") || !strings.Contains(reason, "cannot tell which is newer") { + t.Errorf("a missing commit time needs the plain reason, got %q", reason) + } + if _, reason := CompareLaunchers(early, twin); !strings.Contains(reason, "same recorded commit time") { + t.Errorf("equal commit times need the plain reason, got %q", reason) + } + if _, reason := CompareLaunchers(late, early); !strings.Contains(reason, "older") || !strings.Contains(reason, "0.16.9-dev.ffff") { + t.Errorf("an older dev build needs a reason naming its version, got %q", reason) + } + if _, reason := CompareLaunchers(devRoot+"0.16.9/forgecell", devRoot+"0.16.8/forgecell"); !strings.Contains(reason, "older") || !strings.Contains(reason, "0.16.8") { + t.Errorf("an older build needs a reason naming its version, got %q", reason) + } +} + +func TestDevBuildRebaseOrdersSameVersionDevBuildsByCommitTime(t *testing.T) { + first, second := devRoot+"0.9.2-dev.ffff/forgecell", devRoot+"0.9.2-dev.0000/forgecell" + untimed := devRoot + "0.9.2-dev.abcd/forgecell" + withCommitTimes(t, map[string]int64{first: 1_700_000_000, second: 1_700_000_100}) + recipe := strings.ReplaceAll(launcherRecipe, oldLauncher, first) + if _, err := Parse([]byte(recipe)); err != nil { + t.Fatal(err) + } + to := func(launcher string) []byte { + return []byte(strings.Replace(recipe, first+", __adapter, claude", launcher+", __adapter, claude", 1)) + } + if err := LauncherOnlyChange([]byte(recipe), to(second)); err != nil { + t.Fatalf("a newer dev build of the same version (alphabetically lower) was refused: %v", err) + } + if err := LauncherOnlyChange(to(second), []byte(recipe)); err == nil { + t.Fatal("an older dev build of the same version was carried as an upgrade") + } + if err := LauncherOnlyChange([]byte(recipe), to(untimed)); err == nil { + t.Fatal("a dev build without a recorded commit time was carried as an upgrade") + } + if err := LauncherOnlyChange([]byte(recipe), to(devRoot+"0.9.3-dev.0000/forgecell")); err != nil { + t.Fatalf("a dev build of a newer version was refused: %v", err) + } +} + +func writeDevBuild(t *testing.T, dir string, content []byte, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, DevBuildFile), content, mode); err != nil { + t.Fatal(err) + } +} + +func TestDevCommitTimeReadsOnlyATrustworthyRecord(t *testing.T) { + releases := filepath.Join(t.TempDir(), "releases") + record := func(version string, at int64) []byte { + raw, _ := json.Marshal(DevBuild{Version: version, CommitTime: at}) + return raw + } + good := filepath.Join(releases, "0.9.2-dev.abcd") + writeDevBuild(t, good, record("0.9.2-dev.abcd", 1_700_000_000), 0600) + if got := devCommitTime(filepath.Join(good, "forgecell")); got != 1_700_000_000 { + t.Fatalf("a valid record reads %d", got) + } + cases := map[string][]byte{ + "0.9.2-dev.0001": record("0.9.2-dev.other", 1_700_000_000), + "0.9.2-dev.0002": record("0.9.2-dev.0002", 0), + "0.9.2-dev.0003": record("0.9.2-dev.0003", -5), + "0.9.2-dev.0004": []byte(`{"version":"0.9.2-dev.0004","commitTime":1700000000,"extra":1}`), + "0.9.2-dev.0005": []byte(`{"version":"0.9.2-dev.0005","commitTime":1700000000}{}`), + "0.9.2-dev.0006": []byte("not json"), + "0.9.2-dev.0007": []byte(`{"version":"0.9.2-dev.0007","commitTime":1700000000,"pad":"` + strings.Repeat("x", 2000) + `"}`), + "0.9.2-dev.0008": record("0.9.2-dev.0008", maxCommitTime+1), + } + for version, raw := range cases { + writeDevBuild(t, filepath.Join(releases, version), raw, 0600) + if got := devCommitTime(filepath.Join(releases, version, "forgecell")); got != 0 { + t.Errorf("%s: an untrustworthy record read as %d", version, got) + } + } + if got := devCommitTime(filepath.Join(releases, "0.9.2-dev.missing", "forgecell")); got != 0 { + t.Errorf("a missing record read as %d", got) + } + linked := filepath.Join(releases, "0.9.2-dev.link") + if err := os.MkdirAll(linked, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(good, DevBuildFile), filepath.Join(linked, DevBuildFile)); err != nil { + t.Fatal(err) + } + if got := devCommitTime(filepath.Join(linked, "forgecell")); got != 0 { + t.Errorf("a symlinked record was followed: %d", got) + } + linkedDir := filepath.Join(releases, "0.9.2-dev.abce") + if err := os.Symlink(good, linkedDir); err != nil { + t.Fatal(err) + } + if got := devCommitTime(filepath.Join(linkedDir, "forgecell")); got != 0 { + t.Errorf("a symlinked release directory was followed: %d", got) + } +} diff --git a/lab/internal/formula/formula.go b/lab/internal/formula/formula.go index ed2a3e0..99d4900 100644 --- a/lab/internal/formula/formula.go +++ b/lab/internal/formula/formula.go @@ -13,6 +13,7 @@ import ( "regexp" "slices" "strings" + "syscall" "unicode" "unicode/utf8" @@ -638,7 +639,137 @@ func splitReleaseLauncher(p string) (root, version string) { func launcherUpgrade(previous, next string) bool { oldRoot, oldVersion := splitReleaseLauncher(previous) newRoot, newVersion := splitReleaseLauncher(next) - return oldRoot == newRoot && compareReleaseVersions(newVersion, oldVersion) > 0 + if oldRoot != newRoot { + return false + } + c, ordered := releaseOrder(newVersion, oldVersion, func() (int64, int64) { return devCommitTime(next), devCommitTime(previous) }) + return ordered && c > 0 +} + +// isDevBuild reports a dev build's pre-release (dev.[.dirty.]). +func isDevBuild(pre string) bool { return strings.HasPrefix(pre, "dev.") } + +// sameVersionDevBuilds reports two different dev builds of one major.minor.patch, +// whose identifiers are commit hashes that sort alphabetically, not by time. +func sameVersionDevBuilds(a, b string) bool { + aCore, aPre, _ := strings.Cut(a, "-") + bCore, bPre, _ := strings.Cut(b, "-") + return aCore == bCore && aPre != bPre && isDevBuild(aPre) && isDevBuild(bPre) +} + +// releaseOrder is compareReleaseVersions, except for two dev builds of the same +// major.minor.patch from different builds: those are ordered by the commit times +// their installs recorded (times reads them, a then b, only then), and only when +// both carry a time and the times differ. Otherwise they are unordered, and a +// rebind between them is never treated as an upgrade. +func releaseOrder(a, b string, times func() (aTime, bTime int64)) (int, bool) { + if !sameVersionDevBuilds(a, b) { + return compareReleaseVersions(a, b), true + } + aTime, bTime := times() + switch { + case aTime <= 0 || bTime <= 0 || aTime == bTime: + return 0, false + case aTime > bTime: + return 1, true + } + return -1, true +} + +// DevBuildFile is the record forgecell dev install writes beside a dev build's +// manifest.json: the release version and the Unix committer time of the source +// commit it was built from, as git log -1 --format=%ct reported it. +const DevBuildFile = "dev-build.json" + +// DevBuild is the content of DevBuildFile. +type DevBuild struct { + Version string `json:"version"` + CommitTime int64 `json:"commitTime"` +} + +// maxDevBuildBytes bounds DevBuildFile; maxCommitTime (year 3000) bounds the time. +const ( + maxDevBuildBytes = 1024 + maxCommitTime = 32503680000 +) + +// devCommitTime is the commit time the dev build at launcher recorded, or 0 when +// it records none or the record is not trustworthy: the release directory must +// be a real directory and the record a small regular file owned by this user, +// opened without following a symlink, that decodes strictly and names the +// directory's own version. Tests replace it. +var devCommitTime = func(launcher string) int64 { + dir := filepath.Dir(launcher) + if st, err := os.Lstat(dir); err != nil || !st.IsDir() { + return 0 + } + f, err := os.OpenFile(filepath.Join(dir, DevBuildFile), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return 0 + } + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() || st.Size() > maxDevBuildBytes { + return 0 + } + if sys, ok := st.Sys().(*syscall.Stat_t); !ok || int(sys.Uid) != os.Getuid() { + return 0 + } + raw, err := io.ReadAll(io.LimitReader(f, maxDevBuildBytes+1)) + if err != nil || len(raw) > maxDevBuildBytes { + return 0 + } + var record DevBuild + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if dec.Decode(&record) != nil || dec.More() || record.Version != filepath.Base(dir) || record.CommitTime <= 0 || record.CommitTime > maxCommitTime { + return 0 + } + return record.CommitTime +} + +// Launcher orders, from CompareLaunchers. +const ( + LauncherNewer = "newer" + LauncherSame = "same" + LauncherOlder = "older" + LauncherUnordered = "unordered" + LauncherUnrelated = "unrelated" +) + +// CompareLaunchers orders the build of next against the build of current for a +// rebind: newer, same, older, unordered (two dev builds of one version whose +// recorded commit times do not order them) or unrelated (not both release launchers of one +// installation, so there is no version order). reason is a plain sentence that +// names versions only, never a path. +func CompareLaunchers(current, next string) (order, reason string) { + if current == next { + return LauncherSame, "This build is already the Formula's launcher." + } + if !IsReleaseLauncher(current) || !IsReleaseLauncher(next) { + return LauncherUnrelated, "The Formula's launcher and this build are not both installed releases, so Forgecell cannot order them." + } + oldRoot, oldVersion := splitReleaseLauncher(current) + newRoot, newVersion := splitReleaseLauncher(next) + if oldRoot != newRoot { + return LauncherUnrelated, "This build belongs to another Forgecell installation than the Formula's launcher." + } + var newTime, oldTime int64 + c, ordered := releaseOrder(newVersion, oldVersion, func() (int64, int64) { + newTime, oldTime = devCommitTime(next), devCommitTime(current) + return newTime, oldTime + }) + switch { + case !ordered && newTime > 0 && newTime == oldTime: + return LauncherUnordered, fmt.Sprintf("This build (%s) and the Formula's launcher (%s) are dev builds of the same version with the same recorded commit time, so Forgecell cannot tell which is newer.", newVersion, oldVersion) + case !ordered: + return LauncherUnordered, fmt.Sprintf("This build (%s) and the Formula's launcher (%s) are dev builds of the same version, and at least one records no commit time, so Forgecell cannot tell which is newer.", newVersion, oldVersion) + case c > 0: + return LauncherNewer, fmt.Sprintf("This build (%s) is newer than the Formula's launcher (%s).", newVersion, oldVersion) + case c < 0: + return LauncherOlder, fmt.Sprintf("This build (%s) is older than the Formula's launcher (%s).", newVersion, oldVersion) + } + return LauncherSame, fmt.Sprintf("This build (%s) is the same version as the Formula's launcher.", newVersion) } // compareReleaseVersions orders X.Y.Z[-pre] versions by semantic-version diff --git a/lab/internal/install/dev.go b/lab/internal/install/dev.go index 37d6968..8c9290d 100644 --- a/lab/internal/install/dev.go +++ b/lab/internal/install/dev.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "io/fs" @@ -12,9 +13,11 @@ import ( "regexp" "slices" "sort" + "strconv" "strings" "time" + "github.com/DanielNetzer/forgecell/lab/internal/formula" "github.com/DanielNetzer/forgecell/lab/internal/harness" "github.com/DanielNetzer/forgecell/lab/internal/process" "github.com/DanielNetzer/forgecell/lab/internal/resources" @@ -223,6 +226,65 @@ func inspectCheckout(ctx context.Context, root string) (c checkout, err error) { return c, nil } +// parseCommitTime reads git log --format=%ct output: a positive Unix time no later +// than year 3000, or 0 (absent) for anything else. +func parseCommitTime(out string) int64 { + text := strings.TrimSpace(out) + if text == "" || len(text) > 12 || strings.Trim(text, "0123456789") != "" { + return 0 + } + at, err := strconv.ParseInt(text, 10, 64) + if err != nil || at <= 0 || at > 32503680000 { + return 0 + } + return at +} + +// headCommitTime is the committer time of the checkout's HEAD, which the build +// cannot edit (never a file mtime), or 0 when git cannot report it. Tests +// replace it. +var headCommitTime = func(ctx context.Context, root string) int64 { + out, err := runGit(ctx, root, "log", "-1", "--format=%ct", "HEAD") + if err != nil { + return 0 + } + return parseCommitTime(out) +} + +// recordDevBuild writes formula.DevBuildFile beside a dev build's manifest, so a +// Formula rebind can order two dev builds of one version by commit time. A +// missing time is recorded as absent: no file is written, and an existing record +// is left alone. The record replaces any earlier one atomically. +func recordDevBuild(dir, version string, commitTime int64) error { + if commitTime <= 0 { + return nil + } + raw, err := json.Marshal(formula.DevBuild{Version: version, CommitTime: commitTime}) + if err != nil { + return err + } + path := filepath.Join(dir, formula.DevBuildFile) + if st, e := os.Lstat(path); e == nil && !st.Mode().IsRegular() { + return fmt.Errorf("refusing unmanaged dev build record: %s", path) + } + tmp, err := os.CreateTemp(dir, ".dev-build-") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if _, err = tmp.Write(raw); err == nil { + err = tmp.Chmod(0600) + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + return os.Rename(name, path) +} + // buildEnv is the only environment go build sees: nothing ambient (GOFLAGS such as // -toolexec, GOEXPERIMENT, credentials) reaches the compiler of an install. func buildEnv(environ []string) []string { @@ -328,5 +390,8 @@ func DevInstall(ctx context.Context, o DevOptions) (r DevRelease, err error) { if !meta.Found { return r, fmt.Errorf("staged release has no readable manifest: %s", dir) } + if err = recordDevBuild(dir, version, headCommitTime(ctx, root)); err != nil { + return r, err + } return DevRelease{Version: version, Path: filepath.Join(dir, "forgecell"), ManifestSHA256: meta.Digest, Activated: o.Activate}, nil } diff --git a/lab/internal/install/dev_test.go b/lab/internal/install/dev_test.go index f8bb03d..880d8b3 100644 --- a/lab/internal/install/dev_test.go +++ b/lab/internal/install/dev_test.go @@ -2,6 +2,7 @@ package install import ( "context" + "encoding/json" "errors" "os" "os/exec" @@ -9,9 +10,11 @@ import ( "reflect" "regexp" "sort" + "strconv" "strings" "testing" + "github.com/DanielNetzer/forgecell/lab/internal/formula" "github.com/DanielNetzer/forgecell/lab/internal/harness" ) @@ -580,6 +583,88 @@ func TestDevInstallDoesNotRunTheCheckoutsFsmonitor(t *testing.T) { } } +func readDevBuild(t *testing.T, release string) (formula.DevBuild, bool) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(filepath.Dir(release), formula.DevBuildFile)) + if os.IsNotExist(err) { + return formula.DevBuild{}, false + } + if err != nil { + t.Fatal(err) + } + var record formula.DevBuild + if err = json.Unmarshal(raw, &record); err != nil { + t.Fatal(err) + } + return record, true +} + +func TestDevInstallRecordsTheSourceCommitTime(t *testing.T) { + checkout := devCheckout(t) + o := devOptions(t, checkout) + want, err := strconv.ParseInt(gitRun(t, checkout, "log", "-1", "--format=%ct", "HEAD"), 10, 64) + if err != nil || want <= 0 { + t.Fatalf("fixture commit time: %v", err) + } + r, err := DevInstall(context.Background(), o) + if err != nil { + t.Fatal(err) + } + record, found := readDevBuild(t, r.Path) + if !found || record.Version != r.Version || record.CommitTime != want { + t.Fatalf("dev build record %+v (found %v), want %s at %d", record, found, r.Version, want) + } + if st, err := os.Lstat(filepath.Join(filepath.Dir(r.Path), formula.DevBuildFile)); err != nil || st.Mode().Perm() != 0600 { + t.Fatalf("record mode: %v %v", st, err) + } + // The manifest the harness reads strictly is unchanged by the record. + if m := harness.ReadManifest(filepath.Dir(r.Path)); !m.Found || m.Digest != r.ManifestSHA256 { + t.Fatalf("%+v", m) + } + entries, err := os.ReadDir(filepath.Dir(r.Path)) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + t.Fatalf("recording left %s behind", e.Name()) + } + } + assertNoInstallLeftovers(t, o.Home) +} + +func TestDevInstallRecordsAMissingCommitTimeAsAbsent(t *testing.T) { + previous := headCommitTime + headCommitTime = func(context.Context, string) int64 { return 0 } + t.Cleanup(func() { headCommitTime = previous }) + o := devOptions(t, devCheckout(t)) + r, err := DevInstall(context.Background(), o) + if err != nil { + t.Fatalf("a missing commit time must not fail the install: %v", err) + } + if record, found := readDevBuild(t, r.Path); found { + t.Fatalf("a missing commit time was recorded: %+v", record) + } + for _, out := range []string{"", "\n", "abc", "-5", "0", "12.5", "1700000000 1", "99999999999999", "32503680001"} { + if got := parseCommitTime(out); got != 0 { + t.Errorf("parseCommitTime(%q) = %d, want absent", out, got) + } + } + if got := parseCommitTime("1700000000\n"); got != 1700000000 { + t.Errorf("parseCommitTime = %d", got) + } +} + +func TestDevInstallRefusesAnUnmanagedDevBuildRecord(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, formula.DevBuildFile), 0700); err != nil { + t.Fatal(err) + } + if err := recordDevBuild(dir, "0.2.0-dev.abcd", 1700000000); err == nil { + t.Fatal("a directory in the record's place was replaced") + } +} + func TestDevInstallIsIdempotentAndRefusesDivergentRebuilds(t *testing.T) { checkout := devCheckout(t) o := devOptions(t, checkout) diff --git a/lab/internal/onboarding/proposal.go b/lab/internal/onboarding/proposal.go index 5b1989b..905b622 100644 --- a/lab/internal/onboarding/proposal.go +++ b/lab/internal/onboarding/proposal.go @@ -51,6 +51,19 @@ type Proposal struct { ReviewedAt string `json:"reviewedAt,omitempty"` } +// now is the clock behind a proposal's createdAt and reviewedAt, which the +// proposal identity covers; SetClock replaces it in tests. +var now = time.Now + +// SetClock replaces the proposal clock and returns a function that restores it. +// Only tests call it, so a UI-created and a CLI-created proposal can be compared +// byte for byte. +func SetClock(clock func() time.Time) (restore func()) { + previous := now + now = clock + return func() { now = previous } +} + func hash(data []byte) string { return fmt.Sprintf("%x", sha256.Sum256(data)) } func fingerprint(dir string) (string, error) { cfg, err := os.ReadFile(filepath.Join(dir, "lab.json")) @@ -183,7 +196,7 @@ func saveWithEvidence(dir, yaml string, evidence *Repository, reports ...*Bindin if err != nil { return Proposal{}, err } - p := Proposal{ConfigBefore: cfgHash, YAML: yaml, YAMLHash: f.SHA256, Baseline: baseline, TargetBefore: before, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), Status: "pending"} + p := Proposal{ConfigBefore: cfgHash, YAML: yaml, YAMLHash: f.SHA256, Baseline: baseline, TargetBefore: before, CreatedAt: now().UTC().Format(time.RFC3339Nano), Status: "pending"} if cfgHash != "absent" { active, e := formula.Inspect(dir, "") if e != nil { @@ -281,7 +294,7 @@ func Review(dir, id, decision string) (p Proposal, err error) { } if decision == "dismiss" { p.Status = "dismissed" - p.ReviewedAt = time.Now().UTC().Format(time.RFC3339Nano) + p.ReviewedAt = now().UTC().Format(time.RFC3339Nano) return p, saveProposal(dir, p) } if p.ActiveIDBefore != "" && p.ActiveIDBefore != f.ID { @@ -309,7 +322,7 @@ func Review(dir, id, decision string) (p Proposal, err error) { return p, err } p.Status = "approved" - p.ReviewedAt = time.Now().UTC().Format(time.RFC3339Nano) + p.ReviewedAt = now().UTC().Format(time.RFC3339Nano) if err = saveProposal(dir, p); err != nil { return p, err } diff --git a/lab/internal/onboarding/proposal_view.go b/lab/internal/onboarding/proposal_view.go new file mode 100644 index 0000000..99b17ef --- /dev/null +++ b/lab/internal/onboarding/proposal_view.go @@ -0,0 +1,353 @@ +package onboarding + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "go.yaml.in/yaml/v4" +) + +// A read-only view of a saved proposal for a reviewer: the pending proposal, the +// approved Formula it changes, its changed keys (old -> new) and a bounded line +// diff. Nothing here writes, locks or activates; every bound fails safe by +// reporting the view as truncated. + +// Proposal kinds, inferred from the changed keys. +const ( + KindRebind = "rebind" + KindBaseline = "baseline" + KindEnableReview = "enable-review" + KindModelPin = "model-pin" + KindBootstrap = "bootstrap" + KindChange = "change" +) + +const ( + // maxDiffLeaves bounds the keys one Formula document is flattened into. + maxDiffLeaves = 4000 + // maxDiffDepth bounds the nesting the flattening follows. + maxDiffDepth = 12 + // maxKeyChanges bounds the changed keys a view carries. + maxKeyChanges = 100 + // maxDiffValueBytes bounds one old or new value. + maxDiffValueBytes = 400 + // maxDiffLines bounds the lines each side of a line diff compares. + maxDiffLines = 2000 + // maxDiffOutput bounds the lines a line diff carries. + maxDiffOutput = 1500 + // diffContext is the unchanged lines kept around each change. + diffContext = 3 +) + +// PendingProposal is the proposal init would preserve: an interrupted activation +// first, else the first pending one; nil when there is none. +func PendingProposal(dir string) (*Proposal, error) { return pendingProposal(dir) } + +// ProposalBefore is the exact approved Formula the proposal changes, when it can +// be established: the YAML the proposal saved, else the active Formula it names, +// either only when its SHA-256 is the one the proposal recorded. +func ProposalBefore(dir string, p Proposal) (string, bool) { + if p.ActiveHashBefore == "" { + return "", false + } + candidates := []string{} + if e := p.CapabilityEvidence; e != nil { + candidates = append(candidates, e.BeforeYAML) + if e.Meta != nil { + candidates = append(candidates, e.Meta.BeforeYAML) + } + } + for _, c := range candidates { + if c != "" && hash([]byte(c)) == p.ActiveHashBefore { + return c, true + } + } + if p.ActiveIDBefore != "" { + if active, err := formula.Inspect(dir, p.ActiveIDBefore); err == nil && active.Formula.SHA256 == p.ActiveHashBefore { + return active.Formula.YAML, true + } + } + return "", false +} + +// KeyChange is one changed key of a Formula: Change is added, removed or +// changed, and Old and New are the JSON of the values ("" when absent). +type KeyChange struct { + Key string `json:"key"` + Change string `json:"change"` + Old string `json:"old,omitempty"` + New string `json:"new,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// ProposalDiff lists the keys that differ between two Formula documents. Lists +// of mappings with an id are keyed by it (atoms[review], requiredChecks[go-test]), +// so an insertion reads as one added entry. truncated reports a bound reached. +func ProposalDiff(before, after string) (changes []KeyChange, truncated bool, err error) { + var a, b any + if err = yaml.Unmarshal([]byte(before), &a); err != nil { + return nil, false, err + } + if err = yaml.Unmarshal([]byte(after), &b); err != nil { + return nil, false, err + } + left, right := &flat{values: map[string]string{}}, &flat{values: map[string]string{}} + left.walk("", a, 0) + right.walk("", b, 0) + truncated = left.truncated || right.truncated + record := func(c KeyChange) { + if len(changes) == maxKeyChanges { + truncated = true + return + } + c.Old, c.Truncated = bounded(c.Old, c.Truncated) + c.New, c.Truncated = bounded(c.New, c.Truncated) + truncated = truncated || c.Truncated + changes = append(changes, c) + } + for _, key := range left.order { + old := left.values[key] + next, ok := right.values[key] + switch { + case !ok: + record(KeyChange{Key: key, Change: "removed", Old: old}) + case next != old: + record(KeyChange{Key: key, Change: "changed", Old: old, New: next}) + } + } + for _, key := range right.order { + if _, ok := left.values[key]; !ok { + record(KeyChange{Key: key, Change: "added", New: right.values[key]}) + } + } + return changes, truncated, nil +} + +func bounded(s string, truncated bool) (string, bool) { + if len(s) <= maxDiffValueBytes { + return s, truncated + } + cut := maxDiffValueBytes + for cut > 0 && s[cut]&0xC0 == 0x80 { + cut-- + } + return s[:cut] + "…", true +} + +// flat is a Formula document flattened to key paths and JSON leaf values. +type flat struct { + values map[string]string + order []string + truncated bool +} + +func (f *flat) leaf(key string, v any) { + if len(f.order) == maxDiffLeaves { + f.truncated = true + return + } + data, err := json.Marshal(v) + if err != nil { + data = []byte(fmt.Sprintf("%q", fmt.Sprint(v))) + } + if _, seen := f.values[key]; !seen { + f.order = append(f.order, key) + } + f.values[key] = string(data) +} + +func scalarList(list []any) bool { + for _, item := range list { + switch item.(type) { + case map[string]any, []any: + return false + } + } + return true +} + +func (f *flat) walk(prefix string, v any, depth int) { + if depth > maxDiffDepth { + f.truncated = true + return + } + join := func(key string) string { + if prefix == "" { + return key + } + return prefix + "." + key + } + switch value := v.(type) { + case map[string]any: + keys := make([]string, 0, len(value)) + for k := range value { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + f.leaf(prefix, value) + } + for _, k := range keys { + f.walk(join(k), value[k], depth+1) + } + case []any: + if len(value) == 0 || scalarList(value) { + f.leaf(prefix, value) + return + } + for i, item := range value { + name := fmt.Sprintf("%s[%d]", prefix, i) + if m, ok := item.(map[string]any); ok { + if id, ok := m["id"].(string); ok && id != "" && !strings.ContainsAny(id, "[].") { + name = prefix + "[" + id + "]" + } + } + f.walk(name, item, depth+1) + } + default: + f.leaf(prefix, value) + } +} + +// ProposalKind names the kind of change from its keys: a baseline refresh changes +// only requiredChecks, enabling review only adds atoms[review], a model pin only +// model keys and a rebind only binding and command keys. hasBefore is false for a +// proposal with no approved Formula before it. +func ProposalKind(changes []KeyChange, hasBefore bool) string { + if !hasBefore { + return KindBootstrap + } + if len(changes) == 0 { + return KindChange + } + all := func(ok func(KeyChange) bool) bool { + for _, c := range changes { + if !ok(c) { + return false + } + } + return true + } + switch { + case all(func(c KeyChange) bool { + return c.Key == "requiredChecks" || strings.HasPrefix(c.Key, "requiredChecks[") + }): + return KindBaseline + case all(func(c KeyChange) bool { return c.Change == "added" && strings.HasPrefix(c.Key, "atoms[review].") }): + return KindEnableReview + case all(func(c KeyChange) bool { + return c.Key == "harness.model" || strings.HasPrefix(c.Key, "harnesses.") && strings.HasSuffix(c.Key, ".model") && strings.Count(c.Key, ".") == 2 + }): + return KindModelPin + case all(func(c KeyChange) bool { + return c.Change == "changed" && (strings.HasSuffix(c.Key, ".binding") || strings.HasSuffix(c.Key, ".command")) + }): + return KindRebind + } + return KindChange +} + +// DiffLine is one line of a line diff: Op is " " (unchanged), "-" (removed), +// "+" (added) or "…" (unchanged lines left out). +type DiffLine struct { + Op string `json:"op"` + Text string `json:"text"` +} + +// LineDiff is a bounded line diff of two texts with diffContext unchanged lines +// around each change. A side longer than maxDiffLines after the common prefix and +// suffix is shown as removed and added whole, and truncated is set; so it is when +// the output reaches maxDiffOutput lines. +func LineDiff(before, after string) (lines []DiffLine, truncated bool) { + a, b := strings.Split(before, "\n"), strings.Split(after, "\n") + prefix := 0 + for prefix < len(a) && prefix < len(b) && a[prefix] == b[prefix] { + prefix++ + } + suffix := 0 + for suffix < len(a)-prefix && suffix < len(b)-prefix && a[len(a)-1-suffix] == b[len(b)-1-suffix] { + suffix++ + } + midA, midB := a[prefix:len(a)-suffix], b[prefix:len(b)-suffix] + var ops []DiffLine + for _, line := range a[:prefix] { + ops = append(ops, DiffLine{" ", line}) + } + if len(midA) > maxDiffLines || len(midB) > maxDiffLines { + truncated = true + for _, line := range midA { + ops = append(ops, DiffLine{"-", line}) + } + for _, line := range midB { + ops = append(ops, DiffLine{"+", line}) + } + } else { + ops = append(ops, lcsDiff(midA, midB)...) + } + for _, line := range a[len(a)-suffix:] { + ops = append(ops, DiffLine{" ", line}) + } + keep := make([]bool, len(ops)) + for i, op := range ops { + if op.Op == " " { + continue + } + for j := max(0, i-diffContext); j <= min(len(ops)-1, i+diffContext); j++ { + keep[j] = true + } + } + for i, op := range ops { + if len(lines) >= maxDiffOutput { + return lines, true + } + if keep[i] { + op.Text, _ = bounded(op.Text, false) + lines = append(lines, op) + } else if len(lines) == 0 || lines[len(lines)-1].Op != "…" { + lines = append(lines, DiffLine{"…", ""}) + } + } + return lines, truncated +} + +// lcsDiff is the line diff of a and b by longest common subsequence; each side +// has at most maxDiffLines lines. +func lcsDiff(a, b []string) []DiffLine { + n, m := len(a), len(b) + table := make([]int32, (n+1)*(m+1)) + at := func(i, j int) *int32 { return &table[i*(m+1)+j] } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[j] { + *at(i, j) = *at(i+1, j+1) + 1 + } else { + *at(i, j) = max(*at(i+1, j), *at(i, j+1)) + } + } + } + var out []DiffLine + i, j := 0, 0 + for i < n && j < m { + switch { + case a[i] == b[j]: + out = append(out, DiffLine{" ", a[i]}) + i, j = i+1, j+1 + case *at(i+1, j) >= *at(i, j+1): + out = append(out, DiffLine{"-", a[i]}) + i++ + default: + out = append(out, DiffLine{"+", b[j]}) + j++ + } + } + for ; i < n; i++ { + out = append(out, DiffLine{"-", a[i]}) + } + for ; j < m; j++ { + out = append(out, DiffLine{"+", b[j]}) + } + return out +} diff --git a/lab/internal/onboarding/proposal_view_test.go b/lab/internal/onboarding/proposal_view_test.go new file mode 100644 index 0000000..09e28aa --- /dev/null +++ b/lab/internal/onboarding/proposal_view_test.go @@ -0,0 +1,173 @@ +package onboarding + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" +) + +// pendingView reads the pending proposal back and returns its kind and changes. +func pendingView(t *testing.T, dir, id string) (string, []KeyChange) { + t.Helper() + p, err := PendingProposal(dir) + if err != nil || p == nil || p.ID != id { + t.Fatalf("pending %+v %v, want %s", p, err, id) + } + before, ok := ProposalBefore(dir, *p) + if !ok { + t.Fatal("the approved Formula the proposal changes was not established") + } + changes, truncated, err := ProposalDiff(before, p.YAML) + if err != nil || truncated { + t.Fatalf("diff %v truncated %v", err, truncated) + } + lines, truncated := LineDiff(before, p.YAML) + if truncated || !strings.Contains(fmt.Sprint(lines), "+") { + t.Fatalf("line diff %v truncated %v", lines, truncated) + } + return ProposalKind(changes, true), changes +} + +func hasChange(changes []KeyChange, key, change string) bool { + for _, c := range changes { + if c.Key == key && c.Change == change { + return true + } + } + return false +} + +func TestProposalViewReadableDiffForEachKind(t *testing.T) { + t.Run("baseline", func(t *testing.T) { + root, runner, dir := refreshLab(t) + commitWorkflow(t, root, refreshWorkflow("go test -timeout 20m ./...")) + out, err := refresh(root, runner, dir) + if err != nil || out.Proposal == nil { + t.Fatalf("%+v %v", out, err) + } + kind, changes := pendingView(t, dir, out.Proposal.ID) + if kind != KindBaseline || len(changes) == 0 { + t.Fatalf("kind %s changes %+v", kind, changes) + } + for _, c := range changes { + if !strings.HasPrefix(c.Key, "requiredChecks") { + t.Fatalf("a baseline refresh changed %s", c.Key) + } + } + }) + t.Run("enable-review", func(t *testing.T) { + root, runner, dir := reviewLab(t, "claude-code") + out, err := enableReview(root, runner, dir) + if err != nil || out.Proposal == nil { + t.Fatalf("%+v %v", out, err) + } + kind, changes := pendingView(t, dir, out.Proposal.ID) + if kind != KindEnableReview || !hasChange(changes, "atoms[review].type", "added") { + t.Fatalf("kind %s changes %+v", kind, changes) + } + }) + t.Run("model-pin", func(t *testing.T) { + root, runner, dir := claudeLab(t, nil) + out, err := Prepare(context.Background(), InitOptions{Cwd: root, LabDir: dir, Launcher: "/cli", Candidates: rebindCandidates(), Runner: runner, Model: pinnedModel, ModelSet: true}) + if err != nil || out.Proposal == nil { + t.Fatalf("%+v %v", out, err) + } + kind, changes := pendingView(t, dir, out.Proposal.ID) + if kind != KindModelPin || !hasChange(changes, "harness.model", "added") { + t.Fatalf("kind %s changes %+v", kind, changes) + } + for _, c := range changes { + if c.Key == "harness.model" && c.New != `"`+pinnedModel+`"` { + t.Fatalf("new value %s", c.New) + } + } + }) + t.Run("rebind", func(t *testing.T) { + root, runner, dir, _ := approvedLab(t) + out, err := rebind(t, root, runner, dir, "claude-code") + if err != nil || out.Proposal == nil { + t.Fatalf("%+v %v", out, err) + } + kind, changes := pendingView(t, dir, out.Proposal.ID) + if kind != KindRebind || !hasChange(changes, "harness.binding", "changed") { + t.Fatalf("kind %s changes %+v", kind, changes) + } + for _, c := range changes { + if c.Key == "harness.binding" && (c.Old != `"codex"` || c.New != `"claude-code"`) { + t.Fatalf("binding %s -> %s", c.Old, c.New) + } + } + }) +} + +func TestPendingProposalIsNilWithoutProposals(t *testing.T) { + if p, err := PendingProposal(t.TempDir()); err != nil || p != nil { + t.Fatalf("%+v %v", p, err) + } +} + +func TestProposalBeforeRefusesAnUnmatchedBefore(t *testing.T) { + p := Proposal{ActiveHashBefore: hash([]byte("a")), CapabilityEvidence: &BindingReport{BeforeYAML: "b"}} + if _, ok := ProposalBefore(t.TempDir(), p); ok { + t.Fatal("a before YAML whose hash differs from the recorded one was used") + } + if _, ok := ProposalBefore(t.TempDir(), Proposal{}); ok { + t.Fatal("a proposal without an approved Formula before it has a before") + } +} + +func TestProposalDiffBoundsFailSafe(t *testing.T) { + var before, after strings.Builder + for i := 0; i < maxKeyChanges+20; i++ { + fmt.Fprintf(&before, "k%d: 1\n", i) + fmt.Fprintf(&after, "k%d: 2\n", i) + } + changes, truncated, err := ProposalDiff(before.String(), after.String()) + if err != nil || !truncated || len(changes) != maxKeyChanges { + t.Fatalf("%d changes, truncated %v, %v", len(changes), truncated, err) + } + long := strings.Repeat("é", maxDiffValueBytes) + changes, truncated, err = ProposalDiff("k: a\n", "k: "+long+"\n") + if err != nil || !truncated || len(changes) != 1 || !changes[0].Truncated || len(changes[0].New) > maxDiffValueBytes+len("…") || !strings.HasSuffix(changes[0].New, "…") { + t.Fatalf("%+v %v %v", changes, truncated, err) + } + if _, _, err = ProposalDiff("k: [", "k: 1"); err == nil { + t.Fatal("invalid YAML was diffed") + } + var big strings.Builder + for i := 0; i < maxDiffLines+10; i++ { + fmt.Fprintf(&big, "line %d\n", i) + } + lines, truncated := LineDiff("a\n", big.String()) + if !truncated || len(lines) > maxDiffOutput { + t.Fatalf("%d lines, truncated %v", len(lines), truncated) + } + lines, truncated = LineDiff("a\nb\nc\nd\ne\nf\ng\nh\ni\n", "a\nb\nc\nd\nE\nf\ng\nh\ni\n") + want := []DiffLine{{"…", ""}, {" ", "b"}, {" ", "c"}, {" ", "d"}, {"-", "e"}, {"+", "E"}, {" ", "f"}, {" ", "g"}, {" ", "h"}, {"…", ""}} + if truncated || fmt.Sprint(lines) != fmt.Sprint(want) { + t.Fatalf("%v", lines) + } +} + +func TestProposalKindNamesOnlyExactShapes(t *testing.T) { + for want, changes := range map[string][]KeyChange{ + KindBaseline: {{Key: "requiredChecks[go-test].argv", Change: "changed"}}, + KindEnableReview: {{Key: "atoms[review].id", Change: "added"}, {Key: "atoms[review].type", Change: "added"}}, + KindModelPin: {{Key: "harness.model", Change: "added"}, {Key: "harnesses.coding.model", Change: "changed"}}, + KindRebind: {{Key: "harness.binding", Change: "changed"}, {Key: "atoms[learn].command", Change: "changed"}}, + KindChange: {{Key: "harness.binding", Change: "changed"}, {Key: "requiredChecks", Change: "added"}}, + } { + if got := ProposalKind(changes, true); got != want { + t.Errorf("%+v: kind %s, want %s", changes, got, want) + } + } + if ProposalKind(nil, false) != KindBootstrap { + t.Error("a proposal with nothing before it is a bootstrap") + } + if _, err := formula.Parse([]byte(roleFormula(""))); err != nil { + t.Fatal(err) + } +} diff --git a/lab/internal/ui/formula_proposals.go b/lab/internal/ui/formula_proposals.go new file mode 100644 index 0000000..3bb2e9d --- /dev/null +++ b/lab/internal/ui/formula_proposals.go @@ -0,0 +1,901 @@ +package ui + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "syscall" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/onboarding" +) + +// The Formula view changes the Formula only through the engine's own proposal +// functions, the ones forgecell init uses, behind the drive write protection +// (cookie, tab header, exact Origin, Fetch Metadata and one strict JSON object of +// at most 4 KiB): +// +// GET /api/formula → the active Formula, any pending proposal and what can be proposed +// POST /api/formula/proposals {"kind","digest","model","role","anyway"} +// → onboarding.Prepare (init --rebind, --refresh-baseline, --enable-review, --model) +// POST /api/formula/proposals/{id}/approve {"digest"} → onboarding.Review(approve) (init --approve) +// POST /api/formula/proposals/{id}/dismiss {"digest"} → onboarding.Review(dismiss) (init --dismiss) +// +// digest binds what was shown: on create, the active Formula's SHA-256; on a +// decision, the pending proposal's YAML SHA-256. A mismatch is refused before the +// engine is called. One Formula write from this UI runs at a time. Engine error +// text, which can name local paths, is never served: each refusal is a fixed +// category, message and reason. Main-view fields carry no YAML, paths, assay ids +// or full digests; those are only under details. + +const ( + // formulaTimeout bounds one proposal: repository discovery and the binding + // probe run processes, inside the server's write timeout. + formulaTimeout = 45 * time.Second + // maxModelBytes bounds a model name before the engine validates it. + maxModelBytes = 200 + // maxLabelBytes bounds a display value in the main view. + maxLabelBytes = 120 +) + +// FormulaAction is the action name for the Formula proposal routes. +const FormulaAction = "Formula proposals" + +// Proposal kinds the Formula view creates, as the CLI flags name them. +const ( + ProposeRebind = "rebind" + ProposeBaseline = "baseline" + ProposeReview = "enable-review" + ProposeModel = "model" +) + +var safeWord = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`) +var releaseVersion = regexp.MustCompile(`/releases/([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?)/forgecell$`) + +// formulaRoute reports a POST Formula path, and for a decision its proposal id. +func formulaRoute(path string) (id, action string, ok bool) { + if path == "/api/formula/proposals" { + return "", "create", true + } + rest, found := strings.CutPrefix(path, "/api/formula/proposals/") + if !found { + return "", "", false + } + for _, decision := range []string{"approve", "dismiss"} { + if id, found = strings.CutSuffix(rest, "/"+decision); found && !strings.Contains(id, "/") { + return id, decision, true + } + } + return "", "", false +} + +type proposalRequest struct { + Kind string `json:"kind"` + Digest string `json:"digest"` + Model string `json:"model"` + Role string `json:"role"` + Anyway bool `json:"anyway"` +} + +type decisionRequest struct { + Digest string `json:"digest"` +} + +// FormulaPage is GET /api/formula and every accepted Formula write. +type FormulaPage struct { + Sample bool `json:"sample"` + Writable bool `json:"writable"` + Reason string `json:"reason,omitempty"` + Active ActiveFormulaPage `json:"active"` + Pending *ProposalView `json:"pending,omitempty"` + Create []CreateOptionView `json:"create"` + InFlight InFlightView `json:"inFlight"` + // Outcome names what the write just did: proposed, approved or dismissed. + Outcome string `json:"outcome,omitempty"` +} + +// ActiveFormulaPage is the active Formula in plain words. +type ActiveFormulaPage struct { + State string `json:"state"` + Name string `json:"name,omitempty"` + ShortSHA string `json:"shortSha,omitempty"` + Atoms []string `json:"atoms"` + Coding AgentView `json:"coding"` + Reviewer *AgentView `json:"reviewer,omitempty"` + Checks []CheckView `json:"checks"` + ApprovedAt string `json:"approvedAt,omitempty"` + // ApprovedBy is never invented: the engine records no approver. + ApprovedBy string `json:"approvedBy"` + Details ActiveFormulaExtra `json:"details"` +} + +// ActiveFormulaExtra is shown only behind details. +type ActiveFormulaExtra struct { + FormulaID string `json:"formulaId,omitempty"` + SHA256 string `json:"sha256,omitempty"` +} + +// AgentView is a binding in plain words. +type AgentView struct { + Agent string `json:"agent"` + Model string `json:"model,omitempty"` + Source string `json:"source,omitempty"` + Warning string `json:"warning,omitempty"` +} + +// CheckView is a required check by its command, with its id behind details. +type CheckView struct { + Name string `json:"name"` + ID string `json:"id"` +} + +// CreateOptionView is one proposal kind the view offers. Available is false with +// Reason when it cannot be proposed now; NeedsAnyway asks for an explicit Rebind +// Anyway, with Reason, before an older or unordered build is proposed. +type CreateOptionView struct { + Kind string `json:"kind"` + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + NeedsAnyway bool `json:"needsAnyway,omitempty"` + Build string `json:"build,omitempty"` +} + +// InFlightView counts Molecules the ledgers show running or allocating, and those +// waiting for plan approval. Incomplete reports a ledger inventory that could not +// be read whole, so the counts may be low. +type InFlightView struct { + Running int `json:"running"` + Waiting int `json:"waiting"` + Incomplete bool `json:"incomplete,omitempty"` +} + +// ProposalView is the pending proposal for review. +type ProposalView struct { + Kind string `json:"kind"` + Title string `json:"title"` + CreatedAt string `json:"createdAt,omitempty"` + Changes []ChangeView `json:"changes"` + Truncated bool `json:"truncated,omitempty"` + Notes []string `json:"notes"` + Warning string `json:"warning,omitempty"` + Interrupted bool `json:"interrupted,omitempty"` + Rebase bool `json:"rebase,omitempty"` + Details ProposalDetailView `json:"details"` +} + +// ChangeView is one changed key in plain words: old → new. +type ChangeView struct { + Label string `json:"label"` + Change string `json:"change"` + Old string `json:"old"` + New string `json:"new"` +} + +// ProposalDetailView is shown only behind details: the exact id and digest the +// decision binds, the raw changed keys and the exact YAML line diff. +type ProposalDetailView struct { + ID string `json:"id"` + Digest string `json:"digest"` + Keys []onboarding.KeyChange `json:"keys"` + Lines []onboarding.DiffLine `json:"lines"` + Truncated bool `json:"truncated,omitempty"` +} + +// formulaRefusal is one fixed refusal for engine error text it recognises. +type formulaRefusal struct { + parts []string + code int + category, message, reason string +} + +var proposalRefusals = []formulaRefusal{ + {[]string{"a proposal is already pending"}, http.StatusConflict, "pending", "a proposal is already pending; nothing was saved", "Approve or dismiss the pending proposal first"}, + {[]string{"another Formula write is active"}, http.StatusConflict, "locked", "another Formula write is active in this Lab; nothing was saved", "Wait for it to finish; if nothing is running, inspect the Lab's Formula write lock with forgecell init"}, + {[]string{"already has a review Atom", "already has an Atom with id review"}, http.StatusConflict, "has-review", "the Formula already has a review Atom; nothing was saved", "In-loop review is already part of this Formula"}, + {[]string{"no usable binding", "configure harnesses.review, or a meta binding"}, http.StatusConflict, "no-reviewer", "no reviewer binding: the review Atom would have no agent to run; nothing was saved", "Declare harnesses.review or a meta binding through a reviewed Formula change, then enable review"}, + {[]string{"ticket readiness"}, http.StatusConflict, "not-ready", "the Formula is not in the shape review needs; nothing was saved", "Run forgecell init --enable-review to see the engine's own reason"}, + {[]string{"nothing to rebind"}, http.StatusConflict, "nothing-to-rebind", "the coding agent is already bound to this build; nothing to rebind", "Nothing is needed"}, + {[]string{"cannot rebind to", "launcher and executable are required"}, http.StatusConflict, "rebind-unavailable", "the coding agent is not ready on this machine, or this build's launcher is unknown; nothing was saved", "Run forgecell doctor to see what is missing, then try again"}, + {[]string{"already pinned to", "nothing to remove"}, http.StatusConflict, "unchanged", "every targeted agent already uses this model; nothing was saved", "Choose another model, or leave the pin as it is"}, + {[]string{"cannot pin a model"}, http.StatusConflict, "model-unsupported", "a targeted agent cannot carry a pinned model; nothing was saved", "Pin a model on a Claude Code agent with a role, or run forgecell init --model to see which agents refuse it"}, + {[]string{"is not declared, so there is no"}, http.StatusConflict, "role-undeclared", "this Formula declares no agent for that role; nothing was saved", "Leave the role empty to pin the default agent, or declare the role through a reviewed Formula change"}, + {[]string{"yield no recognized baseline check"}, http.StatusConflict, "baseline-empty", "your pull-request workflows yield no check Forgecell recognises, so the baseline would drop every protected check; nothing was saved", "Keep the current checks, or add a recognised check to a pull-request workflow"}, + {[]string{"cannot be edited in place", "cannot be located in place"}, http.StatusConflict, "not-editable", "the Formula's text cannot be changed in place for this kind; nothing was saved", "Run the same change with forgecell init to see the engine's own reason"}, + {[]string{"belongs to another repository"}, http.StatusConflict, "other-repo", "this checkout is not the repository this Lab's Formula targets; nothing was saved", "Restart forgecell ui from a checkout of that repository"}, + {[]string{"current exact approval", "needs an approved Formula"}, http.StatusConflict, "unapproved", "the active Formula has no current exact approval; nothing was saved", "Approve the active Formula with forgecell init first"}, + {[]string{"repository discovery failed", "origin must identify", "GitHub repository identity mismatch"}, http.StatusConflict, "checkout", "the source checkout or its GitHub repository could not be read; nothing was saved", "Check that gh is signed in and the checkout's origin is the Lab's repository, then try again"}, +} + +var decisionRefusals = []formulaRefusal{ + {[]string{"another Formula write is active"}, http.StatusConflict, "locked", "another Formula write is active in this Lab; nothing was decided", "Wait for it to finish; if nothing is running, inspect the Lab's Formula write lock with forgecell init"}, + {[]string{"stale approval refused", "original active Formula changed", "proposal changed since review"}, http.StatusConflict, "stale", "the Formula or the proposal changed since it was shown; nothing was approved", "Dismiss this proposal and create a new one from the current Formula"}, + {[]string{"approval started; resume approve"}, http.StatusConflict, "interrupted", "an approval of this proposal was interrupted; it cannot be dismissed", "Approve it to resume the exact saved approval"}, + {[]string{"proposal already reviewed"}, http.StatusConflict, "reviewed", "this proposal was already decided; nothing was written", "Reload the Formula view"}, +} + +// refuseFormula serves the fixed refusal matching err, or a generic one. +func (h *handler) refuseFormula(w http.ResponseWriter, err error, refusals []formulaRefusal) { + if errors.Is(err, context.DeadlineExceeded) { + h.refuseWith(w, http.StatusGatewayTimeout, "timeout", "the proposal took too long and was stopped; nothing was saved", "Try again; if it keeps timing out, run the same change with forgecell init") + return + } + text := err.Error() + for _, r := range refusals { + if slices.ContainsFunc(r.parts, func(p string) bool { return strings.Contains(text, p) }) { + h.refuseWith(w, r.code, r.category, r.message, r.reason) + return + } + } + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "the engine refused the change; nothing else was attempted", categoryReasons["refused"]) +} + +// formulaPost serves one POST Formula route after the write protection passed. +func (h *handler) formulaPost(w http.ResponseWriter, r *http.Request, id, action string) { + if h.opts.Demo { + h.refuseWith(w, http.StatusNotFound, "sample", "sample data has no Formula to change; nothing was written", categoryReasons["sample"]) + return + } + if action != "create" && !assayID.MatchString(id) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "invalid proposal id") + return + } + var create proposalRequest + var decide decisionRequest + var body any = &decide + if action == "create" { + body = &create + } + if decodeGate(w, r, body) != nil { + h.refuseGate(w, http.StatusBadRequest, "invalid", "send one small JSON object with exactly the action's fields") + return + } + if !h.formulaMu.TryLock() { + h.refuseWith(w, http.StatusConflict, "busy", "another Formula change from this Lab UI is in progress; nothing was written", categoryReasons["busy"]) + return + } + defer h.formulaMu.Unlock() + if action == "create" { + h.createProposal(w, r.Context(), create) + return + } + h.decideProposal(w, id, action, decide) +} + +// activeFormula is the approved active Formula, or false when there is none. +func (h *handler) activeFormula() (formula.Loaded, bool) { + active, err := formula.Load(h.opts.LabDir, "") + return active, err == nil && active.Approved && active.Formula.ID != "" +} + +func nativeLauncher(command []string) string { + if len(command) == 4 && command[1] == "__adapter" { + return command[0] + } + return "" +} + +// launcherUnknownReason is the plain next step when this build's launcher was +// not accepted by ValidateLauncher. +const launcherUnknownReason = "Start forgecell ui from a Forgecell build you installed under your Forgecell home to rebind to it" + +// needsAnyway reports a launcher order a rebind proposes only with an explicit +// Rebind Anyway: an older build, two dev builds whose commit times do not order +// them, or a build unrelated to the Formula's launcher. +func needsAnyway(order string) bool { + return order == formula.LauncherOlder || order == formula.LauncherUnordered || order == formula.LauncherUnrelated +} + +// managedTrees are the trees under the managed Forgecell home a rebind may name. +var managedTrees = []string{"releases", "development"} + +// ValidateLauncher returns candidate, the launcher forgecell init --rebind would +// save, only when it resolves through symlinks (and a managed launcher through +// its active release) to a regular file this user owns under home's releases or +// development tree. Otherwise it returns "", and the Formula view says the +// launcher is unknown and offers no rebind. +func ValidateLauncher(home, candidate string) string { + if home == "" || candidate == "" || !filepath.IsAbs(home) || !filepath.IsAbs(candidate) || len(candidate) > 4096 { + return "" + } + realHome, err := filepath.EvalSymlinks(home) + if err != nil { + return "" + } + exe := harness.ResolveLauncher(candidate).Executable + if exe == "" { + return "" + } + real, err := filepath.EvalSymlinks(exe) + if err != nil || underManagedTree(realHome, real) == "" { + return "" + } + st, err := os.Lstat(real) + if err != nil || !st.Mode().IsRegular() { + return "" + } + if sys, ok := st.Sys().(*syscall.Stat_t); !ok || int(sys.Uid) != os.Getuid() { + return "" + } + return candidate +} + +// underManagedTree names the managed tree under home that holds p, or "". +func underManagedTree(home, p string) string { + for _, tree := range managedTrees { + rel, err := filepath.Rel(filepath.Join(home, tree), p) + if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) { + return tree + } + } + return "" +} + +// buildWord names a Forgecell launcher in plain words: a managed release, a dev +// build, or a build outside the managed tree, by version only, never by path. +func buildWord(home, launcher string) string { + version := "" + if m := releaseVersion.FindStringSubmatch(launcher); m != nil { + version = m[1] + } + tree := "" + if home != "" && filepath.IsAbs(launcher) && filepath.Clean(launcher) == launcher { + roots := []string{home} + if real, err := filepath.EvalSymlinks(home); err == nil && real != home { + roots = append(roots, real) + } + for _, root := range roots { + if tree = underManagedTree(root, launcher); tree != "" { + break + } + } + } + dev := strings.Contains(version, "-dev.") + switch { + case tree == "releases" && version != "" && dev: + return "Forgecell dev build " + version + case tree == "releases" && version != "": + return "Forgecell release " + version + case tree == "development": + return "a Forgecell dev build" + case tree != "": + return "a managed Forgecell build" + case version != "": + return "Forgecell " + version + ", outside the managed tree" + } + return "a Forgecell build outside the managed tree" +} + +// rebindOrder orders this build against the Formula's launcher; current is "" +// when the default harness is not a native command. +func (h *handler) rebindOrder(f formula.Formula) (current, order, reason string) { + current = nativeLauncher(f.Harness.Command) + if current == "" || h.opts.Launcher == "" { + return current, "", "" + } + order, reason = formula.CompareLaunchers(current, h.opts.Launcher) + return current, order, reason +} + +// initOptions are the InitOptions forgecell init builds for kind. +func (h *handler) initOptions(f formula.Formula, req proposalRequest) onboarding.InitOptions { + o := onboarding.InitOptions{Cwd: h.opts.SourceRoot, LabDir: h.opts.LabDir, Launcher: h.opts.Launcher, Runner: h.formulaRunner, Candidates: h.formulaCandidates} + switch req.Kind { + case ProposeRebind: + o.Rebind = f.Harness.Binding + case ProposeBaseline: + o.RefreshBaseline = true + case ProposeReview: + o.EnableReview = true + case ProposeModel: + o.Model, o.ModelSet, o.Role = req.Model, true, req.Role + } + return o +} + +func (h *handler) createProposal(w http.ResponseWriter, ctx context.Context, req proposalRequest) { + switch req.Kind { + case ProposeRebind, ProposeBaseline, ProposeReview, ProposeModel: + default: + h.refuseGate(w, http.StatusBadRequest, "invalid", "give kind as rebind, baseline, enable-review or model") + return + } + if !sha256Hex.MatchString(req.Digest) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "give the digest of the active Formula shown") + return + } + if req.Kind == ProposeModel { + if len(req.Model) > maxModelBytes || req.Model == "" || formula.ValidateModel(req.Model) != nil { + h.refuseWith(w, http.StatusBadRequest, "invalid", "give a model name such as claude-opus-4-8; nothing was saved", categoryReasons["invalid"]) + return + } + if req.Role != "" && !slices.Contains([]string{"analysis", "coding", "meta", "review"}, req.Role) { + h.refuseWith(w, http.StatusBadRequest, "invalid", "give the role as analysis, coding, meta or review, or leave it empty; nothing was saved", categoryReasons["invalid"]) + return + } + } else if req.Model != "" || req.Role != "" { + h.refuseGate(w, http.StatusBadRequest, "invalid", "model and role apply only to a model pin") + return + } + if req.Anyway && req.Kind != ProposeRebind { + h.refuseGate(w, http.StatusBadRequest, "invalid", "anyway applies only to a rebind") + return + } + if h.sourceMissing(w) { + return + } + active, ok := h.activeFormula() + if !ok { + h.refuseWith(w, http.StatusConflict, "unapproved", "this Lab has no approved active Formula; nothing was saved", "Approve a Formula with forgecell init first") + return + } + if active.Formula.SHA256 != req.Digest { + h.refuseWith(w, http.StatusConflict, "stale", "the active Formula changed since it was shown; nothing was saved", "Reload the Formula view and decide again") + return + } + if pending, err := onboarding.PendingProposal(h.opts.LabDir); err != nil { + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "a saved proposal could not be read safely; nothing was saved", "Inspect the Lab's proposals with forgecell init") + return + } else if pending != nil { + h.refuseWith(w, proposalRefusals[0].code, proposalRefusals[0].category, proposalRefusals[0].message, proposalRefusals[0].reason) + return + } + if req.Kind == ProposeRebind { + current, order, reason := h.rebindOrder(active.Formula) + switch { + case h.opts.Launcher == "": + h.refuseWith(w, http.StatusConflict, "rebind-unavailable", "this build's launcher is unknown; nothing was saved", launcherUnknownReason) + return + case current == "": + h.refuseWith(w, http.StatusConflict, "rebind-unavailable", "the coding agent runs a custom command, which a rebind does not repoint; nothing was saved", "Change it through a reviewed Formula change") + return + case order == formula.LauncherSame: + h.refuseWith(w, http.StatusConflict, "nothing-to-rebind", "the coding agent is already bound to this build; nothing to rebind", "Nothing is needed") + return + case order == formula.LauncherOlder && !req.Anyway: + h.refuseWith(w, http.StatusConflict, "older-build", reason+" Nothing was saved.", "Choose Rebind Anyway if you mean to bind this build") + return + case needsAnyway(order) && !req.Anyway: + h.refuseWith(w, http.StatusConflict, "unordered-build", reason+" Nothing was saved.", "Choose Rebind Anyway if you mean to bind this build") + return + } + } + ctx, cancel := context.WithTimeout(ctx, formulaTimeout) + defer cancel() + out, err := h.prepare(ctx, h.initOptions(active.Formula, req)) + if err != nil { + h.refuseFormula(w, err, proposalRefusals) + return + } + if out.Proposal == nil { + if req.Kind == ProposeBaseline && out.Status == "unchanged" { + h.refuseWith(w, http.StatusConflict, "unchanged", "the CI baseline already matches the approved required checks; nothing was saved", "Nothing is needed") + return + } + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "the engine saved no proposal", categoryReasons["refused"]) + return + } + // The proposal stays bound to the Formula shown: a Formula approved elsewhere + // (forgecell init --approve) between the check above and the engine's own read + // would make it a change of another Formula than the one the request showed. + if out.Proposal.ActiveHashBefore != req.Digest { + if _, err = onboarding.Review(h.opts.LabDir, out.Proposal.ID, "dismiss"); err != nil { + h.refuseWith(w, http.StatusConflict, "stale", "the active Formula changed while the proposal was made, and the new proposal could not be dismissed; it stays pending and nothing was approved", "Review it in the Formula view, or dismiss it with forgecell init --dismiss") + return + } + h.refuseWith(w, http.StatusConflict, "stale", "the active Formula changed while the proposal was made; the new proposal was dismissed and nothing was approved", "Reload the Formula view and decide again") + return + } + v := h.formulaView() + v.Outcome = "proposed" + h.json(w, http.StatusOK, v) +} + +func (h *handler) decideProposal(w http.ResponseWriter, id, decision string, req decisionRequest) { + if !sha256Hex.MatchString(req.Digest) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "give the digest of the proposal shown") + return + } + pending, err := onboarding.PendingProposal(h.opts.LabDir) + if err != nil { + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "a saved proposal could not be read safely; nothing was decided", "Inspect the Lab's proposals with forgecell init") + return + } + if pending == nil || pending.ID != id { + // A proposal decided elsewhere (forgecell init --approve or --dismiss) after + // it was shown is reported as already decided, never as this view's decision. + if p, err := onboarding.InspectProposal(h.opts.LabDir, id); err == nil && p.ID == id && p.Status != "pending" { + h.refuseWith(w, decisionRefusals[3].code, decisionRefusals[3].category, "this proposal was already decided outside this view; nothing was written", decisionRefusals[3].reason) + return + } + h.refuseWith(w, http.StatusConflict, "stale", "this is not the pending proposal; nothing was decided", "Reload the Formula view and decide again") + return + } + p, err := onboarding.InspectProposal(h.opts.LabDir, id) + if err != nil || p.YAMLHash != req.Digest || p.ID != id { + h.refuseWith(w, http.StatusConflict, "stale", "the proposal changed since it was shown; nothing was decided", "Reload the Formula view and decide again") + return + } + if _, err = onboarding.Review(h.opts.LabDir, id, decision); err != nil { + h.refuseFormula(w, err, decisionRefusals) + return + } + v := h.formulaView() + v.Outcome = map[string]string{"approve": "approved", "dismiss": "dismissed"}[decision] + h.json(w, http.StatusOK, v) +} + +func (h *handler) formulaGet(w http.ResponseWriter) { + if h.opts.Demo { + h.json(w, http.StatusOK, demoFormulaPage()) + return + } + h.json(w, http.StatusOK, h.formulaView()) +} + +// demoFormulaPage is the sample Formula view: it offers nothing. +func demoFormulaPage() FormulaPage { + v := FormulaPage{Sample: true, Reason: "Sample data never writes; run forgecell ui in a Lab of your own repository to change its Formula", Active: ActiveFormulaPage{State: "sample", Name: "repository-care", Atoms: atomWords(nil), Coding: AgentView{Agent: "Sample agent"}, Checks: []CheckView{}, ApprovedBy: "Not recorded"}, Create: []CreateOptionView{}} + for _, kind := range []string{ProposeRebind, ProposeBaseline, ProposeReview, ProposeModel} { + v.Create = append(v.Create, CreateOptionView{Kind: kind, Reason: "Sample data never writes"}) + } + return v +} + +// formulaView reads the active Formula, the pending proposal and the ledgers. +func (h *handler) formulaView() FormulaPage { + v := FormulaPage{Create: []CreateOptionView{}, Active: ActiveFormulaPage{Atoms: []string{}, Checks: []CheckView{}, ApprovedBy: "Not recorded"}} + list := h.list(ledgerETag(h.opts.LabDir)) + v.InFlight.Incomplete = !list.Complete || list.Truncated + for _, m := range list.Molecules { + if active(m.Status) { + v.InFlight.Running++ + } else if m.Phase == "scope-waiting" && m.Lane != LaneRetired { + v.InFlight.Waiting++ + } + } + id := formula.ActiveIdentity(h.opts.LabDir) + v.Active.State = id.State + loaded, approved := h.activeFormula() + if approved { + v.Active = activeView(h.opts.LabDir, loaded) + } else if id.FormulaID != "" && safeWord.MatchString(id.FormulaID) { + v.Active.Name = id.FormulaID + } + pending, err := onboarding.PendingProposal(h.opts.LabDir) + pendingReason := "" + switch { + case err != nil: + pendingReason = "A saved proposal could not be read safely; inspect the Lab's proposals with forgecell init" + case pending != nil: + v.Pending = formulaProposalView(h.opts.LabDir, h.opts.Home, *pending) + pendingReason = "A proposal is already pending; approve or dismiss it first" + } + v.Writable = h.opts.SourceRoot != "" && approved && pendingReason == "" + switch { + case h.opts.SourceRoot == "": + v.Reason = "This Lab UI was started without a source checkout, so it proposes nothing; you can still approve or dismiss a pending proposal" + case !approved: + v.Reason = "This Lab has no approved active Formula; approve one with forgecell init first" + default: + v.Reason = pendingReason + } + offer := func(kind string, available bool, reason string) CreateOptionView { + o := CreateOptionView{Kind: kind, Available: v.Writable && available, Reason: v.Reason} + if v.Writable && !available { + o.Reason = reason + } + return o + } + if approved { + f := loaded.Formula + current, order, reason := h.rebindOrder(f) + switch { + case current == "": + // A custom coding command is not repointed by a rebind. + case h.opts.Launcher == "": + v.Create = append(v.Create, offer(ProposeRebind, false, "This build's launcher is unknown, so no rebind is offered. "+launcherUnknownReason)) + case current != h.opts.Launcher && order != formula.LauncherSame: + o := offer(ProposeRebind, true, "") + if m := releaseVersion.FindStringSubmatch(h.opts.Launcher); m != nil { + o.Build = m[1] + } + if o.Available && needsAnyway(order) { + o.NeedsAnyway, o.Reason = true, reason + } + v.Create = append(v.Create, o) + } + v.Create = append(v.Create, offer(ProposeBaseline, true, "")) + if _, present, err := f.ReviewAtom(); err == nil && !present { + v.Create = append(v.Create, offer(ProposeReview, true, "")) + } + v.Create = append(v.Create, offer(ProposeModel, true, "")) + } + return v +} + +// atomWords are the Formula's Atoms in plain words, in Formula order. +func atomWords(atoms []formula.Atom) []string { + words := []string{} + for _, a := range atoms { + switch { + case a.Type == "gate" && a.Purpose == "scope": + words = append(words, "Plan approval") + case a.Type == "gate": + words = append(words, "Your review") + default: + if w, ok := map[string]string{"intake": "Intake", "harness": "Coding", "check": "Checks", "review": "Review", "ship": "Ship", "document": "Document", "learn": "Learn"}[a.Type]; ok { + words = append(words, w) + } else { + words = append(words, "Other Atom") + } + } + } + return words +} + +// agentName is a binding id in plain words. +func agentName(binding string) string { + if name, ok := map[string]string{"claude-code": "Claude Code", "codex": "Codex", "cursor": "Cursor"}[binding]; ok { + return name + } + if safeWord.MatchString(binding) { + return binding + } + return "Custom agent" +} + +var reviewerSources = map[string]string{"review-role": "its own review binding", "meta-override": "the meta binding", "learn-atom": "the learn Atom's binding"} + +func reviewerView(f formula.Formula) *AgentView { + res, configured, err := onboarding.ResolveReviewBinding(f) + if !configured { + return nil + } + if err != nil { + return &AgentView{Agent: "No reviewer binding", Warning: "The review Atom has no agent to run; declare harnesses.review or a meta binding"} + } + a := &AgentView{Agent: agentName(res.Binding.Binding), Model: modelWord(res.Binding.Model), Source: reviewerSources[res.Source]} + if res.SameCommandAsCoding { + a.Warning = "Warning: " + onboarding.SameReviewerWarning + } + return a +} + +func modelWord(model string) string { + if model != "" && len(model) <= maxModelBytes && formula.ValidateModel(model) == nil { + return model + } + return "" +} + +// checkName is a required check's command, or "" when it names an absolute path. +func checkName(c formula.RequiredCheck) string { + for _, arg := range c.Argv { + if strings.HasPrefix(arg, "/") || strings.ContainsAny(arg, "\\\n") { + return "" + } + } + name := strings.Join(c.Argv, " ") + if len(name) > maxLabelBytes { + name, _ = clip(name) + } + return name +} + +func clip(s string) (string, bool) { + if len(s) <= maxLabelBytes { + return s, false + } + cut := maxLabelBytes + for cut > 0 && s[cut]&0xC0 == 0x80 { + cut-- + } + return s[:cut] + "…", true +} + +func activeView(lab string, loaded formula.Loaded) ActiveFormulaPage { + f := loaded.Formula + v := ActiveFormulaPage{State: formula.IdentityApproved, Atoms: atomWords(f.Atoms), Checks: []CheckView{}, ApprovedBy: "Not recorded", Details: ActiveFormulaExtra{SHA256: f.SHA256}} + if safeWord.MatchString(f.ID) { + v.Name, v.Details.FormulaID = f.ID, f.ID + } + if len(f.SHA256) >= 12 { + v.ShortSHA = f.SHA256[:12] + } + coding, _ := f.RoleBinding(formula.CodingHarness) + v.Coding = AgentView{Agent: agentName(coding.Binding), Model: modelWord(coding.Model)} + v.Reviewer = reviewerView(f) + for _, c := range f.RequiredChecks { + id := c.ID + if !safeWord.MatchString(id) { + id = "" + } + name := checkName(c) + if name == "" { + name = notRecordedWord(id) + } + v.Checks = append(v.Checks, CheckView{Name: name, ID: id}) + } + if loaded.Approval.SourceKind == "bootstrap-assay" && assayID.MatchString(loaded.Approval.SourceID) { + if p, err := onboarding.InspectProposal(lab, loaded.Approval.SourceID); err == nil { + if _, err := time.Parse(time.RFC3339Nano, p.ReviewedAt); err == nil { + v.ApprovedAt = p.ReviewedAt + } + } + } + return v +} + +func notRecordedWord(s string) string { + if s == "" { + return "Not recorded" + } + return s +} + +var kindTitles = map[string]string{ + onboarding.KindRebind: "Rebind the coding agent", + onboarding.KindBaseline: "Refresh the required checks from CI", + onboarding.KindEnableReview: "Enable in-loop review", + onboarding.KindModelPin: "Change the pinned model", + onboarding.KindBootstrap: "Approve a new Formula", + onboarding.KindChange: "Change the Formula", +} + +var kindNotes = map[string]string{ + onboarding.KindRebind: "Only the coding agent's binding and launcher change. Atoms, checks, instructions and timeouts are kept.", + onboarding.KindBaseline: "Only the required checks change, re-derived from your repository's pull-request workflows. Every later plan must include them.", + onboarding.KindEnableReview: "Adds one review Atom after Checks and before your review. Runs still stop for your review.", + onboarding.KindModelPin: "Only model pins change. Plans in flight for an agent whose pin changes need a fresh intake.", + onboarding.KindBootstrap: "This Lab has no approved Formula before it. Review the exact YAML under details before you approve.", + onboarding.KindChange: "This change is not one of the kinds this view proposes. Review the exact YAML under details before you approve.", +} + +// formulaProposalView builds the review of a pending proposal; home names builds in the +// readable diff. Only Details carries the exact id, digest, keys and YAML lines. +func formulaProposalView(lab, home string, p onboarding.Proposal) *ProposalView { + v := &ProposalView{Changes: []ChangeView{}, Notes: []string{}, Details: ProposalDetailView{ID: p.ID, Digest: p.YAMLHash, Keys: []onboarding.KeyChange{}, Lines: []onboarding.DiffLine{}}} + if _, err := time.Parse(time.RFC3339Nano, p.CreatedAt); err == nil { + v.CreatedAt = p.CreatedAt + } + before, hasBefore := onboarding.ProposalBefore(lab, p) + var changes []onboarding.KeyChange + if hasBefore { + var err error + changes, v.Truncated, err = onboarding.ProposalDiff(before, p.YAML) + if err != nil { + v.Truncated = true + } + } + v.Kind = onboarding.ProposalKind(changes, hasBefore) + v.Title = kindTitles[v.Kind] + v.Notes = append(v.Notes, kindNotes[v.Kind]) + v.Details.Keys = append(v.Details.Keys, changes...) + v.Details.Lines, v.Details.Truncated = onboarding.LineDiff(before, p.YAML) + v.Changes = readableChanges(home, changes) + if f, err := formula.Parse([]byte(p.YAML)); err == nil && v.Kind == onboarding.KindEnableReview { + if r := reviewerView(f); r != nil { + note := "Reviewer: " + r.Agent + if r.Source != "" { + note += " (from " + r.Source + ")" + } + v.Notes = append(v.Notes, note+".") + v.Warning = r.Warning + } + } + if incomplete, err := onboarding.ProposalActivationIncomplete(lab, p); err == nil && incomplete { + v.Interrupted = true + v.Notes = append(v.Notes, "An approval of this proposal was interrupted; Approve resumes the exact saved approval.") + } + v.Rebase = v.Kind == onboarding.KindRebind && hasBefore && formula.LauncherOnlyChange([]byte(before), []byte(p.YAML)) == nil + return v +} + +// keyLabel names a changed key in plain words. +func keyLabel(key string) string { + role := func(r string) string { + if w, ok := map[string]string{"analysis": "Planning agent", "coding": "Coding agent", "meta": "Learning agent", "review": "Review agent"}[r]; ok { + return w + } + return "Agent" + } + switch { + case key == "harness.binding": + return "Coding agent" + case key == "harness.command": + return "Coding agent's build" + case key == "harness.model": + return "Coding agent's model" + case strings.HasPrefix(key, "harnesses."): + parts := strings.Split(key, ".") + if len(parts) == 3 { + switch parts[2] { + case "binding": + return role(parts[1]) + case "command": + return role(parts[1]) + "'s build" + case "model": + return role(parts[1]) + "'s model" + } + } + case strings.HasPrefix(key, "atoms["): + id, field, _ := strings.Cut(strings.TrimPrefix(key, "atoms["), "].") + atom := map[string]string{"harness": "Coding", "learn": "Learn", "review": "Review", "check": "Checks"}[id] + if atom == "" { + atom = "An" + } + if label, ok := map[string]string{"binding": atom + " Atom's agent", "command": atom + " Atom's build"}[field]; ok { + return label + } + return atom + " Atom setting" + case strings.HasPrefix(key, "requiredChecks"): + id, field, _ := strings.Cut(strings.TrimPrefix(key, "requiredChecks["), "].") + if !safeWord.MatchString(id) { + return "Required checks" + } + return "Required check " + id + " · " + map[string]string{"argv": "command", "dir": "folder", "timeoutMs": "time limit", "workflow": "workflow", "reason": "reason", "select": "selection"}[field] + } + return "Another Formula key" +} + +// displayValue is a changed value in plain words, with paths left to details. +func displayValue(home, key, raw string) string { + if raw == "" { + return "none" + } + var list []string + if json.Unmarshal([]byte(raw), &list) == nil { + if launcher := nativeLauncher(list); launcher != "" && strings.HasSuffix(key, ".command") { + return buildWord(home, launcher) + " · " + agentName(list[2]) + } + for _, item := range list { + if strings.Contains(item, "/") || strings.Contains(item, "\\") { + return "see details" + } + } + s, _ := clip(strings.Join(list, " ")) + return s + } + var s string + if json.Unmarshal([]byte(raw), &s) == nil { + if strings.HasSuffix(key, ".binding") { + return agentName(s) + } + if strings.ContainsAny(s, "/\\\n") { + return "see details" + } + s, _ = clip(s) + return s + } + if strings.ContainsAny(raw, "/\\") { + return "see details" + } + s, _ = clip(raw) + return s +} + +// readableChanges are the changed keys in plain words; the review Atom's keys +// read as one added Atom. +func readableChanges(home string, changes []onboarding.KeyChange) []ChangeView { + out := []ChangeView{} + review := false + for _, c := range changes { + if strings.HasPrefix(c.Key, "atoms[review].") && c.Change == "added" { + if !review { + out = append(out, ChangeView{Label: "Review Atom", Change: "added", Old: "none", New: "after Checks, before your review"}) + review = true + } + continue + } + out = append(out, ChangeView{Label: keyLabel(c.Key), Change: c.Change, Old: displayValue(home, c.Key, c.Old), New: displayValue(home, c.Key, c.New)}) + } + return out +} diff --git a/lab/internal/ui/formula_proposals_test.go b/lab/internal/ui/formula_proposals_test.go new file mode 100644 index 0000000..293c7ff --- /dev/null +++ b/lab/internal/ui/formula_proposals_test.go @@ -0,0 +1,685 @@ +package ui + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/onboarding" + "github.com/DanielNetzer/forgecell/lab/internal/process" +) + +// formulaClock pins the proposal clock, so a UI-created and a CLI-created +// proposal carry the same createdAt and so the same identity. +func formulaClock(t *testing.T) { + t.Helper() + at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + t.Cleanup(onboarding.SetClock(func() time.Time { return at })) +} + +func formulaCandidates() []onboarding.Candidate { + var all []onboarding.Candidate + for _, id := range []string{"codex", "claude-code"} { + all = append(all, onboarding.Candidate{ID: id, Executable: "/" + id, Readiness: "ready", Capabilities: harness.AdapterCapabilities(id)}) + } + return all +} + +func formulaWorkflow(run string) string { + return "on: pull_request\njobs:\n core:\n runs-on: ubuntu-latest\n steps:\n - run: " + run + "\n working-directory: service\n" +} + +func formulaGit(t *testing.T, root string, args ...string) { + t.Helper() + c := exec.Command("git", args...) + c.Dir = root + if b, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %s %v", args, b, err) + } +} + +// formulaCommitWorkflow writes and commits the pull-request workflow. +func formulaCommitWorkflow(t *testing.T, root, run string) { + t.Helper() + os.MkdirAll(filepath.Join(root, ".github", "workflows"), 0700) + if err := os.WriteFile(filepath.Join(root, ".github", "workflows", "ci.yml"), []byte(formulaWorkflow(run)), 0600); err != nil { + t.Fatal(err) + } + formulaGit(t, root, "add", ".") + formulaGit(t, root, "commit", "-qm", "workflow") +} + +// formulaLab is a fixture checkout of acme/widgets (gh stubbed) and a Lab whose +// approved Formula codes with claude-code on launcher and learns with codex. +func formulaLab(t *testing.T, launcher string) (root, lab string, runner onboarding.Runner) { + t.Helper() + root, lab = t.TempDir(), t.TempDir() + formulaGit(t, root, "init", "-q") + formulaGit(t, root, "config", "user.name", "Test") + formulaGit(t, root, "config", "user.email", "test@example.test") + formulaGit(t, root, "remote", "add", "origin", "https://github.com/acme/widgets.git") + os.Mkdir(filepath.Join(root, "service"), 0700) + os.WriteFile(filepath.Join(root, "service", "go.mod"), []byte("module example/service\n\ngo 1.27\n"), 0600) + formulaCommitWorkflow(t, root, "go test ./...") + runner = func(ctx context.Context, o process.Options) process.Result { + if o.Argv[0] == "gh" { + return process.Result{OK: true, Stdout: `{"nameWithOwner":"acme/widgets","defaultBranchRef":{"name":"main"}}`} + } + return process.Run(ctx, o) + } + boot, err := onboarding.Prepare(context.Background(), onboarding.InitOptions{Cwd: root, LabDir: lab, Launcher: launcher, Candidates: formulaCandidates(), Preferred: "claude-code", MetaHarness: "codex", Runner: runner}) + if err != nil || boot.Proposal == nil { + t.Fatalf("%+v %v", boot, err) + } + if _, err = onboarding.Review(lab, boot.Proposal.ID, "approve"); err != nil { + t.Fatal(err) + } + return root, lab, runner +} + +// formulaHandler serves lab from root with launcher as this build, counting +// engine proposal calls. +func formulaHandler(t *testing.T, root, lab, launcher string, runner onboarding.Runner) (*handler, *int32) { + t.Helper() + h := testHandler(t, Options{LabDir: lab, SourceRoot: root, Launcher: launcher}).(*handler) + h.formulaRunner, h.formulaCandidates = runner, formulaCandidates() + calls := new(int32) + prepare := h.prepare + h.prepare = func(ctx context.Context, o onboarding.InitOptions) (onboarding.Outcome, error) { + atomic.AddInt32(calls, 1) + return prepare(ctx, o) + } + return h, calls +} + +func getFormula(t *testing.T, h http.Handler) FormulaPage { + t.Helper() + return viewOf[FormulaPage](t, do(t, h, authed("/api/formula")), http.StatusOK) +} + +func option(v FormulaPage, kind string) (CreateOptionView, bool) { + for _, o := range v.Create { + if o.Kind == kind { + return o, true + } + } + return CreateOptionView{}, false +} + +// formulaRefusalOf checks a fixed refusal with a reason and no local paths. +func formulaRefusalOf(t *testing.T, w interface { + Result() *http.Response +}, body string, code int, category string, leaks ...string) gateRefusal { + t.Helper() + var v gateRefusal + if w.Result().StatusCode != code || json.Unmarshal([]byte(body), &v) != nil || v.Category != category || v.Error == "" || v.Reason == "" { + t.Fatalf("status %d, want %d %s: %s", w.Result().StatusCode, code, category, body) + } + noLeak(t, body, leaks...) + return v +} + +// mainView is the view without what only Details shows. +func mainView(t *testing.T, v FormulaPage) string { + t.Helper() + v.Active.Details = ActiveFormulaExtra{} + if v.Pending != nil { + p := *v.Pending + p.Details = ProposalDetailView{} + v.Pending = &p + } + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func noMainLeak(t *testing.T, v FormulaPage, root, lab string) { + t.Helper() + text := mainView(t, v) + for _, leak := range []string{root, lab, "/cli", "/opt/", "/claude-code", "/codex", "assay-", "kind: formula", "schemaVersion", v.Active.Details.SHA256} { + if leak != "" && strings.Contains(text, leak) { + t.Errorf("the main view carries %q: %s", leak, text) + } + } + if v.Pending != nil && (strings.Contains(text, v.Pending.Details.Digest) || strings.Contains(text, v.Pending.Details.ID)) { + t.Errorf("the main view carries the proposal's full id or digest: %s", text) + } +} + +func TestUIFormulaProposalsMatchTheCLIBytesForEachKind(t *testing.T) { + formulaClock(t) + for _, c := range []struct { + kind, wantKind, body string + prepare func(*onboarding.InitOptions) + before func(t *testing.T, root string) + }{ + // /cli and /cli2 are not release launchers, so the rebind needs Rebind Anyway. + {ProposeRebind, onboarding.KindRebind, `"kind":"rebind","anyway":true`, func(o *onboarding.InitOptions) { o.Rebind = "claude-code" }, nil}, + {ProposeBaseline, onboarding.KindBaseline, `"kind":"baseline"`, func(o *onboarding.InitOptions) { o.RefreshBaseline = true }, func(t *testing.T, root string) { formulaCommitWorkflow(t, root, "go test -timeout 20m ./...") }}, + {ProposeReview, onboarding.KindEnableReview, `"kind":"enable-review"`, func(o *onboarding.InitOptions) { o.EnableReview = true }, nil}, + {ProposeModel, onboarding.KindModelPin, `"kind":"model","model":"claude-opus-4-8"`, func(o *onboarding.InitOptions) { o.Model, o.ModelSet = "claude-opus-4-8", true }, nil}, + } { + t.Run(c.kind, func(t *testing.T) { + root, lab, runner := formulaLab(t, "/cli") + if c.before != nil { + c.before(t, root) + } + h, _ := formulaHandler(t, root, lab, "/cli2", runner) + v := getFormula(t, h) + if o, ok := option(v, c.kind); !ok || !o.Available || o.NeedsAnyway != (c.kind == ProposeRebind) { + t.Fatalf("%s is not offered: %+v", c.kind, v.Create) + } + w := do(t, h, gatePost("/api/formula/proposals", `{`+c.body+`,"digest":"`+v.Active.Details.SHA256+`"}`)) + proposed := viewOf[FormulaPage](t, w, http.StatusOK) + if proposed.Outcome != "proposed" || proposed.Pending == nil || proposed.Pending.Kind != c.wantKind || len(proposed.Pending.Changes) == 0 { + t.Fatalf("%+v", proposed.Pending) + } + noMainLeak(t, proposed, root, lab) + if o, _ := option(proposed, c.kind); o.Available || o.Reason == "" { + t.Fatalf("a pending proposal does not block new ones with a reason: %+v", o) + } + id := proposed.Pending.Details.ID + file := filepath.Join(lab, "assays", id+".json") + ui, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if err = os.Remove(file); err != nil { + t.Fatal(err) + } + // The CLI path: forgecell init builds these InitOptions. + o := onboarding.InitOptions{Cwd: root, LabDir: lab, Launcher: "/cli2", Candidates: formulaCandidates(), Runner: runner, Env: map[string]string{"CLAUDECODE": ""}} + c.prepare(&o) + out, err := onboarding.Prepare(context.Background(), o) + if err != nil || out.Proposal == nil || out.Proposal.ID != id { + t.Fatalf("CLI path %+v %v, want %s", out.Proposal, err, id) + } + cli, err := os.ReadFile(file) + if err != nil || string(cli) != string(ui) { + t.Fatalf("the UI saved other bytes than the CLI:\nUI %s\nCLI %s", ui, cli) + } + }) + } +} + +func TestUIFormulaDecisionsBindTheExactIdAndDigest(t *testing.T) { + formulaClock(t) + root, lab, runner := formulaLab(t, "/cli") + h, _ := formulaHandler(t, root, lab, "/cli2", runner) + v := getFormula(t, h) + active := v.Active.Details.SHA256 + if v.Active.Name == "" || len(v.Active.Atoms) == 0 || v.Active.Coding.Agent != "Claude Code" || v.Active.ApprovedBy != "Not recorded" || v.Active.ApprovedAt == "" { + t.Fatalf("active %+v", v.Active) + } + noMainLeak(t, v, root, lab) + + // A stale active Formula digest is refused before the engine runs. + before := snapshot(t, lab) + w := do(t, h, gatePost("/api/formula/proposals", `{"kind":"enable-review","digest":"`+strings.Repeat("0", 64)+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "stale", root, lab) + if snapshot(t, lab) != before { + t.Fatal("a stale create wrote to the Lab") + } + + proposed := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals", `{"kind":"enable-review","digest":"`+active+`"}`)), http.StatusOK) + p := proposed.Pending + if p == nil || p.Kind != onboarding.KindEnableReview || !strings.Contains(strings.Join(p.Notes, " "), "Reviewer: Codex") { + t.Fatalf("%+v", p) + } + if len(p.Details.Lines) == 0 || len(p.Details.Keys) == 0 { + t.Fatal("details lack the exact YAML diff") + } + + // A pending proposal blocks every new one, with the reason. + w = do(t, h, gatePost("/api/formula/proposals", `{"kind":"model","model":"claude-opus-4-8","digest":"`+active+`"}`)) + if r := formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "pending", root, lab); !strings.Contains(r.Error, "already pending") { + t.Fatalf("refusal %+v", r) + } + + // A mismatched digest or another id is refused and writes nothing. + before = snapshot(t, lab) + for name, req := range map[string]request{ + "wrong digest": gatePost("/api/formula/proposals/"+p.Details.ID+"/approve", `{"digest":"`+strings.Repeat("a", 64)+`"}`), + "active digest": gatePost("/api/formula/proposals/"+p.Details.ID+"/approve", `{"digest":"`+active+`"}`), + "other id": gatePost("/api/formula/proposals/assay-"+strings.Repeat("b", 64)+"/approve", `{"digest":"`+p.Details.Digest+`"}`), + "dismiss other id": gatePost("/api/formula/proposals/assay-"+strings.Repeat("b", 64)+"/dismiss", `{"digest":"`+p.Details.Digest+`"}`), + "dismiss bad digest": gatePost("/api/formula/proposals/"+p.Details.ID+"/dismiss", `{"digest":"`+strings.Repeat("c", 64)+`"}`), + } { + w := do(t, h, req) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "stale", root, lab) + if snapshot(t, lab) != before { + t.Fatalf("%s wrote to the Lab", name) + } + } + + // Dismiss goes through the engine's flow. + dismissed := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals/"+p.Details.ID+"/dismiss", `{"digest":"`+p.Details.Digest+`"}`)), http.StatusOK) + if dismissed.Outcome != "dismissed" || dismissed.Pending != nil { + t.Fatalf("%+v", dismissed) + } + if saved, err := onboarding.InspectProposal(lab, p.Details.ID); err != nil || saved.Status != "dismissed" { + t.Fatalf("%+v %v", saved.Status, err) + } + + // Approve activates exactly the proposal shown. + proposed = viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals", `{"kind":"model","model":"claude-opus-4-8","digest":"`+active+`"}`)), http.StatusOK) + p = proposed.Pending + approved := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals/"+p.Details.ID+"/approve", `{"digest":"`+p.Details.Digest+`"}`)), http.StatusOK) + if approved.Outcome != "approved" || approved.Pending != nil || approved.Active.Coding.Model != "claude-opus-4-8" { + t.Fatalf("%+v", approved) + } + saved, err := onboarding.InspectProposal(lab, p.Details.ID) + live, lerr := formula.Load(lab, "") + if err != nil || lerr != nil || !live.Approved || live.Formula.YAML != saved.YAML || live.Formula.SHA256 != p.Details.Digest { + t.Fatalf("approval did not activate the exact proposal: %v %v", err, lerr) + } + // The old active digest is now stale for a new proposal. + w = do(t, h, gatePost("/api/formula/proposals", `{"kind":"baseline","digest":"`+active+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "stale", root, lab) +} + +func TestUIFormulaEngineRefusalsShowFixedReasons(t *testing.T) { + formulaClock(t) + root, lab, runner := formulaLab(t, "/cli") + h, _ := formulaHandler(t, root, lab, "/cli2", runner) + active := getFormula(t, h).Active.Details.SHA256 + // The baseline already matches CI: nothing to propose. + w := do(t, h, gatePost("/api/formula/proposals", `{"kind":"baseline","digest":"`+active+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "unchanged", root, lab) + // The model pin on a role the Formula does not declare. + w = do(t, h, gatePost("/api/formula/proposals", `{"kind":"model","model":"claude-opus-4-8","role":"review","digest":"`+active+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "role-undeclared", root, lab) + if entries, _ := os.ReadDir(filepath.Join(lab, "assays")); len(entries) == 0 { + t.Fatal("the fixture's bootstrap proposal is missing") + } + if p, err := onboarding.PendingProposal(lab); err != nil || p != nil { + t.Fatalf("a refused proposal was saved: %+v %v", p, err) + } +} + +func TestUIFormulaDevBuildRebindOrdersBuilds(t *testing.T) { + formulaClock(t) + const current = "/opt/fc/releases/0.16.9-dev.0000/forgecell" + root, lab, runner := formulaLab(t, current) + + // An older build is refused with the plain reason, unless Rebind Anyway is chosen. + h, calls := formulaHandler(t, root, lab, "/opt/fc/releases/0.16.8/forgecell", runner) + v := getFormula(t, h) + o, ok := option(v, ProposeRebind) + if !ok || !o.Available || !o.NeedsAnyway || !strings.Contains(o.Reason, "older") || o.Build != "0.16.8" { + t.Fatalf("%+v", o) + } + before := snapshot(t, lab) + w := do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","digest":"`+v.Active.Details.SHA256+`"}`)) + if r := formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "older-build", root, lab, "/opt/"); !strings.Contains(r.Error, "older") || !strings.Contains(r.Reason, "Rebind Anyway") { + t.Fatalf("%+v", r) + } + if snapshot(t, lab) != before || atomic.LoadInt32(calls) != 0 { + t.Fatal("a refused older-build rebind reached the engine") + } + anyway := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","anyway":true,"digest":"`+v.Active.Details.SHA256+`"}`)), http.StatusOK) + if anyway.Pending == nil || anyway.Pending.Kind != onboarding.KindRebind { + t.Fatalf("%+v", anyway.Pending) + } + noMainLeak(t, anyway, root, lab) + p := anyway.Pending + viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals/"+p.Details.ID+"/dismiss", `{"digest":"`+p.Details.Digest+`"}`)), http.StatusOK) + + // Same-version dev builds from other commits are unordered: never an upgrade. + h, _ = formulaHandler(t, root, lab, "/opt/fc/releases/0.16.9-dev.ffff/forgecell", runner) + if o, _ := option(getFormula(t, h), ProposeRebind); !o.NeedsAnyway || !strings.Contains(o.Reason, "cannot tell which is newer") { + t.Fatalf("%+v", o) + } + + // A newer dev build is rebound directly. + h, _ = formulaHandler(t, root, lab, "/opt/fc/releases/0.17.0-dev.0000/forgecell", runner) + h.opts.Home = "/opt/fc" + v = getFormula(t, h) + if o, _ := option(v, ProposeRebind); !o.Available || o.NeedsAnyway { + t.Fatalf("%+v", o) + } + newer := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","digest":"`+v.Active.Details.SHA256+`"}`)), http.StatusOK) + if newer.Pending == nil || newer.Pending.Kind != onboarding.KindRebind { + t.Fatalf("%+v", newer.Pending) + } + if text := mainView(t, newer); !strings.Contains(text, "Forgecell dev build 0.17.0-dev.0000") || !strings.Contains(text, "Forgecell dev build 0.16.9-dev.0000") { + t.Fatalf("the readable diff does not name the new build: %s", mainView(t, newer)) + } + // The --rebase-formula offer follows the engine's own launcher-only verdict. + saved, err := onboarding.InspectProposal(lab, newer.Pending.Details.ID) + if err != nil { + t.Fatal(err) + } + prior, _ := onboarding.ProposalBefore(lab, saved) + if want := formula.LauncherOnlyChange([]byte(prior), []byte(saved.YAML)) == nil; newer.Pending.Rebase != want { + t.Fatalf("rebase offered %v, engine allows %v", newer.Pending.Rebase, want) + } +} + +func TestUIFormulaLauncherOnlyRebindOffersRebase(t *testing.T) { + formulaClock(t) + const current, next = "/opt/fc/releases/0.16.9-dev.0000/forgecell", "/opt/fc/releases/0.17.0-dev.0000/forgecell" + _, lab, _ := formulaLab(t, current) + active, err := formula.Load(lab, "") + if err != nil { + t.Fatal(err) + } + yaml := strings.ReplaceAll(active.Formula.YAML, current, next) + p := onboarding.Proposal{ID: "assay-" + strings.Repeat("d", 64), YAML: yaml, ActiveIDBefore: active.Formula.ID, ActiveHashBefore: active.Formula.SHA256, Status: "pending"} + v := formulaProposalView(lab, "", p) + if v.Kind != onboarding.KindRebind || !v.Rebase || len(v.Changes) == 0 { + t.Fatalf("%+v", v) + } + // An older build is never launcher-only: the engine refuses to carry it. + p.YAML = strings.ReplaceAll(active.Formula.YAML, current, "/opt/fc/releases/0.16.8/forgecell") + if v = formulaProposalView(lab, "", p); v.Rebase { + t.Fatalf("rebase offered for an older build: %+v", v) + } +} + +func TestUIFormulaGuardsAndDemoRefuseEveryWrite(t *testing.T) { + formulaClock(t) + root, lab, runner := formulaLab(t, "/cli") + h, calls := formulaHandler(t, root, lab, "/cli2", runner) + active := getFormula(t, h).Active.Details.SHA256 + id := "assay-" + strings.Repeat("a", 64) + routes := map[string]string{ + "/api/formula/proposals": `{"kind":"enable-review","digest":"` + active + `"}`, + "/api/formula/proposals/" + id + "/approve": `{"digest":"` + active + `"}`, + "/api/formula/proposals/" + id + "/dismiss": `{"digest":"` + active + `"}`, + } + before := snapshot(t, lab) + for path, body := range routes { + for name, forged := range forgedWrites(path, body) { + if w := do(t, h, forged); w.Code != http.StatusForbidden { + t.Errorf("%s %s: %d %s", name, path, w.Code, w.Body) + } + } + for name, req := range map[string]request{ + "no content type": func() request { q := gatePost(path, body); delete(q.header, "Content-Type"); return q }(), + "unknown field": gatePost(path, strings.TrimSuffix(body, "}")+`,"approve":true}`), + "trailing data": gatePost(path, body+`{}`), + "oversized": gatePost(path, strings.TrimSuffix(body, "}")+strings.Repeat(" ", maxGateBody)+`}`), + "array": gatePost(path, `[]`), + } { + if w := do(t, h, req); w.Code != http.StatusBadRequest { + t.Errorf("%s %s: %d %s", name, path, w.Code, w.Body) + } + } + if w := do(t, h, request{method: http.MethodPut, path: path, cookie: authed(path).cookie, origin: testOrigin}); w.Code != http.StatusMethodNotAllowed { + t.Errorf("PUT %s: %d", path, w.Code) + } + } + for _, body := range []string{`{"kind":"other","digest":"` + active + `"}`, `{"kind":"model","digest":"` + active + `"}`, `{"kind":"model","model":"a b","digest":"` + active + `"}`, `{"kind":"model","model":"claude-opus-4-8","role":"owner","digest":"` + active + `"}`, `{"kind":"baseline","model":"x","digest":"` + active + `"}`, `{"kind":"baseline","anyway":true,"digest":"` + active + `"}`, `{"kind":"baseline","digest":"short"}`} { + if w := do(t, h, gatePost("/api/formula/proposals", body)); w.Code != http.StatusBadRequest { + t.Errorf("%s: %d %s", body, w.Code, w.Body) + } + } + if w := do(t, h, gatePost("/api/formula/proposals/bad/approve", `{"digest":"`+active+`"}`)); w.Code != http.StatusBadRequest { + t.Errorf("bad id: %d", w.Code) + } + if n := atomic.LoadInt32(calls); n != 0 { + t.Fatalf("refused writes reached the engine %d times", n) + } + if snapshot(t, lab) != before { + t.Fatal("a refused write changed the Lab") + } + + // Without a source checkout nothing is proposed. + bare := testHandler(t, Options{LabDir: lab, Launcher: "/cli2"}).(*handler) + if v := getFormula(t, bare); v.Writable || v.Reason == "" { + t.Fatalf("%+v", v) + } + w := do(t, bare, gatePost("/api/formula/proposals", routes["/api/formula/proposals"])) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "unavailable", root, lab) + + // Demo mode refuses all of it and reads no Lab. + demo := testHandler(t, Options{Demo: true}) + v := getFormula(t, demo) + if !v.Sample || v.Writable || v.Pending != nil { + t.Fatalf("%+v", v) + } + for _, o := range v.Create { + if o.Available { + t.Fatalf("sample data offers %s", o.Kind) + } + } + for path, body := range routes { + w := do(t, demo, gatePost(path, body)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusNotFound, "sample") + } +} + +func TestUIFormulaUnrelatedOrUnknownLauncherNeedsAnyway(t *testing.T) { + formulaClock(t) + root, lab, runner := formulaLab(t, "/opt/fc/releases/0.16.9/forgecell") + + // A build outside the Formula launcher's installation cannot be ordered: it + // is offered only as Rebind Anyway, bound to the digest shown. + h, calls := formulaHandler(t, root, lab, "/elsewhere/forgecell", runner) + v := getFormula(t, h) + o, ok := option(v, ProposeRebind) + if !ok || !o.Available || !o.NeedsAnyway || !strings.Contains(o.Reason, "cannot order them") { + t.Fatalf("%+v", o) + } + before := snapshot(t, lab) + w := do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","digest":"`+v.Active.Details.SHA256+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "unordered-build", root, lab, "/opt/", "/elsewhere") + if snapshot(t, lab) != before || atomic.LoadInt32(calls) != 0 { + t.Fatal("an unrelated rebind without Rebind Anyway reached the engine") + } + w = do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","anyway":true,"digest":"`+strings.Repeat("e", 64)+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "stale", root, lab) + anyway := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","anyway":true,"digest":"`+v.Active.Details.SHA256+`"}`)), http.StatusOK) + if anyway.Pending == nil || anyway.Pending.Kind != onboarding.KindRebind || !strings.Contains(mainView(t, anyway), "a Forgecell build outside the managed tree") { + t.Fatalf("%s", mainView(t, anyway)) + } + noMainLeak(t, anyway, root, lab) + p := anyway.Pending + viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals/"+p.Details.ID+"/dismiss", `{"digest":"`+p.Details.Digest+`"}`)), http.StatusOK) + + // A launcher ValidateLauncher refused: the view says so and offers no rebind. + h, calls = formulaHandler(t, root, lab, "", runner) + v = getFormula(t, h) + if o, ok := option(v, ProposeRebind); !ok || o.Available || !strings.Contains(o.Reason, "launcher is unknown") { + t.Fatalf("%+v", o) + } + w = do(t, h, gatePost("/api/formula/proposals", `{"kind":"rebind","anyway":true,"digest":"`+v.Active.Details.SHA256+`"}`)) + formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "rebind-unavailable", root, lab) + if atomic.LoadInt32(calls) != 0 { + t.Fatal("a rebind with an unknown launcher reached the engine") + } +} + +func TestUIFormulaValidateLauncherAcceptsOnlyOwnedManagedBuilds(t *testing.T) { + home, outside := t.TempDir(), t.TempDir() + exe := func(path string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0700); err != nil { + t.Fatal(err) + } + return path + } + link := func(path, target string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + return path + } + release := exe(filepath.Join(home, "releases", "0.17.0", "forgecell")) + dev := exe(filepath.Join(home, "development", "build", "forgecell")) + foreign := exe(filepath.Join(outside, "forgecell")) + if err := os.MkdirAll(filepath.Join(home, "releases", "0.17.1", "forgecell"), 0700); err != nil { + t.Fatal(err) + } + plain := filepath.Join(home, "releases", "0.17.2", "forgecell") + exe(plain) + if err := os.Chmod(plain, 0600); err != nil { + t.Fatal(err) + } + for name, c := range map[string]struct { + home, candidate string + ok bool + }{ + "managed release": {home, release, true}, + "managed dev build": {home, dev, true}, + "link into the managed tree": {home, link(filepath.Join(outside, "into"), release), true}, + "outside the managed tree": {home, foreign, false}, + "link out of the managed tree": {home, link(filepath.Join(home, "releases", "0.17.3", "forgecell"), foreign), false}, + "missing": {home, filepath.Join(home, "releases", "0.9.9", "forgecell"), false}, + "directory": {home, filepath.Join(home, "releases", "0.17.1", "forgecell"), false}, + "not executable": {home, plain, false}, + "relative": {home, "releases/0.17.0/forgecell", false}, + "traversal out of the tree": {home, filepath.Join(home, "releases") + "/../../" + filepath.Base(outside) + "/forgecell", false}, + "no home": {"", release, false}, + "another home": {outside, release, false}, + "the releases directory itself": {home, filepath.Join(home, "releases"), false}, + "empty": {home, "", false}, + "home file outside both trees": {home, exe(filepath.Join(home, "bin", "forgecell")), false}, + "a symlinked home is resolved": {link(filepath.Join(outside, "home-link"), home), release, true}, + "oversized candidate": {home, "/" + strings.Repeat("a", 5000), false}, + "dev build under releases (dev)": {home, exe(filepath.Join(home, "releases", "0.17.0-dev.abcd", "forgecell")), true}, + } { + got := ValidateLauncher(c.home, c.candidate) + if c.ok && got != c.candidate || !c.ok && got != "" { + t.Errorf("%s: ValidateLauncher = %q, want ok=%v", name, got, c.ok) + } + } +} + +func TestUIFormulaReadableDiffNamesTheBuild(t *testing.T) { + const home = "/h/.forgecell" + for launcher, want := range map[string]string{ + home + "/releases/0.17.0/forgecell": "Forgecell release 0.17.0", + home + "/releases/0.17.0-dev.abcd/forgecell": "Forgecell dev build 0.17.0-dev.abcd", + home + "/development/x/forgecell": "a Forgecell dev build", + "/opt/fc/releases/0.17.0/forgecell": "Forgecell 0.17.0, outside the managed tree", + "/usr/local/bin/forgecell": "a Forgecell build outside the managed tree", + home + "/releases/../../x/forgecell": "a Forgecell build outside the managed tree", + } { + if got := buildWord(home, launcher); got != want { + t.Errorf("buildWord(%s) = %q, want %q", launcher, got, want) + } + raw, _ := json.Marshal([]string{launcher, "__adapter", "claude-code", "/bin/claude"}) + changes := readableChanges(home, []onboarding.KeyChange{{Key: "harness.command", Change: "changed", Old: string(raw), New: string(raw)}}) + if len(changes) != 1 || changes[0].New != want+" · Claude Code" || strings.Contains(changes[0].New, "/") { + t.Errorf("%s: %+v", launcher, changes) + } + } + if got := buildWord("", home+"/releases/0.17.0/forgecell"); got != "Forgecell 0.17.0, outside the managed tree" { + t.Errorf("without a home every build is outside the managed tree: %q", got) + } +} + +func TestUIFormulaCreateStaysBoundToTheShownFormula(t *testing.T) { + formulaClock(t) + root, lab, runner := formulaLab(t, "/cli") + h, _ := formulaHandler(t, root, lab, "/cli2", runner) + shown := getFormula(t, h).Active.Details.SHA256 + prepare := h.prepare + var cliID string + h.prepare = func(ctx context.Context, o onboarding.InitOptions) (onboarding.Outcome, error) { + // forgecell init --model … then --approve lands between this view's check + // and the engine's own read of the active Formula. + cli := o + cli.EnableReview, cli.Model, cli.ModelSet = false, "claude-opus-4-8", true + out, err := onboarding.Prepare(ctx, cli) + if err != nil || out.Proposal == nil { + t.Fatalf("CLI proposal %+v %v", out, err) + } + if _, err = onboarding.Review(lab, out.Proposal.ID, "approve"); err != nil { + t.Fatal(err) + } + cliID = out.Proposal.ID + return prepare(ctx, o) + } + w := do(t, h, gatePost("/api/formula/proposals", `{"kind":"enable-review","digest":"`+shown+`"}`)) + if r := formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "stale", root, lab); !strings.Contains(r.Error, "dismissed") { + t.Fatalf("%+v", r) + } + h.prepare = prepare + if p, err := onboarding.PendingProposal(lab); err != nil || p != nil { + t.Fatalf("the proposal made from another Formula stays pending: %+v %v", p, err) + } + live, err := formula.Load(lab, "") + if err != nil || !live.Approved || live.Formula.SHA256 == shown { + t.Fatalf("the CLI's approval does not stand: %v", err) + } + entries, err := os.ReadDir(filepath.Join(lab, "assays")) + if err != nil { + t.Fatal(err) + } + dismissed := 0 + for _, e := range entries { + id := strings.TrimSuffix(e.Name(), ".json") + if !assayID.MatchString(id) || id == cliID { + continue + } + if p, err := onboarding.InspectProposal(lab, id); err == nil && p.Status == "dismissed" && p.ActiveHashBefore == live.Formula.SHA256 { + dismissed++ + } + } + if dismissed != 1 { + t.Fatalf("want the one stale proposal dismissed, found %d", dismissed) + } + + // A CLI approval between showing a proposal and clicking it is reported as + // already decided, never as this view's approval. + v := getFormula(t, h) + proposed := viewOf[FormulaPage](t, do(t, h, gatePost("/api/formula/proposals", `{"kind":"model","model":"claude-opus-4-9","digest":"`+v.Active.Details.SHA256+`"}`)), http.StatusOK) + p := proposed.Pending + if p == nil { + t.Fatal("no proposal") + } + if _, err = onboarding.Review(lab, p.Details.ID, "approve"); err != nil { + t.Fatal(err) + } + before := snapshot(t, lab) + for _, decision := range []string{"approve", "dismiss"} { + w = do(t, h, gatePost("/api/formula/proposals/"+p.Details.ID+"/"+decision, `{"digest":"`+p.Details.Digest+`"}`)) + if r := formulaRefusalOf(t, w, w.Body.String(), http.StatusConflict, "reviewed", root, lab); !strings.Contains(r.Error, "already decided outside this view") || strings.Contains(w.Body.String(), `"outcome"`) { + t.Fatalf("%s: %s", decision, w.Body.String()) + } + } + if snapshot(t, lab) != before { + t.Fatal("an already decided proposal was written again") + } +} + +func TestUIServedJSFormulaPage(t *testing.T) { + js := servedJS(t) + for _, want := range []string{ + "'/api/formula'", "'/api/formula/proposals'", "/approve', { digest: digest }", "/dismiss', { digest: digest }", + "name: 'model', maxlength: '200', autocomplete: 'off', spellcheck: 'false', placeholder: 'claude-opus-4-8…'", + "'Rebind to This Build'", "'Refresh Baseline from CI'", "'Enable In-Loop Review'", "'Rebind Anyway'", "'Approve Proposal'", + "el('details', { class: 'formula-details' }", + } { + if !strings.Contains(js, want) { + t.Errorf("the Formula view lacks %q", want) + } + } + page := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/")).Body.String() + for _, want := range []string{`id="formula-error" class="formula-error" role="alert"`, `id="formula-live" class="sr-only" role="status" aria-live="polite"`, `id="formula-suggestions"`} { + if !strings.Contains(page, want) { + t.Errorf("index.html lacks %s", want) + } + } +} diff --git a/lab/internal/ui/server.go b/lab/internal/ui/server.go index 22c4cca..bdde20e 100644 --- a/lab/internal/ui/server.go +++ b/lab/internal/ui/server.go @@ -7,7 +7,8 @@ // 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), Set up your Lab in a checkout with no ready Lab (setup.go), each a POST that needs the session cookie, +// for closed Issues (bulk_retire.go), Set up your Lab in a checkout with no ready Lab (setup.go) and +// creating, approving or dismissing a Formula proposal (formula_proposals.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 @@ -31,6 +32,7 @@ import ( "github.com/DanielNetzer/forgecell/lab/internal/formula" "github.com/DanielNetzer/forgecell/lab/internal/intake" "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/onboarding" "github.com/DanielNetzer/forgecell/lab/internal/resources" "io" "io/fs" @@ -71,6 +73,14 @@ type Options struct { // Setup, when set, serves Set up your Lab (setup.go) until the Lab has an // approved Formula; every other API route is refused until then. Setup *SetupOptions + // Launcher is the Forgecell launcher a rebind saves, as forgecell init picks + // it and ValidateLauncher accepts it; without it the Formula view says the + // launcher is unknown and offers no rebind. + Launcher string + // Home is the managed Forgecell home (FORGECELL_HOME or ~/.forgecell); the + // Formula view names a build under its releases or development tree as + // managed and any other as outside it. It is never served. + Home string } // Server is a UI listening on 127.0.0.1. @@ -226,6 +236,14 @@ type handler struct { // 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) + // prepare is the engine call behind a Formula proposal, onboarding.Prepare; + // formulaRunner and formulaCandidates are its process runner and discovered + // harnesses (nil: the engine's own). formulaMu keeps one Formula write from + // this UI at a time. Tests replace all three seams. + prepare func(context.Context, onboarding.InitOptions) (onboarding.Outcome, error) + formulaRunner onboarding.Runner + formulaCandidates []onboarding.Candidate + formulaMu sync.Mutex } // staticFiles are the only embedded files served, with their content types. @@ -334,7 +352,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), setup: newSetupState(o.Setup, o.LabDir), recover: molecule.Recover, deriveActions: molecule.NextActions}, 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), setup: newSetupState(o.Setup, o.LabDir), recover: molecule.Recover, deriveActions: molecule.NextActions, prepare: onboarding.Prepare}, nil } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -364,19 +382,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.refuse(w, http.StatusForbidden, "cross-site request refused") return } - // GET serves every read; POST is served only on the gate, drive and setup - // routes. + // GET serves every read; POST is served only on the gate, drive, setup, action and + // Formula proposal routes. gateID, gateAction, gate := gateRoute(r.URL.Path) driveID, driveAction, drive := driveRoute(r.URL.Path) setupAction, setup := setupRoute(r.URL.Path) actionID, actionKind, action := actionRoute(r.URL.Path) - if r.Method != http.MethodGet && (r.Method != http.MethodPost || !(gate || drive || setup || action)) { - if gate || drive || setup || action { + formulaID, formulaAction, formulaWrite := formulaRoute(r.URL.Path) + if r.Method != http.MethodGet && (r.Method != http.MethodPost || !(gate || drive || setup || action || formulaWrite)) { + if gate || drive || setup || action || formulaWrite { header.Set("Allow", http.MethodPost) } else { header.Set("Allow", http.MethodGet) } - h.refuse(w, http.StatusMethodNotAllowed, "only GET is served here; the only writes are the gate, drive, setup and action POST routes") + h.refuse(w, http.StatusMethodNotAllowed, "only GET is served here; the only writes are the gate, drive, setup, action and Formula proposal POST routes") return } if launch { @@ -425,6 +444,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.moleculeAction(w, r, actionID, actionKind) return } + if formulaWrite { + h.formulaPost(w, r, formulaID, formulaAction) + return + } if h.refuseRetiredScope(w, gateID, gateAction) { return } @@ -438,6 +461,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.lab(w) case path == "/api/loop": h.loopStatus(w) + case path == "/api/formula": + h.formulaGet(w) case strings.HasPrefix(path, "/api/runs/"): h.runStatus(w, strings.TrimPrefix(path, "/api/runs/")) case path == "/api/molecules": diff --git a/lab/internal/ui/web/css/formula.css b/lab/internal/ui/web/css/formula.css index 20b0f99..fb7760b 100644 --- a/lab/internal/ui/web/css/formula.css +++ b/lab/internal/ui/web/css/formula.css @@ -10,11 +10,60 @@ .unavailable-tag { font-size: 10px; padding: 3px 7px; border: 1px dashed #9fb7a3; border-radius: 5px; color: var(--muted); } .unavailable { font-size: 18px; font-weight: 500; margin: 6px 0 0; } +/* The active Formula's facts, and Details behind a disclosure. */ +.formula-facts { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 4px; font-size: 12px; } +.formula-facts li { overflow-wrap: anywhere; } +.formula-key { color: var(--muted); font-size: 11px; margin-right: 4px; } +.formula-details { margin-top: 8px; font-size: 11px; } +.formula-details summary { cursor: pointer; min-height: 32px; display: inline-flex; align-items: center; } +.formula-details .mono-text { font: 11px ui-monospace, monospace; overflow-wrap: anywhere; margin: 4px 0; } +.formula-copy { height: 28px; min-height: 28px; padding: 0 8px; font-size: 11px; } + +/* Proposals: the pending one, then the kinds this view creates. */ +.formula-proposals, .formula-suggestions { margin-top: 20px; } +.formula-proposals h2, .formula-suggestions h2 { font-size: 16px; font-weight: 500; margin: 0 0 8px; } +.formula-proposal h3, .formula-create h3 { font-size: 14px; font-weight: 500; margin: 0 0 4px; } +.formula-proposal, .formula-create { padding: 14px; margin-top: 10px; min-width: 0; } +.formula-proposal small, .formula-help { display: block; font-size: 11px; color: var(--muted); } +.formula-empty { font-size: 12px; color: var(--muted); margin: 0; } +.formula-error { margin: 8px 0; padding: 8px 10px; border: 1px solid var(--danger-edge); border-radius: var(--radius-control); color: var(--danger-ink); background: var(--paper); font-size: 12px; } +.formula-changes, .formula-options, .formula-keys { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 8px; } +.formula-change { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 10px; font-size: 12px; padding: 8px 10px; border: 1px solid var(--card-edge); border-radius: var(--radius-control); } +.formula-change-kind { font-size: 10px; color: var(--muted); } +.formula-change-values { grid-column: 1 / -1; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.formula-old { text-decoration: line-through; color: var(--muted); } +.formula-new { font-weight: 500; } +.formula-notes p { font-size: 12px; margin: 8px 0 0; } +.formula-warning { color: var(--danger-ink); } +.formula-inflight { display: block; font-size: 11px; margin-top: 4px; padding: 6px 8px; border-left: 3px solid var(--forest); background: #eef5ec; } +.formula-diff { margin: 6px 0 0; padding: 8px; max-height: 360px; overflow: auto; border: 1px solid var(--card-edge); border-radius: var(--radius-control); background: var(--paper); font: 11px/1.45 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +.diff-add { color: #22573c; background: #d4e8d6; } +.diff-del { color: var(--danger-ink); background: #f6e1de; } +.diff-gap { color: var(--muted); } +.formula-decide { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.formula-option { display: grid; gap: 4px; padding: 10px 0; border-top: 1px solid var(--card-edge); } +.formula-option:first-child { border-top: 0; } +.formula-option .btn { justify-self: start; } +.formula-model-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; } +.formula-model-form input, .formula-model-form select { height: 40px; min-width: 0; max-width: 100%; padding: 0 10px; border: 1px solid var(--card-edge); border-radius: var(--radius-control); background: var(--paper); font: inherit; } +.formula-model-form input { flex: 1 1 180px; } +.formula-proposals button, .formula-proposals summary, .formula-proposals select, .formula-proposals input, .active-formula summary, .active-formula button { touch-action: manipulation; } +.formula-proposals :focus-visible, .active-formula :focus-visible, .formula-error:focus { outline: 2px solid var(--focus); outline-offset: 2px; } + +@media (pointer: coarse) { + .formula-copy, .formula-details summary, .formula-model-form input, .formula-model-form select { min-height: 44px; } +} @media (max-width: 720px) { .formula-view h1 { font-size: 22px; } .formula-view { padding: 18px; } .active-formula { align-items: flex-start; flex-direction: column; gap: 6px; } + .formula-model-form { align-items: stretch; flex-direction: column; } +} +@media (prefers-reduced-motion: reduce) { + .formula-proposals *, .active-formula * { transition: none !important; } } @media (forced-colors: active) { .active-formula { background: Canvas; color: CanvasText; box-shadow: none; border: 1px solid CanvasText; } + .formula-change, .formula-diff, .formula-error, .formula-inflight, .formula-model-form input, .formula-model-form select { background: Canvas; color: CanvasText; border: 1px solid CanvasText; } + .diff-add, .diff-del { background: Canvas; color: CanvasText; } } diff --git a/lab/internal/ui/web/index.html b/lab/internal/ui/web/index.html index e386dfd..a3defa4 100644 --- a/lab/internal/ui/web/index.html +++ b/lab/internal/ui/web/index.html @@ -224,6 +224,17 @@

Lab overview