diff --git a/flows/openai-codex-bindings/exec/update-bindings.ts b/flows/openai-codex-bindings/exec/update-bindings.ts index e6f3e10..4b1b072 100644 --- a/flows/openai-codex-bindings/exec/update-bindings.ts +++ b/flows/openai-codex-bindings/exec/update-bindings.ts @@ -75,13 +75,7 @@ try { const status = await run("final git status", ["git", "status", "--short"]); if (enabled("commit", true)) { - await run("stage binding update", ["git", "add", "--", generatedDir, packageJsonPath, path.join(repoRoot, "bun.lock")]); - await run("commit binding update", [ - "git", - "commit", - "-m", - `flow: update codex-flows for openai codex ${version}`, - ]); + await commitRemainingChanges({ version, generatedDir, packageJsonPath }); } if (enabled("push", false)) { @@ -155,6 +149,8 @@ async function maybeRunFollowupTurn(input: { "", "Review the changed files and make any source, test, or export updates needed so the package still builds against the regenerated bindings.", "Keep changes focused on codex-flows compatibility with the regenerated app-server surface.", + "If you make source, test, export, or generated-file changes, commit them before finishing.", + `Use a focused commit message such as "flow: update codex-flows for openai codex ${input.version}".`, "Do not push, publish, or edit release metadata beyond what this flow already changed.", "", "Current changed files:", @@ -197,6 +193,38 @@ async function maybeRunFollowupTurn(input: { }; } +async function commitRemainingChanges(input: { + version: string; + generatedDir: string; + packageJsonPath: string; +}): Promise { + const status = await run("dirty check before release commit", ["git", "status", "--porcelain=v1"]); + if (!status.stdout.trim()) { + return undefined; + } + await run("stage release update", [ + "git", + "add", + "--", + input.generatedDir, + input.packageJsonPath, + path.join(repoRoot, "bun.lock"), + path.join(repoRoot, "packages/codex-client/src"), + path.join(repoRoot, "packages/codex-client/test"), + path.join(repoRoot, "packages/codex-client/scripts"), + ]); + const staged = await run("staged release update status", ["git", "diff", "--cached", "--quiet"], { allowFailure: true }); + if (staged.exitCode === 0) { + return undefined; + } + return await run("commit release update", [ + "git", + "commit", + "-m", + `flow: update codex-flows for openai codex ${input.version}`, + ]); +} + async function loadRunCodexAgentTurnFromFlow(): Promise<( context: unknown, options: Record, diff --git a/flows/peezy-codex-fork/exec/update-fork.ts b/flows/peezy-codex-fork/exec/update-fork.ts index f7356b1..efd6794 100644 --- a/flows/peezy-codex-fork/exec/update-fork.ts +++ b/flows/peezy-codex-fork/exec/update-fork.ts @@ -1,11 +1,21 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; + type FlowContext = { flow: { + name?: string; + root?: string; + step?: string; config?: Record; event: { + id?: string; type: string; payload?: Record; }; }; + runtime?: { + workspaceBackendUrl?: string; + }; }; type FlowResult = { @@ -47,8 +57,10 @@ let codexRepo = ""; let codexRustDir = ""; let codexBinary = ""; let flowFlagOverrides: Record = {}; +let flowContext: FlowContext; export default async function updateFork(context: FlowContext): Promise { + flowContext = context; config = context.flow.config ?? {}; payload = context.flow.event.payload ?? {}; eventType = String(context.flow.event.type || ""); @@ -176,7 +188,7 @@ async function collectConflictContext(params) { { max_output_tokens: 16000, textLimit: 12000 } ) : undefined; - return { + const context = { beforeSha: params.beforeSha, baseRef: params.baseRef, baseSha: params.baseSha, @@ -194,6 +206,133 @@ async function collectConflictContext(params) { "After resolving conflicts, continue the cherry-pick, update main to the rebuilt HEAD, switch back to main, and run the configured verification commands." ].join(" ") }; + return { + ...context, + interventionTurn: await maybeRunInterventionTurn(context) + }; +} + +async function maybeRunInterventionTurn(conflictContext) { + if (!enabled("intervention_turn", true)) { + return undefined; + } + try { + const runCodexAgentTurnFromFlow = await loadRunCodexAgentTurnFromFlow(); + const threadJson = cfg("intervention_thread_json", ".codex/flow-artifacts/peezy-codex-fork-intervention-thread.json"); + const prompt = interventionPrompt(conflictContext); + const turn = await runCodexAgentTurnFromFlow( + { + flow: { + name: flowContext.flow.name ?? "peezy-codex-fork", + root: flowContext.flow.root ?? codexRepo, + step: flowContext.flow.step ?? operation, + event: flowContext.flow.event, + }, + runtime: flowContext.runtime, + }, + { + prompt, + cwd: codexRepo, + approvalPolicy: "never", + sandbox: "danger-full-access", + appServerUrl: process.env.CODEX_APP_SERVER_URL, + requestTimeoutMs: numberConfig("intervention_request_timeout_ms", 900000), + wait: { + timeoutMs: numberConfig("intervention_wait_timeout_ms", 900000), + throwOnFailure: false, + }, + exportThreadJson: threadJson || false, + }, + ) as { + artifacts?: Record; + threadId?: string; + turnId?: string; + threadJsonPath?: string; + }; + return { + threadId: turn.threadId, + turnId: turn.turnId, + threadJsonPath: turn.threadJsonPath, + ...(turn.artifacts ?? {}), + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error) + }; + } +} + +function interventionPrompt(conflictContext) { + const failedPatch = conflictContext.failedPatch?.name; + const failedPatchLabel = failedPatch ?? "the failed patch branch"; + const branchRefresh = failedPatch + ? "After resolving this cherry-pick, run `git cherry-pick --continue`, update the failed patch branch to the new commit with `git branch -f " + failedPatch + " HEAD`, then continue applying any remaining ordered patch/* branches." + : "After resolving this cherry-pick, run `git cherry-pick --continue`, identify the corresponding patch/* branch, update it to the new commit, then continue applying any remaining ordered patch/* branches."; + const unmerged = Array.isArray(conflictContext.unmergedFiles) && conflictContext.unmergedFiles.length + ? conflictContext.unmergedFiles.map((file) => "- " + file).join("\n") + : "- none recorded"; + return [ + "The peezy-codex-fork release flow stopped during a Git patch-stack rebuild.", + "", + "Repository:", + codexRepo, + "", + "Release event:", + releaseTag ? "openai/codex " + releaseTag : "openai/codex upstream branch update", + "", + "Paused operation:", + "A cherry-pick of " + failedPatchLabel + " is in progress and needs conflict resolution.", + "", + "Unmerged files:", + unmerged, + "", + "Required outcome:", + "Resolve the cherry-pick in this checkout without aborting it.", + "Preserve the patch-stack model: each patch/* branch remains one logical patch commit, and main is rebuilt from the upstream release/base plus the ordered patch branches.", + branchRefresh, + "When all patch commits are applied, update `main` to the rebuilt HEAD and switch back to `main`.", + "Run the configured verification checks for this release flow before finishing.", + "", + "Important constraints:", + "Do not push, publish, delete unrelated branches, or revert unrelated user changes.", + "If you cannot complete the rebuild safely, leave the repository in its current paused state and explain the blocker.", + "", + "Conflict context:", + conflictContext.statusOutput || "", + "", + "Conflict diff:", + conflictContext.conflictDiff || "", + ].join("\n"); +} + +async function loadRunCodexAgentTurnFromFlow() { + const candidates = [ + "@peezy.tech/codex-flows/flows", + flowContext.flow.root + ? pathToFileURL(path.resolve(flowContext.flow.root, "../..", "packages/codex-client/src/app-server/flows.ts")).href + : undefined, + ].filter(Boolean); + const errors: string[] = []; + for (const candidate of candidates) { + try { + const module = await import(String(candidate)) as { runCodexAgentTurnFromFlow?: unknown }; + if (typeof module.runCodexAgentTurnFromFlow === "function") { + return module.runCodexAgentTurnFromFlow as (context: unknown, options: Record) => Promise; + } + errors.push(String(candidate) + ": runCodexAgentTurnFromFlow is not exported"); + } catch (error) { + errors.push(String(candidate) + ": " + (error instanceof Error ? error.message : String(error))); + } + } + throw new Error("Could not load codex-flows follow-up turn helper:\n" + errors.join("\n")); +} + +function numberConfig(name, fallback) { + const envName = "CODEX_FLOW_" + name.toUpperCase(); + const envValue = process.env[envName]; + const raw = envValue !== undefined ? envValue : config[name]; + const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback; + return Number.isFinite(value) ? value : fallback; } async function requireCleanWorktree() { @@ -212,11 +351,13 @@ async function requireNoPausedGitOperation() { { max_output_tokens: 4000 } ); if (cherryPick.exit_code === 0) { + const failedPatch = await currentCherryPickPatchBranch(); const context = await collectConflictContext({ beforeSha: undefined, baseRef: undefined, baseSha: undefined, rebuildOutput: "A cherry-pick was already in progress before this flow started.", + failedPatch, applied: [] }); finish("blocked", "A cherry-pick is already in progress in the Codex checkout.", context); @@ -234,6 +375,18 @@ async function requireNoPausedGitOperation() { } } +async function currentCherryPickPatchBranch() { + const head = await run("resolve paused cherry-pick commit", "git rev-parse --verify CHERRY_PICK_HEAD", { + max_output_tokens: 4000 + }); + if (!ok(head)) { + return undefined; + } + const sha = trim(head.output); + const patches = await listPatchBranches(); + return patches.find((patch) => patch.sha === sha); +} + async function ensureUpstreamRemote() { const remote = await run( "ensure upstream openai/codex remote", diff --git a/flows/peezy-codex-fork/flow.toml b/flows/peezy-codex-fork/flow.toml index f716bf5..1cada68 100644 --- a/flows/peezy-codex-fork/flow.toml +++ b/flows/peezy-codex-fork/flow.toml @@ -19,6 +19,8 @@ stage_npm_wrapper = true link_local_package = false push = false publish = false +intervention_turn = true +intervention_thread_json = ".codex/flow-artifacts/peezy-codex-fork-intervention-thread.json" [guidance] skills = ["jojo-development-flow", "bun-flow-author"]