diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f64ef2d..1ff6643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,36 @@ jobs: - name: Build the CLI run: pnpm build + + desktop: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version-file: desktop/go.mod + cache-dependency-path: desktop/go.sum + - run: pnpm install --frozen-lockfile + + # The engine the app ships: the dispatcher, a runner and the API, in one file. + - name: Build the engine + run: pnpm build:engine + + - name: Vet the app + working-directory: desktop + run: go vet ./... && go tool mygo vet . + + # Draws every screen without a window, and loads them from a real engine. + - name: Test the app + working-directory: desktop + run: CGO_ENABLED=0 go test ./... + + - name: Build the app + working-directory: desktop + run: go build ./... diff --git a/.gitignore b/.gitignore index 91f029d..fe98771 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ config/oauth-app.json # Trying hosted mode locally (docs/hosting.md) .hosted/ + +# The desktop app's engine, built by scripts/build-engine.mjs (docs/desktop.md) +desktop/resources/engine/ diff --git a/AGENTS.md b/AGENTS.md index e805baa..63ca264 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Work comes in from GitHub, Slack, Lark, email, or the clock. A loop says what to - Tailwind CSS with shadcn/ui - SQLite for state (libSQL when hosted), encrypted secrets when hosted, the macOS keychain (or a file elsewhere) in `pnpm dev` - Hosted on Cloudflare: the app as a Worker, the dispatcher as a Container; see `docs/hosting.md` +- A desktop app in `desktop/`, native UI in Go with MyGo, running the same engine (dispatcher, runner, API) as a Node process beside it; see `docs/desktop.md` ## Workspaces diff --git a/config/oauth-app.example.json b/config/oauth-app.example.json index 270248b..1af73b1 100644 --- a/config/oauth-app.example.json +++ b/config/oauth-app.example.json @@ -1,5 +1,10 @@ { "_comment": "Copy to config/oauth-app.json (gitignored) to sign in and connect while developing. Register each app with the loopback callback: GitHub http://127.0.0.1:4321/api/connectors/github/callback, Google http://127.0.0.1:4321/api/connectors/gmail/callback.", "github": { "clientId": "", "clientSecret": "" }, - "gmail": { "clientId": "", "clientSecret": "" } + "gmail": { "clientId": "", "clientSecret": "" }, + "desktop": { + "_comment": "Built into the desktop app by scripts/build-engine.mjs. GitHub: an OAuth app with device flow turned on, ID only. Google: a client of type Desktop app.", + "github": { "clientId": "" }, + "gmail": { "clientId": "", "clientSecret": "" } + } } diff --git a/desktop/.agents/skills/mygo-maintenance/SKILL.md b/desktop/.agents/skills/mygo-maintenance/SKILL.md new file mode 100644 index 0000000..9839fdc --- /dev/null +++ b/desktop/.agents/skills/mygo-maintenance/SKILL.md @@ -0,0 +1,37 @@ +--- +name: mygo-maintenance +description: Maintain Go applications using github.com/egoist/mygo, including UI changes, crash fixes, and dependency upgrades. Apply MyGo-specific lifetime, identity, threading, and testing practices. +--- + +# MyGo app maintenance + +Check the app's pinned MyGo version and existing conventions before changing APIs. Locate its source with `go list -m -f '{{.Dir}}' github.com/egoist/mygo`; read `ui/doc.go` and relevant implementation comments. Read `docs/ui/migration.md` only for major-version upgrades, including minor-version bumps while on `0.x` (for example, `0.2.x` to `0.3.x`, or `1.x` to `2.x`). Confirm API availability against the pinned source. + +The native UI rules below apply where the app uses package `ui`; preserve a web app's frontend and typed Go/TypeScript bindings. + +## UI lifetime and identity + +- Views receive a stable `*ui.Context` for their window. `Children(func())` shares it and changes its parent scope during the callback. Keep Context out of app state and workers; capture `c.Services()` for persistent clipboard/URL callbacks and background redraws. +- Constructors return checked `ui.Element` values. Elements and custom parts expire before the next build pass, including another pass in the same frame. Store app data, widget state and `ui.Handle`, never an element or parts. Zero elements are safe and `Valid()` is a quiet check; stale operations panic in development/Tester and become empty results or no-ops in production. Fix the ownership rather than relying on validity guards or recovery. +- Bind persistent identity with `.Bind(&handle)`. `handle.Focused(c)` and `FocusWithin(c)` read committed focus independently of construction order. `handle.Focus(c)` requests focus and waits while hidden; `CancelFocus(c)` cancels it. Handles support several windows; select one with its Context, and keep widget state separate per window. +- Use `.FocusBind(&a.pane, files)` when app data should request focus. The field holds desired focus; `ui.FocusedValue(c, &a.pane)` reads actual focus while a hidden target waits. Use a comparable field, unique target values and its zero value for no focus. +- Use `handle.Bounds(c)` for committed geometry in input callbacks. `c.Resolve(handle)` returns only the current build's element. Do not capture an element in a later input callback to query its bounds. +- Give controls stable, unique identity before construction with `c.Key(key)`, including stateful widgets. Container `.Key(key)` must precede children and local state. Configure fluent lists before `.Rows`; use `.ItemKey` for stable item identity and `ui.ListRow.Selected()`/`ListFocused()` for row styling. + +## Input and actions + +- Bound values update after construction and configuration. `Changed`/`Submitted` notices appear in the following build pass. Bind directly to persistent model fields when possible. A local value recomputed each pass loses the edit before its notice; write derived checkbox or segmented values back in `OnClick`, after that build's bound input updates them. +- `OnClick`/`OnShortcut` run after construction and bound input; `OnChange`/`OnSubmit` observe the rebuilt view's notices. Prefer deferred actions when changing a collection being built. `Clicked()` and shortcut queries remain supported for local interactions. +- Declare scoped commands with `handle.OnShortcut(c, mods, key, fn)`, before or after Bind. Only an enabled control present in the current build takes its command. Use `c.OnShortcut` for commands in the current parent scope. + +## State, work, and rendering + +- Views may rebuild several times for one event. Trigger external side effects through handled actions or explicit guarded jobs, not unconditional rendering code. +- Keep slow I/O and expensive computation off the UI thread. Publish results through `Window.Update`; it is asynchronous. `Invalidate` requests redraws without synchronizing model writes. Cancel obsolete jobs or reject stale results after source changes or window closure. +- Keep drawing callbacks limited to painting; they can run repeatedly. Prefer MyGo widgets/base widgets to preserve keyboard, focus, disabled, and accessibility behavior. Follow the app's theme and sizing conventions. + +## Verification + +- Reproduce UI failures with `ui.NewTester`. Exercise extra frames while elements disappear and return, plus relevant focus, typing, and shortcut behavior. +- Run targeted regression tests, `go tool mygo vet .` for stored build values and goroutine captures, and `CGO_ENABLED=0 go test ./...`. Use race checks when changing shared state, and inspect rendered output or the native app when changing layout/platform behavior, respecting the user's verification preferences. +- For upgrades, preview with `go tool mygo migrate-ui .`, apply with `-write`, and review persistent identity/services, separately assigned keys and derived bound values manually. Verify affected API/lifecycle assumptions against the new pinned source and rerun relevant UI tests. Report remaining platform-specific verification limits. diff --git a/desktop/.agents/skills/mygo-maintenance/agents/openai.yaml b/desktop/.agents/skills/mygo-maintenance/agents/openai.yaml new file mode 100644 index 0000000..a8ed1db --- /dev/null +++ b/desktop/.agents/skills/mygo-maintenance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "MyGo Maintenance" + short_description: "Maintain MyGo apps with safe UI lifetimes" diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..48d63f6 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +/build/ +/.mygo/ +mygo_windows_*.syso +/desktop diff --git a/desktop/agents.go b/desktop/agents.go new file mode 100644 index 0000000..03bfb6d --- /dev/null +++ b/desktop/agents.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +var modeLabels = map[string]string{ + "read_only": "Read only", + "workspace_write": "Can write in the workspace", +} + +type agentEdit struct { + open bool + mode string + model string + seconds string + testOutput string + from string // settings this was made from, to start over on a change elsewhere +} + +func (a *app) agentsPage(c *ui.Context) { + t := c.Theme() + a.header(c, "", "", "Agents", "The coding CLIs on this Mac. Loops run with one of them.", "", nil) + if a.m == nil { + ui.Spinner(c) + return + } + if len(a.agents) == 0 { + ui.Spinner(c) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for _, manifest := range a.m.Agents { + for i := range a.agents { + if a.agents[i].AgentID == manifest.ID { + a.agentRow(c, &manifest, &a.agents[i]) + } + } + } + }) +} + +func presence(view *AgentView) string { + if view.Auth != nil && view.Auth.SignedIn { + if view.Auth.Detail != "" { + return "Signed in · " + view.Auth.Detail + } + return "Signed in" + } + if view.Installed { + if view.Auth != nil && view.Auth.Detail != "" { + return "Installed, not signed in · " + view.Auth.Detail + } + return "Installed, not signed in" + } + return "Not installed" +} + +func (a *app) agentRow(c *ui.Context, manifest *AgentManifest, view *AgentView) { + t := c.Theme() + edit := a.agentEdit[manifest.ID] + signature := fmt.Sprintf("%s|%s|%v", view.Settings.PermissionMode, deref(view.Settings.Model), view.Settings.TimeoutMs) + if edit == nil { + edit = &agentEdit{open: view.IsDefault && !view.DefaultIsImplicit} + a.agentEdit[manifest.ID] = edit + } + if edit.from != signature { + edit.from = signature + edit.mode = modeLabels[view.Settings.PermissionMode] + edit.model = deref(view.Settings.Model) + edit.seconds = itoa(int(view.Settings.TimeoutMs/1000 + 0.5)) + } + online := view.Auth != nil && view.Auth.SignedIn + busy := a.busy["agent:"+manifest.ID] + + ui.Column(c).Key(manifest.ID).Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Padding(12, 16).Children(func() { + connectorTile(c, manifest.ID, 32, !online) + head := ui.ButtonBase(c).Grow(1).Column().AlignItems(ui.Start).Gap(2).Label(manifest.Name) + head.Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, manifest.Name).FontSize(13).Bold() + if view.IsDefault { + ui.Badge(c, "Default") + } + }) + ui.Text(c, presence(view)).FontSize(11).TextColor(t.TextMuted).SingleLine() + }) + if head.Clicked() { + edit.open = !edit.open + } + if !view.IsDefault { + if ui.Button(c, "Make default").Disabled(busy).Clicked() { + id, name := manifest.ID, manifest.Name + a.run("agent:"+id, func() (string, error) { + if err := a.call("chooseDefaultAgent", map[string]string{"agentId": id}, nil); err != nil { + return "", err + } + return name + " will run the loops", nil + }) + } + } + label := "Settings" + if edit.open { + label = "Hide" + } + if ui.Button(c, label).Clicked() { + edit.open = !edit.open + } + }) + if !edit.open { + return + } + ui.Column(c).Gap(12).Padding(14, 16, 16, 60).BorderWidth(1, 0, 0, 0).BorderColor(t.Border).Background(t.Background).Children(func() { + if !online { + ui.Text(c, manifest.InstallHint).FontSize(12).TextColor(t.TextMuted) + } + ui.Row(c).Gap(16).AlignItems(ui.Start).Wrap().Children(func() { + ui.Field(c, "Permission", func() { + labels := make([]string, len(manifest.PermissionModes)) + for i, mode := range manifest.PermissionModes { + labels[i] = modeLabels[mode] + } + ui.Select(c, &edit.mode, labels).Width(260) + }) + if manifest.SupportsModel { + ui.Field(c, "Model", func() { + ui.TextInput(c, &edit.model).Placeholder(manifest.ModelPlaceholder).Width(220) + }) + } + ui.Field(c, "Timeout (seconds)", func() { + ui.TextInput(c, &edit.seconds).Width(120) + }) + }) + mono(c, view.CommandPreview) + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + saveLabel := "Save" + if busy { + saveLabel = "Working…" + } + if ui.PrimaryButton(c, saveLabel).Disabled(busy).Clicked() { + mode := view.Settings.PermissionMode + for value, label := range modeLabels { + if label == edit.mode { + mode = value + } + } + seconds, _ := strconv.Atoi(strings.TrimSpace(edit.seconds)) + var model *string + if m := strings.TrimSpace(edit.model); m != "" { + model = &m + } + id, name := manifest.ID, manifest.Name + body := map[string]any{"agentId": id, "permissionMode": mode, "model": model, "timeoutMs": seconds * 1000} + a.run("agent:"+id, func() (string, error) { + if err := a.call("saveAgent", body, nil); err != nil { + return "", err + } + return "Saved " + name + " settings", nil + }) + } + if view.Installed { + if ui.Button(c, "Test").Disabled(busy).Clicked() { + edit.testOutput = "" + id, name := manifest.ID, manifest.Name + a.run("agent:"+id, func() (string, error) { + var result AgentTest + if err := a.call("runAgentTest", map[string]string{"agentId": id}, &result); err != nil { + return "", err + } + text := fmt.Sprintf("Answered correctly in %.1fs.", result.DurationMs/1000) + if !result.Passed { + detail := "The agent did not answer as expected." + if result.Detail != nil { + detail = *result.Detail + } + text = strings.TrimSpace(detail + "\n" + result.Output) + } + a.update(func() { edit.testOutput = text }) + if result.Passed { + return name + " is working", nil + } + return "", fmt.Errorf("%s did not pass", name) + }) + } + } + }) + if edit.testOutput != "" { + ui.Text(c, edit.testOutput).FontSize(12).TextColor(t.TextMuted).Selectable() + } + }) + }) +} diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 0000000..08a08de --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,521 @@ +package main + +import ( + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +// app is everything the window shows. All of it is read and written on +// the main thread: goroutines hand their results over with win.Update. +type app struct { + win *mygo.Window + tray *mygo.Tray + router *ui.Router + engine *Engine + quitting bool + + m *Manifests + loops []LoopView + readiness *LoopReadiness + tasks []TaskView + runnable []AgentChoice + agents []AgentView + overview []ConnectorOverview + status *Status + + // The task page, the loop page, and the connector page, each for one id. + task taskPage + loopPage loopPage + + busy map[string]bool + toasts []string + forms map[string]*loopForm + fields map[string]*fieldValues + ask askState + connect *connectFlow + agentEdit map[string]*agentEdit + confirm confirmState + open map[string]bool + settings settingsPage + + // Task states last seen, to notice one finishing. + seen map[string]string + refreshing atomic.Bool + gen atomic.Uint64 + lastTick time.Time +} + +type taskPage struct { + id string + task *TaskView + log string +} + +type loopPage struct { + id string + loop *LoopView + tasks []TaskView + poll *LoopPollState + tab int + tryURL string + dryRun bool +} + +type askState struct { + open bool + prompt string + agent string + running bool +} + +type confirmState struct { + open bool + kind string // "loop" or "connection" + id string + name string + title string + body string +} + +func newApp() *app { + a := &app{ + router: ui.NewRouter("/inbox"), + busy: map[string]bool{}, + forms: map[string]*loopForm{}, + fields: map[string]*fieldValues{}, + agentEdit: map[string]*agentEdit{}, + open: map[string]bool{}, + seen: map[string]string{}, + } + a.loopPage.dryRun = true + a.engine = newEngine(func() { a.update(func() {}) }) + return a +} + +// update runs fn on the main thread and draws a frame. Safe before the +// window exists, when there is nothing to draw. +func (a *app) update(fn func()) { + if a.win == nil { + // No window, as in tests: there is no main thread to wait for. + fn() + return + } + if a.win.IsDestroyed() { + return + } + a.win.Update(fn) +} + +func (a *app) toast(message string) { + a.toasts = append(a.toasts, message) +} + +func errText(err error) string { + if err == nil { + return "" + } + return strings.TrimSpace(err.Error()) +} + +// run does work off the main thread, then says how it went and refreshes. +// key marks what is busy meanwhile, so its buttons can wait. +func (a *app) run(key string, work func() (string, error)) { + if a.busy[key] { + return + } + a.busy[key] = true + go func() { + message, err := work() + a.update(func() { + delete(a.busy, key) + if err != nil { + a.toast(errText(err)) + } else if message != "" { + a.toast(message) + } + a.refresh() + }) + }() +} + +func (a *app) call(name string, in any, out any) error { + return a.engine.Call(name, in, out) +} + +// refresh loads what the page on screen needs, that page's own data +// first, so a page never waits on another's. Called on the main thread; +// the loading happens elsewhere and lands with update. A newer refresh +// outranks an older one still in flight. +func (a *app) refresh() { + if a.engine == nil { + return + } + state, _, _, _, _ := a.engine.snapshot() + if state != "running" { + return + } + a.refreshing.Store(true) + gen := a.gen.Add(1) + path := a.router.Path() + needManifests := a.m == nil + taskID, loopID := a.task.id, a.loopPage.id + latest := func() bool { return a.gen.Load() == gen } + go func() { + defer func() { + if latest() { + a.refreshing.Store(false) + } + }() + if needManifests { + var got Manifests + if err := a.call("getManifests", nil, &got); err == nil { + a.update(func() { a.m = &got }) + } + } + + switch { + case path == "/inbox": + a.loadInbox() + case strings.HasPrefix(path, "/inbox/") && taskID != "": + var t TaskView + if err := a.call("getTaskById", map[string]string{"id": taskID}, &t); err == nil && t.ID != "" { + var text *string + log := "" + if a.call("getTaskLog", map[string]string{"id": taskID}, &text) == nil && text != nil { + log = *text + } + a.update(func() { + if a.task.id == t.ID { + a.task.task, a.task.log = &t, log + } + }) + } + case path == "/loops" || path == "/loops/new" || strings.HasPrefix(path, "/loops/new/"): + a.loadLoops() + if path != "/loops" { + a.loadOverview() + } + case strings.HasPrefix(path, "/loops/") && loopID != "": + var l LoopView + if err := a.call("getLoopById", map[string]string{"id": loopID}, &l); err == nil && l.ID != "" { + lp := &loopPage{id: loopID, loop: &l} + _ = a.call("getLoopTasks", map[string]string{"loopId": loopID}, &lp.tasks) + var poll LoopPollState + if a.call("getLoopPollState", map[string]string{"id": loopID}, &poll) == nil { + lp.poll = &poll + } + a.update(func() { + if a.loopPage.id == lp.id { + a.loopPage.loop, a.loopPage.tasks, a.loopPage.poll = lp.loop, lp.tasks, lp.poll + } + }) + } + a.loadLoops() + case path == "/connectors": + a.loadOverview() + case strings.HasPrefix(path, "/connectors/"): + a.loadOverview() + a.loadLoops() + case path == "/agents": + var agents struct { + Agents []AgentView `json:"agents"` + } + if a.call("getAgentsPage", nil, &agents) == nil { + a.update(func() { a.agents = agents.Agents }) + } + case path == "/settings": + var s Status + if a.call("getStatus", nil, &s) == nil { + a.update(func() { a.status = &s }) + } + } + + // The inbox feeds the sidebar's count and the notifications, so it + // is looked at after every page, when nothing newer is on its way. + if path != "/inbox" && latest() { + a.loadInbox() + } + }() +} + +func (a *app) loadInbox() { + var inbox struct { + Tasks []TaskView `json:"tasks"` + Agents []AgentChoice `json:"agents"` + } + if a.call("getInbox", nil, &inbox) != nil { + return + } + a.update(func() { + a.noticeFinished(inbox.Tasks) + a.tasks, a.runnable = inbox.Tasks, inbox.Agents + if a.ask.agent == "" && len(inbox.Agents) > 0 { + a.ask.agent = inbox.Agents[0].Name + } + }) +} + +func (a *app) loadLoops() { + var page struct { + Loops []LoopView `json:"loops"` + Readiness LoopReadiness `json:"readiness"` + } + if a.call("getLoopsPage", nil, &page) != nil { + return + } + a.update(func() { a.loops, a.readiness = page.Loops, &page.Readiness }) +} + +func (a *app) loadOverview() { + var overview []ConnectorOverview + if a.call("getConnectorOverview", nil, &overview) != nil { + return + } + a.update(func() { a.overview = overview }) +} + +// noticeFinished tells the person about a run that ended while they were +// not looking, once per run. +func (a *app) noticeFinished(tasks []TaskView) { + first := len(a.seen) == 0 + for i := range tasks { + t := &tasks[i] + before, known := a.seen[t.ID] + a.seen[t.ID] = t.State + if first || !known || before == t.State || t.Active() { + continue + } + if a.win != nil && a.win.IsVisible() && a.win.IsFocused() { + continue + } + if !mygo.NotificationsSupported() { + continue + } + title := t.Title() + body := "" + switch t.State { + case "done": + body = "Written: " + a.m.destinationLabel(t.ActionConnectorID, t.ActionID) + case "failed": + body = "Failed" + if t.Error != nil { + body = "Failed: " + firstLine(*t.Error) + } + case "skipped": + body = "Nothing to write" + case "prepared": + body = "Prepared, nothing written" + default: + continue + } + id := t.ID + n := mygo.NewNotification(mygo.NotificationOptions{Title: title, Body: body}) + n.OnClick(func() { + a.update(func() { + a.show() + a.goTo("/inbox/" + id) + }) + }) + _ = n.Show() + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +// goTo opens a page and starts loading what it needs. +func (a *app) goTo(path string) { + a.enter(path) + a.router.Push(path) + a.refresh() +} + +// enter notes which record a page is about before it is built. +func (a *app) enter(path string) { + if rest, ok := strings.CutPrefix(path, "/inbox/"); ok && rest != "" { + if a.task.id != rest { + a.task = taskPage{id: rest} + // What the list already knows, shown at once; the log follows. + for i := range a.tasks { + if a.tasks[i].ID == rest { + t := a.tasks[i] + a.task.task = &t + } + } + } + } + if rest, ok := strings.CutPrefix(path, "/loops/"); ok && rest != "" && rest != "new" && !strings.HasPrefix(rest, "new/") { + id := rest + if i := strings.IndexByte(id, '?'); i >= 0 { + id = id[:i] + } + if a.loopPage.id != id { + a.loopPage = loopPage{id: id, dryRun: true} + for i := range a.loops { + if a.loops[i].ID == id { + l := a.loops[i] + a.loopPage.loop = &l + } + } + } + } +} + +func (a *app) show() { + if a.win == nil { + return + } + a.win.Show() + a.win.Focus() + a.refresh() +} + +func (a *app) ready() { + a.win = mygo.NewWindow(mygo.WindowOptions{ + Title: "Loopable", + Width: 1040, + Height: 700, + MinWidth: 760, + MinHeight: 520, + StateKey: "main", + Hidden: mygo.App.WasOpenedAtLogin(), + Content: ui.View(a.view), + }) + // Closing the window leaves the app in the menu bar, with the loops running. + a.win.OnClose(func(e *mygo.CloseEvent) { + if !a.quitting { + e.PreventDefault() + a.win.Hide() + } + }) + a.makeMenus() + a.engine.Start() + go a.ticker() +} + +// ticker refreshes the page every couple of seconds while something runs, +// and every ten otherwise, so the inbox is live without anyone pressing +// anything. Notifications come from the same look. +func (a *app) ticker() { + for { + time.Sleep(2 * time.Second) + a.update(func() { + active := false + for i := range a.tasks { + if a.tasks[i].Active() { + active = true + break + } + } + visible := a.win != nil && a.win.IsVisible() + every := 10 * time.Second + if active { + every = 2 * time.Second + } else if !visible { + every = 20 * time.Second + } + if time.Since(a.lastTick) < every || a.refreshing.Load() { + return + } + a.lastTick = time.Now() + a.refresh() + }) + } +} + +func (a *app) makeMenus() { + mygo.App.SetMenu(mygo.NewMenu([]*mygo.MenuItem{ + {Role: mygo.RoleAppMenu}, + {Label: "File", Submenu: []*mygo.MenuItem{ + {Label: "New Loop", Accelerator: "CmdOrCtrl+N", Click: func(*mygo.MenuItem, *mygo.Window) { + a.show() + a.goTo("/loops/new") + }}, + {Label: "Ask an Agent", Accelerator: "CmdOrCtrl+K", Click: func(*mygo.MenuItem, *mygo.Window) { + a.show() + a.goTo("/inbox") + a.ask.open = true + }}, + mygo.Separator(), + {Role: mygo.RoleClose}, + }}, + {Role: mygo.RoleEditMenu}, + {Label: "View", Submenu: []*mygo.MenuItem{ + {Label: "Inbox", Accelerator: "CmdOrCtrl+1", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/inbox") }}, + {Label: "Loops", Accelerator: "CmdOrCtrl+2", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/loops") }}, + {Label: "Connectors", Accelerator: "CmdOrCtrl+3", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/connectors") }}, + {Label: "Agents", Accelerator: "CmdOrCtrl+4", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/agents") }}, + {Label: "Settings", Accelerator: "CmdOrCtrl+,", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/settings") }}, + mygo.Separator(), + {Label: "Reload", Accelerator: "CmdOrCtrl+R", Click: func(*mygo.MenuItem, *mygo.Window) { a.refresh() }}, + }}, + {Role: mygo.RoleWindowMenu}, + })) + + tray, err := mygo.NewTray(mygo.TrayOptions{ + Icon: trayIcon(), + IconIsTemplate: true, + ToolTip: "Loopable", + Menu: mygo.NewMenu([]*mygo.MenuItem{ + {Label: "Open Loopable", Click: func(*mygo.MenuItem, *mygo.Window) { a.show() }}, + {Label: "Inbox", Click: func(*mygo.MenuItem, *mygo.Window) { a.show(); a.goTo("/inbox") }}, + {Label: "Loops", Click: func(*mygo.MenuItem, *mygo.Window) { a.show(); a.goTo("/loops") }}, + mygo.Separator(), + {Role: mygo.RoleQuit}, + }), + }) + if err == nil { + a.tray = tray + } +} + +// ago says how long since an ISO time: "just now", "5m ago", "3h ago", or a date. +func ago(iso string) string { + t, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + return iso + } + seconds := int(time.Since(t).Seconds()) + switch { + case seconds < 60: + return "just now" + case seconds < 3600: + return fmt.Sprintf("%dm ago", (seconds+30)/60) + case seconds < 86400: + return fmt.Sprintf("%dh ago", (seconds+1800)/3600) + } + return t.Local().Format("Jan 2, 2006") +} + +func localTime(iso string) string { + t, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + return iso + } + return t.Local().Format("Jan 2, 2006 15:04") +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func contains(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} diff --git a/desktop/connectors.go b/desktop/connectors.go new file mode 100644 index 0000000..790b0da --- /dev/null +++ b/desktop/connectors.go @@ -0,0 +1,648 @@ +package main + +import ( + "encoding/base64" + "strings" + "time" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +func statusBadge(c *ui.Context, status string) { + label := map[string]string{ + "connected": "Connected", "needs_reauth": "Reconnect", "error": "Error", "paused": "Paused", + }[status] + if label == "" { + label = status + } + ui.Badge(c, label) +} + +func (a *app) overviewOf(id string) *ConnectorOverview { + for i := range a.overview { + if a.overview[i].ConnectorID == id { + return &a.overview[i] + } + } + return nil +} + +func (a *app) connectorsPage(c *ui.Context) { + t := c.Theme() + a.header(c, "", "", "Connectors", "The services loops watch and write to.", "", nil) + if a.m == nil { + ui.Spinner(c) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for i := range a.m.Connectors { + manifest := &a.m.Connectors[i] + if !a.m.needsAccount(manifest.ID) { + continue + } + state := a.overviewOf(manifest.ID) + row := ui.ButtonBase(c).Key(manifest.ID).Row().Gap(12).AlignItems(ui.Center).Padding(12, 16).Label(manifest.Name) + if row.Hovered() { + row.Background(t.SurfaceHover) + } + row.Children(func() { + connectorTile(c, manifest.ID, 32, false) + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, manifest.Name).FontSize(13).Bold() + switch { + case state != nil && !state.Readiness.Ready: + ui.Text(c, state.Readiness.Reason).FontSize(11).TextColor(t.TextMuted).SingleLine() + case state != nil && len(state.Connections) > 0: + ui.Row(c).Gap(10).AlignItems(ui.Center).Wrap().Children(func() { + for _, connection := range state.Connections { + ui.Row(c).Gap(5).AlignItems(ui.Center).Children(func() { + ui.Text(c, connection.AccountLabel).FontSize(11).TextColor(t.TextMuted) + statusBadge(c, connection.Status) + }) + } + }) + default: + ui.Text(c, "No "+a.m.connectionNoun(manifest.ID)+" yet").FontSize(11).TextColor(t.TextMuted) + } + }) + ui.Icon(c, iconChevron).TextColor(t.TextMuted) + }) + if row.Clicked() { + a.goTo("/connectors/" + manifest.ID) + } + } + }) +} + +// connectFlow is a connection being made: a device code, a browser page, +// a QR code, or a token pasted. +type connectFlow struct { + connectorID string + kind string + loading bool + // device + userCode, verificationURL string + // device and qr + attempt any + hint string + expired string + // qr + image *ui.Bitmap + // token + values map[string]*string + stop chan struct{} +} + +func (f *connectFlow) end() { + if f != nil && f.stop != nil { + close(f.stop) + f.stop = nil + } +} + +func (a *app) connectorPage(c *ui.Context, id string) { + t := c.Theme() + if a.m == nil { + ui.Spinner(c) + return + } + manifest := a.m.connector(id) + if manifest == nil { + a.header(c, "/connectors", "Connectors", "Unknown connector", "", "", nil) + return + } + state := a.overviewOf(id) + if a.connect != nil && a.connect.connectorID != id { + a.connect.end() + a.connect = nil + } + var connections []ConnectionView + ready := false + if state != nil { + connections, ready = state.Connections, state.Readiness.Ready + } + canAdd := manifest.AllowsMultipleAccounts || len(connections) == 0 + + a.header(c, "/connectors", "Connectors", manifest.Name, manifest.Tagline, manifest.ID, func() { + if !ready || !canAdd { + return + } + if a.connect != nil { + if ui.Button(c, "Cancel").Clicked() { + a.connect.end() + a.connect = nil + } + return + } + label := "Connect" + switch manifest.Auth.Kind { + case "token": + label = "Add bot" + case "oauth_redirect": + if len(connections) > 0 { + label = "Add account" + } + } + if ui.PrimaryButton(c, label).Clicked() { + a.beginConnect(manifest) + } + }) + + if state != nil && !ready { + // The engine words this for the desktop: what this Mac is missing, + // or that the build is, never an app to register. + lines := []string{state.Readiness.Reason} + if state.Readiness.FixHint != "" { + lines = append(lines, state.Readiness.FixHint) + } + alert(c, "Not ready", lines, false) + } + + if a.connect != nil { + a.connectView(c, manifest) + } + + if len(connections) > 0 { + card(c).Dividers(1, t.Border).Children(func() { + for i := range connections { + a.connectionRow(c, manifest, &connections[i]) + } + }) + } else if ready && a.connect == nil { + empty(c, "No "+a.m.connectionNoun(id)+" connected yet.", func() { + if manifest.Auth.Kind == "oauth_redirect" && manifest.Auth.Note != "" { + ui.Text(c, manifest.Auth.Note).FontSize(11).TextColor(t.TextMuted).MaxWidth(480).TextAlign(ui.Center) + } + }) + } + + // Loops that watch this connector, or write through it. + ui.Column(c).Gap(8).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Loops").FontSize(13).Bold().Grow(1) + if len(manifest.Triggers) > 0 { + if ui.Button(c, "New loop").Clicked() { + a.goTo("/loops/new") + } + } + }) + var using []*LoopView + for i := range a.loops { + if a.loops[i].ConnectorID == id || a.loops[i].ActionConnectorID == id { + using = append(using, &a.loops[i]) + } + } + if len(using) == 0 { + ui.Text(c, "No loop uses "+manifest.Name+" yet.").FontSize(12).TextColor(t.TextMuted) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for _, loop := range using { + loop := loop + row := ui.ButtonBase(c).Key(loop.ID).Row().Gap(12).AlignItems(ui.Center).Padding(10, 16).Label(loop.Name) + if row.Hovered() { + row.Background(t.SurfaceHover) + } + row.Children(func() { + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, loop.Name).FontSize(13).Bold().SingleLine() + text := a.m.destinationLabel(loop.ActionConnectorID, loop.ActionID) + if loop.ConnectorID != id { + text = "Answers here, from " + a.m.connectorName(loop.ConnectorID) + } + ui.Text(c, text).FontSize(11).TextColor(t.TextMuted).SingleLine() + }) + if !loop.Enabled { + ui.Badge(c, "Off") + } + ui.Icon(c, iconChevron).TextColor(t.TextMuted) + }) + if row.Clicked() { + a.goTo("/loops/" + loop.ID) + } + } + }) + }) + + ui.Row(c).Gap(24).AlignItems(ui.Start).Children(func() { + capability := func(title, emptyText string, items [][2]string) { + ui.Column(c).Grow(1).Basis(0).Gap(8).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + if len(items) == 0 { + ui.Text(c, emptyText).FontSize(12).TextColor(t.TextMuted) + } + for _, item := range items { + ui.Column(c).Gap(1).Children(func() { + ui.Text(c, item[0]).FontSize(12) + ui.Text(c, item[1]).FontSize(11).TextColor(t.TextMuted) + }) + } + }) + } + var triggers, workflows, actions [][2]string + for _, trigger := range manifest.Triggers { + triggers = append(triggers, [2]string{trigger.When, "When " + trigger.Trigger + "."}) + } + for _, workflow := range manifest.Workflows { + workflows = append(workflows, [2]string{workflow.Name, workflow.Summary}) + } + for _, action := range manifest.Actions { + actions = append(actions, [2]string{action.Name, action.Summary}) + } + capability("Starts when", "Nothing. This connector cannot start a loop.", triggers) + capability("Workflows", "None. Use a custom loop.", workflows) + capability("Send to", "Nothing. This connector only reads.", actions) + }) +} + +func (a *app) beginConnect(manifest *Connector) { + flow := &connectFlow{connectorID: manifest.ID, stop: make(chan struct{})} + a.connect = flow + switch manifest.Auth.Kind { + case "token": + flow.kind = "token" + flow.values = map[string]*string{} + for _, field := range manifest.Auth.Fields { + s := "" + flow.values[field.Key] = &s + } + case "qr_scan": + flow.kind = "qr" + a.startQr(flow) + default: + flow.loading = true + go func() { + var started struct { + Kind string `json:"kind"` + URL string `json:"url"` + AccountLabel string `json:"accountLabel"` + Attempt any `json:"attempt"` + UserCode string `json:"userCode"` + VerificationURL string `json:"verificationUrl"` + IntervalMs int `json:"intervalMs"` + } + err := a.call("beginConnect", map[string]string{"connectorId": manifest.ID}, &started) + a.update(func() { + if a.connect != flow { + return + } + flow.loading = false + if err != nil { + a.toast(errText(err)) + a.connect = nil + return + } + flow.kind = started.Kind + if started.Kind == "connected" { + a.toast("Connected " + started.AccountLabel) + a.connect = nil + a.refresh() + return + } + if started.Kind == "browser" { + _ = mygo.Shell.OpenExternal(started.URL) + flow.hint = "Finish in your browser. This page updates when the account is connected." + go a.watchConnections(flow, manifest.ID) + return + } + flow.userCode, flow.verificationURL, flow.attempt = started.UserCode, started.VerificationURL, started.Attempt + interval := time.Duration(started.IntervalMs) * time.Millisecond + if interval < time.Second { + interval = 5 * time.Second + } + _ = mygo.Shell.OpenExternal(started.VerificationURL) + go a.pollConnect(flow, "continueDeviceLogin", manifest.ID, interval) + }) + }() + } +} + +func (a *app) startQr(flow *connectFlow) { + flow.loading = true + flow.expired, flow.hint, flow.image = "", "", nil + go func() { + var started struct { + Attempt any `json:"attempt"` + Image string `json:"image"` + } + err := a.call("beginQrLogin", map[string]string{"connectorId": flow.connectorID}, &started) + var bitmap *ui.Bitmap + if err == nil { + if data, decodeErr := base64.StdEncoding.DecodeString(started.Image); decodeErr == nil { + bitmap, _ = ui.DecodeBitmap(data) + } + } + a.update(func() { + if a.connect != flow { + return + } + flow.loading = false + if err != nil { + flow.expired = errText(err) + return + } + flow.attempt, flow.image = started.Attempt, bitmap + go a.pollConnect(flow, "continueQrLogin", flow.connectorID, 2*time.Second) + }) + }() +} + +// pollConnect asks every so often whether the code was used, until it +// was, it expired, or the flow was cancelled. +func (a *app) pollConnect(flow *connectFlow, method, connectorID string, every time.Duration) { + stop := flow.stop + for { + select { + case <-stop: + return + case <-time.After(every): + } + var attempt any + done := make(chan bool, 1) + a.update(func() { + attempt = flow.attempt + done <- a.connect == flow && flow.expired == "" + }) + if !<-done { + return + } + var outcome struct { + State string `json:"state"` + Attempt any `json:"attempt"` + Hint string `json:"hint"` + Reason string `json:"reason"` + AccountLabel string `json:"accountLabel"` + } + err := a.call(method, map[string]any{"connectorId": connectorID, "attempt": attempt}, &outcome) + finished := false + a.update(func() { + if a.connect != flow { + finished = true + return + } + if err != nil { + flow.hint = errText(err) + return + } + switch outcome.State { + case "connected": + a.toast("Connected " + outcome.AccountLabel) + a.connect = nil + finished = true + a.refresh() + case "expired": + flow.expired = outcome.Reason + if flow.expired == "" { + flow.expired = "The code expired before it was used." + } + finished = true + default: + flow.attempt, flow.hint = outcome.Attempt, outcome.Hint + } + }) + if finished { + return + } + } +} + +// watchConnections waits for a browser authorization to land, for a few +// minutes, by looking at the connections. +func (a *app) watchConnections(flow *connectFlow, connectorID string) { + stop := flow.stop + before := -1 + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + select { + case <-stop: + return + case <-time.After(2 * time.Second): + } + var overview []ConnectorOverview + if a.call("getConnectorOverview", nil, &overview) != nil { + continue + } + count := 0 + for _, entry := range overview { + if entry.ConnectorID == connectorID { + count = len(entry.Connections) + } + } + if before < 0 { + before = count + continue + } + if count > before { + a.update(func() { + if a.connect == flow { + a.connect = nil + a.toast("Connected") + a.refresh() + } + }) + return + } + } +} + +func (a *app) connectView(c *ui.Context, manifest *Connector) { + t := c.Theme() + flow := a.connect + card(c).Padding(16).Gap(12).Children(func() { + switch flow.kind { + case "token": + if manifest.Auth.Note != "" { + ui.Text(c, manifest.Auth.Note).FontSize(12).TextColor(t.TextMuted) + } + if manifest.Auth.HelpURL != "" { + ui.Link(c, "Open the app dashboard", manifest.Auth.HelpURL).FontSize(12) + } + ui.Form(c, func() { + for _, field := range manifest.Auth.Fields { + field := field + label := field.Label + if !field.Optional { + label += " *" + } + ui.Field(c, label, func() { + input := ui.TextInput(c, flow.values[field.Key]).Placeholder(field.Placeholder).MaxWidth(480) + if field.Secret { + input.Password() + } + }) + } + }) + ui.Row(c).Children(func() { + label := "Connect" + if flow.loading { + label = "Connecting…" + } + if ui.PrimaryButton(c, label).Disabled(flow.loading).Clicked() { + fields := map[string]string{} + for key, value := range flow.values { + fields[key] = strings.TrimSpace(*value) + } + flow.loading = true + go func() { + var result struct { + AccountLabel string `json:"accountLabel"` + } + err := a.call("connectWithToken", map[string]any{"connectorId": manifest.ID, "fields": fields}, &result) + a.update(func() { + flow.loading = false + if err != nil { + a.toast(errText(err)) + return + } + a.toast("Connected " + result.AccountLabel) + if a.connect == flow { + a.connect = nil + } + a.refresh() + }) + }() + } + }) + case "device": + ui.Text(c, "Enter this code on GitHub to connect the account Loopable should watch and answer as.").FontSize(12).TextColor(t.TextMuted) + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, flow.userCode).Font("monospace").FontSize(26).Bold().Selectable().Padding(8, 14).Radius(8).Background(t.Background).Border(1, t.Border) + if ui.Button(c, "Copy code").Clicked() { + mygo.Clipboard.WriteText(flow.userCode) + c.Toast("Copied") + } + if ui.PrimaryButton(c, "Open GitHub").Clicked() { + _ = mygo.Shell.OpenExternal(flow.verificationURL) + } + }) + a.connectStatus(c, flow) + case "qr": + if manifest.Auth.Note != "" { + ui.Text(c, manifest.Auth.Note).FontSize(12).TextColor(t.TextMuted) + } + ui.Box(c).Size(240, 240).Radius(8).Background(ui.Hex("#ffffff")).Border(1, t.Border).Center().AlignSelf(ui.Center).Children(func() { + if flow.image != nil && flow.expired == "" { + ui.Image(c, flow.image).Size(232, 232) + } else if flow.expired == "" { + ui.Spinner(c) + } + }) + a.connectStatus(c, flow) + ui.Row(c).Justify(ui.Center).Children(func() { + label := "Start over" + if flow.expired != "" { + label = "New code" + } + if ui.Button(c, label).Disabled(flow.loading).Clicked() { + a.startQr(flow) + } + }) + default: + ui.Row(c).Gap(10).AlignItems(ui.Center).Children(func() { + ui.Spinner(c) + text := flow.hint + if text == "" { + text = "Starting…" + } + ui.Text(c, text).FontSize(12).TextColor(t.TextMuted) + }) + } + }) +} + +func (a *app) connectStatus(c *ui.Context, flow *connectFlow) { + t := c.Theme() + if flow.expired != "" { + alert(c, "Not connected", []string{flow.expired}, true) + return + } + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Spinner(c) + text := flow.hint + if text == "" { + text = "Waiting…" + } + ui.Text(c, text).FontSize(12).TextColor(t.TextMuted) + }) +} + +func (a *app) connectionRow(c *ui.Context, manifest *Connector, connection *ConnectionView) { + t := c.Theme() + busy := a.busy["connection:"+connection.ID] + broken := connection.Status == "needs_reauth" || connection.Status == "error" + ui.Column(c).Key(connection.ID).Gap(12).Padding(12, 16).Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Avatar(c, connection.AccountLabel, nil).Size(32, 32) + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, connection.AccountLabel).FontSize(13).Bold().SingleLine() + statusBadge(c, connection.Status) + }) + parts := []string{"Not checked yet"} + if connection.LastSyncedAt != nil { + parts[0] = "Checked " + ago(*connection.LastSyncedAt) + } + if len(connection.Scopes) > 0 { + parts = append(parts, strings.Join(connection.Scopes, ", ")) + } + ui.Text(c, strings.Join(parts, " · ")).FontSize(11).TextColor(t.TextMuted).SingleLine() + }) + if broken && manifest.Auth.Kind == "oauth_redirect" { + if ui.PrimaryButton(c, "Reconnect").Disabled(busy || a.connect != nil).Clicked() { + a.beginConnect(manifest) + } + } + if ui.Button(c, "Verify").Disabled(busy).Clicked() { + id := connection.ID + a.run("connection:"+id, func() (string, error) { + var updated ConnectionView + if err := a.call("verifyConnection", map[string]string{"id": id}, &updated); err != nil { + return "", err + } + if updated.Status == "connected" { + return updated.AccountLabel + " is reachable", nil + } + if updated.LastError != nil { + return "", errorString(*updated.LastError) + } + return "", errorString("This connection is not usable") + }) + } + if ui.Button(c, "Disconnect").Disabled(busy).Clicked() { + a.confirm = confirmState{ + open: true, kind: "connection", id: connection.ID, name: connection.AccountLabel, + title: "Disconnect " + connection.AccountLabel + "?", body: "Loops that use it stop until another is connected.", + } + } + }) + if connection.LastError != nil && *connection.LastError != "" { + ui.Text(c, *connection.LastError).FontSize(11).TextColor(t.Danger).Selectable() + } + if len(manifest.Settings) > 0 { + key := "connection:" + connection.ID + values := a.fields[key] + if values == nil { + values = newFieldValues(manifest.Settings, connection.Settings) + a.fields[key] = values + } + ui.Form(c, func() { values.render(c) }) + ui.Row(c).Children(func() { + if ui.Button(c, "Save settings").Disabled(busy).Clicked() { + id := connection.ID + settings := values.values() + a.run("connection:"+id, func() (string, error) { + if err := a.call("saveConnectionSettings", map[string]any{"id": id, "settings": settings}, nil); err != nil { + return "", err + } + return "Settings saved", nil + }) + } + }) + } + }) +} + +type errorString string + +func (e errorString) Error() string { return string(e) } diff --git a/desktop/engine.go b/desktop/engine.go new file mode 100644 index 0000000..084d457 --- /dev/null +++ b/desktop/engine.go @@ -0,0 +1,360 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/egoist/mygo" +) + +// Engine is the Node process that does everything: the dispatcher, the +// runner and the JSON API the screens call. The app starts it, keeps it +// running, and stops it on quit. +type Engine struct { + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + port int + token string + state string // starting, running, stopped, failed + detail string // why, when failed + node nodeInfo + lines []string + stopping bool + restarts int + onChange func() + client *http.Client +} + +// OAuthApps are Loopable's own GitHub and Google apps, built into the app +// by scripts/build-engine.mjs as oauth-apps.json beside the engine. Client +// IDs only, and Google's desktop "secret": the app is a public client. +// oauth.json in the app's data folder can stand in for them while +// developing; nobody using the app sees either. +type OAuthApps struct { + GitHub struct { + ClientID string `json:"clientId"` + } `json:"github"` + Gmail struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } `json:"gmail"` +} + +func newEngine(onChange func()) *Engine { + b := make([]byte, 24) + _, _ = rand.Read(b) + return &Engine{ + token: hex.EncodeToString(b), + state: "starting", + onChange: onChange, + client: &http.Client{Timeout: 10 * time.Minute}, + } +} + +func userDataDir() string { + dir, err := mygo.App.Path(mygo.PathUserData) + if err != nil || dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, "Library", "Application Support", "Loopable") + } + _ = os.MkdirAll(dir, 0o755) + return dir +} + +func oauthFile() string { return filepath.Join(userDataDir(), "oauth.json") } + +func readOAuthApps() OAuthApps { + var built, local OAuthApps + if data, err := os.ReadFile(filepath.Join(engineDir(), "oauth-apps.json")); err == nil { + _ = json.Unmarshal(data, &built) + } + if data, err := os.ReadFile(oauthFile()); err == nil { + _ = json.Unmarshal(data, &local) + } + if local.GitHub.ClientID != "" { + built.GitHub = local.GitHub + } + if local.Gmail.ClientID != "" { + built.Gmail = local.Gmail + } + return built +} + +// engineDir is where engine.mjs lives: beside the app's resources, or +// wherever LOOPABLE_ENGINE_DIR points while developing. +func engineDir() string { + if dir := os.Getenv("LOOPABLE_ENGINE_DIR"); dir != "" { + return dir + } + if dir, err := mygo.App.Path(mygo.PathResources); err == nil { + return filepath.Join(dir, "engine") + } + return "resources/engine" +} + +func (e *Engine) snapshot() (state, detail string, port int, node nodeInfo, lines []string) { + e.mu.Lock() + defer e.mu.Unlock() + return e.state, e.detail, e.port, e.node, append([]string(nil), e.lines...) +} + +func (e *Engine) set(state, detail string) { + if debug { + fmt.Fprintln(os.Stderr, "engine state:", state, detail) + } + e.mu.Lock() + e.state, e.detail = state, detail + e.mu.Unlock() + if e.onChange != nil { + e.onChange() + } +} + +func (e *Engine) log(line string) { + if debug { + fmt.Fprintln(os.Stderr, "engine:", line) + } + e.mu.Lock() + e.lines = append(e.lines, line) + if len(e.lines) > 300 { + e.lines = e.lines[len(e.lines)-300:] + } + e.mu.Unlock() +} + +// Start runs the engine, and runs it again when it dies, until Stop. +func (e *Engine) Start() { + go func() { + for { + e.mu.Lock() + stopping := e.stopping + e.mu.Unlock() + if stopping { + return + } + err := e.runOnce() + e.mu.Lock() + stopping = e.stopping + e.mu.Unlock() + if stopping { + e.set("stopped", "") + return + } + if err != nil { + var fatal fatalError + if errors.As(err, &fatal) { + e.set("failed", err.Error()) + return + } + e.log("engine: " + err.Error()) + } + e.restarts++ + wait := time.Duration(min(e.restarts, 5)) * 2 * time.Second + e.set("starting", fmt.Sprintf("Starting again in %s…", wait)) + time.Sleep(wait) + } + }() +} + +type fatalError struct{ error } + +func (e *Engine) runOnce() error { + dir := engineDir() + script := filepath.Join(dir, "engine.mjs") + if _, err := os.Stat(script); err != nil { + return fatalError{fmt.Errorf("The engine is missing at %s.", script)} + } + path := enginePath() + node, err := findNode(path) + if err != nil { + return fatalError{err} + } + e.mu.Lock() + e.node = node + e.mu.Unlock() + + apps := readOAuthApps() + env := []string{ + "PATH=" + path, + "HOME=" + os.Getenv("HOME"), + "USER=" + os.Getenv("USER"), + "SHELL=" + os.Getenv("SHELL"), + "LANG=" + os.Getenv("LANG"), + "TMPDIR=" + os.Getenv("TMPDIR"), + "LOOPABLE_DESKTOP=1", + "LOOPABLE_DESKTOP_TOKEN=" + e.token, + "LOOPABLE_HOME=" + filepath.Join(userDataDir(), "data"), + "LOOPABLE_SECRET_PREFIX=loopable-desktop", + "LOOPABLE_GITHUB_CLIENT_ID=" + apps.GitHub.ClientID, + "LOOPABLE_GMAIL_CLIENT_ID=" + apps.Gmail.ClientID, + "LOOPABLE_GMAIL_CLIENT_SECRET=" + apps.Gmail.ClientSecret, + } + cmd := exec.Command(node.Path, script) + cmd.Dir = dir + cmd.Env = env + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return fatalError{fmt.Errorf("Node could not start: %v", err)} + } + e.mu.Lock() + e.cmd, e.stdin, e.port = cmd, stdin, 0 + e.mu.Unlock() + e.set("starting", "Starting the engine…") + + go func() { + scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + e.log(scanner.Text()) + } + }() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + var event struct { + Event string `json:"event"` + Port int `json:"port"` + Owner string `json:"owner"` + } + if json.Unmarshal(scanner.Bytes(), &event) != nil { + continue + } + switch event.Event { + case "listening": + e.mu.Lock() + e.port = event.Port + e.restarts = 0 + e.mu.Unlock() + e.set("running", "") + case "busy": + e.log("another Loopable is using this data folder: " + event.Owner) + } + } + err = cmd.Wait() + e.mu.Lock() + e.cmd, e.stdin, e.port = nil, nil, 0 + e.mu.Unlock() + if err != nil { + return fmt.Errorf("the engine stopped: %v", err) + } + return errors.New("the engine stopped") +} + +// Stop ends the engine, for quitting; Restart ends it and lets Start's +// loop bring it back. +func (e *Engine) Stop() { + e.mu.Lock() + e.stopping = true + cmd, stdin := e.cmd, e.stdin + e.mu.Unlock() + e.kill(cmd, stdin) +} + +func (e *Engine) Restart() { + e.mu.Lock() + cmd, stdin := e.cmd, e.stdin + e.restarts = 0 + e.mu.Unlock() + e.set("starting", "Restarting…") + e.kill(cmd, stdin) +} + +func (e *Engine) kill(cmd *exec.Cmd, stdin io.WriteCloser) { + if cmd == nil || cmd.Process == nil { + return + } + if stdin != nil { + _ = stdin.Close() + } + _ = cmd.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { + _, _ = cmd.Process.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(8 * time.Second): + _ = cmd.Process.Kill() + } +} + +// debug echoes the engine to this process's stderr, for a terminal run. +var debug = os.Getenv("LOOPABLE_DEBUG") == "1" + +var errNotRunning = errors.New("The engine is not running yet.") + +// Call asks the engine for one thing, by the name it is known by in +// src/desktop/api.ts, and reads the answer into out. +func (e *Engine) Call(name string, in any, out any) error { + return e.CallContext(context.Background(), name, in, out) +} + +func (e *Engine) CallContext(ctx context.Context, name string, in any, out any) error { + e.mu.Lock() + port, token := e.port, e.token + e.mu.Unlock() + if port == 0 { + return errNotRunning + } + body, err := json.Marshal(in) + if err != nil { + return err + } + if in == nil { + body = []byte("null") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/rpc/%s", port, name), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + res, err := e.client.Do(req) + if err != nil { + return errors.New("The engine did not answer: " + err.Error()) + } + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + var failure struct { + Error string `json:"error"` + } + if json.Unmarshal(data, &failure) == nil && failure.Error != "" { + return errors.New(failure.Error) + } + return fmt.Errorf("%s: %s", name, strings.TrimSpace(string(data))) + } + if out == nil || string(data) == "null" { + return nil + } + return json.Unmarshal(data, out) +} diff --git a/desktop/engine_test.go b/desktop/engine_test.go new file mode 100644 index 0000000..2ba5161 --- /dev/null +++ b/desktop/engine_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// A real engine in a throwaway data folder, for the screens to load from. +// Skipped where there is no Node or no built engine. +func startTestEngine(t *testing.T) *Engine { + t.Helper() + dir, _ := filepath.Abs("resources/engine") + if _, err := os.Stat(filepath.Join(dir, "engine.mjs")); err != nil { + t.Skip("no engine built; run node scripts/build-engine.mjs") + } + node, err := findNode(enginePath()) + if err != nil { + t.Skip(err) + } + e := newEngine(nil) + home := t.TempDir() + cmd := exec.Command(node.Path, filepath.Join(dir, "engine.mjs")) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "LOOPABLE_DESKTOP=1", "LOOPABLE_DESKTOP_TOKEN="+e.token, "LOOPABLE_HOME="+home, + "LOOPABLE_KEYCHAIN=0", "LOOPABLE_SECRET_PREFIX=loopable-desktop-test", + ) + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = stdin.Close() + _ = cmd.Process.Signal(os.Interrupt) + _, _ = cmd.Process.Wait() + }) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + var event struct { + Event string `json:"event"` + Port int `json:"port"` + } + if json.Unmarshal(scanner.Bytes(), &event) == nil && event.Event == "listening" { + e.port = event.Port + e.state = "running" + break + } + } + if e.port == 0 { + t.Fatal("the engine did not start") + } + // The runner joins a moment after the engine listens, once it has + // looked for the agents; the pages are about what it found. + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + var s Status + if e.Call("getStatus", nil, &s) == nil && len(s.Runners) == 1 { + return e + } + time.Sleep(200 * time.Millisecond) + } + t.Fatal("the runner did not join") + return nil +} + +func TestScreensWithARealEngine(t *testing.T) { + a := newApp() + a.engine = startTestEngine(t) + a.settings.loaded = true + + load := func(path string) { + a.router.Push(path) + a.enter(path) + a.refresh() + deadline := time.Now().Add(30 * time.Second) + for a.refreshing.Load() && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + } + load("/inbox") + if a.m == nil { + t.Fatal("the manifests did not load") + } + if len(a.m.Connectors) < 5 || len(a.m.Agents) < 3 { + t.Fatalf("manifests: %d connectors, %d agents", len(a.m.Connectors), len(a.m.Agents)) + } + if a.m.workflow("github", "github.review") == nil && len(a.m.connector("github").Workflows) == 0 { + t.Fatalf("GitHub should offer workflows") + } + + // Make a loop through the form, as a person would, on the clock, which + // needs no account. + load("/loops/new") + tt := ui.NewTester(a.view, 1040, 800) + shots := os.Getenv("LOOPABLE_SHOTS") + if !tt.HasText("Custom loop") { + t.Fatalf("the new loop page should offer a custom loop: %q", tt.Texts()) + } + schedule := a.m.connector("schedule") + if schedule == nil || len(schedule.Workflows) == 0 { + t.Fatal("the clock should offer a workflow") + } + workflow := schedule.Workflows[0] + load("/loops/new/schedule/" + workflow.ID) + tt = ui.NewTester(a.view, 1040, 900) + form := a.forms["new:schedule/"+workflow.ID] + if form == nil { + t.Fatal("no form") + } + form.name = "Test loop from the desktop" + form.folder = "code/web" + a.saveLoop(form, a.m.workflow("schedule", workflow.ID)) + deadline := time.Now().Add(20 * time.Second) + for form.saving && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if form.saving { + t.Fatal("saving did not finish") + } + load(a.router.Path()) + if a.loopPage.loop == nil || a.loopPage.loop.Name != "Test loop from the desktop" { + t.Fatalf("the saved loop should be open: %+v at %s (toasts %q)", a.loopPage.loop, a.router.Path(), a.toasts) + } + tt = ui.NewTester(a.view, 1040, 800) + for range 40 { + tt.Frame() + } + if !tt.HasText("Test loop from the desktop") { + t.Fatalf("the loop page should show the loop: %q", tt.Texts()) + } + save(t, tt, shots, "live-loop") + + load("/loops") + tt = ui.NewTester(a.view, 1040, 700) + for range 40 { + tt.Frame() + } + if !tt.HasText("Test loop from the desktop") { + t.Fatalf("the loops page should list the loop: %q", tt.Texts()) + } + save(t, tt, shots, "live-loops") + + load("/agents") + tt = ui.NewTester(a.view, 1040, 700) + for range 40 { + tt.Frame() + } + if len(a.agents) < 3 { + t.Fatalf("agents: %d", len(a.agents)) + } + save(t, tt, shots, "live-agents") + + load("/connectors") + tt = ui.NewTester(a.view, 1040, 700) + for range 40 { + tt.Frame() + } + if !tt.HasText("GitHub") || !tt.HasText("Slack") { + t.Fatalf("connectors: %q", tt.Texts()) + } + save(t, tt, shots, "live-connectors") + + load("/settings") + tt = ui.NewTester(a.view, 1040, 760) + for range 40 { + tt.Frame() + } + if a.status == nil || len(a.status.Runners) != 1 { + t.Fatalf("status: %+v", a.status) + } + save(t, tt, shots, "live-settings") +} + +func save(t *testing.T, tt *ui.Tester, dir, name string) { + t.Helper() + if dir == "" { + return + } + _ = os.MkdirAll(dir, 0o755) + f, err := os.Create(filepath.Join(dir, name+".png")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := encodePNG(f, tt.Image()); err != nil { + t.Fatal(err) + } +} diff --git a/desktop/go.mod b/desktop/go.mod new file mode 100644 index 0000000..f76dd7f --- /dev/null +++ b/desktop/go.mod @@ -0,0 +1,13 @@ +module desktop + +go 1.27.2 + +tool github.com/egoist/mygo/cmd/mygo + +require github.com/egoist/mygo v0.4.8 + +require ( + github.com/ebitengine/purego v0.11.1 // indirect + github.com/go-text/typesetting v0.3.5 // indirect + golang.org/x/image v0.46.0 // indirect +) diff --git a/desktop/go.sum b/desktop/go.sum new file mode 100644 index 0000000..4b19db0 --- /dev/null +++ b/desktop/go.sum @@ -0,0 +1,10 @@ +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/egoist/mygo v0.4.8 h1:NpzFFiv8L4uXhkriknAehoucN//gtCtnGgr/DzQxZIw= +github.com/egoist/mygo v0.4.8/go.mod h1:+B7ZTBwVfa/nL/6+o6Vm8xBFYwJoFCg4VJbY9k6T0E8= +github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= +github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= +github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= +github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= diff --git a/desktop/icons.go b/desktop/icons.go new file mode 100644 index 0000000..34e2d3a --- /dev/null +++ b/desktop/icons.go @@ -0,0 +1,75 @@ +package main + +import ( + _ "embed" + + "github.com/egoist/mygo/ui" +) + +// Line icons in the style of Lucide, drawn with the text color. +func icon(paths string) *ui.SVG { + return ui.MustParseSVG([]byte(`` + paths + ``)) +} + +var ( + iconInbox = icon(``) + iconLoops = icon(``) + iconConnectors = icon(``) + iconAgents = icon(``) + iconSettings = icon(``) + iconPlay = icon(``) + iconMore = icon(``) + iconCheck = icon(``) + iconX = icon(``) + iconMinus = icon(``) + iconClock = icon(``) + iconFile = icon(``) + iconBot = iconAgents + iconAlert = icon(``) + iconPencil = icon(``) + iconRefresh = icon(``) + iconStop = icon(``) + iconOpen = icon(``) + iconPlus = icon(``) + iconChevron = icon(``) + + // Loopable's own mark, as LoopableMark in src/components/brand.tsx draws it. + iconLoopable = ui.MustParseSVG([]byte(``)) +) + +// connectorTile draws a connector's or an agent's logo on a plain tile, +// size DIPs square. The schedule is a clock, being the thing itself, and +// anything without a logo is a plug. +func connectorTile(c *ui.Context, id string, size float32, dim bool) ui.Element { + t := c.Theme() + box := ui.Box(c).Size(size, size).Radius(size/4).Border(1, t.Border).Center().Shrink(0) + if dim { + box.Opacity(0.5) + } + if m, ok := marks[id]; ok { + face := ui.Hex("#ffffff") + if t.Dark { + face = ui.Hex("#171717") + } + box.Background(face).TextColor(t.Text).Children(func() { + ui.Image(c, m).Size(size*0.55, size*0.55) + }) + return box + } + glyph := iconConnectors + if id == "schedule" { + glyph = iconClock + } + box.Background(t.SurfaceHover).Children(func() { + ui.Icon(c, glyph).FontSize(size * 0.55).TextColor(t.TextMuted) + }) + return box +} + +// trayPNG is the mark in black on transparent, 22 points at 2x, which macOS +// tints to suit the menu bar. +// +//go:embed tray.png +var trayPNG []byte + +func trayIcon() []byte { return trayPNG } diff --git a/desktop/inbox.go b/desktop/inbox.go new file mode 100644 index 0000000..e85aa66 --- /dev/null +++ b/desktop/inbox.go @@ -0,0 +1,316 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +var kindLabel = map[string]string{ + "pull_request": "Pull request", "issue": "Issue", "message": "Message", + "email": "Email", "occurrence": "Scheduled run", "prompt": "Test run", +} + +type stateMeta struct { + label string + icon *ui.SVG + tone string // muted, success, danger + spins bool +} + +var states = map[string]stateMeta{ + "queued": {"Queued", iconClock, "muted", false}, + "preparing": {"Working", nil, "muted", true}, + "awaiting_agent": {"Running", nil, "muted", true}, + "applying": {"Writing", nil, "muted", true}, + "prepared": {"Prepared", iconFile, "muted", false}, + "done": {"Written", iconCheck, "success", false}, + "skipped": {"Nothing to say", iconMinus, "muted", false}, + "failed": {"Failed", iconX, "danger", false}, + "cancelled": {"Stopped", iconStop, "muted", false}, +} + +func stateLabel(c *ui.Context, state string) { + t := c.Theme() + meta, ok := states[state] + if !ok { + meta = stateMeta{label: state, tone: "muted"} + } + color := t.TextMuted + switch meta.tone { + case "success": + color = t.Success + case "danger": + color = t.Danger + } + ui.Row(c).Gap(5).AlignItems(ui.Center).Shrink(0).Children(func() { + if meta.spins { + ui.Spinner(c).FontSize(12).TextColor(color) + } else if meta.icon != nil { + ui.Icon(c, meta.icon).FontSize(13).TextColor(color) + } + ui.Text(c, meta.label).FontSize(12).TextColor(color) + }) +} + +func (a *app) inboxPage(c *ui.Context) { + a.header(c, "", "", "Inbox", "Every run, newest first.", "", func() { + if ui.Button(c, "Ask an agent").Clicked() { + a.ask.open = true + } + }) + a.taskList(c, a.tasks, true, "Nothing has run yet.") + a.askDialog(c) +} + +func (a *app) taskList(c *ui.Context, tasks []TaskView, showLoop bool, emptyText string) { + t := c.Theme() + if len(tasks) == 0 { + empty(c, emptyText, nil) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for i := range tasks { + task := &tasks[i] + row := ui.ButtonBase(c).Key(task.ID).Row().Gap(12).AlignItems(ui.Start).Padding(12, 16).Label(task.Title()) + if row.Hovered() { + row.Background(t.SurfaceHover) + } + row.Children(func() { + if task.SourceKind == "prompt" { + ui.Box(c).Size(28, 28).Radius(7).Background(t.Background).Border(1, t.Border).Center().Shrink(0).Children(func() { + ui.Icon(c, iconBot).FontSize(15).TextColor(t.TextMuted) + }) + } else { + connectorTile(c, task.ConnectorID, 28, false) + } + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, task.Title()).FontSize(13).Bold().Grow(1).SingleLine() + if task.DryRun { + ui.Badge(c, "Dry run") + } + stateLabel(c, task.State) + }) + ui.Text(c, a.taskMeta(task, showLoop)).FontSize(11).TextColor(t.TextMuted).SingleLine() + if task.Output != nil && *task.Output != "" { + ui.Text(c, firstLine(*task.Output)).FontSize(11).TextColor(t.TextMuted).SingleLine() + } else if task.Error != nil && *task.Error != "" { + ui.Text(c, firstLine(*task.Error)).FontSize(11).TextColor(t.Danger).SingleLine() + } + }) + }) + if row.Clicked() { + a.goTo("/inbox/" + task.ID) + } + } + }) +} + +func (a *app) taskMeta(task *TaskView, showLoop bool) string { + var parts []string + if (task.SourceKind == "pull_request" || task.SourceKind == "issue") && task.SourceTitle != nil { + parts = append(parts, task.SourceRef) + } + if showLoop && task.LoopName != "" { + parts = append(parts, task.LoopName) + } + if task.AgentID != nil { + parts = append(parts, a.m.agentName(*task.AgentID)) + } + parts = append(parts, ago(task.CreatedAt)) + return strings.Join(parts, " · ") +} + +// askDialog asks an agent something once. The reply stays in Inbox. +func (a *app) askDialog(c *ui.Context) { + t := c.Theme() + ui.Modal(c, &a.ask.open, func() { + ui.Column(c).Gap(12).Width(520).Children(func() { + ui.Text(c, "Ask an agent").FontSize(16).Bold() + ui.Text(c, "Runs once. The reply stays in Inbox.").FontSize(12).TextColor(t.TextMuted) + ui.TextArea(c, &a.ask.prompt).Lines(5, 12).Placeholder("What should the agent do?").Label("Prompt").AutoFocus() + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + if len(a.runnable) == 0 { + ui.Text(c, "No agent is signed in.").FontSize(12).TextColor(t.TextMuted) + } else { + names := make([]string, len(a.runnable)) + for i, agent := range a.runnable { + names[i] = agent.Name + } + if !contains(names, a.ask.agent) { + a.ask.agent = names[0] + } + ui.Select(c, &a.ask.agent, names).Label("Agent") + } + ui.Spacer(c) + if ui.Button(c, "Cancel").Clicked() { + a.ask.open = false + } + can := strings.TrimSpace(a.ask.prompt) != "" && len(a.runnable) > 0 && !a.ask.running + if ui.PrimaryButton(c, "Run").Disabled(!can).Clicked() || (can && c.Shortcut(ui.Cmd, ui.KeyEnter)) { + a.askRun() + } + }) + }) + }) +} + +func (a *app) askRun() { + agentID := "" + for _, agent := range a.runnable { + if agent.Name == a.ask.agent { + agentID = agent.AgentID + } + } + prompt := a.ask.prompt + a.ask.running = true + go func() { + err := a.call("runPrompt", map[string]string{"prompt": prompt, "agentId": agentID}, nil) + a.update(func() { + a.ask.running = false + if err != nil { + a.toast(errText(err)) + return + } + a.ask.prompt = "" + a.ask.open = false + a.toast("Queued") + a.refresh() + }) + }() +} + +func (a *app) taskPage(c *ui.Context, id string) { + t := c.Theme() + task := a.task.task + if task == nil || task.ID != id { + a.header(c, "/inbox", "Inbox", "Run", "", "", nil) + ui.Spinner(c) + return + } + live := task.Active() + a.header(c, "/inbox", "Inbox", task.Title(), a.taskMeta(task, true), "", func() { + if task.DryRun { + ui.Badge(c, "Dry run") + } + stateLabel(c, task.State) + if live { + asked := task.CancelRequested || a.busy["stop:"+task.ID] + label := "Stop" + if asked { + label = "Stopping" + } + if ui.Button(c, label).Disabled(asked).Clicked() { + a.run("stop:"+task.ID, func() (string, error) { + return "", a.call("stopTask", map[string]string{"id": task.ID}, nil) + }) + } + } + }) + if task.SourceURL != "" && task.SourceKind != "prompt" { + ui.Link(c, task.SourceRef, task.SourceURL).FontSize(12) + } + if task.LoopID != nil { + ui.Link(c, task.LoopName, "/loops/"+*task.LoopID).FontSize(12) + } + + if task.Error != nil && *task.Error != "" { + title := "Will try again" + switch task.State { + case "failed": + title = "Failed" + case "cancelled": + title = "Stopped" + } + alert(c, title, []string{*task.Error}, task.State == "failed") + } + + if task.Output != nil && *task.Output != "" { + card(c).Padding(16).Gap(12).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + title := "Result" + switch task.State { + case "done": + title = "Written" + case "skipped": + title = "Nothing to write" + } + ui.Text(c, title).FontSize(14).Bold().Grow(1) + if task.ResultURL != nil && *task.ResultURL != "" { + if ui.Button(c, "Open in "+a.m.writerName(task.ActionConnectorID)).Clicked() { + _ = mygo.Shell.OpenExternal(*task.ResultURL) + } + } + }) + ui.Text(c, *task.Output).FontSize(13).LineHeight(1.5).Selectable() + if len(task.Comments) > 0 { + ui.Divider(c) + ui.Textf(c, "%d line comments", len(task.Comments)).FontSize(11).TextColor(t.TextMuted) + for _, comment := range task.Comments { + where := fmt.Sprintf("%s:%d", comment.Path, comment.Line) + if comment.StartLine != nil { + where = fmt.Sprintf("%s:%d-%d", comment.Path, *comment.StartLine, comment.Line) + } + ui.Column(c).Gap(4).Children(func() { + ui.Text(c, where).Font("monospace").FontSize(11).TextColor(t.TextMuted) + ui.Text(c, comment.Body).FontSize(13).Selectable().Padding(0, 0, 0, 10).BorderWidth(0, 0, 0, 2).BorderColor(t.Border) + }) + } + } + }) + } else if task.State == "skipped" { + ui.Text(c, "The agent had nothing to say, so nothing was written.").FontSize(13).TextColor(t.TextMuted) + } + + if a.task.log != "" || live { + key := "log:" + task.ID + if _, set := a.open[key]; !set { + a.open[key] = live || task.State == "failed" + } + open := a.open[key] + ui.Collapsible(c, "Agent log", &open, func() { + ui.Scroll(c).MaxHeight(360).Radius(8).Background(t.Surface).Border(1, t.Border).Children(func() { + text := a.task.log + if text == "" { + text = "Waiting for the agent…" + } + ui.Text(c, text).Font("monospace").FontSize(11).LineHeight(1.4).Selectable().Padding(10) + }) + }) + a.open[key] = open + } + + details := a.open["details:"+task.ID] + ui.Collapsible(c, "Details", &details, func() { + ui.Column(c).Gap(8).Padding(8, 0, 0).Children(func() { + row := func(label, value string) { + ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() { + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).Width(90).Shrink(0) + ui.Text(c, value).FontSize(12).Selectable().Grow(1) + }) + } + if task.SourceKind != "prompt" { + row("Writes", a.m.destinationLabel(task.ActionConnectorID, task.ActionID)) + } + row("Queued", localTime(task.CreatedAt)) + if task.DurationMs != nil && *task.DurationMs > 0 { + row("Took", fmt.Sprintf("%.1fs", *task.DurationMs/1000)) + } + if task.Attempts > 1 { + row("Attempts", itoa(task.Attempts)) + } + if task.Prompt != nil && *task.Prompt != "" { + ui.Text(c, "Prompt").FontSize(12).TextColor(t.TextMuted) + ui.Scroll(c).MaxHeight(260).Children(func() { mono(c, *task.Prompt) }) + } + if task.AgentCommand != nil && *task.AgentCommand != "" { + ui.Text(c, "Command").FontSize(12).TextColor(t.TextMuted) + mono(c, *task.AgentCommand) + } + }) + }) + a.open["details:"+task.ID] = details +} diff --git a/desktop/loopform.go b/desktop/loopform.go new file mode 100644 index 0000000..71ce2e2 --- /dev/null +++ b/desktop/loopform.go @@ -0,0 +1,419 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +const defaultAgent = "Default agent" + +var pollChoices = []struct { + ms float64 + label string +}{ + {0, "Every couple of minutes"}, + {60_000, "Every minute"}, + {10 * 60_000, "Every 10 minutes"}, + {30 * 60_000, "Every 30 minutes"}, + {60 * 60_000, "Every hour"}, + {4 * 60 * 60_000, "Every 4 hours"}, +} + +var runsIn = map[string]string{ + "checkout": "A fresh clone of the repository", + "folder": "The folder this loop names", + "temp": "A scratch folder with only what it was given", +} + +var answers = map[string]string{ + "review": "A summary, plus comments on lines", + "code": "A change to the code", + "text": "One block of text", +} + +// fieldValues holds what a connector's generic fields are being edited to: +// one renderer for connection settings and loop conditions alike. +type fieldValues struct { + fields []SettingField + strs map[string]*string // text, string_list (newline-joined), select (label) + bools map[string]*bool +} + +func newFieldValues(fields []SettingField, stored map[string]any) *fieldValues { + v := &fieldValues{fields: fields, strs: map[string]*string{}, bools: map[string]*bool{}} + for _, f := range fields { + current := stored[f.Key] + switch f.Kind { + case "boolean": + b, ok := current.(bool) + if !ok { + b, _ = f.Default.(bool) + } + v.bools[f.Key] = &b + case "string_list": + var lines []string + if list, ok := current.([]any); ok { + for _, item := range list { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + } + s := strings.Join(lines, "\n") + v.strs[f.Key] = &s + case "select": + value, ok := current.(string) + if !ok { + value, _ = f.Default.(string) + } + label := value + for _, option := range f.Options { + if option.Value == value { + label = option.Label + } + } + v.strs[f.Key] = &label + default: + s, _ := current.(string) + v.strs[f.Key] = &s + } + } + return v +} + +func (v *fieldValues) values() map[string]any { + out := map[string]any{} + for _, f := range v.fields { + switch f.Kind { + case "boolean": + out[f.Key] = *v.bools[f.Key] + case "string_list": + lines := []string{} + for _, line := range strings.Split(*v.strs[f.Key], "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + out[f.Key] = lines + case "select": + label := *v.strs[f.Key] + value := label + for _, option := range f.Options { + if option.Label == label { + value = option.Value + } + } + out[f.Key] = value + default: + out[f.Key] = *v.strs[f.Key] + } + } + return out +} + +func (v *fieldValues) render(c *ui.Context) { + for _, f := range v.fields { + f := f + field := ui.Field(c, f.Label, func() { + switch f.Kind { + case "boolean": + ui.Switch(c, v.bools[f.Key]) + case "string_list": + ui.TextArea(c, v.strs[f.Key]).Lines(3, 8).Placeholder(f.Placeholder).MaxWidth(480) + case "select": + labels := make([]string, len(f.Options)) + for i, option := range f.Options { + labels[i] = option.Label + } + ui.Select(c, v.strs[f.Key], labels).MaxWidth(320) + default: + ui.TextInput(c, v.strs[f.Key]).Placeholder(f.Placeholder).MaxWidth(480) + } + }) + if f.Help != "" { + field.Description(f.Help) + } + } +} + +type loopForm struct { + key string + edit *LoopEdit + fresh bool + updatedAt string + + name, prompt, folder, mcp string + agent string + poll string + settings *fieldValues + actionConnectorID string + actionID string + target *fieldValues + writer, action string + written bool + saving bool + details bool +} + +func (a *app) newLoopForm(key string, edit *LoopEdit) *loopForm { + w := a.m.workflow(edit.ConnectorID, edit.WorkflowID) + f := &loopForm{key: key, edit: edit, fresh: edit.ID == nil, name: edit.Name, prompt: edit.Prompt} + if folder, ok := edit.Settings["folder"].(string); ok { + f.folder = strings.TrimPrefix(folder, "~/") + } + f.agent = defaultAgent + if edit.AgentID != nil { + f.agent = a.m.agentName(*edit.AgentID) + } + f.mcp = strings.Join(edit.McpServers, ", ") + var settings []SettingField + if w != nil { + settings = w.Settings + } + f.settings = newFieldValues(settings, edit.Settings) + ms := 0.0 + if edit.PollEveryMs != nil { + ms = *edit.PollEveryMs + } + f.poll = pollLabel(ms) + f.actionConnectorID, f.actionID = edit.ActionConnectorID, edit.ActionID + f.writer = a.m.writerName(edit.ActionConnectorID) + if action := a.m.action(edit.ActionConnectorID, edit.ActionID); action != nil { + f.action = action.Name + f.target = newFieldValues(action.Target, edit.ActionTarget) + } else { + f.target = newFieldValues(nil, nil) + } + f.written = strings.TrimSpace(edit.Prompt) != "" + return f +} + +func pollLabel(ms float64) string { + for _, choice := range pollChoices { + if choice.ms == ms { + return choice.label + } + } + return fmt.Sprintf("Every %d minutes", int(ms/60_000+0.5)) +} + +func (a *app) chooseDestination(f *loopForm, connectorID, actionID string) { + f.actionConnectorID, f.actionID = connectorID, actionID + f.writer = a.m.writerName(connectorID) + if action := a.m.action(connectorID, actionID); action != nil { + f.action = action.Name + f.target = newFieldValues(action.Target, nil) + } else { + f.action = "" + f.target = newFieldValues(nil, nil) + } +} + +func (a *app) loopFormView(c *ui.Context, f *loopForm) { + t := c.Theme() + edit := f.edit + w := a.m.workflow(edit.ConnectorID, edit.WorkflowID) + sourceName := a.m.connectorName(edit.ConnectorID) + if w == nil { + alert(c, "This workflow is no longer offered", []string{ + fmt.Sprintf("%s no longer has %s, so this loop cannot run or be edited. You can still delete it.", sourceName, edit.WorkflowID), + }, true) + return + } + action := a.m.action(f.actionConnectorID, f.actionID) + + section(c, "When", "When "+w.Trigger+".", func() { + ui.Form(c, func() { + ui.Field(c, "Name", func() { ui.TextInput(c, &f.name).MaxWidth(480) }) + f.settings.render(c) + if edit.ConnectorID != "schedule" { + labels := make([]string, len(pollChoices)) + for i, choice := range pollChoices { + labels[i] = choice.label + } + if !contains(labels, f.poll) { + labels = append(labels, f.poll) + } + ui.Field(c, "Check", func() { ui.Select(c, &f.poll, labels).MaxWidth(320) }) + } + }) + ui.Collapsible(c, "Fixed by the trigger", &f.details, func() { + ui.Column(c).Gap(6).Padding(10).Radius(8).Background(t.Surface).Children(func() { + row := func(label, value string) { + ui.Row(c).Gap(10).Children(func() { + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).Width(90).Shrink(0) + ui.Text(c, value).FontSize(12).Selectable() + }) + } + if w.Watches != "" { + row("Looks for", w.Watches) + } + runs := w.RunsIn + if runs == "" { + runs = "temp" + } + row("Runs in", runsIn[runs]) + row("Answer", answers[w.Answer]) + }) + }) + }) + + section(c, "Agent", "", func() { + ui.Form(c, func() { + names := []string{defaultAgent} + for _, agent := range a.m.Agents { + names = append(names, agent.Name) + } + ui.Field(c, "Run with", func() { ui.Select(c, &f.agent, names).MaxWidth(320) }) + ui.Field(c, "MCP servers", func() { + ui.TextInput(c, &f.mcp).Placeholder("None").Font("monospace").MaxWidth(420) + }).Description("Servers the agent may call without asking, named as on this Mac, such as sentry. Others are refused.") + if w.RunsIn == "folder" { + ui.Field(c, "Folder", func() { + ui.Row(c).Gap(4).AlignItems(ui.Center).MaxWidth(420).Children(func() { + ui.Text(c, "~/").Font("monospace").FontSize(13).TextColor(t.TextMuted) + ui.TextInput(c, &f.folder).Placeholder("code/web").Font("monospace").Grow(1) + }) + }).Description("Inside your home folder. The agent works here.") + } + field := ui.Field(c, "Prompt", func() { + ui.TextArea(c, &f.prompt).Lines(8, 24).Font("monospace").FontSize(12).Placeholder("What should the agent do?") + }) + if f.written { + field.Description(fmt.Sprintf("Starts as “%s”. Changes stay on this loop.", w.Name)) + } + }) + }) + + section(c, "Answer", "", func() { + ui.Form(c, func() { + var writerNames []string + writers := a.m.writers() + for _, writer := range writers { + writerNames = append(writerNames, a.m.writerName(writer.ID)) + } + ui.Field(c, "Send to", func() { + if ui.Select(c, &f.writer, writerNames).MaxWidth(320).Changed() { + for _, writer := range writers { + if a.m.writerName(writer.ID) == f.writer && writer.ID != f.actionConnectorID && len(writer.Actions) > 0 { + a.chooseDestination(f, writer.ID, writer.Actions[0].ID) + } + } + } + }) + if !isInboxOnly(f.actionConnectorID, f.actionID) { + var actionNames []string + if connector := a.m.connector(f.actionConnectorID); connector != nil { + for _, entry := range connector.Actions { + actionNames = append(actionNames, entry.Name) + } + } + ui.Field(c, "As", func() { + if ui.Select(c, &f.action, actionNames).MaxWidth(320).Changed() { + if connector := a.m.connector(f.actionConnectorID); connector != nil { + for _, entry := range connector.Actions { + if entry.Name == f.action && entry.ID != f.actionID { + a.chooseDestination(f, f.actionConnectorID, entry.ID) + } + } + } + } + }) + } + f.target.render(c) + }) + crosses := action != nil && f.actionConnectorID != edit.ConnectorID + flattens := action != nil && w.Answer == "review" && !contains(action.Accepts, "review") + if crosses && !isInboxOnly(f.actionConnectorID, f.actionID) { + ui.Text(c, sourceName+" and "+a.m.writerName(f.actionConnectorID)+" both need a connection.").FontSize(11).TextColor(t.TextMuted) + } else if flattens { + ui.Text(c, "Line comments arrive as text, with file and line written in.").FontSize(11).TextColor(t.TextMuted) + } + }) + + ui.Row(c).Gap(8).PaddingY(8).BorderWidth(1, 0, 0, 0).BorderColor(t.Border).Children(func() { + label := "Save changes" + if f.fresh { + label = "Add loop" + } + if f.saving { + label = "Saving…" + } + if ui.PrimaryButton(c, label).Disabled(f.saving).Clicked() { + a.saveLoop(f, w) + } + if ui.Button(c, "Cancel").Disabled(f.saving).Clicked() { + delete(a.forms, f.key) + if f.fresh { + a.goTo("/loops/new") + } else { + a.refresh() + } + } + }) +} + +func (a *app) saveLoop(f *loopForm, w *Workflow) { + edit := *f.edit + edit.Name = strings.TrimSpace(f.name) + edit.Prompt = f.prompt + edit.Guidance = nil + edit.AgentID = nil + if f.agent != defaultAgent { + for _, agent := range a.m.Agents { + if agent.Name == f.agent { + id := agent.ID + edit.AgentID = &id + } + } + } + edit.McpServers = []string{} + for _, server := range strings.FieldsFunc(f.mcp, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) { + if server != "" { + edit.McpServers = append(edit.McpServers, server) + } + } + edit.Settings = f.settings.values() + if w.RunsIn == "folder" { + edit.Settings["folder"] = strings.TrimSpace(f.folder) + } + edit.ActionConnectorID, edit.ActionID = f.actionConnectorID, f.actionID + edit.ActionTarget = f.target.values() + edit.PollEveryMs = nil + for _, choice := range pollChoices { + if choice.label == f.poll && choice.ms > 0 { + ms := choice.ms + edit.PollEveryMs = &ms + } + } + if edit.PollEveryMs == nil && strings.HasPrefix(f.poll, "Every ") && strings.HasSuffix(f.poll, " minutes") { + if n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(f.poll, "Every "), " minutes")); err == nil && n > 0 { + ms := float64(n) * 60_000 + edit.PollEveryMs = &ms + } + } + f.saving = true + fresh := f.fresh + key := f.key + go func() { + var saved LoopView + err := a.call("saveLoop", edit, &saved) + a.update(func() { + f.saving = false + if err != nil { + a.toast(errText(err)) + return + } + delete(a.forms, key) + if fresh { + a.toast("Added " + saved.Name) + } else { + a.toast("Saved " + saved.Name) + } + a.goTo("/loops/" + saved.ID) + }) + }() +} diff --git a/desktop/loops.go b/desktop/loops.go new file mode 100644 index 0000000..63b6e62 --- /dev/null +++ b/desktop/loops.go @@ -0,0 +1,560 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" +) + +func (a *app) loopsPage(c *ui.Context) { + t := c.Theme() + a.header(c, "", "", "Loops", "When two loops match the same thing, the higher one runs.", "", func() { + if ui.PrimaryButton(c, "New loop").Clicked() { + a.goTo("/loops/new") + } + }) + if a.readiness != nil { + if gaps := a.gapsFor(a.readiness, a.loops); len(gaps) > 0 { + alert(c, "Nothing will run yet", gaps, false) + } + } + if len(a.loops) == 0 { + empty(c, "No loops yet.", func() { + if ui.Button(c, "New loop").Clicked() { + a.goTo("/loops/new") + } + }) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for i := range a.loops { + a.loopRow(c, i) + } + }) +} + +func (a *app) summaryOf(loop *LoopView) string { + if a.m.workflow(loop.ConnectorID, loop.WorkflowID) == nil { + return "Workflow no longer offered" + } + agent := "Default agent" + if loop.AgentID != nil { + agent = a.m.agentName(*loop.AgentID) + } + return a.m.destinationLabel(loop.ActionConnectorID, loop.ActionID) + " · " + agent +} + +func (a *app) loopRow(c *ui.Context, i int) { + t := c.Theme() + loop := &a.loops[i] + busy := a.busy["loop:"+loop.ID] + var issues []string + if a.readiness != nil { + issues = a.issuesFor(a.readiness, loop) + } + ui.Row(c).Key(loop.ID).Gap(12).AlignItems(ui.Center).Padding(10, 16).Children(func() { + ui.Text(c, itoa(i+1)).FontSize(11).TextColor(t.TextMuted).Width(16).TextAlign(ui.Center).Shrink(0) + connectorTile(c, loop.ConnectorID, 28, !loop.Enabled) + link := ui.ButtonBase(c).Grow(1).Column().AlignItems(ui.Start).Gap(2).Label(loop.Name) + link.Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, loop.Name).FontSize(13).Bold().SingleLine() + if !loop.Enabled { + ui.Badge(c, "Off") + } + }) + if len(issues) > 0 { + ui.Text(c, issues[0]).FontSize(11).TextColor(t.Warning).SingleLine() + } else { + ui.Text(c, a.summaryOf(loop)).FontSize(11).TextColor(t.TextMuted).SingleLine() + } + }) + if link.Clicked() { + a.goTo("/loops/" + loop.ID) + } + on := loop.Enabled + if ui.Switch(c, &on).Disabled(busy).Label("On").Changed() { + a.toggleLoop(loop, on) + } + ui.MenuButton(c, "", func(m *ui.Menu) { + if m.Item("Move up").Disabled(i == 0).Chosen() { + a.moveLoop(loop, "up") + } + if m.Item("Move down").Disabled(i == len(a.loops)-1).Chosen() { + a.moveLoop(loop, "down") + } + m.Separator() + if m.Item("Delete…").Chosen() { + a.askDeleteLoop(loop) + } + }).Label("More").Children(func() { ui.Icon(c, iconMore) }) + }) +} + +func (a *app) toggleLoop(loop *LoopView, on bool) { + id, name := loop.ID, loop.Name + loop.Enabled = on + a.run("loop:"+id, func() (string, error) { + if err := a.call("toggleLoop", map[string]any{"id": id, "enabled": on}, nil); err != nil { + return "", err + } + if on { + return name + " is on", nil + } + return name + " is off", nil + }) +} + +func (a *app) moveLoop(loop *LoopView, direction string) { + id := loop.ID + a.run("loop:"+id, func() (string, error) { + return "", a.call("reorderLoop", map[string]string{"id": id, "direction": direction}, nil) + }) +} + +func (a *app) askDeleteLoop(loop *LoopView) { + a.confirm = confirmState{ + open: true, kind: "loop", id: loop.ID, name: loop.Name, + title: "Delete " + loop.Name + "?", body: "Its runs are deleted with it.", + } +} + +// What stands between loops and running, as src/lib/gaps.ts words it. +func (a *app) wantedAgent(loop *LoopView, r *LoopReadiness) string { + if loop.AgentID != nil { + return *loop.AgentID + } + return deref(r.DefaultAgentID) +} + +func (a *app) runnersFor(loop *LoopView) string { + w := a.m.workflow(loop.ConnectorID, loop.WorkflowID) + if w == nil || w.RunsIn != "folder" { + return "any" + } + if folder, ok := loop.Settings["folder"].(string); ok && strings.HasPrefix(strings.TrimSpace(folder), "/") { + return "host" + } + return "folder" +} + +func (a *app) issuesFor(r *LoopReadiness, loop *LoopView) []string { + if !loop.Enabled { + return nil + } + var issues []string + agentID := a.wantedAgent(loop, r) + needs := a.runnersFor(loop) + pool := r.AvailableAgentIDs + switch needs { + case "host": + pool = r.HostAgentIDs + case "folder": + pool = r.FolderAgentIDs + } + if agentID == "" { + if loop.AgentID != nil { + issues = append(issues, fmt.Sprintf("%s uses %s, which is not signed in on this Mac.", loop.Name, a.m.agentName(*loop.AgentID))) + } else { + issues = append(issues, "No default agent is chosen, so this loop cannot run.") + } + } else if !contains(pool, agentID) { + if needs == "host" { + issues = append(issues, fmt.Sprintf("%s works in an absolute folder from an older version. Edit it and give the folder relative to your home.", loop.Name)) + } else { + issues = append(issues, fmt.Sprintf("%s uses %s, which is not signed in on this Mac.", loop.Name, a.m.agentName(agentID))) + } + } + return issues +} + +func (a *app) gapsFor(r *LoopReadiness, loops []LoopView) []string { + var missing []string + connected := map[string]bool{} + for _, id := range r.ConnectedConnectorIDs { + connected[id] = true + } + if len(connected) == 0 { + missing = append(missing, "Nothing is connected yet, so no signal can arrive.") + } + if len(r.AvailableAgentIDs) == 0 { + missing = append(missing, "No coding agent is signed in on this Mac, so nothing can be prepared.") + } else if r.DefaultAgentID == nil { + missing = append(missing, "No default agent is chosen, so loops that do not name one cannot run.") + } + if len(connected) > 0 { + for i := range loops { + loop := &loops[i] + if !loop.Enabled { + continue + } + if a.m.needsAccount(loop.ConnectorID) && !connected[loop.ConnectorID] { + missing = append(missing, fmt.Sprintf("%s watches %s, which is not connected.", loop.Name, a.m.connectorName(loop.ConnectorID))) + } + if a.m.needsAccount(loop.ActionConnectorID) && !connected[loop.ActionConnectorID] { + missing = append(missing, fmt.Sprintf("%s answers on %s, which is not connected.", loop.Name, a.m.connectorName(loop.ActionConnectorID))) + } + } + } + if len(r.AvailableAgentIDs) > 0 { + for i := range loops { + for _, issue := range a.issuesFor(r, &loops[i]) { + if issue == "No default agent is chosen, so this loop cannot run." { + continue + } + missing = append(missing, issue) + } + } + } + return missing +} + +// connected says whether loops can be made on a connector yet. +func (a *app) connected(id string) bool { + if a.readiness == nil { + return false + } + return contains(a.readiness.ConnectedConnectorIDs, id) +} + +func (a *app) usable(connector *Connector) bool { + return connector.Auth.Kind == "none" || a.connected(connector.ID) +} + +func (a *app) newLoopPage(c *ui.Context, r *ui.Route) { + t := c.Theme() + a.header(c, "/loops", "Loops", "New loop", "", "", nil) + if a.m == nil { + ui.Spinner(c) + return + } + inUse := func(workflowID string) string { + n := 0 + for i := range a.loops { + if a.loops[i].WorkflowID == workflowID { + n++ + } + } + switch n { + case 0: + return "" + case 1: + return "You have 1 loop like this" + } + return fmt.Sprintf("You have %d loops like this", n) + } + choice := func(key string, tileID, title, summary, note string, needs *Connector, target string) { + box := ui.ButtonBase(c).Key(key).Row().Gap(12).AlignItems(ui.Start).Padding(14).Radius(10).Background(t.Surface).Border(1, t.Border).Label(title).Basis(0).Grow(1).MinWidth(300) + if needs == nil && box.Hovered() { + box.Background(t.SurfaceHover) + } + box.Disabled(needs != nil) + box.Children(func() { + if tileID == "" { + ui.Box(c).Size(32, 32).Radius(8).Background(t.Background).Border(1, t.Border).Center().Shrink(0).Children(func() { + ui.Icon(c, iconPencil).FontSize(16) + }) + } else { + connectorTile(c, tileID, 32, needs != nil) + } + ui.Column(c).Grow(1).Gap(4).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + ui.Text(c, summary).FontSize(11).TextColor(t.TextMuted) + if needs != nil { + ui.Link(c, "Connect "+needs.Name+" first", "/connectors/"+needs.ID).FontSize(11) + } else if note != "" { + ui.Text(c, note).FontSize(11).TextColor(t.TextMuted) + } + }) + }) + if box.Clicked() && needs == nil { + a.goTo(target) + } + } + grid := func(fn func()) { + ui.Row(c).Wrap().Gap(12).AlignItems(ui.Stretch).Children(fn) + } + grid(func() { + choice("custom", "", "Custom loop", "Pick a trigger and write your own prompt.", "", nil, "/loops/new/"+a.firstUsableTrigger()+"?custom=1") + for _, connector := range a.m.ordered() { + if connector.ID != "schedule" { + continue + } + for _, w := range connector.Workflows { + choice(connector.ID+"/"+w.ID, connector.ID, w.Name, w.Summary, inUse(w.ID), nil, "/loops/new/"+connector.ID+"/"+w.ID) + } + } + }) + ui.Text(c, "From a connector").FontSize(13).Bold().TextColor(t.TextMuted) + grid(func() { + for _, ready := range []bool{true, false} { + for _, connector := range a.m.ordered() { + if connector.ID == "schedule" || a.usable(connector) != ready { + continue + } + var needs *Connector + if !ready { + needs = connector + } + for _, w := range connector.Workflows { + choice(connector.ID+"/"+w.ID, connector.ID, w.Name, w.Summary, inUse(w.ID), needs, "/loops/new/"+connector.ID+"/"+w.ID) + } + } + } + }) + _ = r +} + +// firstUsableTrigger is where a custom loop starts: the first trigger that +// can actually run. +func (a *app) firstUsableTrigger() string { + var first, usable string + for _, connector := range a.m.ordered() { + for _, trigger := range connector.Triggers { + path := connector.ID + "/" + trigger.ID + if first == "" { + first = path + } + if connector.Auth.Kind != "none" && a.connected(connector.ID) { + return path + } + if usable == "" && a.usable(connector) { + usable = path + } + } + } + if usable != "" { + return usable + } + return first +} + +func (a *app) shapeLoopPage(c *ui.Context, connectorID, workflowID string, custom bool) { + t := c.Theme() + if a.m == nil { + ui.Spinner(c) + return + } + w := a.m.workflow(connectorID, workflowID) + if w == nil { + a.header(c, "/loops/new", "New loop", "Unknown workflow", "", "", nil) + return + } + key := "new:" + connectorID + "/" + workflowID + if custom { + key = "custom:" + connectorID + "/" + workflowID + } + form := a.forms[key] + if form == nil { + draft := a.m.draftFor(connectorID, workflowID) + if custom { + draft.Name, draft.Prompt = "Custom loop", "" + } + form = a.newLoopForm(key, draft) + a.forms[key] = form + } + if custom { + a.header(c, "/loops/new", "New loop", "Custom loop", "Pick what starts it, then say what the agent should do.", "", nil) + // The trigger: changing it starts the form over, since each has its + // own fields and its own place to answer. + var labels []string + var paths []string + current := "" + for _, connector := range a.m.ordered() { + for _, trigger := range connector.Triggers { + label := trigger.When + if !a.usable(connector) { + label += " (connect " + connector.Name + " first)" + } + labels = append(labels, label) + paths = append(paths, connector.ID+"/"+trigger.ID) + if connector.ID == connectorID && trigger.ID == workflowID { + current = label + } + } + } + ui.Field(c, "Starts when", func() { + if ui.Select(c, ¤t, labels).MaxWidth(420).Changed() { + for i, label := range labels { + if label == current && paths[i] != connectorID+"/"+workflowID { + a.goTo("/loops/new/" + paths[i] + "?custom=1") + } + } + } + }) + _ = t + } else { + a.header(c, "/loops/new", "New loop", w.Name, w.Summary, connectorID, nil) + } + a.loopFormView(c, form) +} + +func (a *app) loopDetailPage(c *ui.Context, r *ui.Route) { + t := c.Theme() + id := r.Param("id") + page := &a.loopPage + loop := page.loop + if loop == nil || loop.ID != id || a.m == nil { + a.header(c, "/loops", "Loops", "Loop", "", "", nil) + ui.Spinner(c) + return + } + connector := a.m.connector(loop.ConnectorID) + byHand := "none" + if connector != nil { + byHand = connector.ByHand.Kind + } + busy := a.busy["loop:"+loop.ID] + + summary := []string{a.m.destinationLabel(loop.ActionConnectorID, loop.ActionID)} + if loop.AgentID != nil { + summary = append(summary, a.m.agentName(*loop.AgentID)) + } else { + summary = append(summary, "Default agent") + } + if loop.Enabled && page.poll != nil { + if page.poll.PolledAt != nil { + summary = append(summary, "Checked "+ago(*page.poll.PolledAt)) + } else { + summary = append(summary, "Not checked yet") + } + } + a.header(c, "/loops", "Loops", loop.Name, strings.Join(summary, " · "), loop.ConnectorID, func() { + if byHand == "now" { + if ui.Button(c, "Run now").Disabled(busy).Clicked() { + a.run("loop:"+loop.ID, func() (string, error) { + if err := a.call("runLoopNow", map[string]any{"loopId": loop.ID, "url": "", "dryRun": false}, nil); err != nil { + return "", err + } + return "Queued", nil + }) + } + } else { + if ui.Button(c, "Check now").Disabled(busy || !loop.Enabled).Clicked() { + a.run("loop:"+loop.ID, func() (string, error) { + var report LookReport + if err := a.call("lookLoopNow", map[string]string{"id": loop.ID}, &report); err != nil { + return "", err + } + if report.Error != "" { + return "", fmt.Errorf("%s", report.Error) + } + return "Checked", nil + }) + } + } + on := loop.Enabled + if ui.Switch(c, &on).Disabled(busy).Label("On").Changed() { + a.toggleLoop(loop, on) + } + label := "Off" + if loop.Enabled { + label = "On" + } + ui.Text(c, label).FontSize(12) + }) + + if a.readiness != nil { + if issues := a.issuesFor(a.readiness, loop); len(issues) > 0 { + alert(c, "Cannot run yet", issues, false) + } + } + if loop.Enabled && page.poll != nil && page.poll.PollError != nil && *page.poll.PollError != "" { + alert(c, "The last check failed", []string{*page.poll.PollError}, true) + } + + tasksLabel := "Tasks" + if len(page.tasks) > 0 { + tasksLabel = fmt.Sprintf("Tasks %d", len(page.tasks)) + } + ui.Tabs(c, &page.tab, "Settings", tasksLabel) + + if page.tab == 0 { + key := "edit:" + loop.ID + form := a.forms[key] + if form == nil || form.updatedAt != loop.UpdatedAt { + form = a.newLoopForm(key, editOf(loop)) + form.updatedAt = loop.UpdatedAt + a.forms[key] = form + } + a.loopFormView(c, form) + section(c, "Delete", "", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Its runs are deleted with it.").FontSize(12).TextColor(t.TextMuted).Grow(1) + if ui.Button(c, "Delete loop").TextColor(t.Danger).Clicked() { + a.askDeleteLoop(loop) + } + }) + }) + return + } + + // Try the loop on one link, by hand. + if connector != nil && connector.ByHand.Kind == "link" { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.TextInput(c, &page.tryURL).Placeholder("Try on a link: " + connector.ByHand.Placeholder).Label(connector.Name + " link").Grow(1) + ui.Switch(c, &page.dryRun).Label("Dry run") + ui.Text(c, "Dry run").FontSize(12).TextColor(t.TextMuted) + if ui.PrimaryButton(c, "Run").Disabled(busy || strings.TrimSpace(page.tryURL) == "").Clicked() { + url, dry := strings.TrimSpace(page.tryURL), page.dryRun + a.run("loop:"+loop.ID, func() (string, error) { + if err := a.call("runLoopNow", map[string]any{"loopId": loop.ID, "url": url, "dryRun": dry}, nil); err != nil { + return "", err + } + if dry { + return "Queued as a dry run", nil + } + return "Queued", nil + }) + page.tryURL = "" + } + }) + } + if page.poll != nil && len(page.poll.Backlog) > 0 { + held := 0 + for _, item := range page.poll.Backlog { + if item.Hold != nil { + held++ + } + } + before := len(page.poll.Backlog) - held + var reasons []string + if before > 0 { + verb := "were" + if before == 1 { + verb = "was" + } + reasons = append(reasons, fmt.Sprintf("%d %s open before this loop", before, verb)) + } + if held > 0 { + reasons = append(reasons, fmt.Sprintf("%d held back by a rule", held)) + } + open := a.open["backlog:"+loop.ID] + ui.Collapsible(c, fmt.Sprintf("%d not run · %s", len(page.poll.Backlog), strings.Join(reasons, ", ")), &open, func() { + card(c).Dividers(1, t.Border).Children(func() { + for _, item := range page.poll.Backlog { + item := item + ui.Row(c).Key(item.Key).Gap(12).AlignItems(ui.Center).Padding(8, 16).Children(func() { + ui.Column(c).Grow(1).Children(func() { + ui.Text(c, item.SourceRef+" "+item.SourceTitle).FontSize(12).SingleLine() + if item.Hold != nil { + ui.Text(c, *item.Hold).FontSize(11).TextColor(t.Warning).SingleLine() + } + }) + if ui.Button(c, "Run").Disabled(busy).Clicked() { + a.run("loop:"+loop.ID, func() (string, error) { + if err := a.call("runLoopBacklog", map[string]string{"id": loop.ID, "key": item.Key}, nil); err != nil { + return "", err + } + return "Queued", nil + }) + } + }) + } + }) + }) + a.open["backlog:"+loop.ID] = open + } + a.taskList(c, page.tasks, false, "No runs yet.") +} diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..e8b4bf1 --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,38 @@ +// Loopable for the desktop: the loops, the inbox, the connectors and the +// agents in a native window, with the engine (dispatcher, runner and API) +// running beside it in Node. See ../docs/desktop.md. +package main + +import ( + "log" + + "github.com/egoist/mygo" +) + +func main() { + mygo.App.SetName("Loopable") + if !mygo.App.RequestSingleInstanceLock() { + return + } + a := newApp() + mygo.App.OnSecondInstance(func([]string, string) { a.show() }) + mygo.App.WhenReady(a.ready) + // The window closing leaves the app in the menu bar; the Dock icon + // brings the window back. + mygo.App.OnWindowAllClosed(func() {}) + mygo.App.OnActivate(func(hasVisibleWindows bool) { + if !hasVisibleWindows { + a.show() + } + }) + mygo.App.OnBeforeQuit(func(*mygo.QuitEvent) { a.quitting = true }) + mygo.App.OnWillQuit(func(*mygo.QuitEvent) { + a.engine.Stop() + if a.tray != nil { + a.tray.Destroy() + } + }) + if err := mygo.App.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/desktop/marks.go b/desktop/marks.go new file mode 100644 index 0000000..bfe1b45 --- /dev/null +++ b/desktop/marks.go @@ -0,0 +1,23 @@ +package main + +import "github.com/egoist/mygo/ui" + +// Each product's own logo, in its own colors, as the web app draws them in +// src/components/connector-icon.tsx and copied from there by hand. Service +// marks are from Simple Icons (CC0); Codex, Claude Code and Cursor from +// LobeHub Icons (MIT). GitHub and Cursor are drawn in currentColor, so they +// turn white in the dark. +func mark(svg string) *ui.SVG { return ui.MustParseSVG([]byte(svg)) } + +const slackPath = "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" + +var marks = map[string]*ui.SVG{ + "github": mark(``), + "slack": mark(``), + "feishu": mark(``), + "gmail": mark(``), + "wechat": mark(``), + "codex": mark(``), + "claude": mark(``), + "cursor-agent": mark(``), +} diff --git a/desktop/model.go b/desktop/model.go new file mode 100644 index 0000000..8e3d0b9 --- /dev/null +++ b/desktop/model.go @@ -0,0 +1,517 @@ +package main + +// The engine's answers, as src/lib/domain.ts and the connector manifests +// shape them. Tags match the JSON exactly, since some of these go back. + +type Finding struct { + Path string `json:"path"` + Line int `json:"line"` + StartLine *int `json:"startLine,omitempty"` + Body string `json:"body"` +} + +type TaskView struct { + ID string `json:"id"` + LoopID *string `json:"loopId"` + LoopName string `json:"loopName"` + ConnectorID string `json:"connectorId"` + State string `json:"state"` + SourceURL string `json:"sourceUrl"` + SourceKind string `json:"sourceKind"` + SourceRef string `json:"sourceRef"` + SourceTitle *string `json:"sourceTitle"` + Prompt *string `json:"prompt"` + DryRun bool `json:"dryRun"` + AgentID *string `json:"agentId"` + RunnerID *string `json:"runnerId"` + AgentCommand *string `json:"agentCommand"` + Output *string `json:"output"` + Comments []Finding `json:"comments"` + ActionConnectorID string `json:"actionConnectorId"` + ActionID string `json:"actionId"` + ResultURL *string `json:"resultUrl"` + Error *string `json:"error"` + Attempts int `json:"attempts"` + CancelRequested bool `json:"cancelRequested"` + DurationMs *float64 `json:"durationMs"` + CreatedAt string `json:"createdAt"` + StartedAt *string `json:"startedAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (t *TaskView) Title() string { + if t.SourceTitle != nil && *t.SourceTitle != "" { + return *t.SourceTitle + } + return t.SourceRef +} + +func (t *TaskView) Active() bool { + switch t.State { + case "queued", "preparing", "awaiting_agent", "applying": + return true + } + return false +} + +type LoopView struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + ConnectorID string `json:"connectorId"` + WorkflowID string `json:"workflowId"` + Settings map[string]any `json:"settings"` + Prompt string `json:"prompt"` + Guidance *string `json:"guidance"` + McpServers []string `json:"mcpServers"` + AgentID *string `json:"agentId"` + ActionConnectorID string `json:"actionConnectorId"` + ActionID string `json:"actionId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type LoopEdit struct { + ID *string `json:"id"` + Name string `json:"name"` + ConnectorID string `json:"connectorId"` + WorkflowID string `json:"workflowId"` + Prompt string `json:"prompt"` + Guidance *string `json:"guidance"` + AgentID *string `json:"agentId"` + McpServers []string `json:"mcpServers"` + Settings map[string]any `json:"settings"` + ActionConnectorID string `json:"actionConnectorId"` + ActionID string `json:"actionId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` + Enabled bool `json:"enabled"` +} + +type LoopReadiness struct { + ConnectedConnectorIDs []string `json:"connectedConnectorIds"` + DefaultAgentID *string `json:"defaultAgentId"` + AvailableAgentIDs []string `json:"availableAgentIds"` + HostAgentIDs []string `json:"hostAgentIds"` + FolderAgentIDs []string `json:"folderAgentIds"` +} + +type BacklogItem struct { + Key string `json:"key"` + SourceKind string `json:"sourceKind"` + SourceRef string `json:"sourceRef"` + SourceTitle string `json:"sourceTitle"` + SourceURL string `json:"sourceUrl"` + Hold *string `json:"hold"` + SeenAt string `json:"seenAt"` +} + +type LoopPollState struct { + PolledAt *string `json:"polledAt"` + PollError *string `json:"pollError"` + Backlog []BacklogItem `json:"backlog"` +} + +type LookReport struct { + LoopName string `json:"loopName"` + Found int `json:"found"` + Queued int `json:"queued"` + Error string `json:"error"` +} + +type ConnectionView struct { + ID string `json:"id"` + ConnectorID string `json:"connectorId"` + Status string `json:"status"` + AccountLabel string `json:"accountLabel"` + AccountURL *string `json:"accountUrl"` + AvatarURL *string `json:"avatarUrl"` + Scopes []string `json:"scopes"` + Settings map[string]any `json:"settings"` + LastSyncedAt *string `json:"lastSyncedAt"` + LastError *string `json:"lastError"` + CreatedAt string `json:"createdAt"` +} + +type Readiness struct { + Ready bool `json:"ready"` + Reason string `json:"reason"` + FixHint string `json:"fixHint"` +} + +type ConnectorOverview struct { + ConnectorID string `json:"connectorId"` + Readiness Readiness `json:"readiness"` + Connections []ConnectionView `json:"connections"` +} + +type AgentOnRunner struct { + RunnerID string `json:"runnerId"` + Name string `json:"name"` + Status string `json:"status"` + SignedIn bool `json:"signedIn"` + Version *string `json:"version"` +} + +type AgentSettings struct { + PermissionMode string `json:"permissionMode"` + Model *string `json:"model"` + TimeoutMs float64 `json:"timeoutMs"` +} + +type AgentAuth struct { + SignedIn bool `json:"signedIn"` + Detail string `json:"detail"` +} + +type AgentView struct { + AgentID string `json:"agentId"` + Installed bool `json:"installed"` + BinaryPath *string `json:"binaryPath"` + Version *string `json:"version"` + Auth *AgentAuth `json:"auth"` + Settings AgentSettings `json:"settings"` + IsDefault bool `json:"isDefault"` + DefaultIsImplicit bool `json:"defaultIsImplicit"` + CommandPreview string `json:"commandPreview"` + OnRunners []AgentOnRunner `json:"onRunners"` +} + +type AgentChoice struct { + AgentID string `json:"agentId"` + Name string `json:"name"` +} + +type AgentTest struct { + Passed bool `json:"passed"` + Output string `json:"output"` + Detail *string `json:"detail"` + DurationMs float64 `json:"durationMs"` + Command string `json:"command"` +} + +type InventoryEntry struct { + AgentID string `json:"agentId"` + Installed bool `json:"installed"` + Version *string `json:"version"` + SignedIn bool `json:"signedIn"` + Detail string `json:"detail"` +} + +type RunnerView struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Status string `json:"status"` + Inventory []InventoryEntry `json:"inventory"` + LastSeenAt *string `json:"lastSeenAt"` + Version *string `json:"version"` + Enabled bool `json:"enabled"` +} + +type Status struct { + Version string `json:"version"` + PID int `json:"pid"` + DataDir string `json:"dataDir"` + Node string `json:"node"` + Alive bool `json:"alive"` + Runners []RunnerView `json:"runners"` + Paused bool `json:"paused"` + MaxConcurrentRuns int `json:"maxConcurrentRuns"` +} + +type SelectOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type SettingField struct { + Key string `json:"key"` + Kind string `json:"kind"` + Label string `json:"label"` + Help string `json:"help"` + Placeholder string `json:"placeholder"` + Default any `json:"default"` + Options []SelectOption `json:"options"` +} + +type TokenField struct { + Key string `json:"key"` + Label string `json:"label"` + Placeholder string `json:"placeholder"` + Secret bool `json:"secret"` + Optional bool `json:"optional"` +} + +type AuthDescriptor struct { + Kind string `json:"kind"` + Scopes []string `json:"scopes"` + NeedsAppRegistration bool `json:"needsAppRegistration"` + Note string `json:"note"` + Fields []TokenField `json:"fields"` + HelpURL string `json:"helpUrl"` +} + +type ByHand struct { + Kind string `json:"kind"` + Placeholder string `json:"placeholder"` +} + +type Trigger struct { + ID string `json:"id"` + When string `json:"when"` + Trigger string `json:"trigger"` + Watches string `json:"watches"` + RunsIn string `json:"runsIn"` + Settings []SettingField `json:"settings"` + Answer string `json:"answer"` + ActionID string `json:"actionId"` + ActionConnectorID *string `json:"actionConnectorId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` +} + +// Workflow is a trigger with a workflow merged on, as connectorWorkflow +// returns it: what a loop runs, by the id it stores. +type Workflow struct { + ID string `json:"id"` + TriggerID string `json:"triggerId"` + Name string `json:"name"` + Summary string `json:"summary"` + Prompt string `json:"prompt"` + Custom bool `json:"custom"` + When string `json:"when"` + Trigger string `json:"trigger"` + Watches string `json:"watches"` + RunsIn string `json:"runsIn"` + Settings []SettingField `json:"settings"` + Answer string `json:"answer"` + ActionID string `json:"actionId"` + ActionConnectorID *string `json:"actionConnectorId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` +} + +type Action struct { + ID string `json:"id"` + Name string `json:"name"` + Summary string `json:"summary"` + Target []SettingField `json:"target"` + Accepts []string `json:"accepts"` +} + +type Connector struct { + ID string `json:"id"` + Name string `json:"name"` + Tagline string `json:"tagline"` + DocsURL *string `json:"docsUrl"` + Icon string `json:"icon"` + Auth AuthDescriptor `json:"auth"` + ByHand ByHand `json:"byHand"` + Settings []SettingField `json:"settings"` + AllowsMultipleAccounts bool `json:"allowsMultipleAccounts"` + Triggers []Trigger `json:"triggers"` + Workflows []Workflow `json:"workflows"` + Actions []Action `json:"actions"` +} + +type AgentManifest struct { + ID string `json:"id"` + Name string `json:"name"` + Tagline string `json:"tagline"` + DocsURL *string `json:"docsUrl"` + Icon string `json:"icon"` + Binaries []string `json:"binaries"` + InstallHint string `json:"installHint"` + PermissionModes []string `json:"permissionModes"` + SupportsModel bool `json:"supportsModel"` + ModelPlaceholder string `json:"modelPlaceholder"` + Defaults AgentSettings `json:"defaults"` +} + +type Manifests struct { + Connectors []Connector `json:"connectors"` + Agents []AgentManifest `json:"agents"` +} + +const inboxConnector, inboxAction = "schedule", "schedule.record" + +func (m *Manifests) connector(id string) *Connector { + if m == nil { + return nil + } + for i := range m.Connectors { + if m.Connectors[i].ID == id { + return &m.Connectors[i] + } + } + return nil +} + +func (m *Manifests) agent(id string) *AgentManifest { + if m == nil { + return nil + } + for i := range m.Agents { + if m.Agents[i].ID == id { + return &m.Agents[i] + } + } + return nil +} + +func (m *Manifests) agentName(id string) string { + if a := m.agent(id); a != nil { + return a.Name + } + return id +} + +func (m *Manifests) connectorName(id string) string { + if c := m.connector(id); c != nil { + return c.Name + } + return id +} + +// workflow is what a loop runs: a workflow, or a trigger alone for a custom loop. +func (m *Manifests) workflow(connectorID, workflowID string) *Workflow { + c := m.connector(connectorID) + if c == nil { + return nil + } + for i := range c.Workflows { + if c.Workflows[i].ID == workflowID { + return &c.Workflows[i] + } + } + for _, t := range c.Triggers { + if t.ID == workflowID { + w := customOn(t) + return &w + } + } + return nil +} + +func customOn(t Trigger) Workflow { + return Workflow{ + ID: t.ID, TriggerID: t.ID, Name: t.When, Summary: "When " + t.Trigger + ".", Custom: true, + When: t.When, Trigger: t.Trigger, Watches: t.Watches, RunsIn: t.RunsIn, Settings: t.Settings, + Answer: t.Answer, ActionID: t.ActionID, ActionConnectorID: t.ActionConnectorID, + ActionTarget: t.ActionTarget, PollEveryMs: t.PollEveryMs, + } +} + +func (m *Manifests) action(connectorID, actionID string) *Action { + c := m.connector(connectorID) + if c == nil { + return nil + } + for i := range c.Actions { + if c.Actions[i].ID == actionID { + return &c.Actions[i] + } + } + return nil +} + +func isInboxOnly(connectorID, actionID string) bool { + return connectorID == inboxConnector && actionID == inboxAction +} + +func (m *Manifests) writerName(connectorID string) string { + if connectorID == inboxConnector { + return "Inbox" + } + return m.connectorName(connectorID) +} + +func (m *Manifests) destinationLabel(connectorID, actionID string) string { + if isInboxOnly(connectorID, actionID) { + return "Inbox only" + } + if a := m.action(connectorID, actionID); a != nil { + return m.writerName(connectorID) + " · " + a.Name + } + return "No destination" +} + +// writers are the connectors that can write, Inbox first. +func (m *Manifests) writers() []*Connector { + var out []*Connector + for i := range m.Connectors { + if m.Connectors[i].ID == inboxConnector && len(m.Connectors[i].Actions) > 0 { + out = append(out, &m.Connectors[i]) + } + } + for i := range m.Connectors { + if m.Connectors[i].ID != inboxConnector && len(m.Connectors[i].Actions) > 0 { + out = append(out, &m.Connectors[i]) + } + } + return out +} + +func (m *Manifests) needsAccount(id string) bool { + c := m.connector(id) + return c != nil && c.Auth.Kind != "none" +} + +func (m *Manifests) connectionNoun(id string) string { + if c := m.connector(id); c != nil && c.Auth.Kind == "token" { + return "bot" + } + return "account" +} + +// ordered lists the connectors the clock first, as the new loop page does. +func (m *Manifests) ordered() []*Connector { + var out []*Connector + for i := range m.Connectors { + if m.Connectors[i].ID == "schedule" { + out = append(out, &m.Connectors[i]) + } + } + for i := range m.Connectors { + if m.Connectors[i].ID != "schedule" { + out = append(out, &m.Connectors[i]) + } + } + return out +} + +// draftFor is draftForWorkflow: one of a connector's workflows as a loop +// nobody has saved yet. +func (m *Manifests) draftFor(connectorID, workflowID string) *LoopEdit { + w := m.workflow(connectorID, workflowID) + if w == nil { + return nil + } + actionConnector := connectorID + if w.ActionConnectorID != nil { + actionConnector = *w.ActionConnectorID + } + target := w.ActionTarget + if target == nil { + target = map[string]any{} + } + return &LoopEdit{ + Name: w.Name, ConnectorID: connectorID, WorkflowID: workflowID, Prompt: w.Prompt, + McpServers: []string{}, Settings: map[string]any{}, ActionConnectorID: actionConnector, + ActionID: w.ActionID, ActionTarget: target, PollEveryMs: w.PollEveryMs, Enabled: true, + } +} + +func editOf(l *LoopView) *LoopEdit { + id := l.ID + return &LoopEdit{ + ID: &id, Name: l.Name, ConnectorID: l.ConnectorID, WorkflowID: l.WorkflowID, Prompt: l.Prompt, + Guidance: l.Guidance, AgentID: l.AgentID, McpServers: l.McpServers, Settings: l.Settings, + ActionConnectorID: l.ActionConnectorID, ActionID: l.ActionID, ActionTarget: l.ActionTarget, + PollEveryMs: l.PollEveryMs, Enabled: l.Enabled, + } +} diff --git a/desktop/mygo.json b/desktop/mygo.json new file mode 100644 index 0000000..95f19d0 --- /dev/null +++ b/desktop/mygo.json @@ -0,0 +1,13 @@ +{ + "name": "Loopable", + "identifier": "si.goodone.loopable", + "version": "0.1.0", + "copyright": "© 2026 Loopable", + "out": "build", + "macos": { + "minimumSystemVersion": "13.0", + "infoPlist": { + "LSApplicationCategoryType": "public.app-category.developer-tools" + } + } +} diff --git a/desktop/node.go b/desktop/node.go new file mode 100644 index 0000000..906101c --- /dev/null +++ b/desktop/node.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// A GUI app on macOS starts with almost no PATH, while Node and the agent +// CLIs live wherever a shell put them. The login shell knows; it is asked +// once, and the usual places are tried without it. + +const minNode = 22 + +func homeDirs() []string { + home, _ := os.UserHomeDir() + dirs := []string{ + "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", + filepath.Join(home, ".local/bin"), filepath.Join(home, ".bun/bin"), + filepath.Join(home, ".cargo/bin"), filepath.Join(home, ".volta/bin"), + filepath.Join(home, ".deno/bin"), filepath.Join(home, "n/bin"), + } + // Version managers keep every Node apart. Their installs, not fnm's + // per-shell folders, which are links to the same installs and of which + // a machine gathers thousands. + for _, pattern := range []string{ + filepath.Join(home, ".nvm/versions/node/*/bin"), + filepath.Join(home, ".local/share/fnm/node-versions/*/installation/bin"), + filepath.Join(home, "Library/Application Support/fnm/node-versions/*/installation/bin"), + filepath.Join(home, ".asdf/installs/nodejs/*/bin"), + "/opt/homebrew/opt/node@*/bin", + } { + matches, _ := filepath.Glob(pattern) + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + dirs = append(dirs, matches...) + } + return dirs +} + +// loginPath asks the login shell for its PATH, with a short patience. +func loginPath() string { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/zsh" + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, shell, "-lc", `printf %s "$PATH"`).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// enginePath is the PATH the engine runs with: the login shell's, then the +// usual places, then whatever this process had. +func enginePath() string { + seen := map[string]bool{} + var dirs []string + add := func(list []string) { + for _, d := range list { + if d == "" || seen[d] { + continue + } + seen[d] = true + dirs = append(dirs, d) + } + } + add(strings.Split(loginPath(), ":")) + add(homeDirs()) + add(strings.Split(os.Getenv("PATH"), ":")) + return strings.Join(dirs, ":") +} + +type nodeInfo struct { + Path string + Version string + Major int +} + +// findNode takes the first Node along path new enough for the engine, the +// one a terminal would run, asking each candidate its version only until +// one will do. A Node reached by two paths is asked once. +func findNode(path string) (nodeInfo, error) { + seen := map[string]bool{} + var firstErr error + for _, dir := range strings.Split(path, ":") { + if dir == "" || strings.Contains(dir, "fnm_multishells") { + continue + } + candidate := filepath.Join(dir, "node") + real, err := filepath.EvalSymlinks(candidate) + if err != nil || seen[real] { + continue + } + seen[real] = true + version, err := nodeVersion(candidate) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if major := majorOf(version); major >= minNode { + return nodeInfo{Path: candidate, Version: version, Major: major}, nil + } + } + if firstErr != nil { + return nodeInfo{}, firstErr + } + return nodeInfo{}, errNoNode +} + +type nodeError string + +func (e nodeError) Error() string { return string(e) } + +const errNoNode = nodeError("Node.js 22 or newer was not found. Install it from nodejs.org or with Homebrew, then restart Loopable.") + +func nodeVersion(bin string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "--version").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func majorOf(version string) int { + v := strings.TrimPrefix(version, "v") + if i := strings.Index(v, "."); i > 0 { + v = v[:i] + } + n, _ := strconv.Atoi(v) + return n +} diff --git a/desktop/node_test.go b/desktop/node_test.go new file mode 100644 index 0000000..9a63f0d --- /dev/null +++ b/desktop/node_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + "time" +) + +// The app should find Node in well under a second, however many folders a +// version manager has left behind. +func TestFindNodeIsQuick(t *testing.T) { + start := time.Now() + path := enginePath() + node, err := findNode(path) + took := time.Since(start) + if err != nil { + t.Skip(err) + } + t.Logf("found %s (%s) in %s", node.Path, node.Version, took) + if took > 3*time.Second { + t.Fatalf("finding Node took %s", took) + } +} diff --git a/desktop/render_test.go b/desktop/render_test.go new file mode 100644 index 0000000..783888a --- /dev/null +++ b/desktop/render_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/egoist/mygo/ui" +) + +func ptr[T any](v T) *T { return &v } + +// fixture is enough of a workspace to draw every page. +func fixture() *app { + a := newApp() + a.engine.state = "running" + a.engine.port = 1 + a.m = &Manifests{ + Connectors: []Connector{ + { + ID: "github", Name: "GitHub", Tagline: "Reviews and replies on your repositories.", Icon: "Github", + Auth: AuthDescriptor{Kind: "oauth_redirect", Scopes: []string{"repo"}, Note: "Connect the account Loopable should answer as."}, + ByHand: ByHand{Kind: "link", Placeholder: "https://github.com/acme/web/pull/7"}, + AllowsMultipleAccounts: true, + Settings: []SettingField{{Key: "repositories", Kind: "string_list", Label: "Repositories", Help: "owner/name, one per line."}}, + Triggers: []Trigger{{ID: "github.review_requested", When: "A review is requested", Trigger: "someone asks for your review", Watches: "is:pr review-requested:@me", RunsIn: "checkout", Answer: "review", ActionID: "github.submit_review", + Settings: []SettingField{{Key: "drafts", Kind: "boolean", Label: "Include drafts", Default: false}}}}, + Workflows: []Workflow{{ID: "github.review", TriggerID: "github.review_requested", Name: "Review pull requests", Summary: "Review each pull request you are asked to.", Prompt: "Review this pull request.", When: "A review is requested", Trigger: "someone asks for your review", Watches: "is:pr review-requested:@me", RunsIn: "checkout", Answer: "review", ActionID: "github.submit_review", + Settings: []SettingField{{Key: "drafts", Kind: "boolean", Label: "Include drafts", Default: false}}}}, + Actions: []Action{{ID: "github.submit_review", Name: "Submit a review", Summary: "A review on the pull request.", Accepts: []string{"review", "text"}}}, + }, + { + ID: "schedule", Name: "Schedule", Tagline: "Run a loop when the time comes round.", Icon: "Clock", + Auth: AuthDescriptor{Kind: "none"}, ByHand: ByHand{Kind: "now"}, + Triggers: []Trigger{{ID: "schedule.recurring", When: "On a schedule", Trigger: "the time you set comes round", RunsIn: "folder", Answer: "text", ActionID: "schedule.record", + Settings: []SettingField{{Key: "every", Kind: "select", Label: "How often", Options: []SelectOption{{"hour", "Every hour"}, {"day", "Every day"}}, Default: "day"}, {Key: "at", Kind: "text", Label: "At", Help: "24-hour time, in UTC.", Placeholder: "09:00"}}}}, + Workflows: []Workflow{{ID: "schedule.daily", TriggerID: "schedule.recurring", Name: "Daily check", Summary: "Look over the folder every morning.", Prompt: "Look around.", When: "On a schedule", Trigger: "the time you set comes round", RunsIn: "folder", Answer: "text", ActionID: "schedule.record", + Settings: []SettingField{{Key: "every", Kind: "select", Label: "How often", Options: []SelectOption{{"hour", "Every hour"}, {"day", "Every day"}}, Default: "day"}, {Key: "at", Kind: "text", Label: "At", Help: "24-hour time, in UTC.", Placeholder: "09:00"}}}}, + Actions: []Action{{ID: "schedule.record", Name: "Keep in Inbox", Summary: "The answer stays here.", Accepts: []string{"text"}}}, + }, + {ID: "slack", Name: "Slack", Tagline: "A bot in your Slack.", Auth: AuthDescriptor{Kind: "token", Fields: []TokenField{{Key: "botToken", Label: "Bot User OAuth Token", Placeholder: "xoxb-…", Secret: true}}, Note: "Paste the bot token.", HelpURL: "https://api.slack.com/apps"}, + Triggers: []Trigger{{ID: "slack.mention", When: "The bot is mentioned", Trigger: "someone mentions the bot", RunsIn: "folder", Answer: "text", ActionID: "slack.reply"}}, + Workflows: []Workflow{{ID: "slack.answer", TriggerID: "slack.mention", Name: "Answer questions", Summary: "Answer in the thread.", Prompt: "Answer.", When: "The bot is mentioned", Trigger: "someone mentions the bot", RunsIn: "folder", Answer: "text", ActionID: "slack.reply"}}, + Actions: []Action{{ID: "slack.reply", Name: "Reply in Slack", Summary: "In the thread.", Accepts: []string{"text"}}}}, + }, + Agents: []AgentManifest{ + {ID: "codex", Name: "Codex", InstallHint: "npm install -g @openai/codex", PermissionModes: []string{"read_only", "workspace_write"}, SupportsModel: true, ModelPlaceholder: "gpt-5", Defaults: AgentSettings{PermissionMode: "read_only", TimeoutMs: 600000}}, + {ID: "claude", Name: "Claude Code", InstallHint: "npm install -g @anthropic-ai/claude-code", PermissionModes: []string{"read_only", "workspace_write"}, SupportsModel: true, Defaults: AgentSettings{PermissionMode: "read_only", TimeoutMs: 600000}}, + }, + } + a.loops = []LoopView{ + {ID: "l1", Name: "Review pull requests", Enabled: true, ConnectorID: "github", WorkflowID: "github.review", Prompt: "Review this pull request.", Settings: map[string]any{"drafts": false}, ActionConnectorID: "github", ActionID: "github.submit_review", ActionTarget: map[string]any{}, UpdatedAt: "2026-10-13T10:00:00Z"}, + {ID: "l2", Name: "Morning look", Enabled: false, ConnectorID: "schedule", WorkflowID: "schedule.daily", Prompt: "Look around.", Settings: map[string]any{"every": "day", "at": "09:00", "folder": "code/web"}, ActionConnectorID: "schedule", ActionID: "schedule.record", ActionTarget: map[string]any{}, AgentID: ptr("claude"), UpdatedAt: "2026-10-13T10:00:00Z"}, + } + a.readiness = &LoopReadiness{ConnectedConnectorIDs: []string{"github"}, DefaultAgentID: ptr("codex"), AvailableAgentIDs: []string{"codex", "claude"}, HostAgentIDs: []string{"codex", "claude"}, FolderAgentIDs: []string{"codex", "claude"}} + a.tasks = []TaskView{ + {ID: "t1", LoopID: ptr("l1"), LoopName: "Review pull requests", ConnectorID: "github", State: "awaiting_agent", SourceKind: "pull_request", SourceRef: "acme/web#7", SourceTitle: ptr("Fix checkout totals"), AgentID: ptr("codex"), ActionConnectorID: "github", ActionID: "github.submit_review", CreatedAt: "2026-10-13T12:00:00Z", UpdatedAt: "2026-10-13T12:00:00Z"}, + {ID: "t2", LoopID: ptr("l1"), LoopName: "Review pull requests", ConnectorID: "github", State: "done", SourceKind: "pull_request", SourceRef: "acme/web#6", SourceTitle: ptr("Add retries to the mailer"), AgentID: ptr("codex"), Output: ptr("Looks good overall. Two things to check before merging:\n\n1. The retry loop never backs off.\n2. The test only covers the happy path."), Comments: []Finding{{Path: "src/mailer.ts", Line: 42, Body: "This retries at once, with no wait between tries."}}, ActionConnectorID: "github", ActionID: "github.submit_review", ResultURL: ptr("https://github.com/acme/web/pull/6#pullrequestreview-1"), DurationMs: ptr(83000.0), CreatedAt: "2026-10-13T09:00:00Z", UpdatedAt: "2026-10-13T09:02:00Z"}, + {ID: "t3", LoopName: "", ConnectorID: "", State: "failed", SourceKind: "prompt", SourceRef: "Ask", SourceTitle: ptr("What changed this week?"), Prompt: ptr("What changed this week?"), AgentID: ptr("claude"), Error: ptr("The agent did not finish: timed out after 600s."), ActionConnectorID: "schedule", ActionID: "schedule.record", Attempts: 2, CreatedAt: "2026-10-12T09:00:00Z", UpdatedAt: "2026-10-12T09:12:00Z"}, + } + a.runnable = []AgentChoice{{"codex", "Codex"}, {"claude", "Claude Code"}} + a.agents = []AgentView{ + {AgentID: "codex", Installed: true, Version: ptr("0.42.0"), Auth: &AgentAuth{SignedIn: true, Detail: "Logged in using ChatGPT"}, Settings: AgentSettings{PermissionMode: "read_only", TimeoutMs: 600000}, IsDefault: true, CommandPreview: "codex exec --sandbox read-only "}, + {AgentID: "claude", Installed: true, Auth: &AgentAuth{SignedIn: false, Detail: "Not signed in"}, Settings: AgentSettings{PermissionMode: "workspace_write", TimeoutMs: 300000}, CommandPreview: "claude -p "}, + } + a.overview = []ConnectorOverview{ + {ConnectorID: "github", Readiness: Readiness{Ready: true}, Connections: []ConnectionView{{ID: "c1", ConnectorID: "github", Status: "connected", AccountLabel: "imsobear", Scopes: []string{"repo", "notifications"}, Settings: map[string]any{"repositories": []any{"acme/web"}}, LastSyncedAt: ptr("2026-10-13T12:00:00Z")}}}, + {ConnectorID: "slack", Readiness: Readiness{Ready: true}}, + {ConnectorID: "schedule", Readiness: Readiness{Ready: true}}, + } + a.status = &Status{Version: "0.8.1", DataDir: "/Users/me/Library/Application Support/Loopable/data", Alive: true, Runners: []RunnerView{{Name: "mac.local", Inventory: []InventoryEntry{{AgentID: "codex", SignedIn: true}}}}} + a.task = taskPage{id: "t2", task: &a.tasks[1], log: "codex: reading the diff…\ncodex: 3 files changed\ncodex: done"} + a.loopPage = loopPage{id: "l2", loop: &a.loops[1], tasks: a.tasks[:1], poll: &LoopPollState{PolledAt: ptr("2026-10-13T12:00:00Z"), Backlog: []BacklogItem{{Key: "b1", SourceRef: "acme/web#3", SourceTitle: "Old one", Hold: ptr("is a draft")}}}, dryRun: true} + a.settings.loaded = true + return a +} + +func shoot(t *testing.T, a *app, name, path string, w, h int) { + t.Helper() + a.router.Push(path) + a.enter(path) + tt := ui.NewTester(a.view, w, h) + for range 40 { + tt.Frame() + } + dir := os.Getenv("LOOPABLE_SHOTS") + if dir == "" { + return + } + _ = os.MkdirAll(dir, 0o755) + f, err := os.Create(filepath.Join(dir, name+".png")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := encodePNG(f, tt.Image()); err != nil { + t.Fatal(err) + } +} + +func encodePNG(f *os.File, img image.Image) error { return png.Encode(f, img) } + +func TestPagesRender(t *testing.T) { + a := fixture() + pages := []struct{ name, path string }{ + {"inbox", "/inbox"}, {"task", "/inbox/t2"}, {"loops", "/loops"}, {"loop-new", "/loops/new"}, + {"loop-shape", "/loops/new/github/github.review"}, {"loop-custom", "/loops/new/schedule/schedule.recurring?custom=1"}, + {"loop-detail", "/loops/l2"}, {"connectors", "/connectors"}, {"connector", "/connectors/github"}, + {"connector-slack", "/connectors/slack"}, {"agents", "/agents"}, {"settings", "/settings"}, + } + for _, page := range pages { + shoot(t, a, page.name, page.path, 1040, 700) + } +} + +func TestInboxOpensATask(t *testing.T) { + a := fixture() + tt := ui.NewTester(a.view, 1040, 700) + if !tt.HasText("Fix checkout totals") { + t.Fatalf("inbox should list the runs: %q", tt.Texts()) + } + if err := tt.Click("Add retries to the mailer"); err != nil { + t.Fatal(err) + } + tt.Frame() + if a.router.Path() != "/inbox/t2" { + t.Fatalf("clicking a run should open it, got %s", a.router.Path()) + } + if !tt.HasText("Written") { + t.Fatalf("the run page should show the result: %q", tt.Texts()) + } +} + +func TestLoopFormSaves(t *testing.T) { + a := fixture() + a.router.Push("/loops/new/github/github.review") + tt := ui.NewTester(a.view, 1040, 900) + if !tt.HasText("Review pull requests") { + t.Fatalf("the form should be titled after the workflow: %q", tt.Texts()) + } + form := a.forms["new:github/github.review"] + if form == nil { + t.Fatal("no form was made") + } + if form.writer != "GitHub" || form.action != "Submit a review" { + t.Fatalf("the destination should start as the workflow's: %q %q", form.writer, form.action) + } +} diff --git a/desktop/resources/icon.png b/desktop/resources/icon.png new file mode 100644 index 0000000..7ae1e92 Binary files /dev/null and b/desktop/resources/icon.png differ diff --git a/desktop/settings.go b/desktop/settings.go new file mode 100644 index 0000000..d7a5fa4 --- /dev/null +++ b/desktop/settings.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +type settingsPage struct { + loaded bool + openAtLogin bool +} + +func (a *app) settingsPage(c *ui.Context) { + t := c.Theme() + s := &a.settings + if !s.loaded { + s.loaded = true + s.openAtLogin = mygo.App.OpenAtLogin() + } + a.header(c, "", "", "Settings", "This Mac runs the loops. Nothing leaves it but what the loops answer.", "", nil) + + state, detail, port, node, lines := a.engine.snapshot() + section(c, "Engine", "The dispatcher and the runner, in Node, started by the app.", func() { + card(c).Padding(14, 16).Gap(10).Children(func() { + row := func(label, value string) { + ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() { + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).Width(110).Shrink(0) + ui.Text(c, value).FontSize(12).Selectable().Grow(1) + }) + } + status := state + switch state { + case "running": + status = "Running" + if a.status != nil && a.status.Paused { + status = "Paused: no new runs start" + } + case "starting": + status = "Starting" + case "failed": + status = "Failed" + case "stopped": + status = "Stopped" + } + if detail != "" { + status += " · " + detail + } + row("Status", status) + if node.Path != "" { + row("Node", fmt.Sprintf("%s (%s)", node.Version, node.Path)) + } + if port != 0 { + row("Port", fmt.Sprintf("127.0.0.1:%d, this app only", port)) + } + if a.status != nil { + row("Version", "engine "+a.status.Version+", app "+mygo.App.Version()) + row("Data", a.status.DataDir) + for _, runner := range a.status.Runners { + var agents []string + for _, entry := range runner.Inventory { + if entry.SignedIn { + agents = append(agents, a.m.agentName(entry.AgentID)) + } + } + if len(agents) == 0 { + agents = []string{"no agent signed in"} + } + row("Runner", runner.Name+" · "+strings.Join(agents, ", ")) + } + } + ui.Row(c).Gap(8).Children(func() { + if a.status != nil { + paused := a.status.Paused + if ui.Switch(c, &paused).Label("Paused").Changed() { + a.status.Paused = paused + a.run("pause", func() (string, error) { + if err := a.call("pauseDispatcher", map[string]bool{"paused": paused}, nil); err != nil { + return "", err + } + if paused { + return "Paused. Loops are watched, but no run starts.", nil + } + return "Running again", nil + }) + } + ui.Text(c, "Pause: watch, but start no runs").FontSize(12).TextColor(t.TextMuted).Grow(1) + } else { + ui.Spacer(c) + } + if ui.Button(c, "Restart engine").Clicked() { + a.engine.Restart() + } + if ui.Button(c, "Open data folder").Clicked() { + _ = mygo.Shell.OpenPath(filepath.Join(userDataDir(), "data")) + } + }) + }) + }) + + section(c, "This Mac", "", func() { + ui.Row(c).Gap(10).AlignItems(ui.Center).Children(func() { + if ui.Switch(c, &s.openAtLogin).Label("Open at login").Changed() { + if err := mygo.App.SetOpenAtLogin(s.openAtLogin); err != nil { + a.toast(errText(err)) + s.openAtLogin = mygo.App.OpenAtLogin() + } + } + ui.Column(c).Gap(1).Children(func() { + ui.Text(c, "Open at login").FontSize(13) + ui.Text(c, "Starts in the menu bar, so loops run from the moment you sign in.").FontSize(11).TextColor(t.TextMuted) + }) + }) + }) + + section(c, "Engine log", "", func() { + text := strings.Join(lines, "\n") + if text == "" { + text = "Nothing yet." + } + ui.Scroll(c).MaxHeight(300).Radius(8).Background(t.Surface).Border(1, t.Border).Children(func() { + ui.Text(c, text).Font("monospace").FontSize(11).LineHeight(1.4).Selectable().Padding(10) + }) + }) +} diff --git a/desktop/shell.go b/desktop/shell.go new file mode 100644 index 0000000..c8e7c3d --- /dev/null +++ b/desktop/shell.go @@ -0,0 +1,290 @@ +package main + +import ( + "strings" + + "github.com/egoist/mygo/ui" +) + +const pageWidth = 820 + +func (a *app) view(c *ui.Context) { + for _, message := range a.toasts { + c.Toast(message) + } + a.toasts = nil + t := c.Theme() + + ui.Row(c).Fill().AlignItems(ui.Stretch).Children(func() { + a.sidebar(c) + ui.Column(c).Grow(1).Background(t.Background).Children(func() { + a.router.View(c, func(r *ui.Route) { + a.enter(r.Path()) + ui.Scroll(c).Fill().Children(func() { + ui.Column(c).Padding(28, 32, 40).Gap(20).MaxWidth(pageWidth).Children(func() { + state, detail, _, _, _ := a.engine.snapshot() + if state != "running" && r.Path() != "/settings" { + a.engineBanner(c, state, detail) + } + switch { + case r.Match("/inbox"): + r.Title("Inbox") + a.inboxPage(c) + case r.Match("/inbox/{id}"): + r.Title("Run") + a.taskPage(c, r.Param("id")) + case r.Match("/loops"): + r.Title("Loops") + a.loopsPage(c) + case r.Match("/loops/new"): + r.Title("New loop") + a.newLoopPage(c, r) + case r.Match("/loops/new/{connector}/{workflow}"): + r.Title("New loop") + a.shapeLoopPage(c, r.Param("connector"), r.Param("workflow"), r.Query("custom") == "1") + case r.Match("/loops/{id}"): + r.Title("Loop") + a.loopDetailPage(c, r) + case r.Match("/connectors"): + r.Title("Connectors") + a.connectorsPage(c) + case r.Match("/connectors/{id}"): + r.Title("Connector") + a.connectorPage(c, r.Param("id")) + case r.Match("/agents"): + r.Title("Agents") + a.agentsPage(c) + case r.Match("/settings"): + r.Title("Settings") + a.settingsPage(c) + default: + ui.Text(c, "Not found") + } + }) + }) + }) + }) + }) + a.confirmDialog(c) +} + +func (a *app) sidebar(c *ui.Context) { + t := c.Theme() + ui.Column(c).Width(196).Background(t.Surface).Shrink(0).BorderWidth(0, 1, 0, 0).BorderColor(t.Border).Children(func() { + ui.Row(c).Padding(18, 20, 10).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Icon(c, iconLoopable).FontSize(18) + ui.Text(c, "Loopable").FontSize(15).Bold() + }) + path := a.router.Path() + page := "/" + strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)[0] + ui.Sidebar(c, &page, func() { + ui.SidebarItem(c, "/inbox", iconInbox, "Inbox").Children(func() { + active := 0 + for i := range a.tasks { + if a.tasks[i].Active() { + active++ + } + } + if active > 0 { + ui.Badge(c, itoa(active)) + } + }) + ui.SidebarItem(c, "/loops", iconLoops, "Loops") + ui.SidebarItem(c, "/connectors", iconConnectors, "Connectors") + ui.SidebarItem(c, "/agents", iconAgents, "Agents") + ui.SidebarItem(c, "/settings", iconSettings, "Settings") + }).Grow(1).Label("Pages").OnChange(func() { + a.goTo(page) + }) + a.engineLine(c) + }) +} + +// engineLine is the one line at the bottom of the sidebar that says whether +// loops are running. +func (a *app) engineLine(c *ui.Context) { + t := c.Theme() + state, detail, _, _, _ := a.engine.snapshot() + label, color := "Engine starting…", t.TextMuted + switch state { + case "running": + label, color = "Running", t.Success + if a.status != nil && a.status.Paused { + label, color = "Paused", t.Warning + } + case "failed": + label, color = "Engine failed", t.Danger + case "stopped": + label, color = "Engine stopped", t.TextMuted + } + row := ui.ButtonBase(c).Padding(10, 20, 14).Gap(8).Row().AlignItems(ui.Center).Label("Engine: " + label).Tooltip(detail) + row.Children(func() { + ui.Box(c).Size(8, 8).Radius(4).Background(color) + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).SingleLine() + }) + if row.Clicked() { + a.goTo("/settings") + } +} + +func (a *app) engineBanner(c *ui.Context, state, detail string) { + t := c.Theme() + title := "The engine is starting" + if state == "failed" { + title = "The engine could not start" + } else if state == "stopped" { + title = "The engine is stopped" + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Padding(12, 16).Radius(10).Background(t.Surface).Border(1, t.Border).Children(func() { + if state == "starting" { + ui.Spinner(c) + } else { + ui.Icon(c, iconAlert).TextColor(t.Danger) + } + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + if detail != "" { + ui.Text(c, detail).FontSize(12).TextColor(t.TextMuted) + } + }) + if ui.Button(c, "Settings").Clicked() { + a.goTo("/settings") + } + }) +} + +// header is the top of a page: a way back, a title, what it is, and what +// can be done to it. +func (a *app) header(c *ui.Context, back, backLabel, title, description string, tileID string, actions func()) { + t := c.Theme() + ui.Column(c).Gap(6).Children(func() { + if back != "" { + link := ui.ButtonBase(c).Row().Gap(2).AlignItems(ui.Center).Label("Back to " + backLabel).AlignSelf(ui.Start) + link.Children(func() { + ui.Text(c, "‹ "+backLabel).FontSize(12).TextColor(t.TextMuted) + }) + if link.Clicked() { + a.goTo(back) + } + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + if tileID != "" { + connectorTile(c, tileID, 36, false) + } + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, title).FontSize(22).Bold() + if description != "" { + ui.Text(c, description).FontSize(13).TextColor(t.TextMuted) + } + }) + if actions != nil { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(actions) + } + }) + }) +} + +// card is a bordered block, as the web app's List and Card. +func card(c *ui.Context) ui.Element { + t := c.Theme() + return ui.Column(c).Radius(10).Background(t.Surface).Border(1, t.Border).Clip() +} + +// alert is a notice: a line or two that call for attention. +func alert(c *ui.Context, title string, lines []string, danger bool) { + t := c.Theme() + color := t.Border + if danger { + color = t.Danger + } + ui.Row(c).Gap(10).AlignItems(ui.Start).Padding(12, 14).Radius(10).Border(1, color).Children(func() { + ui.Icon(c, iconAlert).FontSize(16).TextColor(color).Padding(1, 0, 0) + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + for _, line := range lines { + ui.Text(c, line).FontSize(12).TextColor(t.TextMuted).Selectable() + } + }) + }) +} + +// section is a titled group of a form, with a description under the title. +func section(c *ui.Context, title, description string, body func()) { + t := c.Theme() + ui.Column(c).Gap(12).PaddingY(8).Children(func() { + ui.Column(c).Gap(2).Children(func() { + ui.Text(c, title).FontSize(15).Bold() + if description != "" { + ui.Text(c, description).FontSize(12).TextColor(t.TextMuted) + } + }) + body() + }) +} + +func empty(c *ui.Context, text string, action func()) { + t := c.Theme() + ui.Column(c).Gap(10).Center().Padding(32).Radius(10).Border(1, t.Border).BorderStyle(ui.BorderDashed).Children(func() { + ui.Text(c, text).FontSize(13).TextColor(t.TextMuted) + if action != nil { + action() + } + }) +} + +func mono(c *ui.Context, text string) ui.Element { + t := c.Theme() + return ui.Text(c, text).Font("monospace").FontSize(11).Selectable().Padding(10).Radius(8).Background(t.Background).Border(1, t.Border) +} + +func (a *app) confirmDialog(c *ui.Context) { + if !a.confirm.open { + return + } + switch ui.AlertDialog(c, &a.confirm.open, a.confirm.title, a.confirm.body, "Cancel", "Delete") { + case 1: + kind, id, name := a.confirm.kind, a.confirm.id, a.confirm.name + switch kind { + case "loop": + a.run("delete:"+id, func() (string, error) { + if err := a.call("removeLoop", map[string]string{"id": id}, nil); err != nil { + return "", err + } + return "Deleted " + name, nil + }) + if strings.HasPrefix(a.router.Path(), "/loops/"+id) { + a.goTo("/loops") + } + case "connection": + a.run("disconnect:"+id, func() (string, error) { + if err := a.call("disconnectConnection", map[string]string{"id": id}, nil); err != nil { + return "", err + } + return "Disconnected " + name, nil + }) + } + } +} + +func itoa(n int) string { + return strings.TrimSpace(strings.Repeat(" ", 0) + fmtInt(n)) +} + +func fmtInt(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + if neg { + b = append([]byte{'-'}, b...) + } + return string(b) +} diff --git a/desktop/tray.png b/desktop/tray.png new file mode 100644 index 0000000..6190bd1 Binary files /dev/null and b/desktop/tray.png differ diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..1fbdc17 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,59 @@ +# The desktop app + +Loopable for one Mac: the loops, the inbox, the connectors and the agents in a native window, with no account and nothing hosted. Everything runs on the machine, and the window is the only way in. + +It lives in `desktop/`, written in Go with [MyGo](https://mygo.egoist.dev), whose native UI draws the screens on the GPU: no web page, no webview. The engine, everything that is not a screen, is the same TypeScript as the hosted service, bundled into one file and run as a Node process the app starts and stops. + +## What runs where + +| Part | Where | Does | +| --- | --- | --- | +| App | `desktop/*.go` | The window, the menu bar icon, notifications, open at login, settings | +| Engine | `src/desktop/engine.ts`, built to `desktop/resources/engine/engine.mjs` | The dispatcher (polls connectors, writes answers back), a runner in the same process, and a JSON API on loopback for the screens | + +The app starts the engine with a token it makes up at each launch, in the environment, and the engine takes requests only with that token, only on 127.0.0.1. No other program on the machine can ask it anything. When the app's stdin closes, the engine stops. + +The engine's data lives in `~/Library/Application Support/Loopable/data`: the SQLite file, and each run's log and scratch files. Credentials go in the keychain under the `loopable-desktop` namespace. The CLI runner's `~/.loopable` and `pnpm dev`'s `~/.loopable-dev` are untouched. + +The runner is `src/desktop/local-runner.ts`: the CLI runner with the HTTP taken out. It joins, heartbeats, claims and completes through the same functions the API routes call, so the database sees one runner, this machine, with the agents it finds installed. + +## Connecting services + +Slack, Feishu and WeChat connect as they do on the web: a pasted bot token, or a scan. + +GitHub connects from the GitHub CLI's login on the Mac, in one click: the engine asks `gh auth token`, and keeps asking every few minutes, so signing in again with `gh` carries over. It is the account this Mac's git already works as. Without `gh` signed in, the connector says to run `gh auth login`. Only the desktop engine ever reads it (`src/connectors/github/local-login.ts`). + +When there is no CLI login, GitHub and Gmail connect through Loopable's own apps, built into the desktop app, so nobody using it registers anything. The desktop app is a public client: it ships client IDs and keeps no secret. + +- **GitHub.** An OAuth app with *device flow* turned on, in its settings on GitHub. The app shows a code to enter on github.com and polls until GitHub hands over a token. No callback address and no client secret. +- **Google.** A client of type *Desktop app* in Google Cloud, with the Gmail API enabled. Google calls what it gives a desktop client a secret, but does not treat it as one. The browser comes back to the engine's loopback address, which desktop clients may use on any port. + +`scripts/build-engine.mjs` writes them into the engine directory as `oauth-apps.json`, from `LOOPABLE_DESKTOP_GITHUB_CLIENT_ID`, `LOOPABLE_DESKTOP_GMAIL_CLIENT_ID` and `LOOPABLE_DESKTOP_GMAIL_CLIENT_SECRET` when releasing, or from the `desktop` entry of the gitignored `config/oauth-app.json` when developing (see `config/oauth-app.example.json`). Without them the build says which connector it lacks, and the app shows that connector as not available. These are not the hosted service's apps: that GitHub app needs no device flow, and its Google client is a web client. + +## Developing + +Needs Go 1.27 or later and Node 22 or later. From the repository root: + +```bash +pnpm desktop +``` + +That builds the engine (`scripts/build-engine.mjs`: esbuild, the migrations, and libsql installed flat with npm) into `desktop/resources/engine`, then runs the app with `go tool mygo dev`, which rebuilds it as the Go changes. The engine is built once; run `pnpm build:engine` after changing anything under `src/`. `LOOPABLE_DEBUG=1`, which `pnpm desktop` sets, echoes the engine's log to the terminal. + +The screens draw without a window in tests, as pictures and as text, and one test loads them from a real engine in a throwaway folder: + +```bash +cd desktop && CGO_ENABLED=0 go test ./... +``` + +`LOOPABLE_SHOTS=/tmp/shots go test ./...` writes a PNG of every screen there, which is the quickest way to look at a change. + +## Packaging + +```bash +pnpm build:engine && cd desktop && go tool mygo build +``` + +That makes `desktop/build/darwin-arm64/Loopable.app` and a disk image, signed ad hoc until `mygo.json` names a Developer ID identity and a notarization profile; see MyGo's [distribution](https://mygo.egoist.dev/docs/distribution) docs. The engine directory ships inside the app's resources, and MyGo signs libsql's native module with the rest. + +The app has no updater yet. Adding one is MyGo's `updates` configuration and a release of the built app on GitHub. diff --git a/package.json b/package.json index eaa0aba..f9c4d6f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "runner": "LOOPABLE_DEV=1 node --experimental-strip-types src/cli.ts runner", "generate-routes": "tsr generate", "build": "vite build && node scripts/build-cli.mjs", + "build:engine": "node scripts/build-engine.mjs", + "desktop": "node scripts/build-engine.mjs && cd desktop && LOOPABLE_ENGINE_DIR=$PWD/resources/engine LOOPABLE_DEBUG=1 go tool mygo dev", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/build-engine.mjs b/scripts/build-engine.mjs new file mode 100644 index 0000000..f4edf27 --- /dev/null +++ b/scripts/build-engine.mjs @@ -0,0 +1,96 @@ +import * as esbuild from "esbuild"; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +/** + * The desktop engine: src/desktop/engine.ts and everything it reaches, as + * one file under desktop/resources/engine, beside the migrations and the one + * dependency that cannot be bundled, libsql's native module, installed flat + * with npm so the app ships no symlinks. + */ +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const src = join(root, "src"); +const out = join(root, "desktop", "resources", "engine"); +const pkg = JSON.parse(execSync("cat package.json", { cwd: root, encoding: "utf8" })); + +const aliases = { + name: "aliases", + setup(build) { + build.onResolve({ filter: /^[#@]\// }, (args) => ({ + path: join(src, args.path.slice(2)), + })); + }, +}; + +rmSync(out, { recursive: true, force: true }); +mkdirSync(out, { recursive: true }); + +await esbuild.build({ + bundle: true, + platform: "node", + format: "esm", + target: "node22", + absWorkingDir: root, + plugins: [aliases], + external: ["@libsql/client", "@libsql/client/*"], + legalComments: "none", + logLevel: "info", + entryPoints: [join(root, "src/desktop/engine.ts")], + outfile: join(out, "engine.mjs"), + // The bundle is ESM; some dependencies still ask for require(). + banner: { + js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', + }, +}); + +cpSync(join(root, "drizzle"), join(out, "drizzle"), { recursive: true }); + +/** + * Loopable's own GitHub and Google apps, built into the app so nobody using + * it registers one. Public clients: GitHub needs only an ID (device flow), + * and Google does not treat a desktop client's secret as one. Taken from the + * environment when releasing, or from the desktop entry of the gitignored + * config/oauth-app.json when developing; nothing here lives in git. + */ +function desktopApps() { + let config = {}; + try { + config = JSON.parse(readFileSync(join(root, "config", "oauth-app.json"), "utf8")).desktop ?? {}; + } catch {} + const env = process.env; + return { + github: { clientId: env.LOOPABLE_DESKTOP_GITHUB_CLIENT_ID ?? config.github?.clientId ?? "" }, + gmail: { + clientId: env.LOOPABLE_DESKTOP_GMAIL_CLIENT_ID ?? config.gmail?.clientId ?? "", + clientSecret: env.LOOPABLE_DESKTOP_GMAIL_CLIENT_SECRET ?? config.gmail?.clientSecret ?? "", + }, + }; +} +const apps = desktopApps(); +writeFileSync(join(out, "oauth-apps.json"), JSON.stringify(apps, null, 2)); +const missing = [ + apps.github.clientId ? null : "GitHub", + apps.gmail.clientId && apps.gmail.clientSecret ? null : "Gmail", +].filter(Boolean); +if (missing.length) { + console.warn(`no ${missing.join(" or ")} app built in: those connectors will say they are not set up (docs/desktop.md)`); +} + +writeFileSync( + join(out, "package.json"), + JSON.stringify( + { + name: "loopable-engine", + private: true, + version: pkg.version, + type: "module", + dependencies: { "@libsql/client": pkg.dependencies["@libsql/client"] }, + }, + null, + 2, + ), +); +execSync("npm install --omit=dev --no-audit --no-fund --loglevel=error", { cwd: out, stdio: "inherit" }); +console.log(`engine built in ${out}`); diff --git a/src/connectors/github/device.test.ts b/src/connectors/github/device.test.ts new file mode 100644 index 0000000..4d08a4a --- /dev/null +++ b/src/connectors/github/device.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { pollDeviceCode, requestDeviceCode } from "./device.ts"; + +function answering(body: unknown, calls: Array<{ url: string; body: unknown }> = []) { + const fetchImpl = (async (url: string, init?: RequestInit) => { + calls.push({ url, body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify(body), { status: 200 }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +describe("GitHub device flow", () => { + it("asks for a code with the scopes, and reads what to show", async () => { + const { fetchImpl, calls } = answering({ + device_code: "dev-1", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, + }); + const code = await requestDeviceCode("client-1", ["repo", "notifications"], fetchImpl); + expect(code).toEqual({ + deviceCode: "dev-1", + userCode: "ABCD-EFGH", + verificationUri: "https://github.com/login/device", + expiresInMs: 900_000, + intervalMs: 5_000, + }); + expect(calls[0]).toEqual({ + url: "https://github.com/login/device/code", + body: { client_id: "client-1", scope: "repo notifications" }, + }); + }); + + it("says when device flow is off for the app", async () => { + for (const error of ["unauthorized_client", "device_flow_disabled"]) { + const { fetchImpl } = answering({ error, error_description: "nope" }); + await expect(requestDeviceCode("client-1", ["repo"], fetchImpl)).rejects.toThrow(/device flow turned off/); + } + }); + + it("keeps waiting while the code is not entered, and slows down when told", async () => { + expect(await pollDeviceCode("c", "d", answering({ error: "authorization_pending" }).fetchImpl)).toEqual({ + state: "pending", + slowDown: false, + }); + expect(await pollDeviceCode("c", "d", answering({ error: "slow_down" }).fetchImpl)).toEqual({ + state: "pending", + slowDown: true, + }); + }); + + it("gives up on an expired or declined code", async () => { + expect(await pollDeviceCode("c", "d", answering({ error: "expired_token" }).fetchImpl)).toMatchObject({ + state: "expired", + }); + expect(await pollDeviceCode("c", "d", answering({ error: "access_denied" }).fetchImpl)).toMatchObject({ + state: "expired", + }); + }); + + it("hands over the token once it is granted, without a client secret", async () => { + const { fetchImpl, calls } = answering({ access_token: "gho_x", expires_in: 3600, refresh_token: "ghr_y" }); + const outcome = await pollDeviceCode("client-1", "dev-1", fetchImpl); + expect(outcome.state).toBe("granted"); + if (outcome.state !== "granted") return; + expect(outcome.credential.accessToken).toBe("gho_x"); + expect(outcome.credential.refreshToken).toBe("ghr_y"); + expect(outcome.credential.expiresAt).toBeGreaterThan(Date.now()); + expect(calls[0]!.body).toEqual({ + client_id: "client-1", + device_code: "dev-1", + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + }); +}); diff --git a/src/connectors/github/device.ts b/src/connectors/github/device.ts new file mode 100644 index 0000000..8228539 --- /dev/null +++ b/src/connectors/github/device.ts @@ -0,0 +1,120 @@ +import type { GithubCredential } from "./oauth.ts"; + +/** + * GitHub's device flow: the app asks for a code, the person types it on + * github.com, and the app polls until GitHub hands over a token. Nothing comes + * back to the app's address, so the app needs none, and no client secret. + * The OAuth app must have device flow turned on in its settings. + */ + +type FetchLike = typeof fetch; + +export type DeviceCode = { + deviceCode: string; + userCode: string; + verificationUri: string; + expiresInMs: number; + intervalMs: number; +}; + +async function postJson( + url: string, + payload: Record, + fetchImpl: FetchLike, +): Promise> { + const res = await fetchImpl(url, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + try { + return JSON.parse(text) as Record; + } catch { + throw new Error(`GitHub returned a non-JSON response: ${text.slice(0, 200)}`); + } +} + +export async function requestDeviceCode( + clientId: string, + scopes: string[], + fetchImpl: FetchLike = fetch, +): Promise { + const body = await postJson( + "https://github.com/login/device/code", + { client_id: clientId, scope: scopes.join(" ") }, + fetchImpl, + ); + const error = typeof body.error === "string" ? body.error : ""; + if (error) { + const description = typeof body.error_description === "string" ? body.error_description : error; + throw new Error( + error === "unauthorized_client" || error === "device_flow_disabled" + ? "This GitHub app has device flow turned off. Turn it on in the app's settings on GitHub." + : description, + ); + } + const deviceCode = typeof body.device_code === "string" ? body.device_code : ""; + const userCode = typeof body.user_code === "string" ? body.user_code : ""; + const verificationUri = typeof body.verification_uri === "string" ? body.verification_uri : ""; + if (!deviceCode || !userCode || !verificationUri) { + throw new Error("GitHub did not return a device code."); + } + const expiresIn = Number(body.expires_in); + const interval = Number(body.interval); + return { + deviceCode, + userCode, + verificationUri, + expiresInMs: (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 900) * 1000, + intervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 5) * 1000, + }; +} + +export type DevicePoll = + | { state: "pending"; slowDown: boolean } + | { state: "expired"; reason: string } + | { state: "granted"; credential: GithubCredential }; + +export async function pollDeviceCode( + clientId: string, + deviceCode: string, + fetchImpl: FetchLike = fetch, +): Promise { + const body = await postJson( + "https://github.com/login/oauth/access_token", + { + client_id: clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + fetchImpl, + ); + const error = typeof body.error === "string" ? body.error : ""; + if (error === "authorization_pending") return { state: "pending", slowDown: false }; + if (error === "slow_down") return { state: "pending", slowDown: true }; + if (error === "expired_token") { + return { state: "expired", reason: "The code expired before it was entered." }; + } + if (error === "access_denied") { + return { state: "expired", reason: "The authorization was declined on GitHub." }; + } + if (error) { + const description = typeof body.error_description === "string" ? body.error_description : error; + throw new Error(description); + } + const accessToken = typeof body.access_token === "string" ? body.access_token : ""; + if (!accessToken) throw new Error("GitHub did not return an access token"); + const expiresIn = Number(body.expires_in); + return { + state: "granted", + credential: { + accessToken, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined, + expiresAt: + Number.isFinite(expiresIn) && expiresIn > 0 + ? Date.now() + expiresIn * 1000 - 60_000 + : undefined, + }, + }; +} diff --git a/src/connectors/github/local-login.test.ts b/src/connectors/github/local-login.test.ts new file mode 100644 index 0000000..c77f6c8 --- /dev/null +++ b/src/connectors/github/local-login.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { forgetLocalToken, localToken } from "./local-login.ts"; + +const previous = process.env.LOOPABLE_DESKTOP; +afterEach(() => { + if (previous === undefined) delete process.env.LOOPABLE_DESKTOP; + else process.env.LOOPABLE_DESKTOP = previous; + forgetLocalToken(); +}); + +describe("the GitHub CLI's login", () => { + it("is never read outside the desktop app", async () => { + delete process.env.LOOPABLE_DESKTOP; + let asked = false; + const token = await localToken({ fresh: true, runner: async () => ((asked = true), { stdout: "gho_x" }) }); + expect(token).toBeNull(); + expect(asked).toBe(false); + }); + + it("reads the token in the desktop app, and keeps it a while", async () => { + process.env.LOOPABLE_DESKTOP = "1"; + let calls = 0; + const runner = async (bin: string, args: string[]) => { + calls++; + expect([bin, ...args]).toEqual(["gh", "auth", "token", "--hostname", "github.com"]); + return { stdout: "gho_x\n" }; + }; + expect(await localToken({ fresh: true, runner })).toBe("gho_x"); + expect(await localToken({ runner })).toBe("gho_x"); + expect(calls).toBe(1); + }); + + it("is null when the CLI is missing or signed out", async () => { + process.env.LOOPABLE_DESKTOP = "1"; + const runner = async () => { + throw new Error("gh: not found"); + }; + expect(await localToken({ fresh: true, runner })).toBeNull(); + }); +}); diff --git a/src/connectors/github/local-login.ts b/src/connectors/github/local-login.ts new file mode 100644 index 0000000..c887234 --- /dev/null +++ b/src/connectors/github/local-login.ts @@ -0,0 +1,43 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); + +/** + * The GitHub CLI's login on this machine, which the desktop app connects + * GitHub with: nothing to register, nothing to type, and the account is the + * one this machine's git already works as. Only ever read in the desktop + * app, whose engine runs as the person; the hosted service has no such + * thing and must never look. + */ +export function canUseLocalLogin(): boolean { + return process.env.LOOPABLE_DESKTOP === "1"; +} + +const KEEP_MS = 5 * 60_000; +let cached: { token: string | null; at: number } | null = null; + +type Run = (bin: string, args: string[]) => Promise<{ stdout: string }>; +const run: Run = (bin, args) => exec(bin, args, { timeout: 10_000 }); + +/** + * The CLI's current token, or null when it is not installed or signed in. + * Asked again every few minutes, so signing in again with gh is picked up. + */ +export async function localToken(options: { fresh?: boolean; runner?: Run } = {}): Promise { + if (!canUseLocalLogin()) return null; + if (!options.fresh && cached && Date.now() - cached.at < KEEP_MS) return cached.token; + let token: string | null = null; + try { + const { stdout } = await (options.runner ?? run)("gh", ["auth", "token", "--hostname", "github.com"]); + token = stdout.trim() || null; + } catch { + token = null; + } + cached = { token, at: Date.now() }; + return token; +} + +export function forgetLocalToken(): void { + cached = null; +} diff --git a/src/connectors/github/oauth.ts b/src/connectors/github/oauth.ts index 8056be7..c167935 100644 --- a/src/connectors/github/oauth.ts +++ b/src/connectors/github/oauth.ts @@ -6,6 +6,11 @@ export type GithubCredential = { refreshToken?: string; /** Epoch ms, already reduced by a safety margin. */ expiresAt?: number; + /** + * "gh" when it came from the GitHub CLI's login, in the desktop app: the + * CLI's current token is used, and the stored one only when it has none. + */ + source?: "gh"; }; export function createPkce(): { verifier: string; challenge: string } { diff --git a/src/connectors/github/runtime.ts b/src/connectors/github/runtime.ts index 9314a37..c818e8e 100644 --- a/src/connectors/github/runtime.ts +++ b/src/connectors/github/runtime.ts @@ -8,6 +8,9 @@ import { pullForBranch, submitReview, } from "./api.ts"; +import { pollDeviceCode, requestDeviceCode } from "./device.ts"; +import { canUseLocalLogin, forgetLocalToken, localToken } from "./local-login.ts"; +import { GITHUB_SCOPES } from "./manifest.ts"; import { pollGithub } from "./poll.ts"; import { githubRef, parseGithubRef, parseGithubUrl, resolveWorkItem } from "./work-item.ts"; import { @@ -83,6 +86,9 @@ function pullBody(input: { body: string; source: ActionSource; stat: string }): export async function usableToken( credential: GithubCredential, ): Promise<{ accessToken: string; refreshed?: GithubCredential }> { + if (credential.source === "gh") { + return { accessToken: (await localToken()) ?? credential.accessToken }; + } const stillValid = !credential.expiresAt || Date.now() < credential.expiresAt; if (stillValid) return { accessToken: credential.accessToken }; if (!credential.refreshToken) { @@ -100,6 +106,14 @@ export async function usableToken( export const githubRuntime = defineRuntime({ async readiness() { if (oauthAppRegistration("github")) return { ready: true }; + if (canUseLocalLogin()) { + if (await localToken()) return { ready: true }; + return { + ready: false, + reason: "Sign in to the GitHub CLI on this Mac, and Loopable connects with that account.", + fixHint: "Run gh auth login in a terminal, then come back here. Install it with brew install gh.", + }; + } return { ready: false, reason: "No GitHub app is registered here.", @@ -211,5 +225,55 @@ export const githubRuntime = defineRuntime({ const account = accountFrom(await getViewer(accessToken)); return refreshed ? { account, renewedCredential: refreshed } : { account }; }, + + // The desktop app's first choice: the GitHub CLI's login, in one step. + async fromLocalLogin() { + forgetLocalToken(); + const token = await localToken({ fresh: true }); + if (!token) throw new Error("The GitHub CLI is not signed in on this Mac. Run gh auth login first."); + const viewer = await getViewer(token); + const credential: GithubCredential = { accessToken: token, source: "gh" }; + return { credential, account: accountFrom(viewer) }; + }, + + // Device flow: the desktop app has no callback address, and needs no + // client secret this way. The attempt carries what polling needs. + async startDeviceLogin() { + const registration = requireRegistration(); + const code = await requestDeviceCode(registration.clientId, GITHUB_SCOPES); + return { + attempt: { + deviceCode: code.deviceCode, + intervalMs: code.intervalMs, + expiresAt: Date.now() + code.expiresInMs, + }, + userCode: code.userCode, + verificationUrl: code.verificationUri, + expiresInMs: code.expiresInMs, + intervalMs: code.intervalMs, + }; + }, + async pollDeviceLogin(attempt) { + const registration = requireRegistration(); + const current = attempt as { deviceCode: string; intervalMs: number; expiresAt: number }; + if (Date.now() > current.expiresAt) { + return { state: "expired", reason: "The code expired before it was entered." }; + } + const outcome = await pollDeviceCode(registration.clientId, current.deviceCode); + if (outcome.state === "granted") { + const viewer = await getViewer(outcome.credential.accessToken); + return { state: "confirmed", result: { credential: outcome.credential, account: accountFrom(viewer) } }; + } + if (outcome.state === "expired") return { state: "expired", reason: outcome.reason }; + return { + state: "pending", + attempt: { + ...current, + // GitHub asks for five seconds more between polls when it says so. + intervalMs: outcome.slowDown ? current.intervalMs + 5_000 : current.intervalMs, + }, + hint: "Waiting for the code to be entered on GitHub…", + }; + }, }, }); diff --git a/src/connectors/gmail/runtime.ts b/src/connectors/gmail/runtime.ts index f072a30..e7762c5 100644 --- a/src/connectors/gmail/runtime.ts +++ b/src/connectors/gmail/runtime.ts @@ -65,6 +65,12 @@ function messageIdFrom(url: string, payload: unknown): string { export const gmailRuntime = defineRuntime({ async readiness() { if (oauthAppRegistration("gmail")) return { ready: true }; + if (process.env.LOOPABLE_DESKTOP === "1") { + return { + ready: false, + reason: "This copy of Loopable was built without its Google app, so Gmail cannot be connected yet. A newer version will fix it.", + }; + } return { ready: false, reason: "No Google app is registered here.", diff --git a/src/connectors/oauth-app.test.ts b/src/connectors/oauth-app.test.ts index c3383fe..f644261 100644 --- a/src/connectors/oauth-app.test.ts +++ b/src/connectors/oauth-app.test.ts @@ -7,11 +7,13 @@ import { oauthAppRegistration } from "./oauth-app.ts"; const previousId = process.env.LOOPABLE_GITHUB_CLIENT_ID; const previousSecret = process.env.LOOPABLE_GITHUB_CLIENT_SECRET; const previousBase = process.env.LOOPABLE_BASE_URL; +const previousDesktop = process.env.LOOPABLE_DESKTOP; afterEach(() => { restore("LOOPABLE_GITHUB_CLIENT_ID", previousId); restore("LOOPABLE_GITHUB_CLIENT_SECRET", previousSecret); restore("LOOPABLE_BASE_URL", previousBase); + restore("LOOPABLE_DESKTOP", previousDesktop); }); function restore(key: string, value: string | undefined): void { @@ -29,6 +31,17 @@ describe("oauth app registration", () => { }); }); + it("takes a client ID alone in the desktop app, which keeps no secret", () => { + const cwd = vi.spyOn(process, "cwd").mockReturnValue(mkdtempSync(join(tmpdir(), "loopable-oauth-"))); + process.env.LOOPABLE_GITHUB_CLIENT_ID = "public-id"; + delete process.env.LOOPABLE_GITHUB_CLIENT_SECRET; + delete process.env.LOOPABLE_DESKTOP; + expect(oauthAppRegistration("github")).toBeNull(); + process.env.LOOPABLE_DESKTOP = "1"; + expect(oauthAppRegistration("github")).toEqual({ clientId: "public-id", clientSecret: "" }); + cwd.mockRestore(); + }); + it("has no app built in", () => { // Away from any config/oauth-app.json this checkout happens to have. const cwd = vi.spyOn(process, "cwd").mockReturnValue(mkdtempSync(join(tmpdir(), "loopable-oauth-"))); diff --git a/src/connectors/oauth-app.ts b/src/connectors/oauth-app.ts index 1458e52..3a0c57a 100644 --- a/src/connectors/oauth-app.ts +++ b/src/connectors/oauth-app.ts @@ -50,8 +50,18 @@ function fromFile(connectorId: string): OAuthAppRegistration | null { return pick(flat.clientId, flat.clientSecret); } +/** + * The desktop app is a public client: it ships its client IDs and cannot keep + * a secret, so GitHub connects by device flow and Google's desktop client + * treats its "secret" as public. An ID alone is a registration there. + */ +function isPublicClient(): boolean { + return process.env.LOOPABLE_DESKTOP === "1"; +} + function pick(id: unknown, secret: unknown): OAuthAppRegistration | null { const clientId = typeof id === "string" ? id.trim() : ""; const clientSecret = typeof secret === "string" ? secret.trim() : ""; - return clientId && clientSecret ? { clientId, clientSecret } : null; + if (clientId && clientSecret) return { clientId, clientSecret }; + return clientId && isPublicClient() ? { clientId, clientSecret: "" } : null; } diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 6ffd5f3..b8bc60a 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -299,6 +299,22 @@ export type QrOutcome = | { state: "expired"; reason?: string } | { state: "confirmed"; result: AuthResult }; +/** + * A login that finishes on the provider's site with a short code typed in, + * for an app that has no address a browser can be sent back to: the desktop + * app. Polled the way a QR scan is. + */ +export type DeviceChallenge = { + attempt: JsonValue; + /** What the person types on the provider's page. */ + userCode: string; + /** Where they type it. */ + verificationUrl: string; + expiresInMs: number; + /** How long to wait between polls; the provider says. */ + intervalMs: number; +}; + /** What a loop is acting on, resolved from a link or from a signal. */ export type WorkItem = WorkItemRef & { title: string; @@ -505,6 +521,17 @@ export type ConnectorRuntime = { /** Both required when auth.kind is "qr_scan". */ startQrLogin?(): Promise; pollQrLogin?(attempt: JsonValue): Promise; + /** + * Optional for "oauth_redirect": the same account, connected without a + * callback address, which the desktop app prefers. + */ + startDeviceLogin?(): Promise; + /** + * Optional for "oauth_redirect": the account a tool on this machine is + * already signed in as, connected in one step. Desktop app only. + */ + fromLocalLogin?(): Promise; + pollDeviceLogin?(attempt: JsonValue): Promise; /** Re-read the account to prove a stored credential still works. */ identity(credential: unknown): Promise; revoke?(credential: unknown): Promise; diff --git a/src/desktop/api.ts b/src/desktop/api.ts new file mode 100644 index 0000000..36eaa3d --- /dev/null +++ b/src/desktop/api.ts @@ -0,0 +1,301 @@ +import { randomBytes } from "node:crypto"; +import { toDataURL } from "qrcode"; +import { AGENT_MANIFESTS, fallbackAgent } from "#/agents/manifests.ts"; +import type { PermissionMode } from "#/agents/types.ts"; +import { CONNECTOR_MANIFESTS, connectorWorkflow } from "#/connectors/manifests.ts"; +import { oauthAppRegistration } from "#/connectors/oauth-app.ts"; +import { connectorRuntime } from "#/connectors/runtimes.ts"; +import { dispatcherIsAlive } from "#/dispatcher/instance.ts"; +import type { AgentOnRunner, AgentView, ConnectionSettings, JsonValue, LoopEdit } from "#/lib/domain.ts"; +import { VERSION } from "#/lib/version.ts"; +import { listAgents, saveAgentSettings, setDefaultAgent, testAgent } from "#/server/agents.ts"; +import { + checkConnection, + listConnections, + removeConnection, + saveAuthorizedConnection, + startAuthAttempt, + updateConnectionSettings, +} from "#/server/connections.ts"; +import { + createLoop, + deleteLoop, + getLoop, + listLoops, + loopReadiness, + moveLoop, + setLoopEnabled, + updateLoop, +} from "#/server/loops.ts"; +import { dataDir } from "#/server/paths.ts"; +import { availableAgentIds, listRunners } from "#/server/runners.ts"; +import { dispatcherSettings, setDispatcherPaused } from "#/server/settings.ts"; +import { lookNow, loopPollState, runBacklog } from "#/server/signals.ts"; +import { + enqueuePrompt, + enqueueTask, + getTask, + listRunnableAgents, + listTasks, + readTaskLog, + requestCancel, +} from "#/server/tasks.ts"; + +/** + * What the desktop app can ask the engine, by name. The same operations the + * web pages reach through server functions, reached over plain JSON instead: + * the native screens are Go, and know nothing of TanStack. + * + * Kept as one map so the Go side has one list to mirror. + */ +export type RpcHandler = (data: never) => Promise; + +/** The engine's own address, set once it listens. OAuth callbacks come here. */ +let engineOrigin = ""; +export function setEngineOrigin(origin: string): void { + engineOrigin = origin; +} + +/** Copied from the agents server functions, which the engine cannot import. */ +async function withRunners(agents: AgentView[]): Promise { + const runners = await listRunners(); + const available = await availableAgentIds(); + const stored = agents.find((agent) => agent.isDefault)?.agentId ?? null; + const implicit = stored ? null : fallbackAgent(null, available); + return agents.map((agent) => { + const onRunners: AgentOnRunner[] = runners.flatMap((runner) => { + const entry = runner.inventory.find((item) => item.agentId === agent.agentId); + if (!entry?.installed) return []; + return [ + { + runnerId: runner.id, + name: runner.name, + status: runner.status, + signedIn: entry.signedIn, + version: entry.version, + }, + ]; + }); + const isDefault = agent.agentId === stored || agent.agentId === implicit; + return { + ...agent, + onRunners, + isDefault, + defaultIsImplicit: isDefault && agent.agentId === implicit, + }; + }); +} + +/** + * Every connector, with its workflows already merged onto their triggers, so + * the Go side never has to repeat connectorWorkflow. Agents come along, with + * their defaults, for the same reason. + */ +function manifests() { + return { + connectors: CONNECTOR_MANIFESTS.map((manifest) => ({ + id: manifest.id, + name: manifest.name, + tagline: manifest.tagline, + docsUrl: manifest.docsUrl ?? null, + icon: manifest.icon, + auth: manifest.auth, + byHand: manifest.byHand, + settings: manifest.settings, + allowsMultipleAccounts: manifest.allowsMultipleAccounts, + triggers: manifest.triggers, + workflows: manifest.workflows + .map((entry) => connectorWorkflow(manifest.id, entry.id)) + .filter((entry) => entry !== undefined), + actions: manifest.actions, + })), + agents: AGENT_MANIFESTS, + }; +} + +export const rpc = { + // Loops + getLoopsPage: async () => ({ loops: await listLoops(), readiness: await loopReadiness() }), + getLoopReadiness: async () => await loopReadiness(), + getLoopById: async (data: { id: string }) => await getLoop(data.id), + saveLoop: async (data: LoopEdit) => { + const { id, ...values } = data; + return id ? await updateLoop(id, values) : await createLoop(values); + }, + toggleLoop: async (data: { id: string; enabled: boolean }) => + await setLoopEnabled(data.id, data.enabled), + removeLoop: async (data: { id: string }) => { + await deleteLoop(data.id); + return await listLoops(); + }, + reorderLoop: async (data: { id: string; direction: "up" | "down" }) => + await moveLoop(data.id, data.direction), + getLoopPollState: async (data: { id: string }) => await loopPollState(data.id), + runLoopBacklog: async (data: { id: string; key?: string }) => ({ + queued: await runBacklog(data.id, data.key), + }), + lookLoopNow: async (data: { id: string }) => await lookNow(data.id), + + // Tasks + getInbox: async () => ({ tasks: await listTasks(), agents: await listRunnableAgents() }), + getLoopTasks: async (data: { loopId: string }) => await listTasks({ loopId: data.loopId }), + getTaskById: async (data: { id: string }) => await getTask(data.id), + getTaskLog: async (data: { id: string }) => await readTaskLog(data.id), + runPrompt: async (data: { prompt: string; agentId: string }) => await enqueuePrompt(data), + runLoopNow: async (data: { loopId: string; url: string; dryRun: boolean }) => + await enqueueTask(data), + stopTask: async (data: { id: string }) => await requestCancel(data.id), + getDispatcherState: async () => ({ + alive: await dispatcherIsAlive(), + hosted: false, + ...(await dispatcherSettings()), + }), + pauseDispatcher: async (data: { paused: boolean }) => await setDispatcherPaused(data.paused), + + // Agents + getAgentsPage: async () => ({ agents: await withRunners(await listAgents()) }), + saveAgent: async (data: { + agentId: string; + permissionMode: PermissionMode; + model: string | null; + timeoutMs: number; + }) => { + await saveAgentSettings(data.agentId, { + permissionMode: data.permissionMode, + model: data.model, + timeoutMs: data.timeoutMs, + }); + return await withRunners(await listAgents()); + }, + chooseDefaultAgent: async (data: { agentId: string }) => { + await setDefaultAgent(data.agentId); + return await withRunners(await listAgents()); + }, + runAgentTest: async (data: { agentId: string }) => { + const result = await testAgent(data.agentId); + return { + passed: result.passed, + output: result.output.slice(0, 2000), + detail: result.detail ?? null, + durationMs: result.durationMs, + command: result.command, + }; + }, + + // Connectors + getConnectorOverview: async () => { + const all = await listConnections(); + return Promise.all( + CONNECTOR_MANIFESTS.map(async (manifest) => ({ + connectorId: manifest.id, + readiness: await connectorRuntime(manifest.id).readiness(), + connections: all.filter((connection) => connection.connectorId === manifest.id), + })), + ); + }, + /** + * How to connect a browser-authorized connector from here: a device code + * when the connector offers one, else a page to open in the browser, which + * comes back to the engine's own loopback address. + */ + beginConnect: async (data: { connectorId: string }) => { + const runtime = connectorRuntime(data.connectorId); + // A login this Mac already has comes first: one click, nothing to enter. + if (runtime.auth.fromLocalLogin) { + try { + const connection = await saveAuthorizedConnection(data.connectorId, await runtime.auth.fromLocalLogin()); + return { kind: "connected" as const, accountLabel: connection.accountLabel }; + } catch (error) { + if (!oauthAppRegistration(data.connectorId)) throw error; + } + } + if (runtime.auth.startDeviceLogin && oauthAppRegistration(data.connectorId)) { + return { kind: "device" as const, ...(await runtime.auth.startDeviceLogin()) }; + } + if (!runtime.auth.startAuthorization) { + throw new Error(`${data.connectorId} cannot start an authorization.`); + } + if (!engineOrigin) throw new Error("The engine has no address yet."); + const state = randomBytes(16).toString("base64url"); + const redirectUri = `${engineOrigin}/oauth/${data.connectorId}/callback`; + const { redirectUrl, verifier } = await runtime.auth.startAuthorization({ redirectUri, state }); + await startAuthAttempt({ connectorId: data.connectorId, state, verifier, redirectUri }); + return { kind: "browser" as const, url: redirectUrl }; + }, + continueDeviceLogin: async (data: { connectorId: string; attempt: JsonValue }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.pollDeviceLogin) { + throw new Error(`${data.connectorId} does not connect with a device code.`); + } + const outcome = await runtime.auth.pollDeviceLogin(data.attempt); + if (outcome.state === "confirmed") { + const connection = await saveAuthorizedConnection(data.connectorId, outcome.result); + return { state: "connected" as const, accountLabel: connection.accountLabel }; + } + if (outcome.state === "expired") return { state: "expired" as const, reason: outcome.reason }; + return { state: "pending" as const, attempt: outcome.attempt, hint: outcome.hint }; + }, + beginQrLogin: async (data: { connectorId: string }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.startQrLogin) { + throw new Error(`${data.connectorId} does not connect by scanning.`); + } + const challenge = await runtime.auth.startQrLogin(); + return { + attempt: challenge.attempt, + // A PNG, base64, for the Go side to decode and show. No data URL prefix. + image: (await toDataURL(challenge.encode, { margin: 1, width: 240 })).replace( + /^data:image\/png;base64,/, + "", + ), + expiresInMs: challenge.expiresInMs, + }; + }, + continueQrLogin: async (data: { connectorId: string; attempt: JsonValue }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.pollQrLogin) { + throw new Error(`${data.connectorId} does not connect by scanning.`); + } + const outcome = await runtime.auth.pollQrLogin(data.attempt); + if (outcome.state === "confirmed") { + const connection = await saveAuthorizedConnection(data.connectorId, outcome.result); + return { state: "connected" as const, accountLabel: connection.accountLabel }; + } + if (outcome.state === "expired") return { state: "expired" as const, reason: outcome.reason }; + return { state: "pending" as const, attempt: outcome.attempt, hint: outcome.hint }; + }, + connectWithToken: async (data: { connectorId: string; fields: Record }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.connectWithFields) { + throw new Error(`${data.connectorId} does not connect with a token.`); + } + const result = await runtime.auth.connectWithFields(data.fields); + const connection = await saveAuthorizedConnection(data.connectorId, result); + return { accountLabel: connection.accountLabel }; + }, + disconnectConnection: async (data: { id: string }) => { + await removeConnection(data.id); + return { ok: true }; + }, + verifyConnection: async (data: { id: string }) => await checkConnection(data.id), + saveConnectionSettings: async (data: { id: string; settings: ConnectionSettings }) => + await updateConnectionSettings(data.id, data.settings), + + // The desktop app itself + getManifests: async () => manifests(), + getStatus: async () => ({ + version: VERSION, + pid: process.pid, + dataDir: dataDir(), + node: process.version, + alive: await dispatcherIsAlive(), + runners: await listRunners(), + ...(await dispatcherSettings()), + }), +} satisfies Record; + +export type RpcName = keyof typeof rpc; + +export function isRpcName(name: string): name is RpcName { + return Object.hasOwn(rpc, name); +} diff --git a/src/desktop/engine.ts b/src/desktop/engine.ts new file mode 100644 index 0000000..828bc04 --- /dev/null +++ b/src/desktop/engine.ts @@ -0,0 +1,232 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { connectorRuntime } from "#/connectors/runtimes.ts"; +import { claimSingleInstance, releaseSingleInstance, touchDispatcher } from "#/dispatcher/instance.ts"; +import { saveAuthorizedConnection, takeAuthAttempt } from "#/server/connections.ts"; +import { migrateIfNeeded } from "#/server/db/client.ts"; +import { createPoller } from "#/server/poller.ts"; +import { sweepRunDirs } from "#/server/tasks.ts"; +import { createWorker } from "#/server/worker.ts"; +import { LOCAL_WORKSPACE, withWorkspace } from "#/server/workspace.ts"; +import { isRpcName, rpc, setEngineOrigin } from "./api.ts"; +import { startLocalRunner } from "./local-runner.ts"; + +/** + * The engine of the desktop app: the dispatcher, a runner, and a JSON API on + * loopback for the native screens, in one Node process the app starts and + * stops. Everything Loopable does happens here; the app draws it. + * + * Only the app knows the token, which it makes up at every launch and hands + * over in the environment, so no other program on the machine can ask. + */ + +const token = process.env.LOOPABLE_DESKTOP_TOKEN ?? ""; + +function log(message: string): void { + process.stderr.write(`${new Date().toISOString()} ${message}\n`); +} + +/** Said on stdout as JSON lines, which the app reads. Nothing else goes there. */ +function tell(event: Record): void { + process.stdout.write(`${JSON.stringify(event)}\n`); +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function json(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body ?? null)); +} + +function html(response: ServerResponse, status: number, title: string, text: string): void { + response.writeHead(status, { "content-type": "text/html; charset=utf-8" }); + response.end( + `${title}` + + `

${title}

${text}

` + + `

You can close this window and go back to Loopable.

`, + ); +} + +function escape(text: string): string { + return text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); +} + +/** + * Where the browser comes back after a connector's authorization. The attempt + * says which connector and workspace; the state is the only proof needed. + */ +async function oauthCallback(connectorId: string, url: URL, response: ServerResponse): Promise { + const denied = url.searchParams.get("error"); + if (denied) { + html(response, 400, "Not connected", escape(url.searchParams.get("error_description") ?? denied)); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + html(response, 400, "Not connected", "No authorization code came back."); + return; + } + try { + const attempt = await takeAuthAttempt(state); + if (attempt.connectorId !== connectorId) { + throw new Error("This authorization belongs to a different connector."); + } + const runtime = connectorRuntime(attempt.connectorId); + if (!runtime.auth.completeAuthorization) { + throw new Error("This connector cannot complete a browser authorization."); + } + const result = await runtime.auth.completeAuthorization({ + code, + redirectUri: attempt.redirectUri, + verifier: attempt.verifier, + }); + const connection = await withWorkspace(attempt.workspaceId, () => + saveAuthorizedConnection(attempt.connectorId, result), + ); + html(response, 200, "Connected", `Connected as ${escape(connection.accountLabel)}.`); + } catch (error) { + html(response, 400, "Not connected", escape(error instanceof Error ? error.message : String(error))); + } +} + +async function handle(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? "/", "http://engine"); + const path = url.pathname; + + if (path === "/health") { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + return; + } + + const callback = /^\/oauth\/([^/]+)\/callback$/.exec(path); + if (callback && request.method === "GET") { + await oauthCallback(decodeURIComponent(callback[1]!), url, response); + return; + } + + const call = /^\/rpc\/([A-Za-z]+)$/.exec(path); + if (call && request.method === "POST") { + if ((request.headers.authorization ?? "") !== `Bearer ${token}`) { + json(response, 401, { error: "This engine is not open to you." }); + return; + } + const name = call[1]!; + if (!isRpcName(name)) { + json(response, 404, { error: `Unknown call: ${name}` }); + return; + } + let data: unknown = null; + try { + const body = await readBody(request); + data = body ? JSON.parse(body) : null; + } catch { + json(response, 400, { error: "The request body is not JSON." }); + return; + } + try { + const handler = rpc[name] as (data: unknown) => Promise; + const result = await withWorkspace(LOCAL_WORKSPACE, () => handler(data)); + json(response, 200, result); + } catch (error) { + json(response, 400, { error: error instanceof Error ? error.message : String(error) }); + } + return; + } + + response.writeHead(404); + response.end(); +} + +async function main(): Promise { + if (!token) { + log("LOOPABLE_DESKTOP_TOKEN is not set. The engine is started by the Loopable app."); + process.exit(2); + } + await migrateIfNeeded(); + const claim = await claimSingleInstance(); + if (!claim.ok) { + tell({ event: "busy", owner: claim.owner }); + log(`another Loopable dispatcher is already running (${claim.owner}).`); + process.exit(3); + } + + const swept = sweepRunDirs(); + if (swept > 0) log(`cleared ${swept} old run director${swept === 1 ? "y" : "ies"}`); + + const queue = createWorker({ log }); + const poller = createPoller({ log }); + const beat = setInterval(() => { + void touchDispatcher().catch((error: unknown) => { + log(`lease renew failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }, 5_000); + + const server = createServer((request, response) => { + void handle(request, response).catch((error: unknown) => { + log(`request failed: ${error instanceof Error ? error.message : String(error)}`); + if (!response.headersSent) json(response, 500, { error: "The engine hit an error." }); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + setEngineOrigin(`http://127.0.0.1:${port}`); + + queue.start(); + poller.start(); + const runner = withWorkspace(LOCAL_WORKSPACE, () => startLocalRunner({ log })); + log(`engine started (pid ${process.pid}), listening on ${port}`); + tell({ event: "listening", port, pid: process.pid }); + + let shuttingDown = false; + const shutdown = async (reason: string) => { + if (shuttingDown) return; + shuttingDown = true; + log(`${reason}: stopping`); + clearInterval(beat); + poller.stop(); + server.close(); + await Promise.all([queue.stop(), runner.stop()]); + await releaseSingleInstance(); + log("stopped"); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + + // The app is the parent. When it is gone, so is the reason to run: an + // orphan belongs to launchd, and a parent that cannot be signalled is + // dead. Asked every couple of seconds, and whenever stdin, which the app + // holds open, ends; an end with the app still there is noise. + const parent = process.ppid; + const parentGone = () => { + if (process.ppid !== parent) return true; + try { + process.kill(parent, 0); + return false; + } catch { + return true; + } + }; + setInterval(() => { + if (parentGone()) void shutdown("the app is gone"); + }, 2_000).unref(); + const stdinEnded = () => { + if (parentGone()) void shutdown("the app closed"); + else log("stdin ended, but the app is still here; staying up"); + }; + process.stdin.on("end", stdinEnded); + process.stdin.on("error", stdinEnded); + process.stdin.resume(); +} + +await main(); diff --git a/src/desktop/local-runner.ts b/src/desktop/local-runner.ts new file mode 100644 index 0000000..390f2d3 --- /dev/null +++ b/src/desktop/local-runner.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { hostname as osHostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectAgents } from "#/agents/inventory.ts"; +import { RUNNER_PROTOCOL, VERSION } from "#/lib/version.ts"; +import { runAgentJob } from "#/server/agent-runner.ts"; +import { appendAgentLog, claimAgentJob, completeAgentJob } from "#/server/runner-api.ts"; +import { getJoinToken, heartbeatRunner, instructionFor, joinRunner } from "#/server/runners.ts"; +import { taskRow } from "#/server/tasks.ts"; +import { watchFile } from "./watch-file.ts"; + +const BUILD = { version: VERSION, protocol: RUNNER_PROTOCOL, updatable: false }; + +/** + * The runner of the desktop app, in the same process as the dispatcher. It + * is the runner in src/runner/client.ts with the HTTP taken out: it joins, + * heartbeats, claims and completes through the same functions the API routes + * call, so what the database sees is one runner on this machine, as the + * Runners page always showed. + * + * It never updates itself: the app updates, and the engine with it. + */ +export function startLocalRunner(input: { log: (message: string) => void }) { + let stopping = false; + let current: AbortController | null = null; + let runnerId: string | null = null; + + async function joinHere(): Promise { + const hello = await joinRunner({ + joinToken: await getJoinToken(), + hostname: osHostname(), + inventory: await detectAgents(), + ...BUILD, + }); + input.log(`runner joined as ${hello.name} (${hello.runnerId})`); + return hello.runnerId; + } + + async function heartbeat(): Promise { + if (!runnerId) return; + await heartbeatRunner(runnerId, await detectAgents(), BUILD); + const instruction = await instructionFor(runnerId); + // Forgotten from the app is a fresh start, not an end: this machine is + // the only runner there is here. + if (instruction?.kind === "stop") runnerId = null; + } + + async function runOne(id: string): Promise { + const job = await claimAgentJob(id); + if (!job) return false; + input.log(`running ${job.taskId}`); + const workspace = mkdtempSync(join(tmpdir(), `loopable-${job.taskId}-`)); + const controller = new AbortController(); + current = controller; + const stopLog = watchFile(join(workspace, "agent.log"), (chunk) => { + void appendAgentLog(job.taskId, id, chunk).catch((error: unknown) => { + input.log(`log failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }); + // A Stop pressed in the app leaves a note on the task; this is who reads + // it, since the agent is a child of this process. + const watchCancel = setInterval(() => { + void taskRow(job.taskId).then((row) => { + if (row?.cancelRequested) controller.abort(); + }); + }, 2_000); + try { + const result = await runAgentJob(job, { workspace, signal: controller.signal }); + await completeAgentJob(job.taskId, id, result); + } finally { + clearInterval(watchCancel); + stopLog(); + current = null; + rmSync(workspace, { recursive: true, force: true }); + } + return true; + } + + async function loop(): Promise { + let beatAt = 0; + while (!stopping) { + try { + if (!runnerId) runnerId = await joinHere(); + if (Date.now() - beatAt > 15_000) { + await heartbeat(); + beatAt = Date.now(); + if (!runnerId) continue; + } + const ran = await runOne(runnerId); + if (!ran) await new Promise((resolve) => setTimeout(resolve, 1_000)); + } catch (error) { + input.log(`runner: ${error instanceof Error ? error.message : String(error)}`); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + } + } + + const done = loop(); + + return { + async stop(): Promise { + stopping = true; + current?.abort(); + await done; + }, + }; +} diff --git a/src/desktop/watch-file.ts b/src/desktop/watch-file.ts new file mode 100644 index 0000000..3fc2ed4 --- /dev/null +++ b/src/desktop/watch-file.ts @@ -0,0 +1,25 @@ +import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs"; + +/** Tails a file that is being appended to, handing over each new piece. */ +export function watchFile(path: string, onChunk: (chunk: string) => void): () => void { + let offset = 0; + const flush = () => { + if (!existsSync(path)) return; + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + if (size <= offset) return; + const buf = Buffer.alloc(size - offset); + readSync(fd, buf, 0, buf.length, offset); + offset = size; + onChunk(buf.toString("utf8")); + } finally { + closeSync(fd); + } + }; + const timer = setInterval(flush, 400); + return () => { + clearInterval(timer); + flush(); + }; +} diff --git a/src/server/agents.ts b/src/server/agents.ts index 438aab1..8e7d3ea 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -1,14 +1,15 @@ import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { hostname as osHostname, tmpdir } from "node:os"; import { join } from "node:path"; import { and, eq } from "drizzle-orm"; import { AGENT_MANIFESTS, agentManifest } from "#/agents/manifests.ts"; import { agentRuntime } from "#/agents/runtimes.ts"; import type { AgentRunResult, AgentSettingsValues } from "#/agents/types.ts"; -import type { AgentView } from "#/lib/domain.ts"; +import type { AgentView, RunnerInventoryEntry } from "#/lib/domain.ts"; import { isHosted } from "#/lib/hosted.ts"; import { db } from "./db/client.ts"; import { agentSettings } from "./db/schema.ts"; +import { listRunners } from "./runners.ts"; import { readSetting, writeSetting } from "./settings.ts"; import { workspaceId } from "./workspace.ts"; @@ -35,10 +36,15 @@ export async function settingsFor(agentId: string): Promise * Detection, authorization state and settings for every known agent. Detection * runs on every call because installing or removing a CLI should show up * without anyone pressing refresh. + * + * The desktop app is the exception: its runner, in the same process, detects + * the agents on every heartbeat, and spawning three CLIs on every page would + * make every page wait on them. Its inventory is what this host has. */ export async function listAgents(): Promise { const chosen = await storedSetting(DEFAULT_AGENT_KEY); const hosted = isHosted(); + const known = process.env.LOOPABLE_DESKTOP === "1" ? await hostInventory() : null; const views = await Promise.all( AGENT_MANIFESTS.map(async (manifest) => { @@ -64,6 +70,22 @@ export async function listAgents(): Promise { } satisfies AgentView; } + if (known) { + const entry = known.find((item) => item.agentId === manifest.id); + return { + agentId: manifest.id, + installed: entry?.installed ?? false, + binaryPath: null, + version: entry?.version ?? null, + auth: entry?.installed ? { signedIn: entry.signedIn, detail: entry.detail } : null, + settings, + isDefault: false, + defaultIsImplicit: false, + commandPreview: `${preview.bin} ${preview.args.join(" ")}`, + onRunners: [], + } satisfies AgentView; + } + const detection = await runtime.detect(); const auth = detection.installed ? await runtime.authStatus() : null; return { @@ -86,6 +108,13 @@ export async function listAgents(): Promise { ); } +/** What the runner on this machine last reported, or nothing before it joined. */ +async function hostInventory(): Promise { + const local = osHostname(); + const here = (await listRunners()).find((runner) => runner.hostname === local); + return here?.inventory ?? []; +} + export async function saveAgentSettings(agentId: string, values: AgentSettingsValues): Promise { const manifest = agentManifest(agentId); if (!manifest) throw new Error(`Unknown agent: ${agentId}`);