Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,60 @@ npm uninstall -g stepcat
## License

MIT

## DAG Configuration (Experimental)

Stepcat can be extended with a YAML-based DAG description that models nodes and loops in a BPMN-like flow. This provides a flexible way to define orchestration flows without hardcoding a fixed sequence. The DAG DSL is parsed with `parseDagConfig()` and executed with `DagExecutor` using handler callbacks.

### DAG Schema

Each DAG is a YAML mapping with a top-level `nodes` list. Nodes can be tasks or groups:

- **Task nodes**: `name`, optional `prompt`, and either `agent` (LLM) or `action` (deterministic), plus optional `depends_on`.
- **Group nodes**: `name`, optional `depends_on`, and a nested `nodes` list. Groups can declare one of:
- `for_each`: repeat the group over an array (e.g., plan steps).
- `repeat_until`: repeat until a named condition node returns truthy output.

### Example

```yaml
version: 1
nodes:
- name: implement
prompt: "Implement {{step.title}}"
agent: claude
- name: review
depends_on: [implement]
agent: codex
- name: plan-loop
for_each:
var: step
in: plan.steps
nodes:
- name: implement
prompt: "Implement {{step.title}}"
agent: claude
- name: review
depends_on: [implement]
agent: codex
- name: build-loop
repeat_until:
condition: build_green
max_iterations: 5
nodes:
- name: build_green
action: github_build_green
- name: push
depends_on: [build_green]
action: push_changes
```

### Execution Notes

- `depends_on` enforces DAG ordering (cycles are rejected).
- `for_each` resolves an array from the execution context (e.g. `plan.steps`) and sets `{{step}}` and `{{step_index}}` for each iteration.
- `repeat_until` reruns its group until the named condition node outputs a truthy value.
- `prompt` strings support `{{path.to.value}}` templating from the execution context.
- Use `agent` for LLM-driven nodes and `action` for deterministic nodes like `push_changes`, `ensure_pr`, or `github_build_green`.

The executor is adapter-friendly: register handlers by `agent` name to connect to Claude, Codex, GitHub checks, or custom integrations.
69 changes: 69 additions & 0 deletions backend/__tests__/dag-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { parseDagConfig } from '../dag-config.js';

describe('parseDagConfig', () => {
it('parses a task-only DAG', () => {
const config = parseDagConfig(`
version: 1
nodes:
- name: implement
prompt: "Do the work"
agent: claude
- name: review
depends_on: [implement]
agent: codex
- name: push
action: push_changes
`);

expect(config.version).toBe(1);
expect(config.nodes).toHaveLength(3);
expect(config.nodes[0]).toMatchObject({
name: 'implement',
prompt: 'Do the work',
agent: 'claude',
kind: 'agent',
});
expect(config.nodes[2]).toMatchObject({
name: 'push',
action: 'push_changes',
kind: 'action',
});
});

it('parses for_each and repeat_until groups', () => {
const config = parseDagConfig(`
nodes:
- name: iterate-steps
for_each:
var: step
in: plan.steps
nodes:
- name: implement
prompt: "Implement {{step.title}}"
agent: claude
- name: build-loop
repeat_until:
condition: build_green
max_iterations: 2
nodes:
- name: build_green
agent: github
`);

expect(config.nodes).toHaveLength(2);
const forEachNode = config.nodes[0];
const repeatNode = config.nodes[1];

if ('nodes' in forEachNode) {
expect(forEachNode.for_each).toEqual({ var: 'step', in: 'plan.steps' });
} else {
throw new Error('Expected for_each group.');
}

if ('nodes' in repeatNode) {
expect(repeatNode.repeat_until).toEqual({ condition: 'build_green', max_iterations: 2 });
} else {
throw new Error('Expected repeat_until group.');
}
});
});
104 changes: 104 additions & 0 deletions backend/__tests__/dag-executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { DagExecutor } from '../dag-executor.js';
import type { DagNodeHandler } from '../dag-executor.js';
import type { DagConfig } from '../dag-models.js';

describe('DagExecutor', () => {
it('executes tasks in dependency order and renders templates', async () => {
const config: DagConfig = {
nodes: [
{
name: 'implement',
prompt: 'Implement {{step.title}}',
agent: 'claude',
},
{
name: 'review',
depends_on: ['implement'],
agent: 'codex',
},
{
name: 'push',
depends_on: ['review'],
action: 'push_changes',
},
],
};

const seen: string[] = [];
const handler: DagNodeHandler = (node, context) => {
seen.push(node.name);
return Promise.resolve({
status: 'success',
output: node.resolvedPrompt ?? context.locals.step,
});
};

const executor = new DagExecutor(config, {
handlers: { claude: handler, codex: handler, push_changes: handler },
});

const result = await executor.run({ step: { title: 'Feature' } });

expect(result.status).toBe('success');
expect(seen).toEqual(['implement', 'review', 'push']);
expect(result.results.get('implement')?.output).toBe('Implement Feature');
});

it('runs for_each groups', async () => {
const config: DagConfig = {
nodes: [
{
name: 'plan-loop',
for_each: { var: 'step', in: 'plan.steps' },
nodes: [
{
name: 'implement',
prompt: 'Implement {{step.title}}',
agent: 'claude',
},
],
},
],
};

const outputs: string[] = [];
const handler: DagNodeHandler = node => {
outputs.push(node.resolvedPrompt ?? '');
return Promise.resolve({ status: 'success', output: node.resolvedPrompt });
};

const executor = new DagExecutor(config, { handlers: { claude: handler } });
await executor.run({ plan: { steps: [{ title: 'A' }, { title: 'B' }] } });

expect(outputs).toEqual(['Implement A', 'Implement B']);
});

it('runs repeat_until groups until condition output is truthy', async () => {
const config: DagConfig = {
nodes: [
{
name: 'build-loop',
repeat_until: { condition: 'build_green', max_iterations: 3 },
nodes: [
{
name: 'build_green',
agent: 'github',
},
],
},
],
};

let attempt = 0;
const handler: DagNodeHandler = () => {
attempt += 1;
return Promise.resolve({ status: 'success', output: attempt >= 2 });
};

const executor = new DagExecutor(config, { handlers: { github: handler } });
const result = await executor.run({});

expect(result.status).toBe('success');
expect(attempt).toBe(2);
});
});
Loading