diff --git a/.codex/flows/openai-codex-bindings/exec/update-bindings.ts b/.codex/flows/openai-codex-bindings/exec/update-bindings.ts index f67bc00..e6f3e10 100644 --- a/.codex/flows/openai-codex-bindings/exec/update-bindings.ts +++ b/.codex/flows/openai-codex-bindings/exec/update-bindings.ts @@ -1,8 +1,12 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; type FlowContext = { flow: { + name?: string; + root?: string; + step?: string; config?: Record; event: { id: string; @@ -10,6 +14,9 @@ type FlowContext = { payload: Record; }; }; + runtime?: { + workspaceBackendUrl?: string; + }; }; type CommandResult = { @@ -23,7 +30,7 @@ type CommandResult = { const context = JSON.parse(await Bun.stdin.text()) as FlowContext; const config = context.flow.config ?? {}; -const repoRoot = process.cwd(); +const repoRoot = path.resolve(envConfig(stringConfig("codex_flows_repo_env", "")) || stringConfig("codex_flows_repo", process.cwd())); const commands: CommandResult[] = []; try { @@ -52,15 +59,20 @@ try { await updatePackageVersion(packageJsonPath, version); await run("refresh Bun lockfile", ["bun", "install"]); + + const changedStatus = await run("changed file status", ["git", "status", "--short"]); + if (!changedStatus.stdout.trim()) { + finish("skipped", `No generated binding changes for ${tag}.`, { version, tag }); + } + + const followupTurn = await maybeRunFollowupTurn({ tag, version, changedStatus: changedStatus.stdout }); + await run("codex-flows package release check", ["bun", "run", "--filter", packageName, "release:check"]); await run("workspace typecheck", ["bun", "run", "check:types"]); await run("workspace tests", ["bun", "run", "test"]); await run("git diff check", ["git", "diff", "--check"]); const status = await run("final git status", ["git", "status", "--short"]); - if (!status.stdout.trim()) { - finish("skipped", `No generated binding changes for ${tag}.`, { version, tag }); - } if (enabled("commit", true)) { await run("stage binding update", ["git", "add", "--", generatedDir, packageJsonPath, path.join(repoRoot, "bun.lock")]); @@ -96,6 +108,7 @@ try { committed: enabled("commit", true), pushed: enabled("push", false), published: enabled("publish", false), + followupTurn, }); } catch (error) { finish("failed", error instanceof Error ? error.message : String(error)); @@ -127,6 +140,89 @@ async function updatePackageVersion(packageJsonPath: string, version: string): P await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`); } +async function maybeRunFollowupTurn(input: { + tag: string; + version: string; + changedStatus: string; +}): Promise | undefined> { + if (!enabled("followup_turn", true)) { + return undefined; + } + const runCodexAgentTurnFromFlow = await loadRunCodexAgentTurnFromFlow(); + const threadJson = stringConfig("followup_thread_json", ".codex/flow-artifacts/openai-codex-bindings-thread.json"); + const prompt = [ + `OpenAI Codex ${input.tag} regenerated app-server TypeScript bindings for @peezy.tech/codex-flows ${input.version}.`, + "", + "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.", + "Do not push, publish, or edit release metadata beyond what this flow already changed.", + "", + "Current changed files:", + input.changedStatus.trim(), + ].join("\n"); + const turn = await runCodexAgentTurnFromFlow( + { + flow: { + name: context.flow.name ?? "openai-codex-bindings", + root: repoRoot, + step: context.flow.step ?? "regenerate-bindings", + event: context.flow.event, + }, + runtime: context.runtime, + }, + { + prompt, + cwd: repoRoot, + approvalPolicy: "never", + sandbox: "danger-full-access", + appServerUrl: process.env.CODEX_APP_SERVER_URL, + requestTimeoutMs: numberConfig("followup_request_timeout_ms", 900000), + wait: { + timeoutMs: numberConfig("followup_wait_timeout_ms", 900000), + throwOnFailure: true, + }, + exportThreadJson: threadJson || false, + }, + ) as { + artifacts?: Record; + threadId?: string; + turnId?: string; + threadJsonPath?: string; + }; + return { + threadId: turn.threadId, + turnId: turn.turnId, + threadJsonPath: turn.threadJsonPath, + ...(turn.artifacts ?? {}), + }; +} + +async function loadRunCodexAgentTurnFromFlow(): Promise<( + context: unknown, + options: Record, +) => Promise> { + const candidates = [ + "@peezy.tech/codex-flows/flows", + pathToFileURL(path.join(repoRoot, "packages/codex-client/src/app-server/flows.ts")).href, + ]; + const errors: string[] = []; + for (const candidate of candidates) { + try { + const module = await import(candidate) as { runCodexAgentTurnFromFlow?: unknown }; + if (typeof module.runCodexAgentTurnFromFlow === "function") { + return module.runCodexAgentTurnFromFlow as ( + context: unknown, + options: Record, + ) => Promise; + } + errors.push(`${candidate}: runCodexAgentTurnFromFlow is not exported`); + } catch (error) { + errors.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`); + } + } + throw new Error(`Could not load codex-flows follow-up turn helper:\n${errors.join("\n")}`); +} + async function run( label: string, cmd: string[], @@ -171,11 +267,23 @@ function enabled(name: string, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } +function envConfig(name: string): string | undefined { + return name.trim() ? process.env[name]?.trim() || undefined : undefined; +} + function stringConfig(name: string, fallback: string): string { const value = config[name]; return typeof value === "string" && value.trim() ? value : fallback; } +function numberConfig(name: string, fallback: number): number { + 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; +} + function stringValue(value: unknown, name: string): string { if (typeof value !== "string" || !value.trim()) { throw new Error(`${name} must be a non-empty string`); diff --git a/.codex/flows/openai-codex-bindings/flow.toml b/.codex/flows/openai-codex-bindings/flow.toml index a81250a..d97f1cf 100644 --- a/.codex/flows/openai-codex-bindings/flow.toml +++ b/.codex/flows/openai-codex-bindings/flow.toml @@ -4,9 +4,13 @@ description = "Regenerate @peezy.tech/codex-flows bindings from a canonical open [config] package_name = "@peezy.tech/codex-flows" +codex_flows_repo_env = "PEEZY_CODEX_FLOWS_REPO" +codex_flows_repo = "/home/peezy/meta-workspace/codex-flows" generated_dir = "packages/codex-client/src/app-server/generated" package_json = "packages/codex-client/package.json" commit = true +followup_turn = true +followup_thread_json = ".codex/flow-artifacts/openai-codex-bindings-thread.json" push = false publish = false github_repo = "peezy-tech/codex-flows" diff --git a/.codex/flows/peezy-codex-fork/exec/update-fork.code-mode.js b/.codex/flows/peezy-codex-fork/exec/update-fork.code-mode.js index 4b31b03..2428e0a 100644 --- a/.codex/flows/peezy-codex-fork/exec/update-fork.code-mode.js +++ b/.codex/flows/peezy-codex-fork/exec/update-fork.code-mode.js @@ -1,5 +1,6 @@ const config = flow.config || {}; const payload = flow.event.payload || {}; +const eventType = String(flow.event.type || ""); const commands = []; function q(value) { @@ -88,16 +89,16 @@ async function run(label, cmd, options = {}) { yield_time_ms: options.yield_time_ms || 1000, max_output_tokens: options.max_output_tokens || 12000 }); - const result = { + const command = { label, cmd, workdir, exit_code: exitCodeOf(raw), output: outputOf(raw) }; - commands.push({ ...result, output: truncate(result.output, 4000) }); - text("exit_code=" + String(result.exit_code) + "\n" + truncate(result.output, options.textLimit || 12000) + "\n"); - return result; + commands.push({ ...command, output: truncate(command.output, 4000) }); + text("exit_code=" + String(command.exit_code) + "\n" + truncate(command.output, options.textLimit || 12000) + "\n"); + return command; } function finish(status, message, artifacts = {}) { @@ -105,56 +106,541 @@ function finish(status, message, artifacts = {}) { status, message, artifacts: { + eventType, + operation, releaseTag, version, codexRepo, targetBranch, + upstreamBranch, + patchPrefix, commands, ...artifacts } }); } -async function collectRebaseContext(rebaseOutput, beforeSha) { - const status = await run("rebase conflict status", "git status --short --branch", { max_output_tokens: 12000 }); +async function collectConflictContext(params) { + const status = await run("patch rebuild conflict status", "git status --short --branch", { max_output_tokens: 12000 }); const unmerged = await run("unmerged files", "git diff --name-only --diff-filter=U", { max_output_tokens: 12000 }); const diffStat = await run("conflict diff stat", "git diff --cc --stat", { max_output_tokens: 12000 }); const conflictDiff = await run("conflict diff", "git diff --cc", { max_output_tokens: 30000, textLimit: 20000 }); - const currentPatch = await run("current rebase patch", "git rebase --show-current-patch", { max_output_tokens: 20000, textLimit: 12000 }); + const patchDetails = params.failedPatch + ? await run( + "failed patch details", + "git show --stat --oneline --decorate --no-renames " + q(params.failedPatch.sha), + { max_output_tokens: 16000, textLimit: 12000 } + ) + : undefined; return { - beforeSha, - rebaseOutput, + beforeSha: params.beforeSha, + baseRef: params.baseRef, + baseSha: params.baseSha, + rebuildOutput: params.rebuildOutput, statusOutput: status.output, unmergedFiles: unmerged.output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean), diffStat: diffStat.output, conflictDiff: truncate(conflictDiff.output, 20000), - currentPatch: truncate(currentPatch.output, 12000), - interventionPrompt: "Continue this same Code Mode thread to resolve the paused rebase. Preserve the fork patch stack, do not abort or reset unless explicitly instructed, then run the configured verification commands." + failedPatch: params.failedPatch, + patchDetails: patchDetails ? truncate(patchDetails.output, 12000) : undefined, + applied: params.applied, + interventionPrompt: [ + "Continue this same Code Mode thread to resolve the paused cherry-pick.", + "Preserve the fork patch branch semantics: each patch/* branch remains one logical patch commit, main is rebuilt output, and upstream follows upstream/main.", + "After resolving conflicts, continue the cherry-pick, update main to the rebuilt HEAD, switch back to main, and run the configured verification commands." + ].join(" ") + }; +} + +async function requireCleanWorktree() { + const status = await run("dirty check", "git status --porcelain=v1", { max_output_tokens: 12000 }); + if (trim(status.output)) { + finish("blocked", "Codex checkout has local changes before fork maintenance.", { + dirtyStatus: status.output + }); + } +} + +async function requireNoPausedGitOperation() { + const cherryPick = await run( + "check existing cherry-pick state", + "test -f \"$(git rev-parse --git-path CHERRY_PICK_HEAD)\"", + { max_output_tokens: 4000 } + ); + if (cherryPick.exit_code === 0) { + const context = await collectConflictContext({ + beforeSha: undefined, + baseRef: undefined, + baseSha: undefined, + rebuildOutput: "A cherry-pick was already in progress before this flow started.", + applied: [] + }); + finish("blocked", "A cherry-pick is already in progress in the Codex checkout.", context); + } + + const rebase = await run( + "check existing rebase state", + "test -d \"$(git rev-parse --git-path rebase-merge)\" -o -d \"$(git rev-parse --git-path rebase-apply)\"", + { max_output_tokens: 4000 } + ); + if (rebase.exit_code === 0) { + finish("blocked", "A rebase is already in progress in the Codex checkout.", { + statusOutput: (await run("rebase status", "git status --short --branch", { max_output_tokens: 12000 })).output + }); + } +} + +async function ensureUpstreamRemote() { + const remote = await run( + "ensure upstream openai/codex remote", + "git remote get-url " + q(upstreamRemote) + " >/dev/null 2>&1 && git remote set-url " + q(upstreamRemote) + " " + q(upstreamRepoUrl) + " || git remote add " + q(upstreamRemote) + " " + q(upstreamRepoUrl), + { max_output_tokens: 12000 } + ); + if (!ok(remote)) { + finish("failed", "Could not configure upstream remote.", { remoteOutput: remote.output }); + } +} + +async function fetchUpstreamMainAndTags() { + const refspec = "+refs/heads/" + upstreamMainRef + ":refs/remotes/" + upstreamRemote + "/" + upstreamMainRef; + const fetch = await run( + "fetch upstream main and tags", + "git fetch " + q(upstreamRemote) + " --tags --prune " + q(refspec), + { max_output_tokens: 20000, textLimit: 16000 } + ); + if (!ok(fetch)) { + finish("failed", "Could not fetch upstream main and release tags.", { fetchOutput: fetch.output }); + } + const remoteMain = await run( + "resolve upstream main", + "git rev-parse --verify " + q("refs/remotes/" + upstreamRemote + "/" + upstreamMainRef + "^{commit}"), + { max_output_tokens: 4000 } + ); + if (!ok(remoteMain)) { + finish("failed", "Could not resolve fetched upstream main.", { upstreamMainOutput: remoteMain.output }); + } + + const current = await currentBranch(); + if (current === upstreamBranch) { + const switched = await run("switch away from upstream branch", "git switch " + q(targetBranch), { + max_output_tokens: 12000 + }); + if (!ok(switched)) { + const detached = await run("detach from upstream branch", "git switch --detach", { max_output_tokens: 12000 }); + if (!ok(detached)) { + finish("failed", "Could not leave the upstream branch before updating it.", { + switchOutput: switched.output, + detachOutput: detached.output + }); + } + } + } + + const update = await run( + "update local upstream branch", + "git update-ref " + q("refs/heads/" + upstreamBranch) + " " + q(trim(remoteMain.output)), + { max_output_tokens: 12000 } + ); + if (!ok(update)) { + finish("failed", "Could not update local upstream branch.", { updateOutput: update.output }); + } + return trim(remoteMain.output); +} + +async function resolveReleaseBase() { + const release = await run( + "resolve release tag", + "git rev-parse --verify " + q("refs/tags/" + releaseTag + "^{commit}"), + { max_output_tokens: 4000 } + ); + if (!ok(release)) { + finish("failed", "Could not resolve upstream release tag after fetch.", { + releaseTag, + resolveOutput: release.output + }); + } + return trim(release.output); +} + +async function currentBranch() { + const branch = await run("current branch", "git rev-parse --abbrev-ref HEAD", { max_output_tokens: 4000 }); + return ok(branch) ? trim(branch.output) : ""; +} + +async function resolveCommit(ref) { + const result = await run( + "resolve " + ref, + "git rev-parse --verify " + q(ref + "^{commit}"), + { max_output_tokens: 4000 } + ); + return ok(result) ? trim(result.output) : ""; +} + +async function resolveTree(ref) { + const result = await run( + "resolve tree " + ref, + "git rev-parse --verify " + q(ref + "^{tree}"), + { max_output_tokens: 4000 } + ); + return ok(result) ? trim(result.output) : ""; +} + +async function listPatchBranches() { + const refRoot = "refs/heads/" + patchPrefix.replace(/\/+$/, ""); + const result = await run( + "list patch branches", + "git for-each-ref --format='%(refname:short)%09%(objectname)%09%(contents:subject)' " + q(refRoot), + { max_output_tokens: 20000, textLimit: 16000 } + ); + if (!ok(result)) { + finish("failed", "Could not list patch branches.", { patchListOutput: result.output }); + } + const patches = trim(result.output) + .split(/\r?\n/) + .map((line) => { + const parts = line.split("\t"); + return { + name: parts[0] || "", + sha: parts[1] || "", + subject: parts[2] || "" + }; + }) + .filter((patch) => patch.name.startsWith(patchPrefix) && patch.sha) + .sort((left, right) => left.name.localeCompare(right.name)); + if (patches.length === 0) { + finish("blocked", "No patch branches were found for prefix " + patchPrefix + "."); + } + return patches; +} + +async function rebuildMainFromBase(baseRef, baseSha, beforeSha) { + const beforeTree = beforeSha ? await resolveTree(targetBranch) : ""; + const patches = await listPatchBranches(); + const detach = await run("checkout rebuild base", "git switch --detach " + q(baseSha), { + max_output_tokens: 12000 + }); + if (!ok(detach)) { + finish("failed", "Could not switch to the rebuild base.", { checkoutOutput: detach.output }); + } + + const applied = []; + for (const patch of patches) { + const pick = await run("apply " + patch.name, "git cherry-pick " + q(patch.sha), { + max_output_tokens: 30000, + textLimit: 20000 + }); + if (!ok(pick)) { + const context = await collectConflictContext({ + beforeSha, + baseRef, + baseSha, + rebuildOutput: pick.output, + failedPatch: patch, + applied + }); + finish("needs_intervention", "Patch workspace rebuild paused on " + patch.name + ".", context); + } + applied.push(patch); + } + + const afterHead = await run("rebuilt head", "git rev-parse HEAD", { max_output_tokens: 4000 }); + const afterSha = trim(afterHead.output); + const afterTree = await resolveTree("HEAD"); + + if (beforeTree && afterTree && beforeTree === afterTree) { + const restore = await run("restore target branch", "git switch " + q(targetBranch), { max_output_tokens: 12000 }); + if (!ok(restore)) { + finish("failed", "Rebuilt tree matched target, but switching back failed.", { restoreOutput: restore.output }); + } + return { + changed: false, + beforeSha, + afterSha: beforeSha, + rebuiltSha: afterSha, + applied + }; + } + + const update = await run("update target branch", "git branch -f " + q(targetBranch) + " " + q(afterSha), { + max_output_tokens: 12000 + }); + if (!ok(update)) { + finish("failed", "Could not update target branch after rebuild.", { updateOutput: update.output }); + } + const target = await run("switch target branch", "git switch " + q(targetBranch), { max_output_tokens: 12000 }); + if (!ok(target)) { + finish("failed", "Could not switch to target branch after rebuild.", { switchOutput: target.output }); + } + + return { + changed: beforeSha !== afterSha, + beforeSha, + afterSha, + rebuiltSha: afterSha, + applied + }; +} + +async function verifyBranchUpdateCandidate(rebuild) { + const diffCheck = await run("codex diff whitespace check", "git diff --check", { max_output_tokens: 12000 }); + if (!ok(diffCheck)) { + finish("failed", "Codex git diff --check failed after branch update.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + diffCheckOutput: diffCheck.output + }); + } + + const cargoCheck = await run( + "cargo check code mode packages", + "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo check -p codex-app-server -p codex-core -p codex-app-server-protocol", + { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } + ); + if (!ok(cargoCheck)) { + finish("failed", "Cargo check failed after branch update.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + cargoCheckOutput: cargoCheck.output + }); + } +} + +async function verifyReleaseCandidate(rebuild) { + const cargoVersion = await run( + "validate Cargo.toml version", + "grep -m1 '^version' " + q(codexRustDir + "/Cargo.toml") + " | sed -E 's/version *= *\"([^\"]+)\".*/\\1/'", + { max_output_tokens: 4000 } + ); + if (!ok(cargoVersion) || trim(cargoVersion.output) !== version) { + finish("failed", "Release tag does not match codex-rs/Cargo.toml version.", { + releaseTag, + version, + cargoVersion: trim(cargoVersion.output) + }); + } + + const build = await run( + "build fork binary", + "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo build -p codex-cli --bin codex", + { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } + ); + if (!ok(build)) { + finish("failed", "Fork binary build failed.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + buildOutput: build.output + }); + } + + const versionCheck = await run("verify fork binary", q(codexBinary) + " --version", { max_output_tokens: 4000 }); + if (!ok(versionCheck)) { + finish("failed", "Built fork binary did not run.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + versionOutput: versionCheck.output + }); + } + + const cargoCheck = await run( + "cargo check code mode packages", + "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo check -p codex-app-server -p codex-core -p codex-app-server-protocol", + { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } + ); + if (!ok(cargoCheck)) { + finish("failed", "Cargo check failed after release rebuild.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + cargoCheckOutput: cargoCheck.output + }); + } + + const protocolTest = await run( + "protocol code mode execute test", + "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo test -p codex-app-server-protocol thread_code_mode_execute -- --nocapture", + { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } + ); + if (!ok(protocolTest)) { + finish("failed", "Protocol Code Mode API test failed after release rebuild.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + protocolTestOutput: protocolTest.output + }); + } + + const fmt = await run("cargo fmt check", "cargo fmt --check", { + workdir: codexRustDir, + max_output_tokens: 20000 + }); + if (!ok(fmt)) { + finish("failed", "Cargo fmt --check failed after release rebuild.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + fmtOutput: fmt.output + }); + } + + const diffCheck = await run("codex diff whitespace check", "git diff --check", { max_output_tokens: 12000 }); + if (!ok(diffCheck)) { + finish("failed", "Codex git diff --check failed after release rebuild.", { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + diffCheckOutput: diffCheck.output + }); + } + + const artifacts = { + codexBinary, + codexVersion: trim(versionCheck.output) + }; + if (enabled("stage_npm_wrapper", true)) { + Object.assign(artifacts, await stageLocalNpmWrapper()); + } + return artifacts; +} + +async function detectLocalTargetTriple() { + const target = await run( + "detect local npm target triple", + [ + "case \"$(uname -s):$(uname -m)\" in", + " Linux:x86_64) printf x86_64-unknown-linux-musl ;;", + " Linux:aarch64|Linux:arm64) printf aarch64-unknown-linux-musl ;;", + " Darwin:x86_64) printf x86_64-apple-darwin ;;", + " Darwin:arm64) printf aarch64-apple-darwin ;;", + " *) exit 1 ;;", + "esac" + ].join("\n"), + { max_output_tokens: 4000 } + ); + if (!ok(target) || !trim(target.output)) { + finish("failed", "Could not infer local target triple for npm wrapper staging.", { + targetOutput: target.output + }); + } + return trim(target.output); +} + +async function stageLocalNpmWrapper() { + const targetTriple = await detectLocalTargetTriple(); + const binaryName = targetTriple.includes("windows") ? "codex.exe" : "codex"; + const stage = await run( + "stage local npm wrapper", + [ + "set -euo pipefail", + "stage_root=$(mktemp -d /tmp/peezy-codex-npm-stage.XXXXXX)", + "package_dir=\"$stage_root/package\"", + "python3 " + q(codexRepo + "/codex-cli/scripts/build_npm_package.py") + " --package codex --version " + q(version) + " --staging-dir \"$package_dir\"", + "mkdir -p \"$package_dir/vendor/" + targetTriple + "/codex\"", + "cp " + q(codexBinary) + " \"$package_dir/vendor/" + targetTriple + "/codex/" + binaryName + "\"", + "chmod 0755 \"$package_dir/vendor/" + targetTriple + "/codex/" + binaryName + "\"", + "node \"$package_dir/bin/codex.js\" --version", + "printf '\\nSTAGED_PACKAGE=%s\\n' \"$package_dir\"" + ].join("\n"), + { max_output_tokens: 20000, textLimit: 16000 } + ); + if (!ok(stage)) { + finish("failed", "Local npm wrapper staging failed.", { stageOutput: stage.output }); + } + const match = stage.output.match(/STAGED_PACKAGE=(.+)/); + const stagedPackage = match ? trim(match[1]) : ""; + if (enabled("link_local_package", false) && stagedPackage) { + const link = await run("link local npm wrapper with Bun", "bun pm link", { + workdir: stagedPackage, + max_output_tokens: 12000 + }); + if (!ok(link)) { + finish("failed", "Bun link of local Codex package failed.", { linkOutput: link.output, stagedPackage }); + } + } + return { + targetTriple, + stagedPackage, + linked: enabled("link_local_package", false) + }; +} + +async function maybePushTargetBranch(afterSha) { + if (!enabled("push", false)) { + return false; + } + const push = await run("push fork branch", "git push origin HEAD:" + q(targetBranch) + " --force-with-lease", { + max_output_tokens: 20000 + }); + if (!ok(push)) { + finish("failed", "Could not push rebuilt fork branch.", { pushOutput: push.output }); + } + return true; +} + +async function maybePublishReleaseTag() { + if (!enabled("publish", false)) { + return false; + } + const tagName = "rust-v" + version; + const existing = await run("check release tag", "git rev-parse --verify " + q("refs/tags/" + tagName), { + max_output_tokens: 4000 + }); + if (existing.exit_code !== 0) { + const tag = await run("create release tag", "git tag -a " + q(tagName) + " -m " + q("Release " + version), { + max_output_tokens: 12000 + }); + if (!ok(tag)) { + finish("failed", "Could not create release tag.", { tagOutput: tag.output }); + } + } + const pushTag = await run("push release tag", "git push origin " + q(tagName), { + max_output_tokens: 20000 + }); + if (!ok(pushTag)) { + finish("failed", "Could not push release tag.", { pushTagOutput: pushTag.output }); + } + return true; +} + +function candidateRef(afterSha, pushed) { + return { + kind: "branch", + repo: "peezy-tech/codex", + remote: pushed ? "origin" : "local", + ref: "refs/heads/" + targetBranch, + sha: afterSha, + pushed }; } -const releaseTag = String(payload.tag || ""); -const version = versionFromTag(releaseTag); const flowFlagOverrides = { force: await envFlag("CODEX_FLOW_FORCE"), push: await envFlag("CODEX_FLOW_PUSH"), publish: await envFlag("CODEX_FLOW_PUBLISH"), - squash_patch_stack: await envFlag("CODEX_FLOW_SQUASH_PATCH_STACK") + stage_npm_wrapper: await envFlag("CODEX_FLOW_STAGE_NPM_WRAPPER"), + link_local_package: await envFlag("CODEX_FLOW_LINK_LOCAL_PACKAGE") }; +const releaseTag = eventType === "upstream.release" ? String(payload.tag || "") : ""; +const version = releaseTag ? versionFromTag(releaseTag) : ""; +const operation = eventType === "upstream.release" ? "release-cycle" : "main-branch-update"; const packageName = cfg("package_name", "@peezy.tech/codex"); const targetBranch = (await env(cfg("target_branch_env", ""))) || cfg("target_branch", "main"); +const upstreamBranch = cfg("upstream_branch", "upstream"); +const patchPrefix = cfg("patch_prefix", "patch/"); const upstreamRemote = cfg("upstream_remote", "upstream"); +const upstreamMainRef = cfg("upstream_main_ref", "main"); const upstreamRepoUrl = cfg("upstream_repo_url", "https://github.com/openai/codex.git"); const cargoTargetDir = (await env(cfg("cargo_target_dir_env", ""))) || cfg("cargo_target_dir", "/tmp/peezy-codex-flow-target"); const codexRepo = (await env(cfg("codex_repo_env", ""))) || cfg("codex_repo", ""); const codexRustDir = codexRepo + "/codex-rs"; const codexBinary = cargoTargetDir + "/debug/codex"; -if (!releaseTag) { +if (eventType !== "upstream.release" && eventType !== "upstream.branch_update") { + finish("failed", "Unsupported event type " + eventType + "."); +} +if (String(payload.repo || "") !== "openai/codex") { + finish("skipped", "Ignoring upstream event for " + String(payload.repo || "unknown") + "."); +} +if (eventType === "upstream.release" && !releaseTag) { finish("failed", "Release payload is missing tag."); } -if (!version) { - finish("failed", "Could not infer semantic version from release tag " + releaseTag); +if (eventType === "upstream.release" && !version) { + finish("failed", "Could not infer semantic version from release tag " + releaseTag + "."); } if (!codexRepo) { finish("blocked", "No Codex fork checkout configured. Set codex_repo or codex_repo_env in flow.toml."); @@ -163,230 +649,76 @@ if (!codexRepo) { text([ "Peezy Codex fork update flow", "", - "Release: " + releaseTag, - "Version: " + version, + "Event type: " + eventType, + "Operation: " + operation, + releaseTag ? "Release: " + releaseTag : "Upstream ref: " + String(payload.ref || "refs/heads/" + upstreamMainRef), + version ? "Version: " + version : undefined, "Target branch: " + targetBranch, + "Upstream branch: " + upstreamBranch, + "Patch prefix: " + patchPrefix, "Codex repo: " + codexRepo, "Upstream remote: " + upstreamRemote + " -> " + upstreamRepoUrl, "Cargo target dir: " + cargoTargetDir -].join("\n") + "\n"); +].filter(Boolean).join("\n") + "\n"); -const published = await run("published fork package check", "npm view " + q(packageName + "@" + version) + " version --json", { - max_output_tokens: 4000 -}); -if (ok(published) && !enabled("force", false)) { - finish("skipped", packageName + "@" + version + " is already published."); +if (eventType === "upstream.release") { + const published = await run("published fork package check", "npm view " + q(packageName + "@" + version) + " version --json", { + max_output_tokens: 4000 + }); + if (ok(published) && !enabled("force", false)) { + finish("skipped", packageName + "@" + version + " is already published."); + } } const repoCheck = await run("verify codex repo", "git rev-parse --show-toplevel"); if (!ok(repoCheck)) { - finish("failed", "codex repo is not a git checkout", { repoCheck: repoCheck.output }); + finish("failed", "Codex repo is not a git checkout.", { repoCheck: repoCheck.output }); } const rustWorkspaceCheck = await run("verify codex Rust workspace", "test -f " + q(codexRustDir + "/Cargo.toml"), { max_output_tokens: 4000 }); if (!ok(rustWorkspaceCheck)) { - finish("failed", "codex Rust workspace was not found at the expected codex-rs path.", { + finish("failed", "Codex Rust workspace was not found at the expected codex-rs path.", { codexRustDir, rustWorkspaceCheck: rustWorkspaceCheck.output }); } -const existingRebase = await run( - "check existing rebase state", - "test -d \"$(git rev-parse --git-path rebase-merge)\" -o -d \"$(git rev-parse --git-path rebase-apply)\"", - { max_output_tokens: 4000 } -); -if (existingRebase.exit_code === 0) { - const context = await collectRebaseContext("A rebase was already in progress before this flow started.", undefined); - finish("blocked", "A rebase is already in progress in the Codex checkout.", context); -} - await run("codex status before update", "git status --short --branch", { max_output_tokens: 12000 }); -const branch = await run("current branch", "git rev-parse --abbrev-ref HEAD", { max_output_tokens: 4000 }); -if (!ok(branch)) { - finish("failed", "could not read current branch", { branchOutput: branch.output }); -} - -if (trim(branch.output) !== targetBranch) { - const dirtyBeforeSwitch = await run("dirty check before branch switch", "git status --porcelain=v1", { max_output_tokens: 12000 }); - if (trim(dirtyBeforeSwitch.output)) { - finish("blocked", "codex checkout has local changes before switching branches.", { - dirtyStatus: dirtyBeforeSwitch.output - }); - } - const switched = await run("switch target branch", "git switch " + q(targetBranch), { max_output_tokens: 12000 }); - if (!ok(switched)) { - finish("failed", "could not switch to target branch", { switchOutput: switched.output }); - } -} - -const dirty = await run("dirty check on target branch", "git status --porcelain=v1", { max_output_tokens: 12000 }); -if (trim(dirty.output)) { - finish("blocked", "codex target branch has local changes. Resolve or stash them before updating.", { - dirtyStatus: dirty.output - }); -} - -const remote = await run( - "ensure upstream openai/codex remote", - "git remote get-url " + q(upstreamRemote) + " >/dev/null 2>&1 && git remote set-url " + q(upstreamRemote) + " " + q(upstreamRepoUrl) + " || git remote add " + q(upstreamRemote) + " " + q(upstreamRepoUrl), - { max_output_tokens: 12000 } -); -if (!ok(remote)) { - finish("failed", "could not configure upstream remote", { remoteOutput: remote.output }); -} - -const fetch = await run("fetch upstream tags", "git fetch " + q(upstreamRemote) + " --tags --prune", { - max_output_tokens: 20000 -}); -if (!ok(fetch)) { - finish("failed", "could not fetch upstream release tags", { fetchOutput: fetch.output }); -} - -const releaseCommit = await run("resolve release tag", "git rev-parse --verify " + q("refs/tags/" + releaseTag + "^{commit}"), { - max_output_tokens: 4000 -}); -if (!ok(releaseCommit)) { - finish("failed", "could not resolve upstream release tag after fetch", { - releaseTag, - resolveOutput: releaseCommit.output - }); -} - -const beforeHead = await run("codex head before rebase", "git rev-parse HEAD", { max_output_tokens: 4000 }); -const rebase = await run("rebase target branch onto upstream release", "git rebase " + q(releaseTag), { - max_output_tokens: 30000, - textLimit: 20000 -}); -if (!ok(rebase)) { - const context = await collectRebaseContext(rebase.output, trim(beforeHead.output)); - finish("needs_intervention", "Rebase paused with conflicts.", context); -} - -if (enabled("squash_patch_stack", true)) { - const count = await run("count fork patch commits", "git rev-list --count " + q(releaseTag) + "..HEAD", { - max_output_tokens: 4000 - }); - const commitCount = Number(trim(count.output)); - if (Number.isFinite(commitCount) && commitCount > 1) { - const reset = await run("squash patch stack reset", "git reset --soft " + q(releaseTag), { max_output_tokens: 12000 }); - if (!ok(reset)) { - finish("failed", "could not soft reset patch stack for squashing", { resetOutput: reset.output }); - } - const commit = await run("squash patch stack commit", "git commit -m " + q("peezy: codex fork patches for " + releaseTag), { - max_output_tokens: 20000 - }); - if (!ok(commit)) { - finish("failed", "could not commit squashed patch stack", { commitOutput: commit.output }); - } - } -} - -const afterHead = await run("codex head after rebase", "git rev-parse HEAD", { max_output_tokens: 4000 }); -await run("codex status after rebase", "git status --short --branch", { max_output_tokens: 12000 }); - -const build = await run( - "build fork binary", - "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo build -p codex-cli --bin codex", - { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } -); -if (!ok(build)) { - finish("failed", "fork binary build failed", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - buildOutput: build.output - }); -} - -const versionCheck = await run("verify fork binary", q(codexBinary) + " --version", { max_output_tokens: 4000 }); -if (!ok(versionCheck)) { - finish("failed", "built fork binary did not run", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - versionOutput: versionCheck.output - }); -} - -const cargoCheck = await run( - "cargo check code mode packages", - "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo check -p codex-app-server -p codex-core -p codex-app-server-protocol", - { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } -); -if (!ok(cargoCheck)) { - finish("failed", "cargo check failed after rebase", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - cargoCheckOutput: cargoCheck.output - }); -} - -const protocolTest = await run( - "protocol code mode execute test", - "CARGO_TARGET_DIR=" + q(cargoTargetDir) + " cargo test -p codex-app-server-protocol thread_code_mode_execute -- --nocapture", - { workdir: codexRustDir, max_output_tokens: 30000, textLimit: 20000 } -); -if (!ok(protocolTest)) { - finish("failed", "protocol Code Mode API test failed after rebase", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - protocolTestOutput: protocolTest.output - }); -} - -const fmt = await run("cargo fmt check", "cargo fmt --check", { - workdir: codexRustDir, - max_output_tokens: 20000 -}); -if (!ok(fmt)) { - finish("failed", "cargo fmt --check failed after rebase", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - fmtOutput: fmt.output - }); -} - -const diffCheck = await run("codex diff whitespace check", "git diff --check", { max_output_tokens: 12000 }); -if (!ok(diffCheck)) { - finish("failed", "codex git diff --check failed after rebase", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - diffCheckOutput: diffCheck.output - }); -} - -if (enabled("push", false)) { - const push = await run("push fork branch", "git push origin HEAD:" + q(targetBranch) + " --force-with-lease", { - max_output_tokens: 20000 - }); - if (!ok(push)) { - finish("failed", "could not push rebased fork branch", { pushOutput: push.output }); - } -} - -if (enabled("publish", false)) { - const tagCommand = "git tag -a " + q("rust-v" + version) + " -m " + q("Release " + version); - const tag = await run("create release tag", tagCommand, { max_output_tokens: 12000 }); - if (!ok(tag)) { - finish("failed", "could not create release tag", { tagOutput: tag.output }); - } - const pushTag = await run("push release tag", "git push origin " + q("rust-v" + version), { - max_output_tokens: 20000 - }); - if (!ok(pushTag)) { - finish("failed", "could not push release tag", { pushTagOutput: pushTag.output }); - } -} +await requireCleanWorktree(); +await requireNoPausedGitOperation(); +await ensureUpstreamRemote(); +const upstreamMainSha = await fetchUpstreamMainAndTags(); +const baseSha = eventType === "upstream.release" ? await resolveReleaseBase() : upstreamMainSha; +const baseRef = eventType === "upstream.release" ? releaseTag : upstreamBranch; +const beforeSha = await resolveCommit(targetBranch); +const rebuild = await rebuildMainFromBase(baseRef, baseSha, beforeSha); +const verificationArtifacts = eventType === "upstream.release" + ? await verifyReleaseCandidate(rebuild) + : await verifyBranchUpdateCandidate(rebuild).then(() => ({})); +const pushed = await maybePushTargetBranch(rebuild.afterSha); +const published = eventType === "upstream.release" ? await maybePublishReleaseTag() : false; const finalStatus = await run("final codex status", "git status --short --branch", { max_output_tokens: 12000 }); -finish("changed", "Peezy Codex fork rebased onto upstream release and verified.", { - beforeSha: trim(beforeHead.output), - afterSha: trim(afterHead.output), - codexHead: trim(afterHead.output), - codexBinary, - codexVersion: trim(versionCheck.output), - finalStatus: finalStatus.output, - pushed: enabled("push", false), - published: enabled("publish", false) -}); +const status = rebuild.changed ? "changed" : "completed"; +finish( + status, + eventType === "upstream.release" + ? "Peezy Codex fork rebuilt from upstream release and verified." + : "Peezy Codex fork main rebuilt from upstream/main and verified.", + { + beforeSha: rebuild.beforeSha, + afterSha: rebuild.afterSha, + rebuiltSha: rebuild.rebuiltSha, + baseRef, + baseSha, + upstreamMainSha, + applied: rebuild.applied, + finalStatus: finalStatus.output, + pushed, + published, + candidateRefs: [candidateRef(rebuild.afterSha, pushed)], + ...verificationArtifacts + } +); diff --git a/.codex/flows/peezy-codex-fork/flow.toml b/.codex/flows/peezy-codex-fork/flow.toml index 5eed1c5..b1b6baf 100644 --- a/.codex/flows/peezy-codex-fork/flow.toml +++ b/.codex/flows/peezy-codex-fork/flow.toml @@ -1,26 +1,30 @@ name = "peezy-codex-fork" version = 1 -description = "Rebase the Peezy Codex fork patch stack onto a canonical openai/codex release." +description = "Maintain the Peezy Codex fork patch workspace from openai/codex releases and main updates." [config] package_name = "@peezy.tech/codex" codex_repo_env = "PEEZY_CODEX_REPO" codex_repo = "/home/peezy/meta-workspace/codex" target_branch_env = "PEEZY_CODEX_TARGET_BRANCH" -target_branch = "code-mode-exec-hooks" +target_branch = "main" +upstream_branch = "upstream" +patch_prefix = "patch/" upstream_remote = "upstream" +upstream_main_ref = "main" upstream_repo_url = "https://github.com/openai/codex.git" cargo_target_dir_env = "PEEZY_CODEX_CARGO_TARGET_DIR" cargo_target_dir = "/tmp/peezy-codex-flow-target" -squash_patch_stack = true +stage_npm_wrapper = true +link_local_package = false push = false publish = false [guidance] -skills = ["jojo-development-flow"] +skills = ["jojo-development-flow", "code-mode-flow-author"] [[steps]] -name = "rebase-patch-stack" +name = "release-cycle" runner = "code-mode" script = "exec/update-fork.code-mode.js" timeout_ms = 3600000 @@ -28,3 +32,13 @@ timeout_ms = 3600000 [steps.trigger] type = "upstream.release" schema = "schemas/upstream-release.schema.json" + +[[steps]] +name = "main-branch-update" +runner = "code-mode" +script = "exec/update-fork.code-mode.js" +timeout_ms = 3600000 + +[steps.trigger] +type = "upstream.branch_update" +schema = "schemas/upstream-branch-update.schema.json" diff --git a/.codex/flows/peezy-codex-fork/schemas/upstream-branch-update.schema.json b/.codex/flows/peezy-codex-fork/schemas/upstream-branch-update.schema.json new file mode 100644 index 0000000..2d8b904 --- /dev/null +++ b/.codex/flows/peezy-codex-fork/schemas/upstream-branch-update.schema.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "required": ["repo", "ref"], + "properties": { + "provider": { "type": "string" }, + "repo": { "type": "string", "enum": ["openai/codex"] }, + "repoOwner": { "type": "string" }, + "repoName": { "type": "string" }, + "ref": { "type": "string" }, + "sha": { "type": "string" }, + "url": { "type": "string" }, + "publishedAt": { "type": "string" }, + "sourceId": { "type": "string" }, + "entryId": { "type": "string" } + } +} diff --git a/.codex/pack-lock.json b/.codex/pack-lock.json index 3d0664f..1b02c32 100644 --- a/.codex/pack-lock.json +++ b/.codex/pack-lock.json @@ -7,12 +7,12 @@ "source": { "input": "../codex-flows", "type": "local", - "commit": "7083213cd6e8a3a3a175ca63529134f7807e2afe" + "commit": "c3905c73feff72add4f88ba8ec5c11cb1921c386" }, "sourcePath": "flows/openai-codex-bindings", "destinationPath": ".codex/flows/openai-codex-bindings", - "contentHash": "sha256:5fcb04e9525e94c24ca319fcefc99fd05ed973e9c640a5e27629e263f13ca968", - "installedAt": "2026-05-18T18:57:28.444Z" + "contentHash": "sha256:6bfd8118be031c145813dcacbf47f6939b2460aeeb0d8959075e6018297d0403", + "installedAt": "2026-05-18T19:14:31.421Z" }, { "name": "peezy-codex-fork", @@ -20,12 +20,12 @@ "source": { "input": "../codex-flows", "type": "local", - "commit": "7083213cd6e8a3a3a175ca63529134f7807e2afe" + "commit": "c3905c73feff72add4f88ba8ec5c11cb1921c386" }, "sourcePath": "flows/peezy-codex-fork", "destinationPath": ".codex/flows/peezy-codex-fork", - "contentHash": "sha256:521e7766cd1888fd54fc0c10a6d2a3a7f235d9a7c8aa2577b4f4681e3a8fdabe", - "installedAt": "2026-05-18T18:57:28.444Z" + "contentHash": "sha256:613f0733ebea22b191ed6e706ad09e05a2dc97aad16cdbe14db01065a1b06582", + "installedAt": "2026-05-18T19:14:31.421Z" } ] } diff --git a/apps/patch/feed-sources.json b/apps/patch/feed-sources.json index dc54b15..fead476 100644 --- a/apps/patch/feed-sources.json +++ b/apps/patch/feed-sources.json @@ -53,10 +53,15 @@ "defaultBranch": "main" }, "target": { - "provider": "github", - "repoFullName": "peezy-tech/codex", - "branch": "main", - "mode": "notify_only" + "mode": "workspace_flow", + "eventType": "upstream.branch_update", + "workspaceUrlEnv": "PATCH_WORKSPACE_BACKEND_URL", + "workspaceSecretEnv": "PATCH_WORKSPACE_BACKEND_SECRET", + "payload": { + "provider": "github", + "repo": "openai/codex", + "ref": "refs/heads/main" + } }, "pollIntervalSeconds": 300 }, diff --git a/apps/patch/src/cli.ts b/apps/patch/src/cli.ts index 1818ac4..2b53c80 100644 --- a/apps/patch/src/cli.ts +++ b/apps/patch/src/cli.ts @@ -7,6 +7,7 @@ import { discoverFlows, matchingSteps, type FlowEvent as RuntimeFlowEvent } from import { dispatchWorkspaceEventDetailed, maintenanceAttemptForWorkspaceDispatch, + patchUpstreamBranchUpdateEvent, patchUpstreamReleaseEvent, replayWorkspaceEventDetailed, type WorkspaceDispatchConfig, @@ -53,6 +54,7 @@ Usage: patch.moi attempts [--event-id ID] [--status STATUS] [--limit N] [--data-dir DIR] [--json] patch.moi run harness [--event FILE] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--json] patch.moi run codex-release --tag TAG [--repo openai/codex] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--allow-local] [--json] + patch.moi run codex-main [--sha SHA] [--repo openai/codex] [--ref refs/heads/main] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--allow-local] [--json] patch.moi run event --file FILE [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--json] patch.moi patch doctor [--repo DIR] [--main BRANCH] [--upstream BRANCH] [--json] patch.moi patch list [--repo DIR] [--prefix patch/] [--json] @@ -252,6 +254,19 @@ async function handleRun(positionals: string[], context: CliContext): Promise> { + return { + id: `${serviceSource}:upstream.branch_update:${input.repo}:${input.ref}${input.sha ? `:${input.sha}` : ""}`, + type: "upstream.branch_update", + source: serviceSource, + receivedAt: input.receivedAt ?? new Date().toISOString(), + payload: { + repo: input.repo, + ref: input.ref, + ...(input.sha ? { sha: input.sha } : {}), + }, + }; +} + export async function dispatchFlowEvent( event: FlowEvent, target: Partial = {}, diff --git a/apps/patch/test/cli.test.ts b/apps/patch/test/cli.test.ts index 864c741..4d11b0a 100644 --- a/apps/patch/test/cli.test.ts +++ b/apps/patch/test/cli.test.ts @@ -84,10 +84,38 @@ describe("patch.moi CLI", () => { expect(dryRun.code).toBe(0); expect(JSON.parse(dryRun.stdout).matches).toEqual([ { flow: "openai-codex-bindings", step: "regenerate-bindings", runner: "bun" }, - { flow: "peezy-codex-fork", step: "rebase-patch-stack", runner: "code-mode" }, + { flow: "peezy-codex-fork", step: "release-cycle", runner: "code-mode" }, ]); }); + test("dry-runs Codex main branch update matching", async () => { + const dryRun = await invoke([ + "run", + "codex-main", + "--sha", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--workspace-root", + workspaceRoot, + "--dry-run", + "--json", + ]); + + expect(dryRun.code).toBe(0); + expect(JSON.parse(dryRun.stdout)).toMatchObject({ + event: { + type: "upstream.branch_update", + payload: { + repo: "openai/codex", + ref: "refs/heads/main", + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + matches: [ + { flow: "peezy-codex-fork", step: "main-branch-update", runner: "code-mode" }, + ], + }); + }); + test("syncs a maintenance attempt from workspace run state", async () => { const dataDir = await mkdtemp(join(tmpdir(), "patch-cli-")); const store = new EventStore(dataDir); @@ -155,7 +183,7 @@ describe("patch.moi CLI", () => { test("sets up the Codex upstream remote when explicitly applied", async () => { const repo = await mkdtemp(join(tmpdir(), "patch-cli-codex-")); await mkdir(repo, { recursive: true }); - await git(repo, ["init", "-b", "code-mode-exec-hooks"]); + await git(repo, ["init", "-b", "main"]); await git(repo, ["remote", "add", "origin", "https://github.com/peezy-tech/codex"]); const result = await invoke([ @@ -170,7 +198,7 @@ describe("patch.moi CLI", () => { expect(result.code).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ path: repo, - branch: "code-mode-exec-hooks", + branch: "main", upstream: "https://github.com/openai/codex.git", addedUpstream: true, clean: true, diff --git a/apps/patch/test/feed.test.ts b/apps/patch/test/feed.test.ts index 1a99bfe..1006308 100644 --- a/apps/patch/test/feed.test.ts +++ b/apps/patch/test/feed.test.ts @@ -89,6 +89,10 @@ describe("feed watcher", () => { "github-openai-codex-main", "github-openai-codex-releases", ]); + expect(sources.find((item) => item.id === "github-openai-codex-main")?.target).toMatchObject({ + mode: "workspace_flow", + eventType: "upstream.branch_update", + }); }); test("first poll primes state without emitting old entries", async () => { @@ -210,6 +214,66 @@ describe("feed watcher", () => { }); }); + test("later main commit polls dispatch upstream branch update events", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "patch-feed-")); + const sourcesPath = join(dataDir, "sources.json"); + const branchSource: FeedSourceConfig = { + ...source, + target: { + mode: "workspace_flow", + eventType: "upstream.branch_update", + workspaceUrlEnv: "WORKSPACE_URL", + payload: { + repo: "openai/codex", + provider: "github", + ref: "refs/heads/main", + }, + }, + }; + await writeFile(sourcesPath, JSON.stringify({ sources: [branchSource] }), "utf8"); + await writeFile(join(dataDir, "feed-state.json"), JSON.stringify({ + "github-openai-codex-main": { + lastSeenId: "tag:github.com,2008:Grit::Commit/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + lastCheckedAt: "2026-05-12T09:00:00.000Z", + }, + }), "utf8"); + + let dispatchedBody = ""; + await pollFeedsOnce({ + dataDir, + sourcesPath, + discord: { enabled: false, notifyEvents: new Set(["push"]) }, + flowDispatch: { + env: { + WORKSPACE_URL: "https://workspace.example/events", + }, + fetchImpl: async (_url, init) => { + dispatchedBody = String(init.body); + const eventId = JSON.parse(String(init.body ?? "{}")).id; + return Response.json({ status: "accepted", eventId, runIds: [], matched: 1 }, { status: 202 }); + }, + }, + }, async () => { + return new Response(atom, { status: 200 }); + }); + + const flowEventText = await readFile(join(dataDir, "flow-events.jsonl"), "utf8"); + const flowEvent = JSON.parse(flowEventText.trim()) as Record; + expect(flowEvent.type).toBe("upstream.branch_update"); + expect(flowEvent.payload.repo).toBe("openai/codex"); + expect(flowEvent.payload.ref).toBe("refs/heads/main"); + expect(flowEvent.payload.sha).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(JSON.parse(dispatchedBody).id).toBe(flowEvent.id); + const attempt = JSON.parse((await readFile(join(dataDir, "maintenance-attempts.jsonl"), "utf8")).trim()); + expect(attempt).toMatchObject({ + eventType: "upstream.branch_update", + status: "started", + upstreamRepo: "openai/codex", + upstreamRef: "refs/heads/main", + upstreamSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + }); + test("workspace dispatch uses default workspace backend env names", async () => { let dispatchedUrl = ""; let dispatchedSignature = ""; diff --git a/apps/patch/test/flow.test.ts b/apps/patch/test/flow.test.ts index 14907d0..49341fb 100644 --- a/apps/patch/test/flow.test.ts +++ b/apps/patch/test/flow.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { FlowRunView } from "@peezy.tech/codex-flows/flow-runtime/client"; import { + patchUpstreamBranchUpdateEvent, maintenanceAttemptForWorkspaceDispatch, maintenanceAttemptWithWorkspaceRuns, patchUpstreamReleaseEvent, @@ -88,6 +89,25 @@ describe("maintenance attempt sync", () => { { ref: "refs/heads/codex-candidate", sha: "def" }, ]); }); + + test("Patch upstream branch helper creates deterministic product events", () => { + expect(patchUpstreamBranchUpdateEvent({ + repo: "openai/codex", + ref: "refs/heads/main", + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + receivedAt: "2026-05-16T00:00:00.000Z", + })).toEqual({ + id: "patch:upstream.branch_update:openai/codex:refs/heads/main:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + type: "upstream.branch_update", + source: "patch", + receivedAt: "2026-05-16T00:00:00.000Z", + payload: { + repo: "openai/codex", + ref: "refs/heads/main", + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }); + }); }); function statusFor(statuses: string[]): string { diff --git a/apps/patch/test/harness-flow.test.ts b/apps/patch/test/harness-flow.test.ts index 034e7d5..b0c6c81 100644 --- a/apps/patch/test/harness-flow.test.ts +++ b/apps/patch/test/harness-flow.test.ts @@ -66,7 +66,7 @@ describe("patch.moi harness flow", () => { expect(matches.map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toEqual([ "openai-codex-bindings/regenerate-bindings", - "peezy-codex-fork/rebase-patch-stack", + "peezy-codex-fork/release-cycle", ]); const codeModeMatch = matches.find((entry) => entry.flow.manifest.name === "peezy-codex-fork"); diff --git a/docs/pages/concepts/codex-fork-model.md b/docs/pages/concepts/codex-fork-model.md index deab127..658cbfa 100644 --- a/docs/pages/concepts/codex-fork-model.md +++ b/docs/pages/concepts/codex-fork-model.md @@ -68,8 +68,8 @@ The Codex fork makes the desired model concrete: - `origin/main` is a useful local comparison baseline, but a real upstream remote is still needed for canonical release tags. -- `code-mode-exec-hooks` is the maintained patch branch, but internal use is - not simply "run from this branch." +- `main` is the maintained output branch; `code-mode-exec-hooks` remains only a + legacy reference for the initial conversion. - `rust-v0.130.0` at the branch head is a downstream candidate or release tag. - the npm package rename to `@peezy.tech/*` is part of the patch stack, not a Patch service setting. @@ -91,12 +91,16 @@ git remote get-url origin git remote get-url upstream || git remote add upstream https://github.com/openai/codex.git git fetch upstream --tags --prune git fetch origin --prune -git rev-list --oneline origin/main..code-mode-exec-hooks +git branch --list 'patch/*' +git status --short --branch ``` For an upstream release event, the workspace can resolve the upstream tag, -rebase `code-mode-exec-hooks`, run Codex-specific checks, and push a candidate -branch or tag when policy allows. +rebuild `main` from that tag plus ordered `patch/*` branches, run Codex-specific +checks, and push a candidate branch or tag when policy allows. For an upstream +main commit event, the workspace updates the local `upstream` branch from +`upstream/main` and rebuilds `main` from that branch plus the same patch +inventory. In service mode, that work should be triggered through the remote fork host. For example, patch.moi can create or update a maintenance branch on GitHub, trigger @@ -109,7 +113,7 @@ service's durable execution surface. Feature development should happen in a separate workspace or branch. A new custom feature starts from the current maintained branch, produces commits, and only becomes part of the maintained patch stack after it is intentionally merged -or rebased into `code-mode-exec-hooks`. +or captured into a single logical `patch/*` branch. ## Channel Split diff --git a/docs/pages/concepts/codex-use-case.md b/docs/pages/concepts/codex-use-case.md index d0ae76c..92e51f7 100644 --- a/docs/pages/concepts/codex-use-case.md +++ b/docs/pages/concepts/codex-use-case.md @@ -5,26 +5,27 @@ description: How patch.moi applies to Codex fork maintenance. # Codex Use Case -patch.moi watches OpenAI Codex branch and release feeds. Branch activity can -notify operators. Release activity can emit a deterministic `upstream.release` -flow event that starts Codex fork maintenance. +patch.moi watches OpenAI Codex branch and release feeds. Branch activity emits +deterministic `upstream.branch_update` events for main-branch maintenance. +Release activity emits deterministic `upstream.release` events for release-cycle +maintenance. The concrete local model is the neighboring `../codex` checkout: - `origin` points at `https://github.com/peezy-tech/codex`. -- `code-mode-exec-hooks` is the maintained patch branch. -- `origin/main` is the current comparison branch. -- `rust-v0.130.0` is a downstream tag on the maintained branch head. -- the patch stack contains Code Mode exec/replay work and Peezy npm release - changes. +- `main` is the rebuildable maintained fork output. +- `upstream` mirrors `upstream/main`. +- ordered `patch/*` branches hold the logical patch commits. +- `rust-v0.130.0` is a downstream tag on the legacy maintained branch head. +- the current patch inventory contains Code Mode exec/replay work and Peezy npm + release changes. -That checkout currently lacks an `upstream` remote, so a patch.moi setup flow -should add or confirm `https://github.com/openai/codex.git` before a release -rebase. +That checkout has the canonical `upstream` remote, so maintenance can fetch +OpenAI Codex main and release tags before rebuilding the fork output branch. -The Codex maintenance flow rebases the Peezy Codex fork patch stack onto a -canonical upstream release tag. That is a patch application workspace job, not -a public release by itself. +The Codex maintenance flow rebuilds the Peezy Codex fork from a canonical +upstream base plus ordered patch branches. That is a patch application workspace +job, not a public release by itself. Internal Codex use can track a fast-moving branch for local work. Public npm release can follow upstream release tags and trusted publishing. Those channels diff --git a/docs/pages/concepts/workspaces-and-channels.md b/docs/pages/concepts/workspaces-and-channels.md index 5194d13..9c73c21 100644 --- a/docs/pages/concepts/workspaces-and-channels.md +++ b/docs/pages/concepts/workspaces-and-channels.md @@ -38,9 +38,9 @@ The workspace may be local or runner-managed. The patch stack itself still lives in Git. Repo-native `codex-flows workspace` tasks are operator automation for running known commands; they are not a separate patch-stack database. -In the current Codex fork, this means carrying the commits on -`code-mode-exec-hooks` ahead of `origin/main` onto a canonical upstream release -tag from `openai/codex`. +In the current Codex fork, this means rebuilding `main` from a canonical +OpenAI Codex release tag or `upstream/main` plus the ordered local `patch/*` +branches. ## Feature Development Workspace diff --git a/docs/pages/guides/configure-feed-sources.md b/docs/pages/guides/configure-feed-sources.md index ac8b0bd..f96f807 100644 --- a/docs/pages/guides/configure-feed-sources.md +++ b/docs/pages/guides/configure-feed-sources.md @@ -46,8 +46,9 @@ backend adapter: ``` For patch-stack maintenance, prefer `workspace_flow` to create an -`upstream.release` or `upstream.update` trigger. Let the receiving workspace read -Git to discover the maintained patch branch and candidate refs. +`upstream.release` or `upstream.branch_update` trigger. Let the receiving +workspace read Git to discover the maintained branch, patch inventory, and +candidate refs. Use explicit payload fields for patch.moi-dispatched events. Do not rely on implicit workspace flow defaults for maintenance events, because patch.moi uses diff --git a/docs/pages/guides/maintain-a-fork.md b/docs/pages/guides/maintain-a-fork.md index df36b0e..5c8efe5 100644 --- a/docs/pages/guides/maintain-a-fork.md +++ b/docs/pages/guides/maintain-a-fork.md @@ -98,7 +98,18 @@ bun run patch.moi -- run codex-release \ The output should show the flow steps that will receive the event. For the current Codex release package, the expected fanout is the bindings update flow -and the fork rebase flow. +and the Codex fork release-cycle flow. + +Dry-run the upstream main branch update path separately: + +```bash +bun run patch.moi -- run codex-main \ + --sha '' \ + --dry-run \ + --json +``` + +That event should match the Codex fork `main-branch-update` Code Mode step. ## 5. Dispatch the maintenance attempt @@ -195,7 +206,8 @@ bun run patch.moi -- replay '' --json Flow packages decide exactly what `CODEX_FLOW_PUSH=1` means. The harness flow pushes configured branch refs with `--force-with-lease`. The Codex fork flow -pushes the maintained branch and, when configured to publish, release tags. +pushes the maintained `main` branch and, when configured to publish, release +tags. Keep `CODEX_FLOW_PUBLISH=0` unless you are intentionally entering the public release channel. diff --git a/docs/pages/reference/cli.md b/docs/pages/reference/cli.md index e934b02..dda751e 100644 --- a/docs/pages/reference/cli.md +++ b/docs/pages/reference/cli.md @@ -90,6 +90,14 @@ Verify Codex release flow matching without executing release work: bun run patch.moi -- run codex-release --tag rust-v0.130.0 --dry-run ``` +Verify Codex upstream main update matching without executing branch maintenance: + +```bash +bun run patch.moi -- run codex-main \ + --sha '' \ + --dry-run +``` + Dispatching the Codex release task requires an explicit execution surface. Use Actions/local mode when no workspace backend is running: diff --git a/docs/pages/reference/feed-sources.md b/docs/pages/reference/feed-sources.md index a6a1248..adc7872 100644 --- a/docs/pages/reference/feed-sources.md +++ b/docs/pages/reference/feed-sources.md @@ -51,6 +51,7 @@ adapter. The flow payload includes provider, event, source id, entry id, title, URL, author, published time, repository fields, ref, SHA, tag, and raw feed metadata. Values from `target.payload` are merged last. -For release maintenance, use a stable event type such as `upstream.release` and -include only routing hints in `payload`. Avoid copying branch topology into the -feed source when it can be read from the repository. +For release maintenance, use a stable event type such as `upstream.release`. +For upstream main movement, use `upstream.branch_update`. Include only routing +hints in `payload`; avoid copying branch topology into the feed source when it +can be read from the repository. diff --git a/docs/pages/reference/packages.md b/docs/pages/reference/packages.md index f5d9486..90f9e92 100644 --- a/docs/pages/reference/packages.md +++ b/docs/pages/reference/packages.md @@ -60,10 +60,11 @@ maintenance flows from the sibling `../codex-flows` repository: codex-flows pack doctor --json ``` -`openai-codex-bindings` and `peezy-codex-fork` both match -`upstream.release` events for `openai/codex`. They are installed capabilities, -not patch.moi product state. patch.moi still records feed-owned flow events, -workspace dispatches, and maintenance attempts under `DATA_DIR`. +`openai-codex-bindings` matches `upstream.release` events for `openai/codex`. +`peezy-codex-fork` matches both `upstream.release` and +`upstream.branch_update` events. They are installed capabilities, not patch.moi +product state. patch.moi still records feed-owned flow events, workspace +dispatches, and maintenance attempts under `DATA_DIR`. ## Related Runtime Package diff --git a/docs/pages/tutorials/dispatch-codex-release-flow.md b/docs/pages/tutorials/dispatch-codex-release-flow.md index 5fcd734..bfed6de 100644 --- a/docs/pages/tutorials/dispatch-codex-release-flow.md +++ b/docs/pages/tutorials/dispatch-codex-release-flow.md @@ -7,8 +7,9 @@ description: Connect the OpenAI Codex release feed to a Codex patch-stack mainte This tutorial connects upstream OpenAI Codex releases to Codex fork maintenance. Patch records the upstream release and dispatches a deterministic -event. The receiving workspace or runner rebases the maintained patch stack -onto the upstream release tag and verifies the candidate. +event. The receiving workspace or runner rebuilds the maintained `main` branch +from the upstream release tag plus the ordered `patch/*` branches, then +verifies the candidate. ## 1. Use the release source @@ -18,7 +19,13 @@ the upstream repository and release tag in the payload. The maintained Codex fork should still be modeled in Git. In the neighboring `../codex` checkout, `origin` is `https://github.com/peezy-tech/codex` and -`code-mode-exec-hooks` is the maintained patch branch. +the local branch shape is: + +```text +main rebuildable maintained fork output +upstream local mirror of upstream/main +patch/* ordered one-commit logical patches +``` Before running release maintenance, make sure the checkout has a canonical upstream remote: @@ -26,13 +33,13 @@ upstream remote: ```bash cd ../codex git remote get-url upstream || git remote add upstream https://github.com/openai/codex.git -git fetch upstream --tags --prune +git fetch upstream --tags --prune main git fetch origin --prune git status --short --branch ``` If `git status` shows local changes or untracked files, resolve them before an -automated rebase. +automated rebuild. ## 2. Install the Codex release capabilities @@ -55,8 +62,8 @@ repo flow. Safe local verification stops at event matching and runner gating. The test suite confirms that a stored `upstream.release` event for `openai/codex` -selects both installed Codex release steps, and that the Code Mode step still -requires `CODEX_FLOWS_MODE=code-mode`. Do not fabricate a full +selects both installed release steps, and that the Code Mode step still requires +`CODEX_FLOWS_MODE=code-mode`. Do not fabricate a full `openai/codex` release lifecycle just to exercise the flow. You can run the same safe match check through the CLI: @@ -113,10 +120,10 @@ or `X-Patch-Admin-Token: `. Patch dispatches the generic event. The installed Codex release flow or workspace owns the work that happens next: -- fetch upstream tags +- fetch upstream main and tags - resolve the release tag -- rebase the maintained patch branch -- collect conflict context when the rebase stops +- rebuild `main` from the release tag plus ordered `patch/*` branches +- collect conflict context when a cherry-pick stops - run the configured checks - optionally push a candidate ref