Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion lab/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer
return demo.ServeStub(args[1:], in, out, stderr)
}
defaultLab := ""
// labChoices are the Labs forgecell ui offers when several match the
// repository, where every other command asks for --lab DIR.
var labChoices []string
if labCommands[args[0]] || args[0] == "loop" || args[0] == "ui" || args[0] == "retire" {
rest, name, explicitLab, err := labArgs(args)
if err != nil {
Expand All @@ -72,6 +75,11 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer
args = rest
if !explicitLab && !(args[0] == "ui" && demoRequested(args[1:])) {
resolved, err := resolveLab(ctx, name)
if err != nil && args[0] == "ui" {
if labChoices = uiLabChoices(ctx, name); labChoices != nil {
err = nil
}
}
if err != nil {
return fail(err)
}
Expand All @@ -94,7 +102,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, stderr io.Writer
}

if args[0] == "ui" {
return runUI(ctx, args[1:], defaultLab, version, out, stderr)
return runUIChoosing(ctx, args[1:], defaultLab, labChoices, version, out, stderr)
}

if args[0] == "learn" || args[0] == "suggestion" {
Expand Down
48 changes: 41 additions & 7 deletions lab/internal/cli/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ func identName(ident string) string {
// human gates (exact scope approval and draft PR publication), starting intake
// or the loop, and stopping work it started. It never merges or deploys.
func runUI(ctx context.Context, args []string, defaultLab, version string, out, stderr io.Writer) int {
return runUIChoosing(ctx, args, defaultLab, nil, version, out, stderr)
}

// runUIChoosing is runUI with the Labs to choose from when more than one
// matches the checkout's repository. A Lab with no approved Formula yet, or a
// chooser, is served as Set up your Lab (#263), whose every step is an engine
// call; the resource guard opens only once the Lab is ready.
func runUIChoosing(ctx context.Context, args []string, defaultLab string, choices []string, version string, out, stderr io.Writer) int {
fail := func(err error) int { fmt.Fprintln(stderr, "forgecell:", err); return 1 }
flags := flag.NewFlagSet("ui", flag.ContinueOnError)
flags.SetOutput(stderr)
Expand All @@ -140,32 +148,58 @@ func runUI(ctx context.Context, args []string, defaultLab, version string, out,
if flags.NArg() != 0 {
return fail(fmt.Errorf("ui accepts only --lab, --lab-name, --port, --no-open and --demo"))
}
if !*demo && *lab == "" {
explicitLab := false
flags.Visit(func(f *flag.Flag) { explicitLab = explicitLab || f.Name == "lab" })
if explicitLab {
choices = nil
}
if !*demo && *lab == "" && len(choices) == 0 {
return fail(fmt.Errorf("no Lab selected; use --lab DIR"))
}
options := ui.Options{LabDir: *lab, Version: version, Demo: *demo, Port: *port}
setup := false
if !*demo {
// Work started from the UI runs from this checkout under the Lab's resource
// guard, as forgecell run and loop do.
cwd, err := os.Getwd()
if err != nil {
return fail(err)
}
guard, err := newResourceGuard(*lab, stderr)
if err != nil {
return fail(err)
setup = len(choices) > 1 || ui.NeedsSetup(*lab)
if setup && !explicitLab && os.Getenv("FORGECELL_LAB") == "" {
// As forgecell init does, a Lab kept in the checkout is continued
// explicitly, never set up again beside it.
if legacy := existingCheckoutLab(); legacy != "" {
return fail(fmt.Errorf("existing checkout Lab at %s; use --lab %s to continue it, or move its contents to the home Lab before retrying", safeText(legacy), shellWord(legacy)))
}
}
if setup {
options.Setup = uiSetupOptions(choices, stderr)
} else {
guard, err := newResourceGuard(*lab, stderr)
if err != nil {
return fail(err)
}
options.Resources = guard
}
options.SourceRoot, options.Resources = cwd, guard
options.SourceRoot = cwd
// The avatar's name; demo mode never reads git.
options.Operator = operatorName(ctx, cwd)
}
s, err := ui.Listen(options)
if err != nil {
return fail(err)
}
if *demo {
switch {
case *demo:
fmt.Fprintln(out, "Forgecell Lab UI · Sample data (synthetic; no Lab is read)")
} else {
case len(choices) > 1:
fmt.Fprintf(out, "Forgecell Lab UI · Choose a Lab: %d Labs match this repository\n", len(choices))
fmt.Fprintln(out, "Writes only through the engine, the same paths as forgecell init: creating the Lab and approving its first Formula only when you click Approve Formula. It never merges or deploys.")
case setup:
fmt.Fprintf(out, "Forgecell Lab UI · Set up your Lab %s\n", safeText(*lab))
fmt.Fprintln(out, "Writes only through the engine, the same paths as forgecell init: creating the Lab and approving its first Formula only when you click Approve Formula. It never merges or deploys.")
default:
fmt.Fprintf(out, "Forgecell Lab UI · Lab %s\n", safeText(*lab))
fmt.Fprintln(out, "Writes only through the engine: exact scope approval, draft PR publication, starting intake or the loop from this checkout, and stopping work it started. It never merges or deploys.")
}
Expand Down
81 changes: 81 additions & 0 deletions lab/internal/cli/ui_setup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package cli

import (
"context"
"github.com/DanielNetzer/forgecell/lab/internal/onboarding"
"github.com/DanielNetzer/forgecell/lab/internal/resources"
"github.com/DanielNetzer/forgecell/lab/internal/ui"
"io"
"os"
"path/filepath"
"strings"
)

// uiSetupOptions are the engine calls behind Set up your Lab (#263): the paths
// forgecell init and doctor use, never a file the UI writes itself. Labs are
// the matching Labs to choose from, if more than one. Tests replace it.
var uiSetupOptions = func(labs []string, stderr io.Writer) *ui.SetupOptions {
launcher := initLauncher()
if launcher == "" {
// As forgecell init does, the running executable is the launcher; if even
// that is unknown, Launcher stays empty and creating the Lab is refused.
launcher, _ = os.Executable()
}
env := map[string]string{}
for _, key := range []string{"CODEX_THREAD_ID", "CLAUDECODE", "CURSOR_AGENT", "TERM_PROGRAM"} {
env[key] = os.Getenv(key)
}
return &ui.SetupOptions{
Labs: labs,
Launcher: launcher,
Env: env,
ReadRepository: func(ctx context.Context, cwd string) (onboarding.Repository, error) {
return onboarding.ReadRepository(ctx, cwd, nil)
},
Discover: func(ctx context.Context, cwd string) []onboarding.Candidate {
return onboarding.Discover(ctx, cwd, doctorRun)
},
Prepare: onboarding.Prepare,
Review: onboarding.Review,
Resources: func(lab string) (*resources.Guard, error) {
return newResourceGuard(lab, stderr)
},
}
}

// uiLabChoices lists the Labs that match the checkout's repository when more
// than one does, so forgecell ui offers a chooser where other commands ask for
// --lab DIR. It only reads; FORGECELL_LAB, a Lab name, no GitHub origin or a
// truncated or failed scan gives none, and the resolution error stands. An
// unreadable Lab is never offered, as resolution never considers one.
func uiLabChoices(ctx context.Context, name string) []string {
if os.Getenv("FORGECELL_LAB") != "" || name != "" {
return nil
}
root, err := resolvedCheckoutRoot()
if err != nil {
return nil
}
repo, _ := checkoutRepository(ctx, root)
if repo == "" {
return nil
}
home, err := forgecellHome(root)
if err != nil {
return nil
}
entries, truncated, err := scanLabs(filepath.Join(home, "labs"))
if err != nil || truncated {
return nil
}
var matches []string
for _, e := range entries {
if e.Err == "" && strings.EqualFold(e.Repo, repo) {
matches = append(matches, e.Dir)
}
}
if len(matches) < 2 {
return nil
}
return matches
}
193 changes: 193 additions & 0 deletions lab/internal/cli/ui_setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package cli

import (
"context"
"github.com/DanielNetzer/forgecell/lab/internal/onboarding"
"github.com/DanielNetzer/forgecell/lab/internal/ui"
"io"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
)

// uiSetupFakes replaces the browser, git identity and the setup engine seams,
// and records the Labs each setup launch was offered. No real git, gh or
// coding agent runs.
func uiSetupFakes(t *testing.T) func() [][]string {
t.Helper()
previousOpen := openBrowser
openBrowser = func(string) error { return nil }
t.Cleanup(func() { openBrowser = previousOpen })
previousName := gitIdentity
gitIdentity = func(context.Context, string, ...string) (string, error) { return "Test Operator\n", nil }
t.Cleanup(func() { gitIdentity = previousName })
var mu sync.Mutex
var offered [][]string
previousSetup := uiSetupOptions
uiSetupOptions = func(labs []string, _ io.Writer) *ui.SetupOptions {
mu.Lock()
defer mu.Unlock()
offered = append(offered, slices.Clone(labs))
return &ui.SetupOptions{}
}
t.Cleanup(func() { uiSetupOptions = previousSetup })
return func() [][]string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(offered)
}
}

// With no Lab for the checkout's repository, forgecell ui serves Set up your
// Lab instead of failing, and creates nothing until an engine call does.
func TestUISetupServesSetUpYourLabWhenNoLabExists(t *testing.T) {
home := labIdentityEnv(t)
identityFakeCheckout(t)
identityStubOrigin(t, "https://github.com/acme/widgets.git")
offered := uiSetupFakes(t)
labs := filepath.Join(home, ".forgecell", "labs")

_, stop := startUI(t, "--no-open")
code, output := stop()
if code != 0 || !strings.Contains(output, "Set up your Lab") || strings.Contains(output, "no Lab selected") {
t.Fatalf("ui with no Lab: %d %s", code, output)
}
if got := offered(); len(got) != 1 || len(got[0]) != 0 {
t.Fatalf("setup offered %q", got)
}
if _, err := os.Lstat(labs); !os.IsNotExist(err) {
t.Fatalf("forgecell ui wrote Lab storage before any engine call: %v", err)
}
}

// approvedLab is a Lab for repo whose Formula was proposed and approved through
// the engine's own proposal path.
func approvedLab(t *testing.T, dir, repo string) {
t.Helper()
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
recipe := "kind: formula\nid: widgets-github-issue\nname: " + repo + " issue workflow\nintake: {source: github-issues, repo: " + repo + "}\nharness: {binding: codex, command: [/cli, __adapter, codex, /codex]}\natoms:\n - {id: intake, type: intake, source: github-issues}\n - {id: scope, type: gate, purpose: scope, mode: human}\n - {id: harness, type: harness, binding: codex}\n - {id: check, type: check}\n - {id: gate, type: gate, purpose: review, mode: human}\n - {id: ship, type: ship, via: github}\n - {id: document, type: document, target: github-issue}\n"
p, err := onboarding.SaveProposal(dir, recipe)
if err != nil {
t.Fatal(err)
}
if _, err := onboarding.Review(dir, p.ID, "approve"); err != nil {
t.Fatal(err)
}
if ui.NeedsSetup(dir) {
t.Fatal("an approved Lab needs setup")
}
}

// With exactly one Lab for the repository, forgecell ui opens it as before.
func TestUISetupOpensTheOneMatchingLab(t *testing.T) {
home := labIdentityEnv(t)
identityFakeCheckout(t)
identityStubOrigin(t, "https://github.com/acme/widgets.git")
offered := uiSetupFakes(t)
lab := filepath.Join(home, ".forgecell", "labs", "acme--widgets")
approvedLab(t, lab, "acme/widgets")

_, stop := startUI(t, "--no-open")
code, output := stop()
if code != 0 || strings.Contains(output, "Set up your Lab") || strings.Contains(output, "Choose a Lab") || !strings.Contains(output, "Lab "+lab) {
t.Fatalf("ui with one Lab: %d %s", code, output)
}
if got := offered(); len(got) != 0 {
t.Fatalf("a ready Lab was served as setup: %q", got)
}
}

// The one Lab for the repository with no approved Formula opens in Set up your
// Lab for that Lab, with no chooser, so its Formula can be approved again.
func TestUISetupContinuesTheOneUnapprovedLab(t *testing.T) {
home := labIdentityEnv(t)
identityFakeCheckout(t)
identityStubOrigin(t, "https://github.com/acme/widgets.git")
offered := uiSetupFakes(t)
labs := filepath.Join(home, ".forgecell", "labs")
lab := filepath.Join(labs, "acme--widgets")
identityLab(t, lab, "acme/widgets")
before := identityTree(t, labs)

_, stop := startUI(t, "--no-open")
code, output := stop()
if code != 0 || !strings.Contains(output, "Set up your Lab "+lab) || strings.Contains(output, "Choose a Lab") {
t.Fatalf("ui with one unapproved Lab: %d %s", code, output)
}
if got := offered(); len(got) != 1 || len(got[0]) != 0 {
t.Fatalf("setup offered %q", got)
}
if identityTree(t, labs) != before {
t.Fatal("serving setup modified the Lab")
}
}

// With several Labs for the repository, forgecell ui offers a chooser where
// every other command still asks for --lab DIR; an explicit --lab wins.
func TestUISetupOffersAChooserForSeveralLabs(t *testing.T) {
home := labIdentityEnv(t)
identityFakeCheckout(t)
identityStubOrigin(t, "https://github.com/acme/widgets.git")
offered := uiSetupFakes(t)
labs := filepath.Join(home, ".forgecell", "labs")
first, second := filepath.Join(labs, "widgets-a"), filepath.Join(labs, "widgets-b")
identityLab(t, first, "acme/widgets")
identityLab(t, second, "Acme/Widgets")
identityLab(t, filepath.Join(labs, "other"), "acme/other")
before := identityTree(t, labs)

if got := uiLabChoices(context.Background(), ""); !slices.Equal(got, []string{first, second}) {
t.Fatalf("choices %q", got)
}
if got := uiLabChoices(context.Background(), "named"); got != nil {
t.Fatalf("a Lab name still offered a chooser: %q", got)
}
_, stop := startUI(t, "--no-open")
code, output := stop()
if code != 0 || !strings.Contains(output, "Choose a Lab: 2 Labs match") {
t.Fatalf("ui with several Labs: %d %s", code, output)
}
if got := offered(); len(got) != 1 || !slices.Equal(got[0], []string{first, second}) {
t.Fatalf("chooser offered %q", got)
}
if code, _, stderr := identityRun("doctor"); code != 1 || !strings.Contains(stderr, "--lab") {
t.Fatalf("doctor no longer asks for --lab: %d %s", code, stderr)
}

_, stop = startUI(t, "--no-open", "--lab", first)
if code, output := stop(); code != 0 || strings.Contains(output, "Choose a Lab") || !strings.Contains(output, "Lab "+first) {
t.Fatalf("explicit --lab: %d %s", code, output)
}
if identityTree(t, labs) != before {
t.Fatal("choosing among Labs modified them")
}

// FORGECELL_LAB chooses the Lab itself, so no chooser is offered.
t.Setenv("FORGECELL_LAB", first)
if got := uiLabChoices(context.Background(), ""); got != nil {
t.Fatalf("FORGECELL_LAB still offered a chooser: %q", got)
}
}

// A truncated Lab scan cannot prove which Labs match, so no chooser is
// offered and the resolution error stands.
func TestUISetupRefusesAChooserFromATruncatedScan(t *testing.T) {
home := labIdentityEnv(t)
identityFakeCheckout(t)
identityStubOrigin(t, "https://github.com/acme/widgets.git")
labs := filepath.Join(home, ".forgecell", "labs")
identityLab(t, filepath.Join(labs, "widgets-a"), "acme/widgets")
identityLab(t, filepath.Join(labs, "widgets-b"), "acme/widgets")
identityLab(t, filepath.Join(labs, "widgets-c"), "acme/widgets")
previous := maxLabEntries
maxLabEntries = 2
t.Cleanup(func() { maxLabEntries = previous })
if got := uiLabChoices(context.Background(), ""); got != nil {
t.Fatalf("a truncated scan offered %q", got)
}
}
Loading
Loading