From 8b1e31df1a55a3b832a635bbf16f3900058bf31f Mon Sep 17 00:00:00 2001 From: matamune Date: Mon, 18 May 2026 22:34:53 +0000 Subject: [PATCH] Reshape harness flows like fork maintenance --- .gitignore | 1 + apps/patch/src/cli.ts | 4 +- apps/patch/src/run-harness-flow.ts | 40 +- apps/patch/test/harness-flow.test.ts | 129 +++++- docs/pages/concepts/architecture.md | 2 +- docs/pages/index.md | 3 +- .../tutorials/run-harness-maintenance-flow.md | 27 +- .../exec/generate-bindings.ts | 124 ++++++ .../fixtures/upstream-release-v0.1.3.json | 4 +- flows/patch-moi-harness-bindings/flow.toml | 19 + .../schemas/upstream-release.schema.json | 19 + .../exec/release-fork.ts | 178 +++++++++ ...downstream-fork-release-v0.1.3-fork.0.json | 11 + flows/patch-moi-harness-flows-fork/flow.toml | 28 ++ .../schemas/downstream-release.schema.json | 22 + .../exec/update-fork.ts | 375 ++++++++++++++++++ .../fixtures/upstream-main-v0.1.3.json | 11 + .../fixtures/upstream-release-v0.1.3.json | 11 + flows/patch-moi-harness-fork/flow.toml | 44 ++ .../upstream-branch-update.schema.json | 20 + .../schemas/upstream-release.schema.json | 19 + flows/patch-moi-harness/README.md | 42 -- flows/patch-moi-harness/exec/rebase-fork.ts | 336 ---------------- flows/patch-moi-harness/flow.toml | 25 -- .../schemas/upstream-release.schema.json | 15 - harness/README.md | 49 ++- 26 files changed, 1089 insertions(+), 469 deletions(-) create mode 100644 flows/patch-moi-harness-bindings/exec/generate-bindings.ts rename flows/{patch-moi-harness => patch-moi-harness-bindings}/fixtures/upstream-release-v0.1.3.json (76%) create mode 100644 flows/patch-moi-harness-bindings/flow.toml create mode 100644 flows/patch-moi-harness-bindings/schemas/upstream-release.schema.json create mode 100644 flows/patch-moi-harness-flows-fork/exec/release-fork.ts create mode 100644 flows/patch-moi-harness-flows-fork/fixtures/downstream-fork-release-v0.1.3-fork.0.json create mode 100644 flows/patch-moi-harness-flows-fork/flow.toml create mode 100644 flows/patch-moi-harness-flows-fork/schemas/downstream-release.schema.json create mode 100644 flows/patch-moi-harness-fork/exec/update-fork.ts create mode 100644 flows/patch-moi-harness-fork/fixtures/upstream-main-v0.1.3.json create mode 100644 flows/patch-moi-harness-fork/fixtures/upstream-release-v0.1.3.json create mode 100644 flows/patch-moi-harness-fork/flow.toml create mode 100644 flows/patch-moi-harness-fork/schemas/upstream-branch-update.schema.json create mode 100644 flows/patch-moi-harness-fork/schemas/upstream-release.schema.json delete mode 100644 flows/patch-moi-harness/README.md delete mode 100644 flows/patch-moi-harness/exec/rebase-fork.ts delete mode 100644 flows/patch-moi-harness/flow.toml delete mode 100644 flows/patch-moi-harness/schemas/upstream-release.schema.json diff --git a/.gitignore b/.gitignore index 55a4112..23cdaea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ coverage /.codex/workspace/local/ /.codex/workspace/actions/ /.codex/pack-backups/ +/.codex/flow-artifacts/ data/ apps/*/data/ docs/out/ diff --git a/apps/patch/src/cli.ts b/apps/patch/src/cli.ts index 6f5afd3..b80c490 100644 --- a/apps/patch/src/cli.ts +++ b/apps/patch/src/cli.ts @@ -240,7 +240,7 @@ async function handleRun(positionals: string[], context: CliContext): Promise>; const flows = await discoverFlows({ cwd: workspaceRoot }); const matches = await matchingSteps(flows, event); -const match = matches.find((entry) => entry.flow.manifest.name === "patch-moi-harness"); +const harnessMatches = matches.filter((entry) => entry.flow.manifest.name.startsWith("patch-moi-harness-")); -if (!match) { - throw new Error(`No patch-moi-harness flow step matched ${event.type} from ${fixturePath}`); +if (harnessMatches.length === 0) { + throw new Error(`No patch-moi-harness-* flow steps matched ${event.type} from ${fixturePath}`); } -const result = await runFlowStep({ - flow: match.flow, - step: match.step, +const results = []; +for (const match of harnessMatches) { + results.push({ + flow: match.flow.manifest.name, + step: match.step.name, + result: await runFlowStep({ + flow: match.flow, + step: match.step, + event, + }), + }); +} + +console.log(JSON.stringify({ + status: aggregateStatus(results.map((entry) => entry.result.status)), event, -}); + results, +}, null, 2)); -console.log(JSON.stringify(result, null, 2)); - -if (["blocked", "failed", "needs_intervention"].includes(result.status)) { +if (results.some((entry) => ["blocked", "failed", "needs_intervention"].includes(entry.result.status))) { process.exit(1); } + +function aggregateStatus(statuses: string[]): string { + for (const status of ["failed", "blocked", "needs_intervention", "changed"]) { + if (statuses.includes(status)) return status; + } + return statuses.includes("completed") ? "completed" : "skipped"; +} diff --git a/apps/patch/test/harness-flow.test.ts b/apps/patch/test/harness-flow.test.ts index b0c6c81..5cafd20 100644 --- a/apps/patch/test/harness-flow.test.ts +++ b/apps/patch/test/harness-flow.test.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { access, readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, test } from "bun:test"; import { @@ -12,24 +12,50 @@ const workspaceRoot = path.resolve(import.meta.dir, "../../.."); const harnessFork = path.join(workspaceRoot, "harness/fork"); describe("patch.moi harness flow", () => { - test("matches the release fixture and verifies the current fork", async () => { + test("matches the release fixture like the Codex fork release fanout", async () => { const event = JSON.parse(await readFile( - path.join(workspaceRoot, "flows/patch-moi-harness/fixtures/upstream-release-v0.1.3.json"), + path.join(workspaceRoot, "flows/patch-moi-harness-fork/fixtures/upstream-release-v0.1.3.json"), "utf8", )) as FlowEvent>; const flows = await discoverFlows({ cwd: workspaceRoot }); const matches = await matchingSteps(flows, event); - const match = matches.find((entry) => entry.flow.manifest.name === "patch-moi-harness"); + const harnessMatches = matches + .filter((entry) => entry.flow.manifest.name.startsWith("patch-moi-harness-")) + .map(({ flow, step }) => `${flow.manifest.name}/${step.name}`); - expect(match).toBeDefined(); - if (!match) { - return; - } + expect(harnessMatches).toEqual([ + "patch-moi-harness-bindings/generate-bindings", + "patch-moi-harness-fork/release-cycle", + ]); + }); + + test("release fixture regenerates bindings and rebuilds the patch-branch fork", async () => { + const event = JSON.parse(await readFile( + path.join(workspaceRoot, "flows/patch-moi-harness-fork/fixtures/upstream-release-v0.1.3.json"), + "utf8", + )) as FlowEvent>; + const flows = await discoverFlows({ cwd: workspaceRoot }); + const matches = await matchingSteps(flows, event); + const bindingMatch = matches.find((entry) => entry.flow.manifest.name === "patch-moi-harness-bindings"); + const forkMatch = matches.find((entry) => entry.flow.manifest.name === "patch-moi-harness-fork"); + + expect(bindingMatch).toBeDefined(); + expect(forkMatch).toBeDefined(); + if (!bindingMatch || !forkMatch) return; const beforeHead = await git(["rev-parse", "HEAD"]); - const result = await runFlowStep({ - flow: match.flow, - step: match.step, + const bindings = await runFlowStep({ + flow: bindingMatch.flow, + step: bindingMatch.step, + event, + env: { + CODEX_FLOW_FETCH: "0", + CODEX_FLOW_PUSH: "0", + }, + }); + const fork = await runFlowStep({ + flow: forkMatch.flow, + step: forkMatch.step, event, env: { CODEX_FLOW_FETCH: "0", @@ -38,9 +64,18 @@ describe("patch.moi harness flow", () => { }); const afterHead = await git(["rev-parse", "HEAD"]); - expect(result.status).toBe("completed"); - expect(result.message).toContain("package checks passed"); - expect(result.artifacts?.candidateRefs).toMatchObject([ + expect(["changed", "completed"]).toContain(bindings.status); + expect(typeof bindings.artifacts?.artifactPath).toBe("string"); + await access(String(bindings.artifacts?.artifactPath)); + + expect(fork.status).toBe("completed"); + expect(fork.message).toContain("Harness fork already matches"); + expect(fork.artifacts?.applied).toMatchObject([ + { name: "patch/010-maintained-greeting" }, + { name: "patch/020-shout-mode" }, + { name: "patch/030-package-identity" }, + ]); + expect(fork.artifacts?.candidateRefs).toMatchObject([ { kind: "branch", repo: "matamune-peezy/patch-moi-harness", @@ -54,6 +89,72 @@ describe("patch.moi harness flow", () => { expect(await git(["status", "--porcelain=v1"])).toBe(""); }); + test("matches and runs the harness main branch update path", async () => { + const event = JSON.parse(await readFile( + path.join(workspaceRoot, "flows/patch-moi-harness-fork/fixtures/upstream-main-v0.1.3.json"), + "utf8", + )) as FlowEvent>; + const flows = await discoverFlows({ cwd: workspaceRoot }); + const matches = await matchingSteps(flows, event); + expect(matches.map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toContain( + "patch-moi-harness-fork/main-branch-update", + ); + const match = matches.find((entry) => entry.flow.manifest.name === "patch-moi-harness-fork"); + if (!match) return; + + const beforeHead = await git(["rev-parse", "HEAD"]); + const result = await runFlowStep({ + flow: match.flow, + step: match.step, + event, + env: { + CODEX_FLOW_FETCH: "0", + CODEX_FLOW_PUSH: "0", + }, + }); + + expect(result.status).toBe("completed"); + expect(result.artifacts?.upstreamBranch).toBe("upstream"); + expect(await git(["rev-parse", "HEAD"])).toBe(beforeHead); + expect(await git(["status", "--porcelain=v1"])).toBe(""); + }); + + test("matches and runs the downstream harness fork package artifact path", async () => { + const event = JSON.parse(await readFile( + path.join(workspaceRoot, "flows/patch-moi-harness-flows-fork/fixtures/downstream-fork-release-v0.1.3-fork.0.json"), + "utf8", + )) as FlowEvent>; + const flows = await discoverFlows({ cwd: workspaceRoot }); + const matches = await matchingSteps(flows, event); + expect(matches.map(({ flow, step }) => `${flow.manifest.name}/${step.name}`)).toContain( + "patch-moi-harness-flows-fork/release-fork", + ); + const match = matches.find((entry) => entry.flow.manifest.name === "patch-moi-harness-flows-fork"); + if (!match) return; + + const result = await runFlowStep({ + flow: match.flow, + step: match.step, + event, + env: { + CODEX_FLOW_FETCH: "0", + CODEX_FLOW_PUSH: "0", + CODEX_FLOW_PUBLISH: "0", + }, + }); + const tarballs = await readdir(path.join(workspaceRoot, ".codex/flow-artifacts/patch-moi-harness-flows-fork-release")); + + expect(result.status).toBe("changed"); + expect(result.artifacts?.candidateRefs).toMatchObject([ + { + kind: "artifact", + repo: "matamune-peezy/patch-moi-harness", + pushed: false, + }, + ]); + expect(tarballs.some((entry) => entry.endsWith(".tgz"))).toBe(true); + }); + test("matches installed Codex release flows without executing release work", async () => { const flows = await discoverFlows({ cwd: workspaceRoot }); const matches = await matchingSteps(flows, { diff --git a/docs/pages/concepts/architecture.md b/docs/pages/concepts/architecture.md index 78499b3..750ff0e 100644 --- a/docs/pages/concepts/architecture.md +++ b/docs/pages/concepts/architecture.md @@ -44,7 +44,7 @@ The current service has these pieces: | feed poller | reads configured upstream feeds and emits normalized signals | | JSONL store | writes feed events, flow events, workspace dispatches, and maintenance attempts under `DATA_DIR` | | workspace backend adapter | dispatches locally when no backend URL is set, or calls a configured Codex workspace backend | -| harness flow | exercises real fork maintenance through `flows/patch-moi-harness` | +| harness flows | exercise Codex-shaped fork maintenance through `flows/patch-moi-harness-*` | | repo workspace config | exposes manual operator tasks through `codex-flows workspace doctor|tick|run` | Those pieces are intentionally narrow. The service coordinates and records; the diff --git a/docs/pages/index.md b/docs/pages/index.md index 372446d..a65e916 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -112,7 +112,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. +- `flows/patch-moi-harness-*`: source harness flows that mirror the Codex + fork release, main-update, and downstream-release surfaces. - `.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. diff --git a/docs/pages/tutorials/run-harness-maintenance-flow.md b/docs/pages/tutorials/run-harness-maintenance-flow.md index 12d995c..5c0b864 100644 --- a/docs/pages/tutorials/run-harness-maintenance-flow.md +++ b/docs/pages/tutorials/run-harness-maintenance-flow.md @@ -6,8 +6,14 @@ description: Use the patch.moi harness repos to rehearse an upstream release and # Run the harness maintenance flow This tutorial runs the smallest real patch.moi maintenance loop. The upstream -repo is `harness/upstream`. The maintained fork is `harness/fork`. The flow -package is `flows/patch-moi-harness`. +repo is `harness/upstream`. The maintained fork is `harness/fork`. The source +flow packages mirror the Codex fork structure: + +- `flows/patch-moi-harness-bindings` handles upstream release metadata. +- `flows/patch-moi-harness-fork` rebuilds the maintained fork from + `upstream` plus ordered `patch/*` branches. +- `flows/patch-moi-harness-flows-fork` prepares a downstream fork package + artifact. There are two local operator paths: @@ -39,9 +45,11 @@ bun run harness:flow ``` The fixture event is `v0.1.3`, which the current fork already contains. The -flow should skip rebase work, run `npm test` and `npm run pack:dry-run` in the -fork, report `candidateRefs` for the maintained fork branch, and leave the fork -checkout unchanged. +release event fans out to the bindings flow and the fork flow. The fork flow +seeds the local `upstream` and `patch/*` branches when needed, verifies that +`main` already equals `upstream + patches`, runs `npm test` and +`npm run pack:dry-run`, reports `candidateRefs` for the maintained fork branch, +and leaves the fork checkout unchanged. ## 3. Run the fixture through workspace autonomy @@ -107,9 +115,10 @@ bun run harness:flow ``` Use an event file whose `id`, `occurredAt`, `receivedAt`, and `payload.tag` -identify the new upstream tag. The flow rebases `harness/fork` onto that tag, -verifies the fork package, reports the local candidate branch, and keeps pushes -off. +identify the new upstream tag. The fork flow updates the local `upstream` +branch to that tag, rebuilds `main` by cherry-picking ordered `patch/*` branch +tips, verifies the fork package, reports the local candidate branch, and keeps +pushes off. ## 6. Push only after review @@ -119,7 +128,7 @@ When the local result is the maintained fork state you want: CODEX_FLOW_PUSH=1 bun run harness:flow ``` -That pushes the rebased fork branch to the configured `origin` and `jojo` +That pushes the rebuilt fork branch to the configured `origin` and `jojo` remotes with `--force-with-lease` and reports those pushed branch refs as candidate refs. Public npm publishing remains a separate trusted-publishing release path. diff --git a/flows/patch-moi-harness-bindings/exec/generate-bindings.ts b/flows/patch-moi-harness-bindings/exec/generate-bindings.ts new file mode 100644 index 0000000..afae813 --- /dev/null +++ b/flows/patch-moi-harness-bindings/exec/generate-bindings.ts @@ -0,0 +1,124 @@ +import { mkdir, 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 = { + code: number; + stdout: string; + stderr: string; +}; + +const context = JSON.parse(await Bun.stdin.text()) as FlowContext; +const config = context.flow.config ?? {}; +const payload = context.flow.event.payload ?? {}; + +function finish(value: Record): never { + process.stdout.write(`FLOW_RESULT ${JSON.stringify(value)}\n`); + process.exit(0); +} + +try { + const expectedRepo = stringConfig("expected_repo", "peezy-tech/patch-moi-harness"); + const repo = stringValue(payload.repo); + const tag = stringValue(payload.tag) || shortTag(stringValue(payload.ref) ?? ""); + if (repo !== expectedRepo) { + finish({ status: "skipped", message: `Harness bindings ignore ${repo}.` }); + } + if (!tag) { + finish({ status: "failed", message: "upstream.release requires payload.tag." }); + } + + const workspaceRoot = process.cwd(); + const upstreamRepo = path.resolve(workspaceRoot, stringConfig("upstream_repo", "harness/upstream")); + const artifactDir = path.resolve(workspaceRoot, stringConfig("artifact_dir", ".codex/flow-artifacts/patch-moi-harness-bindings")); + const packageJsonPath = path.join(upstreamRepo, "package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { + name?: string; + version?: string; + exports?: unknown; + bin?: unknown; + }; + const releaseSha = (await runChecked(["git", "rev-parse", "--verify", `${tag}^{commit}`], upstreamRepo)).stdout.trim(); + const bindings = { + generatedBy: "patch-moi-harness-bindings", + eventId: context.flow.event.id, + repo, + tag, + releaseSha, + packageName: packageJson.name, + version: packageJson.version, + exports: packageJson.exports ?? null, + bin: packageJson.bin ?? null, + }; + const artifactJson = `${JSON.stringify(bindings, null, 2)}\n`; + const artifactPath = path.join(artifactDir, "bindings.json"); + await mkdir(artifactDir, { recursive: true }); + const previous = await readFile(artifactPath, "utf8").catch(() => ""); + await writeFile(artifactPath, artifactJson, "utf8"); + const changed = previous !== artifactJson; + + finish({ + status: changed ? "changed" : "completed", + message: changed + ? `Regenerated harness bindings for ${repo}@${tag}.` + : `Harness bindings already current for ${repo}@${tag}.`, + artifacts: { + repo, + tag, + releaseSha, + artifactPath, + candidateRefs: [{ + kind: "artifact", + repo: expectedRepo, + ref: artifactPath, + sha: releaseSha, + pushed: false, + }], + }, + }); +} catch (error) { + finish({ + status: "failed", + message: error instanceof Error ? error.message : String(error), + }); +} + +async function runChecked(command: string[], cwd: string): Promise { + const proc = Bun.spawn(command, { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) { + throw new Error(`${command.join(" ")} failed with exit ${code}:\n${stderr || stdout}`); + } + return { code, stdout, stderr }; +} + +function stringConfig(name: string, fallback: string): string { + const value = config[name]; + return typeof value === "string" && value.trim() ? value : fallback; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function shortTag(value: string): string { + return value.replace(/^refs\/tags\//, ""); +} diff --git a/flows/patch-moi-harness/fixtures/upstream-release-v0.1.3.json b/flows/patch-moi-harness-bindings/fixtures/upstream-release-v0.1.3.json similarity index 76% rename from flows/patch-moi-harness/fixtures/upstream-release-v0.1.3.json rename to flows/patch-moi-harness-bindings/fixtures/upstream-release-v0.1.3.json index 6cf4dca..e46d3f6 100644 --- a/flows/patch-moi-harness/fixtures/upstream-release-v0.1.3.json +++ b/flows/patch-moi-harness-bindings/fixtures/upstream-release-v0.1.3.json @@ -4,10 +4,8 @@ "source": "patch", "receivedAt": "2026-05-16T00:00:00.000Z", "payload": { - "provider": "github", "repo": "peezy-tech/patch-moi-harness", "tag": "v0.1.3", - "url": "https://github.com/peezy-tech/patch-moi-harness/releases/tag/v0.1.3", - "publishedAt": "2026-05-16T00:00:00.000Z" + "url": "https://github.com/peezy-tech/patch-moi-harness/releases/tag/v0.1.3" } } diff --git a/flows/patch-moi-harness-bindings/flow.toml b/flows/patch-moi-harness-bindings/flow.toml new file mode 100644 index 0000000..956e6f2 --- /dev/null +++ b/flows/patch-moi-harness-bindings/flow.toml @@ -0,0 +1,19 @@ +name = "patch-moi-harness-bindings" +version = 1 +description = "Regenerate a local harness bindings artifact from upstream harness releases." + +[config] +expected_repo = "peezy-tech/patch-moi-harness" +upstream_repo = "harness/upstream" +artifact_dir = ".codex/flow-artifacts/patch-moi-harness-bindings" + +[[steps]] +name = "generate-bindings" +runner = "bun" +script = "exec/generate-bindings.ts" +cwd = "../.." +timeout_ms = 300000 + +[steps.trigger] +type = "upstream.release" +schema = "schemas/upstream-release.schema.json" diff --git a/flows/patch-moi-harness-bindings/schemas/upstream-release.schema.json b/flows/patch-moi-harness-bindings/schemas/upstream-release.schema.json new file mode 100644 index 0000000..9f12446 --- /dev/null +++ b/flows/patch-moi-harness-bindings/schemas/upstream-release.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["repo", "tag"], + "properties": { + "repo": { + "type": "string", + "enum": ["peezy-tech/patch-moi-harness"] + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string" + } + }, + "additionalProperties": true +} diff --git a/flows/patch-moi-harness-flows-fork/exec/release-fork.ts b/flows/patch-moi-harness-flows-fork/exec/release-fork.ts new file mode 100644 index 0000000..4b908fd --- /dev/null +++ b/flows/patch-moi-harness-flows-fork/exec/release-fork.ts @@ -0,0 +1,178 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type FlowContext = { + flow: { + config?: Record; + event: { + id: string; + payload?: Record; + }; + }; +}; + +type CommandResult = { + code: number; + stdout: string; + stderr: string; +}; + +const context = JSON.parse(await Bun.stdin.text()) as FlowContext; +const config = context.flow.config ?? {}; +const payload = context.flow.event.payload ?? {}; + +function finish(value: Record): never { + process.stdout.write(`FLOW_RESULT ${JSON.stringify(value)}\n`); + process.exit(0); +} + +try { + const packageName = stringValue(payload.packageName); + const version = stringValue(payload.version); + if (!packageName || !version) { + finish({ status: "failed", message: "downstream.release requires packageName and version." }); + } + const acceptedPackages = stringArrayConfig("accepted_packages", [ + "@peezy.tech/patch-moi-harness", + "@peezy.tech/patch-moi-harness-fork", + ]); + if (!acceptedPackages.includes(packageName)) { + finish({ status: "skipped", message: `Harness fork release ignores ${packageName}.` }); + } + + const workspaceRoot = process.cwd(); + const forkRepo = path.resolve(workspaceRoot, stringConfig("fork_repo", "harness/fork")); + const forkRepoFullName = stringConfig("fork_repo_full_name", "matamune-peezy/patch-moi-harness"); + const sourceBranch = stringConfig("source_branch", "main"); + const worktreeDir = path.resolve(workspaceRoot, stringConfig("worktree_dir", ".codex/flow-artifacts/patch-moi-harness-flows-fork-worktree")); + const artifactDir = path.resolve(workspaceRoot, stringConfig("artifact_dir", ".codex/flow-artifacts/patch-moi-harness-flows-fork-release")); + + await requireCleanRepo(forkRepo); + const sourceSha = (await runChecked("resolve harness fork branch", ["git", "rev-parse", "--verify", `${sourceBranch}^{commit}`], forkRepo)).stdout.trim(); + await prepareWorktree(forkRepo, worktreeDir, sourceBranch); + + const packageJsonPath = path.join(worktreeDir, "package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + const baseVersion = typeof packageJson.version === "string" ? packageJson.version : "0.0.0"; + const forkVersion = forkPackageVersion(baseVersion, version); + packageJson.version = forkVersion; + packageJson.description = `Fork release artifact prepared from ${packageName}@${version}.`; + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); + + await runChecked("install harness fork package", ["npm", "install", "--package-lock-only"], worktreeDir); + await runChecked("test harness fork package", ["npm", "test"], worktreeDir); + + await rm(artifactDir, { recursive: true, force: true }); + await mkdir(artifactDir, { recursive: true }); + const pack = await runChecked("pack harness fork package", ["npm", "pack", "--pack-destination", artifactDir], worktreeDir); + const tarball = pack.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + const tarballPath = tarball ? path.join(artifactDir, tarball) : undefined; + + const candidateSha = (await runChecked("read harness fork release candidate", ["git", "rev-parse", "HEAD"], worktreeDir)).stdout.trim(); + finish({ + status: "changed", + message: `Prepared harness fork package ${forkVersion} from ${packageName}@${version}.`, + artifacts: { + eventId: context.flow.event.id, + sourcePackage: packageName, + sourceVersion: version, + forkRepo, + forkRepoFullName, + sourceBranch, + sourceSha, + baseVersion, + forkVersion, + worktreeDir, + tarballPath, + pushed: false, + published: false, + candidateRefs: [{ + kind: "artifact", + repo: forkRepoFullName, + ref: tarballPath ?? artifactDir, + sha: candidateSha, + pushed: false, + }], + }, + }); +} catch (error) { + finish({ + status: "failed", + message: error instanceof Error ? error.message : String(error), + }); +} + +async function requireCleanRepo(repoRoot: string): Promise { + const status = await runChecked("read harness fork status", ["git", "status", "--porcelain=v1"], repoRoot); + if (status.stdout.trim()) { + finish({ + status: "blocked", + message: "Harness fork checkout has local changes before release artifact preparation.", + artifacts: { status: status.stdout }, + }); + } +} + +async function prepareWorktree(repoRoot: string, worktreeDir: string, sourceBranch: string): Promise { + if (existsSync(worktreeDir)) { + await run("remove old harness fork release worktree", ["git", "worktree", "remove", "--force", worktreeDir], repoRoot); + await rm(worktreeDir, { recursive: true, force: true }); + } + await run("prune harness fork worktrees", ["git", "worktree", "prune"], repoRoot); + await runChecked("create harness fork release worktree", ["git", "worktree", "add", "--detach", worktreeDir, sourceBranch], repoRoot); +} + +async function runChecked(label: string, command: string[], cwd: string): Promise { + const result = await run(label, command, cwd); + if (result.code !== 0) { + throw new Error(`${label} failed with exit ${result.code}:\n${result.stderr || result.stdout}`); + } + return result; +} + +async function run(label: string, command: string[], cwd: string): Promise { + process.stderr.write(`+ ${label}: ${command.join(" ")}\n`); + const proc = Bun.spawn(command, { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (stdout) process.stderr.write(stdout); + if (stderr) process.stderr.write(stderr); + return { code, stdout, stderr }; +} + +function forkPackageVersion(baseVersion: string, sourceVersion: string): string { + return `${baseVersion}-harness.${sanitizePrerelease(sourceVersion)}`; +} + +function sanitizePrerelease(value: string): string { + return value + .replace(/^v/, "") + .replace(/[^0-9A-Za-z]+/g, ".") + .split(".") + .filter(Boolean) + .join(".") || "0"; +} + +function stringConfig(name: string, fallback: string): string { + const value = config[name]; + return typeof value === "string" && value.trim() ? value : fallback; +} + +function stringArrayConfig(name: string, fallback: string[]): string[] { + const value = config[name]; + if (!Array.isArray(value)) return fallback; + const entries = value.filter((entry): entry is string => typeof entry === "string" && entry.trim()); + return entries.length > 0 ? entries : fallback; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/flows/patch-moi-harness-flows-fork/fixtures/downstream-fork-release-v0.1.3-fork.0.json b/flows/patch-moi-harness-flows-fork/fixtures/downstream-fork-release-v0.1.3-fork.0.json new file mode 100644 index 0000000..81a88e3 --- /dev/null +++ b/flows/patch-moi-harness-flows-fork/fixtures/downstream-fork-release-v0.1.3-fork.0.json @@ -0,0 +1,11 @@ +{ + "id": "patch:downstream.release:@peezy.tech/patch-moi-harness-fork:0.1.3-fork.0", + "type": "downstream.release", + "source": "patch", + "receivedAt": "2026-05-16T00:00:00.000Z", + "payload": { + "packageName": "@peezy.tech/patch-moi-harness-fork", + "version": "0.1.3-fork.0", + "repo": "matamune-peezy/patch-moi-harness" + } +} diff --git a/flows/patch-moi-harness-flows-fork/flow.toml b/flows/patch-moi-harness-flows-fork/flow.toml new file mode 100644 index 0000000..ba08a58 --- /dev/null +++ b/flows/patch-moi-harness-flows-fork/flow.toml @@ -0,0 +1,28 @@ +name = "patch-moi-harness-flows-fork" +version = 1 +description = "Prepare a local harness fork package artifact from downstream harness package releases." + +[config] +fork_repo = "harness/fork" +fork_repo_full_name = "matamune-peezy/patch-moi-harness" +source_branch = "main" +worktree_dir = ".codex/flow-artifacts/patch-moi-harness-flows-fork-worktree" +artifact_dir = ".codex/flow-artifacts/patch-moi-harness-flows-fork-release" +fetch = false +push = false +publish = false +accepted_packages = [ + "@peezy.tech/patch-moi-harness", + "@peezy.tech/patch-moi-harness-fork", +] + +[[steps]] +name = "release-fork" +runner = "bun" +script = "exec/release-fork.ts" +cwd = "../.." +timeout_ms = 600000 + +[steps.trigger] +type = "downstream.release" +schema = "schemas/downstream-release.schema.json" diff --git a/flows/patch-moi-harness-flows-fork/schemas/downstream-release.schema.json b/flows/patch-moi-harness-flows-fork/schemas/downstream-release.schema.json new file mode 100644 index 0000000..31003f9 --- /dev/null +++ b/flows/patch-moi-harness-flows-fork/schemas/downstream-release.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["packageName", "version"], + "properties": { + "packageName": { + "type": "string", + "enum": [ + "@peezy.tech/patch-moi-harness", + "@peezy.tech/patch-moi-harness-fork" + ] + }, + "version": { + "type": "string", + "minLength": 1 + }, + "repo": { + "type": "string" + } + }, + "additionalProperties": true +} diff --git a/flows/patch-moi-harness-fork/exec/update-fork.ts b/flows/patch-moi-harness-fork/exec/update-fork.ts new file mode 100644 index 0000000..eb29eec --- /dev/null +++ b/flows/patch-moi-harness-fork/exec/update-fork.ts @@ -0,0 +1,375 @@ +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; + code: number; + stdout: string; + stderr: string; +}; + +type PatchBranch = { + name: string; + sha: string; + subject: string; +}; + +const context = JSON.parse(await Bun.stdin.text()) as FlowContext; +const config = context.flow.config ?? {}; +const payload = context.flow.event.payload ?? {}; +const commands: CommandResult[] = []; + +const workspaceRoot = process.cwd(); +const forkRepo = path.resolve(workspaceRoot, stringConfig("fork_repo", "harness/fork")); +const forkRepoFullName = stringConfig("fork_repo_full_name", "matamune-peezy/patch-moi-harness"); +const targetBranch = stringConfig("target_branch", "main"); +const upstreamBranch = stringConfig("upstream_branch", "upstream"); +const patchPrefix = stringConfig("patch_prefix", "patch/"); +const upstreamRemote = stringConfig("upstream_remote", "upstream"); +const upstreamRepoUrl = stringConfig("upstream_repo_url", "https://github.com/peezy-tech/patch-moi-harness.git"); +const verifyCommands = stringArrayConfig("verify_commands", ["npm test", "npm run pack:dry-run"]); +const pushRemotes = stringArrayConfig("push_remotes", ["origin", "jojo"]); + +function finish(value: Record): never { + process.stdout.write(`FLOW_RESULT ${JSON.stringify(value)}\n`); + process.exit(0); +} + +try { + const expectedRepo = stringConfig("expected_repo", "peezy-tech/patch-moi-harness"); + const repo = stringValue(payload.repo); + if (repo !== expectedRepo) { + finish({ status: "skipped", message: `Harness fork flow ignores ${repo}.` }); + } + + await requireGitRepo(); + await ensureUpstreamRemote(); + if (enabled("fetch", true)) { + await run("fetch upstream refs", ["git", "fetch", upstreamRemote, "--tags", "--prune"]); + } + await requireNoRebaseOrCherryPickInProgress(); + await requireCleanWorktree("before harness patch rebuild"); + + const base = await resolveBase(); + await run("update local upstream branch", ["git", "branch", "-f", upstreamBranch, base.sha]); + await ensureSeedPatchBranches(); + + const currentSha = (await runChecked("read harness checkout head", ["git", "rev-parse", "HEAD"])).stdout.trim(); + const targetExists = await branchExists(targetBranch); + const beforeSha = targetExists ? await resolveCommit(targetBranch) : currentSha; + const beforeTree = beforeSha ? await resolveTree(beforeSha) : ""; + const patchBranches = await listPatchBranches(); + if (patchBranches.length === 0) { + finish({ + status: "blocked", + message: `Harness fork has no ${patchPrefix} branches.`, + artifacts: baseArtifacts(base), + }); + } + + await run("switch to upstream rebuild base", ["git", "switch", "--detach", base.sha]); + const applied: PatchBranch[] = []; + for (const patchBranch of patchBranches) { + const pick = await run(`apply ${patchBranch.name}`, ["git", "cherry-pick", patchBranch.sha], { allowFailure: true }); + if (pick.code !== 0) { + const status = await run("patch rebuild conflict status", ["git", "status", "--short", "--branch"], { allowFailure: true }); + const unmerged = await run("unmerged files", ["git", "diff", "--name-only", "--diff-filter=U"], { allowFailure: true }); + finish({ + status: "needs_intervention", + message: `Harness patch rebuild stopped while applying ${patchBranch.name}.`, + artifacts: { + ...baseArtifacts(base), + beforeSha, + applied, + failedPatch: patchBranch, + statusOutput: status.stdout, + unmergedFiles: lines(unmerged.stdout), + commands: commandArtifacts(), + }, + }); + } + applied.push(patchBranch); + } + + const candidateSha = (await runChecked("read rebuilt harness head", ["git", "rev-parse", "HEAD"])).stdout.trim(); + const candidateTree = await resolveTree(candidateSha); + const changed = !beforeSha || candidateTree !== beforeTree; + if (changed) { + await run("update maintained harness branch", ["git", "branch", "-f", targetBranch, candidateSha]); + } else if (!targetExists) { + await run("seed maintained harness branch", ["git", "branch", "-f", targetBranch, beforeSha]); + } + await run("switch maintained harness branch", ["git", "switch", targetBranch]); + + for (const command of verifyCommands) { + const result = await run(`verify: ${command}`, ["bash", "-lc", command], { allowFailure: true }); + if (result.code !== 0) { + finish({ + status: "needs_intervention", + message: `Harness verification failed: ${command}.`, + artifacts: { + ...baseArtifacts(base), + beforeSha, + candidateSha, + applied, + failedCommand: command, + commands: commandArtifacts(), + }, + }); + } + } + await requireCleanWorktree("after harness verification"); + + const afterSha = (await runChecked("read maintained harness head", ["git", "rev-parse", "HEAD"])).stdout.trim(); + if (enabled("push", false)) { + for (const remote of pushRemotes) { + await run(`push ${remote}/${targetBranch}`, [ + "git", + "push", + "--force-with-lease", + remote, + `HEAD:${targetBranch}`, + ]); + } + } + + finish({ + status: changed ? "changed" : "completed", + message: changed + ? `Harness fork rebuilt ${targetBranch} from ${base.label} plus ${patchBranches.length} patches.` + : `Harness fork already matches ${base.label} plus ${patchBranches.length} patches.`, + artifacts: { + ...baseArtifacts(base), + eventId: context.flow.event.id, + forkRepo, + forkRepoFullName, + targetBranch, + upstreamBranch, + patchPrefix, + beforeSha, + afterSha, + applied, + checks: verifyCommands.map((name) => ({ name, status: "passed" })), + candidateRefs: candidateRefsFor(afterSha), + commands: commandArtifacts(), + }, + }); +} catch (error) { + finish({ + status: "failed", + message: error instanceof Error ? error.message : String(error), + artifacts: { commands: commandArtifacts() }, + }); +} + +async function requireGitRepo(): Promise { + await runChecked("verify harness fork checkout", ["git", "rev-parse", "--show-toplevel"]); +} + +async function ensureUpstreamRemote(): Promise { + const current = await run("read upstream remote", ["git", "remote", "get-url", upstreamRemote], { allowFailure: true }); + if (current.code === 0) return; + if (!upstreamRepoUrl) { + finish({ status: "blocked", message: `Missing ${upstreamRemote} remote and no upstream_repo_url is configured.` }); + } + await run("add upstream remote", ["git", "remote", "add", upstreamRemote, upstreamRepoUrl]); +} + +async function requireNoRebaseOrCherryPickInProgress(): Promise { + const state = await run( + "check existing replay state", + ["bash", "-lc", 'test -d "$(git rev-parse --git-path rebase-merge)" -o -d "$(git rev-parse --git-path rebase-apply)" -o -f "$(git rev-parse --git-path CHERRY_PICK_HEAD)"'], + { allowFailure: true }, + ); + if (state.code === 0) { + finish({ + status: "blocked", + message: "A rebase or cherry-pick is already in progress in the harness fork checkout.", + artifacts: { forkRepo, commands: commandArtifacts() }, + }); + } +} + +async function requireCleanWorktree(stage: string): Promise { + const status = await run(`dirty worktree check ${stage}`, ["git", "status", "--porcelain=v1"]); + if (status.stdout.trim()) { + finish({ + status: "blocked", + message: `Harness fork checkout has local changes ${stage}.`, + artifacts: { + dirtyStatus: status.stdout, + forkRepo, + commands: commandArtifacts(), + }, + }); + } +} + +async function resolveBase(): Promise<{ kind: "release" | "branch"; label: string; sha: string }> { + if (context.flow.event.type === "upstream.release") { + const tag = stringValue(payload.tag) || shortTag(stringValue(payload.ref) ?? ""); + if (!tag) { + finish({ status: "failed", message: "upstream.release requires payload.tag." }); + } + return { kind: "release", label: tag, sha: await resolveCommit(`refs/tags/${tag}`).catch(() => resolveCommit(tag)) }; + } + if (context.flow.event.type === "upstream.branch_update") { + const ref = stringValue(payload.ref) ?? "refs/heads/main"; + const sha = stringValue(payload.sha); + if (!sha) { + finish({ status: "failed", message: "upstream.branch_update requires payload.sha." }); + } + return { kind: "branch", label: `${ref}@${sha}`, sha: await resolveCommit(sha) }; + } + finish({ status: "skipped", message: `Unsupported harness fork event ${context.flow.event.type}.` }); +} + +async function ensureSeedPatchBranches(): Promise { + const seeds = stringArrayConfig("seed_patch_refs", []); + for (const seed of seeds) { + const [name, ref] = seed.split("="); + if (!name?.startsWith(patchPrefix) || !ref) { + throw new Error(`Invalid seed_patch_refs entry: ${seed}`); + } + const exists = await branchExists(name); + if (exists) continue; + await resolveCommit(ref); + await run(`seed ${name}`, ["git", "branch", "-f", name, ref]); + } +} + +async function listPatchBranches(): Promise { + const refsPath = `refs/heads/${patchPrefix.replace(/\/+$/, "")}`; + const result = await run("list harness patch branches", [ + "git", + "for-each-ref", + "--format=%(refname:short)%09%(objectname)%09%(contents:subject)", + refsPath, + ], { allowFailure: true }); + if (result.code !== 0 || !result.stdout.trim()) return []; + return result.stdout.trim().split(/\r?\n/).map((line) => { + const [name = "", sha = "", subject = ""] = line.split("\t"); + return { name, sha, subject }; + }).filter((branch) => branch.name.startsWith(patchPrefix)).sort((left, right) => left.name.localeCompare(right.name)); +} + +async function branchExists(branch: string): Promise { + return (await run(`check branch ${branch}`, ["git", "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { allowFailure: true })).code === 0; +} + +async function resolveCommit(ref: string): Promise { + return (await runChecked(`resolve ${ref}`, ["git", "rev-parse", "--verify", `${ref}^{commit}`])).stdout.trim(); +} + +async function resolveTree(ref: string): Promise { + return (await runChecked(`resolve tree ${ref}`, ["git", "rev-parse", "--verify", `${ref}^{tree}`])).stdout.trim(); +} + +async function runChecked(label: string, cmd: string[]): Promise { + const result = await run(label, cmd); + if (result.code !== 0) { + throw new Error(`${label} failed with exit ${result.code}:\n${result.stderr || result.stdout}`); + } + return result; +} + +async function run(label: string, cmd: string[], options: { allowFailure?: boolean } = {}): Promise { + process.stderr.write(`+ ${label}: ${cmd.join(" ")}\n`); + const child = Bun.spawn(cmd, { + cwd: forkRepo, + env: process.env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (stdout) process.stderr.write(stdout); + if (stderr) process.stderr.write(stderr); + const result = { label, cmd, cwd: forkRepo, code, stdout, stderr }; + commands.push(result); + if (code !== 0 && !options.allowFailure) { + throw new Error(`${label} failed with exit ${code}:\n${stderr || stdout}`); + } + return result; +} + +function baseArtifacts(base: { kind: string; label: string; sha: string }): Record { + return { + upstreamKind: base.kind, + upstreamLabel: base.label, + upstreamSha: base.sha, + }; +} + +function candidateRefsFor(sha: string): Array> { + const pushed = enabled("push", false); + const remotes = pushed ? pushRemotes : ["local"]; + return remotes.map((remote) => ({ + kind: "branch", + repo: forkRepoFullName, + remote, + ref: `refs/heads/${targetBranch}`, + sha, + pushed, + })); +} + +function commandArtifacts(): Array> { + return commands.map((command) => ({ + ...command, + stdout: truncate(command.stdout), + stderr: truncate(command.stderr), + })); +} + +function enabled(name: string, fallback: boolean): boolean { + const envValue = process.env[`CODEX_FLOW_${name.toUpperCase()}`]; + 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 stringArrayConfig(name: string, fallback: string[]): string[] { + const value = config[name]; + if (!Array.isArray(value)) return fallback; + const entries = value.filter((entry): entry is string => typeof entry === "string" && entry.trim()); + return entries.length > 0 ? entries : fallback; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function lines(value: string): string[] { + return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); +} + +function shortTag(value: string): string { + return value.replace(/^refs\/tags\//, ""); +} + +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/flows/patch-moi-harness-fork/fixtures/upstream-main-v0.1.3.json b/flows/patch-moi-harness-fork/fixtures/upstream-main-v0.1.3.json new file mode 100644 index 0000000..107ff46 --- /dev/null +++ b/flows/patch-moi-harness-fork/fixtures/upstream-main-v0.1.3.json @@ -0,0 +1,11 @@ +{ + "id": "patch:harness-main:772bdd62c6031302b57b175e428110fa51671861:upstream.branch_update", + "type": "upstream.branch_update", + "source": "patch", + "receivedAt": "2026-05-16T00:00:00.000Z", + "payload": { + "repo": "peezy-tech/patch-moi-harness", + "ref": "refs/heads/main", + "sha": "772bdd62c6031302b57b175e428110fa51671861" + } +} diff --git a/flows/patch-moi-harness-fork/fixtures/upstream-release-v0.1.3.json b/flows/patch-moi-harness-fork/fixtures/upstream-release-v0.1.3.json new file mode 100644 index 0000000..e46d3f6 --- /dev/null +++ b/flows/patch-moi-harness-fork/fixtures/upstream-release-v0.1.3.json @@ -0,0 +1,11 @@ +{ + "id": "patch:harness:v0.1.3:upstream.release", + "type": "upstream.release", + "source": "patch", + "receivedAt": "2026-05-16T00:00:00.000Z", + "payload": { + "repo": "peezy-tech/patch-moi-harness", + "tag": "v0.1.3", + "url": "https://github.com/peezy-tech/patch-moi-harness/releases/tag/v0.1.3" + } +} diff --git a/flows/patch-moi-harness-fork/flow.toml b/flows/patch-moi-harness-fork/flow.toml new file mode 100644 index 0000000..0a58e4c --- /dev/null +++ b/flows/patch-moi-harness-fork/flow.toml @@ -0,0 +1,44 @@ +name = "patch-moi-harness-fork" +version = 1 +description = "Maintain the harness fork patch workspace from upstream releases and main updates." + +[config] +expected_repo = "peezy-tech/patch-moi-harness" +fork_repo = "harness/fork" +fork_repo_full_name = "matamune-peezy/patch-moi-harness" +target_branch = "main" +upstream_branch = "upstream" +patch_prefix = "patch/" +upstream_remote = "upstream" +upstream_repo_url = "https://github.com/peezy-tech/patch-moi-harness.git" +fetch = true +push = false +push_remotes = ["origin", "jojo"] +verify_commands = ["npm test", "npm run pack:dry-run"] +seed_patch_refs = [ + "patch/010-maintained-greeting=e8e54070d4465b509b07d9d718ed693a75b6f870", + "patch/020-shout-mode=5a06a998390b0b835adcad134f8055a2c2eb5b02", + "patch/030-package-identity=9fd9d8c31a004059e16f769453d75afba29959be", +] + +[[steps]] +name = "release-cycle" +runner = "bun" +script = "exec/update-fork.ts" +cwd = "../.." +timeout_ms = 600000 + +[steps.trigger] +type = "upstream.release" +schema = "schemas/upstream-release.schema.json" + +[[steps]] +name = "main-branch-update" +runner = "bun" +script = "exec/update-fork.ts" +cwd = "../.." +timeout_ms = 600000 + +[steps.trigger] +type = "upstream.branch_update" +schema = "schemas/upstream-branch-update.schema.json" diff --git a/flows/patch-moi-harness-fork/schemas/upstream-branch-update.schema.json b/flows/patch-moi-harness-fork/schemas/upstream-branch-update.schema.json new file mode 100644 index 0000000..cee19f7 --- /dev/null +++ b/flows/patch-moi-harness-fork/schemas/upstream-branch-update.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["repo", "ref", "sha"], + "properties": { + "repo": { + "type": "string", + "enum": ["peezy-tech/patch-moi-harness"] + }, + "ref": { + "type": "string", + "enum": ["refs/heads/main"] + }, + "sha": { + "type": "string", + "pattern": "^[0-9a-fA-F]{7,40}$" + } + }, + "additionalProperties": true +} diff --git a/flows/patch-moi-harness-fork/schemas/upstream-release.schema.json b/flows/patch-moi-harness-fork/schemas/upstream-release.schema.json new file mode 100644 index 0000000..9f12446 --- /dev/null +++ b/flows/patch-moi-harness-fork/schemas/upstream-release.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["repo", "tag"], + "properties": { + "repo": { + "type": "string", + "enum": ["peezy-tech/patch-moi-harness"] + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string" + } + }, + "additionalProperties": true +} diff --git a/flows/patch-moi-harness/README.md b/flows/patch-moi-harness/README.md deleted file mode 100644 index b7868c1..0000000 --- a/flows/patch-moi-harness/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# patch-moi-harness Flow - -This flow is the first executable patch.moi maintenance harness. It consumes a -generic `upstream.release` event for `peezy-tech/patch-moi-harness`, then uses -Git state in `harness/fork` to keep the maintained fork on top of the upstream -release tag. - -The default behavior is local and reviewable: - -- fetch the configured upstream remote -- switch to the maintained fork branch -- rebase onto the release tag when the tag is not already an ancestor -- run the configured package checks -- emit candidate branch refs in the `FLOW_RESULT` artifacts -- leave pushes disabled unless `CODEX_FLOW_PUSH=1` is set - -Useful overrides: - -```bash -CODEX_FLOW_FETCH=0 bun run harness:flow -CODEX_FLOW_PUSH=1 bun run harness:flow -``` - -The fixture event is `fixtures/upstream-release-v0.1.3.json`. It should be a -no-op rebase against the current harness fork while still verifying the package -surface and reporting the local maintained branch as the candidate ref. - -The repo also exposes an experimental workspace-owned flow smoke task: - -```bash -cd ../codex-flows -bun run workspace:backend --cwd /home/peezy/meta-workspace/patch.moi -``` - -```bash -CODEX_WORKSPACE_BACKEND_WS_URL=ws://127.0.0.1:3586 \ -CODEX_FLOW_FETCH=0 CODEX_FLOW_PUSH=0 \ -bun run workspace:run:harness-flow -``` - -That task requires a running Codex workspace backend and writes backend -event/run state, not patch.moi `DATA_DIR` maintenance-attempt state. diff --git a/flows/patch-moi-harness/exec/rebase-fork.ts b/flows/patch-moi-harness/exec/rebase-fork.ts deleted file mode 100644 index bc2c8c1..0000000 --- a/flows/patch-moi-harness/exec/rebase-fork.ts +++ /dev/null @@ -1,336 +0,0 @@ -import path from "node:path"; -import { defineBunFlow } from "@peezy.tech/codex-flows/flow-runtime/bun"; -import type { FlowResult, FlowResultStatus, FlowRunContext } from "@peezy.tech/codex-flows/flow-runtime"; - -type HarnessFlowContext = FlowRunContext & { - flow: FlowRunContext["flow"] & { - event: FlowRunContext["flow"]["event"] & { - payload: Record; - }; - }; -}; - -type CommandResult = { - label: string; - cmd: string[]; - cwd: string; - exitCode: number | null; - stdout: string; - stderr: string; -}; - -const expectedRepo = "peezy-tech/patch-moi-harness"; -let context: HarnessFlowContext; -let config: Record; -let commands: CommandResult[]; -let releaseTag: string; -let repo: string; -let forkRepo: string; -let forkRepoFullName: string; -let targetBranch: string; -let upstreamRemote: string; -let upstreamRepoUrl: string; -let verifyCommands: string[]; -let pushRemotes: string[]; - -class FlowFinished extends Error { - constructor(readonly result: FlowResult) { - super(result.message ?? result.status); - } -} - -export default defineBunFlow(async (flowContext: FlowRunContext): Promise => { - context = flowContext as HarnessFlowContext; - config = context.flow.config ?? {}; - commands = []; - - const workspaceRoot = process.cwd(); - const payload = context.flow.event.payload ?? {}; - releaseTag = tagFromPayload(payload); - repo = stringValue(payload.repo, "payload.repo"); - forkRepo = path.resolve(workspaceRoot, stringConfig("fork_repo", "harness/fork")); - forkRepoFullName = stringConfig("fork_repo_full_name", "matamune-peezy/patch-moi-harness"); - targetBranch = stringConfig("target_branch", "main"); - upstreamRemote = stringConfig("upstream_remote", "upstream"); - upstreamRepoUrl = stringConfig("upstream_repo_url", "https://github.com/peezy-tech/patch-moi-harness.git"); - verifyCommands = stringArrayConfig("verify_commands", ["npm test", "npm run pack:dry-run"]); - pushRemotes = stringArrayConfig("push_remotes", ["origin", "jojo"]); - - try { - if (repo !== expectedRepo) { - finish("skipped", `Harness flow ignores ${repo}.`, { repo, expectedRepo }); - } - if (!releaseTag) { - finish("failed", "Release payload is missing tag or refs/tags ref."); - } - - const repoCheck = await run("verify harness fork checkout", ["git", "rev-parse", "--show-toplevel"]); - if (repoCheck.exitCode !== 0) { - finish("blocked", `Harness fork checkout is not available at ${forkRepo}.`, { forkRepo }); - } - - await ensureUpstreamRemote(); - - if (enabled("fetch", true)) { - await run("fetch upstream release refs", ["git", "fetch", upstreamRemote, "--tags", "--prune"]); - } - - await requireNoRebaseInProgress(); - await requireCleanWorktree("before harness fork rebase"); - - const branch = (await run("read current branch", ["git", "branch", "--show-current"])).stdout.trim(); - if (branch !== targetBranch) { - await run("switch maintained fork branch", ["git", "switch", targetBranch]); - } - - const beforeSha = (await run("read fork head before rebase", ["git", "rev-parse", "HEAD"])).stdout.trim(); - const releaseSha = await resolveReleaseCommit(releaseTag); - const alreadyContainsRelease = (await run( - "check release ancestor", - ["git", "merge-base", "--is-ancestor", releaseSha, "HEAD"], - { allowFailure: true }, - )).exitCode === 0; - - if (!alreadyContainsRelease) { - const rebase = await run( - "rebase harness fork onto upstream release", - ["git", "rebase", releaseSha], - { allowFailure: true }, - ); - if (rebase.exitCode !== 0) { - const context = await collectRebaseContext(rebase, beforeSha); - finish("needs_intervention", `Harness fork rebase stopped on ${releaseTag}.`, context); - } - } - - for (const command of verifyCommands) { - const result = await run(`verify: ${command}`, ["bash", "-lc", command], { allowFailure: true }); - if (result.exitCode !== 0) { - finish("needs_intervention", `Harness verification failed: ${command}.`, { - failedCommand: command, - }); - } - } - - await requireCleanWorktree("after harness fork verification"); - - const afterSha = (await run("read fork head after rebase", ["git", "rev-parse", "HEAD"])).stdout.trim(); - - if (enabled("push", false)) { - for (const remote of pushRemotes) { - await run(`push ${remote}/${targetBranch}`, [ - "git", - "push", - "--force-with-lease", - remote, - `HEAD:${targetBranch}`, - ]); - } - } - - finish(beforeSha === afterSha ? "completed" : "changed", harnessMessage(beforeSha, afterSha), { - eventId: context.flow.event.id, - repo, - forkRepo, - forkRepoFullName, - targetBranch, - releaseTag, - releaseSha, - beforeSha, - afterSha, - pushed: enabled("push", false), - checks: verifyCommands.map((command) => ({ name: command, status: "passed" })), - candidateRefs: candidateRefsFor(afterSha), - }); - } catch (error) { - if (error instanceof FlowFinished) { - return error.result; - } - return buildResult("failed", error instanceof Error ? error.message : String(error)); - } -}); - -async function ensureUpstreamRemote(): Promise { - const current = await run("read upstream remote", ["git", "remote", "get-url", upstreamRemote], { - allowFailure: true, - }); - if (current.exitCode === 0) { - return; - } - if (!upstreamRepoUrl) { - finish("blocked", `Missing ${upstreamRemote} remote and no upstream_repo_url is configured.`); - } - await run("add upstream remote", ["git", "remote", "add", upstreamRemote, upstreamRepoUrl]); -} - -async function requireNoRebaseInProgress(): Promise { - const state = await run( - "check existing rebase state", - ["bash", "-lc", 'test -d "$(git rev-parse --git-path rebase-merge)" -o -d "$(git rev-parse --git-path rebase-apply)"'], - { allowFailure: true }, - ); - if (state.exitCode === 0) { - finish("blocked", "A rebase is already in progress in the harness fork checkout.", { - forkRepo, - }); - } -} - -async function requireCleanWorktree(stage: string): Promise { - const status = await run(`dirty worktree check ${stage}`, ["git", "status", "--porcelain=v1"]); - if (status.stdout.trim()) { - finish("blocked", `Harness fork checkout has local changes ${stage}.`, { - dirtyStatus: status.stdout, - forkRepo, - }); - } -} - -async function resolveReleaseCommit(tag: string): Promise { - const candidates = tag.startsWith("refs/") - ? [`${tag}^{commit}`, `${shortTag(tag)}^{commit}`] - : [`refs/tags/${tag}^{commit}`, `${tag}^{commit}`]; - for (const candidate of candidates) { - const result = await run(`resolve release ref ${candidate}`, ["git", "rev-parse", "--verify", candidate], { - allowFailure: true, - }); - if (result.exitCode === 0 && result.stdout.trim()) { - return result.stdout.trim(); - } - } - finish("blocked", `Could not resolve upstream release tag ${tag}.`, { - tag, - forkRepo, - }); -} - -async function collectRebaseContext(rebase: CommandResult, beforeSha: string): Promise> { - const status = await run("rebase conflict status", ["git", "status", "--short", "--branch"], { - allowFailure: true, - }); - const unmerged = await run("unmerged files", ["git", "diff", "--name-only", "--diff-filter=U"], { - allowFailure: true, - }); - const currentPatch = await run("current rebase patch", ["git", "rebase", "--show-current-patch"], { - allowFailure: true, - }); - return { - beforeSha, - rebaseOutput: truncate(rebase.stderr || rebase.stdout, 8000), - statusOutput: status.stdout, - unmergedFiles: unmerged.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean), - currentPatch: truncate(currentPatch.stdout || currentPatch.stderr, 12000), - }; -} - -async function run( - label: string, - cmd: string[], - options: { allowFailure?: boolean } = {}, -): Promise { - const child = Bun.spawn(cmd, { - cwd: forkRepo, - env: process.env, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - const result = { label, cmd, cwd: forkRepo, 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: FlowResultStatus, message: string, artifacts: Record = {}): never { - throw new FlowFinished(buildResult(status, message, artifacts)); -} - -function buildResult(status: FlowResultStatus, message: string, artifacts: Record = {}): FlowResult { - const commandArtifacts = commands.map((command) => ({ - ...command, - stdout: truncate(command.stdout), - stderr: truncate(command.stderr), - })); - return { - status, - message, - artifacts: { - ...artifacts, - commands: commandArtifacts, - }, - }; -} - -function harnessMessage(beforeSha: string, afterSha: string): string { - if (beforeSha === afterSha) { - return `Harness fork already contains ${releaseTag}; package checks passed.`; - } - return `Harness fork rebased onto ${releaseTag}; package checks passed.`; -} - -function candidateRefsFor(sha: string): Array> { - const pushed = enabled("push", false); - const remotes = pushed ? pushRemotes : ["local"]; - return remotes.map((remote) => ({ - kind: "branch", - repo: forkRepoFullName, - remote, - ref: `refs/heads/${targetBranch}`, - sha, - pushed, - })); -} - -function enabled(name: string, fallback: boolean): boolean { - const envValue = process.env[`CODEX_FLOW_${name.toUpperCase()}`]; - 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 stringArrayConfig(name: string, fallback: string[]): string[] { - const value = config[name]; - if (!Array.isArray(value)) { - return fallback; - } - const entries = value.filter((entry): entry is string => typeof entry === "string" && entry.trim()); - return entries.length > 0 ? entries : 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 tagFromPayload(value: Record): string { - if (typeof value.tag === "string" && value.tag.trim()) { - return value.tag.trim(); - } - if (typeof value.ref === "string" && value.ref.startsWith("refs/tags/")) { - return value.ref.trim(); - } - return ""; -} - -function shortTag(value: string): string { - return value.replace(/^refs\/tags\//, ""); -} - -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/flows/patch-moi-harness/flow.toml b/flows/patch-moi-harness/flow.toml deleted file mode 100644 index f6a04a4..0000000 --- a/flows/patch-moi-harness/flow.toml +++ /dev/null @@ -1,25 +0,0 @@ -name = "patch-moi-harness" -version = 1 -description = "Rebase the maintained patch.moi harness fork onto an upstream harness release." - -[config] -fork_repo = "harness/fork" -fork_repo_full_name = "matamune-peezy/patch-moi-harness" -target_branch = "main" -upstream_remote = "upstream" -upstream_repo_url = "https://github.com/peezy-tech/patch-moi-harness.git" -fetch = true -push = false -push_remotes = ["origin", "jojo"] -verify_commands = ["npm test", "npm run pack:dry-run"] - -[[steps]] -name = "rebase-fork" -runner = "bun" -script = "exec/rebase-fork.ts" -cwd = "../.." -timeout_ms = 600000 - -[steps.trigger] -type = "upstream.release" -schema = "schemas/upstream-release.schema.json" diff --git a/flows/patch-moi-harness/schemas/upstream-release.schema.json b/flows/patch-moi-harness/schemas/upstream-release.schema.json deleted file mode 100644 index b2120e4..0000000 --- a/flows/patch-moi-harness/schemas/upstream-release.schema.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "type": "object", - "required": ["repo", "tag"], - "properties": { - "provider": { "type": "string" }, - "repo": { - "type": "string", - "enum": ["peezy-tech/patch-moi-harness"] - }, - "tag": { "type": "string", "minLength": 1 }, - "ref": { "type": "string" }, - "url": { "type": "string" }, - "publishedAt": { "type": "string" } - } -} diff --git a/harness/README.md b/harness/README.md index f2894a7..ad492a1 100644 --- a/harness/README.md +++ b/harness/README.md @@ -34,8 +34,13 @@ git -C harness/fork fetch jojo ## Branch Model - `harness/upstream` `main`: upstream package history and upstream releases. -- `harness/fork` `upstream/main`: fetched copy of upstream `main`. -- `harness/fork` `main`: maintained fork with patch commits applied. +- `harness/fork` `upstream`: local branch that follows the selected upstream + release tag or main commit for a maintenance run. +- `harness/fork` `main`: maintained fork rebuilt from `upstream` plus ordered + `patch/*` branches. +- `harness/fork` `patch/*`: one logical fork patch per branch tip. The current + seeds are `patch/010-maintained-greeting`, `patch/020-shout-mode`, and + `patch/030-package-identity`. - `harness/fork` `jojo/main`: service-style maintained fork remote. The upstream npm package is `@peezy.tech/patch-moi-harness`. It publishes from @@ -84,7 +89,7 @@ git push jojo main Expected result: the GitHub fork and jojo maintained branch both move ahead of the last upstream commit. -## Scenario: Rebase Fork Onto Upstream +## Scenario: Rebuild Fork Onto Upstream Use this when upstream has released and the maintained fork needs to carry its patches forward. @@ -92,25 +97,30 @@ patches forward. ```bash cd harness/fork git fetch upstream -git fetch origin +git branch -f upstream upstream/main +git checkout --detach upstream +for patch in $(git for-each-ref --format='%(refname:short)' refs/heads/patch | sort); do + git cherry-pick "$patch" +done +git branch -f main HEAD git checkout main -git rebase upstream/main npm test git push --force-with-lease origin main git push --force-with-lease jojo main ``` Expected result: fork `main` is still patched, but its base is the latest -upstream release. +upstream release or main commit. -The same maintenance path is executable through the first patch.moi harness -flow: +The same maintenance path is executable through the patch.moi harness flows: ```bash CODEX_FLOW_FETCH=0 CODEX_FLOW_PUSH=0 bun run harness:flow ``` -That direct command is local-mode execution. The repo-native workspace autonomy +That direct command is local-mode execution. The default upstream release +fixture fans out to `patch-moi-harness-bindings/generate-bindings` and +`patch-moi-harness-fork/release-cycle`. The repo-native workspace autonomy surface runs the same harness through a manual command task: ```bash @@ -125,7 +135,26 @@ flow event to the configured workspace backend. The default fixture targets `v0.1.3`, which should verify the current fork without changing it and report `candidateRefs` for the maintained fork branch. For a new upstream tag, run the same command with an event file whose -`payload.tag` names that tag. +`payload.tag` names that tag. For upstream main movement, use the +`flows/patch-moi-harness-fork/fixtures/upstream-main-v0.1.3.json` event shape +with the new main SHA. + +## Scenario: Downstream Release Artifact + +Use this when a downstream package release should prepare a local fork artifact +without pushing or publishing: + +```bash +bun run patch.moi -- run event \ + --file flows/patch-moi-harness-flows-fork/fixtures/downstream-fork-release-v0.1.3-fork.0.json \ + --allow-local \ + --json +``` + +Expected result: `patch-moi-harness-flows-fork/release-fork` creates a +flow-owned worktree under `.codex/flow-artifacts`, runs the fork package tests, +and writes an npm tarball under +`.codex/flow-artifacts/patch-moi-harness-flows-fork-release`. ## Scenario: Fork Release