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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions cmd/envctl/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/sam-bretz/envctl/internal/tui"
)

func runArtifactCmd(g *globals) *cobra.Command {
var output string
c := &cobra.Command{Use: "artifact <digest>", Short: "Export a checksum-verified checkpoint artifact to a new file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
var open bool
c := &cobra.Command{Use: "artifact <digest>", Short: "Export or open a checksum-verified checkpoint artifact", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
if output == "" && !open {
return errors.New("either --output or --open is required")
}
client, err := connect(cmd.Context(), g)
if err != nil {
return err
Expand All @@ -20,17 +27,31 @@ func runArtifactCmd(g *globals) *cobra.Command {
if err != nil {
return err
}
filename, err := filepath.Abs(output)
if output != "" {
filename, err := filepath.Abs(output)
if err != nil {
return err
}
if err = exportArtifact(filename, raw); err != nil {
return err
}
if !open {
return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"path": filename, "digest": args[0], "bytes": len(raw)})
}
}
session, _, err := startWebSession(cmd.Context(), g, client, "127.0.0.1:0")
if err != nil {
return err
return fmt.Errorf("could not start the artifact browser: %w", err)
}
if err = exportArtifact(filename, raw); err != nil {
return err
url := session.ArtifactURL(args[0])
fmt.Fprintf(cmd.OutOrStdout(), "Open: %s\nPress Ctrl+C to stop.\n", url)
if err := tui.OpenInBrowser(url); err != nil {
return fmt.Errorf("could not open a browser (%v); open the link above", err)
}
return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"path": filename, "digest": args[0], "bytes": len(raw)})
return session.Wait()
}}
c.Flags().StringVarP(&output, "output", "o", "", "new destination file (existing paths are never overwritten)")
_ = c.MarkFlagRequired("output")
c.Flags().BoolVar(&open, "open", false, "open the verified artifact in the local browser")
return c
}

Expand Down
50 changes: 23 additions & 27 deletions cmd/envctl/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,34 @@ import (
"errors"
"fmt"
"net"
"net/http"
"os"
"time"

"github.com/spf13/cobra"

"github.com/sam-bretz/envctl/internal/tui"
"github.com/sam-bretz/envctl/internal/web"
)

func startWebSession(ctx context.Context, g *globals, api web.API, addr string) (*web.Session, *web.Server, error) {
dir, err := stateDir(g)
if err != nil {
return nil, nil, err
}
token, err := web.LoadToken(dir)
if err != nil {
return nil, nil, err
}
srv := &web.Server{API: api, Root: workflowRoot(ctx, g.dir), Token: token, Owner: "local"}
if user := os.Getenv("USER"); user != "" {
srv.Owner = user
}
session, err := web.Start(ctx, srv, addr)
if err != nil {
return nil, nil, err
}
return session, srv, nil
}

func webCmd(g *globals) *cobra.Command {
var addr string
var noOpen bool
Expand All @@ -26,44 +44,22 @@ func webCmd(g *globals) *cobra.Command {
if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return errors.New("--addr must be a loopback address such as 127.0.0.1:4777; the dashboard can approve and publish work")
}
dir, err := stateDir(g)
if err != nil {
return err
}
token, err := web.LoadToken(dir)
if err != nil {
return err
}
client, err := connect(cmd.Context(), g)
if err != nil {
return err
}
listener, err := net.Listen("tcp", addr)
session, srv, err := startWebSession(cmd.Context(), g, client, addr)
if err != nil {
return fmt.Errorf("%w; choose another port with --addr 127.0.0.1:0", err)
}
srv := &web.Server{API: client, Root: workflowRoot(cmd.Context(), g.dir), Token: token, Hosts: web.LoopbackHosts(listener.Addr()), Owner: "local"}
if user := os.Getenv("USER"); user != "" {
srv.Owner = user
}
url := fmt.Sprintf("http://%s/?token=%s", listener.Addr(), token)
url := session.URL("/?token=" + srv.Token)
fmt.Fprintf(cmd.OutOrStdout(), "envctl web is serving %s\nOpen: %s\nPress Ctrl+C to stop. Runs keep going without it.\n", srv.Root, url)
if !noOpen {
if err := tui.OpenInBrowser(url); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "could not open a browser (%v); open the link above\n", err)
}
}
server := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() {
<-cmd.Context().Done()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
return session.Wait()
}}
c.Flags().StringVar(&addr, "addr", "127.0.0.1:4777", "loopback address to serve on")
c.Flags().BoolVar(&noOpen, "no-open", false, "print the link without opening a browser")
Expand Down
15 changes: 15 additions & 0 deletions cmd/envctl/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ func launchTUI(cmd *cobra.Command, g *globals, themeName string) error {
if err != nil {
return err
}
webSession, _, webErr := startWebSession(cmd.Context(), g, client, "127.0.0.1:0")
if webErr != nil {
opts.BrowserWarning = webErr.Error()
} else {
opts.OpenArtifact = func(artifact workflow.Artifact, _ workflow.Run, _ workflow.Revision, _ workflow.Checkpoint) error {
return tui.OpenInBrowser(webSession.ArtifactURL(artifact.Digest))
}
}
if webSession != nil {
defer func() {
shutdown, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = webSession.Close(shutdown)
}()
}
return tui.Run(cmd.Context(), client, workflowRoot(cmd.Context(), g.dir), opts)
}

Expand Down
9 changes: 9 additions & 0 deletions docs/src/content/docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,19 @@ Version 2 workflow state belongs to its persistent coordinator. These commands i
envctl run diff <run-id> --node code
envctl run diff <run-id> --node qa --revision <revision-id> --from <checkpoint-id> --json
envctl run artifact <digest> --output <new-file>
envctl run artifact <digest> --open # read it in your browser
```

`diff` defaults to comparing the selected checkpoint against its revision's immutable source pins. `--from` selects any recorded checkpoint in the same run, including historical revisions. The response includes source patches, checkpoint summaries, checks, approvals, and artifact/dataset identities. Repository patches are bounded to 1 MiB each and explicitly report truncation or unavailable retained source. Binary files and LFS objects are represented by Git's binary/pointer differences; database rows are not diffed. `artifact` verifies the transferred checksum and refuses to overwrite existing output.

### Reading an artifact

Stage documents are Markdown and often long, so `o` in the dashboard (and `run artifact --open`) opens the selected artifact as a rendered page in your browser instead of as raw text in the terminal panel. Markdown gets headings, lists, tables, links and highlighted code; JSON is pretty-printed; base64 `screenshot_png` fields from the Playwright plugin become images. The page names the run, revision, stage and attempt the artifact came from, with its digest and size, and links the checkpoint's other artifacts so moving between plan, design and test results needs no terminal.

The page is served by the same loopback-only dashboard server, on `127.0.0.1` with the same per-session token, and loads nothing from the network: the Markdown is rendered in Go and the highlighter is built in, so no CDN or web font is fetched. Artifacts are agent-written, so raw HTML in them is escaped rather than rendered and dangerous link targets are dropped. Content is the same checksum-verified bytes `run artifact --output` writes.

Where no browser can be opened — an SSH session, or no `open`/`xdg-open` — the terminal preview is used instead and says why.

In Bubble Tea, `d` opens the comparison. `b` selects the current checkpoint as the comparison base; navigate to the target and press `d`. `B` resets the base, and Escape closes the loaded review. Selection and review never mutate the workflow.

## Experimental live progress and steering
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/first-workflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The dashboard shows the stage strip, your in-flight runs, and a focused panel:
| `tab` | Cycle panels: Conversation, Checkpoint, Changes, Tests, Services, Readiness, Graph, History |
| `i` | Write to the agent; `s` switches between worker and supervisor |
| `d` | Diff the stage; `b` sets a comparison base, `B` clears it |
| `o` | Open the selected artifact; `,` and `.` select |
| `o` | Open the selected artifact in your browser; `,` and `.` select |
| `[` `]` | Browse revision history |
| `r` `a` `x` | Rewind, approve, cancel |
| `g` | Open the run's pull request in your browser |
Expand Down
41 changes: 35 additions & 6 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ type Model struct {
Picker *themePicker
// OpenURL opens a link in the user's browser; tests substitute it.
OpenURL func(string) error
// OpenArtifact hands a retained artifact to a browser view. A failure is
// shown to the user and the same verified bytes are rendered in the panel.
OpenArtifact func(workflow.Artifact, workflow.Run, workflow.Revision, workflow.Checkpoint) error
BrowserWarning string
// refreshFailed marks Error as a refresh failure, the only kind a later
// successful refresh may clear. Action errors stay until the next action.
refreshFailed bool
Expand All @@ -84,9 +88,11 @@ type actionMsg struct {
notice string // replaces the generic "Saved"
}
type artifactMsg struct {
key string
body []byte
err error
key string
body []byte
err error
fallback string
opened bool
}
type diffMsg struct {
key string
Expand Down Expand Up @@ -203,11 +209,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if v.key != m.ArtifactRequest || v.key != m.viewKey() {
return m, nil
}
if v.err != nil {
if v.opened {
m.Error = ""
m.Notice = "Opened artifact in the browser"
} else if v.err != nil {
m.Error = v.err.Error()
} else {
m.ArtifactText = artifactPreview(v.body)
m.Offset = 0
m.Error = ""
if v.fallback != "" {
m.Notice = "Browser unavailable (" + v.fallback + "); showing the terminal preview"
}
}
case diffMsg:
if v.key != m.DiffRequest || v.key != m.diffKey() {
Expand Down Expand Up @@ -473,14 +486,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "o":
if cp, ok := m.selectedCheckpoint(); ok && len(cp.Result.Artifacts) > 0 {
m.DiffRequest = ""
m.ArtifactText = ""
artifact := cp.Result.Artifacts[min(m.ArtifactIndex, len(cp.Result.Artifacts)-1)]
m.ArtifactRequest = m.viewKey()
api, digest, request := m.API, artifact.Digest, m.ArtifactRequest
api, digest, request, opener := m.API, artifact.Digest, m.ArtifactRequest, m.OpenArtifact
run, revision, checkpoint := *m.current(), *m.viewRevision(), cp
warning := m.BrowserWarning
return m, func() tea.Msg {
if opener != nil {
if err := opener(artifact, run, revision, checkpoint); err == nil {
return artifactMsg{key: request, opened: true}
} else {
warning = err.Error()
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
raw, err := api.Artifact(ctx, digest)
return artifactMsg{key: request, body: raw, err: err}
return artifactMsg{key: request, body: raw, err: err, fallback: warning}
}
}
case "b":
Expand Down Expand Up @@ -961,6 +984,10 @@ type Options struct {
Settings Settings
// Warning is shown on the first frame, for example an unreadable config.
Warning string
// OpenArtifact opens a document in the browser and is optional so the TUI
// remains useful over SSH or on systems without a browser opener.
OpenArtifact func(workflow.Artifact, workflow.Run, workflow.Revision, workflow.Checkpoint) error
BrowserWarning string
}

func Run(ctx context.Context, api API, root string, opts Options) error {
Expand All @@ -970,6 +997,8 @@ func Run(ctx context.Context, api API, root string, opts Options) error {
m.Theme.override = opts.Theme
m.Theme.resolve()
m.Error = opts.Warning
m.OpenArtifact = opts.OpenArtifact
m.BrowserWarning = opts.BrowserWarning
_, err := tea.NewProgram(m, tea.WithContext(ctx)).Run()
return err
}
27 changes: 27 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,30 @@ func TestRefreshPreservesSelectedRun(t *testing.T) {
t.Fatal("selection jumped after reorder")
}
}

func TestBrowserFailureFallsBackToTheVerifiedTerminalPreview(t *testing.T) {
m := modelFixture(t)
m.Runs[0].Current().Checkpoints["plan"] = workflow.Checkpoint{
Node: "plan",
Attempt: "attempt_1",
Result: workflow.Result{Artifacts: []workflow.Artifact{{Name: "plan.md", Digest: strings.Repeat("a", 64), MediaType: "text/markdown"}}},
}
m.OpenArtifact = func(workflow.Artifact, workflow.Run, workflow.Revision, workflow.Checkpoint) error {
return errors.New("xdg-open is unavailable")
}
order, _ := m.viewRevision().Config.Workflow.Order()
for i, id := range order {
if id == "plan" {
m.Node = i
}
}
m, cmd := key(m, "o")
if cmd == nil {
t.Fatal("o did not request an artifact")
}
next, _ := m.Update(cmd())
m = next.(Model)
if m.ArtifactText != "artifact" || !strings.Contains(m.Notice, "Browser unavailable (xdg-open is unavailable)") {
t.Fatalf("fallback preview: notice %q text %q", m.Notice, m.ArtifactText)
}
}
11 changes: 4 additions & 7 deletions internal/tui/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import (
"runtime"
)

// OpenInBrowser hands a URL to the platform's opener without waiting for the
// browser, so the dashboard stays responsive.
// OpenInBrowser hands a URL to the platform's opener. Waiting for the opener
// command to report failure is necessary so callers can show a terminal
// fallback when an SSH session has no browser integration.
func OpenInBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
Expand All @@ -18,9 +19,5 @@ func OpenInBrowser(url string) error {
default:
return errors.New("no browser opener for " + runtime.GOOS)
}
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
return cmd.Run()
}
Loading
Loading