run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
267 lines • 11.7 kB
JavaScript
/**
* `errors` namespace — the release-error-rollup query surface (gateway
* `release-error-rollup`). The platform's durable, grouped error memory:
* every 5xx at the invoke choke points is fingerprinted (deploy-stable by
* normalization) and collapsed into one hot row per distinct failure, each
* baselined against the previous ACTIVE release.
*
* Verdict-first, gateway-authoritative: every page leads with a computed
* promote-vs-revert verdict, and the SDK NEVER recomputes it. No client-side
* fingerprinting, no re-baselining, no re-counting of `new_fingerprints` — the
* gateway's numbers are the truth. `list`/`get` pass the envelope through
* untouched; `watch` polls and reads `verdict.new_fingerprints`.
*
* The promote-gate golden path (run right after an apply/promote activates a
* release):
*
* const w = await r.errors.watch(projectId, { newIn: releaseId });
* if (!w.clean) {
* // w.verdict.new_fingerprints > 0 — new error identities under the new
* // release. w.new_errors carries the grouped rows + fetch_logs drill-downs.
* }
*
* `clean === (verdict.new_fingerprints === 0)` — the gateway's count, never a
* client recount. Exposed both unscoped (`r.errors.list(projectId, …)`) and
* project-scoped (`r.project(id).errors.list(…)`), mirroring `r.events`.
*
* Auth: the addressed project's OWN key when it is cached locally; otherwise
* the client's principal (SIWX wallet / session / grant key) with
* `project.read`. A key for a different project gets 403, never a 404 that
* would confirm existence.
*/
import { LocalError } from "../errors.js";
import { isRun402Error } from "../errors.js";
const WATCH_DEFAULT_DURATION_MS = 600_000;
const WATCH_DEFAULT_INTERVAL_MS = 15_000;
/** Consecutive tolerated-failure polls before `watch` rethrows the last error. */
const WATCH_MAX_CONSECUTIVE_FAILURES = 3;
/** Map {@link ListErrorsOptions} to the wire query string (`newIn` → `new_in`; all else 1:1). */
function errorsQuery(opts = {}) {
const params = new URLSearchParams();
if (opts.since !== undefined)
params.set("since", opts.since);
if (opts.until !== undefined)
params.set("until", opts.until);
if (opts.function !== undefined)
params.set("function", opts.function);
if (opts.kind !== undefined)
params.set("kind", opts.kind);
if (opts.fingerprint !== undefined)
params.set("fingerprint", opts.fingerprint);
if (opts.newIn !== undefined)
params.set("new_in", opts.newIn);
if (opts.limit !== undefined)
params.set("limit", String(opts.limit));
if (opts.cursor !== undefined)
params.set("cursor", opts.cursor);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
/**
* The ONLY error classes `watch` tolerates across polls (so an outage can't
* masquerade as a clean verdict): a network error (fetch produced no
* response), a 408/429, or any 5xx. Everything else — a 4xx auth/validation
* denial, a local credential miss — will not heal on retry and rethrows
* immediately.
*/
function isTransientWatchError(err) {
if (!isRun402Error(err))
return false;
if (err.kind === "network_error")
return true;
const s = err.status;
return s === 408 || s === 429 || (typeof s === "number" && s >= 500);
}
/**
* Sleep `ms`, resolving early if `signal` aborts. setTimeout-based, no busy
* loop; the timer is always cleared and the listener always removed so no
* handle is left dangling.
*/
function sleepRacingSignal(ms, signal) {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
let timer;
let settled = false;
const finish = () => {
if (settled)
return;
settled = true;
if (timer !== undefined)
clearTimeout(timer);
if (signal)
signal.removeEventListener("abort", finish);
resolve();
};
timer = setTimeout(finish, ms);
if (signal)
signal.addEventListener("abort", finish, { once: true });
});
}
/**
* The credential for an error read. The project's own service key when it is
* cached locally (the historical path); otherwise the client's principal
* credential — a SIWX wallet, session, or grant key — which the gateway
* authorizes through `project.read`. That second path is how the agent a Buzz
* page addresses runs `run402 errors list --project …` on a project it never
* deployed: an org member or teammate holds no project key and does not need
* one.
*/
async function credentialFor(client, projectId) {
const keys = await client.getProjectCredentials(projectId);
return keys ? { headers: { apikey: keys.service_key }, withAuth: false } : { withAuth: true };
}
export class Errors {
client;
constructor(client) {
this.client = client;
}
/**
* Read a verdict-first page of a project's grouped error fingerprints
* (`GET /projects/v1/:project_id/errors`). Filters map 1:1 to wire query
* params except `newIn` → `new_in`; `newIn` (a release id or `"active"`)
* drives the verdict's promote-gate `new_fingerprints`. Envelope is passed
* through untouched. Authorized with the addressed project's OWN key.
*/
async list(projectId, opts = {}) {
if (!projectId) {
throw new LocalError("errors.list requires a projectId", "reading release error fingerprints");
}
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/errors${errorsQuery(opts)}`, {
method: "GET",
...(await credentialFor(this.client, projectId)),
context: "reading release error fingerprints",
});
}
/**
* Read one fingerprint's full detail — the same row shape as {@link list}
* with the complete sample ring, a per-sample `fetch_logs` next action, and
* `also_seen_in_functions` when the hash surfaced under more than one
* function (`GET /projects/v1/:project_id/errors/:fingerprint_id`). Throws an
* {@link ApiError} 404 (`RESOURCE_NOT_FOUND`) for an unknown id under an
* authorized project.
*/
async get(projectId, fingerprintId) {
if (!projectId) {
throw new LocalError("errors.get requires a projectId", "reading an error fingerprint");
}
if (!fingerprintId) {
throw new LocalError("errors.get requires a fingerprintId", "reading an error fingerprint");
}
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/errors/${encodeURIComponent(fingerprintId)}`, {
method: "GET",
...(await credentialFor(this.client, projectId)),
context: "reading an error fingerprint",
});
}
/**
* The promote-gate poll loop. Run it right after an apply/promote activates a
* release to watch that release under real traffic. Polls {@link list} with
* `{ newIn }` immediately, then every `intervalMs`, and does one final poll
* when the `durationMs` window elapses. With `failFast` (the default), stops
* the moment a poll reports `verdict.new_fingerprints > 0`.
*
* The verdict is the gateway's — `clean === (verdict.new_fingerprints === 0)`
* is read straight off the last observed page; nothing is recomputed here.
* `new_errors` is that page's `errors[]` (already server-filtered to
* first-seen-under-release when `newIn` is passed).
*
* Fault tolerance so an outage can't masquerade as a verdict: a 4xx (other
* than 408/429) rethrows immediately — auth/validation won't heal; network
* errors, 5xx, 408, and 429 are tolerated, but three CONSECUTIVE failed polls
* rethrow the last error (a successful poll resets the counter).
*
* `signal` aborts cleanly: if at least one poll succeeded, returns the
* result-so-far with `aborted: true`; if none did, throws a {@link LocalError}.
*
* @throws {LocalError} when `projectId` or `opts.newIn` is missing, or when
* aborted before any poll succeeded.
*/
async watch(projectId, opts) {
if (!projectId) {
throw new LocalError("errors.watch requires a projectId", "watching release errors");
}
if (!opts || !opts.newIn) {
throw new LocalError('errors.watch requires opts.newIn (a release id or "active")', "watching release errors");
}
const durationMs = opts.durationMs ?? WATCH_DEFAULT_DURATION_MS;
const intervalMs = opts.intervalMs ?? WATCH_DEFAULT_INTERVAL_MS;
const failFast = opts.failFast ?? true;
const signal = opts.signal;
const started = Date.now();
let polls = 0;
let consecutiveFailures = 0;
let lastError;
let lastPage;
let aborted = false;
for (;;) {
if (signal?.aborted) {
aborted = true;
break;
}
// Whether THIS poll is the final one (its window budget is already spent).
const windowElapsed = Date.now() - started >= durationMs;
let triggered = false;
try {
const page = await this.list(projectId, { newIn: opts.newIn });
consecutiveFailures = 0;
polls += 1;
lastPage = page;
if (opts.onPoll) {
try {
opts.onPoll(page, { poll: polls, elapsedMs: Date.now() - started });
}
catch {
// A caller's onPoll must never break the watch loop.
}
}
if (failFast && page.verdict.new_fingerprints > 0)
triggered = true;
}
catch (err) {
lastError = err;
// Won't heal (4xx auth/validation, local credential miss) → surface now.
if (!isTransientWatchError(err))
throw err;
consecutiveFailures += 1;
// A sustained outage must not read as a verdict.
if (consecutiveFailures >= WATCH_MAX_CONSECUTIVE_FAILURES)
throw err;
}
if (triggered)
break;
if (windowElapsed)
break; // this was the final poll (success or tolerated failure)
await sleepRacingSignal(intervalMs, signal);
if (signal?.aborted) {
aborted = true;
break;
}
}
if (!lastPage) {
// No poll ever succeeded.
if (aborted) {
throw new LocalError("errors.watch was aborted before any poll succeeded", "watching release errors", { cause: lastError });
}
// Reached only if the window elapsed with zero successful polls.
// eslint-disable-next-line @typescript-eslint/only-throw-error -- rethrows the last poll failure unchanged
if (lastError !== undefined)
throw lastError;
throw new LocalError("errors.watch produced no result", "watching release errors");
}
const newErrors = lastPage.errors;
const result = {
clean: lastPage.verdict.new_fingerprints === 0,
verdict: lastPage.verdict,
new_errors: newErrors,
polls,
elapsed_ms: Date.now() - started,
};
if (aborted)
result.aborted = true;
return result;
}
}
//# sourceMappingURL=errors.js.map