Skip to content

Commit 0a2aa02

Browse files
author
Roman Melnikov
committed
feat(dev-workflow): stream live progress for issue #234
1 parent 52417fb commit 0a2aa02

3 files changed

Lines changed: 348 additions & 11 deletions

File tree

packages/dev-workflow/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ Every code change to the ageflow monorepo goes through this pipeline:
88
PLAN → BUILD → TEST → VERIFY → SHIP — implemented as an ageflow workflow.
99
This is the strategic commitment from issue #194 to use our own tool in anger.
1010

11-
## Status: scaffold — sub-PR 1 of 5 (issue #194)
11+
## Status: in active dogfood rollout (issue #194)
1212

1313
| Sub-PR | Status | Contents |
1414
|--------|--------|----------|
15-
| 1 (this) | merged | Package scaffold, pipeline stubs, run.ts wiring |
15+
| 1 | merged | Package scaffold, pipeline stubs, run.ts wiring |
1616
| 2 | pending | Role library + ageflow-orchestrator role |
1717
| 3 | pending | Learning hooks + SQLite store |
18-
| 4 | pending | First real issue run through dogfood |
18+
| 4 | in progress | Real issue runs through dogfood with live stream progress |
1919
| 5 | pending | 10-run retrospective + tuning |
2020

2121
## Invoke (once sub-PR 4 is merged)
@@ -28,12 +28,13 @@ bun run --filter @ageflow/dev-workflow dev-workflow <issue-number>
2828
bun run --filter @ageflow/dev-workflow dev-workflow --dry-run <issue-number>
2929
```
3030

31+
Live (non-dry) runs stream workflow progress to stdout, including task start/completion,
32+
per-task durations, running spend in USD, and budget-cap progress/warnings.
33+
3134
## What is NOT implemented yet
3235

3336
- **Real LLM tasks** — pipeline stubs use `defineFunction` no-ops. Real
3437
role-based agents land in sub-PR 2.
35-
- **Executor dispatch**`run.ts` loads the issue and logs the plan but does
36-
not call `WorkflowExecutor.stream()`. That wiring lands in sub-PR 4.
3738
- **Git worktree creation**`createWorktree()` logs the would-be command
3839
but does not run `git worktree add`. Real call lands in sub-PR 4.
3940
- **Learning hooks**`@ageflow/learning` is declared as a dependency but
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import type { TaskMetrics, WorkflowEvent } from "@ageflow/core";
2+
import type { WorkflowExecutor } from "@ageflow/executor";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { renderEvent, runWithProgress } from "../run.js";
5+
import type { WorkflowInput } from "../shared/types.js";
6+
7+
vi.mock("../shared/learning.js", () => ({
8+
initLearning: () => ({
9+
hooks: {},
10+
store: { close: () => {} },
11+
dbPath: "/tmp/learning.sqlite",
12+
}),
13+
}));
14+
15+
function taskMetrics(estimatedCost: number, latencyMs: number): TaskMetrics {
16+
return {
17+
tokensIn: 0,
18+
tokensOut: 0,
19+
latencyMs,
20+
retries: 0,
21+
estimatedCost,
22+
};
23+
}
24+
25+
const baseEvent = {
26+
runId: "run-1",
27+
workflowName: "feature-pipeline",
28+
} as const;
29+
30+
const fakeInput: WorkflowInput = {
31+
issue: {
32+
number: 234,
33+
title: "progress smoke",
34+
labels: ["feature"],
35+
state: "open",
36+
url: "https://example.com/issues/234",
37+
},
38+
worktreePath: "/tmp/agents-workflow-wt-234",
39+
specPath: "/tmp/spec.md",
40+
dryRun: false,
41+
};
42+
43+
describe("renderEvent", () => {
44+
beforeEach(() => {
45+
vi.restoreAllMocks();
46+
});
47+
48+
it("prints task start and complete progress, including budget progress", () => {
49+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
50+
51+
const state = {
52+
starts: new Map<string, number>(),
53+
spentUsd: 0,
54+
warned80: false,
55+
};
56+
57+
const startEv: WorkflowEvent = {
58+
...baseEvent,
59+
type: "task:start",
60+
taskName: "build",
61+
timestamp: 1_000,
62+
};
63+
const completeEv: WorkflowEvent = {
64+
...baseEvent,
65+
type: "task:complete",
66+
taskName: "build",
67+
output: { ok: true },
68+
metrics: taskMetrics(0.25, 999),
69+
timestamp: 2_500,
70+
};
71+
72+
renderEvent(startEv, 1, state);
73+
renderEvent(completeEv, 1, state);
74+
75+
expect(state.spentUsd).toBeCloseTo(0.25);
76+
const lines = logSpy.mock.calls.map((call) => String(call[0]));
77+
expect(lines.some((line) => line.includes("build started"))).toBe(true);
78+
expect(lines.some((line) => line.includes("build completed in 1.5s"))).toBe(
79+
true,
80+
);
81+
expect(
82+
lines.some((line) => line.includes("$0.2500 / $1.0000 (25.0%)")),
83+
).toBe(true);
84+
});
85+
86+
it("emits the 80% warning once", () => {
87+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
88+
vi.spyOn(console, "log").mockImplementation(() => {});
89+
90+
const state = {
91+
starts: new Map<string, number>(),
92+
spentUsd: 0,
93+
warned80: false,
94+
};
95+
96+
const completeA: WorkflowEvent = {
97+
...baseEvent,
98+
type: "task:complete",
99+
taskName: "plan",
100+
output: "ok",
101+
metrics: taskMetrics(0.4, 100),
102+
timestamp: 10,
103+
};
104+
const completeB: WorkflowEvent = {
105+
...baseEvent,
106+
type: "task:complete",
107+
taskName: "build",
108+
output: "ok",
109+
metrics: taskMetrics(0.4, 100),
110+
timestamp: 20,
111+
};
112+
const completeC: WorkflowEvent = {
113+
...baseEvent,
114+
type: "task:complete",
115+
taskName: "test",
116+
output: "ok",
117+
metrics: taskMetrics(0.4, 100),
118+
timestamp: 30,
119+
};
120+
121+
renderEvent(completeA, 1, state);
122+
renderEvent(completeB, 1, state);
123+
renderEvent(completeC, 1, state);
124+
125+
expect(state.warned80).toBe(true);
126+
expect(warnSpy).toHaveBeenCalledTimes(1);
127+
expect(String(warnSpy.mock.calls[0]?.[0] ?? "")).toContain(
128+
"reached 80.0% of cap",
129+
);
130+
});
131+
132+
it("prints unlimited-budget line when budget cap is not set", () => {
133+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
134+
135+
const state = {
136+
starts: new Map<string, number>(),
137+
spentUsd: 0,
138+
warned80: false,
139+
};
140+
141+
const completeEv: WorkflowEvent = {
142+
...baseEvent,
143+
type: "task:complete",
144+
taskName: "verify",
145+
output: "ok",
146+
metrics: taskMetrics(0.1, 100),
147+
timestamp: 10,
148+
};
149+
150+
renderEvent(completeEv, undefined, state);
151+
152+
const lines = logSpy.mock.calls.map((call) => String(call[0]));
153+
expect(lines.some((line) => line.includes("(no cap)"))).toBe(true);
154+
});
155+
156+
it("prints task error with duration when a start timestamp exists", () => {
157+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
158+
vi.spyOn(console, "log").mockImplementation(() => {});
159+
160+
const state = {
161+
starts: new Map<string, number>(),
162+
spentUsd: 0,
163+
warned80: false,
164+
};
165+
166+
const startEv: WorkflowEvent = {
167+
...baseEvent,
168+
type: "task:start",
169+
taskName: "ship",
170+
timestamp: 2_000,
171+
};
172+
const errorEv: WorkflowEvent = {
173+
...baseEvent,
174+
type: "task:error",
175+
taskName: "ship",
176+
error: { name: "Error", message: "push failed" },
177+
attempt: 1,
178+
terminal: true,
179+
timestamp: 7_000,
180+
};
181+
182+
renderEvent(startEv, 1, state);
183+
renderEvent(errorEv, 1, state);
184+
185+
expect(String(errorSpy.mock.calls[0]?.[0] ?? "")).toContain(
186+
"ship failed after 5.0s: push failed",
187+
);
188+
});
189+
});
190+
191+
describe("runWithProgress", () => {
192+
it("drains stream events and returns generator done value", async () => {
193+
vi.spyOn(console, "log").mockImplementation(() => {});
194+
vi.spyOn(console, "warn").mockImplementation(() => {});
195+
vi.spyOn(console, "error").mockImplementation(() => {});
196+
197+
const events: WorkflowEvent[] = [
198+
{
199+
...baseEvent,
200+
type: "task:start",
201+
taskName: "plan",
202+
timestamp: 1,
203+
},
204+
{
205+
...baseEvent,
206+
type: "task:complete",
207+
taskName: "plan",
208+
output: { ok: true },
209+
metrics: taskMetrics(0.05, 100),
210+
timestamp: 101,
211+
},
212+
{
213+
...baseEvent,
214+
type: "task:error",
215+
taskName: "build",
216+
error: { name: "Error", message: "lint failed" },
217+
attempt: 1,
218+
terminal: true,
219+
timestamp: 202,
220+
},
221+
];
222+
223+
const doneValue = {
224+
outputs: { plan: { ok: true } },
225+
metrics: { totalEstimatedCost: 0.05 },
226+
};
227+
228+
const fakeExecutor = {
229+
async *stream() {
230+
for (const ev of events) {
231+
yield ev;
232+
}
233+
return doneValue;
234+
},
235+
} as unknown as WorkflowExecutor<Record<string, never>>;
236+
237+
const result = await runWithProgress(fakeExecutor, fakeInput, 5);
238+
expect(result).toEqual(doneValue);
239+
});
240+
});

0 commit comments

Comments
 (0)