Skip to content

Commit 6947d5e

Browse files
author
Roman Melnikov
committed
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).
1 parent 56a7ea0 commit 6947d5e

3 files changed

Lines changed: 234 additions & 38 deletions

File tree

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.11",
3+
"version": "0.0.12",
44
"private": true,
55
"type": "module",
66
"scripts": {

packages/dev-workflow/pipelines/feature.ts

Lines changed: 189 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,124 @@ 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], { cwd: input.worktreePath }).catch(
219+
(err) =>
220+
console.warn(
221+
`[ship] git add ${file} failed: ${(err as Error).message}`,
222+
),
223+
);
224+
}
225+
226+
const commitMsg = `feat: #${input.issueNumber}${input.summary}\n\nCloses #${input.issueNumber}`;
227+
await execa("git", ["commit", "-m", commitMsg], {
228+
cwd: input.worktreePath,
229+
});
230+
231+
const { stdout: commit } = await execa("git", ["rev-parse", "HEAD"], {
232+
cwd: input.worktreePath,
233+
});
234+
235+
await execa("git", ["push", "-u", "origin", currentBranch], {
236+
cwd: input.worktreePath,
237+
});
238+
239+
const body = `## Summary\n\n${input.summary}\n\nCloses #${input.issueNumber}`;
240+
const { stdout: prUrl } = await execa(
241+
"gh",
242+
[
243+
"pr",
244+
"create",
245+
"--head",
246+
currentBranch,
247+
"--title",
248+
`feat: #${input.issueNumber}${input.issueTitle}`,
249+
"--body",
250+
body,
251+
],
252+
{ cwd: input.worktreePath },
253+
);
254+
255+
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
256+
const prNumber = prNumberMatch ? Number(prNumberMatch[1]) : null;
257+
258+
return { prNumber, branch: currentBranch, commit: commit.trim() };
259+
},
260+
});
261+
120262
export const createFeaturePipeline = defineWorkflowFactory(
121263
(input: WorkflowInput) => ({
122264
name: "feature-pipeline",
123265
tasks: {
124266
// PLAN phase — architect produces the technical plan.
125-
// Sub-PR 2: wired as real defineAgent. Sub-PR 4 runs the executor.
126267
plan: {
127268
agent: architectAgent,
128269
input: () => ({
@@ -132,30 +273,36 @@ export const createFeaturePipeline = defineWorkflowFactory(
132273
}),
133274
},
134275

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

143-
// TEST phase — run `bun test` in worktree.
144-
// Sub-PR 4: replace with a deterministic test-runner function.
293+
// TEST phase — run `bun test` in worktree deterministically.
145294
test: {
146-
fn: noopFn,
295+
fn: testFn,
147296
dependsOn: ["build"] as const,
148297
input: () => ({ worktreePath: input.worktreePath }),
149298
},
150299

151300
// 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).
301+
// Depend on both `test` (for ordering) and `plan` (for the plan text
302+
// passed to the reviewer's prompt). `CtxFor` only exposes direct
303+
// dependencies, so `plan` must be listed here.
154304
verify: {
155305
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.
159306
dependsOn: ["test", "plan"] as const,
160307
input: (ctx: {
161308
plan: { output: { plan: string } };
@@ -167,11 +314,22 @@ export const createFeaturePipeline = defineWorkflowFactory(
167314
},
168315

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

0 commit comments

Comments
 (0)