From af373a1e43b56e9f46bdf5bc535ad7db328e5984 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Wed, 4 Nov 2026 17:14:40 -0800 Subject: [PATCH] Show teammates' shared machines, read-only, on the Machines page and in a document's machine menu, and run their builds there A machine its owner shares with teammates could run a teammate's Planner, but GET /api/connector/machines listed only the signed-in person's machines, so the Machines page and a document's machine menu never showed that machine, whether it was online, or let anyone pick it. A document that said "Planner: starting on " named a machine its people could not look at. The list now includes the machines teammates share (`teammate: true`, with `owner: { login, avatarUrl }` and `repositories`), and the person's own rows say `teammate: false`. A teammate's row leaves out everything only the owner sees or sets: autoRun, reauthorize, finishing, favorite, shared, createdAt, and also the host, system and agent. A machine that is not shared, one that is revoked, one whose owner cannot push, and one that serves nothing the person can push to never appears; the owner-only routes (/label, /favorite, /shared, /revoke) still answer a non-owner 404. The owner's own shared machine is listed once, as theirs. It is listed wherever Chopin has machines, with or without Planners on machines: checking a repository takes the machine's own token, which under AUTH_MODE=local is the owner's live login. The push check is Default machine's: `reach` now holds each answer by who was asked and the repository, behind ownerReaches(), asked with the machine's own token, held 10 minutes for a pass and a minute for a refusal, and asked once however many listings ask at once. A connected machine serves a repository by serves(), so a protocol 5 machine with no registered checkouts, which clones what it needs, serves every repository its owner can push to; an offline machine is listed, disabled, whatever it has checked out. The Machines page no longer depends on the person's projects, and for a machine that serves every repository it asks GitHub nothing per repository: it intersects two lists of what can be pushed to among GitHub App installations, as the repository picker lists them, every page of every installation, with no bound on how many repositories either side has. The person's own list is held a minute. The machine owner's is listed with the machine's own token, held 10 minutes (a minute after a failure) once per owner for everyone who looks, and asked once however many people and polls ask at once, so the owner's GitHub rate limit pays a few listings every 10 minutes however many teammates have the page open and however often it refreshes; before, up to 50 of each teammate's repositories were each asked with the owner's token every minute (about 3000 calls an hour for one teammate), and a repository past the first 50 the person could push to was missing until they worked in it. A machine that needs its GitHub authorization renewed is listed to no teammate, as the menu already did. Only the repositories a machine has checkouts of are asked one at a time, eight at a time: the owner's with the machine's token as above, and the person's, for one their installations do not list, with their own login once, kept 10 minutes for a pass and a minute for a refusal in the same `reach` map under their id, with the answers that have run out forgotten as a listing begins. The document's own menu names its repository and asks about it alone. The Machines page gets a "Shared with you" table (a list of rows on a phone) with name, owner, release, repositories and state, and none of the star, switch, rename, disconnect or setup; an owner's avatar that fails to load no longer leaves their name out of line with its column. A document's machine menu lists the same machines under "Shared by ", offline ones disabled. The sidebar count, the favorite and Automatic read the person's own machines alone, and the machine button shows for a person whose only machines are a teammate's. Anyone who can edit a document may choose a listed machine for it, and the creator's own choice still decides where its Planner runs (#771). Another editor's choice counts only while the creator chose no machine, and it takes the Planner from Default machine only where its machine can run it for them at that moment (online, started with a Planner command, and for a teammate's machine its owner able to push): a Planner attached on the creator's machine stops, one that was only asked is withdrawn and loses its credential, and only the chosen machine is asked. A choice that cannot run it yet is stored and stops nothing: a Planner under way, a reply included, is never interrupted by it, nor later by that machine coming online, and the machine takes the document once it can run it and no Planner is attached on Default machine. Whether a stored choice places the Planner is derived from the stored choices on every ask, not from what the process remembers of an earlier takeover, so after a restart Default machine is not asked for a document whose stored choice places it on a machine that can run it, whichever of the two connects first. The creator choosing their own machine or Default machine takes it back: the Planner on the editor's machine stops and the choices that were placing it, even one whose machine had not been asked yet, are cleared; an editor's choice made while the creator's own stood is only stored, for their own builds and runs, and counts again once the creator chooses no machine. The owner stopping sharing takes the machine off teammates' lists, clears their choices of it, and lets a Planner it is replying with, asking about or running finish first. A document is given to Default machine then only where the machine ran its Planner, had been asked to run it or was placing it by that choice, or where someone uses the document; any other document just loses the choice, so a build-only choice starts no Planner on its creator's machine. Choosing a teammate's machine now also sends that person's Build plan, Build, Retry, Run and manual rebuild there. The agent runs on the machine with its owner's GitHub account, so sharing is the owner's consent to that, and the copy says so on the sharing switch, the pairing card and the Machines page, in docs/chopin-cli.md and in the other docs that stated the old boundary. A build records the person who asked as `user`, and the owner as `machineOwner` when the machine is not theirs; only the connection it was queued on may claim or report it, and it is queued, offered and claimed only while the machine is shared, its owner can push to the repository (the same ownerReaches() check) and the person still chooses it. A queued build that stops qualifying ends saying so; a build already running finishes, like a Planner mid-reply. A choice whose machine is online but no longer takes the work fails with `machine-unavailable`, never on another machine; an offline one says it is offline. The lifecycle run is recorded as the person's, the Build tab names whose machine it ran on, and the coding agent is told, in `read_implementation` and `read_rebuild` and in its prompt, to end each pull request body with a note saying who requested it, because the pull request is the owner's. Rebuilds and prototypes Chopin starts by itself (LIVE_BUILD, LIVE_SPIKES) still run only on their builder's own machines with that machine's own opt-in, never on a teammate's. The machines e2e's two tests that share a machine ("the machines table", which shares studio, and "server or VM", which pairs a machine shared by its key) now sign in as someone who can push to a repository of their own, `octo-org/relay-`, which the fake GitHub shows only to them. A machine shared with teammates is listed, and offered in a document's machine menu, to everyone who can push to a repository its owner can, so one shared in octo-org/score showed up to every other test's person on the same server for as long as it lasted, and for good where a test never stopped sharing it, whatever order and beside whichever tests these ran: "server or VM" got studio as a second row and timed out, and machine-choice's menus and its machine button saw an extra machine. A machine they share is now listed to nobody else, so the tests no longer depend on each other, and the revoke "server or VM" needed at its end is gone. Fixes #769 Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run c69c9dc3-c0be-49f1-a9f3-3afbfabdbf57) Assistant-duration: 6h13m converged, estimated 90m Assistant-verification: bun test passed on the final tree: 7071 pass, 0 fail, 22 skipped without a database, run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository Assistant-verification: bun test apps/server/src/storage/postgres not rerun: no storage file changed in this repair (the 126 passes on PostgreSQL 17.11 reported earlier were on the tree before it) Assistant-verification: mutation checks passed against the final listing code (routes.ts has not changed since): nine one-line breaks of the new listing code were each caught by planner-dispatch.test.ts: the two lists cut at 50 (5 tests failed), the owner checked once per repository again instead of by list (7), the lists never held nor shared (4), no de-duplication of a list in flight (1), the owner's list held only as long as a person's (1), the owner's list kept per machine instead of per owner (1), a machine needing its authorization renewed still listed (1), a failed list held as long as a pass (1) and a failed list never held (1); the earlier mutation checks of the reach cache, the one-at-a-time checks, the unshare guards and the Default takeover were not rerun Assistant-verification: bun run fix passed: no changes after the last edit, 71 lint warnings, as before Assistant-verification: bun run types passed: whole workspace and e2e Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record and design check Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e planner-picker machines machine-choice passed twice on freshly created databases, on the final server and e2e code (the only change since is one assertion added to planner-dispatch.test.ts): 57 passed, 0 failed, none retried, in parallel on four PostgreSQL 17 databases of one server at port 55440 with the client built from this checkout; machines, then machine-choice, then planner-picker one after another on one database, the opposite of their alphabetical order: 9, 35 and 13 passed; machines with --repeat-each=3 twice on that database: 27 passed each time, and "the machines table" alone, then "server or VM" alone, on a database the first had used: both passed; the machines suite with its e2e files as they were before this repair failed in "server or VM" with studio as a second teammate row; a run of the three suites on a database an earlier full run had used failed four machine-choice "populated sidebar" cases whose documents of the same titles were still in the sidebar, which no fresh database has Assistant-verification: screenshot viewed: /tmp/chopin-shots-769/machines-shared-no-checkout.png, retaken on the final tree (the Machines page of tessa, who has no project: "Shared with you", a Cloud group, Build box, casey, chopin 0.0.24, octo-org/score, Connected); the other screenshots under /tmp/chopin-shots-769/ were taken before this repair, which changes no web code Co-authored-by: Alex Lavaee --- AGENTS.md | 19 + apps/connector/src/implementation.test.ts | 12 + apps/connector/src/implementation.ts | 15 + apps/server/src/auth/machines.ts | 7 +- apps/server/src/experiments/connections.ts | 41 +- apps/server/src/experiments/pairing-keys.ts | 2 +- apps/server/src/experiments/routes.ts | 710 +++++++-- .../harness/remote/planner-dispatch.test.ts | 1329 ++++++++++++++++- apps/server/src/main.ts | 2 + apps/server/src/storage/machine-contract.ts | 57 + apps/server/src/storage/memory/machines.ts | 8 +- apps/server/src/storage/model.ts | 5 +- apps/server/src/storage/port.ts | 2 + apps/server/src/storage/postgres/machines.ts | 16 +- apps/server/src/tasks/builds.ts | 38 +- apps/server/src/tasks/connector.ts | 87 +- apps/server/src/tasks/routes.test.ts | 426 ++++++ apps/server/src/tasks/routes.ts | 81 +- apps/server/src/testing/agent-link.ts | 43 +- apps/web/src/build-start.test.ts | 2 + apps/web/src/build-start.ts | 5 +- apps/web/src/build-view.tsx | 6 +- apps/web/src/experiments/add-server.tsx | 8 +- apps/web/src/experiments/api.test.ts | 3 + apps/web/src/experiments/api.ts | 6 +- apps/web/src/experiments/machine-list.test.ts | 67 +- apps/web/src/experiments/machine-list.tsx | 184 ++- apps/web/src/experiments/machines.css | 7 + apps/web/src/experiments/machines.tsx | 11 +- apps/web/src/experiments/pairing-key.ts | 2 +- apps/web/src/experiments/pairing.tsx | 14 +- apps/web/src/machine-button.tsx | 11 +- apps/web/src/machine-choice.test.ts | 75 +- apps/web/src/machine-choice.ts | 78 +- apps/web/src/machine-summary.test.ts | 32 +- apps/web/src/machine-summary.ts | 49 +- apps/web/src/room-workspace.tsx | 6 +- docs/agent-connection.md | 8 +- docs/chopin-cli.md | 122 +- docs/hosted-agent.md | 6 +- docs/implementation-lifecycle.md | 6 +- docs/local-experiments.md | 3 +- docs/local-launcher.md | 11 +- e2e/connector.e2e.ts | 2 +- e2e/github.ts | 21 + e2e/machines.e2e.ts | 56 +- e2e/planner-picker.e2e.ts | 369 ++++- packages/protocol/implementation.d.ts | 8 + .../exceptions/dynamic-web.json | 4 +- 49 files changed, 3709 insertions(+), 373 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1beb266fb..e5cb616ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,25 @@ collaborative state and external implementation runs are durable. `LIVE_SPIKES` prototypes run only passages the consenting editor changed, on a push-refusing throwaway worktree, under hourly caps and a 20-minute deadline; see [Prototype spikes](docs/local-experiments.md#prototype-spikes-live_spikes). +- **A shared machine runs teammates' work as its owner.** An owner who shares a machine + consents to the Planner, builds and runs of a document whose editor chose it, in repositories + the owner can push to (checked with the machine's own token, as Default machine does). The + agent runs with the owner's GitHub account; the build records the editor as `user` and the + owner as `machineOwner`, and only the connection it was queued on may claim or report it. + Automatic work never leaves its builder's own machines. Where a document's Planner runs is + derived from stored choices (the creator's, else an editor's whose machine can run it now), + never from process memory, so a restart asks one machine; a choice that cannot run it yet + stops nothing and never interrupts an attached Planner. That check is held per machine and + repository (a pass 10 minutes, a refusal a minute, asked once however many listings ask + at once). The Machines page, polled every few seconds, lists a machine that serves every + repository from two held lists of what can be pushed to, not a check per repository: the + person's own (a minute) and its owner's, listed with the machine's token once per owner + for everyone (10 minutes, a failure a minute), so its cost to the owner's token does not + grow with teammates or polls. Only a repository the machine has a checkout of is asked + on its own. Unsharing clears teammates' choices of the + machine and hands a document to Default machine only where the machine ran its Planner, + had been asked to, was placing it, or someone uses the document; any other just loses + the choice. - **Persistence should precede publication.** Do not acknowledge or broadcast a domain mutation before its fenced durable commit. diff --git a/apps/connector/src/implementation.test.ts b/apps/connector/src/implementation.test.ts index f57ac2737..58004d40c 100644 --- a/apps/connector/src/implementation.test.ts +++ b/apps/connector/src/implementation.test.ts @@ -19,3 +19,15 @@ test.each([ expect(text).toContain("use your best judgment"); expect(text).toContain("pullRequestSection, unchanged"); }); + +test.each([ + ["a build", implementationPrompt, "read_implementation"], + ["a living document's first build", liveImplementationPrompt, "read_implementation"], + ["a rebuild", rebuildPrompt, "read_rebuild"], +])("%s asked for by a teammate says so in every pull request it opens", (_name, prompt, read) => { + let text = prompt(worktrees); + expect(text).toContain(`If ${read} returns requestedBy, a teammate asked for this build`); + expect(text).toContain( + "put its pullRequestNote, unchanged, at the end of the body of every pull request", + ); +}); diff --git a/apps/connector/src/implementation.ts b/apps/connector/src/implementation.ts index bde138e76..254948615 100644 --- a/apps/connector/src/implementation.ts +++ b/apps/connector/src/implementation.ts @@ -104,6 +104,18 @@ function openDecisionLines(read: "read_implementation" | "read_rebuild") { ]; } +/** + * A build asked for by a teammate runs on this machine with its owner's GitHub account, so its pull + * requests come from the owner. The server says whose request it was; the agent passes that on. + */ +function requestedByLines(read: "read_implementation" | "read_rebuild") { + return [ + `If ${read} returns requestedBy, a teammate asked for this build: put its pullRequestNote,` + + " unchanged, at the end of the body of every pull request you open or update, so the" + + " pull request says whose request it was.", + ]; +} + export function implementationPrompt(worktrees: RunWorktrees) { return [ "Read the human-approved graph and document with read_implementation from Chopin's MCP server.", @@ -116,6 +128,7 @@ export function implementationPrompt(worktrees: RunWorktrees) { "Open small reviewable draft pull requests (`gh pr create --draft`), linking the document URL", "and its #task-ID anchors. Never merge PRs.", ...openDecisionLines("read_implementation"), + ...requestedByLines("read_implementation"), "If a task needs no code change, open no pull request: call complete_task with noChange, giving", "the reason and Markdown evidence of what you checked. Its dependents build on the same base.", "If a new human decision is needed, block the affected task with its question and stop that work.", @@ -138,6 +151,7 @@ export function liveImplementationPrompt(worktrees: RunWorktrees) { "Open small reviewable draft pull requests (`gh pr create --draft`), linking the document URL", "and its #task-ID anchors. Never merge PRs.", ...openDecisionLines("read_implementation"), + ...requestedByLines("read_implementation"), "If a task needs no code change, open no pull request: call complete_task with noChange, giving", "the reason and Markdown evidence of what you checked. Its dependents build on the same base.", "If a new human decision is needed, block the affected task with its question and stop that work.", @@ -163,6 +177,7 @@ export function rebuildPrompt(worktrees: RunWorktrees) { "that has none.", "Never open any other pull request. Never merge.", ...openDecisionLines("read_rebuild"), + ...requestedByLines("read_rebuild"), "Code that fits no existing pull request goes on the most closely related one.", "If the change needs no code and nothing is outstanding, make no commits.", "Finally call report_rebuild with a summary, every commit you pushed, and one task per new change.", diff --git a/apps/server/src/auth/machines.ts b/apps/server/src/auth/machines.ts index b15fbdb8e..cf9908b36 100644 --- a/apps/server/src/auth/machines.ts +++ b/apps/server/src/auth/machines.ts @@ -50,7 +50,7 @@ export type PairMachine = { label: string; repositories: MachineRepository[]; autoRun: boolean; - /** Whether teammates' documents may run their Planner here on Default machine. */ + /** Whether teammates may choose this machine for their documents' Planners, builds and runs. */ shared?: boolean; /** The machine's own GitHub authorization; GitHub refresh tokens are single-use, never shared. */ grant?: GitHubTokenGrant; @@ -144,6 +144,11 @@ export class Machines { return this.#storage.machines.list(userId); } + /** Every live machine its owner shares with teammates. */ + shared(): Promise { + return this.#storage.machines.shared(); + } + /** Rename the owner's live machine; false for another person's or a revoked one. */ rename(id: string, userId: string, label: string): Promise { return this.#storage.machines.rename(id, userId, label); diff --git a/apps/server/src/experiments/connections.ts b/apps/server/src/experiments/connections.ts index 4108ae3c2..0b4bd22b0 100644 --- a/apps/server/src/experiments/connections.ts +++ b/apps/server/src/experiments/connections.ts @@ -28,8 +28,9 @@ export type Connection = { */ autoRun?: boolean; /** - * Its owner shares the machine with teammates: Default machine may run the Planner of a - * document whose creator has no machine that can. Stored with the machine. + * Its owner shares the machine with teammates, who may choose it for a document's work; Default + * machine may also run the Planner of a document whose creator has no machine that can. Stored + * with the machine. */ shared?: boolean; /** The chopin release that opened it, when it said; known only while it is connected. */ @@ -446,11 +447,39 @@ export class Connections { || Number(b.id === last) - Number(a.id === last) ); } - /** Whether a connection may take its owner's work on `documentId`, as candidates() decides. */ - allows(documentId: string, connection: Connection) { + /** + * The live connections of the teammate's shared machine `owner` chose for `documentId`. Whether + * its owner can push to the repository is checked where work is queued and claimed. + */ + chosenShared(repositoryId: string, owner: string, documentId: string) { + let chosen = this.chosen(documentId, owner); + return !chosen || !this.#choicesRead + ? [] + : this.list(repositoryId).filter(value => + value.machineId === chosen && value.owner !== owner && value.shared + ); + } + /** + * The connections that may take the work `owner` asks for on `documentId`: those candidates() + * offers, then the teammate's shared machine they chose. Never for automatic work, which stays + * on its own owner's machines. + */ + requested(repositoryId: string, owner: string, documentId: string) { + return [ + ...this.candidates(repositoryId, owner, documentId), + ...this.chosenShared(repositoryId, owner, documentId), + ]; + } + /** + * Whether a connection may take `person`'s work on `documentId` (its owner's unless said), as + * candidates() decides. A teammate's machine takes only the work of someone who chose it. + */ + allows(documentId: string, connection: Connection, person = connection.owner) { if (!this.#choicesRead) return false; - let chosen = this.chosen(documentId, connection.owner); - return chosen === undefined || connection.machineId === chosen; + let chosen = this.chosen(documentId, person); + return connection.owner === person + ? chosen === undefined || connection.machineId === chosen + : connection.machineId === chosen; } /** * Whether the stored choices have been read. Until they are, no connection takes anyone's diff --git a/apps/server/src/experiments/pairing-keys.ts b/apps/server/src/experiments/pairing-keys.ts index 25d79363a..c530f1b20 100644 --- a/apps/server/src/experiments/pairing-keys.ts +++ b/apps/server/src/experiments/pairing-keys.ts @@ -18,7 +18,7 @@ const KEY = /^chopin_k_[A-Za-z0-9_-]{43}$/; /** * `autoRun`: whether the machine it pairs lets Chopin build and prototype automatically. - * `shared`: whether it runs teammates' Planners on Default machine. + * `shared`: whether teammates may choose it, so it runs their Planners, builds and runs. */ type Key = { userId: string; diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index 608d551d6..98e6da9d8 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -6,7 +6,13 @@ import type { HostedAuth } from "../auth/routes"; import { AdmissionDenied } from "../auth/admission"; import { ownOrigin } from "../auth/config"; import { MachineError } from "../auth/machines"; -import type { GitHubTokenGrant } from "../github/client"; +import type { + GitHubInstallation, + GitHubTokenGrant, + InstallationPage, + Repository, + RepositoryPage, +} from "../github/client"; import type { ChannelRecord, StoredMachine } from "../storage/model"; import type { AuthenticatedSession } from "../auth/session"; import type { Router } from "../http/router"; @@ -86,6 +92,16 @@ export function offlineMessage(label = "The machine you chose") { return `${label} is offline. Start chopin connect on it, or choose another machine.`; } +/** + * What a person is told when the teammate's shared machine they chose for a document no longer + * takes their builds, runs or investigations: its owner stopped sharing it or can't push to the + * repository. + */ +export function unavailableMessage(label: string) { + return `${label} no longer takes this work: its owner stopped sharing it or can't push to this ` + + "repository. Choose another machine."; +} + /** What a person is told while their machine choices cannot be read; their work is refused. */ export const CHOICE_UNAVAILABLE = "Can't read your machine choice right now. Try again."; /** Reads of the stored machine choices one request makes before refusing, and their spacing. */ @@ -144,6 +160,28 @@ const connectionSchema = z.union([ z.object({ label, checkouts: z.array(checkoutSchema.strict()).max(100) }).strict(), ]); +/** A repository as GitHub names it, for the checks of whether someone can push to it. */ +type ReachRepository = { id: string; owner: string; name: string }; + +/** The key of a held push check: who was asked, a machine or `viewer:` and a person, and what. */ +function reachKey(who: string, repositoryId: string) { + return `${who}\n${repositoryId}`; +} + +/** The repository as GitHub names it when `access` lets its account push to it, else none. */ +function pushing(access: Repository | undefined, repository: ReachRepository) { + return access && access.id === repository.id && access.permissions.pull + && (access.permissions.push || access.permissions.admin) + ? { id: access.id, owner: access.owner, name: access.name } + : undefined; +} + +/** The repository a checkout names as `owner/name`, with its id; none when it names no owner. */ +function named(repository: string, id: string): ReachRepository[] { + let [owner, name] = repository.split("/"); + return owner && name ? [{ id, owner, name }] : []; +} + /** Whoever a GitHub repository check runs for: a browser login or a paired machine. */ type Caller = { userId: string; @@ -171,6 +209,12 @@ export const PLANNER_IDLE_MS = 30 * 60_000; */ export const SHARED_REACH_MS = 10 * 60_000; const SHARED_RETRY_MS = 60_000; +/** How many of a machine's checkouts the Machines page checks with GitHub at once. */ +const SHARED_CHECKED_AT_ONCE = 8; +/** How long the Machines page remembers the repositories a person can push to. */ +const SHARED_VIEWER_MS = 60_000; +/** The pages of installations, and of repositories in each, a list of what someone can push to reads. */ +const LISTED_PAGES = 100; /** Why a machine that is no longer shared stopped a teammate's Planner, told to its connector. */ function stoppedSharing(login: string) { return (label: string) => @@ -390,7 +434,39 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } return repository; } - /** Work only ever runs on the clicker's own live connection for the document's repository. */ + /** + * Whether a machine its owner shares may take a document's work: its owner can push to the + * repository, as Default machine requires. + */ + async function sharedReaches(machineId: string, documentId: string) { + let channel = await auth.storage.channels.get(documentId); + return !!channel && await ownerReaches(machineId, { + id: channel.repositoryId, + owner: channel.repositoryOwner, + name: channel.repositoryName, + }); + } + /** + * Whether a connection may take `person`'s work on a document now: one of their own machines, + * or the teammate's shared machine they chose while its owner still shares it and can push. + */ + async function mayTake(connection: Connection, person: string, documentId: string) { + if (connection.owner === person) return true; + return !!connection.shared && connections.chosen(documentId, person) === connection.machineId + && await sharedReaches(connection.machineId, documentId); + } + /** + * The live connections that may take the work `owner` asks for on a document: their own, and the + * teammate's shared machine they chose. Automatic work never reaches a teammate's machine. + */ + async function takers(owner: string, repositoryId: string, documentId: string) { + let found: Connection[] = []; + for (let connection of connections.requested(repositoryId, owner, documentId)) { + if (await mayTake(connection, owner, documentId)) found.push(connection); + } + return found; + } + /** Work only ever runs on a live connection of the machine the clicker chose, or their own. */ async function busy(connection: Connection) { return (await service.store.active()).some(item => item.connectionId === connection.id && ["running", "publishing"].includes(item.state) @@ -402,13 +478,14 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio */ async function choose(owner: string, repositoryId: string, documentId: string) { await readChoices(); - let live = connections.candidates(repositoryId, owner, documentId); + let live = await takers(owner, repositoryId, documentId); if (!live.length) await refuseOffline(owner, documentId); for (let connection of live) if (!await busy(connection)) return connection; return live[0]; } /** * Nothing can take the work: `machine-offline`, naming the machine, when the person chose one, + * `machine-unavailable` when it is a teammate's that is online but no longer takes their work, * else `no-workspace`; `machine-choice-unavailable` while their choice cannot be read. A chosen * machine is never silently replaced by another. */ @@ -416,7 +493,11 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio await readChoices(); let chosen = connections.chosen(documentId, owner); if (!chosen) fail("no-workspace"); - fail("machine-offline", offlineMessage((await auth.storage.machines.get(chosen))?.label)); + let machine = await auth.storage.machines.get(chosen); + if (machine && machine.userId !== owner && connections.machine(machine.id).length) { + fail("machine-unavailable", unavailableMessage(machine.label)); + } + fail("machine-offline", offlineMessage(machine?.label)); } /** * The documents whose Planner each paired machine was told to run, by machine id, each with its @@ -440,12 +521,6 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio return !!options.planners && auth.machines.durable && !!connection.about?.plannerCommand && (connection.protocol ?? 1) >= 4; } - /** Whether a machine has a connection that can run Planners in a repository now. */ - function plannerReady(machineId: string, repositoryId: string) { - return connections.machine(machineId).some(value => - serves(value, repositoryId) && runsPlanners(value) - ); - } /** * Documents someone uses, by id, with when they last were seen in use: open in Chopin, their * Planner asked for, or their Planner waiting on a question or workflow run. On Default machine @@ -474,12 +549,13 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (!failures.size) startFailures.delete(documentId); } /** - * Whether each shared machine's owner can push to a repository, by machine and repository id, - * as GitHub last said with the machine's own token; a machine shared with teammates runs only - * the Planners of documents its owner could work on themselves. + * Whether someone can push to a repository, by who was asked and the repository id, as GitHub + * last said: each shared machine's owner, with the machine's own token (a machine shared with + * teammates runs only the Planners of documents its owner could work on themselves), and a + * person the Machines page asks about with their own login, with the repository's name. */ - let reach = new Map(); - let reachKey = (machineId: string, repositoryId: string) => `${machineId}\n${repositoryId}`; + let reach = new Map(); + let reaching = new Map>(); /** * Machines whose owner stopped sharing them while teammates' Planners on them were mid-work, by * machine id: who stopped, and those documents. Each keeps its Planner there until its work @@ -499,14 +575,15 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio )?.machineId; } /** - * Whether a machine can run a document's Planner: its creator's own, one shared with them, or - * one that stopped being shared while finishing this document's work. + * Whether a machine can run a document's Planner for `user` (its creator unless said): their + * own, one shared with them, or one that stopped being shared while finishing this document's + * work. */ - function readyFor(machineId: string, channel: ChannelRecord) { + function readyFor(machineId: string, channel: ChannelRecord, user = channel.createdBy) { let finishes = !!finishing.get(machineId)?.documents.has(channel.id); return connections.machine(machineId).some(value => serves(value, channel.repositoryId) && runsPlanners(value) - && (value.owner === channel.createdBy || finishes + && (value.owner === user || finishes || !!value.shared && !!reach.get(reachKey(machineId, channel.repositoryId))?.ok) ); } @@ -533,12 +610,14 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio * document, then any of theirs), counting only machines that can run Planners. A creator with * none gets a teammate's machine shared with teammates, its owner's favorite first. Once the * creator has a machine of their own, a Planner on a teammate's machine moves to it unless it - * is attached there. + * is attached there. None while a member's stored choice places it on a machine that can run + * it now (see placesElsewhere()). */ function defaultMachine(channel: ChannelRecord): string | undefined { if ( !inUse.has(channel.id) || channel.archivedAt || channel.parentChannelId || !connections.choicesRead || connections.chosen(channel.id, channel.createdBy) + || placesElsewhere(channel) ) { defaults.delete(channel.id); return undefined; @@ -561,38 +640,159 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio else defaults.delete(channel.id); return next; } + /** + * What GitHub last said of `key`'s push check (the repository as it names it, when they can), + * asked again once SHARED_REACH_MS (a pass) or a minute (a refusal, a failed check included) is + * up; however many ask at once, it is asked once. + */ + async function reached( + key: string, + ask: () => Promise, + ): Promise { + let known = reach.get(key); + let now = auth.clock().getTime(); + if (known && known.until > now) return known.repository; + let pending = reaching.get(key); + if (pending) return pending; + let asked = ask().then(repository => { + reach.set(key, { + ok: !!repository, + until: now + (repository ? SHARED_REACH_MS : SHARED_RETRY_MS), + ...repository ? { repository } : {}, + }); + return repository; + }).finally(() => reaching.delete(key)); + reaching.set(key, asked); + return asked; + } + /** + * Whether a shared machine's owner can push to a repository, as GitHub last said with the + * machine's own token. + */ + async function ownerReaches( + machineId: string, + repository: ReachRepository, + ): Promise { + return !!await reached(reachKey(machineId, repository.id), async () => { + try { + let machine = await auth.machines.current(machineId); + return pushing( + await (await machineCaller(machine)).use(token => + auth.github.repositoryAccess(token, repository.owner, repository.name) + ), + repository, + ); + } catch (error) { + if (!(error instanceof ExperimentError || error instanceof MachineError)) { + console.error("chopin: could not check a shared machine's repository access -", error); + } + return undefined; + } + }); + } /** * Ask GitHub, with each shared machine's own token, whether its owner can push to a document's - * repository, for a document whose creator has no machine of their own that can run its Planner. - * Whether any answer changed. + * repository, for a document whose creator has no machine of their own that can run its + * Planner, and for a machine someone chose for it. Whether any answer changed. */ async function reachShared(channel: ChannelRecord): Promise { if (!options.planners || !auth.machines.durable || channel.parentChannelId) return false; - if (ownPlannerMachine(channel)) return false; + let chosen = new Set(connections.chosenFor(channel.id)); + let own = !!ownPlannerMachine(channel); let changed = false; let asked = new Set(); for (let connection of connections.shared(channel.repositoryId, channel.createdBy)) { if (!runsPlanners(connection) || asked.has(connection.machineId)) continue; + if (own && !chosen.has(connection.machineId)) continue; asked.add(connection.machineId); - let key = reachKey(connection.machineId, channel.repositoryId); - let known = reach.get(key); - let now = auth.clock().getTime(); - if (known && known.until > now) continue; - let ok = false; - try { - let machine = await auth.machines.current(connection.machineId); - await documentAccess(await machineCaller(machine), channel.id, true); - ok = true; - } catch (error) { - if (!(error instanceof ExperimentError || error instanceof MachineError)) { - console.error("chopin: could not check a shared machine's repository access -", error); - } - } - reach.set(key, { ok, until: now + (ok ? SHARED_REACH_MS : SHARED_RETRY_MS) }); + let known = reach.get(reachKey(connection.machineId, channel.repositoryId)); + let ok = await ownerReaches(connection.machineId, { + id: channel.repositoryId, + owner: channel.repositoryOwner, + name: channel.repositoryName, + }); if (ok !== known?.ok) changed = true; } return changed; } + /** + * The machines their owners share with teammates that are not `viewer`'s: those a person may + * see and choose, for their builds and runs wherever Chopin has machines, and for their Planner + * where it runs Planners on them. Checking a repository takes the machine's own token, which a + * machine borrows from its owner's live login under AUTH_MODE=local, so one that needs its + * GitHub authorization renewed is none of them. + */ + async function sharedByOthers(viewer: string) { + return (await auth.machines.shared()).filter(machine => + machine.userId !== viewer && !machine.reauthorizeAt + ); + } + /** + * Which of `machines` serve work, and where: one that is connected serves the repositories its + * connections say (every repository, from connector protocol 5 on, whatever checkouts it has or + * none), one that is not may serve any, and its owner must be able to push to the repository, + * as Default machine requires. A repository `candidates` names for a machine is checked with + * GitHub, SHARED_CHECKED_AT_ONCE at a time: `viewer`, when given, must be able to push there + * too and gives the repository's name. A machine that serves every repository also serves those + * `together` names, which both can push to already. + */ + async function sharedServing( + machines: StoredMachine[], + candidates: (machine: StoredMachine) => Promise, + viewer?: (repository: ReachRepository) => Promise, + together?: (machine: StoredMachine) => Promise, + ) { + let found: Array<{ machine: StoredMachine; repositories: string[] }> = []; + for (let machine of machines) { + let live = connections.machine(machine.id); + let names: string[] = []; + let looked = await candidates(machine); + for (let first = 0; first < looked.length; first += SHARED_CHECKED_AT_ONCE) { + let checked = await Promise.all( + looked.slice(first, first + SHARED_CHECKED_AT_ONCE).map(async candidate => { + let repository = viewer ? await viewer(candidate) : candidate; + if (!repository || live.length && !live.some(value => serves(value, repository.id))) { + return undefined; + } + return await ownerReaches(machine.id, repository) + ? `${repository.owner}/${repository.name}` + : undefined; + }), + ); + names.push(...checked.filter(name => name !== undefined)); + } + if (together && (!live.length || live.some(value => value.anyRepository))) { + names = [ + ...new Set([ + ...names, + ...(await together(machine)).map(value => `${value.owner}/${value.name}`), + ]), + ]; + } + if (names.length) found.push({ machine, repositories: names }); + } + return found; + } + /** + * What a teammate may know of a machine shared with them: its name, its owner, its release and + * whether it is a server. Not its host, system or agent, nor anything its owner alone sets. + */ + async function teammateFacts(machine: StoredMachine) { + let live = connections.machine(machine.id); + let owner = await auth.storage.users.get(machine.userId); + return { + id: machine.id, + label: machine.label, + teammate: true as const, + owner: { login: owner?.login ?? machine.userId, avatarUrl: owner?.avatarUrl ?? "" }, + lastSeenAt: machine.lastSeenAt.getTime(), + version: live.find(value => value.version)?.version ?? null, + // An offline machine keeps its server or computer icon. + service: live.length + ? live.some(value => value.about?.service) + : !!connections.about(machine.id)?.service, + }; + } /** * Whether a member's own choice places a document's Planner. A document has one Planner, so its * creator's choice decides where it runs: another member's choice counts only while the creator @@ -601,10 +801,32 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio function placesPlanner(channel: ChannelRecord, userId: string) { return userId === channel.createdBy || !connections.chosen(channel.id, channel.createdBy); } - /** The machines whose choice places a document's Planner: its creator's, else any member's. */ - function plannerChoices(channel: ChannelRecord) { + /** The machines some member chose for a document that may run its Planner for them now. */ + function ableChoices(channel: ChannelRecord) { + return [...new Set(connections.chosenFor(channel.id))].filter(machineId => + connections.choosing(machineId).some(value => + value.documentId === channel.id && readyFor(machineId, channel, value.owner) + ) + ); + } + /** Those of them that place a document's Planner: the creator's choice, else any member's. */ + function placedChoices(channel: ChannelRecord) { let creators = connections.chosen(channel.id, channel.createdBy); - return creators ? [creators] : connections.chosenFor(channel.id); + return ableChoices(channel).filter(machineId => !creators || machineId === creators); + } + /** + * Whether a member's stored choice places a document's Planner, while its creator chose no + * machine, on a machine that can run it now: Default machine is then asked for none, so the + * choice, which survives a restart, decides it however the machines connect afterwards. The + * exception is a Planner already attached (or kept with its work) on another machine: it + * stays there and is never interrupted by a machine that merely became able; the chosen one is + * asked once it ends. + */ + function placesElsewhere(channel: ChannelRecord) { + let placed = placedChoices(channel); + if (!placed.length) return false; + let holder = options.attachments?.()?.holding(channel.id); + return !(holder?.machineId && !placed.includes(holder.machineId)); } /** The machine that runs a document's Planner for `userId`: the one they chose, else Default's. */ function plannerMachine(channel: ChannelRecord, userId: string) { @@ -629,9 +851,10 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio repositoryId: channel.repositoryId, }); for (let { documentId, owner } of connections.choosing(connection.machineId)) { - if (owner !== connection.owner) continue; let channel = await auth.storage.channels.get(documentId); if (!channel || channel.archivedAt || !serves(connection, channel.repositoryId)) continue; + // A teammate's choice of this machine counts only while its owner shares it and can push. + if (owner !== connection.owner && !readyFor(connection.machineId, channel, owner)) continue; // A member's choice yields to the creator's, which keeps its own machine asked instead. if (!placesPlanner(channel, owner)) continue; let holder = options.attachments?.()?.holding(documentId); @@ -707,7 +930,8 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (!channel || channel.archivedAt) return undefined; await readChoices().catch(() => {}); let fallback = defaultMachine(channel); - for (let machineId of [...plannerChoices(channel), ...fallback ? [fallback] : []]) { + // A machine chosen for it starts it for whoever chose it, if that machine may run it for them. + for (let machineId of [...placedChoices(channel), ...fallback ? [fallback] : []]) { let live = connections.machine(machineId).find(value => serves(value, channel.repositoryId) && runsPlanners(value) ); @@ -785,6 +1009,49 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } return found; } + /** + * The machines, other than `keep`, that run a document's Planner, were asked to, or were + * `placing` it by a member's choice before `by` chose where it runs: the choices other members + * made of them go, so none takes it back, even one whose connector had not been asked yet. + * Those machines, to be stopped. + */ + async function displaced( + channel: ChannelRecord, + by: string, + keep: Array, + placing: string[], + ) { + let holder = options.attachments?.()?.holding(channel.id); + let found = new Set([ + ...dispatched.entries().filter(([, told]) => told.has(channel.id)).map(([id]) => id), + ...holder?.machineId ? [holder.machineId] : [], + ...placing, + ]); + for (let machineId of keep) if (machineId) found.delete(machineId); + for (let machineId of found) { + for (let chooser of connections.choosing(machineId)) { + if (chooser.documentId !== channel.id || chooser.owner === by) continue; + await auth.storage.machines.choose( + { channelId: channel.id, userId: chooser.owner }, + auth.clock(), + ); + connections.choose(channel.id, chooser.owner, undefined); + options.chosen?.(channel.id, chooser.owner); + } + } + return found; + } + /** Stop a document's Planner on `machines`, saying who chose otherwise: `chose` names them. */ + async function stopDisplaced(channel: ChannelRecord, machines: Iterable, chose: string) { + for (let machineId of machines) { + await stopPlanners( + machineId, + label => `${chose} for this document, so ${label} stopped running its Planner.`, + documentId => documentId !== channel.id, + channel.id, + ); + } + } /** * Stop the Planner backends `machineId` was told to run, except where `keep` says otherwise: * its credentials are withdrawn, then its attached link is refused with `message` and closed. @@ -925,7 +1192,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }, code === "authentication-required" || code.startsWith("machine-") && code !== "machine-offline" - && code !== "machine-choice-unavailable" + && code !== "machine-unavailable" && code !== "machine-choice-unavailable" ? 401 : code.includes("forbidden") || code === "pairing-key-invalid" ? 403 @@ -1297,38 +1564,181 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio await disconnectMachine(machine.id); return json({ revoked: true }); }); + /** + * Add the repositories a token can push to among its GitHub App installations to `into`, as + * the repository picker lists them, every one of them. + */ + async function listPushable(token: string, into: ReachRepository[]) { + let installations: GitHubInstallation[] = []; + for (let page: number | undefined = 1, pages = 0; page && pages < LISTED_PAGES; pages++) { + let listing: InstallationPage = await auth.github.installations(token, page); + installations.push(...listing.installations); + page = listing.nextPage; + } + for (let installation of installations) { + if (installation.suspended || !installation.permissions.contents) continue; + for (let page: number | undefined = 1, pages = 0; page && pages < LISTED_PAGES; pages++) { + let listing: RepositoryPage = await auth.github.installationRepositories( + token, + installation.id, + page, + ); + for (let value of listing.repositories) { + if (value.permissions.pull && (value.permissions.push || value.permissions.admin)) { + into.push({ id: value.id, owner: value.owner, name: value.name }); + } + } + page = listing.nextPage; + } + } + } + /** + * What someone can push to among their installations, whoever's list it is: a person's own held + * a minute, a machine owner's, listed with the token of the machine that asked first, as long + * as a pass of a check is, and either a minute after a failure; asked once however many ask at + * once. Evicted as it runs out, as a listing begins. + */ + let pushable = new Map }>(); + function pushableRepositories(who: string, held: number, caller: () => Caller | Promise) { + let now = auth.clock().getTime(); + for (let [key, entry] of pushable) if (entry.until <= now) pushable.delete(key); + let known = pushable.get(who); + if (known) return known.found; + let entry = { until: Infinity, found: Promise.resolve([]) }; + entry.found = (async () => { + let repositories: ReachRepository[] = []; + let listed = false; + try { + await (await caller()).use(token => listPushable(token, repositories)); + listed = true; + } catch (error) { + if (!(error instanceof ExperimentError || error instanceof MachineError)) { + console.error("chopin: could not list the repositories someone can push to -", error); + } + } + // What GitHub said of a list it could not finish stands, and it is asked again soon. + entry.until = auth.clock().getTime() + (listed ? held : SHARED_RETRY_MS); + return repositories; + })(); + pushable.set(who, entry); + return entry.found; + } + function viewerRepositories(session: AuthenticatedSession) { + return pushableRepositories( + `viewer:${session.user.id}`, + SHARED_VIEWER_MS, + () => browser(session), + ); + } + function ownerRepositories(machine: StoredMachine) { + return pushableRepositories( + `owner:${machine.userId}`, + SHARED_REACH_MS, + () => machineCaller(machine), + ); + } + /** + * Whether a person can push to a repository, by its id with GitHub's name for it: from those + * they can push to among their installations, else asked with their own login, which `reach` + * holds like a machine owner's answer, whichever listing asks. A repository GitHub can't vouch + * for is left out. Answers that have run out are forgotten as a person's check begins. + */ + function viewerPushes(session: AuthenticatedSession) { + let who = `viewer:${session.user.id}`; + let now = auth.clock().getTime(); + for (let [key, value] of reach) { + if (key.startsWith("viewer:") && value.until <= now) reach.delete(key); + } + return async (repository: ReachRepository) => { + let listed = (await viewerRepositories(session)).find(value => value.id === repository.id); + return listed ?? await reached(reachKey(who, repository.id), async () => { + try { + return pushing( + await browser(session).use(token => + auth.github.repositoryAccess(token, repository.owner, repository.name) + ), + repository, + ); + } catch { + return undefined; + } + }); + }; + } + /** + * The repositories a machine says it has checkouts of: those it was paired with and those it + * connected, which the Machines page asks about one at a time. + */ + async function checkedOutOf(machine: StoredMachine): Promise { + let candidates = [ + ...machine.repositories.flatMap(value => named(value.repository, value.repositoryId)), + ...connections.machine(machine.id).flatMap(value => + value.checkouts.flatMap(checkout => named(checkout.repository, checkout.repositoryId)) + ), + ]; + return [...new Map(candidates.map(value => [value.id, value])).values()]; + } + /** + * The repositories both a person and a machine's owner can push to, from the two lists of what + * each can push to: no check of a repository, however many either can push to. A person who + * can push to none leaves the owner's list unasked. + */ + async function pushableTogether(session: AuthenticatedSession, machine: StoredMachine) { + let mine = await viewerRepositories(session); + if (!mine.length) return []; + let theirs = new Set((await ownerRepositories(machine)).map(value => value.id)); + return mine.filter(value => theirs.has(value.id)); + } route("GET", "/api/connector/machines", async request => { let session = await auth.sessions.authenticate(request); if (!session) fail("authentication-required"); let machines = await auth.machines.list(session.user.id); + // Teammates' machines shared with them, in the repositories both can push to, read-only. + let others = await sharedByOthers(session.user.id); + let teammates = others.length + ? await sharedServing( + others, + checkedOutOf, + viewerPushes(session), + machine => pushableTogether(session, machine), + ) + : []; // The star shows only once the favorite is known; the list itself never waits on it. await readChoices().catch(() => {}); let favorite = connections.choicesRead ? connections.favorite(session.user.id) : undefined; return json({ - machines: machines.map(machine => ({ - id: machine.id, - label: machine.label, - favorite: machine.id === favorite, - // The checkouts it said it has; it serves every repository its owner can push to. - repositories: [ - ...new Set([ - ...machine.repositories.map(value => value.repository), - ...connections.machine(machine.id).flatMap(value => - value.checkouts.map(checkout => checkout.repository) - ), - ]), - ], - autoRun: machine.autoRun, - shared: !!machine.shared, - // Teammates' documents it still runs the Planner of until their work finishes. - finishing: finishing.get(machine.id)?.documents.size ?? 0, - createdAt: machine.createdAt.getTime(), - lastSeenAt: machine.lastSeenAt.getTime(), - connected: connections.machine(machine.id).length > 0, - version: connections.machine(machine.id).find(value => value.version)?.version ?? null, - ...described(connections.machine(machine.id)), - reauthorize: !!machine.reauthorizeAt, - })), + machines: [ + ...machines.map(machine => ({ + id: machine.id, + label: machine.label, + teammate: false, + favorite: machine.id === favorite, + // The checkouts it said it has; it serves every repository its owner can push to. + repositories: [ + ...new Set([ + ...machine.repositories.map(value => value.repository), + ...connections.machine(machine.id).flatMap(value => + value.checkouts.map(checkout => checkout.repository) + ), + ]), + ], + autoRun: machine.autoRun, + shared: !!machine.shared, + // Teammates' documents it still runs the Planner of until their work finishes. + finishing: finishing.get(machine.id)?.documents.size ?? 0, + createdAt: machine.createdAt.getTime(), + lastSeenAt: machine.lastSeenAt.getTime(), + connected: connections.machine(machine.id).length > 0, + version: connections.machine(machine.id).find(value => value.version)?.version ?? null, + ...described(connections.machine(machine.id)), + reauthorize: !!machine.reauthorizeAt, + })), + ...await Promise.all(teammates.map(async ({ machine, repositories }) => ({ + ...await teammateFacts(machine), + repositories, + connected: connections.machine(machine.id).length > 0, + }))), + ], }); }); /** The owner renames a machine: the name shown in Chopin and by `chopin status`. */ @@ -1374,10 +1784,11 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio return json({ favorite: connections.favorite(userId) ?? null }); }); /** - * The owner shares a machine with teammates, or stops. Shared, it may run the Planner of a - * document on Default machine whose creator has no machine of their own that can, in a - * repository its owner can push to. Stopping takes no new teammate documents and ends the - * Planners it runs for teammates: at once where idle, else once their current work finishes. + * The owner shares a machine with teammates, or stops. Shared, teammates can see and choose it, + * and it may run the Planner of a document on Default machine whose creator has no machine of + * their own that can, in a repository its owner can push to. Stopping takes no new teammate + * documents, clears the teammates' choices of it, and ends the Planners it runs for them: at + * once where idle, else once their current work finishes. */ route("POST", "/api/connector/machines/:id/shared", async (request, params) => { let session = await auth.sessions.authenticate(request); @@ -1389,6 +1800,25 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio finishing.delete(params.id); let teammates: ChannelRecord[] = []; if (!shared) { + // A teammate's choice of the machine ends with the sharing, so it never holds back their + // own work; the Planner it placed there finishes and stops as Default machine's does. + // Only a document the machine ran, was asked to run or was placing the Planner of, or + // that someone uses, is then given to Default machine; any other just loses the choice. + for (let { documentId, owner } of connections.choosing(params.id)) { + if (owner === userId) continue; + let channel = await auth.storage.channels.get(documentId); + let ran = !!channel && ( + options.attachments?.()?.holding(documentId)?.machineId === params.id + || !!dispatched.get(params.id)?.has(documentId) + || placedChoices(channel).includes(params.id) + ); + await auth.storage.machines.choose({ channelId: documentId, userId: owner }, auth.clock()); + connections.choose(documentId, owner, undefined); + options.chosen?.(documentId, owner); + if (!ran && !inUse.has(documentId)) continue; + defaults.set(documentId, params.id); + inUse.set(documentId, auth.clock().getTime()); + } for (let [documentId, machineId] of defaults) { if (machineId !== params.id) continue; let channel = await auth.storage.channels.get(documentId); @@ -1422,12 +1852,13 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio return json({ revoked: true }); }); /** - * The caller's own machines, whatever repositories they have checkouts of, the one they chose - * for a document (null for automatic), and the machine automatic would use now. A machine is - * connected for the document when one of its live connections serves its repository. Only ever - * the caller's own. + * The caller's own machines, whatever repositories they have checkouts of, and the machines + * teammates share that serve the document's repository while their owners can push to it (for + * a caller who can push to it too); the one they chose for a document (null for automatic), and + * the machine automatic would use now. A machine is connected for the document when one of its + * live connections serves its repository. */ - async function machineChoice(userId: string, channel: ChannelRecord) { + async function machineChoice(userId: string, channel: ChannelRecord, canPush: boolean) { let { id: documentId, repositoryId } = channel; await readChoices(); let serving = await auth.machines.list(userId); @@ -1443,6 +1874,14 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } automatic ??= live[0]; let label = (id: string) => serving.find(machine => machine.id === id)?.label ?? null; + let others = canPush ? await sharedByOthers(userId) : []; + let teammates = others.length + ? await sharedServing(others, async () => [{ + id: repositoryId, + owner: channel.repositoryOwner, + name: channel.repositoryName, + }]) + : []; let favorite = connections.favorite(userId); let capable = (machineId: string) => { let live = connections.machine(machineId); @@ -1463,24 +1902,32 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio favorite: automatic.machineId === favorite, } : null, - machines: serving.map(machine => { - let live = connections.machine(machine.id); - return { - id: machine.id, - label: machine.label, - connected: live.some(value => serves(value, repositoryId)), - reauthorize: !!machine.reauthorizeAt, - lastSeenAt: machine.lastSeenAt.getTime(), - version: live.find(value => value.version)?.version ?? null, - ...described(live), - // An offline machine keeps its server or computer icon. - service: live.length - ? live.some(value => value.about?.service) - : !!connections.about(machine.id)?.service, - // Whether it can run the document's Planner when chosen: it has a Planner command. + machines: [ + ...serving.map(machine => { + let live = connections.machine(machine.id); + return { + id: machine.id, + label: machine.label, + teammate: false, + connected: live.some(value => serves(value, repositoryId)), + reauthorize: !!machine.reauthorizeAt, + lastSeenAt: machine.lastSeenAt.getTime(), + version: live.find(value => value.version)?.version ?? null, + ...described(live), + // An offline machine keeps its server or computer icon. + service: live.length + ? live.some(value => value.about?.service) + : !!connections.about(machine.id)?.service, + // Whether it can run the document's Planner when chosen: it has a Planner command. + runsPlanners: capable(machine.id), + }; + }), + ...await Promise.all(teammates.map(async ({ machine }) => ({ + ...await teammateFacts(machine), + connected: connections.machine(machine.id).some(value => serves(value, repositoryId)), runsPlanners: capable(machine.id), - }; - }), + }))), + ], }; } /** @@ -1504,8 +1951,10 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let machine = serving.some(value => value.id === holder.machineId) ? holder.machineId! : null; return { state: "disconnected" as const, login: holder.login, machine }; } - let machine = plannerMachine(channel, userId); - if (machine && plannerReady(machine, channel.repositoryId)) { + // Its creator is told where a member's choice that can run it places it, as Default would. + let machine = plannerMachine(channel, userId) + ?? (userId === channel.createdBy ? placedChoices(channel)[0] : undefined); + if (machine && readyFor(machine, channel, userId)) { // A teammate's shared machine says whose it is; the caller's own is named by its id. let teammate = connections.machine(machine).find(value => value.owner !== userId); return { state: "starting" as const, login: teammate?.login ?? null, machine }; @@ -1513,12 +1962,18 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio return { state: "none" as const, login: null, machine: null }; } route("GET", "/api/documents/:id/machine", async (request, params) => { - let { session, channel } = await access( + let { session, channel, repository } = await access( await auth.sessions.authenticate(request), params.id, false, ); - return json(await machineChoice(session.user.id, channel)); + return json( + await machineChoice( + session.user.id, + channel, + repository.permissions.push || repository.permissions.admin, + ), + ); }); /** Choose one of the caller's machines for a document's work, or null for automatic. */ route("POST", "/api/documents/:id/machine", async (request, params) => { @@ -1534,7 +1989,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio // Choosing replaces a stored choice, so the stored ones must be known first. await readChoices(); if (machine !== null) { - let current = await machineChoice(userId, channel); + let current = await machineChoice(userId, channel, true); if (!current.machines.some(value => value.id === machine)) { let owned = (await auth.machines.list(userId)).some(value => value.id === machine); fail(owned ? "repository-unserved" : "not-found"); @@ -1544,52 +1999,41 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio { channelId: channel.id, userId, ...(machine ? { machineId: machine } : {}) }, auth.clock(), ); - // Where the person's Planner of the document runs now, chosen or Default machine's. + // Where the person's Planner of the document runs now, chosen or Default machine's; and the + // machines members' choices place it on, while its creator chose none. let previous = plannerMachine(channel, userId); + let placing = connections.chosen(channel.id, channel.createdBy) ? [] : ableChoices(channel); connections.choose(channel.id, userId, machine ?? undefined); // Default machine picks again for its creator, as for any change of choice. if (userId === channel.createdBy) defaults.delete(channel.id); let next = plannerMachine(channel, userId); - // The machines that stop: the one that ran it, unless it is the one that runs it now. The - // creator's choice also ends a Planner another member's machine runs or was asked to run, - // and that member's choice with it, so it does not take the document back. + // The machines that stop: the one that ran it, unless it is the one that runs it now. let stops = new Set(previous && previous !== next ? [previous] : []); - if (userId === channel.createdBy) { - let holder = options.attachments?.()?.holding(channel.id); - let theirs = [ - ...dispatched.entries().filter(([, told]) => told.has(channel.id)).map(([id]) => id), - ...holder?.machineId ? [holder.machineId] : [], - ]; - for (let machineId of theirs) { - if (machineId === next || machineId === previous) continue; + // A choice that places the Planner also ends it on every other machine that runs it, was + // asked to, or was placing it, and the other members' choices of those machines with it, so + // none takes it back. The creator's always places it. A member's does only where its machine + // can run it for them now; one that cannot (a machine without a Planner command, an offline + // one, one whose owner can't push) is stored and stops nothing else: the Planner keeps + // running where it is, a reply under way included, and the machine takes it once it can and + // the Planner there has ended. + let places = userId === channel.createdBy + || machine !== null && placesPlanner(channel, userId) + && placedChoices(channel).includes(machine); + if (places) { + for (let machineId of await displaced(channel, userId, [next, previous], placing)) { stops.add(machineId); - for (let chooser of connections.choosing(machineId)) { - if (chooser.documentId !== channel.id || chooser.owner === userId) continue; - await auth.storage.machines.choose( - { channelId: channel.id, userId: chooser.owner }, - auth.clock(), - ); - connections.choose(channel.id, chooser.owner, undefined); - options.chosen?.(channel.id, chooser.owner); - } } } // The new machine starts. - for (let machineId of stops) { - await stopPlanners( - machineId, - label => - `${session.user.login} chose ${ - machine ? "another machine" : "Default machine" - } for this document, so ${label} stopped running its Planner.`, - documentId => documentId !== channel.id, - channel.id, - ); - } + await stopDisplaced( + channel, + stops, + `${session.user.login} chose ${machine ? "another machine" : "Default machine"}`, + ); connections.wake(channel.repositoryId); options.chosen?.(channel.id, userId); options.plannerStatus?.(channel.id); - return json(await machineChoice(userId, channel)); + return json(await machineChoice(userId, channel, true)); }); route("GET", "/api/documents/:id/experiments", async (request, params) => { await access(await auth.sessions.authenticate(request), params.id, false); @@ -2298,6 +2742,8 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio json, sweep, refuseOffline, + takers, + mayTake, machineChoices: readChoices, plannerChanged, plannerUsed, diff --git a/apps/server/src/harness/remote/planner-dispatch.test.ts b/apps/server/src/harness/remote/planner-dispatch.test.ts index 3ab564eb3..c79ebcdda 100644 --- a/apps/server/src/harness/remote/planner-dispatch.test.ts +++ b/apps/server/src/harness/remote/planner-dispatch.test.ts @@ -7,7 +7,12 @@ import { } from "@chopin/protocol/connector"; import { Machines } from "../../auth/machines"; -import { PLANNER_IDLE_MS, registerExperimentRoutes } from "../../experiments/routes"; +import { + offlineMessage, + PLANNER_IDLE_MS, + registerExperimentRoutes, + unavailableMessage, +} from "../../experiments/routes"; import { Router } from "../../http/router"; import { linkAuth, @@ -21,6 +26,7 @@ import { hostInputRoom } from "../../testing/decisions"; import { startingMessage } from "./attachments"; import { MachinePlanners } from "./machine-planners"; +import type { RepositoryPage } from "../../github/client"; import type { RemotePlanner } from "./registry"; let cleanups: Array<() => unknown> = []; @@ -32,52 +38,72 @@ afterEach(async () => { * A HARNESS=remote server whose accounts pair machines that connect checkouts of octo-org/score, * choose them for the linked document, and run its Planner on them over the agent connection. */ -async function dispatchServer() { +async function dispatchServer( + /** `planners: false` is a server that runs no Planner on machines, as with AGENT=off. */ + { planners: runsPlanners = true }: { planners?: boolean } = {}, +) { let linked = await linkAuth(); let { auth, storage, sessions, cookies, channel } = linked; - let machines = new Machines(storage, () => new Date(), { - key: new Uint8Array(32).fill(7), - origin: auth.config.origin, - clientId: "client", - authorize: async (userId, token) => (await auth.admission.user(token)).id === userId, - }); - let planners = new MachinePlanners({ - machines, - sessions, - storage, - github: auth.github, - config: auth.config, - }); - let server = linkServer(auth, 2_000, undefined, 300, undefined, planners); - cleanups.push(server.stop); - let router = new Router(); let lease = (await storage.leases.acquire("writer", "test", 60_000))!; /** Documents open in Chopin now, as `main.ts` says from its rooms; and how far the clock moved. */ let open = new Set(); let skew = { ms: 0 }; let clock = () => new Date(Date.now() + skew.ms); - let runtime = registerExperimentRoutes( - router, - { ...auth, clock, machines, pairings: {} } as never, - { - lease: () => lease, - changed: () => {}, - context: async () => ({ source: "# Context", revision: 0 }), - planners, - attachments: () => server.attachments, - documentOpen: id => open.has(id), - // No builds wait anywhere; only the Planner poll is exercised. - implementations: () => - ({ - waiting: async () => undefined, - busy: async () => false, - disconnected: async () => {}, - sweep: async () => {}, - call: async () => ({}), - }) as never, - }, - ); - server.attachments.onChange(channelId => runtime.plannerChanged(channelId)); + let machines!: Machines; + let planners!: MachinePlanners; + let server!: ReturnType; + let router!: Router; + let runtime!: ReturnType; + /** + * One server process over the same storage: its machines' credentials, agent connection and + * routes, and everything they keep in memory. `restart()` starts another, as a deploy does. + */ + function start() { + machines = new Machines(storage, () => new Date(), { + key: new Uint8Array(32).fill(7), + origin: auth.config.origin, + clientId: "client", + authorize: async (userId, token) => (await auth.admission.user(token)).id === userId, + }); + planners = new MachinePlanners({ + machines, + sessions, + storage, + github: auth.github, + config: auth.config, + }); + server = linkServer(auth, 2_000, undefined, 300, undefined, planners); + cleanups.push(server.stop); + router = new Router(); + runtime = registerExperimentRoutes( + router, + { ...auth, clock, machines, pairings: {} } as never, + { + lease: () => lease, + changed: () => {}, + context: async () => ({ source: "# Context", revision: 0 }), + ...runsPlanners ? { planners } : {}, + attachments: () => server.attachments, + documentOpen: id => open.has(id), + // No builds wait anywhere; only the Planner poll is exercised. + implementations: () => + ({ + waiting: async () => undefined, + busy: async () => false, + disconnected: async () => {}, + sweep: async () => {}, + call: async () => ({}), + }) as never, + }, + ); + server.attachments.onChange(channelId => runtime.plannerChanged(channelId)); + } + start(); + /** The server process ends and another starts: every machine connects again, as its connector does. */ + async function restart() { + await server.stop(); + start(); + } async function send( path: string, @@ -108,7 +134,15 @@ async function dispatchServer() { login: string, userId: string, label: string, - connector: { protocol?: number; plannerCommand?: boolean; shared?: boolean } = {}, + connector: { + protocol?: number; + plannerCommand?: boolean; + shared?: boolean; + /** Connect with no checkout, as a protocol 5 connector does where it clones what it needs. */ + bare?: boolean; + /** The chopin release it says it runs. */ + version?: string; + } = {}, ) { let { machine, token } = await machines.pair({ userId, @@ -123,14 +157,16 @@ async function dispatchServer() { refreshExpiresIn: 15_897_600, }, }); - let protocol = connector.protocol ?? 4; + let protocol = connector.protocol ?? (connector.bare ? 5 : 4); /** `chopin connect` on the machine: a connection of its own, as after a restart. */ async function connect() { let opened = await send("/api/connector/connections", { bearer: token, - body: { repository: "octo-org/score", commit: "a".repeat(40), label: "score" }, + body: connector.bare + ? { label: "score", checkouts: [] } + : { repository: "octo-org/score", commit: "a".repeat(40), label: "score" }, headers: { - [CONNECTOR_VERSION_HEADER]: "dev", + [CONNECTOR_VERSION_HEADER]: connector.version ?? "dev", [CONNECTOR_PROTOCOL_HEADER]: String(protocol), ...(connector.plannerCommand ?? true ? { [CONNECTOR_PLANNER_COMMAND_HEADER]: "1" } : {}), }, @@ -248,6 +284,11 @@ async function dispatchServer() { }); return response.status; }; + /** `GET /api/connector/machines` as a signed-in person: their own and their teammates'. */ + let listing = async (login: string) => + (await (await send("/api/connector/machines", { login })).json()).machines as Array< + Record + >; /** Someone opens the document in Chopin; closing it starts its idle time. */ let visit = async (id = channel.id) => { open.add(id); @@ -256,10 +297,20 @@ async function dispatchServer() { let url = expect.stringContaining(`https://chopin.test/`); return { ...linked, - machines, - planners, - server, - runtime, + // The current process's: another after `restart()`. + get machines() { + return machines; + }, + get planners() { + return planners; + }, + get server() { + return server; + }, + get runtime() { + return runtime; + }, + restart, open, skew, send, @@ -268,6 +319,7 @@ async function dispatchServer() { choice, favor, share, + listing, visit, url, }; @@ -279,6 +331,105 @@ async function upgrade(url: string, credential: string) { return response.status; } +/** + * Count the GitHub calls each token makes: repository checks (`asked`), and the installation and + * repository listings that make up a list of what someone can push to (`listed`); and how many + * repository checks run at once. + */ +function counting(github: { + repositoryAccess: (token: string, owner: string, name: string) => Promise; + installations: (token: string, page: number) => Promise; + installationRepositories: (token: string, installation: string, page: number) => Promise; +}) { + let asked = new Map(); + let listed = new Map(); + let running = { now: 0, most: 0 }; + let access = github.repositoryAccess; + github.repositoryAccess = async (token, owner, name) => { + asked.set(token, (asked.get(token) ?? 0) + 1); + running.now++; + running.most = Math.max(running.most, running.now); + try { + await Bun.sleep(5); + return await access(token, owner, name); + } finally { + running.now--; + } + }; + let installations = github.installations; + github.installations = async (token, page) => { + listed.set(token, (listed.get(token) ?? 0) + 1); + await Bun.sleep(5); + return installations(token, page); + }; + let repositories = github.installationRepositories; + github.installationRepositories = async (token, installation, page) => { + listed.set(token, (listed.get(token) ?? 0) + 1); + await Bun.sleep(5); + return repositories(token, installation, page); + }; + return { + asked: (token: string) => asked.get(token) ?? 0, + listed: (token: string) => listed.get(token) ?? 0, + running, + }; +} + +/** + * What GitHub lists of the App's one installation: for each token the repositories of octo-org + * `names` gives, all of them pushable, a hundred to a page; for a token it names none, what the + * account can really do. + */ +function installed( + github: { + installationRepositories: ( + token: string, + installation?: string, + page?: number, + ) => Promise; + }, + names: (token: string) => string[] | undefined, +) { + let original = github.installationRepositories; + github.installationRepositories = async (token, installation, page = 1) => { + let all = names(token); + if (!all) return original(token, installation, page); + let first = (page - 1) * 100; + return { + repositories: all.slice(first, first + 100).map(name => ({ + id: `R_${name}`, + owner: "octo-org", + name, + fullName: `octo-org/${name}`, + private: true, + url: `https://github.test/octo-org/${name}`, + defaultBranch: "main", + permissions: { pull: true, push: true, admin: false }, + })), + nextPage: first + 100 < all.length ? page + 1 : undefined, + }; + }; +} + +/** GitHub failing to list installations for the tokens that start with `prefix`, while `down.now`. */ +function unavailable( + github: { installations: (token: string, page: number) => Promise }, + prefix: string, +) { + let down = { now: true }; + let listed = github.installations; + github.installations = async (token, page) => { + if (down.now && token.startsWith(prefix)) throw new Error("GitHub is unavailable"); + return listed(token, page); + }; + return down; +} + +/** `count` repositories, `repo-0` on, then the ones named in `last`. */ +function others(count: number, ...last: string[]) { + return [...Array.from({ length: count }, (_, index) => `repo-${index}`), ...last]; +} + /** What a connector says when its backend exited before attaching `attempts` times in a row. */ function failing(document: string, attempts: number, reason = "unsafe-checkout: refused") { return { document, failure: { attempts, how: "exit 1", reason, at: Date.now() } }; @@ -620,7 +771,7 @@ describe("choosing a machine for a document", () => { }); it("keeps the creator's Planner when they go back to Default machine after a member chose", async () => { - let { machine, choose, choice, visit, channel, server } = await dispatchServer(); + let { machine, choose, choice, visit, channel, server, url } = await dispatchServer(); let laptop = await machine("member", "U_member", "laptop"); let rivals = await machine("rival", "U_rival", "rival-box"); await visit(); @@ -637,6 +788,19 @@ describe("choosing a machine for a document", () => { expect(await rivals.poll([], 300)).toEqual({ waiting: true }); expect(server.attachments.holder(channel.id)).toBe("member"); expect((await choice("rival")).choice).toBe(rivals.id); + + // The creator chose no machine now, so the member's stored choice counts again: it is asked + // once the Planner on Default machine has ended, and Default machine no more. + mine.send({ type: "detach" }); + for (let deadline = Date.now() + 4_000; server.attachments.holder(channel.id);) { + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(50); + } + expect(await rivals.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + expect(await laptop.poll([channel.id])).toEqual({ kind: "planners", planners: [] }); }); it("withdraws a machine's credential once it is no longer asked, so it never attaches after Automatic", async () => { @@ -1313,6 +1477,1073 @@ describe("a teammate's machine shared with teammates", () => { }); }); +describe("a teammate's shared machine, listed read-only", () => { + /** rival's shared build-box, with no checkout, and member who can push to octo-org/score. */ + async function teammates() { + let dispatch = await dispatchServer(); + let box = await dispatch.machine("rival", "U_rival", "build-box", { + bare: true, + shared: true, + version: "0.1.5", + }); + return { ...dispatch, box }; + } + + it("shows a machine with no checkouts to a teammate for the repositories its owner and they can push to", async () => { + let { listing, box } = await teammates(); + let [row, ...rest] = await listing("member"); + expect(rest).toEqual([]); + expect(row).toEqual({ + id: box.id, + label: "build-box", + teammate: true, + owner: { login: "rival", avatarUrl: "" }, + repositories: ["octo-org/score"], + connected: true, + version: "0.1.5", + service: false, + lastSeenAt: expect.any(Number), + }); + }); + + it("lists a shared machine once to its owner, as theirs, never as a teammate's", async () => { + let { listing, choice, box } = await teammates(); + // rival can push to the repository too, so the machine would qualify for them as a teammate's. + let own = (await listing("rival")).filter(value => value.id === box.id); + expect(own).toHaveLength(1); + expect(own[0]).toMatchObject({ teammate: false, shared: true }); + let menu = (await choice("rival")).machines.filter((value: { id: string }) => + value.id === box.id + ); + expect(menu).toHaveLength(1); + expect(menu[0]).toMatchObject({ teammate: false }); + }); + + it("leaves out what only the owner may see or change", async () => { + let { listing, box } = await teammates(); + let [row] = await listing("member"); + for ( + let owned of [ + "autoRun", + "reauthorize", + "finishing", + "favorite", + "shared", + "createdAt", + "hostname", + "platform", + "agent", + ] + ) { + expect(row).not.toHaveProperty(owned); + } + // The owner's own row has them all, and says it is theirs. + expect((await listing("rival")).find(value => value.id === box.id)).toMatchObject({ + teammate: false, + autoRun: false, + shared: true, + reauthorize: false, + finishing: 0, + favorite: false, + }); + }); + + it("never shows a machine that is not shared, or whose owner can't push to the repository", async () => { + let { machine, listing, share, box } = await teammates(); + // Not shared: nobody but its owner sees it. + let plain = await machine("rival", "U_rival", "private", { bare: true }); + expect((await listing("member")).map(value => value.id)).toEqual([box.id]); + expect((await listing("rival")).map(value => value.id).sort()) + .toEqual([box.id, plain.id].sort()); + // Shared by an owner who can only pull: it serves nothing a teammate could push to. + let viewers = await machine("viewer", "U_viewer", "viewer-box", { bare: true, shared: true }); + expect((await listing("member")).map(value => value.id)).toEqual([box.id]); + expect((await listing("viewer")).map(value => value.id)).toEqual([viewers.id]); + expect(await share("rival", plain.id)).toBe(200); + expect((await listing("member")).map(value => value.id).sort()) + .toEqual([box.id, plain.id].sort()); + }); + + it("shows nothing to someone who can't push to any repository the machine serves", async () => { + let { listing } = await teammates(); + // A reader can pull from the repository, but can't push to it. + expect(await listing("viewer")).toEqual([]); + }); + + it("says whether a teammate's machine is online, and keeps listing it offline", async () => { + let { listing, box } = await teammates(); + expect((await listing("member"))[0]).toMatchObject({ connected: true, version: "0.1.5" }); + await box.disconnect(); + let [row] = await listing("member"); + expect(row).toMatchObject({ id: box.id, connected: false, version: null, teammate: true }); + }); + + it("takes a machine back off the list when its owner stops sharing it", async () => { + let { listing, share, box } = await teammates(); + expect(await listing("member")).toHaveLength(1); + expect(await share("rival", box.id, false)).toBe(200); + expect(await listing("member")).toEqual([]); + expect(await listing("rival")).toHaveLength(1); + expect(await share("rival", box.id)).toBe(200); + expect(await listing("member")).toHaveLength(1); + }); + + it("never offers a revoked machine", async () => { + let { listing, send, box } = await teammates(); + let revoked = await send(`/api/connector/machines/${box.id}/revoke`, { + login: "rival", + body: {}, + }); + expect(revoked.status).toBe(200); + expect(await listing("member")).toEqual([]); + }); + + it("never lets a teammate rename, favorite, share, stop sharing or disconnect it", async () => { + let { send, listing, box } = await teammates(); + for ( + let [action, body] of [ + ["label", { label: "mine now" }], + ["favorite", { favorite: true }], + ["shared", { shared: false }], + ["revoke", {}], + ] as const + ) { + let refused = await send(`/api/connector/machines/${box.id}/${action}`, { + login: "member", + body, + }); + expect(refused.status).toBe(404); + } + let [row] = await listing("member"); + expect(row).toMatchObject({ id: box.id, label: "build-box", connected: true }); + expect((await listing("rival"))[0]).toMatchObject({ shared: true, favorite: false }); + }); + + it("shows a machine with no checkouts for the repositories both can push to, with no project opened", async () => { + let { machine, listing } = await dispatchServer(); + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + // Neither member nor rival has opened a document in octo-org/score, which both can push to. + expect(await listing("member")).toEqual([ + expect.objectContaining({ id: box.id, teammate: true, repositories: ["octo-org/score"] }), + ]); + }); + + it("lists a machine for the repositories it has checkouts of even when GitHub can't list the person's installations", async () => { + let { auth, machine, listing } = await dispatchServer(); + auth.github.installations = async () => { + throw new Error("GitHub is unavailable"); + }; + // A checkout of octo-org/score, as the connector registers it. + let box = await machine("rival", "U_rival", "build-box", { shared: true }); + expect(await listing("member")).toEqual([ + expect.objectContaining({ id: box.id, repositories: ["octo-org/score"] }), + ]); + }); + + it("never names a repository the person can't push to, even one the machine has a checkout of", async () => { + let { machine, listing } = await dispatchServer(); + // The checkout is of octo-org/score, which viewer can only pull. + await machine("rival", "U_rival", "build-box", { shared: true }); + expect(await listing("viewer")).toEqual([]); + }); + + it("lists every repository both can push to for a machine with no checkouts, however many their installations list", async () => { + let { auth, machine, listing } = await dispatchServer(); + // member's installation lists 250 repositories over three pages, octo-org/score the last; + // rival's lists three of them, octo-org/score too. + installed(auth.github, token => + token.startsWith("rival") + ? ["repo-3", "score", "repo-120"] + : others(249, "score")); + await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + let [row] = await listing("member"); + expect(row!.repositories).toEqual(["octo-org/repo-3", "octo-org/repo-120", "octo-org/score"]); + }); + + it("lists a machine with no checkouts for someone with no projects whose only repository both can push to comes after their first fifty", async () => { + let { auth, machine, listing } = await dispatchServer(); + installed(auth.github, token => token.startsWith("rival") ? ["score"] : others(50, "score")); + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + expect(await listing("member")).toEqual([ + expect.objectContaining({ id: box.id, repositories: ["octo-org/score"] }), + ]); + }); + + it("asks GitHub for the machine owner's repositories once for everyone and every poll, a pass held ten minutes", async () => { + let { auth, machine, listing, skew } = await dispatchServer(); + // Both signed-in teammates can push to 120 repositories, the owner to octo-org/score alone, + // so a check of each of theirs with the owner's token would ask 120 times. + installed(auth.github, token => token.startsWith("guest") ? ["score"] : others(120, "score")); + let box = await machine("guest", "U_guest", "build-box", { bare: true, shared: true }); + let github = counting(auth.github); + let polled = async () => { + for (let poll = 0; poll < 20; poll++) { + for (let login of ["member", "rival"]) { + expect((await listing(login)).map(value => value.id)).toEqual([box.id]); + } + } + }; + await polled(); + // The owner's installations and one repository listing, each person's and no more. + expect(github.listed("guest-machine-1")).toBe(2); + expect(github.asked("guest-machine-1")).toBe(0); + for (let login of ["member", "rival"]) { + expect(github.listed(`${login}-browser-login`)).toBe(3); + expect(github.asked(`${login}-browser-login`)).toBe(0); + } + // A minute on, the people's lists are asked for again, not the owner's. + skew.ms = 61_000; + await polled(); + expect(github.listed("guest-machine-1")).toBe(2); + for (let login of ["member", "rival"]) { + expect(github.listed(`${login}-browser-login`)).toBe(6); + } + skew.ms = 9 * 60_000; + await polled(); + expect(github.listed("guest-machine-1")).toBe(2); + skew.ms = 11 * 60_000; + await polled(); + expect(github.listed("guest-machine-1")).toBe(4); + expect(github.asked("guest-machine-1")).toBe(0); + }); + + it("asks for the machine owner's repositories once, not per machine of theirs", async () => { + let { auth, machine, listing } = await dispatchServer(); + installed(auth.github, token => token.startsWith("guest") ? ["score"] : others(60, "score")); + let first = await machine("guest", "U_guest", "build-box", { bare: true, shared: true }); + let second = await machine("guest", "U_guest", "desk", { bare: true, shared: true }); + let github = counting(auth.github); + for (let poll = 0; poll < 5; poll++) { + expect((await listing("member")).map(value => value.id).sort()) + .toEqual([first.id, second.id].sort()); + } + expect(github.listed("guest-machine-1")).toBe(2); + }); + + it("lists the machines of an owner whose other machine needs its GitHub authorization renewed, from the machine that can ask", async () => { + let { auth, storage, machine, listing, choice } = await dispatchServer(); + installed(auth.github, token => token.startsWith("guest") ? ["score"] : others(60, "score")); + let stale = await machine("guest", "U_guest", "stale-box", { bare: true, shared: true }); + let fresh = await machine("guest", "U_guest", "fresh-box", { bare: true, shared: true }); + await storage.machines.reauthorize(stale.id, 1, new Date()); + let github = counting(auth.github); + expect((await listing("member")).map(value => value.id)).toEqual([fresh.id]); + expect((await choice("member")).machines.map((value: { id: string }) => value.id)) + .toEqual([fresh.id]); + expect(github.listed("guest-machine-1")).toBe(2); + }); + + it("holds a refusal of the machine owner's repositories for a minute, however often it is asked, then asks again", async () => { + let { auth, machine, listing, skew } = await dispatchServer(); + let box = await machine("guest", "U_guest", "build-box", { bare: true, shared: true }); + let down = unavailable(auth.github, "guest"); + let github = counting(auth.github); + for (let poll = 0; poll < 10; poll++) expect(await listing("member")).toEqual([]); + expect(github.listed("guest-machine-1")).toBe(1); + down.now = false; + expect(await listing("member")).toEqual([]); + skew.ms = 61_000; + expect((await listing("member")).map(value => value.id)).toEqual([box.id]); + expect(github.listed("guest-machine-1")).toBe(3); + }); + + it("never lists a machine whose owner can only pull, whatever the person can push to", async () => { + let { auth, machine, listing } = await dispatchServer(); + installed(auth.github, token => token.startsWith("viewer") ? undefined : others(60, "score")); + await machine("viewer", "U_viewer", "viewer-box", { bare: true, shared: true }); + expect(await listing("member")).toEqual([]); + }); + + it("remembers a refusal of the person's own push check between listings, and asks again a minute on", async () => { + let { auth, machine, listing, skew } = await dispatchServer(); + // viewer can only pull octo-org/score, which the machine has a checkout of. + await machine("rival", "U_rival", "build-box", { shared: true }); + let github = counting(auth.github); + for (let poll = 0; poll < 5; poll++) expect(await listing("viewer")).toEqual([]); + expect(github.asked("viewer-browser-login")).toBe(1); + skew.ms = 61_000; + expect(await listing("viewer")).toEqual([]); + expect(await listing("viewer")).toEqual([]); + expect(github.asked("viewer-browser-login")).toBe(2); + }); + + it("remembers that the person can push to a repository their installations don't list for ten minutes", async () => { + let { auth, machine, listing, skew } = await dispatchServer(); + // Their installations list nothing of the machine's checkout, so they are asked with their own login. + auth.github.installationRepositories = async () => ({ repositories: [], nextPage: undefined }); + let box = await machine("rival", "U_rival", "build-box", { shared: true }); + let github = counting(auth.github); + let listed = async () => (await listing("member")).map(value => value.id); + for (let poll = 0; poll < 5; poll++) expect(await listed()).toEqual([box.id]); + expect(github.asked("member-browser-login")).toBe(1); + skew.ms = 9 * 60_000; + expect(await listed()).toEqual([box.id]); + expect(github.asked("member-browser-login")).toBe(1); + skew.ms = 11 * 60_000; + expect(await listed()).toEqual([box.id]); + expect(github.asked("member-browser-login")).toBe(2); + }); + + it("asks GitHub once for listings that overlap, for the person and for the machine's owner", async () => { + let { auth, machine, listing } = await dispatchServer(); + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + let github = counting(auth.github); + let lists = await Promise.all(Array.from({ length: 5 }, () => listing("member"))); + for (let list of lists) expect(list.map(value => value.id)).toEqual([box.id]); + expect(github.listed("member-browser-login")).toBe(2); + expect(github.listed("rival-machine-1")).toBe(2); + }); + + it("checks the repositories a machine has checkouts of a few at a time", async () => { + let { auth, storage, machine, listing } = await dispatchServer(); + installed(auth.github, () => others(40)); + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + for (let name of others(40)) { + await storage.machines.serve(box.id, { + repositoryId: `R_${name}`, + repository: `octo-org/${name}`, + commit: "a".repeat(40), + }); + } + let github = counting(auth.github); + let [row] = await listing("member"); + expect(row!.repositories).toHaveLength(40); + expect(github.asked("rival-machine-1")).toBe(40); + expect(github.running.most).toBeGreaterThan(1); + expect(github.running.most).toBeLessThan(40); + // Polled again, nothing is asked again of either. + expect((await listing("member"))[0]!.repositories).toHaveLength(40); + expect(github.asked("rival-machine-1")).toBe(40); + expect(github.asked("member-browser-login")).toBe(0); + }); + + it("lists shared machines where Chopin runs no Planners on machines", async () => { + let { machine, listing, choice } = await dispatchServer({ planners: false }); + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + expect((await listing("member")).map(value => value.id)).toEqual([box.id]); + expect((await choice("member")).machines).toEqual([ + expect.objectContaining({ id: box.id, teammate: true, connected: true, runsPlanners: false }), + ]); + }); +}); + +/** The machine with an id in a listing of machines. */ +function teammate(list: Array>, id: string) { + return list.find(value => value.id === id); +} + +describe("a teammate's shared machine, in a document's machine menu", () => { + async function menu() { + let dispatch = await dispatchServer(); + let box = await dispatch.machine("rival", "U_rival", "build-box", { + bare: true, + shared: true, + version: "0.1.5", + }); + let desk = await dispatch.machine("member", "U_member", "desk", { plannerCommand: false }); + return { ...dispatch, box, desk }; + } + + it("lists it beside the person's own, under its owner, without what only its owner sees", async () => { + let { choice, box, desk } = await menu(); + let { machines } = await choice("member"); + expect(machines.map((value: Record) => value.id).sort()) + .toEqual([box.id, desk.id].sort()); + expect(teammate(machines, box.id)).toEqual({ + id: box.id, + label: "build-box", + teammate: true, + owner: { login: "rival", avatarUrl: "" }, + connected: true, + lastSeenAt: expect.any(Number), + version: "0.1.5", + service: false, + runsPlanners: true, + }); + expect(teammate(machines, desk.id)).toMatchObject({ teammate: false, reauthorize: false }); + }); + + it("serves any repository its owner can push to, with no checkout of it", async () => { + let { choice, box } = await menu(); + // The machine connected with no checkouts at all, and still serves the document's repository. + expect(teammate((await choice("member")).machines, box.id)).toMatchObject({ connected: true }); + }); + + it("is listed offline, and nothing else changes", async () => { + let { choice, box } = await menu(); + await box.disconnect(); + expect(teammate((await choice("member")).machines, box.id)).toMatchObject({ + connected: false, + teammate: true, + version: null, + }); + }); + + it("is never listed unless it is shared, and goes when its owner stops sharing it", async () => { + let { choice, share, machine, box } = await menu(); + let plain = await machine("rival", "U_rival", "plain", { bare: true }); + expect(teammate((await choice("member")).machines, plain.id)).toBeUndefined(); + expect(await share("rival", box.id, false)).toBe(200); + expect(teammate((await choice("member")).machines, box.id)).toBeUndefined(); + // The owner still lists it, as their own. + expect(teammate((await choice("rival")).machines, box.id)).toMatchObject({ teammate: false }); + }); + + it("is never listed when its owner can't push to the document's repository", async () => { + let { choice, machine } = await menu(); + let readers = await machine("viewer", "U_viewer", "reader-box", { bare: true, shared: true }); + expect(teammate((await choice("member")).machines, readers.id)).toBeUndefined(); + }); + + it("is never listed to someone who can only read the document", async () => { + let { choice, box } = await menu(); + let read = await choice("viewer"); + expect(teammate(read.machines, box.id)).toBeUndefined(); + expect(read.machines).toEqual([]); + }); +}); + +describe("choosing a teammate's shared machine for a document", () => { + /** guest's shared build-box, with no checkout, for a document member created and rival edits. */ + async function chosen() { + let dispatch = await dispatchServer(); + let box = await dispatch.machine("guest", "U_guest", "build-box", { bare: true, shared: true }); + let path = `/api/documents/${dispatch.channel.id}/machine`; + return { ...dispatch, box, path }; + } + + it("lets anyone who can edit the document choose it, and runs the Planner there, as its owner", async () => { + let { choose, choice, box, channel, server, url } = await chosen(); + // rival neither created the document nor owns the machine. + let picked = await choose("rival", box.id); + expect(picked.choice).toBe(box.id); + expect(picked.planner).toEqual({ state: "starting", login: "guest", machine: box.id }); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + let link = await box.backend(); + await link.next("attached"); + expect(server.attachments.holder(channel.id)).toBe("guest"); + expect((await choice("rival")).choice).toBe(box.id); + expect((await choice("rival")).planner) + .toEqual({ state: "held", login: "guest", machine: null }); + }); + + it("lets the document's creator choose it, and runs the Planner there", async () => { + let { choose, box, channel, url } = await chosen(); + let picked = await choose("member", box.id); + expect(picked.planner).toEqual({ state: "starting", login: "guest", machine: box.id }); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + }); + + it("refuses someone who can only read the document", async () => { + let { send, box, path, runtime } = await chosen(); + let refused = await send(path, { login: "viewer", body: { machine: box.id } }); + expect(refused.status).toBe(403); + expect(runtime.connections.choosing(box.id)).toEqual([]); + }); + + it("refuses a machine that is not shared, or whose owner can't push, as one that is not there", async () => { + let { send, machine, share, path, box } = await chosen(); + let plain = await machine("rival", "U_rival", "plain", { bare: true }); + let readers = await machine("viewer", "U_viewer", "reader-box", { bare: true, shared: true }); + for (let target of [plain.id, readers.id]) { + let refused = await send(path, { login: "member", body: { machine: target } }); + expect(refused.status).toBe(404); + } + // Shared at first, then not: choosing it is refused like any machine that isn't theirs. + expect(await share("rival", plain.id)).toBe(200); + expect((await send(path, { login: "member", body: { machine: plain.id } })).status).toBe(200); + expect(await share("rival", plain.id, false)).toBe(200); + expect((await send(path, { login: "member", body: { machine: plain.id } })).status).toBe(404); + expect(box.id).toBeString(); + }); + + it("lets the creator's own choice still decide where the Planner runs", async () => { + let { choose, choice, machine, box, channel, server, url } = await chosen(); + let laptop = await machine("member", "U_member", "laptop"); + // rival chose the teammate's machine first, and its Planner attached. + await choose("rival", box.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + let theirs = await box.backend(); + await theirs.next("attached"); + expect(server.attachments.holding(channel.id)?.machineId).toBe(box.id); + + // The creator chooses their own machine: the teammate's Planner stops, rival's choice goes. + let picked = await choose("member", laptop.id); + expect(picked.planner).toEqual({ state: "starting", login: null, machine: laptop.id }); + expect(await theirs.next("refused")).toMatchObject({ code: "access-revoked" }); + expect(await box.poll([channel.id])).toEqual({ + kind: "planners", + planners: [], + stopped: { [channel.id]: expect.stringContaining("member chose another machine") }, + }); + expect((await choice("rival")).choice).toBeNull(); + }); + + it("never asks it for an editor's choice while the creator chose another machine", async () => { + let { choose, choice, machine, box, channel, url } = await chosen(); + let laptop = await machine("member", "U_member", "laptop"); + await choose("member", laptop.id); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let theirs = await choose("rival", box.id); + expect(theirs.choice).toBe(box.id); + expect(theirs.planner).toEqual({ state: "none", login: null, machine: null }); + expect(await box.poll([])).toEqual({ waiting: true }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: null, machine: laptop.id }); + }); + + it("offers it no more, and stops asking it, once its owner can no longer push", async () => { + let { auth, choose, choice, box, skew, channel, runtime } = await chosen(); + await choose("rival", box.id); + expect(await box.poll([])).toMatchObject({ kind: "planners" }); + expect(await runtime.plannerStarting(channel.id)).toEqual({ + login: "guest", + machineId: box.id, + }); + expect((await choice("rival")).machines.map((value: { id: string }) => value.id)) + .toEqual([box.id]); + // GitHub now refuses its owner's token, which Chopin finds out when it next asks. + let github = auth.github; + let asked = github.repositoryAccess; + github.repositoryAccess = async (token, owner, name) => + token.startsWith("guest-machine") ? undefined : asked(token, owner, name); + skew.ms = 11 * 60_000; + let after = await choice("rival"); + expect(after.machines).toEqual([]); + expect(after.planner).toEqual({ state: "none", login: null, machine: null }); + expect(await runtime.plannerStarting(channel.id)).toBeUndefined(); + // Its connector, still running the Planner, is told to stop. + expect(await box.poll([channel.id])).toMatchObject({ kind: "planners", planners: [] }); + }); + + it("runs the chooser's investigation there, and refuses it once the machine is offline or no longer takes their work", async () => { + let { auth, choose, send, box, channel, runtime, skew } = await chosen(); + await choose("member", box.id); + let run = async () => { + let id = crypto.randomUUID(); + let created = await send(`/api/documents/${channel.id}/experiments`, { + login: "member", + body: { id, brief: "Measure startup" }, + }); + expect(created.status).toBe(200); + let started = await send(`/api/documents/${channel.id}/experiments/${id}/run`, { + login: "member", + body: {}, + }); + return { id, started }; + }; + // Online: the run is the chooser's, on the teammate's machine. + let first = await run(); + expect(first.started.status).toBe(200); + let stored = await runtime.service.store.get(first.id); + expect(stored).toMatchObject({ connectionId: box.connectionId, requester: "U_member" }); + expect(stored?.input?.authorizer).toBe("U_member"); + // Offline: the machine's own words, never another machine. + await box.disconnect(); + let offline = await run(); + expect(offline.started.status).toBe(409); + expect(await offline.started.json()).toEqual({ + error: "machine-offline", + message: offlineMessage("build-box"), + }); + expect((await runtime.service.store.get(offline.id))?.connectionId).toBeUndefined(); + // Back online, but its owner can no longer push to the repository. + await box.reconnect(); + let github = auth.github; + let asked = github.repositoryAccess; + github.repositoryAccess = async (token, owner, name) => + token.startsWith("guest-machine") ? undefined : asked(token, owner, name); + skew.ms = 11 * 60_000; + let unavailable = await run(); + expect(unavailable.started.status).toBe(409); + expect(await unavailable.started.json()).toEqual({ + error: "machine-unavailable", + message: unavailableMessage("build-box"), + }); + expect((await runtime.service.store.get(unavailable.id))?.connectionId).toBeUndefined(); + }); + + it("takes the choice back when its owner stops sharing the machine, and stops its Planner", async () => { + let dispatch = await dispatchServer(); + let { machine, choose, choice, share, channel, url } = dispatch; + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + await choose("member", box.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + + expect(await share("rival", box.id, false)).toBe(200); + // The teammate's choice goes with the sharing, so their own work is never held for it. + expect((await choice("member")).choice).toBeNull(); + expect(await box.poll([channel.id])).toEqual({ + kind: "planners", + planners: [], + stopped: { [channel.id]: expect.stringContaining("stopped sharing build-box") }, + }); + }); + + it("keeps a teammate's Planner through its reply when sharing stops, then stops it", async () => { + let dispatch = await dispatchServer(); + let { machine, choose, share, channel, server, url } = dispatch; + let box = await machine("rival", "U_rival", "build-box", { bare: true, shared: true }); + await choose("member", box.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + let link = await box.backend(); + await link.next("attached"); + let room = await hostInputRoom(); + cleanups.push(room.close); + let planner: RemotePlanner = { + channelId: channel.id, + isolated: false, + humanInput: report => createHumanInput(room.room, undefined, report), + }; + let started = link.next("start").then(start => { + link.send({ type: "started", session: start.session }); + }); + let session = await server.links.at(-1)!.startSession("planner-session", planner); + cleanups.push(() => session.doDestroy?.()); + await started; + let turn = session.doPromptTurn({ + prompt: "outline the plan", + skills: [], + tools: [], + emit: () => {}, + }); + let { turn: id } = await link.next("turn"); + await turn; + let stillRuns = async () => { + await dispatch.runtime.sweep(); + return "waiting" in await box.poll([channel.id]); + }; + expect(await share("rival", box.id, false)).toBe(200); + expect(await stillRuns()).toBe(true); + link.send({ type: "turn-end", session: "planner-session", turn: id, status: "finished" }); + await Bun.sleep(50); + expect(await stillRuns()).toBe(false); + expect(await link.next("refused")).toMatchObject({ code: "access-revoked" }); + }); + + /** + * member created the document and runs Default machine's Planners on their desk; nobody has it + * open. rival's shared build-box is the machine member chooses for it. + */ + async function chosenByCreator(connector: { plannerCommand?: boolean } = {}) { + let dispatch = await dispatchServer(); + let box = await dispatch.machine("rival", "U_rival", "build-box", { + bare: true, + shared: true, + ...connector, + }); + let desk = await dispatch.machine("member", "U_member", "desk"); + return { ...dispatch, box, desk }; + } + + it("starts nothing on Default machine for a document whose Planner it never ran, when sharing stops", async () => { + let { choose, choice, share, box, desk } = await chosenByCreator({ plannerCommand: false }); + // A choice for the person's builds only: the machine runs no Planners, and nobody opened it. + await choose("member", box.id); + expect(await desk.poll([], 200)).toEqual({ waiting: true }); + + expect(await share("rival", box.id, false)).toBe(200); + expect((await choice("member")).choice).toBeNull(); + expect(await desk.poll([], 200)).toEqual({ waiting: true }); + expect((await choice("member")).planner).toEqual({ state: "none", login: null, machine: null }); + }); + + it("hands the document to Default machine when sharing stops, if the machine ran its Planner or was about to", async () => { + let { choose, share, box, desk, channel, url } = await chosenByCreator(); + await choose("member", box.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect(await desk.poll([], 200)).toEqual({ waiting: true }); + + expect(await share("rival", box.id, false)).toBe(200); + expect(await desk.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect(await box.poll([channel.id])).toEqual({ + kind: "planners", + planners: [], + stopped: { [channel.id]: expect.stringContaining("stopped sharing build-box") }, + }); + + // Chosen and able to run it, but its connector had not asked yet: the same. + let other = await chosenByCreator(); + await other.choose("member", other.box.id); + expect(await other.share("rival", other.box.id, false)).toBe(200); + expect(await other.desk.poll([])).toEqual({ + kind: "planners", + planners: [{ id: other.channel.id, url }], + }); + }); + + it("hands a document someone has open to Default machine when sharing stops, whatever the machine ran", async () => { + let { choose, share, visit, box, desk, channel, url } = await chosenByCreator({ + plannerCommand: false, + }); + await visit(); + // The creator's own choice keeps the document off Default machine while it stands. + await choose("member", box.id); + expect(await desk.poll([], 200)).toEqual({ waiting: true }); + + // The desk's connector is already waiting for work when sharing stops, and is told at once. + let waiting = desk.poll([], 5_000); + await Bun.sleep(50); + expect(await share("rival", box.id, false)).toBe(200); + expect(await waiting).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + }); +}); + +describe("an editor's choice of a teammate's shared machine, on a document on Default machine", () => { + /** + * member created the document and their laptop runs its Planner on Default machine; guest's shared + * build-box is the machine rival, who can edit it, chooses. + */ + async function contested(connector: { plannerCommand?: boolean } = {}) { + let dispatch = await dispatchServer(); + let box = await dispatch.machine("guest", "U_guest", "build-box", { + bare: true, + shared: true, + ...connector, + }); + let laptop = await dispatch.machine("member", "U_member", "laptop"); + await dispatch.visit(); + return { ...dispatch, box, laptop }; + } + /** Wait until nobody holds the document's Planner, as after its backend detached. */ + async function released( + server: { attachments: { holder(id: string): string | undefined } }, + id: string, + ) { + for (let deadline = Date.now() + 4_000; server.attachments.holder(id);) { + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(50); + } + } + + it("takes the Planner from an attached Default machine, which stops, and asks only the chosen machine", async () => { + let { choose, choice, visit, box, laptop, channel, server, url } = await contested(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let mine = await laptop.backend(); + await mine.next("attached"); + expect(server.attachments.holding(channel.id)?.machineId).toBe(laptop.id); + + let picked = await choose("rival", box.id); + expect(picked.planner).toEqual({ state: "starting", login: "guest", machine: box.id }); + expect(await mine.next("refused")).toMatchObject({ code: "access-revoked" }); + expect(await laptop.poll([channel.id])).toEqual({ + kind: "planners", + planners: [], + stopped: { [channel.id]: expect.stringContaining("rival chose another machine") }, + }); + // Only the chosen machine is asked, and the document staying in use does not ask the other. + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + await visit(); + expect(await laptop.poll([], 200)).toEqual({ waiting: true }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: "guest", machine: box.id }); + let theirs = await box.backend(); + await theirs.next("attached"); + expect(server.attachments.holder(channel.id)).toBe("guest"); + expect((await choice("rival")).choice).toBe(box.id); + }); + + it("takes it from a Default machine that was asked but has not attached, and withdraws its credential", async () => { + let { choose, box, laptop, channel, server, url } = await contested(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let credential = await laptop.credential(); + + await choose("rival", box.id); + expect(await laptop.poll([channel.id])).toMatchObject({ kind: "planners", planners: [] }); + expect(await upgrade(server.url, credential)).toBe(401); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + }); + + it("asks only the chosen machine for a Default machine that would have attached", async () => { + let { choose, box, laptop, channel, url } = await contested(); + // The laptop's connector has not polled yet; the choice comes first. + await choose("rival", box.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect(await laptop.poll([], 200)).toEqual({ waiting: true }); + }); + + it("goes back to Default machine when the choice is cleared", async () => { + let { choose, choice, box, laptop, channel, url } = await contested(); + await choose("rival", box.id); + expect(await laptop.poll([], 200)).toEqual({ waiting: true }); + let cleared = await choose("rival", null); + expect(cleared.choice).toBeNull(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: null, machine: laptop.id }); + }); + + it("never takes it from a machine the creator chose", async () => { + let { choose, choice, box, laptop, channel, server } = await contested(); + await choose("member", laptop.id); + let mine = await laptop.backend(); + await mine.next("attached"); + expect((await choose("rival", box.id)).planner) + .toEqual({ state: "held", login: "member", machine: null }); + expect(await box.poll([], 200)).toEqual({ waiting: true }); + expect(server.attachments.holder(channel.id)).toBe("member"); + expect((await choice("member")).planner) + .toEqual({ state: "attached", login: "member", machine: laptop.id }); + }); + + it("keeps an attached Default machine Planner when the chosen machine runs no Planners", async () => { + let { choose, choice, box, laptop, channel, server, url } = await contested({ + plannerCommand: false, + }); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let mine = await laptop.backend(); + await mine.next("attached"); + + // A builds-only machine cannot run the Planner, so choosing it stops nothing. + let picked = await choose("rival", box.id); + expect(picked.choice).toBe(box.id); + expect(picked.planner).toEqual({ state: "held", login: "member", machine: null }); + expect(server.attachments.holding(channel.id)).toMatchObject({ + machineId: laptop.id, + attached: true, + }); + expect(await laptop.poll([channel.id], 200)).toEqual({ waiting: true }); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + expect(await box.poll([], 200)).toEqual({ waiting: true }); + expect((await choice("member")).planner) + .toEqual({ state: "attached", login: "member", machine: laptop.id }); + // The editor's choice stands for their own builds and runs. + expect((await choice("rival")).choice).toBe(box.id); + }); + + it("never interrupts a Default machine Planner mid-reply for a choice that cannot take over", async () => { + let { choose, box, laptop, channel, server, url } = await contested({ plannerCommand: false }); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let link = await laptop.backend(); + await link.next("attached"); + let room = await hostInputRoom(); + cleanups.push(room.close); + let planner: RemotePlanner = { + channelId: channel.id, + isolated: false, + humanInput: report => createHumanInput(room.room, undefined, report), + }; + let started = link.next("start").then(start => { + link.send({ type: "started", session: start.session }); + }); + let session = await server.links.at(-1)!.startSession("planner-session", planner); + cleanups.push(() => session.doDestroy?.()); + await started; + let turn = session.doPromptTurn({ + prompt: "outline the plan", + skills: [], + tools: [], + emit: () => {}, + }); + let { turn: id } = await link.next("turn"); + await turn; + expect(server.attachments.busy(channel.id)).toBe(true); + + await choose("rival", box.id); + expect(server.attachments.busy(channel.id)).toBe(true); + expect(server.attachments.holding(channel.id)).toMatchObject({ + machineId: laptop.id, + attached: true, + }); + expect(await laptop.poll([channel.id], 200)).toEqual({ waiting: true }); + // The reply ends as it would have, and the Planner is still its. + link.send({ type: "turn-end", session: "planner-session", turn: id, status: "finished" }); + await Bun.sleep(50); + expect(server.attachments.busy(channel.id)).toBe(false); + expect(server.attachments.attached(channel.id)).toBe(true); + }); + + it("keeps an attached Default machine Planner when the chosen machine is offline, and the machine takes it once it is back and the Planner is no longer attached", async () => { + let { choose, choice, box, laptop, channel, server, url } = await contested(); + await box.disconnect(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let mine = await laptop.backend(); + await mine.next("attached"); + + let picked = await choose("rival", box.id); + expect(picked.choice).toBe(box.id); + expect(picked.planner).toEqual({ state: "held", login: "member", machine: null }); + expect(server.attachments.holding(channel.id)).toMatchObject({ + machineId: laptop.id, + attached: true, + }); + expect(await laptop.poll([channel.id], 200)).toEqual({ waiting: true }); + + // It comes back and can run the Planner, but never interrupts the one attached. + await box.reconnect(); + expect(await box.poll([], 200)).toEqual({ waiting: true }); + expect(await laptop.poll([channel.id], 200)).toEqual({ waiting: true }); + expect(server.attachments.attached(channel.id)).toBe(true); + + // Once that Planner ends, the machine the editor chose runs it and Default machine is not asked. + mine.send({ type: "detach" }); + await released(server, channel.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect(await laptop.poll([], 200)).toEqual({ waiting: true }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: "guest", machine: box.id }); + }); + + it("keeps a Default machine Planner that was only asked when the chosen machine is offline", async () => { + let { choose, choice, box, laptop, channel, server, url } = await contested(); + await box.disconnect(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + let credential = await laptop.credential(); + expect((await choose("rival", box.id)).choice).toBe(box.id); + expect(await laptop.poll([channel.id], 200)).toEqual({ waiting: true }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: null, machine: laptop.id }); + // Its backend can still attach with the credential it was given. + expect(await upgrade(server.url, credential)).toBe(101); + }); + + it("asks only the chosen machine after a restart, and Default machine again once it can't run the Planner", async () => { + let dispatch = await contested(); + let { choose, choice, box, laptop, channel, url } = dispatch; + await choose("rival", box.id); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + + // The deploy forgets what Default machine gave the document, but the editor's choice is stored. + await dispatch.restart(); + await laptop.reconnect(); + await box.reconnect(); + await dispatch.visit(); + expect(await laptop.poll([], 200)).toEqual({ waiting: true }); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect((await choice("rival")).choice).toBe(box.id); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: "guest", machine: box.id }); + + // The chosen machine goes away: the document is not left without a Planner. + await box.disconnect(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: null, machine: laptop.id }); + // And back to the chosen machine when it returns. + await box.reconnect(); + await dispatch.visit(); + expect(await laptop.poll([channel.id])).toEqual({ kind: "planners", planners: [] }); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + }); + + it("never asks both machines after a restart, whichever one connects first", async () => { + let dispatch = await contested(); + let { choose, box, laptop, channel, url } = dispatch; + await choose("rival", box.id); + await dispatch.restart(); + // Default machine's machine is back first and is asked, as nothing yet says otherwise. + await laptop.reconnect(); + await dispatch.visit(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + // Then the chosen machine, which can run it: Default machine's is no longer asked. + await box.reconnect(); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect(await laptop.poll([channel.id])).toEqual({ kind: "planners", planners: [] }); + expect(await laptop.poll([], 200)).toEqual({ waiting: true }); + }); + + it("keeps the creator's own choice ahead of the editor's after a restart", async () => { + let dispatch = await contested(); + let { choose, choice, box, laptop, channel, url } = dispatch; + await choose("member", laptop.id); + await choose("rival", box.id); + await dispatch.restart(); + await laptop.reconnect(); + await box.reconnect(); + await dispatch.visit(); + expect(await box.poll([], 200)).toEqual({ waiting: true }); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + expect((await choice("rival")).choice).toBe(box.id); + }); + + it("lets the creator take it back with Default machine from a choice no machine was asked for yet", async () => { + let { choose, choice, box, laptop, channel, url } = await contested(); + // The chosen machine can run it, but its connector has not polled yet. + await choose("rival", box.id); + let back = await choose("member", null); + expect(back.planner).toEqual({ state: "starting", login: null, machine: laptop.id }); + expect((await choice("rival")).choice).toBeNull(); + expect(await box.poll([], 200)).toEqual({ waiting: true }); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + }); + + it("lets the creator's own machine, or Default machine, take it back from the editor's choice", async () => { + let { choose, choice, box, laptop, channel, server, url } = await contested(); + let take = async () => { + await choose("rival", box.id); + expect(await box.poll([])).toMatchObject({ kind: "planners" }); + let theirs = await box.backend(); + await theirs.next("attached"); + return theirs; + }; + let theirs = await take(); + // The creator's own machine. + let own = await choose("member", laptop.id); + expect(own.planner).toEqual({ state: "starting", login: null, machine: laptop.id }); + expect(await theirs.next("refused")).toMatchObject({ code: "access-revoked" }); + expect((await choice("rival")).choice).toBeNull(); + let mine = await laptop.backend(); + await mine.next("attached"); + expect(server.attachments.holder(channel.id)).toBe("member"); + + // And again, with Default machine, once the editor chooses it anew. + mine.send({ type: "detach" }); + for (let deadline = Date.now() + 4_000; server.attachments.holder(channel.id);) { + expect(Date.now()).toBeLessThan(deadline); + await Bun.sleep(50); + } + await choose("member", null); + theirs = await take(); + let back = await choose("member", null); + expect(back.planner).toEqual({ state: "starting", login: null, machine: laptop.id }); + expect(await theirs.next("refused")).toMatchObject({ code: "access-revoked" }); + expect((await choice("rival")).choice).toBeNull(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + }); +}); + describe("a machine's GitHub token", () => { it("goes only to the owner's machine, for a repository its owner can push to", async () => { let { machine, machines, send } = await dispatchServer(); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 195879880..cf8033695 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -2793,6 +2793,8 @@ implementations = registerImplementationRoutes(router, hostedAuth, { resumeMs: config.buildResumeMs, connections: experiments.connections, refuseOffline: experiments.refuseOffline, + takers: experiments.takers, + mayTake: experiments.mayTake, machineChoices: experiments.machineChoices, sourceFor: experiments.sourceFor, busy: async connectionId => diff --git a/apps/server/src/storage/machine-contract.ts b/apps/server/src/storage/machine-contract.ts index 0817ac3b8..2f4649550 100644 --- a/apps/server/src/storage/machine-contract.ts +++ b/apps/server/src/storage/machine-contract.ts @@ -226,6 +226,37 @@ export function machineContract(name: string, factory: StorageFactory) { } }); + it("lets a person choose a teammate's machine only while its owner shares it", async () => { + let storage = await openedStorage(factory); + try { + let { userId: owner, channelId } = await userAndChannel(storage); + let now = new Date("2026-01-05T03:04:05.000Z"); + let teammate = id("machine-teammate"); + await storage.users.put({ id: teammate, login: "teammate", avatarUrl: "", now }); + let theirs = machine(owner); + await storage.machines.create(theirs); + let chosen = () => + storage.machines.choices().then(list => + list.filter(value => value.channelId === channelId) + ); + await expect( + storage.machines.choose({ channelId, userId: teammate, machineId: theirs.id }, now), + ) + .rejects.toBeInstanceOf(StorageError); + await storage.machines.share(theirs.id, owner, true); + await storage.machines.choose({ channelId, userId: teammate, machineId: theirs.id }, now); + expect(await chosen()).toEqual([{ channelId, userId: teammate, machineId: theirs.id }]); + await storage.machines.revoke(theirs.id, owner, now); + expect(await chosen()).toEqual([]); + await expect( + storage.machines.choose({ channelId, userId: teammate, machineId: theirs.id }, now), + ) + .rejects.toBeInstanceOf(StorageError); + } finally { + await storage.close(); + } + }); + it("forgets the choices of a revoked machine", async () => { let storage = await openedStorage(factory); try { @@ -299,6 +330,32 @@ export function machineContract(name: string, factory: StorageFactory) { } }); + it("lists the machines their owners share with teammates, never a revoked or unshared one", async () => { + let storage = await openedStorage(factory); + try { + let userId = await owner(storage); + let shared = machine(userId, { shared: true }); + let plain = machine(userId); + let revoked = machine(userId, { shared: true }); + for (let input of [shared, plain, revoked]) await storage.machines.create(input); + let now = new Date("2026-01-05T03:04:05.000Z"); + await storage.machines.revoke(revoked.id, userId, now); + // The database may hold other tests' machines; this owner's are the ones to read. + let ids = async () => + (await storage.machines.shared()).filter(value => value.userId === userId) + .map(value => value.id); + expect(await ids()).toEqual([shared.id]); + expect((await storage.machines.shared()).find(value => value.id === shared.id)) + .toMatchObject({ userId, shared: true }); + await storage.machines.share(plain.id, userId, true); + expect((await ids()).sort()).toEqual([shared.id, plain.id].sort()); + await storage.machines.share(shared.id, userId, false); + expect(await ids()).toEqual([plain.id]); + } finally { + await storage.close(); + } + }); + it("remembers each repository a machine connects, once", async () => { let storage = await openedStorage(factory); try { diff --git a/apps/server/src/storage/memory/machines.ts b/apps/server/src/storage/memory/machines.ts index 4bfd96ad6..220779ac0 100644 --- a/apps/server/src/storage/memory/machines.ts +++ b/apps/server/src/storage/memory/machines.ts @@ -52,6 +52,12 @@ export class MemoryMachineStore implements MachineStore { .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) .map(copy); } + async shared() { + return [...this.#machines.values()] + .filter(value => value.shared && !value.revokedAt) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .map(copy); + } async seen(id: string, now: Date) { let found = this.#machines.get(id); if (found && !found.revokedAt) found.lastSeenAt = new Date(now); @@ -116,7 +122,7 @@ export class MemoryMachineStore implements MachineStore { return; } let found = this.#machines.get(choice.machineId); - if (!found || found.revokedAt || found.userId !== choice.userId) { + if (!found || found.revokedAt || found.userId !== choice.userId && !found.shared) { throw missing(`machine ${choice.machineId} is not the person's`); } if (!this.channelExists(choice.channelId)) { diff --git a/apps/server/src/storage/model.ts b/apps/server/src/storage/model.ts index 4cd29a708..80ddcdca9 100644 --- a/apps/server/src/storage/model.ts +++ b/apps/server/src/storage/model.ts @@ -203,8 +203,9 @@ export type StoredMachine = { repositories: MachineRepository[]; autoRun: boolean; /** - * The owner lets Default machine run the Planner of a teammate's document here, when the - * teammate has no machine of their own that can. Off unless the owner turns it on. + * The owner lets teammates choose this machine for a document: its Planner, builds and runs + * then run here as the owner, and Default machine may run the Planner of a teammate's document + * whose creator has no machine of their own that can. Off unless the owner turns it on. */ shared?: boolean; tokenHash: Uint8Array; diff --git a/apps/server/src/storage/port.ts b/apps/server/src/storage/port.ts index 0a24c0643..71b1ccace 100644 --- a/apps/server/src/storage/port.ts +++ b/apps/server/src/storage/port.ts @@ -115,6 +115,8 @@ export interface MachineStore { byToken(tokenHash: Uint8Array): Promise; /** An owner's unrevoked machines, most recently paired first. */ list(userId: string): Promise; + /** Every unrevoked machine its owner shares with teammates, most recently paired first. */ + shared(): Promise; seen(id: string, now: Date): Promise; rotate(id: string, expectedRevision: number, credentials: MachineCredentials): Promise; /** Drop credentials GitHub refused at `expectedRevision`; the owner must pair again. */ diff --git a/apps/server/src/storage/postgres/machines.ts b/apps/server/src/storage/postgres/machines.ts index 407f51e74..82d84474a 100644 --- a/apps/server/src/storage/postgres/machines.ts +++ b/apps/server/src/storage/postgres/machines.ts @@ -103,6 +103,16 @@ export class PostgresMachineStore implements MachineStore { return rows.map(machine); }); } + shared() { + return this.run("list shared machines", async () => { + let rows = await this.sql` + SELECT ${this.sql.unsafe(COLUMNS)} FROM machines + WHERE shared AND revoked_at IS NULL + ORDER BY created_at DESC, id + `; + return rows.map(machine); + }); + } seen(id: string, now: Date) { return this.run("record machine contact", async () => { await this.sql` @@ -203,11 +213,13 @@ export class PostgresMachineStore implements MachineStore { `; return; } - // Only the person's own unrevoked machine; anything else is missing. + // The person's own unrevoked machine, or one its owner shares with teammates; anything + // else is missing. let inserted = await this.sql<{ machineId: string }[]>` INSERT INTO machine_choices (channel_id, user_id, machine_id, chosen_at) SELECT ${choice.channelId}, ${choice.userId}, id, ${now} FROM machines - WHERE id = ${choice.machineId} AND user_id = ${choice.userId} AND revoked_at IS NULL + WHERE id = ${choice.machineId} AND (user_id = ${choice.userId} OR shared) + AND revoked_at IS NULL ON CONFLICT (channel_id, user_id) DO UPDATE SET machine_id = EXCLUDED.machine_id, chosen_at = EXCLUDED.chosen_at RETURNING machine_id AS "machineId" diff --git a/apps/server/src/tasks/builds.ts b/apps/server/src/tasks/builds.ts index 4eb9b92f1..eb965121b 100644 --- a/apps/server/src/tasks/builds.ts +++ b/apps/server/src/tasks/builds.ts @@ -36,6 +36,7 @@ let buildSchema = z.object({ id: z.string().uuid(), retryOf: z.string().uuid().optional(), user: z.string().min(1).max(128), + machineOwner: z.string().min(1).max(128).optional(), connectionId: z.string().min(1).max(128), /** The paired machine it runs on; builds stored before machines were recorded have none. */ machineId: z.string().min(1).max(128).optional(), @@ -71,6 +72,11 @@ let buildSchema = z.object({ "invalid rebuild revisions", ); +/** Whose connection runs a build: its requester's, or the teammate's whose shared machine it went to. */ +export function runner(build: Pick): string { + return build.machineOwner ?? build.user; +} + /** Whether a build closes document editing. A rebuild works from a sealed delta instead. */ export function locksEditing(build: { state: string; kind?: string }): boolean { return build.kind !== "rebuild" && ["queued", "starting", "running"].includes(build.state); @@ -179,6 +185,7 @@ type BuildInput = { graphVersion: number; graphRevision: number; user: string; + machineOwner?: string; connectionId: string; machineId: string; machineService?: true; @@ -205,6 +212,7 @@ export async function queueBuild(plan: Plan, input: BuildInput): Promise Connection[]; assign: (connectionId: string, documentId: string) => void; assigned: (connectionId: string) => string | undefined; - /** Whether the connection may take its owner's work on this document: their machine choice. */ - allows?: (documentId: string, connection: Connection) => boolean; + /** Whether the connection may take `person`'s work on this document: their machine choice. */ + allows?: (documentId: string, connection: Connection, person: string) => boolean; }; export type RebuildResult = @@ -292,8 +300,10 @@ export function stoppedForRevision(plan: Plan, revision = plan.revision): boolea * * Under LIVE_BUILD the live build's user keeps ownership; its original connection is preferred, * and another of the same user's connections for this repository stands in once that one has gone. - * A person who presses Build instead (`by`) builds the edits on their own connection. A connection - * already holding another document's build is skipped so the rebuild cannot strand that build. + * A person who presses Build instead (`by`) builds the edits on their own connection, or on the + * teammate's shared machine they chose for the document; automatic work never leaves its owner's + * machines. A connection already holding another document's build is skipped so the rebuild cannot + * strand that build. */ /** The checkout a connection says it has of a repository, else `fallback`. */ function checkoutFor( @@ -316,7 +326,7 @@ export async function queueRebuild( options: { /** An automatic rebuild runs only on a connection whose owner opted in when pairing it. */ automatic?: boolean; - /** Who pressed Build; a pressed rebuild runs on their own agent. */ + /** Who pressed Build; a pressed rebuild runs on the agent of the machine they chose. */ by?: string; } = {}, ): Promise { @@ -335,10 +345,13 @@ export async function queueRebuild( let original = plan.builds.find(build => build.id === live.buildId); let usable = (connection: Connection | undefined) => { let assigned = connection && connections.assigned(connection.id); - return !!connection && connection.owner === owner + return !!connection + && (connection.owner === owner + ? connections.allows?.(plan.id, connection, owner) ?? true + : !options.automatic && !!connection.shared + && !!connections.allows?.(plan.id, connection, owner)) && serves(connection, live.repositoryId) && (!options.automatic || connection.autoRun === true) - && (connections.allows?.(plan.id, connection) ?? true) && (assigned === undefined || assigned === plan.id); }; let connection = original && connections.get(original.connectionId); @@ -368,6 +381,7 @@ export async function queueRebuild( id: crypto.randomUUID(), kind: "rebuild", user: owner, + ...(connection.owner !== owner ? { machineOwner: connection.owner } : {}), connectionId: connection.id, machineId: connection.machineId, ...(connection.about?.service ? { machineService: true } : {}), @@ -467,7 +481,10 @@ export function renewQueued(plan: Plan, id: string): Promise { }); } -/** Persist pickup before the companion receives permission to spawn a process. */ +/** + * Persist pickup before the companion receives permission to spawn a process. `user` owns the + * connection that picks it up: the build's requester, or the owner of the shared machine it runs on. + */ export function pickBuild( plan: Plan, user: string, @@ -478,7 +495,7 @@ export function pickBuild( return exclusive(plan, async () => { let build = plan.builds.at(-1); if ( - !build || build.user !== user || build.connectionId !== connectionId + !build || runner(build) !== user || build.connectionId !== connectionId || build.state !== "queued" ) { return; @@ -533,6 +550,7 @@ export function rebindBuild( }); } +/** `user` owns the connection that reports it, as for pickBuild(). */ export function reportBuild( plan: Plan, user: string, @@ -551,7 +569,7 @@ export function reportBuild( return exclusive(plan, async () => { let current = plan.builds.at(-1); if ( - !current || current.id !== id || current.user !== user + !current || current.id !== id || runner(current) !== user || current.connectionId !== connectionId ) { throw new Error("build is unavailable"); diff --git a/apps/server/src/tasks/connector.ts b/apps/server/src/tasks/connector.ts index 8214c9d14..fbc9c14ab 100644 --- a/apps/server/src/tasks/connector.ts +++ b/apps/server/src/tasks/connector.ts @@ -16,6 +16,7 @@ import { renewQueued, reportBuild, reportRebuild, + runner, } from "./builds"; import { implementationLifecycle } from "./lifecycle"; import { unapprovedContext } from "./open-decisions"; @@ -26,6 +27,9 @@ export const RESTARTED = "Chopin restarted, so this build ended."; export const DISCONNECTED = "Your local agent disconnected."; /** The build's heartbeat stopped while its machine may still be connected. */ export const UNRESPONSIVE = "Your local agent stopped responding."; +/** A queued build on a teammate's shared machine that stopped taking it. */ +export const WITHDRAWN = + "The machine stopped taking this build: it is no longer shared, its owner can no longer push to this repository, or another machine was chosen."; /** A run credential's build is resumable, but its machine has not come back since the restart. */ export const RESUMING = "Chopin restarted and is waiting for this machine's connector to come back. Retry in a few seconds."; @@ -164,8 +168,21 @@ export function implementationConnector( documentExists: (id: string) => Promise, /** Whether a prototype is executing on a connection; a build queued behind it waits. */ prototyping?: (connectionId: string) => Promise, - options: { retryMs?: number; retries?: number; resumeMs?: number } = {}, + options: { + retryMs?: number; + retries?: number; + resumeMs?: number; + /** + * Whether a connection may take `person`'s work on a document now: their own machine, or the + * teammate's shared machine they chose while its owner can push. Without it, only their own. + */ + mayTake?: (connection: Connection, person: string, documentId: string) => Promise; + /** The GitHub login of a person, for the agent to credit in the pull requests it opens. */ + login?: (userId: string) => Promise; + } = {}, ) { + let mayTake = options.mayTake + ?? (async (connection: Connection, person: string) => connection.owner === person); /** When this process started; a build it never connected may still resume until the window ends. */ let started = Date.now(); let resumeMs = options.resumeMs ?? RESUME_MS; @@ -183,6 +200,26 @@ export function implementationConnector( let back = build.machineId ? connections.connectedSince(build.machineId) : undefined; return back === undefined || Date.now() < back + RECONNECTED_MS; }; + /** + * What the agent of a build on a teammate's shared machine is told besides the document: who + * asked for it, and the note for every pull request it opens. The pull requests come from the + * machine owner's GitHub account, so the note says whose request it was. + */ + let requestedBy = async (plan: Plan, build: BuildRequest, connection: Connection) => { + if (build.machineOwner === undefined) return; + let asked = await options.login?.(build.user) ?? "a teammate"; + let owner = connection.login; + return { + requestedBy: { + login: asked, + instructions: + `${asked} asked for this build, which runs on ${owner}'s shared machine with ${owner}'s GitHub account, so its pull requests come from ${owner}. Put pullRequestNote, unchanged, at the end of the body of every pull request you open or update.`, + pullRequestNote: `Requested by ${asked} in ${await documentUrl( + plan.id, + )}. Built by a coding agent on ${owner}'s shared machine, with ${owner}'s GitHub account.`, + }, + }; + }; let tracked = new Set(); // Lifecycle calls of one run apply in arrival order, one at a time. let chains = new Map>(); @@ -205,7 +242,7 @@ export function implementationConnector( let build = await documentExists(documentId) ? await withPlan(documentId, async plan => { let build = plan.builds.at(-1); - return build?.connectionId === connection.id && build.user === connection.owner + return build?.connectionId === connection.id && runner(build) === connection.owner ? build : undefined; }) @@ -244,9 +281,14 @@ export function implementationConnector( }, async waiting(connection: Connection) { let build = await pending(connection); - return build?.state === "queued" - ? { id: build.id, kind: "implementation" as const } - : undefined; + if (build?.state !== "queued") return undefined; + // A teammate's machine is offered the build only while it may still take it. + let documentId = connections.assigned(connection.id); + if ( + build.machineOwner !== undefined + && !(documentId && await mayTake(connection, build.user, documentId)) + ) return undefined; + return { id: build.id, kind: "implementation" as const }; }, async busy(connection: Connection) { let build = await pending(connection); @@ -329,7 +371,7 @@ export function implementationConnector( let own = plan.builds.find(item => item.id === grant.run!.id); if ( own?.kind === "rebuild" && own.state === "stopped" && grant.run.generation === 1 - && own.connectionId === connection.id && own.user === connection.owner + && own.connectionId === connection.id && runner(own) === connection.owner ) { let report = rebuildReportSchema.safeParse(args); if (!report.success) fail("invalid-request"); @@ -356,7 +398,7 @@ export function implementationConnector( let own = plan.builds.find(item => item.id === args.id); if ( own?.kind === "rebuild" && own.state === "stopped" - && own.connectionId === connection.id && own.user === connection.owner + && own.connectionId === connection.id && runner(own) === connection.owner && serves(connection, own.repositoryId) ) { return name === "renew_implementation_build" @@ -381,7 +423,7 @@ export function implementationConnector( // owner. if ( build && !grant.run && build.connectionId !== connection.id && build.id === args.id - && build.machineId === connection.machineId && build.user === connection.owner + && build.machineId === connection.machineId && runner(build) === connection.owner && serves(connection, build.repositoryId) && ["renew_implementation_build", "report_implementation_build"].includes(name) && ["starting", "running"].includes(build.state) @@ -409,7 +451,7 @@ export function implementationConnector( refuse("build-forbidden", build); } if ( - !build || build.connectionId !== connection.id || build.user !== connection.owner + !build || build.connectionId !== connection.id || runner(build) !== connection.owner || !serves(connection, build.repositoryId) || build.id !== (grant.run?.id ?? args.id) ) refuse("build-forbidden", build); @@ -422,7 +464,8 @@ export function implementationConnector( ) refuse("build-inactive", build); if (rebuild) { if (name === "read_rebuild") { - return await readRebuild(plan, build.id) ?? fail("build-inactive"); + let read = await readRebuild(plan, build.id) ?? fail("build-inactive"); + return { ...read, ...await requestedBy(plan, build, connection) }; } if (name !== "report_rebuild") fail("tool-forbidden"); let report = rebuildReportSchema.safeParse(args); @@ -448,6 +491,7 @@ export function implementationConnector( lifecycle: plan.lifecycle, }), ...unapprovedContext(plan.records.values()), + ...await requestedBy(plan, build, connection), })); } let parsed = lifecycleCall(name, { ...args, id: plan.id, runId: build.id }); @@ -458,6 +502,10 @@ export function implementationConnector( } if (name === "claim_implementation_build") { if (build.expiresAt <= Date.now()) fail("build-expired"); + // A teammate's machine claims only while it may still take the requester's work. + if (build.machineOwner !== undefined && !await mayTake(connection, build.user, plan.id)) { + refuse("build-forbidden", build); + } let picked = await pickBuild( plan, connection.owner, @@ -523,7 +571,7 @@ export function implementationConnector( graphRevision: build.graphRevision, run: { id: build.id, - user: connection.owner, + user: build.user, client: { name: "chopin-acp", version: "0.1.0" }, session: report.session, graphVersion: build.graphVersion, @@ -555,7 +603,7 @@ export function implementationConnector( build?.connectionId !== connectionId || !["queued", "starting", "running"].includes(build.state) ) return; - await reportBuild(plan, build.user, connectionId, build.id, { + await reportBuild(plan, runner(build), connectionId, build.id, { state: "failed", error: DISCONNECTED, }); @@ -578,6 +626,19 @@ export function implementationConnector( return; } let connected = !!connections.get(build.connectionId); + // A teammate's machine that stopped taking it would only let it expire unclaimed. + if (connected && build.state === "queued" && build.machineOwner !== undefined) { + let held = connections.get(build.connectionId)!; + if (!await mayTake(held, build.user, id)) { + await reportBuild(plan, runner(build), held.id, build.id, { + state: "failed", + error: WITHDRAWN, + }); + connections.release(held.id, id); + tracked.delete(id); + return; + } + } // Queued behind another document's build on the same connection. let behind = connections.assigned(build.connectionId) !== id && connections.queued(build.connectionId).includes(id); @@ -604,7 +665,7 @@ export function implementationConnector( `chopin: build ${build.id} of document ${id} on connection ${build.connectionId} ` + `of machine ${build.machineId ?? "unknown"} ended - ${error}`, ); - await reportBuild(plan, build.user, build.connectionId, build.id, { + await reportBuild(plan, runner(build), build.connectionId, build.id, { state: "failed", error, }); diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index 5f7d27795..9340a5562 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -215,6 +215,8 @@ async function setup( ...(options.resumeMs === undefined ? {} : { resumeMs: options.resumeMs }), connections: current.connections, refuseOffline: current.refuseOffline, + takers: current.takers, + mayTake: current.mayTake, machineChoices: current.machineChoices, busy: async connectionId => (await current.service.store.active()).some(item => @@ -2690,6 +2692,430 @@ test("Build uses the machine the person chose for the document, never another", } }); +/** + * other shares a paired machine named desk with teammates, and the document's person (test) chose + * it. With `own`, test has a machine of their own too, which is not the one chosen. + */ +async function shared( + context: Awaited>, + own = false, + /** Its owner also let Chopin start work on it by itself. */ + autoRun = false, +) { + let mine = own ? await paired(context) : undefined; + let theirs = await paired(context, context.otherCookie, autoRun); + let owner = (url: string, value: unknown) => + context.call(url, value, undefined, undefined, context.otherCookie); + await owner(`/api/connector/machines/${theirs.machine}/label`, { label: "desk" }); + await owner(`/api/connector/machines/${theirs.machine}/shared`, { shared: true }); + let choose = (machine: string | null, cookie?: string) => + context.call( + `/api/documents/${context.plan.id}/machine`, + { machine }, + undefined, + undefined, + cookie, + ); + expect((await choose(theirs.machine)).status).toBe(200); + return { mine, theirs, choose, owner }; +} +let review = { planRevision: 0, graphVersion: 1, graphRevision: 1 }; + +test("Build goes to the teammate's shared machine the person chose, and runs there on their behalf", async () => { + let context = await setup(); + try { + let { mine, theirs } = await shared(context, true); + let built = await (await context.call(context.path, review)).json(); + // The person asked; the machine is its owner's. + expect(built).toMatchObject({ + user: "U_test", + machineOwner: "U_other", + connectionId: theirs.connection.id, + machineId: theirs.machine, + state: "queued", + }); + expect(context.experiments.connections.assigned(theirs.connection.id)).toBe(context.plan.id); + expect(context.experiments.connections.assigned(mine!.connection.id)).toBeUndefined(); + // Only the machine it was queued on is offered it, and it claims it. + expect(await tool(context, theirs.token, "wait_for_work")).toEqual({ + id: built.id, + kind: "implementation", + }); + let claim = await tool(context, theirs.token, "claim_implementation_build", { id: built.id }); + expect(claim.build).toMatchObject({ + state: "starting", + user: "U_test", + machineOwner: "U_other", + }); + expect( + (await tool(context, theirs.token, "report_implementation_build", { + id: built.id, + state: "running", + session: "acp-session", + })).isError, + ).toBeUndefined(); + // The run is recorded as the person's who asked, never the machine owner's. + expect(context.plan.execution?.user).toBe("U_test"); + // The agent is told whose request it is, to say so in each pull request it opens. + let read = await tool(context, claim.runToken, "read_implementation"); + expect(read.requestedBy.login).toBe("test"); + expect(read.requestedBy.pullRequestNote).toContain("Requested by test in https://chopin.test/"); + expect(read.requestedBy.pullRequestNote).toContain("other's shared machine"); + expect(read.requestedBy.instructions).toContain("pullRequestNote"); + // Everyone who sees the build is told whose machine it ran on. + let snapshot = await (await context.call(context.path)).json(); + expect(snapshot.startedBy).toBe("test"); + expect(snapshot.buildMachines[built.id]).toMatchObject({ label: "desk", owner: "other" }); + expect( + (await tool(context, theirs.token, "renew_implementation_build", { id: built.id })).isError, + ).toBeUndefined(); + // The person who asked can stop it. + expect((await context.call(`${context.path}/stop`, { id: built.id })).status).toBe(200); + expect(context.plan.builds.at(-1)).toMatchObject({ + state: "failed", + error: "Stopped in Chopin.", + }); + } finally { + await Plan.close(context.plan); + } +}); + +test("a build for a teammate is the same build for its owner's own machine: nobody's, in the owner's own document", async () => { + let context = await setup(); + try { + let mine = await paired(context); + let built = await (await context.call(context.path, review)).json(); + expect(built.machineOwner).toBeUndefined(); + expect(built.connectionId).toBe(mine.connection.id); + } finally { + await Plan.close(context.plan); + } +}); + +test("Build on a chosen shared machine counts the machine as the person's local agent", async () => { + let context = await setup(); + try { + await shared(context); + let snapshot = await (await context.call(context.path)).json(); + expect(snapshot.localAgent).toBe(true); + } finally { + await Plan.close(context.plan); + } +}); + +test("a machine that is not the one the build was queued on never claims or reports it", async () => { + let context = await setup(); + try { + let { mine, theirs, owner } = await shared(context, true); + // A second machine of the owner's, shared too, but not the one the person chose. + let other = await paired(context, context.otherCookie); + await owner(`/api/connector/machines/${other.machine}/shared`, { shared: true }); + let built = await (await context.call(context.path, review)).json(); + expect(built.connectionId).toBe(theirs.connection.id); + expect( + context.experiments.connections.chosenShared("R_test", "U_test", context.plan.id).map(value => + value.id + ), + ).toEqual([theirs.connection.id]); + for (let wrong of [mine!, other]) { + expect( + (await tool(context, wrong.token, "claim_implementation_build", { id: built.id })).isError, + ) + .toBe(true); + } + expect(context.plan.builds.at(-1)?.state).toBe("queued"); + let claim = await tool(context, theirs.token, "claim_implementation_build", { id: built.id }); + expect(claim.build.state).toBe("starting"); + for (let wrong of [mine!, other]) { + for ( + let report of [ + { id: built.id, state: "running", session: "x" }, + { id: built.id, state: "failed", error: "gone" }, + ] + ) { + expect((await tool(context, wrong.token, "report_implementation_build", report)).isError) + .toBe(true); + } + expect( + (await tool(context, wrong.token, "renew_implementation_build", { id: built.id })).isError, + ) + .toBe(true); + } + expect(context.plan.builds.at(-1)?.state).toBe("starting"); + } finally { + await Plan.close(context.plan); + } +}); + +test("a queued build is not taken by a machine whose owner stopped sharing it, and ends", async () => { + let context = await setup(); + try { + let { theirs, owner } = await shared(context); + let built = await (await context.call(context.path, review)).json(); + let connection = () => context.experiments.connections.get(theirs.connection.id)!; + expect(await context.implementations.waiting(connection())).toEqual({ + id: built.id, + kind: "implementation", + }); + expect(await owner(`/api/connector/machines/${theirs.machine}/shared`, { shared: false })) + .toMatchObject({ status: 200 }); + // It is no longer offered the build, and claiming it is refused. + expect(await context.implementations.waiting(connection())).toBeUndefined(); + expect( + (await tool(context, theirs.token, "claim_implementation_build", { id: built.id })).isError, + ) + .toBe(true); + expect(context.plan.builds.at(-1)?.state).toBe("queued"); + await context.implementations.sweep(); + expect(context.plan.builds.at(-1)).toMatchObject({ + state: "failed", + error: expect.stringContaining("no longer shared"), + }); + expect(context.experiments.connections.assigned(theirs.connection.id)).toBeUndefined(); + } finally { + await Plan.close(context.plan); + } +}); + +test("a queued build is not taken by a machine the person no longer chose", async () => { + let context = await setup(); + try { + let { mine, theirs, choose } = await shared(context, true); + let built = await (await context.call(context.path, review)).json(); + expect((await choose(mine!.machine)).status).toBe(200); + expect( + (await tool(context, theirs.token, "claim_implementation_build", { id: built.id })).isError, + ) + .toBe(true); + expect(context.plan.builds.at(-1)?.state).toBe("queued"); + await context.implementations.sweep(); + expect(context.plan.builds.at(-1)?.state).toBe("failed"); + } finally { + await Plan.close(context.plan); + } +}); + +test("a build already running on a shared machine finishes when its owner stops sharing it", async () => { + let context = await setup(); + try { + let { theirs, owner } = await shared(context); + let built = await (await context.call(context.path, review)).json(); + await tool(context, theirs.token, "claim_implementation_build", { id: built.id }); + expect( + (await tool(context, theirs.token, "report_implementation_build", { + id: built.id, + state: "running", + session: "acp-session", + })).isError, + ).toBeUndefined(); + await owner(`/api/connector/machines/${theirs.machine}/shared`, { shared: false }); + await context.implementations.sweep(); + expect(context.plan.builds.at(-1)?.state).toBe("running"); + expect( + (await tool(context, theirs.token, "report_implementation_build", { + id: built.id, + state: "stopped", + })).isError, + ).toBeUndefined(); + expect(context.plan.builds.at(-1)?.state).toBe("stopped"); + } finally { + await Plan.close(context.plan); + } +}); + +test("Build is refused, naming the machine, while the shared machine chosen is offline or no longer takes the work", async () => { + let context = await setup(); + try { + let { theirs } = await shared(context); + await tool(context, theirs.token, "disconnect_workspace"); + let offline = await context.call(context.path, review); + expect(offline.status).toBe(409); + expect(await offline.json()).toEqual({ + error: "machine-offline", + message: "desk is offline. Start chopin connect on it, or choose another machine.", + }); + // Back online, but its owner can't push to the repository any more. + await reconnect(context, theirs.machineToken, context.otherCookie); + let github = context.auth.github; + let asked = github.repositoryAccess; + github.repositoryAccess = async (token, owner, name) => + token === "other" ? undefined : asked(token, owner, name); + context.now.setTime(context.now.getTime() + 11 * 60_000); + let refused = await context.call(context.path, review); + expect(refused.status).toBe(409); + expect(await refused.json()).toEqual({ + error: "machine-unavailable", + message: + "desk no longer takes this work: its owner stopped sharing it or can't push to this repository. Choose another machine.", + }); + expect(context.plan.builds).toEqual([]); + expect(context.experiments.connections.assigned(theirs.connection.id)).toBeUndefined(); + } finally { + await Plan.close(context.plan); + } +}); + +test("a shared machine nobody chose never takes anyone's build", async () => { + let context = await setup(); + try { + let { theirs, mine, choose } = await shared(context, true); + expect((await choose(mine!.machine)).status).toBe(200); + let built = await (await context.call(context.path, review)).json(); + expect(built.connectionId).toBe(mine!.connection.id); + expect(built.machineOwner).toBeUndefined(); + expect( + (await tool(context, theirs.token, "claim_implementation_build", { id: built.id })).isError, + ) + .toBe(true); + } finally { + await Plan.close(context.plan); + } +}); + +test("a machine whose sharing is off takes no build even while a choice of it stands", async () => { + let context = await setup(); + try { + let { theirs } = await shared(context); + let connections = context.experiments.connections; + let connection = () => connections.get(theirs.connection.id)!; + connections.share(theirs.machine, false); + expect(connections.chosenShared("R_test", "U_test", context.plan.id)).toEqual([]); + let refused = await context.call(context.path, review); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("machine-unavailable"); + expect(context.plan.builds).toEqual([]); + // A build queued while it was shared is not taken while it is not, though the choice stands. + connections.share(theirs.machine, true); + let built = await (await context.call(context.path, review)).json(); + connections.share(theirs.machine, false); + expect(await context.experiments.mayTake(connection(), "U_test", context.plan.id)).toBe(false); + expect(await context.implementations.waiting(connection())).toBeUndefined(); + expect( + (await tool(context, theirs.token, "claim_implementation_build", { id: built.id })).isError, + ).toBe(true); + connections.share(theirs.machine, true); + expect(await context.experiments.mayTake(connection(), "U_test", context.plan.id)).toBe(true); + expect( + (await tool(context, theirs.token, "claim_implementation_build", { id: built.id })).build + .state, + ).toBe("starting"); + } finally { + await Plan.close(context.plan); + } +}); + +test("a shared machine is not the person's until they choose it", async () => { + let context = await setup(); + try { + let theirs = await paired(context, context.otherCookie); + await context.call( + `/api/connector/machines/${theirs.machine}/shared`, + { shared: true }, + undefined, + undefined, + context.otherCookie, + ); + let connections = context.experiments.connections; + expect(connections.requested("R_test", "U_test", context.plan.id)).toEqual([]); + let refused = await context.call(context.path, review); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); + expect(context.plan.builds).toEqual([]); + expect(connections.assigned(theirs.connection.id)).toBeUndefined(); + } finally { + await Plan.close(context.plan); + } +}); + +test("a machine that is not shared can't be chosen, so no build goes to it", async () => { + let context = await setup(); + try { + let theirs = await paired(context, context.otherCookie); + let refused = await context.call( + `/api/documents/${context.plan.id}/machine`, + { machine: theirs.machine }, + ); + expect(refused.status).toBe(404); + // With nothing of their own, the person has nothing to build on. + let built = await context.call(context.path, review); + expect(built.status).toBe(409); + expect((await built.json()).error).toBe("no-workspace"); + } finally { + await Plan.close(context.plan); + } +}); + +test("a person's rebuild goes to the shared machine they chose, and automatic work never does", async () => { + let context = await setup(); + context.plan.persistence.liveBuild = true; + try { + // Its owner allowed automatic work on it, which is for their own documents' builders only. + let { theirs } = await shared(context, false, true); + let built = await (await context.call(context.path, review)).json(); + let claim = await tool(context, theirs.token, "claim_implementation_build", { id: built.id }); + expect(claim.live).toBe(true); + await tool(context, theirs.token, "report_implementation_build", { + id: built.id, + state: "running", + session: "session", + }); + for ( + let [name, args] of [ + ["start_task", { taskId: "first", idempotencyKey: "start" }], + ["report_pr", { taskId: "first", idempotencyKey: "pr", url: pullRequest, state: "open" }], + ["complete_task", { taskId: "first", idempotencyKey: "done", summary: "Launched" }], + ] as const + ) expect((await tool(context, claim.runToken, name, args)).isError).toBeUndefined(); + await tool(context, theirs.token, "report_implementation_build", { + id: built.id, + state: "stopped", + }); + expect(context.plan.live).toMatchObject({ user: "U_test" }); + expect((await edit(context.plan, "A rebuilt paragraph.")).ok).toBe(true); + // Chopin's own rebuild runs only on its builder's own machines, whatever they chose. + let connections = context.experiments.connections; + expect((await queueRebuild(context.plan, connections, { automatic: true })).kind) + .toBe("unavailable"); + expect(context.plan.builds.at(-1)?.id).toBe(built.id); + // Nor does a person's rebuild, while sharing is off or they chose another machine. + connections.share(theirs.machine, false); + expect((await queueRebuild(context.plan, connections, { by: "U_test" })).kind) + .toBe("unavailable"); + connections.share(theirs.machine, true); + connections.choose(context.plan.id, "U_test", "another-machine"); + expect((await queueRebuild(context.plan, connections, { by: "U_test" })).kind) + .toBe("unavailable"); + connections.choose(context.plan.id, "U_test", theirs.machine); + expect(context.plan.builds.at(-1)?.id).toBe(built.id); + // Their press of Build runs it on the machine they chose. + let rebuilt = await context.call(`${context.path}/rebuild`, {}); + expect(rebuilt.status).toBe(200); + expect(await rebuilt.json()).toMatchObject({ + kind: "rebuild", + user: "U_test", + machineOwner: "U_other", + connectionId: theirs.connection.id, + }); + expect(await tool(context, theirs.token, "wait_for_work")).toMatchObject({ + kind: "implementation", + }); + let rebuild = await tool(context, theirs.token, "claim_implementation_build", { + id: context.plan.builds.at(-1)!.id, + }); + expect(rebuild.build).toMatchObject({ kind: "rebuild", state: "starting" }); + await tool(context, theirs.token, "report_implementation_build", { + id: rebuild.build.id, + documentId: rebuild.documentId, + state: "running", + session: "rebuild-session", + }); + let read = await tool(context, rebuild.runToken, "read_rebuild"); + expect(read.requestedBy).toMatchObject({ login: "test" }); + } finally { + await Plan.close(context.plan); + } +}); + test("an automatic rebuild follows the builder's machine choice", async () => { let { context, rebuild } = await rebuilding(true); try { diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index e8e3615aa..db15e1acf 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -1,6 +1,13 @@ import { documentPath } from "@chopin/protocol/document-url"; import { z } from "zod"; -import { liveSnapshot, queueBuild, queueRebuild, reportBuild, requestBuild } from "./builds"; +import { + liveSnapshot, + queueBuild, + queueRebuild, + reportBuild, + requestBuild, + runner, +} from "./builds"; import { implementationLifecycle } from "./lifecycle"; import { planningReadiness, reportImplementationLifecycle } from "./plan-graphs"; import { exclusive } from "../plan/service"; @@ -27,8 +34,19 @@ type Options = { liveBuild?: boolean; /** CHOPIN_BUILD_RESUME_MS: how long after a restart a build's machine may come back to it. */ resumeMs?: number; - /** Throws `machine-offline` naming the machine the person chose, else `no-workspace`. */ + /** + * Throws `machine-offline` naming the machine the person chose, `machine-unavailable` when it is + * a teammate's that no longer takes their work, else `no-workspace`. + */ refuseOffline?: (owner: string, documentId: string) => Promise; + /** + * The live connections that may take the work `owner` asks for on a document: their own, and + * the teammate's shared machine they chose while its owner can push to the repository. Without + * it, only their own. + */ + takers?: (owner: string, repositoryId: string, documentId: string) => Promise; + /** Whether a connection may take `person`'s work on a document now, as `takers` decides. */ + mayTake?: (connection: Connection, person: string, documentId: string) => Promise; /** Reads the stored machine choices, or throws `machine-choice-unavailable`. */ machineChoices?: () => Promise; /** @@ -77,8 +95,8 @@ async function body(request: Request, schema: z.ZodType): Promise { } /** - * The person's machine choice refuses the work: the machine they chose is offline (409), or their - * choice cannot be read right now (503). The message says which. + * The person's machine choice refuses the work: the machine they chose is offline or no longer + * takes their work (409), or their choice cannot be read right now (503). The message says which. */ class MachineRefused extends Error { constructor(readonly code: string, message: string, readonly status: number) { @@ -86,8 +104,14 @@ class MachineRefused extends Error { } } -/** A browser build runs on the clicker's own connection for the document's repository. */ +/** + * A browser build runs on the machine the clicker chose for the document: their own connection for + * its repository, or a teammate's shared machine whose owner can push there. + */ export function registerImplementationRoutes(router: Router, auth: HostedAuth, options: Options) { + let takers = options.takers + ?? (async (owner: string, repositoryId: string, id: string) => + options.connections.candidates(repositoryId, owner, id)); let connector = implementationConnector( options.withPlan, options.connections, @@ -101,7 +125,11 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o }, async id => !!await auth.storage.channels.get(id), options.liveBuild ? options.busy : undefined, - options.resumeMs === undefined ? {} : { resumeMs: options.resumeMs }, + { + ...options.resumeMs === undefined ? {} : { resumeMs: options.resumeMs }, + ...options.mayTake ? { mayTake: options.mayTake } : {}, + login: async id => (await auth.storage.users.get(id))?.login, + }, ); let safe = (handler: RouteHandler): RouteHandler => async (request, url, params) => { try { @@ -131,7 +159,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o return { session, channel, repository }; } /** - * The caller's live connections for a repository that can take a build for `id`: idle ones + * The live connections that can take the caller's build for `id`: idle ones * first, then those still running a prototype, then those building another document, which * queue the build until theirs finishes. Within each, the one last used for this document * first, then the most recently heard from. A build already waiting on this document does not @@ -141,7 +169,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o if (!options.liveBuild) { // Without LIVE_BUILD a busy connection is refused, never queued. let idle: Connection[] = []; - for (let connection of options.connections.candidates(repositoryId, owner, id)) { + for (let connection of await takers(owner, repositoryId, id)) { let here = options.connections.assigned(connection.id) === id; if (await options.busy?.(connection.id) || !here && await connector.busy(connection)) { continue; @@ -154,7 +182,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o let free: Connection[] = []; let prototyping: Connection[] = []; let building: Connection[] = []; - for (let connection of options.connections.candidates(repositoryId, owner, id)) { + for (let connection of await takers(owner, repositoryId, id)) { let here = options.connections.assigned(connection.id) === id || options.connections.queued(connection.id).includes(id); let list = !here && await connector.busy(connection) @@ -169,19 +197,23 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o } /** * Refuse a Build that nothing can take: `machine-offline` naming the machine the person chose - * for the document, never silently another, `machine-choice-unavailable` while that choice - * cannot be read, or else `no-workspace`. + * for the document, never silently another, `machine-unavailable` when it is a teammate's that + * no longer takes their work, `machine-choice-unavailable` while that choice cannot be read, or + * else `no-workspace`. */ async function unavailable(owner: string, repositoryId: string, id: string): Promise { try { await options.machineChoices?.(); - if (options.connections.candidates(repositoryId, owner, id).length) return; + if ((await takers(owner, repositoryId, id)).length) return; await options.refuseOffline?.(owner, id); } catch (error) { let code = (error as { code?: unknown } | undefined)?.code; - if (code === "machine-offline" || code === "machine-choice-unavailable") { + if ( + code === "machine-offline" || code === "machine-unavailable" + || code === "machine-choice-unavailable" + ) { let message = (error as Error).message; - throw new MachineRefused(code, message, code === "machine-offline" ? 409 : 503); + throw new MachineRefused(code, message, code === "machine-choice-unavailable" ? 503 : 409); } } throw new GitHubError("no-workspace", 409); @@ -193,7 +225,8 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o let { session, channel } = await browser(request, id); connector.track(id); // Cheap on purpose: whether one is free is decided when Build is pressed. - let localAgent = options.connections.list(channel.repositoryId, session.user.id).length > 0; + let localAgent = options.connections.list(channel.repositoryId, session.user.id).length > 0 + || options.connections.chosenShared(channel.repositoryId, session.user.id, id).length > 0; // Read before the plan lock; the experiment store has its own. let queued = options.liveBuild ? await options.withPlan(id, async plan => plan.builds.at(-1)) @@ -258,6 +291,9 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o if (!machine) continue; buildMachines[build.id] = { label: machine.label, + ...(build.machineOwner !== undefined + ? { owner: (await auth.storage.users.get(build.machineOwner))?.login } + : {}), service: build.machineService === true, // Only the starter learns whether their own machine is online. ...(build.user === session.user.id @@ -325,6 +361,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o let build = await queueBuild(plan, { ...input, user: session.user.id, + ...(connection.owner !== session.user.id ? { machineOwner: connection.owner } : {}), connectionId: connection.id, machineId: connection.machineId, ...(connection.about?.service ? { machineService: true as const } : {}), @@ -361,9 +398,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o await unavailable(session.user.id, channel.repositoryId, id); // Like Build: a connection running a prototype or another document's build is busy. let free: Connection[] = []; - for ( - let connection of options.connections.candidates(channel.repositoryId, session.user.id, id) - ) { + for (let connection of await takers(session.user.id, channel.repositoryId, id)) { let here = options.connections.assigned(connection.id) === id; if (await options.busy?.(connection.id) || !here && await connector.busy(connection)) { continue; @@ -381,11 +416,15 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o get: connectionId => idle.has(connectionId) ? options.connections.get(connectionId) : undefined, list: (repositoryId, owner) => - options.connections.list(repositoryId, owner).filter(item => idle.has(item.id)), + [ + ...options.connections.list(repositoryId, owner), + ...owner ? options.connections.chosenShared(repositoryId, owner, id) : [], + ].filter(item => idle.has(item.id)), assign: (connectionId, documentId) => options.connections.assign(connectionId, documentId), assigned: connectionId => options.connections.assigned(connectionId), - allows: (documentId, connection) => options.connections.allows(documentId, connection), + allows: (documentId, connection, person) => + options.connections.allows(documentId, connection, person), }, { by: session.user.id }), ); if (result.kind === "busy") throw new GitHubError("implementation is already active", 409); @@ -420,7 +459,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o } try { // A human interruption must not publish unfinished work as a delivered live build. - await reportBuild(plan, build.user, build.connectionId, build.id, { + await reportBuild(plan, runner(build), build.connectionId, build.id, { state: "failed", error: "Stopped in Chopin.", stoppedByUser: true, diff --git a/apps/server/src/testing/agent-link.ts b/apps/server/src/testing/agent-link.ts index 02decbfb7..3fec02bcb 100644 --- a/apps/server/src/testing/agent-link.ts +++ b/apps/server/src/testing/agent-link.ts @@ -13,7 +13,7 @@ import { MemoryStorage } from "../storage/memory/adapter"; import type { AgentClient, AgentHandlers, TurnContext } from "@chopin/agent-link/client"; import type { AddressInfo, Socket } from "node:net"; import type { ServerWebSocket } from "bun"; -import type { Repository } from "../github/client"; +import type { Repository, RepositoryPage } from "../github/client"; import type { AttachmentAuth } from "../harness/remote/attachments"; import type { AgentAuth, AgentSocketData } from "../harness/remote/endpoint"; import type { AgentLink } from "../harness/remote/link"; @@ -140,6 +140,47 @@ export async function linkAuth(channelId?: string) { ?? machineAccount(token); return account ? scoreRepository(owner, name, account.push) : undefined; }, + /** The head of octo-org/score's default branch, which a machine with no checkout clones. */ + async branchHead() { + return "b".repeat(40); + }, + /** The App is installed on octo-org for every account that has a browser login or a machine. */ + async installations(token: string) { + let account = Object.values(ACCOUNTS).find(value => loginToken(value.login) === token) + ?? machineAccount(token); + return { + installations: account + ? [{ + id: "1", + account: { login: "octo-org", avatarUrl: "", type: "organization" }, + repositorySelection: "all", + configureUrl: "https://github.test/octo-org", + suspended: false, + permissions: { + contents: true, + pullRequests: true, + checks: false, + statuses: false, + issues: false, + }, + }] + : [], + nextPage: undefined, + }; + }, + /** octo-org/score, with the role the account has there. */ + async installationRepositories( + token: string, + _installation?: string, + _page?: number, + ): Promise { + let account = Object.values(ACCOUNTS).find(value => loginToken(value.login) === token) + ?? machineAccount(token); + return { + repositories: account ? [scoreRepository("octo-org", "score", account.push)] : [], + nextPage: undefined, + }; + }, }, }; return { diff --git a/apps/web/src/build-start.test.ts b/apps/web/src/build-start.test.ts index c42eccdb8..6d48d8b6e 100644 --- a/apps/web/src/build-start.test.ts +++ b/apps/web/src/build-start.test.ts @@ -10,6 +10,8 @@ function refused(error: string, status: number, message?: string) { test("a refused Build says the server's sentence about the person's machine choice", async () => { await expect(refused("machine-offline", 409, "build-box is offline. Start chopin connect on it.")) .rejects.toThrow("build-box is offline. Start chopin connect on it."); + let unavailable = "build-box no longer takes this work: its owner stopped sharing it."; + await expect(refused("machine-unavailable", 409, unavailable)).rejects.toThrow(unavailable); let unreadable = "Can't read your machine choice right now. Try again."; await expect(refused("machine-choice-unavailable", 503, unreadable)).rejects.toThrow(unreadable); // Other refusals keep their code, which the Build plan button turns into copy. diff --git a/apps/web/src/build-start.ts b/apps/web/src/build-start.ts index 23be5eaf3..e82047488 100644 --- a/apps/web/src/build-start.ts +++ b/apps/web/src/build-start.ts @@ -9,8 +9,9 @@ export function implementationEndpoint(room: string): string { export async function implementationResponse(result: Response): Promise { let value = await result.json(); if (!result.ok) { - // A machine the person chose is offline, or their choice cannot be read: the server's sentence. - let sentence = value.error === "machine-offline" + // A machine the person chose is offline or no longer takes their work, or their choice + // cannot be read: the server's sentence. + let sentence = value.error === "machine-offline" || value.error === "machine-unavailable" || value.error === "machine-choice-unavailable"; let message = sentence && value.message ? value.message : value.error; throw new ApiError(message ?? "Build is unavailable", result.status); diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index 3fc466d83..88513ba8c 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -75,7 +75,11 @@ function MachineLabel({ machine }: { machine: BuildMachine }) { return ( {machine.connected === undefined ? ( diff --git a/apps/web/src/experiments/add-server.tsx b/apps/web/src/experiments/add-server.tsx index 9116dbe00..ab8b393bf 100644 --- a/apps/web/src/experiments/add-server.tsx +++ b/apps/web/src/experiments/add-server.tsx @@ -263,10 +263,12 @@ export function AddServerPage() { setShared(value); if (pairing.state !== "idle") reset(); }} - title="Plan teammates' documents" + title="Run teammates' work" > - Off: nobody else's work runs here. On: when a teammate's document has no machine of its - own, its Planner runs here as you, in repositories you can push to. Builds stay yours. + Off: nobody else's work runs here. On: teammates see this machine and can choose it for + a document, and that document's Planner, builds and runs then run here as you, with your + GitHub account, in repositories you can push to. Rebuilds and prototypes Chopin starts + by itself stay yours. diff --git a/apps/web/src/experiments/api.test.ts b/apps/web/src/experiments/api.test.ts index b274e72e3..131b220b7 100644 --- a/apps/web/src/experiments/api.test.ts +++ b/apps/web/src/experiments/api.test.ts @@ -17,6 +17,9 @@ test("a refused Run says the server's sentence about the person's machine choice let offline = "build-box is offline. Start chopin connect on it, or choose another machine."; answer(409, { error: "machine-offline", message: offline }); await expect(experimentRequest("/run", {})).rejects.toThrow(offline); + let unavailable = "build-box no longer takes this work: its owner stopped sharing it."; + answer(409, { error: "machine-unavailable", message: unavailable }); + await expect(experimentRequest("/run", {})).rejects.toThrow(unavailable); // Other refusals keep their code, which the card turns into copy. answer(409, { error: "workspace-busy" }); await expect(experimentRequest("/run", {})).rejects.toThrow("workspace-busy"); diff --git a/apps/web/src/experiments/api.ts b/apps/web/src/experiments/api.ts index ce3205304..f03ce2070 100644 --- a/apps/web/src/experiments/api.ts +++ b/apps/web/src/experiments/api.ts @@ -13,9 +13,9 @@ export async function experimentRequest( }); let result = await response.json(); if (!response.ok) { - // The machine the person chose is offline, or their choice cannot be read: the server's - // sentence says which. Work never moves to another machine. - let sentence = result.error === "machine-offline" + // The machine the person chose is offline or no longer takes their work, or their choice + // cannot be read: the server's sentence says which. Work never moves to another machine. + let sentence = result.error === "machine-offline" || result.error === "machine-unavailable" || result.error === "machine-choice-unavailable"; throw new Error( (sentence && result.message) || result.error || "Investigation request failed", diff --git a/apps/web/src/experiments/machine-list.test.ts b/apps/web/src/experiments/machine-list.test.ts index cb74de19e..0f3accd29 100644 --- a/apps/web/src/experiments/machine-list.test.ts +++ b/apps/web/src/experiments/machine-list.test.ts @@ -1,14 +1,18 @@ import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { finishingWords, groupMachines, machineMatches, machineState, platformName, + SHARE_DETAIL, + SharedWithYou, statusWords, } from "./machine-list"; -import type { Machine } from "../machine-summary"; +import type { Machine, TeammateMachine } from "../machine-summary"; const NOW = Date.UTC(2026, 9, 14, 12); const base: Machine = { @@ -87,3 +91,64 @@ test("a machine that stopped being shared says how many teammate documents it is expect(finishingWords({ finishing: 1 })).toBe("Finishing 1 teammate document"); expect(finishingWords({ finishing: 2 })).toBe("Finishing 2 teammate documents"); }); + +test("sharing a machine says teammates' Planners, builds and runs run on it, as its owner", () => { + for (let consent of ["Planner, builds and runs", "as you", "your GitHub account", "stay yours"]) { + expect(SHARE_DETAIL).toContain(consent); + } + expect(SHARE_DETAIL).not.toContain("No one else's builds"); +}); + +const theirs: TeammateMachine = { + id: "t1", + label: "build-box", + teammate: true, + owner: { login: "rival", avatarUrl: "https://example.test/rival.png" }, + repositories: ["octo-org/score", "octo-org/site", "octo-org/notes", "octo-org/docs"], + connected: true, + lastSeenAt: NOW, + version: "0.1.5", + service: true, +}; + +function shared(teammates: TeammateMachine[] | undefined) { + return renderToStaticMarkup( + createElement(SharedWithYou, { machines: { teammates } as never }), + ); +} + +test("teammates' machines are listed read-only under Shared with you: owner, state, version, repositories", () => { + let markup = shared([theirs]); + expect(markup).toContain("Shared with you"); + expect(markup).toContain(">build-box<"); + expect(markup).toContain("rival"); + expect(markup).toContain("chopin 0.1.5"); + expect(markup).toContain("Connected"); + // A few repositories by name, the rest counted. + expect(markup).toContain("octo-org/score"); + expect(markup).toContain("octo-org/site"); + expect(markup).not.toContain("octo-org/notes"); + expect(markup).toContain("2 more"); + // Grouped by kind, as the person's own are. + expect(markup).toContain(">Cloud<"); + // Nothing changes the machine: no star, switch, rename, disconnect or actions menu. + for ( + let managing of [ + "switch", + "Actions", + "favorite", + "Set as favorite", + "Disconnect", + "Edit machine", + ] + ) expect(markup).not.toContain(managing); + expect(markup).not.toContain(" { + // The table reads the clock itself, so the time is relative to it. + expect(shared([{ ...theirs, connected: false, lastSeenAt: Date.now() - 3 * 3_600_000 }])) + .toContain("Last seen 3h ago"); + expect(shared([])).toBe(""); + expect(shared(undefined)).toBe(""); +}); diff --git a/apps/web/src/experiments/machine-list.tsx b/apps/web/src/experiments/machine-list.tsx index b45d9e7dd..491549b69 100644 --- a/apps/web/src/experiments/machine-list.tsx +++ b/apps/web/src/experiments/machine-list.tsx @@ -41,18 +41,23 @@ import { import { pairCommand, UPDATE_COMMAND } from "./setup-commands"; import type { ReactNode } from "react"; -import type { Machine } from "../machine-summary"; +import type { Machine, TeammateMachine } from "../machine-summary"; import type { NavigationDialogMotion } from "../navigation-dialog"; import type { CopyItem, Owner, useMachines } from "./machines"; export type MachineState = "connected" | "offline" | "reauthorize"; -export function machineState(machine: Machine): MachineState { +export function machineState( + machine: Pick & { reauthorize?: boolean }, +): MachineState { return machine.reauthorize ? "reauthorize" : machine.connected ? "connected" : "offline"; } /** What the dot means, in words: the status line beside it, and the dot's own name where alone. */ -export function statusWords(machine: Machine, now: number): string { +export function statusWords( + machine: Pick & { reauthorize?: boolean }, + now: number, +): string { let state = machineState(machine); if (state === "reauthorize") return "Needs reconnecting"; if (state === "connected") return "Connected"; @@ -75,7 +80,9 @@ function agentName(agent: Machine["agent"]): string | undefined { * repository identifies it. */ /** Machines by kind, Local then Cloud, each in list order; a kind with none is left out. */ -export function groupMachines(list: Machine[]): { kind: "local" | "cloud"; machines: Machine[] }[] { +export function groupMachines>( + list: T[], +): { kind: "local" | "cloud"; machines: T[] }[] { let groups = [ { kind: "local" as const, machines: list.filter(machine => !machine.service) }, { kind: "cloud" as const, machines: list.filter(machine => machine.service) }, @@ -103,15 +110,17 @@ export function machineMatches(machine: Machine, query: string, login: string | ].some(value => value?.toLowerCase().includes(words)); } -/** The owner's GitHub login, after their avatar where GitHub has one. */ +/** The owner's GitHub login, after their avatar where GitHub has one and it loads. */ function OwnerName({ owner }: { owner: Owner }) { + let [broken, setBroken] = useState(false); return ( - {owner.avatarUrl && ( + {owner.avatarUrl && !broken && ( setBroken(true)} src={owner.avatarUrl} width={16} /> @@ -134,7 +143,12 @@ export function StatusDot({ label, state }: { label?: string; state: MachineStat } /** The dot, then the state in quiet words, as the Last seen column shows it. */ -function Status({ machine, now }: { machine: Machine; now: number }) { +function Status( + { machine, now }: { + machine: Pick & { reauthorize?: boolean }; + now: number; + }, +) { return ( @@ -154,9 +168,11 @@ function Dash({ label }: { label: string }) { /** What sharing a machine with teammates does, as the switch's tooltip and the phone row say. */ export const SHARE_DETAIL = - "A teammate's document with no machine of its own can run its Planner here, as you, " - + "in repositories you can push to. Builds stay yours. Turned off, a teammate's Planner " - + "finishes the reply, question or run it is on first."; + "Teammates see this machine under Shared with you and can choose it for a document. That " + + "document's Planner, builds and runs then run here, as you, with your GitHub account, in " + + "repositories you can push to; a Planner also runs here on Default machine when its creator " + + "has no machine of their own. Rebuilds and prototypes Chopin starts by itself stay yours. " + + "Turned off, a teammate's Planner or build finishes the work it is on first."; /** * Whether teammates' documents may plan on a machine: a switch, named for the machine, that @@ -487,6 +503,152 @@ function MachineTable( ); } +/** A teammate's repositories, a few by name and the rest counted. */ +const SHOWN_REPOSITORIES = 2; + +function TeammateRepositories({ machine }: { machine: TeammateMachine }) { + let shown = machine.repositories.slice(0, SHOWN_REPOSITORIES); + let more = machine.repositories.length - shown.length; + return ( + + {shown.map(name => {name})} + {more > 0 && {more} more} + + ); +} + +/** What a teammate's machine shows: no star, switch or actions, as only its owner changes it. */ +function TeammateTable({ labelledBy, list }: { labelledBy: string; list: TeammateMachine[] }) { + let now = Date.now(); + return ( + + + + Machine + Owner + Version + Repositories + Last seen + + + {groupMachines(list).map(group => ( + + + + + + {GROUP_LABEL[group.kind]} + + + + {group.machines.map(machine => ( + + + + + {machine.label} + + + + + + + + + + + {machine.version + ? chopin {machine.version} + : } + + + + + + + + + + ))} + + ))} +
+ ); +} + +/** A phone's teammates' rows: the dot before the name, who owns it, then its repositories. */ +function TeammateRows({ list }: { list: TeammateMachine[] }) { + let now = Date.now(); + return ( +
+ {groupMachines(list).map(group => ( +
+

+ + {GROUP_LABEL[group.kind]} +

+
    + {group.machines.map(machine => { + let parts = [machine.owner.login, machine.version && `chopin ${machine.version}`] + .filter(Boolean); + return ( +
  • +
    + + + + {machine.label} + + + {parts.map((part, index) => ( + + {index > 0 && " "} + {part} + + ))} + + + {machine.repositories.join(", ")} + + +
    +
  • + ); + })} +
+
+ ))} +
+ ); +} + +/** + * Machines teammates share with the person: a table, or on a phone rows, of each machine's name, + * owner, release, repositories and state. Nothing in it changes a machine; only its owner does, + * and the person chooses one for a document from that document's machine button. Nothing at all + * until a teammate shares one that serves a repository they can push to. + */ +export function SharedWithYou({ machines }: { machines: ReturnType }) { + let headingId = useId(); + let phone = usePhoneWidth(); + let list = machines.teammates; + if (!list?.length) return null; + return ( +
+
+

Shared with you

+

+ Machines your teammates share. Only their owners change them. +

+
+ {phone ? : } +
+ ); +} + /** Where the phone list was scrolled, so coming back from a machine finds it there. */ const SCROLL_KEY = "chopin:machines:scroll"; @@ -696,7 +858,7 @@ export function MachineDetail( [ machine.service && "stays connected", machine.autoRun && "builds automatically", - machine.shared && "plans teammates' documents", + machine.shared && "runs teammates' work", ].filter(Boolean).join(", "), ], ]; diff --git a/apps/web/src/experiments/machines.css b/apps/web/src/experiments/machines.css index f73f824fd..217a176b8 100644 --- a/apps/web/src/experiments/machines.css +++ b/apps/web/src/experiments/machines.css @@ -167,6 +167,13 @@ gap: calc(var(--spacing) * 3); } +/* The heading of the machines teammates share, and the line that says only their owners change them. */ +.machines-shared-heading { + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 0.5); +} + /* The pairing confirmation and the server's steps sit on the page surface, raised off the ground. */ .machines-steps, .machines-card { diff --git a/apps/web/src/experiments/machines.tsx b/apps/web/src/experiments/machines.tsx index 5f5d244e4..1f731071a 100644 --- a/apps/web/src/experiments/machines.tsx +++ b/apps/web/src/experiments/machines.tsx @@ -29,12 +29,13 @@ import type { Machine } from "../machine-summary"; import type { NavigationDialogMotion } from "../navigation-dialog"; /** - * The signed-in person's machines, asked for again every few seconds while the page is in view. - * `machines` is undefined until the first answer; `error` is the server's code for the last - * failed answer, which the page turns into words. The sidebar's count shares the same list. + * The signed-in person's machines, and the teammates' machines shared with them, asked for again + * every few seconds while the page is in view. `machines` is undefined until the first answer; + * `error` is the server's code for the last failed answer, which the page turns into words. The + * sidebar's count shares the same list. */ export function useMachines() { - let { machines, error } = useLiveMachines(true); + let { machines, teammates, error } = useLiveMachines(true); let [recommended, setRecommended] = useState(null); useEffect(() => { let abort = new AbortController(); @@ -70,7 +71,7 @@ export function useMachines() { void liveMachines.load(); } }; - return { machines, error, recommended, forget, retry, favor, share }; + return { machines, teammates, error, recommended, forget, retry, favor, share }; } /** diff --git a/apps/web/src/experiments/pairing-key.ts b/apps/web/src/experiments/pairing-key.ts index 57f6b5c7c..c3ac3efb8 100644 --- a/apps/web/src/experiments/pairing-key.ts +++ b/apps/web/src/experiments/pairing-key.ts @@ -13,7 +13,7 @@ export function expiryLabel(expiresAt: number, now: number): string { return left > 0 ? `Works once and expires in ${Math.ceil(left / 60_000)} min` : "Expired"; } -/** What the machine a key pairs may do: build on its own, and plan teammates' documents. */ +/** What the machine a key pairs may do: build on its own, and run teammates' work. */ export type KeyChoices = { autoRun: boolean; shared: boolean }; export type PairingKey = diff --git a/apps/web/src/experiments/pairing.tsx b/apps/web/src/experiments/pairing.tsx index 31bed3df5..04ab5d472 100644 --- a/apps/web/src/experiments/pairing.tsx +++ b/apps/web/src/experiments/pairing.tsx @@ -6,7 +6,7 @@ import { motionImmediately } from "../motion-input"; import { AddServerPage } from "./add-server"; import { experimentRequest } from "./api"; import { AddComputerDialog, AddMachineMenu } from "./machine-setup"; -import { MachineDetail, YourMachines } from "./machine-list"; +import { MachineDetail, SharedWithYou, YourMachines } from "./machine-list"; import { goTo, useArrivedMachine, useMachines, useOwner } from "./machines"; import { pairingProblem } from "./pairing-problems"; import { SETUP_GUIDE } from "./setup-commands"; @@ -129,6 +129,7 @@ function MachinesHome({ onServer }: { onServer: () => void }) { )} + {dialog.phase !== "closed" && ( setShared(event.target.checked)} type="checkbox" /> - Let teammates' documents plan on this machine + Let teammates run their work on this machine

- Off: nobody else's work runs here. On: when a teammate's document has no machine - of its own, its Planner runs here as you, in repositories you can push to. - Builds stay yours. Change it any time on Machines. + Off: nobody else's work runs here. On: teammates see this machine and can choose + it for a document, and that document's Planner, builds and runs then run here as + you, with your GitHub account, in repositories you can push to. Rebuilds and + prototypes Chopin starts by itself stay yours. Change it any time on Machines.

diff --git a/apps/web/src/machine-button.tsx b/apps/web/src/machine-button.tsx index 2bda2a9a0..e762713df 100644 --- a/apps/web/src/machine-button.tsx +++ b/apps/web/src/machine-button.tsx @@ -69,7 +69,8 @@ function RowContent({ row }: { row: MachineRow }) { /** * Which machine runs this document's Planner and builds: an icon with its state dot, the chosen * machine's, or the one Automatic uses now. Its menu lists the person's machines serving the - * repository under Local and Cloud, offline ones disabled, and Add machine. The sidebar's + * repository under Local and Cloud, then each teammate's shared machine under its owner, offline + * ones disabled, and Add machine. The sidebar's * Machines menu sets the default. Every button for a document shares one `choice`. A phone opens * the same rows as a sheet from the bottom edge. */ @@ -86,6 +87,7 @@ export function MachineButton( let state = choice.state; let shown = shownMachine(state); let rows = state ? machineRows(state) : []; + let owners = [...new Set(rows.flatMap(row => row.owner ? [row.owner] : []))]; let close = (restoreFocus: boolean) => { setOpen(false); if (restoreFocus) trigger.current?.focus(); @@ -209,8 +211,11 @@ export function MachineButton( ); let body = (sheet: boolean) => ( <> - {group("Local", rows.filter(row => !row.cloud), sheet)} - {group("Cloud", rows.filter(row => row.cloud), sheet)} + {group("Local", rows.filter(row => !row.cloud && !row.owner), sheet)} + {group("Cloud", rows.filter(row => row.cloud && !row.owner), sheet)} + {owners.map(owner => + group(`Shared by ${owner}`, rows.filter(row => row.owner === owner), sheet) + )} {rows.length > 0 &&
} {addItem(sheet)} diff --git a/apps/web/src/machine-choice.test.ts b/apps/web/src/machine-choice.test.ts index 965917fbb..41487c8eb 100644 --- a/apps/web/src/machine-choice.test.ts +++ b/apps/web/src/machine-choice.test.ts @@ -11,7 +11,7 @@ import { } from "./machine-choice"; import type { ChoosableMachine, MachineChoiceState } from "./machine-choice"; -import type { Machine } from "./machine-summary"; +import type { Machine, TeammateMachine } from "./machine-summary"; const NOW = Date.UTC(2026, 9, 14, 12); @@ -115,3 +115,76 @@ test("the machine choice shows only once the person has a paired machine, connec expect(showsMachineChoice([paired])).toBe(true); expect(showsMachineChoice([paired, { ...paired, id: "M_two", connected: true }])).toBe(true); }); + +const theirs = { login: "rival", avatarUrl: "" }; + +function teammateMachine(overrides: Partial = {}): ChoosableMachine { + return { + id: "M_theirs", + label: "desk", + teammate: true, + owner: theirs, + connected: true, + lastSeenAt: NOW, + version: "0.1.5", + service: false, + runsPlanners: true, + ...overrides, + }; +} + +test("the menu lists a teammate's machines last, each under its owner, offline ones disabled", () => { + let rows = machineRows( + state({ + machines: [ + teammateMachine({ id: "M_a", label: "desk" }), + machine(), + teammateMachine({ id: "M_b", label: "rack", connected: false, service: true }), + machine({ id: "M_laptop", label: "laptop", service: false }), + teammateMachine({ id: "M_c", label: "studio", owner: { login: "casey", avatarUrl: "" } }), + teammateMachine({ id: "M_d", label: "lab", owner: theirs }), + ], + }), + ); + expect(rows.map(row => [row.id, row.owner, row.disabled])).toEqual([ + ["M_laptop", undefined, false], + ["M_box", undefined, false], + ["M_a", "rival", false], + ["M_b", "rival", true], + ["M_d", "rival", false], + ["M_c", "casey", false], + ]); + // A teammate's machine says nothing of its owner's authorization, and is disabled only offline. + expect(rows.find(row => row.id === "M_b")?.detail).toBe("Offline"); +}); + +test("a teammate's chosen machine is shown, and Build runs there unless it is offline", () => { + let chosen = state({ choice: "M_theirs", machines: [machine(), teammateMachine()] }); + expect(machineButtonLabel(chosen)).toBe("Runs on desk"); + expect(machineRows(chosen).find(row => row.checked)?.id).toBe("M_theirs"); + // Online, it takes the person's builds like one of their own: nothing greys Build plan out. + expect(offlineChoice(chosen)).toBeUndefined(); + // Offline, it says so, as one of their own does. + expect( + offlineChoice( + state({ choice: "M_theirs", machines: [teammateMachine({ connected: false })] }), + ), + ).toBe("desk is offline"); + expect(offlineChoice(state({ choice: "M_box" }))).toBeUndefined(); +}); + +test("a teammate's shared machine alone is enough to offer the machine choice", () => { + expect(showsMachineChoice([], state({ machines: [teammateMachine()] }))).toBe(true); + expect(showsMachineChoice(undefined, state({ machines: [teammateMachine()] }))).toBe(true); + expect(showsMachineChoice([], state({ machines: [] }))).toBe(false); + expect(showsMachineChoice(undefined, undefined)).toBe(false); +}); + +test("the choice is read again when a teammate's shared machine goes offline or comes back", () => { + let box = { id: "m1", connected: true, reauthorize: false } as Machine; + let desk = { id: "t1", connected: true } as TeammateMachine; + expect(presence([box], [desk])).not.toBe(presence([box], [{ ...desk, connected: false }])); + expect(presence([box], [desk])).not.toBe(presence([box], [])); + expect(presence([box], [desk])).toBe(presence([box], [{ ...desk, label: "renamed" }])); + expect(presence([box])).toBe(presence([box], [])); +}); diff --git a/apps/web/src/machine-choice.ts b/apps/web/src/machine-choice.ts index 525a89079..679d7b7a9 100644 --- a/apps/web/src/machine-choice.ts +++ b/apps/web/src/machine-choice.ts @@ -2,27 +2,33 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { machines as liveMachines } from "./machine-summary"; -import type { Machine } from "./machine-summary"; +import type { Machine, TeammateMachine } from "./machine-summary"; -/** One of the person's machines serving a document's repository, as the server lists it. */ +/** + * A machine serving a document's repository, as the server lists it: one of the person's own, or + * a teammate's that its owner shares and can push to the repository from. + */ export type ChoosableMachine = { id: string; label: string; /** It has a live connection for this repository now. */ connected: boolean; - /** GitHub stopped accepting its authorization; it needs pairing again. */ - reauthorize: boolean; + /** GitHub stopped accepting its authorization; it needs pairing again. Only the person's own say. */ + reauthorize?: boolean; lastSeenAt: number; version: string | null; - hostname: string | null; - platform: { os: string; arch: string } | null; - agent: { name: string; version?: string } | null; + hostname?: string | null; + platform?: { os: string; arch: string } | null; + agent?: { name: string; version?: string } | null; /** The background service runs it. */ service: boolean; /** It can run Planners. */ - planner: boolean; + planner?: boolean; /** It has a Planner command, so it can run the Planner of a document it is chosen for. */ runsPlanners?: boolean; + /** A teammate's machine: its owner shares it, and only they change it. */ + teammate?: boolean; + owner?: { login: string; avatarUrl: string }; }; /** @@ -81,15 +87,18 @@ function automaticLabel(state: MachineChoiceState) { } /** - * Whether a document offers the machine choice: its button left of Build plan. Only once the - * person has added a machine, connected or not, and whatever repositories its checkouts are of; - * before that the Machines page is the way to add one. + * Whether a document offers the machine choice: its button left of Build plan. Once the person + * has added a machine, connected or not, and whatever repositories its checkouts are of, or the + * document lists a teammate's shared machine; before that the Machines page is the way to add one. */ -export function showsMachineChoice(machines: readonly Pick[] | undefined): boolean { - return !!machines?.length; +export function showsMachineChoice( + machines: readonly Pick[] | undefined, + state?: MachineChoiceState, +): boolean { + return !!machines?.length || !!state?.machines.some(machine => machine.teammate); } -/** Why Build can't start: the chosen machine is offline, named. Automatic never is. */ +/** Why Build can't start: the chosen machine, the person's or a teammate's, is offline. Named. Automatic never is. */ export function offlineChoice(state: MachineChoiceState | undefined): string | undefined { let chosen = chosenMachine(state); return chosen && !chosen.connected ? `${chosen.label} is offline` : undefined; @@ -118,10 +127,15 @@ export type MachineRow = { disabled: boolean; /** The machine the background service runs, which the menu lists under Cloud. */ cloud: boolean; + /** The login of the teammate whose machine it is, which the menu lists it under; else undefined. */ + owner?: string; machine: ChoosableMachine; }; -/** The menu's rows, one per machine: Local ones first, then Cloud, offline ones disabled. */ +/** + * The menu's rows, one per machine: the person's Local ones first, then Cloud, then each teammate's + * under their login, offline ones disabled. + */ export function machineRows(state: MachineChoiceState): MachineRow[] { let shown = shownMachine(state); let rows = state.machines.map(machine => ({ @@ -129,11 +143,20 @@ export function machineRows(state: MachineChoiceState): MachineRow[] { label: machine.label, detail: STATE_WORDS[machineState(machine)], checked: shown?.id === machine.id, - disabled: !machine.connected || machine.reauthorize, + disabled: !machine.connected || !!machine.reauthorize, cloud: machine.service, + ...(machine.teammate ? { owner: machine.owner?.login ?? "a teammate" } : {}), machine, })); - return [...rows.filter(row => !row.cloud), ...rows.filter(row => row.cloud)]; + let own = rows.filter(row => !row.owner); + let theirs = rows.filter(row => row.owner); + return [ + ...own.filter(row => !row.cloud), + ...own.filter(row => row.cloud), + ...[...new Set(theirs.map(row => row.owner))].flatMap(owner => + theirs.filter(row => row.owner === owner) + ), + ]; } function endpoint(room: string) { @@ -149,10 +172,18 @@ async function answer(response: Response): Promise { /** How often the choice is read again while the document is open, so states stay current. */ const REFRESH_MS = 15_000; -/** What of the person's machines a document's choice depends on: which are paired and online. */ -export function presence(list: readonly Machine[] | undefined): string | undefined { - return list?.map(machine => `${machine.id}:${machine.connected}:${machine.reauthorize}`) - .sort().join(" "); +/** + * What of the machines a document's choice depends on: which of the person's are paired and online, + * and which teammates' shared machines are. + */ +export function presence( + list: readonly Machine[] | undefined, + teammates: readonly TeammateMachine[] = [], +): string | undefined { + return list && [ + ...list.map(machine => `${machine.id}:${machine.connected}:${machine.reauthorize}`), + ...teammates.map(machine => `${machine.id}:${machine.connected}:shared`), + ].sort().join(" "); } export type MachineChoice = { @@ -225,9 +256,10 @@ export function useMachineChoice(room: string, enabled: boolean): MachineChoice // A machine going offline or coming back changes what the buttons and Build plan show. useEffect(() => { if (!enabled) return; - let seen = presence(liveMachines.snapshot().machines); + let seen = presence(liveMachines.snapshot().machines, liveMachines.snapshot().teammates); let unwatch = liveMachines.watch(() => { - let now = presence(liveMachines.snapshot().machines); + let { machines, teammates } = liveMachines.snapshot(); + let now = presence(machines, teammates); if (now === undefined || now === seen) return; let changed = seen !== undefined; seen = now; diff --git a/apps/web/src/machine-summary.test.ts b/apps/web/src/machine-summary.test.ts index ee336e6bb..b34656503 100644 --- a/apps/web/src/machine-summary.test.ts +++ b/apps/web/src/machine-summary.test.ts @@ -1,6 +1,8 @@ import { expect, test } from "bun:test"; import { machineFeed, machinesLabel, summarizeMachines, updateAvailable } from "./machine-summary"; +import type { TeammateMachine } from "./machine-summary"; + let machine = (connected: boolean) => ({ connected }); test("the summary counts paired machines and those connected now", () => { @@ -115,10 +117,38 @@ test("a failed answer keeps the list it had and says why; a hidden page asks not await feed.load(); fail = true; await feed.load(); - expect(feed.snapshot()).toEqual({ machines: [machineOf("box", true)], error: "unavailable" }); + expect(feed.snapshot()).toEqual({ + machines: [machineOf("box", true)], + teammates: [], + error: "unavailable", + }); visible = false; await feed.load(); expect(answers).toBe(2); feed.update(list => list.filter(machine => machine.id !== "box")); expect(feed.snapshot().machines).toEqual([]); }); + +test("the feed keeps teammates' shared machines apart from the person's own", async () => { + let shared: TeammateMachine = { + id: "theirs", + label: "build-box", + teammate: true, + owner: { login: "rival", avatarUrl: "" }, + repositories: ["octo-org/score"], + connected: true, + lastSeenAt: 1, + version: null, + service: false, + }; + let feed = machineFeed(async () => [machineOf("mine", true), shared]); + await feed.load(); + expect(feed.snapshot().machines?.map(machine => machine.id)).toEqual(["mine"]); + expect(feed.snapshot().teammates).toEqual([shared]); + // Counts, the favorite star and the sidebar read the person's own list alone. + expect(summarizeMachines(feed.snapshot().machines!)).toEqual({ paired: 1, connected: 1 }); + // A change to the person's own list leaves the teammates' alone. + feed.update(list => list.filter(machine => machine.id !== "mine")); + expect(feed.snapshot().machines).toEqual([]); + expect(feed.snapshot().teammates).toEqual([shared]); +}); diff --git a/apps/web/src/machine-summary.ts b/apps/web/src/machine-summary.ts index 1427b52cd..4475ccd90 100644 --- a/apps/web/src/machine-summary.ts +++ b/apps/web/src/machine-summary.ts @@ -28,9 +28,30 @@ export type Machine = { finishing?: number; }; +/** + * A teammate's machine shared with the person, as `GET /api/connector/machines` lists it: read-only, + * so without what only its owner sees or changes. + */ +export type TeammateMachine = { + id: string; + label: string; + teammate: true; + owner: { login: string; avatarUrl: string }; + /** The repositories it serves the person in: ones its owner and the person can both push to. */ + repositories: string[]; + connected: boolean; + lastSeenAt: number; + /** The chopin release it runs, known only while it is connected with a release build. */ + version: string | null; + /** The background service runs it. */ + service: boolean; +}; + export type MachineSummary = { paired: number; connected: number }; -export async function listMachines(signal?: AbortSignal): Promise { +export async function listMachines( + signal?: AbortSignal, +): Promise> { let response = await fetch("/api/connector/machines", { signal }); let result = await response.json(); if (!response.ok) throw new Error(result.error ?? "Could not load your machines"); @@ -75,24 +96,34 @@ export function updateAvailable(version: string | null, recommended: string | nu return current.prerelease && !wanted.prerelease; } +function isTeammate(machine: Machine | TeammateMachine): machine is TeammateMachine { + return "teammate" in machine && machine.teammate === true; +} + +function isOwn(machine: Machine | TeammateMachine): machine is Machine { + return !isTeammate(machine); +} + /** How often the list is asked again while a document or the Machines page is in view. */ export const LIVE_MS = 3000; export type LiveMachines = { - /** Undefined until the first answer. */ + /** The person's own machines; undefined until the first answer. */ machines?: Machine[]; + /** Teammates' machines shared with them; undefined until the first answer. */ + teammates?: TeammateMachine[]; /** The server's code for the last failed answer; empty after a good one. */ error: string; }; /** - * One copy of the person's machine list for the whole page, so the sidebar's count, a document's - * machine buttons and the Machines page agree. `watch` hears every answer. While anything `poll`s, + * One copy of the person's machine list, and the teammates' machines shared with them, for the + * whole page, so the sidebar's count, a document's machine buttons and the Machines page agree. `watch` hears every answer. While anything `poll`s, * the list is asked again every `interval` ms the page is visible, so a machine going offline or * coming back shows within seconds; otherwise it is asked on `load` alone. */ export function machineFeed( - list: (signal: AbortSignal) => Promise, + list: (signal: AbortSignal) => Promise>, { interval = LIVE_MS, visible = () => true }: { interval?: number; visible?: () => boolean } = {}, ) { let current: LiveMachines = { error: "" }; @@ -114,9 +145,13 @@ export function machineFeed( abort?.abort(); let mine = abort = new AbortController(); try { - let machines = await list(mine.signal); + let listed = await list(mine.signal); if (mine.signal.aborted) return; - set({ machines, error: "" }); + set({ + machines: listed.filter(isOwn), + teammates: listed.filter(isTeammate), + error: "", + }); } catch (error) { if (mine.signal.aborted) return; set({ ...current, error: error instanceof Error ? error.message : "unavailable" }); diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 50e1bfdea..9812d3769 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -718,9 +718,9 @@ export function RoomWorkspace( room, profile.implementation && !!effectiveCanEdit && !(archivedAt ?? metadata.archivedAt), ); - // The choice shows only while the person has a paired machine, live: it appears with their - // first and goes with their last. - let ownsMachine = showsMachineChoice(useLiveMachines(true).machines); + // The choice shows only while the person has a paired machine, live, or the document lists a + // teammate's shared one: it appears with their first and goes with their last. + let ownsMachine = showsMachineChoice(useLiveMachines(true).machines, machineChoice.state); // Refresh the machine choice when the Planner moves to another machine. let refreshMachine = machineChoice.refresh; useEffect(() => { diff --git a/docs/agent-connection.md b/docs/agent-connection.md index 31cc964e8..c4eeb2a22 100644 --- a/docs/agent-connection.md +++ b/docs/agent-connection.md @@ -1136,7 +1136,13 @@ forgets it as it releases every attachment. Attached this way, the document's Planner owner is a login of the machine's owner that no browser holds, whose GitHub token is the machine's own; Chopin's -repository tools act with it. +repository tools act with it. That holds for a teammate's document too, when the +machine's owner shares it and someone chose it (or Default machine gave it): the +Planner runs as the owner. So do the builds and runs that person starts on the +machine, which the connector claims with the same machine token; Chopin records +the person as who asked, and the build's agent is told to name them in each +pull request (see +[Share a machine with teammates](chopin-cli.md#share-a-machine-with-teammates)). ### What the backend must do diff --git a/docs/chopin-cli.md b/docs/chopin-cli.md index 676a9908a..7128051c0 100644 --- a/docs/chopin-cli.md +++ b/docs/chopin-cli.md @@ -238,8 +238,10 @@ choice is yours alone and holds for that document until you change it: - Where Chopin runs Planners on machines, the document's Planner runs there too, and on **Default machine** it runs on the creator's; see [Run a Planner on this machine](#run-a-planner-on-this-machine). -- You can choose only your own machines. A teammate's machine can run the - document's Planner only on **Default machine**; see +- The list also shows machines teammates share, under **Shared by** and their + name, when they serve the document's repository and their owner can push to + it. Anyone who can edit the document may choose one, and its Planner, your + builds and your runs then run there, as its owner; see [Share a machine with teammates](#share-a-machine-with-teammates). - If the machine you chose is offline, **Build plan** is greyed out, as offline machines are in the list, and starts nothing. It changes within a few seconds @@ -374,7 +376,11 @@ about itself: its hostname, its chopin release and system, the coding agent it runs, and whether it is connected (a green dot) or when it was last seen. Search finds a machine by any of these. **Edit machine name…** in a machine's menu renames it in Chopin, and `chopin status` shows that name. Only you see -your machines. +your machines, except one you share with teammates: they see its name, its +owner, its chopin release, whether it is online and the repositories you both +can push to, read-only, under **Shared with you**. A teammate who can push to a +repository sees it there without having opened a document in it; see +[Share a machine with teammates](#share-a-machine-with-teammates). On the machine: @@ -490,29 +496,101 @@ an `@chopin` message sent meanwhile waits for it. #### Share a machine with teammates -A teammate who has no machine of their own can still get a Planner on a -document they create, on a machine someone shared with teammates. Sharing is -off unless its owner turns it on: with **Plan teammates' documents** as they -add the machine (**Let teammates' documents plan on this machine** on the -pairing card), or later with the machine's **Shared** switch on the -**Machines** page (**Share with teammates** in its view on a phone). - -- Only a document left on **Default machine** whose creator has no connected - machine that can run Planners uses a shared one. A document whose creator - chose a machine never does, and nobody's builds, runs or investigations go - to a teammate's machine. -- The machine runs the Planner as its owner, with the owner's GitHub - authorization, so only in repositories its owner can push to. Chopin checks - that with the machine's own token before it asks, and again every 10 minutes. +Sharing a machine lets teammates run their work on it. It is off unless its +owner turns it on: with **Run teammates' work** as they add the machine +(**Let teammates run their work on this machine** on the pairing card), or later +with the machine's **Shared** switch on the **Machines** page (**Share with +teammates** in its view on a phone). Turning it on is the owner's consent to +three things for a document whose teammate chose the machine, in repositories +the owner can push to: + +- its **Planner** runs there, as the owner, with the owner's GitHub + authorization; on **Default machine** it also does so for a document whose + creator has no machine of their own that can run Planners; +- **Build plan**, **Build**, **Retry**, **Run** and investigations the teammate + starts run there, behind any work the machine is already running. The agent + runs on the owner's machine with the owner's GitHub account, so its pull + requests are opened by the owner, not the teammate. Chopin records the + teammate as the person who asked, shows their name on the build, and tells the + agent to end each pull request body with a note naming who requested it; +- nothing automatic: rebuilds and prototypes Chopin starts by itself still run + only on the machine of the person whose living document they are, and only + where that machine's owner ticked automatic work. They never run on a + teammate's machine, chosen or not. + +A build there is claimed and reported only by the machine it was queued on. It +is queued only while the machine is shared, its owner can push to the +repository, and the teammate has chosen it; it is not taken once any of those +stops being true, and ends within a few seconds saying so. A build already +running keeps going until it finishes, like a Planner mid-reply. + +- A teammate sees a shared machine, read-only, under **Shared with you** on + **Machines**: its name, its owner, whether it is online, its chopin release + and the repositories it serves them in. It serves every repository its owner + can push to, so a machine with no checkouts counts too. A machine that is not + shared, or whose owner can't push to a repository, is never listed to anyone + who works there; nobody but its owner can rename it, star it, change its + sharing or disconnect it. For a machine that serves every repository, the page + intersects two lists of what can be pushed to among GitHub App installations: + the teammate's own, held a minute, and its owner's, listed with the machine's + own token and held 10 minutes for everyone who looks (a minute after a failure). + Neither list is cut short, and nothing is asked of GitHub for each repository, + so the page's refresh every few seconds, and any number of teammates, cost the + owner's GitHub rate limit a few listings every 10 minutes. A teammate need not + have opened a document in a repository first. For the repositories a machine + has checkouts of, each is asked on its own a few at a time and held as the + owner's check is (below): the teammate's own check, for a repository their + installations don't list, is made with their own login and kept 10 minutes for a + pass and a minute for a refusal. It is listed wherever Chopin has machines, + with or without Planners on machines; under `AUTH_MODE=local`, where machines + hold no GitHub authorization of their own, only while its owner is signed in. +- A document's **Machine** list offers it the same way, under **Shared by** and + its owner, to anyone who can edit the document and push to its repository, and + online or offline like your own. Choosing it sends your builds and runs there, + and that document's Planner too where the rules below allow. Chopin checks + with the machine's own token that its owner can push to the repository before + it lists or asks it, and again every 10 minutes. If it is offline, **Build + plan** is greyed out and says so, as for your own; if its owner stopped + sharing it or can't push any more, a build or run fails saying that, and never + moves to another machine. +- The document's creator's choice still decides where its Planner runs. Another + editor's choice of a machine places it only while the creator has chosen no + machine, and only on a machine that can run it for them now: online, started + with `--planner-command`, and (for a teammate's) its owner able to push. Then + it takes the Planner from Default machine's machine: a Planner attached there + stops, one that was only asked is withdrawn, and only the chosen machine is + asked. The choice is stored, so it still holds after Chopin restarts, and + Default machine is not asked for the document while a stored choice places it. + A choice of a machine that can't run the Planner now (offline, started without + a Planner command, or its owner can no longer push) is stored and stops + nothing: the Planner keeps running where it is, a reply under way included. + The chosen machine takes it once it can run it: at once when no Planner is + attached on Default machine, otherwise once that one has ended. A machine + coming online never interrupts an attached Planner. Choosing another machine + replaces your earlier choice, so a Planner on the machine you chose before + stops. When the creator chooses their own machine, or Default machine, the + Planner on an editor's machine stops and the editor's choice that placed it + goes, even if that machine had not been asked yet. An editor's choice made + while the creator's own stood is only stored, for their own builds and runs, + and counts again once the creator chooses no machine. Builds and runs follow + each person's own choice, whoever created the document. +- A document left on **Default machine** whose creator has no connected + machine that can run Planners uses a shared one too. Builds, runs and + investigations never go to a teammate's machine unless the person chose it. - With several shared machines, each owner's default goes first. - Once the creator connects a machine of their own, the Planner moves there, unless it is already attached on the shared machine; then it stays until it stops. -- Turning sharing off takes no new teammate documents. A teammate's Planner - that is idle, or waits only on a paused run, stops at once; one that is - replying, has a question open, or has a live run keeps going until that work - finishes, then stops within 15 seconds. Meanwhile the machine's row on - **Machines** says **Finishing 1 teammate document**. Each stop prints why: +- Turning sharing off takes the machine off teammates' lists and clears their + choices of it, and takes no new teammate work. A build still waiting on it + ends. A document whose Planner the machine was not running, had not been asked + to run and was not about to run, and that nobody is using, only loses the + choice: nothing starts for it on the creator's machine. A teammate's Planner + that is idle, or waits only on a paused run, stops + at once; one that is replying, has a question open, or has a live run keeps + going until that work finishes, then stops within 15 seconds. A build or run + already going finishes. Meanwhile the machine's row on **Machines** says + **Finishing 1 teammate document**. Each stop prints why: ` stopped sharing with teammates`. A paused run stays on the machine and resumes if its owner shares it again. diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index a32e8b4ff..4dbf8a411 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -468,7 +468,11 @@ Planner backend command it starts per document access is checked with the machine's own GitHub authorization, and it owns the document through a delegated login of the machine's owner that no browser holds (`apps/server/src/harness/remote/machine-planners.ts`), so no browser sign-in is -needed and signing out does not release it. Revoking the machine, or GitHub +needed and signing out does not release it. A machine its owner shares with +teammates does the same for a teammate's document as its owner; the same sharing +is the owner's consent for that teammate's builds and runs to run on the machine +too, as the owner (see +[Share a machine with teammates](chopin-cli.md#share-a-machine-with-teammates)). Revoking the machine, or GitHub refusing its authorization, ends that login and closes its connections with `access-revoked`. diff --git a/docs/implementation-lifecycle.md b/docs/implementation-lifecycle.md index f1a2113e5..45c8eadb4 100644 --- a/docs/implementation-lifecycle.md +++ b/docs/implementation-lifecycle.md @@ -5,7 +5,8 @@ to an external coding agent. It persists a versioned dependency graph, lets a coding agent claim an approved version, records task and pull-request progress, and requires graph-wide verification before releasing a successful run. The ordinary MCP read-before-claim path accepts MCP-created documents. Browser plans -run on the clicker's own ACP connector, which serves every repository they can push to, and its run-scoped +run on the ACP connector of the machine the clicker chose, their own (which serves every repository they can +push to) or a teammate's shared machine, and its run-scoped bridge; see the [local implementation tracer](local-launcher.md). @@ -20,7 +21,8 @@ implementation, automatic restart and an installer remain future work. `read_implementation_graph` and `edit_implementation_graph`. - A **person** is the only actor allowed by the domain model to approve a draft. The header's **Build plan** press approves the exact drafted - graph and queues it, with the clicker's own connection and checkout, in one + graph and queues it, with the connection and checkout of the machine the clicker + chose (their own, or a teammate's shared one, recorded as `machineOwner`), in one durable commit. - The **local connector** creates an exact-commit worktree and ACP session, then claims a browser-approved graph before prompting the orchestrator. Its scoped diff --git a/docs/local-experiments.md b/docs/local-experiments.md index 9af8c6946..3ef0780b5 100644 --- a/docs/local-experiments.md +++ b/docs/local-experiments.md @@ -41,7 +41,8 @@ On any open line of the document, type `/` and choose **Investigate**, next to `` card where the caret was, marked **Ready to run**. The document holds only that id; the investigation record owns the brief, state and result. Select **Run**: -the work runs on your own local agent, never on another person's. Chopin +the work runs on your own local agent, or on a teammate's shared machine if you +chose one for the document, never on another machine by itself. Chopin prefers the connection that last ran work for this document, then your most recently active one that is not busy. When all of yours are busy, the work queues behind one of them and starts when it is free. Without any, the card shows the connector command to start, and you select diff --git a/docs/local-launcher.md b/docs/local-launcher.md index 9c18ee6c9..1e1438b78 100644 --- a/docs/local-launcher.md +++ b/docs/local-launcher.md @@ -30,9 +30,14 @@ normal tools, project instructions and configured services. document, it asks the hosted Planner to draft them without posting a Chat message, then starts them without a separate review. See [drafting tasks from Build plan](implementation-lifecycle.md#drafting-tasks-from-build-plan). -3. The build runs on your own local agent: the server picks your idle - connection for the repository, preferring the one that last ran work for this - document, and derives the checkout from it. Without one, the header reads +3. The build runs on the machine you chose for the document: the server picks + your idle connection for the repository, preferring the one that last ran work + for this document, and derives the checkout from it. If you chose a + teammate's shared machine, it runs there instead, as that machine's owner (see + [Share a machine with teammates](chopin-cli.md#share-a-machine-with-teammates)): + the connection it queues on belongs to the owner, the build records you as + the person who asked, and only that connection claims and reports it, while + the machine is shared and its owner can push to the repository. Without one, the header reads **Build stopped**, and Build says your local agent isn't connected and shows the connector command to run in your project folder; **Retry** tries again once connected. Approval persists the document and graph diff --git a/e2e/connector.e2e.ts b/e2e/connector.e2e.ts index cdfe7cb7d..5f7385f2e 100644 --- a/e2e/connector.e2e.ts +++ b/e2e/connector.e2e.ts @@ -171,7 +171,7 @@ test("a paired machine reconnects without pairing, until it is disconnected in t ).not.toBeChecked(); await expect( page.getByRole("checkbox", { - name: "Let teammates' documents plan on this machine", + name: "Let teammates run their work on this machine", exact: true, }), ).not.toBeChecked(); diff --git a/e2e/github.ts b/e2e/github.ts index 89dcc2784..bdb54b172 100644 --- a/e2e/github.ts +++ b/e2e/github.ts @@ -47,6 +47,24 @@ const CREATOR_WRITABLE = new Set(["archive-1", "archive-8", "archive-9"]); // octo-org/score: a reader may pull but not push, and an outsider cannot see it. const SCORE_READER = "score-reader-"; const SCORE_OUTSIDER = "score-outsider-"; +// Someone who shares a machine in a test sees one repository, a private one of their own, so the +// machine is listed to nobody else on the server: a shared machine serves every repository its +// owner can push to, and a teammate sees it wherever they can push to one too. +const MACHINE_SHARER = "machine-sharer-"; + +function sharerRepository(handle: string) { + let name = `relay-${handle.slice(MACHINE_SHARER.length)}`; + return { + node_id: `R_${name}`, + owner: { login: "octo-org", avatar_url: "https://example.invalid/octo-org.png" }, + name, + full_name: `octo-org/${name}`, + private: true, + html_url: `https://github.com/octo-org/${name}`, + default_branch: "main", + permissions: { pull: true, push: true, admin: false }, + }; +} const installations = [ { @@ -211,6 +229,7 @@ let fake = async (input: Parameters[0], init?: Parameters { let headers = new Headers(responseInit.headers); headers.set("etag", etag); @@ -280,6 +299,8 @@ let fake = async (input: Parameters[0], init?: Parameters { +test("server or VM: a generated script pairs with its single-use key, automatic work as chosen", async ({ baseURL, page }) => { test.setTimeout(240_000); - let page = await enter(person()); + let { repository } = await sharer(page, baseURL!); let minted = keyRequests(page); await page.goto("/machines"); await addMachine(page, "Cloud"); @@ -262,8 +276,8 @@ test("server or VM: a generated script pairs with its single-use key, automatic await expect(automatic).toHaveAttribute("aria-checked", "false"); await automatic.click(); await expect(automatic).toHaveAttribute("aria-checked", "true"); - // Planning teammates' documents is off until asked for, and the key carries it to the machine. - let teammates = page.getByRole("switch", { name: "Plan teammates' documents", exact: true }); + // Running teammates' work is off until asked for, and the key carries it to the machine. + let teammates = page.getByRole("switch", { name: "Run teammates' work", exact: true }); await expect(teammates).toHaveAttribute("aria-checked", "false"); await teammates.click(); await expect(teammates).toHaveAttribute("aria-checked", "true"); @@ -309,7 +323,7 @@ test("server or VM: a generated script pairs with its single-use key, automatic // The page ends at the command, as Tailscale's does: no wait for the machine. await expect(page.getByText(/Waiting/)).toHaveCount(0); - let server = await machine(); + let server = await machine(repository); try { // Pasted on the machine as it is: it pairs at once, with nothing opened in a browser. expect(await server.run(command), server.output()).toBe(0); @@ -372,7 +386,12 @@ const CONNECTOR = { * Pair a machine for the person signed in on `page` with a key, as Add a server's script would, * and, given `about`, connect a checkout with what its connector says about it. */ -async function pairedMachine(page: Page, label: string, about?: Record) { +async function pairedMachine( + page: Page, + label: string, + about?: Record, + repository = "octo-org/score", +) { let origin = new URL(page.url()).origin; await page.goto("/auth/github/connector?key"); await expect(page).toHaveURL(/\/machines\?server$/); @@ -380,7 +399,7 @@ async function pairedMachine(page: Page, label: string, about?: Record) => fetch(new URL(path, origin), { @@ -400,28 +419,27 @@ async function pairedMachine(page: Page, label: string, about?: Record { - let login = person(); - let page = await enter(login); - await threeMachines(page); +test("the machines table: search, Tailscale's columns, a row's menu and renaming", async ({ baseURL, page }) => { + let { login, repository } = await sharer(page, baseURL!); + await threeMachines(page, repository); let table = page.getByRole("table", { name: "Your machines" }); // A row's name starts with its favorite star's. let row = (name: string) => diff --git a/e2e/planner-picker.e2e.ts b/e2e/planner-picker.e2e.ts index 8a49e3853..d57a17a1e 100644 --- a/e2e/planner-picker.e2e.ts +++ b/e2e/planner-picker.e2e.ts @@ -14,12 +14,13 @@ import { CONNECTOR_PLANNER_COMMAND_HEADER, CONNECTOR_PLATFORM_HEADER, CONNECTOR_PROTOCOL_HEADER, + CONNECTOR_SERVICE_HEADER, CONNECTOR_VERSION_HEADER, } from "../packages/protocol/connector"; import { CONNECTOR_PROTOCOL } from "../apps/connector/src/version"; import { chatInput, fillChat } from "./chat-input"; -import { createChannel, registerTestDatabase } from "./database"; -import { authenticate, expect, ready, roomPath, test } from "./room"; +import { createChannel, registerTestDatabase, seedChannel } from "./database"; +import { authenticate, content, expect, ready, roomPath, test } from "./room"; import { RELEASE } from "./release"; import { HOST, ROOT } from "./servers"; @@ -144,9 +145,16 @@ test.afterEach(async () => { /** * Pair a machine for the person signed in on `page` with a pairing key and connect a checkout of - * octo-org/score, as `chopin connect` does; with `planner`, as `--planner-command` does. + * octo-org/score, as `chopin connect` does; with `planner`, as `--planner-command` does, with + * `service`, as the background service on a server does, and with `bare`, with no checkout, as a + * machine that clones what it needs connects. */ -async function machine(page: Page, label: string, planner: boolean) { +async function machine( + page: Page, + label: string, + planner: boolean, + { service = false, bare = false }: { service?: boolean; bare?: boolean } = {}, +) { let back = page.url(); await page.goto(`${ORIGIN}/auth/github/connector?key`); await expect(page).toHaveURL(/\/machines\?server$/); @@ -166,13 +174,20 @@ async function machine(page: Page, label: string, planner: boolean) { }, body: JSON.stringify(body), }); - let paired = await (await post("/api/connector/pairings", { ...checkout, key: minted.key })) - .json() as { token: string }; - let opened = await post("/api/connector/connections", checkout, { - [CONNECTOR_PLATFORM_HEADER]: "linux x64", - ...(planner ? { [CONNECTOR_PLANNER_COMMAND_HEADER]: "1" } : {}), - authorization: `Bearer ${paired.token}`, - }); + let paired = await (await post("/api/connector/pairings", { + ...bare ? { label } : checkout, + key: minted.key, + })).json() as { token: string }; + let opened = await post( + "/api/connector/connections", + bare ? { label, checkouts: [] } : checkout, + { + [CONNECTOR_PLATFORM_HEADER]: "linux x64", + ...(planner ? { [CONNECTOR_PLANNER_COMMAND_HEADER]: "1" } : {}), + ...(service ? { [CONNECTOR_SERVICE_HEADER]: "1" } : {}), + authorization: `Bearer ${paired.token}`, + }, + ); expect(opened.status, await opened.clone().text()).toBe(200); let { token } = await opened.json() as { token: string }; await page.goto(back); @@ -206,6 +221,18 @@ async function machine(page: Page, label: string, planner: boolean) { }; return JSON.parse(answer.result.content[0]!.text); }, + /** The connector's wait for work: the build Chopin queued on this machine, if any. */ + work: async () => { + let answer = await (await post("/connector/mcp", { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "wait_for_work", arguments: {} }, + }, { authorization: `Bearer ${token}` })).json() as { + result: { content: Array<{ text: string }> }; + }; + return JSON.parse(answer.result.content[0]!.text); + }, /** What the connector tells Chopin when its Planner backend keeps exiting before it attaches. */ reportFailure: (body: unknown) => post("/api/connector/planner/failure", body, { authorization: `Bearer ${paired.token}` }), @@ -229,6 +256,16 @@ async function open(browser: Browser, handle: string, room: string, options: obj return page; } +/** Someone who can only read the document, opened once its text is on the page. */ +async function openReader(browser: Browser, room: string) { + let context = await browser.newContext(DESKTOP); + let page = await context.newPage(); + await authenticate(page, `score-reader-${room.slice(0, 8)}`, ORIGIN); + await page.goto(`${ORIGIN}${roomPath(room)}`); + await expect(content(page)).toHaveAttribute("contenteditable", "false"); + return page; +} + async function shoot(page: Page, name: string, target?: ReturnType) { if (!SHOTS) return; await mkdir(SHOTS, { recursive: true }); @@ -586,3 +623,313 @@ test("a teammate's Planner finishes its reply after its machine stops being shar await expect(owner.getByText(/^Finishing/)).toHaveCount(0, { timeout: 15_000 }); await link.detach().catch(() => {}); }); + +/** A first build's tasks, so Build plan can show without a Planner's draft. */ +const GRAPH = { + versions: [{ + number: 1, + revision: 1, + planRevision: 0, + state: "draft" as const, + definition: { + tasks: [{ + id: "connect", + title: "Connect the local agent", + context: "An isolated tracer.", + goal: "Read the approved graph.", + acceptance: ["The agent can read the graph.", "Its progress appears in Chopin."], + dependsOn: [], + }], + }, + }], +}; + +/** Where an element starts, from the left of the page. */ +async function left(target: ReturnType) { + return (await target.boundingBox())!.x; +} + +/** The Machines page's list of machines teammates share. */ +function sharedWithYou(page: Page) { + return page.getByRole("region", { name: "Shared with you" }); +} + +/** The document's machine button, left of Build plan. */ +function runsOnButton(page: Page) { + return page.getByRole("button", { name: /^Runs on/ }); +} + +/** The owner shares one of their machines with teammates from the Machines table, and waits for it. */ +async function share(owner: Page, label: string, shared = true) { + await owner.goto(`${ORIGIN}/machines`); + let sharing = owner.getByRole("switch", { name: `Share ${label} with teammates` }); + if (await sharing.getAttribute("aria-checked") !== String(shared)) await sharing.click(); + await expect(sharing).toHaveAttribute("aria-checked", String(shared)); +} + +test("teammates' shared machines are listed read-only under Shared with you, and go when unshared", async ({ browser }) => { + test.setTimeout(150_000); + let room = await remoteServer(); + let owner = await open(browser, "casey", room, DESKTOP); + let box = await machine(owner, "Build box", true, { service: true }); + await machine(owner, "Desk", true); + let teammate = await open(browser, "drew", room, DESKTOP); + let reader = await openReader(browser, room); + + // Nothing is shared yet: a teammate's Machines page has no such section. + await teammate.goto(`${ORIGIN}/machines`); + await expect(teammate.getByRole("heading", { name: "Add your first machine" })).toBeVisible(); + await expect(sharedWithYou(teammate)).toHaveCount(0); + + // casey shares Build box but not Desk. + await share(owner, "Build box"); + let section = sharedWithYou(teammate); + await expect(section).toBeVisible({ timeout: 15_000 }); + let row = section.getByRole("row", { name: /Build box/ }); + await expect(row).toContainText("casey"); + await expect(row).toContainText(`chopin ${RELEASE}`); + await expect(row).toContainText("octo-org/score"); + await expect(row).toContainText("Connected"); + await expect(section.getByRole("rowgroup").filter({ hasText: "Cloud" })).toBeVisible(); + await expect(section.getByText("Desk")).toHaveCount(0); + // Read-only: nothing in it renames, favorites, shares, disconnects or sets anything up. + await expect(section.getByRole("button")).toHaveCount(0); + await expect(section.getByRole("switch")).toHaveCount(0); + await expect(section.getByRole("link")).toHaveCount(0); + await shoot(teammate, "machines-shared-with-you"); + + // Chopin refuses the owner-only routes to a teammate, whatever they send. + let listed = await (await teammate.request.get(`${ORIGIN}/api/connector/machines`)).json() as { + machines: Array>; + }; + expect(listed.machines).toEqual([expect.objectContaining({ + label: "Build box", + teammate: true, + owner: expect.objectContaining({ login: "casey" }), + repositories: ["octo-org/score"], + connected: true, + })]); + let id = listed.machines[0]!.id as string; + for ( + let [action, data] of [["label", { label: "mine" }], ["favorite", { favorite: true }], [ + "shared", + { shared: false }, + ], ["revoke", {}]] as const + ) { + let refused = await teammate.request.post(`${ORIGIN}/api/connector/machines/${id}/${action}`, { + data, + headers: { origin: ORIGIN }, + }); + expect(refused.status(), action).toBe(404); + } + await expect(owner.getByRole("switch", { name: "Share Build box with teammates" })) + .toHaveAttribute("aria-checked", "true"); + + // Someone who can't push to the repository never sees it. + await reader.goto(`${ORIGIN}/machines`); + await expect(reader.getByRole("heading", { name: "Add your first machine" })).toBeVisible(); + await expect(sharedWithYou(reader)).toHaveCount(0); + + // Offline, it stays listed, with when it was last seen. + await box.disconnect(); + await expect(row).toContainText("Last seen", { timeout: 15_000 }); + await expect(row).not.toContainText("Connected"); + + // Its owner stops sharing it: it leaves the teammate's page. + await share(owner, "Build box", false); + await expect(sharedWithYou(teammate)).toHaveCount(0, { timeout: 15_000 }); +}); + +test("on a phone, a teammate's shared machine is a row with its owner, version and repositories", async ({ browser }) => { + test.setTimeout(120_000); + let room = await remoteServer(); + let owner = await open(browser, "casey", room, DESKTOP); + await machine(owner, "Build box", true, { service: true }); + await share(owner, "Build box"); + let page = await open(browser, "drew", room, PHONE); + await page.goto(`${ORIGIN}/machines`); + let section = page.getByRole("region", { name: "Shared with you" }); + await expect(section).toBeVisible({ timeout: 15_000 }); + let row = section.getByRole("listitem").filter({ hasText: "Build box" }); + await expect(row).toContainText("casey"); + await expect(row).toContainText(`chopin ${RELEASE}`); + await expect(row).toContainText("octo-org/score"); + await expect(section.getByRole("button")).toHaveCount(0); + await shoot(page, "machines-shared-with-you-phone"); +}); + +test("anyone who can edit a document chooses a teammate's shared machine for its Planner; its creator's own choice decides", async ({ browser }) => { + test.setTimeout(180_000); + let room = await remoteServer(); + await seedChannel(PORT, room, "# Shared machine\n\nThe Planner runs where it is chosen.\n", { + graph: GRAPH, + }); + let owner = await open(browser, "casey", room, DESKTOP); + let box = await machine(owner, "Build box", true, { service: true }); + await machine(owner, "Desk", true); + // drew created the document and has a machine of their own; quinn edits it and has none. + let creator = await open(browser, "drew", room, DESKTOP); + await createdBy(room, "drew"); + let laptop = await machine(creator, "Laptop", true); + let editor = await open(browser, "quinn", room, DESKTOP); + let reader = await openReader(browser, room); + + // Not shared, nothing of casey's is offered; a reader has no machine button at all. + await editor.reload(); + await ready(editor); + await expect(runsOnButton(editor)).toHaveCount(0); + await expect(runsOnButton(reader)).toHaveCount(0); + expect( + (await (await reader.request.get(`${ORIGIN}/api/documents/${room}/machine`)).json() as { + machines: unknown[]; + }).machines, + ).toEqual([]); + + // Shared, Build box is offered to the editor under its owner's name, Desk is not. + await share(owner, "Build box"); + await editor.reload(); + await ready(editor); + let menu = await runOn(editor); + let group = menu.getByRole("group", { name: "Shared by casey" }); + await expect(group.getByRole("menuitemradio")).toHaveCount(1); + let item = group.getByRole("menuitemradio", { name: /^Build box/ }); + await expect(item).toContainText("Connected"); + await expect(item).not.toHaveAttribute("aria-disabled", "true"); + await expect(menu.getByText("Desk")).toHaveCount(0); + await expect(menu.getByRole("group", { name: "Local" })).toHaveCount(0); + await shoot(editor, "shared-machine-menu"); + + // quinn chooses it: Build box is asked to run the Planner, and the button says where. + await item.click(); + await expect(menu).toBeHidden(); + await expect(runsOnButton(editor)).toHaveAccessibleName("Runs on Build box"); + let asked = await box.poll([]); + expect(asked).toEqual({ + kind: "planners", + planners: [{ id: room, url: expect.stringContaining(ORIGIN) }], + }); + // The machine is one quinn can see in their own menu, so Chat names it rather than its owner. + await expect(plannerStatus(editor)).toHaveText("Planner: starting on Build box…"); + // Quinn's builds run there too: Build plan is ready, not greyed out. + let plan = editor.getByRole("button", { name: "Build plan", exact: true }); + await expect(plan).toBeVisible(); + await expect(plan).not.toHaveAttribute("aria-disabled", "true"); + await shoot(editor, "shared-machine-chosen"); + + // drew, who created the document, chooses their own machine: that decides, so Build box stops + // and quinn's choice goes. + await (await runOn(creator)).getByRole("menuitemradio", { name: /^Laptop/ }).click(); + await expect(runsOnButton(creator)).toHaveAccessibleName("Runs on Laptop"); + expect(await box.poll([room])).toEqual({ + kind: "planners", + planners: [], + stopped: { [room]: expect.stringContaining("drew chose another machine") }, + }); + expect(await laptop.poll([])).toMatchObject({ kind: "planners" }); + await editor.reload(); + await ready(editor); + await expect(runsOnButton(editor)).toHaveAccessibleName("Runs on: no machine connected"); + + // Offline, its row stays but can't be chosen. + await box.disconnect(); + await editor.reload(); + await ready(editor); + menu = await runOn(editor); + let offline = menu.getByRole("group", { name: "Shared by casey" }) + .getByRole("menuitemradio", { name: /^Build box/ }); + await expect(offline).toHaveAttribute("aria-disabled", "true"); + await expect(offline).toContainText("Offline"); + await editor.keyboard.press("Escape"); + + // Its owner stops sharing it: the machine leaves the menu. + await share(owner, "Build box", false); + await editor.reload(); + await ready(editor); + await expect(runsOnButton(editor)).toHaveCount(0); +}); + +test("a shared machine with no checkouts serves every repository its owner can push to, and builds there", async ({ browser }) => { + test.setTimeout(180_000); + let room = await remoteServer(); + await seedChannel(PORT, room, "# Shared machine\n\nIt is built where it is chosen.\n", { + graph: GRAPH, + }); + let owner = await open(browser, "casey", room, DESKTOP); + // Build box clones what it needs, so it connects with no checkout at all. + let box = await machine(owner, "Build box", true, { service: true, bare: true }); + await share(owner, "Build box"); + + // tessa has opened no document: the Machines page still lists it for octo-org/score, which she + // and casey can both push to, and its owner's name lines up with its column. + let context = await browser.newContext(DESKTOP); + let page = await context.newPage(); + await authenticate(page, "tessa", ORIGIN); + await page.goto(`${ORIGIN}/machines`); + let section = sharedWithYou(page); + await expect(section).toBeVisible({ timeout: 15_000 }); + let row = section.getByRole("row", { name: /Build box/ }); + await expect(row).toContainText("casey"); + await expect(row).toContainText("octo-org/score"); + await expect(row).toContainText("Connected"); + let machineOffset = await left(row.getByText("Build box", { exact: true })) + - await left(section.getByRole("columnheader", { name: "Machine" })); + let ownerOffset = await left(row.getByText("casey", { exact: true })) + - await left(section.getByRole("columnheader", { name: "Owner" })); + expect(Math.abs(ownerOffset - machineOffset)).toBeLessThan(1.5); + await shoot(page, "machines-shared-no-checkout"); + + // Her document's machine menu offers it under casey's name, and choosing it is a choice like any. + await page.goto(`${ORIGIN}${roomPath(room)}`); + await ready(page); + let menu = await runOn(page); + let item = menu.getByRole("group", { name: "Shared by casey" }) + .getByRole("menuitemradio", { name: /^Build box/ }); + await expect(item).toContainText("Connected"); + await item.click(); + await expect(menu).toBeHidden(); + await expect(runsOnButton(page)).toHaveAccessibleName("Runs on Build box"); + + // Build plan is ready, and pressing it queues the build on casey's machine, for tessa. + let plan = page.getByRole("button", { name: "Build plan", exact: true }); + await expect(plan).toBeVisible(); + await expect(plan).not.toHaveAttribute("aria-disabled", "true"); + await shoot(page, "shared-machine-build-plan"); + await plan.click(); + expect(await box.work()).toEqual({ id: expect.any(String), kind: "implementation" }); + let snapshot = await (await page.request.get(`${ORIGIN}/api/channels/${room}/implementation`)) + .json() as { + build: { id: string; user: string; machineOwner: string; state: string }; + startedBy: string; + buildMachines: Record; + }; + expect(snapshot).toMatchObject({ + build: { state: "queued", machineOwner: expect.any(String) }, + startedBy: "tessa", + }); + expect(snapshot.build.user).not.toBe(snapshot.build.machineOwner); + expect(snapshot.buildMachines[snapshot.build.id]).toMatchObject({ + label: "Build box", + owner: "casey", + }); + await shoot(page, "shared-machine-building"); +}); + +test("on a phone, the machine sheet lists a teammate's shared machine under its owner", async ({ browser }) => { + test.setTimeout(120_000); + let room = await remoteServer(); + let owner = await open(browser, "casey", room, DESKTOP); + await machine(owner, "Build box", true, { service: true }); + await share(owner, "Build box"); + let page = await open(browser, "quinn", room, PHONE); + await page.getByRole("navigation", { name: "Workspace view" }) + .getByRole("button", { name: "Chat", exact: true }).tap(); + await page.locator(".phone-top-bar").getByRole("button", { name: /^Runs on/ }).tap(); + let sheet = page.getByRole("dialog", { name: "Runs on", exact: true }); + let group = sheet.getByRole("group", { name: "Shared by casey" }); + await expect(group.getByRole("menuitemradio", { name: /^Build box/ })).toContainText("Connected"); + await shoot(page, "shared-machine-phone-sheet"); + await group.getByRole("menuitemradio", { name: /^Build box/ }).tap(); + await expect(sheet).toBeHidden(); + await expect(page.locator(".phone-top-bar").getByRole("button", { name: /^Runs on/ })) + .toHaveAccessibleName("Runs on Build box"); +}); diff --git a/packages/protocol/implementation.d.ts b/packages/protocol/implementation.d.ts index 3faca8fec..78a5af9ee 100644 --- a/packages/protocol/implementation.d.ts +++ b/packages/protocol/implementation.d.ts @@ -7,7 +7,13 @@ export type CheckoutContext = { repository: string; branch?: string; commit: str export type BuildRequest = { id: string; retryOf?: string; + /** The person who asked for the build. */ user: string; + /** + * Whose machine runs it, when it is not `user`'s: the owner of the teammate's shared machine + * `user` chose for the document. That machine runs the agent with its owner's GitHub account. + */ + machineOwner?: string; connectionId: string; /** The paired machine it runs on; builds stored before machines were recorded have none. */ machineId?: string; @@ -97,6 +103,8 @@ export type LiveSnapshot = { export type BuildMachine = { /** The machine's paired name, as its owner last renamed it. */ label: string; + /** Whose machine it is, when it is a teammate's shared machine the build's starter chose. */ + owner?: string; /** It ran as a background service rather than an interactive session. */ service: boolean; /** diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index 2df96b41c..cae2d1841 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -325,7 +325,7 @@ 1 ] ], - "sourceHash": "dc900a88bb31f1481af9ea0482e3c0e6d1eeecd85d8199c94275ac8ddb51809b" + "sourceHash": "3f7c38849e990931806023c5c4b13904e84a00976158367b6d99dec76bae03b7" }, { "file": "apps/web/src/experiments/machine-setup.tsx", @@ -353,6 +353,6 @@ 1 ] ], - "sourceHash": "b1854427e2ab3bb30ea96ed20e9ba8c97e97e4bc42e1f9530e5b0100f3b4d37a" + "sourceHash": "5bfa8572fa33d0cd8b5d6315c533fc344981df23423a1552e9845f5babed53c4" } ]