diff --git a/Makefile b/Makefile index c0464f5..4ffc7c9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # make dist release archives for macOS and Linux in dist/ # make clean remove build output -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null) LDFLAGS := -s -w -X arranger/internal/version.Version=$(VERSION) \ -X arranger/internal/version.Commit=$(COMMIT) \ diff --git a/README.md b/README.md index 5a9a738..438bfb3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ One agent with one huge prompt drifts, forgets, and claims success. A team with - **Checks decide "done", not the agent.** Arranger runs your checks itself after every attempt and sends failures back with the exact output, up to three tries. - **Parallel and isolated.** Every agent works in its own git worktree and branch, so agents on the same level run at the same time without touching each other's files. Your branches are never changed until you merge. - **Review before anything merges.** Managers read each report's diff and accept it or send it back with feedback. -- **You stay in control.** Live logs and status for every agent, a diff per agent, remove or promote single changes, revert any checkpoint, token limits per agent, and one-click merge into the branch you pick. +- **You stay in control.** Approve or edit a manager's plan before its team runs, live logs and status for every agent, a diff per agent, remove or promote single changes, revert any checkpoint, token limits per agent, and one-click merge into the branch you pick. ## Install @@ -43,30 +43,34 @@ You also need `git`, and at least one agent CLI on your `PATH` (for example `cla arranger ``` - Then open . + Then open . 2. **Point it at a repo.** Click **Settings** in the header and enter the path to a git repository. Leave the base branch blank to use the current branch. The repo needs at least one commit. -3. **Arrange the team.** Drag agent types from the left onto the canvas. Drop one agent onto another to put it under that agent. Click a box to pick its tool (runtime), model, color and instructions in the **Settings** tab. Click **Save**. +3. **Arrange the team.** Press **Teams & Tools** in the sidebar to drop in a ready team (Feature team, Bug fix, Docs) and check which agent tools are installed, or drag agent types from the left onto the canvas. Drop one agent onto another to put it under that agent. Click a box to pick its tool (runtime), model, color and instructions in the **Settings** tab. Click **Save**. -4. **Set the goal.** Select the top agent and fill in the **Goal** tab: +4. **Add goals.** Select the top agent and press **+ Add goal** in its **Goal** tab. Each goal has: - **Goal:** the outcome, e.g. "Add rate limiting to /login". - **Acceptance criteria:** what reviewers should hold the work to. - **Checks:** one shell command per line. All of them must exit 0, e.g. `go test ./...`. -5. **Run.** Press **Run**. A manager plans one subgoal (with its own checks) for each report, runs them in parallel, reviews and merges their work into its own branch, then runs its own checks. Links on the canvas animate while agents hand work down and report back. The **Logs** tab streams what each agent is doing. +5. **Run.** Press **Run** and confirm the goals it will work through. They run one after another, each on the code the one before it left: a manager plans one subgoal (with its own checks) for each report, runs them in parallel, reviews and merges their work into its own branch, then runs its own checks. If a goal fails, it stops there or goes on with the next, as you choose. Links on the canvas animate while agents hand work down and report back, and the **Logs** tab shows each goal's run. -6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (**Run** plans again from the goal instead.) + Want to check the plan first? Tick **Let me approve the plan before the team runs** under the manager's goals. The manager then stops after planning and shows you each report's goal and checks: edit them, skip or add reports, send the plan back with feedback, or approve it to start the team. + +6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (To redo a finished goal from scratch, use **Add again** on it and press **Run**.) + + Blocked because one report failed? Fix what it needed, then press **Continue** on the manager: the reports that finished keep their work, only the others run again, and then everything is reviewed, merged and checked together, with no new plan. 7. **Review and merge.** Open the **Diff** tab to see every change, remove or promote single hunks, or revert a checkpoint. When you're happy, press **Merge…** to merge the agent's branch into `main` (or any branch, created if needed) as a merge commit, a squash, or a fast-forward. -**Stats** in the header shows cost, tokens, time and first-try success, per agent and per runtime. +**Stats** (click the token count in the header) shows tokens, time and first-try success, per agent and per runtime. ## Options ```text arranger [flags] - -addr string listen address (default "127.0.0.1:7777") + -addr string listen address (default "127.0.0.1:7889") -data string data directory (default "~/.arranger") -parallel int max agent processes running at once (default 4) -log string log level: debug, info, warn or error (default "info") diff --git a/cmd/arranger/main.go b/cmd/arranger/main.go index 90fb1f5..23b02f4 100644 --- a/cmd/arranger/main.go +++ b/cmd/arranger/main.go @@ -23,7 +23,7 @@ import ( func main() { home, _ := os.UserHomeDir() - addr := flag.String("addr", "127.0.0.1:7777", "listen address") + addr := flag.String("addr", "127.0.0.1:7889", "listen address") data := flag.String("data", filepath.Join(home, ".arranger"), "data directory") parallel := flag.Int("parallel", 4, "max agent processes running at once") level := flag.String("log", "info", "log level: debug, info, warn or error") diff --git a/internal/agents/agents.go b/internal/agents/agents.go index de3b28f..d0348e3 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -32,6 +32,9 @@ type Runtime struct { Tail []string PromptArg bool Parse func(line []byte) []Event + // Allow turns shell commands the agent may run without asking into flags, for CLIs that + // otherwise refuse commands in headless mode. nil: the CLI needs nothing. + Allow func(commands []string) []string } // withModel returns an Args func: base flags plus flag+model when a model is set. @@ -46,7 +49,10 @@ func withModel(flag string, base ...string) func(string) []string { } var Runtimes = map[string]Runtime{ - "claude": {Bin: "claude", Args: withModel("--model", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"), Parse: parseClaude}, + // acceptEdits allows file edits but refuses most shell commands in -p mode, so the agent's own + // check commands are allowed explicitly: it can run its tests before it says it's done. + "claude": {Bin: "claude", Args: withModel("--model", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"), Parse: parseClaude, + Allow: func(cmds []string) []string { return claudeAllow(cmds) }}, "codex": {Bin: "codex", Args: withModel("-m", "exec", "--json", "--full-auto"), Tail: []string{"-"}, Parse: parseCodex}, "gemini": {Bin: "gemini", Args: withModel("-m", "--output-format", "stream-json", "--approval-mode", "auto_edit"), Parse: parseGemini}, "pi": {Bin: "pi", Args: withModel("--model", "-p", "--mode", "json"), Parse: parsePi}, @@ -68,9 +74,14 @@ func Installed() map[string]bool { return m } -// Command builds the process for a run. extra is the agent's free-form args. -func (rt Runtime) Command(ctx context.Context, model, extra, prompt string) (*exec.Cmd, error) { - bin, args := rt.Bin, append(rt.Args(model), strings.Fields(extra)...) +// Command builds the process for a run. extra is the agent's free-form args; allow lists shell +// commands the agent may run without asking (its checks). +func (rt Runtime) Command(ctx context.Context, model, extra, prompt string, allow []string) (*exec.Cmd, error) { + args := rt.Args(model) + if rt.Allow != nil && len(allow) > 0 { + args = append(args, rt.Allow(allow)...) + } + bin, args := rt.Bin, append(args, strings.Fields(extra)...) if bin == "" { if len(args) == 0 { return nil, errors.New("generic runtime: put the command in the agent's extra args") @@ -438,3 +449,48 @@ func Clip(s string, n int) string { } return s } + +// buildTools are programs a check commonly runs, allowed with any arguments so the agent can also +// run a narrower version of its check (one package, one test file). Anything else is allowed only +// exactly as the check spells it. +var buildTools = map[string]bool{ + "go": true, "gofmt": true, "npm": true, "npx": true, "pnpm": true, "yarn": true, "bun": true, "node": true, "deno": true, + "tsc": true, "jest": true, "vitest": true, "eslint": true, "prettier": true, "playwright": true, + "cargo": true, "rustc": true, "make": true, "cmake": true, "ctest": true, + "python": true, "python3": true, "pytest": true, "uv": true, "poetry": true, "ruff": true, "mypy": true, "tox": true, + "mvn": true, "gradle": true, "./gradlew": true, "./mvnw": true, "dotnet": true, "swift": true, "xcodebuild": true, + "mix": true, "bundle": true, "rake": true, "rspec": true, "php": true, "composer": true, "phpunit": true, + "test": true, "grep": true, "diff": true, "cat": true, "ls": true, +} + +// claudeAllow lets Claude Code run the given check commands: each check exactly as written, plus +// the build and test tools it uses with any arguments. +func claudeAllow(checks []string) []string { + seen := map[string]bool{} + var tools []string + add := func(t string) { + if !seen[t] { + seen[t] = true + tools = append(tools, t) + } + } + for _, c := range checks { + if c = strings.TrimSpace(c); c == "" { + continue + } + add("Bash(" + c + ")") + for _, part := range strings.FieldsFunc(c, func(r rune) bool { return r == '&' || r == '|' || r == ';' }) { + words := strings.Fields(part) + for len(words) > 0 && strings.Contains(words[0], "=") && !strings.HasPrefix(words[0], "-") { + words = words[1:] // FOO=bar go test: the program is go + } + if len(words) > 0 && buildTools[words[0]] { + add("Bash(" + words[0] + ":*)") + } + } + } + if len(tools) == 0 { + return nil + } + return append([]string{"--allowedTools"}, tools...) +} diff --git a/internal/agents/agents_test.go b/internal/agents/agents_test.go index 5f093be..b7e43f9 100644 --- a/internal/agents/agents_test.go +++ b/internal/agents/agents_test.go @@ -98,17 +98,17 @@ func TestParseOthers(t *testing.T) { } func TestCommand(t *testing.T) { - if _, err := Runtimes["generic"].Command(context.Background(), "", "", "x"); err == nil { + if _, err := Runtimes["generic"].Command(context.Background(), "", "", "x", nil); err == nil { t.Error("generic without a command should fail") } - cmd, err := Runtimes["generic"].Command(context.Background(), "", "sh -c cat", "hello") + cmd, err := Runtimes["generic"].Command(context.Background(), "", "sh -c cat", "hello", nil) if err != nil || cmd.Args[0] != "sh" { t.Fatalf("generic: %v %v", cmd, err) } if out, _ := cmd.Output(); string(out) != "hello" { t.Errorf("generic stdin: %q", out) } - if cmd, err := Runtimes["codex"].Command(context.Background(), "o3", "--foo", "do it"); err == nil { + if cmd, err := Runtimes["codex"].Command(context.Background(), "o3", "--foo", "do it", nil); err == nil { if got := strings.Join(cmd.Args[1:], " "); got != "exec --json --full-auto -m o3 --foo -" { t.Errorf("codex args: %s", got) } @@ -123,3 +123,23 @@ func TestParseClaudeUsage(t *testing.T) { t.Fatalf("got %+v", es) } } + +// Claude Code refuses most shell commands in -p mode unless they're allowed; a worker's checks are. +func TestClaudeAllowsChecks(t *testing.T) { + got := strings.Join(claudeAllow([]string{"go test ./...", "CI=1 npm run lint && ./scripts/verify.sh", " "}), " ") + want := "--allowedTools Bash(go test ./...) Bash(go:*) Bash(CI=1 npm run lint && ./scripts/verify.sh) Bash(npm:*)" + if got != want { + t.Fatalf("got %s\nwant %s", got, want) + } + if claudeAllow(nil) != nil { + t.Fatal("no checks, no flag") + } + rt := Runtime{Bin: "sh", Args: withModel(""), Allow: claudeAllow} + cmd, err := rt.Command(context.Background(), "", "--extra", "p", []string{"make test"}) + if err != nil { + t.Fatal(err) + } + if a := strings.Join(cmd.Args[1:], " "); a != "--allowedTools Bash(make test) Bash(make:*) --extra" { + t.Fatalf("args: %s", a) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 50cd841..0e9fbce 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -9,7 +9,9 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "strings" + "sync" ) // Run runs git in dir and returns its combined output. @@ -72,8 +74,14 @@ func BranchExists(repo, branch string) bool { return err == nil } +// worktreeMu serializes creating worktrees: reports that start together would otherwise run +// "git worktree add" in the same repository at once, and git's locks make all but one fail. +var worktreeMu sync.Mutex + // EnsureWorktree returns the agent's worktree, creating it on branch arranger/ from base. func EnsureWorktree(root, repo, base, agentID string) (string, error) { + worktreeMu.Lock() + defer worktreeMu.Unlock() dir := filepath.Join(root, agentID) if _, err := os.Stat(dir); err == nil { return dir, nil @@ -88,6 +96,84 @@ func EnsureWorktree(root, repo, base, agentID string) (string, error) { return dir, err } +// RemoveWorktree deletes an agent's worktree and its branch arranger/, for an agent that was +// removed. Missing pieces are fine: it may never have run. +func RemoveWorktree(repo, dir, agentID string) error { + if _, err := os.Stat(dir); err == nil { + if _, err := Run(repo, "worktree", "remove", "--force", dir); err != nil { + if err := os.RemoveAll(dir); err != nil { + return err + } + } + } + Run(repo, "worktree", "prune") + if BranchExists(repo, BranchOf(agentID)) { + if _, err := Run(repo, "branch", "-D", BranchOf(agentID)); err != nil { + return err + } + } + return nil +} + +// housekeeping commits say nothing about what the work changes: saves, syncs, and edits made by +// hand in the Diff tab. +func housekeeping(s string) bool { + for _, p := range []string{"Save uncommitted work", "Sync with", "arranger: ", "Promote "} { + if strings.HasPrefix(s, p) { + return true + } + } + return strings.Contains(s, "change(s) by hand in the Diff tab") +} + +// oldCheckpoint matches checkpoints from before commits described their change: "arranger: Name attempt 2". +var oldCheckpoint = regexp.MustCompile(`^arranger: .+ attempt \d+$`) + +// ChangeLines are the one-line messages of the work on to that from doesn't have yet, oldest first: +// no merges, housekeeping or repeats, and a reverted commit takes its line out with it. Old +// checkpoints that don't say what they changed are described by describe (if it knows, e.g. from +// the run log), else by the files they touch. +func ChangeLines(dir, from, to string, describe func(sha string) string) []string { + out, err := Run(dir, "log", "--reverse", "--no-merges", "--format=%H%x1f%s", from+".."+to) + if err != nil { + return nil + } + var ls []string + for _, row := range lines(out) { + sha, l, _ := strings.Cut(row, "\x1f") + if oldCheckpoint.MatchString(l) { + if describe != nil { + l = describe(sha) + } + if l == "" || oldCheckpoint.MatchString(l) { + l = filesLine(dir, sha) + } + } + if undone, ok := strings.CutPrefix(l, `Revert "`); ok { + undone = strings.TrimSuffix(undone, `"`) + ls = slices.DeleteFunc(ls, func(x string) bool { return x == undone }) + continue + } + if l != "" && !housekeeping(l) && !slices.Contains(ls, l) { + ls = append(ls, l) + } + } + return ls +} + +// filesLine describes a commit by the files it touches: "Update a.go, b.go and 3 more". +func filesLine(dir, sha string) string { + out, _ := Run(dir, "show", "--name-only", "--format=", sha) + fs := lines(out) + switch { + case len(fs) == 0: + return "" + case len(fs) > 3: + return fmt.Sprintf("Update %s and %d more", strings.Join(fs[:3], ", "), len(fs)-3) + } + return "Update " + strings.Join(fs, ", ") +} + // Commit commits everything in the worktree. Returns "" when there was nothing to commit. func Commit(dir, msg string) (string, error) { if _, err := Run(dir, "add", "-A"); err != nil { @@ -233,7 +319,7 @@ func HunkPatch(fs []FileDiff, ids map[string]bool) (patch string, n int) { // commits. Hunks dir already has are skipped (present); the rest apply as a patch, or as a // three-way merge when dir's files have moved on. On a conflict dir is left as it was. func ApplyHunks(dir string, fs []FileDiff, ids map[string]bool) (applied, present int, err error) { - if _, err := Commit(dir, "arranger: save work before applying changes"); err != nil { + if _, err := Commit(dir, "Save uncommitted work before applying selected changes"); err != nil { return 0, 0, err } todo := map[string]bool{} @@ -296,7 +382,7 @@ func Checkpoints(dir, base string) ([]Checkpoint, error) { // Revert undoes one checkpoint with a new commit; a conflicting revert is abandoned. func Revert(dir, sha string) error { - if _, err := Commit(dir, "arranger: save work before revert"); err != nil { + if _, err := Commit(dir, "Save uncommitted work before reverting a checkpoint"); err != nil { return err } if _, err := Run(dir, append(identity, "revert", "--no-edit", sha)...); err != nil { @@ -308,7 +394,7 @@ func Revert(dir, sha string) error { // MergeBranch merges branch into the worktree at dir; a conflicting merge is abandoned. func MergeBranch(dir, branch, msg string) error { - if _, err := Commit(dir, "arranger: save work before merge"); err != nil { + if _, err := Commit(dir, "Save uncommitted work before merging"); err != nil { return err } if _, err := Run(dir, append(identity, "merge", "--no-ff", "-m", msg, branch)...); err != nil { @@ -318,6 +404,24 @@ func MergeBranch(dir, branch, msg string) error { return nil } +// MoveOnto replays the worktree's own commits since oldBase onto newBase, for an agent that moved +// to another manager: it keeps its work and drops the old manager's. A conflicting move is +// abandoned and the conflicts named. +func MoveOnto(dir, newBase, oldBase string) error { + if _, err := Commit(dir, "Save uncommitted work before moving to a new manager"); err != nil { + return err + } + if _, err := Run(dir, append(identity, "rebase", "--onto", newBase, oldBase)...); err != nil { + out, _ := Run(dir, "diff", "--name-only", "--diff-filter=U") + Run(dir, "rebase", "--abort") + if cs := lines(out); len(cs) > 0 { + return fmt.Errorf("its work conflicts with %s in %s", newBase, strings.Join(cs, ", ")) + } + return err + } + return nil +} + // Behind counts the commits on branch that the worktree at dir doesn't have yet. func Behind(dir, branch string) int { out, err := Run(dir, "rev-list", "--count", "HEAD.."+branch) @@ -332,14 +436,14 @@ func Behind(dir, branch string) int { // Sync merges branch into the worktree at dir so the agent works on the latest code, and returns // how many new commits it brought in. A conflicting merge is abandoned and the conflicts named. func Sync(dir, branch string) (int, error) { - if _, err := Commit(dir, "arranger: save work before sync"); err != nil { + if _, err := Commit(dir, "Save uncommitted work before syncing"); err != nil { return 0, err } n := Behind(dir, branch) if n == 0 { return 0, nil } - if _, err := Run(dir, append(identity, "merge", "--no-edit", "-m", "arranger: sync with "+branch, branch)...); err != nil { + if _, err := Run(dir, append(identity, "merge", "--no-edit", "-m", fmt.Sprintf("Sync with %s: bring in %d new commit(s)", branch, n), branch)...); err != nil { out, _ := Run(dir, "diff", "--name-only", "--diff-filter=U") Run(dir, "merge", "--abort") if cs := lines(out); len(cs) > 0 { @@ -388,6 +492,17 @@ type MergePreview struct { Dels int `json:"dels"` CanFF bool `json:"canFF"` Conflicts []string `json:"conflicts"` + Message string `json:"message"` // the merge commit's message, listing every change it brings in + // Uncommitted counts files changed in the source's worktree since its last checkpoint. They + // aren't in the numbers above; merging commits them first. + Uncommitted int `json:"uncommitted"` +} + +// Uncommitted counts the files changed in the worktree at dir that aren't committed yet, without +// taking git's optional locks (a preview must not write). +func Uncommitted(dir string) int { + out, _ := Run(dir, "--no-optional-locks", "status", "--porcelain") + return len(lines(out)) } // PreviewMerge reports what merging source into target would do, without changing anything. diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 5862867..46021be 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,6 +1,7 @@ package git import ( + "fmt" "os" "os/exec" "path/filepath" @@ -314,3 +315,20 @@ func TestDiffShowsUncommittedNewFiles(t *testing.T) { t.Fatal("removing the hunk should delete the new file") } } + +// Reports that start together create their worktrees at the same moment; none may fail. +func TestEnsureWorktreeConcurrently(t *testing.T) { + repo, root := newRepo(t), t.TempDir() + errs := make(chan error, 8) + for i := range 8 { + go func() { + _, err := EnsureWorktree(root, repo, "main", fmt.Sprintf("a%d", i)) + errs <- err + }() + } + for range 8 { + if err := <-errs; err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/orch/agent.go b/internal/orch/agent.go index cad3d46..369c774 100644 --- a/internal/orch/agent.go +++ b/internal/orch/agent.go @@ -2,11 +2,14 @@ package orch import ( "bufio" + "bytes" "context" "fmt" "io" + "os" "os/exec" "strings" + "sync" "time" "go.uber.org/zap" @@ -51,21 +54,42 @@ func runAgent(j *job, prompt, dir string) (string, error) { rt := agents.Runtimes[j.a.Runtime] ctx, cancel := context.WithCancel(j.ctx) // cancelled early when the hard token limit is hit defer cancel() - // ponytail: cancel kills the agent process only, not its children; use a process group if strays show up. - cmd, err := rt.Command(ctx, j.a.Model, j.a.Args, prompt) + cmd, err := rt.Command(ctx, j.a.Model, j.a.Args, prompt, j.allow) if err != nil { return "", err } cmd.Dir = dir cmd.WaitDelay = 5 * time.Second - stdout, err := cmd.StdoutPipe() + ownGroup(cmd) // stopping it stops everything it started + // stdout is a plain pipe handed to the process, so reading ends when the process group is gone, + // even if something the agent started in the background inherited it + stdout, pw, err := os.Pipe() if err != nil { return "", err } - cmd.Stderr = lineWriter(func(l string) { j.emit("stderr", agents.Clip(l, 500), "") }) - if err := cmd.Start(); err != nil { + defer stdout.Close() + er, ew, err := os.Pipe() + if err != nil { + pw.Close() + return "", err + } + cmd.Stdout, cmd.Stderr = pw, ew + err = cmd.Start() + pw.Close() + ew.Close() + if err != nil { + er.Close() return "", err } + stderr := &lineWriter{emit: func(l string) { j.emit("stderr", agents.Clip(l, 500), "") }} + stderrDone := make(chan struct{}) + go func() { io.Copy(stderr, er); er.Close(); stderr.Flush(); close(stderrDone) }() + exited := make(chan error, 1) + go func() { + err := cmd.Wait() + killGroup(cmd) // whatever the agent left running (dev servers, watchers) goes with it + exited <- err + }() started := time.Now() zap.L().Debug("agent process started", zap.String("agent", j.a.Name), zap.String("cmd", cmd.Path), zap.Int("pid", cmd.Process.Pid), zap.String("dir", dir)) var cost float64 @@ -128,6 +152,9 @@ func runAgent(j *job, prompt, dir string) (string, error) { j.emit(e.Kind, e.Text, raw) } } + if j.overLimit { + break // the run ends at the limit; output still buffered from before the kill isn't counted + } if err == io.EOF { break } else if err != nil { @@ -136,7 +163,8 @@ func runAgent(j *job, prompt, dir string) (string, error) { } } flush() - werr := cmd.Wait() + werr := <-exited + <-stderrDone j.o.Store.UpdateRun(j.run, cost, in, out, cmd.ProcessState.ExitCode()) zap.L().Info("agent process exited", zap.String("agent", j.a.Name), zap.Int("exit", cmd.ProcessState.ExitCode()), zap.Duration("took", time.Since(started)), zap.Int("tokensIn", in), zap.Int("tokensOut", out), zap.Float64("cost", cost)) @@ -157,7 +185,8 @@ func runChecks(j *job, dir string, checks []string) (int, string) { cctx, cancel := context.WithTimeout(j.ctx, 10*time.Minute) cmd := exec.CommandContext(cctx, "sh", "-c", c) // ponytail: needs sh; Windows users need Git Bash on PATH cmd.Dir = dir - out, err := cmd.CombinedOutput() + ownGroup(cmd) // a timeout or Stop kills everything the check started + out, err := combinedOutput(cmd) cancel() tail := string(out) if len(tail) > 2000 { @@ -175,13 +204,56 @@ func runChecks(j *job, dir string, checks []string) (int, string) { return passed, fb.String() } -// lineWriter calls f for each non-empty line written. -// ponytail: a line split across two writes shows up as two lines. -type lineWriter func(string) +// combinedOutput runs cmd and returns its stdout and stderr together. It returns as soon as cmd +// exits, killing anything it left running in the background (which may hold its output open). +func combinedOutput(cmd *exec.Cmd) ([]byte, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + cmd.Stdout, cmd.Stderr = w, w + err = cmd.Start() + w.Close() + if err != nil { + r.Close() + return nil, err + } + var out bytes.Buffer + read := make(chan struct{}) + go func() { io.Copy(&out, r); r.Close(); close(read) }() + err = cmd.Wait() + killGroup(cmd) + <-read + return out.Bytes(), err +} + +// lineWriter calls emit for each non-empty line written. A line split across writes is held +// until it's complete; Flush emits what's left when the writer is done. +type lineWriter struct { + emit func(string) + mu sync.Mutex + partial []byte +} -func (f lineWriter) Write(p []byte) (int, error) { - for _, l := range lines(string(p)) { - f(l) +func (w *lineWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + w.partial = append(w.partial, p...) + if i := bytes.LastIndexByte(w.partial, '\n'); i >= 0 { + for _, l := range lines(string(w.partial[:i])) { + w.emit(l) + } + w.partial = append(w.partial[:0], w.partial[i+1:]...) } return len(p), nil } + +// Flush emits a last line that never got its newline. +func (w *lineWriter) Flush() { + w.mu.Lock() + defer w.mu.Unlock() + if l := strings.TrimSpace(string(w.partial)); l != "" { + w.emit(l) + } + w.partial = nil +} diff --git a/internal/orch/approve.go b/internal/orch/approve.go new file mode 100644 index 0000000..ce6aedb --- /dev/null +++ b/internal/orch/approve.go @@ -0,0 +1,128 @@ +package orch + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "arranger/internal/store" +) + +// planTimeout is how long a plan waits for the user's decision before the run stops. +var planTimeout = 24 * time.Hour + +// ErrStale means there's no plan waiting under that draft id: it was decided (maybe in another +// tab), replaced by a new plan, or its run ended. +var ErrStale = errors.New("this plan isn't waiting for a decision anymore; reload it") + +// Decision is the user's answer to a manager's draft plan. +type Decision struct { + Draft int64 `json:"draft"` + Action string `json:"action"` // approve | replan | cancel + Subgoals []subgoal `json:"subgoals"` // approve, kind "plan": the plan as the user left it + Changes []revision `json:"changes"` // approve, kind "revision" + Feedback string `json:"feedback"` // replan: what the new plan should do differently +} + +// Awaiting reports whether the agent's run is waiting for the user to decide on its plan. +func (o *Orchestrator) Awaiting(agentID string) bool { + o.mu.Lock() + defer o.mu.Unlock() + _, ok := o.approvals[agentID] + return ok +} + +// Decide hands the user's decision to the run waiting on the draft. An approved plan is checked +// the same way the manager's own plan is. +func (o *Orchestrator) Decide(agentID string, d Decision) error { + o.mu.Lock() + ch := o.approvals[agentID] + o.mu.Unlock() + p, err := o.Store.PendingPlan(agentID) + if ch == nil || err != nil || p.ID != d.Draft { + return ErrStale + } + var status string + var final any + switch d.Action { + case "approve": + kids, err := o.Store.Children(agentID) + if err != nil { + return err + } + byID, goals := map[string]store.Agent{}, map[string]store.Goal{} + for _, k := range kids { + byID[k.ID] = k + goals[k.ID], _ = o.Store.Goal(k.ID) + } + if p.Kind == "revision" { + if err := validateRevision(d.Changes, byID, goals); err != nil { + return err + } + d.Subgoals, final = nil, revisionDraft{Changes: d.Changes} + } else { + if err := validatePlan(d.Subgoals, byID); err != nil { + return err + } + d.Changes, final = nil, planDraft{Subgoals: d.Subgoals} + } + status = "approved" + case "replan": + status = "replanned" + case "cancel": + status = "cancelled" + default: + return fmt.Errorf("action must be approve, replan or cancel") + } + if !o.Store.DecidePlan(p.ID, status, d.Feedback, final) { + return ErrStale + } + ch <- d // buffered, and DecidePlan lets only one decision through + return nil +} + +// await saves the proposed plan as a draft and blocks until the user decides on it. It returns +// the decision, or the run's final status when the run ended instead (stopped, or no decision +// in time). Waiting holds no parallel slot: those are only taken while an agent CLI runs. +func (j *job) await(kind string, proposed any) (Decision, string) { + st := j.o.Store + id, err := st.SavePlan(j.a.ID, kind, proposed) + if err != nil { + return Decision{}, j.fail("failed", "couldn't save the plan: "+err.Error()) + } + ch := make(chan Decision, 1) + j.o.mu.Lock() + j.o.approvals[j.a.ID] = ch + j.o.mu.Unlock() + defer func() { + j.o.mu.Lock() + delete(j.o.approvals, j.a.ID) + j.o.mu.Unlock() + }() + j.status("awaiting", nil) + j.emit("plan", "plan ready: waiting for your approval", "") + j.o.Hub.Publish(j.p.ID, map[string]any{"type": "plan", "agent": j.a.ID}) + + timer := time.NewTimer(planTimeout) + defer timer.Stop() + select { + case d := <-ch: + return d, "" + case <-j.ctx.Done(): + st.DecidePlan(id, "cancelled", "", nil) + return Decision{}, j.fail("stopped", "stopped by user") + case <-timer.C: + if st.DecidePlan(id, "expired", "", nil) { + return Decision{}, j.fail("stopped", "the plan wasn't approved in time") + } + return <-ch, "" // the user decided just as time ran out + } +} + +// sameJSON reports whether a and b encode the same, e.g. a plan and the plan the user approved. +func sameJSON(a, b any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} diff --git a/internal/orch/approve_test.go b/internal/orch/approve_test.go new file mode 100644 index 0000000..042d521 --- /dev/null +++ b/internal/orch/approve_test.go @@ -0,0 +1,220 @@ +package orch + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "arranger/internal/agents" + "arranger/internal/store" +) + +// approvalTeam sets up a manager "m" that asks for approval, with reports "a" and "b". The fake +// manager plans both reports (a new plan when it gets feedback), routes changes to "a", and +// accepts everything in review. It records each prompt it gets in prompts. +func approvalTeam(t *testing.T) (o *Orchestrator, prompts string) { + t.Helper() + o = setup(t) + prompts = filepath.Join(t.TempDir(), "prompts") + plain := func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} } + agents.Runtimes["apmgr"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat); printf '%s\n=====\n' "$p" >> ` + prompts + ` +case "$p" in +*"WHAT EACH ONE ALREADY DID"*) echo '{"changes":[{"agent":"a","change":"write A","checks":["grep -q A a.txt"]}]}' ;; +*"sent back your plan"*) echo '{"subgoals":[{"agent":"b","title":"only b","checks":["test -f b.txt"]}]}' ;; +*"YOUR TEAM:"*) echo '{"subgoals":[{"agent":"a","title":"write a","checks":["test -f a.txt"]},{"agent":"b","title":"write b","checks":["test -f b.txt"]}]}' ;; +*) echo '{"verdicts":[]}' ;; +esac`} + }} + agents.Runtimes["apworker"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat); case "$p" in *"write A"*) echo A > a.txt ;; *"You are Alpha"*) echo a > a.txt ;; *) echo b > b.txt ;; esac`} + }} + t.Cleanup(func() { delete(agents.Runtimes, "apmgr"); delete(agents.Runtimes, "apworker") }) + + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: newRepo(t), Base: "main"}) + if err := o.Store.SaveArrangement(p.ID, []store.Agent{ + {ID: "m", Name: "Lead", Role: "manager", Runtime: "apmgr", ApprovePlan: true}, + {ID: "a", Name: "Alpha", Role: "coder", Parent: "m", Runtime: "apworker"}, + {ID: "b", Name: "Beta", Role: "coder", Parent: "m", Runtime: "apworker"}, + }); err != nil { + t.Fatal(err) + } + o.Store.SaveGoal("b", store.Goal{Title: "b's old goal", Checks: "true"}) + o.Store.SaveGoal("m", store.Goal{Title: "files", Checks: "true"}) + return o, prompts +} + +// waitPlan waits until m's plan waits for a decision and returns the draft. +func waitPlan(t *testing.T, o *Orchestrator) store.Plan { + t.Helper() + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + // the draft is saved just before the status changes, so wait for both + if p, err := o.Store.PendingPlan("m"); err == nil && o.Awaiting("m") { + if g, _ := o.Store.Goal("m"); g.Status == "awaiting" { + return p + } + } + } + t.Fatal("no plan to approve") + return store.Plan{} +} + +func runs(o *Orchestrator, id string) (n int) { + o.Store.SQL().QueryRow(`SELECT count(*) FROM runs WHERE agent_id=?`, id).Scan(&n) + return +} + +// Nothing is saved or run until the user approves; the edited plan is what runs. +func TestApproveEditedPlan(t *testing.T) { + o, _ := approvalTeam(t) + if err := o.Start("m"); err != nil { + t.Fatal(err) + } + p := waitPlan(t, o) + var draft planDraft + json.Unmarshal(p.JSON, &draft) + if p.Kind != "plan" || len(draft.Subgoals) != 2 { + t.Fatalf("draft: %+v", p) + } + if g, _ := o.Store.Goal("a"); g.Title != "" { + t.Fatal("a report's goal changed before approval") + } + if g, _ := o.Store.Goal("b"); g.Title != "b's old goal" || runs(o, "a")+runs(o, "b") != 0 { + t.Fatal("reports touched before approval") + } + + // the user edits a's goal and skips b + edited := []subgoal{{Agent: "a", Title: "write a, edited", Checks: []string{"test -f a.txt"}}} + if err := o.Decide("m", Decision{Draft: p.ID + 1, Action: "approve", Subgoals: edited}); !errors.Is(err, ErrStale) { + t.Fatalf("a wrong draft id should be stale, got %v", err) + } + if err := o.Decide("m", Decision{Draft: p.ID, Action: "approve", Subgoals: []subgoal{{Agent: "a", Title: "no checks"}}}); err == nil { + t.Fatal("a plan without checks should be refused") + } + if err := o.Decide("m", Decision{Draft: p.ID, Action: "approve", Subgoals: edited}); err != nil { + t.Fatal(err) + } + if err := o.Decide("m", Decision{Draft: p.ID, Action: "cancel"}); !errors.Is(err, ErrStale) { + t.Fatal("a second decision should be stale") + } + if g := waitDone(t, o, "m"); g.Status != "done" { + es, _ := o.Store.Events("m", 50) + t.Fatalf("manager: %+v\n%+v", g, es) + } + if g, _ := o.Store.Goal("a"); g.Title != "write a, edited" || g.Status != "done" { + t.Fatalf("a: %+v", g) + } + if g, _ := o.Store.Goal("b"); g.Title != "b's old goal" || runs(o, "b") != 0 { + t.Fatalf("skipped b should keep its goal and not run: %+v", g) + } + var status, saved string + o.Store.SQL().QueryRow(`SELECT status, json FROM plans WHERE id=?`, p.ID).Scan(&status, &saved) + if status != "approved" || !strings.Contains(saved, "edited") { + t.Fatalf("draft after approval: %s %s", status, saved) + } +} + +// Sending a plan back with feedback plans again with that feedback; nothing ran in between. +func TestReplanWithFeedback(t *testing.T) { + o, prompts := approvalTeam(t) + o.Start("m") + first := waitPlan(t, o) + if err := o.Decide("m", Decision{Draft: first.ID, Action: "replan", Feedback: "Beta can do it alone"}); err != nil { + t.Fatal(err) + } + var second store.Plan + for deadline := time.Now().Add(20 * time.Second); second.ID <= first.ID; time.Sleep(20 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("no second plan") + } + second, _ = o.Store.PendingPlan("m") + } + b, _ := os.ReadFile(prompts) + if !strings.Contains(string(b), "Beta can do it alone") || !strings.Contains(string(b), `"title":"write a"`) { + t.Fatalf("the new plan should get the feedback and the plan sent back:\n%s", b) + } + if runs(o, "a")+runs(o, "b") != 0 { + t.Fatal("reports ran before approval") + } + if !strings.Contains(string(second.JSON), "only b") { + t.Fatalf("second plan: %s", second.JSON) + } + o.Stop("m") + waitDone(t, o, "m") +} + +// Cancel, Stop and running out of time all end the run as stopped without touching the reports. +func TestPlanCancelStopExpire(t *testing.T) { + defer func(d time.Duration) { planTimeout = d }(planTimeout) + for _, how := range []string{"cancel", "stop", "expire"} { + t.Run(how, func(t *testing.T) { + o, _ := approvalTeam(t) + if how == "expire" { + planTimeout = 300 * time.Millisecond + } else { + planTimeout = time.Hour + } + o.Start("m") + p := waitPlan(t, o) + switch how { + case "cancel": + if err := o.Decide("m", Decision{Draft: p.ID, Action: "cancel"}); err != nil { + t.Fatal(err) + } + case "stop": + o.Stop("m") + } + if g := waitDone(t, o, "m"); g.Status != "stopped" { + t.Fatalf("manager: %+v", g) + } + want := map[string]string{"cancel": "cancelled", "stop": "cancelled", "expire": "expired"}[how] + var status string + o.Store.SQL().QueryRow(`SELECT status FROM plans WHERE id=?`, p.ID).Scan(&status) + if status != want { + t.Fatalf("draft status %q, want %q", status, want) + } + if g, _ := o.Store.Goal("b"); g.Title != "b's old goal" || runs(o, "a")+runs(o, "b") != 0 { + t.Fatal("reports touched") + } + if o.Awaiting("m") { + t.Fatal("still marked as waiting") + } + }) + } +} + +// A requested change is routed as a draft too; approving it runs only the reports it names. +func TestApproveRevision(t *testing.T) { + o, _ := approvalTeam(t) + o.Start("m") + p := waitPlan(t, o) + var draft planDraft + json.Unmarshal(p.JSON, &draft) + o.Decide("m", Decision{Draft: p.ID, Action: "approve", Subgoals: draft.Subgoals}) + if g := waitDone(t, o, "m"); g.Status != "done" { + t.Fatalf("first run: %+v", g) + } + + if err := o.Revise("m", "capital A please"); err != nil { + t.Fatal(err) + } + p = waitPlan(t, o) + if p.Kind != "revision" || !strings.Contains(string(p.JSON), "write A") { + t.Fatalf("revision draft: %+v %s", p, p.JSON) + } + var rev revisionDraft + json.Unmarshal(p.JSON, &rev) + if err := o.Decide("m", Decision{Draft: p.ID, Action: "approve", Changes: rev.Changes}); err != nil { + t.Fatal(err) + } + if g := waitDone(t, o, "m"); g.Status != "done" { + t.Fatalf("revision: %+v", g) + } + if runs(o, "a") != 2 || runs(o, "b") != 1 { + t.Fatalf("only a should re-run: a=%d b=%d", runs(o, "a"), runs(o, "b")) + } +} diff --git a/internal/orch/commits.go b/internal/orch/commits.go new file mode 100644 index 0000000..69761d0 --- /dev/null +++ b/internal/orch/commits.go @@ -0,0 +1,60 @@ +package orch + +import ( + "fmt" + "regexp" + "strings" + + "arranger/internal/agents" + "arranger/internal/git" +) + +// A commit's message is one line saying what changed: the agent's own "Commit:" line when it gave +// one, else what it was asked to do. A manager's merge reuses the line of the work it merges. + +var commitLine = regexp.MustCompile(`(?im)^\s*\**commit\**:\**\s*(.+?)\s*$`) + +// oneLine is a commit message cut to one readable line. +func oneLine(s string) string { + s = strings.TrimSpace(strings.SplitN(strings.TrimSpace(s), "\n", 2)[0]) + s = strings.Trim(s, "`\"' ") + s = strings.TrimSuffix(s, ".") + if s == "" { + return "" + } + return agents.Clip(strings.ToUpper(s[:1])+s[1:], 72) +} + +// checkpointMessage is the one line for a worker's attempt: its own description of the change, +// else the change or goal it worked on. +func checkpointMessage(summary, change, goal string) string { + if m := commitLine.FindAllStringSubmatch(summary, -1); len(m) > 0 { + if s := oneLine(m[len(m)-1][1]); s != "" { + return s + } + } + if s := oneLine(change); s != "" { + return s + } + return oneLine(goal) +} + +// MergeMessage is the message for merging several changes at once: the one change's line, or a +// subject naming how many with every change on its own line below it. fallback is used when no +// change has a line of its own. +func MergeMessage(changes []string, fallback string) string { + switch len(changes) { + case 0: + return oneLine(fallback) + case 1: + return changes[0] + } + subj := agents.Clip(fmt.Sprintf("%d changes: %s", len(changes), strings.Join(changes, "; ")), 72) + return subj + "\n\n- " + strings.Join(changes, "\n- ") + "\n" +} + +// mergeMessage is the message for a manager merging a report's branch: all the report's new +// changes, else its subgoal. +func mergeMessage(dir, branch, subgoal string) string { + return MergeMessage(git.ChangeLines(dir, "HEAD", branch, nil), subgoal) +} diff --git a/internal/orch/hub.go b/internal/orch/hub.go index ace03c6..1743f83 100644 --- a/internal/orch/hub.go +++ b/internal/orch/hub.go @@ -9,23 +9,40 @@ import ( // activity line. type Hub struct { mu sync.Mutex - subs map[chan []byte]string // chan -> project id - last map[string]string // agent id -> latest activity + subs map[chan []byte]*sub + last map[string]string // agent id -> latest activity +} + +type sub struct { + project string + dropped bool // a message didn't fit; the page must reload its state } func NewHub() *Hub { - return &Hub{subs: map[chan []byte]string{}, last: map[string]string{}} + return &Hub{subs: map[chan []byte]*sub{}, last: map[string]string{}} } // Subscribe returns a channel of JSON messages for one project. func (h *Hub) Subscribe(project string) chan []byte { c := make(chan []byte, 256) h.mu.Lock() - h.subs[c] = project + h.subs[c] = &sub{project: project} h.mu.Unlock() return c } +// Dropped reports, once, whether messages to c were dropped since the last call. +func (h *Hub) Dropped(c chan []byte) bool { + h.mu.Lock() + defer h.mu.Unlock() + s := h.subs[c] + if s == nil || !s.dropped { + return false + } + s.dropped = false + return true +} + func (h *Hub) Unsubscribe(c chan []byte) { h.mu.Lock() delete(h.subs, c) @@ -37,13 +54,14 @@ func (h *Hub) Publish(project string, v any) { b, _ := json.Marshal(v) h.mu.Lock() defer h.mu.Unlock() - for c, p := range h.subs { - if p != project { + for c, s := range h.subs { + if s.project != project { continue } select { case c <- b: - default: // ponytail: slow client drops live events; the Logs tab refetches on open + default: // a slow page misses this; it's told to reload once it catches up + s.dropped = true } } } diff --git a/internal/orch/manager.go b/internal/orch/manager.go index 6deed18..98b2604 100644 --- a/internal/orch/manager.go +++ b/internal/orch/manager.go @@ -49,31 +49,158 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s planned = planned || goals[k.ID].Title != "" } - // 1. plan, or route the requested change to the reports it concerns - j.newRun(1) + // 1. plan, or route the requested change to the reports it concerns; with approval on, the + // user approves, edits, sends back or cancels it before anything is saved or run var team []store.Agent - var err error changes := map[string]string{} // report id -> its part of the requested change - if j.change != "" && planned { - team, err = j.revise(g, dir, kids, byID, goals, feedback, changes) + resumeFix := false // continuing a fix round + if j.cont { + // continuing a blocked run: the same team, work and changes as its last plan; no new plan + var err error + if team, resumeFix, err = j.lastTeam(byID, changes); err != nil { + return j.fail("failed", err.Error()) + } + j.emit("plan", "continuing with the same plan: "+names(team), "") } else { - team, err = j.plan(g, dir, kids, byID, feedback) - } - if j.ctx.Err() != nil { - return j.fail("stopped", "stopped by user") + revising := j.change != "" && planned + planFeedback := feedback + for { + j.newRun(1) + var sgs []subgoal + var cs []revision + var err error + if revising { + cs, err = j.proposeRevision(g, dir, kids, byID, goals, planFeedback) + } else { + sgs, err = j.proposePlan(g, dir, kids, byID, planFeedback) + } + if j.ctx.Err() != nil { + return j.fail("stopped", "stopped by user") + } + if errors.Is(err, errTokenLimit) { + return j.fail("failed", j.limitMsg()) + } + if err != nil { + return j.fail("failed", "no valid plan: "+err.Error()) + } + if !j.a.ApprovePlan { + team = j.apply(sgs, cs, byID, goals, changes) + break + } + kind, proposed := "plan", any(planDraft{Subgoals: sgs}) + if revising { + kind, proposed = "revision", revisionDraft{Changes: cs} + } + d, ended := j.await(kind, proposed) + if ended != "" { + return ended + } + if d.Action == "cancel" { + return j.fail("stopped", "you cancelled the plan") + } + if d.Action == "replan" { + b, _ := json.Marshal(proposed) + note := "The user sent back your plan without running it" + if d.Feedback != "" { + note += " and said: " + d.Feedback + } + j.emit("request", "you asked for a new plan"+strings.TrimPrefix(note, "The user sent back your plan without running it"), "") + planFeedback = strings.TrimSpace(feedback + "\n\n" + note + ".\nThe plan they sent back: " + string(b)) + j.status("planning", nil) + continue + } + edited := false + if revising { + edited = !sameJSON(cs, d.Changes) + sgs, cs = nil, d.Changes + } else { + edited = !sameJSON(sgs, d.Subgoals) + sgs, cs = d.Subgoals, nil + } + if edited { + j.emit("request", "you approved the plan with edits", "") + } else { + j.emit("request", "you approved the plan", "") + } + team = j.apply(sgs, cs, byID, goals, changes) + break + } } - if errors.Is(err, errTokenLimit) { - return j.fail("failed", j.limitMsg()) + + // 2. run children, review, merge; rejected children re-run with the review feedback. A continued + // run keeps the work of the reports that already finished and runs only the others. + if s := j.runTeam(g, dir, team, byID, changes, resumeFix, j.cont); s != "" { + return s } - if err != nil { - return j.fail("failed", "no valid plan: "+err.Error()) + + // 3. verify the merged result against the manager's own goal. When it fails, the manager + // passes the failure to the reports it concerns, they fix it, and the result is checked again. + checks := lines(g.Checks) + for round := 0; ; round++ { + j.status("verifying", nil) + passed, fbk := runChecks(j, dir, checks) + st.SetGoal(j.a.ID, map[string]any{"passed": passed, "total": len(checks)}) + if j.ctx.Err() != nil { + return j.fail("stopped", "stopped by user") + } + if passed == len(checks) { + j.emit("done", fmt.Sprintf("team work merged, all %d checks passed", passed), "") + return j.status("done", map[string]any{"feedback": ""}) + } + if round == maxFixRounds { + return j.fail("failed", fmt.Sprintf("merged work still fails the manager's checks after %d fix rounds:\n%s", round, fbk)) + } + j.emit("warn", fmt.Sprintf("merged work fails my checks; passing the failure to the team (fix %d/%d)", round+1, maxFixRounds), "") + j.status("fixing", nil) + j.newRun(1) + for _, k := range kids { + goals[k.ID], _ = st.Goal(k.ID) + } + cs, err := j.proposeFix(g, dir, kids, byID, goals, fbk) + if j.ctx.Err() != nil { + return j.fail("stopped", "stopped by user") + } + if errors.Is(err, errTokenLimit) { + return j.fail("failed", j.limitMsg()) + } + if err != nil { + return j.fail("failed", "merged work fails the manager's checks, and it couldn't pass the fix to its team ("+err.Error()+"):\n"+fbk) + } + fixes := map[string]string{} + team := j.applyRevision("fix", cs, byID, goals, fixes) + if s := j.runTeam(g, dir, team, byID, fixes, true, false); s != "" { + return s + } } +} - // 2. run children, review, merge; rejected children re-run with the review feedback +// runTeam runs the given reports in parallel, reviews their work and merges what it accepts; +// rejected reports re-run with the review's feedback. changes holds each report's part of a +// requested change (fix: of a fix for the manager's failing checks). It returns "" once all the +// work is merged, else the manager's final status. +// +// With keepDone, reports whose goal is already done aren't run again in the first round; they go +// straight to review with the others. +func (j *job) runTeam(g store.Goal, dir string, team []store.Agent, byID map[string]store.Agent, changes map[string]string, fix, keepDone bool) string { + st := j.o.Store fb := map[string]string{} for round := 1; len(team) > 0; round++ { j.status("waiting", nil) - results := runChildren(j, team, fb, changes) + run, results := team, map[string]string{} + if keepDone && round == 1 { + run = nil + for _, k := range team { + if kg, _ := st.Goal(k.ID); kg.Status == "done" { + results[k.ID] = "done" + j.emit("msg", k.Name+" already finished; keeping its work", "") + } else { + run = append(run, k) + } + } + } + for id, s := range runChildren(j, run, fb, changes, fix) { + results[id] = s + } if j.ctx.Err() != nil { return j.fail("stopped", "stopped by user") } @@ -92,7 +219,7 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s j.status("reviewing", nil) j.newRun(round) - verdicts, err := j.review(g, dir, done, changes) + verdicts, err := j.review(g, dir, done, changes, fix) if j.ctx.Err() != nil { return j.fail("stopped", "stopped by user") } @@ -112,7 +239,9 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s again = append(again, k) continue } - if err := git.MergeBranch(dir, git.BranchOf(id), "arranger: merge "+k.Name); err != nil { + kg, _ := st.Goal(id) + msg := mergeMessage(dir, git.BranchOf(id), kg.Title) + if err := git.MergeBranch(dir, git.BranchOf(id), msg); err != nil { return j.fail("blocked", "needs you: merging "+k.Name+" failed: "+err.Error()) } j.emit("verdict", "✓ accepted and merged "+k.Name, "") @@ -124,53 +253,112 @@ func runManager(j *job, g store.Goal, dir string, kids []store.Agent, feedback s team = again } - // 3. verify the merged result against the manager's own goal - checks := lines(g.Checks) - j.status("verifying", nil) - passed, fbk := runChecks(j, dir, checks) - st.SetGoal(j.a.ID, map[string]any{"passed": passed, "total": len(checks)}) - if j.ctx.Err() != nil { - return j.fail("stopped", "stopped by user") - } - if passed < len(checks) { - return j.fail("failed", "merged work fails the manager's checks:\n"+fbk) - } - j.emit("done", fmt.Sprintf("team work merged, all %d checks passed", passed), "") - return j.status("done", map[string]any{"feedback": ""}) + return "" +} + +// planDraft and revisionDraft are what a manager proposes, as stored and shown for approval. +type planDraft struct { + Subgoals []subgoal `json:"subgoals"` +} + +type revisionDraft struct { + Changes []revision `json:"changes"` } -// plan splits the manager's goal into a subgoal per report and saves them as their goals. -func (j *job) plan(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, feedback string) ([]store.Agent, error) { - var plan struct{ Subgoals []subgoal } +// proposePlan asks the manager to split its goal into a subgoal per report. +func (j *job) proposePlan(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, feedback string) ([]subgoal, error) { + var plan planDraft err := j.decide(planPrompt(j.a, g, kids, feedback, j.change), dir, &plan, func() error { return validatePlan(plan.Subgoals, byID) }) - if err != nil { - return nil, err + return plan.Subgoals, err +} + +// apply saves a plan (sgs) or a routed change (cs) to the reports and returns who runs. +func (j *job) apply(sgs []subgoal, cs []revision, byID map[string]store.Agent, goals map[string]store.Goal, changes map[string]string) []store.Agent { + if cs != nil { + return j.applyRevision("revision", cs, byID, goals, changes) } + return j.applyPlan(sgs, byID) +} + +// applyPlan saves each subgoal as its report's goal. +func (j *job) applyPlan(sgs []subgoal, byID map[string]store.Agent) []store.Agent { st := j.o.Store - st.AddDecision(j.a.ID, "plan", plan) + st.AddDecision(j.a.ID, "plan", planDraft{Subgoals: sgs}) var team []store.Agent - for _, s := range plan.Subgoals { + for _, s := range sgs { st.SaveGoal(s.Agent, store.Goal{Title: s.Title, Body: s.Body, Criteria: s.Criteria, Checks: strings.Join(s.Checks, "\n")}) st.SetGoal(s.Agent, map[string]any{"status": "idle", "attempts": 0, "feedback": "", "passed": 0, "total": 0}) team = append(team, byID[s.Agent]) j.emit("plan", fmt.Sprintf("%s → %s (checks: %s)", byID[s.Agent].Name, s.Title, strings.Join(s.Checks, "; ")), "") } - return team, nil + return team } -// revise asks the manager which reports the user's change concerns and what each must do. Those -// reports keep their goals (plus any check that proves the change) and are the only ones re-run. -func (j *job) revise(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal, - feedback string, changes map[string]string) ([]store.Agent, error) { - var rev struct{ Changes []revision } +// proposeRevision asks the manager which reports the user's change concerns and what each must do. +func (j *job) proposeRevision(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal, + feedback string) ([]revision, error) { + var rev revisionDraft err := j.decide(revisePrompt(j.a, g, kids, goals, j.change, feedback), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) }) - if err != nil { - return nil, err + return rev.Changes, err +} + +// lastTeam rebuilds the team of the manager's last plan, routed change or fix round, with each +// report's part of the change, for continuing a blocked run. +func (j *job) lastTeam(byID map[string]store.Agent, changes map[string]string) (team []store.Agent, fix bool, err error) { + kind, raw := j.o.Store.LastDecision(j.a.ID, "plan", "revision", "fix") + if kind == "" { + return nil, false, errors.New("there's no plan to continue; press Run to plan") + } + var d struct { + Subgoals []subgoal + Changes []revision + } + if err := json.Unmarshal([]byte(raw), &d); err != nil { + return nil, false, err } + add := func(id string) { + if k, ok := byID[id]; ok { + team = append(team, k) + } + } + for _, s := range d.Subgoals { + add(s.Agent) + } + for _, c := range d.Changes { + add(c.Agent) + changes[c.Agent] = c.Change + } + if len(team) == 0 { + return nil, false, errors.New("the last plan's reports aren't on the team anymore; press Run to plan again") + } + return team, kind == "fix", nil +} + +func names(as []store.Agent) string { + ns := make([]string, len(as)) + for i, a := range as { + ns[i] = a.Name + } + return strings.Join(ns, ", ") +} + +// proposeFix asks the manager which reports must fix what, now that their merged work fails its +// checks. The answer has the same shape as a routed change. +func (j *job) proposeFix(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal, + failure string) ([]revision, error) { + var rev revisionDraft + err := j.decide(fixPrompt(j.a, g, kids, goals, failure), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) }) + return rev.Changes, err +} + +// applyRevision gives each concerned report its part of the change. They keep their goals (plus +// any check that proves the change) and are the only ones re-run. +// kind is how it's recorded: "revision" for a change the user asked for, "fix" for failing checks. +func (j *job) applyRevision(kind string, cs []revision, byID map[string]store.Agent, goals map[string]store.Goal, changes map[string]string) []store.Agent { st := j.o.Store - st.AddDecision(j.a.ID, "revision", rev) + st.AddDecision(j.a.ID, kind, revisionDraft{Changes: cs}) var team []store.Agent - for _, c := range rev.Changes { + for _, c := range cs { kg := goals[c.Agent] if added := addChecks(&kg, c.Checks); len(added) > 0 { st.SaveGoal(c.Agent, kg) @@ -184,7 +372,7 @@ func (j *job) revise(g store.Goal, dir string, kids []store.Agent, byID map[stri } j.emit("plan", msg, "") } - return team, nil + return team } // addChecks appends the checks g doesn't have yet and returns them. @@ -231,7 +419,7 @@ func validateRevision(cs []revision, team map[string]store.Agent, goals map[stri // runChildren executes the given children in parallel and returns each one's final status. // changes holds each child's part of a change the user asked for, if any. -func runChildren(j *job, kids []store.Agent, feedback, changes map[string]string) map[string]string { +func runChildren(j *job, kids []store.Agent, feedback, changes map[string]string, fix bool) map[string]string { var mu sync.Mutex var wg sync.WaitGroup res := map[string]string{} @@ -242,7 +430,7 @@ func runChildren(j *job, kids []store.Agent, feedback, changes map[string]string res[k.ID] = "already running" continue } - kj := &job{o: j.o, ctx: ctx, p: j.p, a: k, change: changes[k.ID]} + kj := &job{o: j.o, ctx: ctx, p: j.p, a: k, change: changes[k.ID], fix: fix && changes[k.ID] != ""} kj.status("starting", nil) // the hand-off shows on the canvas at once, not after the worktree is ready wg.Add(1) go func(k store.Agent) { @@ -281,7 +469,7 @@ func (j *job) decide(prompt, dir string, v any, valid func() error) error { } } -func (j *job) review(g store.Goal, dir string, ids []string, changes map[string]string) (map[string]verdict, error) { +func (j *job) review(g store.Goal, dir string, ids []string, changes map[string]string, fix bool) (map[string]verdict, error) { var b strings.Builder fmt.Fprintf(&b, "You are %s, a manager reviewing your team's work toward: %s\n", j.a.Name, g.Title) b.WriteString("Every item below already passed its checks. Judge the substance: accept only if the change does what its subgoal asks, meets its criteria, and changes nothing unrelated. Where the user asked for a change, accept only if it was made. When you reject, say exactly what to fix.\n") @@ -292,11 +480,11 @@ func (j *job) review(g store.Goal, dir string, ids []string, changes map[string] if err != nil { return nil, err } - if len(d) > maxDiffInReview { - d = d[:maxDiffInReview] + "\n… (diff truncated)" - } + d = reviewDiff(d, id, kg.Base) fmt.Fprintf(&b, "\n=== agent %q (%s)\nsubgoal: %s\ncriteria: %s\n", id, k.Name, kg.Title, kg.Criteria) - if c := changes[id]; c != "" { + if c := changes[id]; c != "" && fix { + fmt.Fprintf(&b, "fix you asked for, because the merged work failed your checks: %s\n", c) + } else if c != "" { fmt.Fprintf(&b, "change the user asked for: %s\n", c) } fmt.Fprintf(&b, "diff:\n%s\n", d) @@ -320,6 +508,56 @@ func (j *job) review(g store.Goal, dir string, ids []string, changes map[string] return vs, nil } +// reviewDiff fits a report's diff into the review: a list of every file it changed, then whole +// files' diffs up to maxDiffInReview bytes. Files that don't fit are named, with the command +// that shows them, so the manager never judges work it can't see. +func reviewDiff(d, id, base string) string { + files := splitDiff(d) + var b strings.Builder + b.WriteString("files changed:\n") + for _, f := range git.ParseDiff(d) { + fmt.Fprintf(&b, " %s (+%d -%d)\n", f.Path, f.Adds, f.Dels) + } + var left []string + size := 0 + for _, f := range files { + if size+len(f) > maxDiffInReview { + left = append(left, diffPath(f)) + continue + } + size += len(f) + b.WriteString(f) + } + if len(left) > 0 { + fmt.Fprintf(&b, "\n(%d file(s) too big to show here: %s. Read them with: git diff %s arranger/%s -- )\n", + len(left), strings.Join(left, ", "), base, id) + } + return b.String() +} + +// splitDiff splits a unified diff into one chunk per file. +func splitDiff(d string) []string { + var files []string + for len(d) > 0 { + next := strings.Index(d[1:], "\ndiff --git ") + if next < 0 { + files = append(files, d) + break + } + files = append(files, d[:next+2]) + d = d[next+2:] + } + return files +} + +// diffPath is the file a one-file diff chunk is about. +func diffPath(chunk string) string { + if fs := git.ParseDiff(chunk); len(fs) > 0 { + return fs[0].Path + } + return "?" +} + func validatePlan(sgs []subgoal, team map[string]store.Agent) error { if len(sgs) == 0 { return errors.New("the plan has no subgoals") @@ -343,11 +581,27 @@ func validatePlan(sgs []subgoal, team map[string]store.Agent) error { return nil } -// extractJSON decodes the outermost {...} in s, tolerating prose or code fences around it. +// extractJSON decodes the manager's answer from its reply: the last complete, non-empty JSON object +// in it. Replies often wrap the answer in prose, code fences or code with braces of their own. func extractJSON(s string, v any) error { - i, k := strings.Index(s, "{"), strings.LastIndex(s, "}") - if i < 0 || k < i { - return errors.New("no JSON object in the reply") + var objs []json.RawMessage + for i := 0; i < len(s); i++ { + if s[i] != '{' { + continue + } + dec := json.NewDecoder(strings.NewReader(s[i:])) + var raw json.RawMessage + if dec.Decode(&raw) != nil { + continue // prose like "{roughly}", or code + } + objs = append(objs, raw) + i += int(dec.InputOffset()) - 1 // skip what's inside it + } + for k := len(objs) - 1; k >= 0; k-- { + var keys map[string]json.RawMessage + if json.Unmarshal(objs[k], &keys) == nil && len(keys) > 0 { // skips {} from code like map[string]int{} + return json.Unmarshal(objs[k], v) + } } - return json.Unmarshal([]byte(s[i:k+1]), v) + return errors.New("no JSON object in the reply") } diff --git a/internal/orch/orch.go b/internal/orch/orch.go index ab359cf..f0a65e8 100644 --- a/internal/orch/orch.go +++ b/internal/orch/orch.go @@ -25,6 +25,7 @@ const ( maxAttempts = 3 // worker: first try + 2 retries fed with the failing checks' output maxPlanTries = 2 // manager: re-ask when the plan/review isn't valid JSON or misses checks maxReviewRounds = 3 // manager: review, re-run rejected children, review again... + maxFixRounds = 2 // manager: pass its failing checks to the team, re-run, check again... maxDiffInReview = 15000 ) @@ -36,15 +37,22 @@ type Orchestrator struct { Hub *Hub Root string // one worktree per agent lives here - slots chan struct{} // caps concurrent agent processes - mu sync.Mutex - running map[string]context.CancelFunc // agent id -> cancel of its run + slots chan struct{} // caps concurrent agent processes + mu sync.Mutex + running map[string]context.CancelFunc // agent id -> cancel of its run + approvals map[string]chan Decision // manager id -> its run, waiting for the user to decide on its plan } // New returns an orchestrator that runs at most parallel agent processes at once. func New(st *store.Store, root string, parallel int) *Orchestrator { return &Orchestrator{Store: st, Hub: NewHub(), Root: root, slots: make(chan struct{}, max(1, parallel)), - running: map[string]context.CancelFunc{}} + running: map[string]context.CancelFunc{}, approvals: map[string]chan Decision{}} +} + +// Remove deletes a removed agent's worktree and branch. +func (o *Orchestrator) Remove(repo, agentID string) error { + o.Hub.SetNow(agentID, "") + return git.RemoveWorktree(repo, o.Dir(agentID), agentID) } // Dir is the agent's worktree path (it may not exist yet). @@ -133,18 +141,41 @@ func (o *Orchestrator) Workspace(p store.Project, a store.Agent) (dir, base stri return "", "", err } base = strings.TrimSpace(out) - o.Store.SetGoal(a.ID, map[string]any{"base": base}) + o.Store.SetGoal(a.ID, map[string]any{"base": base, "from_branch": branch}) return dir, base, nil } +// errMoved means an agent that moved to another manager couldn't bring its work along. +var errMoved = errors.New("moved to another manager") + // Sync brings the agent's worktree up to date with the branch it works from (see BaseFor) and // returns its worktree, its new diff base, and how many commits came in. The agent's diff is // measured from the synced tip from then on, so upstream changes don't show as its work. +// +// An agent that moved to another manager since it last worked first has its own commits replayed +// onto its new manager's branch, so it keeps its work but not its old manager's. func (o *Orchestrator) Sync(p store.Project, a store.Agent) (dir, base string, n int, err error) { + prev, _ := o.Store.Goal(a.ID) if dir, base, err = o.Workspace(p, a); err != nil { return "", "", 0, err } from := BaseFor(p, a) + if prev.From != "" && prev.From != from && base == prev.Base { + out, err := git.Run(dir, "rev-parse", from) + if err != nil { + return dir, base, 0, err + } + tip := strings.TrimSpace(out) + if err := git.MoveOnto(dir, tip, base); err != nil { + return dir, base, 0, fmt.Errorf("%w: couldn't move %s's work from %s onto %s: %v", errMoved, a.Name, prev.From, from, err) + } + o.Store.SetGoal(a.ID, map[string]any{"base": tip, "from_branch": from}) + o.RefreshDiff(a.ID, dir, tip) + return dir, tip, 0, nil + } + if prev.From != from { + o.Store.SetGoal(a.ID, map[string]any{"from_branch": from}) + } if n, err = git.Sync(dir, from); err != nil || n == 0 { return dir, base, 0, err } @@ -181,7 +212,34 @@ func checkGoal(a store.Agent, g store.Goal) error { } // Start validates a run and starts it in the background. A manager runs its whole subtree. -func (o *Orchestrator) Start(agentID string) error { return o.start(agentID, "") } +func (o *Orchestrator) Start(agentID string) error { _, err := o.start(agentID, "", false); return err } + +// Continue picks a blocked manager's run back up where it stopped: the reports that finished keep +// their work, the others run again, and then everything is reviewed, merged and checked together, +// with no new plan. The goal it was on, if from its list, is marked running again. +func (o *Orchestrator) Continue(agentID string) error { + kids, err := o.Store.Children(agentID) + if err != nil { + return err + } + if len(kids) == 0 { + return errors.New("only a manager can continue; run this agent instead") + } + g, _ := o.Store.Goal(agentID) + if g.Status != "blocked" && g.Status != "failed" { + return fmt.Errorf("there's nothing to continue: it's %s", g.Status) + } + last, lastErr := o.Store.LastQueueItem(agentID) + session, err := o.start(agentID, "", true) + if err != nil { + return err + } + if lastErr == nil && last.Title == g.Title && (last.Status == "blocked" || last.Status == "failed") { + o.Store.StartQueueItem(last.ID, session) + o.queueChanged(agentID) + } + return nil +} // Revise re-runs an agent that already worked, to make the change the user describes. A worker // keeps its work and makes the change; a manager passes each part of it to the reports it @@ -190,59 +248,67 @@ func (o *Orchestrator) Revise(agentID, change string) error { if change = strings.TrimSpace(change); change == "" { return errors.New("describe what should change") } - return o.start(agentID, change) + _, err := o.start(agentID, change, false) + return err } -func (o *Orchestrator) start(agentID, change string) error { +// start returns the new run's log session. cont continues a blocked run (see Continue). +func (o *Orchestrator) start(agentID, change string, cont bool) (int64, error) { a, pid, err := o.Store.Agent(agentID) if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("agent %s not found; save the arrangement first", agentID) + return 0, fmt.Errorf("agent %s not found; save the arrangement first", agentID) } else if err != nil { - return fmt.Errorf("agent %s: %w", agentID, err) + return 0, fmt.Errorf("agent %s: %w", agentID, err) } p, err := o.Store.Project(pid) if err != nil { - return err + return 0, err } if p.Repo == "" { - return errors.New("set a git repository for this project first") + return 0, errors.New("set a git repository for this project first") } g, err := o.Store.Goal(agentID) if err != nil { - return err + return 0, err } if err := checkGoal(a, g); err != nil { - return err + return 0, err } ctx, cancel := context.WithCancel(context.Background()) if !o.register(agentID, cancel) { cancel() - return fmt.Errorf("%s is already running", a.Name) + return 0, fmt.Errorf("%s is already running", a.Name) } - j := &job{o: o, ctx: ctx, p: p, a: a, change: change} + j := &job{o: o, ctx: ctx, p: p, a: a, change: change, cont: cont, session: time.Now().UnixMilli()} if change != "" { j.emit("request", "you asked for changes: "+change, "") } j.status("starting", nil) // before returning, so the page shows it working right away go func() { - defer o.unregister(agentID) - defer cancel() - o.execute(j, "") + status := o.execute(j, "") + cancel() + o.unregister(agentID) + o.afterRun(agentID, status, j.session) // its queue, if it has one running, goes on }() - return nil + return j.session, nil } // job is one agent's run: where it works and how it reports. type job struct { - o *Orchestrator - ctx context.Context - p store.Project - a store.Agent - run int64 // current runs.id, tags events + o *Orchestrator + ctx context.Context + p store.Project + a store.Agent + run int64 // current runs.id, tags events + session int64 // this run as the user sees it (Run to result), tags events // change is what the user asked to change since the last run (or, for a report, the part of // it its manager passed down). "" for a plain run from the goal. change string + fix bool // change is a fix the manager asks for because the team's merged work fails its checks + cont bool // continuing a blocked run: same plan, only unfinished reports run + + allow []string // shell commands the agent may run without asking: a worker's own checks used int // tokens this run, across attempts warned bool // soft limit already reported @@ -250,9 +316,11 @@ type job struct { } func (j *job) emit(kind, text, raw string) { - e := store.LogEvent{Agent: j.a.ID, Run: j.run, Kind: kind, Text: text, Raw: raw} + e := store.LogEvent{Agent: j.a.ID, Run: j.run, Session: j.session, Kind: kind, Text: text, Raw: raw} j.o.Store.AddEvent(&e) - if kind != "stderr" { + if kind == "start" { + j.o.Hub.SetNow(j.a.ID, "started: "+agents.Clip(text, 80)) + } else if kind != "stderr" { j.o.Hub.SetNow(j.a.ID, kind+": "+agents.Clip(text, 80)) } j.o.Hub.Publish(j.p.ID, map[string]any{"type": "event", "event": e}) @@ -278,6 +346,9 @@ func (j *job) newRun(attempt int) { j.run = j.o.Store.NewRun(j.a.ID, attempt) } // execute runs an agent's goal and returns its final status. feedback comes from a reviewer. func (o *Orchestrator) execute(j *job, feedback string) (status string) { + if j.session == 0 { // a report its manager started + j.session = time.Now().UnixMilli() + } lg := zap.L().With(zap.String("agent", j.a.Name), zap.String("id", j.a.ID), zap.String("runtime", j.a.Runtime)) lg.Info("run started", zap.String("project", j.p.Name), zap.Bool("rerun", feedback != "")) start := time.Now() @@ -289,6 +360,7 @@ func (o *Orchestrator) execute(j *job, feedback string) (status string) { if err != nil { return j.fail("failed", err.Error()) } + j.emit("start", g.Title, "") // opens this run in the log right away kids, err := o.Store.Children(j.a.ID) if err != nil { return j.fail("failed", err.Error()) @@ -298,6 +370,8 @@ func (o *Orchestrator) execute(j *job, feedback string) (status string) { switch { case dir == "": return j.fail("failed", err.Error()) + case errors.Is(err, errMoved): // working on anyway would mix two managers' work + return j.fail("blocked", "needs you: "+err.Error()) case err != nil: j.emit("warn", "couldn't sync with "+BaseFor(j.p, j.a)+", working from where this agent left off: "+err.Error(), "") case n > 0: @@ -313,13 +387,15 @@ func (o *Orchestrator) execute(j *job, feedback string) (status string) { func runWorker(j *job, g store.Goal, dir, feedback string) string { checks := lines(g.Checks) + j.allow = checks // so it can run its checks itself before it finishes for attempt := 1; attempt <= maxAttempts; attempt++ { j.status("running", map[string]any{"attempts": attempt}) j.newRun(attempt) j.emit("msg", fmt.Sprintf("attempt %d/%d with %s", attempt, maxAttempts, j.a.Runtime), "") - _, agentErr := runAgent(j, workerPrompt(j.a, g, dir, checks, feedback, j.change), dir) - if sha, err := git.Commit(dir, fmt.Sprintf("arranger: %s attempt %d", j.a.Name, attempt)); err != nil { + summary, agentErr := runAgent(j, workerPrompt(j.a, g, dir, checks, feedback, j.change, j.fix), dir) + msg := checkpointMessage(summary, j.change, g.Title) + if sha, err := git.Commit(dir, msg); err != nil { j.emit("error", err.Error(), "") } else if sha != "" { j.emit("msg", "checkpoint "+sha, "") diff --git a/internal/orch/orch_test.go b/internal/orch/orch_test.go index 1c2bf25..132eb8b 100644 --- a/internal/orch/orch_test.go +++ b/internal/orch/orch_test.go @@ -1,6 +1,8 @@ package orch import ( + "context" + "fmt" "os" "os/exec" "path/filepath" @@ -142,6 +144,13 @@ esac`} if _, err := git.Run(repo, "cat-file", "-e", "arranger/m:junk.txt"); err == nil { t.Fatal("manager's own file edits should be discarded") } + // each commit is one line saying what changed: the report's checkpoint, and the merge that brings it in + if out, _ := git.Run(repo, "log", "-1", "--format=%B", "arranger/a"); strings.TrimSpace(out) != "Write a" { + t.Fatalf("Alpha's checkpoint message: %q", out) + } + if out, _ := git.Run(repo, "log", "--merges", "--format=%s", "arranger/m"); !strings.Contains(out, "Write a\n") || strings.Contains(out, "Alpha") { + t.Fatalf("Lead's merge messages: %q", out) + } var decisions int o.Store.SQL().QueryRow(`SELECT count(*) FROM decisions WHERE agent_id='m'`).Scan(&decisions) if decisions != 3 { // plan + two reviews @@ -395,3 +404,351 @@ func TestValidateRevision(t *testing.T) { } } } + +// When the team's merged work fails the manager's own checks, the manager passes the failure to the +// report it concerns, which fixes it; the merged result then passes. Reports it doesn't name don't re-run. +func TestManagerFixesFailingChecks(t *testing.T) { + for _, tc := range []struct { + name, check, status string + runsA int + }{ + {"fixed", "grep -q fixed a.txt", "done", 2}, + {"gives up", "false", "failed", 1 + maxFixRounds}, + } { + t.Run(tc.name, func(t *testing.T) { + o := setup(t) + plain := func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} } + agents.Runtimes["fixmgr"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat) +case "$p" in +*"your own checks fail on the merged result"*) echo '{"changes":[{"agent":"a","change":"write fixed into a.txt"}]}' ;; +*"YOUR TEAM:"*) echo '{"subgoals":[{"agent":"a","title":"write a","checks":["test -f a.txt"]},{"agent":"b","title":"write b","checks":["test -f b.txt"]}]}' ;; +*) echo '{"verdicts":[]}' ;; +esac`} + }} + agents.Runtimes["fixworker"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat) +case "$p" in +*"ITS CHECKS FAIL"*"write fixed into a.txt"*) echo fixed > a.txt ;; +*"You are Alpha"*) echo a > a.txt ;; +*) echo b > b.txt ;; +esac`} + }} + defer delete(agents.Runtimes, "fixmgr") + defer delete(agents.Runtimes, "fixworker") + + repo := newRepo(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: repo, Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{ + {ID: "m", Name: "Lead", Role: "manager", Runtime: "fixmgr"}, + {ID: "a", Name: "Alpha", Role: "coder", Parent: "m", Runtime: "fixworker"}, + {ID: "b", Name: "Beta", Role: "coder", Parent: "m", Runtime: "fixworker"}, + }) + o.Store.SaveGoal("m", store.Goal{Title: "both files", Checks: tc.check}) + if err := o.Start("m"); err != nil { + t.Fatal(err) + } + g := waitDone(t, o, "m") + if g.Status != tc.status { + es, _ := o.Store.Events("m", 60) + eb, _ := o.Store.Events("b", 60) + t.Fatalf("manager: %+v\nevents: %+v\nbeta: %+v", g, es, eb) + } + var runsA, runsB, fixes int + o.Store.SQL().QueryRow(`SELECT count(*) FROM runs WHERE agent_id='a'`).Scan(&runsA) + o.Store.SQL().QueryRow(`SELECT count(*) FROM runs WHERE agent_id='b'`).Scan(&runsB) + o.Store.SQL().QueryRow(`SELECT count(*) FROM decisions WHERE agent_id='m' AND kind='fix'`).Scan(&fixes) + if runsA != tc.runsA || runsB != 1 || fixes != tc.runsA-1 { + t.Fatalf("runs a=%d b=%d, fix rounds %d", runsA, runsB, fixes) + } + if tc.status == "done" { + if out, _ := git.Run(repo, "show", "arranger/m:a.txt"); strings.TrimSpace(out) != "fixed" { + t.Fatalf("the fix should be merged into the manager: a.txt = %q", out) + } + } else if !strings.Contains(g.Feedback, fmt.Sprintf("after %d fix rounds", maxFixRounds)) { + t.Fatalf("feedback: %q", g.Feedback) + } + }) + } +} + +// alive reports whether the process whose pid is in file is still running. +func alive(t *testing.T, file string) bool { + t.Helper() + var b []byte + for deadline := time.Now().Add(5 * time.Second); len(strings.TrimSpace(string(b))) == 0; time.Sleep(20 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("no pid in %s", file) + } + b, _ = os.ReadFile(file) + } + return exec.Command("kill", "-0", strings.TrimSpace(string(b))).Run() == nil +} + +// A check that leaves a process running in the background still finishes, and the process is killed. +func TestCheckWithBackgroundProcess(t *testing.T) { + o := setup(t) + pidFile := filepath.Join(t.TempDir(), "pid") + j := &job{o: o, ctx: context.Background(), a: store.Agent{ID: "w", Name: "W"}} + start := time.Now() + passed, fb := runChecks(j, t.TempDir(), []string{"sleep 30 & echo $! > " + pidFile + "; echo started"}) + if passed != 1 { + t.Fatalf("the check itself passed: %s", fb) + } + if time.Since(start) > 10*time.Second { + t.Fatal("the check waited for its background process") + } + for deadline := time.Now().Add(3 * time.Second); alive(t, pidFile); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the background process outlived its check") + } + } +} + +// Whatever an agent starts goes away with it: when it's stopped, and when it exits on its own. +func TestAgentProcessGroup(t *testing.T) { + for _, stop := range []bool{true, false} { + o := setup(t) + pidFile := filepath.Join(t.TempDir(), "pid") + script := "cat >/dev/null; sleep 300 & echo $! > " + pidFile + "; echo started; " + if stop { + script += "sleep 300" + } + agents.Runtimes["spawner"] = agents.Runtime{Bin: "sh", Parse: func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} }, + Args: func(string) []string { return []string{"-c", script} }} + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: newRepo(t), Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{{ID: "w", Name: "W", Role: "coder", Runtime: "spawner"}}) + o.Store.SaveGoal("w", store.Goal{Title: "spawn", Checks: "true"}) + start := time.Now() + if err := o.Start("w"); err != nil { + t.Fatal(err) + } + if stop { + alive(t, pidFile) // wait until it's up + o.Stop("w") + } + g := waitDone(t, o, "w") + if want := map[bool]string{true: "stopped", false: "done"}[stop]; g.Status != want { + t.Fatalf("stop=%v: status %q", stop, g.Status) + } + if time.Since(start) > 15*time.Second { + t.Fatalf("stop=%v: the run waited on the background process", stop) + } + for deadline := time.Now().Add(3 * time.Second); alive(t, pidFile); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("stop=%v: the agent's background process is still running", stop) + } + } + delete(agents.Runtimes, "spawner") + } +} + +// Stderr lines that arrive in pieces come out whole. +func TestLineWriter(t *testing.T) { + var got []string + w := &lineWriter{emit: func(l string) { got = append(got, l) }} + w.Write([]byte("hel")) + w.Write([]byte("lo wor")) + w.Write([]byte("ld\nsecond\nthi")) + w.Write([]byte("rd")) + w.Flush() + if strings.Join(got, "|") != "hello world|second|third" { + t.Fatalf("lines: %q", got) + } +} + +// A manager's reply often has prose or code with braces around the JSON; the plan is still found. +func TestExtractJSONAmongBraces(t *testing.T) { + for name, reply := range map[string]string{ + "brace in prose": "I'll split it {roughly} in two:\n{\"subgoals\":[{\"agent\":\"a\",\"title\":\"x\",\"checks\":[\"true\"]}]}", + "code before": "The handler is `func h() { return }`. Plan:\n```json\n{\"subgoals\":[{\"agent\":\"a\",\"title\":\"x\",\"checks\":[\"true\"]}]}\n```\nDone {ok}.", + "brace after": "{\"subgoals\":[{\"agent\":\"a\",\"title\":\"x\",\"checks\":[\"true\"]}]}\nNote: map[string]int{} is fine.", + "draft then final": "First try: {\"subgoals\":[]}\nBetter:\n{\"subgoals\":[{\"agent\":\"a\",\"title\":\"x\",\"checks\":[\"true\"]}]}", + } { + var v struct{ Subgoals []subgoal } + if err := extractJSON(reply, &v); err != nil || len(v.Subgoals) != 1 || v.Subgoals[0].Title != "x" { + t.Errorf("%s: %v %+v", name, err, v) + } + } + var v struct{ Subgoals []subgoal } + if extractJSON("no json here {at all}", &v) == nil { + t.Error("a reply without a JSON object should be an error") + } +} + +// A big change is never cut mid-file for review: every file is listed, whole files are shown up to +// the limit, and the rest are named with the command that shows them. +func TestReviewDiff(t *testing.T) { + small := "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1 +1 @@\n-x\n+y\n" + big := "diff --git a/big.go b/big.go\n--- a/big.go\n+++ b/big.go\n@@ -0,0 +1,1 @@\n+" + strings.Repeat("z", maxDiffInReview) + "\n" + got := reviewDiff(small+big, "w", "abc123") + for _, want := range []string{"a.go (+1 -1)", "big.go (+1 -0)", "+y", "1 file(s) too big to show here: big.go", "git diff abc123 arranger/w -- "} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%.400s", want, got) + } + } + if strings.Contains(got, "zzzz") { + t.Error("the file that doesn't fit shouldn't be half shown") + } + if got := reviewDiff(small, "w", "b"); strings.Contains(got, "too big") || !strings.Contains(got, "+y") { + t.Errorf("a small diff is shown whole: %s", got) + } +} + +// An agent moved to another manager keeps its own work and drops its old manager's, instead of +// showing the old manager's work as its own. +func TestMoveToAnotherManager(t *testing.T) { + o := setup(t) + repo := newRepo(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: repo, Base: "main"}) + arrange := func(parent string) { + o.Store.SaveArrangement(p.ID, []store.Agent{ + {ID: "m1", Name: "M1", Role: "manager"}, {ID: "m2", Name: "M2", Role: "manager"}, + {ID: "c", Name: "C", Role: "coder", Parent: parent}, + }) + } + arrange("m1") + agent := func(id string) store.Agent { a, _, _ := o.Store.Agent(id); return a } + work := func(id, file string) { + dir, _, _, err := o.Sync(p, agent(id)) + if err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, file), []byte(id+"\n"), 0o644) + if _, err := git.Commit(dir, id+" work"); err != nil { + t.Fatal(err) + } + } + work("m1", "m1.txt") + work("m2", "m2.txt") + work("c", "c.txt") // under M1: has m1.txt too + + arrange("m2") + dir, base, _, err := o.Sync(p, agent("c")) + if err != nil { + t.Fatal(err) + } + fs, _ := git.Diff(dir, base) + if len(fs) != 1 || fs[0].Path != "c.txt" { + t.Fatalf("after the move its diff should be only its own work: %+v", fs) + } + for file, want := range map[string]bool{"c.txt": true, "m2.txt": true, "m1.txt": false} { + if _, err := os.Stat(filepath.Join(dir, file)); (err == nil) != want { + t.Errorf("%s present=%v, want %v", file, err == nil, want) + } + } + if g, _ := o.Store.Goal("c"); g.From != "arranger/m2" { + t.Fatalf("from: %q", g.From) + } +} + +// A page too slow for the live updates is told it missed some, once. +func TestHubReportsDrops(t *testing.T) { + h := NewHub() + c := h.Subscribe("p") + if h.Dropped(c) { + t.Fatal("nothing dropped yet") + } + for range cap(c) + 5 { + h.Publish("p", map[string]any{"type": "status"}) + } + if !h.Dropped(c) || h.Dropped(c) { + t.Fatal("drops should be reported exactly once") + } +} + +// Everything one run logs shares a session, attempts included, so the Logs tab can show it as +// one run; the next run gets a new one. +func TestEventsGroupedByRun(t *testing.T) { + o := setup(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: newRepo(t), Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{{ID: "w", Name: "W", Role: "programmer", Runtime: "generic", Args: "true"}}) + o.Store.SaveGoal("w", store.Goal{Title: "t", Checks: "test -f nope"}) // fails, so it retries + for range 2 { + if err := o.Start("w"); err != nil { + t.Fatal(err) + } + waitDone(t, o, "w") + } + es, _ := o.Store.Events("w", 500) + sessions, attempts := map[int64]bool{}, map[int]bool{} + for _, e := range es { + if e.Session == 0 { + t.Fatalf("event without a session: %+v", e) + } + sessions[e.Session] = true + attempts[e.Attempt] = true + } + if len(sessions) != 2 || !attempts[1] || !attempts[3] { + t.Fatalf("sessions %v, attempts %v", sessions, attempts) + } +} + +// The agent's own "Commit:" line is the message; without one, the change or goal it worked on. +func TestCheckpointMessage(t *testing.T) { + for _, c := range []struct{ summary, change, goal, want string }{ + {"Did the thing.\n\nCommit: add retry with backoff to webhook sender.", "", "webhooks", "Add retry with backoff to webhook sender"}, + {"**Commit:** `Rename LoginAttempts to Attempts`", "", "x", "Rename LoginAttempts to Attempts"}, + {"I will commit this later.", "", "add rate limiting to /login", "Add rate limiting to /login"}, + {"", "make the button say Subscribe", "checkout", "Make the button say Subscribe"}, + {"Commit: first\nmore work\nCommit: second, final", "", "x", "Second, final"}, + } { + if got := checkpointMessage(c.summary, c.change, c.goal); got != c.want { + t.Errorf("%q: got %q, want %q", c.summary, got, c.want) + } + } +} + +// A blocked manager continues where it stopped: the report that finished keeps its work and isn't +// run again, the one that failed runs again, and then both are reviewed, merged and checked. +func TestContinueBlockedRun(t *testing.T) { + o := setup(t) + fixed := filepath.Join(t.TempDir(), "fixed") + plain := func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} } + agents.Runtimes["contmgr"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + return []string{"-c", `p=$(cat); case "$p" in *"YOUR TEAM:"*) echo '{"subgoals":[{"agent":"a","title":"write a","checks":["test -f a.txt"]},{"agent":"b","title":"write b","checks":["test -f b.txt"]}]}' ;; *) echo '{"verdicts":[]}' ;; esac`} + }} + agents.Runtimes["contworker"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string { + // Beta can't do its part until the user fixes something + return []string{"-c", `p=$(cat); case "$p" in *"You are Alpha"*) echo a > a.txt ;; *) test -f ` + fixed + ` && echo b > b.txt ;; esac; true`} + }} + defer delete(agents.Runtimes, "contmgr") + defer delete(agents.Runtimes, "contworker") + + repo := newRepo(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: repo, Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{ + {ID: "m", Name: "Lead", Role: "manager", Runtime: "contmgr"}, + {ID: "a", Name: "Alpha", Role: "programmer", Parent: "m", Runtime: "contworker"}, + {ID: "b", Name: "Beta", Role: "programmer", Parent: "m", Runtime: "contworker"}, + }) + o.Store.SaveGoal("m", store.Goal{Title: "both files", Checks: "test -f a.txt && test -f b.txt"}) + if err := o.Continue("m"); err == nil { + t.Fatal("nothing to continue before a run") + } + o.Start("m") + if g := waitDone(t, o, "m"); g.Status != "blocked" { + t.Fatalf("first run: %+v", g) + } + runs := func(id string) (n int) { + o.Store.SQL().QueryRow(`SELECT count(*) FROM runs WHERE agent_id=?`, id).Scan(&n) + return + } + ranA, ranB := runs("a"), runs("b") + os.WriteFile(fixed, nil, 0o644) // the user fixes what Beta needed + if err := o.Continue("m"); err != nil { + t.Fatal(err) + } + if g := waitDone(t, o, "m"); g.Status != "done" { + es, _ := o.Store.Events("m", 50) + t.Fatalf("continued run: %+v\n%+v", g, es) + } + if runs("a") != ranA || runs("b") <= ranB { + t.Fatalf("Alpha should keep its work (runs %d -> %d), Beta should run again (%d -> %d)", ranA, runs("a"), ranB, runs("b")) + } + for _, f := range []string{"a.txt", "b.txt"} { + if _, err := git.Run(repo, "cat-file", "-e", "arranger/m:"+f); err != nil { + t.Fatalf("%s should be merged into the manager", f) + } + } +} diff --git a/internal/orch/procgroup_other.go b/internal/orch/procgroup_other.go new file mode 100644 index 0000000..b567aa2 --- /dev/null +++ b/internal/orch/procgroup_other.go @@ -0,0 +1,15 @@ +//go:build !unix + +package orch + +import "os/exec" + +// ownGroup and killGroup only kill the process itself where process groups don't exist. +func ownGroup(cmd *exec.Cmd) {} + +func killGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + return cmd.Process.Kill() +} diff --git a/internal/orch/procgroup_unix.go b/internal/orch/procgroup_unix.go new file mode 100644 index 0000000..3e301cf --- /dev/null +++ b/internal/orch/procgroup_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package orch + +import ( + "os/exec" + "syscall" +) + +// ownGroup runs cmd in a process group of its own, so everything it starts (test servers, +// watchers, builds) can be killed with it. Cancelling cmd's context kills the whole group. +func ownGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return killGroup(cmd) } +} + +// killGroup kills cmd's process group: whatever it left running after it exited, or everything +// when it's stopped. +func killGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return nil + } + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) +} diff --git a/internal/orch/prompts.go b/internal/orch/prompts.go index c84d1b0..7246173 100644 --- a/internal/orch/prompts.go +++ b/internal/orch/prompts.go @@ -22,7 +22,7 @@ func goalText(b *strings.Builder, g store.Goal, checks []string) { } } -func workerPrompt(a store.Agent, g store.Goal, dir string, checks []string, feedback, change string) string { +func workerPrompt(a store.Agent, g store.Goal, dir string, checks []string, feedback, change string, fix bool) string { var b strings.Builder fmt.Fprintf(&b, "You are %s, a %s agent. You work in the git worktree %s; only change files there.\n", a.Name, a.Role, dir) if a.Prompt != "" { @@ -30,10 +30,14 @@ func workerPrompt(a store.Agent, g store.Goal, dir string, checks []string, feed } goalText(&b, g, checks) b.WriteString("\nStay within the goal. Don't claim something works unless you verified it. Don't commit; the orchestrator does.\n") + b.WriteString("End your reply with one line describing what you changed, used as the commit message, in this form:\n" + + "Commit: \n") if g.Notes != "" { b.WriteString("\nTHE USER REMOVED THESE CHANGES OF YOURS. Do not re-add them:\n" + g.Notes + "\n") } - if change != "" { + if change != "" && fix { + b.WriteString("\nYOU ALREADY WORKED ON THIS GOAL. YOUR MANAGER MERGED THE TEAM'S WORK AND ITS CHECKS FAIL; IT NEEDS THIS FIXED. Keep the rest of your work; fix only this:\n" + change + "\n") + } else if change != "" { b.WriteString("\nYOU ALREADY WORKED ON THIS GOAL, AND THE USER ASKS FOR THESE CHANGES. Keep the rest of your work; change only this:\n" + change + "\n") } if feedback != "" { @@ -103,3 +107,32 @@ Reply with ONLY a JSON object, no prose: {"changes":[{"agent":"","change":"...","checks":["optional extra check"]}]}`) return b.String() } + +// fixPrompt asks a manager whose team's merged work fails its checks which reports must fix what. +// Like revisePrompt, the reports keep their goal and work and get only their part of the fix. +func fixPrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[string]store.Goal, failure string) string { + var b strings.Builder + fmt.Fprintf(&b, "You are %s, a manager agent. You delegate and review; you do not edit files.\n", a.Name) + if a.Prompt != "" { + b.WriteString("\n" + a.Prompt + "\n") + } + b.WriteString("\nYour team's work was merged into your branch, but your own checks fail on the merged result:\n" + failure + "\n") + goalText(&b, g, lines(g.Checks)) + b.WriteString("\nYOUR TEAM AND THE SUBGOAL EACH ONE WORKED ON:\n") + for _, k := range kids { + kg := goals[k.ID] + fmt.Fprintf(&b, "- id %q: %s, role %s", k.ID, k.Name, k.Role) + if kg.Title == "" { + b.WriteString(": no subgoal, can't take a fix\n") + continue + } + fmt.Fprintf(&b, ": subgoal %q (checks: %s)\n", kg.Title, strings.Join(lines(kg.Checks), "; ")) + } + b.WriteString(` +Read the repository and the failure output. The parts may each work alone but not together, e.g. one member calls a +function another named differently. Decide which members must fix what, and give each one a concrete instruction. +Leave out members that need no change. When a shell check can prove the fix, add it (checks run from the repository root). +Reply with ONLY a JSON object, no prose: +{"changes":[{"agent":"","change":"...","checks":["optional extra check"]}]}`) + return b.String() +} diff --git a/internal/orch/queue.go b/internal/orch/queue.go new file mode 100644 index 0000000..8e53d74 --- /dev/null +++ b/internal/orch/queue.go @@ -0,0 +1,99 @@ +package orch + +import ( + "database/sql" + "errors" + "fmt" + + "arranger/internal/store" +) + +// A queue runs an agent's goals one after another, each as its own run on the code the previous +// one left. A goal that fails pauses the queue, or is skipped, as the user chose. + +// StartQueue starts running the agent's queued goals: the next one now, or when its current run ends. +func (o *Orchestrator) StartQueue(agentID string) error { + if _, err := o.Store.NextQueueItem(agentID); errors.Is(err, sql.ErrNoRows) { + return errors.New("the queue is empty; add a goal first") + } else if err != nil { + return err + } + on := true + if err := o.Store.SetQueue(agentID, &on, ""); err != nil { + return err + } + o.queueChanged(agentID) + if o.IsRunning(agentID) { + return nil // the next goal starts when this run ends + } + return o.runNext(agentID) +} + +// PauseQueue stops the queue after the goal it's running. +func (o *Orchestrator) PauseQueue(agentID string) error { + off := false + err := o.Store.SetQueue(agentID, &off, "") + o.queueChanged(agentID) + return err +} + +// runNext makes the next queued goal the agent's goal and runs it. With nothing left, the queue stops. +func (o *Orchestrator) runNext(agentID string) error { + for { + it, err := o.Store.NextQueueItem(agentID) + if errors.Is(err, sql.ErrNoRows) { + off := false + o.Store.SetQueue(agentID, &off, "") + o.queueChanged(agentID) + return nil + } + if err != nil { + return err + } + if err := o.Store.SaveGoal(agentID, store.Goal{Title: it.Title, Body: it.Body, Criteria: it.Criteria, Checks: it.Checks}); err != nil { + return err + } + session, err := o.start(agentID, "", false) + if err == nil { + o.Store.StartQueueItem(it.ID, session) + o.queueChanged(agentID) + return nil + } + // it couldn't even start (say, its runtime isn't installed): that's a failure like any other + o.Store.FinishQueueItem(it.ID, "failed") + if q, _ := o.Store.Queue(agentID); q.OnFail != "skip" { + off := false + o.Store.SetQueue(agentID, &off, "") + o.queueChanged(agentID) + return fmt.Errorf("%q couldn't start: %w", it.Title, err) + } + } +} + +// afterRun records how a queued goal's run ended and, while the queue is on, moves on: to the +// next goal after a success; after a failure, to the next or nowhere, as the user chose. A run the +// user stopped pauses the queue. +func (o *Orchestrator) afterRun(agentID, status string, session int64) { + if it, err := o.Store.RunningQueueItem(agentID); err == nil && it.Session == session { + o.Store.FinishQueueItem(it.ID, status) + o.queueChanged(agentID) + } + q, err := o.Store.Queue(agentID) + if err != nil || !q.Active { + return + } + if status == "done" || status != "stopped" && q.OnFail == "skip" { + o.runNext(agentID) + return + } + off := false + o.Store.SetQueue(agentID, &off, "") + o.queueChanged(agentID) +} + +// queueChanged tells the page the agent's queue changed. +func (o *Orchestrator) queueChanged(agentID string) { + if _, pid, err := o.Store.Agent(agentID); err == nil { + o.Hub.Publish(pid, map[string]any{"type": "queue", "agent": agentID}) + } +} diff --git a/internal/orch/queue_test.go b/internal/orch/queue_test.go new file mode 100644 index 0000000..bbf3a6e --- /dev/null +++ b/internal/orch/queue_test.go @@ -0,0 +1,95 @@ +package orch + +import ( + "testing" + "time" + + "arranger/internal/store" +) + +// waitQueue waits until the agent's queue stops running and nothing is running. +func waitQueue(t *testing.T, o *Orchestrator, id string) store.Queue { + t.Helper() + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if q, _ := o.Store.Queue(id); !q.Active && !o.IsRunning(id) { + return q + } + } + t.Fatal("queue never finished") + return store.Queue{} +} + +// A queue runs its goals one after another, each as its own run; a failure pauses it or is +// skipped, as chosen. +func TestQueue(t *testing.T) { + for _, onFail := range []string{"pause", "skip"} { + t.Run(onFail, func(t *testing.T) { + o := setup(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: newRepo(t), Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{{ID: "w", Name: "W", Role: "programmer", Runtime: "generic", Args: "true"}}) + if err := o.StartQueue("w"); err == nil { + t.Fatal("an empty queue shouldn't start") + } + for _, it := range []store.QueueItem{ + {Agent: "w", Title: "first", Checks: "true"}, + {Agent: "w", Title: "second", Checks: "false"}, // fails every attempt + {Agent: "w", Title: "third", Checks: "true"}, + } { + if err := o.Store.AddQueueItem(&it); err != nil { + t.Fatal(err) + } + } + o.Store.SetQueue("w", nil, onFail) + if err := o.StartQueue("w"); err != nil { + t.Fatal(err) + } + q := waitQueue(t, o, "w") + got := map[string]string{} + sessions := map[int64]bool{} + for _, it := range q.Items { + got[it.Title] = it.Status + if it.Session != 0 { + sessions[it.Session] = true + } + } + want := map[string]string{"first": "done", "second": "failed", "third": "queued"} + if onFail == "skip" { + want["third"] = "done" + } + for title, st := range want { + if got[title] != st { + t.Errorf("%s: %q, want %q (all: %v)", title, got[title], st, got) + } + } + if len(sessions) != len(want)-map[string]int{"pause": 1, "skip": 0}[onFail] { + t.Errorf("each goal that ran should have its own run: %v", sessions) + } + s, _ := o.Store.Summary(p.ID) + if paused := s.Agents["w"].QueuePaused; (onFail == "pause") != (paused == "second") { + t.Errorf("paused on: %q", paused) + } + }) + } +} + +// Stopping the goal a queue is running pauses the queue instead of moving on. +func TestStopPausesQueue(t *testing.T) { + o := setup(t) + p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: newRepo(t), Base: "main"}) + o.Store.SaveArrangement(p.ID, []store.Agent{{ID: "w", Name: "W", Role: "programmer", Runtime: "generic", Args: "sleep 30"}}) + for _, title := range []string{"slow", "next"} { + o.Store.AddQueueItem(&store.QueueItem{Agent: "w", Title: title, Checks: "true"}) + } + o.Store.SetQueue("w", nil, "skip") // even with skip, a stop means stop + if err := o.StartQueue("w"); err != nil { + t.Fatal(err) + } + for !o.IsRunning("w") { + time.Sleep(20 * time.Millisecond) + } + o.Stop("w") + q := waitQueue(t, o, "w") + if q.Active || q.Items[0].Title != "next" || q.Items[0].Status != "queued" { + t.Fatalf("queue after stop: %+v", q) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b6e326a..c60bd50 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,6 +1,7 @@ package server import ( + "database/sql" "errors" "fmt" "net/http" @@ -9,8 +10,11 @@ import ( "strings" "unicode/utf8" + "go.uber.org/zap" + "arranger/internal/agents" "arranger/internal/git" + "arranger/internal/orch" "arranger/internal/store" ) @@ -90,16 +94,39 @@ func (s *Server) saveArrangement(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, err) return } + parent := map[string]string{} + for _, a := range as { + parent[a.ID] = a.Parent + } + names := map[string]string{} + for _, a := range old { + names[a.ID] = a.Name + } for _, a := range old { if !keep[a.ID] && s.o.IsRunning(a.ID) { fail(w, http.StatusConflict, fmt.Errorf("%s is running; stop it before removing it", a.Name)) return } + // a waiting plan names its manager's reports; they stay put until it's decided + if a.Parent != "" && s.o.Awaiting(a.Parent) && (!keep[a.ID] || parent[a.ID] != a.Parent) { + fail(w, http.StatusConflict, fmt.Errorf("%s's plan is waiting for you; approve or cancel it before moving or removing %s", names[a.Parent], a.Name)) + return + } } if err := s.st.SaveArrangement(r.PathValue("id"), as); err != nil { fail(w, http.StatusBadRequest, err) return } + // removed agents take their worktree and branch with them (the page warned about unmerged work) + if p, err := s.st.Project(r.PathValue("id")); err == nil && p.Repo != "" { + for _, a := range old { + if !keep[a.ID] { + if err := s.o.Remove(p.Repo, a.ID); err != nil { + zap.L().Warn("clean up removed agent", zap.String("agent", a.Name), zap.Error(err)) + } + } + } + } w.WriteHeader(http.StatusNoContent) } @@ -114,7 +141,17 @@ func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, err) return } - writeJSON(w, map[string]any{"agent": a, "goal": g}) + // dir is where the agent works, "" until its first run made the worktree + dir, shown := s.o.Dir(a.ID), "" + if _, err := os.Stat(dir); err == nil { + shown = dir + if home, err := os.UserHomeDir(); err == nil { + if rest, ok := strings.CutPrefix(dir, home+string(filepath.Separator)); ok { + shown = "~/" + rest + } + } + } + writeJSON(w, map[string]any{"agent": a, "goal": g, "dir": shown, "branch": git.BranchOf(a.ID)}) } func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { @@ -162,12 +199,72 @@ func (s *Server) run(w http.ResponseWriter, r *http.Request) { } // revise re-runs an agent to make the change the user describes (see Orchestrator.Revise). +// With a goal (a finished goal of its list), the change is about that goal: it becomes the +// agent's goal again, and the change is made on top of the agent's current work. func (s *Server) revise(w http.ResponseWriter, r *http.Request) { - var req struct{ Change string } + var req struct { + Change string + Goal int64 + } if !readJSON(w, r, &req) { return } - if err := s.o.Revise(r.PathValue("id"), req.Change); err != nil { + id := r.PathValue("id") + if req.Goal != 0 { + it, err := s.st.QueueItem(id, req.Goal) + if err != nil { + http.Error(w, "that goal isn't in this agent's list", http.StatusNotFound) + return + } + if s.o.IsRunning(id) { + http.Error(w, "stop it before asking for changes", http.StatusConflict) + return + } + if err := s.st.SaveGoal(id, store.Goal{Title: it.Title, Body: it.Body, Criteria: it.Criteria, Checks: it.Checks}); err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + } + if err := s.o.Revise(id, req.Change); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusAccepted) +} + +// getPlan returns the agent's plan waiting for the user's decision. +func (s *Server) getPlan(w http.ResponseWriter, r *http.Request) { + p, err := s.st.PendingPlan(r.PathValue("id")) + if errors.Is(err, sql.ErrNoRows) || err == nil && !s.o.Awaiting(p.Agent) { + http.Error(w, "no plan is waiting for a decision", http.StatusNotFound) + return + } + if err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, p) +} + +// decidePlan approves (possibly edited), sends back, or cancels the agent's waiting plan. +func (s *Server) decidePlan(w http.ResponseWriter, r *http.Request) { + var d orch.Decision + if !readJSON(w, r, &d) { + return + } + if err := s.o.Decide(r.PathValue("id"), d); errors.Is(err, orch.ErrStale) { + fail(w, http.StatusConflict, err) + return + } else if err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusAccepted) +} + +// continueRun picks a blocked manager's run back up (see Orchestrator.Continue). +func (s *Server) continueRun(w http.ResponseWriter, r *http.Request) { + if err := s.o.Continue(r.PathValue("id")); err != nil { fail(w, http.StatusBadRequest, err) return } @@ -179,7 +276,7 @@ func (s *Server) stop(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// runAll starts every top-level agent that has a goal; managers run their own subtrees. +// runAll starts every top-level agent's goals; managers run their own subtrees. func (s *Server) runAll(w http.ResponseWriter, r *http.Request) { as, err := s.st.Agents(r.PathValue("id")) if err != nil { @@ -188,10 +285,10 @@ func (s *Server) runAll(w http.ResponseWriter, r *http.Request) { } started, errs := 0, []string{} for _, a := range as { - if g, _ := s.st.Goal(a.ID); a.Parent != "" || g.Title == "" { - continue + if _, err := s.st.NextQueueItem(a.ID); a.Parent != "" || err != nil { + continue // a report, or no goals to run } - if err := s.o.Start(a.ID); err != nil { + if err := s.o.StartQueue(a.ID); err != nil { errs = append(errs, err.Error()) } else { started++ @@ -209,6 +306,16 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { writeJSON(w, es) } +// runs lists the agent's process calls with their time and tokens, for the Logs tab. +func (s *Server) runs(w http.ResponseWriter, r *http.Request) { + rs, err := s.st.Runs(r.PathValue("id"), 500) + if err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, rs) +} + func (s *Server) summary(w http.ResponseWriter, r *http.Request) { sum, err := s.st.Summary(r.PathValue("id")) if err != nil { @@ -291,6 +398,9 @@ func (s *Server) sse(w http.ResponseWriter, r *http.Request) { return case b := <-c: fmt.Fprintf(w, "data: %s\n\n", b) + if s.o.Hub.Dropped(c) { + fmt.Fprint(w, "data: {\"type\":\"resync\"}\n\n") // it missed updates: reload the state + } flusher.Flush() } } diff --git a/internal/server/queue.go b/internal/server/queue.go new file mode 100644 index 0000000..19848fe --- /dev/null +++ b/internal/server/queue.go @@ -0,0 +1,111 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + + "arranger/internal/store" +) + +// Queue endpoints: an agent's goals waiting to run one after another (see orch.StartQueue). + +func (s *Server) getQueue(w http.ResponseWriter, r *http.Request) { + q, err := s.st.Queue(r.PathValue("id")) + if err != nil { + fail(w, http.StatusNotFound, err) + return + } + writeJSON(w, q) +} + +func (s *Server) addQueueItem(w http.ResponseWriter, r *http.Request) { + var it store.QueueItem + if !readJSON(w, r, &it) { + return + } + it.Agent = r.PathValue("id") + if _, _, err := s.st.Agent(it.Agent); err != nil { + http.Error(w, "agent not found (save the arrangement first)", http.StatusNotFound) + return + } + if err := s.st.AddQueueItem(&it); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + s.o.Hub.Publish(s.projectOf(it.Agent), map[string]any{"type": "queue", "agent": it.Agent}) + writeJSON(w, it) +} + +// queueItem runs a change on one goal of the queue, named by the {item} path value. +func (s *Server) queueItem(change func(agent string, id int64, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("item"), 10, 64) + if err != nil { + http.Error(w, "bad goal id", http.StatusBadRequest) + return + } + agent := r.PathValue("id") + if err := change(agent, id, r); err != nil { + fail(w, http.StatusConflict, err) + return + } + s.o.Hub.Publish(s.projectOf(agent), map[string]any{"type": "queue", "agent": agent}) + w.WriteHeader(http.StatusNoContent) + } +} + +func (s *Server) editQueueItem(agent string, id int64, r *http.Request) error { + var it store.QueueItem + if err := json.NewDecoder(r.Body).Decode(&it); err != nil { + return err + } + it.ID, it.Agent = id, agent + return s.st.UpdateQueueItem(it) +} + +func (s *Server) reorderQueue(w http.ResponseWriter, r *http.Request) { + var req struct{ IDs []int64 } + if !readJSON(w, r, &req) { + return + } + if err := s.st.ReorderQueue(r.PathValue("id"), req.IDs); err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + s.o.Hub.Publish(s.projectOf(r.PathValue("id")), map[string]any{"type": "queue", "agent": r.PathValue("id")}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) startQueue(w http.ResponseWriter, r *http.Request) { + if err := s.o.StartQueue(r.PathValue("id")); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusAccepted) +} + +func (s *Server) pauseQueue(w http.ResponseWriter, r *http.Request) { + if err := s.o.PauseQueue(r.PathValue("id")); err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) queueSettings(w http.ResponseWriter, r *http.Request) { + var req struct{ OnFail string } + if !readJSON(w, r, &req) { + return + } + if err := s.st.SetQueue(r.PathValue("id"), nil, req.OnFail); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) projectOf(agentID string) string { + _, pid, _ := s.st.Agent(agentID) + return pid +} diff --git a/internal/server/server.go b/internal/server/server.go index 4f86866..a62a80a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -52,8 +52,21 @@ func New(st *store.Store, o *orch.Orchestrator, loopback bool) http.Handler { mux.HandleFunc("PUT /api/agents/{id}/goal", s.saveGoal) mux.HandleFunc("POST /api/agents/{id}/run", s.run) mux.HandleFunc("POST /api/agents/{id}/revise", s.revise) + mux.HandleFunc("POST /api/agents/{id}/continue", s.continueRun) + mux.HandleFunc("GET /api/agents/{id}/queue", s.getQueue) + mux.HandleFunc("POST /api/agents/{id}/queue", s.addQueueItem) + mux.HandleFunc("PUT /api/agents/{id}/queue/{item}", s.queueItem(s.editQueueItem)) + mux.HandleFunc("DELETE /api/agents/{id}/queue/{item}", s.queueItem(func(a string, id int64, _ *http.Request) error { return s.st.DeleteQueueItem(a, id) })) + mux.HandleFunc("POST /api/agents/{id}/queue/{item}/skip", s.queueItem(func(a string, id int64, _ *http.Request) error { return s.st.SkipQueueItem(a, id) })) + mux.HandleFunc("POST /api/agents/{id}/queue/order", s.reorderQueue) + mux.HandleFunc("POST /api/agents/{id}/queue/start", s.startQueue) + mux.HandleFunc("POST /api/agents/{id}/queue/pause", s.pauseQueue) + mux.HandleFunc("PUT /api/agents/{id}/queue/settings", s.queueSettings) + mux.HandleFunc("GET /api/agents/{id}/plan", s.getPlan) + mux.HandleFunc("POST /api/agents/{id}/plan", s.decidePlan) mux.HandleFunc("POST /api/agents/{id}/stop", s.stop) mux.HandleFunc("GET /api/agents/{id}/events", s.events) + mux.HandleFunc("GET /api/agents/{id}/runs", s.runs) mux.HandleFunc("GET /api/agents/{id}/diff", s.diff) mux.HandleFunc("GET /api/agents/{id}/checkpoints", s.checkpoints) mux.HandleFunc("POST /api/agents/{id}/revert", s.revert) @@ -61,6 +74,9 @@ func New(st *store.Store, o *orch.Orchestrator, loopback bool) http.Handler { mux.HandleFunc("POST /api/agents/{id}/hunks", s.hunks) mux.HandleFunc("GET /api/agents/{id}/merge", s.merge) mux.HandleFunc("POST /api/agents/{id}/merge", s.merge) + mux.HandleFunc("GET /api/setup", s.setup) + mux.HandleFunc("POST /api/templates", s.saveTemplate) + mux.HandleFunc("DELETE /api/templates/{id}", s.deleteTemplate) mux.HandleFunc("POST /api/types", s.saveType) mux.HandleFunc("PUT /api/types/{id}", s.saveType) mux.HandleFunc("DELETE /api/types/{id}", s.deleteType) @@ -122,7 +138,7 @@ func (s *Server) arrange(w http.ResponseWriter, r *http.Request) { } err = s.pages.ExecuteTemplate(w, "arrange.html", map[string]any{ "Projects": ps, "Project": cur, "Agents": as, "Runtimes": names, "Installed": installed, - "Types": types, "Build": s.build, "Version": version.Get(), "Repo": version.Repo, + "Types": types, "Roles": store.RolePrompts, "Build": s.build, "Version": version.Get(), "Repo": version.Repo, }) if err != nil { zap.L().Error("render page", zap.Error(err)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4296c9a..5f887df 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -111,7 +112,7 @@ func TestPages(t *testing.T) { t.Fatalf("/: %d → %q", w.Code, w.Header().Get("Location")) } page := a.must(200, "GET", "/arrange?p="+a.pid, "") - if !strings.Contains(page, `rel="icon"`) || !strings.Contains(page, "API Coder") { + if !strings.Contains(page, `rel="icon"`) || !strings.Contains(page, "API Programmer") { t.Fatal("arrange page should carry the favicon and the demo agents") } if v := version.Get(); !strings.Contains(page, `id="version"`) || !strings.Contains(page, v.Version) { @@ -332,8 +333,9 @@ func TestRunThenWorkWithTheChanges(t *testing.T) { t.Fatalf("preview: %+v", m) } a.must(200, "POST", "/api/agents/k/merge", `{"target":"feature","strategy":"squash"}`) - if out, _ := git.Run(repo, "log", "-1", "--format=%s", "feature"); strings.TrimSpace(out) != "write files" { - t.Fatalf("squash commit message should default to the goal: %q", out) + // the message is the change itself; the hunk removed and brought back by hand isn't a change + if out, _ := git.Run(repo, "log", "-1", "--format=%B", "feature"); strings.TrimSpace(out) != "Write files" { + t.Fatalf("squash commit message should list the change: %q", out) } } @@ -500,3 +502,297 @@ func TestRequestChangesAPI(t *testing.T) { t.Fatalf("goal kept, run done: %+v", g) } } + +// A manager that asks for approval waits with its plan; the page reads it, can't move its +// reports meanwhile, and approves it through the API. +func TestPlanApprovalAPI(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + os.WriteFile(filepath.Join(repo, "plan.sh"), []byte(`cat >/dev/null; echo '{"subgoals":[{"agent":"k","title":"do it","checks":["true"]}]}'`+"\n"), 0o644) + git.Commit(repo, "add plan.sh") + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + arrangement := `[{"id":"m","name":"Lead","role":"manager","approvePlan":true},{"id":"k","name":"Kid","role":"coder","parent":"m"}]` + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", arrangement) + if m, _, _ := a.st.Agent("m"); !m.ApprovePlan { + t.Fatal("a new agent should take approvePlan from the arrangement") + } + a.must(204, "PUT", "/api/agents/m", `{"name":"Lead","runtime":"generic","args":"sh plan.sh","approvePlan":true}`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + a.must(204, "PUT", "/api/agents/m/goal", `{"title":"lead","checks":"true"}`) + a.must(404, "GET", "/api/agents/m/plan", "") + + a.must(202, "POST", "/api/agents/m/run", "") + var draft store.Plan + for deadline := time.Now().Add(20 * time.Second); draft.ID == 0; time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("no plan to approve") + } + if code, body := a.do("GET", "/api/agents/m/plan", ""); code == 200 { + json.Unmarshal([]byte(body), &draft) + } + } + if draft.Kind != "plan" || !strings.Contains(string(draft.JSON), "do it") { + t.Fatalf("draft: %+v", draft) + } + a.must(409, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"m","name":"Lead","role":"manager"},{"id":"k","name":"Kid","role":"coder"}]`) + id := strconv.FormatInt(draft.ID, 10) + a.must(409, "POST", "/api/agents/m/plan", `{"draft":`+id+`1,"action":"approve"}`) + a.must(400, "POST", "/api/agents/m/plan", `{"draft":`+id+`,"action":"approve","subgoals":[{"agent":"k","title":"no checks"}]}`) + a.must(202, "POST", "/api/agents/m/plan", `{"draft":`+id+`,"action":"approve","subgoals":[{"agent":"k","title":"do it now","checks":["true"]}]}`) + for deadline := time.Now().Add(20 * time.Second); a.o.IsRunning("m"); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("run never finished") + } + } + if g, _ := a.st.Goal("m"); g.Status != "done" { + t.Fatalf("manager: %+v", g) + } + if g, _ := a.st.Goal("k"); g.Title != "do it now" || g.Status != "done" { + t.Fatalf("report runs the approved goal: %+v", g) + } +} + +// Removing an agent removes its worktree, its branch in the user's repo, and its history. +func TestRemovedAgentIsCleanedUp(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"k","name":"Kid","role":"coder"},{"id":"s","name":"Stay","role":"coder"}]`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + a.must(204, "PUT", "/api/agents/k/goal", `{"title":"t","checks":"true"}`) + a.must(202, "POST", "/api/agents/k/run", "") + for deadline := time.Now().Add(20 * time.Second); a.o.IsRunning("k"); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("run never finished") + } + } + if !git.BranchExists(repo, "arranger/k") { + t.Fatal("the run should have made a branch") + } + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"s","name":"Stay","role":"coder"}]`) + if _, err := os.Stat(a.o.Dir("k")); err == nil { + t.Error("worktree still there") + } + if git.BranchExists(repo, "arranger/k") { + t.Error("branch still there") + } + var rows int + a.st.SQL().QueryRow(`SELECT (SELECT count(*) FROM runs WHERE agent_id='k') + (SELECT count(*) FROM events WHERE agent_id='k')`).Scan(&rows) + if rows != 0 { + t.Errorf("%d history rows left", rows) + } +} + +// Opening the merge preview doesn't change the agent's work; merging includes what wasn't committed. +func TestMergePreviewOnlyReads(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"k","name":"Kid","role":"coder"}]`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + a.must(204, "PUT", "/api/agents/k/goal", `{"title":"t","checks":"true"}`) + a.must(202, "POST", "/api/agents/k/run", "") + for a.o.IsRunning("k") { + time.Sleep(50 * time.Millisecond) + } + os.WriteFile(filepath.Join(a.o.Dir("k"), "late.txt"), []byte("late\n"), 0o644) // changed after its last checkpoint + head, _ := git.Run(a.o.Dir("k"), "rev-parse", "HEAD") + var m git.MergePreview + json.Unmarshal([]byte(a.must(200, "GET", "/api/agents/k/merge?target=out", "")), &m) + if after, _ := git.Run(a.o.Dir("k"), "rev-parse", "HEAD"); after != head || m.Uncommitted != 1 { + t.Fatalf("preview committed (%v) or missed the uncommitted file: %+v", after != head, m) + } + a.must(200, "POST", "/api/agents/k/merge", `{"target":"out","strategy":"merge"}`) + if _, err := git.Run(repo, "cat-file", "-e", "out:late.txt"); err != nil { + t.Fatal("merging should include the uncommitted file") + } +} + +// The queue API: add, edit, reorder, skip, remove and run goals one after another. +func TestQueueAPI(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"k","name":"Kid","role":"programmer"}]`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + a.must(400, "POST", "/api/agents/k/queue", `{"title":"no checks"}`) + a.must(400, "POST", "/api/agents/k/queue/start", "") + ids := []int64{} + for _, title := range []string{"one", "two", "three", "four"} { + var it store.QueueItem + json.Unmarshal([]byte(a.must(200, "POST", "/api/agents/k/queue", `{"title":"`+title+`","checks":"true"}`)), &it) + ids = append(ids, it.ID) + } + id := func(i int) string { return strconv.FormatInt(ids[i], 10) } + a.must(204, "PUT", "/api/agents/k/queue/"+id(0), `{"title":"one, edited","checks":"true"}`) + a.must(204, "POST", "/api/agents/k/queue/order", `{"ids":[`+id(1)+`,`+id(0)+`,`+id(2)+`,`+id(3)+`]}`) + a.must(204, "POST", "/api/agents/k/queue/"+id(2)+"/skip", "") + a.must(204, "DELETE", "/api/agents/k/queue/"+id(3), "") + a.must(204, "PUT", "/api/agents/k/queue/settings", `{"onFail":"skip"}`) + a.must(400, "PUT", "/api/agents/k/queue/settings", `{"onFail":"explode"}`) + a.must(202, "POST", "/api/agents/k/queue/start", "") + var q store.Queue + for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) { + json.Unmarshal([]byte(a.must(200, "GET", "/api/agents/k/queue", "")), &q) + if !q.Active && !a.o.IsRunning("k") { + break + } + if time.Now().After(deadline) { + t.Fatal("queue never finished") + } + } + order := []string{} + for _, it := range q.Items { + order = append(order, it.Title+":"+it.Status) + } + // finished goals come newest first; the skipped one stays in the history, the removed one is gone + if got := strings.Join(order, " "); !strings.Contains(got, "one, edited:done") || !strings.Contains(got, "two:done") || + !strings.Contains(got, "three:skipped") || strings.Contains(got, "four") || q.OnFail != "skip" { + t.Fatalf("queue: %s (onFail %s)", got, q.OnFail) + } + if g, _ := a.st.Goal("k"); g.Title != "one, edited" { + t.Fatalf("the last goal it ran is its goal now: %q", g.Title) + } + a.must(409, "PUT", "/api/agents/k/queue/"+id(1), `{"title":"too late","checks":"true"}`) +} + +// Run all runs each top-level agent's goals. +func TestRunAllRunsGoals(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"k","name":"Kid","role":"programmer"},{"id":"idle","name":"Idle","role":"programmer"}]`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + a.must(200, "POST", "/api/agents/k/queue", `{"title":"one","checks":"true"}`) + a.must(200, "POST", "/api/agents/k/queue", `{"title":"two","checks":"true"}`) + var r struct{ Started int } + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects/"+p.ID+"/run", "")), &r) + if r.Started != 1 { + t.Fatalf("started %d, want only the agent with goals", r.Started) + } + for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) { + if q, _ := a.st.Queue("k"); !q.Active && !a.o.IsRunning("k") { + if q.Items[0].Status != "done" || q.Items[1].Status != "done" { + t.Fatalf("goals: %+v", q.Items) + } + return + } + if time.Now().After(deadline) { + t.Fatal("goals never finished") + } + } +} + +// Request changes on an older finished goal makes that goal the agent's goal again for the change. +func TestReviseFinishedGoal(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"k","name":"Kid","role":"programmer"}]`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"true"}`) + var first store.QueueItem + json.Unmarshal([]byte(a.must(200, "POST", "/api/agents/k/queue", `{"title":"first","checks":"true"}`)), &first) + a.must(200, "POST", "/api/agents/k/queue", `{"title":"second","checks":"true"}`) + a.must(202, "POST", "/api/agents/k/queue/start", "") + wait := func() { + for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) { + if q, _ := a.st.Queue("k"); !q.Active && !a.o.IsRunning("k") { + return + } + if time.Now().After(deadline) { + t.Fatal("never finished") + } + } + } + wait() + if g, _ := a.st.Goal("k"); g.Title != "second" { + t.Fatalf("goal after the list: %q", g.Title) + } + a.must(404, "POST", "/api/agents/k/revise", `{"change":"x","goal":99999}`) + a.must(202, "POST", "/api/agents/k/revise", `{"change":"make it nicer","goal":`+strconv.FormatInt(first.ID, 10)+`}`) + wait() + if g, _ := a.st.Goal("k"); g.Title != "first" || g.Status != "done" { + t.Fatalf("the change should run with the first goal: %+v", g) + } +} + +// Merging an agent's work lists every change from all its goals, not just the last goal. +func TestMergeMessageListsEveryChange(t *testing.T) { + a := newApp(t) + repo := newRepo(t) + os.WriteFile(filepath.Join(repo, "w.sh"), []byte("p=$(cat); date +%s%N > out.txt\ncase \"$p\" in *first*) echo 'Commit: add the first thing' ;; *) echo 'Commit: add the second thing' ;; esac\n"), 0o644) + git.Commit(repo, "add w.sh") + var p store.Project + json.Unmarshal([]byte(a.must(200, "POST", "/api/projects", `{"name":"r","repo":"`+repo+`"}`)), &p) + a.must(204, "POST", "/api/projects/"+p.ID+"/arrangement", `[{"id":"k","name":"Kid","role":"programmer"}]`) + a.must(204, "PUT", "/api/agents/k", `{"name":"Kid","runtime":"generic","args":"sh w.sh"}`) + a.must(200, "POST", "/api/agents/k/queue", `{"title":"first goal","checks":"true"}`) + a.must(200, "POST", "/api/agents/k/queue", `{"title":"second goal","checks":"true"}`) + a.must(202, "POST", "/api/agents/k/queue/start", "") + for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) { + if q, _ := a.st.Queue("k"); !q.Active && !a.o.IsRunning("k") { + break + } + if time.Now().After(deadline) { + t.Fatal("never finished") + } + } + var m git.MergePreview + json.Unmarshal([]byte(a.must(200, "GET", "/api/agents/k/merge?target=release", "")), &m) + want := "2 changes: Add the first thing; Add the second thing\n\n- Add the first thing\n- Add the second thing\n" + if m.Message != want { + t.Fatalf("preview message:\n%q\nwant\n%q", m.Message, want) + } + a.must(200, "POST", "/api/agents/k/merge", `{"target":"release","strategy":"merge"}`) + if out, _ := git.Run(repo, "log", "-1", "--format=%B", "release"); strings.TrimSpace(out) != strings.TrimSpace(want) { + t.Fatalf("merge commit message:\n%s", out) + } +} + +// Checks are suggested from the repo's own build files. +func TestSuggestChecks(t *testing.T) { + repo := t.TempDir() + os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module x\n"), 0o644) + os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"scripts":{"build":"vite build","test":"echo \"Error: no test specified\" && exit 1","lint":"eslint ."}}`), 0o644) + os.WriteFile(filepath.Join(repo, "pnpm-lock.yaml"), nil, 0o644) + os.WriteFile(filepath.Join(repo, "Makefile"), []byte("build:\n\tgo build\ntest:\n\tgo test ./...\n"), 0o644) + var got []string + for _, c := range suggestChecks(repo) { + got = append(got, c.Cmd) + } + want := "go build ./...|go vet ./...|go test ./...|pnpm run build|pnpm run lint|make test" + if strings.Join(got, "|") != want { + t.Fatalf("got %s\nwant %s", strings.Join(got, "|"), want) + } + if len(suggestChecks("")) != 0 { + t.Fatal("no repo, no suggestions") + } +} + +// Starter teams are always there; the user's own templates can be saved and deleted. +func TestTemplatesAPI(t *testing.T) { + a := newApp(t) + var s struct { + Tools []tool + Templates []store.Template + } + json.Unmarshal([]byte(a.must(200, "GET", "/api/setup?project="+a.pid, "")), &s) + if len(s.Templates) != 3 || s.Templates[0].Name != "Feature team" || s.Templates[0].Agents[1].Prompt == "" || len(s.Tools) == 0 { + t.Fatalf("setup: %+v", s) + } + a.must(400, "POST", "/api/templates", `{"name":"empty","agents":[]}`) + var saved store.Template + json.Unmarshal([]byte(a.must(200, "POST", "/api/templates", `{"name":"Mine","agents":[{"key":"x","name":"X","role":"programmer"}]}`)), &saved) + json.Unmarshal([]byte(a.must(200, "GET", "/api/setup?project="+a.pid, "")), &s) + if len(s.Templates) != 4 || s.Templates[3].Name != "Mine" { + t.Fatalf("saved template missing: %+v", s.Templates) + } + a.must(400, "DELETE", "/api/templates/starter-docs", "") + a.must(204, "DELETE", "/api/templates/"+saved.ID, "") +} diff --git a/internal/server/setup.go b/internal/server/setup.go new file mode 100644 index 0000000..05d1941 --- /dev/null +++ b/internal/server/setup.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "arranger/internal/agents" + "arranger/internal/store" +) + +// Getting started: which agent CLIs are ready, checks that fit the project's repo, and starter teams. + +// tool tells a new user how to get one agent CLI ready. +type tool struct { + Runtime string `json:"runtime"` + Bin string `json:"bin"` + Installed bool `json:"installed"` + Version string `json:"version"` + Install string `json:"install"` + Login string `json:"login"` + Docs string `json:"docs"` +} + +// toolHelp is how to install each agent CLI and sign in; check the tool's docs if a command changes. +var toolHelp = map[string]struct{ install, login, docs string }{ + "claude": {"npm install -g @anthropic-ai/claude-code", "claude (then /login)", "https://docs.anthropic.com/en/docs/claude-code"}, + "codex": {"npm install -g @openai/codex", "codex login", "https://github.com/openai/codex"}, + "gemini": {"npm install -g @google/gemini-cli", "gemini (then sign in)", "https://github.com/google-gemini/gemini-cli"}, + "opencode": {"npm install -g opencode-ai", "opencode auth login", "https://opencode.ai/docs"}, + "cursor": {"curl https://cursor.com/install -fsS | bash", "cursor-agent login", "https://cursor.com/cli"}, + "amp": {"npm install -g @sourcegraph/amp", "amp login", "https://ampcode.com"}, + "pi": {"npm install -g @mariozechner/pi-coding-agent", "pi (then /login)", "https://github.com/badlogic/pi-mono"}, +} + +func tools(ctx context.Context) []tool { + ts := []tool{} + for name, rt := range agents.Runtimes { + h, ok := toolHelp[name] + if !ok || rt.Bin == "" { + continue + } + t := tool{Runtime: name, Bin: rt.Bin, Install: h.install, Login: h.login, Docs: h.docs} + if _, err := exec.LookPath(rt.Bin); err == nil { + t.Installed = true + c, cancel := context.WithTimeout(ctx, 3*time.Second) + if out, err := exec.CommandContext(c, rt.Bin, "--version").Output(); err == nil { + t.Version = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) + } + cancel() + } + ts = append(ts, t) + } + sort.Slice(ts, func(i, j int) bool { + if ts[i].Installed != ts[j].Installed { + return ts[i].Installed + } + return ts[i].Runtime < ts[j].Runtime + }) + return ts +} + +// check is a shell check that fits the repo, and what it proves. +type check struct { + Cmd string `json:"cmd"` + Why string `json:"why"` +} + +// suggestChecks reads the repo's build files and suggests checks that prove work is done. +func suggestChecks(repo string) []check { + cs := []check{} + has := func(f string) bool { _, err := os.Stat(filepath.Join(repo, f)); return err == nil } + if repo == "" { + return cs + } + if has("go.mod") { + cs = append(cs, check{"go build ./...", "it compiles"}, check{"go vet ./...", "no suspicious code"}, check{"go test ./...", "the tests pass"}) + } + if has("package.json") { + pm := "npm" + switch { + case has("pnpm-lock.yaml"): + pm = "pnpm" + case has("yarn.lock"): + pm = "yarn" + case has("bun.lockb"), has("bun.lock"): + pm = "bun" + } + var pkg struct{ Scripts map[string]string } + if b, err := os.ReadFile(filepath.Join(repo, "package.json")); err == nil { + json.Unmarshal(b, &pkg) + } + for _, s := range []struct{ name, why string }{{"build", "it builds"}, {"typecheck", "types check"}, {"lint", "lint passes"}, {"test", "the tests pass"}} { + if sc, ok := pkg.Scripts[s.name]; ok && !strings.Contains(sc, "no test specified") { + cs = append(cs, check{pm + " run " + s.name, s.why}) + } + } + } + if has("Cargo.toml") { + cs = append(cs, check{"cargo build", "it compiles"}, check{"cargo test", "the tests pass"}) + } + if has("pyproject.toml") || has("setup.py") || has("requirements.txt") { + cs = append(cs, check{"python3 -m pytest -q", "the tests pass"}) + } + if b, err := os.ReadFile(filepath.Join(repo, "Makefile")); err == nil { + for _, target := range []string{"test", "check", "lint"} { + if strings.Contains("\n"+string(b), "\n"+target+":") { + cs = append(cs, check{"make " + target, "make " + target + " passes"}) + } + } + } + return cs +} + +// setup returns what a new user needs: agent CLIs, checks for the project's repo, starter teams. +func (s *Server) setup(w http.ResponseWriter, r *http.Request) { + p, _ := s.st.Project(r.URL.Query().Get("project")) + ts, err := s.st.Templates() + if err != nil { + fail(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, map[string]any{"tools": tools(r.Context()), "checks": suggestChecks(p.Repo), "templates": ts}) +} + +func (s *Server) saveTemplate(w http.ResponseWriter, r *http.Request) { + var t store.Template + if !readJSON(w, r, &t) { + return + } + if err := s.st.SaveTemplate(&t); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + writeJSON(w, t) +} + +func (s *Server) deleteTemplate(w http.ResponseWriter, r *http.Request) { + if err := s.st.DeleteTemplate(r.PathValue("id")); err != nil { + fail(w, http.StatusBadRequest, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/work.go b/internal/server/work.go index 0004497..136d762 100644 --- a/internal/server/work.go +++ b/internal/server/work.go @@ -188,7 +188,7 @@ func (s *Server) hunks(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusConflict, err) return } - git.Commit(wk.dir, fmt.Sprintf("arranger: user removed %d change(s)", n)) + git.Commit(wk.dir, fmt.Sprintf("Remove %d change(s) by hand in the Diff tab\n\nThe agent won't add them back.", n)) notes := wk.g.Notes + patch if len(notes) > 8000 { // keep the newest removals; the prompt shouldn't grow without bound notes = notes[len(notes)-8000:] @@ -216,7 +216,7 @@ func (s *Server) hunks(w http.ResponseWriter, r *http.Request) { return } if applied > 0 { - git.Commit(mdir, fmt.Sprintf("arranger: promote %d change(s) from %s", applied, wk.a.Name)) + git.Commit(mdir, fmt.Sprintf("Promote %d change(s) from %s by hand in the Diff tab", applied, wk.a.Name)) s.changed(wk.p, mgr, mdir, mbase) } writeJSON(w, map[string]any{"applied": applied, "present": present, "to": mgr.Name}) @@ -228,6 +228,16 @@ func (s *Server) hunks(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// mergeMessage lists every change merging the agent's branch into target brings in: its work from +// all its goals, not just the last one. +func (s *Server) mergeMessage(wk work, target string) string { + into := target + if !git.BranchExists(wk.p.Repo, target) { + into = wk.p.Base + } + return orch.MergeMessage(git.ChangeLines(wk.p.Repo, into, git.BranchOf(wk.a.ID), s.st.CheckpointGoal), wk.g.Title) +} + // merge previews (GET) or performs (POST) merging an agent's branch into a branch of the user's repo. func (s *Server) merge(w http.ResponseWriter, r *http.Request) { wk, ok := s.agentWork(w, r) @@ -256,18 +266,20 @@ func (s *Server) merge(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusConflict, errors.New("stop "+wk.a.Name+" before merging its work")) return } - git.Commit(wk.dir, "arranger: save work before merge") // include anything not yet committed - if r.Method == http.MethodGet { + if r.Method == http.MethodGet { // a preview only reads: uncommitted work is counted, not committed m, err := git.PreviewMerge(wk.p.Repo, git.BranchOf(wk.a.ID), req.Target, wk.p.Base) if err != nil { fail(w, http.StatusInternalServerError, err) return } + m.Uncommitted = git.Uncommitted(wk.dir) + m.Message = s.mergeMessage(wk, req.Target) writeJSON(w, m) return } + git.Commit(wk.dir, "Save uncommitted work before merging") // include anything not yet committed if req.Message = strings.TrimSpace(req.Message); req.Message == "" { - req.Message = wk.g.Title + req.Message = s.mergeMessage(wk, req.Target) } if req.Message == "" { req.Message = "Merge work from " + wk.a.Name diff --git a/internal/store/queue.go b/internal/store/queue.go new file mode 100644 index 0000000..b5f739a --- /dev/null +++ b/internal/store/queue.go @@ -0,0 +1,189 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// QueueItem is a goal waiting in an agent's queue, or one the queue already ran. +type QueueItem struct { + ID int64 `json:"id"` + Agent string `json:"agent"` + Pos int `json:"pos"` + Title string `json:"title"` + Body string `json:"body"` + Criteria string `json:"criteria"` + Checks string `json:"checks"` + Status string `json:"status"` // queued | running | done | failed | blocked | stopped | skipped + Session int64 `json:"session"` // its run in the log, once it ran + Finished int64 `json:"finished"` +} + +// Queue is an agent's queue: its goals and how it runs them. +type Queue struct { + Active bool `json:"active"` // runs the next goal when one ends + OnFail string `json:"onFail"` // pause | skip + Items []QueueItem `json:"items"` +} + +const queueCols = `id, agent_id, pos, title, body, criteria, checks, status, session, finished` + +func scanQueueItem(sc interface{ Scan(...any) error }) (q QueueItem, err error) { + err = sc.Scan(&q.ID, &q.Agent, &q.Pos, &q.Title, &q.Body, &q.Criteria, &q.Checks, &q.Status, &q.Session, &q.Finished) + return +} + +// Queue returns the agent's queue, waiting goals in order and then finished ones, newest first. +func (s *Store) Queue(agentID string) (Queue, error) { + q := Queue{OnFail: "pause", Items: []QueueItem{}} + if err := s.db.QueryRow(`SELECT queue_active, queue_on_fail FROM agents WHERE id=?`, agentID).Scan(&q.Active, &q.OnFail); err != nil { + return q, err + } + rows, err := s.db.Query(`SELECT `+queueCols+` FROM queue WHERE agent_id=? + ORDER BY status IN ('queued', 'running') DESC, CASE WHEN status IN ('queued', 'running') THEN pos ELSE -finished END, id`, agentID) + if err != nil { + return q, err + } + defer rows.Close() + for rows.Next() { + it, err := scanQueueItem(rows) + if err != nil { + return q, err + } + q.Items = append(q.Items, it) + } + return q, rows.Err() +} + +// cleanItem checks a goal for the queue: it needs a title and a check, like any goal. +func cleanItem(it *QueueItem) error { + it.Title, it.Checks = strings.TrimSpace(it.Title), strings.TrimSpace(it.Checks) + if it.Title == "" { + return errors.New("give the goal a title") + } + if it.Checks == "" { + return errors.New("add at least one check: nothing is done until a check passes") + } + return nil +} + +// AddQueueItem puts a goal at the end of the agent's queue. +func (s *Store) AddQueueItem(it *QueueItem) error { + if err := cleanItem(it); err != nil { + return err + } + res, err := s.db.Exec(`INSERT INTO queue(agent_id, pos, title, body, criteria, checks) + VALUES(?, (SELECT coalesce(max(pos), 0) + 1 FROM queue WHERE agent_id=?), ?, ?, ?, ?)`, + it.Agent, it.Agent, it.Title, it.Body, it.Criteria, it.Checks) + if err != nil { + return err + } + it.ID, _ = res.LastInsertId() + it.Status = "queued" + return nil +} + +// errNotQueued refuses changes to a goal the queue already started or finished. +var errNotQueued = errors.New("that goal already ran or is running; only waiting goals can change") + +// UpdateQueueItem changes a waiting goal. +func (s *Store) UpdateQueueItem(it QueueItem) error { + if err := cleanItem(&it); err != nil { + return err + } + return s.onlyQueued(s.db.Exec(`UPDATE queue SET title=?, body=?, criteria=?, checks=? WHERE id=? AND agent_id=? AND status='queued'`, + it.Title, it.Body, it.Criteria, it.Checks, it.ID, it.Agent)) +} + +// SkipQueueItem marks a waiting goal as skipped; it stays in the history. +func (s *Store) SkipQueueItem(agentID string, id int64) error { + return s.onlyQueued(s.db.Exec(`UPDATE queue SET status='skipped', finished=? WHERE id=? AND agent_id=? AND status='queued'`, + time.Now().UnixMilli(), id, agentID)) +} + +// DeleteQueueItem removes a goal that isn't running. +func (s *Store) DeleteQueueItem(agentID string, id int64) error { + res, err := s.db.Exec(`DELETE FROM queue WHERE id=? AND agent_id=? AND status != 'running'`, id, agentID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return errors.New("that goal is running; stop it first") + } + return nil +} + +func (s *Store) onlyQueued(res sql.Result, err error) error { + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return errNotQueued + } + return nil +} + +// ReorderQueue puts the agent's waiting goals in the order of ids. +func (s *Store) ReorderQueue(agentID string, ids []int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for i, id := range ids { + if _, err := tx.Exec(`UPDATE queue SET pos=? WHERE id=? AND agent_id=? AND status='queued'`, i+1, id, agentID); err != nil { + return err + } + } + return tx.Commit() +} + +// SetQueue changes how the agent's queue runs: active, and what a failure does ("pause" or "skip"). +func (s *Store) SetQueue(agentID string, active *bool, onFail string) error { + if onFail != "" && onFail != "pause" && onFail != "skip" { + return fmt.Errorf("when a goal fails: pause or skip, not %q", onFail) + } + if active != nil { + if _, err := s.db.Exec(`UPDATE agents SET queue_active=? WHERE id=?`, *active, agentID); err != nil { + return err + } + } + if onFail != "" { + _, err := s.db.Exec(`UPDATE agents SET queue_on_fail=? WHERE id=?`, onFail, agentID) + return err + } + return nil +} + +// QueueItem returns one goal of the agent's list. +func (s *Store) QueueItem(agentID string, id int64) (QueueItem, error) { + return scanQueueItem(s.db.QueryRow(`SELECT `+queueCols+` FROM queue WHERE agent_id=? AND id=?`, agentID, id)) +} + +// NextQueueItem is the agent's next waiting goal; sql.ErrNoRows when there's none. +func (s *Store) NextQueueItem(agentID string) (QueueItem, error) { + return scanQueueItem(s.db.QueryRow(`SELECT `+queueCols+` FROM queue WHERE agent_id=? AND status='queued' ORDER BY pos, id LIMIT 1`, agentID)) +} + +// RunningQueueItem is the goal the agent's queue is running; sql.ErrNoRows when there's none. +func (s *Store) RunningQueueItem(agentID string) (QueueItem, error) { + return scanQueueItem(s.db.QueryRow(`SELECT `+queueCols+` FROM queue WHERE agent_id=? AND status='running' LIMIT 1`, agentID)) +} + +// LastQueueItem is the goal of the agent's list that finished last; sql.ErrNoRows when none did. +func (s *Store) LastQueueItem(agentID string) (QueueItem, error) { + return scanQueueItem(s.db.QueryRow(`SELECT `+queueCols+` FROM queue WHERE agent_id=? AND status NOT IN ('queued', 'running', 'skipped') ORDER BY finished DESC, id DESC LIMIT 1`, agentID)) +} + +// StartQueueItem marks a goal as running in the given log session. +func (s *Store) StartQueueItem(id, session int64) { + s.db.Exec(`UPDATE queue SET status='running', session=? WHERE id=?`, session, id) +} + +// FinishQueueItem records how a goal's run ended. +func (s *Store) FinishQueueItem(id int64, status string) { + s.db.Exec(`UPDATE queue SET status=?, finished=? WHERE id=?`, status, time.Now().UnixMilli(), id) +} diff --git a/internal/store/roles.go b/internal/store/roles.go new file mode 100644 index 0000000..02ffe2a --- /dev/null +++ b/internal/store/roles.go @@ -0,0 +1,47 @@ +package store + +// Default instructions for the default agent types. New agents of these types start with them in +// their Instructions, where the user can see and change them; without them a programmer and a +// tester would differ by one word in the prompt. +const ( + managerGuide = `How you lead: +- Give each report one clear subgoal it can finish on its own. Reports work in parallel on separate branches and can't see each other's work until you merge it. +- Split the work by files or modules so no two reports edit the same files. +- Match work to roles: product managers write specs, programmers build, testers write tests, writers document. Leave out reports you don't need. +- Make every check a real test of its subgoal: a command that fails before the work is done and passes after. Never use checks like "true" or "ls". +- Prefer a few well-scoped subgoals over many tiny ones. +- When you review, accept work that does what its subgoal asks and meets its criteria. When you reject, say exactly what to fix.` + + programmerGuide = `How you work: +- Implement exactly what the goal asks, with the smallest change that meets the criteria. No unrelated refactors, and no new dependencies unless the goal asks for them. +- Read the surrounding code first and follow its style, naming and patterns. +- Add or update tests for the behavior you change. +- Run the checks yourself before you finish if you can, and fix what fails.` + + testerGuide = `How you work: +- You write and improve automated tests; you don't build features. +- Cover the behavior your goal names: the main path, edge cases and failure cases. +- Use the project's existing test framework, layout and helpers. +- Change production code only to fix a bug a test exposes, and say so. +- Keep tests deterministic: no sleeps, network or wall-clock time unless the project already relies on them.` + + writerGuide = `How you work: +- You write documentation, not code: READMEs, guides, tutorials, API references, changelogs and in-product help text. +- Read the code and the existing docs first. Everything you write must match how the code actually behaves; run the commands and examples you document to confirm them. +- Write for the reader your goal names: lead with what they need, use short sentences and concrete examples, and give commands they can copy and run. +- Follow the project's existing doc structure, tone and formatting. +- In code, change only comments and docstrings, never behavior. +- Keep every link and example working.` + + productManagerGuide = `How you work: +- You define what to build, not how: specs, user stories, acceptance criteria and priorities, written as files in the repo (for example docs/specs/). +- Start from the problem in your goal and who has it. Read the code and docs to know what exists today. +- Write each user story as "As a , I want so that ", each with acceptance criteria a tester could check. +- Say what's in scope, what's out, and list open questions and risks. +- Keep specs short and concrete enough that a programmer can build from them and a tester can verify them. +- Don't change code.` +) + +// RolePrompts are the default instructions per default agent type. +var RolePrompts = map[string]string{"manager": managerGuide, "programmer": programmerGuide, "tester": testerGuide, + "writer": writerGuide, "product-manager": productManagerGuide} diff --git a/internal/store/store.go b/internal/store/store.go index 87f1296..a7abd6b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strings" "time" _ "modernc.org/sqlite" // pure-Go driver, so the binary stays cgo-free @@ -38,6 +39,15 @@ CREATE TABLE IF NOT EXISTS agent_types ( model TEXT NOT NULL DEFAULT '', args TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT ''); CREATE TABLE IF NOT EXISTS decisions ( id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, ts INTEGER NOT NULL, kind TEXT NOT NULL, json TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS queue ( + id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, pos INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', + criteria TEXT NOT NULL DEFAULT '', checks TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'queued', + session INTEGER NOT NULL DEFAULT 0, finished INTEGER NOT NULL DEFAULT 0); +CREATE INDEX IF NOT EXISTS queue_agent ON queue(agent_id, status, pos); +CREATE TABLE IF NOT EXISTS templates (id TEXT PRIMARY KEY, name TEXT NOT NULL, about TEXT NOT NULL DEFAULT '', json TEXT NOT NULL, created INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS plans ( + id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL, kind TEXT NOT NULL, json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', feedback TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL, decided INTEGER NOT NULL DEFAULT 0); ` // migrations add columns to tables created by older versions; "duplicate column" errors are expected. @@ -47,11 +57,22 @@ var migrations = []string{ `ALTER TABLE agents ADD COLUMN token_soft INTEGER NOT NULL DEFAULT 0`, // warn above this many tokens per run; 0 = off `ALTER TABLE agents ADD COLUMN token_hard INTEGER NOT NULL DEFAULT 0`, // stop above this many tokens per run; 0 = off `ALTER TABLE goals ADD COLUMN since INTEGER NOT NULL DEFAULT 0`, // when the current run started (ms) + `ALTER TABLE goals ADD COLUMN from_branch TEXT NOT NULL DEFAULT ''`, // the branch its work is based on; a change means it moved `ALTER TABLE agents ADD COLUMN color TEXT NOT NULL DEFAULT ''`, // overrides the role's color; '' = role color + `ALTER TABLE events ADD COLUMN session INTEGER NOT NULL DEFAULT 0`, // which run of the agent it belongs to; 0 = before this existed `ALTER TABLE agents ADD COLUMN x REAL`, // canvas position; NULL = lay out automatically `ALTER TABLE agents ADD COLUMN y REAL`, // a crash mid-run leaves stale statuses behind - `UPDATE goals SET status = 'stopped' WHERE status IN ('starting', 'running', 'verifying', 'planning', 'waiting', 'reviewing')`, + `UPDATE goals SET status = 'stopped' WHERE status IN ('starting', 'running', 'verifying', 'planning', 'waiting', 'reviewing', 'awaiting', 'fixing')`, + `ALTER TABLE agents ADD COLUMN approve_plan INTEGER NOT NULL DEFAULT 0`, // a manager waits for the user to approve its plan (opt-in) + // nobody is waiting on a draft plan after a restart + `UPDATE agents SET role = 'programmer' WHERE role = 'coder'`, // the Coder type is now called Programmer + `ALTER TABLE agents ADD COLUMN queue_active INTEGER NOT NULL DEFAULT 0`, // runs its queued goals one after another + `ALTER TABLE agents ADD COLUMN queue_on_fail TEXT NOT NULL DEFAULT 'pause'`, // pause | skip + // a restart ends the run a queue was on; the queue waits for the user to start it again + `UPDATE queue SET status = 'stopped' WHERE status = 'running'`, + `UPDATE agents SET queue_active = 0`, + `UPDATE plans SET status = 'expired', decided = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE status = 'pending'`, } type Project struct { @@ -75,6 +96,8 @@ type Agent struct { Color string `json:"color"` X *float64 `json:"x"` // nil until the user places the box Y *float64 `json:"y"` + + ApprovePlan bool `json:"approvePlan"` // as a manager, wait for the user to approve each plan } // AgentType is a user-defined palette entry: a role with default settings for new agents. @@ -102,27 +125,29 @@ type Goal struct { Dels int `json:"dels"` Base string `json:"base"` Notes string `json:"notes"` + From string `json:"from"` // the branch Base came from: its manager's, or the project base } // LogEvent is one line of an agent's activity log. type LogEvent struct { - ID int64 `json:"id"` - Agent string `json:"agent"` - Run int64 `json:"run"` - TS int64 `json:"ts"` - Kind string `json:"kind"` - Text string `json:"text"` - Raw string `json:"raw,omitempty"` + ID int64 `json:"id"` + Agent string `json:"agent"` + Run int64 `json:"run"` // the runs row: one agent process call (an attempt, a plan, a review) + Session int64 `json:"session"` // the run the user sees: everything from pressing Run to its result; 0 = unknown + Attempt int `json:"attempt"` // the worker attempt Run belongs to, when known (read only) + TS int64 `json:"ts"` + Kind string `json:"kind"` + Text string `json:"text"` + Raw string `json:"raw,omitempty"` } // DemoAgents seed the first project so the arrange screen isn't empty. var DemoAgents = []Agent{ {ID: "lead", Name: "Lead", Role: "manager"}, {ID: "be", Name: "Backend", Role: "manager", Parent: "lead"}, - {ID: "fe", Name: "Frontend", Role: "coder", Parent: "lead"}, - {ID: "rev", Name: "Reviewer", Role: "reviewer", Parent: "lead"}, - {ID: "api", Name: "API Coder", Role: "coder", Parent: "be"}, - {ID: "db", Name: "DB Coder", Role: "coder", Parent: "be"}, + {ID: "fe", Name: "Frontend", Role: "programmer", Parent: "lead"}, + {ID: "api", Name: "API Programmer", Role: "programmer", Parent: "be"}, + {ID: "db", Name: "DB Programmer", Role: "programmer", Parent: "be"}, {ID: "test", Name: "Tester", Role: "tester", Parent: "be"}, } @@ -144,6 +169,26 @@ func Open(path string) (*Store, error) { for _, m := range migrations { db.Exec(m) } + // once: agents of a default type that have no instructions get their type's defaults + var v int + db.QueryRow(`PRAGMA user_version`).Scan(&v) + if v < 1 { + for role, prompt := range RolePrompts { + db.Exec(`UPDATE agents SET prompt = ? WHERE role = ? AND trim(prompt) = ''`, prompt, role) + } + db.Exec(`PRAGMA user_version = 1`) + v = 1 + } + // once: a top-level agent's single goal becomes the first in its list of goals + if v < 2 { + db.Exec(`INSERT INTO queue(agent_id, pos, title, body, criteria, checks, status, finished) + SELECT a.id, 1, g.title, g.body, g.criteria, g.checks, + CASE WHEN g.status = 'done' THEN 'done' WHEN g.status IN ('failed', 'blocked', 'stopped') THEN g.status ELSE 'queued' END, + CASE WHEN g.status IN ('done', 'failed', 'blocked', 'stopped') THEN CAST(strftime('%s','now') AS INTEGER) * 1000 ELSE 0 END + FROM agents a JOIN goals g ON g.agent_id = a.id + WHERE a.parent = '' AND trim(g.title) != '' AND trim(g.checks) != '' AND NOT EXISTS (SELECT 1 FROM queue q WHERE q.agent_id = a.id)`) + db.Exec(`PRAGMA user_version = 2`) + } s := &Store{db: db} var n int db.QueryRow(`SELECT count(*) FROM projects`).Scan(&n) @@ -156,6 +201,7 @@ func Open(path string) (*Store, error) { } as := make([]Agent, len(DemoAgents)) for i, a := range DemoAgents { + a.Prompt = RolePrompts[a.Role] a.ID = p.ID + "-" + a.ID if a.Parent != "" { a.Parent = p.ID + "-" + a.Parent @@ -207,11 +253,11 @@ func (s *Store) Projects() ([]Project, error) { return ps, rows.Err() } -const agentCols = `id, name, role, parent, runtime, model, prompt, args, token_soft, token_hard, color, x, y` +const agentCols = `id, name, role, parent, runtime, model, prompt, args, token_soft, token_hard, color, x, y, approve_plan` func scanAgent(sc interface{ Scan(...any) error }, extra ...any) (a Agent, err error) { var x, y sql.NullFloat64 - err = sc.Scan(append([]any{&a.ID, &a.Name, &a.Role, &a.Parent, &a.Runtime, &a.Model, &a.Prompt, &a.Args, &a.Soft, &a.Hard, &a.Color, &x, &y}, extra...)...) + err = sc.Scan(append([]any{&a.ID, &a.Name, &a.Role, &a.Parent, &a.Runtime, &a.Model, &a.Prompt, &a.Args, &a.Soft, &a.Hard, &a.Color, &x, &y, &a.ApprovePlan}, extra...)...) if x.Valid && y.Valid { a.X, a.Y = &x.Float64, &y.Float64 } @@ -252,8 +298,8 @@ func (s *Store) Agent(id string) (a Agent, projectID string, err error) { // UpdateAgent saves an agent's settings (not its place in the tree). func (s *Store) UpdateAgent(a Agent) error { - _, err := s.db.Exec(`UPDATE agents SET name=?, runtime=?, model=?, prompt=?, args=?, token_soft=?, token_hard=?, color=? WHERE id=?`, - a.Name, a.Runtime, a.Model, a.Prompt, a.Args, a.Soft, a.Hard, a.Color, a.ID) + _, err := s.db.Exec(`UPDATE agents SET name=?, runtime=?, model=?, prompt=?, args=?, token_soft=?, token_hard=?, color=?, approve_plan=? WHERE id=?`, + a.Name, a.Runtime, a.Model, a.Prompt, a.Args, a.Soft, a.Hard, a.Color, a.ApprovePlan, a.ID) return err } @@ -277,9 +323,9 @@ func (s *Store) SaveArrangement(projectID string, as []Agent) error { rt = "claude" } // settings only apply to new agents (e.g. from a custom type); existing ones keep theirs - if _, err := tx.Exec(`INSERT INTO agents(id, project_id, name, role, parent, pos, runtime, model, args, prompt, color, token_soft, token_hard, x, y) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + if _, err := tx.Exec(`INSERT INTO agents(id, project_id, name, role, parent, pos, runtime, model, args, prompt, color, token_soft, token_hard, x, y, approve_plan) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, role=excluded.role, parent=excluded.parent, pos=excluded.pos, x=excluded.x, y=excluded.y`, - a.ID, projectID, a.Name, a.Role, a.Parent, i, rt, a.Model, a.Args, a.Prompt, a.Color, a.Soft, a.Hard, a.X, a.Y); err != nil { + a.ID, projectID, a.Name, a.Role, a.Parent, i, rt, a.Model, a.Args, a.Prompt, a.Color, a.Soft, a.Hard, a.X, a.Y, a.ApprovePlan); err != nil { return err } keep[a.ID] = true @@ -301,8 +347,10 @@ func (s *Store) SaveArrangement(projectID string, as []Agent) error { if _, err := tx.Exec(`DELETE FROM agents WHERE id=?`, id); err != nil { return err } - if _, err := tx.Exec(`DELETE FROM goals WHERE agent_id=?`, id); err != nil { - return err + for _, table := range []string{"goals", "runs", "events", "decisions", "plans", "queue"} { + if _, err := tx.Exec(`DELETE FROM `+table+` WHERE agent_id=?`, id); err != nil { + return err + } } } return tx.Commit() @@ -310,8 +358,8 @@ func (s *Store) SaveArrangement(projectID string, as []Agent) error { // Goal returns an agent's goal; an agent without one gets an empty, idle goal. func (s *Store) Goal(agentID string) (g Goal, err error) { - err = s.db.QueryRow(`SELECT title, body, criteria, checks, status, attempts, feedback, passed, total, adds, dels, base, notes FROM goals WHERE agent_id=?`, agentID). - Scan(&g.Title, &g.Body, &g.Criteria, &g.Checks, &g.Status, &g.Attempts, &g.Feedback, &g.Passed, &g.Total, &g.Adds, &g.Dels, &g.Base, &g.Notes) + err = s.db.QueryRow(`SELECT title, body, criteria, checks, status, attempts, feedback, passed, total, adds, dels, base, notes, from_branch FROM goals WHERE agent_id=?`, agentID). + Scan(&g.Title, &g.Body, &g.Criteria, &g.Checks, &g.Status, &g.Attempts, &g.Feedback, &g.Passed, &g.Total, &g.Adds, &g.Dels, &g.Base, &g.Notes, &g.From) if err == sql.ErrNoRows { return Goal{Status: "idle"}, nil } @@ -339,7 +387,7 @@ func (s *Store) SetGoal(agentID string, kv map[string]any) { // AddEvent appends to the log, filling in the event's id and timestamp. func (s *Store) AddEvent(e *LogEvent) { e.TS = time.Now().UnixMilli() - res, err := s.db.Exec(`INSERT INTO events(run_id, agent_id, ts, kind, text, raw) VALUES(?,?,?,?,?,?)`, e.Run, e.Agent, e.TS, e.Kind, e.Text, e.Raw) + res, err := s.db.Exec(`INSERT INTO events(run_id, session, agent_id, ts, kind, text, raw) VALUES(?,?,?,?,?,?,?)`, e.Run, e.Session, e.Agent, e.TS, e.Kind, e.Text, e.Raw) if err != nil { log.Printf("AddEvent: %v", err) return @@ -349,7 +397,8 @@ func (s *Store) AddEvent(e *LogEvent) { // Events returns the last limit events for an agent, oldest first. func (s *Store) Events(agentID string, limit int) ([]LogEvent, error) { - rows, err := s.db.Query(`SELECT * FROM (SELECT id, run_id, ts, kind, text, raw FROM events WHERE agent_id=? ORDER BY id DESC LIMIT ?) ORDER BY id`, agentID, limit) + rows, err := s.db.Query(`SELECT e.id, e.run_id, e.session, coalesce(r.attempt, 0), e.ts, e.kind, e.text, e.raw FROM + (SELECT * FROM events WHERE agent_id=? ORDER BY id DESC LIMIT ?) e LEFT JOIN runs r ON r.id = e.run_id ORDER BY e.id`, agentID, limit) if err != nil { return nil, err } @@ -357,7 +406,7 @@ func (s *Store) Events(agentID string, limit int) ([]LogEvent, error) { es := []LogEvent{} for rows.Next() { e := LogEvent{Agent: agentID} - if err := rows.Scan(&e.ID, &e.Run, &e.TS, &e.Kind, &e.Text, &e.Raw); err != nil { + if err := rows.Scan(&e.ID, &e.Run, &e.Session, &e.Attempt, &e.TS, &e.Kind, &e.Text, &e.Raw); err != nil { return nil, err } es = append(es, e) @@ -365,6 +414,54 @@ func (s *Store) Events(agentID string, limit int) ([]LogEvent, error) { return es, rows.Err() } +// RunUsage is one agent process call's time and tokens, for the Logs tab. +type RunUsage struct { + ID int64 `json:"id"` + Attempt int `json:"attempt"` + Started int64 `json:"started"` + Ended int64 `json:"ended"` + Tokens int `json:"tokens"` +} + +// Runs lists an agent's last limit process calls, oldest first. +func (s *Store) Runs(agentID string, limit int) ([]RunUsage, error) { + rows, err := s.db.Query(`SELECT * FROM (SELECT id, attempt, started, ended, tok_in + tok_out FROM runs WHERE agent_id=? ORDER BY id DESC LIMIT ?) ORDER BY id`, agentID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + rs := []RunUsage{} + for rows.Next() { + var r RunUsage + if err := rows.Scan(&r.ID, &r.Attempt, &r.Started, &r.Ended, &r.Tokens); err != nil { + return nil, err + } + rs = append(rs, r) + } + return rs, rows.Err() +} + +// CheckpointGoal is the goal an agent was working on when it made the checkpoint commit sha, from +// the run log ("" when the log doesn't say). It describes checkpoints from before commits did. +func (s *Store) CheckpointGoal(sha string) string { + var title string + s.db.QueryRow(`SELECT st.text FROM events c JOIN events st ON st.agent_id = c.agent_id AND st.session = c.session AND st.kind = 'start' + WHERE c.kind = 'msg' AND c.text LIKE 'checkpoint %' AND c.session != 0 AND length(c.text) > 14 AND ? LIKE substr(c.text, 12) || '%' + ORDER BY c.id DESC LIMIT 1`, sha).Scan(&title) + return strings.TrimSpace(title) +} + +// LastDecision is the agent's latest decision of one of the kinds: its kind and JSON, or "" if none. +func (s *Store) LastDecision(agentID string, kinds ...string) (kind, raw string) { + q := `SELECT kind, json FROM decisions WHERE agent_id=? AND kind IN (?` + strings.Repeat(",?", len(kinds)-1) + `) ORDER BY id DESC LIMIT 1` + args := []any{agentID} + for _, k := range kinds { + args = append(args, k) + } + s.db.QueryRow(q, args...).Scan(&kind, &raw) + return +} + // AddDecision records a manager's plan or review verdicts. func (s *Store) AddDecision(agentID, kind string, v any) { b, _ := json.Marshal(v) @@ -373,6 +470,57 @@ func (s *Store) AddDecision(agentID, kind string, v any) { } } +// Plan is a manager's plan (kind "plan": subgoals) or routing of a requested change (kind +// "revision": changes), kept as a draft while the user decides on it. +type Plan struct { + ID int64 `json:"id"` + Agent string `json:"agent"` + Kind string `json:"kind"` + JSON json.RawMessage `json:"plan"` + Status string `json:"status"` // pending | approved | replanned | cancelled | expired + Feedback string `json:"feedback"` + Created int64 `json:"created"` +} + +// SavePlan stores v as the agent's pending draft and returns its id. +func (s *Store) SavePlan(agentID, kind string, v any) (int64, error) { + b, err := json.Marshal(v) + if err != nil { + return 0, err + } + res, err := s.db.Exec(`INSERT INTO plans(agent_id, kind, json, created) VALUES(?,?,?,?)`, agentID, kind, string(b), time.Now().UnixMilli()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// PendingPlan returns the agent's draft waiting for a decision; sql.ErrNoRows when there is none. +func (s *Store) PendingPlan(agentID string) (p Plan, err error) { + var raw string + err = s.db.QueryRow(`SELECT id, agent_id, kind, json, status, feedback, created FROM plans WHERE agent_id=? AND status='pending' ORDER BY id DESC LIMIT 1`, agentID). + Scan(&p.ID, &p.Agent, &p.Kind, &raw, &p.Status, &p.Feedback, &p.Created) + p.JSON = json.RawMessage(raw) + return +} + +// DecidePlan records the decision on a pending draft, with the plan as the user left it when v +// isn't nil. It reports false when the draft was already decided, so only one decision wins. +func (s *Store) DecidePlan(id int64, status, feedback string, v any) bool { + q, args := `UPDATE plans SET status=?, feedback=?, decided=? WHERE id=? AND status='pending'`, []any{status, feedback, time.Now().UnixMilli(), id} + if v != nil { + b, _ := json.Marshal(v) + q, args = `UPDATE plans SET status=?, feedback=?, decided=?, json=? WHERE id=? AND status='pending'`, []any{status, feedback, time.Now().UnixMilli(), string(b), id} + } + res, err := s.db.Exec(q, args...) + if err != nil { + log.Printf("DecidePlan: %v", err) + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + // NewRun starts a run record for one attempt and returns its id. func (s *Store) NewRun(agentID string, attempt int) int64 { res, err := s.db.Exec(`INSERT INTO runs(agent_id, attempt, started) VALUES(?,?,?)`, agentID, attempt, time.Now().UnixMilli()) @@ -411,6 +559,12 @@ type AgentStatus struct { AllTokens int `json:"allTokens"` // every run ever Soft int `json:"tokenSoft"` Hard int `json:"tokenHard"` + // its queue, for the badge on its box + QueueDone int `json:"queueDone"` // goals it finished (done, failed, blocked, stopped) + QueueTotal int `json:"queueTotal"` // every goal in it but skipped ones + QueueActive bool `json:"queueActive"` // it's running through them + QueueWaiting int `json:"queueWaiting"` // goals not run yet + QueuePaused string `json:"queuePaused"` // the goal whose failure paused it, when goals are still waiting } func (s *Store) Summary(projectID string) (Summary, error) { @@ -420,7 +574,15 @@ func (s *Store) Summary(projectID string) (Summary, error) { (SELECT coalesce(sum(cost),0) FROM runs r WHERE r.agent_id=a.id AND r.started >= coalesce(g.since,0)), (SELECT coalesce(sum(tok_in+tok_out),0) FROM runs r WHERE r.agent_id=a.id AND r.started >= coalesce(g.since,0)), (SELECT coalesce(sum(cost),0) FROM runs r WHERE r.agent_id=a.id), - (SELECT coalesce(sum(tok_in+tok_out),0) FROM runs r WHERE r.agent_id=a.id) + (SELECT coalesce(sum(tok_in+tok_out),0) FROM runs r WHERE r.agent_id=a.id), + (SELECT count(*) FROM queue q WHERE q.agent_id=a.id AND q.status IN ('done', 'failed', 'blocked', 'stopped')), + (SELECT count(*) FROM queue q WHERE q.agent_id=a.id AND q.status != 'skipped'), + a.queue_active, + (SELECT count(*) FROM queue q WHERE q.agent_id=a.id AND q.status='queued'), + CASE WHEN a.queue_active = 0 AND EXISTS (SELECT 1 FROM queue q WHERE q.agent_id=a.id AND q.status='queued') + THEN coalesce((SELECT CASE WHEN q.status IN ('failed', 'blocked') THEN q.title ELSE '' END FROM queue q + WHERE q.agent_id=a.id AND q.status IN ('done', 'failed', 'blocked', 'stopped') ORDER BY q.finished DESC LIMIT 1), '') + ELSE '' END FROM agents a LEFT JOIN goals g ON g.agent_id = a.id WHERE a.project_id=?`, projectID) if err != nil { return sum, err @@ -430,7 +592,8 @@ func (s *Store) Summary(projectID string) (Summary, error) { var id string var st AgentStatus if err := rows.Scan(&id, &st.Title, &st.Status, &st.Attempts, &st.Passed, &st.Total, &st.Adds, &st.Dels, &st.Since, - &st.Soft, &st.Hard, &st.Cost, &st.Tokens, &st.AllCost, &st.AllTokens); err != nil { + &st.Soft, &st.Hard, &st.Cost, &st.Tokens, &st.AllCost, &st.AllTokens, + &st.QueueDone, &st.QueueTotal, &st.QueueActive, &st.QueueWaiting, &st.QueuePaused); err != nil { return sum, err } sum.Agents[id] = st diff --git a/internal/store/store_test.go b/internal/store/store_test.go index dfd59fc..39d575f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -142,3 +142,119 @@ func TestTypes(t *testing.T) { t.Fatal("type not deleted") } } + +// Only one decision lands on a draft, and a restart expires drafts nobody is waiting on anymore. +func TestPlanDrafts(t *testing.T) { + s, path := open(t) + id, err := s.SavePlan("m", "plan", map[string]any{"subgoals": []any{}}) + if err != nil { + t.Fatal(err) + } + if p, err := s.PendingPlan("m"); err != nil || p.ID != id || p.Kind != "plan" { + t.Fatalf("pending: %+v %v", p, err) + } + if !s.DecidePlan(id, "replanned", "smaller", nil) || s.DecidePlan(id, "approved", "", nil) { + t.Fatal("exactly the first decision should land") + } + if _, err := s.PendingPlan("m"); err == nil { + t.Fatal("a decided draft isn't pending") + } + id2, _ := s.SavePlan("m", "plan", map[string]any{}) + s.Close() + s2, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + var status string + s2.SQL().QueryRow(`SELECT status FROM plans WHERE id=?`, id2).Scan(&status) + if status != "expired" { + t.Fatalf("draft after restart: %q", status) + } + if as, _ := s2.Agents(func() string { ps, _ := s2.Projects(); return ps[0].ID }()); as[0].ApprovePlan { + t.Fatal("plan approval is opt-in") + } +} + +// Agents of a default type start with that type's instructions: the demo, and once, existing agents +// that had none. Instructions the user clears afterwards stay cleared. +func TestDefaultInstructions(t *testing.T) { + s, path := open(t) + ps, _ := s.Projects() + as, _ := s.Agents(ps[0].ID) + if as[0].Prompt != RolePrompts["manager"] || as[2].Prompt != RolePrompts["programmer"] { + t.Fatalf("demo instructions: %q / %q", as[0].Prompt, as[2].Prompt) + } + lead, fe := as[0], as[2] + fe.Prompt = "" + s.UpdateAgent(fe) + s.SQL().Exec(`PRAGMA user_version = 0`) // as before default instructions existed + lead.Prompt = "my own" + s.UpdateAgent(lead) + s.Close() + + reopen := func() []Agent { + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + as, _ := s.Agents(ps[0].ID) + return as + } + as = reopen() + if as[2].Prompt != RolePrompts["programmer"] || as[0].Prompt != "my own" { + t.Fatalf("backfill: programmer %q, lead %q", as[2].Prompt, as[0].Prompt) + } + s2, _ := Open(path) + as[2].Prompt = "" + s2.UpdateAgent(as[2]) + s2.Close() + if as := reopen(); as[2].Prompt != "" { + t.Fatal("instructions the user cleared must stay cleared") + } +} + +// The Coder type is called Programmer now; existing coders are renamed when the store opens. +func TestCoderBecomesProgrammer(t *testing.T) { + s, path := open(t) + ps, _ := s.Projects() + s.SQL().Exec(`UPDATE agents SET role = 'coder' WHERE role = 'programmer'`) // as before the rename + s.Close() + s2, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + as, _ := s2.Agents(ps[0].ID) + for _, a := range as { + if a.Role == "coder" { + t.Fatalf("%s is still a coder", a.Name) + } + } +} + +// A top-level agent's single goal from before goal lists becomes the first goal in its list, once; +// a report's goal stays its own. +func TestGoalBecomesFirstInList(t *testing.T) { + s, path := open(t) + ps, _ := s.Projects() + as, _ := s.Agents(ps[0].ID) + lead, report := as[0], as[1] + s.SaveGoal(lead.ID, Goal{Title: "ship it", Checks: "true"}) + s.SaveGoal(report.ID, Goal{Title: "its part", Checks: "true"}) + s.SQL().Exec(`PRAGMA user_version = 1`) // as before goal lists + s.Close() + s2, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer s2.Close() + q, _ := s2.Queue(lead.ID) + if len(q.Items) != 1 || q.Items[0].Title != "ship it" || q.Items[0].Status != "queued" { + t.Fatalf("lead's goals: %+v", q.Items) + } + if q, _ := s2.Queue(report.ID); len(q.Items) != 0 { + t.Fatalf("a report's goal isn't a list: %+v", q.Items) + } +} diff --git a/internal/store/templates.go b/internal/store/templates.go new file mode 100644 index 0000000..7942be4 --- /dev/null +++ b/internal/store/templates.go @@ -0,0 +1,106 @@ +package store + +import ( + "encoding/json" + "errors" + "strings" + "time" +) + +// Template is a team to drop onto the canvas: a starter team, or an arrangement the user saved. +type Template struct { + ID string `json:"id"` + Name string `json:"name"` + About string `json:"about"` + Agents []TemplateAgent `json:"agents"` + BuiltIn bool `json:"builtIn"` +} + +// TemplateAgent is one box of a template; Key and Parent link boxes within it, X and Y are relative. +type TemplateAgent struct { + Key string `json:"key"` + Name string `json:"name"` + Role string `json:"role"` + Parent string `json:"parent"` + Runtime string `json:"runtime"` + Model string `json:"model"` + Args string `json:"args"` + Prompt string `json:"prompt"` + Color string `json:"color"` + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// StarterTeams are the teams a new project can start from. +var StarterTeams = []Template{ + {ID: "starter-feature", Name: "Feature team", About: "A manager with a product manager, two programmers and a tester.", BuiltIn: true, Agents: []TemplateAgent{ + {Key: "lead", Name: "Lead", Role: "manager", X: 360, Y: 0}, + {Key: "pm", Name: "Product Manager", Role: "product-manager", Parent: "lead", X: 0, Y: 150}, + {Key: "be", Name: "Backend", Role: "programmer", Parent: "lead", X: 240, Y: 150}, + {Key: "fe", Name: "Frontend", Role: "programmer", Parent: "lead", X: 480, Y: 150}, + {Key: "qa", Name: "Tester", Role: "tester", Parent: "lead", X: 720, Y: 150}, + }}, + {ID: "starter-bugfix", Name: "Bug fix", About: "A manager with a programmer who fixes it and a tester who proves it.", BuiltIn: true, Agents: []TemplateAgent{ + {Key: "lead", Name: "Lead", Role: "manager", X: 120, Y: 0}, + {Key: "fix", Name: "Fixer", Role: "programmer", Parent: "lead", X: 0, Y: 150}, + {Key: "qa", Name: "Tester", Role: "tester", Parent: "lead", X: 240, Y: 150}, + }}, + {ID: "starter-docs", Name: "Docs", About: "A technical content writer on its own.", BuiltIn: true, Agents: []TemplateAgent{ + {Key: "doc", Name: "Technical Content Writer", Role: "writer", X: 0, Y: 0}, + }}, +} + +// Templates are the starter teams, then the user's saved ones. +func (s *Store) Templates() ([]Template, error) { + ts := append([]Template{}, StarterTeams...) + for i := range ts { + for j := range ts[i].Agents { + a := &ts[i].Agents[j] + if a.Prompt == "" { + a.Prompt = RolePrompts[a.Role] + } + } + } + rows, err := s.db.Query(`SELECT id, name, about, json FROM templates ORDER BY created`) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var t Template + var raw string + if err := rows.Scan(&t.ID, &t.Name, &t.About, &raw); err != nil { + return nil, err + } + json.Unmarshal([]byte(raw), &t.Agents) + ts = append(ts, t) + } + return ts, rows.Err() +} + +// SaveTemplate stores the user's arrangement as a new template. +func (s *Store) SaveTemplate(t *Template) error { + t.Name = strings.TrimSpace(t.Name) + if t.Name == "" { + return errors.New("give the template a name") + } + if len(t.Agents) == 0 { + return errors.New("the canvas is empty; add agents first") + } + b, err := json.Marshal(t.Agents) + if err != nil { + return err + } + t.ID, t.BuiltIn = newID("tpl"), false + _, err = s.db.Exec(`INSERT INTO templates(id, name, about, json, created) VALUES(?,?,?,?,?)`, t.ID, t.Name, t.About, string(b), time.Now().UnixMilli()) + return err +} + +// DeleteTemplate removes a saved template; starter teams stay. +func (s *Store) DeleteTemplate(id string) error { + if strings.HasPrefix(id, "starter-") { + return errors.New("starter teams can't be deleted") + } + _, err := s.db.Exec(`DELETE FROM templates WHERE id=?`, id) + return err +} diff --git a/internal/version/version.go b/internal/version/version.go index 8408545..6c8e251 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -80,9 +80,6 @@ func (i Info) Short() string { func (i Info) String() string { var parts []string if c := i.Short(); c != "" { - if i.Dirty { - c += "+dirty" - } parts = append(parts, c) } if w := i.When(); w != "" { diff --git a/web/app.js b/web/app.js index 16f9978..856d2d5 100644 --- a/web/app.js +++ b/web/app.js @@ -6,7 +6,7 @@ const PID = document.body.dataset.project; const main = $("main"), canvas = $("#canvas"), linksSvg = $("#links"); const SVG = "http://www.w3.org/2000/svg"; const MAX_LOG_ROWS = 1000; -const BUSY = ["starting", "running", "verifying", "planning", "waiting", "reviewing"]; +const BUSY = ["starting", "running", "verifying", "planning", "waiting", "reviewing", "fixing"]; const NEEDS = ["failed", "blocked"]; const GAP_X = 240, GAP_Y = 150, PAD = 48, SNAP = 8; let dirty = false, selected = null, tab = "goal"; @@ -100,7 +100,7 @@ function layout(all) { // "down" while the manager hands out subgoals (planning, or the report is just starting), // "up" while the report works for its manager, or the manager reviews what it sent back. function linkFlow(mgrStatus, kidStatus) { - if (kidStatus === "starting" || mgrStatus === "planning") return { dir: "down", status: kidStatus === "starting" ? kidStatus : mgrStatus }; + if (kidStatus === "starting" || mgrStatus === "planning" || mgrStatus === "fixing") return { dir: "down", status: kidStatus === "starting" ? kidStatus : mgrStatus }; if (BUSY.includes(kidStatus)) return { dir: "up", status: kidStatus }; if (mgrStatus === "reviewing" && kidStatus === "done") return { dir: "up", status: mgrStatus }; return null; @@ -124,7 +124,20 @@ function drawLinks() { path.dataset.status = flow.status; path.classList.add("flow", flow.dir); } - paths.push(path); + // hovering the line shows an x at its middle; clicking it removes the connection + const g = document.createElementNS(SVG, "g"), hit = path.cloneNode(), cut = document.createElementNS(SVG, "g"); + g.setAttribute("class", "link"); + hit.setAttribute("class", "hit"); + cut.setAttribute("class", "cut"); + cut.setAttribute("transform", `translate(${(x1 + x2) / 2},${(y1 + y2) / 2})`); + const ring = document.createElementNS(SVG, "circle"), x = document.createElementNS(SVG, "path"), tip = document.createElementNS(SVG, "title"); + ring.setAttribute("r", 9); + x.setAttribute("d", "M-3.5,-3.5 L3.5,3.5 M3.5,-3.5 L-3.5,3.5"); + tip.textContent = `Remove: ${a.name} stops reporting to ${p.name}`; + cut.append(tip, ring, x); + cut.addEventListener("click", () => unlink(a.id)); + g.append(hit, path, cut); + paths.push(g); } linksSvg.replaceChildren(...paths); // room to spread out: the canvas always reaches well past the last box and the viewport @@ -145,10 +158,12 @@ function addAgent(role, name, type, x, y, parent) { id: slug(role) + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5), name, role, runtime: type?.runtime ?? "claude", parent: parent ?? "", color: "", x: Math.max(0, Math.round(x / SNAP) * SNAP), y: Math.max(0, Math.round(y / SNAP) * SNAP), - defaults: type ? { model: type.model, args: type.args, prompt: type.prompt } : null, + defaults: type ? { model: type.model, args: type.args, prompt: type.prompt } + : window.ROLES?.[role] ? { prompt: window.ROLES[role] } : null, // a default type starts with its role's instructions }; agents.set(a.id, a); makeCard(a); + showEmptyCanvas(); return a; } @@ -184,10 +199,70 @@ main.addEventListener("drop", e => { refreshCards(); }); +// unlink removes the connection above an agent: it no longer reports to anyone. +function unlink(id) { + const a = agents.get(id), was = parentOf(id); + if (!a || !was) return; + a.parent = ""; + paintCard(a); + drawLinks(); + say(`${a.name} no longer reports to ${nameOf(was)} · unsaved`); + dirty = true; + if (selected === id) fillParents(id); +} + +// linkFrom draws a connection from one of a box's dots, like draw.io. From the bottom dot, the agent +// it's released over reports to this one; from a side dot, whichever box is higher on the canvas +// manages (level: the one dropped on reports). +function linkFrom(e, c) { + const src = agents.get(c.dataset.id), draft = document.createElementNS(SVG, "path"), cls = e.target.classList; + const side = cls.contains("left") ? -1 : cls.contains("right") ? 1 : 0, down = cls.contains("down"); + draft.classList.add("draft"); + canvas.classList.add("linking"); + c.classList.add("src"); + e.target.classList.add("on"); + let over = null; + try { e.target.setPointerCapture(e.pointerId); } catch {} + const x1 = side ? src.x + (side > 0 ? c.offsetWidth : 0) : src.x + c.offsetWidth / 2; + const y1 = side ? src.y + c.offsetHeight / 2 : src.y + c.offsetHeight, dir = 1; + // the manager and the report a drop would make, and whether that changes anything without a loop + const managesIt = id => side ? agents.get(id).y >= src.y : down; + const pair = id => managesIt(id) ? { mgr: src.id, kid: id } : { mgr: id, kid: src.id }; + const allowed = id => { const { mgr, kid } = pair(id); return agents.get(kid).parent !== mgr && !isUnder(mgr, kid); }; + e.target.onpointermove = m => { + const r = canvas.getBoundingClientRect(), x2 = m.clientX - r.left, y2 = m.clientY - r.top; + const bend = Math.max(40, Math.abs((side ? x2 - x1 : y2 - y1)) / 2); + draft.setAttribute("d", side + ? `M${x1},${y1} C${x1 + side * bend},${y1} ${x2 - side * bend},${y2} ${x2},${y2}` + : `M${x1},${y1} C${x1},${y1 + dir * bend} ${x2},${y2 - dir * bend} ${x2},${y2}`); + if (!draft.isConnected) linksSvg.append(draft); // a live refresh may have redrawn the links + over?.classList.remove("drop"); + over = document.elementsFromPoint(m.clientX, m.clientY).find(x => x !== c && x.matches?.("#canvas .card")); + if (over && !allowed(over.dataset.id)) over = null; // no-ops and loops + over?.classList.add("drop"); + }; + e.target.onpointerup = e.target.onpointercancel = () => { + e.target.onpointermove = e.target.onpointerup = e.target.onpointercancel = null; + draft.remove(); + canvas.classList.remove("linking"); + c.classList.remove("src"); + e.target.classList.remove("on"); + if (!over) return; + over.classList.remove("drop"); + const { mgr, kid: kidId } = pair(over.dataset.id), kid = agents.get(kidId); + kid.parent = mgr; + paintCard(kid); + say(`${kid.name} now reports to ${nameOf(mgr)} · unsaved`); + dirty = true; + drawLinks(); + }; +} + // boxes on the canvas: drag to move; release over another box to report to it; click to open canvas.addEventListener("pointerdown", e => { const c = e.target.closest(".card"); if (!c || e.button !== 0 || e.target.classList.contains("x")) return; + if (e.target.classList.contains("port")) { e.preventDefault(); linkFrom(e, c); return; } const a = agents.get(c.dataset.id), sx = e.clientX, sy = e.clientY, ox = a.x, oy = a.y; let moved = false, over = null; try { c.setPointerCapture(e.pointerId); } catch {} // keeps the drag when the pointer outruns the box @@ -209,7 +284,14 @@ canvas.addEventListener("pointerdown", e => { c.onpointerup = c.onpointercancel = () => { c.onpointermove = c.onpointerup = c.onpointercancel = null; c.classList.remove("dragging"); - if (!moved) { select(a.id); return; } + if (!moved && e.shiftKey) { + const ids = new Set(picked); + if (selected && !ids.size) ids.add(selected); + ids.has(a.id) ? ids.delete(a.id) : ids.add(a.id); + setPicked(ids); + return; + } + if (!moved) { setPicked([]); select(a.id); return; } if (over) { over.classList.remove("drop"); a.parent = over.dataset.id; @@ -221,34 +303,150 @@ canvas.addEventListener("pointerdown", e => { drawLinks(); }; }); -// empty space: drag to pan, like draw.io +// picked: the agents selected together, by dragging a box around them or shift-clicking +const picked = new Set(); +function setPicked(ids) { + picked.clear(); + ids.forEach(id => picked.add(id)); + for (const a of agents.values()) a.el.classList.toggle("picked", picked.has(a.id)); + if (picked.size > 1) say(`${picked.size} agents selected · Backspace removes them, Esc clears`); +} + +// empty space: drag to select the boxes in a rectangle, like draw.io; hold Space (or use the +// middle button) and drag to move around, or just scroll +let spaceDown = false; +document.addEventListener("keydown", e => { + if (e.code !== "Space" || e.repeat || e.target.closest?.("input, textarea, select, button, [contenteditable], dialog")) return; + spaceDown = true; + main.classList.add("can-pan"); + e.preventDefault(); +}); +document.addEventListener("keyup", e => { if (e.code === "Space") { spaceDown = false; main.classList.remove("can-pan"); } }); main.addEventListener("pointerdown", e => { - if (e.button !== 0 || e.target.closest(".card, button")) return; - const sx = e.clientX + main.scrollLeft, sy = e.clientY + main.scrollTop; + if (e.button !== 0 && e.button !== 1 || e.target.closest("button, #links .link, #empty-canvas") || e.target.closest(".card") && !spaceDown) return; try { main.setPointerCapture(e.pointerId); } catch {} - main.classList.add("panning"); - main.onpointermove = m => { main.scrollLeft = sx - m.clientX; main.scrollTop = sy - m.clientY; }; + if (spaceDown || e.button === 1) { + e.preventDefault(); + const sx = e.clientX + main.scrollLeft, sy = e.clientY + main.scrollTop; + main.classList.add("panning"); + main.onpointermove = m => { main.scrollLeft = sx - m.clientX; main.scrollTop = sy - m.clientY; }; + main.onpointerup = main.onpointercancel = () => { + main.onpointermove = main.onpointerup = main.onpointercancel = null; + main.classList.remove("panning"); + }; + return; + } + const r0 = canvas.getBoundingClientRect(), x0 = e.clientX - r0.left, y0 = e.clientY - r0.top; + const box = el("div"); + box.id = "marquee"; + let moved = false; + const before = e.shiftKey ? new Set(picked) : new Set(); // shift adds to what's selected + main.onpointermove = m => { + const r = canvas.getBoundingClientRect(), x1 = m.clientX - r.left, y1 = m.clientY - r.top; + if (!moved && Math.hypot(x1 - x0, y1 - y0) < 4) return; + if (!moved) { moved = true; canvas.append(box); } + const left = Math.min(x0, x1), top = Math.min(y0, y1), w = Math.abs(x1 - x0), h = Math.abs(y1 - y0); + Object.assign(box.style, { left: left + "px", top: top + "px", width: w + "px", height: h + "px" }); + const inside = [...agents.values()].filter(a => a.x < left + w && a.x + a.el.offsetWidth > left && a.y < top + h && a.y + a.el.offsetHeight > top).map(a => a.id); + setPicked([...before, ...inside]); + }; main.onpointerup = main.onpointercancel = () => { main.onpointermove = main.onpointerup = main.onpointercancel = null; - main.classList.remove("panning"); + box.remove(); + if (!moved && !e.shiftKey) setPicked([]); // a click on empty space clears the selection }; }); -canvas.addEventListener("click", e => { - if (!e.target.classList.contains("x")) return; - const id = e.target.closest(".card").dataset.id, a = agents.get(id); - if (BUSY.includes(a.el.dataset.status)) { say(`${a.name} is running; stop it before removing it`, true); return; } - kidsOf(id).forEach(k => k.parent = a.parent); // reports move up a level - if (selected === id) closeInspector(); - a.el.remove(); - agents.delete(id); +// ask shows the styled confirm dialog and resolves true when the user presses the action button, +// which is red unless danger is false. +function ask(title, text, action, keep = "Cancel", danger = true) { + const d = $("#confirm-dialog"); + $("#c-input").hidden = true; + $("#c-ok").classList.toggle("danger", danger); + $("#c-cancel").textContent = keep; + $("h3", d).textContent = title; + $("#c-text").textContent = text; + $("#c-text").hidden = !text; + $("#c-ok").textContent = action; + d.returnValue = ""; + d.showModal(); + $("#c-cancel").focus(); // Enter shouldn't delete by accident + return new Promise(r => d.addEventListener("close", () => r(d.returnValue === "ok"), { once: true })); +} + +// removeAgent takes an agent off the canvas after the user confirms; its reports move up a level. +async function removeAgent(id) { return removeAgents([id]); } + +// removeAgents takes agents off the canvas after one confirmation. Reports of a removed agent move +// up to its nearest manager that stays. +async function removeAgents(ids) { + const gone = ids.map(id => agents.get(id)).filter(Boolean); + if (!gone.length) return; + const busy = gone.filter(a => BUSY.includes(a.el.dataset.status)); + if (busy.length) { say(`${names(busy.map(a => a.name))} ${busy.length === 1 ? "is" : "are"} running; stop ${busy.length === 1 ? "it" : "them"} before removing`, true); return; } + const out = new Set(gone.map(a => a.id)); + const keeper = id => { let p = parentOf(id); while (p && out.has(p)) p = parentOf(p); return p || ""; }; + const moving = [...agents.values()].filter(k => !out.has(k.id) && out.has(k.parent)); + const withWork = gone.filter(a => summary.agents[a.id]?.adds || summary.agents[a.id]?.dels); + const notes = []; + if (gone.length === 1) { + const a = gone[0], st = summary.agents[a.id]; + if (moving.length) notes.push(`Its ${moving.length === 1 ? "report moves" : `${moving.length} reports move`} up to ${keeper(a.id) ? nameOf(keeper(a.id)) : "the top level"}.`); + if (withWork.length) notes.push(`Its work (+${st.adds} −${st.dels}) and its branch arranger/${a.id} are deleted when you save. Merge it first if you want to keep it.`); + } else { + notes.push(names(gone.map(a => a.name)) + "."); + if (moving.length) notes.push(`${moving.length === 1 ? "1 report moves" : `${moving.length} reports move`} up to the nearest manager that stays.`); + if (withWork.length === gone.length) notes.push("Their work and branches are deleted when you save. Merge first if you want to keep it."); + else if (withWork.length) notes.push(`The work of ${names(withWork.map(a => a.name))} and ${withWork.length === 1 ? "its branch are" : "their branches are"} deleted when you save. Merge first if you want to keep it.`); + } + if (!await ask(gone.length === 1 ? `Remove ${gone[0].name}?` : `Remove ${gone.length} agents?`, notes.join(" "), gone.length === 1 ? "Remove" : `Remove ${gone.length}`)) return; + for (const k of moving) if (agents.has(k.id)) k.parent = keeper(k.id); + for (const a of gone) { + if (!agents.has(a.id)) continue; // removed some other way while the dialog was open + if (selected === a.id) closeInspector(); + a.el.remove(); + agents.delete(a.id); + } + setPicked([]); markDirty(); drawLinks(); + showEmptyCanvas(); +} +canvas.addEventListener("click", e => { + if (e.target.classList.contains("x")) removeAgent(e.target.closest(".card").dataset.id); +}); +// Backspace or Delete removes the selected agent, unless the user is typing or a dialog is open +document.addEventListener("keydown", e => { + const t = e.target; + if (t.closest?.("input, textarea, select, [contenteditable], dialog") || document.querySelector("dialog[open]")) return; + if (e.key === "Escape" && picked.size) { setPicked([]); say(""); return; } + if (e.key.toLowerCase() === "a" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) { // select every agent + if (!agents.size) return; + e.preventDefault(); + setPicked([...agents.keys()]); + return; + } + if (e.key !== "Backspace" && e.key !== "Delete" || e.metaKey || e.ctrlKey || e.altKey) return; + const ids = picked.size ? [...picked] : selected ? [selected] : []; + if (!ids.length) return; + e.preventDefault(); + removeAgents(ids); }); -canvas.addEventListener("dblclick", e => { +// askText is ask with a text field; it resolves to the text, or null when cancelled. +async function askText(title, value, action) { + const done = ask(title, "", action, "Cancel", false), input = $("#c-input"); + input.hidden = false; + input.value = value; + input.focus(); + input.select(); + return (await done) ? input.value.trim() : null; +} + +canvas.addEventListener("dblclick", async e => { const c = e.target.closest(".card"); const a = c && agents.get(c.dataset.id); - const v = a && prompt("Agent name", a.name); - if (v?.trim()) { a.name = v.trim(); paintCard(a); markDirty(); } + if (!a) return; + const v = await askText("Rename agent", a.name, "Rename"); + if (v && v !== a.name) { a.name = v; paintCard(a); markDirty(); } }); $("#tidy").onclick = () => { layout(true); @@ -306,6 +504,7 @@ tform.onsubmit = async e => { e.preventDefault(); try { if (action === "delete") { + if (!await ask(`Delete the ${editingType.name} type?`, "You can't drag new agents from it anymore. Agents already on the canvas keep their settings.", "Delete")) return; await api("DELETE", `/api/types/${editingType.id}`); TYPES.delete(editingType.name); } else { @@ -322,10 +521,20 @@ tform.onsubmit = async e => { /* ---------- header: projects, theme, run all, stats ---------- */ -$("#project").onchange = e => { - if (dirty && !confirm("Discard unsaved changes?")) { e.target.value = PID; return; } - location = "/arrange?p=" + encodeURIComponent(e.target.value); -}; +// switching projects leaves this page; unsaved canvas changes would be lost, so ask first +$$("#project ul a").forEach(link => link.onclick = async e => { + $("#project").open = false; + if (link.classList.contains("on")) { e.preventDefault(); return; } + if (!dirty) return; + e.preventDefault(); + if (await ask("Discard unsaved changes?", "The canvas has changes you haven't saved. Switching projects throws them away.", "Discard and switch")) { + dirty = false; // beforeunload would ask again + location = link.href; + } +}); +// open menus close on a click elsewhere or Escape +document.addEventListener("click", e => $$("details.menu[open], #needs[open]").forEach(d => { if (!d.contains(e.target)) d.open = false; })); +document.addEventListener("keydown", e => { if (e.key === "Escape") $$("details.menu[open], #needs[open]").forEach(d => { d.open = false; d.querySelector("summary").focus(); }); }); const dlg = $("#project-dialog"), pform = $("#project-form"); let editing = null; // null = creating @@ -357,6 +566,12 @@ $("#theme").onclick = () => { }; $("#run-all").onclick = async () => { + const tops = [...agents.values()].filter(a => !parentOf(a.id) && summary.agents[a.id]?.queueWaiting); + if (!tops.length) { say("No top-level agent has goals to run. Select one and add a goal first.", true); return; } + const names = tops.map(a => a.name).join(", "); + if (!await ask(`Run all ${tops.length} top-level ${tops.length === 1 ? "agent" : "agents"}?`, + `${tops.map(a => `${a.name}: ${plural(summary.agents[a.id].queueWaiting, "goal")}`).join(", ")}. Managers plan for their teams, so every agent below them may run too.` + + (dirty ? " Unsaved changes on the canvas are saved first." : ""), "Run all", "Cancel", false)) return; try { if (dirty) await save(); const r = await api("POST", `/api/projects/${PID}/run`); @@ -384,15 +599,15 @@ $("#stats").onclick = async () => { const tiles = el("div", "tiles"); [[`${s.done}/${s.goals}`, "goals done"], [pct(s.firstTry, s.done), "done on the first try"], [s.failed, "failed or blocked"], [s.runs, "agent runs"], [fmtDur(s.seconds * 1000), "agent time"], [fmtTok(s.tokens), "tokens"], - ["$" + s.cost.toFixed(2), "cost"], [s.done ? "$" + (s.cost / s.done).toFixed(2) : "–", "per finished goal"], [[el("b", "ok", "+" + s.adds), el("b", "bad", "−" + s.dels)], "lines changed"]] + [s.done ? fmtTok(Math.round(s.tokens / s.done)) : "–", "tokens per finished goal"], [[el("b", "ok", "+" + s.adds), el("b", "bad", "−" + s.dels)], "lines changed"]] .forEach(([v, l]) => { const d = el("div"); d.append(...(Array.isArray(v) ? v : [el("b", "", v)]), el("span", "", l)); tiles.append(d); }); body.replaceChildren(tiles, el("h2", "", "By runtime"), - table(["runtime", "agents", "runs", "done", "failed", "time", "tokens", "cost", "$ / done"], - s.runtimes.map(r => [r.runtime, r.agents, r.runs, r.done, r.failed, fmtDur(r.seconds * 1000), fmtTok(r.tokens), "$" + r.cost.toFixed(2), r.done ? "$" + (r.cost / r.done).toFixed(2) : "–"])), + table(["runtime", "agents", "runs", "done", "failed", "time", "tokens", "tokens / done"], + s.runtimes.map(r => [r.runtime, r.agents, r.runs, r.done, r.failed, fmtDur(r.seconds * 1000), fmtTok(r.tokens), r.done ? fmtTok(Math.round(r.tokens / r.done)) : "–"])), el("h2", "", "By agent"), - table(["agent", "runtime", "status", "attempts", "runs", "time", "tokens", "cost", "lines"], - s.agents.map(a => [a.name, a.runtime, a.status, a.attempts, a.runs, fmtDur(a.seconds * 1000), fmtTok(a.tokens), "$" + a.cost.toFixed(2), `+${a.adds} −${a.dels}`]))); + table(["agent", "runtime", "status", "attempts", "runs", "time", "tokens", "lines"], + s.agents.map(a => [a.name, a.runtime, a.status, a.attempts, a.runs, fmtDur(a.seconds * 1000), fmtTok(a.tokens), `+${a.adds} −${a.dels}`]))); }; /* ---------- inspector ---------- */ @@ -428,22 +643,99 @@ function fillParents(id) { sel.replaceChildren(new Option("nobody (top level)", "")); for (const a of agents.values()) if (a.id !== id && !isUnder(a.id, id)) sel.append(new Option(a.name, a.id)); sel.value = parentOf(id) ?? ""; + drawPick(sel); +} + +// drawPick shows a native select as the app's own dropdown: the select stays (hidden) as the value, +// and choosing an option sets it and fires its change event, so existing handlers keep working. +function drawPick(sel) { + let menu = sel.nextElementSibling; + if (!menu?.classList.contains("pick")) { + menu = el("details", "menu pick"); + menu.append(el("summary"), el("ul")); + sel.after(menu); + sel.hidden = true; + } + const cur = sel.selectedOptions[0]; + const sum = menu.querySelector("summary"); + sum.replaceChildren(el("span", "", cur?.text ?? ""), (() => { + const s = document.createElementNS(SVG, "svg"), p = document.createElementNS(SVG, "path"); + s.setAttribute("viewBox", "0 0 24 24"); p.setAttribute("d", "M6 9l6 6 6-6"); s.append(p); return s; + })()); + menu.querySelector("ul").replaceChildren(...[...sel.options].map(o => { + const li = el("li"), b = el("button", o.selected ? "on" : "", o.text); + b.type = "button"; + b.onclick = () => { + menu.open = false; + if (sel.value === o.value) return; + sel.value = o.value; + drawPick(sel); + sel.dispatchEvent(new Event("change")); + }; + li.append(b); + return li; + })); +} + +$("#i-dir").onclick = async e => { + const path = e.currentTarget.dataset.path; + if (!path) return; + try { await navigator.clipboard.writeText(path); say("Copied " + path); } + catch { say("Couldn't copy the path", true); } +}; + +// selAgent is the selected agent's settings as last saved. +let selAgent = null; + +// showApprove shows the plan-approval setting, in Settings and next to the goal, for managers only. +function showApprove(on) { + const manager = selected && kidsOf(selected).length > 0; + aform.elements.approvePlan.checked = $("#g-approve").checked = on; + $("#a-approve-row").style.display = $("#g-approve-row").style.display = manager ? "flex" : "none"; } +// The Goal tab's checkbox saves the setting right away; the rest of the settings stay as saved. +$("#g-approve").onchange = async e => { + const on = e.target.checked, id = selected; + try { + await api("PUT", `/api/agents/${encodeURIComponent(id)}`, { ...selAgent, approvePlan: on }); + if (id !== selected) return; + selAgent.approvePlan = on; + agents.get(id).approvePlan = on; + aform.elements.approvePlan.checked = on; + status($("#g-msg"), on ? `${nameOf(id)} will show you its plan before its team runs.` : `${nameOf(id)}'s team starts as soon as it has a plan.`); + } catch (err) { + e.target.checked = !on; + status($("#g-msg"), err.message, true); + } +}; + async function select(id) { let data; - try { data = await api("GET", `/api/agents/${encodeURIComponent(id)}`); } - catch (e) { say(dirty ? "Save the arrangement first" : e.message, true); return; } + const load = () => api("GET", `/api/agents/${encodeURIComponent(id)}`); + try { data = await load(); } + catch (e) { + // an agent just added to the canvas isn't saved yet: save the canvas, then open it + if (!dirty || !agents.has(id)) { say(e.message, true); return; } + try { await save(); data = await load(); } catch (e2) { say(e2.message, true); return; } + } selected = id; $$(".card.sel").forEach(c => c.classList.remove("sel")); cardOf(id)?.classList.add("sel"); insp.hidden = false; const { agent: a, goal: g } = data; + selAgent = a; $("#i-name").textContent = a.name; + const d = $("#i-dir"); + d.textContent = data.dir ? `(${data.dir})` : ""; + d.dataset.path = data.dir; + d.title = data.dir ? `Worktree on branch ${data.branch}. Click to copy the path.` : ""; for (const k of ["title", "body", "criteria", "checks"]) gform.elements[k].value = g[k]; + showSuggestions($("#g-suggest"), gform.elements.checks); for (const k of ["name", "runtime", "model", "args", "prompt"]) aform.elements[k].value = a[k]; aform.elements.tokenSoft.value = a.tokenSoft || ""; aform.elements.tokenHard.value = a.tokenHard || ""; + showApprove(a.approvePlan); colorChoice = a.color || ""; if (agents.has(id)) agents.get(id).color = colorChoice; $("#a-color").value = colorChoice || roleColor(id); @@ -453,6 +745,12 @@ async function select(id) { status($("#g-msg"), ""); if (id !== revisingFor) { $("#r-change").value = ""; status($("#r-msg"), ""); } // a draft belongs to its agent showGoalState(g); + renderPlan(); + $("#q-form").hidden = true; + $("#q-actions").hidden = false; + status($("#q-msg"), ""); + $("#g-single").hidden = !parentOf(id); // a report's goal comes from its manager; top-level agents have a list + loadQueue(); renderStats(); showTab(tab); } @@ -479,12 +777,10 @@ $("#a-parent").onchange = e => { function showGoalState(g) { const mgr = parentOf(selected), team = kidsOf(selected); - // once an agent has run (and isn't running), changes go through Request changes, not goal edits - $("#revise").hidden = !g.title || g.status === "idle" || BUSY.includes(g.status); - $("#r-hint").textContent = team.length - ? `${nameOf(selected)} passes each part to the ${team.length === 1 ? "agent" : "agents"} it concerns and re-runs only them. Everyone keeps their work; ${nameOf(selected)} reviews and merges the changes and runs its checks again.` - : `${nameOf(selected)} keeps its work and changes only this, then its checks run again.`; - $("#i-run").title = team.length ? "Plan again from the goal and run the whole team" : "Run from the goal"; + $("#i-run").title = !mgr ? "Run the goals in its list" : "Run from its goal"; + const canContinue = team.length > 0 && ["blocked", "failed"].includes(g.status); + $("#i-continue").hidden = !canContinue; + $("#i-run").classList.toggle("primary", !canContinue); // one primary action at a time $("#g-hint").textContent = kidsOf(selected).length ? "Manager: Run plans subgoals for its team, runs them in parallel, reviews and merges their work, then runs these checks on the result." : mgr ? `${nameOf(mgr)} sets this goal when it plans; editing it here is fine, but the next plan replaces it.` : ""; @@ -498,15 +794,42 @@ function showGoalState(g) { // limitClass says whether a token count is past the agent's soft or hard limit. const limitClass = st => st.tokenHard && st.tokens >= st.tokenHard ? "over" : st.tokenSoft && st.tokens >= st.tokenSoft ? "warn" : ""; +// WORKING says what a busy agent is doing, for the banner at the top of its Goal tab. +const WORKING = { starting: "is starting", running: "is working", verifying: "is running its checks", planning: "is planning for its team", + waiting: "is waiting for its team", reviewing: "is reviewing its team's work", + fixing: "is working out who fixes its failing checks" }; + +function renderWorking(st) { + if (tab === "logs") renderLogNow(); + const box = $("#g-working"), busy = st && BUSY.includes(st.status); + box.hidden = !busy; + if (!busy) return; + box.dataset.status = st.status; + $("#g-working-dot").dataset.status = st.status; + $("#g-working-text").textContent = `${nameOf(selected)} ${WORKING[st.status] ?? st.status}` + (st.since ? ` · ${fmtDur(Date.now() - st.since)}` : ""); + $("#g-working-note").textContent = st.status === "planning" && agents.get(selected)?.approvePlan + ? "It's reading the repo and writing the plan. The plan shows up here for your approval; nothing runs until you approve it." : ""; +} + +// showStop enables Stop only while the selected agent is working or waiting on its plan. +function showStop() { + const s = selected && summary.agents[selected]?.status; + const on = BUSY.includes(s) || s === "awaiting"; + $("#i-stop").disabled = !on; + $("#i-stop").title = on ? "Stop it (and its team)" : "Nothing to stop: it isn't running"; +} + function renderStats() { const st = selected && summary.agents[selected], box = $("#i-stats"); + showStop(); + renderWorking(st); if (!st) { box.replaceChildren(); return; } const parts = [el("span", "", st.status)]; if (BUSY.includes(st.status) && st.since) parts.push(el("span", "", "working " + fmtDur(Date.now() - st.since))); const run = el("span", limitClass(st)); - run.append("this run ", el("b", "", fmtTok(st.tokens) + " tok"), ` · $${st.cost.toFixed(2)}`); + run.append("this run ", el("b", "", fmtTok(st.tokens) + " tok")); if (st.tokenSoft || st.tokenHard) run.append(` (limits ${st.tokenSoft ? fmtTok(st.tokenSoft) : "–"} / ${st.tokenHard ? fmtTok(st.tokenHard) : "–"})`); - parts.push(run, el("span", "", `all time ${fmtTok(st.allTokens)} tok · $${st.allCost.toFixed(2)}`)); + parts.push(run, el("span", "", `all time ${fmtTok(st.allTokens)} tok`)); box.replaceChildren(...parts); } @@ -545,14 +868,18 @@ aform.onsubmit = async e => { e.preventDefault(); const f = aform.elements; const body = { name: f.name.value, runtime: f.runtime.value, model: f.model.value, args: f.args.value, prompt: f.prompt.value, - tokenSoft: Number(f.tokenSoft.value) || 0, tokenHard: Number(f.tokenHard.value) || 0, color: colorChoice }; + tokenSoft: Number(f.tokenSoft.value) || 0, tokenHard: Number(f.tokenHard.value) || 0, color: colorChoice, + approvePlan: f.approvePlan.checked }; try { + if (dirty) await save(); // the canvas too: new agents, moves, renames await api("PUT", `/api/agents/${selected}`, body); const a = agents.get(selected); - Object.assign(a, { name: body.name, runtime: body.runtime, color: body.color }); + Object.assign(a, { name: body.name, runtime: body.runtime, color: body.color, approvePlan: body.approvePlan }); + Object.assign(selAgent, body); + $("#g-approve").checked = body.approvePlan; paintCard(a); $("#i-name").textContent = a.name; - status($("#a-msg"), dirty ? "Settings saved. The arrangement still has unsaved changes." : "Saved."); + status($("#a-msg"), "Saved."); refreshSummary(); } catch (err) { status($("#a-msg"), err.message, true); } }; @@ -561,6 +888,7 @@ aform.onsubmit = async e => { const runMsg = (t, bad) => { status($("#i-msg"), t, bad); if (t) say(t, bad); }; $("#i-run").onclick = async e => { + if (!parentOf(selected)) return openRunDialog(); const btn = e.currentTarget, id = selected, f = gform.elements; const isManager = kidsOf(id).length > 0; // catch what the server would refuse, and point at the field to fix @@ -578,14 +906,44 @@ $("#i-run").onclick = async e => { await api("PUT", `/api/agents/${id}/goal`, goalBody()); // Run uses what's in the form, saved or not await api("POST", `/api/agents/${id}/run`); setStatus(id, "starting"); - runMsg(isManager ? `${nameOf(id)} is planning for its team.` : `${nameOf(id)} is working.`); + runMsg(!isManager ? `${nameOf(id)} is working.` : agents.get(id)?.approvePlan + ? `${nameOf(id)} is planning. You'll review the plan here before anyone on the team starts.` + : `${nameOf(id)} is planning for its team.`); refreshSummary(); showTab("logs"); } catch (err) { runMsg(err.message, true); } btn.disabled = false; }; // Request changes: re-run an agent that already worked with what the user wants different. +// Request changes: after a run, send the agent back to change what isn't right, keeping its work. +// It's offered where results are read: on the latest finished goal, and after the latest run's log. let revisingFor = null; +// canRevise says whether the agent has a result to change: it ran, and isn't working now. +function canRevise(id) { + const st = summary.agents[id]; + return !!st?.title && !["idle", "awaiting"].includes(st.status) && !BUSY.includes(st.status); +} +// reviseGoal is the finished goal the change is about, or null for the agent's current goal. +let reviseGoal = null; +function openRevise(item) { + const id = selected, team = kidsOf(id); + reviseGoal = item?.id ?? null; + $("#revise-dialog h3").textContent = item ? `Request changes to "${item.title}"` : `Request changes from ${nameOf(id)}`; + $("#r-hint").textContent = team.length + ? `${nameOf(id)} passes each part to the ${team.length === 1 ? "agent" : "agents"} it concerns and re-runs only them. Everyone keeps their work; ${nameOf(id)} reviews and merges the changes and runs its checks again.` + : `${nameOf(id)} keeps its work and changes only this, then its checks run again.`; + status($("#r-msg"), ""); + $("#revise-dialog").showModal(); + $("#r-change").focus(); +} +// a revise button, for the Goals list and the Logs tab +function reviseButton(cls = "link", item) { + const b = el("button", cls, "Request changes"); + b.type = "button"; + b.title = "Not quite right? Say what should change; the agent keeps its work and changes only that"; + b.onclick = () => openRevise(item); + return b; +} $("#r-change").oninput = () => { revisingFor = selected; }; $("#r-change").onkeydown = e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); $("#r-go").click(); } }; $("#r-go").onclick = async e => { @@ -595,10 +953,11 @@ $("#r-go").onclick = async e => { status($("#r-msg"), "Starting…"); try { if (dirty) await save(); - await api("POST", `/api/agents/${id}/revise`, { change }); + await api("POST", `/api/agents/${id}/revise`, { change, goal: reviseGoal || 0 }); $("#r-change").value = ""; revisingFor = null; status($("#r-msg"), ""); + $("#revise-dialog").close(); setStatus(id, "starting"); runMsg(kidsOf(id).length ? `${nameOf(id)} is passing your changes to its team.` : `${nameOf(id)} is making your changes.`); refreshSummary(); @@ -607,15 +966,30 @@ $("#r-go").onclick = async e => { btn.disabled = false; }; +// Continue: a blocked manager picks up where it stopped instead of planning and running everyone again. +async function continueRun() { + const id = selected; + try { + if (dirty) await save(); + await api("POST", `/api/agents/${encodeURIComponent(id)}/continue`); + setStatus(id, "starting"); + runMsg(`${nameOf(id)} is continuing: finished reports keep their work, the others run again.`); + refreshSummary(); + showTab("logs"); + } catch (err) { runMsg(err.message, true); } +} +$("#i-continue").onclick = continueRun; + $("#i-clone").onclick = async () => { - if (!confirm(`Add a copy of ${nameOf(selected)}? It gets the same settings and manager, but no goal.` + (dirty ? " Unsaved changes on the canvas are saved too." : ""))) return; + if (!await ask(`Add a copy of ${nameOf(selected)}?`, "It gets the same settings and manager, but no goal." + (dirty ? " Unsaved changes on the canvas are saved too." : ""), + "Add copy", "Cancel", false)) return; try { const { agent: src } = await api("GET", `/api/agents/${encodeURIComponent(selected)}`); const s = agents.get(selected), parent = parentOf(selected) ?? ""; const { x, y } = parent ? slotUnder(parent) : { x: s.x + GAP_X, y: s.y }; const a = addAgent(s.role, src.name + " copy", null, x, y, parent); Object.assign(a, { runtime: src.runtime, color: src.color, - defaults: { model: src.model, args: src.args, prompt: src.prompt, tokenSoft: src.tokenSoft, tokenHard: src.tokenHard } }); + defaults: { model: src.model, args: src.args, prompt: src.prompt, tokenSoft: src.tokenSoft, tokenHard: src.tokenHard, approvePlan: src.approvePlan } }); paintCard(a); await save(); refreshCards(); @@ -624,49 +998,979 @@ $("#i-clone").onclick = async () => { }; $("#i-stop").onclick = () => api("POST", `/api/agents/${selected}/stop`).then(() => runMsg("Stopping…")).catch(e => runMsg(e.message, true)); -/* ---------- logs ---------- */ +/* ---------- plan approval ---------- */ + +// plans: manager id -> its draft plan waiting for the user's decision +const plans = new Map(); +const baseTitle = document.title; +const planItems = d => d.plan.subgoals || d.plan.changes || []; + +// loadPlan fetches the manager's waiting plan. Only the newest request per manager counts: an older +// one can come back after it, e.g. "no plan" from just before a new plan was made. +const planLoads = new Map(); +async function loadPlan(id) { + const n = (planLoads.get(id) ?? 0) + 1; + planLoads.set(id, n); + let d = null; + try { d = await api("GET", `/api/agents/${encodeURIComponent(id)}/plan`); } catch {} + if (planLoads.get(id) !== n) return; + d ? plans.set(id, d) : plans.delete(id); + planChanged(id); +} -const log = $("#log"), logPane = $('[data-pane="logs"]'); +function planChanged(id) { + showTabState(); + refreshCards(); + if (id === selected) renderPlan(); +} + +// showTabState puts the project's state in the browser tab, for someone working in another tab: +// "2 need you · …", "Working · …", "Done · …", with a matching dot on the icon. +let tabState = { needs: 0, running: 0 }; +function showTabState(needs = tabState.needs, running = tabState.running) { + tabState = { needs, running }; + const tops = [...agents.values()].filter(a => !parentOf(a.id)).map(a => summary.agents[a.id]?.status); + const [label, color] = needs ? [`${needs} ${needs === 1 ? "needs" : "need"} you`, "failed"] + : running ? ["Working", "running"] + : tops.length && tops.some(s => s === "done") && tops.every(s => ["done", "idle", undefined].includes(s)) ? ["Done", "done"] + : ["", ""]; + document.title = (label ? label + " · " : "") + baseTitle; + drawFavicon(color); +} + +// drawFavicon is the app icon with a colored dot for the state, or the plain icon. +const favicon = $('link[rel="icon"]'), faviconImg = new Image(); +let faviconColor = null; +faviconImg.src = "/arranger.png"; +function drawFavicon(color) { + if (color === faviconColor) return; + faviconColor = color; + if (!color) { favicon.href = "/arranger.png"; return; } + const draw = () => { + const c = document.createElement("canvas"); + c.width = c.height = 64; + const g = c.getContext("2d"); + if (faviconImg.complete && faviconImg.naturalWidth) g.drawImage(faviconImg, 0, 0, 64, 64); + g.beginPath(); + g.arc(46, 46, 16, 0, 2 * Math.PI); + g.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--" + color).trim() || "#d64545"; + g.fill(); + g.lineWidth = 5; + g.strokeStyle = "#fff"; + g.stroke(); + favicon.href = c.toDataURL("image/png"); + }; + faviconImg.complete ? draw() : faviconImg.addEventListener("load", draw, { once: true }); +} -function evRow(e) { - const row = el("div", "ev"); - row.dataset.kind = e.kind; - row.append(el("span", "t", time(e.ts)), el("span", "k", e.kind), el("span", "x", e.text || "·")); - if (e.raw) { - row.dataset.raw = ""; - row.raw = e.raw; - if ($("#raw").checked) row.append(el("pre", "", e.raw)); +// syncPlans fetches drafts for managers that wait on the user and drops the ones that don't anymore. +function syncPlans() { + for (const [id, s] of Object.entries(summary.agents)) if (s.status === "awaiting" && !plans.has(id)) loadPlan(id); + for (const id of [...plans.keys()]) if (summary.agents[id]?.status !== "awaiting") { plans.delete(id); planChanged(id); } +} + +// planEdit is the plan as the user is editing it: report id -> {on, f}, for one draft. +let planEdit = null; + +function renderPlan() { + const d = selected && plans.get(selected); + $("#plan-review").hidden = !d; + if (!d) { planEdit = null; return; } + if (planEdit?.draft !== d.id) { + const items = new Map(); + for (const k of kidsOf(selected)) { + const x = planItems(d).find(i => i.agent === k.id); + items.set(k.id, { on: !!x, f: x ? { ...structuredClone(x), checks: x.checks || [] } + : d.kind === "revision" ? { agent: k.id, change: "", checks: [] } : { agent: k.id, title: "", body: "", criteria: "", checks: [] } }); + } + planEdit = { draft: d.id, kind: d.kind, mgr: selected, items, open: planItems(d)[0]?.agent }; + $("#pr-feedback").value = ""; + status($("#pr-msg"), ""); } + drawPlan(); +} + +const WEAK_CHECK = /^(true|:|exit 0|pwd|ls(\s.*)?|echo(\s.*)?)$/; +function checkWarnings(kind, checks) { + const ws = checks.filter(c => WEAK_CHECK.test(c)).map(c => `\`${c}\` can't fail, so it proves nothing.`); + if (kind === "plan" && !checks.length) ws.unshift("No checks: nothing can prove this is done."); + return ws; +} +const checkLines = v => v.split("\n").map(l => l.trim()).filter(Boolean); + +function drawPlan() { + const { kind, mgr, items } = planEdit, on = [...items.values()].filter(i => i.on).length; + const st = summary.agents[mgr]; + $("#pr-title").textContent = kind === "revision" ? `${nameOf(mgr)}'s plan for your change` : `${nameOf(mgr)}'s plan`; + $("#pr-sub").textContent = `${on} of ${items.size} reports get work` + (st?.tokens ? ` · planning used ${fmtTok(st.tokens)} tokens` : "") + + ". Nothing runs until you approve."; + $("#pr-approve").disabled = on === 0; + $("#pr-approve").title = on ? "" : "Give at least one report work, or cancel."; + $("#pr-items").replaceChildren(...[...items].map(([id, it]) => it.on ? planCard(id, it) : leftOut(id, it))); +} + +// planCard is one report's part of the plan, editable in place. +function planCard(id, it) { + const det = el("details", "pr-item"), sum = el("summary"), f = it.f, team = kidsOf(id).length; + det.open = planEdit.open === id; + det.ontoggle = () => { if (det.open) planEdit.open = id; }; + const what = el("span", "what"); + const showWhat = () => what.textContent = (team ? `manager, ${team} reports · ` : "") + (planEdit.kind === "revision" ? f.change : f.title); + showWhat(); + const skip = el("button", "", "Skip"); + skip.type = "button"; + skip.title = "Leave this report out of this run; its current goal stays"; + skip.onclick = e => { e.preventDefault(); it.on = false; drawPlan(); }; + sum.append(el("span", "who", nameOf(id)), what, skip); + + const fields = el("div", "fields"), warn = el("div"); + const showWarn = () => warn.replaceChildren(...checkWarnings(planEdit.kind, f.checks).map(w => el("p", "warn", "⚠ " + w))); + const field = (label, key, tag = "textarea", cls = "") => { + const input = el(tag, cls); + input.value = key === "checks" ? f.checks.join("\n") : f[key] ?? ""; + input.oninput = () => { + if (key === "checks") { f.checks = checkLines(input.value); showWarn(); } else { f[key] = input.value; showWhat(); } + }; + fields.append(el("label", "", label), input); + }; + if (planEdit.kind === "revision") { + field("Change", "change"); + field("New checks (optional), one per line", "checks", "textarea", "mono"); + } else { + field("Goal", "title", "input"); + field("Details", "body"); + field("Acceptance criteria", "criteria"); + field("Checks: one shell command per line, all must exit 0", "checks", "textarea", "mono"); + } + fields.append(warn); + showWarn(); + det.append(sum, fields); + return det; +} + +// leftOut is a report the plan gives no work, with a way to bring it in. +function leftOut(id, it) { + const row = el("div", "pr-item out"), noGoal = planEdit.kind === "revision" && !summary.agents[id]?.title; + const add = el("button", "", "Add"); + add.type = "button"; + add.disabled = noGoal; + add.onclick = () => { it.on = true; planEdit.open = id; drawPlan(); $("#pr-items details[open] input, #pr-items details[open] textarea")?.focus(); }; + row.append(el("span", "who", nameOf(id)), el("span", "what", noGoal ? "no goal yet, so no work to change" : planEdit.kind === "revision" ? "no change" : "not in this plan"), add); return row; } -log.onclick = e => { - const row = e.target.closest(".ev[data-raw]"); - if (!row) return; - const pre = row.querySelector("pre"); - pre ? pre.remove() : row.append(el("pre", "", row.raw)); + +async function decidePlan(btn, body, working, done) { + const mgr = planEdit.mgr, draft = planEdit.draft; + btn.disabled = true; + status($("#pr-msg"), working); + try { + await api("POST", `/api/agents/${encodeURIComponent(mgr)}/plan`, { draft, ...body }); + if (plans.get(mgr)?.id === draft) { plans.delete(mgr); planChanged(mgr); } // a new plan may already be here + planLoads.set(mgr, (planLoads.get(mgr) ?? 0) + 1); // and a request still in flight must not bring the old one back + runMsg(done); + refreshSummary(); + } catch (err) { + status($("#pr-msg"), err.message, true); + loadPlan(mgr); // it may have been decided elsewhere, or replaced; edits to the same draft are kept + } + btn.disabled = false; +} + +$("#pr-approve").onclick = e => { + const on = [...planEdit.items.values()].filter(i => i.on).map(i => i.f); + const body = planEdit.kind === "revision" ? { action: "approve", changes: on } : { action: "approve", subgoals: on }; + decidePlan(e.currentTarget, body, "Approving…", `${nameOf(planEdit.mgr)} is running its team.`); +}; +$("#pr-replan").onclick = e => { + decidePlan(e.currentTarget, { action: "replan", feedback: $("#pr-feedback").value.trim() }, "Asking for a new plan…", `${nameOf(planEdit.mgr)} is planning again.`); +}; +$("#pr-cancel").onclick = async e => { + const btn = e.currentTarget; + if (!await ask(`Cancel ${nameOf(planEdit.mgr)}'s plan?`, "The run stops and no report's goal changes.", "Cancel plan", "Keep it")) return; + decidePlan(btn, { action: "cancel" }, "Cancelling…", `${nameOf(planEdit.mgr)}'s plan was cancelled.`); +}; + +/* ---------- queue: goals a top-level agent runs one after another ---------- */ + +let queue = null, editingItem = null; +const qURL = (path = "") => `/api/agents/${encodeURIComponent(selected)}/queue${path}`; +const plainCount = (s, one) => { const n = lines(s).length; return `${n} ${one}${n === 1 ? "" : "s"}`; }; +const lines = s => (s || "").split("\n").map(l => l.trim()).filter(Boolean); + +// refreshGoalFields shows the agent's current goal in the form after its queue moved on, +// unless the user is typing in it. +async function refreshGoalFields() { + if (gform.contains(document.activeElement) && !$("#queue").contains(document.activeElement)) return; + const id = selected, g = (await api("GET", `/api/agents/${encodeURIComponent(id)}`).catch(() => null))?.goal; + if (!g || id !== selected) return; + for (const k of ["title", "body", "criteria", "checks"]) gform.elements[k].value = g[k]; +} + +async function loadQueue() { + const box = $("#queue"), id = selected; + box.hidden = !id || !!parentOf(id); // reports get their goals from their manager + if (box.hidden) return; + try { queue = await api("GET", qURL()); } catch { box.hidden = true; return; } + if (id === selected) renderQueue(); +} + +function renderQueue() { + const waiting = queue.items.filter(i => i.status === "queued" || i.status === "running"); + const finished = queue.items.filter(i => i.status !== "queued" && i.status !== "running"); + const running = waiting.find(i => i.status === "running"); + const st = summary.agents[selected]; + const left = waiting.filter(i => i.status === "queued").length; + $("#q-state").textContent = running ? `working on goal ${finished.filter(i => i.status !== "skipped").length + 1} of ${queue.items.filter(i => i.status !== "skipped").length}` + : st?.queuePaused ? `stopped at "${st.queuePaused}": it failed. Run goes on with the rest.` + : left ? `${left} to run` : ""; + $("#q-hint").hidden = queue.items.length > 0; + // one list in the order the goals run: finished ones where they were, then the one running, then the rest + const ran = [...finished].sort((a, b) => a.finished - b.finished || a.id - b.id); + const rows = [...ran.map(doneRow), ...waiting.map(queueRow)]; + $("#q-items").replaceChildren(...(rows.length ? rows : [el("li", "empty hint", "No goals yet. Add one, then press Run.")])); +} + +const GRIP = "M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01"; +function queueRow(it) { + const li = el("li", "qi"); + li.dataset.id = it.id; + li.dataset.status = it.status; + const running = it.status === "running"; + if (!running) { + const g = document.createElementNS(SVG, "svg"), p = document.createElementNS(SVG, "path"); + g.setAttribute("viewBox", "0 0 24 24"); g.setAttribute("class", "grip li"); p.setAttribute("d", GRIP); g.append(p); + li.append(g); + li.draggable = true; + } + const t = el("span", "qt", it.title); + t.title = it.title; + li.append(t, el("span", "qm", running ? "running" : plainCount(it.checks, "check"))); + if (!running) li.append(itemMenu(it)); + return li; +} + +function itemMenu(it) { + const d = el("details", "menu"), ul = el("ul"); + d.append(el("summary", "", "···"), ul); + d.querySelector("summary").title = "More"; + const act = (label, fn, cls = "") => { const b = el("button", cls, label); b.type = "button"; b.onclick = e => { e.preventDefault(); d.open = false; fn(); }; const li = el("li"); li.append(b); ul.append(li); }; + act("Edit", () => openItemForm(it)); + act("Run next", () => moveToFront(it.id)); + act("Skip", () => queueCall("POST", `/${it.id}/skip`, null, "Skipped.")); + act("Remove", async () => { if (await ask(`Remove "${it.title}" from the queue?`, "", "Remove")) queueCall("DELETE", `/${it.id}`, null, "Removed."); }, "danger"); + return d; +} + +function doneRow(it) { + const li = el("li", "qi fin"); + li.dataset.status = it.status; + const t = el("span", "qt", it.title); + t.title = it.title; + const word = { done: "done", failed: "failed", blocked: "needs you", stopped: "stopped", skipped: "skipped" }[it.status] || it.status; + const mark = it.status === "done" ? icon("ok", "ok") : ["failed", "blocked"].includes(it.status) ? icon("bad", "bad") : icon("bad"); + li.append(mark, t, el("span", "qm", word + (it.finished ? " · " + clock(it.finished) : ""))); + const acts = el("span", "qa"); // on a line of their own, so a narrow panel never pushes them out of view + if (it.session) { + const b = el("button", "link", "View log"); + b.type = "button"; + b.onclick = () => { logState.focus = it.session; showTab("logs"); }; + acts.append(b); + } + if (it.status !== "skipped" && canRevise(selected)) acts.append(reviseButton("link", it)); + li.append(acts); + const again = el("button", "link", "Add again"); + again.type = "button"; + again.title = "Put a copy of this goal back in the list, to run it again"; + again.onclick = () => queueCall("POST", "", { title: it.title, body: it.body, criteria: it.criteria, checks: it.checks }, "Added again. Press Run to run it."); + acts.append(again); + return li; +} + +async function queueCall(method, path, body, done) { + try { + await api(method, qURL(path), body); + status($("#q-msg"), done || ""); + await loadQueue(); + refreshSummary(); + } catch (err) { status($("#q-msg"), err.message, true); } +} + +function moveToFront(id) { + const ids = queue.items.filter(i => i.status === "queued").map(i => i.id); + queueCall("POST", "/order", { ids: [id, ...ids.filter(x => x !== id)] }, "It runs next."); +} + +// add or edit a goal in the queue, in the form under the list +function openItemForm(it) { + editingItem = it; + $("#q-form").hidden = false; + $("#qf-title").value = it?.title ?? ""; + $("#qf-body").value = it?.body ?? ""; + $("#qf-criteria").value = it?.criteria ?? ""; + $("#qf-checks").value = it?.checks ?? gform.elements.checks.value; // the agent's own checks are a good start + $("#qf-save").textContent = it ? "Save goal" : "Add goal"; + showSuggestions($("#qf-suggest"), $("#qf-checks")); + $("#q-actions").hidden = true; + $("#qf-title").focus(); +} +$("#q-add").onclick = () => openItemForm(null); +$("#qf-cancel").onclick = () => { $("#q-form").hidden = true; $("#q-actions").hidden = false; editingItem = null; }; +$("#qf-save").onclick = async () => { + const body = { title: $("#qf-title").value, body: $("#qf-body").value, criteria: $("#qf-criteria").value, checks: $("#qf-checks").value }; + try { + if (editingItem) await api("PUT", qURL(`/${editingItem.id}`), body); + else await api("POST", qURL(), body); + $("#q-form").hidden = true; + $("#q-actions").hidden = false; + status($("#q-msg"), editingItem ? "Saved." : "Added. Press Run when your goals are ready."); + editingItem = null; + await loadQueue(); + refreshSummary(); + } catch (err) { status($("#q-msg"), err.message, true); } +}; +// Enter in the queue's fields must not submit the goal form around them +$("#queue").addEventListener("keydown", e => { if (e.key === "Enter" && e.target.matches("input")) { e.preventDefault(); if (e.target.id === "qf-title") $("#qf-save").click(); } }); + +// Run on a top-level agent shows the goals it's about to work through, then starts them. +async function openRunDialog() { + const id = selected; + try { queue = await api("GET", qURL()); } catch (err) { runMsg(err.message, true); return; } + const todo = queue.items.filter(i => i.status === "queued"); + if (!todo.length) { + showTab("goal"); + runMsg(`${nameOf(id)} has no goals to run. Add one first.`, true); + openItemForm(null); + return; + } + $("#run-dialog h3").textContent = `Run ${todo.length === 1 ? "this goal" : `these ${todo.length} goals`}?`; + $("#run-goals").replaceChildren(...todo.map(it => { + const li = el("li"), cs = lines(it.checks); + li.append(el("b", "", it.title)); + const checks = el("span", "rg"); + checks.append(`${cs.length === 1 ? "Check" : `${cs.length} checks`}: `, ...cs.slice(0, 3).flatMap((c, i) => [i ? ", " : "", el("code", "", c)]), cs.length > 3 ? ` and ${cs.length - 3} more` : ""); + li.append(checks); + const body = (it.body || "").trim().split("\n")[0]; + if (body) li.append(el("span", "rg", body.length > 140 ? body.slice(0, 140) + "…" : body)); + return li; + })); + $$('#run-form input[name="run-onfail"]').forEach(r => r.checked = r.value === queue.onFail); + $("#run-go").textContent = todo.length === 1 ? "Run" : `Run ${todo.length} goals`; + $("#run-dialog").showModal(); +} +$("#run-form").onsubmit = async e => { + if (e.submitter?.value !== "run") return; + e.preventDefault(); + const id = selected, onFail = $('#run-form input[name="run-onfail"]:checked')?.value || "pause"; + $("#run-go").disabled = true; + try { + if (dirty) await save(); + if (onFail !== queue.onFail) await api("PUT", qURL("/settings"), { onFail }); + await api("POST", qURL("/start")); + $("#run-dialog").close(); + setStatus(id, "starting"); + runMsg(`${nameOf(id)} is working on its goals.`); + loadQueue(); + refreshSummary(); + showTab("logs"); + } catch (err) { runMsg(err.message, true); $("#run-dialog").close(); } + $("#run-go").disabled = false; +}; + +// drag a waiting goal to reorder +let qDragged = null; +$("#q-items").addEventListener("dragstart", e => { qDragged = e.target.closest(".qi"); qDragged?.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.stopPropagation(); }); +$("#q-items").addEventListener("dragover", e => { + if (!qDragged) return; + e.preventDefault(); + const over = e.target.closest(".qi"); + if (!over || over === qDragged || over.dataset.status !== "queued") return; // finished and running goals stay put + const r = over.getBoundingClientRect(); + over.parentNode.insertBefore(qDragged, e.clientY < r.top + r.height / 2 ? over : over.nextSibling); +}); +$("#q-items").addEventListener("dragend", e => { + e.stopPropagation(); + if (!qDragged) return; + qDragged.classList.remove("dragging"); + qDragged = null; + const ids = $$("#q-items .qi").filter(li => li.dataset.status === "queued").map(li => +li.dataset.id); + queueCall("POST", "/order", { ids }); +}); + +/* ---------- get started: agent tools, starter teams, checks for this repo ---------- */ + +let setupData = null; +async function loadSetup() { + try { setupData = await api("GET", `/api/setup?project=${encodeURIComponent(PID)}`); } catch { setupData = null; } + return setupData; +} + +function copyButton(text) { + const b = el("button", "", "Copy"); + b.type = "button"; + b.onclick = async () => { + try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; } catch { b.textContent = "Select it"; } + setTimeout(() => b.textContent = "Copy", 1500); + }; + return b; +} +function cmdRow(label, text) { + const r = el("div", "su-cmd"); + r.append(el("span", "", label), el("code", "", text), copyButton(text)); + return r; +} + +function renderSetup() { + const d = setupData; + if (!d) { $("#su-tools").replaceChildren(el("p", "note bad", "Couldn't check this machine.")); return; } + $("#su-tools").replaceChildren(...d.tools.map(t => { + const row = el("div", "su-tool"), state = el("span", t.installed ? "ok" : "no", t.installed ? `Installed${t.version ? " · " + t.version : ""}` : "Not installed"); + const cmds = el("div", "cmds"); + if (!t.installed) cmds.append(cmdRow("install", t.install)); + cmds.append(cmdRow("sign in", t.login)); + const docs = el("a", "", "Docs"); + docs.href = t.docs; docs.target = "_blank"; docs.rel = "noopener"; + const head = el("span"); + head.append(state, " · ", docs); + row.append(el("span", "name", t.runtime), head, cmds); + return row; + })); + $("#su-teams").replaceChildren(...d.templates.map(t => { + const card = el("div", "su-team"), add = el("button", "primary", "Add to canvas"), row = el("div", "row"); + add.type = "button"; + add.title = "Add this team next to the agents already on the canvas"; + add.onclick = () => addTemplate(t); + const only = el("button", "", "Replace canvas"); + only.type = "button"; + only.title = "Remove every agent on the canvas and use only this team"; + only.onclick = () => replaceWithTemplate(t); + row.append(add, only); + if (!t.builtIn) { + const del = el("button", "danger", "Delete"); + del.type = "button"; + del.onclick = async () => { + if (!await ask(`Delete the "${t.name}" template?`, "Teams already on a canvas stay.", "Delete")) return; + try { await api("DELETE", `/api/templates/${encodeURIComponent(t.id)}`); await loadSetup(); renderSetup(); } catch (err) { status($("#su-msg"), err.message, true); } + }; + row.append(del); + } + card.append(el("b", "", t.name), el("p", "hint", t.about || `${t.agents.length} agents`), el("span", "hint", t.agents.map(a => a.name).join(", ")), row); + return card; + })); + $("#su-checks").replaceChildren(...(d.checks.length ? d.checks.map(c => { const s = el("span", "", c.cmd); s.append(el("i", "", c.why)); return s; }) + : [el("p", "hint", window.PROJECT?.repo ? "No build files found to suggest checks from. Write your own: any shell command that exits 0 when the goal is met." : "Set this project's git repository (⚙ in the header) to get suggestions.")])); +} + +async function openSetup() { + status($("#su-msg"), ""); + $("#su-tools").replaceChildren(el("p", "hint", "Checking this machine…")); + $("#setup-dialog").showModal(); + await loadSetup(); + renderSetup(); +} +$("#get-started").onclick = openSetup; +$("#empty-start").onclick = openSetup; + +// addTemplate drops a team onto the canvas, to the right of what's there, and saves it. +async function addTemplate(t) { + let x0 = PAD, y0 = PAD; + for (const a of agents.values()) x0 = Math.max(x0, a.x + (a.el?.offsetWidth || 210) + GAP_X / 2); + const ids = {}; + for (const ta of t.agents) { + const a = addAgent(ta.role, ta.name, TYPES.get(ta.role) || null, x0 + ta.x, y0 + ta.y, ta.parent ? ids[ta.parent] : ""); + ids[ta.key] = a.id; + Object.assign(a, { runtime: ta.runtime || a.runtime, color: ta.color || "" }); + a.defaults = { ...(a.defaults || {}), ...(ta.model ? { model: ta.model } : {}), ...(ta.args ? { args: ta.args } : {}), ...(ta.prompt ? { prompt: ta.prompt } : {}) }; + paintCard(a); + } + drawLinks(); + showEmptyCanvas(); + try { + await save(); + $("#setup-dialog").close(); + say(`Added the ${t.name} team`); + main.scrollTo({ left: Math.max(0, x0 - PAD), top: 0, behavior: "smooth" }); + } catch (err) { status($("#su-msg"), err.message, true); } +} + +// replaceWithTemplate clears the canvas and puts only this team on it, after the user confirms. +async function replaceWithTemplate(t) { + const all = [...agents.values()]; + if (!all.length) return addTemplate(t); + const busy = all.filter(a => BUSY.includes(a.el.dataset.status)); + if (busy.length) { status($("#su-msg"), `${names(busy.map(a => a.name))} ${busy.length === 1 ? "is" : "are"} running; stop ${busy.length === 1 ? "it" : "them"} first.`, true); return; } + const withWork = all.filter(a => summary.agents[a.id]?.adds || summary.agents[a.id]?.dels); + if (!await ask(`Replace the canvas with ${t.name}?`, + `This removes the ${all.length === 1 ? "agent" : `${all.length} agents`} on it now (${names(all.map(a => a.name))}).` + + (withWork.length ? ` Their work and branches are deleted when it saves; merge first if you want to keep ${withWork.length === 1 ? "it" : "them"}.` : ""), + "Replace canvas")) return; + closeInspector(); + setPicked([]); + for (const a of all) { a.el.remove(); agents.delete(a.id); } + await addTemplate(t); // places it at the top left and saves +} + +$("#su-save").onclick = async () => { + const name = $("#su-name").value.trim(); + if (!name) { status($("#su-msg"), "Give the template a name.", true); $("#su-name").focus(); return; } + if (!agents.size) { status($("#su-msg"), "The canvas is empty; add agents first.", true); return; } + try { + if (dirty) await save(); + const list = [...agents.values()]; + const minX = Math.min(...list.map(a => a.x)), minY = Math.min(...list.map(a => a.y)); + const full = await Promise.all(list.map(a => api("GET", `/api/agents/${encodeURIComponent(a.id)}`).then(r => r.agent))); + // parents first, so a template always builds top-down + const depth = a => { let n = 0; for (let p = parentOf(a.id); p; p = parentOf(p)) n++; return n; }; + const rows = list.map((a, i) => ({ a, f: full[i] })).sort((x, y) => depth(x.a) - depth(y.a)); + await api("POST", "/api/templates", { name, agents: rows.map(({ a, f }) => ({ + key: a.id, name: a.name, role: a.role, parent: parentOf(a.id) || "", runtime: f.runtime, model: f.model, args: f.args, + prompt: f.prompt, color: f.color, x: a.x - minX, y: a.y - minY })) }); + $("#su-name").value = ""; + status($("#su-msg"), `Saved "${name}".`); + await loadSetup(); + renderSetup(); + } catch (err) { status($("#su-msg"), err.message, true); } }; -$("#raw").onchange = loadLogs; + +// showEmptyCanvas invites a new user to start from a team when the canvas is empty. +function showEmptyCanvas() { $("#empty-canvas").hidden = agents.size > 0; } + +// suggested checks under each Checks box: one click adds the line +function showSuggestions(box, textarea) { + const cs = setupData?.checks || []; + if (!cs.length) { box.replaceChildren(); return; } + const have = new Set(lines(textarea.value)); + const todo = cs.filter(c => !have.has(c.cmd)); + if (!todo.length) { box.replaceChildren(); return; } + box.replaceChildren("Suggested:", ...todo.map(c => { + const b = el("button", "", c.cmd); + b.type = "button"; + b.title = `Add this check: ${c.why}`; + b.onclick = () => { + textarea.value = (textarea.value.trim() ? textarea.value.trimEnd() + "\n" : "") + c.cmd; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + showSuggestions(box, textarea); + }; + return b; + })); +} +$("#qf-checks").addEventListener("input", () => showSuggestions($("#qf-suggest"), $("#qf-checks"))); +gform.elements.checks.addEventListener("input", () => showSuggestions($("#g-suggest"), gform.elements.checks)); + +/* ---------- logs: one section per run, outcome and reason first ---------- */ + +const log = $("#log"), logPane = $('[data-pane="logs"]'); + +// line icons, drawn like the sidebar's (no emoji) +const ICONS = { + msg: "M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.4A8 8 0 1 1 21 12Z", + tool: "M4 17l6-5-6-5M12 19h8", + file: "M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M9 15l2 2 4-4", + ok: "M20 6 9 17l-5-5", + bad: "M18 6 6 18M6 6l12 12", + error: "M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z", + plan: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01", + verdict: "M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9", + you: "M20 21a8 8 0 0 0-16 0M12 13a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z", + flag: "M4 22V4M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1", +}; +function icon(name, cls = "") { + const s = document.createElementNS(SVG, "svg"), p = document.createElementNS(SVG, "path"); + s.setAttribute("viewBox", "0 0 24 24"); + s.setAttribute("class", "li " + cls); + s.setAttribute("aria-hidden", "true"); + p.setAttribute("d", ICONS[name]); + s.append(p); + return s; +} + +// logState holds the selected agent's log, and how the user is looking at it. +const logState = { agent: null, events: [], runs: new Map(), open: new Map(), filter: "all", q: "" }; + +const isTerminalDone = e => e.kind === "done" && /^(all \d+ checks? passed|team work merged)/.test(e.text); +const isJSON = t => /^\s*\{[\s\S]*\}\s*$/.test(t || ""); +const attemptMsg = e => e.kind === "msg" && /^attempt (\d+)\/(\d+) with /.exec(e.text || ""); +const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`; +const names = xs => xs.length <= 3 ? xs.join(", ") : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`; +const clock = ts => { const d = new Date(ts); return d.toDateString() === new Date().toDateString() ? time(ts).slice(0, 5) : d.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + time(ts).slice(0, 5); }; +const since = (ts, t0) => { const s = Math.max(0, Math.round((ts - t0) / 1000)); return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; + +// check parses a check event: "✓ cmd" or "✗ cmd (exit status 1)". +function check(e) { + const ok = e.text.startsWith("✓"); + let cmd = e.text.slice(2); + if (!ok) { const i = cmd.lastIndexOf(" ("); if (i > 0) cmd = cmd.slice(0, i); } + return { ok, cmd, out: e.raw || "" }; +} +// keyLine picks the line of a failure's output that says what went wrong. +function keyLine(out) { + const ls = out.split("\n").map(l => l.trim()).filter(Boolean); + return ls.find(l => /error|fail|panic|undefined|exception|expected|not found|cannot|denied|no such/i.test(l) && !/^ok\b/.test(l)) || ls.at(-1) || ""; +} +const fileOf = e => (e.text || "").replace(/^\S+\s+/, ""); +const fileVerb = e => ({ write: "Wrote", create_file: "Created", delete: "Deleted" })[(e.text || "").split(" ")[0].toLowerCase()] || "Edited"; + +// sessionsOf splits the events into runs: one per press of Run (or a manager starting this agent). +// Events from before runs were recorded are split at requests, results and long pauses. +function sessionsOf(events) { + const out = []; + let cur = null; + const attemptOfRun = new Map(); + for (const e of events) { + const m = attemptMsg(e); + if (m) attemptOfRun.set(e.run, +m[1]); + const legacy = !e.session; + const fresh = !cur || (legacy + ? !cur.legacy || e.kind === "request" || cur.ended || e.ts - cur.last > 10 * 60e3 + : cur.key !== e.session); + if (fresh) out.push(cur = { key: legacy ? "L" + e.id : e.session, legacy, events: [], ended: false, first: e.ts }); + e.att = e.attempt || attemptOfRun.get(e.run) || 0; + cur.events.push(e); + cur.last = e.ts; + if (isTerminalDone(e) || e.kind === "error" && /^(stopped by user|you cancelled the plan|needs you|checks still failing|merged work)/.test(e.text)) cur.ended = true; + } + return out; +} + +// outcome says how a run ended, in one word and one sentence, with the reason for a failure. +function outcome(g, latest) { + const st = latest ? summary.agents[logState.agent]?.status : null; + if (st === "awaiting") return { state: "awaiting", word: "Waiting for you", line: "The plan is ready. Approve it in the Goal tab." }; + const attempts = Math.max(0, ...g.events.map(e => e.att)); + const manager = kidsOf(logState.agent).length > 0; + if (latest && BUSY.includes(st)) return { state: "running", word: "Working", line: attempts && !manager ? `Working on attempt ${attempts} of 3.` : `${nameOf(logState.agent)} ${WORKING[st] ?? "is working"}.` }; + for (let i = g.events.length - 1; i >= 0; i--) { + const e = g.events[i]; + if (isTerminalDone(e)) { + const n = /all (\d+)/.exec(e.text)?.[1]; + const checks = +n === 1 ? "its check" : `all ${n} checks`; + return { by: e, state: "done", word: "Done", line: e.text.startsWith("team") + ? `Done. The team's work is merged and ${checks} passed.` + : `Done${attempts > 1 ? ` on attempt ${attempts}` : ""}. ${checks[0].toUpperCase() + checks.slice(1)} passed.` }; + } + if (e.kind !== "error") continue; + if (/^stopped by user/.test(e.text)) return { by: e, state: "stopped", word: "Stopped", line: "You stopped it." }; + if (/^you cancelled the plan/.test(e.text)) return { by: e, state: "stopped", word: "Cancelled", line: "You cancelled the plan." }; + if (/^the plan wasn't approved/.test(e.text)) return { by: e, state: "stopped", word: "Stopped", line: "The plan wasn't approved in time." }; + if (/^needs you: /.test(e.text)) return { by: e, state: "blocked", word: "Needs you", line: e.text.replace(/^needs you: /, "").split("\n")[0].replace(/^./, c => c.toUpperCase()) + "." }; + return { by: e, state: "failed", word: "Failed", line: failureLine(g, attempts, e) }; + } + if (g.events.some(e => e.kind === "merge")) return { state: "other", word: "Merged", line: "" }; + if (g.events.some(e => /^synced with/.test(e.text))) return { state: "other", word: "Synced", line: "" }; + return { state: "stopped", word: "Ended", line: "It ended without a result." }; +} +// failureLine puts the reason first: the check that still fails and its telling line. +function failureLine(g, attempts, err) { + const last = lastChecks(g).filter(c => !c.ok); + const head = `Failed${attempts > 1 ? ` after ${attempts} attempts` : ""}`; + if (last.length) return [`${head}: `, el("code", "", last[0].cmd), last.length > 1 ? ` and ${plural(last.length - 1, "other check")} still fail.` : " still fails."]; + return `${head}: ${err.text.split("\n")[0]}`; +} +// lastChecks are the checks of the run's last round (worker attempt or manager verify). +function lastChecks(g) { + const cs = g.events.filter(e => e.kind === "check"); + if (!cs.length) return []; + const lastAtt = cs.at(-1).att, round = []; + for (let i = cs.length - 1; i >= 0 && cs[i].att === lastAtt; i--) { + round.unshift(check(cs[i])); + if (i > 0 && cs[i].id - cs[i - 1].id > 1) break; // an earlier round of the same attempt number + } + return round; +} +// activity says what the run did, in one line. +function activity(g) { + const ev = g.events, parts = []; + const plans = ev.filter(e => e.kind === "plan" && !/^plan ready/.test(e.text)); + const verdicts = ev.filter(e => e.kind === "verdict"); + if (plans.length) parts.push(`Gave work to ${names([...new Set(plans.map(e => e.text.split(" → ")[0]))])}`); + const acc = verdicts.filter(e => e.text.startsWith("✓")).map(e => e.text.replace(/^✓ accepted and merged |^✓ /, "")); + const rej = verdicts.filter(e => e.text.startsWith("✗")).map(e => e.text.replace(/^✗ rejected /, "").split(":")[0]); + if (acc.length) parts.push(`merged ${names(acc)}`); + if (rej.length) parts.push(`sent back ${names(rej)}`); + const fixes = ev.filter(e => e.kind === "warn" && /fails my checks/.test(e.text)).length; + if (fixes) parts.push(`${plural(fixes, "fix round")}`); + const files = [...new Set(ev.filter(e => e.kind === "file").map(fileOf))]; + const cmds = ev.filter(e => e.kind === "tool" && /^bash /i.test(e.text)).length; + const reads = ev.filter(e => e.kind === "tool" && !/^bash /i.test(e.text)).length; + if (files.length) parts.push(`changed ${names(files)}`); + if (cmds) parts.push(`ran ${plural(cmds, "command")}`); + if (reads) parts.push(`looked at the code ${plural(reads, "time")}`); + const s = parts.join(", "); + return s ? s[0].toUpperCase() + s.slice(1) + "." : ""; +} + +// category is what a filter chip calls an event. +function category(e) { + switch (e.kind) { + case "tool": return "commands"; + case "file": return "files"; + case "check": return "checks"; + case "error": case "warn": case "stderr": return "errors"; + default: return "messages"; + } +} +// hidden events carry no information for a reader unless they ask for everything. +const hidden = e => e.kind === "stderr" || e.kind === "usage" || e.kind === "done" && e.text === "finished" || + e.kind === "msg" && (isJSON(e.text) || /^checkpoint [0-9a-f]+$/.test(e.text)) || !!attemptMsg(e); + +function matches(e) { + if (logState.filter !== "all" && category(e) !== logState.filter) return false; + return !logState.q || (e.text || "").toLowerCase().includes(logState.q); +} + +// timeline turns a run's events into readable rows: commands and checks in a row fold together, +// attempts get a divider that says why they happened, noise is left out. +function timeline(g, all, decided) { + const rows = [], t0 = g.first, filtered = logState.filter !== "all" || logState.q; + let att = 0, prevChecks = [], i = 0; + // the event that decided the outcome is already the section's first line + const ev = g.events.filter(e => (all || !hidden(e) && e !== decided) && matches(e)); + while (i < ev.length) { + const e = ev[i]; + if (!filtered && e.att > 1 && e.att !== att && g.events.some(x => x.att === e.att - 1)) { + const failed = prevChecks.filter(c => !c.ok).map(c => c.cmd); + const d = el("li", "ldiv"); + d.append(el("b", "", `Attempt ${e.att} of 3`), failed.length ? el("span", "", `because ${failed.length === 1 ? "a check" : plural(failed.length, "check")} failed: ${names(failed)}`) : ""); + rows.push(d); + } + att = e.att || att; + // a run of the same kind folds into one row + let j = i; + while (j + 1 < ev.length && ["tool", "check"].includes(e.kind) && ev[j + 1].kind === e.kind && ev[j + 1].att === e.att) j++; + const group = ev.slice(i, j + 1); + if (e.kind === "check") prevChecks = group.map(check); + rows.push(row(group, t0)); + i = j + 1; + } + return rows; +} + +// toolText is a tool call as a reader wants it: the command itself for the shell, else "Read file". +const toolText = e => /^bash /i.test(e.text) ? e.text.replace(/^bash /i, "") : e.text; + +// inline renders the little markdown agents write: `code`, **bold**, and headings as plain lines. +function inline(text) { + const out = []; + for (const part of text.replace(/^#{1,6} /gm, "").split(/(`[^`\n]+`|\*\*[^*\n]+\*\*)/)) { + if (/^`[^`]+`$/.test(part)) out.push(el("code", "", part.slice(1, -1))); + else if (/^\*\*[^*]+\*\*$/.test(part)) out.push(el("b", "", part.slice(2, -2))); + else if (part) out.push(part); + } + return out; +} + +function row(group, t0) { + const e = group[0], li = el("li", "lrow"), x = el("div", "lx"); + let k = e.kind, ic = "msg", cls = ""; + const t = el("span", "lt", since(e.ts, t0)); + t.title = new Date(e.ts).toLocaleString(); + if (e.kind === "tool") { + ic = "tool"; + if (group.length === 1) { x.append(el("code", "", toolText(e))); x.className = "lx one"; x.title = toolText(e); x.onclick = () => x.classList.toggle("one"); } + else { + const d = el("details"), ul = el("ul"); + group.forEach(g => ul.append(el("li", "", toolText(g)))); + const cmds = group.filter(g => /^bash /i.test(g.text)).length; + d.append(el("summary", "", cmds === group.length ? `Ran ${group.length} commands` : `Used tools ${group.length} times`), ul); + x.append(d); + } + } else if (e.kind === "check") { + const cs = group.map(check), ok = cs.filter(c => c.ok).length; + ic = ok === cs.length ? "ok" : "bad"; cls = ic; + const d = el("details"), ul = el("ul"); + cs.forEach(c => ul.append(el("li", "", `${c.ok ? "passed" : "failed"} ${c.cmd}`))); + d.append(el("summary", "", `Checks: ${ok} of ${cs.length} passed`), ul); + x.append(d); + } else if (e.kind === "file") { + ic = "file"; x.append(`${fileVerb(e)} `, el("code", "", fileOf(e))); + } else if (e.kind === "plan") { + ic = "plan"; cls = "mgr"; x.textContent = e.text.replace(/ \(checks: .*\)$/, ""); + const checks = / \(checks: (.*)\)$/.exec(e.text)?.[1]; + if (checks) x.title = "checks: " + checks; + } else if (e.kind === "verdict") { + ic = e.text.startsWith("✗") ? "bad" : "verdict"; cls = e.text.startsWith("✗") ? "bad" : "mgr"; + x.textContent = e.text.replace(/^[✓✗] /, "").replace(/^./, c => c.toUpperCase()); + } else if (e.kind === "request") { + ic = "you"; cls = "you"; x.textContent = e.text.replace(/^you /, "You "); + } else if (e.kind === "error" || e.kind === "warn" || e.kind === "stderr") { + ic = "error"; cls = e.kind === "warn" ? "warn" : "bad"; x.textContent = e.text; + } else if (e.kind === "done") { + if (isTerminalDone(e)) { ic = "flag"; cls = "ok"; k = "other"; x.textContent = e.text.replace(/^./, c => c.toUpperCase()); } + else { k = "answer"; x.append(...inline(e.text)); x.className = "lx clamp"; x.onclick = () => x.classList.toggle("clamp"); } + } else if (e.kind === "start") { + ic = "flag"; k = "other"; x.append("Started", e.text ? ": " : "", e.text || ""); + } else if (e.kind === "merge") { + ic = "verdict"; cls = "ok"; k = "other"; x.textContent = e.text.replace(/^./, c => c.toUpperCase()); + } else { + x.append(...inline(e.text || "")); + if ((e.text || "").length > 280 || (e.text || "").split("\n").length > 4) { x.className = "lx clamp"; x.onclick = () => x.classList.toggle("clamp"); } + } + if ($("#raw").checked && group.some(g => g.raw)) { + const d = el("details"); d.append(el("summary", "", "raw output"), el("pre", "", group.map(g => g.raw).filter(Boolean).join("\n\n"))); + x.append(d); + } + li.dataset.k = k; + li.append(t, icon(ic, cls), x); + return li; +} + +// checklist shows the last round of checks, with a failure's telling line and output right there. +function checklist(g) { + const cs = lastChecks(g); + if (!cs.length) return null; + const ul = el("ul", "lchk"); + for (const c of cs) { + const li = el("li"); + li.append(icon(c.ok ? "ok" : "bad", c.ok ? "ok" : "bad"), el("code", "", c.cmd)); + if (!c.ok && c.out) { + li.append(el("div", "lkey", keyLine(c.out))); + const d = el("details"); d.append(el("summary", "", "Full output"), el("pre", "", c.out)); + li.append(d); + } + ul.append(li); + } + return ul; +} + +function section(g, latest, all, first) { + const d = el("details", "lrun"), o = outcome(g, latest), sum = el("summary"); + d.dataset.state = o.state; + d.open = logState.open.has(g.key) ? logState.open.get(g.key) : first; // the newest run that did something starts open + d.ontoggle = () => logState.open.set(g.key, d.open); + const runs = new Set(g.events.map(e => e.run).filter(Boolean)); + let tokens = [...runs].reduce((n, r) => n + (logState.runs.get(r)?.tokens || 0), 0); + if (latest && BUSY.includes(summary.agents[logState.agent]?.status)) tokens = Math.max(tokens, summary.agents[logState.agent]?.tokens || 0); + const attempts = Math.max(0, ...g.events.map(e => e.att)); + const meta = [fmtDur((o.state === "running" || o.state === "awaiting" ? Date.now() : g.last) - g.first)]; + if (attempts > 1) meta.push(plural(attempts, "attempt")); + if (tokens) meta.push(fmtTok(tokens) + " tokens"); + const goal = g.events.find(e => e.kind === "start")?.text; + const label = o.state === "other" ? (g.events.some(e => e.kind === "merge") ? "Merge at " : "Sync at ") + clock(g.first) : goal || "Run at " + clock(g.first); + if (goal) meta.unshift(clock(g.first)); + const when = el("span", "lwhen", label); + when.title = goal || ""; + sum.append(el("span", "lstate", o.word), when, el("span", "lmeta", meta.join(" · "))); + if (logState.focus && g.key === logState.focus) { d.open = true; logState.focusEl = d; } + const body = el("div", "lbody"); + const filtered = logState.filter !== "all" || logState.q; + if (!filtered) { + const ask = g.events.find(e => e.kind === "request"); + if (ask) { + const p = el("p", "lask"), newPlan = /^you asked for a new plan/.test(ask.text); + p.append(el("b", "", newPlan ? "You asked for a new plan: " : "You asked: "), + ask.text.replace(/^you asked for (changes: |a new plan( and said: )?)/, "") || "(no note)"); + body.append(p); + } + const out = el("p", "lout"); + Array.isArray(o.line) ? out.append(...o.line) : out.append(o.line); + if (o.line) body.append(out); + const act = activity(g); + if (act) body.append(el("p", "lact", act)); + const cl = checklist(g); + if (cl) body.append(cl); + const manager = kidsOf(logState.agent).length > 0; + if (latest && manager && ["blocked", "failed"].includes(o.state)) { + const p = el("p", "lnext"), b = el("button", "primary", "Continue"); + b.type = "button"; + b.onclick = continueRun; + p.append("Fixed what stopped it? ", b, " picks up from here: finished reports keep their work, the rest run again."); + body.append(p); + } else if (latest && ["done", "failed", "blocked"].includes(o.state) && canRevise(logState.agent)) { + const p = el("p", "lnext"); + p.append("Not quite right? ", reviseButton("primary")); + body.append(p); + } + } + const rows = timeline(g, all, o.by); + if (rows.length) { const ol = el("ol", "ltime"); ol.append(...rows); body.append(ol); } + d.append(sum, body); + return { d, count: rows.length }; +} + +function stoppedRuns(gs) { + const d = el("details", "lrun"), sum = el("summary"); + d.dataset.state = "stopped"; + sum.append(el("span", "lstate", "Stopped"), el("span", "lwhen", `${gs.length} runs stopped before they did anything`), + el("span", "lmeta", `${clock(gs.at(-1).first)} to ${clock(gs[0].first)}`)); + const body = el("div", "lbody"), ul = el("ul", "lchk"); + gs.forEach(g => { const li = el("li"); li.append(icon("error"), el("span", "", "Run at " + clock(g.first))); ul.append(li); }); + body.append(ul); + d.append(sum, body); + return d; +} + +function renderFilters(events, all) { + const counts = { all: 0, messages: 0, commands: 0, files: 0, checks: 0, errors: 0 }; + for (const e of events) if (all || !hidden(e)) { counts.all++; counts[category(e)]++; } + const labels = { all: "All", messages: "Messages", commands: "Commands", files: "Files", checks: "Checks", errors: "Errors" }; + $("#log-filters").replaceChildren(...Object.keys(labels).filter(k => k === "all" || counts[k]).map(k => { + const b = el("button", logState.filter === k ? "on" : ""); + b.type = "button"; + b.append(labels[k], el("b", "", counts[k])); + b.onclick = () => { logState.filter = k; renderLogs(); }; + return b; + })); +} + +function renderLogs() { + if (logState.agent !== selected) return; + const all = $("#raw").checked, top = logPane.scrollTop; + renderFilters(logState.events, all); + const groups = sessionsOf(logState.events).reverse(); + const filtered = logState.filter !== "all" || logState.q; + const st = summary.agents[logState.agent]; + if (st && (BUSY.includes(st.status) || st.status === "awaiting") && st.since && !(groups[0] && groups[0].first >= st.since - 2000 && !groups[0].legacy)) + groups.unshift({ key: "now", events: [], first: st.since, last: Date.now() }); // it started, and hasn't logged anything yet + const secs = []; + for (let i = 0; i < groups.length; i++) { + // runs stopped before they did anything fold into one line + const idle = g => g.events.length <= 3 && g.events.every(e => e.kind === "request" || e.kind === "start" || e.kind === "error" && /^stopped by user/.test(e.text)); + let j = i; + while (!filtered && j + 1 < groups.length && idle(groups[i]) && idle(groups[j + 1]) && !(i === 0 && BUSY.includes(summary.agents[logState.agent]?.status))) j++; + if (j > i) { secs.push({ d: stoppedRuns(groups.slice(i, j + 1)), count: 1 }); i = j; continue; } + secs.push(section(groups[i], i === 0, all, !secs.some(s => s.full))); + secs.at(-1).full = true; + } + if (filtered) secs.splice(0, secs.length, ...secs.filter(s => s.count)); + if (!secs.length) log.replaceChildren(el("div", "empty", logState.events.length ? "Nothing matches." : "No activity yet. Set a goal and press Run.")); + else log.replaceChildren(...secs.map(s => s.d)); + logPane.scrollTop = top; + if (logState.focusEl) { logState.focusEl.scrollIntoView({ block: "start" }); logState.focusEl = logState.focus = null; } + renderLogNow(); +} + +// renderLogNow pins what the agent is doing right now above its log. +function renderLogNow() { + const st = selected && summary.agents[selected], box = $("#log-now"), busy = st && (BUSY.includes(st.status) || st.status === "awaiting"); + box.hidden = !busy; + if (!busy) return; + box.style.setProperty("--s", `var(--${st.status === "awaiting" ? "awaiting" : ["planning", "waiting", "reviewing", "fixing"].includes(st.status) ? "manager" : st.status === "verifying" ? "verifying" : "running"})`); + $("#log-now-text").textContent = (st.status === "awaiting" ? "Waiting for you to approve the plan" : `${nameOf(selected)} ${WORKING[st.status] ?? st.status}`) + + (st.since ? ` · ${fmtDur(Date.now() - st.since)}` : "") + (st.now ? ` · now: ${st.now}` : ""); +} async function loadLogs() { if (!selected) return; - const es = await api("GET", `/api/agents/${selected}/events`).catch(e => (say(e.message, true), [])); - log.replaceChildren(...es.map(evRow)); - if (!es.length) log.append(el("div", "empty", "No activity yet. Set a goal and press Run.")); - logPane.scrollTop = logPane.scrollHeight; + const id = selected; + const [es, rs] = await Promise.all([ + api("GET", `/api/agents/${id}/events`).catch(e => (say(e.message, true), [])), + api("GET", `/api/agents/${id}/runs`).catch(() => []), + ]); + if (id !== selected) return; + if (logState.agent !== id) Object.assign(logState, { open: new Map(), filter: "all", q: "" }), $("#log-search").value = ""; + Object.assign(logState, { agent: id, events: es, runs: new Map(rs.map(r => [r.id, r])) }); + renderLogs(); } -// Live rows are batched per animation frame and capped, so a chatty agent can't stall the page. -let pending = [], frame = 0; +$("#raw").onchange = renderLogs; +$("#log-search").oninput = e => { logState.q = e.target.value.trim().toLowerCase(); renderLogs(); }; + +// live events join the log; the view re-renders at most a few times a second +let logTimer = 0; function appendLive(e) { - pending.push(e); - frame ||= requestAnimationFrame(() => { - const atBottom = logPane.scrollTop + logPane.clientHeight >= logPane.scrollHeight - 30; - log.querySelector(".empty")?.remove(); - log.append(...pending.map(evRow)); - while (log.childElementCount > MAX_LOG_ROWS) log.firstElementChild.remove(); - if (atBottom) logPane.scrollTop = logPane.scrollHeight; - pending = []; frame = 0; - }); + if (logState.agent !== e.agent) return; + logState.events.push(e); + if (logState.events.length > MAX_LOG_ROWS) logState.events.splice(0, logState.events.length - MAX_LOG_ROWS); + logTimer ||= setTimeout(() => { logTimer = 0; renderLogs(); }, 250); } /* ---------- diff: select hunks to remove or promote; revert checkpoints ---------- */ @@ -801,11 +2105,14 @@ async function loadPreview() { catch (e) { box.replaceChildren(el("p", "note bad", e.message)); return; } const lines = []; if (!mform.elements.target.value) mform.elements.target.value = p.target; + if (!mform.elements.message.dataset.edited) mform.elements.message.value = p.message; // every change it brings in const st = summary.agents[selected]; if (st && st.status !== "done") lines.push(el("p", "note", `${nameOf(selected)} isn't done (${st.status}), so this work hasn't passed its checks.`)); + const something = (p.commits && p.files) || p.uncommitted > 0; lines.push(el("span", "", p.commits && p.files ? `${p.commits} commit${p.commits > 1 ? "s" : ""} · ${p.files} file${p.files !== 1 ? "s" : ""} · +${p.adds} −${p.dels}` - : `Nothing to merge: ${p.target} already has all of this work.`)); + : something ? "No checkpoints to merge yet." : `Nothing to merge: ${p.target} already has all of this work.`)); + if (p.uncommitted) lines.push(el("p", "note", `Plus ${p.uncommitted} file${p.uncommitted > 1 ? "s" : ""} changed since the last checkpoint, not counted above. Merging saves ${p.uncommitted > 1 ? "them" : "it"} as a checkpoint first.`)); if (!p.exists) lines.push(el("span", "", `Creates branch ${p.target} from ${p.start}.`)); if (p.checkedOut) lines.push(el("p", p.dirty ? "note bad" : "note", p.dirty ? `${p.target} is checked out in ${p.checkedOut} with uncommitted changes. Commit or stash them first.` @@ -817,18 +2124,20 @@ async function loadPreview() { } $("#m-ff").disabled = !p.canFF; if (!p.canFF && mform.elements.strategy.value === "ff") mform.elements.strategy.value = "merge"; - $("#m-go").disabled = !p.commits || !p.files || p.dirty || p.conflicts.length > 0; + $("#m-go").disabled = !something || p.dirty || p.conflicts.length > 0; box.replaceChildren(...lines); } $("#i-merge").onclick = async () => { $("#m-agent").textContent = nameOf(selected); mform.elements.target.value = ""; - mform.elements.message.value = gform.elements.title.value; + mform.elements.message.value = ""; + delete mform.elements.message.dataset.edited; status($("#m-msg"), ""); mdlg.showModal(); await loadPreview(); }; +mform.elements.message.oninput = () => { mform.elements.message.dataset.edited = "1"; }; mform.elements.target.oninput = () => { clearTimeout(previewTimer); previewTimer = setTimeout(loadPreview, 350); }; mform.onsubmit = async e => { if (e.submitter?.value === "cancel") return; @@ -855,6 +2164,10 @@ function refreshCards() { const busy = BUSY.includes(card.dataset.status); card.classList.toggle("busy", busy); card.querySelector(".now").textContent = st?.now || (st?.title ? "goal: " + st.title : "no goal yet"); + const draft = plans.get(parentOf(a.id)), item = draft && planItems(draft).find(x => x.agent === a.id); + card.classList.toggle("proposed", !!item); + card.classList.toggle("left-out", !!draft && !item); + if (item) card.querySelector(".now").textContent = draft.kind === "revision" ? "proposed change: " + item.change : "proposed: " + item.title; const b = card.querySelector(".badges"); b.replaceChildren(); if (!st) continue; @@ -864,12 +2177,17 @@ function refreshCards() { b.append(t); } if (st.total) b.append(el("span", st.passed === st.total ? "ok" : "bad", `✓ ${st.passed}/${st.total}`)); + if (st.queueTotal) { + const q = el("span", "q", `goals ${st.queueDone}/${st.queueTotal}`); + q.title = st.queueActive ? "Working through its goals" : st.queuePaused ? `Stopped at "${st.queuePaused}": it failed` : `${plural(st.queueWaiting, "goal")} to run`; + b.append(q); + } if (st.adds || st.dels) b.append(el("span", "", `+${st.adds} −${st.dels}`)); if (st.attempts > 1) b.append(el("span", "", `try ${st.attempts}/3`)); if (st.tokens) { const lc = limitClass(st); const tok = el("span", lc, (lc ? "⚠ " : "") + fmtTok(st.tokens) + " tok"); - tok.title = `$${st.cost.toFixed(2)} this run` + (lc === "over" ? " · over the hard limit" : lc === "warn" ? " · over the soft limit" : ""); + tok.title = "tokens this run" + (lc === "over" ? " · over the hard limit" : lc === "warn" ? " · over the soft limit" : ""); b.append(tok); } } @@ -881,23 +2199,25 @@ async function refreshSummary() { refreshCards(); renderStats(); const all = Object.entries(summary.agents); - const goals = all.filter(([, s]) => s.title); - const done = goals.filter(([, s]) => s.status === "done").length; - const needs = all.filter(([, s]) => NEEDS.includes(s.status)); + const needs = all.filter(([, s]) => NEEDS.includes(s.status) || s.status === "awaiting" || s.queuePaused); + syncPlans(); const running = all.filter(([, s]) => BUSY.includes(s.status)).length; - $("#n-done").textContent = done; - $("#n-goals").textContent = goals.length; $("#n-running").textContent = running; - $("#bar .live").hidden = running === 0; - $("#bar .prog i").style.width = goals.length ? (100 * done / goals.length) + "%" : 0; - $("#cost").textContent = summary.cost.toFixed(2); + $("#running").hidden = running === 0; + $("#tokens").textContent = fmtTok(all.reduce((n, [, s]) => n + (s.allTokens || 0), 0)); $("#n-needs").textContent = needs.length; + showTabState(needs.length, running); $("#needs").dataset.n = needs.length; + $("#needs").dataset.kind = needs.every(([, s]) => s.status === "awaiting") ? "plan" : "fail"; // amber for plans, red for failures + if (!needs.length) $("#needs").open = false; $("#needs ul").replaceChildren(...needs.map(([id, s]) => { - const li = el("li", "", `✗ ${nameOf(id)}: ${s.status} · ${s.title}`); - li.onclick = () => { $("#needs").open = false; select(id); }; + const li = el("li", "", s.status === "awaiting" ? `${nameOf(id)}: plan waiting for your approval` + : s.queuePaused ? `${nameOf(id)} stopped at "${s.queuePaused}": it failed` + : `${nameOf(id)}: ${s.status} · ${s.title}`); + li.onclick = () => { $("#needs").open = false; if (s.status === "awaiting") tab = "goal"; select(id); }; return li; })); + if (selected && queue && !$("#queue").hidden) renderQueue(); // whether it can take changes depends on its status if (selected && summary.agents[selected]) { const g = (await api("GET", `/api/agents/${selected}`).catch(() => null))?.goal; if (g) showGoalState(g); @@ -911,6 +2231,7 @@ function setStatus(id, s) { st.status = s; if (BUSY.includes(s) && !st.since) st.since = Date.now(); refreshCards(); + if (id === selected) showStop(); } let summaryTimer = 0; @@ -939,23 +2260,34 @@ setInterval(() => { const es = new EventSource("/api/events?project=" + encodeURIComponent(PID)); es.onmessage = m => { const msg = JSON.parse(m.data); - if (msg.type === "hello") { - $("#stale").hidden = msg.build === String(window.BUILD); // this page came from an older run of the server + if (msg.type === "hello" || msg.type === "resync") { + // a (re)connect or missed updates: whatever changed meanwhile never arrived, so reload the state + if (msg.type === "hello") $("#stale").hidden = msg.build === String(window.BUILD); // this page came from an older run of the server + refreshSummary(); + if (selected && tab === "logs") loadLogs(); } else if (msg.type === "event") { const e = msg.event; if (e.kind !== "stderr") { const now = cardOf(e.agent)?.querySelector(".now"); - if (now) now.textContent = `${e.kind}: ${e.text}`; + if (now) now.textContent = `${e.kind === "start" ? "started" : e.kind}: ${e.text}`; } if (e.agent === selected && tab === "logs") appendLive(e); if (e.agent === selected && e.kind !== "stderr") scheduleLiveDiff(); // it edited files, ran a tool, checkpointed... + } else if (msg.type === "plan") { + loadPlan(msg.agent); + } else if (msg.type === "queue") { + scheduleSummary(); + if (msg.agent === selected) { loadQueue(); refreshGoalFields(); } } else if (msg.type === "status" || msg.type === "usage") { if (msg.type === "status") setStatus(msg.agent, msg.status); + if (msg.type === "status" && msg.agent === selected && tab === "logs") loadLogs(); // its outcome and totals changed scheduleSummary(); if (msg.type === "status" && msg.agent === selected && tab === "diff" && !selectedHunks().length) loadDiff(); // don't wipe a selection in progress } }; $("#reload").onclick = () => location.reload(); +// a backstop for anything a live update didn't cover +setInterval(() => { if (!document.hidden) refreshSummary(); }, 15000); /* ---------- start ---------- */ @@ -963,5 +2295,7 @@ renderTypes(); if ([...agents.values()].some(a => a.x == null || a.y == null)) layout(false); agents.forEach(makeCard); refreshCards(); +showEmptyCanvas(); +loadSetup(); // suggested checks for the goal forms refreshSummary(); document.fonts?.ready.then(drawLinks); // box heights settle once fonts load diff --git a/web/arrange.html b/web/arrange.html index f53af0a..15331e9 100644 --- a/web/arrange.html +++ b/web/arrange.html @@ -11,17 +11,18 @@ :root { --bg: #f4f5f8; --grid: #0b0d140b; --panel: #fff; --sunk: #f0f1f3; --line: #dcdde1; --text: #1b1c1f; --muted: #6b7079; --accent: #3d6fe0; --on-accent: #fff; --drop: #3d6fe01f; - --manager: #8a5cf6; --coder: #3d6fe0; --reviewer: #c98a12; --tester: #2f9e44; - --idle: #a0a4ab; --running: #3d6fe0; --verifying: #c98a12; --done: #2f9e44; --failed: #d64545; --stopped: #6b7079; + --manager: #8a5cf6; --coder: #3d6fe0; --reviewer: #c98a12; --tester: #2f9e44; --writer: #0e8fa0; --pm: #c2418f; + --idle: #a0a4ab; --running: #3d6fe0; --verifying: #c98a12; --done: #2f9e44; --failed: #d64545; --stopped: #6b7079; --awaiting: #d9730d; --add-bg: #e6f4ea; --del-bg: #fdecec; --link: #8b919c; font: 14px/1.4 var(--sans); } [data-theme=dark] { --bg: #0e0f12; --grid: #c0caf509; --panel: #16181d; --sunk: #111317; --line: #2a2d35; --text: #e6e6e6; --muted: #8a8f98; --accent: #7aa2f7; --on-accent: #0e0f12; --drop: #7aa2f733; - --manager: #bb9af7; --coder: #7aa2f7; --reviewer: #e0af68; --tester: #9ece6a; - --idle: #5c616b; --running: #7aa2f7; --verifying: #e0af68; --done: #9ece6a; --failed: #f7768e; --stopped: #8a8f98; + --manager: #bb9af7; --coder: #7aa2f7; --reviewer: #e0af68; --tester: #9ece6a; --writer: #7dcfff; --pm: #f5a3cf; + --idle: #5c616b; --running: #7aa2f7; --verifying: #e0af68; --done: #9ece6a; --failed: #f7768e; --stopped: #8a8f98; --awaiting: #ff9e64; --add-bg: #1f2d22; --del-bg: #331f24; --link: #6b7280; + --card: #1d2027; --card-line: #3a3f4b; --card-shadow: 0 1px 0 #ffffff08 inset, 0 2px 8px #0006; } * { box-sizing: border-box; border-radius: 0; } body { margin: 0; color: var(--text); background: var(--bg); display: grid; grid-template: auto 1fr / 180px 1fr auto; height: 100vh; overflow: hidden; } @@ -34,25 +35,64 @@ button:hover { border-color: var(--muted); } button.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; } button.danger { color: var(--failed); } +button:disabled { opacity: .45; cursor: not-allowed; } +button:disabled:hover { border-color: var(--line); } +button.primary.danger { background: var(--failed); border-color: var(--failed); color: #fff; } input, select, textarea { background: var(--panel); border: 1px solid var(--line); padding: 6px 8px; width: 100%; } textarea { resize: vertical; min-height: 60px; } .mono, textarea.mono { font-family: var(--mono); font-size: 12px; } label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; } -header { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; } +header { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; padding: 7px 14px; border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; } +header button.icon { width: 30px; padding: 5px 0; text-align: center; color: var(--muted); } +header button.icon:hover { color: var(--text); } +header .proj { display: flex; } +header .proj > * + * { margin-left: -1px; } +.menu { position: relative; } +.menu > summary { list-style: none; display: flex; align-items: center; gap: 8px; height: 100%; padding: 5px 10px 5px 12px; border: 1px solid var(--line); + background: var(--panel); cursor: pointer; user-select: none; font-weight: 600; } +.menu > summary::-webkit-details-marker { display: none; } +.menu > summary span { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.menu > summary svg { width: 14px; height: 14px; fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform .15s; } +.menu[open] > summary svg { transform: rotate(180deg); } +.menu > summary:hover { border-color: var(--muted); } +.menu ul { position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; min-width: 240px; max-height: 360px; overflow: auto; margin: 0; padding: 4px 0; + list-style: none; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 8px 24px #0003; } +.menu li.head { padding: 6px 12px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); } +.menu li a { display: flex; align-items: center; gap: 8px; padding: 7px 12px; color: var(--text); text-decoration: none; } +.menu li a::before { content: ""; width: 14px; flex: none; } +.menu li a.on { font-weight: 600; } +.menu li a.on::before { content: "✓"; color: var(--accent); } +.menu li a:hover, .menu li a:focus-visible { background: var(--sunk); outline: none; } +.menu.pick > summary { justify-content: space-between; font-weight: 400; padding: 6px 10px; } +.menu.pick > summary span { max-width: none; } +.menu.pick ul { right: 0; min-width: 0; } +.menu.pick li button { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; border: 0; background: none; padding: 7px 12px; } +.menu.pick li button::before { content: ""; width: 14px; flex: none; } +.menu.pick li button.on { font-weight: 600; } +.menu.pick li button.on::before { content: "✓"; color: var(--accent); } +.menu.pick li button:hover { background: var(--sunk); } header h1 { font: 600 14px var(--mono); margin: 0 8px 0 0; } header h1 a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 8px; } header h1 img { width: 22px; height: 22px; display: block; } header select { width: auto; } .sp { flex: 1; } -#bar { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--muted); } -#bar .prog { width: 120px; height: 6px; background: var(--sunk); border: 1px solid var(--line); } -#bar .prog i { display: block; height: 100%; background: var(--done); width: 0; } +#bar { display: flex; align-items: stretch; height: 30px; margin-left: 8px; border: 1px solid var(--line); background: var(--sunk); font-size: 12px; color: var(--muted); } +#bar > * { display: flex; align-items: center; gap: 6px; padding: 0 12px; white-space: nowrap; } +#bar > * + * { border-left: 1px solid var(--line); } +#bar > [hidden] { display: none; } +#bar > button { border: 0; border-left: 1px solid var(--line); background: none; font-size: 12px; color: var(--muted); } +#bar > button:hover { background: var(--panel); color: var(--text); } #bar b { color: var(--text); font-weight: 600; } #needs { position: relative; } -#needs summary { cursor: pointer; list-style: none; color: var(--failed); font-weight: 600; } -#needs[data-n="0"] summary { color: var(--muted); font-weight: 400; } -#needs ul { position: absolute; top: 22px; left: 0; z-index: 5; background: var(--panel); border: 1px solid var(--line); margin: 0; padding: 4px 0; list-style: none; min-width: 220px; } +#needs summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; height: 100%; color: var(--s); font-weight: 600; } +#needs summary::-webkit-details-marker { display: none; } +#needs summary b { color: inherit; } +#needs[data-n="0"] { display: none; } +#needs, #needs[data-kind=fail] { --s: var(--failed); } #needs[data-kind=plan] { --s: var(--awaiting); } +#needs .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--s); box-shadow: 0 0 0 0 var(--s); animation: beacon 1.4s ease-out infinite; } +@keyframes beacon { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--s) 70%, transparent); } 100% { box-shadow: 0 0 0 7px transparent; } } +#needs ul { position: absolute; top: 29px; left: 0; z-index: 5; background: var(--panel); border: 1px solid var(--line); margin: 0; padding: 4px 0; list-style: none; min-width: 220px; } #needs li { padding: 6px 12px; cursor: pointer; color: var(--text); } #needs li:hover { background: var(--sunk); } #msg { font-size: 12px; color: var(--muted); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -75,6 +115,21 @@ #links { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; } #links path { fill: none; stroke: var(--link); stroke-width: 2.5; stroke-dasharray: 2 7; stroke-linecap: round; } /* live links: up toward the manager while a report works for it, down while the manager hands out work */ +#links .link .hit { stroke: transparent; stroke-width: 16; stroke-dasharray: none; pointer-events: stroke; cursor: pointer; animation: none; } +#links .link .cut { opacity: 0; pointer-events: all; cursor: pointer; transition: opacity .12s; } +#links .link:hover .cut, #links .link .cut:focus { opacity: 1; } +#links .link:hover > path:not(.hit) { stroke: var(--failed); } +#links .cut circle { fill: var(--panel); stroke: var(--failed); stroke-width: 1.5; } +#links .cut path { stroke: var(--failed); stroke-width: 2; stroke-dasharray: none; fill: none; animation: none; } +#links path.draft { stroke: var(--accent); stroke-width: 2; stroke-dasharray: 6 5; animation: none; } +#canvas .card .port { position: absolute; left: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%; z-index: 3; + background: var(--panel); border: 2px solid var(--c, var(--accent)); opacity: 0; cursor: crosshair; transition: opacity .12s, transform .12s; } +#canvas .card .port.down { bottom: -7px; } +#canvas .card .port.left, #canvas .card .port.right { top: 50%; margin: -6.5px 0 0; } +#canvas .card .port.left { left: -7px; } #canvas .card .port.right { left: auto; right: -7px; } +#canvas .card:hover .port, #canvas.linking .card.src .port.on { opacity: 1; } +#canvas .card .port:hover, #canvas.linking .card.src .port.on { transform: scale(1.25); } +#canvas.linking, #canvas.linking .card { cursor: crosshair; } #links path.flow { stroke: var(--s); stroke-width: 3.5; animation: flow .7s linear infinite; } #links path.flow.down { animation-direction: reverse; } @keyframes flow { to { stroke-dashoffset: 18; } } @@ -82,10 +137,13 @@ #canvas .card.dragging { cursor: grabbing; z-index: 5; box-shadow: 0 6px 18px #0002; } #tidy { position: sticky; float: right; top: 12px; right: 12px; margin: 12px; z-index: 4; } -.card { position: relative; display: inline-grid; grid-template-columns: 1fr auto; gap: 2px 10px; text-align: left; background: var(--panel); border: 1px solid var(--line); +.card { position: relative; display: inline-grid; grid-template-columns: 1fr auto; gap: 2px 10px; text-align: left; background: var(--card, var(--panel)); border: 1px solid var(--card-line, var(--line)); box-shadow: var(--card-shadow, none); border-left: 3px solid var(--c, var(--accent)); padding: 10px 14px; cursor: grab; user-select: none; width: 210px; } .card:hover { border-color: var(--muted); border-left-color: var(--c); } .card.sel { outline: 2px solid var(--accent); outline-offset: -1px; } +.card.picked { outline: 2px solid var(--accent); outline-offset: 2px; background: color-mix(in srgb, var(--accent) 8%, var(--card, var(--panel))); } +#marquee { position: absolute; z-index: 4; pointer-events: none; border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); } +main.can-pan, main.can-pan .card { cursor: grab; } .card.drop { outline: 2px dashed var(--accent); background: var(--drop); } .card .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .card .x { color: var(--muted); cursor: pointer; } @@ -102,23 +160,29 @@ .card.busy .now { color: var(--text); } .card .elapsed { color: var(--s); font-weight: 600; } @keyframes sweep { from { background-position: -60% 0; } to { background-position: 160% 0; } } -#bar .live { display: inline-block; width: 8px; height: 8px; background: var(--running); margin-right: 4px; vertical-align: middle; animation: pulse 1s infinite alternate; } -#bar .live[hidden] { display: none; } +#bar .live { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--running); animation: pulse 1s infinite alternate; } @media (prefers-reduced-motion: reduce) { #links path.flow { animation: none; } .card.busy::after { animation: none; background: var(--s); } - .st::before, #bar .live { animation: none !important; } + .st::before, #bar .live, #needs .dot { animation: none !important; } } .st::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 5px; background: var(--s, var(--idle)); } .busy .st::before, .st.busy::before { animation: pulse 1s infinite alternate; } @keyframes pulse { to { opacity: .3; } } [data-status=starting], [data-status=running] { --s: var(--running); } [data-status=verifying] { --s: var(--verifying); } [data-status=done] { --s: var(--done); } [data-status=failed], [data-status=blocked] { --s: var(--failed); } [data-status=stopped] { --s: var(--stopped); } -[data-status=planning], [data-status=waiting], [data-status=reviewing] { --s: var(--manager); } +[data-status=planning], [data-status=waiting], [data-status=reviewing], [data-status=fixing] { --s: var(--manager); } +[data-status=awaiting] { --s: var(--awaiting); } +.card[data-status=awaiting] { border-color: var(--s); border-left-color: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--s) 22%, transparent); } +.card[data-status=awaiting] .st::before, .st[data-status=awaiting]::before { animation: pulse 1s infinite alternate; } +.card.proposed .now { font-style: italic; opacity: .75; } +.card.left-out { opacity: .45; } .ok { color: var(--done); } .bad { color: var(--failed); } -[data-role=manager] { --c: var(--manager); } [data-role=coder] { --c: var(--coder); } +[data-role=manager] { --c: var(--manager); } [data-role=programmer], [data-role=coder] { --c: var(--coder); } [data-role=reviewer] { --c: var(--reviewer); } [data-role=tester] { --c: var(--tester); } +[data-role=writer] { --c: var(--writer); } [data-role=product-manager] { --c: var(--pm); } aside .card { display: grid; width: 100%; margin-bottom: 8px; } +aside .card .name { white-space: normal; overflow: visible; line-height: 1.25; } #inspector { width: var(--iw, min(560px, 45vw)); min-width: 320px; max-width: 85vw; border-left: 1px solid var(--line); background: var(--panel); display: flex; flex-direction: column; min-height: 0; position: relative; } @@ -133,7 +197,8 @@ .istats .warn { color: var(--verifying); } .istats .over { color: var(--failed); } #inspector[hidden] { display: none; } .ihead { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); } -.ihead .name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ihead .name { font-weight: 600; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#i-dir { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px var(--mono); color: var(--muted); cursor: copy; } .tabs { display: flex; border-bottom: 1px solid var(--line); } .tabs button { border: 0; border-bottom: 2px solid transparent; background: none; padding: 8px 14px; color: var(--muted); } .tabs button.on { color: var(--text); border-bottom-color: var(--accent); } @@ -142,18 +207,94 @@ .row { display: flex; gap: 8px; margin-top: 14px; align-items: center; } .feedback { white-space: pre-wrap; background: var(--del-bg); border: 1px solid var(--line); padding: 8px; max-height: 200px; overflow: auto; } -#log { font: 12px/1.5 var(--mono); padding-top: 8px; } -#log .ev { display: grid; grid-template-columns: 64px 48px 1fr; gap: 8px; padding: 2px 0; border-bottom: 1px solid var(--sunk); cursor: default; } -#log .ev[data-raw] { cursor: pointer; } -#log .t { color: var(--muted); } -#log .k { color: var(--muted); } -#log .x { white-space: pre-wrap; word-break: break-word; } -#log .ev[data-kind=error] .x, #log .ev[data-kind=error] .k { color: var(--failed); } -#log .ev[data-kind=done] .k, #log .ev[data-kind=file] .k { color: var(--done); } -#log .ev[data-kind=tool] .k { color: var(--accent); } -#log .ev[data-kind=stderr] .x { color: var(--muted); } -#log pre { grid-column: 1 / -1; margin: 4px 0; padding: 8px; background: var(--sunk); overflow: auto; max-height: 300px; white-space: pre-wrap; word-break: break-all; } +/* logs: one section per run, outcome first */ +#log-now { display: flex; align-items: center; gap: 2px; margin: 12px 0 0; padding: 7px 10px; border: 1px solid var(--s); border-left-width: 3px; + background: color-mix(in srgb, var(--s) 8%, var(--panel)); font-size: 13px; } +#log-now[hidden] { display: none; } +#log-now-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 4px; } +.lchips { display: flex; flex-wrap: wrap; gap: 4px; flex: 1 1 auto; } +.lchips button { padding: 3px 9px; font-size: 12px; color: var(--muted); } +.lchips button b { font-weight: 600; color: var(--text); margin-left: 4px; } +.lchips button.on { border-color: var(--accent); color: var(--text); background: color-mix(in srgb, var(--accent) 12%, var(--panel)); } +#log-search { width: 130px; padding: 4px 8px; font-size: 12px; } +.lbar .check { font-size: 12px; color: var(--muted); } +#log { padding: 4px 0 20px; } +#log .empty { color: var(--muted); font-size: 13px; padding: 16px 0; } +.lrun { border: 1px solid var(--line); background: var(--panel); margin-top: 10px; } +.lrun > summary { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; list-style: none; font-size: 13px; } +.lrun > summary::-webkit-details-marker { display: none; } +.lrun > summary::before { content: ""; width: 6px; height: 6px; flex: none; border-right: 1.5px solid var(--muted); border-bottom: 1.5px solid var(--muted); transform: rotate(-45deg); transition: transform .12s; } +.lrun[open] > summary::before { transform: rotate(45deg); } +.lrun .lstate { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; color: var(--s); } +.lrun .lstate::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--s); } +.lrun[data-state=running] .lstate::before, .lrun[data-state=awaiting] .lstate::before { animation: pulse 1s infinite alternate; } +.lrun .lwhen { color: var(--text); } +.lrun .lmeta { color: var(--muted); font-size: 12px; margin-left: auto; white-space: nowrap; } +.lrun[data-state=done] { --s: var(--done); } .lrun[data-state=failed], .lrun[data-state=blocked] { --s: var(--failed); } +.lrun[data-state=stopped], .lrun[data-state=other] { --s: var(--stopped); } .lrun[data-state=running] { --s: var(--running); } +.lrun[data-state=awaiting] { --s: var(--awaiting); } +.lbody { padding: 0 12px 12px; border-top: 1px solid var(--sunk); } +.lask { margin: 10px 0 0; padding: 6px 10px; border-left: 3px solid var(--accent); background: var(--sunk); font-size: 13px; } +.lask b { color: var(--muted); font-weight: 400; } +.lout { margin: 10px 0 0; font-size: 14px; font-weight: 600; color: var(--s); line-height: 1.4; } +.lnext { margin: 12px 0 0; font-size: 13px; color: var(--muted); } +#revise-dialog { width: min(520px, 94vw); } +#get-started { width: 100%; margin-top: 8px; } +#empty-canvas { position: absolute; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 2; display: grid; justify-items: center; gap: 8px; + padding: 24px 28px; background: var(--panel); border: 1px dashed var(--line); text-align: center; color: var(--muted); } +#empty-canvas[hidden] { display: none; } +#empty-canvas b { color: var(--text); font-size: 15px; } +#setup-dialog { width: min(720px, 94vw); } +#setup-dialog h4 { margin: 18px 0 6px; font-size: 14px; } +.su-tool { display: grid; grid-template-columns: 90px 1fr; gap: 2px 12px; padding: 8px 0; border-top: 1px solid var(--sunk); font-size: 13px; } +.su-tool .name { font-weight: 600; } +.su-tool .ok { color: var(--done); } .su-tool .no { color: var(--muted); } +.su-tool .cmds { grid-column: 2; display: grid; gap: 4px; } +.su-cmd { display: flex; align-items: center; gap: 8px; } +.su-cmd code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; background: var(--sunk); padding: 4px 8px; font: 12px var(--mono); } +.su-cmd span { width: 44px; color: var(--muted); font-size: 12px; } +.su-cmd button, .su-team button { padding: 3px 10px; font-size: 12px; } +#su-msg:empty { display: none; } +#su-teams { margin-top: 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; } +.su-team { border: 1px solid var(--line); padding: 10px 12px; display: grid; gap: 4px; align-content: start; font-size: 13px; } +.su-team b { font-size: 14px; } +.su-team .hint { margin: 0; } +.su-team .row { margin-top: 6px; flex-wrap: wrap; gap: 6px; } +.su-team .row button { white-space: nowrap; } +#su-checks { display: flex; flex-wrap: wrap; gap: 6px; } +.suggest { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; font-size: 12px; color: var(--muted); } +.suggest:empty { display: none; } +.suggest button, #su-checks span { font: 12px var(--mono); padding: 2px 8px; } +#su-checks span { border: 1px solid var(--line); background: var(--sunk); } +#su-checks i { font: 12px var(--sans); color: var(--muted); font-style: normal; margin-left: 6px; } +.lout code, .lchk code { font: 12px var(--mono); } +.lact { margin: 4px 0 0; font-size: 13px; color: var(--muted); line-height: 1.45; } +.lchk { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 4px; } +.lchk > li { display: grid; grid-template-columns: 16px 1fr; gap: 6px; align-items: start; font-size: 13px; } +.lchk > li > code { min-width: 0; white-space: pre-wrap; word-break: break-all; } +.lchk .lkey { grid-column: 2; font: 12px var(--mono); color: var(--failed); white-space: pre-wrap; word-break: break-word; } +.lchk details { grid-column: 2; font-size: 12px; color: var(--muted); } +.lchk summary { cursor: pointer; } +.ltime { list-style: none; margin: 12px 0 0; padding: 0; border-top: 1px dashed var(--line); padding-top: 8px; display: grid; gap: 2px; } +.lrow { display: grid; grid-template-columns: 44px 16px 1fr; gap: 6px; align-items: start; padding: 3px 0; font-size: 13px; line-height: 1.45; } +.lrow .lt { font: 11px/1.9 var(--mono); color: var(--muted); text-align: right; } +.lrow .lx { min-width: 0; white-space: pre-wrap; word-break: break-word; } +.lrow .lx code { font: 12px var(--mono); } +.lrow .lx.one { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } +.lrow .lx.clamp { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; } +.lrow details summary { cursor: pointer; color: var(--text); } +.lrow details ul { margin: 4px 0 0; padding-left: 16px; font: 12px/1.6 var(--mono); color: var(--muted); } +.lrow[data-k=error] .lx { color: var(--failed); } .lrow[data-k=warn] .lx { color: var(--verifying); } +.lrow[data-k=msg] .lx, .lrow[data-k=answer] .lx { color: var(--text); } +.lrow[data-k=tool] .lx, .lrow[data-k=file] .lx, .lrow[data-k=check] .lx, .lrow[data-k=other] .lx { color: var(--muted); } +.lrow pre, .lchk pre { margin: 6px 0 0; padding: 8px; background: var(--sunk); overflow: auto; max-height: 280px; font: 11.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-all; } +.ldiv { display: flex; align-items: center; gap: 8px; margin: 8px 0 2px; font-size: 12px; color: var(--muted); } +.ldiv b { color: var(--text); font-weight: 600; white-space: nowrap; } +.ldiv::after { content: ""; flex: 1; border-top: 1px solid var(--line); } +svg.li { width: 14px; height: 14px; margin-top: 3px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; color: var(--muted); } +svg.li.ok { color: var(--done); } svg.li.bad { color: var(--failed); } svg.li.mgr { color: var(--manager); } svg.li.you { color: var(--accent); } svg.li.warn { color: var(--verifying); } #diff-stat.live::after { content: " · live"; color: var(--running); font-weight: 600; } #diff-stat.live:empty::after { content: "live"; } .file { border: 1px solid var(--line); margin-top: 10px; } @@ -185,12 +326,70 @@ .radios small { color: var(--muted); } #m-preview { font-size: 13px; margin-top: 12px; display: grid; gap: 6px; } #m-preview ul { margin: 0; padding-left: 18px; font: 12px var(--mono); } -#log .ev[data-kind=plan] .k, #log .ev[data-kind=verdict] .k { color: var(--manager); } -#log .ev[data-kind=request] .k, #log .ev[data-kind=request] .x { color: var(--accent); font-weight: 600; } /* the way to iterate once an agent has run: say what should change, re-run only what it concerns */ -#revise { margin-top: 14px; padding: 4px 12px 10px; background: var(--sunk); border: 1px solid var(--line); border-left: 3px solid var(--accent); } -#revise label { color: var(--text); font-weight: 600; font-size: 13px; } -#revise .hint { margin-top: 6px; } +#g-working { display: flex; align-items: center; gap: 2px; margin-top: 14px; padding: 8px 12px; border: 1px solid var(--s); border-left-width: 3px; + background: color-mix(in srgb, var(--s) 8%, var(--panel)); font-weight: 600; } +#g-working[hidden] { display: none; } +.help { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 8px; font-size: 12px; line-height: 1.35; color: var(--muted); } +.help li { display: flex; gap: 8px; align-items: flex-start; } +.help svg { width: 14px; height: 14px; flex: none; margin-top: 1px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +#queue { margin-top: 16px; } +#q-actions { margin-top: 8px; } +#q-actions[hidden] { display: none; } +#run-dialog { width: min(560px, 94vw); } +#run-goals { margin: 12px 0 0; padding-left: 22px; display: grid; gap: 10px; max-height: 50vh; overflow: auto; } +#run-goals li b { font-weight: 600; } +#run-goals .rg { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } +#run-goals .rg code { font: 12px var(--mono); color: var(--text); } +.qhead { display: flex; align-items: center; gap: 8px; } +.qhead h4 { margin: 0; font-size: 14px; } +.qhead .hint { margin: 0; } +#queue > .hint { margin-top: 4px; } +#q-items { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 4px; counter-reset: q; } +.qi { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--panel); border: 1px solid var(--line); font-size: 13px; } +#q-items .qi { counter-increment: q; } +#q-items .qi::before { content: counter(q); width: 16px; color: var(--muted); font: 12px var(--mono); text-align: right; } +.qi .grip { width: 12px; height: 16px; cursor: grab; color: var(--muted); flex: none; } +.qi.dragging { opacity: .4; } +.qi .qt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qi .qm { color: var(--muted); font-size: 12px; white-space: nowrap; } +.qi[data-status=running] { border-color: var(--running); } +.qi[data-status=running] .qm { color: var(--running); font-weight: 600; } +.qi .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--s, var(--idle)); } +.qi[data-status=done] { --s: var(--done); } .qi[data-status=failed], .qi[data-status=blocked] { --s: var(--failed); } +.qi[data-status=stopped], .qi[data-status=skipped] { --s: var(--stopped); } .qi[data-status=running] { --s: var(--running); } +.qi .menu > summary { padding: 0 6px; border: 0; background: none; font-weight: 400; color: var(--muted); letter-spacing: 1px; } +.qi .menu ul { left: auto; right: 0; min-width: 150px; } +.qi .menu li button { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 7px 12px; } +.qi .menu li button:hover { background: var(--sunk); } +.qi .menu li button.danger { color: var(--failed); } +.qi button.link { border: 0; background: none; padding: 0; color: var(--accent); font-size: 12px; white-space: nowrap; } +.qi.fin { flex-wrap: wrap; row-gap: 4px; } +.qi.fin > svg.li { margin-top: 0; } +.qi.fin .qa { display: flex; flex-wrap: wrap; gap: 4px 14px; width: 100%; padding-left: 46px; } +.qi.fin[data-status=skipped] .qt, .qi.fin[data-status=stopped] .qt { color: var(--muted); } +#q-form { margin-top: 10px; padding: 4px 12px 12px; background: var(--sunk); border: 1px solid var(--line); } +.qfail { margin-top: 12px; font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; } +.card .badges .q { color: var(--accent); } +label.check { display: flex; gap: 6px; align-items: center; font-weight: 400; color: var(--text); } +label.check input { width: auto; margin: 0; padding: 0; accent-color: var(--accent); } +label.check input[type=radio] { appearance: auto; -webkit-appearance: radio; width: 14px; height: 14px; border: 0; background: none; } +#g-approve-row { margin-top: 10px; } +#g-working-note { display: block; font-weight: 400; font-size: 12px; color: var(--muted); } +#g-working-note:empty { display: none; } +#g-working .st::before { width: 9px; height: 9px; margin-right: 8px; border-radius: 50%; } +#plan-review { margin-top: 14px; padding: 4px 12px 12px; background: var(--sunk); border: 1px solid var(--line); border-left: 3px solid var(--awaiting); } +#plan-review h3 { margin: 8px 0 2px; font-size: 14px; } +#plan-review .pr-item { background: var(--panel); border: 1px solid var(--line); margin-top: 8px; } +#plan-review .pr-item > summary { display: flex; gap: 8px; align-items: center; padding: 6px 10px; cursor: pointer; } +#plan-review .pr-item > summary .who { font-weight: 600; white-space: nowrap; } +#plan-review .pr-item > summary .what { flex: 1; min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#plan-review .pr-item .fields { padding: 0 10px 10px; } +#plan-review .pr-item.out { display: flex; gap: 8px; align-items: center; padding: 6px 10px; color: var(--muted); } +#plan-review .pr-item.out .who { font-weight: 600; color: var(--text); } +#plan-review .pr-item.out .what { flex: 1; } +#plan-review .warn { color: var(--awaiting); font-size: 12px; margin: 6px 0 0; } +#plan-review .row { flex-wrap: wrap; } dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); padding: 20px; width: min(460px, 92vw); } dialog::backdrop { background: #0006; } @@ -217,27 +416,33 @@ · + + +

arranger

- - - +
+ + + +
- - 0/0 done - 0 running -
Needs you (0)
    - $0.00 + +
    Needs you (0)
      +
      - - +
      @@ -245,31 +450,46 @@

      arranger

      -
      +
      + +