diff --git a/progi/alembic/versions/0006_parallel_edge.py b/progi/alembic/versions/0006_parallel_edge.py new file mode 100644 index 0000000..a95c053 --- /dev/null +++ b/progi/alembic/versions/0006_parallel_edge.py @@ -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") diff --git a/progi/db.py b/progi/db.py index e3552a0..0d368a7 100644 --- a/progi/db.py +++ b/progi/db.py @@ -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: @@ -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 ], @@ -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"] ], @@ -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 @@ -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 diff --git a/progi/models.py b/progi/models.py index c6738c8..adcdd33 100644 --- a/progi/models.py +++ b/progi/models.py @@ -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( diff --git a/progi/prompts/workflow_skeleton.md b/progi/prompts/workflow_skeleton.md index f7d1e70..5ecb1da 100644 --- a/progi/prompts/workflow_skeleton.md +++ b/progi/prompts/workflow_skeleton.md @@ -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 diff --git a/progi/web/static/app.js b/progi/web/static/app.js index 103d4b4..2ce8b5d 100644 --- a/progi/web/static/app.js +++ b/progi/web/static/app.js @@ -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 { diff --git a/progi/web/templates/partials/step_detail.html b/progi/web/templates/partials/step_detail.html index 7805c07..1e28a5c 100644 --- a/progi/web/templates/partials/step_detail.html +++ b/progi/web/templates/partials/step_detail.html @@ -30,19 +30,24 @@

-
- +
+
+ + +
-