|
1 | 1 | import { lstat, readdir, readFile, realpath } from "node:fs/promises" |
2 | 2 | import { isAbsolute, join, normalize, relative, sep } from "node:path" |
3 | 3 | 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" |
5 | 5 | import { isPlainObject as isRecord } from "./object-utils.js" |
6 | 6 | import { RUNTIME_EPISODE_TRACE_SCHEMA, validateRuntimeEpisodeTrace } from "./runtime-episode.js" |
7 | 7 | import { RUNTIME_REFERENCE_MANIFEST_SCHEMA, RUNTIME_REPLAY_REFERENCE_INDEX_SCHEMA, runtimeReferenceManifestDigest, runtimeReplayReferenceIndexDigest } from "./runtime-reference.js" |
@@ -40,6 +40,50 @@ export interface ArtifactBundleVerificationResult { |
40 | 40 | manifest?: ArtifactManifest |
41 | 41 | } |
42 | 42 |
|
| 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 | + |
43 | 87 | export interface VerifyArtifactBundleOptions { |
44 | 88 | manifestFileName?: string |
45 | 89 | allowOrphanedFiles?: boolean |
@@ -121,6 +165,133 @@ export async function verifyArtifactBundle(directory: string, options: VerifyArt |
121 | 165 | return artifactBundleVerificationResult(bundleDirectory, violations, manifest) |
122 | 166 | } |
123 | 167 |
|
| 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 | + |
124 | 295 | async function verifyBundleFileTopology(directory: string, path: string, fieldPath: string, violations: ArtifactBundleVerificationViolation[]): Promise<void> { |
125 | 296 | const absolutePath = join(directory, path) |
126 | 297 | const fileStat = await lstat(absolutePath) |
|
0 commit comments