diff --git a/CLAUDE.md b/CLAUDE.md index a793b461..8d4bcedf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ conformance tests on BOTH sides. Never hand-edit one side of a contract. | connection pull v1 | CP producer `core/connections/pull-wire.ts`, routes in `core/connections/pull-routes.ts` (`GET /workspaces/self/connections`, `POST /workspaces/self/connections/:name/token`) ↔ Go consumer `broker/internal/workspace/connections.go`, printed by `blitz-cred list\|get\|env`. Carries BOTH credential planes: the member's own connection grant and the workspace credential store (plans/MEMBER-MACHINES.md §4) | `fixtures/connection-pull/` | `test/connection-pull-conformance.test.ts` + `test/pull-credentials.test.ts` + `test/member-machines.test.ts` (CP), `broker/internal/workspace/connections_test.go` + `broker/cmd/blitz-cred/main_test.go` (box) | | entitlements | CP `core/entitlements.ts` (`PUT /orgs/:id/entitlements` writer, `GET /orgs/:id/usage`, the 402 seat-limit refusal and its HS256 handoff token) ↔ the PRIVATE billing service, which owns plans, writes the integers, and verifies the token — core never learns a plan name | `fixtures/entitlements/` | `test/entitlements-fixtures.test.ts` (CP); the billing service copies the corpus and pins it on its side | | recipe invocation files | `core/bootstrap.ts` writer (recipe launches emit `/var/lib/blitz/recipe/prompt.txt` + `invocation.env`) ↔ guest readers: `blitz-term` through the shared parser `box/rootfs/usr/local/libexec/blitz-recipe-invocation`, plus the bootstrap-emitted chat sender's raw `prompt.txt` read (the sender never parses `invocation.env` — model/effort/permission are interpolated into its source at render time) | `fixtures/recipe-invocation/` | `test/recipe-invocation-fixtures.test.ts` (CP), `box/actor/test/recipe-invocation-guest.test.ts` (guest: shared parser vs corpus + blitz-term delivery semantics) | +| machine-stats | guest producer `box/rootfs/usr/local/bin/blitz-machine-stats` (s6 longrun `machine-stats`, one report every 10 min) ↔ CP consumer `core/machine-stats.ts` (`POST /workspaces/self/machine-stats`), which fills `machines.disk_used_percent` and surfaces as `MachineView.volumeUsedPercent` | `fixtures/machine-stats/` | `test/machine-stats-conformance.test.ts` (CP), `box/actor/test/machine-stats-conformance.test.ts` (guest: the real script against a local origin) | | box config v1 | CP `core/box-config.ts` producer (`GET /workspaces/self/box-config`) and consumer (`POST /workspaces/self/box-update-result`) ↔ host updater bash/python emitted by `core/bootstrap.ts` (`blitz-box-update`; cloud-VM path only — the microVM provider has its own guest lifecycle and no update path yet) | `fixtures/box-config/` | `test/box-config-conformance.test.ts` (CP), `test/box-update-conformance.test.mjs` (runs real `python3` over the emitted parser/producer, `bash -n` over the emitted scripts), `test/box-update-host.test.mjs` (runs the emitted updater in real bash against a live CP over real curl) | Retired: the `workspace environment` contract (`GET /workspaces/self/environment` @@ -129,6 +130,29 @@ The page components (`TemplatesHome`, `RecipesHome`, `CreateTemplateScreen`, `CreateRecipeScreen`), the client methods and the recipe rows are untouched and unreachable. Restoring a surface means restoring both branches. +## Settings surface style (webapp) + +`packages/webapp/src/settings-surface.css` is canon for every +settings-shaped screen: the workspace-details dialog and its three tabs, +"My machine", the account settings page and its panels, and the section +headings of the create-workspace dialog. One class prefix, `cfg-`; the rules +and the whole vocabulary are documented at the top of that file. Read it +before restyling a settings surface, and extend it rather than starting a +seventh heading treatment somewhere else. + +The four rules a change must not break: + +- The tabbed dialog has ONE fixed height. `.workspace-details-dialog` sets + `height`, not `max-height`; the body scrolls; switching tabs never moves + the frame. +- Section titles are sentence case, never all-caps, always `--cfg-title-*` + (the ink white and size the "Agent rules" heading had). Descriptions are + `--cfg-desc-*`. Field micro-labels are sentence case too. +- Exactly one thin divider between two adjacent sections, drawn by + `.cfg-section ~ .cfg-section` and by nothing else. Card outlines and + list-row separators are structure, not dividers. +- No new colours: everything resolves to a token in `tokens.css`. + ## VM provider architecture (do not regress) - The plugin contract is `VmProvider` in `core/compute/types.ts`: diff --git a/packages/box/Dockerfile b/packages/box/Dockerfile index f164e33c..6b499134 100644 --- a/packages/box/Dockerfile +++ b/packages/box/Dockerfile @@ -141,7 +141,8 @@ COPY packages/box/rootfs/ / RUN set -eux; \ chmod 0755 /usr/local/bin/blitz /usr/local/bin/claude /usr/local/bin/codex \ /usr/local/bin/blitz-cred-claude /usr/local/bin/blitz-cred-codex \ - /usr/local/bin/blitz-rules /usr/local/bin/blitz-cgroup; \ + /usr/local/bin/blitz-rules /usr/local/bin/blitz-cgroup \ + /usr/local/bin/blitz-machine-stats; \ chmod 0755 /usr/local/libexec/blitz-* /etc/s6-overlay/s6-rc.d/*/run; \ install -d -m 0755 /workspace /var/lib/blitz /srv/blitz-files; \ ln -s /workspace /srv/blitz-files/workspace; \ diff --git a/packages/box/actor/test/machine-stats-conformance.test.ts b/packages/box/actor/test/machine-stats-conformance.test.ts new file mode 100644 index 00000000..20c3d51c --- /dev/null +++ b/packages/box/actor/test/machine-stats-conformance.test.ts @@ -0,0 +1,212 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +/** Producer side of the `machine-stats` cross-runtime contract. The + * control-plane consumer is pinned against the same fixtures in + * packages/control-plane/test/machine-stats-conformance.test.ts; here the real + * `blitz-machine-stats report` script runs against a local origin and what it + * posts is checked against the corpus accept rule. */ + +interface StatsFixture { + request: Record; + accepts: boolean; +} + +const scriptPath = fileURLToPath( + new URL("../../rootfs/usr/local/bin/blitz-machine-stats", import.meta.url), +); +const fixturesDirectory = fileURLToPath( + new URL("../../../schema/fixtures/machine-stats/", import.meta.url), +); + +function fixtureNames(): string[] { + return readdirSync(fixturesDirectory).filter((name) => name.endsWith(".json")).sort(); +} + +function readFixture(name: string): StatsFixture { + // SAFETY: The machine-stats fixtures are trusted local test data authored to + // the { request, accepts } shape; the control-plane consumer test pins the + // same corpus. + return JSON.parse(readFileSync(join(fixturesDirectory, name), "utf8")) as StatsFixture; +} + +/** The corpus accept rule, restated here so the producer is checked against + * the same sentence the consumer implements: an object whose `diskUsedPercent` + * is an integer 0-100. The fixture list is the proof that this restatement and + * the control plane's agree. */ +function accepts(body: unknown): boolean { + if (typeof body !== "object" || body === null || Array.isArray(body)) return false; + const percent = (body as { diskUsedPercent?: unknown }).diskUsedPercent; + return typeof percent === "number" && Number.isInteger(percent) + && percent >= 0 && percent <= 100; +} + +interface BoxState { + stateDir: string; +} + +function makeState(origin: string | null, token: string | null): BoxState { + const stateDir = mkdtempSync(join(tmpdir(), "stats-state-")); + if (origin !== null) writeFileSync(join(stateDir, "origin"), `${origin}\n`); + if (token !== null) { + writeFileSync( + join(stateDir, "box-credential.json"), + `${JSON.stringify({ box_id: "box", access_token: token, refresh_token: "refresh" })}\n`, + ); + } + return { stateDir }; +} + +interface RunResult { + status: number | null; + stderr: string; +} + +function runReport(state: BoxState): Promise { + return new Promise((resolve) => { + const child = spawn("sh", [scriptPath, "report"], { + env: { ...process.env, BLITZ_STATE_DIR: state.stateDir }, + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (status) => resolve({ status, stderr: stderr.trim() })); + }); +} + +interface Posted { + authorization: string | undefined; + contentType: string | undefined; + body: unknown; +} + +const servers: Server[] = []; + +function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise { + const server = createServer(handler); + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + // SAFETY: The callback runs after binding an explicit TCP host/port, so + // address() is an AddressInfo, never a pipe string or null. + const address = server.address() as AddressInfo; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +} + +/** A stand-in control plane that records the one report it is sent. */ +function collector(status: number, posted: Posted[]): Promise { + return startServer((req, res) => { + if (req.url !== "/workspaces/self/machine-stats" || req.method !== "POST") { + res.writeHead(404); + res.end(); + return; + } + if (req.headers.authorization !== "Bearer good-token") { + res.writeHead(401); + res.end(); + return; + } + let raw = ""; + req.on("data", (chunk) => { + raw += String(chunk); + }); + req.on("end", () => { + let body: unknown = null; + try { + body = JSON.parse(raw); + } catch { + body = raw; + } + posted.push({ + authorization: req.headers.authorization, + contentType: req.headers["content-type"], + body, + }); + res.writeHead(status); + res.end(); + }); + }); +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))), + ); +}); + +describe("blitz-machine-stats producer contract", () => { + it("pins the shared machine-stats fixture corpus", () => { + expect(fixtureNames()).toEqual([ + "invalid-fractional-percent.json", + "invalid-missing-percent.json", + "invalid-negative-percent.json", + "invalid-null-percent.json", + "invalid-over-hundred.json", + "invalid-string-percent.json", + "valid-extra-key.json", + "valid-full.json", + "valid-mid.json", + "valid-zero.json", + ]); + }); + + it("agrees with the corpus about which bodies the control plane takes", () => { + for (const name of fixtureNames()) { + const fixture = readFixture(name); + expect(accepts(fixture.request), name).toBe(fixture.accepts); + } + }); + + it("posts a body the control plane accepts, with the box credential", async () => { + const posted: Posted[] = []; + const origin = await collector(204, posted); + const state = makeState(origin, "good-token"); + + const result = await runReport(state); + + expect(result.status, result.stderr).toBe(0); + expect(posted).toHaveLength(1); + const report = posted[0]; + expect(report?.authorization).toBe("Bearer good-token"); + expect(report?.contentType).toBe("application/json"); + expect(accepts(report?.body)).toBe(true); + // The one key the contract names, and no other: an integer percentage + // measured off a real filesystem, this test's own temporary directory. + expect(Object.keys(report?.body as Record)).toEqual(["diskUsedPercent"]); + }); + + it("stays quiet and succeeds when there is nothing to report with", async () => { + const posted: Posted[] = []; + const origin = await collector(204, posted); + + for (const state of [makeState(null, "good-token"), makeState(origin, null)]) { + const result = await runReport(state); + expect(result.status, result.stderr).toBe(0); + } + expect(posted).toHaveLength(0); + }); + + it("fails open on a refused or broken control plane", async () => { + const posted: Posted[] = []; + const refusing = await collector(204, posted); + // Wrong token: the collector answers 401 before it records anything. + expect((await runReport(makeState(refusing, "wrong-token"))).status).toBe(0); + expect(posted).toHaveLength(0); + + const failing = await collector(500, posted); + expect((await runReport(makeState(failing, "good-token"))).status).toBe(0); + + // Nothing is listening on this port, so the fetch itself fails. + const dead = makeState("http://127.0.0.1:1", "good-token"); + expect((await runReport(dead)).status).toBe(0); + }); +}); diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register @@ -0,0 +1 @@ + diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run new file mode 100644 index 00000000..107a4548 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run @@ -0,0 +1,15 @@ +#!/command/with-contenv bash +# Report the state directory's disk usage to the control plane, every ten +# minutes, forever. +# +# The loop lives here rather than in the script so the script stays one shot +# and one thing: `blitz-machine-stats report` measures once, posts once, and +# exits 0 whatever happened. A disk figure is worth nothing next to the box it +# describes, so nothing in this service may take the box down with it — hence +# the `|| true` and the sleep that follows every outcome alike. +set -uo pipefail + +while true; do + /usr/local/bin/blitz-machine-stats report || true + sleep 600 +done diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type @@ -0,0 +1 @@ +longrun diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats @@ -0,0 +1 @@ + diff --git a/packages/box/rootfs/usr/local/bin/blitz-machine-stats b/packages/box/rootfs/usr/local/bin/blitz-machine-stats new file mode 100755 index 00000000..2dd20490 --- /dev/null +++ b/packages/box/rootfs/usr/local/bin/blitz-machine-stats @@ -0,0 +1,109 @@ +#!/bin/sh +set -eu + +usage() { + echo 'usage: blitz-machine-stats report' >&2 + exit 2 +} + +action=${1:-} +[ "$action" = report ] || usage +shift +[ "$#" -eq 0 ] || usage + +# The state directory is the mount point of the member's persistent volume when +# there is one, so measuring it measures the disk the member keeps files on. +# The conformance test overrides it to point at a scratch directory. +state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} + +exec node - "$state_dir" <<'NODE' +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const [stateDir] = process.argv.slice(2); +const TIMEOUT_MS = 10_000; + +// Every failure below is expected: a box with no credential yet, an origin +// that is unreachable, a df that will not answer. Say why on stderr and exit 0. +// A disk figure is worth exactly nothing next to the service it reports on, so +// nothing here may ever fail a boot or restart a supervised process. +function giveUp(reason) { + console.error(`blitz-machine-stats: no report (${reason})`); + process.exit(0); +} + +function read(name) { + try { + return fs.readFileSync(path.join(stateDir, name), 'utf8'); + } catch (error) { + giveUp(`no ${name} (${error && error.code ? error.code : 'unreadable'})`); + } +} + +function origin() { + const value = read('origin').split('\n', 1)[0].trim(); + if (value === '') giveUp('empty origin'); + return value.replace(/\/+$/, ''); +} + +function accessToken() { + let parsed; + try { + parsed = JSON.parse(read('box-credential.json')); + } catch { + giveUp('unparseable credential'); + } + const token = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed.access_token + : undefined; + if (typeof token !== 'string' || token.length === 0) giveUp('credential has no access_token'); + return token; +} + +// `df --output=pcent DIR` prints a header line and then one percentage, e.g. +// " 62%". The control plane takes an integer 0-100 and refuses anything else +// (packages/schema/fixtures/machine-stats/), so a line that does not parse is +// not sent at all. +function diskUsedPercent() { + let output; + try { + output = execFileSync('df', ['--output=pcent', stateDir], { + encoding: 'utf8', + timeout: TIMEOUT_MS, + }); + } catch (error) { + giveUp(`df failed (${error && error.message ? error.message : 'unknown'})`); + } + const figure = String(output).trim().split('\n').pop().trim(); + // Anchored, so an empty line cannot become 0%: Number('') is 0, and 0% is + // the one wrong answer that looks like a right one. + const matched = /^(\d{1,3})%$/.exec(figure); + const percent = matched === null ? -1 : Number(matched[1]); + if (percent < 0 || percent > 100) giveUp(`df said ${JSON.stringify(figure)}`); + return percent; +} + +async function main() { + const target = `${origin()}/workspaces/self/machine-stats`; + const token = accessToken(); + const diskUsed = diskUsedPercent(); + let response; + try { + response = await fetch(target, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ diskUsedPercent: diskUsed }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (error) { + giveUp(`fetch failed (${error && error.message ? error.message : 'network'})`); + } + if (!response.ok) giveUp(`http ${response.status}`); + console.error(`blitz-machine-stats: reported ${diskUsed}%`); + process.exit(0); +} + +void main(); +NODE diff --git a/packages/control-plane/core/app.ts b/packages/control-plane/core/app.ts index 74f772a5..af10c71e 100644 --- a/packages/control-plane/core/app.ts +++ b/packages/control-plane/core/app.ts @@ -7,6 +7,7 @@ import { addEntitlementsRoutes, SeatLimitReached, seatLimitEnvelope } from "./en import { frameworkHttpError, HttpError } from "./http.js"; import { addFilesRoutes } from "./files/routes.js"; import { addMachineRoutes } from "./machines.js"; +import { addMachineStatsRoutes } from "./machine-stats.js"; import { addIdentityRoutes } from "./identity/routes.js"; import { addOAuthRoutes } from "./oauth.js"; import { addOperatorTokenRoutes, findOperatorTokenPrincipal } from "./operator-tokens.js"; @@ -105,6 +106,9 @@ export function installControlPlaneRoutes( // same reason; its one session route (/workspaces/:id/box-update) collides // with nothing. addBoxConfigRoutes(router, runtimeFactory, requireMembershipPrincipal); + // Box-authenticated too, and registered here for the same prefix reason: the + // guest's own disk report (packages/schema/fixtures/machine-stats/). + addMachineStatsRoutes(router, runtimeFactory); // Registered before addWorkspaceRoutes: /workspaces/:id/members and // /workspaces/:id/credentials are literal paths under the same prefix. addWorkspaceMemberRoutes(router, runtimeFactory, requireMembershipPrincipal); diff --git a/packages/control-plane/core/machine-stats.ts b/packages/control-plane/core/machine-stats.ts new file mode 100644 index 00000000..bcd6b11a --- /dev/null +++ b/packages/control-plane/core/machine-stats.ts @@ -0,0 +1,67 @@ +import { changed } from "./db.js"; +import { HttpError, isNumber, isRecord, readJson, type JsonValue } from "./http.js"; +import { authenticateBox } from "./oauth.js"; +import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; +import type { MachineStatsRequest } from "./wire.js"; + +// The machine-stats contract (see packages/schema/fixtures/machine-stats/): +// the guest measures the filesystem holding its state directory every ten +// minutes and posts `{ diskUsedPercent }` here with its box credential. The +// producer is `box/rootfs/usr/local/bin/blitz-machine-stats`; both sides are +// pinned against that corpus. Edit the accept rule below and the guest's copy +// of it together, never one alone. + +/** The body cap. One integer in one object needs nothing like this much room; + * the slack is there so a forward-compatible guest can add a field without + * this route refusing the whole report. */ +const MAX_BODY_BYTES = 4 * 1024; + +/** + * Accepts iff the body is an object whose `diskUsedPercent` is an integer + * between 0 and 100 inclusive. + * + * Everything else is a 400, including a float and a numeric string. A guest + * that cannot measure its disk must send nothing at all — a wrong number + * would overwrite the last true one, and the column has no way to say "this + * figure is a guess". Unknown extra keys are tolerated, because a newer guest + * reporting more than this control plane knows about must still land its + * percentage. + */ +export function parseMachineStats(value: JsonValue): MachineStatsRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const percent = value.diskUsedPercent; + if (!isNumber(percent) || !Number.isInteger(percent)) { + throw new HttpError(400, "diskUsedPercent must be an integer"); + } + if (percent < 0 || percent > 100) { + throw new HttpError(400, "diskUsedPercent must be between 0 and 100"); + } + return { diskUsedPercent: percent }; +} + +export function addMachineStatsRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, +): void { + // Box-authenticated, like every other /workspaces/self/* route: the machine + // reports on itself, so the row it writes is the one its credential names + // and no id crosses the wire. + router.post("/workspaces/self/machine-stats", async (context: CoreContext) => { + const runtime = runtimeFactory(context); + const box = await authenticateBox(context.req.raw, runtime.db); + if (box === null) throw new HttpError(401, "invalid box access token"); + if (box.workspaceId === null) { + throw new HttpError(403, "only workspace machines report machine stats"); + } + const input = parseMachineStats(await readJson(context.req.raw, MAX_BODY_BYTES)); + // No `updated_at` bump and no workspace revision bump: a disk figure that + // moves one point must not wake every poller in the organization, and the + // next poll carries it anyway. + await changed(runtime.db, { + q: `UPDATE machines SET disk_used_percent = ?1, disk_reported_at = ?2 + WHERE id = ?3 RETURNING id`, + v: [input.diskUsedPercent, Date.now(), box.id], + }); + return context.body(null, 204); + }); +} diff --git a/packages/control-plane/core/wire-machines.ts b/packages/control-plane/core/wire-machines.ts index a08f6f09..78f75afb 100644 --- a/packages/control-plane/core/wire-machines.ts +++ b/packages/control-plane/core/wire-machines.ts @@ -33,12 +33,25 @@ export interface MachineView { /** This machine's type. The workspace holds only a default. */ machineTypeId: string; volumeId: string | null; + /** How full the machine's persistent volume is, 0-100, as the guest last + * measured it. Null means the question has no answer yet: there is no + * volume, or no guest has reported one (every box image before the reporter + * shipped). Null is never 0 — an unmeasured disk is not an empty one. */ + volumeUsedPercent: number | null; membershipId: string; error: string | null; createdAt: number; updatedAt: number; } +/** The guest's own disk report (`POST /workspaces/self/machine-stats`). + * `diskUsedPercent` is an integer 0-100, the used percentage of the filesystem + * holding the state directory. Anything else is a 400: a machine reporting + * nonsense about its disk must not overwrite the last true figure. */ +export interface MachineStatsRequest { + diskUsedPercent: number; +} + export interface WorkspaceMemberView { membershipId: string; name: string; diff --git a/packages/control-plane/core/wire.ts b/packages/control-plane/core/wire.ts index 2bcae433..fdcf9565 100644 --- a/packages/control-plane/core/wire.ts +++ b/packages/control-plane/core/wire.ts @@ -181,6 +181,7 @@ export { type AddWorkspaceMemberRequest, type MachineResponse, type MachineState, + type MachineStatsRequest, type MachineView, type ProvisionMemberMachineRequest, type PutWorkspaceCredentialRequest, diff --git a/packages/control-plane/core/workspace-records.ts b/packages/control-plane/core/workspace-records.ts index 8108f391..0aa3d938 100644 --- a/packages/control-plane/core/workspace-records.ts +++ b/packages/control-plane/core/workspace-records.ts @@ -60,6 +60,8 @@ export interface MachineRow { broker_box_id: string | null; box_update_requested: number; box_image_reported: string | null; + disk_used_percent: number | null; + disk_reported_at: number | null; error: string | null; created_at: number; updated_at: number; @@ -111,12 +113,22 @@ function machineTypeIdForRow(row: MachineRow): string { : row.machine_type_id; } +/** The guest's last disk report, but only for a machine that has a volume to + * report on. A machine with no volume measures its VM's root disk, which is + * the provider's business and not a durable thing anybody keeps files on, so + * the field keeps the name it is given and answers null. The 0-100 range is + * the column's own CHECK (migration 0045), not something to re-decide here. */ +function volumeUsedPercentForRow(row: MachineRow): number | null { + return row.volume_id === null ? null : row.disk_used_percent; +} + export function machineView(row: MachineRow): MachineView { return { id: row.id, state: row.state, machineTypeId: machineTypeIdForRow(row), volumeId: row.volume_id, + volumeUsedPercent: volumeUsedPercentForRow(row), membershipId: row.membership_id, error: row.error, createdAt: row.created_at, diff --git a/packages/control-plane/migrations/0045_machine_disk_usage.sql b/packages/control-plane/migrations/0045_machine_disk_usage.sql new file mode 100644 index 00000000..1acbd13f --- /dev/null +++ b/packages/control-plane/migrations/0045_machine_disk_usage.sql @@ -0,0 +1,17 @@ +-- What the guest says about its own disk. +-- +-- The persistent volume is the durable half of a member's machine +-- (plans/MEMBER-MACHINES.md §1), and nothing in the control plane could say +-- how full it was: a provider reports a size, never a usage. Only the guest +-- can measure it, so the guest reports it (POST /workspaces/self/machine-stats) +-- and it lands here. +-- +-- Both columns are nullable and stay null forever for a machine whose guest +-- predates the reporter. That is the honest pending state the UI shows, and it +-- is why neither column takes a default: 0 would read as "empty disk". +ALTER TABLE machines ADD COLUMN disk_used_percent INTEGER + CHECK (disk_used_percent IS NULL OR (disk_used_percent BETWEEN 0 AND 100)); + +-- When that percentage was measured, in epoch ms. A stale figure is worth +-- less than a fresh one, and only this column can tell them apart. +ALTER TABLE machines ADD COLUMN disk_reported_at INTEGER; diff --git a/packages/control-plane/scripts/lib/worker-source.mjs b/packages/control-plane/scripts/lib/worker-source.mjs index dae5d20e..2e2af257 100644 --- a/packages/control-plane/scripts/lib/worker-source.mjs +++ b/packages/control-plane/scripts/lib/worker-source.mjs @@ -45,6 +45,7 @@ export const CORE_MANIFEST = Object.freeze([ "core/identity/google.ts", "core/identity/invites.ts", "core/identity/members.ts", "core/identity/orgs.ts", "core/identity/routes.ts", "core/janitors.ts", "core/machines.ts", + "core/machine-stats.ts", "core/oauth-state.ts", "core/oauth.ts", "core/operator-tokens.ts", @@ -226,7 +227,7 @@ export const BLITZDEV_CONFIG = Object.freeze({ }, // One VM per (workspace, member). The volume is the durable half: a type // change destroys the VM and keeps the disk. - { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, + { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "disk_used_percent", type: "integer", sqlType: "integer", check: "disk_used_percent IS NULL OR (disk_used_percent BETWEEN 0 AND 100)" }, { name: "disk_reported_at", type: "integer", sqlType: "integer" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, { name: "workspace_members", fields: [{ name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('admin', 'member', 'viewer')" }, { name: "added_by_membership_id", type: "text", sqlType: "text", foreignKey: { table: "memberships", column: "id" } }, { name: "added_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "membership", fields: ["membership_id", "workspace_id"] }], extensions: [DENY_ALL_RULES] }, { name: "workspace_credentials", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "label", type: "text", sqlType: "text" }, { name: "ciphertext", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, { name: "revoked_at", type: "integer", sqlType: "integer" }], indexes: [{ name: "workspace", fields: ["workspace_id", "created_at"] }], extensions: [DENY_ALL_RULES] }, { diff --git a/packages/control-plane/test/blitzdev-emitter.test.ts b/packages/control-plane/test/blitzdev-emitter.test.ts index 70364e06..94565bdb 100644 --- a/packages/control-plane/test/blitzdev-emitter.test.ts +++ b/packages/control-plane/test/blitzdev-emitter.test.ts @@ -101,6 +101,7 @@ const expected = [ "core/identity/routes.ts", "core/janitors.ts", "core/machines.ts", + "core/machine-stats.ts", "core/oauth-state.ts", "core/oauth.ts", "core/operator-tokens.ts", @@ -155,7 +156,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed emitter [vendor-onl expect(UPLOAD_MANIFEST).toEqual(expected); expect(first.files.map((file) => file.path)).toEqual(expected); expect(first).toEqual(second); - expect(first.files).toHaveLength(107); + expect(first.files).toHaveLength(108); expect(first.files.every((file) => file.bytes <= 1024 * 1024)).toBe(true); }); diff --git a/packages/control-plane/test/core-imports.test.ts b/packages/control-plane/test/core-imports.test.ts index f44a3311..b1cfed01 100644 --- a/packages/control-plane/test/core-imports.test.ts +++ b/packages/control-plane/test/core-imports.test.ts @@ -64,6 +64,7 @@ const expected = [ "identity/routes.ts", "index.ts", "janitors.ts", + "machine-stats.ts", "machines.ts", "oauth.ts", "operator-tokens.ts", @@ -129,6 +130,6 @@ describe("portable core imports", () => { (values: string[]) => values.every((value) => value.startsWith("./") || value.startsWith("../")), ); } - expect(expected).toHaveLength(104); + expect(expected).toHaveLength(105); }); }); diff --git a/packages/control-plane/test/machine-stats-conformance.test.ts b/packages/control-plane/test/machine-stats-conformance.test.ts new file mode 100644 index 00000000..9aff6d0d --- /dev/null +++ b/packages/control-plane/test/machine-stats-conformance.test.ts @@ -0,0 +1,177 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + appRequest, + harness, + operatorSession, + phoneHomeUrl, + resetDatabase, + type BoxCredential, +} from "./helpers.js"; + +// Control-plane side of the `machine-stats` cross-runtime contract: this +// Worker consumes the guest's disk report. The producer +// (box/rootfs/usr/local/bin/blitz-machine-stats) is pinned against the same +// corpus in packages/box/actor/test/machine-stats-conformance.test.ts. + +interface StatsFixture { + request: Record; + accepts: boolean; +} + +const fixtureSources = import.meta.glob( + "../../schema/fixtures/machine-stats/*.json", + { eager: true, import: "default", query: "?raw" }, +); + +function fixtures(): Array<[string, StatsFixture]> { + return Object.entries(fixtureSources) + .map(([path, source]): [string, string] => [path.slice(path.lastIndexOf("/") + 1), source]) + // SAFETY: The machine-stats fixtures are trusted local test data authored + // to the { request, accepts } shape; the guest test pins the same corpus. + .map(([name, source]): [string, StatsFixture] => [name, JSON.parse(source) as StatsFixture]) + .sort(([left], [right]) => left.localeCompare(right)); +} + +type Harness = ReturnType; + +async function readyWorkspaceBox( + { app, providers }: Harness, + cookie: string, +): Promise<{ workspaceId: string; box: BoxCredential }> { + const created = await appRequest(app, "/workspaces", { + method: "POST", + headers: { Cookie: cookie, "Content-Type": "application/json" }, + body: JSON.stringify({ machineTypeId: "small" }), + }); + expect(created.status).toBe(201); + const { workspace } = await created.json<{ workspace: { id: string } }>(); + const callback = new URL(phoneHomeUrl(providers, workspace.id)); + const enrolled = await appRequest(app, callback.pathname, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAC3Nzatest host" }), + }); + expect(enrolled.status).toBe(200); + return { workspaceId: workspace.id, box: await enrolled.json() }; +} + +function report( + app: Harness["app"], + box: BoxCredential, + body: unknown, +): Promise { + return appRequest(app, "/workspaces/self/machine-stats", { + method: "POST", + headers: { + Authorization: `Bearer ${box.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +async function storedStats( + workspaceId: string, +): Promise<{ disk_used_percent: number | null; disk_reported_at: number | null }> { + const row = await env.DB + .prepare("SELECT disk_used_percent, disk_reported_at FROM machines WHERE workspace_id = ?1") + .bind(workspaceId) + .first<{ disk_used_percent: number | null; disk_reported_at: number | null }>(); + if (row === null) throw new Error("machine row missing"); + return row; +} + +describe("machine-stats control-plane conformance", () => { + beforeEach(async () => { + await resetDatabase(); + }); + + it("pins the shared machine-stats fixture corpus", () => { + expect(fixtures().map(([name]) => name)).toEqual([ + "invalid-fractional-percent.json", + "invalid-missing-percent.json", + "invalid-negative-percent.json", + "invalid-null-percent.json", + "invalid-over-hundred.json", + "invalid-string-percent.json", + "valid-extra-key.json", + "valid-full.json", + "valid-mid.json", + "valid-zero.json", + ]); + }); + + it("answers every fixture exactly as the corpus says it must", async () => { + const h = harness(); + const cookie = await operatorSession(); + const { workspaceId, box } = await readyWorkspaceBox(h, cookie); + + for (const [name, fixture] of fixtures()) { + const response = await report(h.app, box, fixture.request); + expect(response.status, name).toBe(fixture.accepts ? 204 : 400); + if (fixture.accepts) { + const stored = await storedStats(workspaceId); + expect(stored.disk_used_percent, name).toBe(fixture.request.diskUsedPercent); + expect(stored.disk_reported_at, name).toBeGreaterThan(0); + } + } + }); + + it("leaves the last true figure alone when a report is refused", async () => { + const h = harness(); + const cookie = await operatorSession(); + const { workspaceId, box } = await readyWorkspaceBox(h, cookie); + + expect((await report(h.app, box, { diskUsedPercent: 62 })).status).toBe(204); + expect((await report(h.app, box, { diskUsedPercent: 900 })).status).toBe(400); + + expect((await storedStats(workspaceId)).disk_used_percent).toBe(62); + }); + + it("writes only the reporting machine's own row", async () => { + const h = harness(); + const cookie = await operatorSession(); + const first = await readyWorkspaceBox(h, cookie); + const second = await readyWorkspaceBox(h, cookie); + + expect((await report(h.app, first.box, { diskUsedPercent: 12 })).status).toBe(204); + + expect((await storedStats(first.workspaceId)).disk_used_percent).toBe(12); + expect((await storedStats(second.workspaceId)).disk_used_percent).toBeNull(); + }); + + it("refuses an unauthenticated report", async () => { + const h = harness(); + const response = await appRequest(h.app, "/workspaces/self/machine-stats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ diskUsedPercent: 10 }), + }); + expect(response.status).toBe(401); + }); + + it("carries the reported figure onto the wire as volumeUsedPercent", async () => { + const h = harness(); + // The fake places no volume unless a suite asks it to, and the wire field + // is about the volume, so this suite asks. + h.providers.volumeLocation = () => "test"; + const cookie = await operatorSession(); + const { workspaceId, box } = await readyWorkspaceBox(h, cookie); + expect((await report(h.app, box, { diskUsedPercent: 62 })).status).toBe(204); + + const poll = await appRequest(h.app, "/workspaces", { headers: { Cookie: cookie } }); + expect(poll.status).toBe(200); + const { workspaces } = await poll.json<{ + workspaces: Array<{ + id: string; + members: Array<{ machine: { volumeId: string | null; volumeUsedPercent: number | null } | null }>; + }>; + }>(); + const machine = workspaces.find(({ id }) => id === workspaceId)?.members[0]?.machine; + // The fake provider gives this machine a volume, so the percentage is + // about something a member keeps files on and travels. + expect(machine?.volumeId).not.toBeNull(); + expect(machine?.volumeUsedPercent).toBe(62); + }); +}); diff --git a/packages/control-plane/test/wire-drift.test.ts b/packages/control-plane/test/wire-drift.test.ts index b3f64fe8..a89c67c1 100644 --- a/packages/control-plane/test/wire-drift.test.ts +++ b/packages/control-plane/test/wire-drift.test.ts @@ -142,12 +142,26 @@ const machine: SharedShape = { state: "running", machineTypeId: "mv-2c2g@lab", volumeId: volume.id, + volumeUsedPercent: 62, membershipId: "membership", error: null, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_005_000, }; +// A machine whose guest has not reported yet answers null, which covers +// different ground than an integer does. +const unreportedMachine: SharedShape = { + ...machine, + id: "machine-unreported", + volumeUsedPercent: null, +}; + +const machineStats: SharedShape< + wire.MachineStatsRequest, + schema.MachineStatsRequest +> = { diskUsedPercent: 62 }; + const workspaceMember: SharedShape< wire.WorkspaceMemberView, schema.WorkspaceMemberView @@ -660,6 +674,8 @@ const connectionsResponse: SharedShape< const fullFieldValues = [ machine, + unreportedMachine, + machineStats, workspaceMember, viewerMember, workspaceCredential, @@ -752,6 +768,7 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/schema/fixtures/machine-stats/README.md b/packages/schema/fixtures/machine-stats/README.md new file mode 100644 index 00000000..847b4a57 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/README.md @@ -0,0 +1,27 @@ +# Machine-stats fixtures + +The guest measures the filesystem holding its state directory and posts the +result to `POST /workspaces/self/machine-stats` with its box credential. The +payload crosses TS ↔ sh/node: the producer is +`packages/box/rootfs/usr/local/bin/blitz-machine-stats`, the consumer is +`packages/control-plane/core/machine-stats.ts`. This corpus is the accept rule +both sides are pinned to. + +Each fixture pairs a candidate request body (`request`) with whether the +control-plane consumer must accept it (`accepts`). + +The accept rule: a JSON object whose `diskUsedPercent` is an integer between 0 +and 100 inclusive. A float, a numeric string, a null and a missing field are +all refused with 400 — a machine that cannot measure its disk must send +nothing, because a wrong figure overwrites the last true one and the column +has no way to say "this one is a guess". Unknown extra keys are accepted, so a +newer guest that reports more than this control plane understands still lands +its percentage. + +Conformance tests: + +- `packages/control-plane/test/machine-stats-conformance.test.ts` — the + consumer, over the real route with a real box credential. +- `packages/box/actor/test/machine-stats-conformance.test.ts` — the producer: + the real reporter script runs against a local origin, and what it posts is + checked against the same accept rule. diff --git a/packages/schema/fixtures/machine-stats/invalid-fractional-percent.json b/packages/schema/fixtures/machine-stats/invalid-fractional-percent.json new file mode 100644 index 00000000..fc94a215 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-fractional-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 62.5 }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-missing-percent.json b/packages/schema/fixtures/machine-stats/invalid-missing-percent.json new file mode 100644 index 00000000..b064e219 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-missing-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "note": "a report with no measurement in it" }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-negative-percent.json b/packages/schema/fixtures/machine-stats/invalid-negative-percent.json new file mode 100644 index 00000000..d804d009 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-negative-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": -1 }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-null-percent.json b/packages/schema/fixtures/machine-stats/invalid-null-percent.json new file mode 100644 index 00000000..5956a16b --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-null-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": null }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-over-hundred.json b/packages/schema/fixtures/machine-stats/invalid-over-hundred.json new file mode 100644 index 00000000..d3fda155 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-over-hundred.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 101 }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-string-percent.json b/packages/schema/fixtures/machine-stats/invalid-string-percent.json new file mode 100644 index 00000000..a302a143 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-string-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": "62" }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/valid-extra-key.json b/packages/schema/fixtures/machine-stats/valid-extra-key.json new file mode 100644 index 00000000..c08bdaff --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-extra-key.json @@ -0,0 +1,7 @@ +{ + "request": { + "diskUsedPercent": 41, + "note": "unknown keys are tolerated for forward compatibility" + }, + "accepts": true +} diff --git a/packages/schema/fixtures/machine-stats/valid-full.json b/packages/schema/fixtures/machine-stats/valid-full.json new file mode 100644 index 00000000..b7a6ffdd --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-full.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 100 }, + "accepts": true +} diff --git a/packages/schema/fixtures/machine-stats/valid-mid.json b/packages/schema/fixtures/machine-stats/valid-mid.json new file mode 100644 index 00000000..85810ec8 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-mid.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 62 }, + "accepts": true +} diff --git a/packages/schema/fixtures/machine-stats/valid-zero.json b/packages/schema/fixtures/machine-stats/valid-zero.json new file mode 100644 index 00000000..9be2802d --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-zero.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 0 }, + "accepts": true +} diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index 87d5d8b5..c3a84197 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -59,12 +59,25 @@ export interface MachineView { /** This machine's type. The workspace holds only a default. */ machineTypeId: string; volumeId: string | null; + /** How full the machine's persistent volume is, 0-100, as the guest last + * measured it. Null means the question has no answer yet: there is no + * volume, or no guest has reported one (every box image before the reporter + * shipped). Null is never 0 — an unmeasured disk is not an empty one. */ + volumeUsedPercent: number | null; membershipId: string; error: string | null; createdAt: number; updatedAt: number; } +/** The guest's own disk report (`POST /workspaces/self/machine-stats`). + * `diskUsedPercent` is an integer 0-100, the used percentage of the filesystem + * holding the state directory. Anything else is a 400: a machine reporting + * nonsense about its disk must not overwrite the last true figure. */ +export interface MachineStatsRequest { + diskUsedPercent: number; +} + export interface WorkspaceMemberView { membershipId: string; name: string; diff --git a/packages/webapp/src/AgentRulesPicker.tsx b/packages/webapp/src/AgentRulesPicker.tsx index e5670436..6031596e 100644 --- a/packages/webapp/src/AgentRulesPicker.tsx +++ b/packages/webapp/src/AgentRulesPicker.tsx @@ -119,16 +119,16 @@ export function AgentRulesPicker({ }; return ( -
-
-

Agent rules

-

+

+
+

Agent rules

+

The always-loaded instructions your agents read in this workspace. Leave it on the default unless your team needs its own.

-