Add expose_port previews with a Preview tab; leave setup to AGENTS.md

This commit is contained in:
Ian Gulliver
2026-10-15 13:15:49 -07:00
parent 86e4e772ed
commit f95a0e5332
32 changed files with 1042 additions and 431 deletions
+2 -1
View File
@@ -61,7 +61,8 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
}
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
g := guidance + fmt.Sprintf("\n- This task's ports are %d to %d; $PORT holds the first. Run dev servers and anything else that listens on these, so parallel tasks don't collide.",
g := guidance + fmt.Sprintf("\n- This task's ports are %d to %d; $PORT holds the first. Run dev servers and anything else that listens on these, so parallel tasks don't collide, and listen on 127.0.0.1. To show the human a running server, call mcp__byre__expose_port."+
"\n- This worktree starts with only what git tracks. Before other work in a new worktree, do any setup the project's AGENTS.md describes.",
task.PortBase, task.PortBase+store.PortBlock-1)
if task.TodoItem != nil {
g += fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
+3 -3
View File
@@ -297,15 +297,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("show",
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), preview (the dev servers the task's agent exposed with expose_port), inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Omit both task_id and project_id to clear the pane. "+
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
"Otherwise refused while the human is working in the right pane, unless it is on the project tab or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "project"]}
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"]}
}, "required": ["tabs", "tab"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
f := api.Focus{}
+16 -25
View File
@@ -62,10 +62,6 @@ type Manager struct {
snapMu sync.Mutex
snaps map[string]*snapState
setupMu sync.Mutex
setups map[string]bool
setupFailures map[string]string
mergeMu sync.Mutex
mergeHolders map[string]string
@@ -102,21 +98,19 @@ type live struct {
func New(st *store.Store, limits Limits, worktreeRoot, leadRoot string) *Manager {
m := &Manager{
store: st,
limits: limits,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocused: map[string]bool{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},
setups: map[string]bool{},
setupFailures: map[string]string{},
mergeHolders: map[string]string{},
bad: newBadLines(),
watchStop: make(chan struct{}),
store: st,
limits: limits,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocused: map[string]bool{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},
mergeHolders: map[string]string{},
bad: newBadLines(),
watchStop: make(chan struct{}),
}
go m.signalLoop()
return m
@@ -422,7 +416,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
return api.AgentSession{}, err
}
if room && !m.settingUp(task.Id) {
if room {
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
}
@@ -516,9 +510,6 @@ func (m *Manager) drain(ctx context.Context) error {
}
for _, q := range queued {
if m.settingUp(q.TaskID) {
continue
}
room, err := m.hasRoom(ctx, q.Vendor)
if err != nil {
return err
@@ -669,9 +660,9 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
if err != nil {
return nil, err
}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.exposePortTool(l)}
hooks := map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: m.remindTaskGuidance(l, g)}},
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.afterTool(l)}},
}
+118
View File
@@ -0,0 +1,118 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"time"
"unicode/utf8"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/tailscale"
)
const maxPreviewName = 40
func portEnv(t api.Task) []string {
return []string{"PORT=" + strconv.Itoa(t.PortBase)}
}
type exposePortArgs struct {
Name string `json:"name"`
Port int `json:"port"`
}
func (m *Manager) exposePortTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "expose_port",
Description: "Show the human a server you are running, such as a dev server, in the task's Preview tab. " +
"Start the server yourself first (in the background), listening on 127.0.0.1 (not 0.0.0.0 or localhost) on a port in this task's block. " +
"byre then serves it over HTTPS on the same port of byre's host through tailscale serve, and returns the URL. " +
"name labels it in the tab (\"web\", \"storybook\"); exposing a name or port again replaces the earlier one. " +
"The server stops when your process does (a byre restart, compaction, the task closing); the human sees it as unreachable until you start it again.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"name": {"type": "string", "description": "Short label, shown in the Preview tab."},
"port": {"type": "integer", "description": "The port the server listens on, at 127.0.0.1, within this task's block."}
},
"required": ["name", "port"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.exposePort(ctx, l, raw)
},
}
}
func (m *Manager) exposePort(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := exposePortArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed expose_port call: %w", err)
}
name := strings.TrimSpace(args.Name)
if name == "" || strings.ContainsAny(name, "\r\n") || utf8.RuneCountInString(name) > maxPreviewName {
return "", fmt.Errorf("name must be one non-empty line of at most %d characters", maxPreviewName)
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
last := task.PortBase + store.PortBlock - 1
if args.Port < task.PortBase || args.Port > last {
return "", fmt.Errorf("port %d is outside this task's block, %d to %d", args.Port, task.PortBase, last)
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(args.Port))
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
return "", fmt.Errorf("nothing is listening on %s; start the server listening on 127.0.0.1 first: %w", addr, err)
}
conn.Close()
err = tailscale.Serve(ctx, args.Port)
if err != nil {
return "", err
}
dropped, err := m.store.SetPreview(ctx, api.Preview{TaskId: task.Id, Name: name, Port: args.Port, CreatedAt: time.Now().UTC()}, agentActor)
if errors.Is(err, store.ErrNotFound) {
return "", errors.New("this task is no longer active")
}
if err != nil {
return "", err
}
for _, port := range dropped {
err = tailscale.Off(ctx, port)
if err != nil {
slog.Error("stop serving replaced preview", "task", task.Id, "port", port, "err", err)
}
}
return fmt.Sprintf("Exposed %q: https://<byre's host>:%d/ proxies to %s, and the human sees it in the task's Preview tab.", name, args.Port, addr), nil
}
func (m *Manager) Unexpose(ctx context.Context, taskID string) {
previews, err := m.store.ListPreviews(ctx, taskID)
if err != nil {
slog.Error("list previews", "task", taskID, "err", err)
return
}
for _, p := range previews {
err = tailscale.Off(ctx, p.Port)
if err != nil {
slog.Error("stop serving preview", "task", taskID, "port", p.Port, "err", err)
continue
}
slog.Info("stopped serving preview", "task", taskID, "name", p.Name, "port", p.Port)
}
}
+71
View File
@@ -0,0 +1,71 @@
package agents
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func TestExposePortRefusals(t *testing.T) {
ctx := context.Background()
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
m := New(st, DefaultLimits, t.TempDir(), t.TempDir())
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1"}
call := func(name string, port int) error {
raw, err := json.Marshal(exposePortArgs{Name: name, Port: port})
if err != nil {
t.Fatalf("marshal: %v", err)
}
_, err = m.exposePort(ctx, l, raw)
return err
}
cases := []struct {
name string
port int
want string
}{
{"", 41000, "name must be"},
{"two\nlines", 41000, "name must be"},
{strings.Repeat("x", maxPreviewName+1), 41000, "name must be"},
{"web", 40999, "outside this task's block, 41000 to 41009"},
{"web", 41010, "outside this task's block, 41000 to 41009"},
{"web", 41009, "nothing is listening on 127.0.0.1:41009"},
}
for _, c := range cases {
err = call(c.name, c.port)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("expose_port(%q, %d) = %v, want an error containing %q", c.name, c.port, err, c.want)
}
}
previews, err := st.ListPreviews(ctx, "t1")
if err != nil || len(previews) != 0 {
t.Fatalf("previews after refusals = %v, %v", previews, err)
}
}
-113
View File
@@ -1,113 +0,0 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
)
const (
setupScript = "script/setup"
setupTimeout = 15 * time.Minute
setupOutputBytes = 64 << 10
)
func portEnv(t api.Task) []string {
return []string{"PORT=" + strconv.Itoa(t.PortBase)}
}
func (m *Manager) startSetup(t api.Task) {
path := filepath.Join(t.WorktreePath, setupScript)
_, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return
}
m.setupMu.Lock()
m.setups[t.Id] = true
m.setupMu.Unlock()
go m.runSetup(t, path)
}
func (m *Manager) settingUp(taskID string) bool {
m.setupMu.Lock()
defer m.setupMu.Unlock()
return m.setups[taskID]
}
func (m *Manager) takeSetupFailure(taskID string) string {
m.setupMu.Lock()
defer m.setupMu.Unlock()
msg := m.setupFailures[taskID]
delete(m.setupFailures, taskID)
return msg
}
func (m *Manager) runSetup(t api.Task, path string) {
ctx := context.Background()
m.emitTask(ctx, t, "setup.started", map[string]any{"script": setupScript, "port_base": t.PortBase})
start := time.Now()
cctx, cancel := context.WithTimeout(ctx, setupTimeout)
defer cancel()
cmd := exec.CommandContext(cctx, path)
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), portEnv(t)...)
out, err := cmd.CombinedOutput()
if cctx.Err() != nil {
err = fmt.Errorf("timed out after %s", setupTimeout)
}
output := string(out)
if len(out) > setupOutputBytes {
output = "…" + string(out[len(out)-setupOutputBytes:])
}
payload := map[string]any{"script": setupScript, "output": output, "duration_ms": time.Since(start).Milliseconds()}
if err != nil {
payload["error"] = err.Error()
}
slog.Info("setup ended", "task", t.Id, "err", err, "took", time.Since(start))
m.emitTask(ctx, t, "setup.ended", payload)
m.setupMu.Lock()
delete(m.setups, t.Id)
if err != nil {
m.setupFailures[t.Id] = fmt.Sprintf("byre ran %s in this worktree when the task was created, and it failed (%s). Its output:\n\n```\n%s\n```\n\nThe worktree may be missing dependencies or env files the setup would have provided.", setupScript, err, output)
}
m.setupMu.Unlock()
err = m.drain(ctx)
if err != nil {
slog.Error("start queued turns", "err", err)
}
}
func (m *Manager) emitTask(ctx context.Context, t api.Task, kind string, payload map[string]any) {
err := m.store.AppendEvent(ctx, api.Event{Kind: kind, ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: "byre", Payload: payload})
if err != nil {
slog.Error("record setup event", "task", t.Id, "kind", kind, "err", err)
}
}
func (m *Manager) remindTaskGuidance(l *live, text string) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
failure := m.takeSetupFailure(l.taskID)
if failure == "" {
return remindGuidance(text)(ctx, input)
}
m.fileError(l, failure)
return remindGuidance(text+"\n\n"+failure)(ctx, input)
}
}
-91
View File
@@ -1,91 +0,0 @@
package agents_test
import (
"os"
"path/filepath"
"strings"
"testing"
"g.fc.run/misc/byre/internal/api"
)
func (e *env) commitSetup(script string) {
e.t.Helper()
err := os.MkdirAll(filepath.Join(e.repo, "script"), 0o755)
if err != nil {
e.t.Fatal(err)
}
err = os.WriteFile(filepath.Join(e.repo, "script", "setup"), []byte(script), 0o755)
if err != nil {
e.t.Fatal(err)
}
gitOut(e.t, e.repo, "add", "script/setup")
gitOut(e.t, e.repo, "commit", "--quiet", "--message", "setup")
}
func (e *env) waitSetup(taskID string) api.Event {
e.t.Helper()
seen := e.waitFor("setup.ended", func(ev api.Event) bool {
return ev.Kind == "setup.ended" && ev.TaskId != nil && *ev.TaskId == taskID
})
return seen[len(seen)-1]
}
func TestSetupScriptAndPorts(t *testing.T) {
e := newEnv(t)
e.commitSetup("#!/bin/sh\nset -e\ncp \"$(git rev-parse --path-format=absolute --git-common-dir)/../.env\" .env\necho \"port $PORT\"\n")
err := os.WriteFile(filepath.Join(e.repo, ".env"), []byte("SECRET=1\n"), 0o644)
if err != nil {
t.Fatal(err)
}
m := e.manager()
task, err := m.CreateTask(e.ctx, e.project, "with setup", login, "main", login)
if err != nil {
t.Fatalf("create task: %v", err)
}
if task.PortBase != 41010 {
t.Fatalf("port_base = %d, want 41010 (41000 is taken by the first task)", task.PortBase)
}
ev := e.waitSetup(task.Id)
if ev.Payload["error"] != nil {
t.Fatalf("setup failed: %v", ev.Payload)
}
want := "port 41010"
if out, _ := ev.Payload["output"].(string); !strings.Contains(out, want) {
t.Fatalf("output %q lacks %q", out, want)
}
env, err := os.ReadFile(filepath.Join(task.WorktreePath, ".env"))
if err != nil || string(env) != "SECRET=1\n" {
t.Fatalf("worktree .env = %q, %v", env, err)
}
_, err = e.store.DiscardTask(e.ctx, e.task.Id, login)
if err != nil {
t.Fatalf("discard: %v", err)
}
reused, err := m.CreateTask(e.ctx, e.project, "reuses ports", login, "main", login)
if err != nil {
t.Fatalf("create task: %v", err)
}
if reused.PortBase != 41000 {
t.Fatalf("port_base = %d, want the freed 41000", reused.PortBase)
}
e.waitSetup(reused.Id)
}
func TestSetupFailure(t *testing.T) {
e := newEnv(t)
e.commitSetup("#!/bin/sh\necho installing\nexit 3\n")
m := e.manager()
task, err := m.CreateTask(e.ctx, e.project, "failing setup", login, "main", login)
if err != nil {
t.Fatalf("create task: %v", err)
}
ev := e.waitSetup(task.Id)
if ev.Payload["error"] != "exit status 3" || ev.Payload["output"] != "installing\n" {
t.Fatalf("setup.ended payload = %v", ev.Payload)
}
}
+1 -6
View File
@@ -85,12 +85,7 @@ func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Tas
if err != nil {
return api.Task{}, err
}
t, err = m.store.GetTask(ctx, t.Id)
if err != nil {
return api.Task{}, err
}
m.startSetup(t)
return t, nil
return m.store.GetTask(ctx, t.Id)
}
type createTaskArgs struct {
+197 -151
View File
@@ -326,6 +326,7 @@ const (
TabChanges TaskTab = "changes"
TabFiles TaskTab = "files"
TabInbox TaskTab = "inbox"
TabPreview TaskTab = "preview"
TabProject TaskTab = "project"
TabTerminal TaskTab = "terminal"
TabTranscript TaskTab = "transcript"
@@ -340,6 +341,8 @@ func (e TaskTab) Valid() bool {
return true
case TabInbox:
return true
case TabPreview:
return true
case TabProject:
return true
case TabTerminal:
@@ -671,6 +674,16 @@ type Participant struct {
Login string `json:"login"`
}
// Preview defines model for Preview.
type Preview struct {
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
// Port A port in the task's block, served at https://<byre's host>:<port>/.
Port int `json:"port"`
TaskId string `json:"task_id"`
}
// Project defines model for Project.
type Project struct {
CreatedAt time.Time `json:"created_at"`
@@ -818,7 +831,7 @@ type Task struct {
// MergeTitle The title of the squash commit Merge makes: the task title until the agent sets another with set_merge_title.
MergeTitle string `json:"merge_title"`
// PortBase The first of the task's block of ports (port_base to port_base+9), passed to its setup script and agent as PORT.
// PortBase The first of the task's block of ports (port_base to port_base+9), passed to its agent and terminal as PORT.
PortBase int `json:"port_base"`
ProjectId string `json:"project_id"`
Status TaskStatus `json:"status"`
@@ -1161,6 +1174,9 @@ type ServerInterface interface {
// (POST /api/tasks/{taskId}/merge-in)
MergeIn(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/previews)
ListPreviews(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/sessions)
ListSessions(w http.ResponseWriter, r *http.Request, taskId string)
@@ -2317,6 +2333,32 @@ func (siw *ServerInterfaceWrapper) MergeIn(w http.ResponseWriter, r *http.Reques
handler.ServeHTTP(w, r)
}
// ListPreviews operation middleware
func (siw *ServerInterfaceWrapper) ListPreviews(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ListPreviews(w, r, taskId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListSessions operation middleware
func (siw *ServerInterfaceWrapper) ListSessions(w http.ResponseWriter, r *http.Request) {
@@ -2662,6 +2704,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/dir", wrapper.GetTaskDir)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/file", wrapper.GetTaskFile)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/terminal", wrapper.GetTaskTerminal)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/previews", wrapper.ListPreviews)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots", wrapper.ListSnapshots)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots/{n}/fork", wrapper.ForkSnapshot)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
@@ -2686,156 +2729,159 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"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",
"psIcQ62m/PNwc870d7vDHz/+3wAAAP//",
"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",
"889Dx6XpH3aH37//fwEAAP//",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+16
View File
@@ -83,6 +83,7 @@ func (s *Server) publish(ev api.Event) {
s.agents.Observe(ev)
if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil {
s.terminals.close(*ev.TaskId)
go s.agents.Unexpose(context.Background(), *ev.TaskId)
}
}
@@ -535,6 +536,21 @@ func (s *Server) ListSnapshots(w http.ResponseWriter, r *http.Request, taskID st
writeJSON(w, http.StatusOK, snaps)
}
func (s *Server) ListPreviews(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
previews, err := s.store.ListPreviews(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, previews)
}
func (s *Server) ForkSnapshot(w http.ResponseWriter, r *http.Request, taskID string, n int) {
t, p, ok := s.activeTask(w, r, taskID)
if !ok {
+13
View File
@@ -42,4 +42,17 @@ func TestPortBlocksStartAtPortFirst(t *testing.T) {
t.Fatalf("task %s port_base = %d, want %d", id, task.PortBase, want)
}
}
_, err = st.DiscardTask(ctx, "t1", "alice")
if err != nil {
t.Fatalf("discard: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t3", ProjectId: "p1", Title: "t3", Branch: "t3", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task t3: %v", err)
}
reused, err := st.GetTask(ctx, "t3")
if err != nil || reused.PortBase != 51000 {
t.Fatalf("t3 port_base = %d, want the freed 51000: %v", reused.PortBase, err)
}
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"context"
"database/sql"
"errors"
"g.fc.run/misc/byre/internal/api"
)
const previewColumns = `task_id, name, port, created_at`
func (s *Store) SetPreview(ctx context.Context, p api.Preview, actor string) ([]int, error) {
dropped := []int{}
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
projectID := ""
err := tx.QueryRowContext(ctx, `SELECT project_id FROM tasks WHERE id = ? AND status = ?`, p.TaskId, string(api.TaskStatusActive)).Scan(&projectID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `DELETE FROM previews WHERE task_id = ? AND (name = ? OR port = ?) RETURNING port`, p.TaskId, p.Name, p.Port)
if err != nil {
return err
}
for rows.Next() {
port := 0
err = rows.Scan(&port)
if err != nil {
rows.Close()
return err
}
if port != p.Port {
dropped = append(dropped, port)
}
}
rows.Close()
err = rows.Err()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO previews (`+previewColumns+`) VALUES (?, ?, ?, ?)`, p.TaskId, p.Name, p.Port, formatTime(p.CreatedAt))
if err != nil {
return err
}
return emit(api.Event{Kind: "preview.exposed", ProjectId: &projectID, TaskId: &p.TaskId, Actor: actor, Payload: ToPayload(p)})
})
return dropped, err
}
func (s *Store) ListPreviews(ctx context.Context, taskID string) ([]api.Preview, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+previewColumns+` FROM previews WHERE task_id = ? ORDER BY name`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
previews := []api.Preview{}
for rows.Next() {
p := api.Preview{}
createdAt := ""
err = rows.Scan(&p.TaskId, &p.Name, &p.Port, &createdAt)
if err != nil {
return nil, err
}
p.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
}
previews = append(previews, p)
}
return previews, rows.Err()
}
+90
View File
@@ -0,0 +1,90 @@
package store_test
import (
"context"
"errors"
"fmt"
"path/filepath"
"slices"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func TestSetPreviewReplacesNameAndPort(t *testing.T) {
ctx := context.Background()
events := []api.Event{}
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(ev api.Event) { events = append(events, ev) })
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "t1", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
set := func(name string, port int) []int {
t.Helper()
dropped, err := st.SetPreview(ctx, api.Preview{TaskId: "t1", Name: name, Port: port, CreatedAt: now}, "agent")
if err != nil {
t.Fatalf("set preview %s:%d: %v", name, port, err)
}
return dropped
}
list := func() []string {
t.Helper()
previews, err := st.ListPreviews(ctx, "t1")
if err != nil {
t.Fatalf("list previews: %v", err)
}
out := []string{}
for _, p := range previews {
out = append(out, fmt.Sprintf("%s:%d", p.Name, p.Port))
}
return out
}
if d := set("web", 41001); len(d) != 0 {
t.Fatalf("first preview dropped %v", d)
}
if d := set("api", 41002); len(d) != 0 {
t.Fatalf("second preview dropped %v", d)
}
if d := set("web", 41001); len(d) != 0 {
t.Fatalf("re-exposing the same name and port dropped %v", d)
}
if d := set("web", 41003); !slices.Equal(d, []int{41001}) {
t.Fatalf("moving web to 41003 dropped %v, want [41001]", d)
}
if d := set("storybook", 41002); len(d) != 0 {
t.Fatalf("renaming 41002 dropped %v, want none", d)
}
if got, want := list(), []string{"storybook:41002", "web:41003"}; !slices.Equal(got, want) {
t.Fatalf("previews = %v, want %v", got, want)
}
if last := events[len(events)-1]; last.Kind != "preview.exposed" || last.Payload["name"] != "storybook" {
t.Fatalf("last event = %s %v", last.Kind, last.Payload)
}
_, err = st.DiscardTask(ctx, "t1", "alice")
if err != nil {
t.Fatalf("discard: %v", err)
}
_, err = st.SetPreview(ctx, api.Preview{TaskId: "t1", Name: "late", Port: 41004, CreatedAt: now}, "agent")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("set preview on a closed task: %v, want ErrNotFound", err)
}
}
+9
View File
@@ -271,6 +271,15 @@ UPDATE tasks SET port_base = (
`}, {"add-task-merge-queue", `
ALTER TABLE tasks ADD COLUMN merge_queued_by TEXT NOT NULL DEFAULT '';
ALTER TABLE tasks ADD COLUMN merge_queued_at TEXT NOT NULL DEFAULT '';
`}, {"add-task-previews", `
CREATE TABLE previews (
task_id TEXT NOT NULL REFERENCES tasks(id),
name TEXT NOT NULL,
port INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (task_id, name),
UNIQUE (task_id, port)
);
`}}
const (
+27
View File
@@ -0,0 +1,27 @@
package tailscale
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
)
const cli = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
func Serve(ctx context.Context, port int) error {
return run(ctx, "serve", "--bg", "--https="+strconv.Itoa(port), "http://127.0.0.1:"+strconv.Itoa(port))
}
func Off(ctx context.Context, port int) error {
return run(ctx, "serve", "--https="+strconv.Itoa(port), "off")
}
func run(ctx context.Context, args ...string) error {
out, err := exec.CommandContext(ctx, cli, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("tailscale %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}