Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,21 @@ Recipes that import a generated site into a clean runtime can export replay evid
}
```

The step writes `files/replay-package/manifest.json`, `blueprint.after.json`, `blueprint.after-notes.json`, and `files/runtime-snapshot.json` under the runtime artifact root. Its stdout is a `wp-codebox/wordpress-replay-export/v1` envelope with `importMs`, `materializeMs`, `snapshotMs`, `exportMs`, `databaseTables`, `wpContentFiles`, `snapshotBytes`, and `blueprintBytes`. The exported `blueprint.after.json` keeps the runtime snapshot as a referenced package file instead of embedding the full snapshot as one large `runPHP` string.
The step writes `files/replay-package/manifest.json`, `blueprint.after.json`, `blueprint.zip`, `blueprint.after-notes.json`, and `files/runtime-snapshot.json` under the runtime artifact root. Its stdout is a `wp-codebox/wordpress-replay-export/v1` envelope with `importMs`, `materializeMs`, `snapshotMs`, `exportMs`, `databaseTables`, `wpContentFiles`, `snapshotBytes`, and `blueprintBytes`. The exported `blueprint.after.json` keeps the runtime snapshot as a referenced package file instead of embedding the full snapshot as one large `runPHP` string.

Existing `wp-codebox/wordpress-runtime-snapshot/v1` files can be turned into the same replay package without a live Playground runtime or the original recipe-run context:

```bash
wp-codebox materialize-replay-package \
--snapshot ./files/runtime-snapshot.json \
--snapshot-ref artifact:r15/files/runtime-snapshot.json \
--output ./replay-package \
--json
```

The materializer writes `blueprint.after.json`, `blueprint.zip`, `files/runtime-snapshot.json`, `blueprint.after-notes.json`, and `manifest.json`. The generated notes and manifest record source metadata, including the resolved input snapshot path, optional `--snapshot-ref`, and the `wp-codebox materialize-replay-package` command.

Replay packages use a bundled resource for `files/runtime-snapshot.json` so the blueprint and snapshot travel together as one local artifact directory. `blueprint.after.json` is the local/package validation artifact. Public WordPress Playground viewer links must use `blueprint.zip`, because Playground resolves bundled resources from a bundle archive with a root `blueprint.json` entry; a plain JSON URL cannot resolve `files/runtime-snapshot.json`. The manifest therefore records `replayableWordPressSite.publicViewerArtifactPath: "blueprint.zip"`. Use URL resources only when the snapshot is already hosted at a stable, browser-fetchable URL; this avoids shipping the snapshot beside the blueprint, but replay then depends on network access, CORS, URL lifetime, and the full snapshot download at restore time.

`metadata.json` points to the canonical changed-files, patch, test-results, review, and mount-diff artifact paths under `artifacts`. It also includes `provenance` derived from data WP Codebox already has: task input/context where available, WP Codebox runtime version, WordPress version, mounted component/mount metadata, and agent/provider/model fields passed to the sandbox runner. `files/diffs/<mount>.patch` remains available for per-mount detail; `files/patch.diff` is the combined review/apply-back patch surface.

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { runCommandsCommand, runRecipeSchemaCommand } from "./commands/discovery
import { runCleanupCommand, runDoctorCommand } from "./commands/doctor.js"
import { runRecipeBuildCommand } from "./commands/recipe-build.js"
import { runRecipeRunCommand, runRecipeValidateCommand } from "./commands/recipe-run.js"
import { runMaterializeReplayPackageCommand } from "./commands/replay-package.js"
import { runBootCommand, runRunCommand, runValidateBlueprintCommand } from "./commands/runtime.js"
import { runRunsArtifactsCommand, runRunsStatusCommand } from "./commands/runs.js"
import { runWorkspacePolicyCheckCommand } from "./commands/workspace-policy.js"
Expand All @@ -16,6 +17,7 @@ export async function runCli(args: string[]): Promise<number> {
printHelp,
boot: runBootCommand,
validateBlueprint: runValidateBlueprintCommand,
materializeReplayPackage: runMaterializeReplayPackageCommand,
recipeRun: runRecipeRunCommand,
agentTaskRun: runAgentTaskRunCommand,
recipeValidate: runRecipeValidateCommand,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/command-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type CliCommandHandler = (args: string[]) => Promise<number>
const cliCommandRoutes = {
boot: "boot",
"validate-blueprint": "validateBlueprint",
"materialize-replay-package": "materializeReplayPackage",
"recipe-run": "recipeRun",
"agent-task-run": "agentTaskRun",
recipe: {
Expand Down
129 changes: 129 additions & 0 deletions packages/cli/src/commands/replay-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { writeReplayExportPackage, type ReplayExportPackage, type RuntimeSnapshotArtifact } from "@automattic/wp-codebox-playground"
import { captureStdout } from "../output.js"

interface MaterializeReplayPackageOptions {
snapshotPath: string
outputDirectory: string
snapshotRef?: string
id?: string
createdAt?: string
landingPage?: string
json: boolean
}

export async function runMaterializeReplayPackageCommand(args: string[]): Promise<number> {
const options = parseMaterializeReplayPackageOptions(args)
const execute = () => materializeReplayPackage(options)

if (!options.json) {
const output = await execute()
printMaterializeReplayPackageHumanOutput(output)
return 0
}

const { result, logs } = await captureStdout(execute)
const output = logs.length > 0 ? { ...result, logs } : result
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
return 0
}

async function materializeReplayPackage(options: MaterializeReplayPackageOptions): Promise<ReplayExportPackage> {
const snapshotPath = resolve(options.snapshotPath)
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"))
if (!isRuntimeSnapshotArtifact(snapshot)) {
throw new Error(`Input is not a wp-codebox/wordpress-runtime-snapshot/v1 JSON file: ${options.snapshotPath}`)
}

const startedAtMs = Date.now()
return writeReplayExportPackage(snapshot, {
directory: resolve(options.outputDirectory),
id: options.id,
createdAt: options.createdAt,
landingPage: options.landingPage,
materializeMs: Date.now() - startedAtMs,
source: {
inputSnapshotPath: snapshotPath,
inputSnapshotRef: options.snapshotRef ?? options.snapshotPath,
materializerCommand: "wp-codebox materialize-replay-package",
},
})
}

function parseMaterializeReplayPackageOptions(args: string[]): MaterializeReplayPackageOptions {
const options: Partial<MaterializeReplayPackageOptions> = { json: false }

for (let index = 0; index < args.length; index++) {
const arg = args[index]

if (arg === "--json") {
options.json = true
continue
}

const [name, inlineValue] = arg.split("=", 2)
const value = inlineValue ?? args[++index]

if (!name.startsWith("--") || value === undefined) {
throw new Error(`Invalid argument: ${arg}`)
}

switch (name) {
case "--snapshot":
options.snapshotPath = value
break
case "--output":
options.outputDirectory = value
break
case "--snapshot-ref":
options.snapshotRef = value
break
case "--id":
options.id = value
break
case "--created-at":
options.createdAt = value
break
case "--landing-page":
options.landingPage = value
break
default:
throw new Error(`Unknown option: ${name}`)
}
}

if (!options.snapshotPath) {
throw new Error("Missing required option: --snapshot")
}

if (!options.outputDirectory) {
throw new Error("Missing required option: --output")
}

return options as MaterializeReplayPackageOptions
}

function printMaterializeReplayPackageHumanOutput(output: ReplayExportPackage): void {
console.log("WP Codebox replay package")
console.log(`Directory: ${output.directory}`)
console.log(`Blueprint: ${output.artifacts.blueprint}`)
console.log(`Snapshot: ${output.artifacts.snapshot}`)
console.log(`Notes: ${output.artifacts.notes}`)
console.log(`Manifest: ${output.artifacts.manifest}`)
}

function isRuntimeSnapshotArtifact(value: unknown): value is RuntimeSnapshotArtifact {
return isRecord(value)
&& value.schema === "wp-codebox/wordpress-runtime-snapshot/v1"
&& value.version === 1
&& isRecord(value.compatibility)
&& value.compatibility.backend === "wordpress-playground"
&& isRecord(value.database)
&& Array.isArray(value.database.tables)
&& Array.isArray(value.files)
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
7 changes: 5 additions & 2 deletions packages/cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,15 @@ export function printHelp(): void {
wp-codebox runs artifacts --registry <dir> --run-id <id> [--json]
wp-codebox agent-task-run --input-file <path> [--json] [--preview-hold-seconds <n>] [--preview-public-url <url>]
wp-codebox validate-blueprint --blueprint <json|file> [options]
wp-codebox materialize-replay-package --snapshot <path> --output <dir> [--snapshot-ref <ref>] [--json]
wp-codebox recipe-run --recipe <path> [options]
wp-codebox boot [--mount <host>:<vfs>] [options]
wp-codebox run --mount <host>:<vfs> --command <id> [options]

Options:
--recipe <path> Workspace recipe JSON file for recipe-run or recipe validate.
--options <path> Recipe builder options JSON file for recipe build.
--output <path> Optional output JSON path for recipe build; defaults to stdout.
--output <path> Recipe build output JSON path, or materialize-replay-package output directory.
--input-file <path> Agent task input JSON for agent-task-run.
--preview-hold-seconds <n>
Keep preview runtimes alive after agent-task-run/recipe-run.
Expand Down Expand Up @@ -334,7 +335,9 @@ Options:
--arg <key=value> Command argument. Repeatable. Recipe commands include wordpress.run-php, wordpress.phpunit, wordpress.core-phpunit, wordpress.plugin-check, wordpress.wp-cli, wordpress.ability, wordpress.bench, and wordpress.browser-probe.
--wp <version> WordPress version for Playground. Defaults to latest; accepts trunk, nightly, or numeric versions.
--blueprint <json|file>
WordPress Playground blueprint JSON or path for boot or validate-blueprint.
WordPress Playground blueprint JSON or path for boot or validate-blueprint.
--snapshot <path> Runtime snapshot JSON file for materialize-replay-package.
--snapshot-ref <ref> Optional external reference for the input snapshot source metadata.
--artifacts <dir> Artifact root directory.
--preview-hold-seconds <n>
Keep the live Playground preview available after a successful run. Accepts seconds or minutes, e.g. 30s or 15m; max 3600s.
Expand Down
26 changes: 25 additions & 1 deletion packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ class PlaygroundRuntime implements Runtime {
command: spec.command,
args: spec.args ?? [],
exitCode: 0,
stdout: await executePlaygroundCommand(this, spec, this.hostTools),
stdout: await timeoutPlaygroundCommand(executePlaygroundCommand(this, spec, this.hostTools), spec, abortController),
stderr: "",
startedAt,
finishedAt: now(),
Expand Down Expand Up @@ -1214,3 +1214,27 @@ function abortable<T>(operation: Promise<T>, signal: AbortSignal | undefined): P
}),
])
}

function timeoutPlaygroundCommand<T>(operation: Promise<T>, spec: ExecutionSpec, abortController: AbortController): Promise<T> {
const timeoutMs = spec.timeoutMs
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return operation
}

operation.catch(() => undefined)
let timeout: ReturnType<typeof setTimeout> | undefined
return Promise.race([
operation,
new Promise<T>((_resolve, reject) => {
timeout = setTimeout(() => {
abortController.abort()
reject(new Error(`Runtime command ${spec.command} exceeded timeoutMs=${Math.round(timeoutMs)}`))
}, Math.round(timeoutMs))
timeout.unref()
}),
]).finally(() => {
if (timeout) {
clearTimeout(timeout)
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface ReplayableWordPressSiteBundleManifest extends ArtifactManifest
version: 1
replayableWordPressSite: {
blueprintPath: string
playgroundBundlePath?: string
publicViewerArtifactPath?: string
snapshotPath: string
limitationsPath: string
replayStatus: "replayable-runtime-state"
Expand Down Expand Up @@ -64,6 +66,7 @@ export interface ReplayExportPackage {
artifacts: {
manifest: "manifest.json"
blueprint: "blueprint.after.json"
playgroundBundle: "blueprint.zip"
snapshot: "files/runtime-snapshot.json"
notes: "blueprint.after-notes.json"
}
Expand All @@ -78,6 +81,7 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact

const snapshotPath = join(filesDirectory, "runtime-snapshot.json")
const blueprintPath = join(directory, "blueprint.after.json")
const playgroundBundlePath = join(directory, "blueprint.zip")
const notesPath = join(directory, "blueprint.after-notes.json")
const manifestPath = join(directory, "manifest.json")
const blueprint = buildReplayExportBlueprint(snapshot, options)
Expand All @@ -93,8 +97,12 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact
await writeJson(blueprintPath, blueprint)
await writeJson(snapshotPath, snapshot)
await writeJson(notesPath, notes)
await writePlaygroundBlueprintBundle(playgroundBundlePath, [
{ path: "blueprint.json", data: `${JSON.stringify(blueprint, null, 2)}\n` },
{ path: "files/runtime-snapshot.json", data: `${JSON.stringify(snapshot, null, 2)}\n` },
])

const contentInputs = ["blueprint.after.json", "files/runtime-snapshot.json", "blueprint.after-notes.json"]
const contentInputs = ["blueprint.after.json", "blueprint.zip", "files/runtime-snapshot.json", "blueprint.after-notes.json"]
const contentDigest = await calculateArtifactContentDigest(directory, contentInputs)
const id = options.id ?? `replay-export-package-sha256-${contentDigest}`
const manifest: ReplayableWordPressSiteBundleManifest = {
Expand All @@ -111,11 +119,14 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact
files: [
artifactManifestFile("manifest.json", "manifest", "application/json"),
artifactManifestFile("blueprint.after.json", "blueprint-after", "application/json"),
artifactManifestFile("blueprint.zip", "playground-blueprint-bundle", "application/zip"),
artifactManifestFile("files/runtime-snapshot.json", "runtime-snapshot", "application/json"),
artifactManifestFile("blueprint.after-notes.json", "blueprint-after-notes", "application/json"),
],
replayableWordPressSite: {
blueprintPath: "blueprint.after.json",
playgroundBundlePath: "blueprint.zip",
publicViewerArtifactPath: "blueprint.zip",
snapshotPath: "files/runtime-snapshot.json",
limitationsPath: "blueprint.after-notes.json",
replayStatus: "replayable-runtime-state",
Expand Down Expand Up @@ -150,6 +161,7 @@ export async function writeReplayExportPackage(snapshot: RuntimeSnapshotArtifact
artifacts: {
manifest: "manifest.json",
blueprint: "blueprint.after.json",
playgroundBundle: "blueprint.zip",
snapshot: "files/runtime-snapshot.json",
notes: "blueprint.after-notes.json",
},
Expand Down Expand Up @@ -326,3 +338,80 @@ function playgroundBlueprintPhpVersion(version: string): string {
async function writeJson(path: string, value: unknown): Promise<void> {
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
}

async function writePlaygroundBlueprintBundle(path: string, entries: Array<{ path: string; data: string }>): Promise<void> {
const records: Buffer[] = []
const centralDirectoryRecords: Buffer[] = []
let offset = 0

for (const entry of entries) {
const fileName = Buffer.from(entry.path, "utf8")
const data = Buffer.from(entry.data, "utf8")
const crc = crc32(data)
const localHeader = Buffer.alloc(30)
localHeader.writeUInt32LE(0x04034b50, 0)
localHeader.writeUInt16LE(20, 4)
localHeader.writeUInt16LE(0, 6)
localHeader.writeUInt16LE(0, 8)
localHeader.writeUInt16LE(0, 10)
localHeader.writeUInt16LE(0, 12)
localHeader.writeUInt32LE(crc, 14)
localHeader.writeUInt32LE(data.length, 18)
localHeader.writeUInt32LE(data.length, 22)
localHeader.writeUInt16LE(fileName.length, 26)
localHeader.writeUInt16LE(0, 28)
records.push(localHeader, fileName, data)

const centralDirectoryRecord = Buffer.alloc(46)
centralDirectoryRecord.writeUInt32LE(0x02014b50, 0)
centralDirectoryRecord.writeUInt16LE(20, 4)
centralDirectoryRecord.writeUInt16LE(20, 6)
centralDirectoryRecord.writeUInt16LE(0, 8)
centralDirectoryRecord.writeUInt16LE(0, 10)
centralDirectoryRecord.writeUInt16LE(0, 12)
centralDirectoryRecord.writeUInt16LE(0, 14)
centralDirectoryRecord.writeUInt32LE(crc, 16)
centralDirectoryRecord.writeUInt32LE(data.length, 20)
centralDirectoryRecord.writeUInt32LE(data.length, 24)
centralDirectoryRecord.writeUInt16LE(fileName.length, 28)
centralDirectoryRecord.writeUInt16LE(0, 30)
centralDirectoryRecord.writeUInt16LE(0, 32)
centralDirectoryRecord.writeUInt16LE(0, 34)
centralDirectoryRecord.writeUInt16LE(0, 36)
centralDirectoryRecord.writeUInt32LE(0, 38)
centralDirectoryRecord.writeUInt32LE(offset, 42)
centralDirectoryRecords.push(centralDirectoryRecord, fileName)

offset += localHeader.length + fileName.length + data.length
}

const centralDirectoryOffset = offset
const centralDirectorySize = centralDirectoryRecords.reduce((size, record) => size + record.length, 0)
const endOfCentralDirectory = Buffer.alloc(22)
endOfCentralDirectory.writeUInt32LE(0x06054b50, 0)
endOfCentralDirectory.writeUInt16LE(0, 4)
endOfCentralDirectory.writeUInt16LE(0, 6)
endOfCentralDirectory.writeUInt16LE(entries.length, 8)
endOfCentralDirectory.writeUInt16LE(entries.length, 10)
endOfCentralDirectory.writeUInt32LE(centralDirectorySize, 12)
endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16)
endOfCentralDirectory.writeUInt16LE(0, 20)

await writeFile(path, Buffer.concat([...records, ...centralDirectoryRecords, endOfCentralDirectory]))
}

function crc32(data: Buffer): number {
let crc = 0xffffffff
for (const byte of data) {
crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xff]
}
return (crc ^ 0xffffffff) >>> 0
}

const crc32Table = Array.from({ length: 256 }, (_, index) => {
let value = index
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1)
}
return value >>> 0
})
Loading