From 278abd77aa974299aed9f7e01e15d81a22784b8c Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Wed, 28 Oct 2026 12:15:13 -0700 Subject: [PATCH 1/2] Let a teammate's shared machine run the Planner of a document whose creator has none A document on Default machine ran its Planner only on its creator's machines. When a teammate without a paired machine created a document, its Planner never started, even with someone else's machine paired, connected and able to run Planners in the same repository. Chat stayed on "Planner: none attached". A machine's owner can now share it with teammates. Shared, it runs the Planner of a document on Default machine whose creator has no connected machine that can, as its owner, only in repositories its owner can push to. Sharing is off unless the owner turns it on. Routing (apps/server/src/experiments/routes.ts): - Default machine keeps the creator's own machine first. With none that can run Planners, it takes a teammate's shared one, each owner's favorite first. - Before using a shared machine for a repository, Chopin checks with the machine's own GitHub token that its owner can push there (documentAccess), and keeps the answer for 10 minutes, or 1 minute after a refusal. Opening or asking the document checks again once it lapses. - A document opened before the shared machine connected, or before it was shared, is asked again as the machine connects or is shared. - 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. - Turning sharing off stops the Planners the machine runs for teammates and tells the connector " stopped sharing with teammates". - A document whose creator chose a machine never uses a shared one, and builds, runs and investigations still go only to the presser's own machine. - The machine button's Planner state names the teammate while it starts: "starting" with their login. Storage: machines.shared (migration 028_machine_sharing, default false), set by `POST /api/connector/machines/:id/shared` for the owner only, and by pairing (a key minted with `shared`, or approval with `shared`). Live connections follow a change at once. Interface (DESIGN.md patterns, no new tokens): - Machines table: a Teammates column with the same switch the Add server page uses, named "Share with teammates", changed at once like the star. On a phone, the machine's view has "Share with teammates" with what it does. - Pairing card: "Let teammates' documents plan on this machine", off. - Add a cloud machine: "Plan teammates' documents", off; the key carries it. - UsersIcon for the phone row, in the icon catalogue. Docs: chopin-cli.md, "Share a machine with teammates". Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline Assistant-verification: bun test unit passed: GIT_CONFIG_GLOBAL=/dev/null bun test (6746 pass, 0 fail), including 7 new shared-machine dispatch tests and a pairing-key test Assistant-verification: PostgreSQL contract passed: bun test apps/server/src/storage/postgres against postgres:17 (123 pass), including migration 028 and the share contract Assistant-verification: Chromium E2E passed: bun run e2e e2e/machines.e2e.ts e2e/connector.e2e.ts e2e/planner-picker.e2e.ts (16 pass); the table switch saves and survives a reload, a key minted shared pairs a shared machine, both pairing switches start off Assistant-verification: typecheck and lint passed: bun run types and bun run ci (design contract renewed for UsersIcon and two shifted owners) User-preference: A teammate without a machine of their own should get a Planner on a teammate's designated default machine, only when its owner opts in Co-authored-by: Alex Lavaee --- apps/server/src/auth/machines.ts | 7 + apps/server/src/experiments/connections.ts | 26 +++ apps/server/src/experiments/pairing-keys.ts | 31 +++- apps/server/src/experiments/routes.test.ts | 20 ++ apps/server/src/experiments/routes.ts | 172 +++++++++++++++--- .../harness/remote/planner-dispatch.test.ts | 129 ++++++++++++- apps/server/src/storage/machine-contract.ts | 26 +++ apps/server/src/storage/memory/machines.ts | 11 +- apps/server/src/storage/model.ts | 5 + apps/server/src/storage/port.ts | 2 + .../src/storage/postgres/adapter.test.ts | 1 + apps/server/src/storage/postgres/machines.ts | 20 +- .../server/src/storage/postgres/migrations.ts | 3 + .../migrations/028_machine_sharing.sql | 1 + apps/web/src/design-audit/icons.tsx | 2 + apps/web/src/experiments/add-server.tsx | 46 +++-- apps/web/src/experiments/machine-list.test.ts | 1 + apps/web/src/experiments/machine-list.tsx | 64 ++++++- apps/web/src/experiments/machines.css | 10 + apps/web/src/experiments/machines.tsx | 17 +- apps/web/src/experiments/pairing-key.ts | 31 ++-- apps/web/src/experiments/pairing.tsx | 25 ++- apps/web/src/machine-choice.ts | 12 ++ apps/web/src/machine-summary.ts | 2 + docs/chopin-cli.md | 27 ++- e2e/connector.e2e.ts | 6 + e2e/machines.e2e.ts | 27 ++- packages/icons/src/index.ts | 1 + packages/icons/src/line.tsx | 11 ++ .../exceptions/dynamic-packages.json | 9 +- .../exceptions/dynamic-web.json | 4 +- 31 files changed, 668 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/storage/postgres/migrations/028_machine_sharing.sql diff --git a/apps/server/src/auth/machines.ts b/apps/server/src/auth/machines.ts index bd5cc0b10..b15fbdb8e 100644 --- a/apps/server/src/auth/machines.ts +++ b/apps/server/src/auth/machines.ts @@ -50,6 +50,8 @@ export type PairMachine = { label: string; repositories: MachineRepository[]; autoRun: boolean; + /** Whether teammates' documents may run their Planner here on Default machine. */ + shared?: boolean; /** The machine's own GitHub authorization; GitHub refresh tokens are single-use, never shared. */ grant?: GitHubTokenGrant; }; @@ -96,6 +98,7 @@ export class Machines { label: input.label, repositories: input.repositories, autoRun: input.autoRun, + ...(input.shared ? { shared: true } : {}), tokenHash: digest(token), createdAt: now, lastSeenAt: now, @@ -146,6 +149,10 @@ export class Machines { return this.#storage.machines.rename(id, userId, label); } + share(id: string, userId: string, shared: boolean): Promise { + return this.#storage.machines.share(id, userId, shared); + } + async revoke(id: string, userId: string): Promise { let revoked = await this.#storage.machines.revoke(id, userId, this.#clock()); if (revoked) { diff --git a/apps/server/src/experiments/connections.ts b/apps/server/src/experiments/connections.ts index e84a52c00..4108ae3c2 100644 --- a/apps/server/src/experiments/connections.ts +++ b/apps/server/src/experiments/connections.ts @@ -27,6 +27,11 @@ export type Connection = { * Stored with the machine, so a reconnect keeps it. */ 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. + */ + shared?: boolean; /** The chopin release that opened it, when it said; known only while it is connected. */ version?: string; /** @@ -53,6 +58,7 @@ export type Approval = { owner: string; login: string; autoRun: boolean; + shared?: boolean; repositoryId?: string; }; type Pending = { @@ -260,6 +266,7 @@ export class Connections { owner: string; login: string; autoRun: boolean; + shared?: boolean; version?: string; protocol?: number; }, @@ -289,6 +296,7 @@ export class Connections { anyRepository, expiresAt: this.now() + CONNECTION_MS, autoRun: machine.autoRun, + ...(machine.shared ? { shared: true } : {}), ...(machine.version ? { version: machine.version } : {}), ...(machine.protocol !== undefined ? { protocol: machine.protocol } : {}), about, @@ -300,6 +308,24 @@ export class Connections { this.#tokens.set(fingerprint(token), { connectionId: connection.id }); return { connection, token }; } + /** Its owner shared a machine with teammates or stopped: its live connections follow at once. */ + share(machineId: string, shared: boolean) { + for (let connection of this.machine(machineId)) { + if (shared) connection.shared = true; + else delete connection.shared; + } + } + /** + * Live connections of machines their owners share with teammates, other than `owner`'s, that + * serve a repository: each owner's favorite first, then the most recently heard from. + */ + shared(repositoryId: string, owner: string) { + return this.list(repositoryId).filter(value => value.shared && value.owner !== owner) + .sort((a, b) => + Number(this.#favorites.get(b.owner) === b.machineId) + - Number(this.#favorites.get(a.owner) === a.machineId) + ); + } /** Live connections of one paired machine. */ machine(machineId: string) { return [...this.#connections.values()].filter(value => diff --git a/apps/server/src/experiments/pairing-keys.ts b/apps/server/src/experiments/pairing-keys.ts index 5c1e375c6..25d79363a 100644 --- a/apps/server/src/experiments/pairing-keys.ts +++ b/apps/server/src/experiments/pairing-keys.ts @@ -16,8 +16,17 @@ const MINT_WINDOW_MS = 10 * 60_000; const MINTS_PER_WINDOW = 10; const KEY = /^chopin_k_[A-Za-z0-9_-]{43}$/; -/** `autoRun`: whether the machine it pairs lets Chopin build and prototype automatically. */ -type Key = { userId: string; sessionId: string; expiresAt: number; autoRun: boolean }; +/** + * `autoRun`: whether the machine it pairs lets Chopin build and prototype automatically. + * `shared`: whether it runs teammates' Planners on Default machine. + */ +type Key = { + userId: string; + sessionId: string; + expiresAt: number; + autoRun: boolean; + shared: boolean; +}; type Held = { grant: GitHubTokenGrant; sessionId: string; issuedAt: number; until: number }; function digest(key: string) { @@ -71,9 +80,10 @@ export class PairingKeys { /** * A new key for `userId`, minted in `sessionId`, replacing the one before it. The machine it - * pairs has automatic work on only with `autoRun`, chosen in the browser that minted it. + * pairs has automatic work on only with `autoRun`, and is shared with teammates only with + * `shared`, both chosen in the browser that minted it. */ - mint(userId: string, sessionId: string, autoRun = false) { + mint(userId: string, sessionId: string, autoRun = false, shared = false) { this.sweep(); let now = this.now(); let recent = (this.#mints.get(userId) ?? []).filter(time => time > now - MINT_WINDOW_MS); @@ -84,7 +94,7 @@ export class PairingKeys { let key = `chopin_k_${randomBytes(32).toString("base64url")}`; let hash = digest(key); let expiresAt = now + PAIRING_KEY_MS; - this.#keys.set(hash, { userId, sessionId, expiresAt, autoRun }); + this.#keys.set(hash, { userId, sessionId, expiresAt, autoRun, shared }); this.#current.set(userId, hash); // The lifetime too, so a browser whose clock differs from this one counts it right. return { key, expiresAt, expiresIn: PAIRING_KEY_MS }; @@ -97,17 +107,22 @@ export class PairingKeys { peek(key: string) { let found = KEY.test(key) ? this.#keys.get(digest(key)) : undefined; if (!found || found.expiresAt <= this.now()) fail("pairing-key-invalid"); - return { userId: found.userId, grant: this.#grant(found), autoRun: found.autoRun }; + return { + userId: found.userId, + grant: this.#grant(found), + autoRun: found.autoRun, + shared: found.shared, + }; } /** Spend a key: refused unless it is still live, so of two at once only one gets through. */ take(key: string) { - let { userId, grant, autoRun } = this.peek(key); + let { userId, grant, autoRun, shared } = this.peek(key); let hash = digest(key); this.#keys.delete(hash); if (this.#current.get(userId) === hash) this.#current.delete(userId); this.#grants.delete(userId); - return { userId, grant, autoRun }; + return { userId, grant, autoRun, shared }; } /** diff --git a/apps/server/src/experiments/routes.test.ts b/apps/server/src/experiments/routes.test.ts index ff7f35a79..24799ceaf 100644 --- a/apps/server/src/experiments/routes.test.ts +++ b/apps/server/src/experiments/routes.test.ts @@ -1080,6 +1080,26 @@ test("a key minted with automatic work on pairs a machine with it on, as the pai expect(await wrong.json()).toEqual({ error: "invalid-request" }); }); +test("a key minted shared pairs a machine shared with teammates, and its owner can stop sharing it", async () => { + let { alice, request, authorizeKeys } = await setup(); + authorizeKeys("alice"); + let minted = await (await request("/api/connector/pairing-keys", { shared: true }, alice.cookie)) + .json(); + expect((await request("/api/connector/pairings", { ...CHECKOUT, key: minted.key })).status) + .toBe(200); + let listed = await (await request("/api/connector/machines", undefined, alice.cookie)).json(); + expect(listed.machines).toMatchObject([{ label: "Laptop", shared: true, autoRun: false }]); + let id = listed.machines[0].id; + let stopped = await request( + `/api/connector/machines/${id}/shared`, + { shared: false }, + alice.cookie, + ); + expect(await stopped.json()).toEqual({ shared: false }); + listed = await (await request("/api/connector/machines", undefined, alice.cookie)).json(); + expect(listed.machines).toMatchObject([{ shared: false }]); +}); + test("two machines racing with one pairing key: exactly one pairs", async () => { let { alice, request, mint, authorizeKeys } = await setup(); authorizeKeys("alice"); diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index 1bb1b2e05..8d9a355a2 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -164,6 +164,12 @@ export const MAX_INVESTIGATION_IMAGES = 3; * either was so within this long. */ export const PLANNER_IDLE_MS = 30 * 60_000; +/** + * How long a check that a shared machine's owner can push to a document's repository holds: a + * pass for this long, a refusal for a minute, before Default machine asks GitHub again. + */ +export const SHARED_REACH_MS = 10 * 60_000; +const SHARED_RETRY_MS = 60_000; /** * The connector protocols this server speaks; a connector outside them must update first. * Protocol 2 pairs a machine once and connects checkouts with its stored token. Protocol 4 polls @@ -426,11 +432,40 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio * it, so a working Planner never moves just because another machine came online. */ let defaults = new Map(); + /** + * 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. + */ + let reach = new Map(); + let reachKey = (machineId: string, repositoryId: string) => `${machineId}\n${repositoryId}`; + /** The creator's own machine that can run the document's Planner now: the one builds would use. */ + function ownPlannerMachine(channel: ChannelRecord) { + return connections.candidates(channel.repositoryId, channel.createdBy, channel.id, null) + .find(runsPlanners)?.machineId; + } + /** A teammate's shared machine that can run the document's Planner now, and whose owner can push. */ + function sharedPlannerMachine(channel: ChannelRecord) { + return connections.shared(channel.repositoryId, channel.createdBy).find(value => + runsPlanners(value) && reach.get(reachKey(value.machineId, channel.repositoryId))?.ok + )?.machineId; + } + /** Whether a machine can run a document's Planner: its creator's own, or one shared with them. */ + function readyFor(machineId: string, channel: ChannelRecord) { + return connections.machine(machineId).some(value => + serves(value, channel.repositoryId) && runsPlanners(value) + && (value.owner === channel.createdBy + || !!value.shared && !!reach.get(reachKey(machineId, channel.repositoryId))?.ok) + ); + } /** * The machine Default machine runs a document's Planner on, while someone uses it and its - * creator chose no machine for it: the creator's machine it already runs on while that one can, - * else the machine their builds would go to (their favorite, then the one last used for the - * document, then any of theirs), counting only machines that can run Planners. + * creator chose no machine for it: the machine it already runs on while that one can, else the + * machine the creator's builds would go to (their favorite, then the one last used for the + * 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. */ function defaultMachine(channel: ChannelRecord): string | undefined { if ( @@ -440,14 +475,49 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio defaults.delete(channel.id); return undefined; } + let own = ownPlannerMachine(channel); let kept = defaults.get(channel.id); - if (kept && plannerReady(kept, channel.repositoryId)) return kept; - let next = connections.candidates(channel.repositoryId, channel.createdBy, channel.id, null) - .find(runsPlanners)?.machineId; + if (kept && readyFor(kept, channel)) { + let teammates = connections.machine(kept)[0]?.owner !== channel.createdBy; + if (!teammates || !own || options.attachments?.()?.holding(channel.id)) return kept; + } + let next = own ?? sharedPlannerMachine(channel); if (next) defaults.set(channel.id, next); else defaults.delete(channel.id); return next; } + /** + * 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. + */ + async function reachShared(channel: ChannelRecord): Promise { + if (!options.planners || !auth.machines.durable || channel.parentChannelId) return false; + if (ownPlannerMachine(channel)) return false; + 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; + 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) }); + if (ok !== known?.ok) changed = true; + } + return changed; + } /** The machine that runs a document's Planner for `userId`: the one they chose, else Default's. */ function plannerMachine(channel: ChannelRecord, userId: string) { return connections.chosen(channel.id, userId) @@ -455,8 +525,9 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } /** * The documents whose Planner a connection should run, in the repositories it serves: those its - * owner chose its machine for, and those of theirs Default machine gives it, except one whose - * Planner another account holds, which is never taken over. + * owner chose its machine for, and those Default machine gives it (its owner's, or a teammate's + * when its owner shares it), except one whose Planner another account holds, which is never + * taken over. */ async function plannerDocuments(connection: Connection): Promise { if (!runsPlanners(connection)) return []; @@ -479,8 +550,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio for (let documentId of inUse.keys()) { let channel = await auth.storage.channels.get(documentId); if ( - !channel || channel.createdBy !== connection.owner - || !serves(connection, channel.repositoryId) + !channel || !serves(connection, channel.repositoryId) || defaultMachine(channel) !== connection.machineId ) continue; // Nor one its owner's Planner is attached to elsewhere, until that one lets go. @@ -519,8 +589,10 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (!channel || channel.archivedAt) return; let fresh = !inUse.has(channel.id); inUse.set(channel.id, auth.clock().getTime()); - if (!fresh) return; await readChoices().catch(() => {}); + // A creator with no machine of their own may get a teammate's shared one, checked anew here. + let reached = await reachShared(channel); + if (!fresh && !reached) return; connections.wake(channel.repositoryId); options.plannerStatus?.(channel.id); } @@ -623,14 +695,23 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (!told.size) dispatched.delete(machineId); } /** - * One of `owner`'s machines connected or disconnected: the documents it may run the Planner of, - * chosen for it or on Default machine, are asked again and say who starts their Planner now. + * One of `owner`'s machines connected, disconnected, or was shared or no longer: the documents + * it may run the Planner of, chosen for it or on Default machine (any in use when it is + * `shared`), are asked again and say who starts their Planner now. */ - async function plannersMoved(machineId: string, owner: string) { + async function plannersMoved(machineId: string, owner: string, shared = false) { let documents = new Set( connections.choosing(machineId).map(value => value.documentId), ); for (let channel of await created(owner)) documents.add(channel.id); + if (shared) { + for (let documentId of inUse.keys()) { + let channel = await auth.storage.channels.get(documentId); + if (!channel || channel.createdBy === owner) continue; + await reachShared(channel); + documents.add(channel.id); + } + } for (let documentId of documents) { let channel = await auth.storage.channels.get(documentId); if (channel) connections.wake(channel.repositoryId); @@ -649,8 +730,8 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio label => `${label} disconnected from Chopin, so it stopped running this document's Planner.`, (_documentId, repositoryId) => !serves(connection, repositoryId) || others.some(value => serves(value, repositoryId)), - ).then(() => plannersMoved(connection.machineId, connection.owner)).catch(error => - console.error("chopin: could not stop a disconnected machine's Planner -", error) + ).then(() => plannersMoved(connection.machineId, connection.owner, !!connection.shared)).catch( + error => console.error("chopin: could not stop a disconnected machine's Planner -", error), ); }; async function mutationAllowed(id: string) { @@ -751,15 +832,16 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio * key also carries the person's choice of automatic work, as the pairing card's checkbox does. */ route("POST", "/api/connector/pairing-keys", async request => { - let { autoRun } = z.object({ autoRun: z.boolean().optional() }).strict().parse( - await body(request), - ); + let { autoRun, shared } = z.object({ + autoRun: z.boolean().optional(), + shared: z.boolean().optional(), + }).strict().parse(await body(request)); let session = await auth.sessions.authenticate(request); if (!session) fail("authentication-required"); if (auth.machines.durable && !keys.authorized(session.user.id, session.session.id)) { return json({ authorize: "/auth/github/connector?key" }); } - return json(keys.mint(session.user.id, session.session.id, autoRun)); + return json(keys.mint(session.user.id, session.session.id, autoRun, shared)); }); /** * Pair a machine with a key: approved at once as the key's owner, with automatic work as they @@ -799,6 +881,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }] : [], autoRun: spent.autoRun, + ...(spent.shared ? { shared: true } : {}), ...(spent.grant && auth.machines.durable ? { grant: spent.grant } : {}), }); return { token }; @@ -850,6 +933,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }] : [], autoRun: approval.autoRun, + ...(approval.shared ? { shared: true } : {}), ...(grant ? { grant } : {}), }); connections.paired(id, machine.id, token); @@ -858,10 +942,11 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } auth.pairings.complete = complete; route("POST", "/api/connector/pairings/:id/approve", async (request, params) => { - // Automatic work is opt-in, once, as the machine is paired; it is off unless asked for. - let { autoRun } = z.object({ autoRun: z.boolean().optional() }).strict().parse( - await body(request), - ); + // Automatic work and sharing are opt-in, as the machine is paired; both off unless asked for. + let { autoRun, shared } = z.object({ + autoRun: z.boolean().optional(), + shared: z.boolean().optional(), + }).strict().parse(await body(request)); let session = await auth.sessions.authenticate(request); if (!session) fail("authentication-required"); let pending = connections.pending(params.id); @@ -872,6 +957,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio owner: session.user.id, login: session.user.login, autoRun: autoRun ?? false, + ...(shared ? { shared: true } : {}), ...(repository ? { repositoryId: repository.id } : {}), }); // GitHub refresh tokens are single-use, so the machine needs its own authorization. @@ -926,6 +1012,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio owner: machine.userId, login: user?.login ?? machine.userId, autoRun: machine.autoRun, + ...(machine.shared ? { shared: true } : {}), version: releaseVersion(request), protocol, }, @@ -953,7 +1040,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio options.connected?.(anyRepository ? undefined : checkouts[0]!.repositoryId, machine.userId); // A machine that can run Planners may be the one to start a document's now. if (runsPlanners(connection)) { - void plannersMoved(machine.id, machine.userId).catch(error => + void plannersMoved(machine.id, machine.userId, !!machine.shared).catch(error => console.error("chopin: could not ask a connected machine for Planners -", error) ); } @@ -1065,6 +1152,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio ]), ], autoRun: machine.autoRun, + shared: !!machine.shared, createdAt: machine.createdAt.getTime(), lastSeenAt: machine.lastSeenAt.getTime(), connected: connections.machine(machine.id).length > 0, @@ -1116,6 +1204,36 @@ 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 ends the Planners it runs for teammates. + */ + route("POST", "/api/connector/machines/:id/shared", async (request, params) => { + let session = await auth.sessions.authenticate(request); + if (!session) fail("authentication-required"); + let { shared } = z.object({ shared: z.boolean() }).strict().parse(await body(request)); + let userId = session.user.id; + await readChoices(); + if (!await auth.machines.share(params.id, userId, shared)) fail("not-found"); + connections.share(params.id, shared); + if (!shared) { + for (let key of reach.keys()) if (key.startsWith(`${params.id}\n`)) reach.delete(key); + for (let [documentId, machineId] of defaults) { + if (machineId !== params.id) continue; + let channel = await auth.storage.channels.get(documentId); + if (!channel || channel.createdBy === userId) continue; + await redefault( + channel, + label => + `${session.user.login} stopped sharing ${label} with teammates, so it stopped running ` + + "this document's Planner.", + ); + } + } + await plannersMoved(params.id, userId, shared); + return json({ shared }); + }); route("POST", "/api/connector/machines/:id/revoke", async (request, params) => { let session = await auth.sessions.authenticate(request); if (!session) fail("authentication-required"); @@ -1204,7 +1322,9 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } let machine = plannerMachine(channel, userId); if (machine && plannerReady(machine, channel.repositoryId)) { - return { state: "starting" as const, login: null, machine }; + // 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 }; } return { state: "none" as const, login: null, machine: null }; } diff --git a/apps/server/src/harness/remote/planner-dispatch.test.ts b/apps/server/src/harness/remote/planner-dispatch.test.ts index c70e5600a..2843361cb 100644 --- a/apps/server/src/harness/remote/planner-dispatch.test.ts +++ b/apps/server/src/harness/remote/planner-dispatch.test.ts @@ -107,13 +107,14 @@ async function dispatchServer() { login: string, userId: string, label: string, - connector: { protocol?: number; plannerCommand?: boolean } = {}, + connector: { protocol?: number; plannerCommand?: boolean; shared?: boolean } = {}, ) { let { machine, token } = await machines.pair({ userId, label, repositories: [], autoRun: false, + ...(connector.shared ? { shared: true } : {}), grant: { accessToken: machineToken(login), accessExpiresIn: 28_800, @@ -226,6 +227,14 @@ async function dispatchServer() { }); expect(response.status).toBe(200); }; + /** Share a machine with teammates as its owner, or stop. */ + let share = async (login: string, machine: string, shared = true) => { + let response = await send(`/api/connector/machines/${machine}/shared`, { + login, + body: { shared }, + }); + return response.status; + }; /** Someone opens the document in Chopin; closing it starts its idle time. */ let visit = async (id = channel.id) => { open.add(id); @@ -245,6 +254,7 @@ async function dispatchServer() { choose, choice, favor, + share, visit, url, }; @@ -735,6 +745,123 @@ describe("a document on Default machine", () => { }); }); +describe("a teammate's machine shared with teammates", () => { + it("runs the Planner of a document whose creator has no machine, as its owner", async () => { + let { machine, share, visit, choice, channel, runtime, url } = await dispatchServer(); + // The creator's own machine can't run Planners, so it doesn't count as theirs here. + let plain = await machine("member", "U_member", "plain", { plannerCommand: false }); + let box = await machine("rival", "U_rival", "build-box"); + // Not shared: a teammate's machine is never anyone else's Default machine. + await visit(); + expect(await box.poll([])).toEqual({ waiting: true }); + expect((await choice("member")).planner).toEqual({ state: "none", login: null, machine: null }); + + expect(await share("rival", box.id)).toBe(200); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + expect((await choice("member")).planner) + .toEqual({ state: "starting", login: "rival", machine: box.id }); + expect(await runtime.plannerStarting(channel.id)).toEqual({ login: "rival" }); + + expect(await plain.poll([])).toEqual({ waiting: true }); + + let link = await box.backend(); + await link.next("attached"); + expect((await choice("member")).planner) + .toEqual({ state: "held", login: "rival", machine: null }); + }); + + it("takes a machine shared as it pairs, and finds it for a document opened before it connected", async () => { + let { machine, machines, visit, channel, url } = await dispatchServer(); + await visit(); + // Shared from the start, as the pairing page's switch pairs it, and connected only now. + let box = await machine("rival", "U_rival", "build-box", { shared: true }); + expect((await machines.current(box.id)).shared).toBe(true); + expect(await box.poll([], 1_000)).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + }); + + it("stops the Planner when its owner stops sharing, and tells the machine why", async () => { + let { machine, share, visit, choice, channel, url } = await dispatchServer(); + let box = await machine("rival", "U_rival", "build-box"); + await share("rival", box.id); + await visit(); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + let link = await box.backend(); + await link.next("attached"); + + expect(await share("rival", box.id, false)).toBe(200); + expect(await link.next("refused")).toMatchObject({ code: "access-revoked" }); + expect(await box.poll([channel.id])).toEqual({ + kind: "planners", + planners: [], + stopped: { [channel.id]: expect.stringContaining("stopped sharing build-box") }, + }); + expect((await choice("member")).planner).toEqual({ state: "none", login: null, machine: null }); + }); + + it("prefers the creator's own machine, and moves there from a teammate's while not attached", async () => { + let { machine, share, visit, channel, url } = await dispatchServer(); + let box = await machine("rival", "U_rival", "build-box"); + await share("rival", box.id); + let laptop = await machine("member", "U_member", "laptop"); + await visit(); + expect(await laptop.poll([])).toEqual({ + kind: "planners", + planners: [{ id: channel.id, url }], + }); + expect(await box.poll([])).toEqual({ waiting: true }); + + // Another document starts on the teammate's machine while the creator has none online. + let other = await dispatchServer(); + let otherBox = await other.machine("rival", "U_rival", "build-box"); + await other.share("rival", otherBox.id); + await other.visit(); + expect(await otherBox.poll([])).toEqual({ + kind: "planners", + planners: [{ id: other.channel.id, url }], + }); + // The creator's own machine comes online before it attached: the Planner moves there. + let desk = await other.machine("member", "U_member", "desk"); + expect(await desk.poll([], 1_000)).toEqual({ + kind: "planners", + planners: [{ id: other.channel.id, url }], + }); + expect(await otherBox.poll([other.channel.id])).toEqual({ kind: "planners", planners: [] }); + }); + + it("keeps an attached Planner on the teammate's machine when the creator's own comes online", async () => { + let { machine, share, visit, channel, server, url } = await dispatchServer(); + let box = await machine("rival", "U_rival", "build-box"); + await share("rival", box.id); + await visit(); + expect(await box.poll([])).toEqual({ kind: "planners", planners: [{ id: channel.id, url }] }); + let link = await box.backend(); + await link.next("attached"); + let laptop = await machine("member", "U_member", "laptop"); + expect(await laptop.poll([])).toEqual({ waiting: true }); + expect(await box.poll([channel.id])).toEqual({ waiting: true }); + expect(server.attachments.holding(channel.id)?.machineId).toBe(box.id); + }); + + it("passes over a shared machine whose owner can't push to the repository", async () => { + let { machine, share, visit, choice } = await dispatchServer(); + // A protocol 5 machine connects for any repository, whatever its owner can push to. + let box = await machine("viewer", "U_viewer", "viewer-box", { protocol: 5 }); + expect(await share("viewer", box.id)).toBe(200); + await visit(); + expect(await box.poll([])).toEqual({ waiting: true }); + expect((await choice("member")).planner).toEqual({ state: "none", login: null, machine: null }); + }); + + it("lets only the owner share a machine", async () => { + let { machine, share } = await dispatchServer(); + let box = await machine("rival", "U_rival", "build-box"); + expect(await share("member", box.id)).toBe(404); + }); +}); + 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/storage/machine-contract.ts b/apps/server/src/storage/machine-contract.ts index 6435a5ee9..0817ac3b8 100644 --- a/apps/server/src/storage/machine-contract.ts +++ b/apps/server/src/storage/machine-contract.ts @@ -273,6 +273,32 @@ export function machineContract(name: string, factory: StorageFactory) { } }); + it("shares a machine with teammates only at its owner's word, and never once revoked", async () => { + let storage = await openedStorage(factory); + try { + let userId = await owner(storage); + let input = machine(userId); + let paired = machine(userId, { shared: true }); + await storage.machines.create(input); + await storage.machines.create(paired); + expect((await storage.machines.get(input.id))?.shared).toBeUndefined(); + expect((await storage.machines.get(paired.id))?.shared).toBe(true); + expect(await storage.machines.share(input.id, userId, true)).toBe(true); + expect((await storage.machines.get(input.id))?.shared).toBe(true); + expect(await storage.machines.share(input.id, userId, false)).toBe(true); + expect((await storage.machines.get(input.id))?.shared).toBeUndefined(); + let now = new Date("2026-01-05T03:04:05.000Z"); + let other = id("machine-teammate"); + await storage.users.put({ id: other, login: "teammate", avatarUrl: "", now }); + expect(await storage.machines.share(input.id, other, true)).toBe(false); + expect((await storage.machines.get(input.id))?.shared).toBeUndefined(); + await storage.machines.revoke(paired.id, userId, now); + expect(await storage.machines.share(paired.id, userId, true)).toBe(false); + } 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 33d0623b9..4bfd96ad6 100644 --- a/apps/server/src/storage/memory/machines.ts +++ b/apps/server/src/storage/memory/machines.ts @@ -33,7 +33,9 @@ export class MemoryMachineStore implements MachineStore { if (this.#machines.has(input.id) || this.#tokens.has(hex(input.tokenHash))) { throw conflict(`machine ${input.id} already exists`); } - this.#machines.set(input.id, copy(input)); + let stored = copy(input); + if (!stored.shared) delete stored.shared; + this.#machines.set(input.id, stored); this.#tokens.set(hex(input.tokenHash), input.id); } async get(id: string) { @@ -80,6 +82,13 @@ export class MemoryMachineStore implements MachineStore { found.label = label; return true; } + async share(id: string, userId: string, shared: boolean) { + let found = this.#machines.get(id); + if (!found || found.userId !== userId || found.revokedAt) return false; + if (shared) found.shared = true; + else delete found.shared; + return true; + } async revoke(id: string, userId: string, now: Date) { let found = this.#machines.get(id); if (!found || found.userId !== userId || found.revokedAt) return false; diff --git a/apps/server/src/storage/model.ts b/apps/server/src/storage/model.ts index 693e10438..4cd29a708 100644 --- a/apps/server/src/storage/model.ts +++ b/apps/server/src/storage/model.ts @@ -202,6 +202,11 @@ export type StoredMachine = { label: string; 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. + */ + shared?: boolean; tokenHash: Uint8Array; credentials?: MachineCredentials; createdAt: Date; diff --git a/apps/server/src/storage/port.ts b/apps/server/src/storage/port.ts index 5fa5efaa3..603b73be7 100644 --- a/apps/server/src/storage/port.ts +++ b/apps/server/src/storage/port.ts @@ -125,6 +125,8 @@ export interface MachineStore { rename(id: string, userId: string, label: string): Promise; /** Add a repository the machine connected a checkout of, unless it already serves it. */ serve(id: string, repository: MachineRepository): Promise; + /** The owner shares one of their machines with teammates, or stops; false when it isn't theirs. */ + share(id: string, userId: string, shared: boolean): Promise; /** Every choice of an unrevoked machine. Revoking a machine forgets its choices. */ choices(): Promise; /** Set one person's machine for one document, or without `machineId` go back to automatic. */ diff --git a/apps/server/src/storage/postgres/adapter.test.ts b/apps/server/src/storage/postgres/adapter.test.ts index cf17e44fe..caa9e9721 100644 --- a/apps/server/src/storage/postgres/adapter.test.ts +++ b/apps/server/src/storage/postgres/adapter.test.ts @@ -1014,6 +1014,7 @@ if (url) { "025_document_version_summaries", "026_workflow_run_stages", "027_decision_answers", + "028_machine_sharing", ]); expect([ ...await sql<{ table: string | null }[]>`SELECT to_regclass('channel_slugs') AS table`, diff --git a/apps/server/src/storage/postgres/machines.ts b/apps/server/src/storage/postgres/machines.ts index e68ecccf1..407f51e74 100644 --- a/apps/server/src/storage/postgres/machines.ts +++ b/apps/server/src/storage/postgres/machines.ts @@ -16,6 +16,7 @@ type MachineRow = { label: string; repositories: MachineRepository[] | string; autoRun: boolean; + shared: boolean; tokenHash: Uint8Array; ciphertext: Uint8Array | null; revision: string | number | null; @@ -25,7 +26,7 @@ type MachineRow = { revokedAt: Date | string | null; }; -const COLUMNS = `id, user_id AS "userId", label, repositories, auto_run AS "autoRun", +const COLUMNS = `id, user_id AS "userId", label, repositories, auto_run AS "autoRun", shared, token_hash AS "tokenHash", credential_ciphertext AS ciphertext, credential_revision AS revision, created_at AS "createdAt", last_seen_at AS "lastSeenAt", reauthorize_at AS "reauthorizeAt", revoked_at AS "revokedAt"`; @@ -39,6 +40,7 @@ function machine(row: MachineRow): StoredMachine { ? JSON.parse(row.repositories) : row.repositories, autoRun: row.autoRun, + ...(row.shared ? { shared: true } : {}), tokenHash: new Uint8Array(row.tokenHash), ...(row.ciphertext && row.revision !== null ? { @@ -61,13 +63,15 @@ export class PostgresMachineStore implements MachineStore { return this.run("pair machine", async () => { await this.sql` INSERT INTO machines ( - id, user_id, label, repositories, auto_run, token_hash, credential_ciphertext, + id, user_id, label, repositories, auto_run, shared, token_hash, credential_ciphertext, credential_revision, created_at, last_seen_at ) VALUES ( ${input.id}, ${input.userId}, ${input.label}, ${ JSON.stringify(input.repositories) }::text::jsonb, - ${input.autoRun}, ${input.tokenHash}, ${input.credentials?.ciphertext ?? null}, + ${input.autoRun}, ${input.shared ?? false}, ${input.tokenHash}, ${ + input.credentials?.ciphertext ?? null + }, ${input.credentials?.revision ?? null}, ${input.createdAt}, ${input.lastSeenAt} ) `; @@ -158,6 +162,16 @@ export class PostgresMachineStore implements MachineStore { return updated.length > 0; })); } + share(id: string, userId: string, shared: boolean) { + return this.run("share machine", async () => { + let updated = await this.sql<{ id: string }[]>` + UPDATE machines SET shared = ${shared} + WHERE id = ${id} AND user_id = ${userId} AND revoked_at IS NULL + RETURNING id + `; + return updated.length > 0; + }); + } serve(id: string, repository: MachineRepository) { return this.run("record a machine's repository", async () => { let entry = JSON.stringify([repository]); diff --git a/apps/server/src/storage/postgres/migrations.ts b/apps/server/src/storage/postgres/migrations.ts index bd09edb13..00c770e57 100644 --- a/apps/server/src/storage/postgres/migrations.ts +++ b/apps/server/src/storage/postgres/migrations.ts @@ -103,6 +103,9 @@ const MIGRATIONS = [{ }, { id: "027_decision_answers", path: join(import.meta.dir, "migrations/027_decision_answers.sql"), +}, { + id: "028_machine_sharing", + path: join(import.meta.dir, "migrations/028_machine_sharing.sql"), }] satisfies Migration[]; /** Navigation shipped as 002 before document slugs claimed that number on main. */ diff --git a/apps/server/src/storage/postgres/migrations/028_machine_sharing.sql b/apps/server/src/storage/postgres/migrations/028_machine_sharing.sql new file mode 100644 index 000000000..78113505c --- /dev/null +++ b/apps/server/src/storage/postgres/migrations/028_machine_sharing.sql @@ -0,0 +1 @@ +ALTER TABLE machines ADD COLUMN shared boolean NOT NULL DEFAULT false; diff --git a/apps/web/src/design-audit/icons.tsx b/apps/web/src/design-audit/icons.tsx index d8adea86c..0c01f7202 100644 --- a/apps/web/src/design-audit/icons.tsx +++ b/apps/web/src/design-audit/icons.tsx @@ -24,6 +24,7 @@ import { SignInIcon, SirenIcon, SparkleIcon, + UsersIcon, WarningIcon, WrenchIcon, } from "@chopin/icons"; @@ -84,6 +85,7 @@ const NUCLEO_ICONS: readonly IconCatalogueItem[] = [ { icon: LoaderIcon, name: "Loader" }, { icon: MachineIcon, name: "Machine" }, { icon: ServerIcon, name: "Server" }, + { icon: UsersIcon, name: "Users" }, ]; export function IconCatalogue() { diff --git a/apps/web/src/experiments/add-server.tsx b/apps/web/src/experiments/add-server.tsx index b67852474..9116dbe00 100644 --- a/apps/web/src/experiments/add-server.tsx +++ b/apps/web/src/experiments/add-server.tsx @@ -22,13 +22,19 @@ import { import type { ReactNode } from "react"; import type { CodingAgent } from "@chopin/protocol/agents"; -import type { PairingKey } from "./pairing-key"; +import type { KeyChoices, PairingKey } from "./pairing-key"; import type { SetupSystem } from "./setup-commands"; /** The server page's choices, kept for this tab across the trip to GitHub and back. */ const SETTINGS_KEY = "chopin:machines:server"; -type Settings = { system?: SetupSystem; background?: boolean; autoRun?: boolean; name?: string }; +type Settings = { + system?: SetupSystem; + background?: boolean; + autoRun?: boolean; + shared?: boolean; + name?: string; +}; function storedSettings(): Settings { try { @@ -78,9 +84,9 @@ function Setting( /** What stands in the command's place before there is a key to put in it. */ function Generate( - { autoRun, generate, pairing }: { - autoRun: boolean; - generate: (autoRun: boolean) => void; + { choices, generate, pairing }: { + choices: KeyChoices; + generate: (choices: KeyChoices) => void; pairing: Exclude; }, ) { @@ -91,7 +97,7 @@ function Generate( actions={problem.retry && ( + ); +} + function copiesFor(machine: Machine, recommended: string | null): CopyItem[] { return [ ...(updateAvailable(machine.version, recommended) @@ -325,6 +353,9 @@ function MachineTable( Host Version Agent + + Teammates + Last seen Actions @@ -334,14 +365,14 @@ function MachineTable( {!list.length && ( - No machines match. + No machines match. )} {groupMachines(list).map(group => ( - + {GROUP_LABEL[group.kind]} @@ -413,6 +444,12 @@ function MachineTable( {machine.planner && Planner} + + void machines.share(machine.id, shared)} + /> + @@ -640,10 +677,11 @@ export function MachineDetail( ], [ "Runs", - [machine.service && "stays connected", machine.autoRun && "builds automatically"].filter( - Boolean, - ) - .join(", "), + [ + machine.service && "stays connected", + machine.autoRun && "builds automatically", + machine.shared && "plans teammates' documents", + ].filter(Boolean).join(", "), ], ]; return ( @@ -674,6 +712,20 @@ export function MachineDetail( {machine.favorite ? "Remove favorite" : "Set as favorite"} +
  • + +
  • {copiesFor(machine, machines.recommended).map(item => (