diff --git a/apps/cli/src/main.test.ts b/apps/cli/src/main.test.ts index b703a75e..53fd346f 100644 --- a/apps/cli/src/main.test.ts +++ b/apps/cli/src/main.test.ts @@ -1,9 +1,72 @@ +vi.mock("../../server/src/resource-probe.js", () => ({ + resourceProbe: () => ({ footprintsMiB: new Map(), pressure: "normal", pressureKnown: true }), +})); + import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test, vi } from "vitest"; import { startService } from "../../server/src/http.js"; import type { JobLogRequest, JobLogs } from "../../../packages/protocol/src/jobs.js"; +import { activityFor } from "../../server/src/activities.js"; +import type { RunnerProgress } from "../../../packages/protocol/src/runners.js"; + +test("operation and activity inspection retain phases in pretty and compact JSON", async () => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-phases-")); + const server = await startService({ home }); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + const command = { type: "runner.run", bindingId: "binding" } as const; + const operation = server.store.accept( + "runner", + "runner", + command.type, + activityFor("runner", command), + "runner", + ).operation; + const result: RunnerProgress = { + bindingId: "binding", + environmentId: "linux", + leaseKey: operation.id, + stage: "finished", + phases: [{ name: "vm_start", startedAt: operation.createdAt, endedAt: operation.createdAt }], + }; + server.store.update(operation, { status: "succeeded", result }); + for (const command of ["operation", "activity"]) { + for (const flags of [[], ["--json"]]) { + output.mockClear(); + vi.resetModules(); + process.argv = [ + process.execPath, + "vectis", + "--home", + home, + command, + "get", + "runner", + ...flags, + ]; + await import("./main.js"); + await vi.waitFor(() => expect(output).toHaveBeenCalledOnce()); + const printed: unknown = JSON.parse(String(output.mock.calls[0]?.[0])); + expect(printed).toMatchObject( + command === "operation" + ? { result } + : { operations: [expect.objectContaining({ result })] }, + ); + } + } + } finally { + process.argv = argv; + process.exitCode = exitCode; + output.mockRestore(); + vi.resetModules(); + await server.close(); + await rm(home, { recursive: true, force: true }); + } +}); test("machine configure settles without --wait and status prints runner capacity", async () => { const home = await mkdtemp(join(tmpdir(), "vectis-cli-capacity-")); @@ -21,6 +84,8 @@ test("machine configure settles without --wait and status prints runner capacity "configure", "--max-runners", "3", + "--memory-budget-mib", + "8192", "--key", "cli-configure", "--json", @@ -29,14 +94,19 @@ test("machine configure settles without --wait and status prints runner capacity await vi.waitFor(() => expect(output).toHaveBeenCalledWith(expect.stringContaining('"status":"succeeded"')), ); - expect(server.service.snapshot().machine.maxRunners).toBe(3); + expect(server.service.snapshot().machine).toMatchObject({ + maxRunners: 3, + memoryBudgetMiB: 8192, + }); output.mockClear(); vi.resetModules(); process.argv = [process.execPath, "vectis", "--home", home, "status", "--json"]; await import("./main.js"); await vi.waitFor(() => expect(output).toHaveBeenCalledWith( - expect.stringContaining('"runnerCapacity":{"max":3,"active":0,"available":0}'), + expect.stringContaining( + '"runnerCapacity":{"max":3,"active":0,"available":0,"environments":[],', + ), ), ); } finally { @@ -48,6 +118,59 @@ test("machine configure settles without --wait and status prints runner capacity } }); +test.each([ + { args: ["--memory-budget-mib", "2048"], valid: true }, + { args: ["--memory-budget-mib", "2047"], valid: false }, + { args: ["--memory-budget-mib", "4096.5"], valid: false }, + { args: ["--memory-budget-mib", "invalid"], valid: false }, + { args: [], valid: false }, +])( + "machine configure parses memory-only and rejects invalid arguments: $args", + async ({ args, valid }) => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-budget-")); + const server = await startService({ home }); + const submit = vi.spyOn(server.service, "submit"); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + vi.resetModules(); + process.argv = [ + process.execPath, + "vectis", + "--home", + home, + "machine", + "configure", + ...args, + "--key", + "budget", + "--json", + ]; + await import("./main.js"); + await vi.waitFor(() => expect(output).toHaveBeenCalled()); + if (valid) { + expect(submit).toHaveBeenCalledWith("budget", { + type: "machine.configure", + memoryBudgetMiB: 2048, + }); + expect(output).toHaveBeenCalledWith(expect.stringContaining('"status":"succeeded"')); + } else { + expect(submit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(output).toHaveBeenCalledWith(expect.stringContaining('"error":')); + } + } finally { + process.argv = argv; + process.exitCode = exitCode; + output.mockRestore(); + submit.mockRestore(); + await server.close(); + await rm(home, { recursive: true, force: true }); + } + }, +); + test.each([ { actors: "7,7,8", approval: "required", expected: { ids: [7, 8] } }, { actors: "any", approval: "automatic", expected: "any" }, @@ -577,3 +700,266 @@ test.each([ await rm(home, { recursive: true, force: true }); } }); + +test.each([ + { value: "0", valid: true }, + { value: "1", valid: true }, + { value: "4", valid: true }, + { value: "-1", valid: false }, + { value: "5", valid: false }, + { value: "1.5", valid: false }, + { value: "invalid", valid: false }, +])( + "environment configure parses --warm-pool $value through the shared command", + async ({ value, valid }) => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-warm-")); + const server = await startService({ home }); + const submit = vi.spyOn(server.service, "submit"); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + vi.resetModules(); + process.argv = [ + process.execPath, + "vectis", + "--home", + home, + "environment", + "configure", + "linux", + "--warm-pool", + value, + "--key", + "pool", + "--json", + ]; + await import("./main.js"); + await vi.waitFor(() => expect(output).toHaveBeenCalled()); + if (valid) + expect(submit).toHaveBeenCalledWith("pool", { + type: "environment.configure", + id: "linux", + warmPool: Number(value), + }); + else { + expect(submit).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } + } finally { + process.argv = argv; + process.exitCode = exitCode; + output.mockRestore(); + submit.mockRestore(); + await server.close(); + await rm(home, { recursive: true, force: true }); + } + }, +); + +test("CLI status shows warm pool VMs without counting them as running jobs", async () => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-warm-status-")); + const server = await startService({ home }); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + server.service.store.put("environment", "linux", { + id: "linux", + name: "Linux", + os: "linux", + state: "ready", + basePath: join(home, "base"), + cpu: 1, + memoryMiB: 512, + warmPool: 1, + }); + server.service.store.put("instance", "warm", { + id: "warm", + environmentId: "linux", + status: "running", + role: "warm", + memoryMiB: 512, + pid: 0, + createdAt: new Date().toISOString(), + }); + vi.resetModules(); + process.argv = [process.execPath, "vectis", "--home", home, "status", "--json"]; + await import("./main.js"); + await vi.waitFor(() => + expect(output).toHaveBeenCalledWith(expect.stringContaining('"role":"warm"')), + ); + expect(output).toHaveBeenCalledWith(expect.stringContaining('"active":0')); + } finally { + process.argv = argv; + process.exitCode = exitCode; + output.mockRestore(); + await server.close(); + await rm(home, { recursive: true, force: true }); + } +}); + +test.each([ + { + action: "enable", + extra: ["--size-gib", "30"], + expected: { type: "repository.cache", bindingId: "binding", enabled: true, sizeGiB: 30 }, + }, + { + action: "disable", + extra: [], + expected: { type: "repository.cache", bindingId: "binding", enabled: false }, + }, + { action: "clear", extra: [], expected: { type: "cache.clear", bindingId: "binding" } }, +])("repository cache $action submits the shared command", async ({ action, extra, expected }) => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-cache-")); + const server = await startService({ home }); + const submit = vi.spyOn(server.service, "submit"); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + vi.resetModules(); + process.argv = [ + process.execPath, + "vectis", + "--home", + home, + "repository", + "cache", + action, + "binding", + ...extra, + "--key", + "cache", + "--json", + ]; + await import("./main.js"); + await vi.waitFor(() => expect(submit).toHaveBeenCalledWith("cache", expected)); + await vi.waitFor(() => + expect(output).toHaveBeenCalledWith(expect.stringContaining('"status":"failed"')), + ); + } finally { + process.argv = argv; + process.exitCode = exitCode; + output.mockRestore(); + submit.mockRestore(); + await server.close(); + await rm(home, { recursive: true, force: true }); + } +}); + +test.each([ + { input: "demand", mode: "demand" }, + { input: "scale-set", mode: "scale_set" }, + { input: "scale_set", mode: undefined }, + { input: "unknown", mode: undefined }, +])( + "repository capacity-mode parses $input and waits for the shared setting", + async ({ input, mode }) => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-mode-")); + const server = await startService({ home }); + const submit = vi.spyOn(server.service, "submit"); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + vi.resetModules(); + process.argv = [ + process.execPath, + "vectis", + "--home", + home, + "repository", + "capacity-mode", + "binding", + input, + "--key", + "capacity-mode", + "--json", + ]; + await import("./main.js"); + if (mode === undefined) { + await vi.waitFor(() => + expect(output).toHaveBeenCalledWith(expect.stringContaining("invalid_capacity_mode")), + ); + expect(submit).not.toHaveBeenCalled(); + } else { + await vi.waitFor(() => + expect(output).toHaveBeenCalledWith(expect.stringContaining('"status":"failed"')), + ); + expect(submit).toHaveBeenCalledWith("capacity-mode", { + type: "repository.capacity-mode", + bindingId: "binding", + mode, + }); + expect(server.service.snapshot().operations[0]?.status).toBe("failed"); + expect(process.exitCode).toBe(1); + } + } finally { + process.argv = argv; + process.exitCode = exitCode; + output.mockRestore(); + submit.mockRestore(); + await server.close(); + await rm(home, { recursive: true, force: true }); + } + }, +); + +test.each([false, true])( + "environment provision reads setup data and parses --no-runner=%s", + async (noRunner) => { + const home = await mkdtemp(join(tmpdir(), "vectis-cli-provision-")); + const server = await startService({ home }); + const submit = vi.spyOn(server.service, "submit"); + const argv = process.argv; + const exitCode = process.exitCode; + const output = vi.spyOn(process.stdout, "write").mockReturnValue(true); + try { + const scriptPath = join(home, "setup.sh"); + await writeFile(scriptPath, "printf 'guest setup text'\n"); + vi.resetModules(); + process.argv = [ + process.execPath, + "vectis", + "--home", + home, + "environment", + "provision", + "derived", + "--from", + "parent", + "--image-directory", + join(home, "image"), + "--name", + "Derived tools", + "--setup-script", + scriptPath, + ...(noRunner ? ["--no-runner"] : []), + "--key", + "provision", + "--json", + ]; + await import("./main.js"); + await vi.waitFor(() => + expect(submit).toHaveBeenCalledWith("provision", { + type: "environment.provision", + id: "derived", + from: "parent", + imageDirectory: join(home, "image"), + name: "Derived tools", + setupScript: "printf 'guest setup text'\n", + ...(noRunner ? { runner: false } : {}), + }), + ); + } finally { + process.argv = argv; + process.exitCode = exitCode; + await server.close(); + output.mockRestore(); + submit.mockRestore(); + await rm(home, { recursive: true, force: true }); + } + }, +); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index a292a024..62732f29 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -57,17 +57,23 @@ async function main() { x64: { type: "string" }, egress: { type: "string" }, "max-runners": { type: "string" }, + "memory-budget-mib": { type: "string" }, + "warm-pool": { type: "string" }, "job-id": { type: "string" }, ref: { type: "string" }, input: { type: "string", multiple: true }, actors: { type: "string" }, approval: { type: "string" }, + "size-gib": { type: "string" }, account: { type: "string" }, binding: { type: "string" }, owner: { type: "string" }, name: { type: "string" }, "disk-gib": { type: "string" }, "image-directory": { type: "string" }, + from: { type: "string" }, + "setup-script": { type: "string" }, + "no-runner": { type: "boolean" }, "restore-path": { type: "string" }, "open-terminal": { type: "boolean" }, "iso-path": { type: "string" }, @@ -127,7 +133,7 @@ Service service status Inspect the login service registration for this home service update Adopt this runtime when idle; restore the previous one on failure service recover-update Restore the previous runtime after an interrupted update - status Inspect the machine and recent operations + status Inspect the machine, unfinished work and bounded recent history storage Inspect image and VM disk usage doctor Inspect host and runtime prerequisites logs [--lines ] Read the newest service log lines (default 200, at most 1000) @@ -144,7 +150,9 @@ Cloud and GitHub login finish Complete an approved remote control login logout Revoke this CLI's remote access and remove its credentials machine list List your machines and when they were last seen - machine configure --max-runners Set concurrent runner limit (1-16, default 5) + machine configure [--max-runners ] [--memory-budget-mib ] + Set runner limit (1-16, default 5) and shared VM memory budget + Memory budget: 2048 MiB to host RAM, default 75% of host RAM Repositories and jobs repository readiness [--environment ] Check readiness before connecting @@ -153,9 +161,12 @@ Repositories and jobs repository connect Connect with --account and --environment , optional --owner repository enable-auto Start runners automatically for matching queued jobs repository disable-auto Stop starting runners automatically + repository capacity-mode demand|scale-set Choose runner capacity mode (scale sets are preview) repository disconnect Stop future runners without interrupting running jobs runner run Start one disposable runner, optionally only for --job-id runner reconcile Clean up an interrupted runner after verifying its VM stopped + repository cache enable|disable|clear [--size-gib N] + Configure or clear an opt-in repository cache disk (default 20 GiB) repository access --actors any| --approval automatic|required Actor IDs are numeric GitHub user IDs: gh api users/ --jq .id job list Read recent GitHub job states for a connected repository @@ -177,6 +188,8 @@ Workflow migration migration publish Create or recover its pull request; never merges Environments + environment provision Derive a new image with --from --image-directory + [--name ] [--setup-script ] [--no-runner] environment prepare-linux Prepare Ubuntu 24.04 with --image-directory and --storage-path environment resume Continue an interrupted Ubuntu preparation environment install-macos Install macOS 26 with --image-directory and --storage-path, optional --restore-path @@ -191,16 +204,17 @@ Environments environment register --file Register an existing prepared environment JSON file environment list List environments and effective network egress environment configure Set --cpu, --memory-mib, --storage-path, --x64 rosetta|off or --egress internet_only|open for future VMs + --warm-pool keeps 0-4 SSH-ready VMs (default 0, uses RAM) environment start Start a clean VM, optionally overriding --cpu, --memory-mib, --storage-path environment remove Remove an idle definition, preserving its disk instance stop Stop a VM owned by this service instance reconcile Recheck an interrupted VM safely Activities and operations - activity list List activities, newest first; filter with --kind, --status, --repository, --limit + activity list List unfinished and recent activities; filter with --kind, --status, --repository, --limit activity get Inspect one activity with the operations that belong to it activity cancel Cancel work an activity owns, or close one that only waits for you - operation get Inspect one operation + operation get Inspect one retained operation, including outside the status window operation wait Wait for an operation's terminal state; on --timeout, print it as it is operation cancel Cancel an active operation, or close one that only waits for you command --file Submit any protocol command as JSON @@ -241,7 +255,7 @@ Options -h, --help Show this help Exit codes: 0 success, 1 failure or cancellation, 3 action required or still pending. -Setting commands always wait: pause/resume, repository enable-auto/disable-auto, +Setting commands always wait: pause/resume, repository enable-auto/disable-auto/capacity-mode, repository cache, environment register/configure/remove, instance stop/reconcile, operation cancel, and activity cancel. Their wait timeout reports wait_cancelled with exit code 3; the operation may still be running. Errors are JSON: {"error":{"code","message","nextStep"}}. @@ -262,6 +276,14 @@ Documentation: https://vectis.kerd.dev/docs "invalid_arguments", "--egress is supported only by environment configure.", ); + if ( + values["warm-pool"] !== undefined && + !(command === "environment" && subcommand === "configure") + ) + throw new VectisError( + "invalid_arguments", + "--warm-pool is supported only by environment configure.", + ); if (values.machine && ["service", "cloud", "login", "logout", "machine"].includes(command)) throw new VectisError("invalid_target", "This command does not accept --machine."); if ( @@ -595,12 +617,17 @@ Documentation: https://vectis.kerd.dev/docs let request: Command; if (command === "pause" || command === "resume") request = { type: "machine.pause", paused: command === "pause" }; - else if (command === "machine" && subcommand === "configure") + else if (command === "machine" && subcommand === "configure") { + if (values["max-runners"] === undefined && values["memory-budget-mib"] === undefined) + throw new VectisError("missing_argument", "Specify --max-runners or --memory-budget-mib."); request = decodeCommand({ type: "machine.configure", - maxRunners: Number(values["max-runners"]), + ...(values["max-runners"] !== undefined ? { maxRunners: Number(values["max-runners"]) } : {}), + ...(values["memory-budget-mib"] !== undefined + ? { memoryBudgetMiB: Number(values["memory-budget-mib"]) } + : {}), }); - else if ( + } else if ( command === "repository" && (subcommand === "enable-auto" || subcommand === "disable-auto") && id @@ -610,7 +637,35 @@ Documentation: https://vectis.kerd.dev/docs bindingId: id, enabled: subcommand === "enable-auto", }); - else if (command === "repository" && subcommand === "access" && id) { + else if (command === "repository" && subcommand === "cache" && positionals[3]) { + const bindingId = positionals[3]; + if (id === "clear" && values["size-gib"] === undefined) + request = decodeCommand({ type: "cache.clear", bindingId }); + else if (id === "enable" || (id === "disable" && values["size-gib"] === undefined)) + request = decodeCommand({ + type: "repository.cache", + bindingId, + enabled: id === "enable", + ...(values["size-gib"] === undefined ? {} : { sizeGiB: Number(values["size-gib"]) }), + }); + else + throw new VectisError( + "invalid_cache_command", + "Use repository cache enable|disable|clear ; --size-gib is only valid with enable.", + ); + } else if (command === "repository" && subcommand === "capacity-mode" && id) { + const mode = positionals[3]; + if (positionals.length !== 4 || (mode !== "demand" && mode !== "scale-set")) + throw new VectisError( + "invalid_capacity_mode", + "Use repository capacity-mode demand|scale-set.", + ); + request = decodeCommand({ + type: "repository.capacity-mode", + bindingId: id, + mode: mode === "scale-set" ? "scale_set" : "demand", + }); + } else if (command === "repository" && subcommand === "access" && id) { if ( !values.actors || (values.actors !== "any" && !/^[1-9][0-9]*(,[1-9][0-9]*)*$/.test(values.actors)) @@ -754,6 +809,18 @@ Documentation: https://vectis.kerd.dev/docs request = decodeCommand({ type: "environment.finish-macos-setup", id }); else if (command === "environment" && subcommand === "resume-macos" && id) request = decodeCommand({ type: "environment.resume-macos", id }); + else if (command === "environment" && subcommand === "provision" && id) + request = decodeCommand({ + type: "environment.provision", + id, + from: values.from, + imageDirectory: values["image-directory"], + ...(values.name !== undefined ? { name: values.name } : {}), + ...(values["setup-script"] !== undefined + ? { setupScript: await readFile(values["setup-script"], "utf8") } + : {}), + ...(values["no-runner"] ? { runner: false } : {}), + }); else if (command === "environment" && subcommand === "prepare-linux" && id) request = decodeCommand({ type: "environment.prepare-linux", @@ -778,6 +845,7 @@ Documentation: https://vectis.kerd.dev/docs request = decodeCommand({ type: "environment.configure", id, + ...(values["warm-pool"] !== undefined ? { warmPool: Number(values["warm-pool"]) } : {}), ...(values.x64 !== undefined ? { x64: values.x64 } : {}), ...(values.egress !== undefined ? { egress: values.egress } : {}), ...(values.cpu !== undefined ? { cpu: Number(values.cpu) } : {}), diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index 2c6b6adf..d1b1450d 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -7,6 +7,7 @@ export type DesktopAction = | "notifications.get" | "notifications.set" | "status" + | "activity" | "storage" | "doctor" | "logs" diff --git a/apps/desktop/src/command-labels.ts b/apps/desktop/src/command-labels.ts index 9427d8da..33c84690 100644 --- a/apps/desktop/src/command-labels.ts +++ b/apps/desktop/src/command-labels.ts @@ -1,4 +1,5 @@ export const commandLabels: Record = { + "environment.provision": "Provision derived environment", "environment.prepare-linux": "Prepare Linux image", "environment.resume": "Resume Linux preparation", "environment.install-macos": "Install macOS", @@ -32,5 +33,5 @@ export const commandLabels: Record = { "environment.remove": "Remove environment", "operation.cancel": "Close operation", "machine.pause": "Change VM intake", - "machine.configure": "Change concurrent VMs", + "machine.configure": "Change VM capacity", }; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8630dee9..4592f527 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -231,6 +231,11 @@ else { case "status": data = await (await client()).status(); break; + case "activity": + data = await ( + await client() + ).activity(Schema.decodeUnknownSync(Schema.NonEmptyString)(input)); + break; case "jobs": { const bindingId = Schema.decodeUnknownSync(Schema.NonEmptyString)(input); data = await (await client()).jobs(bindingId); diff --git a/apps/desktop/src/renderer/components/activity-row.tsx b/apps/desktop/src/renderer/components/activity-row.tsx index 641a3837..7e7f3441 100644 --- a/apps/desktop/src/renderer/components/activity-row.tsx +++ b/apps/desktop/src/renderer/components/activity-row.tsx @@ -5,15 +5,24 @@ import { GitHubIcon } from "@/components/github-icon"; import { JobLog } from "@/components/job-log"; import { Details, ExpandableRow, Mono } from "@/components/layout"; import { OsTile } from "@/components/os-icon"; -import { JobStatus, OperationStatus, RunnerStage, StatusText } from "@/components/status"; +import { RunnerPhases } from "@/components/runner-phases"; +import { + JobStatus, + OperationStatus, + RunnerStage, + StatusBadge, + StatusText, +} from "@/components/status"; import { Button } from "@/components/ui/button"; import { activityJob, activitySummary, activityTitle } from "@/lib/activities"; import { formatDateTime, formatRelative } from "@/lib/format"; +import { resultCodePresentation } from "@/lib/result-code"; import { isGeneratedStageMessage, activeRunnerStage } from "@/lib/runner-stage"; +import { runnerPhaseTimeline } from "@/lib/runner-phases"; import { cancellableCommands, commandLabels } from "@/lib/operations"; import { MigrationResult } from "@/views/migration-result"; import { PreparationResult } from "@/views/preparation-result"; -import { useStateApi } from "@/state"; +import { useActivityDetail, useStateApi } from "@/state"; function VectisTile() { return ( @@ -24,16 +33,19 @@ function VectisTile() { } export function ActivityRow({ - activity, + activity: summary, defaultOpen = false, }: { activity: Activity; defaultOpen?: boolean; }) { const { snapshot, submit, perform } = useStateApi(); - const operations = (snapshot?.operations ?? []).filter( - (operation) => operation.activityId === activity.id, - ); + const [open, setOpen] = useState(defaultOpen); + const detail = useActivityDetail(summary.id, open); + const activity = detail?.activity ?? summary; + const operations = + detail?.operations.toReversed() ?? + (snapshot?.operations ?? []).filter((operation) => operation.activityId === activity.id); // Operations arrive newest first; an activity reads as the story of what happened. const steps = [...operations].reverse(); const deciding = @@ -55,6 +67,7 @@ export function ActivityRow({ const closable = activity.status === "action_required" && !preparation && !reconcilable; const stage = activeRunnerStage(activity, deciding, job?.status); const result: unknown = deciding?.result; + const codeStatus = resultCodePresentation(result); const nextStep = typeof result === "object" && result && @@ -77,9 +90,17 @@ export function ActivityRow({ typeof result.diagnostics === "string" ? result.diagnostics : undefined; + const timeline = + activity.subject.type === "runner" && deciding?.command === "runner.run" + ? runnerPhaseTimeline(result, { + active: running, + end: running ? Date.now() : Date.parse(activity.completedAt ?? activity.updatedAt), + }) + : undefined; return ( @@ -101,6 +122,8 @@ export function ActivityRow({ {job?.status && } {stage ? ( + ) : codeStatus && !runnerQuiet ? ( + ) : ( !runnerQuiet && )} @@ -128,6 +151,7 @@ export function ActivityRow({ )} + {timeline && } {job && activity.bindingId && (
diff --git a/apps/desktop/src/renderer/components/layout.tsx b/apps/desktop/src/renderer/components/layout.tsx index de4d472b..df944123 100644 --- a/apps/desktop/src/renderer/components/layout.tsx +++ b/apps/desktop/src/renderer/components/layout.tsx @@ -270,6 +270,7 @@ export function ExpandableRow({ aside, actions, defaultOpen = false, + onOpenChange, children, }: { id?: string; @@ -279,11 +280,13 @@ export function ExpandableRow({ aside?: ReactNode; actions?: ReactNode; defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; children: ReactNode; }) { return ( diff --git a/apps/desktop/src/renderer/components/path-field.tsx b/apps/desktop/src/renderer/components/path-field.tsx index 2e266281..15a9f3ce 100644 --- a/apps/desktop/src/renderer/components/path-field.tsx +++ b/apps/desktop/src/renderer/components/path-field.tsx @@ -13,6 +13,7 @@ export function PathField({ required, placeholder, description, + fromChooser = (picked) => picked, }: { label: string; value: string; @@ -22,6 +23,7 @@ export function PathField({ required?: boolean; placeholder?: string; description?: string; + fromChooser?: (picked: string) => string; }) { const { perform, machineId } = useStateApi(); const id = useId(); @@ -45,7 +47,7 @@ export function PathField({ aria-label={`Choose ${label}`} onClick={() => void perform(chooser).then((picked) => { - if (typeof picked === "string") onChange(picked); + if (typeof picked === "string") onChange(fromChooser(picked)); }) } > diff --git a/apps/desktop/src/renderer/components/runner-phases.tsx b/apps/desktop/src/renderer/components/runner-phases.tsx new file mode 100644 index 00000000..b84b7b95 --- /dev/null +++ b/apps/desktop/src/renderer/components/runner-phases.tsx @@ -0,0 +1,46 @@ +import { StatusText } from "@/components/status"; +import type { PhaseTimeline } from "@/lib/runner-phases"; +import { cn } from "@/lib/utils"; + +export function RunnerPhases({ timeline }: { timeline: PhaseTimeline }) { + return ( +
+

Timing

+
    + {timeline.rows.map((row) => ( +
  1. + + {row.label} + {row.count > 1 && {row.count} times} + {row.state === "running" && } + {row.state === "unfinished" && } + + + {row.segments.map((segment, index) => ( + + ))} + + + {row.duration} + +
  2. + ))} +
  3. + Total + + {timeline.total} +
  4. +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/status.tsx b/apps/desktop/src/renderer/components/status.tsx index 9e09199c..1e9a1f14 100644 --- a/apps/desktop/src/renderer/components/status.tsx +++ b/apps/desktop/src/renderer/components/status.tsx @@ -8,7 +8,9 @@ import { ClockIcon, TriangleAlertIcon, } from "lucide-react"; +import type { Instance } from "../../../../../packages/protocol/src/index.js"; import { runnerStagePresentation, type RunnerStageKey } from "@/lib/runner-stage"; +import { instanceDisplay } from "@/lib/vm-capacity"; import { cn } from "@/lib/utils"; export const tones = { @@ -61,21 +63,14 @@ export function OperationStatus({ status }: { status: keyof typeof operationStat return ; } -const instanceStates = { - starting: ["queued", "Starting"], - running: ["running", "Running"], - stopped: ["idle", "Stopped"], - interrupted: ["attention", "Interrupted"], -} as const satisfies Record; - export function InstanceStatus({ - status, + instance, inline = false, }: { - status: keyof typeof instanceStates; + instance: Pick; inline?: boolean; }) { - const [tone, label] = instanceStates[status]; + const { tone, label } = instanceDisplay(instance); return inline ? ( ) : ( diff --git a/apps/desktop/src/renderer/lib/activity-detail.test.ts b/apps/desktop/src/renderer/lib/activity-detail.test.ts new file mode 100644 index 00000000..40cd1e28 --- /dev/null +++ b/apps/desktop/src/renderer/lib/activity-detail.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "vitest"; +import type { ActivityDetail, Snapshot } from "../../../../../packages/protocol/src/index.js"; +import type { RunnerProgress } from "../../../../../packages/protocol/src/runners.js"; +import { mergeActivityDetail } from "./activity-detail.js"; + +test("opening an old activity restores its omitted steps without losing current machine state", () => { + const result: RunnerProgress = { + bindingId: "binding", + environmentId: "linux", + leaseKey: "root", + stage: "finished", + phases: [{ name: "vm_start", startedAt: "2020-01-01", endedAt: "2020-01-02" }], + }; + const detail: ActivityDetail = { + activity: { + id: "old", + kind: "vectis", + subject: { type: "command", command: "runner.run" }, + rootOperationId: "root", + status: "succeeded", + message: "Done", + createdAt: "2020-01-01", + updatedAt: "2020-01-02", + }, + operations: ["root", "child"].map((id) => ({ + id, + key: id, + activityId: "old", + command: "runner.run", + result, + status: "succeeded", + message: "Done", + createdAt: "2020-01-01", + updatedAt: "2020-01-02", + })), + }; + const snapshot: Snapshot = { + protocolVersion: 1, + revision: "service:42", + machine: { id: "host", name: "Host", paused: false }, + environments: [], + instances: [], + activities: [], + operations: [ + { + id: "other", + key: "other", + activityId: "other", + command: "job.scan", + status: "running", + message: "Running", + createdAt: "2026-10-16", + updatedAt: "2026-10-16", + }, + ], + }; + const merged = mergeActivityDetail(snapshot, detail); + expect(merged.activities).toEqual([detail.activity]); + expect(merged.operations.map(({ id }) => id)).toEqual(["other", "child", "root"]); + expect(merged.operations.find(({ id }) => id === "root")?.result).toEqual(result); + expect(merged.revision).toBe(snapshot.revision); + expect(merged.machine).toBe(snapshot.machine); + expect(snapshot.activities).toEqual([]); + expect(mergeActivityDetail(merged, detail)).toEqual(merged); +}); diff --git a/apps/desktop/src/renderer/lib/activity-detail.ts b/apps/desktop/src/renderer/lib/activity-detail.ts new file mode 100644 index 00000000..7b316a67 --- /dev/null +++ b/apps/desktop/src/renderer/lib/activity-detail.ts @@ -0,0 +1,15 @@ +import type { ActivityDetail, Snapshot } from "../../../../../packages/protocol/src/index.js"; + +export function mergeActivityDetail(snapshot: Snapshot, detail: ActivityDetail): Snapshot { + return { + ...snapshot, + activities: [ + detail.activity, + ...(snapshot.activities ?? []).filter((activity) => activity.id !== detail.activity.id), + ], + operations: [ + ...snapshot.operations.filter((operation) => operation.activityId !== detail.activity.id), + ...detail.operations.toReversed(), + ], + }; +} diff --git a/apps/desktop/src/renderer/lib/capacity-mode.test.ts b/apps/desktop/src/renderer/lib/capacity-mode.test.ts new file mode 100644 index 00000000..5e9fb1ed --- /dev/null +++ b/apps/desktop/src/renderer/lib/capacity-mode.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { MachineRepositories } from "../../../../../packages/protocol/src/repositories.js"; +import { scaleSetPresentation, scaleSetUnavailableReason } from "./capacity-mode"; + +const binding: MachineRepositories[number] = { + id: "binding", + repositoryId: 1, + repositoryName: "acme/app", + environmentId: "linux", + automatic: true, + capacityMode: "scale_set", +}; + +describe("scaleSetUnavailableReason", () => { + it("blocks switching restricted policies to a scale set", () => { + const demand = { ...binding, capacityMode: "demand" as const }; + expect(scaleSetUnavailableReason(demand)).toBeUndefined(); + expect( + scaleSetUnavailableReason({ + ...demand, + access: { actors: { ids: [7] }, approval: "automatic" }, + }), + ).toBeDefined(); + expect( + scaleSetUnavailableReason({ ...demand, access: { actors: "any", approval: "required" } }), + ).toBeDefined(); + }); + + it("never blocks leaving a scale set", () => { + expect( + scaleSetUnavailableReason({ ...binding, access: { actors: "any", approval: "required" } }), + ).toBeUndefined(); + }); +}); + +describe("scaleSetPresentation", () => { + it("summarizes assigned jobs and reserved runners while listening", () => { + expect( + scaleSetPresentation( + binding, + { bindingId: "binding", state: "listening", assignedJobs: 2, reservedRunners: 1 }, + false, + ), + ).toEqual({ + tone: "running", + label: "Listening", + detail: "2 jobs assigned · 1 runner reserved", + }); + expect( + scaleSetPresentation(binding, { bindingId: "binding", state: "listening" }, false).detail, + ).toBe("No jobs assigned"); + }); + + it("explains why a scale set is not listening", () => { + expect(scaleSetPresentation({ ...binding, automatic: false }, undefined, false)).toMatchObject({ + label: "Stopped", + detail: "Turn on Automatic to receive jobs.", + }); + expect( + scaleSetPresentation(binding, { bindingId: "binding", state: "stopped" }, true).detail, + ).toBe("New VMs are paused. Resume them in Overview."); + expect(scaleSetPresentation(binding, undefined, false).label).toBe("Status unavailable"); + }); + + it("surfaces action required with the service message", () => { + expect( + scaleSetPresentation( + binding, + { + bindingId: "binding", + state: "action_required", + code: "x", + message: "Check the binding.", + }, + false, + ), + ).toEqual({ tone: "attention", label: "Action required", detail: "Check the binding." }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/capacity-mode.ts b/apps/desktop/src/renderer/lib/capacity-mode.ts new file mode 100644 index 00000000..13a2dc35 --- /dev/null +++ b/apps/desktop/src/renderer/lib/capacity-mode.ts @@ -0,0 +1,73 @@ +import type { MachineRepositories } from "../../../../../packages/protocol/src/repositories.js"; +import type { + CapacityMode, + ScaleSetState, +} from "../../../../../packages/protocol/src/scale-sets.js"; +import { restrictiveAccess } from "../../../../../packages/github/src/access-policy.js"; +import type { Tone } from "@/components/status"; + +type Binding = MachineRepositories[number]; + +export function capacityMode(binding: Binding): CapacityMode { + return binding.capacityMode ?? "demand"; +} + +// The cloud refuses scale sets for restricted policies because scale set messages carry neither +// the workflow actor nor an approval decision. +export function scaleSetUnavailableReason(binding: Binding): string | undefined { + if (capacityMode(binding) === "scale_set" || !restrictiveAccess(binding.access)) return undefined; + return "Scale sets need open access: anyone who can run workflows, with no approval step."; +} + +export interface ScaleSetPresentation { + tone: Tone; + label: string; + detail?: string; +} + +function count(value: number, noun: string) { + return `${value} ${noun}${value === 1 ? "" : "s"}`; +} + +export function scaleSetPresentation( + binding: Binding, + state: ScaleSetState | undefined, + paused: boolean, +): ScaleSetPresentation { + if (!binding.automatic && state?.state !== "listening") + return { tone: "idle", label: "Stopped", detail: "Turn on Automatic to receive jobs." }; + if (!state) return { tone: "idle", label: "Status unavailable" }; + switch (state.state) { + case "connecting": + return { tone: "queued", label: "Connecting" }; + case "listening": { + const assigned = state.assignedJobs ?? 0; + const reserved = state.reservedRunners ?? 0; + return { + tone: "running", + label: "Listening", + detail: [ + assigned === 0 ? "No jobs assigned" : `${count(assigned, "job")} assigned`, + ...(reserved > 0 ? [`${count(reserved, "runner")} reserved`] : []), + ].join(" · "), + }; + } + case "action_required": + return { + tone: "attention", + label: "Action required", + detail: + state.code === "scale_set_policy_unsupported" + ? "Restricted access cannot use scale sets. Switch to Demand or open access." + : (state.message ?? "Scale set listening stopped."), + }; + case "stopped": + return { + tone: "idle", + label: "Stopped", + detail: paused + ? "New VMs are paused. Resume them in Overview." + : "Not listening right now.", + }; + } +} diff --git a/apps/desktop/src/renderer/lib/operations.ts b/apps/desktop/src/renderer/lib/operations.ts index fcf31b41..b5aa3d18 100644 --- a/apps/desktop/src/renderer/lib/operations.ts +++ b/apps/desktop/src/renderer/lib/operations.ts @@ -1,5 +1,8 @@ +import { commandLabels as sharedCommandLabels } from "../../command-labels.js"; + export const preparationCommands = [ "environment.prepare-linux", + "environment.provision", "environment.resume", "environment.install-macos", "environment.resume-macos", @@ -19,6 +22,7 @@ export const cancellableCommands = [ "environment.install-windows", "environment.resume-windows", "environment.prepare-linux", + "environment.provision", "environment.resume", "runner.run", "run.rerun-failed", @@ -30,11 +34,19 @@ export const cancellableCommands = [ "migration.publish", "repository.connect", ]; -export { commandLabels } from "../../command-labels.js"; +export const commandLabels: Record = { + ...sharedCommandLabels, + "repository.cache": "Configure repository cache", + "cache.clear": "Clear repository cache", + "repository.capacity-mode": "Change runner capacity mode", +}; // A setting is confirmed after it happened, so its message is what changed, not what was asked. export const settingLabels: Record = { + "repository.capacity-mode": "Runner capacity mode updated", "repository.automatic": "Automatic runners updated", "repository.access": "Repository access updated", + "repository.cache": "Repository cache updated", + "cache.clear": "Repository cache cleared", "environment.configure": "Environment updated", "environment.register": "Environment registered", "environment.remove": "Environment removed", @@ -42,5 +54,5 @@ export const settingLabels: Record = { "instance.reconcile": "VM reconciled", "operation.cancel": "Operation closed", "machine.pause": "VM intake updated", - "machine.configure": "Concurrent VMs updated", + "machine.configure": "VM capacity updated", }; diff --git a/apps/desktop/src/renderer/lib/result-code.test.ts b/apps/desktop/src/renderer/lib/result-code.test.ts new file mode 100644 index 00000000..36f64a2e --- /dev/null +++ b/apps/desktop/src/renderer/lib/result-code.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { resultCodePresentation } from "./result-code"; + +describe("result code presentation", () => { + it("labels images that cannot mount repository caches", () => { + expect(resultCodePresentation({ code: "cache_volume_requires_guest_sudo" })).toEqual({ + tone: "attention", + label: "Cache needs a newer image", + }); + }); + it("reads GitHub outages and rate limits as an external condition", () => { + for (const code of ["github_unavailable", "github_rate_limited"]) { + expect(resultCodePresentation({ code, nextStep: "Wait and retry." })).toEqual({ + tone: "attention", + label: "GitHub degraded", + }); + } + }); + + it("labels runner recovery outcomes", () => { + expect(resultCodePresentation({ code: "runner_recovery_pending" })).toEqual({ + tone: "attention", + label: "Recovering after restart", + }); + expect(resultCodePresentation({ code: "runner_interrupted" })?.tone).toBe("cancelled"); + expect(resultCodePresentation({ code: "runner_registration_failed" })?.tone).toBe("danger"); + expect(resultCodePresentation({ code: "runner_offline" })?.label).toBe("Runner offline"); + }); + + it("ignores unknown codes, local failures and non-objects", () => { + expect(resultCodePresentation({ code: "toString" })).toBeUndefined(); + expect(resultCodePresentation({ code: "job_refresh_failed" })).toBeUndefined(); + expect(resultCodePresentation("github_unavailable")).toBeUndefined(); + expect(resultCodePresentation(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/lib/result-code.ts b/apps/desktop/src/renderer/lib/result-code.ts new file mode 100644 index 00000000..0baccfb7 --- /dev/null +++ b/apps/desktop/src/renderer/lib/result-code.ts @@ -0,0 +1,18 @@ +import type { Tone } from "@/components/status"; + +const codes: Record = { + cache_volume_requires_guest_sudo: ["attention", "Cache needs a newer image"], + github_unavailable: ["attention", "GitHub degraded"], + github_rate_limited: ["attention", "GitHub degraded"], + runner_recovery_pending: ["attention", "Recovering after restart"], + runner_interrupted: ["cancelled", "Interrupted"], + runner_registration_failed: ["danger", "Registration failed"], + runner_offline: ["danger", "Runner offline"], +}; + +export function resultCodePresentation(result: unknown) { + if (typeof result !== "object" || !result || !("code" in result)) return undefined; + if (typeof result.code !== "string" || !Object.hasOwn(codes, result.code)) return undefined; + const [tone, label] = codes[result.code]!; + return { tone, label }; +} diff --git a/apps/desktop/src/renderer/lib/runner-phases.test.ts b/apps/desktop/src/renderer/lib/runner-phases.test.ts new file mode 100644 index 00000000..a8a4af45 --- /dev/null +++ b/apps/desktop/src/renderer/lib/runner-phases.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration, runnerPhaseTimeline } from "./runner-phases"; + +const at = (seconds: number) => new Date(Date.UTC(2026, 0, 1) + seconds * 1000).toISOString(); +const phase = (name: string, start: number, end?: number) => ({ + name, + startedAt: at(start), + ...(end === undefined ? {} : { endedAt: at(end) }), +}); +const base = { stage: "finished", leaseKey: "key" }; + +describe("formatDuration", () => { + it.each([ + [40, "< 0.1 s"], + [100, "0.1 s"], + [5_800, "5.8 s"], + [9_960, "10 s"], + [32_400, "32 s"], + [59_600, "1 min"], + [72_000, "1 min 12 s"], + [3_600_000, "1 h"], + [3_960_000, "1 h 6 min"], + ])("formats %d ms as %s", (ms, text) => { + expect(formatDuration(ms)).toBe(text); + }); +}); + +describe("runnerPhaseTimeline", () => { + it("ignores results without phases", () => { + expect(runnerPhaseTimeline(base, { end: 0, active: false })).toBeUndefined(); + expect(runnerPhaseTimeline({ ...base, phases: [] }, { end: 0, active: false })).toBeUndefined(); + expect(runnerPhaseTimeline(undefined, { end: 0, active: false })).toBeUndefined(); + }); + + it("measures the span, not the sum, when idle checks overlap waiting", () => { + const timeline = runnerPhaseTimeline( + { + ...base, + phases: [ + phase("vm_start", 0, 5.8), + phase("registration", 5.8, 8.4), + phase("waiting_for_job", 8.4, 40.4), + phase("job_check", 10, 10.5), + phase("job_check", 20, 20.5), + phase("job", 40.4, 55.4), + phase("cleanup", 55.4, 57.1), + ], + }, + { end: 999_999, active: false }, + ); + expect(timeline?.total).toBe("57 s"); + expect(timeline?.rows.map((row) => [row.label, row.duration, row.count])).toEqual([ + ["VM start", "5.8 s", 1], + ["Registration", "2.6 s", 1], + ["Waiting for job", "32 s", 1], + ["Job check", "1.0 s", 2], + ["Job", "15 s", 1], + ["Cleanup", "1.7 s", 1], + ]); + const checks = timeline?.rows.find((row) => row.name === "job_check"); + expect(checks?.segments).toHaveLength(2); + expect(checks?.segments[0]?.offset).toBeCloseTo(10 / 57.1); + }); + + it("runs an open phase until now while the runner is active", () => { + const result = { ...base, phases: [phase("vm_start", 0, 6), phase("waiting_for_job", 6)] }; + const now = Date.parse(at(18)); + const running = runnerPhaseTimeline(result, { end: now, active: true }); + expect(running?.rows.at(-1)).toMatchObject({ state: "running", duration: "12 s" }); + expect(running?.total).toBe("18 s"); + expect(runnerPhaseTimeline(result, { end: now, active: false })?.rows.at(-1)?.state).toBe( + "unfinished", + ); + }); + + it("keeps phases from a newer service readable and drops invalid timestamps", () => { + const timeline = runnerPhaseTimeline( + { + ...base, + phases: [phase("snapshot_restore", 0, 2), { name: "vm_start", startedAt: "soon" }], + }, + { end: 0, active: false }, + ); + expect(timeline?.rows.map((row) => row.label)).toEqual(["Snapshot restore"]); + }); +}); diff --git a/apps/desktop/src/renderer/lib/runner-phases.ts b/apps/desktop/src/renderer/lib/runner-phases.ts new file mode 100644 index 00000000..6e97fcd5 --- /dev/null +++ b/apps/desktop/src/renderer/lib/runner-phases.ts @@ -0,0 +1,120 @@ +import { Schema } from "effect"; +import type { PhaseName } from "../../../../../packages/protocol/src/runners.js"; + +const labels = { + queued_locally: "Queued locally", + job_check: "Job check", + vm_start: "VM start", + warm_takeover: "Warm takeover", + vm_start_and_guest_ready: "VM start and guest ready", + guest_ready: "Guest ready", + runner_install: "Runner install", + runner_preinstalled: "Runner preinstalled", + registration: "Registration", + waiting_for_job: "Waiting for job", + job: "Job", + cleanup: "Cleanup", +} as const satisfies Record; + +// Names stay plain strings so a newer service's phases still render. +const PhasesResult = Schema.Struct({ + phases: Schema.Array( + Schema.Struct({ + name: Schema.String, + startedAt: Schema.String, + endedAt: Schema.optional(Schema.String), + }), + ), +}); +const isPhasesResult = Schema.is(PhasesResult); + +export type PhaseState = "done" | "running" | "unfinished"; + +export interface PhaseRow { + name: string; + label: string; + count: number; + durationMs: number; + duration: string; + state: PhaseState; + segments: Array<{ offset: number; width: number }>; +} + +export interface PhaseTimeline { + rows: PhaseRow[]; + totalMs: number; + total: string; +} + +export function formatDuration(ms: number) { + if (ms < 100) return "< 0.1 s"; + const tenths = Math.round(ms / 100); + if (tenths < 100) return `${(tenths / 10).toFixed(1)} s`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds} s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return seconds % 60 ? `${minutes} min ${seconds % 60} s` : `${minutes} min`; + const hours = Math.floor(minutes / 60); + return minutes % 60 ? `${hours} h ${minutes % 60} min` : `${hours} h`; +} + +const knownLabels: Partial> = labels; + +function phaseLabel(name: string) { + const known = Object.hasOwn(knownLabels, name) ? knownLabels[name] : undefined; + if (known) return known; + const words = name.replaceAll("_", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +// An open phase runs until `end` while the runner is active; otherwise its end was never recorded. +export function runnerPhaseTimeline( + result: unknown, + { end, active }: { end: number; active: boolean }, +): PhaseTimeline | undefined { + if (!isPhasesResult(result)) return undefined; + const spans = result.phases + .map((phase) => { + const start = Date.parse(phase.startedAt); + const recordedEnd = phase.endedAt === undefined ? Number.NaN : Date.parse(phase.endedAt); + const open = Number.isNaN(recordedEnd); + return { + name: phase.name, + start, + end: Math.max(start, open ? end : recordedEnd), + open, + }; + }) + .filter((span) => !Number.isNaN(span.start)) + .sort((a, b) => a.start - b.start); + const first = spans[0]; + if (!first) return undefined; + const totalMs = Math.max(...spans.map((span) => span.end)) - first.start; + + const rows = new Map(); + for (const span of spans) { + const row = rows.get(span.name) ?? { + name: span.name, + label: phaseLabel(span.name), + count: 0, + durationMs: 0, + duration: "", + state: "done", + segments: [], + }; + row.count += 1; + row.durationMs += span.end - span.start; + if (span.open) row.state = active ? "running" : "unfinished"; + row.segments.push( + totalMs > 0 + ? { offset: (span.start - first.start) / totalMs, width: (span.end - span.start) / totalMs } + : { offset: 0, width: 0 }, + ); + rows.set(span.name, row); + } + return { + rows: [...rows.values()].map((row) => ({ ...row, duration: formatDuration(row.durationMs) })), + totalMs, + total: formatDuration(totalMs), + }; +} diff --git a/apps/desktop/src/renderer/lib/vm-capacity.test.ts b/apps/desktop/src/renderer/lib/vm-capacity.test.ts new file mode 100644 index 00000000..722a7d68 --- /dev/null +++ b/apps/desktop/src/renderer/lib/vm-capacity.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + budgetLimits, + committedShare, + defaultBudgetMiB, + derivedImageDirectory, + instanceDisplay, + isPoolInstance, + pressureDisplay, + setupScriptTooLarge, + stepBudget, + type MemoryBudget, +} from "./vm-capacity"; + +const budget: MemoryBudget = { + memoryBudgetMiB: 24576, + memoryCommittedMiB: 8192, + hostMemoryMiB: 32768, + pressure: "normal", + pressureKnown: true, +}; + +describe("memory budget", () => { + it("stays between 2 GiB and host memory, which the service enforces", () => { + expect(budgetLimits(32768)).toEqual({ min: 2048, max: 32768 }); + expect(stepBudget(2048, -1, 32768)).toBe(2048); + expect(stepBudget(32000, 1, 32768)).toBe(32768); + expect(stepBudget(13824, 1, 32768)).toBe(14848); + }); + + it("matches the service default of 75% of host memory", () => { + expect(defaultBudgetMiB(18432)).toBe(13824); + expect(defaultBudgetMiB(16383.9)).toBe(12287); + }); + + it("does not report normal pressure when the host cannot measure it", () => { + expect(pressureDisplay({ ...budget, pressureKnown: false }).tone).toBe("unsupported"); + expect(pressureDisplay({ ...budget, pressure: "critical" }).tone).toBe("danger"); + expect(pressureDisplay({ ...budget, pressure: "warning" }).tone).toBe("attention"); + expect(pressureDisplay(budget).tone).toBe("success"); + }); + + it("clamps the committed share for the meter", () => { + expect(committedShare(budget)).toBe(1 / 3); + expect(committedShare({ ...budget, memoryCommittedMiB: 40000 })).toBe(1); + }); +}); + +describe("pool instances", () => { + it("labels warm VMs separately from job VMs", () => { + expect(instanceDisplay({ status: "running", role: "warm" }).label).toBe("Warm"); + expect(instanceDisplay({ status: "starting", role: "warming" }).label).toBe("Warming"); + expect(instanceDisplay({ status: "running" }).label).toBe("Running"); + }); + + it("treats a stopped former pool VM as stopped, not warm", () => { + expect(isPoolInstance({ status: "stopped", role: "warm" })).toBe(false); + expect(instanceDisplay({ status: "stopped", role: "warm" }).label).toBe("Stopped"); + }); +}); + +describe("derived environments", () => { + it("measures the setup script limit in UTF-8 bytes", () => { + expect(setupScriptTooLarge("a".repeat(65536))).toBe(false); + expect(setupScriptTooLarge("é".repeat(32769))).toBe(true); + }); + + it("puts the image in a new folder inside the chosen one", () => { + expect(derivedImageDirectory("/Volumes/VMs/", "ubuntu-custom")).toBe( + "/Volumes/VMs/ubuntu-custom", + ); + expect(derivedImageDirectory("/", "ubuntu-custom")).toBe("/ubuntu-custom"); + }); +}); diff --git a/apps/desktop/src/renderer/lib/vm-capacity.ts b/apps/desktop/src/renderer/lib/vm-capacity.ts new file mode 100644 index 00000000..bae4d148 --- /dev/null +++ b/apps/desktop/src/renderer/lib/vm-capacity.ts @@ -0,0 +1,64 @@ +import type { Instance, RunnerCapacity } from "../../../../../packages/protocol/src/index.js"; +import type { Tone } from "@/components/status"; + +export type MemoryBudget = NonNullable; + +const minimumBudgetMiB = 2048; +const budgetStepMiB = 1024; + +export function budgetLimits(hostMemoryMiB: number) { + return { min: minimumBudgetMiB, max: Math.max(minimumBudgetMiB, Math.floor(hostMemoryMiB)) }; +} + +export function defaultBudgetMiB(hostMemoryMiB: number) { + return Math.floor(hostMemoryMiB * 0.75); +} + +export function stepBudget(currentMiB: number, direction: 1 | -1, hostMemoryMiB: number) { + const { min, max } = budgetLimits(hostMemoryMiB); + return Math.min(max, Math.max(min, currentMiB + direction * budgetStepMiB)); +} + +export function pressureDisplay(budget: MemoryBudget): { tone: Tone; label: string } { + if (budget.pressureKnown === false) return { tone: "unsupported", label: "Pressure unknown" }; + if (budget.pressure === "critical") return { tone: "danger", label: "Memory pressure critical" }; + if (budget.pressure === "warning") return { tone: "attention", label: "Memory pressure high" }; + return { tone: "success", label: "Memory pressure normal" }; +} + +export function committedShare(budget: MemoryBudget) { + if (budget.memoryBudgetMiB <= 0) return 0; + return Math.min(1, Math.max(0, budget.memoryCommittedMiB / budget.memoryBudgetMiB)); +} + +export function isPoolInstance(instance: Pick) { + return !!instance.role && (instance.status === "starting" || instance.status === "running"); +} + +export function instanceDisplay(instance: Pick): { + tone: Tone; + label: string; +} { + if (isPoolInstance(instance)) + return instance.role === "warm" + ? { tone: "idle", label: "Warm" } + : { tone: "queued", label: "Warming" }; + const states = { + starting: { tone: "queued", label: "Starting" }, + running: { tone: "running", label: "Running" }, + stopped: { tone: "idle", label: "Stopped" }, + interrupted: { tone: "attention", label: "Interrupted" }, + } as const satisfies Record; + return states[instance.status]; +} + +export const setupScriptLimitBytes = 65536; + +export function setupScriptTooLarge(script: string) { + return new TextEncoder().encode(script).length > setupScriptLimitBytes; +} + +export function derivedImageDirectory(picked: string, id: string) { + const parent = picked.replace(/\/+$/, ""); + return id ? `${parent}/${id}` : parent; +} diff --git a/apps/desktop/src/renderer/state.tsx b/apps/desktop/src/renderer/state.tsx index 8935f76a..38c00f0b 100644 --- a/apps/desktop/src/renderer/state.tsx +++ b/apps/desktop/src/renderer/state.tsx @@ -3,11 +3,13 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode import { Schema } from "effect"; import { commandActivity, + ActivityDetail, Operation, Snapshot, type Command, } from "../../../../packages/protocol/src/index.js"; import type { DesktopAction } from "../bridge.js"; +import { mergeActivityDetail } from "@/lib/activity-detail"; import { activityTitle, openActivity } from "@/lib/activities"; import { notify } from "@/lib/notify"; import { commandLabels, settingLabels } from "@/lib/operations"; @@ -33,6 +35,7 @@ export async function readJobLogs(input: JobLogRequest, machineId?: string) { // Reads never change anything, so they never need the views to look again. const reads: ReadonlySet = new Set([ "status", + "activity", "notifications.get", "jobs", "job.logs", @@ -93,7 +96,14 @@ export function StateProvider({ children }: { children: ReactNode }) { const value = Schema.decodeUnknownSync(Snapshot)( await request("status", undefined, machineId), ); - if (!stopped) setSnapshot(value); + const route = new URL(window.location.hash.slice(1), "http://localhost"); + const id = route.pathname === "/activities" ? route.searchParams.get("id") : null; + const detail = id + ? await request("activity", id, machineId) + .then((reply) => Schema.decodeUnknownSync(ActivityDetail)(reply)) + .catch(() => undefined) + : undefined; + if (!stopped) setSnapshot(detail ? mergeActivityDetail(value, detail) : value); } catch { if (!stopped) setSnapshot(null); } @@ -113,11 +123,13 @@ export function StateProvider({ children }: { children: ReactNode }) { if (document.visibilityState === "visible") void refresh(); }; document.addEventListener("visibilitychange", wake); + window.addEventListener("hashchange", wake); void refresh(); return () => { stopped = true; clearTimeout(timer); document.removeEventListener("visibilitychange", wake); + window.removeEventListener("hashchange", wake); }; }, [machineId]); useEffect(() => { @@ -211,3 +223,27 @@ export function useStateApi() { if (!state) throw new Error("Missing desktop state."); return state; } + +export function useActivityDetail(id: string, open: boolean) { + const { snapshot, machineId } = useStateApi(); + const [detail, setDetail] = useState<{ machineId: string | undefined; value: ActivityDetail }>(); + const revision = snapshot?.revision; + useEffect(() => { + if (!open) return; + let stopped = false; + void request("activity", id, machineId) + .then((reply) => { + const value = Schema.decodeUnknownSync(ActivityDetail)(reply); + if (!stopped) setDetail({ machineId, value }); + }) + .catch(() => { + if (!stopped) setDetail(undefined); + }); + return () => { + stopped = true; + }; + }, [id, open, machineId, revision]); + return detail?.machineId === machineId && detail?.value.activity.id === id + ? detail.value + : undefined; +} diff --git a/apps/desktop/src/renderer/views/environments.tsx b/apps/desktop/src/renderer/views/environments.tsx index 5d7dd2ed..5293369e 100644 --- a/apps/desktop/src/renderer/views/environments.tsx +++ b/apps/desktop/src/renderer/views/environments.tsx @@ -15,7 +15,13 @@ import { } from "@/components/layout"; import { Reason, Resources } from "@/components/hint"; import { OsTile } from "@/components/os-icon"; -import { formatMemory } from "@/lib/format"; +import { formatDateTime, formatMemory } from "@/lib/format"; +import { + derivedImageDirectory, + isPoolInstance, + setupScriptLimitBytes, + setupScriptTooLarge, +} from "@/lib/vm-capacity"; import { canRestrictNetwork, initialDraft, @@ -51,7 +57,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useStateApi } from "@/state"; -import { PrepareEnvironment, RegisterEnvironment } from "./prepare-environment"; +import { PrepareEnvironment, RegisterEnvironment, TextField } from "./prepare-environment"; export function Environments() { const { snapshot, machineId } = useStateApi(); @@ -118,11 +124,17 @@ function EnvironmentRow({ environment }: { environment: EnvironmentView }) { const network = networkDisplay(environment); const { submit, snapshot } = useStateApi(); const [configuring, setConfiguring] = useState(false); - const running = snapshot?.instances.filter( + const [deriving, setDeriving] = useState(false); + const active = (snapshot?.instances ?? []).filter( (instance) => instance.environmentId === environment.id && (instance.status === "starting" || instance.status === "running"), - ).length; + ); + const warm = active.filter(isPoolInstance).length; + const running = active.length - warm; + const parentId = environment.image?.parentId; + const parentName = snapshot?.environments.find((item) => item.id === parentId)?.name; + const parent = parentName ?? parentId; return ( {!!running && {running} running} + {!!warm && {warm} warm} + {parent && Derived from {parent}} } actions={ @@ -168,8 +182,15 @@ function EnvironmentRow({ environment }: { environment: EnvironmentView }) { setConfiguring(true)}> Resources and storage… + setDeriving(true)} + > + Create derived environment… + + } > +
{environment.id}], + [ + "Derived from", + parentId && + (parentName ? ( + + {parentName} {parentId} + + ) : ( + {parentId} + )), + ], + [ + "Runner", + environment.image && + (environment.image.runnerVersion + ? `Preinstalled, version ${environment.image.runnerVersion}` + : "Not preinstalled"), + ], + ["Provisioned", environment.image && formatDateTime(environment.image.provisionedAt)], ["Base image", {environment.basePath}], [ "VM directory", @@ -352,3 +393,188 @@ function ConfigureEnvironment({ ); } + +const warmPoolSizes = ["0", "1", "2", "3", "4"] as const; + +function WarmPoolField({ environment }: { environment: EnvironmentView }) { + const { submit } = useStateApi(); + const saved = environment.warmPool ?? 0; + const [pending, setPending] = useState(); + const ready = environment.state === "ready"; + const change = async (warmPool: number) => { + setPending(warmPool); + await submit( + { type: "environment.configure", id: environment.id, warmPool }, + warmPool + ? `Warm pool for ${environment.name} set to ${warmPool}` + : `Warm pool for ${environment.name} turned off`, + ); + setPending(undefined); + }; + const label = `${environment.id}-warm-pool`; + return ( +
+
+

+ Warm pool +

+

+ Keeps booted VMs waiting so jobs start faster. Each warm VM uses memory while idle and + never holds job credentials. +

+
+ { + const size = Number(value); + if (size !== (pending ?? saved)) void change(size); + }} + > + + {warmPoolSizes.map((size) => ( + + {size === "0" ? "Off" : size} + + ))} + + +
+ ); +} + +function DeriveEnvironment({ + parent, + open, + onOpenChange, +}: { + parent: EnvironmentView; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + Create derived environment + + Starts from a copy of {parent.name} and saves the result as a new environment.{" "} + {parent.name} itself does not change. + {parent.os === "windows" && + " Windows also gets Git with GNU tar, zstd, PowerShell 7 and a Defender policy exclusion."}{" "} + Other VMs must be stopped while it is provisioned. + + + onOpenChange(false)} /> + + + ); +} + +function DeriveEnvironmentForm({ + parent, + onDone, +}: { + parent: EnvironmentView; + onDone: () => void; +}) { + const { submit } = useStateApi(); + const [id, setId] = useState(`${parent.id}-custom`); + const [imageDirectory, setImageDirectory] = useState(""); + const [setupScript, setSetupScript] = useState(""); + const [runner, setRunner] = useState(true); + const [pending, setPending] = useState(false); + const tooLarge = setupScriptTooLarge(setupScript); + return ( +
{ + event.preventDefault(); + if (tooLarge) return; + const data = new FormData(event.currentTarget); + const name = String(data.get("name") ?? "").trim(); + setPending(true); + void submit({ + type: "environment.provision", + id, + from: parent.id, + imageDirectory, + runner, + ...(name ? { name } : {}), + ...(setupScript.trim() ? { setupScript } : {}), + }) + .then((value) => value !== undefined && onDone()) + .finally(() => setPending(false)); + }} + > +
+ +
+ + Environment ID + setId(event.target.value)} + /> + + +
+ derivedImageDirectory(picked, id)} + description="A new folder for the image. Choosing a folder adds the environment ID as a new folder inside it." + /> + + Setup script (optional) +