diff --git a/lab/internal/cli/cli.go b/lab/internal/cli/cli.go index 6d75c20..9f35500 100644 --- a/lab/internal/cli/cli.go +++ b/lab/internal/cli/cli.go @@ -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 { @@ -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) } @@ -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" { diff --git a/lab/internal/cli/ui.go b/lab/internal/cli/ui.go index 6ff018b..bf8705a 100644 --- a/lab/internal/cli/ui.go +++ b/lab/internal/cli/ui.go @@ -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) @@ -140,10 +148,16 @@ 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. @@ -151,11 +165,24 @@ func runUI(ctx context.Context, args []string, defaultLab, version string, out, 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) } @@ -163,9 +190,16 @@ func runUI(ctx context.Context, args []string, defaultLab, version string, out, 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.") } diff --git a/lab/internal/cli/ui_setup.go b/lab/internal/cli/ui_setup.go new file mode 100644 index 0000000..3832669 --- /dev/null +++ b/lab/internal/cli/ui_setup.go @@ -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 +} diff --git a/lab/internal/cli/ui_setup_test.go b/lab/internal/cli/ui_setup_test.go new file mode 100644 index 0000000..2fa41dd --- /dev/null +++ b/lab/internal/cli/ui_setup_test.go @@ -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) + } +} diff --git a/lab/internal/ui/served_test.go b/lab/internal/ui/served_test.go index fc2bd82..13e3899 100644 --- a/lab/internal/ui/served_test.go +++ b/lab/internal/ui/served_test.go @@ -23,6 +23,7 @@ var servedModules = []string{ "/js/insights.js", "/js/formula.js", "/js/loop.js", + "/js/setup.js", } // servedStylesheets are the stylesheets index.html links, in link order: @@ -37,11 +38,12 @@ var servedStylesheets = []string{ "/css/insights.css", "/css/formula.css", "/css/loop.css", + "/css/setup.css", } // viewModules are the view modules; each exports mount(root, ctx) and // update(state), and imports only the core and its own sub-modules. -var viewModules = []string{"inbox", "molecule", "work", "issues", "insights", "formula", "loop"} +var viewModules = []string{"inbox", "molecule", "work", "issues", "insights", "formula", "loop", "setup"} // maxModuleLines bounds every script module and stylesheet. const maxModuleLines = 600 diff --git a/lab/internal/ui/server.go b/lab/internal/ui/server.go index 741123b..8d54728 100644 --- a/lab/internal/ui/server.go +++ b/lab/internal/ui/server.go @@ -3,7 +3,8 @@ // writes are the human gates in gates.go (scope approval and draft publication), // Request changes on a waiting plan (amend.go, which records a revision and never // approves it), the drive actions in drive.go (start intake, start or stop the -// loop, stop a Molecule this UI runs) and retiring one Molecule (retire.go), each +// loop, stop a Molecule this UI runs), retiring one Molecule (retire.go) and, +// in a checkout with no ready Lab, Set up your Lab (setup.go), each // a POST that needs the session cookie, the exact Origin and the per-tab session // header, and each performed by the engine, which validates digests, locks and // receipts. The one read that runs a process is a Molecule's diff size @@ -62,6 +63,9 @@ type Options struct { // Operator is the source checkout's git config user.name, as the CLI read it. // It is untrusted: GET /api/lab serves it only through operatorView. Operator string + // 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 } // Server is a UI listening on 127.0.0.1. @@ -89,6 +93,16 @@ func Listen(o Options) (*Server, error) { return nil, err } o.LabDir = abs + if o.Setup != nil { + setup := *o.Setup + setup.Labs = make([]string, len(o.Setup.Labs)) + for i, dir := range o.Setup.Labs { + if setup.Labs[i], err = filepath.Abs(dir); err != nil { + return nil, err + } + } + o.Setup = &setup + } } token, err := newToken() if err != nil { @@ -197,6 +211,8 @@ type handler struct { // forge is the read-only GitHub sync behind /api/issues and /api/sync; tests // replace its fetcher and clock. forge *issueSync + // setup is this launch's Set up your Lab progress, or nil (setup.go). + setup *setupState } // staticFiles are the only embedded files served, with their content types. @@ -219,6 +235,7 @@ var staticFiles = map[string]string{ "/js/insights.js": "js/insights.js", "/js/formula.js": "js/formula.js", "/js/loop.js": "js/loop.js", + "/js/setup.js": "js/setup.js", // The stylesheets index.html links, in its order: tokens, base, then views. "/css/tokens.css": "css/tokens.css", "/css/base.css": "css/base.css", @@ -229,6 +246,7 @@ var staticFiles = map[string]string{ "/css/insights.css": "css/insights.css", "/css/formula.css": "css/formula.css", "/css/loop.css": "css/loop.css", + "/css/setup.css": "css/setup.css", // session.js only copies the exchange page's value into sessionStorage; it // holds no secret, so it is served without the cookie. "/session.js": "session.js", @@ -303,7 +321,7 @@ func NewHandler(o Options, token, host string) (http.Handler, error) { if err != nil { return nil, err } - return &handler{opts: o, token: token, exchanged: make(chan struct{}), session: session, tab: tab, host: host, origin: "http://" + host, cookie: "forgecell_ui_" + port, static: static, records: molecule.ReadRecord, receipts: delivery.ReadReceiptStatus, cache: newListCache(), runScope: molecule.Run, preview: delivery.Preview, publish: delivery.Publish, jobs: newGateJobs(), runIssue: molecule.RunIntake, runLoop: molecule.RunLoop, loop: &driveLoop{}, retire: molecule.Retire, listIssues: intake.ReadSelectedOpenIssues, amend: molecule.AmendFromLatest, numstat: molecule.DiffNumstat, diffs: newDiffCache(), forge: newIssueSync(nil, nil)}, nil + return &handler{opts: o, token: token, exchanged: make(chan struct{}), session: session, tab: tab, host: host, origin: "http://" + host, cookie: "forgecell_ui_" + port, static: static, records: molecule.ReadRecord, receipts: delivery.ReadReceiptStatus, cache: newListCache(), runScope: molecule.Run, preview: delivery.Preview, publish: delivery.Publish, jobs: newGateJobs(), runIssue: molecule.RunIntake, runLoop: molecule.RunLoop, loop: &driveLoop{}, retire: molecule.Retire, listIssues: intake.ReadSelectedOpenIssues, amend: molecule.AmendFromLatest, numstat: molecule.DiffNumstat, diffs: newDiffCache(), forge: newIssueSync(nil, nil), setup: newSetupState(o.Setup, o.LabDir)}, nil } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -333,16 +351,18 @@ 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 and drive routes. + // GET serves every read; POST is served only on the gate, drive and setup + // routes. gateID, gateAction, gate := gateRoute(r.URL.Path) driveID, driveAction, drive := driveRoute(r.URL.Path) - if r.Method != http.MethodGet && (r.Method != http.MethodPost || !(gate || drive)) { - if gate || drive { + setupAction, setup := setupRoute(r.URL.Path) + if r.Method != http.MethodGet && (r.Method != http.MethodPost || !(gate || drive || setup)) { + if gate || drive || setup { 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 and drive POST routes") + h.refuse(w, http.StatusMethodNotAllowed, "only GET is served here; the only writes are the gate, drive and setup POST routes") return } if launch { @@ -361,11 +381,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.refuse(w, http.StatusForbidden, "this tab has no Lab UI session; open a new launch link from forgecell ui") return } + // Until the Lab is set up, only setup and the static files are served. + if h.setupActive() && strings.HasPrefix(r.URL.Path, "/api/") && !setup && r.URL.Path != "/api/setup" { + h.refuseWith(w, http.StatusConflict, "setup", "set up your Lab first; nothing was read or written", "Finish Set up your Lab") + return + } if r.Method == http.MethodPost { if !h.writeAllowed(r) { h.refuse(w, http.StatusForbidden, "a write needs this server's exact Origin from the same site; nothing was written") return } + if setup { + h.setupPost(w, r, setupAction) + return + } if drive { h.drive(w, r, driveID, driveAction) return @@ -377,6 +406,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } switch path := r.URL.Path; { + case path == "/api/setup": + h.setupStatus(w) case path == "/api/lab": h.lab(w) case path == "/api/loop": diff --git a/lab/internal/ui/setup.go b/lab/internal/ui/setup.go new file mode 100644 index 0000000..262093e --- /dev/null +++ b/lab/internal/ui/setup.go @@ -0,0 +1,772 @@ +package ui + +import ( + "context" + "errors" + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/onboarding" + "github.com/DanielNetzer/forgecell/lab/internal/resources" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Set up your Lab (#263): forgecell ui started in a checkout with no ready Lab +// serves this view instead of failing. Each step is the engine path the CLI +// uses, never a file the UI writes itself, behind the same write protection as +// the gates (cookie, tab header, exact Origin, Fetch Metadata and one strict +// JSON object): +// +// GET /api/setup → the step, read from the Lab's own state +// POST /api/setup/check {} → onboarding.ReadRepository and Discover (read-only) +// POST /api/setup/choose {"lab"} → one of the matching Labs, by index +// POST /api/setup/create {"harness"} → onboarding.Prepare, as forgecell init --harness ID +// POST /api/setup/approve {"proposal","digest"} → onboarding.Review approve, as init --approve +// POST /api/setup/review {} → onboarding.Prepare EnableReview, a second proposal +// +// One setup action runs at a time; a second submit is refused and nothing is +// resubmitted. Approval binds the shown proposal: its id and YAML digest must +// still be the pending proposal's, or nothing is approved. Engine error text, +// which can name local paths, is never served. Until the Lab has an approved +// Formula every other API route is refused. + +// SetupOptions put a UI launch in setup mode. The functions are the engine +// calls; forgecell ui passes the ones forgecell init uses and tests replace them. +type SetupOptions struct { + // Labs are the existing Lab directories that match the checkout's repository + // when there is more than one; the operator chooses one first. Otherwise + // LabDir is the Lab to set up. + Labs []string + // Launcher and Env are what forgecell init passes to Prepare. + Launcher string + Env map[string]string + // ReadRepository and Discover are read-only: git and gh's own repository + // read, and the coding agent probes forgecell doctor runs. + ReadRepository func(ctx context.Context, cwd string) (onboarding.Repository, error) + Discover func(ctx context.Context, cwd string) []onboarding.Candidate + // Prepare and Review are forgecell init's proposal and decision paths. + Prepare func(context.Context, onboarding.InitOptions) (onboarding.Outcome, error) + Review func(dir, id, decision string) (onboarding.Proposal, error) + // Resources opens the Lab's resource guard once the Lab is ready, so nothing + // is created in a Lab directory before the engine has made it. + Resources func(lab string) (*resources.Guard, error) +} + +const ( + // setupTimeout bounds one setup engine call; it outlives a lost response, + // and the page reads the state back instead of resubmitting. + setupTimeout = 10 * time.Minute + // maxSetupProposals bounds the proposals read from assays/; more fails closed. + maxSetupProposals = 256 + // maxSetupItems bounds the Atoms, checks and gates one proposal view carries. + maxSetupItems = 64 + // maxSetupLabs bounds the Lab chooser. + maxSetupLabs = 32 +) + +// SetupHarnesses are the coding agents setup offers, in display order. +var SetupHarnesses = []string{"claude-code", "codex", "cursor"} + +var setupHarnessNames = map[string]string{"claude-code": "Claude Code", "codex": "Codex", "cursor": "Cursor"} + +var ( + assayID = regexp.MustCompile(`^assay-[a-f0-9]{64}$`) + setupDigest = regexp.MustCompile(`^[a-f0-9]{64}$`) + defaultBranch = regexp.MustCompile(`^[A-Za-z0-9._/-]{1,200}$`) + readinessWord = regexp.MustCompile(`^[a-z-]{1,32}$`) +) + +// errSetupLab is a Lab whose proposals or Formula cannot be read safely. +var errSetupLab = errors.New("the Lab's proposals cannot be read") + +// setupState is one launch's setup progress. busy admits one action at a time; +// ready is set, once, when the Lab has an approved Formula and its resource +// guard is open, and from then on every route is served. +type setupState struct { + o SetupOptions + busy atomic.Bool + ready atomic.Bool + // mu guards the fields below. + mu sync.Mutex + lab string + chosen bool + checked bool + repository SetupRepository + candidates []onboarding.Candidate + // agentCwd is where Check probed the coding agents; Create probes there again. + agentCwd string +} + +func newSetupState(o *SetupOptions, lab string) *setupState { + if o == nil { + return nil + } + s := &setupState{o: *o, lab: lab} + s.o.Labs = slices.Clone(o.Labs) + if len(s.o.Labs) > maxSetupLabs { + s.o.Labs = s.o.Labs[:maxSetupLabs] + } + return s +} + +// choosing reports a Lab chooser still waiting for the operator. +func (s *setupState) choosing() bool { return len(s.o.Labs) > 1 && !s.chosen } + +// setupActive reports that this launch is still setting up its Lab. +func (h *handler) setupActive() bool { return h.setup != nil && !h.setup.ready.Load() } + +// setupRoute reports a POST setup path and its action. +func setupRoute(path string) (string, bool) { + action, ok := strings.CutPrefix(path, "/api/setup/") + switch action { + case "check", "choose", "create", "approve", "review": + return action, ok + } + return "", false +} + +// SetupView is GET /api/setup and every accepted setup response. +type SetupView struct { + // Active is false when this launch has a ready Lab or serves sample data. + Active bool `json:"active"` + // Step is choose, repository, agent, review, done or problem. + Step string `json:"step"` + Busy bool `json:"busy"` + Lab string `json:"lab,omitempty"` + Labs []SetupLab `json:"labs,omitempty"` + Checked bool `json:"checked"` + Repository *SetupRepository `json:"repository,omitempty"` + Agents []SetupAgent `json:"agents,omitempty"` + Proposal *SetupProposal `json:"proposal,omitempty"` + // ReviewAtom is present, available or unavailable once the Formula is approved. + ReviewAtom string `json:"reviewAtom,omitempty"` + Problem string `json:"problem,omitempty"` +} + +// SetupLab is one Lab in the chooser: its directory name, never its path. +type SetupLab struct { + Index int `json:"index"` + Name string `json:"name"` + Formula string `json:"formula,omitempty"` + Binding string `json:"binding,omitempty"` +} + +// SetupRepository is the repository step in plain words. +type SetupRepository struct { + OK bool `json:"ok"` + Repo string `json:"repo,omitempty"` + DefaultBranch string `json:"defaultBranch,omitempty"` + // GitHub is signed-in, sign-in-needed or unknown. + GitHub string `json:"github"` + Message string `json:"message"` +} + +// SetupAgent is one coding agent as the doctor probes found it. The probes +// record no provider version or model, so none is shown; the model is pinned +// later, on the approved Formula, with forgecell init --model. +type SetupAgent struct { + ID string `json:"id"` + Name string `json:"name"` + Ready bool `json:"ready"` + Readiness string `json:"readiness"` + Installed string `json:"installed"` + SignIn string `json:"signIn"` + Detail string `json:"detail"` +} + +// SetupProposal is the waiting Formula proposal in plain words. ID and Digest +// are what an approval must send back. +type SetupProposal struct { + ID string `json:"id"` + Digest string `json:"digest"` + // Kind is bootstrap, enable-review or change. + Kind string `json:"kind"` + // Resume marks an approval that started and must be finished. + Resume bool `json:"resume"` + Formula string `json:"formula"` + Binding string `json:"binding"` + Atoms []SetupAtom `json:"atoms"` + Checks []SetupCheck `json:"checks"` + Gates []SetupGate `json:"gates"` + Truncated bool `json:"truncated,omitempty"` +} + +type SetupAtom struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` +} + +type SetupCheck struct { + ID string `json:"id"` + Command string `json:"command"` + Dir string `json:"dir,omitempty"` + Workflow string `json:"workflow,omitempty"` +} + +type SetupGate struct { + Purpose string `json:"purpose"` + Mode string `json:"mode"` +} + +// setupLab is a Lab's setup state, read from its own files. +type setupLab struct { + approved bool + formula formula.Formula + pending *onboarding.Proposal + incomplete bool +} + +// readSetupLab finds the proposal waiting for a decision, an interrupted +// activation first, as forgecell init does, and whether the active Formula has +// a current approval. It never writes. +func readSetupLab(dir string) (setupLab, error) { + var st setupLab + names, err := proposalNames(filepath.Join(dir, "assays")) + if err != nil { + return st, err + } + var pending, recovery *onboarding.Proposal + for _, name := range names { + id := strings.TrimSuffix(name, ".json") + if name == id || !assayID.MatchString(id) { + continue + } + p, err := onboarding.InspectProposal(dir, id) + if err != nil { + return st, errSetupLab + } + incomplete, err := onboarding.ProposalActivationIncomplete(dir, p) + if err != nil { + return st, errSetupLab + } + if incomplete { + saved := p + recovery = &saved + } + if p.Status == "pending" && pending == nil { + saved := p + pending = &saved + } + } + st.pending, st.incomplete = pending, recovery != nil + if recovery != nil { + st.pending = recovery + } + if loaded, err := formula.Load(dir, ""); err == nil && loaded.Approved { + st.approved, st.formula = true, loaded.Formula + } + return st, nil +} + +// NeedsSetup reports whether forgecell ui should serve Set up your Lab for dir: +// whenever it has no approved active Formula. That is no lab.json yet, a +// proposal or an interrupted activation waiting, a lab.json with nothing +// waiting (Create proposes the Formula to re-approve), or proposals that cannot +// be read safely, which the problem step explains. Only a Lab with an approved +// Formula opens as before. It never writes. +func NeedsSetup(dir string) bool { + loaded, err := formula.Load(dir, "") + return err != nil || !loaded.Approved +} + +// proposalNames lists assays/ without following a link, sorted; a missing +// directory is none, and more than maxSetupProposals entries fails closed. +func proposalNames(dir string) ([]string, error) { + info, err := os.Lstat(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil || !info.IsDir() { + return nil, errSetupLab + } + f, err := os.Open(dir) + if err != nil { + return nil, errSetupLab + } + defer f.Close() + names, err := f.Readdirnames(maxSetupProposals + 1) + if err != nil && err != io.EOF { + return nil, errSetupLab + } + if len(names) > maxSetupProposals { + return nil, errSetupLab + } + sort.Strings(names) + return names, nil +} + +// setupStatus serves GET /api/setup. +func (h *handler) setupStatus(w http.ResponseWriter) { + if h.setup == nil || h.opts.Demo { + h.json(w, http.StatusOK, SetupView{Step: "none"}) + return + } + h.json(w, http.StatusOK, h.setupView()) +} + +// setupView derives the step from the Lab's real state, so a reload resumes +// where the Lab is. Once the Formula is approved it opens the resource guard +// and marks the Lab ready. +func (h *handler) setupView() SetupView { + s := h.setup + s.mu.Lock() + defer s.mu.Unlock() + v := SetupView{Active: !s.ready.Load(), Busy: s.busy.Load(), Checked: s.checked} + if s.choosing() { + v.Step = "choose" + for i, dir := range s.o.Labs { + lab := SetupLab{Index: i, Name: setupText(filepath.Base(dir))} + if active, err := formula.Inspect(dir, ""); err == nil { + lab.Formula, lab.Binding = setupText(active.Formula.ID), setupText(active.Formula.Harness.Binding) + } + v.Labs = append(v.Labs, lab) + } + return v + } + v.Lab = setupText(filepath.Base(s.lab)) + if s.checked { + repository := s.repository + v.Repository = &repository + v.Agents = agentViews(s.candidates) + } + st, err := readSetupLab(s.lab) + switch { + case err != nil: + v.Step, v.Problem = "problem", "The Lab's proposals could not be read safely: one is invalid, linked, oversized or changed. Run forgecell init --review to inspect it; nothing was changed." + case st.pending != nil: + v.Step, v.Proposal = "review", proposalView(*st.pending, st.incomplete) + case st.approved: + v.Step = "done" + v.ReviewAtom = "available" + if _, ok, err := st.formula.ReviewAtom(); err != nil { + v.ReviewAtom = "unavailable" + } else if ok { + v.ReviewAtom = "present" + } + if !s.ready.Load() && !h.finishSetup() { + // Every read retries the guard, so a reload is enough once its cause is gone. + v.Problem = "Your Lab is set up, but its resource guard could not start. Reload to try again, or restart forgecell ui to see why." + } + v.Active = !s.ready.Load() + case !s.checked || !s.repository.OK: + v.Step = "repository" + default: + v.Step = "agent" + } + return v +} + +// finishSetup opens the resource guard and serves the Lab, with s.mu held. +// The Lab directory and guard are written before ready is set, and every other +// route reads them only after reading ready. +func (h *handler) finishSetup() bool { + s := h.setup + var guard *resources.Guard + if s.o.Resources != nil { + g, err := s.o.Resources(s.lab) + if err != nil { + return false + } + guard = g + } + h.opts.LabDir, h.opts.Resources = s.lab, guard + s.ready.Store(true) + return true +} + +type chooseRequest struct { + Lab int `json:"lab"` +} + +type createRequest struct { + Harness string `json:"harness"` +} + +type approveRequest struct { + Proposal string `json:"proposal"` + Digest string `json:"digest"` +} + +// setupPost serves one POST setup route after the write protection has passed. +func (h *handler) setupPost(w http.ResponseWriter, r *http.Request, action string) { + s := h.setup + if s == nil || h.opts.Demo { + h.refuseGate(w, http.StatusNotFound, "setup", "this Lab UI is not setting up a Lab; nothing was written") + return + } + var choose chooseRequest + var create createRequest + var approve approveRequest + var body any = &emptyRequest{} + switch action { + case "choose": + body = &choose + case "create": + body = &create + case "approve": + body = &approve + } + 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 !s.busy.CompareAndSwap(false, true) { + h.refuseWith(w, http.StatusConflict, "busy", "another setup step is still running; nothing was resubmitted", "Wait for it to finish; the page reads the result back") + return + } + ok := func() bool { + defer s.busy.Store(false) + // The engine call outlives a lost response; the page reads the state back. + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), setupTimeout) + defer cancel() + switch action { + case "check": + return h.setupCheck(ctx, w) + case "choose": + return h.setupChoose(w, choose) + case "create": + return h.setupCreate(ctx, w, create) + case "approve": + return h.setupApprove(w, approve) + } + return h.setupReview(ctx, w) + }() + if ok { + h.json(w, http.StatusOK, h.setupView()) + } +} + +// setupLabDir is the Lab setup acts on, or a refusal while the chooser waits. +func (h *handler) setupLabDir(w http.ResponseWriter) (string, bool) { + s := h.setup + s.mu.Lock() + defer s.mu.Unlock() + if s.choosing() { + h.refuseWith(w, http.StatusConflict, "choose", "choose a Lab first; nothing was written", "Choose one of the Labs for this repository") + return "", false + } + return s.lab, true +} + +func (h *handler) setupCheck(ctx context.Context, w http.ResponseWriter) bool { + s := h.setup + root := h.opts.SourceRoot + if root == "" || s.o.ReadRepository == nil || s.o.Discover == nil { + h.refuseWith(w, http.StatusConflict, "source", "this Lab UI has no source checkout; nothing was read", "Run forgecell ui inside the repository's checkout") + return false + } + repo, err := s.o.ReadRepository(ctx, root) + view := repositoryView(repo, err) + cwd := root + if err == nil && filepath.IsAbs(repo.Root) { + cwd = repo.Root + } + candidates := s.o.Discover(ctx, cwd) + s.mu.Lock() + s.checked, s.repository, s.candidates, s.agentCwd = true, view, slices.Clone(candidates), cwd + s.mu.Unlock() + return true +} + +func (h *handler) setupChoose(w http.ResponseWriter, req chooseRequest) bool { + s := h.setup + s.mu.Lock() + defer s.mu.Unlock() + if !s.choosing() { + h.refuseWith(w, http.StatusConflict, "chosen", "a Lab is already chosen; nothing was changed", "Restart forgecell ui to choose another Lab") + return false + } + if req.Lab < 0 || req.Lab >= len(s.o.Labs) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "choose one of the listed Labs") + return false + } + // The Lab is read and written from here on, so it must still be a real + // directory, never a link swapped in since the scan. + dir := s.o.Labs[req.Lab] + if info, err := os.Lstat(dir); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + h.refuseWith(w, http.StatusConflict, "lab", "that Lab is no longer a plain directory; nothing was chosen", "Restart forgecell ui to list the Labs again") + return false + } + s.lab, s.chosen = dir, true + return true +} + +func (h *handler) setupCreate(ctx context.Context, w http.ResponseWriter, req createRequest) bool { + s := h.setup + if !slices.Contains(SetupHarnesses, req.Harness) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "choose claude-code, codex or cursor") + return false + } + lab, ok := h.setupLabDir(w) + if !ok { + return false + } + st, err := readSetupLab(lab) + switch { + case err != nil: + h.refuseWith(w, http.StatusUnprocessableEntity, "lab", "the Lab's proposals could not be read safely; nothing was written", "Run forgecell init --review to inspect them") + return false + case st.pending != nil: + // The Lab is already created and its proposal waits: continue, never + // propose a second one. + return true + case st.approved: + h.refuseWith(w, http.StatusConflict, "done", "this Lab already has an approved Formula; nothing was written", "Go to Issues") + return false + } + s.mu.Lock() + checked, repository, cwd := s.checked, s.repository, s.agentCwd + s.mu.Unlock() + if !checked || !repository.OK { + h.refuseWith(w, http.StatusConflict, "repository", "the repository has not been read; nothing was written", "Check Again on the repository step") + return false + } + if s.o.Prepare == nil || s.o.Discover == nil || s.o.Launcher == "" { + h.refuseGate(w, http.StatusConflict, "refused", "this Lab UI cannot create a Lab; nothing was written") + return false + } + // The probes shown at Check may be stale: an agent can sign out since. As + // forgecell init does, Create probes again and proposes only from this run, + // and the page shows the fresh probes whatever the outcome. + candidates := s.o.Discover(ctx, cwd) + s.mu.Lock() + s.candidates = slices.Clone(candidates) + s.mu.Unlock() + i := slices.IndexFunc(candidates, func(c onboarding.Candidate) bool { return c.ID == req.Harness }) + if i < 0 || candidates[i].Readiness != "ready" { + h.refuseWith(w, http.StatusConflict, "agent", "that coding agent is not ready now; nothing was written", "Sign in with its own CLI, then Check Again") + return false + } + env := map[string]string{} + for k, v := range s.o.Env { + env[k] = v + } + out, err := s.o.Prepare(ctx, onboarding.InitOptions{Cwd: h.opts.SourceRoot, LabDir: lab, Launcher: s.o.Launcher, Preferred: req.Harness, Env: env, Candidates: candidates}) + if err != nil { + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "the engine did not create the Lab; nothing else was attempted", setupReason(err)) + return false + } + if out.Status != "pending" || out.Proposal == nil { + h.refuseWith(w, http.StatusConflict, "agent", "the engine proposed no Formula for that coding agent; nothing else was attempted", "Check Again, then choose a ready coding agent") + return false + } + return true +} + +func (h *handler) setupApprove(w http.ResponseWriter, req approveRequest) bool { + s := h.setup + if !assayID.MatchString(req.Proposal) || !setupDigest.MatchString(req.Digest) { + h.refuseGate(w, http.StatusBadRequest, "invalid", "send the shown proposal and its digest") + return false + } + lab, ok := h.setupLabDir(w) + if !ok { + return false + } + st, err := readSetupLab(lab) + if err != nil { + h.refuseWith(w, http.StatusUnprocessableEntity, "lab", "the Lab's proposals could not be read safely; nothing was approved", "Run forgecell init --review to inspect them") + return false + } + // The approval binds exactly what was shown: the same proposal with the same + // YAML bytes, still waiting. Anything else approves nothing. + if st.pending == nil || st.pending.ID != req.Proposal || st.pending.YAMLHash != req.Digest { + h.refuseWith(w, http.StatusConflict, "stale", "the proposal changed since it was shown; nothing was approved", "Review the current proposal, then approve it") + return false + } + if s.o.Review == nil { + h.refuseGate(w, http.StatusConflict, "refused", "this Lab UI cannot approve a Formula; nothing was approved") + return false + } + if _, err := s.o.Review(lab, req.Proposal, "approve"); err != nil { + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "the engine did not approve the Formula; nothing else was attempted", setupReason(err)) + return false + } + return true +} + +func (h *handler) setupReview(ctx context.Context, w http.ResponseWriter) bool { + s := h.setup + lab, ok := h.setupLabDir(w) + if !ok { + return false + } + st, err := readSetupLab(lab) + switch { + case err != nil: + h.refuseWith(w, http.StatusUnprocessableEntity, "lab", "the Lab's proposals could not be read safely; nothing was written", "Run forgecell init --review to inspect them") + return false + case st.pending != nil: + h.refuseWith(w, http.StatusConflict, "pending", "a proposal is already waiting; nothing was written", "Approve or review the waiting proposal first") + return false + case !st.approved: + h.refuseWith(w, http.StatusConflict, "approve", "the review Atom is added to an approved Formula; nothing was written", "Approve the first Formula first") + return false + } + if _, present, err := st.formula.ReviewAtom(); err != nil || present { + h.refuseWith(w, http.StatusConflict, "present", "this Formula already has a review Atom or cannot take one; nothing was written", "Go to Issues") + return false + } + if s.o.Prepare == nil || h.opts.SourceRoot == "" { + h.refuseGate(w, http.StatusConflict, "refused", "this Lab UI cannot propose a Formula change; nothing was written") + return false + } + if _, err := s.o.Prepare(ctx, onboarding.InitOptions{Cwd: h.opts.SourceRoot, LabDir: lab, EnableReview: true}); err != nil { + h.refuseWith(w, http.StatusUnprocessableEntity, "refused", "the engine did not propose the review Atom; nothing else was attempted", setupReason(err)) + return false + } + return true +} + +// setupReasons map engine error text to a fixed next step; the text itself is +// never served. +var setupReasons = []struct{ part, reason string }{ + {"another Formula write is active", "Another Formula change is in progress; wait for it, then reload"}, + {"stale approval refused", "The Lab changed since this proposal; Check Again to propose a new one"}, + {"belongs to another repository", "This Lab belongs to another repository; run forgecell ui with --lab-name NAME for a separate Lab"}, + {"repository discovery failed for gh", "Run gh auth login, then Check Again"}, + {"launcher are required", "Install Forgecell so its launcher is known, then Check Again"}, +} + +func setupReason(err error) string { + text := err.Error() + for _, r := range setupReasons { + if strings.Contains(text, r.part) { + return r.reason + } + } + return "Run forgecell init in this checkout to see the full error" +} + +// repositoryView puts the repository read in plain words. Error text is +// classified, never served. +func repositoryView(repo onboarding.Repository, err error) SetupRepository { + if err == nil { + name := repository(repo.Repo) + if name == "" { + return SetupRepository{GitHub: "unknown", Message: "GitHub returned a repository name Forgecell does not accept. Check the origin remote, then Check Again."} + } + branch := repo.DefaultBranch + if !defaultBranch.MatchString(branch) { + branch = "" + } + return SetupRepository{OK: true, Repo: name, DefaultBranch: branch, GitHub: "signed-in", Message: "Signed in: the GitHub CLI can read " + name + "."} + } + text := err.Error() + switch { + case strings.HasPrefix(text, "repository discovery failed for gh"): + return SetupRepository{GitHub: "sign-in-needed", Message: "The GitHub CLI could not read this repository. Run gh auth login, then Check Again."} + case strings.HasPrefix(text, "repository discovery failed for git"): + return SetupRepository{GitHub: "unknown", Message: "This is not a git checkout with an origin remote. Run forgecell ui inside your repository's checkout, then Check Again."} + case strings.Contains(text, "GitHub repository"): + return SetupRepository{GitHub: "unknown", Message: "The origin remote is not a GitHub repository this GitHub CLI can read. Check the origin remote, then Check Again."} + } + return SetupRepository{GitHub: "unknown", Message: "The repository could not be read. Run forgecell init in this checkout to see why, then Check Again."} +} + +// agentViews lists every offered coding agent, ready or not, so an unavailable +// one is explained rather than hidden. Detail is redacted and bounded. +func agentViews(candidates []onboarding.Candidate) []SetupAgent { + var out []SetupAgent + for _, id := range SetupHarnesses { + a := SetupAgent{ID: id, Name: setupHarnessNames[id], Readiness: "missing", Installed: "missing", SignIn: "unknown", Detail: "Not found on this machine. Install it, sign in with its own CLI, then Check Again."} + if i := slices.IndexFunc(candidates, func(c onboarding.Candidate) bool { return c.ID == id }); i >= 0 { + c := candidates[i].CompleteUnknowns() + a.Ready = c.Readiness == "ready" + a.Readiness = setupWord(c.Readiness) + a.Installed = setupWord(c.Installed.State) + a.SignIn = setupWord(c.Authentication.State) + a.Detail = redactor{}.reason(c.Detail) + if a.Detail == "" { + a.Detail = "No detail was recorded." + } + } + out = append(out, a) + } + return out +} + +func setupWord(s string) string { + if readinessWord.MatchString(s) { + return s + } + return "unknown" +} + +// setupText is one bounded line with local paths and secrets removed. +func setupText(s string) string { return redactor{}.reason(s) } + +// proposalView shows a proposal in plain words: its Atoms in order, the CI +// baseline checks and the gate policy, each bounded; Truncated says so. +func proposalView(p onboarding.Proposal, resume bool) *SetupProposal { + v := &SetupProposal{ID: p.ID, Digest: p.YAMLHash, Kind: "change", Resume: resume, Atoms: []SetupAtom{}, Checks: []SetupCheck{}, Gates: []SetupGate{}} + f, err := formula.Parse([]byte(p.YAML)) + if err != nil { + v.Truncated = true + return v + } + _, review, _ := f.ReviewAtom() + switch { + case p.ActiveIDBefore == "": + v.Kind = "bootstrap" + case review: + v.Kind = "enable-review" + } + v.Formula, v.Binding = setupText(f.Name), setupText(f.Harness.Binding) + for i, a := range f.Atoms { + if i == maxSetupItems { + v.Truncated = true + break + } + v.Atoms = append(v.Atoms, SetupAtom{ID: setupText(a.ID), Type: setupText(a.Type), Label: atomLabel(a)}) + if a.Type == "gate" && len(v.Gates) < maxSetupItems { + mode := "human" + if policy, ok := a.DeclaresPolicy(); ok { + mode = setupText(policy) + } + v.Gates = append(v.Gates, SetupGate{Purpose: setupText(a.Purpose), Mode: mode}) + } + } + for i, c := range f.RequiredChecks { + if i == maxSetupItems { + v.Truncated = true + break + } + v.Checks = append(v.Checks, SetupCheck{ID: setupText(c.ID), Command: setupText(strings.Join(c.Argv, " ")), Dir: setupText(c.Dir), Workflow: setupText(c.Workflow)}) + } + return v +} + +// atomLabel says what an Atom does, in plain words. +func atomLabel(a formula.Atom) string { + switch { + case a.Type == "intake": + return "Read the Issue" + case a.Type == "gate" && a.Purpose == "scope": + return "You approve the plan" + case a.Type == "harness": + return "The coding agent writes the change" + case a.Type == "check": + return "Checks run in a fresh checkout" + case a.Type == "review": + return "A second agent reviews the change" + case a.Type == "gate" && a.Purpose == "review": + return "You review before a draft PR" + case a.Type == "ship": + return "Publish a draft PR" + case a.Type == "document": + return "Record the outcome on the Issue" + case a.Type == "learn": + return "Learn from finished runs" + } + return "A " + setupText(a.Type) + " Atom" +} diff --git a/lab/internal/ui/setup_test.go b/lab/internal/ui/setup_test.go new file mode 100644 index 0000000..db49280 --- /dev/null +++ b/lab/internal/ui/setup_test.go @@ -0,0 +1,807 @@ +package ui + +import ( + "context" + "encoding/json" + "errors" + "github.com/DanielNetzer/forgecell/lab/internal/harness" + "github.com/DanielNetzer/forgecell/lab/internal/onboarding" + "github.com/DanielNetzer/forgecell/lab/internal/resources" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// setupFormula is the bootstrap Formula the fake Prepare proposes, and +// setupReviewFormula the same Formula with the review Atom inserted. +const setupFormula = `kind: formula +id: widgets-github-issue +name: acme/widgets issue workflow +intake: {source: github-issues, repo: acme/widgets} +harness: {binding: claude-code, command: [/cli, __adapter, claude-code, /claude]} +atoms: + - {id: intake, type: intake, source: github-issues} + - {id: scope, type: gate, purpose: scope, mode: human} + - {id: harness, type: harness, binding: claude-code} + - {id: check, type: check} + - {id: gate, type: gate, purpose: review, mode: human} + - {id: ship, type: ship, via: github} + - {id: document, type: document, target: github-issue} +requiredChecks: + - {id: go-test, argv: [go, test, ./...], dir: lab, timeoutMs: 600000, workflow: .github/workflows/lab.yml, reason: CI runs it on pull requests} +` + +var setupReviewFormula = strings.NewReplacer( + " - {id: gate,", " - {id: review, type: review}\n - {id: gate,", + "atoms:\n", "harnesses:\n review: {binding: codex, command: [/cli, __adapter, codex, /codex]}\natoms:\n", +).Replace(setupFormula) + +// localLeak is a local path the fakes put in errors and probe detail; no +// response may carry it. +const localLeak = "/Users/someone/private" + +// fakeSetup holds the engine seams. Every Lab write happens inside Prepare or +// Review: each seam checks on entry that the Lab is exactly as the last seam +// left it, so a write by anything else fails the test. +type fakeSetup struct { + t *testing.T + lab string + mu sync.Mutex + // last is the Lab snapshot the last seam left. + last string + repoErr, prepareErr, reviewErr error + prepares, reviews, reads, discovers int + options []onboarding.InitOptions + resources []string + entered, release chan struct{} + candidates []onboarding.Candidate +} + +func newFakeSetup(t *testing.T, lab string) *fakeSetup { + f := &fakeSetup{t: t, lab: lab, last: snapshot(t, lab)} + f.candidates = []onboarding.Candidate{ + {ID: "claude-code", Executable: localLeak + "/bin/claude", Readiness: "ready", Detail: "CLI capabilities and authentication verified.", Installed: harness.Capability{State: "verified"}, Authentication: harness.Capability{State: "verified"}}, + {ID: "codex", Executable: localLeak + "/bin/codex", Readiness: "login-required", Detail: "Probe step auth failed at " + localLeak + "/bin/codex — provider reports it is not logged in.", Installed: harness.Capability{State: "verified"}, Authentication: harness.Capability{State: "login-required"}}, + } + return f +} + +func (f *fakeSetup) unchanged(where string) { + f.t.Helper() + if now := snapshot(f.t, f.lab); now != f.last { + f.t.Errorf("%s: the Lab changed outside an engine call:\n%s\n---\n%s", where, f.last, now) + } +} + +func (f *fakeSetup) options_() *SetupOptions { + return &SetupOptions{ + Launcher: "/cli", + Env: map[string]string{"CLAUDECODE": ""}, + ReadRepository: func(context.Context, string) (onboarding.Repository, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.reads++ + if f.repoErr != nil { + return onboarding.Repository{}, f.repoErr + } + return onboarding.Repository{Root: "/src/checkout", Repo: "acme/widgets", DefaultBranch: "main"}, nil + }, + Discover: func(context.Context, string) []onboarding.Candidate { + f.mu.Lock() + defer f.mu.Unlock() + f.discovers++ + return f.candidates + }, + Prepare: func(_ context.Context, o onboarding.InitOptions) (onboarding.Outcome, error) { + if f.entered != nil { + f.entered <- struct{}{} + <-f.release + } + f.mu.Lock() + defer f.mu.Unlock() + f.unchanged("before Prepare") + defer func() { f.last = snapshot(f.t, f.lab) }() + f.prepares++ + f.options = append(f.options, o) + if f.prepareErr != nil { + return onboarding.Outcome{}, f.prepareErr + } + yaml := setupFormula + if o.EnableReview { + yaml = setupReviewFormula + } + p, err := onboarding.SaveProposal(o.LabDir, yaml) + if err != nil { + f.t.Fatal(err) + } + return onboarding.Outcome{Status: "pending", Proposal: &p}, nil + }, + Review: func(dir, id, decision string) (onboarding.Proposal, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.unchanged("before Review") + defer func() { f.last = snapshot(f.t, f.lab) }() + f.reviews++ + if f.reviewErr != nil { + return onboarding.Proposal{}, f.reviewErr + } + return onboarding.Review(dir, id, decision) + }, + Resources: func(lab string) (*resources.Guard, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.resources = append(f.resources, lab) + return nil, nil + }, + } +} + +func (f *fakeSetup) calls() (prepares, reviews int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.prepares, f.reviews +} + +func setupHandler(t *testing.T, f *fakeSetup) http.Handler { + t.Helper() + return testHandler(t, Options{LabDir: f.lab, SourceRoot: "/src/checkout", Setup: f.options_()}) +} + +// setupDo sends one request and checks the response never carries a local path. +func setupDo(t *testing.T, h http.Handler, r request, lab string) (int, SetupView, gateRefusal) { + t.Helper() + w := do(t, h, r) + body := w.Body.String() + for _, leak := range []string{lab, localLeak, "/src/checkout", "/cli"} { + if leak != "" && strings.Contains(body, leak) { + t.Errorf("%s %s serves the local path %q: %s", r.method, r.path, leak, body) + } + } + var v SetupView + var refusal gateRefusal + if w.Code == http.StatusOK { + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatalf("%s: %v %s", r.path, err, body) + } + } else { + _ = json.Unmarshal(w.Body.Bytes(), &refusal) + } + return w.Code, v, refusal +} + +func setupGet(t *testing.T, h http.Handler, lab string) SetupView { + t.Helper() + code, v, _ := setupDo(t, h, authed("/api/setup"), lab) + if code != http.StatusOK { + t.Fatalf("GET /api/setup: %d", code) + } + return v +} + +func setupPost(t *testing.T, h http.Handler, lab, action, body string) (int, SetupView, gateRefusal) { + t.Helper() + return setupDo(t, h, gatePost("/api/setup/"+action, body), lab) +} + +func approveBody(p *SetupProposal) string { + return `{"proposal":"` + p.ID + `","digest":"` + p.Digest + `"}` +} + +// Every step's happy path, from no Lab files at all to an approved Formula: +// the steps come from the Lab's state, each write is one engine call, and every +// other API route is refused until the Lab is ready. +func TestUISetupStepsFromAFreshLabToAnApprovedFormula(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + + v := setupGet(t, h, lab) + if !v.Active || v.Step != "repository" || v.Checked || v.Repository != nil || v.Lab != filepath.Base(lab) { + t.Fatalf("first view %+v", v) + } + for _, path := range []string{"/api/lab", "/api/molecules", "/api/loop", "/api/issues"} { + if code, _, refusal := setupDo(t, h, authed(path), lab); code != http.StatusConflict || refusal.Category != "setup" { + t.Errorf("GET %s during setup: %d %+v", path, code, refusal) + } + } + if code, _, refusal := setupPost(t, h, lab, "../runs", `{"issue":1}`); code == http.StatusOK { + t.Errorf("a non-setup POST during setup: %d %+v", code, refusal) + } + if code, _, refusal := setupDo(t, h, gatePost("/api/runs", `{"issue":1}`), lab); code != http.StatusConflict || refusal.Category != "setup" { + t.Errorf("POST /api/runs during setup: %d %+v", code, refusal) + } + + code, v, _ := setupPost(t, h, lab, "check", `{}`) + if code != http.StatusOK || v.Step != "agent" || !v.Checked || v.Repository == nil || !v.Repository.OK || v.Repository.Repo != "acme/widgets" || v.Repository.DefaultBranch != "main" || v.Repository.GitHub != "signed-in" { + t.Fatalf("check: %d %+v %+v", code, v, v.Repository) + } + if len(v.Agents) != 3 || v.Agents[0].ID != "claude-code" || !v.Agents[0].Ready || v.Agents[1].ID != "codex" || v.Agents[1].Ready || v.Agents[1].SignIn != "login-required" || v.Agents[2].ID != "cursor" || v.Agents[2].Readiness != "missing" { + t.Fatalf("agents %+v", v.Agents) + } + if !strings.Contains(v.Agents[1].Detail, "not logged in") || !strings.Contains(v.Agents[2].Detail, "Not found") { + t.Fatalf("unavailable agents are not explained: %+v", v.Agents) + } + // A reload at the agent step stays there. + if v := setupGet(t, h, lab); v.Step != "agent" { + t.Fatalf("reload after check: %+v", v) + } + + code, v, _ = setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + if code != http.StatusOK || v.Step != "review" || v.Proposal == nil || v.Proposal.Kind != "bootstrap" || v.Proposal.Resume { + t.Fatalf("create: %d %+v", code, v) + } + p := v.Proposal + if len(p.Atoms) != 7 || p.Atoms[0].Label != "Read the Issue" || p.Atoms[1].Label != "You approve the plan" || len(p.Checks) != 1 || p.Checks[0].Command != "go test ./..." || p.Checks[0].Dir != "lab" || len(p.Gates) != 2 || p.Gates[0].Mode != "human" || p.Binding != "claude-code" { + t.Fatalf("proposal %+v", p) + } + if !assayID.MatchString(p.ID) || !setupDigest.MatchString(p.Digest) { + t.Fatalf("proposal identity %q %q", p.ID, p.Digest) + } + f.mu.Lock() + o := f.options[0] + f.mu.Unlock() + if o.Preferred != "claude-code" || o.LabDir != lab || o.Cwd != "/src/checkout" || o.Launcher != "/cli" || o.EnableReview || len(o.Candidates) != 2 { + t.Fatalf("Prepare got %+v", o) + } + + code, v, _ = setupPost(t, h, lab, "approve", approveBody(p)) + if code != http.StatusOK || v.Step != "done" || v.Active || v.ReviewAtom != "available" { + t.Fatalf("approve: %d %+v", code, v) + } + if prepares, reviews := f.calls(); prepares != 1 || reviews != 1 { + t.Fatalf("engine calls: %d prepares, %d reviews", prepares, reviews) + } + if !NeedsSetup(lab) == false || NeedsSetup(lab) { + t.Fatal("an approved Lab still needs setup") + } + // Once ready, the resource guard is open for this Lab and the Lab is served. + if len(f.resources) != 1 || f.resources[0] != lab { + t.Fatalf("resource guard opened for %q", f.resources) + } + if w := do(t, h, authed("/api/lab")); w.Code != http.StatusOK { + t.Fatalf("GET /api/lab after setup: %d", w.Code) + } + f.unchanged("after setup") +} + +// A reload, or a restart of forgecell ui, at each step resumes from the Lab's +// own state; a half-made Lab is continued and never duplicated. +func TestUISetupResumesFromTheLabsOwnState(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + setupPost(t, h, lab, "check", `{}`) + _, created, _ := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + if created.Proposal == nil { + t.Fatalf("create %+v", created) + } + // A new launch reads the waiting proposal without checking anything again. + restarted := setupHandler(t, f) + v := setupGet(t, restarted, lab) + if v.Step != "review" || v.Proposal == nil || v.Proposal.ID != created.Proposal.ID || v.Checked { + t.Fatalf("restart at review: %+v", v) + } + if !NeedsSetup(lab) { + t.Fatal("a Lab with only a waiting proposal does not need setup") + } + // Create again (a double click across a reload) continues that proposal. + code, again, _ := setupPost(t, restarted, lab, "create", `{"harness":"claude-code"}`) + if code != http.StatusOK || again.Proposal == nil || again.Proposal.ID != created.Proposal.ID { + t.Fatalf("create after restart: %d %+v", code, again) + } + if prepares, _ := f.calls(); prepares != 1 { + t.Fatalf("a half-made Lab was proposed again: %d prepares", prepares) + } + entries, _ := os.ReadDir(filepath.Join(lab, "assays")) + if len(entries) != 1 { + t.Fatalf("assays %v", entries) + } + if code, _, _ := setupPost(t, restarted, lab, "approve", approveBody(again.Proposal)); code != http.StatusOK { + t.Fatalf("approve after restart: %d", code) + } + // After approval a new launch finds the Lab done, and the Lab opens. + done := setupGet(t, setupHandler(t, f), lab) + if done.Step != "done" || done.Active { + t.Fatalf("restart after approval: %+v", done) + } + // Approving twice is refused as stale: nothing waits any more. + if code, _, refusal := setupPost(t, restarted, lab, "approve", approveBody(again.Proposal)); code != http.StatusConflict || refusal.Category != "stale" { + t.Fatalf("second approval: %d %+v", code, refusal) + } + if _, reviews := f.calls(); reviews != 1 { + t.Fatalf("reviews %d", reviews) + } +} + +// A second submit while one runs is refused and never reaches the engine. +func TestUISetupDoubleSubmitRunsOneEngineCall(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + setupPost(t, h, lab, "check", `{}`) + f.entered, f.release = make(chan struct{}), make(chan struct{}) + done := make(chan int, 1) + go func() { + w := do(t, h, gatePost("/api/setup/create", `{"harness":"claude-code"}`)) + done <- w.Code + }() + <-f.entered + for _, action := range []string{"create", "check", "approve", "review"} { + body := `{}` + switch action { + case "create": + body = `{"harness":"claude-code"}` + case "approve": + body = `{"proposal":"assay-` + strings.Repeat("a", 64) + `","digest":"` + strings.Repeat("b", 64) + `"}` + } + if code, _, refusal := setupPost(t, h, lab, action, body); code != http.StatusConflict || refusal.Category != "busy" { + t.Errorf("%s while create runs: %d %+v", action, code, refusal) + } + } + if v := setupGet(t, h, lab); !v.Busy { + t.Errorf("the view does not report the running step: %+v", v) + } + close(f.release) + if code := <-done; code != http.StatusOK { + t.Fatalf("first create: %d", code) + } + if prepares, reviews := f.calls(); prepares != 1 || reviews != 0 { + t.Fatalf("engine calls: %d prepares, %d reviews", prepares, reviews) + } + f.mu.Lock() + reads := f.reads + f.mu.Unlock() + if reads != 1 { + t.Fatalf("the refused check read the repository: %d reads", reads) + } +} + +// Approval binds the shown proposal: a changed digest, another proposal id or a +// proposal replaced after it was shown approves nothing and never reaches Review. +func TestUISetupApprovalBindsTheShownDigest(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + setupPost(t, h, lab, "check", `{}`) + _, v, _ := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + p := *v.Proposal + for name, body := range map[string]string{ + "other digest": `{"proposal":"` + p.ID + `","digest":"` + strings.Repeat("0", 64) + `"}`, + "other proposal": `{"proposal":"assay-` + strings.Repeat("0", 64) + `","digest":"` + p.Digest + `"}`, + } { + if code, _, refusal := setupPost(t, h, lab, "approve", body); code != http.StatusConflict || refusal.Category != "stale" { + t.Errorf("%s: %d %+v", name, code, refusal) + } + } + for name, body := range map[string]string{ + "no digest": `{"proposal":"` + p.ID + `"}`, + "short digest": `{"proposal":"` + p.ID + `","digest":"abc"}`, + "path as id": `{"proposal":"../lab.json","digest":"` + p.Digest + `"}`, + "extra field": `{"proposal":"` + p.ID + `","digest":"` + p.Digest + `","force":true}`, + "two objects": approveBody(&p) + approveBody(&p), + "upper-case hash": `{"proposal":"` + p.ID + `","digest":"` + strings.ToUpper(p.Digest) + `"}`, + } { + if code, _, refusal := setupPost(t, h, lab, "approve", body); code != http.StatusBadRequest || refusal.Category != "invalid" { + t.Errorf("%s: %d %+v", name, code, refusal) + } + } + // The proposal shown is dismissed through the engine and another one saved: + // the old id and digest no longer approve anything. + if _, err := onboarding.Review(lab, p.ID, "dismiss"); err != nil { + t.Fatal(err) + } + f.last = snapshot(t, lab) + if code, _, refusal := setupPost(t, h, lab, "approve", approveBody(&p)); code != http.StatusConflict || refusal.Category != "stale" { + t.Fatalf("dismissed proposal: %d %+v", code, refusal) + } + if _, reviews := f.calls(); reviews != 0 { + t.Fatalf("a stale approval reached Review %d times", reviews) + } + if NeedsSetup(lab) == false { + t.Fatal("a Lab without lab.json does not need setup") + } + f.unchanged("after refusals") +} + +// Each step's failure path is refused by category with a fixed next step; engine +// text, which names local paths, is never served, and nothing is written. +func TestUISetupFailurePathsAreRefusedWithoutWrites(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + f.repoErr = errors.New("repository discovery failed for gh: exit 1 " + localLeak) + h := setupHandler(t, f) + + _, v, _ := setupPost(t, h, lab, "check", `{}`) + if v.Step != "repository" || v.Repository == nil || v.Repository.OK || v.Repository.GitHub != "sign-in-needed" || !strings.Contains(v.Repository.Message, "gh auth login") { + t.Fatalf("gh failure: %+v %+v", v, v.Repository) + } + if code, _, refusal := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`); code != http.StatusConflict || refusal.Category != "repository" { + t.Fatalf("create before the repository reads: %d %+v", code, refusal) + } + for text, want := range map[string]string{ + "repository discovery failed for git: not a repository": "not a git checkout", + "origin must identify a GitHub repository": "not a GitHub repository", + "something else at " + localLeak: "could not be read", + } { + if got := repositoryView(onboarding.Repository{}, errors.New(text)); got.OK || !strings.Contains(got.Message, want) || strings.Contains(got.Message, localLeak) { + t.Errorf("%q: %+v", text, got) + } + } + + f.repoErr = nil + setupPost(t, h, lab, "check", `{}`) + for body, category := range map[string]string{ + `{"harness":"codex"}`: "agent", + `{"harness":"cursor"}`: "agent", + `{"harness":"../evil"}`: "invalid", + `{"harness":""}`: "invalid", + `{"harness":"codex","x":1}`: "invalid", + } { + code, _, refusal := setupPost(t, h, lab, "create", body) + if refusal.Category != category || code == http.StatusOK { + t.Errorf("create %s: %d %+v", body, code, refusal) + } + } + f.prepareErr = errors.New("approved Formula belongs to another repository at " + localLeak) + code, _, refusal := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + if code != http.StatusUnprocessableEntity || refusal.Category != "refused" || !strings.Contains(refusal.Reason, "another repository") { + t.Fatalf("Prepare failure: %d %+v", code, refusal) + } + if code, _, refusal := setupPost(t, h, lab, "review", `{}`); code != http.StatusConflict || refusal.Category != "approve" { + t.Fatalf("review Atom before approval: %d %+v", code, refusal) + } + + f.prepareErr = nil + _, v, _ = setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + f.reviewErr = errors.New("Formula changed since proposal; stale approval refused at " + localLeak) + code, _, refusal = setupPost(t, h, lab, "approve", approveBody(v.Proposal)) + if code != http.StatusUnprocessableEntity || refusal.Category != "refused" || !strings.Contains(refusal.Reason, "Check Again") { + t.Fatalf("Review failure: %d %+v", code, refusal) + } + if v := setupGet(t, h, lab); v.Step != "review" { + t.Fatalf("after a refused approval: %+v", v) + } + + // Unsafe Lab state fails closed: a linked assays directory is a problem, not + // an empty Lab. + linked := t.TempDir() + if err := os.Symlink(t.TempDir(), filepath.Join(linked, "assays")); err != nil { + t.Fatal(err) + } + lf := newFakeSetup(t, linked) + lh := setupHandler(t, lf) + if v := setupGet(t, lh, linked); v.Step != "problem" || v.Problem == "" { + t.Fatalf("linked assays: %+v", v) + } + setupPost(t, lh, linked, "check", `{}`) + if code, _, refusal := setupPost(t, lh, linked, "create", `{"harness":"claude-code"}`); code != http.StatusUnprocessableEntity || refusal.Category != "lab" { + t.Fatalf("create in a linked Lab: %d %+v", code, refusal) + } + if prepares, _ := lf.calls(); prepares != 0 { + t.Fatal("an unsafe Lab reached Prepare") + } + f.unchanged("after failures") +} + +// The review Atom is never approved implicitly: it is a second proposal on the +// approved Formula with its own Approve Formula click and digest. +func TestUISetupReviewAtomIsASecondApproval(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + setupPost(t, h, lab, "check", `{}`) + _, v, _ := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + bootstrap := v.Proposal + setupPost(t, h, lab, "approve", approveBody(bootstrap)) + + code, v, _ := setupPost(t, h, lab, "review", `{}`) + if code != http.StatusOK || v.Step != "review" || v.Proposal == nil || v.Proposal.Kind != "enable-review" || v.Proposal.ID == bootstrap.ID || v.Proposal.Digest == bootstrap.Digest { + t.Fatalf("review Atom proposal: %d %+v", code, v) + } + f.mu.Lock() + last := f.options[len(f.options)-1] + f.mu.Unlock() + if !last.EnableReview || last.Preferred != "" || last.LabDir != lab { + t.Fatalf("Prepare got %+v", last) + } + if code, _, refusal := setupPost(t, h, lab, "review", `{}`); code != http.StatusConflict || refusal.Category != "pending" { + t.Fatalf("a second review proposal: %d %+v", code, refusal) + } + if _, reviews := f.calls(); reviews != 1 { + t.Fatalf("the review Atom was approved without a click: %d reviews", reviews) + } + code, v, _ = setupPost(t, h, lab, "approve", approveBody(v.Proposal)) + if code != http.StatusOK || v.Step != "done" || v.ReviewAtom != "present" { + t.Fatalf("approve review Atom: %d %+v", code, v) + } + if code, _, refusal := setupPost(t, h, lab, "review", `{}`); code != http.StatusConflict || refusal.Category != "present" { + t.Fatalf("review Atom twice: %d %+v", code, refusal) + } +} + +// With several Labs for the repository the operator chooses one, by index; +// only directory names are shown, and nothing runs before the choice. +func TestUISetupChoosesAmongSeveralLabs(t *testing.T) { + home := t.TempDir() + first, second := filepath.Join(home, "widgets-a"), filepath.Join(home, "widgets-b") + for _, dir := range []string{first, second} { + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + } + f := newFakeSetup(t, second) + o := f.options_() + o.Labs = []string{first, second} + h := testHandler(t, Options{LabDir: "", SourceRoot: "/src/checkout", Setup: o}) + v := setupGet(t, h, home) + if v.Step != "choose" || len(v.Labs) != 2 || v.Labs[0].Name != "widgets-a" || v.Labs[1].Name != "widgets-b" || v.Labs[1].Index != 1 { + t.Fatalf("chooser %+v", v) + } + for action, body := range map[string]string{"create": `{"harness":"claude-code"}`, "approve": `{"proposal":"assay-` + strings.Repeat("a", 64) + `","digest":"` + strings.Repeat("b", 64) + `"}`, "review": `{}`} { + if code, _, refusal := setupPost(t, h, home, action, body); code != http.StatusConflict || refusal.Category != "choose" { + t.Errorf("%s before choosing: %d %+v", action, code, refusal) + } + } + for _, body := range []string{`{"lab":2}`, `{"lab":-1}`, `{"lab":"` + first + `"}`, `{"path":"/"}`} { + if code, _, _ := setupPost(t, h, home, "choose", body); code != http.StatusBadRequest { + t.Errorf("choose %s: %d", body, code) + } + } + code, v, _ := setupPost(t, h, home, "choose", `{"lab":1}`) + if code != http.StatusOK || v.Step != "repository" || v.Lab != "widgets-b" { + t.Fatalf("chosen: %d %+v", code, v) + } + if code, _, refusal := setupPost(t, h, home, "choose", `{"lab":0}`); code != http.StatusConflict || refusal.Category != "chosen" { + t.Fatalf("choosing again: %d %+v", code, refusal) + } + setupPost(t, h, home, "check", `{}`) + _, v, _ = setupPost(t, h, home, "create", `{"harness":"claude-code"}`) + f.mu.Lock() + got := f.options[0].LabDir + f.mu.Unlock() + if got != second || v.Step != "review" { + t.Fatalf("created in %q: %+v", got, v) + } + if entries, _ := os.ReadDir(first); len(entries) != 0 { + t.Fatalf("the Lab not chosen was written: %v", entries) + } +} + +// Setup POSTs pass the same write protection as the gates, never reach the +// engine when forged, are POST-only, and are refused without setup mode. +func TestUISetupRoutesNeedTheWriteProtection(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + for action, body := range map[string]string{"check": `{}`, "choose": `{"lab":0}`, "create": `{"harness":"claude-code"}`, "approve": `{"proposal":"assay-` + strings.Repeat("a", 64) + `","digest":"` + strings.Repeat("b", 64) + `"}`, "review": `{}`} { + path := "/api/setup/" + action + for name, forged := range forgedWrites(path, body) { + if w := do(t, h, forged); w.Code != http.StatusForbidden { + t.Errorf("%s %s: %d", name, action, w.Code) + } + } + for _, method := range []string{http.MethodPut, http.MethodDelete, http.MethodPatch} { + r := authed(path) + r.method = method + if w := do(t, h, r); w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != http.MethodPost { + t.Errorf("%s %s: %d", method, path, w.Code) + } + } + if w := do(t, h, authed(path)); w.Code != http.StatusNotFound { + t.Errorf("GET %s: %d", path, w.Code) + } + plain := testHandler(t, Options{LabDir: t.TempDir()}) + if w := do(t, plain, gatePost(path, body)); w.Code != http.StatusNotFound { + t.Errorf("%s without setup mode: %d", path, w.Code) + } + } + f.mu.Lock() + n := f.reads + f.discovers + f.prepares + f.reviews + f.mu.Unlock() + if n != 0 { + t.Fatalf("refused setup writes reached the engine %d times", n) + } + // Without setup mode, or in demo mode, GET /api/setup says so and every + // route is served as before. + for _, o := range []Options{{LabDir: t.TempDir()}, {Demo: true}} { + plain := testHandler(t, o) + if v := setupGet(t, plain, ""); v.Active || v.Step != "none" { + t.Errorf("setup view without setup mode: %+v", v) + } + if w := do(t, plain, authed("/api/lab")); w.Code != http.StatusOK { + t.Errorf("/api/lab without setup mode: %d", w.Code) + } + } + f.unchanged("after forged writes") +} + +// NeedsSetup opens only a Lab with an approved Formula as before. A Lab with +// no lab.json, or a lab.json and no approved Formula with nothing waiting, is +// set up, so Create can propose the Formula to approve again. +func TestUISetupNeedsSetupFollowsTheLab(t *testing.T) { + if !NeedsSetup(t.TempDir()) { + t.Error("an empty Lab directory does not need setup") + } + if !NeedsSetup(filepath.Join(t.TempDir(), "missing")) { + t.Error("a missing Lab directory does not need setup") + } + configured := t.TempDir() + if err := os.WriteFile(filepath.Join(configured, "lab.json"), []byte(`{"schemaVersion":"v0","activeFormulaId":"gone"}`), 0600); err != nil { + t.Fatal(err) + } + if !NeedsSetup(configured) { + t.Fatal("a Lab with a lab.json but no approved Formula opens without setup") + } + // The active Formula is present but has no approval. + unapproved := t.TempDir() + if err := os.MkdirAll(filepath.Join(unapproved, "formulas"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(unapproved, "lab.json"), []byte(`{"schemaVersion":"v0","activeFormulaId":"widgets-github-issue"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(unapproved, "formulas", "widgets-github-issue.yaml"), []byte(setupFormula), 0600); err != nil { + t.Fatal(err) + } + if !NeedsSetup(unapproved) { + t.Fatal("a Lab whose Formula has no approval opens without setup") + } + // Set up, it reaches Create, and Create proposes a Formula to approve. + configured = unapproved + f := newFakeSetup(t, configured) + h := setupHandler(t, f) + if v := setupGet(t, h, configured); !v.Active || v.Step != "repository" { + t.Fatalf("unapproved Lab: %+v", v) + } + setupPost(t, h, configured, "check", `{}`) + if code, v, _ := setupPost(t, h, configured, "create", `{"harness":"claude-code"}`); code != http.StatusOK || v.Step != "review" || v.Proposal == nil { + t.Fatalf("create in an unapproved Lab: %d %+v", code, v) + } + if prepares, _ := f.calls(); prepares != 1 { + t.Fatalf("prepares %d", prepares) + } +} + +// A Lab whose proposals cannot be read safely needs setup, so the problem +// step explains it instead of the normal UI opening on it. +func TestUISetupNeedsSetupForAnUnreadableLab(t *testing.T) { + linked := t.TempDir() + if err := os.WriteFile(filepath.Join(linked, "lab.json"), []byte(`{"schemaVersion":"v0","activeFormulaId":"gone"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(linked, "assays")); err != nil { + t.Fatal(err) + } + if _, err := readSetupLab(linked); err == nil { + t.Fatal("a linked assays directory was read") + } + if !NeedsSetup(linked) { + t.Fatal("an unreadable Lab opens without setup") + } + f := newFakeSetup(t, linked) + if v := setupGet(t, setupHandler(t, f), linked); !v.Active || v.Step != "problem" || v.Problem == "" { + t.Fatalf("unreadable Lab: %+v", v) + } + f.unchanged("after the problem step") +} + +// Create probes the coding agents again: one that signed out after Check is +// refused with the plain reason, nothing is written, and the page shows the +// fresh probe. +func TestUISetupCreateReverifiesTheCodingAgent(t *testing.T) { + lab := t.TempDir() + f := newFakeSetup(t, lab) + h := setupHandler(t, f) + if _, v, _ := setupPost(t, h, lab, "check", `{}`); len(v.Agents) == 0 || !v.Agents[0].Ready { + t.Fatalf("check %+v", v) + } + f.mu.Lock() + f.candidates = []onboarding.Candidate{ + {ID: "claude-code", Executable: localLeak + "/bin/claude", Readiness: "login-required", Detail: "Probe step auth failed at " + localLeak + " — provider reports it is not logged in.", Installed: harness.Capability{State: "verified"}, Authentication: harness.Capability{State: "login-required"}}, + } + discovers := f.discovers + f.mu.Unlock() + code, _, refusal := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`) + if code != http.StatusConflict || refusal.Category != "agent" || !strings.Contains(refusal.Reason, "Sign in") { + t.Fatalf("create with a signed-out agent: %d %+v", code, refusal) + } + f.mu.Lock() + again := f.discovers - discovers + f.mu.Unlock() + if prepares, _ := f.calls(); prepares != 0 || again != 1 { + t.Fatalf("create reached Prepare %d times after %d probes", prepares, again) + } + if entries, _ := os.ReadDir(lab); len(entries) != 0 { + t.Fatalf("a refused create wrote %v", entries) + } + if v := setupGet(t, h, lab); v.Step != "agent" || len(v.Agents) == 0 || v.Agents[0].Ready || v.Agents[0].SignIn != "login-required" { + t.Fatalf("the page still shows the stale probe: %+v", v) + } + // Signed in again, Create proposes from the fresh probe. + f.mu.Lock() + f.candidates = newFakeSetup(t, lab).candidates + f.mu.Unlock() + if code, v, _ := setupPost(t, h, lab, "create", `{"harness":"claude-code"}`); code != http.StatusOK || v.Step != "review" { + t.Fatalf("create after signing in: %d %+v", code, v) + } + f.mu.Lock() + o := f.options[0] + f.mu.Unlock() + if len(o.Candidates) != 2 || o.Candidates[0].Readiness != "ready" { + t.Fatalf("Prepare got stale candidates %+v", o.Candidates) + } +} + +// Choosing a Lab refuses one that became a link or a file since the scan. +func TestUISetupChooseRefusesALinkOrAFile(t *testing.T) { + home := t.TempDir() + plain, linked, file := filepath.Join(home, "widgets-a"), filepath.Join(home, "widgets-b"), filepath.Join(home, "widgets-c") + if err := os.Mkdir(plain, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(plain, linked); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, nil, 0600); err != nil { + t.Fatal(err) + } + f := newFakeSetup(t, plain) + o := f.options_() + o.Labs = []string{plain, linked, file, filepath.Join(home, "gone")} + h := testHandler(t, Options{SourceRoot: "/src/checkout", Setup: o}) + for _, body := range []string{`{"lab":1}`, `{"lab":2}`, `{"lab":3}`} { + if code, _, refusal := setupPost(t, h, home, "choose", body); code != http.StatusConflict || refusal.Category != "lab" { + t.Errorf("choose %s: %d %+v", body, code, refusal) + } + } + if v := setupGet(t, h, home); v.Step != "choose" { + t.Fatalf("a refused choice was kept: %+v", v) + } + if code, v, _ := setupPost(t, h, home, "choose", `{"lab":0}`); code != http.StatusOK || v.Lab != "widgets-a" { + t.Fatalf("choose the directory: %d %+v", code, v) + } + f.unchanged("after choosing") +} + +// The view's module and stylesheet are served from the allowlist, render text +// only, and the entry mounts the view and loads nothing else while it shows. +func TestUISetupViewIsServedAndRendersTextOnly(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}) + js := do(t, h, authed("/js/setup.js")) + css := do(t, h, authed("/css/setup.css")) + if js.Code != http.StatusOK || css.Code != http.StatusOK || js.Header().Get("Content-Type") != "text/javascript; charset=utf-8" || css.Header().Get("Content-Type") != "text/css; charset=utf-8" { + t.Fatalf("setup.js %d, setup.css %d", js.Code, css.Code) + } + body := js.Body.String() + for _, sink := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "DOMParser", "eval(", "new Function", "javascript:", "import(", ".append(", "localStorage"} { + if strings.Contains(body, sink) { + t.Errorf("setup.js uses %s", sink) + } + } + for _, want := range []string{"export function mount(root, ctx) {", "export function update(state) {", "export async function begin() {", "'Approve Formula'", "'Check Again'", "'Create Lab'", "{ proposal: id, digest: digest }", "autocomplete: 'off'", "'aria-describedby': 'setup-error-' + name", "if (setup.pending) return false;", "history.pushState"} { + if !strings.Contains(body, want) { + t.Errorf("setup.js lacks %q", want) + } + } + entry := do(t, h, authed("/app.js")).Body.String() + for _, want := range []string{"import * as setup from './js/setup.js';", "[setup, $('[data-view-panel=\"setup\"]')],", "if (state.setup) return;", "setup.begin().then("} { + if !strings.Contains(entry, want) { + t.Errorf("app.js lacks %q", want) + } + } + page := do(t, h, authed("/")).Body.String() + for _, want := range []string{``, ` + + diff --git a/lab/internal/ui/web/js/setup.js b/lab/internal/ui/web/js/setup.js new file mode 100644 index 0000000..a654559 --- /dev/null +++ b/lab/internal/ui/web/js/setup.js @@ -0,0 +1,351 @@ +// Set up your Lab (#263): the view forgecell ui serves in a checkout with no +// ready Lab. Every step is one engine call on the server (GET /api/setup and +// POST /api/setup/…); this page never writes Lab files. The step always comes +// from the Lab's own state, so a reload resumes where the Lab is. One request is +// in flight at a time; after a lost or ambiguous response the state is read back +// and nothing is resubmitted. Approve Formula sends the exact proposal and +// digest shown, and nothing is approved without that click. Every served value +// is untrusted and reaches the page only as a text node. +import { $, el, fill, setText, state, short, counted, SessionError, tabSession, api, failed, showView } from './core.js'; + +const STEPS = [ + ['repository', 'Repository'], + ['agent', 'Coding agent'], + ['create', 'Create the Lab'], + ['review', 'Approve the Formula'], + ['done', 'Done'], +]; +const STEP_INDEX = { choose: 0, repository: 0, agent: 1, review: 3, done: 4, problem: 0 }; +const KNOWN_STEPS = ['choose', 'repository', 'agent', 'review', 'done', 'problem']; +const AGENT_IDS = ['claude-code', 'codex', 'cursor']; +const ASSAY_ID = /^assay-[a-f0-9]{64}$/; +const DIGEST = /^[a-f0-9]{64}$/; + +// setup is this view's state: the last view served, the action in flight, the +// inline error and the form it belongs to, and each card's last rendered key. +const setup = { root: null, view: null, pending: '', error: '', errorFor: '', keys: {}, timer: null, review: false, live: '' }; + +const text = (value, fallback) => (typeof value === 'string' && value ? value : fallback); + +export function mount(root, ctx) { + setup.root = root; + ctx.setupActive = () => Boolean(state.setup); +} + +// update re-renders from the last served view; the entry calls it like any view. +export function update(state) { + if (state && state.setup && setup.view) render(); +} + +// begin reads GET /api/setup once at start-up. It resolves true when this +// launch is setting up its Lab, and the entry then loads nothing else. +export async function begin() { + const view = await read(); + if (!view || !inSetup(view)) return false; + state.setup = true; + document.body.classList.add('setup-mode'); + window.addEventListener('popstate', () => { if (state.setup) showView('setup'); }); + showView('setup'); + setURL(view.step, false); + show(view); + // The repository and coding agents are read once on arrival, never on a poll. + if (!view.checked && (view.step === 'repository' || view.step === 'agent')) submit('check', {}, 'repository'); + return true; +} + +// A Lab approved before this load opens the app; a proposal still waiting, +// even after approval (the review Atom), stays here. +function inSetup(view) { + return KNOWN_STEPS.includes(view.step) && (view.active === true || view.step === 'review'); +} + +async function read() { + try { + const res = await api('/api/setup'); + if (!res.ok) return null; + return await res.json(); + } catch (error) { failed(error); return null; } +} + +function setURL(step, push) { + const query = '?view=setup&step=' + encodeURIComponent(step); + if (location.search === query) return; + if (push) history.pushState({ view: 'setup', step: step }, '', query); + else history.replaceState({ view: 'setup', step: step }, '', query); +} + +function show(view) { + const previous = setup.view && setup.view.step; + setup.view = view; + if (previous && previous !== view.step) setURL(view.step, true); + render(); + // Another tab may run a step: read back while one is in flight. + clearTimeout(setup.timer); + if (view.busy) setup.timer = setTimeout(async () => { const next = await read(); if (next) show(next); }, 2000); +} + +function announce(message) { + if (setup.live === message) return; + setup.live = message; + setText($('#setup-live'), message); +} + +// ---- Requests --------------------------------------------------------------- +async function post(action, payload) { + const session = tabSession(); + if (!session || state.sessionLost) throw new SessionError('no session'); + const res = await fetch('/api/setup/' + action, { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'X-Forgecell-Session': session, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + if (res.status === 403) throw new SessionError('forbidden'); + return res; +} + +const PROGRESS = { check: 'Checking…', choose: 'Opening…', create: 'Creating your Lab…', approve: 'Approving…', review: 'Proposing the review Atom…' }; + +// submit posts once. A refusal shows inline on the form; a lost or 5xx +// response reads the state back instead of resubmitting. +async function submit(action, payload, form) { + if (setup.pending) return false; + setup.pending = action; + setup.error = ''; + setup.errorFor = form; + render(); + announce(PROGRESS[action]); + let res = null, body = {}; + try { + res = await post(action, payload); + body = await res.json().catch(() => ({})); + } catch (error) { + if (error instanceof SessionError) { setup.pending = ''; failed(error); render(); return false; } + res = null; + } + setup.pending = ''; + if (!res || res.status >= 500) { + setup.error = 'The response was lost. Your Lab’s state was read back; nothing was resubmitted.'; + const next = await read(); + if (next) show(next); else render(); + focusError(form); + return false; + } + if (!res.ok) { + setup.error = text(body.error, 'Refused') + '.' + (typeof body.reason === 'string' && body.reason ? ' ' + body.reason + '.' : ''); + if (body.category === 'stale' || body.category === 'busy') { + const next = await read(); + if (next) show(next); + } + render(); + focusError(form); + return false; + } + show(body); + announce(stepTitle(body)); + return true; +} + +function focusError(form) { + const node = document.getElementById('setup-error-' + form); + if (node && !node.hidden) node.focus({ preventScroll: false }); +} + +// ---- Rendering ----------------------------------------------------------------- +function stepTitle(view) { + switch (view.step) { + case 'choose': return 'Choose a Lab for this repository'; + case 'repository': return 'Step 1 of 5: check your repository'; + case 'agent': return 'Step 2 of 5: pick a coding agent'; + case 'review': return 'Step 4 of 5: Formula ready for your approval'; + case 'done': return 'Your Lab is ready'; + } + return 'Your Lab needs attention'; +} + +function render() { + const view = setup.view; + if (!view) return; + renderSteps(view); + const cards = []; + if (view.step === 'choose') cards.push(['choose', chooseCard]); + else if (view.step === 'problem') cards.push(['problem', problemCard]); + else { + cards.push(['repository', repositoryCard]); + if (view.step === 'agent') cards.push(['agent', agentCard]); + if (view.step === 'review') cards.push(['review', reviewCard]); + if (view.step === 'done') cards.push(['done', doneCard]); + } + const body = $('#setup-body'); + const wanted = cards.map(([name]) => 'setup-card-' + name); + for (const node of [...body.children]) if (!wanted.includes(node.id)) { node.remove(); delete setup.keys[node.id]; } + let previous = null; + for (const [name, build] of cards) { + const id = 'setup-card-' + name; + const key = JSON.stringify([view.step, view.busy, view[name === 'agent' ? 'agents' : name] || null, view.repository || null, view.proposal || null, view.reviewAtom || '', view.problem || '', view.labs || null, setup.pending, setup.error, setup.errorFor, setup.review]); + let node = document.getElementById(id); + if (!node || setup.keys[id] !== key) { + const focused = node && node.contains(document.activeElement) ? document.activeElement : null; + const next = build(view); + next.id = id; + if (node) node.replaceWith(next); else if (previous) previous.after(next); else body.prepend(next); + setup.keys[id] = key; + node = next; + if (focused) refocus(node, focused); + } + previous = node; + } +} + +// refocus finds the rebuilt control the operator was on, by id or by name. +function refocus(card, focused) { + const next = (focused.id && card.querySelector('#' + CSS.escape(focused.id))) || (focused.name && card.querySelector('[name="' + CSS.escape(focused.name) + '"]')); + if (next && !next.disabled) next.focus({ preventScroll: true }); +} + +function renderSteps(view) { + const list = $('#setup-steps'); + const current = view.step === 'agent' && setup.pending === 'create' ? 2 : STEP_INDEX[view.step] || 0; + if (list.children.length !== STEPS.length) fill(list, STEPS.map(([key]) => el('li', { class: 'setup-step', dataset: { step: key } }))); + STEPS.forEach(([, label], i) => { + const item = list.children[i]; + const status = i < current ? 'done' : i === current ? 'current' : 'next'; + if (item.dataset.status !== status) item.dataset.status = status; + if (status === 'current') item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current'); + setText(item, (i + 1) + '. ' + label + (status === 'done' ? ' · done' : '')); + }); +} + +function card(title, ...children) { + return el('section', { class: 'glass setup-card', 'aria-label': title }, el('h2', { class: 'setup-card-title' }, title), ...children); +} + +// errorLine is a form's inline error, linked by aria-describedby. +function errorLine(form) { + const shown = setup.errorFor === form && Boolean(setup.error); + return el('p', { id: 'setup-error-' + form, class: 'setup-error', tabindex: '-1', hidden: !shown }, shown ? setup.error : ''); +} + +// SUBMIT_TYPE makes every setup button submit its form, so Enter submits. +const SUBMIT_TYPE = 'submit'; + +function submitButton(action, label, primary) { + const busy = setup.pending === action; + return el('button', { type: SUBMIT_TYPE, class: 'btn ' + (primary ? 'btn-primary' : 'btn-secondary'), disabled: Boolean(setup.pending) || (setup.view && setup.view.busy), 'aria-busy': busy ? 'true' : null }, busy ? PROGRESS[action] : label); +} + +function form(name, onsubmit, ...children) { + return el('form', { class: 'setup-form', autocomplete: 'off', 'aria-describedby': 'setup-error-' + name, onsubmit: (e) => { e.preventDefault(); onsubmit(e.currentTarget); } }, ...children, errorLine(name)); +} + +function chooseCard(view) { + const labs = Array.isArray(view.labs) ? view.labs : []; + return card('Choose a Lab', + el('p', { class: 'setup-note' }, counted(labs.length, 'Lab matches', 'Labs match') + ' this repository. Pick the one to open; the others stay as they are.'), + form('choose', (f) => { const picked = f.querySelector('input[name="lab"]:checked'); if (picked) submit('choose', { lab: Number(picked.value) }, 'choose'); }, + el('fieldset', { class: 'setup-options' }, el('legend', { class: 'sr-only' }, 'Labs for this repository'), + labs.map((lab, i) => el('label', { class: 'setup-option' }, + el('input', { type: 'radio', name: 'lab', value: String(Number.isInteger(lab.index) ? lab.index : i), required: true, checked: i === 0 }), + el('span', { class: 'setup-option-text' }, el('strong', null, text(lab.name, 'Lab')), el('small', null, lab.formula ? 'Formula ' + lab.formula + (lab.binding ? ' · ' + lab.binding : '') : 'No Formula yet'))))), + el('div', { class: 'setup-actions' }, submitButton('choose', 'Open Lab', true)))); +} + +function problemCard(view) { + return card('Your Lab needs attention', el('p', { class: 'setup-note', role: 'alert' }, text(view.problem, 'The Lab could not be read.'))); +} + +function repositoryCard(view) { + const repo = view.repository; + const rows = el('dl', { class: 'setup-facts' }); + let note; + if (!view.checked) note = el('p', { class: 'setup-note' }, setup.pending === 'check' ? 'Reading your repository and coding agents…' : 'Not checked yet.'); + else if (repo && repo.ok) { + fill(rows, + el('dt', null, 'Repository'), el('dd', null, el('code', null, text(repo.repo, 'Not recorded'))), + el('dt', null, 'Default branch'), el('dd', null, el('code', null, text(repo.defaultBranch, 'Not recorded'))), + el('dt', null, 'GitHub'), el('dd', null, text(repo.message, 'Signed in'))); + } else note = el('p', { class: 'setup-note setup-warn' }, text(repo && repo.message, 'The repository could not be read.')); + const done = view.step !== 'repository'; + return card('1. Repository', note, rows.children.length ? rows : null, + el('p', { class: 'setup-hint' }, 'Read-only. Sign-in stays in the gh CLI; Forgecell never asks for credentials.'), + form('repository', () => submit('check', {}, 'repository'), el('div', { class: 'setup-actions' }, submitButton('check', 'Check Again', !done)))); +} + +function agentCard(view) { + const agents = (Array.isArray(view.agents) ? view.agents : []).filter((a) => a && AGENT_IDS.includes(a.id)); + const firstReady = agents.find((a) => a.ready === true); + const ready = agents.filter((a) => a.ready === true).length; + return card('2. Coding agent', + el('p', { class: 'setup-note' }, ready ? 'Pick the coding agent that writes changes. You can change it later with a reviewed Formula change.' : 'No coding agent is ready yet. Install one and sign in with its own CLI, then Check Again.'), + form('agent', (f) => { const picked = f.querySelector('input[name="harness"]:checked'); if (picked) submit('create', { harness: picked.value }, 'agent'); }, + el('fieldset', { class: 'setup-options' }, el('legend', { class: 'sr-only' }, 'Coding agents'), + agents.map((a) => el('label', { class: 'setup-option', dataset: { ready: a.ready === true ? 'yes' : 'no' } }, + el('input', { type: 'radio', name: 'harness', value: a.id, required: true, disabled: a.ready !== true, checked: a === firstReady, 'aria-describedby': 'setup-agent-' + a.id }), + el('span', { class: 'setup-option-text' }, + el('strong', null, text(a.name, a.id)), + el('small', { id: 'setup-agent-' + a.id }, agentLine(a)))))), + el('p', { class: 'setup-hint' }, '3. Create the Lab: Forgecell saves the Lab and proposes its first Formula, as forgecell init --harness does. Nothing is approved yet.'), + el('div', { class: 'setup-actions' }, ready ? submitButton('create', 'Create Lab', true) : null))); +} + +const SIGN_IN = { verified: 'Signed in', 'login-required': 'Sign-in needed', unknown: 'Sign-in not verified' }; +function agentLine(a) { + const parts = [a.ready === true ? 'Ready' : 'Not available', SIGN_IN[a.signIn] || 'Sign-in not verified']; + return parts.join(' · ') + (a.ready === true ? '' : '. ' + text(a.detail, '')); +} + +function reviewCard(view) { + const p = view.proposal || {}; + const valid = ASSAY_ID.test(p.id || '') && DIGEST.test(p.digest || ''); + const atoms = Array.isArray(p.atoms) ? p.atoms : []; + const checks = Array.isArray(p.checks) ? p.checks : []; + const gates = Array.isArray(p.gates) ? p.gates : []; + const title = p.kind === 'enable-review' ? 'Add the review Atom' : '4. Review and approve the first Formula'; + const intro = p.resume ? 'An approval of this Formula started and did not finish. Approving again finishes exactly that approval.' + : p.kind === 'enable-review' ? 'This change inserts one review Atom after the checks. Everything else stays as you approved it.' + : 'Formula ready for your approval. Approving binds the coding agent; every run still stops for your plan approval and your review.'; + return card(title, + el('p', { class: 'setup-note' }, intro), + el('p', { class: 'setup-formula' }, text(p.formula, 'Formula'), ' · coding agent ', el('code', null, text(p.binding, 'Not recorded'))), + el('h3', { class: 'setup-sub' }, 'Atoms, in order'), + el('ol', { class: 'setup-atoms' }, atoms.map((a) => el('li', null, text(a.label, 'Atom'), ' ', el('small', null, text(a.type, ''))))), + el('h3', { class: 'setup-sub' }, 'Baseline checks from CI ', el('span', { class: 'setup-count' }, String(checks.length))), + checks.length ? el('ul', { class: 'setup-checks' }, checks.map((c) => el('li', null, el('code', null, text(c.command, '')), c.dir && c.dir !== '.' ? el('small', null, ' in ' + c.dir) : null))) + : el('p', { class: 'setup-hint' }, 'No pull-request checks were found in CI, so no baseline is required.'), + el('h3', { class: 'setup-sub' }, 'Gate policy'), + el('ul', { class: 'setup-gates' }, gates.map((g) => el('li', null, gateLine(g)))), + p.truncated ? el('p', { class: 'setup-hint' }, 'Only the first part is shown. Run forgecell init --review to read it all before approving.') : null, + el('details', { class: 'setup-details' }, el('summary', null, 'Details'), + el('p', { class: 'setup-hint' }, 'Approval binds this exact proposal. If it changes before you click, nothing is approved.'), + el('div', { class: 'digest-row' }, el('span', { class: 'field-label' }, 'Digest'), el('code', null, short(p.digest)), + el('button', { type: 'button', class: 'btn btn-secondary btn-small', onclick: () => { if (navigator.clipboard && valid) navigator.clipboard.writeText(p.digest).catch(() => {}); } }, 'Copy Digest'))), + form('review', (f) => { + if (!valid) return; + setup.review = p.kind === 'bootstrap' && Boolean(f.querySelector('input[name="review-atom"]:checked')); + approve(p.id, p.digest, setup.review); + }, + p.kind === 'bootstrap' ? el('label', { class: 'setup-toggle' }, el('input', { type: 'checkbox', name: 'review-atom', checked: setup.review }), ' Also propose the review Atom after this approval. You approve it separately.') : null, + el('div', { class: 'setup-actions' }, valid ? submitButton('approve', p.resume ? 'Finish Approval' : 'Approve Formula', true) : null))); +} + +function gateLine(g) { + const what = g.purpose === 'scope' ? 'Plan approval' : g.purpose === 'review' ? 'Review before a draft PR' : 'Gate ' + text(g.purpose, ''); + return what + ': ' + (g.mode === 'human' ? 'you decide' : 'policy ' + text(g.mode, '')); +} + +// approve sends exactly the shown proposal and digest. Only after it is +// approved, and only if you asked, the review Atom is proposed: a second +// proposal, which waits for its own Approve Formula click. +async function approve(id, digest, withReview) { + const ok = await submit('approve', { proposal: id, digest: digest }, 'review'); + if (ok && withReview && setup.view && setup.view.step === 'done' && setup.view.reviewAtom === 'available') { + setup.review = false; + await submit('review', {}, 'done'); + } +} + +function doneCard(view) { + const offer = view.reviewAtom === 'available'; + return card('5. Done', + el('p', { class: 'setup-note' }, 'Your Lab is ready. Pick a first small Issue to try the whole loop: plan, your approval, coding, checks and your review.'), + view.problem ? el('p', { class: 'setup-note setup-warn' }, view.problem) : null, + el('div', { class: 'setup-actions' }, el('a', { class: 'btn btn-primary', href: '?view=issues' }, 'Go to Issues')), + offer ? form('done', () => submit('review', {}, 'done'), + el('p', { class: 'setup-hint' }, 'Optional: a review Atom has a second agent review each change before you do. It is a Formula change you approve separately.'), + el('div', { class: 'setup-actions' }, submitButton('review', 'Propose Review Atom', false))) : null); +}