Skip to content
Merged
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
28 changes: 28 additions & 0 deletions progi/alembic/versions/0006_parallel_edge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add parallel column to step_edges

Revision ID: 0006
Revises: 0005
Create Date: 2026-06-27
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0006"
down_revision: Union[str, None] = "0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("step_edges") as batch_op:
batch_op.add_column(
sa.Column("parallel", sa.Boolean(), nullable=False, server_default="0")
)


def downgrade() -> None:
with op.batch_alter_table("step_edges") as batch_op:
batch_op.drop_column("parallel")
5 changes: 5 additions & 0 deletions progi/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ def save_workflow(
to_step_id=to_id,
condition=edge.get("condition"),
priority=edge.get("priority", 0),
parallel=bool(edge.get("parallel", False)),
)
)
else:
Expand Down Expand Up @@ -1073,6 +1074,7 @@ def get_workflow_with_playbooks(cfg: Config, workflow_id: int) -> dict[str, Any]
"to_step_id": e["to_step_id"],
"condition": e["condition"],
"priority": e["priority"],
"parallel": bool(e["parallel"]),
}
for e in edge_rows
],
Expand All @@ -1099,6 +1101,7 @@ def export_workflow(cfg: Config, workflow_id: int) -> dict[str, Any]:
"to": step_by_id[e["to_step_id"]]["name"],
"condition": e["condition"],
"priority": e["priority"],
"parallel": e.get("parallel", False),
}
for e in wf["edges"]
],
Expand Down Expand Up @@ -1169,6 +1172,7 @@ def get_step_detail(cfg: Config, workflow_id: int, step_id: int) -> dict[str, An
"id": e["from_step_id"],
"name": step_names.get(e["from_step_id"], str(e["from_step_id"])),
"condition": e["condition"],
"parallel": bool(e["parallel"]),
}
for e in edge_rows
if e["to_step_id"] == step_id
Expand All @@ -1178,6 +1182,7 @@ def get_step_detail(cfg: Config, workflow_id: int, step_id: int) -> dict[str, An
"id": e["to_step_id"],
"name": step_names.get(e["to_step_id"], str(e["to_step_id"])),
"condition": e["condition"],
"parallel": bool(e["parallel"]),
}
for e in edge_rows
if e["from_step_id"] == step_id
Expand Down
2 changes: 2 additions & 0 deletions progi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
sa.Column("condition", sa.JSON, nullable=True),
# edges evaluated in ascending priority; first match wins
sa.Column("priority", sa.Integer, nullable=False, server_default="0"),
# when True, this edge is part of a parallel fork (rendered side-by-side)
sa.Column("parallel", sa.Boolean, nullable=False, server_default="0"),
)

playbooks = sa.Table(
Expand Down
19 changes: 19 additions & 0 deletions progi/prompts/workflow_skeleton.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ For a branching workflow, the edges express the routing logic:
- **Loops** are expressed as back-edges: an edge from a later step back to an
earlier step. Use a conditional edge (priority 0) for the exit path and a
second edge (priority 1, condition or `null`) for the loop-back.
- **Parallel steps** — if two or more steps can be done at the same time
(they don't depend on each other's output), mark those edges with
`"parallel": true`. All edges in a parallel group must share the same
`from` step and have `"condition": null`. A join step after the group
receives the output of whichever parallel branch finished last (sequential
execution is unchanged — this flag only affects visualization).

Example — Research, Design, and Legal Review can all happen after Kickoff:

```json
{"from": "Kickoff", "to": "Research", "condition": null, "priority": 0, "parallel": true},
{"from": "Kickoff", "to": "Design", "condition": null, "priority": 0, "parallel": true},
{"from": "Kickoff", "to": "Legal Review", "condition": null, "priority": 0, "parallel": true},
{"from": "Research", "to": "Write Draft", "condition": null, "priority": 0},
{"from": "Design", "to": "Write Draft", "condition": null, "priority": 0},
{"from": "Legal Review", "to": "Write Draft", "condition": null, "priority": 0}
```

Non-parallel edges default to `"parallel": false` and can be omitted.

## After the user approves the skeleton

Expand Down
32 changes: 27 additions & 5 deletions progi/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,38 @@ function workflowEditor() {
def += ` ${nodeId}["${label}"]\n`;
});

// Add edges
// Add edges, grouping parallel forks into Mermaid's `A --> B & C` syntax
if (edges.length > 0) {
// Group edges by from_step_id to detect parallel forks
const byFrom = {};
edges.forEach(e => {
(byFrom[e.from_step_id] = byFrom[e.from_step_id] || []).push(e);
});

// Track which edges were already emitted as part of a parallel group
const emitted = new Set();

edges.forEach(e => {
if (emitted.has(e.id ?? `${e.from_step_id}-${e.to_step_id}`)) return;

const from = `step_${e.from_step_id}`;
const to = `step_${e.to_step_id}`;
if (e.condition) {
const siblings = byFrom[e.from_step_id] || [];
const parallelSiblings = siblings.filter(s => s.parallel);

if (e.parallel && parallelSiblings.length > 1) {
// Emit all parallel edges from this source as one `A --> B & C & D` line
const targets = parallelSiblings.map(s => `step_${s.to_step_id}`).join(' & ');
def += ` ${from} --> ${targets}\n`;
parallelSiblings.forEach(s => {
emitted.add(s.id ?? `${s.from_step_id}-${s.to_step_id}`);
});
} else if (e.condition) {
const label = _conditionLabel(e.condition);
def += ` ${from} -->|"${label}"| ${to}\n`;
def += ` ${from} -->|"${label}"| step_${e.to_step_id}\n`;
emitted.add(e.id ?? `${e.from_step_id}-${e.to_step_id}`);
} else {
def += ` ${from} --> ${to}\n`;
def += ` ${from} --> step_${e.to_step_id}\n`;
emitted.add(e.id ?? `${e.from_step_id}-${e.to_step_id}`);
}
});
} else {
Expand Down
42 changes: 26 additions & 16 deletions progi/web/templates/partials/step_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,24 @@ <h2 id="step-detail-title" class="text-sm font-semibold text-primary" x-text="st
<template x-for="s in prevSteps" :key="s.id">
<div class="flex items-start gap-3 rounded-lg bg-surface-1 px-3 py-2">
<span class="text-accent text-xs mt-0.5">←</span>
<div>
<a
href="#"
@click.prevent="navigateStep(s.id)"
class="text-xs text-accent font-medium hover:underline cursor-pointer"
x-text="s.name"
></a>
<div class="flex-1">
<div class="flex items-center gap-2">
<a
href="#"
@click.prevent="navigateStep(s.id)"
class="text-xs text-accent font-medium hover:underline cursor-pointer"
x-text="s.name"
></a>
<template x-if="s.parallel">
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-faint tracking-wide uppercase">parallel</span>
</template>
</div>
<template x-if="s.condition">
<p class="text-xs text-muted mt-0.5"
x-text="`when ${s.condition.field} ${s.condition.operator} ${JSON.stringify(s.condition.value)}`">
</p>
</template>
<template x-if="!s.condition">
<template x-if="!s.condition && !s.parallel">
<p class="text-xs text-ghost mt-0.5">unconditional</p>
</template>
</div>
Expand All @@ -63,19 +68,24 @@ <h2 id="step-detail-title" class="text-sm font-semibold text-primary" x-text="st
<template x-for="s in nextSteps" :key="s.id">
<div class="flex items-start gap-3 rounded-lg bg-surface-1 px-3 py-2">
<span class="text-accent text-xs mt-0.5">→</span>
<div>
<a
href="#"
@click.prevent="navigateStep(s.id)"
class="text-xs text-accent font-medium hover:underline cursor-pointer"
x-text="s.name"
></a>
<div class="flex-1">
<div class="flex items-center gap-2">
<a
href="#"
@click.prevent="navigateStep(s.id)"
class="text-xs text-accent font-medium hover:underline cursor-pointer"
x-text="s.name"
></a>
<template x-if="s.parallel">
<span class="text-[10px] font-medium px-1.5 py-0.5 rounded bg-surface-3 text-faint tracking-wide uppercase">parallel</span>
</template>
</div>
<template x-if="s.condition">
<p class="text-xs text-muted mt-0.5"
x-text="`when ${s.condition.field} ${s.condition.operator} ${JSON.stringify(s.condition.value)}`">
</p>
</template>
<template x-if="!s.condition">
<template x-if="!s.condition && !s.parallel">
<p class="text-xs text-ghost mt-0.5">unconditional</p>
</template>
</div>
Expand Down
Loading