Skip to content
Merged
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: 16 additions & 0 deletions docs/public-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ Use these package entrypoints from external integrations:
runtime backend packages that need to compose Codebox lifecycle/preload PHP.
- `@automattic/wp-codebox-core/recipe-builders`: typed recipe construction
helpers.
- `executeBoundedRuntimePlan()` from `@automattic/wp-codebox-core/public`:
generic aggregate execution for hosts that materialize one workspace/runtime,
start disposable services once, and run multiple commands at bounded
concurrency. It accepts `wp-codebox/bounded-runtime-plan/v1`; every entry has
`argv`, string-only environment overrides, a per-entry timeout, process
identity, safe artifact namespace, and input index. Results use
`wp-codebox/bounded-runtime-plan-result/v1` in input order. The adapter owns
runtime-specific execution, while Codebox guarantees one materialize/start and
one stop/dispose lifecycle. Set `failFast: true` to cancel not-yet-started
entries, and use `retryBoundedRuntimePlan(plan, priorResult)` to select only
failed, timed-out, or cancelled entries. Environment values are passed only to
the adapter and never copied into result or artifact DTOs; hosts must retain
their existing secret-redaction and reviewer-safe artifact policies. On
timeout, the adapter receives an aborted signal and must settle its execution
promise after the underlying process terminates; the scheduler retains that
entry's concurrency slot until settlement before admitting another entry.
- `@automattic/wp-codebox-core/agent-task-recipe`: agent-task recipe assembly
helpers.
- `@automattic/wp-codebox-core/runtime-presets`: runtime preset registry helpers.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"test:runtime-neutral-contracts": "tsx tests/runtime-neutral-contracts.test.ts",
"test:run-registry": "tsx tests/run-registry.test.ts",
"test:run-plan-executor": "tsx tests/run-plan-executor.test.ts",
"test:bounded-runtime-plan": "tsx tests/bounded-runtime-plan.test.ts",
"test:fanout-execution": "tsx tests/fanout-execution.test.ts",
"test:agent-fanout-executor": "tsx tests/agent-fanout-executor.test.ts",
"test:fanout-aggregation-contract-parity": "tsx tests/fanout-aggregation-contract-parity.test.ts",
Expand Down
183 changes: 183 additions & 0 deletions packages/runtime-core/src/bounded-runtime-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { normalizeRunPlanConcurrency, safeRunPlanNamespace } from "./run-plan.js"

/** A generic aggregate command plan; PHPUnit is one consumer, not a special case. */
export const BOUNDED_RUNTIME_PLAN_SCHEMA = "wp-codebox/bounded-runtime-plan/v1" as const
export const BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA = "wp-codebox/bounded-runtime-plan-result/v1" as const

export interface BoundedRuntimePlanEntry {
id: string
argv: string[]
environment?: Record<string, string>
timeoutMs?: number
processIdentity: string
artifactNamespace: string
inputIndex: number
}

export interface BoundedRuntimePlan {
schema: typeof BOUNDED_RUNTIME_PLAN_SCHEMA
concurrency: number
failFast?: boolean
entries: BoundedRuntimePlanEntry[]
}

export type BoundedRuntimePlanEntryStatus = "succeeded" | "failed" | "timed_out" | "cancelled"

export interface BoundedRuntimePlanEntryResult {
id: string
inputIndex: number
processIdentity: string
artifactNamespace: string
status: BoundedRuntimePlanEntryStatus
success: boolean
exitCode?: number
error?: { code: "execution-failed" | "execution-threw" | "timed-out" | "fail-fast"; message: string }
}

export interface BoundedRuntimePlanResult {
schema: typeof BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA
success: boolean
concurrency: number
entries: BoundedRuntimePlanEntryResult[]
}

export interface BoundedRuntimePlanExecution<TWorkspace = unknown, TRuntime = unknown, TServices = unknown> {
entry: BoundedRuntimePlanEntry
workspace: TWorkspace
runtime: TRuntime
services: TServices
signal: AbortSignal
}

/**
* Lifecycle ownership is deliberately external: callers decide how a workspace,
* runtime, and disposable services are created, while this primitive guarantees
* that each lifecycle boundary is reached at most once per aggregate.
*/
export interface BoundedRuntimePlanAdapter<TWorkspace = unknown, TRuntime = unknown, TServices = unknown> {
materialize(): Promise<{ workspace: TWorkspace; runtime: TRuntime }>
startServices(context: { workspace: TWorkspace; runtime: TRuntime }): Promise<TServices>
execute(context: BoundedRuntimePlanExecution<TWorkspace, TRuntime, TServices>): Promise<{ success: boolean; exitCode?: number; message?: string }>
stopServices(context: { workspace: TWorkspace; runtime: TRuntime; services: TServices }): Promise<void>
dispose(context: { workspace: TWorkspace; runtime: TRuntime }): Promise<void>
}

export function validateBoundedRuntimePlan(plan: BoundedRuntimePlan): void {
if (plan.schema !== BOUNDED_RUNTIME_PLAN_SCHEMA) throw new Error(`Bounded runtime plan schema must be ${BOUNDED_RUNTIME_PLAN_SCHEMA}.`)
if (!Array.isArray(plan.entries) || plan.entries.length === 0) throw new Error("Bounded runtime plan requires at least one entry.")
normalizeRunPlanConcurrency(plan.concurrency, { concurrencyMode: "validate", maxConcurrency: plan.entries.length })
const ids = new Set<string>()
const inputIndexes = new Set<number>()
for (const entry of plan.entries) {
if (!safeIdentifier(entry.id) || ids.has(entry.id)) throw new Error(`Bounded runtime plan entry ids must be unique safe identifiers: ${entry.id || "<empty>"}`)
ids.add(entry.id)
if (!safeIdentifier(entry.processIdentity)) throw new Error(`Bounded runtime plan process identity must be a safe identifier: ${entry.processIdentity || "<empty>"}`)
safeRunPlanNamespace(entry.artifactNamespace)
if (!Array.isArray(entry.argv) || entry.argv.some((argument) => typeof argument !== "string")) throw new Error(`Bounded runtime plan entry argv must be a string array: ${entry.id}`)
if (!Number.isInteger(entry.inputIndex) || entry.inputIndex < 0 || inputIndexes.has(entry.inputIndex)) throw new Error(`Bounded runtime plan input indexes must be unique non-negative integers: ${entry.id}`)
inputIndexes.add(entry.inputIndex)
if (entry.timeoutMs !== undefined && (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0)) throw new Error(`Bounded runtime plan timeout must be positive: ${entry.id}`)
if (entry.environment && Object.entries(entry.environment).some(([name, value]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string")) throw new Error(`Bounded runtime plan environment must contain string environment overrides: ${entry.id}`)
}
}

export async function executeBoundedRuntimePlan<TWorkspace, TRuntime, TServices>(plan: BoundedRuntimePlan, adapter: BoundedRuntimePlanAdapter<TWorkspace, TRuntime, TServices>): Promise<BoundedRuntimePlanResult> {
validateBoundedRuntimePlan(plan)
const concurrency = normalizeRunPlanConcurrency(plan.concurrency, { concurrencyMode: "validate", maxConcurrency: plan.entries.length })
let materialized: { workspace: TWorkspace; runtime: TRuntime } | undefined
let services: TServices | undefined
let servicesStarted = false
try {
materialized = await adapter.materialize()
services = await adapter.startServices(materialized)
servicesStarted = true
const entries = await executeEntries(plan, concurrency, materialized, services, adapter)
return {
schema: BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA,
success: entries.every((entry) => entry.success),
concurrency,
entries,
}
} finally {
try {
if (materialized && servicesStarted) await adapter.stopServices({ ...materialized, services: services as TServices })
} finally {
if (materialized) await adapter.dispose(materialized)
}
}
}

/** Returns only unsuccessful entries, preserving the original deterministic order. */
export function retryBoundedRuntimePlan(plan: BoundedRuntimePlan, previous: Pick<BoundedRuntimePlanResult, "entries">): BoundedRuntimePlan {
validateBoundedRuntimePlan(plan)
const unsuccessful = new Set(previous.entries.filter((entry) => !entry.success).map((entry) => entry.id))
const entries = plan.entries.filter((entry) => unsuccessful.has(entry.id))
if (entries.length === 0) throw new Error("Bounded runtime plan retry requires at least one unsuccessful prior entry.")
return { ...plan, concurrency: Math.min(plan.concurrency, entries.length), entries }
}

async function executeEntries<TWorkspace, TRuntime, TServices>(plan: BoundedRuntimePlan, concurrency: number, materialized: { workspace: TWorkspace; runtime: TRuntime }, services: TServices, adapter: BoundedRuntimePlanAdapter<TWorkspace, TRuntime, TServices>): Promise<BoundedRuntimePlanEntryResult[]> {
const results = new Array<BoundedRuntimePlanEntryResult>(plan.entries.length)
let next = 0
let failed = false
const worker = async (): Promise<void> => {
while (true) {
const index = next++
if (index >= plan.entries.length) return
const entry = plan.entries[index]!
if (failed && plan.failFast) {
results[index] = cancelledResult(entry)
continue
}
const result = await executeEntry(entry, materialized, services, adapter)
results[index] = result
if (!result.success) failed = true
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, plan.entries.length) }, worker))
return results
}

async function executeEntry<TWorkspace, TRuntime, TServices>(entry: BoundedRuntimePlanEntry, materialized: { workspace: TWorkspace; runtime: TRuntime }, services: TServices, adapter: BoundedRuntimePlanAdapter<TWorkspace, TRuntime, TServices>): Promise<BoundedRuntimePlanEntryResult> {
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
let operation: Promise<{ success: boolean; exitCode?: number; message?: string }> | undefined
try {
operation = adapter.execute({ entry, ...materialized, services, signal: controller.signal })
const completed = entry.timeoutMs === undefined ? await operation : await Promise.race([
operation,
new Promise<never>((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new BoundedRuntimePlanTimeoutError(entry.id)) }, entry.timeoutMs) }),
])
return completed.success
? baseResult(entry, "succeeded", true, completed.exitCode)
: { ...baseResult(entry, "failed", false, completed.exitCode), error: { code: "execution-failed", message: redactEntryMessage(completed.message || `Execution failed: ${entry.id}`, entry) } }
} catch (error) {
if (error instanceof BoundedRuntimePlanTimeoutError) {
// Keep the worker slot and shared services alive until the adapter confirms
// that the aborted process has actually terminated.
await operation?.catch(() => undefined)
return { ...baseResult(entry, "timed_out", false), error: { code: "timed-out", message: `Execution timed out: ${entry.id}` } }
}
return { ...baseResult(entry, "failed", false), error: { code: "execution-threw", message: redactEntryMessage(error instanceof Error ? error.message : `Execution threw: ${entry.id}`, entry) } }
} finally {
if (timer) clearTimeout(timer)
}
}

class BoundedRuntimePlanTimeoutError extends Error {}

function baseResult(entry: BoundedRuntimePlanEntry, status: BoundedRuntimePlanEntryStatus, success: boolean, exitCode?: number): BoundedRuntimePlanEntryResult {
return { id: entry.id, inputIndex: entry.inputIndex, processIdentity: entry.processIdentity, artifactNamespace: entry.artifactNamespace, status, success, ...(exitCode === undefined ? {} : { exitCode }) }
}

function cancelledResult(entry: BoundedRuntimePlanEntry): BoundedRuntimePlanEntryResult {
return { ...baseResult(entry, "cancelled", false), error: { code: "fail-fast", message: `Execution was not started after an earlier failure: ${entry.id}` } }
}

function safeIdentifier(value: unknown): value is string {
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)
}

function redactEntryMessage(message: string, entry: BoundedRuntimePlanEntry): string {
return Object.values(entry.environment ?? {}).reduce((redacted, value) => value ? redacted.split(value).join("[redacted]") : redacted, message)
}
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export * from "./host-command-executor.js"
export * from "./managed-host-command.js"
export * from "./run-registry.js"
export * from "./run-plan.js"
export * from "./bounded-runtime-plan.js"
export * from "./fanout-contracts.js"
export * from "./fanout-aggregation.js"
export * from "./fanout-execution.js"
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export * from "./recipe-run-summary.js"
export * from "./recipe-schema.js"
export * from "./recipe-source-packages.js"
export * from "./run-plan.js"
export * from "./bounded-runtime-plan.js"
export * from "./run-registry.js"
export * from "./runner-workspace-publication.js"
export * from "./runtime-boundary-contracts.js"
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime-playground/src/playground-cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
...(bootstrapSharedMount ? [bootstrapSharedMount] : []),
...(wordpressDirectory ? [{ hostPath: wordpressDirectory, vfsPath: "/wordpress" }] : []),
],
wordpressInstallMode,
} : {}),
...(wordpressDirectory ? { wordpressInstallMode } : {}),
wp: localAssetServer?.url ?? wordpressStartupAsset?.wp,
php: spec.environment.phpVersion,
skipSqliteSetup: spec.environment.databaseSetup === "external",
Expand Down Expand Up @@ -372,7 +372,8 @@ function runtimeAutoPrependPhp(spec: RuntimeCreateSpec): string {
}

function runtimeAutoPrependPhpBody(spec: RuntimeCreateSpec): string {
return `${phpEnvAssignments(spec.runtimeEnv ?? {})}${distributionBootstrapPhp(spec)}`
const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(spec.runtimeEnv ?? {}) : ""
return `${runtimeEnv}${distributionBootstrapPhp(spec)}`
}

function distributionBootstrapPhp(spec: RuntimeCreateSpec): string {
Expand Down
Loading
Loading