Skip to content

Commit 0da28ff

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/2081-site-lifecycle
# Conflicts: # packages/runtime-cloudflare/src/d1-operation-repository.ts # packages/runtime-cloudflare/src/provisioning-api.ts # packages/runtime-cloudflare/src/worker.ts # tests/cloudflare-provisioning-api.test.ts
2 parents 34b6325 + 8b1ffb4 commit 0da28ff

21 files changed

Lines changed: 738 additions & 169 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"test:browser-runner-template": "tsx tests/browser-runner-template.test.ts",
161161
"test:browser-observation-assertions": "tsx tests/browser-observation-assertions.test.ts",
162162
"test:editor-actions": "tsx tests/editor-actions.test.ts",
163+
"test:editor-actions-save-integration": "node --import tsx --test tests/editor-actions-save.integration.test.ts",
163164
"test:editor-validate-blocks": "tsx tests/editor-validate-blocks.test.ts",
164165
"test:artifact-result-envelope": "tsx tests/artifact-result-envelope.test.ts",
165166
"test:async-agent-task-contracts": "tsx tests/async-agent-task-contracts.test.ts",

packages/cli/src/commands/recipe-run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
296296
})
297297
interruption?.throwIfInterrupted()
298298

299-
executions.push(...(await applyRecipeRuntimeSetup({ recipe, recipeDirectory, runtime, prepared: preparedRuntimeSetup, phaseExecutor, interruption })).executions)
299+
executions.push(...(await applyRecipeRuntimeSetup({ recipe, recipeDirectory, runtime, runtimeSpec: runtimeCreateSpec, prepared: preparedRuntimeSetup, phaseExecutor, interruption })).executions)
300300

301301
fixtureDatabases = await phaseTracker.run("import_fixture_databases", phaseFixtureDatabaseData(recipe), async () => await awaitRecipe("fixture-databases.import", importRecipeFixtureDatabases(recipe, recipeDirectory, runtime!, executions)))
302302
const siteSeeds = await awaitRecipe("site-seeds.import", importRecipeSiteSeeds(recipe, recipeDirectory, runtime!, executions))

packages/cli/src/commands/recipe-runtime-setup.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { cp, mkdtemp, rm, stat } from "node:fs/promises"
22
import { tmpdir } from "node:os"
33
import { join, posix, resolve } from "node:path"
4-
import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core"
4+
import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core"
5+
import { requiresManagedMysqlMultisitePreinstall } from "@automattic/wp-codebox-playground"
56
import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
67
import { pluginRuntimeHealthProbeStep, type RecipeWorkflowPhase } from "../recipe-validation.js"
78
import { pluginRuntimeHealthProbeStepIndex, pluginRuntimeSetupStepIndex } from "../recipe-dry-run.js"
@@ -52,9 +53,10 @@ export async function applyRecipeRuntimeSetup(args: {
5253
runtime: Runtime
5354
prepared: PreparedRecipeRuntimeSetup
5455
phaseExecutor: RecipeRunPhaseExecutor
56+
runtimeSpec: Pick<RuntimeCreateSpec, "environment" | "runtimeEnv">
5557
interruption?: RecipeInterruptionController
5658
}): Promise<RecipeRuntimeSetupResult> {
57-
const { recipe, recipeDirectory, runtime, prepared, phaseExecutor, interruption } = args
59+
const { recipe, recipeDirectory, runtime, runtimeSpec, prepared, phaseExecutor, interruption } = args
5860
const { workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap } = prepared
5961
const executions: RecipeExecutionResult[] = []
6062
const phaseTracker = phaseExecutor.tracker
@@ -127,19 +129,20 @@ export async function applyRecipeRuntimeSetup(args: {
127129
interruption?.throwIfInterrupted()
128130
}
129131

132+
const extraPluginMounts: MountSpec[] = extraPlugins.map((plugin) => ({
133+
type: "directory",
134+
source: plugin.source,
135+
target: plugin.target,
136+
mode: "readonly",
137+
metadata: {
138+
kind: "extra-plugin",
139+
slug: plugin.slug,
140+
source: plugin.provenance,
141+
},
142+
}))
130143
await phaseTracker.run("mount_plugins", phasePluginMountData(extraPlugins), async () => {
131-
for (const plugin of extraPlugins) {
132-
await awaitRecipe(`extra-plugin.mount:${plugin.slug}`, runtime.mount({
133-
type: "directory",
134-
source: plugin.source,
135-
target: plugin.target,
136-
mode: "readonly",
137-
metadata: {
138-
kind: "extra-plugin",
139-
slug: plugin.slug,
140-
source: plugin.provenance,
141-
},
142-
}))
144+
for (const [index, plugin] of extraPlugins.entries()) {
145+
await awaitRecipe(`extra-plugin.mount:${plugin.slug}`, runtime.mount(extraPluginMounts[index]))
143146
interruption?.throwIfInterrupted()
144147
}
145148
})
@@ -190,6 +193,7 @@ export async function applyRecipeRuntimeSetup(args: {
190193
}
191194

192195
const materializableMounts: MountSpec[] = [
196+
...extraPluginMounts,
193197
...inputMounts,
194198
...stagedFiles.map((stagedFile) => ({
195199
type: stagedFile.type,
@@ -205,17 +209,19 @@ export async function applyRecipeRuntimeSetup(args: {
205209
interruption?.throwIfInterrupted()
206210
}
207211

208-
const muPluginInstallCode = installMuPluginsCode(extraPlugins)
212+
const isolateManagedMultisitePreinstall = recipeHasManagedMysqlMultisitePhpunit(recipe, runtimeSpec)
213+
const muPluginInstallCode = isolateManagedMultisitePreinstall ? null : installMuPluginsCode(extraPlugins)
209214
if (muPluginInstallCode) {
210215
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(muPluginInstallCode) }), "setup", -2, "extra-plugin.install-mu-loader"))
211216
}
212217

213-
const composerAutoloaderInstallCode = installPluginComposerAutoloadersCode(extraPlugins)
218+
const composerAutoloaderInstallCode = isolateManagedMultisitePreinstall ? null : installPluginComposerAutoloadersCode(extraPlugins)
214219
if (composerAutoloaderInstallCode) {
215220
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(composerAutoloaderInstallCode) }), "setup", -2, "extra-plugin.install-composer-autoloaders"))
216221
}
217222

218-
const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false)
223+
const deferredPluginFiles = managedPhpunitDeferredPluginFiles(recipe, runtimeSpec)
224+
const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false && !deferredPluginFiles.has(plugin.pluginFile))
219225
if (activatedPlugins.length > 0) {
220226
const activePluginsAfterActivation = await phaseTracker.run("activate_plugins", phasePluginActivationData(activatedPlugins), async () => {
221227
for (const plugin of activatedPlugins) {
@@ -243,6 +249,28 @@ export async function applyRecipeRuntimeSetup(args: {
243249
return { executions }
244250
}
245251

252+
function managedPhpunitDeferredPluginFiles(recipe: WorkspaceRecipe, runtimeSpec: Pick<RuntimeCreateSpec, "environment" | "runtimeEnv">): Set<string> {
253+
const deferred = new Set<string>()
254+
for (const step of [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])]) {
255+
if (step.command !== "wordpress.phpunit" || !requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec)) continue
256+
const raw = (step.args ?? []).find((arg) => arg.startsWith("dependency-plugins-json="))?.slice("dependency-plugins-json=".length)
257+
if (!raw) continue
258+
const plugins = JSON.parse(raw) as unknown
259+
if (!Array.isArray(plugins)) throw new Error("dependency-plugins-json must be a JSON array")
260+
for (const plugin of plugins) {
261+
if (plugin && typeof plugin === "object" && !Array.isArray(plugin) && typeof (plugin as { pluginFile?: unknown }).pluginFile === "string") {
262+
deferred.add((plugin as { pluginFile: string }).pluginFile)
263+
}
264+
}
265+
}
266+
return deferred
267+
}
268+
269+
function recipeHasManagedMysqlMultisitePhpunit(recipe: WorkspaceRecipe, runtimeSpec: Pick<RuntimeCreateSpec, "environment" | "runtimeEnv">): boolean {
270+
return [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])]
271+
.some((step) => step.command === "wordpress.phpunit" && requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec))
272+
}
273+
246274
export async function cleanupInputMountBaselines(paths: string[]): Promise<void> {
247275
await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })))
248276
paths.length = 0

packages/runtime-cloudflare/src/d1-operation-repository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,8 @@ async function ensureSchema(database: D1Database): Promise<void> {
199199
await database.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_site_lifecycles (site_id TEXT NOT NULL, generation INTEGER NOT NULL, principal TEXT NOT NULL, state TEXT NOT NULL, expires_at INTEGER NOT NULL, retain_until INTEGER NOT NULL, mutation_fence INTEGER NOT NULL, operation_fence INTEGER NOT NULL, cleanup_cursor TEXT, deleted_objects INTEGER NOT NULL, receipt_json TEXT, terminal_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (site_id, generation))").run()
200200
await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_site_hostname ON wp_codebox_sites(hostname)`).run()
201201
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_site_aliases (hostname TEXT PRIMARY KEY, site_id TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run()
202-
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, allocation_fence INTEGER, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run()
203-
try { await database.prepare("ALTER TABLE wp_codebox_operations ADD COLUMN allocation_fence INTEGER").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error }
202+
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operations (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL, artifact_key TEXT NOT NULL, artifact_sha256 TEXT NOT NULL, artifact_size INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, site_title TEXT NOT NULL, allocation_fence INTEGER, state TEXT NOT NULL CHECK (state IN ('queued','running','retryable','publication-pending','succeeded','failed')), stage TEXT NOT NULL, progress INTEGER NOT NULL CHECK (progress BETWEEN 0 AND 100), attempts INTEGER NOT NULL, retry_at INTEGER, claim_token TEXT, claim_expires_at INTEGER, prepared_version INTEGER, prepared_revision TEXT, prepared_manifest_key TEXT, prepared_persisted_at TEXT, prepared_result_json TEXT, prepared_publication_job TEXT, receipt_json TEXT, error_code TEXT, error_message TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, completed_at TEXT, PRIMARY KEY (site_id, operation_id), UNIQUE (site_id, idempotency_key), FOREIGN KEY (site_id) REFERENCES wp_codebox_sites(site_id))`).run()
203+
try { await database.prepare("ALTER TABLE wp_codebox_operations ADD COLUMN allocation_fence INTEGER").run() } catch (error) { if (!(error instanceof Error) || !/duplicate column/i.test(error.message)) throw error }
204204
await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_operation_attempts (site_id TEXT NOT NULL, operation_id TEXT NOT NULL, attempt_number INTEGER NOT NULL, claim_token TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, state TEXT NOT NULL, stage TEXT NOT NULL, error_code TEXT, error_message TEXT, PRIMARY KEY (site_id, operation_id, attempt_number), FOREIGN KEY (site_id, operation_id) REFERENCES wp_codebox_operations(site_id, operation_id))`).run()
205205
await database.prepare(`CREATE UNIQUE INDEX IF NOT EXISTS wp_codebox_one_active_operation ON wp_codebox_operations(site_id) WHERE state IN ('queued','running','retryable')`).run()
206206
await database.prepare(`CREATE INDEX IF NOT EXISTS wp_codebox_operation_ready ON wp_codebox_operations(site_id, state, retry_at, claim_expires_at, created_at)`).run()

packages/runtime-core/src/recipe-builders.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { buildRuntimePackageRunRecipe, CODEBOX_RUN_RUNTIME_PACKAGE_ABILITY, RUNT
44
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"
55
import { normalizeSharedMounts } from "./mount-primitives.js"
66
import { DEFAULT_WORDPRESS_VERSION } from "./runtime-defaults.js"
7+
import { resolvePluginEntrypointContract } from "./component-contracts.js"
78

89
type JsonObject = Record<string, unknown>
910

@@ -76,6 +77,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
7677
const pluginTarget = `/wordpress/wp-content/plugins/${pluginSlug}`
7778
const autoloadFile = options.autoloadFile ?? (options.bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php")
7879
const services = phpunitRuntimeServices(options.databaseType, options.services)
80+
const extraPlugins = normalizeExtraPlugins(options.extra_plugins)
7981

8082
return {
8183
schema: "wp-codebox/workspace-recipe/v1",
@@ -90,7 +92,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
9092
...(options.backendPackage ? { backendPackage: options.backendPackage } : {}),
9193
},
9294
inputs: {
93-
extra_plugins: normalizeExtraPlugins(options.extra_plugins),
95+
extra_plugins: extraPlugins,
9496
...(services.length > 0 ? { services } : {}),
9597
mounts: normalizeRecipeMounts([
9698
...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []),
@@ -116,6 +118,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
116118
commandArg("phpunit-xml", options.phpunitXml ?? `${pluginTarget}/phpunit.xml.dist`),
117119
commandArg("phpunit-xml-default", options.phpunitXml === undefined ? "1" : ""),
118120
commandStringListArg("dependency-mounts", options.dependencyMounts ?? []),
121+
commandJsonArg("dependency-plugins-json", phpunitDependencyPlugins(options.dependencyMounts ?? [], extraPlugins)),
119122
commandJsonArg("bootstrap-files-json", options.bootstrapFiles ?? []),
120123
commandJsonArg("preload-files-json", options.preloadFiles ?? []),
121124
commandJsonArg("phpunit-args-json", options.phpunitArgs ?? []),
@@ -129,6 +132,24 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
129132
}
130133
}
131134

135+
function phpunitDependencyPlugins(mounts: readonly string[], plugins: readonly WorkspaceRecipeExtraPlugin[]): Array<{ path: string; pluginFile: string; activate: boolean; loadAs: "plugin" | "mu-plugin" }> {
136+
return mounts.map((path) => {
137+
const normalized = path.replace(/\/+$/g, "")
138+
const slug = normalized.slice(normalized.lastIndexOf("/") + 1)
139+
const plugin = plugins.find((candidate) => candidate.slug === slug || candidate.mountSlug === slug)
140+
const loadAs = plugin?.loadAs === "mu-plugin" ? "mu-plugin" : "plugin"
141+
const pluginFile = plugin
142+
? resolvePluginEntrypointContract({ source: plugin.source ?? plugin.sourcePath ?? "", slug, pluginFile: plugin.pluginFile, loadAs }).pluginFile
143+
: `${slug}/${slug}.php`
144+
return {
145+
path: loadAs === "mu-plugin" ? `/wordpress/wp-content/mu-plugins/contained-runtime/${slug}` : path,
146+
pluginFile,
147+
activate: plugin?.activate !== false,
148+
loadAs,
149+
}
150+
})
151+
}
152+
132153
function phpunitRuntimeServices(databaseType: WordPressPhpunitRecipeOptions["databaseType"], services: WorkspaceRecipeRuntimeService[] = []): WorkspaceRecipeRuntimeService[] {
133154
if (databaseType !== undefined && databaseType !== "sqlite" && databaseType !== "mysql") {
134155
throw new Error(`Unsupported PHPUnit database type: ${databaseType}`)

packages/runtime-playground/src/browser-visual-compare.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,10 @@ function stringFileRef(value: unknown): string | undefined {
414414
return undefined
415415
}
416416

417+
export function visualCompareWordPressUrl(server: PlaygroundCliServer): string {
418+
return server.wordpressUrl ?? server.serverUrl
419+
}
420+
417421
export async function runVisualCompareCommand({
418422
artifactRoot,
419423
runtimeSpec,
@@ -496,7 +500,7 @@ async function runVisualComparePairCommand({
496500
const candidatePath = artifactSession.absolutePath("candidate.png")
497501
const diffPath = artifactSession.absolutePath("diff.png")
498502
const startedAt = now()
499-
const preview = browserPreviewRouting(args, runtimeSpec, server.serverUrl)
503+
const preview = browserPreviewRouting(args, runtimeSpec, visualCompareWordPressUrl(server))
500504
const sourceTargetUrl = sourceUrl ? resolveBrowserPreviewUrl(sourceUrl, preview.effectiveOrigin) : undefined
501505
const candidateTargetUrl = candidateUrl ? resolveBrowserPreviewUrl(candidateUrl, preview.effectiveOrigin) : undefined
502506
let finalSourceUrl = sourceTargetUrl
@@ -844,7 +848,7 @@ function visualCompareMatrixArtifact(
844848
artifactType: "visual-compare",
845849
requestedUrl: expectedEntries.map((entry) => entry.name).join(","),
846850
url: firstArtifact?.url ?? "visual-compare-matrix",
847-
preview: firstArtifact?.preview ?? browserPreviewRouting(args, runtimeSpec, server.serverUrl),
851+
preview: firstArtifact?.preview ?? browserPreviewRouting(args, runtimeSpec, visualCompareWordPressUrl(server)),
848852
files: {
849853
summary: matrixSummary.files.summary,
850854
...(matrixSummary.files.blocksEngineVisualParity ? { blocksEngineVisualParity: matrixSummary.files.blocksEngineVisualParity } : {}),
@@ -1170,7 +1174,7 @@ async function writeVisualCompareMatrixSummary(
11701174
blocksEngineVisualParity: "files/browser/visual-compare/blocks-engine-visual-parity-report.json",
11711175
},
11721176
...(!matrixComplete ? {
1173-
preview: entries[0]?.artifact.preview ?? browserPreviewRouting(args, runtimeSpec, server.serverUrl),
1177+
preview: entries[0]?.artifact.preview ?? browserPreviewRouting(args, runtimeSpec, visualCompareWordPressUrl(server)),
11741178
limitations: ["visual compare matrix was interrupted or an expected input was missing before all comparisons completed; recovered comparisons contain complete per-entry evidence for finished viewports and structured diagnostics for incomplete entries"],
11751179
} : {}),
11761180
}

0 commit comments

Comments
 (0)