refactor: slim action/main.ts to an orchestrator + extract helpers (#755)
* refactor: extract helpers out of action/main.ts so non-orchestration churn stops touching the file main.ts had grown to ~1240 lines holding ~500 lines of helpers that have nothing to do with the resolver pipeline — billing-error UI/copy, proxy minting, summary/learnings persistence, log formatters, end-of-run cleanup waterfalls. any PR adding a new billing code branch or a new log line was forced to edit main.ts, and since main.ts is in ALWAYS_RUN_ALL the entire 52-job LLM CI matrix fired on what should have been a 0-job change (e.g. #748). extractions: - action/utils/billingErrors.ts — BillingError, TransientError, the format*Summary renderers, billingConsoleUrl - action/utils/proxy.ts — mintProxyKey, buildProxyTokenHeaders, resolveProxyModel, plus runProxyResolution wrapper that renders + rethrows BillingError/TransientError before the outer catch - action/utils/prSummary.ts — fetchPreviousSnapshot, persistSummary co-located with the existing seed/read file helpers - action/utils/learnings.ts — persistLearnings co-located with the existing seed/read file helpers - action/utils/runStartupLog.ts — resolveOutputSchema + logRunStartup (the model/agent/push/shell/timeout block) - action/utils/runErrorRenderer.ts — renderRunError classifies (BillingError reclassify / hang detect / API-key auth) and emits {summary, comment} markdown bodies - action/utils/runLifecycle.ts — persistRunArtifacts, finalizeSuccessRun, writeRunErrorOutputs — the three end-of-run cleanup phases shared between the success path and the error catch path main.ts is now ~570 lines — the irreducible orchestrator: disposables (`await using` for tokenRef / gitAuthServer / mcpHttpServer), the toolContext construction, the agent-timeout race, the catch/finally shape, and the named phase calls. behavior is preserved verbatim (verified: pnpm -r typecheck + pnpm test 695/695 pass, action/test 596/596 pass). wiki/main.md gets a new "file layout" section describing the split. AGENTS.md gets a single line pointing future edits at the helpers instead of main.ts. * anneal: address review findings - restore MainResult.result?: string (accidental removal in initial commit; field was unused in current code but is part of the exported interface surface — keep the diff truly behavior-preserving) - move resolveOutputSchema from runStartupLog.ts to payload.ts (it's an action-input resolver alongside resolvePromptInput / resolvePayload, not a log helper — was placed in runStartupLog.ts for matrix-churn pragmatism but the domain fit is in payload.ts) - un-export resolveProxyModel (only used internally by runProxyResolution in proxy.ts; no external importer) - fix runErrorRenderer.ts JSDoc "Three classifications" → four (Billing, hang, API-key, default) - expand runLifecycle.ts module banner to note that finalizeSuccessRun calls persistRunArtifacts first, and to explain why the catch path splits writeRunErrorOutputs + persistRunArtifacts - update billingErrors.ts header to point at proxy.ts and runErrorRenderer.ts as the actual origin sites (was stale "main.ts") - expand proxy.ts header to spell out the runProxyResolution entrypoint contract (was stale "main.ts can render") - update wiki/main.md resolver chain + dependency table to name runProxyResolution as the actual call site and document the early BillingError/TransientError rendering branch - update wiki/main.md file-layout table to lead with runProxyResolution and describe mintProxyKey/buildProxyTokenHeaders/resolveProxyModel as internal helpers (was implying they were public surface)
This commit is contained in:
committed by
pullfrog[bot]
parent
a78b1542da
commit
0a64659ee7
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Mint an OpenRouter proxy key via `/api/proxy-token` and inject it as
|
||||
* `OPENROUTER_API_KEY` for runs that route through Pullfrog Router (managed
|
||||
* billing accounts) or OSS-grant paths.
|
||||
*
|
||||
* Authenticates one of two ways:
|
||||
* - production: GitHub Actions OIDC token via `core.getIDToken`
|
||||
* - local dev (`API_URL` is localhost): `x-dev-repo` header bypass
|
||||
*
|
||||
* `runProxyResolution` is the entrypoint `main.ts` calls. It wraps
|
||||
* `resolveProxyModel` and renders the user-facing copy itself (job summary
|
||||
* + PR progress comment) before rethrowing the structured error — handled
|
||||
* here, not in the outer `main()` catch, because `toolContext` doesn't
|
||||
* exist yet at this point in the pipeline.
|
||||
*
|
||||
* - 402 → `BillingError` (card declined, balance empty, 3DS, etc.)
|
||||
* - 503 → `TransientError` (transient sync issue — retry next dispatch)
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { apiFetch } from "./apiFetch.ts";
|
||||
import { isLocalApiUrl } from "./apiUrl.ts";
|
||||
import {
|
||||
BillingError,
|
||||
formatBillingErrorSummary,
|
||||
formatTransientErrorSummary,
|
||||
TransientError,
|
||||
} from "./billingErrors.ts";
|
||||
import { log, writeSummary } from "./cli.ts";
|
||||
import { reportErrorToComment } from "./errorReport.ts";
|
||||
import type { ResolvedPayload } from "./payload.ts";
|
||||
import { type AccountPlan, isInfraCovered } from "./runContext.ts";
|
||||
|
||||
export interface OidcCredentials {
|
||||
requestUrl: string;
|
||||
requestToken: string;
|
||||
}
|
||||
|
||||
async function mintProxyKey(ctx: {
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const headers = await buildProxyTokenHeaders(ctx);
|
||||
if (!headers) return null;
|
||||
|
||||
const response = await apiFetch({
|
||||
path: "/api/proxy-token",
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 402) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
declineCode?: string;
|
||||
needsReauthentication?: boolean;
|
||||
} | null;
|
||||
throw new BillingError(body?.error ?? "insufficient balance", {
|
||||
code: body?.code ?? null,
|
||||
declineCode: body?.declineCode ?? null,
|
||||
needsReauthentication: body?.needsReauthentication ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 503 = transient sync issue (partial OpenRouter failure, DB flake,
|
||||
// in-flight top-up). Not the user's fault — TransientError renders a
|
||||
// "temporarily unavailable" summary instead of the "billing error"
|
||||
// label that BillingError uses.
|
||||
if (response.status === 503) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new TransientError(
|
||||
body?.error ?? "billing service temporarily unavailable — retry shortly"
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log.warning(`proxy key mint failed (${response.status})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { key: string };
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) throw error;
|
||||
if (error instanceof TransientError) throw error;
|
||||
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* choose how to authenticate the `/api/proxy-token` request:
|
||||
*
|
||||
* - production: mint a fresh OIDC token via `core.getIDToken` and send as
|
||||
* `Authorization: Bearer …` (the server verifies it cryptographically).
|
||||
* - local dev (no OIDC + `API_URL` is localhost): send `x-dev-repo:
|
||||
* owner/repo` instead. the server-side route only honors this header
|
||||
* when `NODE_ENV === "development"`, so prod is never reachable through
|
||||
* this branch even if the action is misconfigured.
|
||||
*
|
||||
* returns null when neither path is available — caller treats as soft skip.
|
||||
*/
|
||||
async function buildProxyTokenHeaders(ctx: {
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<Record<string, string> | null> {
|
||||
if (ctx.oidcCredentials) {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
return { Authorization: `Bearer ${oidcToken}` };
|
||||
}
|
||||
if (isLocalApiUrl()) {
|
||||
log.info(`» proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
|
||||
return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this run needs a minted proxy key and, if so, mint and
|
||||
* inject it as `OPENROUTER_API_KEY`. Mutates `payload.proxyModel` on success.
|
||||
*
|
||||
* Skipped when:
|
||||
* - `PULLFROG_MODEL` env override is set (BYOK escape hatch)
|
||||
* - `isInfraCovered({ oss, plan })` is false (BYOK account on a paid run)
|
||||
* - `proxyModel` is not set on the run context
|
||||
* - no OIDC credentials available and not talking to a localhost API
|
||||
*
|
||||
* Throws `BillingError` (402) or `TransientError` (503); caller renders.
|
||||
*/
|
||||
async function resolveProxyModel(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<void> {
|
||||
// env override = BYOK escape hatch, don't proxy
|
||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
||||
|
||||
const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
|
||||
if (!needsProxy) return;
|
||||
|
||||
// dev affordance: when talking to a localhost API, the server-side
|
||||
// x-dev-repo bypass replaces OIDC verification, so a play run can
|
||||
// exercise the proxy/router/oss path without GitHub Actions OIDC.
|
||||
if (!ctx.oidcCredentials && !isLocalApiUrl()) {
|
||||
log.warning("» proxy requested but no OIDC credentials available — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
|
||||
if (!key) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
core.setSecret(key);
|
||||
ctx.payload.proxyModel = ctx.proxyModel;
|
||||
const label = ctx.oss ? "oss" : "router";
|
||||
log.info(`» proxy: ${label} → ${ctx.proxyModel}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `resolveProxyModel`; if it throws a Billing or Transient error, render
|
||||
* the user-facing summary, mirror it to the PR progress comment, and rethrow.
|
||||
*
|
||||
* The rethrow is intentional: these errors are terminal for the run, and
|
||||
* letting them surface lets `runMain` exit non-zero so GH Actions applies
|
||||
* the workflow's retry policy. We catch them *here* (before the main try)
|
||||
* because the outer catch needs `toolContext` (which isn't built yet) for
|
||||
* its general-purpose rendering path — a BillingError landing in the outer
|
||||
* catch would get rendered with `core.setFailed` only, losing the
|
||||
* actionable copy + the PR-comment mirror.
|
||||
*/
|
||||
export async function runProxyResolution(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
toolState: ToolState;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await resolveProxyModel({
|
||||
payload: ctx.payload,
|
||||
oss: ctx.oss,
|
||||
plan: ctx.plan,
|
||||
proxyModel: ctx.proxyModel,
|
||||
oidcCredentials: ctx.oidcCredentials,
|
||||
repo: ctx.repo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) {
|
||||
const summary = formatBillingErrorSummary(error, ctx.repo.owner);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
// Mirror to the PR progress comment if the trigger created one (mention /
|
||||
// PR event). Without this, auto-reload declines are only visible in the
|
||||
// job summary — users rarely open that, so the agent just appears to
|
||||
// silently stop mid-run.
|
||||
await reportErrorToComment({ toolState: ctx.toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof TransientError) {
|
||||
const summary = formatTransientErrorSummary(error, ctx.repo.owner);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
await reportErrorToComment({ toolState: ctx.toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user