Add run_on_client: agents run commands with files on the user's Macs
This commit is contained in:
+10
-2
@@ -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/<project-id>/<task-id>`, `leads/<login>` (each lead agent's working directory, the login path-escaped), and `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory). Task branches are `byre/<task-id>` 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/<project-id>/<task-id>`, `leads/<login>` (each lead agent's working directory, the login path-escaped), `blobs/<blob-id>` (uploaded images; the `blobs` table maps each id to its content type and its path relative to this directory), and `bundles/<item-id>.tar.gz` (the files of open run requests, see "Client runs"). Task branches are `byre/<task-id>` 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: <why>"), `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 · <task title>` 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-<seq>` 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/<item id>.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/<uuid>` 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 "<user> ran it on <client>: exit <n>" 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 <data dir>/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 <default branch>`, 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.
|
||||
|
||||
Reference in New Issue
Block a user