Add expose_port previews with a Preview tab; leave setup to AGENTS.md
This commit is contained in:
@@ -4,9 +4,6 @@
|
||||
|
||||
# M2 — Seeing the work
|
||||
|
||||
- Previews
|
||||
Web: byre supervises a per-task dev server and exposes it over Tailscale as a per-task port through `tailscale serve --bg --https=<port> http://127.0.0.1:<port>`, since path-prefix proxying breaks apps that use absolute paths. Any port in the task's block works, and identity headers pass through. While a port is served, a dev server can bind `127.0.0.1` on the same port but not `0.0.0.0` ("Address already in use"), so the dev server must listen on loopback. iOS: on-demand simulator screenshots and recordings attached to the task, with Screen Sharing over Tailscale as the manual fallback. Shown as a pane tab.
|
||||
|
||||
# M3 — Second vendor and review
|
||||
|
||||
- Codex adapter
|
||||
|
||||
+30
-3
@@ -507,6 +507,24 @@ paths:
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/tasks/{taskId}/previews:
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
operationId: listPreviews
|
||||
description: >
|
||||
The ports the task's agent exposed with expose_port, by name. Each is served by tailscale serve on the same
|
||||
port number of byre's host, over HTTPS, until the task is merged or discarded.
|
||||
responses:
|
||||
"200":
|
||||
description: The previews.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Preview" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/tasks/{taskId}/snapshots:
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
@@ -854,7 +872,7 @@ components:
|
||||
branch: { type: string }
|
||||
worktree_path: { type: string }
|
||||
base_ref: { type: string }
|
||||
port_base: { type: integer, description: "The first of the task's block of ports (port_base to port_base+9), passed to its setup script and agent as PORT." }
|
||||
port_base: { type: integer, description: "The first of the task's block of ports (port_base to port_base+9), passed to its agent and terminal as PORT." }
|
||||
status:
|
||||
type: string
|
||||
enum: [active, merged, discarded]
|
||||
@@ -997,6 +1015,15 @@ components:
|
||||
description: "message: the agent wrote text after one or more tool calls; turn_end: after byre's end-of-turn commit. tool_call, ask_human and timer appear only on snapshots taken by earlier builds."
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
Preview:
|
||||
type: object
|
||||
required: [task_id, name, port, created_at]
|
||||
properties:
|
||||
task_id: { type: string }
|
||||
name: { type: string }
|
||||
port: { type: integer, description: "A port in the task's block, served at https://<byre's host>:<port>/." }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
AgentSession:
|
||||
type: object
|
||||
required: [id, task_id, owner, vendor, model, vendor_session_id, state, role, created_by, created_at]
|
||||
@@ -1179,8 +1206,8 @@ components:
|
||||
|
||||
TaskTab:
|
||||
type: string
|
||||
enum: [transcript, changes, files, terminal, inbox, project]
|
||||
x-enum-varnames: [TabTranscript, TabChanges, TabFiles, TabTerminal, TabInbox, TabProject]
|
||||
enum: [transcript, changes, files, terminal, preview, inbox, project]
|
||||
x-enum-varnames: [TabTranscript, TabChanges, TabFiles, TabTerminal, TabPreview, TabInbox, TabProject]
|
||||
|
||||
TerminalSize:
|
||||
type: object
|
||||
|
||||
+13
-9
@@ -10,6 +10,7 @@
|
||||
- `internal/agents`: the agent session manager. It owns the running Claude processes, task agents and lead agents alike (see "Lead agent"), and records their activity as events.
|
||||
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
|
||||
- `internal/push`: web push notifications for new inbox items (see "Notifications").
|
||||
- `internal/tailscale`: `tailscale serve` for task previews (see "Previews").
|
||||
- `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid, see "Inbox"; xterm.js, see "Terminals"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`.
|
||||
|
||||
## Data
|
||||
@@ -22,10 +23,8 @@ Schema changes are the `migrations` slice in `internal/store/store.go`: named en
|
||||
|
||||
Every task worktree is made by `createTask` (`internal/agents/tasks.go`): new tasks, `create_task`, `start_todo` and forks alike.
|
||||
|
||||
- **Ports.** Each active task holds a block of `store.PortBlock` ports starting at `tasks.port_base` (`port_base` on the Task). `Store.CreateTask` gives a new task the lowest block from `byre serve --ports` (default `store.DefaultPortFirst`) up that no active task holds, so a merged or discarded task's block is reused. The agent's `claude` process gets `PORT` (the first port, which most dev servers read on their own), and the session guidance names the range and says to run dev servers on it. `sqlite3 byre.db "SELECT id, port_base FROM tasks WHERE status = 'active'"` lists the blocks in use.
|
||||
- **Setup script.** If the new worktree has an executable `script/setup` (`setupScript` in `internal/agents/setup.go`), byre runs it in the background, in the worktree, with `PORT` added to byre's environment. Nothing in its interface is byre-specific: a script that copies untracked files such as `.env` from the project's checkout finds it as the parent of `git rev-parse --path-format=absolute --git-common-dir`. It is the project's own file, so a branch that changes it changes the setup of tasks started or forked from that branch. It is killed after `setupTimeout`. The task's transcript shows a `setup.started` note and a `setup.ended` note holding the last `setupOutputBytes` of its combined output (`output`), how long it took (`duration_ms`) and `error` when it failed.
|
||||
- **Waiting.** While setup runs, the task's turns are queued as if the caps were full (`Manager.settingUp`, checked in `turn` and `drain`), so the session shows `queued`; when setup ends, byre drains the queue. Task creation itself doesn't wait.
|
||||
- **Failure.** A failed setup doesn't stop the agent. The next prompt its session submits gets the failure and the output appended to the `UserPromptSubmit` guidance (`remindTaskGuidance`), and at that moment byre files the same text as a `session_error` inbox item. The setup state and failure live in memory, so a byre restart during setup leaves the queued turn to start without it and nothing reruns the script.
|
||||
- **Ports.** Each active task holds a block of `store.PortBlock` ports starting at `tasks.port_base` (`port_base` on the Task). `Store.CreateTask` gives a new task the lowest block from `byre serve --ports` (default `store.DefaultPortFirst`) up that no active task holds, so a merged or discarded task's block is reused. The agent's `claude` process gets `PORT` (the first port, which most dev servers read on their own), and the session guidance names the range, says to run dev servers on it listening on `127.0.0.1`, and points at `expose_port` (see "Previews"). `sqlite3 byre.db "SELECT id, port_base FROM tasks WHERE status = 'active'"` lists the blocks in use.
|
||||
- **Setup.** byre runs nothing in a new worktree. The session guidance says the worktree starts with only what git tracks and tells the agent to do any setup the project's AGENTS.md describes before other work, so installing dependencies or copying untracked files such as `.env` is the project's own instruction, carried out by the agent with its own tools. A project's checkout is the parent of `git rev-parse --path-format=absolute --git-common-dir` in the worktree.
|
||||
|
||||
## Closing a task
|
||||
|
||||
@@ -60,16 +59,21 @@ After rebuilding: `go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/
|
||||
|
||||
Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered.
|
||||
|
||||
## Previews
|
||||
|
||||
An agent shows the human a server it runs with the `expose_port` MCP tool (`internal/agents/preview.go`): `name` labels it, `port` is where it listens. The agent starts the server itself, usually as a background command; byre doesn't supervise it, so it dies with the agent's `claude` process (a byre restart, compaction, the task closing) and the agent starts it again when asked. The call is refused when `port` is outside the task's block or nothing accepts a connection on `127.0.0.1:<port>`. Otherwise byre runs `tailscale serve --bg --https=<port> http://127.0.0.1:<port>` (`internal/tailscale`, which runs the macOS CLI at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`, since byre's environment has no `tailscale` on its `PATH`), so the server is at `https://<byre's ts.net name>:<port>/` with Tailscale's identity headers, and records a row in the `previews` table (task, name, port, time) with a `preview.exposed` event, which the transcript shows as a note linking the URL. Each port gets its own HTTPS port rather than a path under byre's, since path-prefix proxying breaks apps that use absolute paths. Exposing a name or port the task already has replaces that row, and a port it no longer uses is turned off with `tailscale serve --https=<port> off`. A server must listen on `127.0.0.1`: while `tailscale serve` holds a port, binding `0.0.0.0` on it fails with "address already in use". When the task is merged or discarded, byre turns off each of its ports (`Manager.Unexpose`); the rows stay. `GET /api/tasks/{taskId}/previews` lists a task's rows by name, and `tailscale serve status` shows what is served.
|
||||
|
||||
The **Preview** tab (`web/src/preview.ts`) lists the task's previews by name and port, shows the selected one (the first by default; the choice is kept per task in memory) in an iframe, and links it in a new tab. It fetches the list when it opens, when the selected task changes, and on each `preview.exposed` in the task's stream, which also reloads the iframe.
|
||||
|
||||
## Previewing byre
|
||||
|
||||
A task's build of byre runs alongside the service with its own empty state, from the worktree root, on the task's port:
|
||||
A task's build of byre runs alongside the service with its own empty state, from the worktree root, on the task's port, and the agent exposes it with `expose_port`:
|
||||
|
||||
```
|
||||
go run ./cmd/byre serve --dev --data "$PWD/.preview" --listen 127.0.0.1:$PORT --ports 51000
|
||||
tailscale serve --bg --https=$PORT http://127.0.0.1:$PORT
|
||||
```
|
||||
|
||||
It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare <checkout> .preview/origin.git`, then clone that), never the real checkout. `tailscale serve --https=$PORT off` removes the preview's port; `tailscale serve status` lists what is served. byre's own environment doesn't have `tailscale` on its `PATH`; on macOS the CLI is `/Applications/Tailscale.app/Contents/MacOS/Tailscale`.
|
||||
It must never share the service's data directory: nothing locks the database, and startup resumes every session marked `running` in its recorded worktree. `--ports` puts the preview's own tasks' port blocks in a range the service's tasks don't use; each instance only knows its own tasks, so with the default both would hand out blocks from the same first port, starting with the one the preview listens on. `.preview/` is gitignored, so the end-of-turn commit leaves it out. The preview starts with no users, projects or sessions; set a git identity with `PUT /api/me` and add a project to try things. A project's Merge pushes to its checkout's `origin`, so a preview project should be a clone whose `origin` is a local bare repository (`git clone --bare <checkout> .preview/origin.git`, then clone that), never the real checkout.
|
||||
|
||||
## Identity
|
||||
|
||||
@@ -95,7 +99,7 @@ Agents don't commit; byre does. Each `user.message` event that starts a turn rec
|
||||
|
||||
byre chooses the Claude session ID (`vendor_session_id`) up front, so every session can be resumed. Processes start on demand: the first message after a restart resumes the session with `--resume`. Sessions are `running` from the moment a turn is sent until its `result` arrives; on startup, every session still marked `running` was cut off mid-turn, and byre resumes it and sends a message telling the agent to re-check the working tree. Stopping the server kills all agent processes. Merging or discarding a task kills its sessions and marks them `completed`.
|
||||
|
||||
Every session gets byre's guidance (`guidance` in `internal/agents/guidance.go`): ask questions with `ask_human`, end each turn with a final message that summarizes it (including every departure from the plan), put everything the human needs in a blocking question's context since that turn files no report, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range (see "Worktree setup and ports"). A task started from a `TODO.md` item gets one more line naming the item and telling the agent to remove it from `TODO.md` in its worktree when the work is done (`taskGuidance`; see "TODO"). It is passed as `--append-system-prompt`, which Claude Code records on a session's first request and reuses on resume, so a running session keeps the text it started with; a `UserPromptSubmit` hook also attaches it as `additionalContext` to every user turn, which reaches existing sessions too.
|
||||
Every session gets byre's guidance (`guidance` in `internal/agents/guidance.go`): ask questions with `ask_human`, end each turn with a final message that summarizes it (including every departure from the plan), put everything the human needs in a blocking question's context since that turn files no report, don't commit (byre commits each turn), and don't push, touch other branches or run git maintenance. A task agent's guidance also names its port range and tells it to do the setup AGENTS.md describes in a new worktree (see "Worktree setup and ports"). A task started from a `TODO.md` item gets one more line naming the item and telling the agent to remove it from `TODO.md` in its worktree when the work is done (`taskGuidance`; see "TODO"). It is passed as `--append-system-prompt`, which Claude Code records on a session's first request and reuses on resume, so a running session keeps the text it started with; a `UserPromptSubmit` hook also attaches it as `additionalContext` to every user turn, which reaches existing sessions too.
|
||||
|
||||
A Stop hook (`bounceProseQuestion` in `internal/agents/guidance.go`) bounces a turn whose final message asks the human something in prose: if `ask_human` wasn't called during the turn and the final message (`last_assistant_message`) has a sentence ending in `?` outside code spans, code blocks, quotes and blockquotes, the stop is blocked once with an instruction to ask through `ask_human`. The per-turn flags reset when the next user message is sent. Claude Code reports the block as a "Stop hook error" notification; that is expected. An agent told explicitly to ask in plain text will refuse the bounce.
|
||||
|
||||
@@ -137,7 +141,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
|
||||
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can also call `compact_self` at a logical point; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
|
||||
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
|
||||
|
||||
The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task, or project with no task, with the Transcript, Changes, Files, Terminal, Inbox and Project tabs, the lead's `tabs` first. ⌘P (Ctrl+P elsewhere) selects the Project tab, keeping the right pane's task, and replaces the browser's print shortcut. ⌘S (Ctrl+S elsewhere) opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled, and replaces the browser's save shortcut.
|
||||
The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task, or project with no task, with the Transcript, Changes, Files, Terminal, Preview, Inbox and Project tabs, the lead's `tabs` first. ⌘P (Ctrl+P elsewhere) selects the Project tab, keeping the right pane's task, and replaces the browser's print shortcut. ⌘S (Ctrl+S elsewhere) opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled, and replaces the browser's save shortcut.
|
||||
|
||||
## TODO
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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)}},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+2
@@ -6,6 +6,7 @@ import { mountInbox } from "./inbox.js";
|
||||
import { mountLead } from "./lead.js";
|
||||
import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountPreview } from "./preview.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
@@ -14,6 +15,7 @@ const views = {
|
||||
changes: { label: "Changes", mount: (root) => mountChanges(root) },
|
||||
files: { label: "Files", mount: mountFiles },
|
||||
terminal: { label: "Terminal", mount: mountTerminal },
|
||||
preview: { label: "Preview", mount: mountPreview },
|
||||
inbox: { label: "Inbox", mount: mountInbox },
|
||||
project: { label: "Project", mount: mountProject },
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+110
@@ -0,0 +1,110 @@
|
||||
import { get } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { onEach, state } from "./state.js";
|
||||
const chosen = new Map();
|
||||
export function previewURL(port) {
|
||||
return `https://${location.hostname}:${port}/`;
|
||||
}
|
||||
export function mountPreview(root) {
|
||||
const head = h("header", { class: "preview-header" });
|
||||
const body = h("div", { class: "preview-body" });
|
||||
let previews;
|
||||
let error = "";
|
||||
let target = 0;
|
||||
let framed = "";
|
||||
let disposed = false;
|
||||
const current = () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
const name = taskId ? chosen.get(taskId) : undefined;
|
||||
return previews?.find((p) => p.name === name) ?? previews?.[0];
|
||||
};
|
||||
const load = async () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
if (!taskId) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const started = ++target;
|
||||
try {
|
||||
const fresh = await get("/api/tasks/{taskId}/previews", { path: { taskId } });
|
||||
if (started === target) {
|
||||
previews = fresh;
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (started === target) {
|
||||
previews = undefined;
|
||||
error = errorText(err);
|
||||
}
|
||||
}
|
||||
if (!disposed && started === target) {
|
||||
render();
|
||||
}
|
||||
};
|
||||
const render = () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
const shown = current();
|
||||
replace(head, h("nav", { class: "preview-names" }, ...(previews ?? []).map((p) => {
|
||||
const b = h("button", { type: "button", class: p === shown ? "preview-name active" : "preview-name" }, p.name, h("span", { class: "preview-port" }, ` :${p.port}`));
|
||||
b.onclick = () => {
|
||||
if (taskId) {
|
||||
chosen.set(taskId, p.name);
|
||||
}
|
||||
render();
|
||||
};
|
||||
return b;
|
||||
})), shown ? h("a", { class: "preview-open", href: previewURL(shown.port), target: "_blank", rel: "noopener" }, "open ↗") : null);
|
||||
if (!taskId) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "No task selected."));
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty error-text" }, error));
|
||||
return;
|
||||
}
|
||||
if (!previews) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
if (!shown) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "Nothing exposed. The task's agent shows a running server here with expose_port."));
|
||||
return;
|
||||
}
|
||||
const url = previewURL(shown.port);
|
||||
if (url === framed) {
|
||||
return;
|
||||
}
|
||||
framed = url;
|
||||
replace(body, h("iframe", { class: "preview-frame", src: url, title: shown.name }));
|
||||
};
|
||||
const offs = [
|
||||
onEach(["selection"], () => {
|
||||
target++;
|
||||
previews = undefined;
|
||||
error = "";
|
||||
render();
|
||||
void load();
|
||||
}),
|
||||
onEach(["transcript"], (fresh) => {
|
||||
if (fresh.some((ev) => ev.kind === "preview.exposed")) {
|
||||
framed = "";
|
||||
void load();
|
||||
}
|
||||
}),
|
||||
];
|
||||
replace(root, h("div", { class: "preview-view" }, head, body));
|
||||
render();
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=preview.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"preview.js","sourceRoot":"","sources":["../src/preview.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { get, put, subscribe } from "./api.js";
|
||||
export const tabs = ["transcript", "changes", "files", "terminal", "inbox", "project"];
|
||||
export const tabs = ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"];
|
||||
export const state = {
|
||||
me: undefined,
|
||||
projects: [],
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
-9
@@ -1,6 +1,7 @@
|
||||
import { get, post } from "./api.js";
|
||||
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state } from "./state.js";
|
||||
export const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
|
||||
const quietReply = "NO_REPLY";
|
||||
@@ -844,15 +845,9 @@ function renderEvent(ev, ctx) {
|
||||
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
|
||||
case "task.merge_queued":
|
||||
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
|
||||
case "setup.started": {
|
||||
const base = Number(p["port_base"]);
|
||||
return h("div", { class: "note" }, `running ${String(p["script"])} with ports ${base}–${base + 9} `, when);
|
||||
}
|
||||
case "setup.ended": {
|
||||
const secs = Math.round(Number(p["duration_ms"]) / 1000);
|
||||
const failed = typeof p["error"] === "string";
|
||||
const summary = failed ? `${String(p["script"])} failed after ${secs}s: ${String(p["error"])}` : `${String(p["script"])} finished in ${secs}s`;
|
||||
return h("details", { class: failed ? "note error" : "note" }, h("summary", {}, summary, " ", when), h("pre", {}, String(p["output"] ?? "")));
|
||||
case "preview.exposed": {
|
||||
const url = previewURL(Number(p["port"]));
|
||||
return h("div", { class: "note" }, `${ev.actor} exposed "${String(p["name"])}" at `, h("a", { href: url, target: "_blank", rel: "noopener" }, url), " ", when);
|
||||
}
|
||||
case "task.merged":
|
||||
return h("div", { class: "note" }, `${ev.actor} merged the task${p["commit"] ? ` as ${String(p["commit"]).slice(0, 7)}` : ""}${p["pushed"] ? ` and pushed it to ${String(p["pushed"])}` : ""} `, when);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+52
-2
@@ -475,6 +475,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/previews": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The ports the task's agent exposed with expose_port, by name. Each is served by tailscale serve on the same port number of byre's host, over HTTPS, until the task is merged or discarded. */
|
||||
get: operations["listPreviews"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{taskId}/snapshots": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -793,7 +812,7 @@ export interface components {
|
||||
branch: string;
|
||||
worktree_path: string;
|
||||
base_ref: string;
|
||||
/** @description The first of the task's block of ports (port_base to port_base+9), passed to its setup script and agent as PORT. */
|
||||
/** @description The first of the task's block of ports (port_base to port_base+9), passed to its agent and terminal as PORT. */
|
||||
port_base: number;
|
||||
/** @enum {string} */
|
||||
status: "active" | "merged" | "discarded";
|
||||
@@ -933,6 +952,14 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
Preview: {
|
||||
task_id: string;
|
||||
name: string;
|
||||
/** @description A port in the task's block, served at https://<byre's host>:<port>/. */
|
||||
port: number;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
AgentSession: {
|
||||
id: string;
|
||||
/** @description Empty for a lead session. */
|
||||
@@ -1076,7 +1103,7 @@ export interface components {
|
||||
focused: boolean;
|
||||
};
|
||||
/** @enum {string} */
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "inbox" | "project";
|
||||
TaskTab: "transcript" | "changes" | "files" | "terminal" | "preview" | "inbox" | "project";
|
||||
TerminalSize: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -1951,6 +1978,29 @@ export interface operations {
|
||||
409: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
listPreviews: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
taskId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The previews. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Preview"][];
|
||||
};
|
||||
};
|
||||
404: components["responses"]["Error"];
|
||||
};
|
||||
};
|
||||
listSnapshots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { mountInbox } from "./inbox.js";
|
||||
import { mountLead } from "./lead.js";
|
||||
import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountPreview } from "./preview.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
import { mountTerminal } from "./terminal.js";
|
||||
@@ -15,6 +16,7 @@ const views: Record<TaskTab, { label: string; mount: (root: HTMLElement) => () =
|
||||
changes: { label: "Changes", mount: (root) => mountChanges(root) },
|
||||
files: { label: "Files", mount: mountFiles },
|
||||
terminal: { label: "Terminal", mount: mountTerminal },
|
||||
preview: { label: "Preview", mount: mountPreview },
|
||||
inbox: { label: "Inbox", mount: mountInbox },
|
||||
project: { label: "Project", mount: mountProject },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { get, type ByreEvent, type Schemas } from "./api.js";
|
||||
import { errorText, h, replace } from "./dom.js";
|
||||
import { onEach, state } from "./state.js";
|
||||
|
||||
type Preview = Schemas["Preview"];
|
||||
|
||||
const chosen = new Map<string, string>();
|
||||
|
||||
export function previewURL(port: number): string {
|
||||
return `https://${location.hostname}:${port}/`;
|
||||
}
|
||||
|
||||
export function mountPreview(root: HTMLElement): () => void {
|
||||
const head = h("header", { class: "preview-header" });
|
||||
const body = h("div", { class: "preview-body" });
|
||||
let previews: Preview[] | undefined;
|
||||
let error = "";
|
||||
let target = 0;
|
||||
let framed = "";
|
||||
let disposed = false;
|
||||
|
||||
const current = (): Preview | undefined => {
|
||||
const taskId = state.selectedTaskId;
|
||||
const name = taskId ? chosen.get(taskId) : undefined;
|
||||
return previews?.find((p) => p.name === name) ?? previews?.[0];
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
if (!taskId) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const started = ++target;
|
||||
try {
|
||||
const fresh = await get("/api/tasks/{taskId}/previews", { path: { taskId } });
|
||||
if (started === target) {
|
||||
previews = fresh;
|
||||
error = "";
|
||||
}
|
||||
} catch (err) {
|
||||
if (started === target) {
|
||||
previews = undefined;
|
||||
error = errorText(err);
|
||||
}
|
||||
}
|
||||
if (!disposed && started === target) {
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const taskId = state.selectedTaskId;
|
||||
const shown = current();
|
||||
replace(
|
||||
head,
|
||||
h(
|
||||
"nav",
|
||||
{ class: "preview-names" },
|
||||
...(previews ?? []).map((p) => {
|
||||
const b = h("button", { type: "button", class: p === shown ? "preview-name active" : "preview-name" }, p.name, h("span", { class: "preview-port" }, ` :${p.port}`));
|
||||
b.onclick = () => {
|
||||
if (taskId) {
|
||||
chosen.set(taskId, p.name);
|
||||
}
|
||||
render();
|
||||
};
|
||||
return b;
|
||||
}),
|
||||
),
|
||||
shown ? h("a", { class: "preview-open", href: previewURL(shown.port), target: "_blank", rel: "noopener" }, "open ↗") : null,
|
||||
);
|
||||
if (!taskId) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "No task selected."));
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty error-text" }, error));
|
||||
return;
|
||||
}
|
||||
if (!previews) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
if (!shown) {
|
||||
framed = "";
|
||||
replace(body, h("div", { class: "empty" }, "Nothing exposed. The task's agent shows a running server here with expose_port."));
|
||||
return;
|
||||
}
|
||||
const url = previewURL(shown.port);
|
||||
if (url === framed) {
|
||||
return;
|
||||
}
|
||||
framed = url;
|
||||
replace(body, h("iframe", { class: "preview-frame", src: url, title: shown.name }));
|
||||
};
|
||||
|
||||
const offs = [
|
||||
onEach(["selection"], () => {
|
||||
target++;
|
||||
previews = undefined;
|
||||
error = "";
|
||||
render();
|
||||
void load();
|
||||
}),
|
||||
onEach(["transcript"], (fresh) => {
|
||||
if ((fresh as ByreEvent[]).some((ev) => ev.kind === "preview.exposed")) {
|
||||
framed = "";
|
||||
void load();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
replace(root, h("div", { class: "preview-view" }, head, body));
|
||||
render();
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
for (const off of offs) {
|
||||
off();
|
||||
}
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ export type Topic =
|
||||
| "lead.transcript"
|
||||
| "lead.stream";
|
||||
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "project"];
|
||||
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"];
|
||||
|
||||
export const state = {
|
||||
me: undefined as User | undefined,
|
||||
|
||||
+4
-10
@@ -1,6 +1,7 @@
|
||||
import { get, post, type AgentSession, type ByreEvent, type Participant, type Snapshot } from "./api.js";
|
||||
import { ago, errorText, h, replace, showAlert, showConfirm } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { previewURL } from "./preview.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, state, type Topic } from "./state.js";
|
||||
|
||||
export type Usage = { used: number; window: number; model: string };
|
||||
@@ -1006,16 +1007,9 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
case "task.merge_queued":
|
||||
return h("div", { class: "note" }, `${ev.actor} asked to merge; held until the merge in progress in this project is done `, when);
|
||||
|
||||
case "setup.started": {
|
||||
const base = Number(p["port_base"]);
|
||||
return h("div", { class: "note" }, `running ${String(p["script"])} with ports ${base}–${base + 9} `, when);
|
||||
}
|
||||
|
||||
case "setup.ended": {
|
||||
const secs = Math.round(Number(p["duration_ms"]) / 1000);
|
||||
const failed = typeof p["error"] === "string";
|
||||
const summary = failed ? `${String(p["script"])} failed after ${secs}s: ${String(p["error"])}` : `${String(p["script"])} finished in ${secs}s`;
|
||||
return h("details", { class: failed ? "note error" : "note" }, h("summary", {}, summary, " ", when), h("pre", {}, String(p["output"] ?? "")));
|
||||
case "preview.exposed": {
|
||||
const url = previewURL(Number(p["port"]));
|
||||
return h("div", { class: "note" }, `${ev.actor} exposed "${String(p["name"])}" at `, h("a", { href: url, target: "_blank", rel: "noopener" }, url), " ", when);
|
||||
}
|
||||
|
||||
case "task.merged":
|
||||
|
||||
@@ -315,6 +315,7 @@ textarea {
|
||||
.changes-view,
|
||||
.files-view,
|
||||
.terminal-view,
|
||||
.preview-view,
|
||||
.inbox-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1174,6 +1175,58 @@ pre.diff .meta {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview-names {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
padding: 0 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-name.active {
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.preview-port {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-open {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user