Skip to content

Commit cdd5eb9

Browse files
committed
Persist bounded plan progress
1 parent 65ab3d5 commit cdd5eb9

4 files changed

Lines changed: 50 additions & 3 deletions

File tree

packages/cli/src/bounded-recipe-plan.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
import { mkdir, readFile, writeFile } from "node:fs/promises"
1+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
22
import { join } from "node:path"
3-
import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core"
3+
import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanEntryResult, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core"
44
import { recipeExecutionSpec } from "./agent-sandbox.js"
55

66
export interface BoundedRecipePlanExecutionOptions {
77
artifactRoot: string
88
recipeDirectory: string
99
}
1010

11+
const BOUNDED_RUNTIME_PLAN_PROGRESS_SCHEMA = "wp-codebox/bounded-runtime-plan-progress/v1"
12+
1113
export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRuntimePlan, options: BoundedRecipePlanExecutionOptions): Promise<BoundedRuntimePlanResult> {
14+
const progressPath = join(options.artifactRoot, "bounded-plan", "progress.json")
15+
const completed = new Map<string, BoundedRuntimePlanEntryResult>()
16+
let progressWrite = Promise.resolve()
17+
await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true })
18+
await writeBoundedPlanProgress(progressPath, plan, completed, false)
1219
const aggregate = await executeBoundedRuntimePlan(plan, {
1320
async materialize() { return { workspace: options.recipeDirectory, runtime } },
1421
async startServices() { return undefined },
@@ -50,10 +57,15 @@ export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRu
5057
}, null, 2)}\n`, "utf8")
5158
return { success: exitCode === 0, exitCode, message: stderr, stdoutRef, stderrRef, resultRef, artifactRefs }
5259
},
60+
async onEntryResult(result) {
61+
completed.set(result.id, result)
62+
progressWrite = progressWrite.then(async () => writeBoundedPlanProgress(progressPath, plan, completed, false))
63+
await progressWrite
64+
},
5365
async stopServices() {},
5466
async dispose() {},
5567
})
56-
await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true })
68+
await writeBoundedPlanProgress(progressPath, plan, completed, true)
5769
await writeFile(join(options.artifactRoot, "bounded-plan/result.json"), `${JSON.stringify(aggregate, null, 2)}\n`, "utf8")
5870
return aggregate
5971
}
@@ -76,3 +88,27 @@ function runtimeArtifactRefs(execution: ExecutionResult | undefined): string[] {
7688
return typeof value === "string" && value ? [value] : []
7789
})
7890
}
91+
92+
async function writeBoundedPlanProgress(path: string, plan: BoundedRuntimePlan, completed: Map<string, BoundedRuntimePlanEntryResult>, complete: boolean): Promise<void> {
93+
const entries = plan.entries.flatMap((entry) => {
94+
const result = completed.get(entry.id)
95+
return result ? [result] : []
96+
})
97+
const progress = {
98+
schema: BOUNDED_RUNTIME_PLAN_PROGRESS_SCHEMA,
99+
complete,
100+
concurrency: Math.min(plan.concurrency, plan.entries.length),
101+
counts: {
102+
total: plan.entries.length,
103+
succeeded: entries.filter((entry) => entry.status === "succeeded").length,
104+
failed: entries.filter((entry) => entry.status === "failed").length,
105+
timedOut: entries.filter((entry) => entry.status === "timed_out").length,
106+
cancelled: entries.filter((entry) => entry.status === "cancelled").length,
107+
unfinished: plan.entries.length - entries.length,
108+
},
109+
entries,
110+
}
111+
const temporaryPath = `${path}.tmp`
112+
await writeFile(temporaryPath, `${JSON.stringify(progress, null, 2)}\n`, "utf8")
113+
await rename(temporaryPath, path)
114+
}

packages/runtime-core/src/bounded-runtime-plan.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface BoundedRuntimePlanAdapter<TWorkspace = unknown, TRuntime = unkn
6464
materialize(): Promise<{ workspace: TWorkspace; runtime: TRuntime }>
6565
startServices(context: { workspace: TWorkspace; runtime: TRuntime }): Promise<TServices>
6666
execute(context: BoundedRuntimePlanExecution<TWorkspace, TRuntime, TServices>): Promise<{ success: boolean; exitCode?: number; message?: string; stdoutRef?: string; stderrRef?: string; resultRef?: string; artifactRefs?: string[] }>
67+
onEntryResult?(result: BoundedRuntimePlanEntryResult): Promise<void>
6768
stopServices(context: { workspace: TWorkspace; runtime: TRuntime; services: TServices }): Promise<void>
6869
dispose(context: { workspace: TWorkspace; runtime: TRuntime }): Promise<void>
6970
}
@@ -134,10 +135,12 @@ async function executeEntries<TWorkspace, TRuntime, TServices>(plan: BoundedRunt
134135
const entry = plan.entries[index]!
135136
if (failed && plan.failFast) {
136137
results[index] = cancelledResult(entry)
138+
await adapter.onEntryResult?.(results[index])
137139
continue
138140
}
139141
const result = await executeEntry(entry, materialized, services, adapter)
140142
results[index] = result
143+
await adapter.onEntryResult?.(result)
141144
if (!result.success) failed = true
142145
}
143146
}

tests/bounded-recipe-plan.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ try {
5757
assert.equal(await readFile(join(root, "entries/one/stdout.txt"), "utf8"), "password=[redacted]\n")
5858
assert.equal(await readFile(join(root, "entries/failed/stderr.txt"), "utf8"), "database [redacted] failed\n")
5959
assert.equal(JSON.parse(await readFile(join(root, "bounded-plan/result.json"), "utf8")).schema, "wp-codebox/bounded-runtime-plan-result/v1")
60+
const progress = JSON.parse(await readFile(join(root, "bounded-plan/progress.json"), "utf8"))
61+
assert.equal(progress.schema, "wp-codebox/bounded-runtime-plan-progress/v1")
62+
assert.equal(progress.complete, true)
63+
assert.deepEqual(progress.counts, { total: 2, succeeded: 1, failed: 1, timedOut: 0, cancelled: 0, unfinished: 0 })
64+
assert.deepEqual(progress.entries.map((entry: { id: string }) => entry.id), ["one", "failed"])
6065
} finally {
6166
await rm(root, { recursive: true, force: true })
6267
}

tests/bounded-runtime-plan.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const plan: BoundedRuntimePlan = {
1313
}
1414

1515
const lifecycle: string[] = []
16+
const completedEntries: string[] = []
1617
let active = 0
1718
let maximumActive = 0
1819
const executed: string[] = []
@@ -31,6 +32,7 @@ const adapter: BoundedRuntimePlanAdapter<{ root: string }, { id: string }, { id:
3132
active--
3233
}
3334
},
35+
async onEntryResult(result) { completedEntries.push(result.id) },
3436
async stopServices() { lifecycle.push("stop-services") },
3537
async dispose() { lifecycle.push("dispose") },
3638
}
@@ -44,6 +46,7 @@ assert.deepEqual(result.counts, { total: 4, succeeded: 2, failed: 1, timedOut: 1
4446
assert.equal(result.entries.every((entry) => Number.isInteger(entry.durationMs) && entry.durationMs >= 0), true)
4547
assert.deepEqual(lifecycle, ["materialize", "start-services", "stop-services", "dispose"], "workspace, service, and runtime lifecycles are each owned once")
4648
assert.deepEqual(executed.sort(), ["failed", "first", "last", "slow"], "one failed entry does not isolate unrelated entries")
49+
assert.deepEqual(completedEntries.sort(), ["failed", "first", "last", "slow"], "each terminal entry is reported to the adapter")
4750

4851
const retry = retryBoundedRuntimePlan(plan, result)
4952
assert.deepEqual(retry.entries.map((entry) => entry.id), ["failed", "slow"], "retry selects only unsuccessful prior entries")

0 commit comments

Comments
 (0)