Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/orgo-plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions lib/server/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -294,7 +294,7 @@ async function readPlan(key: string): Promise<OrgoPlan | null> {
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). */
Expand Down Expand Up @@ -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.
Expand All @@ -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<MainComputer | null> {
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);
Expand Down
50 changes: 48 additions & 2 deletions scripts/test-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) } });
Expand All @@ -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");

Expand Down Expand Up @@ -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`);
Expand Down
Loading