Skip to content

Commit 40f066f

Browse files
authored
Add runtime capture status primitive
Add a generic runtime capture status DTO and normalizer for existing snapshot, patch, changed-file, and mount-diff evidence, exposed through the public runtime-playground API without product-specific semantics.
1 parent 166b9f4 commit 40f066f

7 files changed

Lines changed: 252 additions & 1 deletion

File tree

docs/public-api-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Use these package entrypoints from external integrations:
4444
implementation packages directly.
4545
- `@automattic/wp-codebox-playground`: advanced runtime backend entrypoint and
4646
adapter surface for runtime-backend implementors. New consumers should prefer
47-
`@automattic/wp-codebox-playground/public`. Product consumers should use the Codebox-owned public surfaces above and the WordPress/browser surfaces below.
47+
`@automattic/wp-codebox-playground/public`. Product consumers should use the Codebox-owned public surfaces above and the WordPress/browser surfaces below. The public facade exposes `runtimeCaptureStatus()` for normalizing existing snapshot, workspace patch, changed-file, or mount-diff evidence into `wp-codebox/runtime-capture-status/v1`.
4848
- `@automattic/wp-codebox-cli`: the executable CLI surface for schema, command,
4949
recipe, runtime, and artifact operations.
5050
- `@automattic/wp-codebox-cli/recipe-secret-env`: recipe secret environment

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@
203203
"test:php-provider-credential-boundary": "php scripts/php-provider-credential-boundary-smoke.php",
204204
"test:production-boundary-enforcement": "tsx tests/production-boundary-enforcement.test.ts",
205205
"test:public-api-contract": "tsx tests/public-api-contract.test.ts",
206+
"test:runtime-capture-status": "tsx tests/runtime-capture-status.test.ts",
206207
"test:wordpress-runtime-actions": "tsx tests/wordpress-runtime-actions.test.ts",
207208
"test:wordpress-action-auth-contract": "tsx tests/wordpress-action-auth-contract.test.ts",
208209
"test:wordpress-hotspots-contracts": "tsx tests/wordpress-hotspots-contracts.test.ts",

packages/runtime-playground/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, brows
1515
export { normalizePreviewReviewerAccess, previewReviewerAccess } from "./preview-reviewer-access.js"
1616
export { applyVfsMountSnapshots, materializePlaygroundMountsFromVfs, materializePlaygroundStagedInputs, type HostMountSnapshot, type MountMaterializationResult, type StagedInputMaterializationResult, type VfsMountSnapshot } from "./mount-materialization.js"
1717
export { buildReplayExportBlueprint, buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayExportPackage, writeReplayableWordPressSiteBundle, type ReplayExportPackage, type ReplayExportPackageOptions, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js"
18+
export { RUNTIME_CAPTURE_STATUS_SCHEMA, runtimeCaptureStatus, type RuntimeCaptureDiagnostic, type RuntimeCaptureState, type RuntimeCaptureStatus, type RuntimeCaptureStatusInput } from "./runtime-capture-status.js"
1819
export type { RuntimeSnapshotArtifact } from "./runtime-snapshot.js"

packages/runtime-playground/src/public.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export {
177177
} from "@automattic/wp-codebox-core"
178178
import { browserArtifactMetrics, type BrowserArtifactMetricsResult } from "./browser-metrics.js"
179179
import { createPlaygroundRuntimeBackend, type PlaygroundRuntimeBackendOptions } from "./playground-runtime.js"
180+
export { RUNTIME_CAPTURE_STATUS_SCHEMA, runtimeCaptureStatus, type RuntimeCaptureDiagnostic, type RuntimeCaptureState, type RuntimeCaptureStatus, type RuntimeCaptureStatusInput } from "./runtime-capture-status.js"
180181

181182
export type WordPressRuntimeSpec = Omit<RuntimeCreateSpec, "backend"> & {
182183
backend?: "wordpress-playground"
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { sha256StableJson } from "@automattic/wp-codebox-core/internals"
2+
import type { ArtifactDiagnostic } from "@automattic/wp-codebox-core"
3+
import type { CanonicalChangedFiles, MountDiff, WorkspacePatchArtifact } from "./artifacts.js"
4+
import type { RuntimeSnapshotArtifact } from "./runtime-snapshot.js"
5+
6+
export const RUNTIME_CAPTURE_STATUS_SCHEMA = "wp-codebox/runtime-capture-status/v1" as const
7+
8+
export type RuntimeCaptureState = "clean" | "changed" | "unknown" | "unsupported"
9+
10+
export interface RuntimeCaptureDiagnostic {
11+
severity: "info" | "warning" | "error"
12+
code: string
13+
message: string
14+
details?: Record<string, unknown>
15+
}
16+
17+
export interface RuntimeCaptureStatus {
18+
schema: typeof RUNTIME_CAPTURE_STATUS_SCHEMA
19+
version: 1
20+
state: RuntimeCaptureState
21+
snapshotDigest?: { algorithm: "sha256"; value: string }
22+
captureDigest?: { algorithm: "sha256"; value: string }
23+
resources?: {
24+
databaseTables?: number
25+
wpContentFiles?: number
26+
}
27+
changes?: {
28+
files: number
29+
added: number
30+
modified: number
31+
deleted: number
32+
workspaces?: number
33+
}
34+
diagnostics: RuntimeCaptureDiagnostic[]
35+
limitations: string[]
36+
}
37+
38+
export interface RuntimeCaptureStatusInput {
39+
supported?: boolean
40+
snapshot?: RuntimeSnapshotArtifact
41+
workspacePatch?: Pick<WorkspacePatchArtifact, "summary" | "contentDigest" | "workspaces">
42+
changedFiles?: CanonicalChangedFiles
43+
mountDiffs?: MountDiff[]
44+
captureDigest?: string | { algorithm: "sha256"; value: string }
45+
diagnostics?: Array<RuntimeCaptureDiagnostic | ArtifactDiagnostic>
46+
limitations?: string[]
47+
}
48+
49+
export function runtimeCaptureStatus(input: RuntimeCaptureStatusInput = {}): RuntimeCaptureStatus {
50+
const diagnostics = normalizeRuntimeCaptureDiagnostics(input.diagnostics ?? [])
51+
const limitations = [...(input.limitations ?? [])]
52+
const snapshotDigest = input.snapshot ? digest(input.snapshot) : undefined
53+
const captureDigest = normalizeDigest(input.captureDigest)
54+
?? input.workspacePatch?.contentDigest
55+
?? (input.changedFiles ? digest(input.changedFiles) : undefined)
56+
?? snapshotDigest
57+
const changes = summarizeChanges(input)
58+
const resources = input.snapshot ? {
59+
databaseTables: input.snapshot.database.tables.length,
60+
wpContentFiles: input.snapshot.files.length,
61+
} : undefined
62+
63+
if (input.supported === false) {
64+
return {
65+
schema: RUNTIME_CAPTURE_STATUS_SCHEMA,
66+
version: 1,
67+
state: "unsupported",
68+
...(snapshotDigest ? { snapshotDigest } : {}),
69+
...(captureDigest ? { captureDigest } : {}),
70+
...(resources ? { resources } : {}),
71+
...(changes ? { changes } : {}),
72+
diagnostics,
73+
limitations,
74+
}
75+
}
76+
77+
const hasChangeEvidence = Boolean(input.workspacePatch || input.changedFiles || input.mountDiffs)
78+
const state: RuntimeCaptureState = hasChangeEvidence ? (changes && changes.files > 0 ? "changed" : "clean") : "unknown"
79+
80+
return {
81+
schema: RUNTIME_CAPTURE_STATUS_SCHEMA,
82+
version: 1,
83+
state,
84+
...(snapshotDigest ? { snapshotDigest } : {}),
85+
...(captureDigest ? { captureDigest } : {}),
86+
...(resources ? { resources } : {}),
87+
...(changes ? { changes } : {}),
88+
diagnostics,
89+
limitations,
90+
}
91+
}
92+
93+
function summarizeChanges(input: RuntimeCaptureStatusInput): RuntimeCaptureStatus["changes"] | undefined {
94+
if (input.workspacePatch) {
95+
return {
96+
files: input.workspacePatch.summary.files,
97+
added: input.workspacePatch.summary.added,
98+
modified: input.workspacePatch.summary.modified,
99+
deleted: input.workspacePatch.summary.deleted,
100+
workspaces: input.workspacePatch.workspaces.length,
101+
}
102+
}
103+
104+
if (input.changedFiles) {
105+
return changedFileStats(input.changedFiles.files)
106+
}
107+
108+
if (input.mountDiffs) {
109+
return {
110+
files: input.mountDiffs.filter((diff) => diff.changed).length,
111+
added: 0,
112+
modified: input.mountDiffs.filter((diff) => diff.changed).length,
113+
deleted: 0,
114+
workspaces: input.mountDiffs.length,
115+
}
116+
}
117+
118+
return undefined
119+
}
120+
121+
function changedFileStats(files: CanonicalChangedFiles["files"]): NonNullable<RuntimeCaptureStatus["changes"]> {
122+
return {
123+
files: files.length,
124+
added: files.filter((file) => file.status === "added").length,
125+
modified: files.filter((file) => file.status === "modified").length,
126+
deleted: files.filter((file) => file.status === "deleted").length,
127+
}
128+
}
129+
130+
function normalizeDigest(value: RuntimeCaptureStatusInput["captureDigest"]): RuntimeCaptureStatus["captureDigest"] | undefined {
131+
if (!value) {
132+
return undefined
133+
}
134+
135+
return typeof value === "string" ? { algorithm: "sha256", value } : value
136+
}
137+
138+
function digest(value: unknown): { algorithm: "sha256"; value: string } {
139+
return { algorithm: "sha256", value: sha256StableJson(value) }
140+
}
141+
142+
function normalizeRuntimeCaptureDiagnostics(diagnostics: Array<RuntimeCaptureDiagnostic | ArtifactDiagnostic>): RuntimeCaptureDiagnostic[] {
143+
return diagnostics.map((diagnostic) => ({
144+
severity: normalizeSeverity(diagnostic.severity),
145+
code: ("code" in diagnostic ? diagnostic.code : diagnostic.type) || "runtime-capture-diagnostic",
146+
message: diagnostic.message,
147+
...(diagnostic.details ? { details: diagnostic.details as Record<string, unknown> } : {}),
148+
}))
149+
}
150+
151+
function normalizeSeverity(severity: string): RuntimeCaptureDiagnostic["severity"] {
152+
return severity === "error" || severity === "warning" ? severity : "info"
153+
}

tests/public-api-contract.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,9 @@ assert.equal(typeof playgroundPublicApi.runWordPressBrowserAction, "function")
568568
assert.equal(typeof playgroundPublicApi.probeWordPressBrowser, "function")
569569
assert.equal(typeof playgroundPublicApi.openWordPressEditor, "function")
570570
assert.equal(typeof playgroundPublicApi.collectWordPressArtifacts, "function")
571+
assert.equal(typeof playgroundPublicApi.runtimeCaptureStatus, "function")
572+
assert.equal(playgroundPublicApi.RUNTIME_CAPTURE_STATUS_SCHEMA, "wp-codebox/runtime-capture-status/v1")
573+
assert.equal("buildRuntimeCaptureStatusForWordPressBuild" in playgroundPublicApi, false)
571574
assert.equal(RUNNER_WORKSPACE_BACKEND_FILTER, "wp_codebox_runner_workspace_backend")
572575
assert.ok(RUNNER_WORKSPACE_BACKEND_ABILITY_KEYS.includes("publish_runner_workspace"))
573576
assert.match(runnerWorkspaceAdapter, new RegExp(escapeRegExp(RUNNER_WORKSPACE_BACKEND_FILTER)))
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import assert from "node:assert/strict"
2+
import { readFile } from "node:fs/promises"
3+
import { RUNTIME_CAPTURE_STATUS_SCHEMA, runtimeCaptureStatus } from "../packages/runtime-playground/src/public.js"
4+
import * as playgroundPublicApi from "../packages/runtime-playground/src/public.js"
5+
import type { CanonicalChangedFiles, WorkspacePatchArtifact } from "../packages/runtime-playground/src/artifacts.js"
6+
import type { RuntimeSnapshotArtifact } from "../packages/runtime-playground/src/runtime-snapshot.js"
7+
8+
const snapshot: RuntimeSnapshotArtifact = {
9+
schema: "wp-codebox/wordpress-runtime-snapshot/v1",
10+
version: 1,
11+
id: "snapshot-1",
12+
createdAt: "2026-01-01T00:00:00.000Z",
13+
compatibility: { backend: "wordpress-playground", wordpressVersion: "6.9", phpVersion: "8.3" },
14+
metadata: {
15+
runtime: {
16+
id: "runtime-1",
17+
backend: "wordpress-playground",
18+
status: "running",
19+
createdAt: "2026-01-01T00:00:00.000Z",
20+
environment: { kind: "wordpress", version: "6.9", phpVersion: "8.3" },
21+
},
22+
mounts: [],
23+
mountedInputs: [],
24+
activeTheme: "twentytwentyfive",
25+
activePlugins: ["example/example.php"],
26+
wpContentPath: "/wordpress/wp-content",
27+
},
28+
database: { tables: [{ name: "wp_options", createSql: "CREATE TABLE wp_options (option_id int)", rows: [], rowCount: 0 }] },
29+
files: [{ scope: "wp-content", path: "themes/twentytwentyfive/style.css", bytes: 12, sha256: "a".repeat(64), base64: "ZXhhbXBsZQ==" }],
30+
hashes: { database: { algorithm: "sha256", value: "b".repeat(64) }, files: { algorithm: "sha256", value: "c".repeat(64) } },
31+
}
32+
33+
const cleanChangedFiles: CanonicalChangedFiles = { schema: "wp-codebox/changed-files/v1", files: [] }
34+
const changedFiles: CanonicalChangedFiles = {
35+
schema: "wp-codebox/changed-files/v1",
36+
files: [
37+
{ path: "/tmp/site/wp-content/plugins/example/plugin.php", status: "added", mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/example", relativePath: "plugin.php", patchPath: "files/diffs/mount-0.patch" },
38+
{ path: "/tmp/site/wp-content/themes/theme/style.css", status: "modified", mountIndex: 1, mountTarget: "/wordpress/wp-content/themes/theme", relativePath: "style.css", patchPath: "files/diffs/mount-1.patch" },
39+
{ path: "/tmp/site/wp-content/themes/theme/old.css", status: "deleted", mountIndex: 1, mountTarget: "/wordpress/wp-content/themes/theme", relativePath: "old.css", patchPath: "files/diffs/mount-1.patch" },
40+
],
41+
}
42+
43+
const clean = runtimeCaptureStatus({ snapshot, changedFiles: cleanChangedFiles })
44+
assert.equal(clean.schema, RUNTIME_CAPTURE_STATUS_SCHEMA)
45+
assert.equal(clean.version, 1)
46+
assert.equal(clean.state, "clean")
47+
assert.equal(clean.resources?.databaseTables, 1)
48+
assert.equal(clean.resources?.wpContentFiles, 1)
49+
assert.deepEqual(clean.changes, { files: 0, added: 0, modified: 0, deleted: 0 })
50+
assert.equal(clean.snapshotDigest?.algorithm, "sha256")
51+
assert.equal(clean.captureDigest?.algorithm, "sha256")
52+
53+
const changed = runtimeCaptureStatus({ snapshot, changedFiles })
54+
assert.equal(changed.state, "changed")
55+
assert.deepEqual(changed.changes, { files: 3, added: 1, modified: 1, deleted: 1 })
56+
57+
const unknown = runtimeCaptureStatus({ snapshot, limitations: ["No comparable baseline was provided."] })
58+
assert.equal(unknown.state, "unknown")
59+
assert.deepEqual(unknown.limitations, ["No comparable baseline was provided."])
60+
assert.equal(unknown.resources?.databaseTables, 1)
61+
assert.equal(unknown.snapshotDigest?.value, unknown.captureDigest?.value)
62+
63+
const workspacePatch: Pick<WorkspacePatchArtifact, "summary" | "contentDigest" | "workspaces"> = {
64+
summary: { changed: true, files: 2, added: 1, modified: 1, deleted: 0 },
65+
contentDigest: { algorithm: "sha256", inputs: ["files/changed-files.json", "files/patch.diff"], value: "d".repeat(64) },
66+
workspaces: [
67+
{ mountIndex: 0, target: "/wordpress/wp-content/plugins/example", source: "/tmp/example", status: "changed", changed: true, patch: "files/diffs/mount-0.patch" },
68+
],
69+
}
70+
const workspaceChanged = runtimeCaptureStatus({ workspacePatch })
71+
assert.equal(workspaceChanged.state, "changed")
72+
assert.equal(workspaceChanged.captureDigest?.value, "d".repeat(64))
73+
assert.deepEqual(workspaceChanged.changes, { files: 2, added: 1, modified: 1, deleted: 0, workspaces: 1 })
74+
75+
const unsupported = runtimeCaptureStatus({ supported: false, diagnostics: [{ severity: "warning", code: "runtime-capture-unavailable", message: "Runtime capture is not available for this backend." }] })
76+
assert.equal(unsupported.state, "unsupported")
77+
assert.deepEqual(unsupported.diagnostics, [{ severity: "warning", code: "runtime-capture-unavailable", message: "Runtime capture is not available for this backend." }])
78+
79+
assert.equal(typeof playgroundPublicApi.runtimeCaptureStatus, "function")
80+
assert.equal(playgroundPublicApi.RUNTIME_CAPTURE_STATUS_SCHEMA, "wp-codebox/runtime-capture-status/v1")
81+
assert.equal("buildRuntimeCaptureStatusForWordPressBuild" in playgroundPublicApi, false)
82+
83+
for (const path of [
84+
"packages/runtime-playground/src/runtime-capture-status.ts",
85+
"packages/runtime-playground/src/public.ts",
86+
"packages/runtime-playground/src/index.ts",
87+
]) {
88+
const source = await readFile(new URL(`../${path}`, import.meta.url), "utf8")
89+
assert.doesNotMatch(source, /WP Build|WordPress Build|paid|free|export gating/i, `${path} must stay product-agnostic`)
90+
}
91+
92+
console.log("runtime capture status ok")

0 commit comments

Comments
 (0)