Skip to content

Commit 0858c4d

Browse files
committed
Add replay package export step
1 parent 2a70c0f commit 0858c4d

7 files changed

Lines changed: 298 additions & 5 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,17 @@ Current bundles include:
10811081
- `files/diffs/<mount>.patch`: unified text diff from the mount baseline (a seeded baseline directory, or the git `HEAD` of a git work-tree mount) to the sandbox output.
10821082
- `files/mounts/<index>/...`: copied file contents from readwrite mounts.
10831083

1084+
Recipes that import a generated site into a clean runtime can export replay evidence before final artifact collection with a workflow step:
1085+
1086+
```json
1087+
{
1088+
"command": "wordpress.export-replay-package",
1089+
"args": ["label=studio-web-replay", "landing-page=/"]
1090+
}
1091+
```
1092+
1093+
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.
1094+
10841095
`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.
10851096

10861097
### `files/test-results.json`

packages/runtime-core/src/command-registry.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ export const commandRegistry = [
6363
recipe: true,
6464
handler: { kind: "playground", method: "runCaptureStateBundle" },
6565
},
66+
{
67+
id: "wordpress.export-replay-package",
68+
description: "Export the current imported WordPress runtime as a replay package with a compact blueprint, external runtime snapshot, notes, manifest, and metrics.",
69+
acceptedArgs: [
70+
{ name: "label", description: "Optional human-readable export label recorded in the command output and package source metadata.", format: "string" },
71+
{ name: "output-dir", description: "Optional package directory relative to the runtime artifact root; defaults to files/replay-package.", format: "relative path" },
72+
{ name: "landing-page", description: "Optional replay landing page recorded in blueprint.after.json.", format: "path" },
73+
{ name: "import-ms", description: "Optional importer duration supplied by the caller so replay export metrics can include the preceding import phase.", format: "non-negative integer" },
74+
],
75+
outputShape: "wp-codebox/wordpress-replay-export/v1 JSON with import/materialization/snapshot/export metrics and manifest, blueprint.after.json, blueprint.after-notes.json, and files/runtime-snapshot.json artifact paths.",
76+
policyRequirement: "Runtime policy commands must include wordpress.export-replay-package.",
77+
recipe: true,
78+
handler: { kind: "playground", method: "runExportReplayPackage" },
79+
},
6680
{
6781
id: "wordpress.ability",
6882
description: "Execute a registered WordPress Ability in the sandbox.",

packages/runtime-playground/src/artifact-bundle-builder.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export class ArtifactBundleBuilder {
142142
.filter((ref): ref is typeof ref & { path: string } => typeof ref.path === "string" && ref.path.length > 0)
143143
.map((ref) => artifactManifestFile(join(source.artifactRoot, ref.path), "runtime-snapshot", "application/json")),
144144
)
145+
const replayExportPackageFiles = replayExportPackageManifestFiles(source.artifactRoot, source.commands)
145146
const capturedMounts = await source.captureMountedFiles(filesDirectory, redactor)
146147
const { mountDiffs, changedFiles, patch, diagnostics: mountDiffDiagnostics } = await source.captureMountDiffs(filesDirectory, redactor)
147148
const changedFilesJson = redactor.redact("files/changed-files.json", `${JSON.stringify(changedFiles, null, 2)}\n`)
@@ -323,6 +324,7 @@ export class ArtifactBundleBuilder {
323324
...source.pluginCheckManifestFiles(),
324325
...source.themeCheckManifestFiles(),
325326
...runtimeSnapshotFiles,
327+
...replayExportPackageFiles,
326328
...mountDiffs.map((diff) => artifactManifestFile(join(source.artifactRoot, diff.artifactPath), "diff", "text/x-diff")),
327329
...capturedMounts.files.map((file) =>
328330
artifactManifestFile(join(source.artifactRoot, file.artifactPath), "file", file.contentType),
@@ -1087,6 +1089,50 @@ function artifactPreviewContentType(path: string): string {
10871089
return "application/octet-stream"
10881090
}
10891091

1092+
function replayExportPackageManifestFiles(artifactRoot: string, commands: ExecutionResult[]): ArtifactManifestFile[] {
1093+
return commands.flatMap((command) => {
1094+
if (command.command !== "wordpress.export-replay-package" || command.exitCode !== 0) {
1095+
return []
1096+
}
1097+
1098+
let output: unknown
1099+
try {
1100+
output = JSON.parse(command.stdout.trim() || "{}")
1101+
} catch {
1102+
return []
1103+
}
1104+
1105+
const envelope = asRecord(output)
1106+
const artifacts = asRecord(envelope?.artifacts)
1107+
const directory = stringValue(envelope?.directory)
1108+
if (envelope?.schema !== "wp-codebox/wordpress-replay-export/v1" || !directory || !artifacts) {
1109+
return []
1110+
}
1111+
1112+
const refs: Array<{ key: string; kind: string; contentType: string }> = [
1113+
{ key: "manifest", kind: "replay-package-manifest", contentType: "application/json" },
1114+
{ key: "blueprint", kind: "blueprint-after", contentType: "application/json" },
1115+
{ key: "snapshot", kind: "runtime-snapshot", contentType: "application/json" },
1116+
{ key: "notes", kind: "blueprint-after-notes", contentType: "application/json" },
1117+
]
1118+
1119+
return refs.flatMap((ref) => {
1120+
const artifactPath = stringValue(artifacts[ref.key])
1121+
if (!artifactPath) {
1122+
return []
1123+
}
1124+
1125+
const absolutePath = join(directory, artifactPath)
1126+
const manifestPath = relative(artifactRoot, absolutePath).replace(/\\/g, "/")
1127+
if (manifestPath.startsWith("..") || manifestPath.startsWith("/")) {
1128+
return []
1129+
}
1130+
1131+
return [artifactManifestFile(join(artifactRoot, manifestPath), ref.kind, ref.contentType)]
1132+
})
1133+
})
1134+
}
1135+
10901136
function asRecord(value: unknown): Record<string, unknown> | undefined {
10911137
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined
10921138
}

packages/runtime-playground/src/command-router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface PlaygroundCommandRuntime {
88
runPhp(spec: ExecutionSpec): Promise<string>
99
runWpCli(spec: ExecutionSpec): Promise<string>
1010
runCaptureStateBundle(spec: ExecutionSpec): Promise<string>
11+
runExportReplayPackage(spec: ExecutionSpec): Promise<string>
1112
runRestRequest(spec: ExecutionSpec): Promise<string>
1213
runAbility(spec: ExecutionSpec): Promise<string>
1314
runBench(spec: ExecutionSpec): Promise<string>
@@ -30,6 +31,7 @@ const playgroundCommandHandlers = {
3031
"wordpress.run-php": (runtime, spec) => runtime.runPhp(spec),
3132
"wordpress.wp-cli": (runtime, spec) => runtime.runWpCli(spec),
3233
"wordpress.capture-state-bundle": (runtime, spec) => runtime.runCaptureStateBundle(spec),
34+
"wordpress.export-replay-package": (runtime, spec) => runtime.runExportReplayPackage(spec),
3335
"wordpress.rest-request": (runtime, spec) => runtime.runRestRequest(spec),
3436
"wordpress.ability": (runtime, spec) => runtime.runAbility(spec),
3537
"wordpress.bench": (runtime, spec) => runtime.runBench(spec),

packages/runtime-playground/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ export { browserArtifactMetrics, type BrowserArtifactMetricsResult } from "./bro
44
export { createHostCommandTool, type HostCommandToolConfig } from "./host-command-tool.js"
55
export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js"
66
export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js"
7-
export { buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js"
7+
export { buildReplayExportBlueprint, buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayExportPackage, writeReplayableWordPressSiteBundle, type ReplayExportPackage, type ReplayExportPackageOptions, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js"
88
export type { RuntimeSnapshotArtifact } from "./runtime-snapshot.js"
99
export type { PlaygroundCliModule } from "./playground-cli-runner.js"

packages/runtime-playground/src/playground-runtime.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { materializePlaygroundMountsFromVfs } from "./mount-materialization.js"
1818
import { runAbilityCommand, runBenchCommand, runCorePhpunitCommand, runPhpCommand, runPhpunitCommand, runPluginCheckCommand, runRestRequestCommand, runThemeCheckCommand } from "./wordpress-command-runners.js"
1919
import { PlaygroundSnapshotRestoreError, contentDigest, mountsFromSnapshot, runtimeSnapshotExportPayload, runtimeSnapshotExportPhp, runtimeSnapshotPayload, runtimeSnapshotRestorePhp, runtimeSpecFromSnapshot, snapshotDigest, type RuntimeSnapshotArtifact } from "./runtime-snapshot.js"
2020
import { createRuntimeWpCliBridge, type RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js"
21+
import { writeReplayExportPackage } from "./replayable-wordpress-site-bundle.js"
2122
import { preflightPhpWasmRuntimeAssets } from "./php-wasm-preflight.js"
2223
import { previewReviewerAccess } from "./preview-reviewer-access.js"
2324
import type {
@@ -823,6 +824,67 @@ class PlaygroundRuntime implements Runtime {
823824
}, null, 2)}\n`
824825
}
825826

827+
async runExportReplayPackage(spec: ExecutionSpec): Promise<string> {
828+
const label = stringArg(spec.args ?? [], "label")
829+
const landingPage = stringArg(spec.args ?? [], "landing-page")
830+
const outputDirectory = replayExportOutputDirectory(this.artifactRoot, stringArg(spec.args ?? [], "output-dir"))
831+
const importMs = nonNegativeIntegerStringArg(spec.args ?? [], "import-ms") ?? 0
832+
833+
const materializeStartedAtMs = Date.now()
834+
let materialization: Awaited<ReturnType<typeof materializePlaygroundMountsFromVfs>> | undefined
835+
if (this.status !== "destroyed" && this.cliServerPromise) {
836+
materialization = await materializePlaygroundMountsFromVfs(await this.cliServerPromise, this.mounts)
837+
if (materialization.materialized > 0 || materialization.deleted > 0 || materialization.skipped > 0) {
838+
this.recordEvent("runtime.mounts.materialized", { ...materialization, source: "wordpress.export-replay-package" })
839+
}
840+
}
841+
const materializeMs = Date.now() - materializeStartedAtMs
842+
843+
const snapshotStartedAtMs = Date.now()
844+
const snapshot = await this.snapshot()
845+
const snapshotMs = Date.now() - snapshotStartedAtMs
846+
const payload = await runtimeSnapshotPayload(snapshot)
847+
848+
const exportStartedAtMs = Date.now()
849+
const replayPackage = await writeReplayExportPackage(payload, {
850+
directory: outputDirectory,
851+
landingPage,
852+
importMs,
853+
materializeMs,
854+
snapshotMs,
855+
source: {
856+
...(label ? { label } : {}),
857+
command: "wordpress.export-replay-package",
858+
runtimeId: this.runtimeId,
859+
snapshotId: snapshot.id,
860+
artifactRoot: this.artifactRoot,
861+
...(materialization ? { materialization } : {}),
862+
},
863+
})
864+
replayPackage.metrics.exportMs = Date.now() - exportStartedAtMs
865+
866+
this.recordEvent("runtime.replay-package.exported", {
867+
directory: replayPackage.directory,
868+
metrics: replayPackage.metrics,
869+
artifacts: replayPackage.artifacts,
870+
})
871+
872+
return `${JSON.stringify({
873+
schema: "wp-codebox/wordpress-replay-export/v1",
874+
status: replayPackage.status,
875+
...(label ? { label } : {}),
876+
replayStatus: "replayable-runtime-state",
877+
directory: replayPackage.directory,
878+
metrics: replayPackage.metrics,
879+
artifacts: replayPackage.artifacts,
880+
manifest: {
881+
id: replayPackage.manifest.id,
882+
contentDigest: replayPackage.manifest.contentDigest,
883+
createdAt: replayPackage.manifest.createdAt,
884+
},
885+
}, null, 2)}\n`
886+
}
887+
826888
async runPluginCheck(spec: ExecutionSpec): Promise<string> {
827889
const server = await this.bootPlayground()
828890
const result = await runPluginCheckCommand({
@@ -1065,6 +1127,25 @@ function stringArg(args: string[], name: string): string | undefined {
10651127
return value && value.length > 0 ? value : undefined
10661128
}
10671129

1130+
function nonNegativeIntegerStringArg(args: string[], name: string): number | undefined {
1131+
const value = stringArg(args, name)
1132+
if (!value) {
1133+
return undefined
1134+
}
1135+
1136+
const parsed = Number.parseInt(value, 10)
1137+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
1138+
}
1139+
1140+
function replayExportOutputDirectory(artifactRoot: string, requested: string | undefined): string {
1141+
const relativePath = requested?.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") || "files/replay-package"
1142+
if (relativePath.length === 0 || relativePath.includes("..")) {
1143+
throw new Error("wordpress.export-replay-package output-dir must be a relative path inside the runtime artifact root")
1144+
}
1145+
1146+
return join(artifactRoot, relativePath)
1147+
}
1148+
10681149
function wpContentRelativePath(path: string): string | undefined {
10691150
const normalized = path.replace(/\\/g, "/").replace(/\/+$/g, "")
10701151
const marker = "/wp-content/"

0 commit comments

Comments
 (0)