feat(portal): link testbox runners to actions

This commit is contained in:
Vincent Koc 2026-05-06 03:29:09 -07:00
parent 2a4e08af24
commit eefd71fbb1
No known key found for this signature in database
13 changed files with 337 additions and 23 deletions

View File

@ -21,6 +21,7 @@
- Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation.
- Added `provider: daytona` for Daytona sandbox leases using Daytona's SDK/toolbox for sync and command execution, with short-lived SSH access available through `crabbox ssh`.
- Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK.
- Added best-effort GitHub Actions run and workflow links for external Blacksmith Testbox rows in the portal.
### Changed

View File

@ -159,7 +159,8 @@ blacksmith:
`crabbox list --provider blacksmith-testbox` also refreshes muted external
runner rows in the portal lease table from the current all-status Testbox list
when coordinator auth is configured. Those rows are
when coordinator auth is configured. When GitHub is reachable, Crabbox also
links those rows back to the inferred Actions run and workflow. Those rows are
visibility-only records for Blacksmith-owned Testboxes, not Crabbox leases.
Optional Daytona sandbox:

View File

@ -22,8 +22,10 @@ shape parsed from the Blacksmith table: id, status, repo, workflow, job, ref,
and created time when the upstream table exposes those columns.
When coordinator auth is configured, the same list command also refreshes
owner-scoped external runner rows in the portal lease table from the current
all-status Blacksmith list. Missing runners from later syncs are marked stale
rather than treated as Crabbox leases.
all-status Blacksmith list. Crabbox also attempts to infer the matching GitHub
Actions run/workflow from the row's repo, workflow, ref, and created time.
Missing runners from later syncs are marked stale rather than treated as Crabbox
leases.
In `daytona` and `islo` modes, rendering is core-owned: human output and `--json`
use the normalized Crabbox lease view.

View File

@ -98,9 +98,11 @@ native JSON output, Crabbox should switch to that and drop table parsing.
When coordinator auth is configured, `crabbox list --provider blacksmith-testbox`
also performs a best-effort sync of the current all-status Blacksmith list into
the portal lease table. Those muted rows are owner-scoped visibility records for
Blacksmith-owned Testboxes. They are not Crabbox leases, do not expose access
actions, do not heartbeat, do not participate in Crabbox expiry or cost control,
and become stale when a later sync does not see the runner.
Blacksmith-owned Testboxes. When the row includes enough context, Crabbox queries
GitHub Actions and links the row to the closest workflow run plus the workflow
definition. They are not Crabbox leases, do not expose box access actions, do
not heartbeat, do not participate in Crabbox expiry or cost control, and become
stale when a later sync does not see the runner.
## Auth

View File

@ -66,8 +66,8 @@ normal users. It defaults to active leases when any are active, and falls back t
all visible leases when the active list is empty. External runner rows, currently
Blacksmith Testboxes synced from the CLI's current all-status list, render in the
same grid as muted, disabled rows with search, pagination, status/provider
filters, and stale markers when the next sync no longer sees a previously
visible runner.
filters, inferred GitHub Actions run/workflow links when available, and stale
markers when the next sync no longer sees a previously visible runner.
`/portal/leases/{id-or-slug}` is the authenticated lease detail page. It shows
the lease state, bridge status, compact provider/target badges, latest Linux

View File

@ -50,8 +50,9 @@ Direct-provider mode does not have a central heartbeat or alarm. It labels machi
Delegated external runners, such as Blacksmith Testboxes, are visibility-only
records in the coordinator. `crabbox list --provider blacksmith-testbox` syncs
the current all-status Blacksmith table into muted `/portal` lease-grid rows,
and a later sync marks missing runners stale. They do not heartbeat and do not
participate in Crabbox lease expiry, cleanup, or cost accounting.
adds inferred GitHub Actions run/workflow links when available, and a later sync
marks missing runners stale. They do not heartbeat and do not participate in
Crabbox lease expiry, cleanup, or cost accounting.
## Cleanup

View File

@ -161,15 +161,22 @@ type CoordinatorRunEventsResponse struct {
}
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"`
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"`
ActionsRepo string `json:"actionsRepo,omitempty"`
ActionsRunID string `json:"actionsRunID,omitempty"`
ActionsRunURL string `json:"actionsRunURL,omitempty"`
ActionsRunStatus string `json:"actionsRunStatus,omitempty"`
ActionsRunConclusion string `json:"actionsRunConclusion,omitempty"`
ActionsWorkflowName string `json:"actionsWorkflowName,omitempty"`
ActionsWorkflowURL string `json:"actionsWorkflowURL,omitempty"`
}
type CoordinatorExternalRunnerSyncResponse struct {

View File

@ -4,10 +4,16 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func (a App) list(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("list", a.Stderr)
@ -82,6 +88,7 @@ func (a App) syncExternalRunnersBestEffort(ctx context.Context, cfg Config, back
fmt.Fprintf(a.Stderr, "warning: external runner portal sync skipped: %v\n", err)
return
}
enrichExternalRunnerActionsBestEffort(ctx, cfg, runners)
if _, err := client.SyncExternalRunners(ctx, "blacksmith-testbox", runners); err != nil {
fmt.Fprintf(a.Stderr, "warning: external runner portal sync failed: %v\n", err)
}
@ -105,6 +112,168 @@ func coordinatorExternalRunnersFromListView(view any) ([]CoordinatorExternalRunn
return runners, nil
}
type externalRunnerActionsRun struct {
DatabaseID int64 `json:"databaseId"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
HeadBranch string `json:"headBranch"`
URL string `json:"url"`
WorkflowName string `json:"workflowName"`
DisplayTitle string `json:"displayTitle"`
Name string `json:"name"`
}
func enrichExternalRunnerActionsBestEffort(ctx context.Context, cfg Config, runners []CoordinatorExternalRunner) {
cache := map[string][]externalRunnerActionsRun{}
for i := range runners {
repo, ok := externalRunnerGitHubRepo(cfg, runners[i])
if !ok || runners[i].Workflow == "" {
continue
}
key := repo.Slug() + "\x00" + runners[i].Workflow + "\x00" + runners[i].Ref
runs, seen := cache[key]
if !seen {
var err error
runs, err = externalRunnerGitHubRuns(ctx, repo, runners[i].Workflow, runners[i].Ref)
if err != nil {
cache[key] = nil
continue
}
cache[key] = runs
}
run, ok := matchExternalRunnerActionRun(runners[i], runs)
if !ok {
runners[i].ActionsRepo = repo.Slug()
runners[i].ActionsWorkflowURL = externalRunnerWorkflowURL(repo, runners[i].Workflow)
continue
}
runners[i].ActionsRepo = repo.Slug()
runners[i].ActionsRunID = strconv.FormatInt(run.DatabaseID, 10)
runners[i].ActionsRunURL = run.URL
runners[i].ActionsRunStatus = run.Status
runners[i].ActionsRunConclusion = run.Conclusion
runners[i].ActionsWorkflowName = run.WorkflowName
runners[i].ActionsWorkflowURL = externalRunnerWorkflowURL(repo, runners[i].Workflow)
}
}
func externalRunnerGitHubRepo(cfg Config, runner CoordinatorExternalRunner) (GitHubRepo, bool) {
if strings.Contains(runner.Repo, "/") {
repo, err := parseGitHubRepo(runner.Repo)
return repo, err == nil
}
owner := strings.TrimSpace(cfg.Blacksmith.Org)
if owner == "" && cfg.Actions.Repo != "" {
if repo, err := parseGitHubRepo(cfg.Actions.Repo); err == nil {
owner = repo.Owner
}
}
if owner == "" || runner.Repo == "" {
return GitHubRepo{}, false
}
repo, err := parseGitHubRepo(owner + "/" + runner.Repo)
return repo, err == nil
}
func externalRunnerGitHubRuns(ctx context.Context, repo GitHubRepo, workflow, ref string) ([]externalRunnerActionsRun, error) {
args := []string{
"run", "list",
"--repo", repo.Slug(),
"--workflow", workflow,
"--limit", "30",
"--json", "databaseId,status,conclusion,createdAt,updatedAt,headBranch,url,workflowName,displayTitle,name",
}
if ref != "" {
args = append(args, "--branch", ref)
}
out, err := ghOutput(ctx, "", args...)
if err != nil {
return nil, err
}
var runs []externalRunnerActionsRun
if err := json.Unmarshal([]byte(stripANSI(out)), &runs); err != nil {
return nil, err
}
return runs, nil
}
func matchExternalRunnerActionRun(runner CoordinatorExternalRunner, runs []externalRunnerActionsRun) (externalRunnerActionsRun, bool) {
if len(runs) == 0 {
return externalRunnerActionsRun{}, false
}
runnerTime, hasRunnerTime := parseExternalRunnerTime(runner.CreatedAt)
bestIndex := -1
bestDelta := int64(0)
for i, run := range runs {
if runner.Ref != "" && run.HeadBranch != "" && run.HeadBranch != runner.Ref {
continue
}
if !hasRunnerTime {
return run, true
}
runTime, ok := parseExternalRunnerTime(run.CreatedAt)
if !ok {
continue
}
delta := runTime.Sub(runnerTime)
if delta < 0 {
delta = -delta
}
if delta > 6*time.Hour {
continue
}
deltaMillis := delta.Milliseconds()
if bestIndex < 0 || deltaMillis < bestDelta {
bestIndex = i
bestDelta = deltaMillis
}
}
if bestIndex < 0 {
return externalRunnerActionsRun{}, false
}
return runs[bestIndex], true
}
func parseExternalRunnerTime(value string) (time.Time, bool) {
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
if err != nil {
return time.Time{}, false
}
return t, true
}
func externalRunnerWorkflowURL(repo GitHubRepo, workflow string) string {
if repo.Slug() == "" || workflow == "" {
return ""
}
workflow = strings.TrimPrefix(strings.TrimSpace(workflow), "/")
if strings.HasPrefix(workflow, ".github/workflows/") {
workflow = path.Base(workflow)
}
if !strings.HasSuffix(workflow, ".yml") && !strings.HasSuffix(workflow, ".yaml") && !allDigits(workflow) {
return ""
}
return "https://github.com/" + repo.Slug() + "/actions/workflows/" + url.PathEscape(workflow)
}
func allDigits(value string) bool {
if value == "" {
return false
}
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}
func stripANSI(value string) string {
return ansiEscapePattern.ReplaceAllString(value, "")
}
func activeCoordinatorLeaseIDs(leases []CoordinatorLease) map[string]struct{} {
ids := make(map[string]struct{}, len(leases))
for _, lease := range leases {

View File

@ -139,6 +139,54 @@ func TestCoordinatorExternalRunnersFromBlacksmithListView(t *testing.T) {
}
}
func TestExternalRunnerGitHubRepoFallsBackToBlacksmithOrg(t *testing.T) {
cfg := Config{}
cfg.Blacksmith.Org = "openclaw"
repo, ok := externalRunnerGitHubRepo(cfg, CoordinatorExternalRunner{Repo: "crabbox"})
if !ok {
t.Fatal("repo not inferred")
}
if repo.Slug() != "openclaw/crabbox" {
t.Fatalf("repo=%q", repo.Slug())
}
}
func TestMatchExternalRunnerActionRunChoosesClosestCreatedAt(t *testing.T) {
runner := CoordinatorExternalRunner{
Ref: "main",
CreatedAt: "2026-05-06T10:00:00Z",
}
run, ok := matchExternalRunnerActionRun(runner, []externalRunnerActionsRun{
{DatabaseID: 1, HeadBranch: "main", CreatedAt: "2026-05-06T08:00:00Z"},
{DatabaseID: 2, HeadBranch: "main", CreatedAt: "2026-05-06T10:02:00Z"},
{DatabaseID: 3, HeadBranch: "feature", CreatedAt: "2026-05-06T10:01:00Z"},
})
if !ok {
t.Fatal("run not matched")
}
if run.DatabaseID != 2 {
t.Fatalf("run=%d, want 2", run.DatabaseID)
}
}
func TestExternalRunnerWorkflowURLUsesWorkflowBasename(t *testing.T) {
got := externalRunnerWorkflowURL(
GitHubRepo{Owner: "openclaw", Name: "openclaw"},
".github/workflows/ci-check-testbox.yml",
)
want := "https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml"
if got != want {
t.Fatalf("url=%q want %q", got, want)
}
}
func TestStripANSIRemovesGitHubColorOutput(t *testing.T) {
got := stripANSI("\x1b[1;37m[\x1b[m{\"databaseId\":1}]")
if got != "[{\"databaseId\":1}]" {
t.Fatalf("stripped=%q", got)
}
}
func TestHeartbeatInterval(t *testing.T) {
tests := map[time.Duration]time.Duration{
0: time.Minute,

View File

@ -2251,18 +2251,50 @@ function sanitizeExternalRunner(
provider,
status: nonSecretString(input.status).toLowerCase() || "unknown",
};
for (const key of ["repo", "workflow", "job", "ref"] as const) {
for (const key of [
"repo",
"workflow",
"job",
"ref",
"actionsRepo",
"actionsRunID",
"actionsRunStatus",
"actionsRunConclusion",
"actionsWorkflowName",
] as const) {
const value = nonSecretString(input[key]);
if (value) {
runner[key] = value;
}
}
for (const key of ["actionsRunURL", "actionsWorkflowURL"] as const) {
const value = sanitizeGithubURL(input[key]);
if (value) {
runner[key] = value;
}
}
if (createdAt) {
runner.createdAt = createdAt;
}
return runner;
}
function sanitizeGithubURL(value: unknown): string {
const raw = nonSecretString(value);
if (!raw) {
return "";
}
try {
const parsed = new URL(raw);
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") {
return "";
}
return parsed.toString();
} catch {
return "";
}
}
function sanitizeRunnerTimestamp(value: string | undefined, now: Date): string | undefined {
const parsed = Date.parse(value ?? "");
if (!Number.isFinite(parsed)) {

View File

@ -672,18 +672,35 @@ function externalRunnerLeaseRow(
? `${runner.id} · ${runner.owner || "unknown"}`
: [runner.repo, runner.workflow].filter(Boolean).join(" · ") || runner.id;
const jobRef = [runner.job, runner.ref].filter(Boolean).join(" / ") || "-";
const actionsLinks = externalRunnerActionsLinks(runner);
return `<tr class="external-row" aria-disabled="true" data-filter-tags="${escapeHTML([state, "external", 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><span class="muted" title="Blacksmith owns runner host details">-</span></td>
<td><span title="${escapeHTML([runner.repo, runner.workflow, jobRef].filter(Boolean).join(" · "))}">external</span></td>
<td><span class="access-cell disabled-cell" title="external runner; no Crabbox access data">no access</span></td>
<td><span title="${escapeHTML([runner.repo, runner.workflow, jobRef].filter(Boolean).join(" · "))}">${actionsLinks || "external"}</span></td>
<td><span class="access-cell disabled-cell" title="external runner; no Crabbox access data">${actionsLinks ? "no box access" : "no access"}</span></td>
${timeCell(runnerSortTime(runner))}
<td></td>
</tr>`;
}
function externalRunnerActionsLinks(runner: ExternalRunnerRecord): string {
const links = [];
if (runner.actionsRunURL) {
const status = [runner.actionsRunStatus, runner.actionsRunConclusion].filter(Boolean).join("/");
links.push(
`<a class="row-link" href="${escapeHTML(runner.actionsRunURL)}" target="_blank" rel="noopener" title="${escapeHTML(status || "GitHub Actions run")}">run</a>`,
);
}
if (runner.actionsWorkflowURL) {
links.push(
`<a class="row-link secondary" href="${escapeHTML(runner.actionsWorkflowURL)}" target="_blank" rel="noopener" title="${escapeHTML(runner.actionsWorkflowName || runner.workflow || "GitHub Actions workflow")}">workflow</a>`,
);
}
return links.length ? `<span class="row-links">${links.join("")}</span>` : "";
}
function portalHeader(options: PortalHeaderOptions): string {
const variant = options.variant || "top";
const headerClass = variant === "bar" ? "vnc-bar" : "top";
@ -1301,6 +1318,10 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.external-row .lease-link strong::after { content:"external"; display:inline-flex; margin-left:8px; min-height:18px; align-items:center; padding:0 6px; border:1px solid var(--line); border-radius:999px; color:#8b949e; font-size:10px; font-weight:700; text-transform:uppercase; vertical-align:middle; }
.external-row .icon-label svg { color:#7c8490; }
.external-row .pill { opacity:0.82; }
.row-links { display:inline-flex; align-items:center; gap:5px; min-width:0; }
.row-link { display:inline-flex; align-items:center; min-height:22px; padding:0 7px; border:1px solid color-mix(in srgb, var(--accent) 36%, var(--line)); border-radius:6px; color:#bae6fd; background:color-mix(in srgb, var(--accent) 9%, transparent); text-decoration:none; font-size:11px; font-weight:700; }
.row-link.secondary { color:#cbd5e1; border-color:var(--line); background:#0c0e10; font-weight:600; }
.row-link:hover { border-color:color-mix(in srgb, var(--accent) 58%, var(--line)); background:color-mix(in srgb, var(--accent) 14%, transparent); }
.lease-table th:nth-child(1) { width:25%; }
.lease-table th:nth-child(2) { width:86px; }
.lease-table th:nth-child(3),.lease-table th:nth-child(4) { width:104px; }

View File

@ -248,6 +248,13 @@ export interface ExternalRunnerInput {
job?: string;
ref?: string;
createdAt?: string;
actionsRepo?: string;
actionsRunID?: string;
actionsRunURL?: string;
actionsRunStatus?: string;
actionsRunConclusion?: string;
actionsWorkflowName?: string;
actionsWorkflowURL?: string;
}
export interface ExternalRunnerSyncRequest {
@ -266,6 +273,13 @@ export interface ExternalRunnerRecord {
job?: string;
ref?: string;
createdAt?: string;
actionsRepo?: string;
actionsRunID?: string;
actionsRunURL?: string;
actionsRunStatus?: string;
actionsRunConclusion?: string;
actionsWorkflowName?: string;
actionsWorkflowURL?: string;
firstSeenAt: string;
lastSeenAt: string;
updatedAt: string;

View File

@ -669,6 +669,13 @@ describe("fleet lease identity and idle", () => {
job: "check",
ref: "main",
createdAt: "2026-05-05T10:00:00.000Z",
actionsRepo: "openclaw/openclaw",
actionsRunID: "123456",
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
actionsRunStatus: "in_progress",
actionsWorkflowName: "ci-check-testbox",
actionsWorkflowURL:
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
},
],
},
@ -719,10 +726,15 @@ describe("fleet lease identity and idle", () => {
expect(body).toContain("1 external");
expect(body).toContain('class="external-row"');
expect(body).toContain('aria-disabled="true"');
expect(body).toContain("no access");
expect(body).toContain("no box access");
expect(body).toContain("tbx_01testbox");
expect(body).toContain("blacksmith-testbox");
expect(body).toContain("ci-check-testbox.yml");
expect(body).toContain("https://github.com/openclaw/openclaw/actions/runs/123456");
expect(body).toContain(
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
);
expect(body).toContain('class="row-link"');
expect(body).not.toContain("tbx_friendbox");
expect(body).toContain('data-provider="hetzner"');
expect(body).toContain('data-target="linux"');
@ -768,6 +780,9 @@ describe("fleet lease identity and idle", () => {
job: "check",
ref: "main",
createdAt: "2026-05-06T09:45:16.000000Z",
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
actionsWorkflowURL:
"https://github.com/openclaw/openclaw/actions/workflows/ci-check-testbox.yml",
},
],
},
@ -783,6 +798,7 @@ describe("fleet lease identity and idle", () => {
repo: "openclaw",
owner: "peter@example.com",
org: "openclaw",
actionsRunURL: "https://github.com/openclaw/openclaw/actions/runs/123456",
});
const friendList = await fleet.fetch(