diff --git a/lib/orgo-plans.ts b/lib/orgo-plans.ts index 7b5faef4..4e6876ea 100644 --- a/lib/orgo-plans.ts +++ b/lib/orgo-plans.ts @@ -34,10 +34,16 @@ export type OrgoPlan = { deal?: boolean; /** * The user's one free Bops computer (orgo-web's bops_free, not counted in `inUse`): its id, or null - * while there's none (Bops makes it next, for a main bot). Unknown when Orgo didn't say (an Orgo - * from before free Bops computers): then every computer is on the plan, as before. + * while there's none (Bops makes it next, for a main bot). Unknown when Orgo didn't say: an Orgo + * from before free Bops computers, where every computer is on the plan as before, or one that + * didn't answer (limitsUnread). */ freeComputerId?: string | null; + /** + * Orgo didn't answer for the counts, the memory and the free Bops computer (compute-limits failed or + * timed out, or the user owns no workspace to ask about): unknown for now, not missing from Orgo. + */ + limitsUnread?: true; /** * The Bops plan's computers, when the Bops plan decides them (Bops Cloud's plan limits are on, and * Orgo counts them: compute-limits' bops_computers_limit): the plan, how many Bops computers it diff --git a/lib/server/plan.ts b/lib/server/plan.ts index 30eb8d52..8fa17a36 100644 --- a/lib/server/plan.ts +++ b/lib/server/plan.ts @@ -6,7 +6,7 @@ import { botChatId, freeComputerBot, workspaceOf, type Bot } from "@/lib/types"; import { bopsComputerShort, bopsComputers, computerRam, mainOwnShort, ownComputerShort, ownOnOrgoPlan, planComputers, planFix, planName, planShort, planShortText, type OrgoPlan, type PlanShort } from "@/lib/orgo-plans"; import { orgoHeaders } from "./app-version"; import { cloudOn, cloudSessionNow } from "./cloud"; -import { callOrgo, computerChanges, orgo, OrgoError, ownedWorkspace } from "./orgo"; +import { callOrgo, computerChanges, orgo, orgoProjectFor, OrgoError, ownedWorkspace } from "./orgo"; import { loadOrgoKey, orgoOrigin } from "./orgo-auth"; import { addMessage, getState, stateEpoch, update } from "./store"; @@ -294,7 +294,7 @@ async function readPlan(key: string): Promise { session?.limits && typeof l?.bops_computers_limit === "number" ? { bops: { tier: session.tier, limit: l.bops_computers_limit, extras: Array.isArray(l.bops_max_computer_ids) ? l.bops_max_computer_ids.filter((x): x is string => typeof x === "string") : [] } } : {}; - return { tier, name: tier ? planName(tier) : undefined, computers, inUse: counted?.inUse, ...(memory ? { memory } : {}), ...(deal ? { deal } : {}), ...free, ...bops }; + return { tier, name: tier ? planName(tier) : undefined, computers, inUse: counted?.inUse, ...(memory ? { memory } : {}), ...(deal ? { deal } : {}), ...free, ...bops, ...(l ? {} : { limitsUnread: true as const }) }; } /** Orgo's codes for a computer turned down for the plan (orgo-web lib/quota.ts validateComputerLimits, the create, fork and clone routes). */ @@ -428,6 +428,25 @@ async function planNow(short: (plan: OrgoPlan | null) => PlanShort | null | unde return { plan, short: short(plan) }; } +/** + * The plan, with whether the user's free Bops computer is made known. When Orgo didn't answer for it + * (limitsUnread, or nothing came back), it's asked again, after the main bot's workspace is found or + * made, in case the user owned none to ask about. Still unknown, nothing is made: a computer on the + * plan in its place would take the plan's room, or be refused for it, while the free one is still + * theirs. Not signed in (a self-hosted install on its own key), Orgo's own limit decides, as before. + */ +async function freeKnown(main: Bot, epoch: number) { + const plan = await orgoPlan(); + if (!(await loadOrgoKey()) || (plan && !plan.limitsUnread)) return plan; + // Found or made for this user only: another's state may have come in while Orgo was asked. + sameUser(epoch); + await orgoProjectFor(workspaceOf(main)); + sameUser(epoch); + const again = await orgoPlan({ fresh: true }); + if (again && !again.limitsUnread) return again; + throw new Error("Orgo didn't say whether your free Bops computer is made yet"); +} + /** * Work for one user's state (stateEpoch) stops once a hosted server has swapped in another's: their bots * have the same ids, and Orgo is asked with their key now. @@ -446,12 +465,13 @@ export type MainComputer = { id: string; free?: boolean }; * - Else one on the plan, at the plan's size (computerRam), when the plan has room for it. * - Else, when another workspace's main bot has the free computer, this one works there too (null, * and it says so in its chat). Only one free computer is ever made. - * Throws a PlanLimit, in plain words, when none of those can be, or Orgo turns it down for the plan. + * Throws a PlanLimit, in plain words, when none of those can be, or Orgo turns it down for the plan, + * and an Error when Orgo doesn't say whether the free one is made (freeKnown). */ export function makeMainComputer(main: Bot, name: string, epoch = stateEpoch()): Promise { return oneAtATime(async () => { sameUser(epoch); - const first = await orgoPlan(); + const first = await freeKnown(main, epoch); if (first?.freeComputerId === null) return makeFree(main, name, epoch); const kept = first?.freeComputerId; if (kept && !getState().bots.some((b) => b.computerId === kept)) return holdFree(main, kept, epoch); diff --git a/scripts/test-plan.mjs b/scripts/test-plan.mjs index 9368562e..d1c45456 100644 --- a/scripts/test-plan.mjs +++ b/scripts/test-plan.mjs @@ -139,7 +139,7 @@ assert.deepEqual(plain(await L.orgoPlan({ fresh: true })), { computers: 1_000_00 for (const [tier, deal, computers] of [["hacker_v2", null, 1], ["hacker", null, 5], ["team", null, 10], ["enterprise", { maxDesktops: 3 }, 3], ["free", null, 0]]) { onPlan(tier, 0, 0, { deal, replies: { "GET /api/billing/compute-limits": () => json(503, { error: "Compute limits are unavailable" }) } }); const p = await L.orgoPlan({ fresh: true }); - assert.deepEqual(plain(p), { tier, name: P.planName(tier), computers, ...(deal ? { deal: true } : {}) }, tier); + assert.deepEqual(plain(p), { tier, name: P.planName(tier), computers, ...(deal ? { deal: true } : {}), limitsUnread: true }, tier); assert.equal(P.planShort(p), undefined, `${tier}: unknown in use holds nothing back`); } onPlan("hacker_v2", 1, 1, { replies: { "GET /api/billing/compute-limits": () => json(200, { max_cpu: 1, max_ram_gb: 8 }) } }); @@ -163,7 +163,7 @@ await L.orgoPlan({ fresh: true }); assert.equal(calls.filter((c) => c.path === "/api/billing/compute-limits").at(-1).query, "?workspace_id=ws_mine"); onPlan("hacker_v2", 1, 0, { replies: { "GET /api/workspaces": () => json(200, { workspaces: [{ id: "ws_shared", name: "Team", role: "member" }] }) } }); n = asked("GET", "/api/billing/compute-limits"); -assert.deepEqual(plain(await L.orgoPlan({ fresh: true })), { tier: "hacker_v2", name: "Hacker", computers: 1 }, "owns no workspace: the plan's own count"); +assert.deepEqual(plain(await L.orgoPlan({ fresh: true })), { tier: "hacker_v2", name: "Hacker", computers: 1, limitsUnread: true }, "owns no workspace: the plan's own count"); assert.equal(asked("GET", "/api/billing/compute-limits"), n); assert.equal(asked("POST", "/api/workspaces"), 0, "reading the plan never makes a workspace"); @@ -987,6 +987,52 @@ await X.ensureComputer("kai"); assert.equal(kai().computer, "shared"); assert.equal(workBotOf(kai()).id, "boppy"); +// One read of the plan that Orgo didn't answer (compute-limits failed, or the 8 s wait ran out) when the +// main bot's computer is made: Orgo is asked again, and the free one is made, never one on the plan in +// its place. Before, Hacker with its one computer in use outside Bops had that one refused, and the setup +// said the plan was full; with room, it took the plan's computer, and the free one was never made. +for (const inUse of [1, 0]) { + S.update(() => { + Object.assign(main(), { computerId: undefined, freeComputer: undefined, computerStatus: "none" }); + Object.assign(kai(), { computer: undefined, computerId: undefined, computerStatus: "none" }); + }); + fo = freeOrgo("hacker_v2", 1, { inUse }); + const limits = fo.replies["GET /api/billing/compute-limits"]; + let read = 0; + fo.replies["GET /api/billing/compute-limits"] = (c) => (read++ ? limits(c) : json(503, { error: "Failed to load compute limits" })); + n = calls.length; + assert.deepEqual(plain(await L.makeMainComputer(main(), "boppy-unread")), { id: "c-free-1", free: true }, `${inUse} in use outside Bops`); + assert.deepEqual( + calls.slice(n).filter((c) => c.method === "POST" && c.path === "/api/computers").map((c) => c.body.bops_free === true), + [true], + "only the free one is asked for", + ); + assert.equal(fo.inUse, inUse, "nothing on the plan"); +} +// Orgo doesn't answer again: nothing is made, and the setup says why (the next task tries again). +S.update(() => Object.assign(main(), { computerId: undefined, freeComputer: undefined, computerStatus: "none" })); +fo = freeOrgo("hacker_v2", 1); +fo.replies["GET /api/billing/compute-limits"] = () => json(503, { error: "Failed to load compute limits" }); +n = asked("POST", "/api/computers"); +await assert.rejects(L.makeMainComputer(main(), "boppy-unread"), { message: "Orgo didn't say whether your free Bops computer is made yet" }); +assert.equal(asked("POST", "/api/computers"), n, "nothing made"); +assert.equal(fo.inUse, 0); +// Another user's state swapped in (a hosted server) while that read is out, for a user who owns no +// workspace yet: nothing is made for either, the main bot's workspace included. +fo = freeOrgo("hacker_v2", 1); +fo.replies["GET /api/workspaces"] = () => json(200, { workspaces: [] }); +const subscription = fo.replies["GET /api/user/subscription"]; +const unswapped = S.getState(); +fo.replies["GET /api/user/subscription"] = (c) => { + if (S.getState() === unswapped) swap(JSON.parse(JSON.stringify(unswapped))); + return subscription(c); +}; +globalThis.bopsOrgoWorkspace = undefined; +n = calls.length; +await assert.rejects(L.makeMainComputer(main(), "boppy-swapped"), { message: "Another Orgo account signed in meanwhile" }); +assert.deepEqual(calls.slice(n).filter((c) => c.method === "POST").map((c) => c.path), [], "no workspace or computer made"); +swap(unswapped); + /* ---------------- A computer deleted on Orgo's site: the bot moves on, once ---------------- */ const V = await import(`${root}/app/api/vnc/route.ts`);