diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 8cc10fd5c08..a9be415c792 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -262,7 +262,7 @@ export class SharedQueueConsumer { console.log("✅ Started the SharedQueueConsumer"); - this.#doWork().finally(() => {}); + this.#doWork().finally(() => { }); } #endCurrentSpan() { @@ -417,7 +417,7 @@ export class SharedQueueConsumer { span.end(); setTimeout(() => { - this.#doWork().finally(() => {}); + this.#doWork().finally(() => { }); }, nextInterval); } }); @@ -620,8 +620,8 @@ export class SharedQueueConsumer { return existingTaskRun.lockedById ? await getWorkerDeploymentFromWorkerTask(existingTaskRun.lockedById) : existingTaskRun.lockedToVersionId - ? await getWorkerDeploymentFromWorker(existingTaskRun.lockedToVersionId) - : await findCurrentWorkerDeployment({ + ? await getWorkerDeploymentFromWorker(existingTaskRun.lockedToVersionId) + : await findCurrentWorkerDeployment({ environmentId: existingTaskRun.runtimeEnvironmentId, type: "V1", }); @@ -928,7 +928,6 @@ export class SharedQueueConsumer { machine, nextAttemptNumber, // identifiers - id: "placeholder", // TODO: Remove this completely in a future release envId: lockedTaskRun.runtimeEnvironment.id, envType: lockedTaskRun.runtimeEnvironment.type, orgId: lockedTaskRun.runtimeEnvironment.organizationId, @@ -1650,6 +1649,12 @@ export const AttemptForExecutionGetPayload = { maxDurationInSeconds: true, tags: true, taskEventStore: true, + batch: { + select: { + id: true, + friendlyId: true, + }, + }, }, }, queue: { @@ -1754,7 +1759,11 @@ class SharedQueueTasks { slug: attempt.runtimeEnvironment.project.slug, name: attempt.runtimeEnvironment.project.name, }, - batch: undefined, // TODO: Removing this for now until we can do it more efficiently + batch: attempt.taskRun.batch + ? { + id: attempt.taskRun.batch.friendlyId, + } + : undefined, worker: { id: attempt.backgroundWorkerId, contentHash: attempt.backgroundWorker.contentHash, @@ -1900,9 +1909,9 @@ class SharedQueueTasks { async getResumePayload(attemptId: string): Promise< | { - execution: V3ProdTaskRunExecution; - completion: TaskRunExecutionResult; - } + execution: V3ProdTaskRunExecution; + completion: TaskRunExecutionResult; + } | undefined > { const attempt = await prisma.taskRunAttempt.findFirst({ diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 2938164b74b..5e473d54048 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -274,14 +274,14 @@ async function createWorkerTask( description: task.description, filePath: task.filePath, exportName: task.exportName, - retryConfig: task.retry, - queueConfig: task.queue, - machineConfig: task.machine, + retryConfig: task.retry ?? null, + queueConfig: task.queue ?? null, + machineConfig: task.machine ?? null, triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD", fileId: tasksToBackgroundFiles?.get(task.id) ?? null, maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null, queueId: queue.id, - payloadSchema: task.payloadSchema as any, + payloadSchema: (task.payloadSchema as any) ?? null, }, }); } catch (error) { diff --git a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts index a224e5a86b0..0f2a576a32a 100644 --- a/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/executionSnapshotSystem.ts @@ -60,23 +60,20 @@ function enhanceExecutionSnapshotWithWaitpoints( waitpoints: Waitpoint[], completedWaitpointOrder: string[] ): EnhancedExecutionSnapshot { + const waitpointIndexMap = new Map(); + for (let i = 0; i < completedWaitpointOrder.length; i++) { + const id = completedWaitpointOrder[i]; + const existing = waitpointIndexMap.get(id) ?? []; + existing.push(i); + waitpointIndexMap.set(id, existing); + } + return { ...snapshot, friendlyId: SnapshotId.toFriendlyId(snapshot.id), runFriendlyId: RunId.toFriendlyId(snapshot.runId), completedWaitpoints: waitpoints.flatMap((w) => { - // Get all indexes of the waitpoint in the completedWaitpointOrder - // We do this because the same run can be in a batch multiple times (i.e. same idempotencyKey) - let indexes: (number | undefined)[] = []; - for (let i = 0; i < completedWaitpointOrder.length; i++) { - if (completedWaitpointOrder[i] === w.id) { - indexes.push(i); - } - } - - if (indexes.length === 0) { - indexes.push(undefined); - } + const indexes = waitpointIndexMap.get(w.id) ?? [undefined]; return indexes.map((index) => { return { @@ -89,22 +86,22 @@ function enhanceExecutionSnapshotWithWaitpoints( w.userProvidedIdempotencyKey && !w.inactiveIdempotencyKey ? w.idempotencyKey : undefined, completedByTaskRun: w.completedByTaskRunId ? { - id: w.completedByTaskRunId, - friendlyId: RunId.toFriendlyId(w.completedByTaskRunId), - batch: snapshot.batchId - ? { - id: snapshot.batchId, - friendlyId: BatchId.toFriendlyId(snapshot.batchId), - } - : undefined, - } + id: w.completedByTaskRunId, + friendlyId: RunId.toFriendlyId(w.completedByTaskRunId), + batch: snapshot.batchId + ? { + id: snapshot.batchId, + friendlyId: BatchId.toFriendlyId(snapshot.batchId), + } + : undefined, + } : undefined, completedAfter: w.completedAfter ?? undefined, completedByBatch: w.completedByBatchId ? { - id: w.completedByBatchId, - friendlyId: BatchId.toFriendlyId(w.completedByBatchId), - } + id: w.completedByBatchId, + friendlyId: BatchId.toFriendlyId(w.completedByBatchId), + } : undefined, output: w.output ?? undefined, outputType: w.outputType, @@ -233,19 +230,19 @@ export function executionDataFromSnapshot(snapshot: EnhancedExecutionSnapshot): }, batch: snapshot.batchId ? { - id: snapshot.batchId, - friendlyId: BatchId.toFriendlyId(snapshot.batchId), - } + id: snapshot.batchId, + friendlyId: BatchId.toFriendlyId(snapshot.batchId), + } : undefined, checkpoint: snapshot.checkpoint ? { - id: snapshot.checkpoint.id, - friendlyId: snapshot.checkpoint.friendlyId, - type: snapshot.checkpoint.type, - location: snapshot.checkpoint.location, - imageRef: snapshot.checkpoint.imageRef, - reason: snapshot.checkpoint.reason ?? undefined, - } + id: snapshot.checkpoint.id, + friendlyId: snapshot.checkpoint.friendlyId, + type: snapshot.checkpoint.type, + location: snapshot.checkpoint.location, + imageRef: snapshot.checkpoint.imageRef, + reason: snapshot.checkpoint.reason ?? undefined, + } : undefined, completedWaitpoints: snapshot.completedWaitpoints, }; diff --git a/packages/cli-v3/src/dev/mcpServer.ts b/packages/cli-v3/src/dev/mcpServer.ts index 8c4e57da341..ab02b1eb6fb 100644 --- a/packages/cli-v3/src/dev/mcpServer.ts +++ b/packages/cli-v3/src/dev/mcpServer.ts @@ -57,11 +57,53 @@ server.tool( } }) .describe("The payload to pass to the task run, must be a valid JSON"), - // TODO: expose more parameteres from the trigger options + delay: z + .string() + .optional() + .describe("Delay before the task run starts, e.g. '1m', '30s', '2h', or an ISO 8601 date"), + ttl: z + .union([z.string(), z.number()]) + .optional() + .describe( + "Time-to-live: how long the run remains valid before it starts, e.g. '1h' or seconds as a number" + ), + tags: z + .array(z.string()) + .optional() + .describe("Tags to attach to the task run for filtering and organization"), + queue: z.string().optional().describe("The queue name to use for this task run"), + maxAttempts: z + .number() + .int() + .optional() + .describe("Maximum number of retry attempts for this task run"), + idempotencyKey: z + .string() + .optional() + .describe("Idempotency key for deduplication of task runs"), + concurrencyKey: z + .string() + .optional() + .describe("Concurrency key for controlling concurrent execution"), + priority: z.number().optional().describe("Priority of the task run (higher = more priority)"), + test: z.boolean().optional().describe("Whether this is a test run"), }, - async ({ id, payload }) => { + async ({ id, payload, delay, ttl, tags, queue, maxAttempts, idempotencyKey, concurrencyKey, priority, test }) => { + const options: Record = {}; + + if (delay !== undefined) options.delay = delay; + if (ttl !== undefined) options.ttl = ttl; + if (tags !== undefined) options.tags = tags; + if (queue !== undefined) options.queue = { name: queue }; + if (maxAttempts !== undefined) options.maxAttempts = maxAttempts; + if (idempotencyKey !== undefined) options.idempotencyKey = idempotencyKey; + if (concurrencyKey !== undefined) options.concurrencyKey = concurrencyKey; + if (priority !== undefined) options.priority = priority; + if (test !== undefined) options.test = test; + const result = await sdkApiClient.triggerTask(id, { payload, + options: Object.keys(options).length > 0 ? options : undefined, }); const taskRunUrl = `${dashboardUrl}/projects/v3/${projectRef}/runs/${result.id}`; diff --git a/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts b/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts index 3261e475247..0acc9cdfc4a 100644 --- a/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts +++ b/packages/core/src/v3/runTimelineMetrics/runTimelineMetricsManager.ts @@ -37,7 +37,6 @@ export class StandardRunTimelineMetricsManager implements RunTimelineMetricsMana this._metrics = []; } - // TODO: handle this when processKeepAlive is enabled #seedMetricsFromEnvironment(isWarmStartOverride?: boolean) { const forkStartTime = getEnvVar("TRIGGER_PROCESS_FORK_START_TIME"); const warmStart = getEnvVar("TRIGGER_WARM_START"); @@ -46,12 +45,21 @@ export class StandardRunTimelineMetricsManager implements RunTimelineMetricsMana if (typeof forkStartTime === "string" && !isWarmStart) { const forkStartTimeMs = parseInt(forkStartTime, 10); + const forkDuration = Date.now() - forkStartTimeMs; + + // When processKeepAlive is enabled, the process is reused across multiple runs. + // The TRIGGER_PROCESS_FORK_START_TIME env var from the original cold start persists + // in the process environment and becomes stale. Skip registration if the fork time + // is unreasonably old (> 60s), which indicates a kept-alive process. + if (forkDuration > 60_000) { + return; + } this.registerMetric({ name: "trigger.dev/start", event: "fork", attributes: { - duration: Date.now() - forkStartTimeMs, + duration: forkDuration, }, timestamp: forkStartTimeMs, }); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 76f93af3ffb..0291d2a05c2 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -485,22 +485,10 @@ export const FinalizeDeploymentRequestBody = z.object({ export type FinalizeDeploymentRequestBody = z.infer; -export const BuildServerMetadata = z.object({ - buildId: z.string().optional(), - isNativeBuild: z.boolean().optional(), - artifactKey: z.string().optional(), - skipPromotion: z.boolean().optional(), - configFilePath: z.string().optional(), - skipEnqueue: z.boolean().optional(), -}); - -export type BuildServerMetadata = z.infer; - export const ProgressDeploymentRequestBody = z.object({ contentHash: z.string().optional(), gitMeta: GitMeta.optional(), runtime: z.string().optional(), - buildServerMetadata: BuildServerMetadata.optional(), }); export type ProgressDeploymentRequestBody = z.infer; @@ -540,6 +528,16 @@ export const DeploymentTriggeredVia = z export type DeploymentTriggeredVia = z.infer; +export const BuildServerMetadata = z.object({ + buildId: z.string().optional(), + isNativeBuild: z.boolean().optional(), + artifactKey: z.string().optional(), + skipPromotion: z.boolean().optional(), + configFilePath: z.string().optional(), +}); + +export type BuildServerMetadata = z.infer; + export const UpsertBranchRequestBody = z.object({ git: GitMeta.optional(), env: z.enum(["preview"]), @@ -592,53 +590,41 @@ export const InitializeDeploymentResponseBody = z.object({ export type InitializeDeploymentResponseBody = z.infer; -const InitializeDeploymentRequestBodyBase = z.object({ - contentHash: z.string(), - userId: z.string().optional(), - /** @deprecated This is now determined by the webapp. This is only used to warn users with old CLI versions. */ - selfHosted: z.boolean().optional(), - gitMeta: GitMeta.optional(), - type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(), - runtime: z.string().optional(), - initialStatus: z.enum(["PENDING", "BUILDING"]).optional(), - triggeredVia: DeploymentTriggeredVia.optional(), - buildId: z.string().optional() -}); -type BaseOutput = z.output; - -type NativeBuildOutput = BaseOutput & { - isNativeBuild: true; - skipPromotion?: boolean; - artifactKey?: string; - configFilePath?: string; - skipEnqueue?: boolean; -}; - -type NonNativeBuildOutput = BaseOutput & { - isNativeBuild: false; - skipPromotion?: never; - artifactKey?: never; - configFilePath?: never; - skipEnqueue?: never; -}; - -const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase.extend({ - isNativeBuild: z.boolean().default(false), - skipPromotion: z.boolean().optional(), - artifactKey: z.string().optional(), - configFilePath: z.string().optional(), - skipEnqueue: z.boolean().optional().default(false), -}); - -export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFull.transform( - (data): NativeBuildOutput | NonNativeBuildOutput => { - if (data.isNativeBuild) { - return { ...data, isNativeBuild: true as const }; - } - const { skipPromotion, artifactKey, configFilePath, skipEnqueue, ...rest } = data; - return { ...rest, isNativeBuild: false as const }; - } -); +export const InitializeDeploymentRequestBody = z + .object({ + contentHash: z.string(), + userId: z.string().optional(), + /** @deprecated This is now determined by the webapp. This is only used to warn users with old CLI versions. */ + selfHosted: z.boolean().optional(), + gitMeta: GitMeta.optional(), + type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(), + runtime: z.string().optional(), + initialStatus: z.enum(["PENDING", "BUILDING"]).optional(), + triggeredVia: DeploymentTriggeredVia.optional(), + buildId: z.string().optional(), + }) + .and( + z.preprocess( + (val) => { + const obj = val as any; + if (!obj || !obj.isNativeBuild) { + return { ...obj, isNativeBuild: false }; + } + return obj; + }, + z.discriminatedUnion("isNativeBuild", [ + z.object({ + isNativeBuild: z.literal(true), + skipPromotion: z.boolean(), + artifactKey: z.string(), + configFilePath: z.string().optional(), + }), + z.object({ + isNativeBuild: z.literal(false), + }), + ]) + ) + ); export type InitializeDeploymentRequestBody = z.infer; @@ -708,7 +694,6 @@ export const GetDeploymentResponseBody = z.object({ version: z.string(), imageReference: z.string().nullish(), imagePlatform: z.string(), - commitSHA: z.string().nullish(), externalBuildData: ExternalBuildData.optional().nullable(), errorData: DeploymentErrorData.nullish(), worker: z @@ -725,17 +710,6 @@ export const GetDeploymentResponseBody = z.object({ ), }) .optional(), - integrationDeployments: z - .array( - z.object({ - id: z.string(), - integrationName: z.string(), - integrationDeploymentId: z.string(), - commitSHA: z.string(), - createdAt: z.coerce.date(), - }) - ) - .nullish(), }); export type GetDeploymentResponseBody = z.infer; @@ -1165,12 +1139,6 @@ export const ImportEnvironmentVariablesRequestBody = z.object({ variables: z.record(z.string()), parentVariables: z.record(z.string()).optional(), override: z.boolean().optional(), - source: z - .discriminatedUnion("type", [ - z.object({ type: z.literal("user"), userId: z.string() }), - z.object({ type: z.literal("integration"), integration: z.string() }), - ]) - .optional(), }); export type ImportEnvironmentVariablesRequestBody = z.infer< diff --git a/packages/core/src/v3/schemas/messages.ts b/packages/core/src/v3/schemas/messages.ts index c635e574454..79cfff6ab4b 100644 --- a/packages/core/src/v3/schemas/messages.ts +++ b/packages/core/src/v3/schemas/messages.ts @@ -51,7 +51,6 @@ export const BackgroundWorkerServerMessages = z.discriminatedUnion("type", [ machine: MachinePreset, nextAttemptNumber: z.number().optional(), // identifiers - id: z.string().optional(), // TODO: Remove this completely in a future release envId: z.string(), envType: EnvironmentType, orgId: z.string(), diff --git a/packages/core/src/v3/serverOnly/resourceMonitor.test.ts b/packages/core/src/v3/serverOnly/resourceMonitor.test.ts new file mode 100644 index 00000000000..422bda207e6 --- /dev/null +++ b/packages/core/src/v3/serverOnly/resourceMonitor.test.ts @@ -0,0 +1,205 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { ResourceMonitor } from "./resourceMonitor.js"; + +// Mock node:child_process +vi.mock("node:child_process", () => ({ + exec: vi.fn(), +})); + +// Mock node:v8 +vi.mock("node:v8", () => ({ + getHeapStatistics: vi.fn(() => ({ + total_heap_size: 50 * 1024 * 1024, + total_heap_size_executable: 0, + total_physical_size: 50 * 1024 * 1024, + total_available_size: 100 * 1024 * 1024, + used_heap_size: 25 * 1024 * 1024, + heap_size_limit: 200 * 1024 * 1024, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 0, + })), +})); + +import os from "node:os"; +import { exec } from "node:child_process"; + +const mockedExec = vi.mocked(exec); + +describe("ResourceMonitor", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mocks for os module + vi.spyOn(os, "totalmem").mockReturnValue(4 * 1024 * 1024 * 1024); // 4 GB + vi.spyOn(os, "freemem").mockReturnValue(2 * 1024 * 1024 * 1024); // 2 GB free + vi.spyOn(os, "cpus").mockReturnValue([ + { model: "test", speed: 2400, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }, + { model: "test", speed: 2400, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } }, + ]); + + // Mock process.memoryUsage + vi.spyOn(process, "memoryUsage").mockReturnValue({ + rss: 100 * 1024 * 1024, // 100 MB RSS + heapTotal: 50 * 1024 * 1024, + heapUsed: 25 * 1024 * 1024, + external: 0, + arrayBuffers: 0, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("should return properly formatted system memory metrics", async () => { + // Mock disk metrics (du command fails on non-Linux, so simulate failure) + mockedExec.mockImplementation((( + cmd: string, + callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + if (callback) { + callback(new Error("Command not available"), { stdout: "", stderr: "" }); + } + return {} as any; + }) as any); + + const monitor = new ResourceMonitor({ + dirName: "/tmp", + ctx: {}, + verbose: false, + }); + + const payload = await monitor.getResourceSnapshotPayload(); + + // System memory should reflect our mocked values + // 4GB total, 2GB free = 50% used + expect(parseFloat(payload.system.memory.percentUsed)).toBeCloseTo(50.0, 0); + expect(parseFloat(payload.system.memory.freeGB)).toBeCloseTo(2.0, 0); + }); + + test("should calculate node process memory percentage correctly", async () => { + mockedExec.mockImplementation((( + cmd: string, + callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + if (callback) { + callback(new Error("Command not available"), { stdout: "", stderr: "" }); + } + return {} as any; + }) as any); + + const monitor = new ResourceMonitor({ + dirName: "/tmp", + ctx: {}, + verbose: false, + }); + + const payload = await monitor.getResourceSnapshotPayload(); + + // 100 MB RSS out of 4 GB total = ~2.44% + const nodeMemPercent = parseFloat(payload.process.node.memoryUsagePercent); + expect(nodeMemPercent).toBeCloseTo(2.4, 0); + + // RSS should be ~100 MB + const nodeMemMB = parseFloat(payload.process.node.memoryUsageMB); + expect(nodeMemMB).toBeCloseTo(100.0, 0); + }); + + test("should calculate heap usage percentage correctly", async () => { + mockedExec.mockImplementation((( + cmd: string, + callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + if (callback) { + callback(new Error("Command not available"), { stdout: "", stderr: "" }); + } + return {} as any; + }) as any); + + const monitor = new ResourceMonitor({ + dirName: "/tmp", + ctx: {}, + verbose: false, + }); + + const payload = await monitor.getResourceSnapshotPayload(); + + // 25 MB used / 200 MB limit = 12.5% + const heapPercent = parseFloat(payload.process.node.heapUsagePercent); + expect(heapPercent).toBeCloseTo(12.5, 0); + expect(payload.process.node.isNearHeapLimit).toBe(false); + }); + + test("should detect near heap limit condition", async () => { + // Override getHeapStatistics to return near-limit values + const { getHeapStatistics } = await import("node:v8"); + vi.mocked(getHeapStatistics).mockReturnValue({ + total_heap_size: 180 * 1024 * 1024, + total_heap_size_executable: 0, + total_physical_size: 180 * 1024 * 1024, + total_available_size: 20 * 1024 * 1024, + used_heap_size: 170 * 1024 * 1024, // 85% of 200MB limit + heap_size_limit: 200 * 1024 * 1024, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 0, + }); + + mockedExec.mockImplementation((( + cmd: string, + callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + if (callback) { + callback(new Error("Command not available"), { stdout: "", stderr: "" }); + } + return {} as any; + }) as any); + + const monitor = new ResourceMonitor({ + dirName: "/tmp", + ctx: {}, + verbose: false, + }); + + const payload = await monitor.getResourceSnapshotPayload(); + + // 170/200 = 85% > 80% threshold + expect(payload.process.node.isNearHeapLimit).toBe(true); + }); + + test("should include constraint information", async () => { + mockedExec.mockImplementation((( + cmd: string, + callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + if (callback) { + callback(new Error("Command not available"), { stdout: "", stderr: "" }); + } + return {} as any; + }) as any); + + const monitor = new ResourceMonitor({ + dirName: "/tmp", + ctx: {}, + verbose: false, + }); + + const payload = await monitor.getResourceSnapshotPayload(); + + expect(payload.constraints).toBeDefined(); + expect(payload.constraints.cpu).toBe(2); // 2 CPUs mocked + expect(payload.constraints.memoryGB).toBe(4); // 4 GB mocked + expect(payload.timestamp).toBeDefined(); + }); +});