feat(portal): show external runners

This commit is contained in:
Vincent Koc 2026-05-06 02:56:41 -07:00
parent a17122a907
commit 58435c41e1
No known key found for this signature in database
7 changed files with 546 additions and 5 deletions

View File

@ -160,6 +160,23 @@ type CoordinatorRunEventsResponse struct {
Events []CoordinatorRunEvent `json:"events"`
}
type CoordinatorExternalRunner struct {
ID string `json:"id"`
Provider string `json:"provider,omitempty"`
Status string `json:"status,omitempty"`
Repo string `json:"repo,omitempty"`
Workflow string `json:"workflow,omitempty"`
Job string `json:"job,omitempty"`
Ref string `json:"ref,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
Created string `json:"created,omitempty"`
}
type CoordinatorExternalRunnerSyncResponse struct {
Runners []CoordinatorExternalRunner `json:"runners"`
Stale []CoordinatorExternalRunner `json:"stale"`
}
type CoordinatorRunEventResponse struct {
Event CoordinatorRunEvent `json:"event"`
}
@ -599,6 +616,15 @@ func (c *CoordinatorClient) AppendRunEvent(ctx context.Context, runID string, in
return res.Event, err
}
func (c *CoordinatorClient) SyncExternalRunners(ctx context.Context, provider string, runners []CoordinatorExternalRunner) (CoordinatorExternalRunnerSyncResponse, error) {
var res CoordinatorExternalRunnerSyncResponse
err := c.do(ctx, http.MethodPost, "/v1/runners/sync", map[string]any{
"provider": provider,
"runners": runners,
}, &res)
return res, err
}
func (c *CoordinatorClient) RunEvents(ctx context.Context, runID string, after, limit int) ([]CoordinatorRunEvent, error) {
var res CoordinatorRunEventsResponse
values := url.Values{}

View File

@ -3,6 +3,7 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
@ -44,6 +45,7 @@ func (a App) list(ctx context.Context, args []string) error {
if err != nil {
return err
}
a.syncExternalRunnersBestEffort(ctx, cfg, backend)
if *jsonOut {
if jsonBackend, ok := backend.(JSONListBackend); ok {
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
@ -58,6 +60,51 @@ func (a App) list(ctx context.Context, args []string) error {
return nil
}
func (a App) syncExternalRunnersBestEffort(ctx context.Context, cfg Config, backend Backend) {
if !isBlacksmithProvider(cfg.Provider) {
return
}
client, ok, err := newCoordinatorClient(cfg)
if err != nil || !ok {
return
}
jsonBackend, ok := backend.(JSONListBackend)
if !ok {
return
}
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
if err != nil {
fmt.Fprintf(a.Stderr, "warning: external runner portal sync skipped: %v\n", err)
return
}
runners, err := coordinatorExternalRunnersFromListView(view)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: external runner portal sync skipped: %v\n", err)
return
}
if _, err := client.SyncExternalRunners(ctx, "blacksmith-testbox", runners); err != nil {
fmt.Fprintf(a.Stderr, "warning: external runner portal sync failed: %v\n", err)
}
}
func coordinatorExternalRunnersFromListView(view any) ([]CoordinatorExternalRunner, error) {
data, err := json.Marshal(view)
if err != nil {
return nil, err
}
var runners []CoordinatorExternalRunner
if err := json.Unmarshal(data, &runners); err != nil {
return nil, err
}
for i := range runners {
runners[i].Provider = "blacksmith-testbox"
if runners[i].CreatedAt == "" {
runners[i].CreatedAt = runners[i].Created
}
}
return runners, nil
}
func activeCoordinatorLeaseIDs(leases []CoordinatorLease) map[string]struct{} {
ids := make(map[string]struct{}, len(leases))
for _, lease := range leases {

View File

@ -110,6 +110,35 @@ func TestCoordinatorMachineOrphanField(t *testing.T) {
}
}
func TestCoordinatorExternalRunnersFromBlacksmithListView(t *testing.T) {
view := []map[string]string{
{
"id": "tbx_01kqyahxh67z6qtwtsdkt5xcst",
"status": "ready",
"repo": "openclaw",
"workflow": ".github/workflows/ci-check-testbox.yml",
"job": "check",
"ref": "main",
"created": "2026-05-06T09:45:16.000000Z",
},
}
runners, err := coordinatorExternalRunnersFromListView(view)
if err != nil {
t.Fatal(err)
}
if len(runners) != 1 {
t.Fatalf("len=%d, want 1", len(runners))
}
got := runners[0]
if got.Provider != "blacksmith-testbox" {
t.Fatalf("provider=%q", got.Provider)
}
if got.ID != "tbx_01kqyahxh67z6qtwtsdkt5xcst" || got.CreatedAt != "2026-05-06T09:45:16.000000Z" {
t.Fatalf("unexpected runner: %#v", got)
}
}
func TestHeartbeatInterval(t *testing.T) {
tests := map[time.Duration]time.Duration{
0: time.Minute,

View File

@ -23,6 +23,9 @@ import {
} from "./tailscale";
import type {
Env,
ExternalRunnerInput,
ExternalRunnerRecord,
ExternalRunnerSyncRequest,
LeaseRecord,
LeaseRequest,
LeaseTelemetry,
@ -49,6 +52,7 @@ const maxStoredRunLogBytes = 8 * 1024 * 1024;
const runLogChunkBytes = 64 * 1024;
const maxLeaseTelemetryHistory = 60;
const maxRunTelemetrySamples = 60;
const maxExternalRunnerSyncItems = 200;
const webVNCTicketTTLSeconds = 120;
const codeTicketTTLSeconds = 120;
const maxPendingWebVNCBytes = 1024 * 1024;
@ -211,6 +215,12 @@ export class FleetDurableObject implements DurableObject {
if (method === "GET" && parts.join("/") === "v1/runs") {
return await this.listRuns(request);
}
if (method === "GET" && parts.join("/") === "v1/runners") {
return await this.listExternalRunners(request);
}
if (method === "POST" && parts.join("/") === "v1/runners/sync") {
return await this.syncExternalRunners(request);
}
if (method === "POST" && parts.join("/") === "v1/runs") {
return await this.createRun(request);
}
@ -662,7 +672,11 @@ export class FleetDurableObject implements DurableObject {
private async portalRoute(request: Request, parts: string[]): Promise<Response> {
const method = request.method.toUpperCase();
if (method === "GET" && parts.length === 1) {
return portalHome(await this.portalLeases(request), request);
const [leases, runners] = await Promise.all([
this.portalLeases(request),
this.visibleExternalRunners(request),
]);
return portalHome(leases, runners, request);
}
if (method === "GET" && parts[1] === "runs" && parts[2]) {
return await this.portalRunRoute(request, parts[2], parts[3]);
@ -1638,6 +1652,97 @@ export class FleetDurableObject implements DurableObject {
});
}
private async listExternalRunners(request: Request): Promise<Response> {
const url = new URL(request.url);
const provider = url.searchParams.get("provider") ?? "";
const status = url.searchParams.get("status") ?? "";
const stale = url.searchParams.get("stale") ?? "";
const limit = clampLimit(url.searchParams.get("limit"), 100);
return json({
runners: (await this.visibleExternalRunners(request))
.filter((runner) => !provider || runner.provider === provider)
.filter((runner) => !status || runner.status === status)
.filter((runner) => {
if (stale === "true") {
return runner.stale === true;
}
if (stale === "false") {
return runner.stale !== true;
}
return true;
})
.toSorted((a, b) => runnerSortTime(b).localeCompare(runnerSortTime(a)))
.slice(0, limit),
});
}
private async syncExternalRunners(request: Request): Promise<Response> {
const owner = requestOwner(request);
const org = requestOrg(request, this.env);
const input = await readJson<ExternalRunnerSyncRequest>(request);
const provider = sanitizeRunnerProvider(input.provider);
if (!provider) {
return json({ error: "invalid_provider" }, { status: 400 });
}
const rawRunners = Array.isArray(input.runners) ? input.runners : [];
if (rawRunners.length > maxExternalRunnerSyncItems) {
return json({ error: "too_many_runners" }, { status: 400 });
}
const now = new Date();
const nowISO = now.toISOString();
const existing = await this.externalRunnerRecords();
const seenIDs = new Set<string>();
const synced: ExternalRunnerRecord[] = [];
for (const raw of rawRunners) {
const sanitized = sanitizeExternalRunner(raw, provider, now);
if (!sanitized || seenIDs.has(sanitized.id)) {
continue;
}
seenIDs.add(sanitized.id);
const previous = existing.find(
(runner) =>
runner.provider === provider &&
runner.id === sanitized.id &&
runner.owner === owner &&
runner.org === org,
);
const runner: ExternalRunnerRecord = {
...previous,
...sanitized,
owner,
org,
provider,
firstSeenAt: previous?.firstSeenAt || nowISO,
lastSeenAt: nowISO,
updatedAt: nowISO,
};
delete runner.stale;
await this.putExternalRunner(runner);
synced.push(runner);
}
const stale: ExternalRunnerRecord[] = [];
for (const runner of existing) {
if (
runner.provider !== provider ||
runner.owner !== owner ||
runner.org !== org ||
seenIDs.has(runner.id) ||
runner.stale
) {
continue;
}
const next: ExternalRunnerRecord = {
...runner,
status: "missing",
stale: true,
updatedAt: nowISO,
};
await this.putExternalRunner(next);
stale.push(next);
}
return json({ runners: synced, stale });
}
private async usage(request: Request): Promise<Response> {
const url = new URL(request.url);
const requestedScope = url.searchParams.get("scope") ?? "user";
@ -1792,6 +1897,21 @@ export class FleetDurableObject implements DurableObject {
return [...runs.values()];
}
private async externalRunnerRecords(): Promise<ExternalRunnerRecord[]> {
const runners = await this.state.storage.list<ExternalRunnerRecord>({
prefix: externalRunnerPrefix(),
});
return [...runners.values()];
}
private async visibleExternalRunners(request: Request): Promise<ExternalRunnerRecord[]> {
const runners = await this.externalRunnerRecords();
const admin = isAdminRequest(request);
const owner = requestOwner(request);
const org = requestOrg(request, this.env);
return runners.filter((runner) => admin || (runner.owner === owner && runner.org === org));
}
private async runEvents(runID: string, after = 0, limit = 500): Promise<RunEventRecord[]> {
const events = await this.state.storage.list<RunEventRecord>({
prefix: runEventPrefix(runID),
@ -1840,6 +1960,13 @@ export class FleetDurableObject implements DurableObject {
await this.state.storage.put(runKey(run.id), run);
}
private async putExternalRunner(runner: ExternalRunnerRecord): Promise<void> {
await this.state.storage.put(
externalRunnerKey(runner.provider, runner.id, runner.owner, runner.org),
runner,
);
}
private async appendRunEventRecord(
run: RunRecord,
input: RunEventRequest,
@ -1908,6 +2035,14 @@ function runKey(runID: string): string {
return `run:${runID}`;
}
function externalRunnerPrefix(): string {
return "runner:";
}
function externalRunnerKey(provider: string, runnerID: string, owner: string, org: string): string {
return `${externalRunnerPrefix()}${provider}:${runnerID}:${org}:${owner}`;
}
function runLogKey(runID: string): string {
return `runlog:${runID}`;
}
@ -1996,6 +2131,10 @@ function validCrabboxProviderKey(value: string | undefined): value is string {
return typeof value === "string" && /^crabbox-cbx-[a-f0-9]{12}$/.test(value);
}
function validExternalRunnerID(value: string | undefined): value is string {
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,128}$/.test(value);
}
function clampLimit(value: string | null, fallback: number): number {
const parsed = Number(value ?? "");
if (!Number.isFinite(parsed) || parsed <= 0) {
@ -2085,6 +2224,59 @@ function nonSecretString(value: unknown): string {
return typeof value === "string" ? value.trim().slice(0, 256) : "";
}
function sanitizeRunnerProvider(value: unknown): string {
const provider = nonSecretString(value).toLowerCase();
return /^[a-z0-9][a-z0-9-]{1,63}$/.test(provider) ? provider : "";
}
function sanitizeExternalRunner(
input: ExternalRunnerInput,
provider: string,
now: Date,
):
| Omit<ExternalRunnerRecord, "owner" | "org" | "firstSeenAt" | "lastSeenAt" | "updatedAt">
| undefined {
const id = nonSecretString(input.id);
if (!validExternalRunnerID(id)) {
return undefined;
}
const createdAt = sanitizeRunnerTimestamp(input.createdAt, now);
const runner: Omit<
ExternalRunnerRecord,
"owner" | "org" | "firstSeenAt" | "lastSeenAt" | "updatedAt"
> = {
id,
provider,
status: nonSecretString(input.status).toLowerCase() || "unknown",
};
for (const key of ["repo", "workflow", "job", "ref"] as const) {
const value = nonSecretString(input[key]);
if (value) {
runner[key] = value;
}
}
if (createdAt) {
runner.createdAt = createdAt;
}
return runner;
}
function sanitizeRunnerTimestamp(value: string | undefined, now: Date): string | undefined {
const parsed = Date.parse(value ?? "");
if (!Number.isFinite(parsed)) {
return undefined;
}
const date = new Date(parsed);
if (date.getTime() > now.getTime() + 5 * 60 * 1000) {
return undefined;
}
return date.toISOString();
}
function runnerSortTime(runner: ExternalRunnerRecord): string {
return runner.lastSeenAt || runner.updatedAt || runner.createdAt || runner.firstSeenAt;
}
function webVNCLeaseError(lease: LeaseRecord): string {
if (lease.state !== "active") {
return "lease is not active";

View File

@ -1,4 +1,4 @@
import type { LeaseRecord, RunEventRecord, RunRecord } from "./types";
import type { ExternalRunnerRecord, LeaseRecord, RunEventRecord, RunRecord } from "./types";
const novncModuleURL = "/portal/assets/novnc/rfb.js";
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`;
@ -19,10 +19,18 @@ export interface PortalLeaseBridgeStatus {
codeBridgeConnected: boolean;
}
export function portalHome(leases: LeaseRecord[], request: Request): Response {
export function portalHome(
leases: LeaseRecord[],
runners: ExternalRunnerRecord[],
request: Request,
): Response {
const sortedLeases = leases.toSorted((a, b) => leaseSortTime(b).localeCompare(leaseSortTime(a)));
const active = sortedLeases.filter((lease) => lease.state === "active");
const ended = sortedLeases.length - active.length;
const sortedRunners = runners.toSorted((a, b) =>
runnerSortTime(b).localeCompare(runnerSortTime(a)),
);
const activeRunners = sortedRunners.filter((runner) => !runner.stale);
const admin = request.headers.get("x-crabbox-admin") === "true";
const owner = request.headers.get("x-crabbox-owner") || "";
const org = request.headers.get("x-crabbox-org") || "";
@ -44,9 +52,16 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
const rows = sortedLeases.length
? sortedLeases.map((lease) => leaseRow(lease, { admin, owner, org })).join("")
: `<tr><td colspan="8" class="empty">no leases visible</td></tr>`;
const runnerRows = sortedRunners.length
? sortedRunners.map((runner) => runnerRow(runner, { admin, owner, org })).join("")
: `<tr><td colspan="8" class="empty">no external runners synced</td></tr>`;
const summary = admin
? `${active.length} active / ${ended} ended / ${system} system`
: `${active.length} active / ${ended} ended`;
const runnerSummary =
sortedRunners.length > 0
? `${activeRunners.length} active / ${sortedRunners.length - activeRunners.length} stale`
: "sync with crabbox list --provider blacksmith-testbox";
return html(
"Crabbox Portal",
`<main class="portal-shell">
@ -75,6 +90,27 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
<tbody>${rows}</tbody>
</table>
</section>
<section class="panel table-panel runner-panel">
<div class="section-head">
<h2>external runners</h2>
<span>${escapeHTML(runnerSummary)}</span>
</div>
<table class="runner-table" data-portal-table data-page-size="8" data-search-placeholder="search runners" data-filter-buttons="active:active,stale:stale,blacksmith-testbox:blacksmith,all:all" data-filter-default="${activeRunners.length > 0 ? "active" : "all"}">
<thead>
<tr>
<th>runner</th>
<th>status</th>
<th>provider</th>
<th>repo</th>
<th>workflow</th>
<th>job/ref</th>
<th>seen</th>
<th></th>
</tr>
</thead>
<tbody>${runnerRows}</tbody>
</table>
</section>
</main>`,
);
}
@ -634,6 +670,32 @@ function leaseRow(
</tr>`;
}
function runnerRow(
runner: ExternalRunnerRecord,
context: { admin: boolean; owner: string; org: string },
): string {
const ownership =
context.admin && (runner.owner !== context.owner || runner.org !== context.org)
? "system"
: "mine";
const state = runner.stale ? "stale" : "active";
const subline =
context.admin && ownership === "system"
? `${runner.owner || "unknown"} · ${runner.org || "unknown"}`
: runner.id;
const jobRef = [runner.job, runner.ref].filter(Boolean).join(" / ") || "-";
return `<tr data-filter-tags="${escapeHTML([state, ownership, runner.provider, runner.status, runner.repo, runner.workflow, runner.job, runner.ref].filter(Boolean).join(" "))}">
<td><span class="lease-link"><strong>${escapeHTML(runner.id)}</strong><small>${escapeHTML(subline)}</small></span></td>
<td><span class="pill" data-tone="${runner.stale ? "warn" : runnerStatusTone(runner.status)}">${escapeHTML(runner.status || "-")}</span></td>
<td>${providerBadge(runner.provider)}</td>
<td>${escapeHTML(runner.repo || "-")}</td>
<td>${escapeHTML(runner.workflow || "-")}</td>
<td>${escapeHTML(jobRef)}</td>
${timeCell(runnerSortTime(runner))}
<td></td>
</tr>`;
}
function portalHeader(options: PortalHeaderOptions): string {
const variant = options.variant || "top";
const headerClass = variant === "bar" ? "vnc-bar" : "top";
@ -654,6 +716,10 @@ function leaseOwnership(lease: LeaseRecord, owner: string, org: string): "mine"
return lease.owner === owner && lease.org === org ? "mine" : "system";
}
function runnerSortTime(runner: ExternalRunnerRecord): string {
return runner.lastSeenAt || runner.updatedAt || runner.createdAt || runner.firstSeenAt;
}
function runRow(run: RunRecord): string {
const stateTone = run.state === "succeeded" ? "ok" : run.state === "failed" ? "bad" : "warn";
return `<tr data-filter-tags="${escapeHTML([run.state, run.provider, run.target || "linux"].filter(Boolean).join(" "))}">
@ -1018,6 +1084,9 @@ function telemetryStorage(
}
function providerIcon(provider: string): string {
if (provider === "blacksmith-testbox") {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v5H4z"/><path d="M4 13h16v5H4z"/><path d="M8 8.5h.01M8 15.5h.01M12 8.5h5M12 15.5h5"/></svg>`;
}
if (provider === "aws") {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 15.5c3.8 2.2 9.1 2.5 14.8.9"/><path d="M17.5 13.2 20 16l-3.7.7"/><path d="M7 8.5h10l1.8 4H5.2z"/></svg>`;
}
@ -1027,6 +1096,19 @@ function providerIcon(provider: string): string {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16v12H4z"/><path d="m7 10 3 2-3 2M12 15h5"/></svg>`;
}
function runnerStatusTone(status: string): string {
if (status === "ready" || status === "running") {
return "ok";
}
if (status === "queued" || status === "starting" || status === "pending") {
return "warn";
}
if (status === "failed" || status === "error") {
return "bad";
}
return "";
}
function targetIcon(target: string): string {
if (target === "windows") {
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5 11 4v7H4z"/><path d="m13 3.7 7-1.5V11h-7z"/><path d="M4 13h7v7l-7-1.5z"/><path d="M13 13h7v8.8l-7-1.5z"/></svg>`;
@ -1095,7 +1177,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
html { min-height:100%; background:var(--bg); }
body { margin:0; min-height:100vh; overflow-x:hidden; background:var(--bg); color:var(--fg); font:14px/1.45 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
main { width:min(1180px, calc(100vw - 32px)); max-width:100%; margin:0 auto; padding:10px 0 22px; }
.portal-shell { width:min(1240px, calc(100vw - 16px)); max-width:100%; height:100dvh; display:grid; grid-template-rows:auto minmax(0,1fr); gap:8px; padding:6px 0 8px; overflow:hidden; }
.portal-shell { width:min(1240px, calc(100vw - 16px)); max-width:100%; height:100dvh; display:grid; grid-template-rows:auto minmax(0,1.1fr) minmax(220px,0.9fr); gap:8px; padding:6px 0 8px; overflow:hidden; }
.lease-shell { grid-template-rows:auto auto minmax(0,1fr); }
.run-shell { height:auto; min-height:100dvh; overflow:visible; grid-template-rows:auto; }
h1,h2,p { margin:0; }
@ -1188,6 +1270,7 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.icon-label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.icon-label[data-provider="aws"] svg { color:#fbbf24; }
.icon-label[data-provider="hetzner"] svg { color:#ef4444; }
.icon-label[data-provider="blacksmith-testbox"] svg { color:#a78bfa; }
.icon-label[data-target="linux"] svg { color:#34d399; }
.icon-label[data-target="windows"] svg { color:#38bdf8; }
.icon-label[data-target="macos"] svg { color:#d8b4fe; }
@ -1222,6 +1305,14 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.lease-table th:nth-child(6) { width:118px; }
.lease-table th:nth-child(7) { width:148px; }
.lease-table th:nth-child(8) { width:24px; }
.runner-table th:nth-child(1) { width:22%; }
.runner-table th:nth-child(2) { width:88px; }
.runner-table th:nth-child(3) { width:148px; }
.runner-table th:nth-child(4) { width:118px; }
.runner-table th:nth-child(5) { width:28%; }
.runner-table th:nth-child(6) { width:120px; }
.runner-table th:nth-child(7) { width:138px; }
.runner-table th:nth-child(8) { width:24px; }
.run-table th:nth-child(2) { width:104px; }
.run-table th:nth-child(3) { width:112px; }
.run-table th:nth-child(4) { width:92px; }

View File

@ -239,6 +239,39 @@ export interface RunTelemetryRequest {
telemetry?: Partial<LeaseTelemetry>;
}
export interface ExternalRunnerInput {
id?: string;
provider?: string;
status?: string;
repo?: string;
workflow?: string;
job?: string;
ref?: string;
createdAt?: string;
}
export interface ExternalRunnerSyncRequest {
provider?: string;
runners?: ExternalRunnerInput[];
}
export interface ExternalRunnerRecord {
id: string;
provider: string;
owner: string;
org: string;
status: string;
repo?: string;
workflow?: string;
job?: string;
ref?: string;
createdAt?: string;
firstSeenAt: string;
lastSeenAt: string;
updatedAt: string;
stale?: boolean;
}
export interface RunEventRecord {
runID: string;
seq: number;

View File

@ -9,7 +9,13 @@ import {
resetWebVNCBridge,
type WebVNCBuffer,
} from "../src/fleet";
import type { Env, LeaseRecord, ProvisioningAttempt, RunRecord } from "../src/types";
import type {
Env,
ExternalRunnerRecord,
LeaseRecord,
ProvisioningAttempt,
RunRecord,
} from "../src/types";
afterEach(() => {
vi.unstubAllGlobals();
@ -646,6 +652,49 @@ describe("fleet lease identity and idle", () => {
windowsMode: "wsl2",
}),
);
await fleet.fetch(
request("POST", "/v1/runners/sync", {
headers: {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
},
body: {
provider: "blacksmith-testbox",
runners: [
{
id: "tbx_01testbox",
status: "ready",
repo: "openclaw",
workflow: ".github/workflows/ci-check-testbox.yml",
job: "check",
ref: "main",
createdAt: "2026-05-05T10:00:00.000Z",
},
],
},
}),
);
await fleet.fetch(
request("POST", "/v1/runners/sync", {
headers: {
"x-crabbox-owner": "friend@example.com",
"x-crabbox-org": "openclaw",
},
body: {
provider: "blacksmith-testbox",
runners: [
{
id: "tbx_friendbox",
status: "ready",
repo: "openclaw",
workflow: ".github/workflows/ci-check-testbox.yml",
job: "check",
ref: "main",
},
],
},
}),
);
const response = await fleet.fetch(
request("GET", "/portal", {
@ -666,6 +715,11 @@ describe("fleet lease identity and idle", () => {
'data-filter-buttons="active:active,ended:ended,aws:aws,hetzner:hetzner,linux:linux,macos:macos,windows:windows,all:all"',
);
expect(body).toContain('data-filter-default="active"');
expect(body).toContain("external runners");
expect(body).toContain("tbx_01testbox");
expect(body).toContain("blacksmith-testbox");
expect(body).toContain("ci-check-testbox.yml");
expect(body).not.toContain("tbx_friendbox");
expect(body).toContain('data-provider="hetzner"');
expect(body).toContain('data-target="linux"');
expect(body).toContain('data-target="windows"');
@ -688,6 +742,75 @@ describe("fleet lease identity and idle", () => {
expect(body).not.toContain("amber-krill");
});
it("syncs external runner visibility and marks missing runners stale", async () => {
const storage = new MemoryStorage();
const fleet = testFleet(storage);
const headers = {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
};
const sync = await fleet.fetch(
request("POST", "/v1/runners/sync", {
headers,
body: {
provider: "blacksmith-testbox",
runners: [
{
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
status: "ready",
repo: "openclaw",
workflow: ".github/workflows/ci-check-testbox.yml",
job: "check",
ref: "main",
createdAt: "2026-05-06T09:45:16.000000Z",
},
],
},
}),
);
expect(sync.status).toBe(200);
const synced = (await sync.json()) as { runners: ExternalRunnerRecord[] };
expect(synced.runners).toHaveLength(1);
expect(synced.runners[0]).toMatchObject({
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
provider: "blacksmith-testbox",
status: "ready",
repo: "openclaw",
owner: "peter@example.com",
org: "openclaw",
});
const friendList = await fleet.fetch(
request("GET", "/v1/runners", {
headers: {
"x-crabbox-owner": "friend@example.com",
"x-crabbox-org": "openclaw",
},
}),
);
const friendBody = (await friendList.json()) as { runners: ExternalRunnerRecord[] };
expect(friendBody.runners).toHaveLength(0);
const staleSync = await fleet.fetch(
request("POST", "/v1/runners/sync", {
headers,
body: {
provider: "blacksmith-testbox",
runners: [],
},
}),
);
expect(staleSync.status).toBe(200);
const staleBody = (await staleSync.json()) as { stale: ExternalRunnerRecord[] };
expect(staleBody.stale).toHaveLength(1);
expect(staleBody.stale[0]).toMatchObject({
id: "tbx_01kqyahxh67z6qtwtsdkt5xcst",
status: "missing",
stale: true,
});
});
it("shows non-owned runner leases only in the admin portal", async () => {
const storage = new MemoryStorage();
const fleet = testFleet(storage);