Skip to content

Commit 4aaa445

Browse files
committed
feat: expose artifact apply preflight
1 parent cbdad12 commit 4aaa445

6 files changed

Lines changed: 281 additions & 5 deletions

File tree

packages/cli/src/cli-entry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { routeCliCommand } from "./command-router.js"
2-
import { runArtifactsBenchmarkCommand, runArtifactsBrowserMetricsCommand, runArtifactsVerifyCommand } from "./commands/artifacts.js"
2+
import { runArtifactsApplyPreflightCommand, runArtifactsBenchmarkCommand, runArtifactsBrowserMetricsCommand, runArtifactsVerifyCommand } from "./commands/artifacts.js"
33
import { runAgentTaskRunCommand } from "./commands/agent-task-run.js"
44
import { runArtifactsBenchCompareCommand, runArtifactsBenchResultsCommand, runBenchCompareCommand, runBenchMatrixCommand, runBenchSummarizeCommand } from "./commands/benchmark.js"
55
import { runCommandsCommand, runRecipeSchemaCommand } from "./commands/discovery.js"
@@ -22,6 +22,7 @@ export async function runCli(args: string[]): Promise<number> {
2222
recipeBuild: runRecipeBuildCommand,
2323
workspacePolicyCheck: runWorkspacePolicyCheckCommand,
2424
artifactsVerify: runArtifactsVerifyCommand,
25+
artifactsApplyPreflight: runArtifactsApplyPreflightCommand,
2526
artifactsBrowserMetrics: runArtifactsBrowserMetricsCommand,
2627
artifactsBenchmark: runArtifactsBenchmarkCommand,
2728
artifactsBenchResults: runArtifactsBenchResultsCommand,

packages/cli/src/command-router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface CliCommandRouter {
1212
agentTaskRun: CliCommandHandler
1313
workspacePolicyCheck: CliCommandHandler
1414
artifactsVerify: CliCommandHandler
15+
artifactsApplyPreflight: CliCommandHandler
1516
artifactsBrowserMetrics: CliCommandHandler
1617
artifactsBenchmark: CliCommandHandler
1718
artifactsBenchResults: CliCommandHandler
@@ -79,6 +80,9 @@ export async function routeCliCommand(argv: string[], router: CliCommandRouter):
7980
if (subcommand === "verify") {
8081
return router.artifactsVerify(args)
8182
}
83+
if (subcommand === "apply-preflight") {
84+
return router.artifactsApplyPreflight(args)
85+
}
8286
if (subcommand === "browser-metrics") {
8387
return router.artifactsBrowserMetrics(args)
8488
}

packages/cli/src/commands/artifacts.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { copyFile, mkdir, readFile } from "node:fs/promises"
22
import { dirname, join, resolve } from "node:path"
3-
import { verifyArtifactBundle, type ArtifactBundleVerificationResult } from "@automattic/wp-codebox-core"
3+
import { preflightArtifactBundleApply, verifyArtifactBundle, type ArtifactBundleApplyPreflightResult, type ArtifactBundleVerificationResult } from "@automattic/wp-codebox-core"
44
import { browserArtifactMetrics, type BrowserArtifactMetricsResult } from "@automattic/wp-codebox-playground"
55
import { printArtifactVerifyHumanOutput } from "../output.js"
66

@@ -9,6 +9,10 @@ interface ArtifactVerifyOptions {
99
json: boolean
1010
}
1111

12+
interface ArtifactApplyPreflightOptions extends ArtifactVerifyOptions {
13+
approvedFiles: string[]
14+
}
15+
1216
interface BenchmarkArtifactsOptions extends ArtifactVerifyOptions {
1317
scenarioId?: string
1418
copyTo?: string
@@ -51,6 +55,18 @@ export async function runArtifactsVerifyCommand(args: string[]): Promise<number>
5155
return output.valid ? 0 : 1
5256
}
5357

58+
export async function runArtifactsApplyPreflightCommand(args: string[]): Promise<number> {
59+
const options = parseArtifactApplyPreflightOptions(args)
60+
const output = await applyPreflight(options)
61+
if (!options.json) {
62+
printArtifactApplyPreflightHumanOutput(output)
63+
return output.ready ? 0 : 1
64+
}
65+
66+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
67+
return output.ready ? 0 : 1
68+
}
69+
5470
export async function runArtifactsBrowserMetricsCommand(args: string[]): Promise<number> {
5571
const options = parseArtifactBrowserMetricsOptions(args)
5672
const output = await browserMetrics(options)
@@ -79,6 +95,10 @@ async function verifyArtifacts(options: ArtifactVerifyOptions): Promise<Artifact
7995
return verifyArtifactBundle(resolve(options.bundleDirectory))
8096
}
8197

98+
async function applyPreflight(options: ArtifactApplyPreflightOptions): Promise<ArtifactBundleApplyPreflightResult> {
99+
return preflightArtifactBundleApply(resolve(options.bundleDirectory), { approvedFiles: options.approvedFiles })
100+
}
101+
82102
async function browserMetrics(options: ArtifactVerifyOptions): Promise<BrowserArtifactMetricsResult> {
83103
return browserArtifactMetrics(resolve(options.bundleDirectory))
84104
}
@@ -106,6 +126,47 @@ function parseArtifactBrowserMetricsOptions(args: string[]): ArtifactVerifyOptio
106126
return parseArtifactVerifyOptions(args)
107127
}
108128

129+
function parseArtifactApplyPreflightOptions(args: string[]): ArtifactApplyPreflightOptions {
130+
const options: Partial<ArtifactApplyPreflightOptions> = { json: false, approvedFiles: [] }
131+
132+
for (let index = 0; index < args.length; index++) {
133+
const arg = args[index]
134+
135+
if (arg === "--json") {
136+
options.json = true
137+
continue
138+
}
139+
140+
const [name, inlineValue] = arg.split("=", 2)
141+
const value = inlineValue ?? args[++index]
142+
143+
if (!name.startsWith("--") || value === undefined) {
144+
throw new Error(`Invalid argument: ${arg}`)
145+
}
146+
147+
switch (name) {
148+
case "--bundle":
149+
case "--artifacts":
150+
options.bundleDirectory = value
151+
break
152+
case "--approved-file":
153+
options.approvedFiles?.push(value)
154+
break
155+
case "--approved-files":
156+
options.approvedFiles?.push(...value.split(","))
157+
break
158+
default:
159+
throw new Error(`Unknown option: ${name}`)
160+
}
161+
}
162+
163+
if (!options.bundleDirectory) {
164+
throw new Error("Missing required option: --bundle")
165+
}
166+
167+
return options as ArtifactApplyPreflightOptions
168+
}
169+
109170
function parseBenchmarkArtifactsOptions(args: string[]): BenchmarkArtifactsOptions {
110171
const options: Partial<BenchmarkArtifactsOptions> = { json: false }
111172
for (let index = 0; index < args.length; index++) {
@@ -164,6 +225,20 @@ function printArtifactBrowserMetricsHumanOutput(output: BrowserArtifactMetricsRe
164225
}
165226
}
166227

228+
function printArtifactApplyPreflightHumanOutput(output: ArtifactBundleApplyPreflightResult): void {
229+
console.log("WP Codebox artifact apply preflight")
230+
console.log(`Bundle: ${output.bundleDirectory}`)
231+
console.log(`Ready: ${output.ready ? "yes" : "no"}`)
232+
if (output.payload) {
233+
console.log(`Artifact: ${output.payload.artifactId}`)
234+
console.log(`Changed files: ${output.payload.changedFiles.files.length}`)
235+
console.log(`Patch: ${output.payload.patch.path}`)
236+
}
237+
for (const violation of output.violations) {
238+
console.log(`- ${violation.code} ${violation.path}: ${violation.message}`)
239+
}
240+
}
241+
167242
function printBenchmarkArtifactsHumanOutput(output: BenchmarkArtifactsOutput): void {
168243
console.log("WP Codebox benchmark artifacts")
169244
if (output.scenarioId) {

packages/cli/src/output.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export function printHelp(): void {
256256
wp-codebox bench summarize (--input <recipe-run.json>|--bundle <dir>) [--json]
257257
wp-codebox bench compare --baseline-input <recipe-run.json> --candidate-input <recipe-run.json> [--json]
258258
wp-codebox artifacts verify --bundle <dir> [--json]
259+
wp-codebox artifacts apply-preflight --bundle <dir> --approved-file <path> [--json]
259260
wp-codebox artifacts browser-metrics --bundle <dir> [--json]
260261
wp-codebox artifacts benchmark --bundle <dir> [--scenario-id <id>] [--extract-to <dir>] [--json]
261262
wp-codebox artifacts bench-results --bundle <dir> [--json]
@@ -277,7 +278,11 @@ Options:
277278
Keep preview runtimes alive after agent-task-run/recipe-run.
278279
--preview-public-url <url>
279280
Public preview URL passed through to agent-task-run/recipe-run.
280-
--bundle <dir> Artifact bundle directory for artifacts verify.
281+
--bundle <dir> Artifact bundle directory for artifacts verify and apply-preflight.
282+
--approved-file <path>
283+
Changed file approved for artifacts apply-preflight. Repeatable.
284+
--approved-files <paths>
285+
Comma-separated changed files approved for artifacts apply-preflight.
281286
--scenario-id <id> Filter benchmark artifact refs to one scenario.
282287
--extract-to <dir> Copy listed benchmark artifact refs to a directory.
283288
--input <path> Saved recipe-run JSON output for benchmark summarization.

packages/runtime-core/src/artifact-bundle-verifier.ts

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { lstat, readdir, readFile, realpath } from "node:fs/promises"
22
import { isAbsolute, join, normalize, relative, sep } from "node:path"
33
import { artifactFileDigest, calculateArtifactContentDigest, calculateArtifactManifestFileSha256 } from "./artifact-manifest.js"
4-
import type { ArtifactFileDigest, ArtifactManifest, ArtifactManifestFile } from "./artifact-manifest.js"
4+
import type { ArtifactContentDigest, ArtifactFileDigest, ArtifactManifest, ArtifactManifestFile } from "./artifact-manifest.js"
55
import { isPlainObject as isRecord } from "./object-utils.js"
66
import { RUNTIME_EPISODE_TRACE_SCHEMA, validateRuntimeEpisodeTrace } from "./runtime-episode.js"
77
import { RUNTIME_REFERENCE_MANIFEST_SCHEMA, RUNTIME_REPLAY_REFERENCE_INDEX_SCHEMA, runtimeReferenceManifestDigest, runtimeReplayReferenceIndexDigest } from "./runtime-reference.js"
@@ -40,6 +40,50 @@ export interface ArtifactBundleVerificationResult {
4040
manifest?: ArtifactManifest
4141
}
4242

43+
export interface ArtifactBundleApplyChangedFile {
44+
path: string
45+
status?: string
46+
mountIndex?: number
47+
mountTarget?: string
48+
relativePath?: string
49+
patchPath?: string
50+
[key: string]: unknown
51+
}
52+
53+
export interface ArtifactBundleApplyPreflightOptions extends VerifyArtifactBundleOptions {
54+
approvedFiles?: string[]
55+
}
56+
57+
export interface ArtifactBundleApplyPreflightResult {
58+
schema: "wp-codebox/artifact-bundle-apply-preflight/v1"
59+
bundleDirectory: string
60+
ready: boolean
61+
verification: ArtifactBundleVerificationResult
62+
violations: ArtifactBundleVerificationViolation[]
63+
manifest?: ArtifactManifest
64+
payload?: {
65+
schema: "wp-codebox/artifact-bundle-apply-payload/v1"
66+
artifactId: string
67+
contentDigest: ArtifactContentDigest
68+
patch: {
69+
path: string
70+
sha256: ArtifactFileDigest
71+
body: string
72+
}
73+
changedFiles: {
74+
path: string
75+
files: ArtifactBundleApplyChangedFile[]
76+
}
77+
review?: {
78+
path: string
79+
artifactId?: string
80+
summary?: string
81+
riskFlags?: string[]
82+
}
83+
approvedFiles: string[]
84+
}
85+
}
86+
4387
export interface VerifyArtifactBundleOptions {
4488
manifestFileName?: string
4589
allowOrphanedFiles?: boolean
@@ -121,6 +165,133 @@ export async function verifyArtifactBundle(directory: string, options: VerifyArt
121165
return artifactBundleVerificationResult(bundleDirectory, violations, manifest)
122166
}
123167

168+
export async function preflightArtifactBundleApply(directory: string, options: ArtifactBundleApplyPreflightOptions = {}): Promise<ArtifactBundleApplyPreflightResult> {
169+
const bundleDirectory = normalize(directory)
170+
const verification = await verifyArtifactBundle(bundleDirectory, options)
171+
const violations = [...verification.violations]
172+
if (!verification.valid || !verification.manifest) {
173+
return artifactBundleApplyPreflightResult(bundleDirectory, verification, violations)
174+
}
175+
176+
const manifestFiles = new Set(verification.manifest.files.map((file) => file.path))
177+
const reviewPath = manifestFiles.has("files/review.json") ? "files/review.json" : undefined
178+
const review = reviewPath ? await readOptionalJson(bundleDirectory, reviewPath, violations) : undefined
179+
const evidence = isRecord(review) && isRecord(review.evidence) ? review.evidence : undefined
180+
const patchPath = typeof evidence?.patch === "string" ? evidence.patch : findManifestFileByKind(verification.manifest, "patch")?.path
181+
const changedFilesPath = typeof evidence?.changedFiles === "string" ? evidence.changedFiles : findManifestFileByKind(verification.manifest, "changed-files")?.path
182+
183+
if (!patchPath) {
184+
violations.push({ code: "malformed-reference", path: "files/review.json:evidence.patch", file: "files/review.json", message: "Apply preflight requires review evidence or a manifest patch file." })
185+
} else {
186+
validateArtifactReference(patchPath, "apply.patch", manifestFiles, violations)
187+
}
188+
189+
if (!changedFilesPath) {
190+
violations.push({ code: "malformed-reference", path: "files/review.json:evidence.changedFiles", file: "files/review.json", message: "Apply preflight requires review evidence or a manifest changed-files file." })
191+
} else {
192+
validateArtifactReference(changedFilesPath, "apply.changedFiles", manifestFiles, violations)
193+
}
194+
195+
if (violations.length > 0 || !patchPath || !changedFilesPath) {
196+
return artifactBundleApplyPreflightResult(bundleDirectory, verification, violations)
197+
}
198+
199+
const [patchBody, changedFiles] = await Promise.all([
200+
readFile(join(bundleDirectory, patchPath), "utf8").catch((error) => {
201+
violations.push({ code: "missing-file", path: "apply.patch", file: patchPath, message: `Unable to read patch artifact: ${errorMessage(error)}` })
202+
return undefined
203+
}),
204+
readChangedFiles(bundleDirectory, changedFilesPath, violations),
205+
])
206+
207+
if (patchBody === undefined || !changedFiles) {
208+
return artifactBundleApplyPreflightResult(bundleDirectory, verification, violations)
209+
}
210+
211+
const approvedFiles = normalizeApprovedFiles(options.approvedFiles ?? [])
212+
const approvedFileSet = new Set(approvedFiles)
213+
const missingApprovedFiles = changedFiles.files.map((file) => file.path).filter((path) => !approvedFileSet.has(path))
214+
for (const file of missingApprovedFiles) {
215+
violations.push({ code: "malformed-reference", path: "approvedFiles", file, message: `Changed file is not covered by approvedFiles: ${file}` })
216+
}
217+
218+
if (violations.length > 0) {
219+
return artifactBundleApplyPreflightResult(bundleDirectory, verification, violations)
220+
}
221+
222+
return artifactBundleApplyPreflightResult(bundleDirectory, verification, violations, {
223+
schema: "wp-codebox/artifact-bundle-apply-payload/v1",
224+
artifactId: verification.manifest.id,
225+
contentDigest: verification.manifest.contentDigest,
226+
patch: {
227+
path: patchPath,
228+
sha256: artifactFileDigest(patchBody),
229+
body: patchBody,
230+
},
231+
changedFiles: {
232+
path: changedFilesPath,
233+
files: changedFiles.files,
234+
},
235+
...(reviewPath ? {
236+
review: {
237+
path: reviewPath,
238+
...(isRecord(review) && typeof review.artifactId === "string" ? { artifactId: review.artifactId } : {}),
239+
...(isRecord(review) && typeof review.summary === "string" ? { summary: review.summary } : {}),
240+
...(isRecord(review) && Array.isArray(review.riskFlags) ? { riskFlags: review.riskFlags.filter((flag): flag is string => typeof flag === "string") } : {}),
241+
},
242+
} : {}),
243+
approvedFiles,
244+
})
245+
}
246+
247+
function artifactBundleApplyPreflightResult(bundleDirectory: string, verification: ArtifactBundleVerificationResult, violations: ArtifactBundleVerificationViolation[], payload?: ArtifactBundleApplyPreflightResult["payload"]): ArtifactBundleApplyPreflightResult {
248+
return {
249+
schema: "wp-codebox/artifact-bundle-apply-preflight/v1",
250+
bundleDirectory,
251+
ready: violations.length === 0 && payload !== undefined,
252+
verification,
253+
violations,
254+
...(verification.manifest ? { manifest: verification.manifest } : {}),
255+
...(payload ? { payload } : {}),
256+
}
257+
}
258+
259+
async function readOptionalJson(directory: string, path: string, violations: ArtifactBundleVerificationViolation[]): Promise<unknown> {
260+
try {
261+
return JSON.parse(await readFile(join(directory, path), "utf8"))
262+
} catch (error) {
263+
violations.push({ code: "malformed-reference", path, file: path, message: `Unable to read artifact JSON: ${errorMessage(error)}` })
264+
return undefined
265+
}
266+
}
267+
268+
async function readChangedFiles(directory: string, path: string, violations: ArtifactBundleVerificationViolation[]): Promise<{ files: ArtifactBundleApplyChangedFile[] } | undefined> {
269+
const value = await readOptionalJson(directory, path, violations)
270+
if (!isRecord(value) || !Array.isArray(value.files)) {
271+
violations.push({ code: "malformed-reference", path, file: path, message: "Changed-files artifact must include a files array." })
272+
return undefined
273+
}
274+
275+
const files: ArtifactBundleApplyChangedFile[] = []
276+
for (const [index, file] of value.files.entries()) {
277+
if (!isRecord(file) || typeof file.path !== "string" || file.path.trim().length === 0) {
278+
violations.push({ code: "malformed-reference", path: `${path}:files[${index}]`, file: path, message: "Changed-file entries must include a non-empty path." })
279+
continue
280+
}
281+
files.push({ ...file, path: file.path })
282+
}
283+
284+
return { files }
285+
}
286+
287+
function findManifestFileByKind(manifest: ArtifactManifest, kind: string): ArtifactManifestFile | undefined {
288+
return manifest.files.find((file) => file.kind === kind)
289+
}
290+
291+
function normalizeApprovedFiles(paths: string[]): string[] {
292+
return [...new Set(paths.map((path) => path.trim()).filter(Boolean))]
293+
}
294+
124295
async function verifyBundleFileTopology(directory: string, path: string, fieldPath: string, violations: ArtifactBundleVerificationViolation[]): Promise<void> {
125296
const absolutePath = join(directory, path)
126297
const fileStat = await lstat(absolutePath)

0 commit comments

Comments
 (0)