Skip to content

Commit 91b5c80

Browse files
NeftedollarRoman Melnikov
andauthored
feat(dev-workflow): PR C — real feature pipeline (build/test/ship) (#222)
* feat(dev-workflow): PR C — real feature pipeline (build/test/ship) Swaps 3 noop nodes in feature.ts for real implementations: - build: defineAgent using engineering-senior-developer role (codex) - test: defineFunction spawning bun test with 10min timeout - ship: defineFunction — git + gh pr create (no hardcoded --base) Session on build deferred to PR D where fix→test iteration needs it. Bumps @ageflow/dev-workflow 0.0.11 → 0.0.12 (private). * fix(dev-workflow): PR C lint — biome format feature.ts ship fn CI flagged a multi-line format preference on the git add catch block. --------- Co-authored-by: Roman Melnikov <roman@neftedollar.com>
1 parent 0107479 commit 91b5c80

4 files changed

Lines changed: 236 additions & 39 deletions

File tree

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dev-workflow/__tests__/pipelines.test.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,51 @@ describe("feature pipeline", () => {
4343
expect(verify.agent).toBeDefined();
4444
});
4545

46-
it("build/test/ship remain defineFunction stubs (mixed-node pattern)", () => {
46+
it("build task is an agent (senior-developer role-backed)", () => {
4747
const wf = createFeaturePipeline(FAKE_INPUT);
48-
for (const key of ["build", "test", "ship"] as const) {
49-
const task = wf.tasks[key] as { agent?: unknown; fn?: unknown };
50-
expect(task.fn).toBeDefined();
51-
expect(task.agent).toBeUndefined();
52-
}
48+
const build = wf.tasks.build as { agent?: unknown; fn?: unknown };
49+
expect(build.agent).toBeDefined();
50+
expect(build.fn).toBeUndefined();
51+
});
52+
53+
it("test task is a defineFunction (deterministic bun test runner)", () => {
54+
const wf = createFeaturePipeline(FAKE_INPUT);
55+
const test = wf.tasks.test as { agent?: unknown; fn?: unknown };
56+
expect(test.fn).toBeDefined();
57+
expect(test.agent).toBeUndefined();
58+
});
59+
60+
it("ship task is a defineFunction (deterministic git+gh)", () => {
61+
const wf = createFeaturePipeline(FAKE_INPUT);
62+
const ship = wf.tasks.ship as { agent?: unknown; fn?: unknown };
63+
expect(ship.fn).toBeDefined();
64+
expect(ship.agent).toBeUndefined();
65+
});
66+
67+
it("build dependsOn plan", () => {
68+
const wf = createFeaturePipeline(FAKE_INPUT);
69+
const build = wf.tasks.build as { dependsOn?: readonly string[] };
70+
expect(build.dependsOn).toContain("plan");
71+
});
72+
73+
it("test dependsOn build", () => {
74+
const wf = createFeaturePipeline(FAKE_INPUT);
75+
const test = wf.tasks.test as { dependsOn?: readonly string[] };
76+
expect(test.dependsOn).toContain("build");
77+
});
78+
79+
it("verify dependsOn test and plan", () => {
80+
const wf = createFeaturePipeline(FAKE_INPUT);
81+
const verify = wf.tasks.verify as { dependsOn?: readonly string[] };
82+
expect(verify.dependsOn).toContain("test");
83+
expect(verify.dependsOn).toContain("plan");
84+
});
85+
86+
it("ship dependsOn verify and build", () => {
87+
const wf = createFeaturePipeline(FAKE_INPUT);
88+
const ship = wf.tasks.ship as { dependsOn?: readonly string[] };
89+
expect(ship.dependsOn).toContain("verify");
90+
expect(ship.dependsOn).toContain("build");
5391
});
5492
});
5593

packages/dev-workflow/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ageflow/dev-workflow",
3-
"version": "0.0.12",
3+
"version": "0.0.13",
44
"private": true,
55
"type": "module",
66
"scripts": {

packages/dev-workflow/pipelines/feature.ts

Lines changed: 190 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
// Feature pipeline — PLAN → BUILD → TEST → VERIFY → SHIP DAG.
22
//
3-
// Sub-PR 2: two tasks (plan, verify) are now real `defineAgent` calls that
4-
// load their prompt from the role library via `loadRole()`. The remaining
5-
// three (build, test, ship) stay as `defineFunction` no-ops — they land as
6-
// real agents in sub-PR 4 alongside executor dispatch.
3+
// PR C: build, test, and ship are now real implementations:
4+
// plan — defineAgent using engineering-software-architect role (codex) ✅
5+
// build — defineAgent using engineering-senior-developer role (codex)
6+
// test — defineFunction spawning `bun test` with 10-min timeout
7+
// verify — defineAgent using engineering-code-reviewer role (codex) ✅
8+
// ship — defineFunction — git add/commit/push + gh pr create
79
//
8-
// This mixed-node shape is intentional: it exercises both the agent-with-
9-
// role-prompt path and the deterministic `defineFunction` path inside the
10-
// same DAG, proving the end-to-end wiring without yet calling the executor.
10+
// Session on build is deferred to PR D where fix→test iteration needs it.
1111

1212
import {
1313
defineAgent,
1414
defineFunction,
1515
defineWorkflowFactory,
1616
} from "@ageflow/core";
17+
import { execa } from "execa";
1718
import { z } from "zod";
1819
import { loadRoleSync } from "../shared/role-loader.js";
1920
import type { WorkflowInput } from "../shared/types.js";
2021

21-
const noopFn = defineFunction({
22-
name: "noop",
23-
input: z.object({}).passthrough(),
24-
output: z.object({}),
25-
execute: async () => ({}),
26-
});
27-
2822
// PLAN — engineering-software-architect produces a technical plan.
2923
// Uses codex (primary runner). Output schema is a tight Zod object so raw
3024
// agent stdout never flows downstream unsanitized (security boundary).
@@ -71,6 +65,41 @@ const architectAgent = defineAgent({
7165
},
7266
});
7367

68+
// BUILD — engineering-senior-developer implements the plan in the worktree.
69+
// Session on build deferred to PR D where fix→test iteration needs it.
70+
const seniorDeveloperAgent = defineAgent({
71+
runner: "codex",
72+
input: z.object({
73+
issueNumber: z.number().int().positive(),
74+
issueTitle: z.string(),
75+
plan: z.string(),
76+
affectedPackages: z.array(z.string()),
77+
worktreePath: z.string(),
78+
}),
79+
output: z.object({
80+
filesChanged: z.array(z.string()),
81+
summary: z.string(),
82+
typecheckPassed: z.boolean(),
83+
}),
84+
prompt: (input) => {
85+
const role = loadRoleSync("engineering-senior-developer");
86+
return [
87+
role.body,
88+
"---",
89+
`Feature issue #${input.issueNumber}: ${input.issueTitle}`,
90+
"",
91+
"Plan from architect:",
92+
input.plan,
93+
"",
94+
`Affected packages: ${input.affectedPackages.join(", ")}`,
95+
`Worktree: ${input.worktreePath}`,
96+
"",
97+
"Implement per the plan. Run typecheck + tests locally before returning.",
98+
"Only commit reality: typecheckPassed must reflect actual exit code.",
99+
].join("\n");
100+
},
101+
});
102+
74103
// VERIFY — engineering-code-reviewer reads the diff and decides the gate.
75104
const codeReviewerAgent = defineAgent({
76105
runner: "codex",
@@ -117,12 +146,125 @@ const codeReviewerAgent = defineAgent({
117146
},
118147
});
119148

149+
// TEST — deterministic `bun test` runner. Captures output + exit code.
150+
// Timeout 10 minutes — long enough for slow CI-like runs.
151+
// reject: false — lets us capture the result on non-zero exit instead of throwing.
152+
const testFn = defineFunction({
153+
name: "test",
154+
input: z.object({
155+
worktreePath: z.string(),
156+
}),
157+
output: z.object({
158+
passed: z.boolean(),
159+
output: z.string(),
160+
exitCode: z.number().int(),
161+
}),
162+
execute: async (input) => {
163+
try {
164+
const { stdout, stderr, exitCode } = await execa("bun", ["test"], {
165+
cwd: input.worktreePath,
166+
reject: false,
167+
timeout: 600_000,
168+
});
169+
const combinedOutput = `${stdout}\n${stderr}`.slice(-4000); // last 4KB
170+
return {
171+
passed: exitCode === 0,
172+
output: combinedOutput,
173+
exitCode: exitCode ?? -1,
174+
};
175+
} catch (err) {
176+
return {
177+
passed: false,
178+
output: err instanceof Error ? err.message : String(err),
179+
exitCode: -1,
180+
};
181+
}
182+
},
183+
});
184+
185+
// SHIP — deterministic git + gh. Runs only when verify gate = APPROVED.
186+
// Reads the current branch from the worktree so it works regardless of
187+
// how the worktree was created (branch name already set by createWorktree).
188+
const shipFn = defineFunction({
189+
name: "ship",
190+
input: z.object({
191+
issueNumber: z.number().int().positive(),
192+
issueTitle: z.string(),
193+
worktreePath: z.string(),
194+
filesChanged: z.array(z.string()),
195+
summary: z.string(),
196+
gate: z.enum(["APPROVED", "NEEDS_WORK"]),
197+
}),
198+
output: z.object({
199+
prNumber: z.number().int().positive().nullable(),
200+
branch: z.string(),
201+
commit: z.string(),
202+
}),
203+
execute: async (input) => {
204+
if (input.gate !== "APPROVED") {
205+
throw new Error(`ship blocked: verify gate = ${input.gate}`);
206+
}
207+
208+
// Read the branch the worktree is already on (set by createWorktree).
209+
const { stdout: branch } = await execa(
210+
"git",
211+
["branch", "--show-current"],
212+
{ cwd: input.worktreePath },
213+
);
214+
const currentBranch = branch.trim();
215+
216+
// Stage changed files. Guard against hallucinated paths: skip files that fail.
217+
for (const file of input.filesChanged) {
218+
await execa("git", ["add", "--", file], {
219+
cwd: input.worktreePath,
220+
}).catch((err) =>
221+
console.warn(
222+
`[ship] git add ${file} failed: ${(err as Error).message}`,
223+
),
224+
);
225+
}
226+
227+
const commitMsg = `feat: #${input.issueNumber}${input.summary}\n\nCloses #${input.issueNumber}`;
228+
await execa("git", ["commit", "-m", commitMsg], {
229+
cwd: input.worktreePath,
230+
});
231+
232+
const { stdout: commit } = await execa("git", ["rev-parse", "HEAD"], {
233+
cwd: input.worktreePath,
234+
});
235+
236+
await execa("git", ["push", "-u", "origin", currentBranch], {
237+
cwd: input.worktreePath,
238+
});
239+
240+
const body = `## Summary\n\n${input.summary}\n\nCloses #${input.issueNumber}`;
241+
const { stdout: prUrl } = await execa(
242+
"gh",
243+
[
244+
"pr",
245+
"create",
246+
"--head",
247+
currentBranch,
248+
"--title",
249+
`feat: #${input.issueNumber}${input.issueTitle}`,
250+
"--body",
251+
body,
252+
],
253+
{ cwd: input.worktreePath },
254+
);
255+
256+
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
257+
const prNumber = prNumberMatch ? Number(prNumberMatch[1]) : null;
258+
259+
return { prNumber, branch: currentBranch, commit: commit.trim() };
260+
},
261+
});
262+
120263
export const createFeaturePipeline = defineWorkflowFactory(
121264
(input: WorkflowInput) => ({
122265
name: "feature-pipeline",
123266
tasks: {
124267
// PLAN phase — architect produces the technical plan.
125-
// Sub-PR 2: wired as real defineAgent. Sub-PR 4 runs the executor.
126268
plan: {
127269
agent: architectAgent,
128270
input: () => ({
@@ -132,30 +274,36 @@ export const createFeaturePipeline = defineWorkflowFactory(
132274
}),
133275
},
134276

135-
// BUILD phase — implement plan in worktree.
136-
// Sub-PR 4: replace with engineering-senior-developer agent + session.
277+
// BUILD phase — senior-developer implements plan in worktree.
137278
build: {
138-
fn: noopFn,
279+
agent: seniorDeveloperAgent,
139280
dependsOn: ["plan"] as const,
140-
input: () => ({ worktreePath: input.worktreePath }),
281+
input: (ctx: {
282+
plan: {
283+
output: { plan: string; affectedPackages: readonly string[] };
284+
};
285+
}) => ({
286+
issueNumber: input.issue.number,
287+
issueTitle: input.issue.title,
288+
plan: ctx.plan.output.plan,
289+
affectedPackages: [...ctx.plan.output.affectedPackages],
290+
worktreePath: input.worktreePath,
291+
}),
141292
},
142293

143-
// TEST phase — run `bun test` in worktree.
144-
// Sub-PR 4: replace with a deterministic test-runner function.
294+
// TEST phase — run `bun test` in worktree deterministically.
145295
test: {
146-
fn: noopFn,
296+
fn: testFn,
147297
dependsOn: ["build"] as const,
148298
input: () => ({ worktreePath: input.worktreePath }),
149299
},
150300

151301
// VERIFY phase — code-reviewer returns APPROVED / NEEDS_WORK.
152-
// Sub-PR 2: wired as real defineAgent. Reality-checker + security
153-
// engineer land as parallel peers in sub-PR 3/4 (learning hooks).
302+
// Depend on both `test` (for ordering) and `plan` (for the plan text
303+
// passed to the reviewer's prompt). `CtxFor` only exposes direct
304+
// dependencies, so `plan` must be listed here.
154305
verify: {
155306
agent: codeReviewerAgent,
156-
// Depend on both `test` (for ordering) and `plan` (for the plan text
157-
// passed to the reviewer's prompt). `CtxFor` only exposes direct
158-
// dependencies, so `plan` must be listed here.
159307
dependsOn: ["test", "plan"] as const,
160308
input: (ctx: {
161309
plan: { output: { plan: string } };
@@ -167,11 +315,22 @@ export const createFeaturePipeline = defineWorkflowFactory(
167315
},
168316

169317
// SHIP phase — push branch + open PR via gh.
170-
// Sub-PR 4: replace with ship role (or defineFunction, TBD).
171318
ship: {
172-
fn: noopFn,
173-
dependsOn: ["verify"] as const,
174-
input: () => ({ issueNumber: input.issue.number }),
319+
fn: shipFn,
320+
dependsOn: ["verify", "build"] as const,
321+
input: (ctx: {
322+
build: {
323+
output: { filesChanged: readonly string[]; summary: string };
324+
};
325+
verify: { output: { gate: "APPROVED" | "NEEDS_WORK" } };
326+
}) => ({
327+
issueNumber: input.issue.number,
328+
issueTitle: input.issue.title,
329+
worktreePath: input.worktreePath,
330+
filesChanged: [...ctx.build.output.filesChanged],
331+
summary: ctx.build.output.summary,
332+
gate: ctx.verify.output.gate,
333+
}),
175334
},
176335
},
177336
}),

0 commit comments

Comments
 (0)