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 => (