From eefd71fbb1924d5ea8ca526f84b6066e0fb1d555 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 6 May 2026 03:29:09 -0700 Subject: [PATCH] feat(portal): link testbox runners to actions --- CHANGELOG.md | 1 + README.md | 3 +- docs/commands/list.md | 6 +- docs/features/blacksmith-testbox.md | 8 +- docs/features/coordinator.md | 4 +- docs/orchestrator.md | 5 +- internal/cli/coordinator.go | 25 ++-- internal/cli/pool.go | 169 ++++++++++++++++++++++++++++ internal/cli/pool_test.go | 48 ++++++++ worker/src/fleet.ts | 34 +++++- worker/src/portal.ts | 25 +++- worker/src/types.ts | 14 +++ worker/test/fleet.test.ts | 18 ++- 13 files changed, 337 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca58d83..3a78f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f8baa0b..ae556aa 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/commands/list.md b/docs/commands/list.md index 1662931..f98bdc2 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -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. diff --git a/docs/features/blacksmith-testbox.md b/docs/features/blacksmith-testbox.md index bcbfcc8..69d5f35 100644 --- a/docs/features/blacksmith-testbox.md +++ b/docs/features/blacksmith-testbox.md @@ -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 diff --git a/docs/features/coordinator.md b/docs/features/coordinator.md index 3f96df0..67b7bea 100644 --- a/docs/features/coordinator.md +++ b/docs/features/coordinator.md @@ -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 diff --git a/docs/orchestrator.md b/docs/orchestrator.md index b835afd..d157773 100644 --- a/docs/orchestrator.md +++ b/docs/orchestrator.md @@ -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 diff --git a/internal/cli/coordinator.go b/internal/cli/coordinator.go index 8ccd55e..9cdddc9 100644 --- a/internal/cli/coordinator.go +++ b/internal/cli/coordinator.go @@ -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 { diff --git a/internal/cli/pool.go b/internal/cli/pool.go index 9f1982b..9013723 100644 --- a/internal/cli/pool.go +++ b/internal/cli/pool.go @@ -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 { diff --git a/internal/cli/pool_test.go b/internal/cli/pool_test.go index e2bc1d1..50a5d48 100644 --- a/internal/cli/pool_test.go +++ b/internal/cli/pool_test.go @@ -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, diff --git a/worker/src/fleet.ts b/worker/src/fleet.ts index 606ff31..7bd7b04 100644 --- a/worker/src/fleet.ts +++ b/worker/src/fleet.ts @@ -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)) { diff --git a/worker/src/portal.ts b/worker/src/portal.ts index 6d59e2d..d15fe6c 100644 --- a/worker/src/portal.ts +++ b/worker/src/portal.ts @@ -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 ` ${escapeHTML(runner.id)}${escapeHTML(subline)} ${escapeHTML(runner.status || "-")} ${providerBadge(runner.provider)} - - external - no access + ${actionsLinks || "external"} + ${actionsLinks ? "no box access" : "no access"} ${timeCell(runnerSortTime(runner))} `; } +function externalRunnerActionsLinks(runner: ExternalRunnerRecord): string { + const links = []; + if (runner.actionsRunURL) { + const status = [runner.actionsRunStatus, runner.actionsRunConclusion].filter(Boolean).join("/"); + links.push( + `run`, + ); + } + if (runner.actionsWorkflowURL) { + links.push( + `workflow`, + ); + } + return links.length ? `${links.join("")}` : ""; +} + 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; } diff --git a/worker/src/types.ts b/worker/src/types.ts index 842001e..b59f61b 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -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; diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index 054197b..6a8bdd9 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -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(