Files
nicktrn 3f97cf4a6b Managed run controller revamp (#1927)
* update nypm to support text-based bun lockfiles

* fix retry spans

* only download debug logs if admin

* add nypm changeset

* pull out env override logic

* use runner env gather helper

* handle dev flushing failures gracefully

* fix path normalization for init.ts

* add logger

* add execution heartbeat service

* add snapshot poller service

* fix poller

* add changesets

* create socket in constructor

* enable strictPropertyInitialization

* deprecate dequeue from version

* start is not async

* dependency injection in prep for tests

* add warm start count to all controller logs

* add restore count

* pull out run execution logic

* temp disable pre

* add a controller log when starting an execution

* refactor execution and squash some bugs

* cleanup completed docker containers by default

* execution fixes and logging improvements

* don't throw afet abort cleanup

* poller should use private interval

* rename heartbeat service file

* rename HeartbeatService to IntervalService

* restore old heartbeat service but deprecate it

* use the new interval service everywhere

* Revert "temp disable pre"

This reverts commit e03f4179de6a731c17253b68a6e00bcb7ac1736b.

* add changeset

* replace all run engine find uniques with find first
2025-04-15 16:08:51 +01:00

486 lines
13 KiB
TypeScript

import { execa } from "execa";
import * as nodePath from "node:path";
import * as fs from "node:fs";
import { logger } from "../src/utilities/logger.js";
import { findUpMultiple, findUp } from "find-up";
import { TaskRunExecutionResult, WorkerManifest } from "@trigger.dev/core/v3/schemas";
import { TaskRunProcess } from "../src/executions/taskRunProcess.js";
import { createTestHttpServer } from "@epic-web/test-server/http";
import { TestCase, TestCaseRun } from "./fixtures.js";
import { access } from "node:fs/promises";
import { MachinePreset } from "@trigger.dev/core/v3";
export type PackageManager = "npm" | "pnpm" | "yarn";
export const LOCKFILES = {
npm: "package-lock.json",
npmShrinkwrap: "npm-shrinkwrap.json",
pnpm: "pnpm-lock.yaml",
yarn: "yarn.lock",
bun: "bun.lockb",
};
export async function installFixtureDeps(options: {
fixtureDir: string;
packageManager: PackageManager;
workspaceDir: string;
}) {
const { packageManager, workspaceDir } = options;
if (["pnpm", "yarn"].includes(packageManager)) {
const version = await detectPackageManagerVersion(options);
debug(`Detected ${packageManager}@${version} from package.json 'engines' field`);
const { stdout, stderr } = await execa("corepack", ["use", `${packageManager}@${version}`], {
cwd: workspaceDir,
});
debug(stdout);
if (stderr) console.error(stderr);
} else {
const { stdout, stderr } = await execa(packageManager, installArgs(packageManager), {
cwd: workspaceDir,
env: {
...process.env,
NODE_PATH: nodePath.resolve(nodePath.join(workspaceDir, "node_modules")),
},
});
debug(stdout);
if (stderr) console.error(stderr);
}
}
export async function parsePackageManager(
packageManager: PackageManager | undefined,
fixtureDir: string
): Promise<PackageManager> {
let $packageManager: PackageManager;
if (packageManager) {
$packageManager = packageManager;
} else {
$packageManager = await detectPackageManagerFromArtifacts(fixtureDir);
}
return $packageManager;
}
export async function detectPackageManagerFromArtifacts(path: string): Promise<PackageManager> {
const foundPath = await findUp(Object.values(LOCKFILES), { cwd: path });
if (!foundPath) {
throw new Error("Could not detect package manager from artifacts");
}
logger.debug("Found path from package manager artifacts", { foundPath });
switch (nodePath.basename(foundPath)) {
case LOCKFILES.yarn:
logger.debug("Found yarn artifact", { foundPath });
return "yarn";
case LOCKFILES.pnpm:
logger.debug("Found pnpm artifact", { foundPath });
return "pnpm";
case LOCKFILES.npm:
case LOCKFILES.npmShrinkwrap:
logger.debug("Found npm artifact", { foundPath });
return "npm";
case LOCKFILES.bun:
logger.debug("Found bun artifact", { foundPath });
return "npm";
default:
throw new Error(`Unhandled package manager detection path: ${foundPath}`);
}
}
function debug(message: string) {
if (logger.loggerLevel === "debug") {
console.log(message);
}
}
function installArgs(packageManager: string) {
switch (packageManager) {
case "bun":
return ["install", "--frozen-lockfile"];
case "pnpm":
case "yarn":
throw new Error("pnpm and yarn must install using `corepack use`");
case "npm":
return ["ci", "--no-audit"];
default:
throw new Error(`Unknown package manager '${packageManager}'`);
}
}
async function detectPackageManagerVersion(options: {
fixtureDir: string;
packageManager: PackageManager;
workspaceDir: string;
}): Promise<string> {
const { fixtureDir, packageManager, workspaceDir } = options;
const pkgPaths = await findUpMultiple("package.json", { cwd: workspaceDir, stopAt: fixtureDir });
for (let pkgPath of pkgPaths) {
const buffer = fs.readFileSync(pkgPath, "utf8");
const pkgJSON = JSON.parse(buffer.toString());
if (!pkgJSON.engines) continue;
const version = pkgJSON.engines[packageManager];
if (version) return version;
}
throw new Error(`No version found for package manager ${packageManager}`);
}
export interface TypeScriptError {
file: string;
line: number;
column: number;
message: string;
}
export interface TscResult {
success: boolean;
errors: TypeScriptError[];
stdout: string;
stderr: string;
}
export async function runTsc(
cwd: string,
tsconfigName: string = "tsconfig.json",
binBasePath: string = cwd
): Promise<TscResult> {
const tsconfigPath = nodePath.join(cwd, tsconfigName);
const tscPath = nodePath.join(binBasePath, "node_modules", ".bin", "tsc");
// Ensure the tsconfig file exists
try {
await access(tsconfigPath);
} catch (error) {
throw new Error(`TSConfig file not found: ${tsconfigPath}`);
}
try {
logger.debug(`Running TypeScript compiler: ${tscPath} --project ${tsconfigPath} --noEmit`, {
cwd,
});
const result = await execa(tscPath, ["--project", tsconfigPath, "--noEmit"], {
cwd,
reject: false,
});
const success = result.exitCode === 0;
const errors = success ? [] : parseTypeScriptErrors(result.stderr);
logger.debug(result.stdout);
logger.debug(result.stderr);
return {
success,
errors,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
throw new Error(`Failed to run TypeScript compiler: ${error}`);
}
}
function parseTypeScriptErrors(stderr: string): TypeScriptError[] {
const errorRegex = /(.+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g;
const errors: TypeScriptError[] = [];
let match;
while ((match = errorRegex.exec(stderr)) !== null) {
errors.push({
file: match[1]!,
line: parseInt(match[2]!, 10),
column: parseInt(match[3]!, 10),
message: match[4]!,
});
}
return errors;
}
export type ExecuteTaskCaseRunOptions = {
testCase: TestCase;
run: TestCaseRun;
destination: string;
workerManifest: WorkerManifest;
contentHash: string;
};
export type ExecuteTaskRunUsageReport = {
durationMs: number;
};
export type ExecuteTaskRunResult = {
result: TaskRunExecutionResult;
usageReports: Array<ExecuteTaskRunUsageReport>;
totalDurationMs: number;
spans: Array<ExecuteTaskTraceEvent>;
};
export type ExecuteTaskTraceEvent = {
name: string;
traceId: string;
spanId: string;
parentSpanId?: string;
durationMs: number;
attributes?: { [key: string]: string | number | boolean | undefined };
};
export async function executeTestCaseRun({
run,
testCase,
destination,
workerManifest,
contentHash,
}: ExecuteTaskCaseRunOptions): Promise<ExecuteTaskRunResult> {
const usageReports: Array<ExecuteTaskRunUsageReport> = [];
const spans: Array<ExecuteTaskTraceEvent> = [];
// Create a disposable "server" instance.
const server = await createTestHttpServer({
defineRoutes(router) {
router.post("/usage", async ({ req }) => {
const jsonBody = await req.json();
usageReports.push({
durationMs: jsonBody.durationMs,
});
return Response.json({});
});
router.post("/v1/traces", async ({ req }) => {
const jsonBody = await req.json();
spans.push(...parseTraceBodyIntoEvents(jsonBody));
// TODO: Implement trace endpoint
return Response.json({});
});
router.post("/v1/logs", () => {
// TODO: Implement logs endpoint
return Response.json({});
});
router.post("/v1/chat/completions", async ({ req }) => {
return Response.json({
id: "chatcmpl-7XYZ123ABC456DEF789GHI",
object: "chat.completion",
created: 1631619199,
model: "gpt-3.5-turbo-0613",
choices: [
{
index: 0,
message: {
role: "assistant",
content:
"The capital of France is Paris. Paris is not only the political capital but also the cultural and economic center of France. It's known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral.",
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 29,
completion_tokens: 48,
total_tokens: 77,
},
});
});
},
});
const machine = {
name: "small-1x",
cpu: 1,
memory: 256,
centsPerMs: 0.0000001,
} satisfies MachinePreset;
try {
const taskRunProcess = new TaskRunProcess({
workerManifest: workerManifest!,
cwd: destination,
env: {
USAGE_EVENT_URL: server.http.url("/usage").href,
OTEL_EXPORTER_OTLP_ENDPOINT: server.http.url().origin,
TRIGGER_JWT: "test-jwt",
TRIGGER_SECRET_KEY: "test-secret",
TRIGGER_API_URL: server.http.url().origin,
USAGE_HEARTBEAT_INTERVAL_MS: "500",
OPENAI_API_KEY: "api-key",
OPENAI_BASE_URL: server.http.url().origin + "/v1",
},
serverWorker: {
id: "test",
version: "1.0.0",
contentHash,
},
machineResources: machine,
}).initialize();
const result = await taskRunProcess.execute({
payload: {
traceContext: {
traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
},
environment: {},
execution: {
task: run.task,
attempt: {
id: "attempt_1234",
status: "RUNNING",
number: 1,
startedAt: new Date(),
backgroundWorkerId: "worker_1234",
backgroundWorkerTaskId: "task_1234",
},
run: {
id: "run_1234",
startedAt: new Date(),
payload: run.payload,
payloadType: run.payloadType ?? "application/json",
tags: [],
context: {},
isTest: false,
createdAt: new Date(),
durationMs: 0,
costInCents: 0,
baseCostInCents: 0,
version: "1.0.0",
},
queue: {
id: "queue_1234",
name: "test",
},
environment: {
type: "DEVELOPMENT",
id: "env_1234",
slug: "dev",
},
organization: {
id: "org_1234",
slug: "test",
name: "test",
},
project: {
id: "project_1234",
slug: "test",
ref: "main",
name: "test",
},
machine,
},
},
messageId: "run_1234",
});
await taskRunProcess.cleanup(true);
return {
result,
usageReports,
totalDurationMs: usageReports.reduce((acc, report) => acc + report.durationMs, 0),
spans,
};
} finally {
await server.close();
}
}
function parseTraceBodyIntoEvents(body: any): ExecuteTaskTraceEvent[] {
return body.resourceSpans.flatMap(parseResourceSpanIntoEvents);
}
function parseResourceSpanIntoEvents(resourceSpan: any): ExecuteTaskTraceEvent[] {
return resourceSpan.scopeSpans.flatMap((scopeSpan: any) =>
parseScopeSpanIntoEvents(scopeSpan, resourceSpan.resource)
);
}
function parseScopeSpanIntoEvents(scopeSpan: any, resource: any): ExecuteTaskTraceEvent[] {
return scopeSpan.spans.flatMap((span: any) => parseSpanInEvent(span, resource));
}
function parseSpanInEvent(span: any, resource: any): ExecuteTaskTraceEvent {
return {
name: span.name,
traceId: span.traceId,
spanId: span.spanId,
parentSpanId: span.parentSpanId,
durationMs: calculateSpanDurationMs(span),
attributes: {
...parseAttributes(resource.attributes),
...parseAttributes(span.attributes),
},
};
}
function calculateSpanDurationMs(span: any): number {
return Number(BigInt(span.endTimeUnixNano) - BigInt(span.startTimeUnixNano) / BigInt(1e6));
}
function parseAttributes(attributes: any): ExecuteTaskTraceEvent["attributes"] {
if (!attributes) return {};
return attributes.reduce((acc: any, attribute: any) => {
acc[attribute.key] = isStringValue(attribute.value)
? attribute.value.stringValue
: isIntValue(attribute.value)
? Number(attribute.value.intValue)
: isDoubleValue(attribute.value)
? attribute.value.doubleValue
: isBoolValue(attribute.value)
? attribute.value.boolValue
: isBytesValue(attribute.value)
? binaryToHex(attribute.value.bytesValue)
: undefined;
return acc;
}, {});
}
function isBoolValue(value: any | undefined): value is { boolValue: boolean } {
if (!value) return false;
return typeof value.boolValue === "boolean";
}
function isStringValue(value: any | undefined): value is { stringValue: string } {
if (!value) return false;
return typeof value.stringValue === "string";
}
function isIntValue(value: any | undefined): value is { intValue: bigint } {
if (!value) return false;
return typeof value.intValue === "number";
}
function isDoubleValue(value: any | undefined): value is { doubleValue: number } {
if (!value) return false;
return typeof value.doubleValue === "number";
}
function isBytesValue(value: any | undefined): value is { bytesValue: Buffer } {
if (!value) return false;
return Buffer.isBuffer(value.bytesValue);
}
function binaryToHex(buffer: Buffer | string): string;
function binaryToHex(buffer: Buffer | string | undefined): string | undefined;
function binaryToHex(buffer: Buffer | string | undefined): string | undefined {
if (!buffer) return undefined;
if (typeof buffer === "string") return buffer;
return Buffer.from(Array.from(buffer)).toString("hex");
}