From fbc0699fdf7e364e15b987a3f0ad1fabb11b90ab Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 14:41:04 -0700 Subject: [PATCH] Add run_on_client: agents run commands with files on the user's Macs --- api/openapi.yaml | 55 ++- docs/operations.md | 12 +- internal/agents/clientrun.go | 191 +++++++++ internal/agents/inbox.go | 7 + internal/agents/manager.go | 2 +- internal/agents/respond.go | 4 + internal/api/api.gen.go | 557 ++++++++++++++++----------- internal/server/inbox.go | 62 +++ internal/server/server.go | 1 + internal/store/inbox.go | 45 ++- internal/store/store.go | 3 + mac/Sources/Byre/ClientRun.swift | 166 ++++++++ mac/Sources/Byre/Inbox.swift | 65 +++- mac/Sources/Byre/Notifications.swift | 3 +- mac/Sources/Byre/Startup.swift | 1 + mac/Sources/Byre/Store.swift | 3 + 16 files changed, 928 insertions(+), 249 deletions(-) create mode 100644 internal/agents/clientrun.go create mode 100644 mac/Sources/Byre/ClientRun.swift diff --git a/api/openapi.yaml b/api/openapi.yaml index 798cc60..8a95f0f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -897,6 +897,43 @@ paths: schema: { $ref: "#/components/schemas/InboxItem" } "404": { $ref: "#/components/responses/Error" } + /api/inbox/{itemId}/bundle: + parameters: + - { name: itemId, in: path, required: true, schema: { type: string } } + get: + operationId: getRunBundle + description: A client_run item's files, as a gzipped tar of its paths relative to the worktree. Served while the item is open. + responses: + "200": + description: The bundle. + content: + application/gzip: { schema: { type: string, format: binary } } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + + /api/inbox/{itemId}/runs: + parameters: + - { name: itemId, in: path, required: true, schema: { type: string } } + post: + operationId: reportRun + description: > + Records one Mac's run of a client_run item as a run response, sent to the agent like an answer. + The item stays as it is, so other Macs can run it too; it is accepted after the item closes. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RunReport" } + responses: + "200": + description: The item with its responses. + content: + application/json: + schema: { $ref: "#/components/schemas/InboxItem" } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + /api/inbox/responses: post: operationId: respondInbox @@ -1489,7 +1526,7 @@ components: task_id: { type: string } agent_session_id: { type: string } thread_id: { type: string } - kind: { type: string, enum: [question, session_error, rate_limited] } + kind: { type: string, enum: [question, session_error, rate_limited, client_run] } payload: type: object description: > @@ -1497,6 +1534,7 @@ components: question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. + client_run: why, command, paths (worktree paths in the bundle), bytes (the bundle's size). state: { $ref: "#/components/schemas/InboxState" } created_at: { type: string, format: date-time } resolved_by: { type: string } @@ -1507,7 +1545,7 @@ components: InboxResponseType: type: string - enum: [option, other, dismiss] + enum: [option, other, dismiss, run] InboxResponse: type: object @@ -1520,7 +1558,9 @@ components: options: type: array items: { type: string } - text: { type: string } + text: { type: string, description: "run: the command's output, at most its last 64 KiB." } + client: { type: string, description: "run: the host name of the Mac that ran the command." } + exit_code: { type: integer, description: "run: the command's exit status." } images: type: array items: { type: string } @@ -1550,6 +1590,15 @@ components: type: array items: { $ref: "#/components/schemas/Attachment" } + RunReport: + type: object + required: [run_id, client, exit_code, output] + properties: + run_id: { type: string, description: "The Mac's id for this one run, used as the response's id; reporting it again records nothing new." } + client: { type: string } + exit_code: { type: integer } + output: { type: string } + InboxRespond: type: object required: [responses] diff --git a/docs/operations.md b/docs/operations.md index 3718d2a..342886d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,7 +16,7 @@ ## Data -State lives in the directory given by `byre serve --data`, by default the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees//`, `leads/` (each lead agent's working directory, the login path-escaped), and `blobs/` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/` in the project's repo. +State lives in the directory given by `byre serve --data`, by default the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db`, `worktrees//`, `leads/` (each lead agent's working directory, the login path-escaped), `blobs/` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory), and `bundles/.tar.gz` (the files of open run requests, see "Client runs"). Task branches are `byre/` in the project's repo. Schema changes are the `migrations` slice in `internal/store/store.go`: named entries, each with a short kebab-case name saying what it does. On open, byre applies, in slice order, every entry whose name isn't in the `migrations` table, each in its own transaction that also records the name. Each runs with foreign keys off, so a migration can rebuild a table other tables reference, and `PRAGMA foreign_key_check` must come back empty before it commits. byre is deployed from task branches that each append their own migrations, so after a merge a migration can land before others the database has already applied, and it still runs. Give a new migration a name no other branch will use, and make any data migration safe to run twice. `sqlite3 byre.db 'SELECT * FROM migrations'` lists what has been applied. A database from before the table existed is moved over on open: the first `PRAGMA user_version` entries are recorded as applied, and `user_version` is no longer read or written. @@ -254,7 +254,7 @@ Answers reach the agent as one message holding every undelivered `option` and `o The running macOS app posts a notification for each new inbox item, raised by the app itself from the `inbox.created` events on its SSE stream (`notifyInbox` in `mac/Sources/Byre/Notifications.swift`, through `UNUserNotificationCenter`). With the app not running, nothing notifies; closing its window leaves it running. -- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The app checks them against each item or `turn.ended` event as it arrives. +- **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default; it also covers `client_run` items, whose body reads "Run on your Mac: "), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The app checks them against each item or `turn.ended` event as it arrives. - **Content.** The title is `byre · ` and the body the question (or the turn summary's first line, the error, or "Rate limited"), cut at 200 characters. The notification's identifier is the item's ID, or `turn-` for a turn summary, so a repeat replaces the earlier one. - **Clicking** a notification activates the app, shows its window and points the right pane at the item's task. @@ -274,6 +274,14 @@ A code block fenced as `mermaid`, and an inbox `diagram` attachment, render as a Answers reach the agent as one message, listing each answer under the question or error it responds to, once the session is idle and none of its questions are still waiting for the human. An `other` answer to a question tells the agent that if it asks something back, it should answer with `ask_human` using the item's `thread_id`. `thread_id` only groups items: filing an item never changes any other, so several questions on one thread stay open until each is answered, withdrawn or dismissed. Nothing sets the `superseded` state; it stays in the spec because old items hold it. Every item change is an `inbox.created` or `inbox.updated` event carrying the whole item, including who resolved it. +## Client runs + +An agent asks the human to run a command on their own Mac, with files from its worktree, through the `run_on_client` MCP tool (`internal/agents/clientrun.go`): `paths` (relative to the worktree, checked through an `os.Root` so none leaves it), `command` and `why`. The call packs the paths with `tar -czf` into `bundles/.tar.gz` in the data directory (`Store.BundlePath`), so later changes to them aren't sent, and files a `client_run` inbox item whose payload holds `why`, `command`, `paths` and the bundle's `bytes`. It returns at once; the agent need not end its turn. + +The item is not exclusive to one Mac. Every open app shows it in the Inbox tab with **Run on this Mac** and **Dismiss**; it notifies under the `questions` preference, and the task's state reads **Ready to run** in the warn colour. Run (`runOnThisMac` in `mac/Sources/Byre/ClientRun.swift`) makes a new directory `runs/` under `~/Library/Caches/run.fc.byre.mac`, writes the item id to `item`, the server origin to `server` and the command to `command.sh`, downloads `GET /api/inbox/{itemId}/bundle` (served while the item is open, 409 after) and unpacks it into `files/`, then starts `/bin/zsh -l ../command.sh` in `files/` as a detached child under `/usr/bin/lockf -k lock`, with its output in `output.log` and its exit status written to `exit` when it ends. The `lock` file marks a run whose command started, and lockf holds an exclusive `flock` on it for as long as the command runs. Clicking Run again, or on another Mac, makes another run. The child outlives the app, so a command that quits byre (installing a new build) still finishes. The app polls each run directory once a second for `exit`; when `lock` is no longer locked and there is still no `exit` (the Mac restarted, the command was killed), it reports the run as interrupted, exit status -1 with a line saying so after the output. On startup it picks up every directory left in `runs/` whose `server` is the server it connects to, removing the ones with no `lock` (the app quit while downloading or unpacking, so nothing ran), so the build a command launched reports the run that launched it, and a run is never reported to another server. It reports with `POST /api/inbox/{itemId}/runs` (`run_id`: the run directory's UUID, which becomes the response's id, so a report sent again after its answer was lost records nothing new; `client`: the Mac's computer name, `exit_code`, `output`: the last 64 KiB of `output.log`) and deletes the directory once the server accepts it or answers 4xx; a network failure or 5xx leaves it for the next launch. `paths` may be empty: the server then packs an empty archive (`tar -T /dev/null`). `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` shows `client run started` and `client run reported` lines. + +A report is stored as a `run` response on the item (`inbox_responses.client` and `exit_code`, the output in `text`) without changing its state, and is accepted after the item is closed. It reaches the agent like an answer: as one message once the session is idle, or mid-turn, never held back by the run item itself, since only open questions hold delivery. The card lists each run as " ran it on : exit " with the output collapsed. `POST /api/inbox/responses` takes only `dismiss` for a `client_run` item. Dismissing it, or closing its task, ends it: the `inbox.updated` event with the item no longer open removes its bundle (`removeClosedBundles` in `internal/server/inbox.go`). `ls /bundles` lists the bundles of open run requests. + ## Changes `GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD `, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. diff --git a/internal/agents/clientrun.go b/internal/agents/clientrun.go new file mode 100644 index 0000000..097f50c --- /dev/null +++ b/internal/agents/clientrun.go @@ -0,0 +1,191 @@ +package agents + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "g.fc.run/misc/byre/internal/api" + "g.fc.run/misc/byre/internal/mcp" + "g.fc.run/misc/byre/internal/store" +) + +const ( + bundleTimeout = 5 * time.Minute + MaxRunOutput = 64 << 10 +) + +type runOnClientArgs struct { + Paths []string `json:"paths"` + Command string `json:"command"` + Why string `json:"why"` +} + +func (m *Manager) runOnClientTool(l *live) mcp.Tool { + return mcp.Tool{ + Name: "run_on_client", + Description: "Ask the human to run a command on their own Mac, with files from your worktree, for example to install and launch an app you built. " + + "byre packs paths into a bundle now, so later changes to them aren't sent, and files a card in the human's inbox showing why, the command and the files. " + + "Every Mac the human has byre open on shows the card with Run and Dismiss; each Mac where they click Run downloads the bundle, unpacks it into a new empty directory, " + + "and runs command there with zsh as a login shell, as the human. The directory is deleted once the command finishes, so copy anything that must stay elsewhere first. " + + "The command keeps running if it quits byre's app, and the app reports it when it next starts. " + + "This call returns at once; you don't need to end your turn. Each Mac's run reaches you as a message with its host name, exit status and the last 64 KiB of its output. " + + "The card stays until the human dismisses it, so it can be run again or on another Mac.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "paths": {"type": "array", "items": {"type": "string"}, "description": "Files or directories to send, relative to your worktree; each is unpacked at the same relative path. May be empty."}, + "command": {"type": "string", "description": "The zsh command to run in the directory the bundle unpacks into, e.g. 'rm -rf ~/Applications/byre.app && ditto mac/.build/byre.app ~/Applications/byre.app && (pkill -x Byre; open ~/Applications/byre.app)'."}, + "why": {"type": "string", "description": "One or two sentences for the human: what running it does and why."} + }, + "required": ["paths", "command", "why"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.runOnClient(ctx, l, raw) + }, + } +} + +func (m *Manager) runOnClient(ctx context.Context, l *live, raw json.RawMessage) (string, error) { + args := runOnClientArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed run_on_client call: %w", err) + } + if strings.TrimSpace(args.Command) == "" { + return "", errors.New("command is empty") + } + if strings.TrimSpace(args.Why) == "" { + return "", errors.New("why is empty: say what running it does and why") + } + + paths, err := checkBundlePaths(l.dir, args.Paths) + if err != nil { + return "", err + } + + id := store.NewID() + size, err := m.writeBundle(ctx, l.dir, paths, m.store.BundlePath(id)) + if err != nil { + return "", err + } + + payload := map[string]any{ + "why": args.Why, + "command": args.Command, + "paths": paths, + "bytes": size, + } + err = m.store.CreateInboxItem(ctx, m.newItem(l, id, id, api.ClientRun, payload)) + if err != nil { + os.Remove(m.store.BundlePath(id)) + return "", err + } + return fmt.Sprintf("Run request %s filed in the human's inbox with a %d-byte bundle. Each Mac's run will reach you as a message.", id, size), nil +} + +func checkBundlePaths(dir string, paths []string) ([]string, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + defer root.Close() + + out := []string{} + for _, p := range paths { + clean := filepath.Clean(p) + if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, "-") { + return nil, fmt.Errorf("path %q must be relative to your worktree and inside it", p) + } + _, err = root.Lstat(clean) + if err != nil { + return nil, fmt.Errorf("path %q: %w", p, err) + } + out = append(out, clean) + } + return out, nil +} + +func (m *Manager) writeBundle(ctx context.Context, dir string, paths []string, dest string) (int64, error) { + err := os.MkdirAll(filepath.Dir(dest), 0o755) + if err != nil { + return 0, err + } + tmp := dest + ".tmp" + + cctx, cancel := context.WithTimeout(ctx, bundleTimeout) + defer cancel() + // -T /dev/null lets tar write an empty archive when paths is empty. + cmd := exec.CommandContext(cctx, "tar", append([]string{"-czf", tmp, "-C", dir, "-T", "/dev/null", "--"}, paths...)...) + out, err := cmd.CombinedOutput() + if err != nil { + os.Remove(tmp) + return 0, fmt.Errorf("pack bundle: %w: %s", err, out) + } + + err = os.Rename(tmp, dest) + if err != nil { + return 0, err + } + info, err := os.Stat(dest) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +func (m *Manager) ReportRun(ctx context.Context, user, itemID string, report api.RunReport) (api.InboxItem, error) { + item, err := m.store.GetInboxItem(ctx, itemID) + if err != nil { + return item, err + } + if item.Kind != api.ClientRun { + return item, fmt.Errorf("%w: item %s is a %s, not a client_run", ErrInvalid, itemID, item.Kind) + } + client := strings.TrimSpace(report.Client) + if client == "" { + return item, fmt.Errorf("%w: client is empty", ErrInvalid) + } + if strings.TrimSpace(report.RunId) == "" { + return item, fmt.Errorf("%w: run_id is empty", ErrInvalid) + } + + output := report.Output + if len(output) > MaxRunOutput { + output = strings.ToValidUTF8(output[len(output)-MaxRunOutput:], "") + } + resp := api.InboxResponse{ + Id: report.RunId, + ItemId: itemID, + User: user, + Type: api.Run, + Text: &output, + Client: &client, + ExitCode: &report.ExitCode, + CreatedAt: time.Now().UTC(), + } + added, err := m.store.AddRunResponse(ctx, resp) + if err != nil { + return item, err + } + if !added { + slog.Info("client run already reported", "item", itemID, "response", resp.Id, "client", client) + return m.store.GetInboxItem(ctx, itemID) + } + slog.Info("client run reported", "item", itemID, "response", resp.Id, "user", user, "client", client, "exit", report.ExitCode) + + go func() { + err := m.Deliver(context.Background(), item.AgentSessionId) + if err != nil { + slog.Error("deliver run report", "session", item.AgentSessionId, "err", err) + } + }() + return m.store.GetInboxItem(ctx, itemID) +} diff --git a/internal/agents/inbox.go b/internal/agents/inbox.go index 0d05ba1..83383b1 100644 --- a/internal/agents/inbox.go +++ b/internal/agents/inbox.go @@ -492,9 +492,16 @@ func deliveryMessage(pending []store.Pending) string { fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"]) case api.RateLimited: fmt.Fprintf(b, "Your rate-limited turn %s: %s\n", p.Item.Id, p.Item.Payload["error"]) + case api.ClientRun: + fmt.Fprintf(b, "Your run request %s: %s\n", p.Item.Id, p.Item.Payload["command"]) } switch r.Type { + case api.Run: + fmt.Fprintf(b, "%s ran it on %s: exit status %d\n", r.User, *r.Client, *r.ExitCode) + if r.Text != nil && *r.Text != "" { + fmt.Fprintf(b, "Output:\n```\n%s\n```\n", strings.TrimRight(*r.Text, "\n")) + } case api.Option: if p.Item.Payload["multiple"] == true { fmt.Fprintf(b, "%s checked:\n", r.User) diff --git a/internal/agents/manager.go b/internal/agents/manager.go index a63c082..19bbf72 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -709,7 +709,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api if err != nil { return nil, err } - tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l)}, m.subtaskTools(l)...) + tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.runOnClientTool(l), m.takeSnapshotTool(l)}, m.subtaskTools(l)...) if sess.Role == api.Implementer { g += longTitleGuidance(task) notes, err := m.notesText(ctx, task.CreatedBy, task.ProjectId) diff --git a/internal/agents/respond.go b/internal/agents/respond.go index 4879673..6655de3 100644 --- a/internal/agents/respond.go +++ b/internal/agents/respond.go @@ -113,6 +113,10 @@ func CheckResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat return "", fmt.Errorf("%w: item %s: "+format, append([]any{ErrInvalid, item.Id}, args...)...) } + if item.Kind == api.ClientRun && c.Type != api.Dismiss { + return bad("a run request is only dismissed here; runs are reported to /api/inbox/%s/runs", item.Id) + } + switch c.Type { case api.Option: if !question { diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 4b2a429..e53a743 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -203,6 +203,7 @@ func (e FindingState) Valid() bool { // Defines values for InboxItemKind. const ( + ClientRun InboxItemKind = "client_run" Question InboxItemKind = "question" RateLimited InboxItemKind = "rate_limited" SessionError InboxItemKind = "session_error" @@ -211,6 +212,8 @@ const ( // Valid indicates whether the value is a known member of the InboxItemKind enum. func (e InboxItemKind) Valid() bool { switch e { + case ClientRun: + return true case Question: return true case RateLimited: @@ -227,6 +230,7 @@ const ( Dismiss InboxResponseType = "dismiss" Option InboxResponseType = "option" Other InboxResponseType = "other" + Run InboxResponseType = "run" ) // Valid indicates whether the value is a known member of the InboxResponseType enum. @@ -238,6 +242,8 @@ func (e InboxResponseType) Valid() bool { return true case Other: return true + case Run: + return true default: return false } @@ -749,7 +755,7 @@ type InboxItem struct { Id string `json:"id"` Kind InboxItemKind `json:"kind"` - // Payload question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. + // Payload question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error. client_run: why, command, paths (worktree paths in the bundle), bytes (the bundle's size). Payload map[string]interface{} `json:"payload"` ProjectId string `json:"project_id"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` @@ -771,17 +777,25 @@ type InboxRespond struct { // InboxResponse defines model for InboxResponse. type InboxResponse struct { Attachments []Attachment `json:"attachments"` - CreatedAt time.Time `json:"created_at"` - DeliveredAt *time.Time `json:"delivered_at,omitempty"` - Id string `json:"id"` + + // Client run: the host name of the Mac that ran the command. + Client *string `json:"client,omitempty"` + CreatedAt time.Time `json:"created_at"` + DeliveredAt *time.Time `json:"delivered_at,omitempty"` + + // ExitCode run: the command's exit status. + ExitCode *int `json:"exit_code,omitempty"` + Id string `json:"id"` // Images Blob ids sent to the agent as images alongside the response. - Images []string `json:"images"` - ItemId string `json:"item_id"` - Options *[]string `json:"options,omitempty"` - Text *string `json:"text,omitempty"` - Type InboxResponseType `json:"type"` - User string `json:"user"` + Images []string `json:"images"` + ItemId string `json:"item_id"` + Options *[]string `json:"options,omitempty"` + + // Text run: the command's output, at most its last 64 KiB. + Text *string `json:"text,omitempty"` + Type InboxResponseType `json:"type"` + User string `json:"user"` } // InboxResponseCreate defines model for InboxResponseCreate. @@ -945,6 +959,16 @@ type Review struct { // ReviewState skipped: the snapshot's step changed no files, so no reviewer ran. type ReviewState string +// RunReport defines model for RunReport. +type RunReport struct { + Client string `json:"client"` + ExitCode int `json:"exit_code"` + Output string `json:"output"` + + // RunId The Mac's id for this one run, used as the response's id; reporting it again records nothing new. + RunId string `json:"run_id"` +} + // SessionCreate defines model for SessionCreate. type SessionCreate struct { // Images Blob ids from uploadBlob, sent to the agent as images alongside the prompt. @@ -1398,6 +1422,9 @@ type GetTaskTerminalParams struct { // RespondInboxJSONRequestBody defines body for RespondInbox for application/json ContentType. type RespondInboxJSONRequestBody = InboxRespond +// ReportRunJSONRequestBody defines body for ReportRun for application/json ContentType. +type ReportRunJSONRequestBody = RunReport + // UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType. type UpdateMeJSONRequestBody = UserUpdate @@ -1461,6 +1488,12 @@ type ServerInterface interface { // (GET /api/inbox/{itemId}) GetInboxItem(w http.ResponseWriter, r *http.Request, itemId string) + // (GET /api/inbox/{itemId}/bundle) + GetRunBundle(w http.ResponseWriter, r *http.Request, itemId string) + + // (POST /api/inbox/{itemId}/runs) + ReportRun(w http.ResponseWriter, r *http.Request, itemId string) + // (GET /api/me) GetMe(w http.ResponseWriter, r *http.Request) @@ -1860,6 +1893,58 @@ func (siw *ServerInterfaceWrapper) GetInboxItem(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } +// GetRunBundle operation middleware +func (siw *ServerInterfaceWrapper) GetRunBundle(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "itemId" ------------- + var itemId string + + err = runtime.BindStyledParameterWithOptions("simple", "itemId", r.PathValue("itemId"), &itemId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "itemId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetRunBundle(w, r, itemId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReportRun operation middleware +func (siw *ServerInterfaceWrapper) ReportRun(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "itemId" ------------- + var itemId string + + err = runtime.BindStyledParameterWithOptions("simple", "itemId", r.PathValue("itemId"), &itemId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "itemId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReportRun(w, r, itemId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetMe operation middleware func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) { @@ -3469,6 +3554,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/blobs/{blobId}", wrapper.GetBlob) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox", wrapper.ListInbox) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}", wrapper.GetInboxItem) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/inbox/{itemId}/bundle", wrapper.GetRunBundle) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/{itemId}/runs", wrapper.ReportRun) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/inbox/responses", wrapper.RespondInbox) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus) @@ -3485,228 +3572,234 @@ 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{ - "7H1/cxu3kuBXQfGuStbuiLLfy97dSnV/6NlO4r048VnKufbeS2lBDkjiaQiMAYwoxuXvfoXuBgYzxJAj", - "2bL9NvdPIg9ngEaj0ejf/WEy1+taK6GcnZx9mBhha62sgH+8NEYb/8dcKyeU83/yuq7knDup1enfrVb+", - "mZ2vxJr7v/6rEYvJ2eS/nLajnuKv9hRH+/jxYzEphZ0bWftBJmc4zXTif6B3/VAXS6HcG6PnwsK/a6Nr", - "YZxE0FaCV27l/+qOZV0zv2FrwZVlnLnGKCYtM41SUi0ZVyVTmllnBF8zcSuUYytuGTdG3oqSLbRhMMI1", - "Xzhhrq2Ya1XacybupBMljetWgtUImB98qZWYToqJUM16cvbXib6ZFAiHfwgfTn4rJm5bCwDQSLWcFJO7", - "E//ByS03iq/9ov46+REW9Yv/Dv+8pFHwXy9prI/FpOLWXQP819ztYuHdSqgOmP59tjHaCcZZJZVgTjPp", - "LKFiUkwW2qz9UJOSO3Hi5FpMiok2bOOHko5Zx40T5XRyppqq6i/nYzGpZekBoedSObEUxv9ghbVSq+vO", - "7+131nEngLIs04rBtl/iJ3Gy7uquVoLRoEd+Bf77DECO25uhSW+FKpG0ez99LCZGvG+kEaXfkQT2dsD4", - "OS46LKG/K0Ug0nbz9ezvYu48AOkyd6l7bgR3oqS9zW7NzpLCN7NtdsULbW7iiD1qkW7F6PeF0eszIB1c", - "45FlsmR6AY+AitzKnyT/T/8JuxGitgXjbC2s5Ut4yp5XvCkFe65LUYRjCM91Ke6mOeiT6XcBvICpjixb", - "SGOJFJkRtlkLfxqlJWADVRRs3jjGHYuLLlijSmEYZ0ps6O3rdnPPGZ9Zzww8kNqtRBzKZqEdoKq1LkW1", - "C/5b8b4RFviHf+GciXXttgkziaguxYI3lZuyizA/06raMruRbr4Sls2E2wg4kJbpjWq/hJHzwOqNEmYX", - "Kn+KKr2Uim1W2gpWCV4y7skSUSptANQjhTNP/f64haOZmcnoSuxOZMStFBthzhhn+Hc7DHtihWBv4enx", - "+c4sllXSOg/aLey0sILpxqXcVq7rSqyFcsKfR7+ISRGn3OW7KcOJQ5SVP1F0SUwKuBIrMZ5vp4f50o/+", - "Ckfcef42TrHz0/N2Tg9jM4OtsLvo/FFv2JqrbTiU8J5HWfiEcSPCjVewGZ/fLI1uVMm0EpZJNa+aUpQM", - "bjgYAY6nW/HI5P3TNVyMQpWe5xcZtp6w1951nhANENU+kmkZcXeQ73VV6Y1lniEAkEDg8FcgnQ23jJge", - "20i3OmPL2p38Ex0FjwF/nwDLKfxdb7Z0tHEo/1vLplKSmsNjoINS3GVpaIeD7K4gGTyhd1kWbO7Pm2Kz", - "LZttjThnGrcBIGVuZTzO/Ht4+hrlZGfhkQ/CvsGWWWAI+GkGzb1LrXeZIX9ILjVkY7lFtlcdHPXOpVOk", - "t1ZKwdnrzzk+X61JpuxdfpxQmOGwQpXXXnzJSxpyzZcZBgSPz1jJHWe/vv0pS4c3UpUpT1hIWF8pF4tJ", - "GNj/ky8NiEsefX6Vitd2pf2CG1NlSWUtzJrnCIQGO2Ov8Q1mdWPmIgtezXPCrgfyjBlR6xMjKu7krWD+", - "zSmjJXOFfzH/JpMoExKL3Whz44zwF/TM6qpxwhNiHAfkwzyX9yL+7mIWizO2lA5YPJC64WqZX03A2h6R", - "0Lg9+7xPsvPbcFCsg93O0eVfKj3LUCSqP9f4+odh2eszymvZ5eUOcge44ROZW+0LaV4qZ7a7K5ZLpWGS", - "/j77HcYfUXQR/vszNl1Kh49Bx/K/IK8Vd3DdAAFatuZuvvLaBFwOpTRi7rTZMj7Tt6JLbzOtK8GVB9Pf", - "s3k6kb+LoVMByo38Hah+tnUCZKO4M1K5//Zd9lqz23Ul1U0yXwJJIIC+tsVdiwwmbcEWcH2BysloxHjD", - "bm04ieEIThFZZ/2XW7knvuoxV2vpr3mlNythREHIFLdyLgpm9fxGAIJrWXeutVJ65k6MDSYcKd4EMrna", - "1uIFDJI++R4HTB/9goP3yRX2kWZsEU3bWESaG6DUn6R1HsQdWvVYD2TrxNoeskNEqo8bOuHG8O0wn/Wi", - "ciTWosMjO3tjtHap0Ox/9M+yTNCZRs396TwwH8hha3+yaKGeNhTbCC/lCS8DdCS0SK095MPKioisdP4c", - "wqPpp4trUvIOs6bwYnbs2+y9z/1ys+ccBInrA3aEh/DgcOuP1Zku/clSQa4Nkjdo/BYYH2eNFV4fW+h5", - "Y6dN7acvUaFGZcutxJbNRKW9Eq2n7Bev38Fp918y6UVFUMHEekAS2Faap1C3iK2N9n8OXqvifQc1w0xw", - "+ILdsY+8nxAaC9rAFsSD9w8Qwhuip96hvg3az6gzjSSVOdD+4GTtY3A7+f8I0JcEN5X09xXuJRwxUINr", - "3hFiho4XwUsT5hbr+eTzlZeJMqRfltKDZvPyzkwqbrb5K6kUldjzqa7K6zxTM8LzY6JN/wqbiYVnM8C1", - "4Me9omhWr25sKkTzshQlKhRyIeFPAFegjg7z0xTXc0BNRuMeYGQ0W5HgLkVGRNrgVrQm7e5etMjeZcoo", - "Rlt15NivV9+f/A/mxJ07h//6kwucPy/DDGONZJgxp1LcuewYe64SAG2lq9KiJQn0SLCfkfnAL2n89UFX", - "NeEonZnAG0L3C69I7eC69vJgdkkD+MpDhcPkp1alVMvLYPLpS4p3/gAgw9WpckR2MM0W8m5KAm95xkpp", - "19JaURbA7SuxcEw3EZevhVkKtuFGSbVMRS+YKCvi7JO7CPjv6WP656swRm95v8Jds4vjaO7axz87eNph", - "8vA0i15/y2W2tXMPJaZVBa6SQaM+fYccSC5XjtVcCWZXemPP0+0JL4Jzgi4rvDpd3hMwO4SAK25vrviM", - "3rb54+9/wavcX/9149AQU3h5XptSmCm7ipqPBwu/QGUgXumjLrQUnt6VNmh4uyIEDeBvZx8OcVpABGIv", - "t/mv1EzfvXJinbnPHktgGxiqb70Be7tHSut9EiDQFhPDnbiu5DrvlusKWF30hkHPGKjcd449ec3NTak3", - "CuyPgPb5Sss5mFnBy8Yt+48T9oQdwx3xH+wU/vlP4Z+1nN+caAVa3X+csL+y39L3/nr3W/oeV1s0hwMB", - "HRcsAFSwzWp7Pau85lcWTAPAlj0RfL4igI4sjNRS6nHB1k3lZF0J9sSZBv02YZ5jr+TM9XoN9l/2BAyw", - "3NyIMizwCMY7LhiPljw7ZR1knzH435SlKD8jl0WRPMXxoyvD/3CCP+Alf1zQSH9Tk+LeUq8RVle396Sz", - "+NGAdabjJh91oOG0vKXPcsd6FJ+GUYhL77eDoTH4erQZKcFiahzeOcnpyFHybyX+YB/u2IJbbA3yEcRM", - "uctKPhHTzwEO0ASkeoWfP+sjv4eP0eDanCjfnofRECfW8AxhPIRPlqKSt8J8Hu4KJuTMlfiXSs+YLG1H", - "gELJiVs0PFvGvaZrZRn0CsRb5x7cpd0eBvybQ3RO3K6D64MDDkvSZOAbTV9X/oOPxcQr7yNPWlgNfRQN", - "YoTmokNCB3XoHLV/AaIcogmQfcAkiRuD5pJ4f7V36CwQD3j4mtrzD09QxbdCTb111Qg23fFWqHDPnjNx", - "x+eu2jJ/kaPbM97r7RVNtzwY8pKXUqTcbxmBhjP4RzBBRNlBphcW5HzF1nzLZoKMlRjkQyg2gi3lrVDT", - "Q1uIP/MqmQn2J5WGhJdJAqrydtCHHbn+yYqHCsY7eFKuaNYYtFWTvIiG8WJCql5WRExu4c4Qwg/Ald14", - "ztuOAX/bphbGinJA6vxJ8PJnnTu7n1FM7kpK+/UvBbFidV2BxVl31AeOv4LVsn2FfOw0SH6vh/guGUrv", - "scisT9sP35M9kpFzRPEa7dRDjPPg1fcJ3MtDe78jPxBf9BriGdDhIC2GBYB5gjthIOpjyt5RGF8pFwth", - "kriKNqgARi86IRZJ3FE0DYY4L7JU793nHpyHWc5hnXTIyPSzdnJBgalvjFjYnNXXMz4lNkz6A4x6FGBq", - "IZW0K1EC7qynbrnYIjbRKg92e+kvNQjcMALswsDQenZrr6hk5u5oRjhz3lAY2GtmjP8dfpqyXyB8JISL", - "ZQdq9avMUKlKtg8aI2ptcgNcgZ0BsAVsQCgMwmGc2Wa95mbLnvifp6hBwk/0wxnz+ubxCKNji4sWkiKg", - "uLvCHEm84UoMWKhKwxfBm9hbmNeFNytZCcbxYgNSUX2jCtpUG4XnnY7yLgLBD5T18bXzAKuo6yPLNlKV", - "egMeP/gwOtNpGCYw1s0fPqlaOSiCNQKpAaKiRUIeecbJuax5zkZeSltXfHs96JlfSnct1lxWg7+GT3Ox", - "Y5sQvgyHb8XB4G6FYxwiTGQplJNue47ONOffXgrHlGbOcFkJk2VK4H87LKPja0V3jQnM6eKyiMPgw89z", - "kQ8i2B+GXKCsf94L9AHDkL+YzK0oGXds5Vxtz05P/9Y8ffrnuedmR5attHXwQJzhcz8SPjidfpqTsLUm", - "EAIB+IOazRsUJL5IdDRx0uuZ4WrAIzEgUw3ukBFrkucyP9X6epyHI0Vb+9kOwHG6+wUAEYqHpJ89axu9", - "gB3YB+Bweq6rgaiDe1nuOBy9eeNCigMGS37H/pf8yx7fQzDXGjEX8jb6JXvL5nYgMPFQkkM2UinMe2TZ", - "oqkqVgm1dKtOxNLusRubuhDDOTvGO7LHwToQWQTcwW2xubi4hiwKwQnLqzedN3bB7wVUYCaORwJ6VnjJ", - "ay+1znVTlZ7t19xY0JxLCf82Yq6XSlrBrFRzgcIYxS0X7EZsRelFI1z9NGcv9jusXH431trCFP6O9YJz", - "4cVFYcnRM9p706XnjwfsjYTGCFluK97GS6XH8RVpGW2+QJJSsOvSLLWA27SxoqBofKmWYJ0IUZlHlqEz", - "fle6zfl1dpEYou/bsOfzVOi3N7KuRTndx6bvc+JFYBo9566XBMoztllt8xkn6PK8Bov1XiIe7zgtMrH6", - "fgtWzZorxsvSCAtiHJ+vGAGQEK3nVlKVAuRNI6yX7ekte854+MLrJ+AJEuD09DpJltCDVvM5TAcDqufL", - "cduKa8lEDWslzsiU19JMM1tLd41PGDfLBgyXZ61iEdxux0F5AwwViFZUNcStMNJtCzbnTiwhbM9JV4mC", - "lcJxWRUY8gJqsuzIzS3+YpiyGkhsy0cVECJwXcmhsk7UdLIgCxFCYgtmtf9HWD8zXKWRA21SisfVpCCy", - "9lwbpxkZS4D8o5eEkjx8gaMnT74PEyXPLsOco9PrHkAu+7MVkk3Jeqd2shiCRyo4gDvWoR4TyHFeStL5", - "Ghai2uh1/VAbUc76t67diKAafC+Li5Du8ECPv177k21XPKe64K8QS2OE8G9RZlrNDem8abTtkWU/vrx4", - "ERNUN9wyx2+E+nwXyy2u6f1wqMWRBSubdZRMHDXXQKYAFoknZJY3IlgPMMTburgu1tRt4sWIiLAhnWQX", - "4Dfawt3W0w0DmLZAOn12Ttx4AWnTYmFPvYB16l+3px/oGH489d+dflAf76sZFpOwtwM/yuUyF3jrGqOu", - "hSrPKG2NlFahyhO9OAGLJ5LPlJmQbXnWphPiMSN5CGN37Q3le3vSAX8FvxHXAR/TaOZ80h5TzJ8GmxBC", - "kfp0nNYVm/OqsscF/OPa/6PwE13T9a9K5knNMF7XghsMyNOq3QSkXy8JhHDUWSOr0qYXQgisLiJO/J9h", - "Ps8Tw4T+uZ9ugscbcDLytgjn/Ar343Wcs/fDVWPUS4Cg/4PW1XOEp/fLhb35kaDrf0PA9h6/bWE/cDVk", - "bgM16XCdhPzS490S3kGN+dJlw5WBHvJswjrg7MjKdOPqxlHSMvzClsKfvtmWeeyfx1R7y7RXb3hVUXTh", - "mq34rYim6im7QIdMWpNA3ErdhIHppEPI0JOQjhAzqJeigDCw4246cjt99BDMjBQLjL0Zfw3NuM3HXZMX", - "ASA4shivhkYMgXNOA7+Hk0d2jmPyvOIVAdnPXl2HU4irjSZ87sD9jH4q/A3Ot19swTYRAs9vF5XepBya", - "AFhzSa5ecma1qdkE65S9FQuwyIJzNJkGmcGKqz6EwfHKqw3f2u6aWbLUMAMgPJx6RImXarhUYw+xn/Uv", - "3Io34eP45DWMMizWR7qNOQ7kHApLsBocwph3iiLXJ6ffDprWurD9oshYoxfsmb8tnz196sVqw+dOGFuw", - "Rsn3De5Gkmfltzofzx7FokM4kMo608yHXNl5w1cQQ/dJVX6WC+cEwdE3sniaH6jagPykczji6UqPRHbl", - "pbRzbspoFe1ndNKvcHHNK8GVKK+b2mvUmrW/SneORpjAYuDVpt43ZdbzEpIyOHuBb3VOZvCEAMPAEPx5", - "pf0J3IpB38uemhJJsKz0khhgH2bEz+BcdrzeKdtCNkpf5UtEKM/o8/PSd/1Loa2yApNjzEjtMdJyETAD", - "eElxxfHwYdLwkR1tmwJqe+Why/FthGUo+8HT0zUSF77YIsUSlNL5Gy0iDrPXpQvEmI8BGdCkSfnFZE6U", - "waRla1mCxDdlsqzEGeNzJ29FkThyCURyHKFSEE9D+wFcI7gMI+baU7OX+8zS/z8S+FlC6+QGx6GkWk47", - "5yI5MYCK+JqXAvGMlBpDJUDIbtSRTYLFbas9LKTiVfcU2+6F0NoFqHpFWN4EMs6XIf4EAfJyTQR0bFEL", - "JNJ+3YrkaahykTz6voUiffw6QJQ+fJFAlz5/jpD+Wh80M4BF5z7+J/ygtQkk7Cie2EEOjWcml1+xzHtJ", - "xmfROZLo6YMBEFzOk43EfA0ElDdToVhMlaBypoqXcDnj+QoFo4BavcZt+PwGSNjTWVuXJZQRGc12OuW8", - "MoynnF2D02NkLlRSaml8WmPlMTVUV+UiXb+wbN4YLzZV21BLJa/rkoQ4gPuaHAHXbVzIaL+BzURxHPCz", - "tOSScTShnWInuh6tpuQvwCiL0Z4URMw1xIFk7gwIDwm115TeFExXpQciOCvu51Xx470Vi2zwvBXX80qG", - "nc3aas197T+ZSnD5cjwQ9MjjSsEcseZbttQgherGgY6QFJ2ji0Q6hs4fBpJOM7/JE9nvej2TuaN7Gc4j", - "ZWcEEEjjYRWUBEGaxhuS98gcDroXJpRutwX3e9Sm/F8A7aCPK8F/ctZ3znFynooua+vucfcs9ymxe2x2", - "OWCL0Pwm757bLEdGd8RbwTNpC4NWQyveh3TBJD4qWA+9OBjcHCD9rvRGjTIGZhKzc0BfcXuTyXnlVlxT", - "XZedY7AnJgIl8P0lCEHI9mtBsQRduXTzT4cKD2ZMx1XOofCKvGUkUv36KpgUal4J57xUaK1cKgx8AdsO", - "hC5z5gdsjKd6JDP8DOyyGAPphW8ISoQMT3/EPefiS50/oo8RlPI4yhLVRiEqCyUyN1w6zzli3YqEmRVt", - "OTG/e2DY6M7AZsK/bMRa3+bLUTygAqEKByUa02VHQ+uotclW3EPv80vZp/Ml0vm9CgF6UiemYgY89+0O", - "UtowRE8mn+zbFXoj1mWEqz2iCfKWvSCe3wiEbsiugMeUvJfvG25X0TOjMnaqjmsf6yls4DahOpEaz30W", - "ewjI+0Y0+5hIyKq2rWk/BGiqJNVWqtRiFzmOVMu4o2BRxRhmP2Sws1KRU+WJQVq2EtU9GBOuIWoiGWLz", - "P0VK7iAUV7bmN8KetacJP2jLvoX4DigxggsmH7e7TmYfxPAB/tGiN+UepDu3SjMUmOuW0y2CRwWe40Xm", - "vw26Ay4WXqwb+INMSdqGEqJ+wcNE6m90z9uvyfy5ZxW4o4BlCSEERBMUA0Ym5S4RBR5GP9KS6ZqSbtiw", - "hB9cH07ExgsdNzCUKox8JxhwENZ7sR0vpF/nTexX0cgfpIwkCtQ/gzBq9iQO4Y9o/Mc//+txwWoOQSqE", - "OnJRe1wJswbDBLfszS9vr/YqRAfL/Q7U0AoXSqkpNqZ2XgtBOxPIzORg5WvhtVNlOVhkPVBoZcYQCwSf", - "PUEf4BSmJTFrzg3UUaL8aP/28VlOPEGGgTdnwURlBWvv5CKwlyIYwRwQYMRZ6xsGKTyWrURGyR1dzF6v", - "TkOxQuw5sXjTKBt+vq4FBgCFMrBYWhcdobBFQFBtyAmNAQcbwrwMXdQUfVHQ7FASAKXp6T4jUxeM+AD/", - "DrymZ1bJWqPCbCNtUV5q7Rmi4iOyQrWvAEhvIoi9H3oPX0eg46MXKfSdFwH0/nvdhz/rC1xXviAOiJp5", - "pNwPE429CGO1j3ZgbGwLJJaVBaHvepR20glv2NVVdjI6ztnTGIyBCiXGVY2pbDNgyCsmTpf6WlKRiQOX", - "69UvL36ZrjGfpat6pGb1ezHa4DnKz+53JUwe3Ylx4oXRv4tU7WBPlsL5rXkX3g1G/Lmut8e9FDsEnswH", - "+XodLXzXplEDYnV4BSUMWRKXhOH1Am4i8NX1lmFX3Ij9SLmOps+D06aYQrM2pqp5IDyHG5wH/PX3CJXv", - "ljEgc28qo0V9tj9+0WrB6QUbVM+k4tSeordkV97DCvsyTVZZ6J7UIT0eq4nZvDqfp9ggyRsMQ5rrtQcj", - "1E+OovEJCAYJIyBhP4pMXR2gwFjsKA+2gQjtVQR1LvNxuNU9ijskVdRyOdFG5BYOdTMX3VqN/uQt8Cyg", - "pgIFFYE2y7iQw/D3aJBIBuAISxvcvoGIwj0++Y43Hpkt+pWhD8VABuWwi/sSFaCdIHHK35Y2yKgoUFLo", - "UdERwUicnrKfNX2419s+zOnHsVrOLL/1BBvZrQb2JZV1gkOnAmS3FNQeuShYevzqzlDu60SGpB5Wy2Zi", - "rtfCxiL7fgwKHy7Z3yaYHgX/pIQpho/gc3z0t0nRZsHjJcVt4vXDmBp2lRQxCPGNifDulQe8m9LKN0Ne", - "LsDsELFdYQmsIIiA4Awo9iyM+Egg2GISJH3wis30XZT07CTZqMhtR8sus6t03is+ex6nvuKz72l2/14L", - "wBWfvSIYrvjsbQTjis/etZBc8VnIF/uNlvxuL0FFAuLpbZ0GkxJnL/AQYHzpCk9YLxtSeDU51CzfnSoA", - "8iK+54f894vXP03vkfOXDy9J5s5uPeHxkvKP+qElFXIcqeTaE8aznHBm9ObgWzsZLRVY4f2XWbB0qTM1", - "P++kdXZvtc7exYPNe6LgxzE0rFdyN1Hc4xUzzt+kSw31zQ45NwjwMP7QivO10ma63FL7m91yYn7AI0wK", - "UQ6FyGhjHUxlo48WjQG0QXJV1r8l5sNEC2GudGkq4WVBx1aCg/qJ5boDfBkLoLRDJeaSG2AYcOCAHuwi", - "CsB+XyGSERxdVOEkYIfygacjeWSBOG8RMLRhcD9ms5/njc05PEWVmuzazJ8Nh6KyGDbVWBuh91fpfMWj", - "Q5ArkkakhdjfAToeDvIfa4hEHUmjZjT9lMuFnLIZD9jeXIDH786Ug/ZXRZr3QJhdZlOf4w/B+p1Kw1gA", - "xQh1NGAcz1vIDpTsjQJxryMKVNCHKbGIR9IbBYyfQfbsDlhMGoVrc1C9D+7bJHwDuAjEq1mPjEGoklEy", - "2QXcrSxyrfxs3XL3kxEXyJp8t0EkSeDr1vlNActuOVXmerQKBqMLDRQTlRQpua5DlZJ9d9BuWZNPqVeQ", - "hWAIaUMFZR+MmR7goysq7MpPufShINDBBVLFcGvMtEn0vBndJn5qcoWC2E/pg16Z4F3Zv/UbkYIAYRvr", - "GPLYi8u/DPbyUJ0/Snsgxfdlx2QVHzJS9FKf+Icn9kbWJ6Hi1gm0gPB07Uwj0NS4vIcWjVkM9yqKSDPs", - "25+3TaaVHWg6QzUXku3cB26GAJL01v0CYyaUsgy0IVQZlbJ7th4he1vOvZFNB3wVrwdawZGlksFjA20S", - "LMft27F/jAuIDDUZGpUYyXCjOtvSiSodRwAPzo/pnqlEe+6gjK5XS2XHyvvlRoa462GPHV2whqvEVPkk", - "quUUZKzNjUVXz3EMabtfKFsn5D8D6XDLm7z7jOIEzqAGjmdZW+GmDPqwbKQVux4qMusnKQIAC1Zu7LwS", - "Q+OjjxzKEwV214lKJjA6zfQeKz4ZUAg+l3dx1vZZ6yhqn5GnqH2QRCu3D6MTpX2Uenrap904ZVr69ZA+", - "1bEwBbNTH/3B5TyWpPPnGg9ba41OQEvOwL6DTKFtGbFp33V1f9tFO1gOGgotzADhhqSPfiHwEBdIamSI", - "AcRr2P8VfyFdIl8S/IGtbUcHgFMZZVrYLi4+goC+AJsJ8fjJbGsEKDsGe8hOnk6fTp9iEVOheC0nZ5M/", - "T59O/4wNElaAulNey1NerqU6bb2TSwFKrkcxyIWvysnZ5AfhLoPDo1OD+U9Pn362ltA0Q6Yn9KUwt160", - "gg66wHUbC51YPsLLsI5ZpbFSf61t1roNXcraHnjasMZCAEHsVosm2Jkut8H8avgG3z/HkJdtDeaMUjgx", - "Dy5ETIvcOoEF2taN9Qo8e/PzDwX7tzcvfyjYD6++95z0nZi9mbILh3Vl/oW9ln9BhtnF9a+xOkCbq/sX", - "MgsleAawTpdy0UVw9K+2XTl2iA0//Xstlg/9tlYP/nQjZvV9v+0cEBJye2T47LORIWA+Q4RXIeikhHLF", - "U3+4vkPyzw0X4TuNdX++e/bn0W/3CPv0g//fq/LjviMaaWbvAf2DEc7uJnpMHlk8siRaotsdUASHnDb3", - "u3tsFwSC8bVwwniJ5MPEa/2hJw3ehBPcw0mfmotkTf0V/BbIoO2BRdufY5EnEDKAr07ZS6/Cwj+wcbek", - "GDPx/hz7nxKXg35ZXi/9t8tffp7iP4M6+xO37gSenLx6wZ6QKxR/e99Aid+w7GPsNSzqim/b9DgIdwoQ", - "vcPaCnARFqEHEXfB4Rh6pxkBdVHOcXMqwcv/CXhquxbRm2S/nPOqgmpBbSe2uAyMpFUMmrixpaYxdNtm", - "LceEsRTZy9DHK7e1K8FLkKxoczuYmhQ5Oh0O16cxAaPtkFF2+/Shkpo5g7Q28Cm10d75rq0p+ttBpuPE", - "nUMSPsHUl+5JPnhukUTx0wccznCI0H85dIZetYWAe5oc+15WfveZ4sZQWA9VdDdATDwpDpA2SL+VYpOj", - "r5+kdcGRmSOu3hZ0AlnuvYGfsPdBaxh3daYNSUYQxf47enx7jwG33A4RveZujv4UqPUcjArSRLe7nbIX", - "wajgNBZ4poLIfSo67SwuL3ReQPV32ykNg3NLrAgB1c2wHzoLLxuwPHbqQflXQwkaMD9KLPpbVph7AYo4", - "KeqxTDKw0UaFCvQ5KqQ+K6+iV39I2Hy4NNXp5zJKkvvaVOJFBKoTH/f+3pLePbiTf/tfH8rLTj94EA/I", - "he3yH1F7S3Ccxyk4GaO01Z64xxG1EC0PE7XQajGEzdfiMdEIDqoBDHoxx7MvEFoIEU0GSPTVEJyf/0gn", - "7qBHONAPRU44soSc+53Y9lStxekiVIkfogAsI/+I68QJMguNiREpKRzZnf5+XerojvEWqlvYJCABGzuE", - "LJx2rLa3YkGuL8Mcn013peUUJ5+f4BJ0fDlaG9yDxA6AfZ0f93rokKYXxQ9pgle75AFqEX7WFWwLL7hy", - "zJXarHSF2VuQfwa5SqDmhaQu+8/PjrzE1Ab/J41A6E8r3oOSmPyyWxIx5LtbinE/nrI3HGIW5jdstoWU", - "o0CNGP4TlFg/PA8NSIbk6p8EL/dqbj0ZF0f7TGoWpZy3Q635HQbK/cvTp8X+sLnfHpGc2+7aGZL+FQpN", - "UgMP+O+TELryL0+PC1ZX0H2C8oOtk1XVFhfI7eg0R7ckwe4z0oIPNtpjQ22cHDVT+6qYzERd2Ht5VWn4", - "GXsX+2vAq0nxoH4rm5WoMIPKhoKszjIl7ijzJUN3VBTxJ1SVH4MJdnsTjWKGf/psk0MaE5WXGOKJHfyn", - "CN1QiVttIm6hpsSn3NFAUEpTiZgsHxy6K1vyocQkyP6c6caxFar2Wwgf6ZsA4hpvhKgtFmFMxNm1WM+E", - "CS7hpXBQ99NOB1nUzxqLXjy++hPbiY3UfgCv00GMn37w/yOdA1u474pJL+B5nHlnmd/tbhh+Uj6SQoAw", - "P1QhwOWHgkf3vns7Vfl3jEsXmHqDmav+T48HSgv2TKhNzFDUWihPUaH+yxchqi5LGEdYAQF92krj0PB6", - "aLK3AxTJy7Xtij26uujf26orpzPtBtg9DjPPBfL9J9SkvBJxEtWpvdrIJonSQq1EWmDDYK/rN9zC0Jvk", - "my3G3SUduPD2T279LRjEkiGlK5IS0JiWzxVb61swsIGfgGaec0spQBbuCK0rhuuY5YjpUri23djjUFA7", - "/ijK+W4I85HfPnCLKTXxxAhe7o8faAskfTKDGip8Nia5eDeWohdZrQ3GfqaVgHYYywpqR3i6IZmYCujo", - "BdLUSi5XWNvsPbzX4XhplbgsujxDf9OWvnp8Zh6ypUbw8YuqClaBaGYgYb67CJRUw8iPdAo6Lau+cKBA", - "RNqAFCU2bfvTBx+xgOrTD/TXAVtviu9HujsOrLu75s8txkUsPEySy6HzNJbLHDyMV1R17vFPIpRkGylO", - "tS5HWEG/PdW3hP9iQOPHk9vJOk6Sy2N+doyzGirBTgKBV8coB5qk5liABYJZOynJRXiHtzUPuGtzQWFS", - "6XLqPoINe/U4jC1JCf/CXA0JcJilYZGmL2TyzB9Xyhcd1MBQJ9CLNBUU5EetYxbjMCFJBSkjbUD+Do+F", - "hNVHZLAw/h4vGnSZl9ZTt2ULXlnRFovOpMQqHRDxj8STdalbxvzV+FLIB4eYjrSkTEFVwoOdGrybZCmM", - "ZWqKVH9vy2d1EnpDKYBOVYW2o0S3tl2nOiV9CIUX/Ej5OCZuXEw3fiReFZNj/3is6qHOeyqgcPoB//AU", - "HzrXnX6Arn8fx9F9+P5eZF8MFPT38x9ZVvfaQ+EckEoXmuvB3fgUshMyvncP/xiAOq6PA85K7rLNE9s+", - "iFAPBMqRIrzQRsdSjTTZa7IGRTHvoATalsml0gbfyvs207aOj+TiTKb4Or516ik6cJwQpV/s3s8cD+t0", - "/YhHYvAmeIkuqW6t1VhNjyz+JvGph1aV/mqWlShbXt6oStjOS235zAzhOV2/DRWgvoVtf3yuGOzDpx/o", - "rwPKbjA/PyJ+xni+CNhHkq4iKh4mXeVwekr+hnHn6YEADB2oC0ysFJ0eErHaFEpaK65KvViwJ0vNq4KV", - "Yi7JcwIBqf4JLyFjt0DTfox6LLCT6vGUvYulNUNFWqrlHXyU0kZnC3p7OUgNvd7JWFrUC1yQ3QO1tkii", - "i3JcADdXICurPiL6B8n3yzpuA/SApqRA4lc99qeQ0m6a+usQ6cuQDE6NP9Dxv7ORrwKQ38hWRqTBPpai", - "krcQ+/tVd7IT9/GlN/IymHhAIQAKj/XsYoBC7NPacgbPnILziJTAllXtlL7uxY9gEXOSDDg09kviuakz", - "VogoCa1CwUDFWz/vAMldClW2LTj/kJEmgP7VDqP61nS8pE3uATkmGhIfy6C0R1FOlOTPLbngyh8mtnSR", - "dzpvq5tmzX4v25Iu3Z7G0YT8JBYGKmJhz0bhX5hSrKC9lX/u1yvmEFc2XUoH+qE4ZlBezg9WdGul3qNU", - "6rDZMVinoXgqsiSs4cmuun2u9UZBCbg7jGzzHKSZQ60ZyK4LVUqLtr+0JP05KWBKFTIPLwQzI6FO7FC5", - "1wD5vmKv1ObdiRr7iDVZuYiOQlsHskdjA03CnQ7LoICstvxnd0EFs43fDL+PEN0QeHaIL10JXmLxUl5V", - "ehPQEhtBB3tHP0vK79nkXhaXixRD6oziCgLVtEUmperuHTOcYh546Oh72y2mlQUwtGrfa4QpsrWbw7dn", - "XSRndxuro8R/4f4cxY4L3b7DWonjQYCdqD815/ATvS9EhBm2+UaYE3AReGXkW0sP+tKMWZ/wxq2ond2g", - "SwbMdrG/ZaBdzNulFN1WgOow8LY4LXsCwT1JG4q2CLE2bMVLKO8AYWGY9dZKwKxRJUYKSQPFvo7B0QM+", - "HuIT0vRt8Behlt42hdZzZWoXAQ2DEPildLH0JOPseUCLKE/854bLKp9p/INwz/UFofCLxHpw4+Rc1ly5", - "sY7mdpO/fUmhlIvFXlJslFxIUULdxFDzHk5z20cDLj+47qLzedm5mqZDV9cLP/u4rAMs0Hgvo3024wCr", - "i3/yOLQhnzyOrspQQn+vuvZ4iTuyErANQ1ET3M1Xf2ymXUozeER+UYKV0oi50xDPlhOmi/iGFDZkEjlI", - "BNkCd52yl8rBj/5fJUjREH5ZiYVjunHnLMrVJRP0LoeGPliLFK1t0MfSM2KPChSEKsFvSZ9uwXEro5vl", - "ik2nmH1vt+tKqpsClHS0vu+RN19Ic0jWvKy4XZ1Y4V9yqCJUHH2xuttKwGjtQhFkD0u3ZXioRr3nBH6d", - "U/NCmp+kdXvKpkSigA5DsGN/9FMEheDGmbUeMu+QTesHeSsyjRr8RVaK0u+/KNFI4jTbGOlEq1lQoGrR", - "9nejTnhU7N72jWCQzgdVslLjeL+1zzGcVx1LDHLLrMbS9v3+kWSJD1XvSCT0P/CSSgWvBVcQ+HDOSqNr", - "zD2DxCEQyM5D9Hffvs9WeiNuse88zoL10W3WIgDd6VA9D4XFqSdLZD7th0mbUnbVDoaOvyLG//oPqTaN", - "EWsuVT+SI+1qFluG8coIXm6pHVecicRaG8I+qDmcPWdaxZ5AkKgJ9RKbmjyOftnIUUFsxd9y/I9KGebN", - "T3/6YuanglIKQyt+XFO528p0+nVMd2MybnsVjMDGy5kV7/vJPpjp2k1yrTiFikMGLKfv84k+Hpn3yW79", - "jDWEoIVyPrn12dNHz24dpdwAZsaoNZjyiq3PadOSnNenx9++ouN1lkGSvGBGLJuKG1Rt8jbRKbsSdw5l", - "JCpO3dRYzcjK3wVi55xhdTX0q9KbnnHTxj26dObl+c8rnh0QwO51Qz+qGvOcBhrgnX5H/tgiGFQGHD4E", - "sdinPwMxhnxHmfHCeey2Al9gKU/8mDvH56s1FrADSsc0d101Dso6DdHaOV0DRlhd3UKjTSR8MO6GOqFS", - "WVmKbkuEXvHQAkqHAkiX/+eHgs39/Qi6lrhzQkHsyfD5ebVGV+GYMmOf/wD8o1abtLfLf75bV1+9WKUn", - "3VCs8kFn/c9/TM4AisKXV80gRzlhMkmzyVjNQquFNGuvovnHZDhJkrwh4ojCVEEtomFOpOq2QJLqVlIf", - "KKchVo+Ku2ML8FwEFFOeDKtqW2A2NHblHlCQoGG21yqDnw2b3rWt7HbXqNWhNB2yiveM6mRFz5vNgUGj", - "wb1jjmrN0thTHBsHQKUJufTIWnDrThbabHgIzmwBm6/E/MZjElpD0y6k6mJOT8zohVi/PoaK7GlCn2p8", - "baOeUBIjOC2f9PrgQ+904Y7TVAKqvt+OIKH149IIa/uzBq8t4ayn3eeKXOBOSssW8i7UuIZKKm0Xelge", - "tMK2O6kKg43hI7RB3YXe+qEmLJTMQD9hQVGAoXG7Vp5e4WjgkzZLjHAqyp3u7r33ELtnSYv3aDeBzpY3", - "2OOcYHTsCcXwyAUVWwy1Yo6LiNIAHyIintMy1GhMoQy7Eqb+8eXFi5CXJFQZHBEhOym8RtV4Mr1iiS7a", - "TVHHOVtDi7HetkA5FpduRzB+9DA5ZS8l0TTsUSu0900aGVNFCCHQG0U7nba2LZg/LiAZIWFvhTuj+GiI", - "a8LJME89nIx0gM7hWPNSFGhxkjYwlpgWSC2G/RKKNuKLhsl2uY5ubGrM0OnUQHkQXd+8hwYsGUndK6zP", - "2cf+eWIrw10oja7rwF156vZU2iUJQU++e/qvx20oOXeZWPJsgSKz/NSExXzaPSSgFbkWL6GB8c4tCcC0", - "jZS6sfVhM+6xC0nAh+q0mk275CVtn/OtZ+AqbGMW4NUzOJXYfZBT5f7//ic2X3HD516o8BuDUgPa+PGM", - "gOUv2+8Qft8FwH+AwRN0YR6fBYC0IsFh6FKd0kE9C9wrfpDhhOdY16LDYNMu/x4QVBbGN+Qn2vIre42f", - "J0/e0Egf88UXvlxk4T4rJCIusTQWcDnB1WWhlBP+kFTE+yIZMZ/JfoniI8B+kp7JULppN+qnNHxjGWd4", - "Ujd9xo2MJ7I76ikDVIXVsPxL55G7x1t616T5nKu5qGCaCz/B42e87KODcO14StgQGtS3bwoM6sGX13di", - "MkfHExUFqX1sq+VTSaBkkVhLuNqCslTJubMUTL0UCqhHLRn96RmvrLwceklTJ+CkKSZ4mEM4+JAj66A4", - "O3i5vvrq0f+E83waxzfOourQUnyfk6XW2Q714q7WNohO+I9r/2qRhCNwFMOtMLekfHJZ2TkH+jC3opPu", - "4z9mqoGCfnoBmvKRZSttXcH0rTDsx6urN5dFXyFqlRFtEn/hQJnSN20T9S9R4Ify/MZWFkHYvn3WN4Zs", - "oq8WXi1ApKuFSUKl+237dnfr7RfcrLf32qt/mK3KVE/cxfM3X8Oww3kGKjp+O1txKC1KdRcylDxBtXBL", - "NLDhpb6uXTfhMZ+vhGlEaX7c589YotG/TsGcMTdzEjrzj3EdB9Y4irHGl/vWxHDr8mBBJtW+pSvL5CLt", - "x7wRRsT0nBvhScwxIxb21F/COShPP6iP4y7ic4rA24TYnKjNcTd0SV9GPHwJfhRmG11PNUD3D8D8A6h+", - "v04X2tw8mrJS5Ov/3r8gyYHKQKHEDMUr5eqWodEgep6TlB88EEVsT4a9fDq8GHRsT2BSNeSTSAaggqVa", - "3Qpj0QmEWUPWaR1LXnUTc26FKrWhHLIwjd+LY7T8J2zqKOXqoVFcJz8Ci6ODqXhfkBsYx8vYK9mrXgGm", - "YMiLFl5cUxifO9Z2EsjXYftem5vLNrfp/1caOszYk3qtj2ouGFXNJ19TNc3+aYurSmEpPMmK91N2gc0g", - "iEaqpClb8GdEsqLk0WCOOmcc67MaeF2sIeoV8TI1kBWpN6p1UWId5B2dn5ubpKbtY0k2yQxfvNDvQ6v3", - "9EjOCbOWild7gtXeidmlnt8IR9E26IxL6YDGKBhnlV5KxexKVBWWcU5jeZKw5rmurN9DuPHlArukJYYc", - "hrVboLUwcjYUcTeBOsOkRJlzoVy1ZbXxt0RJHmNwbjWubkgYxr71BeNVBZ1KMHYulEDAOeeVRD4PArWi", - "b3uvksHLyt/RtQg1reOPK12V2PbiioC8lL+3Ln9B6BF3MgQV4DrJVQmcd65Lwb57+vRpdGj61xm2go6R", - "24LbveFFYf5xEUZ+T/aykr0hpANxqX6DHz5oP4bpGd4bPX0JWiS1ZNkSbG2003Nd/bED8RpFffwPduPo", - "q5aekulGAJ/zkLW41MKqI5fNvPs1zP6YFfPDHENV8+n3cNtM/5PtcCiLu1chjLVzowS4MPr3mFwCZuk5", - "CNBlkSS5e046ZT9xr42JUpK1H/1TNlUaYexSqyNHaB6QC4k3vQswP7I7Kc4zFMTnkaDiCr59bS1Aemoa", - "td8iH6KO+ptkGpX4dkSl1dIyp89CBQmpsMRkl1BaNQHCvcAQgElJjjsRsmvMNlTl5yAwiHWNzTqLNjaN", - "HltSzvCKTtK2g5zgdbMBCgq7+rZ51Apz6TRDJt5GYS8+wPU3SD5h2/fbdt/Ft76EMSXMRiL0GJsKVJGJ", - "NHyE2avEi+J7rVcplXkjBk4/+N9G9EYaZk57eiMxqESPlI+sVTpoSEUVDOa63j6IPoZKEz2MhW55P3R6", - "VLBzgvmFrMQZHP0AwYuWaXDL/v3i9U9whXCVROhTunEosfVYraT8/z6HPhxsSZBEUFd8noSepiXoER1e", - "9xlGBuRp/tvlLz8HWw6UhbYxfslz6GdefH329GknfimhqKA5I2V1IOnTWIZlvmm61DJGGx5FKF+uCO4O", - "z9jb/rN3n9+rvcfHj/8vAAD//w==", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/inbox.go b/internal/server/inbox.go index 9acc9ac..254c6a1 100644 --- a/internal/server/inbox.go +++ b/internal/server/inbox.go @@ -1,7 +1,12 @@ package server import ( + "errors" + "fmt" + "io/fs" + "log/slog" "net/http" + "os" "g.fc.run/misc/byre/internal/api" "g.fc.run/misc/byre/internal/store" @@ -36,6 +41,63 @@ func (s *Server) GetInboxItem(w http.ResponseWriter, r *http.Request, itemID str writeJSON(w, http.StatusOK, item) } +func (s *Server) GetRunBundle(w http.ResponseWriter, r *http.Request, itemID string) { + item, err := s.store.GetInboxItem(r.Context(), itemID) + if err != nil { + writeStoreError(w, err) + return + } + if item.Kind != api.ClientRun { + writeError(w, http.StatusBadRequest, fmt.Errorf("item %s is a %s, not a client_run", itemID, item.Kind)) + return + } + if item.State != api.Open { + writeError(w, http.StatusConflict, fmt.Errorf("run request %s is %s", itemID, item.State)) + return + } + + f, err := os.Open(s.store.BundlePath(itemID)) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", "application/gzip") + http.ServeContent(w, r, "", info.ModTime(), f) +} + +func (s *Server) ReportRun(w http.ResponseWriter, r *http.Request, itemID string) { + req := api.RunReport{} + if !readJSON(w, r, &req) { + return + } + + item, err := s.agents.ReportRun(r.Context(), actor(r), itemID, req) + if err != nil { + writeAgentError(w, err) + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) removeClosedBundles(events []api.Event) { + for _, ev := range events { + if ev.Kind != "inbox.updated" || ev.Payload["kind"] != string(api.ClientRun) || ev.Payload["state"] == string(api.Open) { + continue + } + id, _ := ev.Payload["id"].(string) + err := os.Remove(s.store.BundlePath(id)) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + slog.Error("remove run bundle", "item", id, "err", err) + } + } +} + func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) { req := api.InboxRespond{} if !readJSON(w, r, &req) { diff --git a/internal/server/server.go b/internal/server/server.go index 6bd5bd2..ae8069f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -81,6 +81,7 @@ func (s *Server) publish(events []api.Event) { s.hub.publish(ev) } s.agents.Observe(events) + s.removeClosedBundles(events) for _, ev := range events { if (ev.Kind == "task.merged" || ev.Kind == "task.discarded") && ev.TaskId != nil { s.terminals.close(*ev.TaskId) diff --git a/internal/store/inbox.go b/internal/store/inbox.go index 955898c..3f50707 100644 --- a/internal/store/inbox.go +++ b/internal/store/inbox.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" "strings" "time" @@ -14,7 +15,7 @@ import ( func DeliverableResponse(t api.InboxResponseType) bool { switch t { - case api.Option, api.Other: + case api.Option, api.Other, api.Run: return true } return false @@ -167,6 +168,31 @@ func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolut var ErrNotOpen = errors.New("inbox item is not open") +func (s *Store) AddRunResponse(ctx context.Context, resp api.InboxResponse) (bool, error) { + added := false + err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { + res, err := tx.ExecContext(ctx, ` + INSERT INTO inbox_responses (id, item_id, user_login, type, text, client, exit_code, images, attachments, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', ?) + ON CONFLICT (id) DO NOTHING + `, resp.Id, resp.ItemId, resp.User, string(api.Run), resp.Text, *resp.Client, *resp.ExitCode, formatTime(resp.CreatedAt)) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil || n == 0 { + return err + } + added = true + return emitItem(ctx, tx, emit, "inbox.updated", resp.ItemId, resp.User) + }) + return added, err +} + +func (s *Store) BundlePath(itemID string) string { + return filepath.Join(s.dataDir(), "bundles", itemID+".tar.gz") +} + func (s *Store) WithdrawItem(ctx context.Context, id string) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { res, err := tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_at = ? WHERE id = ? AND state = ?`, @@ -235,9 +261,9 @@ type Pending struct { func (s *Store) Undelivered(ctx context.Context, sessionID string) ([]Pending, error) { rows, err := s.db.QueryContext(ctx, ` SELECT r.id, r.item_id FROM inbox_responses r JOIN inbox_items i ON i.id = r.item_id - WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?) + WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?, ?) ORDER BY r.created_at - `, sessionID, string(api.Option), string(api.Other)) + `, sessionID, string(api.Option), string(api.Other), string(api.Run)) if err != nil { return nil, err } @@ -347,7 +373,7 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) { func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxResponse, error) { rows, err := q.QueryContext(ctx, ` - SELECT id, item_id, user_login, type, options, text, images, attachments, created_at, delivered_at + SELECT id, item_id, user_login, type, options, text, client, exit_code, images, attachments, created_at, delivered_at FROM inbox_responses WHERE item_id = ? ORDER BY created_at `, itemID) if err != nil { @@ -361,15 +387,24 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes typ := "" options := sql.NullString{} text := sql.NullString{} + client := "" + exitCode := sql.NullInt64{} images := "" attachments := "" createdAt := "" delivered := sql.NullString{} - err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &images, &attachments, &createdAt, &delivered) + err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &client, &exitCode, &images, &attachments, &createdAt, &delivered) if err != nil { return nil, err } + if client != "" { + r.Client = &client + } + if exitCode.Valid { + code := int(exitCode.Int64) + r.ExitCode = &code + } r.Type = api.InboxResponseType(typ) if options.Valid { diff --git a/internal/store/store.go b/internal/store/store.go index 9a9e259..ffcc4e3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -466,6 +466,9 @@ ALTER TABLE inbox_responses ADD COLUMN images TEXT NOT NULL DEFAULT '[]'; ALTER TABLE lead_notes ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; `}, {"add-session-subagents", ` ALTER TABLE agent_sessions ADD COLUMN subagents INTEGER NOT NULL DEFAULT 0; +`}, {"add-inbox-run-responses", ` +ALTER TABLE inbox_responses ADD COLUMN client TEXT NOT NULL DEFAULT ''; +ALTER TABLE inbox_responses ADD COLUMN exit_code INTEGER; `}} const ( diff --git a/mac/Sources/Byre/ClientRun.swift b/mac/Sources/Byre/ClientRun.swift new file mode 100644 index 0000000..0ad335d --- /dev/null +++ b/mac/Sources/Byre/ClientRun.swift @@ -0,0 +1,166 @@ +import ByreAPI +import Foundation + +private let maxOutput = 64 << 10 + +private let runsDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appending(path: "run.fc.byre.mac/runs", directoryHint: .isDirectory) + +@MainActor +private var watching: Set = [] + +@MainActor +func runOnThisMac(_ api: API, _ item: InboxItem) async throws { + let dir = runsDir.appending(path: UUID().uuidString, directoryHint: .isDirectory) + watching.insert(dir.path) + do { + try await start(api, item, dir) + } catch { + try? FileManager.default.removeItem(at: dir) + watching.remove(dir.path) + throw error + } + poll(api, dir) +} + +@MainActor +private func start(_ api: API, _ item: InboxItem, _ dir: URL) async throws { + let files = dir.appending(path: "files", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: files, withIntermediateDirectories: true) + try item.id.write(to: dir.appending(path: "item"), atomically: true, encoding: .utf8) + try api.origin.absoluteString.write(to: dir.appending(path: "server"), atomically: true, encoding: .utf8) + try str(item.payload.value, "command")!.write(to: dir.appending(path: "command.sh"), atomically: true, encoding: .utf8) + + let (bundle, response) = try await api.session.download(from: api.url("/api/inbox/\(item.id)/bundle")) + let code = (response as! HTTPURLResponse).statusCode + if code != 200 { + let text = (try? String(contentsOf: bundle, encoding: .utf8)) ?? "" + throw ApiError(status: code, message: "download bundle: \(code) \(text)") + } + try await untar(bundle, into: files, errors: dir.appending(path: "untar.log")) + try? FileManager.default.removeItem(at: bundle) + + let p = Process() + p.executableURL = URL(filePath: "/usr/bin/lockf") + p.arguments = ["-k", "lock", "/bin/zsh", "-c", "cd files && /bin/zsh -l ../command.sh >../output.log 2>&1; echo $? >../exit.tmp; mv ../exit.tmp ../exit"] + p.currentDirectoryURL = dir + p.standardInput = FileHandle.nullDevice + p.standardOutput = FileHandle.nullDevice + p.standardError = FileHandle.nullDevice + try p.run() + let lock = dir.appending(path: "lock") + while !FileManager.default.fileExists(atPath: lock.path) { + if !p.isRunning { + throw ApiError(status: 0, message: "start command: lockf exited \(p.terminationStatus) before taking its lock") + } + try await Task.sleep(for: .milliseconds(20)) + } + log.info("client run started: item \(item.id, privacy: .public) in \(dir.path, privacy: .public)") +} + +private func untar(_ bundle: URL, into dir: URL, errors: URL) async throws { + FileManager.default.createFile(atPath: errors.path, contents: nil) + let errFile = try FileHandle(forWritingTo: errors) + defer { + try? errFile.close() + } + let p = Process() + p.executableURL = URL(filePath: "/usr/bin/tar") + p.arguments = ["-xzf", bundle.path, "-C", dir.path] + p.standardError = errFile + p.standardOutput = FileHandle.nullDevice + try await withCheckedThrowingContinuation { (c: CheckedContinuation) in + p.terminationHandler = { _ in + c.resume() + } + do { + try p.run() + } catch { + c.resume(throwing: error) + } + } + if p.terminationStatus != 0 { + let text = tail(errors) + throw ApiError(status: 0, message: "unpack bundle: tar exited \(p.terminationStatus): \(text)") + } +} + +@MainActor +func resumeClientRuns(_ api: API) { + let dirs = (try? FileManager.default.contentsOfDirectory(at: runsDir, includingPropertiesForKeys: nil)) ?? [] + for dir in dirs where !watching.contains(dir.path) && (try? String(contentsOf: dir.appending(path: "server"), encoding: .utf8)) == api.origin.absoluteString { + if !FileManager.default.fileExists(atPath: dir.appending(path: "lock").path) { + log.info("client run never started; removing \(dir.path, privacy: .public)") + try? FileManager.default.removeItem(at: dir) + continue + } + watching.insert(dir.path) + poll(api, dir) + } +} + +@MainActor +private func poll(_ api: API, _ dir: URL) { + Task { + let exitFile = dir.appending(path: "exit").path + var interrupted = false + while !FileManager.default.fileExists(atPath: exitFile) { + if lockFree(dir.appending(path: "lock").path) { + interrupted = !FileManager.default.fileExists(atPath: exitFile) + break + } + try? await Task.sleep(for: .seconds(1)) + } + await report(api, dir, interrupted: interrupted) + watching.remove(dir.path) + } +} + +private func lockFree(_ path: String) -> Bool { + guard let h = FileHandle(forReadingAtPath: path) else { + return false + } + defer { + try? h.close() + } + let fd = h.fileDescriptor + if flock(fd, LOCK_EX | LOCK_NB) != 0 { + return false + } + flock(fd, LOCK_UN) + return true +} + +@MainActor +private func report(_ api: API, _ dir: URL, interrupted: Bool) async { + do { + let itemId = trimmed(try String(contentsOf: dir.appending(path: "item"), encoding: .utf8)) + var exit = -1 + var output = tail(dir.appending(path: "output.log")) + if interrupted { + output += "\n[byre: the command stopped without finishing, for example because the Mac restarted]" + } else { + exit = Int(trimmed(try String(contentsOf: dir.appending(path: "exit"), encoding: .utf8))) ?? -1 + } + let client = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + _ = try await api.client.reportRun(path: .init(itemId: itemId), body: .json(.init(runId: dir.lastPathComponent, client: client, exitCode: exit, output: output))).ok + log.info("client run reported: item \(itemId, privacy: .public) exit \(exit)") + } catch { + log.error("report client run \(dir.path, privacy: .public): \(errorText(error), privacy: .public)") + guard let e = rootError(error) as? ApiError, (400..<500).contains(e.status) else { + return + } + } + try? FileManager.default.removeItem(at: dir) +} + +private func tail(_ file: URL) -> String { + guard let h = try? FileHandle(forReadingFrom: file) else { + return "" + } + defer { + try? h.close() + } + let size = (try? h.seekToEnd()) ?? 0 + try? h.seek(toOffset: size > UInt64(maxOutput) ? size - UInt64(maxOutput) : 0) + return String(decoding: (try? h.readToEnd()) ?? Data(), as: UTF8.self) +} diff --git a/mac/Sources/Byre/Inbox.swift b/mac/Sources/Byre/Inbox.swift index a815d49..e9deca8 100644 --- a/mac/Sources/Byre/Inbox.swift +++ b/mac/Sources/Byre/Inbox.swift @@ -192,6 +192,21 @@ private final class CardModel { } } + func run() { + if busy { + return + } + busy = true + Task { + do { + try await runOnThisMac(store.api, item) + } catch { + overlays.showAlert(errorText(error)) + } + busy = false + } + } + func submitOther() { if typed.isEmpty && !hasImages { return @@ -289,10 +304,29 @@ private struct CardBody: View { } .textSelection(.enabled) .padding(.vertical, 10) + case .clientRun: + VStack(alignment: .leading, spacing: 10) { + MarkdownView(blocks: parseMarkdown(jsString(p["why"] ?? "")), ctx: MDContext(taskId: item.taskId)) + MarkdownView(blocks: runDetails(p), ctx: MDContext(taskId: item.taskId)) + } + .padding(.vertical, 10) } } } + private func runDetails(_ p: JSONDict) -> [MDBlock] { + let paths = (p["paths"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String } + let bytes = (p["bytes"] as? Int) ?? Int((p["bytes"] as? Double) ?? 0) + var files: [MDInline] = [.text("No files.")] + if !paths.isEmpty { + files = [.text("Files (\(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))): ")] + for (i, path) in paths.enumerated() { + files += i == 0 ? [.code(path)] : [.text(", "), .code(path)] + } + } + return [.code(jsString(p["command"] ?? "")), .paragraph(files)] + } + private func attachments(_ v: (any Sendable)?) -> [Attachment] { guard let v else { return [] @@ -451,6 +485,19 @@ private struct CardActions: View { } .disabled(model.busy) } + } else if model.item.kind == .clientRun { + HStack(spacing: 8) { + Button("Dismiss") { + model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss)) + } + .buttonStyle(WebButtonStyle()) + Spacer() + Button("Run on this Mac") { + model.run() + } + .buttonStyle(WebButtonStyle(primary: true)) + } + .disabled(model.busy) } else { HStack(alignment: .top, spacing: 8) { TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) { @@ -596,11 +643,19 @@ private struct Thread: View { if !item.responses.isEmpty { VStack(alignment: .leading, spacing: 2) { ForEach(item.responses, id: \.id) { r in - let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))" - let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)" - Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)") - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) + if r._type == .run { + Text("\(Text(r.user).font(Fonts.font(11, weight: 700))) ran it on \(r.client ?? ""): exit \(r.exitCode ?? -1)") + .textSelection(.enabled) + if let out = r.text, !trimmed(out).isEmpty { + MarkdownView(blocks: [.details(summary: [.text("Output")], body: [.code(out)])], ctx: MDContext(taskId: item.taskId)) + } + } else { + let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))" + let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)" + Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)") + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } } } .font(Fonts.font(11)) diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index b215db6..76bd4fb 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -43,7 +43,7 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate { private func wants(_ p: NotificationPrefs, _ item: InboxItem) -> Bool { switch item.kind { - case .question: p.questions + case .question, .clientRun: p.questions case .sessionError: p.errors case .rateLimited: p.rateLimits } @@ -55,6 +55,7 @@ private func itemText(_ item: InboxItem) -> String { case .question: return str(p, "question") ?? "" case .sessionError: return "Error: \(str(p, "error") ?? "")" case .rateLimited: return "Rate limited" + case .clientRun: return "Run on your Mac: \(str(p, "why") ?? "")" } } diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 4ef09ce..78f5804 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -121,6 +121,7 @@ final class Connection { } phase = .running(store) store.startGlobalStream(after: status.lastEventSeq) + resumeClientRuns(store.api) Notifier.shared.deliver() } catch { await store.stop() diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 850ccac..42b9253 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -163,6 +163,9 @@ final class Store { if open.contains(where: { $0.kind == .question }) { return TaskState(kind: .answer, label: "Needs your answer", title: "The agent asked a question in the inbox") } + if open.contains(where: { $0.kind == .clientRun }) { + return TaskState(kind: .answer, label: "Ready to run", title: "The agent asked to run something on your Mac; see the inbox") + } if task.merging { return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running") }