Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0278679
fix(runners): schedule per environment capacity and count only failed…
kerdofficial Oct 16, 2026
73933c8
fix(github): retry transient GitHub failures
kerdofficial Oct 16, 2026
1fe50ab
feat(github): redeliver failed webhooks
kerdofficial Oct 16, 2026
61036cb
fix(github): keep webhook delivery IDs exact
kerdofficial Oct 16, 2026
1dced22
fix(runner): recover stuck runners on their own
kerdofficial Oct 16, 2026
c895fa8
feat(runner): share one resource budget across VMs
kerdofficial Oct 16, 2026
69a4b4c
feat(runner): record phase timings
kerdofficial Oct 16, 2026
0009b35
feat(environments): provision derived images with a preinstalled runner
kerdofficial Oct 16, 2026
31ca764
feat(runner): per repository cache volumes
kerdofficial Oct 16, 2026
9ba4ece
fix(desktop): label GitHub degraded results
kerdofficial Oct 16, 2026
f21238e
fix(desktop): label runner recovery results
kerdofficial Oct 16, 2026
505f58f
fix(cloud): issue a fresh machine token on every refresh
kerdofficial Oct 16, 2026
b530f17
fix(server): keep status snapshots bounded
kerdofficial Oct 16, 2026
95995a0
fix(environments): surface provisioning output and avoid sudo for the…
kerdofficial Oct 16, 2026
cde185f
feat(runner): keep an optional warm pool
kerdofficial Oct 16, 2026
c6fa962
feat(runner): time job start from the guest and show a phase timeline
kerdofficial Oct 16, 2026
03443a4
Merge branch 'fix/runner-capacity-per-environment' into fix/ci-reliab…
kerdofficial Oct 16, 2026
8cfb00d
Merge branch 'feat/webhook-redelivery' into fix/ci-reliability
kerdofficial Oct 16, 2026
93581cc
Merge branch 'fix/runner-self-recovery' into fix/ci-reliability
kerdofficial Oct 16, 2026
bb770ce
Merge branch 'fix/bounded-status' into fix/ci-reliability
kerdofficial Oct 16, 2026
65460fc
Merge branch 'feat/runner-phase-timings' into fix/ci-reliability
kerdofficial Oct 16, 2026
1069944
Merge branch 'fix/fresh-machine-token' into fix/ci-reliability
kerdofficial Oct 16, 2026
f8e0614
fix(desktop): combine result code labels
kerdofficial Oct 16, 2026
f28e9c3
fix(server): read capacity records outside the status window
kerdofficial Oct 16, 2026
65eb406
fix(environments): stop provisioning guests through the hypervisor
kerdofficial Oct 16, 2026
d61c626
feat(protocol): define the runner scale set contract
kerdofficial Oct 16, 2026
f055e23
Merge branch 'feat/warm-pool' into overnight/int2
kerdofficial Oct 16, 2026
c1748d3
test(server): prove status windows with small injected limits
kerdofficial Oct 16, 2026
f35bea2
Merge branch 'feat/repository-cache-volumes' into overnight/int2
kerdofficial Oct 16, 2026
260617f
test(client): prove the inspection cap with a small injected limit
kerdofficial Oct 16, 2026
ec52383
fix(environments): fail Linux preparation fast with console diagnostics
kerdofficial Oct 16, 2026
08ae10c
fix(windows): ship GNU tar, zstd and pwsh and quiet background services
kerdofficial Oct 16, 2026
680650e
feat(runners): run capacity through GitHub runner scale sets
kerdofficial Oct 16, 2026
78c2b4c
fix(windows): configure Defender through policy and surface guest errors
kerdofficial Oct 16, 2026
996df23
feat(desktop): switch a repository between demand and scale set capacity
kerdofficial Oct 16, 2026
624cfe5
Merge branch 'feat/scale-sets' into overnight/int2
kerdofficial Oct 16, 2026
91482a7
fix(runner): report a clean guest power-off as success
kerdofficial Oct 16, 2026
609f5d6
Merge branch 'feat/scale-sets' into overnight/int2
kerdofficial Oct 16, 2026
5f5b924
Merge branch 'feat/windows-guest-tooling' into overnight/int2
kerdofficial Oct 16, 2026
d5db899
feat(desktop): set the VM memory budget, warm pools and derived envir…
kerdofficial Oct 16, 2026
3493c94
fix(runner): stop cache volume runners early when the guest cannot mo…
kerdofficial Oct 16, 2026
ac5df10
fix(environments): shut macOS provisioning guests down without prompts
kerdofficial Oct 16, 2026
49316c4
fix(runner): read run metadata for cache write-back and report the ca…
kerdofficial Oct 16, 2026
79ebdcc
fix(client): report new environments to the cloud before connecting
kerdofficial Oct 16, 2026
249cc71
Merge branch 'fix/ci-reliability' into overnight/int2
kerdofficial Oct 16, 2026
5119fe1
test(server): prove warm pool and MCP status windows with small limits
kerdofficial Oct 16, 2026
a77228b
docs(runners): drop the unverified scale set guest note
kerdofficial Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 388 additions & 2 deletions apps/cli/src/main.test.ts

Large diffs are not rendered by default.

86 changes: 77 additions & 9 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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 <count>] Read the newest service log lines (default 200, at most 1000)
Expand All @@ -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 <N> Set concurrent runner limit (1-16, default 5)
machine configure [--max-runners <N>] [--memory-budget-mib <N>]
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 <owner/name> [--environment <id>] Check readiness before connecting
Expand All @@ -153,9 +161,12 @@ Repositories and jobs
repository connect <name> Connect with --account <id> and --environment <id>, optional --owner <org>
repository enable-auto <id> Start runners automatically for matching queued jobs
repository disable-auto <id> Stop starting runners automatically
repository capacity-mode <id> demand|scale-set Choose runner capacity mode (scale sets are preview)
repository disconnect <id> Stop future runners without interrupting running jobs
runner run <binding-id> Start one disposable runner, optionally only for --job-id <id>
runner reconcile <operation-id> Clean up an interrupted runner after verifying its VM stopped
repository cache enable|disable|clear <binding-id> [--size-gib N]
Configure or clear an opt-in repository cache disk (default 20 GiB)
repository access <binding-id> --actors any|<id,id,...> --approval automatic|required
Actor IDs are numeric GitHub user IDs: gh api users/<login> --jq .id
job list <binding-id> Read recent GitHub job states for a connected repository
Expand All @@ -177,6 +188,8 @@ Workflow migration
migration publish <preview-id> Create or recover its pull request; never merges

Environments
environment provision <id> Derive a new image with --from <id> --image-directory <new-path>
[--name <name>] [--setup-script <file>] [--no-runner]
environment prepare-linux <id> Prepare Ubuntu 24.04 with --image-directory and --storage-path
environment resume <setup-id> Continue an interrupted Ubuntu preparation
environment install-macos <id> Install macOS 26 with --image-directory and --storage-path, optional --restore-path
Expand All @@ -191,16 +204,17 @@ Environments
environment register --file <path> Register an existing prepared environment JSON file
environment list List environments and effective network egress
environment configure <id> Set --cpu, --memory-mib, --storage-path, --x64 rosetta|off or --egress internet_only|open for future VMs
--warm-pool <N> keeps 0-4 SSH-ready VMs (default 0, uses RAM)
environment start <id> Start a clean VM, optionally overriding --cpu, --memory-mib, --storage-path
environment remove <id> Remove an idle definition, preserving its disk
instance stop <id> Stop a VM owned by this service
instance reconcile <id> 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 <id> Inspect one activity with the operations that belong to it
activity cancel <id> Cancel work an activity owns, or close one that only waits for you
operation get <id> Inspect one operation
operation get <id> Inspect one retained operation, including outside the status window
operation wait <id> Wait for an operation's terminal state; on --timeout, print it as it is
operation cancel <id> Cancel an active operation, or close one that only waits for you
command --file <path> Submit any protocol command as JSON
Expand Down Expand Up @@ -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"}}.
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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 <binding-id>; --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 <binding-id> 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))
Expand Down Expand Up @@ -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",
Expand All @@ -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) } : {}),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type DesktopAction =
| "notifications.get"
| "notifications.set"
| "status"
| "activity"
| "storage"
| "doctor"
| "logs"
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/command-labels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const commandLabels: Record<string, string> = {
"environment.provision": "Provision derived environment",
"environment.prepare-linux": "Prepare Linux image",
"environment.resume": "Resume Linux preparation",
"environment.install-macos": "Install macOS",
Expand Down Expand Up @@ -32,5 +33,5 @@ export const commandLabels: Record<string, string> = {
"environment.remove": "Remove environment",
"operation.cancel": "Close operation",
"machine.pause": "Change VM intake",
"machine.configure": "Change concurrent VMs",
"machine.configure": "Change VM capacity",
};
5 changes: 5 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
36 changes: 30 additions & 6 deletions apps/desktop/src/renderer/components/activity-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 =
Expand All @@ -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 &&
Expand All @@ -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 (
<ExpandableRow
defaultOpen={defaultOpen}
onOpenChange={setOpen}
leading={
activity.kind === "github" ? (
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-muted text-foreground/80">
Expand All @@ -101,6 +122,8 @@ export function ActivityRow({
{job?.status && <JobStatus status={job.status} conclusion={job.conclusion ?? null} />}
{stage ? (
<RunnerStage stage={stage} />
) : codeStatus && !runnerQuiet ? (
<StatusBadge tone={codeStatus.tone} label={codeStatus.label} />
) : (
!runnerQuiet && <OperationStatus status={activity.status} />
)}
Expand Down Expand Up @@ -128,6 +151,7 @@ export function ActivityRow({
</pre>
</div>
)}
{timeline && <RunnerPhases timeline={timeline} />}
{job && activity.bindingId && (
<div className="flex flex-col gap-3">
<div>
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export function ExpandableRow({
aside,
actions,
defaultOpen = false,
onOpenChange,
children,
}: {
id?: string;
Expand All @@ -279,11 +280,13 @@ export function ExpandableRow({
aside?: ReactNode;
actions?: ReactNode;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) {
return (
<Collapsible
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
className="rise-in scroll-mt-4"
id={id ? sectionId(id) : undefined}
>
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/components/path-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function PathField({
required,
placeholder,
description,
fromChooser = (picked) => picked,
}: {
label: string;
value: string;
Expand All @@ -22,6 +23,7 @@ export function PathField({
required?: boolean;
placeholder?: string;
description?: string;
fromChooser?: (picked: string) => string;
}) {
const { perform, machineId } = useStateApi();
const id = useId();
Expand All @@ -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));
})
}
>
Expand Down
Loading
Loading