diff --git a/package.json b/package.json index 1e190d830..c4523ec9c 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,7 @@ "test:php-patch-approval-filter": "php scripts/php-patch-approval-filter-smoke.php", "test:schema-parity": "tsx tests/schema-parity.test.ts", "test:recipe-validation-descriptors": "tsx tests/recipe-validation-descriptors.test.ts", + "test:runtime-workers": "tsx tests/runtime-workers.test.ts", "test:recipe-runtime-backend-normalization": "tsx tests/recipe-runtime-backend-normalization.test.ts", "test:recipe-runtime-setup-staged-materialization": "tsx tests/recipe-runtime-setup-staged-materialization.test.ts", "test:recipe-run-provenance": "tsx tests/recipe-run-provenance.test.ts", diff --git a/packages/cli/src/commands/recipe-build.ts b/packages/cli/src/commands/recipe-build.ts index d23c21118..b903480b7 100644 --- a/packages/cli/src/commands/recipe-build.ts +++ b/packages/cli/src/commands/recipe-build.ts @@ -1,5 +1,5 @@ import { readFile, writeFile } from "node:fs/promises" -import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type RuntimeWordPressInstallMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePHPWasmExtensionManifest, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core" +import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type RuntimeWorkerCount, type RuntimeWordPressInstallMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePHPWasmExtensionManifest, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core" interface RecipeBuildOptions { recipeType: "phpunit" | "bench" | "template" | "generic-ability-runtime-run" | "runtime-package-run" @@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions { blueprint?: unknown wordpressVersion?: string phpVersion?: string + workers?: RuntimeWorkerCount databaseType?: "sqlite" | "mysql" wordpressInstallMode?: RuntimeWordPressInstallMode extensions?: WorkspaceRecipePHPWasmExtensionManifest[] @@ -82,6 +83,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word blueprint: phpunitOptions.blueprint, wordpressVersion: stringOrUndefined(phpunitOptions.wordpressVersion), phpVersion: stringOrUndefined(phpunitOptions.phpVersion), + workers: workerCountOrUndefined(phpunitOptions.workers), databaseType: phpunitOptions.databaseType, wordpressInstallMode: phpunitOptions.wordpressInstallMode, extensions: Array.isArray(phpunitOptions.extensions) ? phpunitOptions.extensions : [], @@ -182,6 +184,12 @@ function requiredString(value: unknown, name: string): string { return value } +function workerCountOrUndefined(value: unknown): RuntimeWorkerCount | undefined { + if (value === undefined || value === "auto") return value + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 64) return value + throw new Error("workers must be an integer from 1 to 64 or auto.") +} + function stringOrUndefined(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined } diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index e61183344..5b15338da 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -217,6 +217,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe name: plan.runtime.name, version: plan.runtime.wp, phpVersion: plan.runtime.phpVersion, + workers: plan.runtime.workers, wordpressInstallMode: plan.runtime.wordpressInstallMode, databaseSetup: recipe.inputs?.services?.some(({ kind }) => kind === "mysql") ? "external" as const : undefined, blueprint: plan.runtime.blueprint, diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts index f0729a86a..77ad187a0 100644 --- a/packages/cli/src/recipe-dry-run.ts +++ b/packages/cli/src/recipe-dry-run.ts @@ -54,6 +54,7 @@ export interface RecipePlan { name: string wp: string phpVersion?: string + workers?: number | "auto" wordpressInstallMode?: RuntimeWordPressInstallMode assets?: RuntimeAssetSpec blueprint: unknown @@ -442,6 +443,7 @@ export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirecto name: recipe.runtime?.name ?? "wp-codebox-recipe", wp: recipe.runtime?.wp ?? context.defaultWordPressVersion, ...(recipe.runtime?.phpVersion ? { phpVersion: recipe.runtime.phpVersion } : {}), + ...(recipe.runtime?.workers !== undefined ? { workers: recipe.runtime.workers } : {}), ...(recipe.runtime?.wordpressInstallMode ? { wordpressInstallMode: recipe.runtime.wordpressInstallMode } : {}), ...(recipe.runtime?.assets ? { assets: recipe.runtime.assets } : {}), ...(recipe.runtime?.extensions ? { extensions: recipe.runtime.extensions } : {}), diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index 159db6b0c..713d1f362 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -1,4 +1,4 @@ -import type { RuntimePreviewSpec, RuntimeWordPressInstallMode, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipeMount, WorkspaceRecipePHPWasmExtensionManifest, WorkspaceRecipeRuntimeBackendPackage, WorkspaceRecipeRuntimeService, WorkspaceRecipeStep } from "./runtime-contracts.js" +import type { RuntimePreviewSpec, RuntimeWorkerCount, RuntimeWordPressInstallMode, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipeMount, WorkspaceRecipePHPWasmExtensionManifest, WorkspaceRecipeRuntimeBackendPackage, WorkspaceRecipeRuntimeService, WorkspaceRecipeStep } from "./runtime-contracts.js" import { commandArg, commandJsonArg, commandStringListArg } from "./command-codecs.js" export { buildRuntimePackageRunRecipe, CODEBOX_RUN_RUNTIME_PACKAGE_ABILITY, RUNTIME_PACKAGE_ARTIFACT_DECLARATION_SCHEMA, RUNTIME_PACKAGE_EXECUTION_INPUT_SCHEMA, RUNTIME_PACKAGE_EXECUTION_RESULT_SCHEMA, RUNTIME_PACKAGE_OUTPUT_PROJECTION_SCHEMA, runtimePackageExecutionInput, type RuntimePackageArtifactDeclaration, type RuntimePackageExecutionInput, type RuntimePackageOutputProjection, type RuntimePackageRunRecipeOptions } from "./runtime-package-execution.js" export { RUNTIME_PACKAGE_DIAGNOSTIC_SCHEMA, RUNTIME_PACKAGE_RESULT_SCHEMA, RUNTIME_PACKAGE_TASK_SCHEMA, normalizeRuntimePackageResult, normalizeRuntimePackageTask, validateRuntimePackageTask, type RuntimePackageDiagnostic, type RuntimePackageResult, type RuntimePackageTask } from "./runtime-package-contracts.js" @@ -14,6 +14,7 @@ export interface NormalizeRecipeMountsOptions { export interface WordPressPhpunitRecipeOptions { wordpressVersion?: string phpVersion?: string + workers?: RuntimeWorkerCount databaseType?: "sqlite" | "mysql" wordpressInstallMode?: RuntimeWordPressInstallMode blueprint?: unknown @@ -81,6 +82,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio runtime: { wp: options.wordpressVersion ?? DEFAULT_WORDPRESS_VERSION, ...(options.phpVersion ? { phpVersion: options.phpVersion } : {}), + ...(options.workers !== undefined ? { workers: options.workers } : {}), ...(options.wordpressInstallMode ? { wordpressInstallMode: options.wordpressInstallMode } : {}), blueprint: blueprintWithMultisite(options.blueprint ?? { steps: [] }, (options.multisite ?? false) && options.databaseType !== "mysql"), ...(options.preview || options.multisite ? { preview: multisitePreview(options.preview, options.multisite ?? false) } : {}), diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 83e96c2e4..de3291072 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -119,6 +119,13 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche pattern: "^[0-9]+\\.[0-9]+$", description: "PHP runtime version passed to WordPress Playground, for example 8.3 or 8.4.", }, + workers: { + anyOf: [ + { type: "integer", minimum: 1, maximum: 64 }, + { const: "auto" }, + ], + description: "Runtime request worker count. Omit to use the backend default.", + }, wordpressInstallMode: { enum: enumValues(options.runtimeWordPressInstallModes, defaultRuntimeWordPressInstallModes), description: "Controls how Playground prepares a mounted WordPress directory. Use do-not-attempt-installing for custom distributions that own their own boot/readiness probes.", diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index a93e9b822..43fe1fb1e 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -3,7 +3,7 @@ import type { RuntimePolicy } from "./runtime-policy.js" import { SANDBOX_WORKSPACE_ROOT } from "./runtime-action-adapter.js" import type { ArtifactFileDigest, ArtifactManifestFile, ArtifactSpec, ArtifactViewerMetadata } from "./artifact-manifest.js" import type { HostToolDefinition, HostToolRegistry } from "./host-tool-registry.js" -import type { BackendNeutralRuntimeProvenance, RuntimePHPWasmExtensionManifest, RuntimeWordPressAssetSpec, RuntimeWordPressDatabaseSetupContract, RuntimeWordPressEnvironmentSpec, RuntimeWordPressInstallModeContract, RuntimeWordPressProvenance } from "./runtime-neutral-contracts.js" +import type { BackendNeutralRuntimeProvenance, RuntimePHPWasmExtensionManifest, RuntimeWorkerCount, RuntimeWordPressAssetSpec, RuntimeWordPressDatabaseSetupContract, RuntimeWordPressEnvironmentSpec, RuntimeWordPressInstallModeContract, RuntimeWordPressProvenance } from "./runtime-neutral-contracts.js" import type { RUNTIME_EPISODE_ACTION_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, @@ -20,6 +20,7 @@ export interface EnvironmentSpec extends RuntimeWordPressEnvironmentSpec {} export type RuntimeWordPressInstallMode = RuntimeWordPressInstallModeContract export type RuntimeWordPressDatabaseSetup = RuntimeWordPressDatabaseSetupContract +export type { RuntimeWorkerCount } export interface RuntimeAssetSpec extends RuntimeWordPressAssetSpec {} @@ -647,6 +648,7 @@ export interface WorkspaceRecipe { name?: string wp?: string phpVersion?: string + workers?: RuntimeWorkerCount wordpressInstallMode?: RuntimeWordPressInstallMode blueprint?: unknown preview?: RuntimePreviewSpec diff --git a/packages/runtime-core/src/runtime-neutral-contracts.ts b/packages/runtime-core/src/runtime-neutral-contracts.ts index a0cccbcce..407ae23db 100644 --- a/packages/runtime-core/src/runtime-neutral-contracts.ts +++ b/packages/runtime-core/src/runtime-neutral-contracts.ts @@ -47,6 +47,7 @@ export interface BackendNeutralEnvironmentSpec { export type RuntimeWordPressInstallModeContract = "install-from-existing-files" | "install-from-existing-files-if-needed" | "do-not-attempt-installing" export type RuntimeWordPressDatabaseSetupContract = "runtime-managed" | "external" +export type RuntimeWorkerCount = number | "auto" export interface RuntimeWordPressAssetSpec extends BackendNeutralRuntimeAssetSpec { wordpressDirectory?: string @@ -61,6 +62,7 @@ export interface RuntimePHPWasmExtensionManifest { export interface RuntimeWordPressEnvironmentSpec extends BackendNeutralEnvironmentSpec { blueprint?: unknown phpVersion?: string + workers?: RuntimeWorkerCount assets?: RuntimeWordPressAssetSpec wordpressInstallMode?: RuntimeWordPressInstallModeContract databaseSetup?: RuntimeWordPressDatabaseSetupContract @@ -115,6 +117,7 @@ export function normalizeRuntimeWordPressEnvironmentSpec(input: unknown): Runtim ...neutral, blueprint: value.blueprint, phpVersion: optionalString(value.phpVersion, "environment.phpVersion"), + workers: normalizeRuntimeWorkerCount(value.workers), assets: normalizeRuntimeWordPressAssetSpec(value.assets), wordpressInstallMode: optionalString(value.wordpressInstallMode, "environment.wordpressInstallMode") as RuntimeWordPressInstallModeContract | undefined, databaseSetup: optionalString(value.databaseSetup, "environment.databaseSetup") as RuntimeWordPressDatabaseSetupContract | undefined, @@ -122,6 +125,15 @@ export function normalizeRuntimeWordPressEnvironmentSpec(input: unknown): Runtim }) } +function normalizeRuntimeWorkerCount(value: unknown): RuntimeWorkerCount | undefined { + if (value === undefined) return undefined + if (value === "auto") return value + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 64) { + throw new Error("environment.workers must be an integer from 1 to 64 or auto.") + } + return value +} + function normalizeRuntimePHPWasmExtensionManifests(input: unknown): RuntimePHPWasmExtensionManifest[] | undefined { if (input === undefined) return undefined if (!Array.isArray(input)) throw new Error("environment.extensions must be an array.") diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 77591566c..6c643f20d 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -150,7 +150,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: quiet: true, verbosity: "quiet", skipBrowser: true, - workers: 6, + workers: spec.environment.workers ?? 6, mount: [ ...postinstallMounts.map((mount) => ({ hostPath: mount.source, diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 1a438d43f..625b063f2 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -33,6 +33,7 @@ try { environment: { version: "mounted-wordpress-source", phpVersion: "8.4", + workers: 1, wordpressInstallMode: "do-not-attempt-installing", databaseSetup: "external", assets: { wordpressDirectory: wordpressDevelopDirectory }, @@ -84,7 +85,7 @@ try { assert.equal(calls[0]["mount-before-install"]?.[4]?.vfsPath, "/wordpress/wp-config.php") assert.deepEqual(calls[0]["mount-before-install"]?.[5], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" }) assert.deepEqual(calls[0].mount, []) - assert.equal(calls[0].workers, 6) + assert.equal(calls[0].workers, 1) assert.equal(calls[0].wordpressInstallMode, "do-not-attempt-installing") assert.equal(calls[0].skipSqliteSetup, true) assert.equal(calls[0].phpEnv?.DB_PASSWORD, "secret") @@ -135,6 +136,7 @@ try { await defaultRuntimeIniServer[Symbol.asyncDispose]() assert.equal(calls.length, 1) + assert.equal(calls[0].workers, 1) assert.deepEqual(calls[0].phpIniEntries, { memory_limit: "512M" }) assert.equal(calls[0].skipSqliteSetup, false) assert.equal(shouldUseProgrammaticPlaygroundRunner(defaultRuntimeIniSpec), true) @@ -146,6 +148,7 @@ try { ...defaultRuntimeIniSpec.environment, version: "latest", wordpressInstallMode: undefined, + workers: undefined, assets: undefined, }, } @@ -158,6 +161,7 @@ try { assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/wp-codebox") assert.match(calls[0]["mount-before-install"]?.[1]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) assert.equal(calls[0].wordpressInstallMode, undefined) + assert.equal(calls[0].workers, 6) assert.equal(shouldUseProgrammaticPlaygroundRunner(downloadedWordPressSpec), false) calls.length = 0 diff --git a/tests/runtime-workers.test.ts b/tests/runtime-workers.test.ts new file mode 100644 index 000000000..b90095ba3 --- /dev/null +++ b/tests/runtime-workers.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import { readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" + +import { runRecipeBuildCommand } from "../packages/cli/src/commands/recipe-build.js" +import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.js" +import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.js" +import { normalizeRuntimeWordPressEnvironmentSpec, validateWorkspaceRecipeJsonSchema, type WorkspaceRecipe } from "../packages/runtime-core/src/index.js" +import { withTempDir } from "../scripts/test-kit.js" + +const recipe: WorkspaceRecipe = { + schema: "wp-codebox/workspace-recipe/v1", + runtime: { workers: 1 }, + workflow: { steps: [{ command: "wordpress.run-php" }] }, +} + +assert.equal(validateWorkspaceRecipeJsonSchema(recipe).valid, true) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...recipe, runtime: { workers: "auto" } }).valid, true) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...recipe, runtime: { workers: 0 } }).valid, false) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...recipe, runtime: { workers: 65 } }).valid, false) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...recipe, runtime: { workers: 1.5 } }).valid, false) +assert.deepEqual(normalizeRuntimeWordPressEnvironmentSpec({ kind: "wordpress", workers: 1 }), { kind: "wordpress", workers: 1 }) +assert.throws(() => normalizeRuntimeWordPressEnvironmentSpec({ kind: "wordpress", workers: 0 }), /workers must be an integer/) + +assert.equal(buildWordPressPhpunitRecipe({ pluginSlug: "example", workers: 1 }).runtime?.workers, 1) +assert.equal(buildWordPressPhpunitRecipe({ pluginSlug: "example", workers: "auto" }).runtime?.workers, "auto") +assert.equal(buildWordPressPhpunitRecipe({ pluginSlug: "example" }).runtime?.workers, undefined) + +const plan = await planWorkspaceRecipe(recipe, process.cwd(), { recipePath: "recipe.json" }, { + defaultWordPressVersion: "latest", + resolveExecutionSpec: async (step) => ({ command: step.command, args: step.args ?? [] }), +}) +assert.equal(plan.runtime.workers, 1) + +await withTempDir("wp-codebox-runtime-workers-", async (directory) => { + const optionsPath = join(directory, "options.json") + const recipePath = join(directory, "recipe.json") + await writeFile(optionsPath, JSON.stringify({ pluginSlug: "example", workers: 1 })) + assert.equal(await runRecipeBuildCommand(["phpunit", "--options", optionsPath, "--output", recipePath]), 0) + assert.equal((JSON.parse(await readFile(recipePath, "utf8")) as WorkspaceRecipe).runtime?.workers, 1) + + await writeFile(optionsPath, JSON.stringify({ pluginSlug: "example", workers: 0 })) + await assert.rejects(runRecipeBuildCommand(["phpunit", "--options", optionsPath, "--output", recipePath]), /workers must be an integer/) +}) + +console.log("runtime workers ok")