From d277e70f7829943cba18e15a7705cd81c229694e Mon Sep 17 00:00:00 2001 From: abs2023 Date: Tue, 26 May 2026 15:51:30 -0400 Subject: [PATCH] fix(keeper): repair CI tests and Docker build after keeper infra merge Align PERPS_REPO/FUTURES_REPO with artifact loader (repo roots), copy pnpm patches into the image, vendor errSerializer for keeper-only Docker context, and opt GitHub Actions into Node 24 for action runtimes. Co-authored-by: Cursor --- .github/workflows/deploy-keeper.yml | 1 + .github/workflows/keeper-test.yml | 7 ++- keeper/Dockerfile | 3 +- keeper/scripts/compile-siblings.ts | 30 +++++++-- keeper/src/index.ts | 2 +- keeper/src/util/errSerializer.ts | 96 +++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 keeper/src/util/errSerializer.ts diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index 7014176..f3375a4 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -64,6 +64,7 @@ permissions: packages: write env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true GHCR_REGISTRY: ghcr.io GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 47dfcd3..1d5b38c 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -25,6 +25,9 @@ defaults: permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: test: name: Test @@ -88,6 +91,6 @@ jobs: - name: Integration tests working-directory: ./keeper env: - PERPS_REPO: ${{ github.workspace }}/perps/contracts - FUTURES_REPO: ${{ github.workspace }}/futures-marketplace/contracts + PERPS_REPO: ${{ github.workspace }}/perps + FUTURES_REPO: ${{ github.workspace }}/futures-marketplace run: pnpm test:integration diff --git a/keeper/Dockerfile b/keeper/Dockerfile index 0621f9a..bd12843 100644 --- a/keeper/Dockerfile +++ b/keeper/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /app RUN corepack enable -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches/ patches/ RUN pnpm install --frozen-lockfile --prod # ── Runtime ─────────────────────────────────────────────────────────────────── diff --git a/keeper/scripts/compile-siblings.ts b/keeper/scripts/compile-siblings.ts index 266c446..78872b7 100644 --- a/keeper/scripts/compile-siblings.ts +++ b/keeper/scripts/compile-siblings.ts @@ -18,8 +18,10 @@ * imports — and the keeper just reads the resulting artifact JSON. * * Path resolution mirrors `tests/integration/artifacts.ts`: - * PERPS_REPO – defaults to ../../perps - * FUTURES_REPO – defaults to ../../futures-marketplace + * PERPS_REPO – sibling repo root (…/perps); defaults to ../../perps + * FUTURES_REPO – sibling repo root; defaults to ../../futures-marketplace + * + * Each repo's Hardhat project lives in `/contracts`. * * Compilation is skipped when `SKIP_COMPILE_SIBLINGS=1` (used in CI when * the artifacts have already been built upstream and committed). @@ -37,12 +39,32 @@ if (process.env.SKIP_COMPILE_SIBLINGS === "1") { const here = dirname(fileURLToPath(import.meta.url)); const workspaceRoot = resolve(here, "..", "..", ".."); +/** Repo root env var → Hardhat package dir (`/contracts`). */ +function contractsPackageDir(repoRootEnv: string | undefined, defaultRepoRoot: string): string { + const root = repoRootEnv ?? defaultRepoRoot; + const pkg = resolve(root, "contracts"); + if (existsSync(resolve(pkg, "hardhat.config.ts")) || existsSync(resolve(pkg, "hardhat.config.js"))) { + return pkg; + } + // Legacy: env pointed directly at the contracts package. + if (existsSync(resolve(root, "hardhat.config.ts")) || existsSync(resolve(root, "hardhat.config.js"))) { + return root; + } + return pkg; +} + const targets = [ { name: "collateral-margin", dir: resolve(here, "..", "..", "contracts") }, - { name: "perps", dir: process.env.PERPS_REPO ?? resolve(workspaceRoot, "perps", "contracts") }, + { + name: "perps", + dir: contractsPackageDir(process.env.PERPS_REPO, resolve(workspaceRoot, "perps")), + }, { name: "futures-marketplace", - dir: process.env.FUTURES_REPO ?? resolve(workspaceRoot, "futures-marketplace", "contracts"), + dir: contractsPackageDir( + process.env.FUTURES_REPO, + resolve(workspaceRoot, "futures-marketplace"), + ), }, ]; diff --git a/keeper/src/index.ts b/keeper/src/index.ts index 3609b7e..21705a9 100644 --- a/keeper/src/index.ts +++ b/keeper/src/index.ts @@ -1,6 +1,6 @@ import pino from "pino"; import { CollateralVaultAbi } from "collateral-margin-abi/CollateralVault.ts"; -import { serializeError } from "../../market-maker/src/core/errSerializer.ts"; +import { serializeError } from "./util/errSerializer.ts"; import { loadConfig } from "./config.ts"; import { createChain } from "./chain.ts"; import { ParticipantTracker } from "./discovery/tracker.ts"; diff --git a/keeper/src/util/errSerializer.ts b/keeper/src/util/errSerializer.ts new file mode 100644 index 0000000..3bbacdc --- /dev/null +++ b/keeper/src/util/errSerializer.ts @@ -0,0 +1,96 @@ +/** + * viem errors nest 4-5 cause levels deep, and every level re-stringifies the + * full multicall calldata into its `message`, `stack`, and `metaMessages`. + * Naively serializing with `pino.stdSerializers.errWithCause` produces tens + * of KB of duplicated hex per failed call. + * + * This serializer instead walks the cause chain once and emits a flat, + * minimal payload: `name`, `message` (preferring viem's `shortMessage`), the + * decoded custom error (`errorName`, e.g. `"FailedCall"`), a trimmed `data` + * hex selector/blob, and a single frames-only `stack` from the top error. + */ + +const MAX_DATA_LEN = 200; + +function isObj(v: unknown): v is Record { + return v !== null && typeof v === "object"; +} + +function* walkCauses(err: unknown): Generator> { + const seen = new Set(); + let cur: unknown = err; + while (isObj(cur) && !seen.has(cur)) { + seen.add(cur); + yield cur; + cur = (cur as Record).cause; + } +} + +function pickString(o: Record, k: string): string | undefined { + const v = o[k]; + return typeof v === "string" ? v : undefined; +} + +function firstLine(s: string): string { + const idx = s.indexOf("\n"); + return idx === -1 ? s : s.slice(0, idx); +} + +function shortMessageOf(lvl: Record): string | undefined { + const sm = pickString(lvl, "shortMessage"); + if (sm) return sm; + const m = pickString(lvl, "message"); + return m === undefined ? undefined : firstLine(m); +} + +function stackFrames(stack: unknown): string { + if (typeof stack !== "string") return ""; + return stack + .split("\n") + .filter((l) => /^\s*at /.test(l)) + .join("\n"); +} + +function trimHex(s: string): string { + return s.length <= MAX_DATA_LEN ? s : `${s.slice(0, MAX_DATA_LEN)}…<+${s.length - MAX_DATA_LEN} chars>`; +} + +export function serializeError(err: unknown): Record { + if (err === null || typeof err !== "object" || !(err instanceof Error)) { + return { raw: err }; + } + + const chain = [...walkCauses(err)]; + const top = chain[0] ?? {}; + + let errorName: string | undefined; + let data: string | undefined; + for (const lvl of chain) { + if (errorName === undefined && isObj(lvl.data)) { + errorName = pickString(lvl.data as Record, "errorName"); + } + if (data === undefined && typeof lvl.data === "string") { + data = trimHex(lvl.data); + } + if (errorName !== undefined && data !== undefined) break; + } + + let stack = ""; + for (const lvl of chain) { + stack = stackFrames(lvl.stack); + if (stack) break; + } + + const name = pickString(top, "name") ?? err.name ?? "Error"; + const message = shortMessageOf(top) ?? "(no message)"; + + const out: Record = { name, message }; + if (errorName) out.errorName = errorName; + if (data !== undefined) out.data = data; + if (stack) out.stack = stack; + for (const k of ["contractAddress", "functionName", "sender", "tenderlyUrl"] as const) { + const v = pickString(top, k); + if (v) out[k] = v; + } + return out; +}