From 39e843fb294a484b48fdb09833ac16f0cdeeb103 Mon Sep 17 00:00:00 2001 From: matamune Date: Sat, 16 May 2026 20:02:15 +0000 Subject: [PATCH] feat: install codex release flows --- .../exec/update-bindings.ts | 196 +++++++++ .codex/flows/openai-codex-bindings/flow.toml | 24 ++ .../schemas/upstream-release.schema.json | 11 + .../exec/update-fork.code-mode.js | 392 ++++++++++++++++++ .codex/flows/peezy-codex-fork/flow.toml | 30 ++ .../schemas/upstream-release.schema.json | 11 + .codex/pack-lock.json | 31 ++ apps/patch/test/flow.test.ts | 147 +++++++ apps/patch/test/harness-flow.test.ts | 35 ++ docs/pages/index.md | 2 + docs/pages/reference/packages.md | 15 + .../tutorials/dispatch-codex-release-flow.md | 31 +- 12 files changed, 922 insertions(+), 3 deletions(-) create mode 100644 .codex/flows/openai-codex-bindings/exec/update-bindings.ts create mode 100644 .codex/flows/openai-codex-bindings/flow.toml create mode 100644 .codex/flows/openai-codex-bindings/schemas/upstream-release.schema.json create mode 100644 .codex/flows/peezy-codex-fork/exec/update-fork.code-mode.js create mode 100644 .codex/flows/peezy-codex-fork/flow.toml create mode 100644 .codex/flows/peezy-codex-fork/schemas/upstream-release.schema.json create mode 100644 .codex/pack-lock.json create mode 100644 apps/patch/test/flow.test.ts diff --git a/.codex/flows/openai-codex-bindings/exec/update-bindings.ts b/.codex/flows/openai-codex-bindings/exec/update-bindings.ts new file mode 100644 index 0000000..f67bc00 --- /dev/null +++ b/.codex/flows/openai-codex-bindings/exec/update-bindings.ts @@ -0,0 +1,196 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type FlowContext = { + flow: { + config?: Record; + event: { + id: string; + type: string; + payload: Record; + }; + }; +}; + +type CommandResult = { + label: string; + cmd: string[]; + cwd: string; + exitCode: number | null; + stdout: string; + stderr: string; +}; + +const context = JSON.parse(await Bun.stdin.text()) as FlowContext; +const config = context.flow.config ?? {}; +const repoRoot = process.cwd(); +const commands: CommandResult[] = []; + +try { + const tag = stringValue(context.flow.event.payload.tag, "payload.tag"); + const version = versionFromTag(tag); + const packageName = stringConfig("package_name", "@peezy.tech/codex-flows"); + const generatedDir = path.resolve(repoRoot, stringConfig("generated_dir", "packages/codex-client/src/app-server/generated")); + const packageJsonPath = path.resolve(repoRoot, stringConfig("package_json", "packages/codex-client/package.json")); + + const published = await npmPackageExists(packageName, version); + if (published && !enabled("force", false)) { + finish("skipped", `${packageName}@${version} is already published.`, { version, tag }); + } + + await requireCleanWorktree(); + await run("regenerate app-server TypeScript bindings", [ + "npx", + "-y", + `@openai/codex@${version}`, + "app-server", + "generate-ts", + "--experimental", + "--out", + generatedDir, + ]); + + await updatePackageVersion(packageJsonPath, version); + await run("refresh Bun lockfile", ["bun", "install"]); + 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")]); + await run("commit binding update", [ + "git", + "commit", + "-m", + `flow: update codex-flows for openai codex ${version}`, + ]); + } + + if (enabled("push", false)) { + await run("push jojo main", ["git", "push", "origin", "HEAD:main"]); + } + + if (enabled("publish", false)) { + await run("push GitHub main", ["git", "push", "github", "HEAD:main"]); + await run("trigger GitHub trusted publish", [ + "gh", + "workflow", + "run", + stringConfig("github_publish_workflow", "publish-codex-flows.yml"), + "--repo", + stringConfig("github_repo", "peezy-tech/codex-flows"), + "-f", + `confirm_package=${packageName}`, + ]); + } + + finish("changed", `${packageName} regenerated for openai/codex ${tag}.`, { + version, + tag, + committed: enabled("commit", true), + pushed: enabled("push", false), + published: enabled("publish", false), + }); +} catch (error) { + finish("failed", error instanceof Error ? error.message : String(error)); +} + +async function requireCleanWorktree(): Promise { + const status = await run("dirty worktree check", ["git", "status", "--porcelain=v1"]); + if (status.stdout.trim()) { + finish("blocked", "codex-flows checkout has local changes before the release update.", { + dirtyStatus: status.stdout, + }); + } +} + +async function npmPackageExists(packageName: string, version: string): Promise { + const result = await run("published package check", [ + "npm", + "view", + `${packageName}@${version}`, + "version", + "--json", + ], { allowFailure: true }); + return result.exitCode === 0 && result.stdout.includes(version); +} + +async function updatePackageVersion(packageJsonPath: string, version: string): Promise { + const parsed = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + parsed.version = version; + await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`); +} + +async function run( + label: string, + cmd: string[], + options: { allowFailure?: boolean; cwd?: string } = {}, +): Promise { + const child = Bun.spawn(cmd, { + cwd: options.cwd ?? repoRoot, + env: process.env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + child.stdout.text(), + child.stderr.text(), + child.exited, + ]); + const result = { label, cmd, cwd: options.cwd ?? repoRoot, exitCode, stdout, stderr }; + commands.push(result); + if (exitCode !== 0 && !options.allowFailure) { + throw new Error(`${label} failed with exit ${exitCode}:\n${stderr || stdout}`); + } + return result; +} + +function finish(status: string, message: string, artifacts: Record = {}): never { + const trimmedCommands = commands.map((command) => ({ + ...command, + stdout: truncate(command.stdout), + stderr: truncate(command.stderr), + })); + console.log(`FLOW_RESULT ${JSON.stringify({ status, message, artifacts: { ...artifacts, commands: trimmedCommands } })}`); + process.exit(0); +} + +function enabled(name: string, fallback: boolean): boolean { + const envName = `CODEX_FLOW_${name.toUpperCase()}`; + const envValue = process.env[envName]; + if (envValue !== undefined) { + return ["1", "true", "yes", "on"].includes(envValue.trim().toLowerCase()); + } + const value = config[name]; + return typeof value === "boolean" ? value : fallback; +} + +function stringConfig(name: string, fallback: string): string { + const value = config[name]; + return typeof value === "string" && value.trim() ? 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`); + } + return value; +} + +function versionFromTag(tag: string): string { + const match = tag.match(/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?/); + if (!match) { + throw new Error(`Could not infer semantic version from release tag ${tag}`); + } + return match[0]; +} + +function truncate(value: string, max = 4000): string { + return value.length <= max ? value : `${value.slice(0, max)}\n...[truncated ${value.length - max} chars]`; +} diff --git a/.codex/flows/openai-codex-bindings/flow.toml b/.codex/flows/openai-codex-bindings/flow.toml new file mode 100644 index 0000000..a81250a --- /dev/null +++ b/.codex/flows/openai-codex-bindings/flow.toml @@ -0,0 +1,24 @@ +name = "openai-codex-bindings" +version = 1 +description = "Regenerate @peezy.tech/codex-flows bindings from a canonical openai/codex release." + +[config] +package_name = "@peezy.tech/codex-flows" +generated_dir = "packages/codex-client/src/app-server/generated" +package_json = "packages/codex-client/package.json" +commit = true +push = false +publish = false +github_repo = "peezy-tech/codex-flows" +github_publish_workflow = "publish-codex-flows.yml" + +[[steps]] +name = "regenerate-bindings" +runner = "bun" +script = "exec/update-bindings.ts" +cwd = "../.." +timeout_ms = 1200000 + +[steps.trigger] +type = "upstream.release" +schema = "schemas/upstream-release.schema.json" diff --git a/.codex/flows/openai-codex-bindings/schemas/upstream-release.schema.json b/.codex/flows/openai-codex-bindings/schemas/upstream-release.schema.json new file mode 100644 index 0000000..da9eea5 --- /dev/null +++ b/.codex/flows/openai-codex-bindings/schemas/upstream-release.schema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": ["repo", "tag"], + "properties": { + "provider": { "type": "string" }, + "repo": { "type": "string", "enum": ["openai/codex"] }, + "tag": { "type": "string" }, + "url": { "type": "string" }, + "publishedAt": { "type": "string" } + } +} 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 new file mode 100644 index 0000000..4b31b03 --- /dev/null +++ b/.codex/flows/peezy-codex-fork/exec/update-fork.code-mode.js @@ -0,0 +1,392 @@ +const config = flow.config || {}; +const payload = flow.event.payload || {}; +const commands = []; + +function q(value) { + return "'" + String(value).replaceAll("'", "'\\''") + "'"; +} + +function trim(value) { + return String(value || "").trim(); +} + +function truncate(value, max) { + const textValue = String(value || ""); + if (textValue.length <= max) { + return textValue; + } + return textValue.slice(0, max) + "\n...[truncated " + String(textValue.length - max) + " chars]"; +} + +function outputOf(result) { + if (typeof result?.output === "string") { + return result.output; + } + return JSON.stringify(result ?? {}); +} + +function exitCodeOf(result) { + if (typeof result?.exit_code === "number") { + return result.exit_code; + } + if (typeof result?.exitCode === "number") { + return result.exitCode; + } + return null; +} + +function ok(result) { + return result.exit_code === 0; +} + +function cfg(name, fallback) { + const value = config[name]; + return typeof value === "string" && value.trim() ? value : fallback; +} + +function enabled(name, fallback) { + const override = flowFlagOverrides[name]; + if (typeof override === "boolean") { + return override; + } + const value = config[name]; + return typeof value === "boolean" ? value : fallback; +} + +function versionFromTag(tag) { + const match = String(tag).match(/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?/); + return match ? match[0] : ""; +} + +async function env(name) { + if (!name) { + return ""; + } + const result = await tools.exec_command({ + cmd: "printf %s \"${" + name + ":-}\"", + workdir: flow.root, + yield_time_ms: 1000, + max_output_tokens: 2000 + }); + return trim(outputOf(result)); +} + +async function envFlag(name) { + const value = (await env(name)).trim().toLowerCase(); + if (!value) { + return undefined; + } + return ["1", "true", "yes", "on"].includes(value); +} + +async function run(label, cmd, options = {}) { + const workdir = options.workdir || codexRepo; + text("\n### " + label + "\n$ " + cmd + "\n"); + const raw = await tools.exec_command({ + cmd, + workdir, + yield_time_ms: options.yield_time_ms || 1000, + max_output_tokens: options.max_output_tokens || 12000 + }); + const result = { + 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; +} + +function finish(status, message, artifacts = {}) { + result({ + status, + message, + artifacts: { + releaseTag, + version, + codexRepo, + targetBranch, + commands, + ...artifacts + } + }); +} + +async function collectRebaseContext(rebaseOutput, beforeSha) { + const status = await run("rebase 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 }); + return { + beforeSha, + rebaseOutput, + 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." + }; +} + +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") +}; +const packageName = cfg("package_name", "@peezy.tech/codex"); +const targetBranch = (await env(cfg("target_branch_env", ""))) || cfg("target_branch", "main"); +const upstreamRemote = cfg("upstream_remote", "upstream"); +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) { + finish("failed", "Release payload is missing tag."); +} +if (!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."); +} + +text([ + "Peezy Codex fork update flow", + "", + "Release: " + releaseTag, + "Version: " + version, + "Target branch: " + targetBranch, + "Codex repo: " + codexRepo, + "Upstream remote: " + upstreamRemote + " -> " + upstreamRepoUrl, + "Cargo target dir: " + cargoTargetDir +].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."); +} + +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 }); +} + +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.", { + 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 }); + } +} + +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) +}); diff --git a/.codex/flows/peezy-codex-fork/flow.toml b/.codex/flows/peezy-codex-fork/flow.toml new file mode 100644 index 0000000..5eed1c5 --- /dev/null +++ b/.codex/flows/peezy-codex-fork/flow.toml @@ -0,0 +1,30 @@ +name = "peezy-codex-fork" +version = 1 +description = "Rebase the Peezy Codex fork patch stack onto a canonical openai/codex release." + +[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" +upstream_remote = "upstream" +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 +push = false +publish = false + +[guidance] +skills = ["jojo-development-flow"] + +[[steps]] +name = "rebase-patch-stack" +runner = "code-mode" +script = "exec/update-fork.code-mode.js" +timeout_ms = 3600000 + +[steps.trigger] +type = "upstream.release" +schema = "schemas/upstream-release.schema.json" diff --git a/.codex/flows/peezy-codex-fork/schemas/upstream-release.schema.json b/.codex/flows/peezy-codex-fork/schemas/upstream-release.schema.json new file mode 100644 index 0000000..da9eea5 --- /dev/null +++ b/.codex/flows/peezy-codex-fork/schemas/upstream-release.schema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required": ["repo", "tag"], + "properties": { + "provider": { "type": "string" }, + "repo": { "type": "string", "enum": ["openai/codex"] }, + "tag": { "type": "string" }, + "url": { "type": "string" }, + "publishedAt": { "type": "string" } + } +} diff --git a/.codex/pack-lock.json b/.codex/pack-lock.json new file mode 100644 index 0000000..d493ff4 --- /dev/null +++ b/.codex/pack-lock.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "items": [ + { + "name": "openai-codex-bindings", + "kind": "flow", + "source": { + "input": "../codex-flows", + "type": "local", + "commit": "5be9b571578409a31af4693caac8c949c06fe388" + }, + "sourcePath": "flows/openai-codex-bindings", + "destinationPath": ".codex/flows/openai-codex-bindings", + "contentHash": "sha256:5fcb04e9525e94c24ca319fcefc99fd05ed973e9c640a5e27629e263f13ca968", + "installedAt": "2026-05-16T19:56:06.687Z" + }, + { + "name": "peezy-codex-fork", + "kind": "flow", + "source": { + "input": "../codex-flows", + "type": "local", + "commit": "5be9b571578409a31af4693caac8c949c06fe388" + }, + "sourcePath": "flows/peezy-codex-fork", + "destinationPath": ".codex/flows/peezy-codex-fork", + "contentHash": "sha256:521e7766cd1888fd54fc0c10a6d2a3a7f235d9a7c8aa2577b4f4681e3a8fdabe", + "installedAt": "2026-05-16T19:56:06.687Z" + } + ] +} diff --git a/apps/patch/test/flow.test.ts b/apps/patch/test/flow.test.ts new file mode 100644 index 0000000..f2eb997 --- /dev/null +++ b/apps/patch/test/flow.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import type { FlowRunView } from "@peezy.tech/flow-runtime/client"; +import { + maintenanceAttemptForWorkspaceDispatch, + maintenanceAttemptWithWorkspaceRuns, + patchUpstreamReleaseEvent, +} from "../src/flow"; +import type { + CandidateRefRecord, + FlowDispatchRecord, + MaintenanceAttemptRecord, +} from "../src/types"; + +describe("maintenance attempt sync", () => { + test("aggregates multi-run fanout statuses and candidate refs", () => { + const attempt = baseAttempt(); + const next = maintenanceAttemptWithWorkspaceRuns(attempt, [ + flowRun("run-completed", "completed", [candidate("refs/heads/a", "aaa")]), + flowRun("run-changed", "changed", [candidate("refs/heads/b", "bbb")]), + ], "2026-05-16T00:10:00.000Z"); + + expect(next.status).toBe("changed"); + expect(next.workspaceRunIds).toEqual(["run-completed", "run-changed"]); + expect(next.workspaceRunStatuses).toEqual({ + "run-completed": "completed", + "run-changed": "changed", + }); + expect(next.candidateRefs).toMatchObject([ + { ref: "refs/heads/a", sha: "aaa" }, + { ref: "refs/heads/b", sha: "bbb" }, + ]); + expect(next.completedAt).toBe("2026-05-16T00:10:00.000Z"); + }); + + test("uses failure precedence across partial fanout", () => { + expect(statusFor(["completed", "skipped"])).toBe("completed"); + expect(statusFor(["skipped", "skipped"])).toBe("skipped"); + expect(statusFor(["completed", "changed"])).toBe("changed"); + expect(statusFor(["changed", "failed"])).toBe("failed"); + expect(statusFor(["failed", "blocked"])).toBe("blocked"); + expect(statusFor(["blocked", "needs_intervention"])).toBe("needs_intervention"); + }); + + test("preserves successful candidates when another run fails", () => { + const next = maintenanceAttemptWithWorkspaceRuns(baseAttempt(), [ + flowRun("run-ok", "completed", [candidate("refs/heads/candidate", "abc")]), + flowRun("run-failed", "failed", [], "release verification failed"), + ]); + + expect(next.status).toBe("failed"); + expect(next.error).toBe("release verification failed"); + expect(next.candidateRefs).toMatchObject([ + { ref: "refs/heads/candidate", sha: "abc" }, + ]); + }); + + test("records replay attempts with workspace run results", () => { + const event = patchUpstreamReleaseEvent({ + repo: "openai/codex", + tag: "rust-v0.130.0", + receivedAt: "2026-05-16T00:00:00.000Z", + }); + const record: FlowDispatchRecord = { + eventId: event.id, + eventType: event.type, + operation: "replay", + target: "workspace-backend", + transport: "workspace-ws", + workspaceBackendUrl: "ws://127.0.0.1:3586", + status: "dispatched", + runIds: ["run-a", "run-b"], + matched: 2, + createdAt: "2026-05-16T00:05:00.000Z", + }; + + const attempt = maintenanceAttemptForWorkspaceDispatch(event, record, [ + flowRun("run-a", "completed"), + flowRun("run-b", "changed", [candidate("refs/heads/codex-candidate", "def")]), + ]); + + expect(attempt.id).toBe(`${event.id}:replay:${record.createdAt}`); + expect(attempt.operation).toBe("replay"); + expect(attempt.status).toBe("changed"); + expect(attempt.upstreamRepo).toBe("openai/codex"); + expect(attempt.upstreamTag).toBe("rust-v0.130.0"); + expect(attempt.workspaceRunIds).toEqual(["run-a", "run-b"]); + expect(attempt.candidateRefs).toMatchObject([ + { ref: "refs/heads/codex-candidate", sha: "def" }, + ]); + }); +}); + +function statusFor(statuses: string[]): string { + return maintenanceAttemptWithWorkspaceRuns( + baseAttempt(), + statuses.map((status, index) => flowRun(`run-${index}`, status)), + ).status; +} + +function baseAttempt(): MaintenanceAttemptRecord { + return { + id: "attempt-1", + eventId: "event-1", + eventType: "upstream.release", + operation: "dispatch", + status: "started", + upstreamRepo: "openai/codex", + upstreamTag: "rust-v0.130.0", + workspaceRunIds: [], + candidateRefs: [], + createdAt: "2026-05-16T00:00:00.000Z", + updatedAt: "2026-05-16T00:00:00.000Z", + }; +} + +function flowRun( + id: string, + status: string, + candidateRefs: CandidateRefRecord[] = [], + message = `${id} ${status}`, +): FlowRunView { + return { + id, + eventId: "event-1", + flowName: "test-flow", + stepName: id, + status, + effectiveStatus: status, + completedAt: "2026-05-16T00:10:00.000Z", + resultPayload: { + status, + message, + artifacts: { candidateRefs }, + }, + } as FlowRunView; +} + +function candidate(ref: string, sha: string): CandidateRefRecord { + return { + kind: "branch", + repo: "peezy-tech/codex", + remote: "local", + ref, + sha, + pushed: false, + }; +} diff --git a/apps/patch/test/harness-flow.test.ts b/apps/patch/test/harness-flow.test.ts index d44ef9e..89f1174 100644 --- a/apps/patch/test/harness-flow.test.ts +++ b/apps/patch/test/harness-flow.test.ts @@ -53,6 +53,41 @@ describe("patch.moi harness flow", () => { expect(afterHead).toBe(beforeHead); expect(await git(["status", "--porcelain=v1"])).toBe(""); }); + + test("matches installed Codex release flows without executing release work", async () => { + const flows = await discoverFlows({ cwd: workspaceRoot }); + const matches = await matchingSteps(flows, { + id: "patch:upstream.release:openai/codex:rust-v0.130.0", + type: "upstream.release", + source: "patch", + receivedAt: "2026-05-16T00:00:00.000Z", + payload: { repo: "openai/codex", tag: "rust-v0.130.0" }, + }); + + expect(matches.map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toEqual([ + "openai-codex-bindings/regenerate-bindings", + "peezy-codex-fork/rebase-patch-stack", + ]); + + const codeModeMatch = matches.find((entry) => entry.flow.manifest.name === "peezy-codex-fork"); + expect(codeModeMatch?.step.runner).toBe("code-mode"); + if (!codeModeMatch) { + return; + } + + await expect(runFlowStep({ + flow: codeModeMatch.flow, + step: codeModeMatch.step, + event: { + id: "patch:upstream.release:openai/codex:rust-v0.130.0", + type: "upstream.release", + source: "patch", + receivedAt: "2026-05-16T00:00:00.000Z", + payload: { repo: "openai/codex", tag: "rust-v0.130.0" }, + }, + env: {}, + })).rejects.toThrow("requires CODEX_FLOWS_ENABLE_CODE_MODE=1"); + }); }); async function git(args: string[]): Promise { diff --git a/docs/pages/index.md b/docs/pages/index.md index c3d2359..0fd595b 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -104,6 +104,8 @@ DATA_DIR=./data FEED_SOURCES_PATH=./feed-sources.json bun run --filter @peezy.te - `apps/patch`: Patch service, feed poller, JSONL store, admin API, Discord output, and workspace backend adapter. - `flows/patch-moi-harness`: executable maintenance flow for the harness repos. +- `.codex/flows`: installed external flow capabilities, currently the Codex + release maintenance flows from the neighboring `../codex-flows` pack. - `harness`: upstream and maintained fork repositories used for rehearsal. - `.codex/workspace.toml`: optional repo-native workspace automation config. - `docs`: this Tome documentation site. diff --git a/docs/pages/reference/packages.md b/docs/pages/reference/packages.md index 45ebee7..def431a 100644 --- a/docs/pages/reference/packages.md +++ b/docs/pages/reference/packages.md @@ -44,6 +44,21 @@ bun run workspace:run:harness Those commands are operator automation around the repo. They do not replace the Patch service package or its `DATA_DIR` state. +## Installed Flow Capabilities + +External flow capabilities are installed under `.codex/flows` and tracked in +`.codex/pack-lock.json`. The current install brings in the Codex release +maintenance flows from the sibling `../codex-flows` repository: + +```bash +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`. + ## Related Runtime Packages These published packages define the current patch.moi integration baseline: diff --git a/docs/pages/tutorials/dispatch-codex-release-flow.md b/docs/pages/tutorials/dispatch-codex-release-flow.md index c1b8090..82f15e1 100644 --- a/docs/pages/tutorials/dispatch-codex-release-flow.md +++ b/docs/pages/tutorials/dispatch-codex-release-flow.md @@ -34,7 +34,32 @@ git status --short --branch If `git status` shows local changes or untracked files, resolve them before an automated rebase. -## 2. Point Patch at a workspace backend +## 2. Install the Codex release capabilities + +The Codex release maintenance capabilities are installed from the neighboring +`../codex-flows` pack into `.codex/flows`: + +```bash +codex-flows pack add ../codex-flows \ + --include openai-codex-bindings \ + --include peezy-codex-fork \ + --apply +codex-flows pack doctor --json +``` + +The current local install pins `openai-codex-bindings` and `peezy-codex-fork` +in `.codex/pack-lock.json`. `@peezy.tech/flow-runtime@0.4.0` discovers +installed `.codex/flows/*` before source-owned `flows/*`, so the installed +Codex capabilities are visible to patch.moi while the harness remains a +source-owned 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_ENABLE_CODE_MODE=1`. Do not fabricate a full +`openai/codex` release lifecycle just to exercise the flow. + +## 3. Point Patch at a workspace backend ```bash PATCH_WORKSPACE_BACKEND_URL=http://127.0.0.1:3586 \ @@ -52,7 +77,7 @@ workspace flow capability. `PATCH_FLOW_BACKEND_URL` and Leave `PATCH_WORKSPACE_BACKEND_URL` unset only when you intentionally want local flow execution from the Patch process working directory. -## 3. Inspect the stored event +## 4. Inspect the stored event ```bash curl http://127.0.0.1:3000/flow-events @@ -61,7 +86,7 @@ curl http://127.0.0.1:3000/flow-events When `PATCH_ADMIN_TOKEN` is set, include either `Authorization: Bearer ` or `X-Patch-Admin-Token: `. -## 4. Keep completion workspace-owned, state app-owned +## 5. Keep completion workspace-owned, state app-owned Patch dispatches the generic event. The installed Codex release flow or workspace owns the work that happens next: