diff --git a/AGENTS.md b/AGENTS.md index edd147a..6a56604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,10 @@ Progi is an MCP-native workflow engine. Key terms: - **workflow**: a reusable template of ordered **steps**; defines a repeatable, reused across many tasks -- **step**: one unit of work in a workflow; has a **playbook**, an input spec, and an output spec -- **playbook**: markdown attached to a step; the agent reads it via `start_or_continue_task` and follows it +- **step**: one unit of work in a workflow; has a **worker playbook** and **QA playbook** +- **workflow playbook**: markdown on a workflow describing its purpose, input, and output; used for documentation and sub-workflow context +- **worker playbook**: markdown on a step; the worker agent reads it via `start_or_continue_task` and follows it to produce the step's output +- **QA playbook**: optional markdown on a step; the QA sub-agent reads it to evaluate the worker's output before the task advances - **task**: a single execution of a workflow; progresses through steps one at a time with lifecycle `todo` → `in_progress` → `done` Two interfaces over one SQLite DB: @@ -27,7 +29,7 @@ what keeps LLM-driven and human-driven edits behaviorally identical. | `progi/db.py` | Schema (SQLAlchemy Core) + **all** queries, mutations, and state-transition logic | | `progi/mcp_server.py` | `@mcp.tool` wrappers (work loop + workflow authoring) | | `progi/web/app.py` | FastAPI routes → Jinja partials | -| `progi/prompts/` | Pass 1 / Pass 2 authoring system prompts (served by tools) | +| `progi/prompts/` | Pass 1 / Pass 2 authoring system prompts + `templates/` blank playbook templates | | `progi/seed.py` | "Blog Post" workflow + sample task (idempotent) | | `tests/test_db.py` | DB roundtrip, full work loop, authoring | @@ -114,9 +116,13 @@ app.include_router(mypage.router) - **No SQL outside `db.py`.** Add a named function; wrap writes in `with engine.begin()`. - **Never `print()` / write to stdout** anywhere reachable from the MCP process — stdout is the MCP protocol channel. Log to stderr via `logging_setup`. - **Web returns HTML partials** for AJAX (not JSON); the swapped element's `id` must match the trigger's `x-target`. -- `input_spec` / `output_spec` / `input_data` / `output` are `sa.JSON` columns — pass and receive plain dicts, no manual `json.dumps`. +- `input_data` / `output` are `sa.JSON` columns — pass and receive plain dicts, no manual `json.dumps`. - Keep the web UI localhost-only (unauthenticated DB viewer). +### Keeping `x-data` lean + +Never put non-trivial logic (multi-line functions, getters with loops) directly in `x-data` attributes in templates. Instead, add the state and methods to the relevant Alpine component function in `app.js` and reference them from the template. Inline `x-data` is fine for simple boolean flags (`x-data="{ open: false }"`), but anything complex belongs in `app.js`. + ### Choosing between fetch + JS state vs. Alpine AJAX partials Use **plain `fetch` + Alpine reactive state** (in `app.js`) when: @@ -165,11 +171,6 @@ uv run python -m pytest # tests (do NOT use `uv run pytest` — a s uv run ruff check progi # lint ``` -## Run modes - -`progi` (MCP + web), `progi --no-web` (MCP only), `progi-web` (web only). -Config via env: `PROGI_DB_PATH`, `PROGI_WEB_HOST`, `PROGI_WEB_PORT`, `PROGI_NO_WEB`. - ## Git commit convention Commit messages must follow Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`, `ci:`. Use `feat!:` for breaking changes. Release Please reads these to generate the changelog and determine the version bump. diff --git a/README.md b/README.md index f622ac0..2196bbf 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Tweak playbooks in Progi Monitoring between runs. Because workflows live in a da |---|---| | `create_task` | Create a new task under a given workflow (status `todo`); returns a preview of its first step | | `list_tasks` | List tasks, optionally filtered by status and/or workflow | -| `start_or_continue_task` | Main work-loop entry point — starts or resumes a task and returns the current step's playbook, input data, and output spec | +| `start_or_continue_task` | Main work-loop entry point — starts or resumes a task and returns the current step's worker playbook and input data | | `update_progress_notes` | Overwrite a task's progress notes (mid-step save point) | | `finish_step` | Mark the current step complete, store its output, and advance to the next step (or mark done) | diff --git a/docs/glossary.md b/docs/glossary.md index 0c08494..4edbcb0 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -10,9 +10,11 @@ Terms used across the progi codebase, README, and documentation. **task** — A piece of work created from a workflow. Advances through steps one at a time and carries a `status` (`todo` / `in_progress` / `done`). Stored in the `tasks` table. -**step** — A single unit of work within a workflow. Ordered, carries an `input_spec` and `output_spec`, and has exactly one **playbook**. Stored in the `steps` table. +**step** — A single unit of work within a workflow. Ordered, has exactly one **worker playbook**, and optionally a **QA playbook**. Stored in the `steps` table. -**playbook** — A markdown document attached to a step. The AI **agent** reads and follows it to perform the step, including when to involve the human and what output satisfies the `output_spec`. Authored in **Pass 2**. Stored in the `playbooks` table. +**worker playbook** — A markdown document attached to a step describing what to do, what inputs are available, and what output is expected. The AI **agent** reads and follows it to perform the step. Stored in the `playbooks` table. + +**QA playbook** — Optional markdown on a step read by a QA sub-agent to evaluate the worker's output before the task advances. **edge** — A directed connection between two steps. Defines execution flow. Can be conditional (evaluated against the step's `output`) or unconditional. Supports branching. Stored in the `step_edges` table. @@ -38,27 +40,19 @@ Terms used across the progi codebase, README, and documentation. ## Input / output -**input_spec** — A JSON dict on a step describing what data the step needs to begin. Fields: `description`, `source` (`"static"` or `"previous_step_output"`), optional `from_step`. Used at authoring time to tell the agent what inputs are available. - -**output_spec** — A JSON dict on a step describing the deliverable that proves the step is done. Fields: `type` (`file` / `url` / `text`), `description`, `constraints`. The agent must produce output matching this spec. - -**input_data** — The resolved, concrete data passed to a step instance when it activates. Derived from `input_spec`; may pull from a prior step's `output`. Stored as JSON on `step_instances.input_data`. +**input_data** — The resolved, concrete data passed to a step instance when it activates. May come from task creation (static) or a prior step's `output`. Stored as JSON on `step_instances.input_data`. **output** — The actual deliverable submitted by the agent via `finish_step`. Stored as JSON on `step_instances.output`. Used to resolve the next step's `input_data` and to evaluate edge `conditions`. -**source** — Field inside `input_spec`. Either `"static"` (data comes from task creation) or `"previous_step_output"` (data comes from a prior step's `output`). - -**from_step** — Field inside `input_spec` when `source` is `"previous_step_output"`. Names the step whose output to pull. - --- ## Workflow authoring -**Pass 1** — First authoring phase. Converts a plain-language description into a structured **skeleton** (steps, specs, edges). Triggered by `get_process_skeleton_prompt`. +**Pass 1** — First authoring phase. Converts a plain-language description into a structured **skeleton** (steps, edges). Triggered by `get_process_skeleton_prompt`. **Pass 2** — Second authoring phase. Authors the **playbook** for each step. Triggered by `get_playbook_authoring_prompt`, which injects full workflow context into the prompt. -**skeleton** — Structured JSON produced in Pass 1: `{ name, description, process: [steps…], edges: [connections…] }`. Each step includes `order`, `name`, `input_spec`, `output_spec`. Reviewed and adjusted before saving. +**skeleton** — Structured JSON produced in Pass 1: `{ name, description, process: [steps…], edges: [connections…] }`. Each step includes `order` and `name`. Reviewed and adjusted before saving. **condition** — A rule on an edge evaluated against a step's `output` to decide whether that edge is taken. Operators: `eq`, `neq`, `in`, `not_in`. A `null` condition means unconditional (always taken if no other condition matches). diff --git a/progi/alembic/versions/0007_sub_workflow_support.py b/progi/alembic/versions/0007_sub_workflow_support.py new file mode 100644 index 0000000..84cc2a5 --- /dev/null +++ b/progi/alembic/versions/0007_sub_workflow_support.py @@ -0,0 +1,47 @@ +"""add sub-workflow support: workflow playbook, steps.sub_workflow_id, step_instances.sub_workflow_step_id + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-06-27 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0007" +down_revision: Union[str, None] = "0006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # workflows.playbook — skip if already exists (can happen from a partial prior run) + existing_wf_cols = {row[1] for row in conn.execute(sa.text("PRAGMA table_info(workflows)")).fetchall()} + if "playbook" not in existing_wf_cols: + with op.batch_alter_table("workflows") as batch_op: + batch_op.add_column(sa.Column("playbook", sa.Text(), nullable=True)) + + existing_step_cols = {row[1] for row in conn.execute(sa.text("PRAGMA table_info(steps)")).fetchall()} + if "sub_workflow_id" not in existing_step_cols: + with op.batch_alter_table("steps") as batch_op: + batch_op.add_column(sa.Column("sub_workflow_id", sa.Integer(), nullable=True)) + + existing_si_cols = {row[1] for row in conn.execute(sa.text("PRAGMA table_info(step_instances)")).fetchall()} + if "sub_workflow_step_id" not in existing_si_cols: + with op.batch_alter_table("step_instances") as batch_op: + batch_op.add_column(sa.Column("sub_workflow_step_id", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("step_instances") as batch_op: + batch_op.drop_column("sub_workflow_step_id") + + with op.batch_alter_table("steps") as batch_op: + batch_op.drop_column("sub_workflow_id") + + with op.batch_alter_table("workflows") as batch_op: + batch_op.drop_column("playbook") diff --git a/progi/alembic/versions/0008_qa_playbooks.py b/progi/alembic/versions/0008_qa_playbooks.py new file mode 100644 index 0000000..c7a27c6 --- /dev/null +++ b/progi/alembic/versions/0008_qa_playbooks.py @@ -0,0 +1,59 @@ +"""add QA playbook support: playbooks.type, steps.max_qa_retries, step_instances qa columns + +Revision ID: 0008 +Revises: 4643697c3984 +Create Date: 2026-06-27 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0008" +down_revision: Union[str, None] = "0007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Drop the old unique constraint on playbooks.step_id (was unique=True inline). + # SQLite doesn't support DROP CONSTRAINT, so we recreate the table. + with op.batch_alter_table("playbooks") as batch_op: + batch_op.add_column( + sa.Column("type", sa.String(16), nullable=False, server_default="worker") + ) + # Replace the per-step unique index with a (step_id, type) unique constraint. + # The old implicit unique index on step_id is dropped as part of the batch rebuild. + batch_op.create_unique_constraint("uq_playbooks_step_type", ["step_id", "type"]) + + # 2. steps: add max_qa_retries (nullable, NULL = use global default). + with op.batch_alter_table("steps") as batch_op: + batch_op.add_column(sa.Column("max_qa_retries", sa.Integer, nullable=True)) + + # 3. step_instances: add QA tracking columns. + with op.batch_alter_table("step_instances") as batch_op: + batch_op.add_column(sa.Column("qa_status", sa.String(16), nullable=True)) + batch_op.add_column(sa.Column("qa_output", sa.JSON, nullable=True)) + batch_op.add_column( + sa.Column( + "qa_retry_count", + sa.Integer, + nullable=False, + server_default="0", + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("step_instances") as batch_op: + batch_op.drop_column("qa_retry_count") + batch_op.drop_column("qa_output") + batch_op.drop_column("qa_status") + + with op.batch_alter_table("steps") as batch_op: + batch_op.drop_column("max_qa_retries") + + with op.batch_alter_table("playbooks") as batch_op: + batch_op.drop_constraint("uq_playbooks_step_type", type_="unique") + batch_op.drop_column("type") diff --git a/progi/alembic/versions/0009_fix_playbooks_unique.py b/progi/alembic/versions/0009_fix_playbooks_unique.py new file mode 100644 index 0000000..53864b6 --- /dev/null +++ b/progi/alembic/versions/0009_fix_playbooks_unique.py @@ -0,0 +1,35 @@ +"""fix playbooks unique constraint: drop old step_id-only index, keep (step_id, type) + +The 0008 migration added the type column and (step_id, type) unique constraint +but left the original step_id-only unique index in place (batch_alter_table used +ADD COLUMN rather than rebuilding the table). This migration forces a full table +rebuild to drop that stale index. + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-06-28 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0009" +down_revision: Union[str, None] = "0008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Force a full table rebuild so the stale unique index on step_id alone is + # dropped. recreate="always" tells Alembic to copy-rename-drop rather than + # issuing ADD/DROP statements (which SQLite doesn't support for constraints). + with op.batch_alter_table("playbooks", recreate="always") as batch_op: + # Re-declare the (step_id, type) constraint — the column-level unique is + # NOT carried over when we supply our own UniqueConstraint. + batch_op.create_unique_constraint("uq_playbooks_step_type", ["step_id", "type"]) + + +def downgrade() -> None: + # Nothing to do — downgrading 0008 already handles cleanup. + pass diff --git a/progi/alembic/versions/0010_fix_playbooks_step_unique.py b/progi/alembic/versions/0010_fix_playbooks_step_unique.py new file mode 100644 index 0000000..95d1def --- /dev/null +++ b/progi/alembic/versions/0010_fix_playbooks_step_unique.py @@ -0,0 +1,52 @@ +"""fix playbooks: drop stale step_id-only unique index via raw SQL table rebuild + +Alembic batch_alter_table reflects the existing table and reproduces the old +column-level unique on step_id. This migration does the rebuild manually so we +have full control over the resulting schema. + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-06-28 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0010" +down_revision: Union[str, None] = "0009" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE playbooks_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + step_id INTEGER NOT NULL REFERENCES steps(id) ON DELETE CASCADE, + type VARCHAR(16) NOT NULL DEFAULT 'worker', + content TEXT NOT NULL, + UNIQUE (step_id, type) + ) + """) + op.execute("INSERT INTO playbooks_new SELECT id, step_id, type, content FROM playbooks") + op.execute("DROP TABLE playbooks") + op.execute("ALTER TABLE playbooks_new RENAME TO playbooks") + + +def downgrade() -> None: + # Restore the original schema (single unique on step_id, no type column). + # Any qa-type rows are dropped because the old schema can't hold them. + op.execute(""" + CREATE TABLE playbooks_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + step_id INTEGER NOT NULL UNIQUE REFERENCES steps(id) ON DELETE CASCADE, + content TEXT NOT NULL + ) + """) + op.execute(""" + INSERT INTO playbooks_old (id, step_id, content) + SELECT id, step_id, content FROM playbooks WHERE type = 'worker' + """) + op.execute("DROP TABLE playbooks") + op.execute("ALTER TABLE playbooks_old RENAME TO playbooks") diff --git a/progi/alembic/versions/0011_qa_enabled.py b/progi/alembic/versions/0011_qa_enabled.py new file mode 100644 index 0000000..e248c87 --- /dev/null +++ b/progi/alembic/versions/0011_qa_enabled.py @@ -0,0 +1,70 @@ +"""Add qa_enabled to steps; insert default QA template for all steps without one + +qa_enabled=False means the QA playbook row exists (so it can be edited in the UI) +but the gate is skipped at runtime. Set to True to activate QA for a step. + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-06-28 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0011" +down_revision: Union[str, None] = "0010" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_QA_TEMPLATE = """\ +## What to evaluate + + + +## Pass criteria + +- +- + +## Reject if + +- +- """ + + +def upgrade() -> None: + with op.batch_alter_table("steps") as batch_op: + batch_op.add_column( + sa.Column("qa_enabled", sa.Boolean, nullable=False, server_default="0") + ) + + # Insert default QA template for every step that doesn't already have one. + conn = op.get_bind() + step_ids = [r[0] for r in conn.execute(sa.text("SELECT id FROM steps")).fetchall()] + existing_qa = { + r[0] + for r in conn.execute( + sa.text("SELECT step_id FROM playbooks WHERE type = 'qa'") + ).fetchall() + } + missing = [sid for sid in step_ids if sid not in existing_qa] + if missing: + conn.execute( + sa.text( + "INSERT INTO playbooks (step_id, type, content) VALUES (:sid, 'qa', :content)" + ), + [{"sid": sid, "content": _QA_TEMPLATE} for sid in missing], + ) + + +def downgrade() -> None: + with op.batch_alter_table("steps") as batch_op: + batch_op.drop_column("qa_enabled") + + # Remove template QA rows (those whose content matches the template exactly). + conn = op.get_bind() + conn.execute( + sa.text("DELETE FROM playbooks WHERE type = 'qa' AND content = :content"), + {"content": _QA_TEMPLATE}, + ) diff --git a/progi/alembic/versions/0012_workflow_state_store.py b/progi/alembic/versions/0012_workflow_state_store.py new file mode 100644 index 0000000..c540f9d --- /dev/null +++ b/progi/alembic/versions/0012_workflow_state_store.py @@ -0,0 +1,51 @@ +"""Add workflow_fields, step_field_refs, task_field_values for named field state store + +Revision ID: 0012 +Revises: 0011 +Create Date: 2026-06-28 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0012" +down_revision: Union[str, None] = "0011" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "workflow_fields", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("workflow_id", sa.Integer, sa.ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("description", sa.Text, nullable=False), + sa.Column("type", sa.String(16), nullable=False, server_default="text"), + sa.UniqueConstraint("workflow_id", "name", name="uq_workflow_fields_wf_name"), + ) + + op.create_table( + "step_field_refs", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("step_id", sa.Integer, sa.ForeignKey("steps.id", ondelete="CASCADE"), nullable=False), + sa.Column("field_id", sa.Integer, sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), nullable=False), + sa.Column("direction", sa.String(8), nullable=False), + sa.UniqueConstraint("step_id", "field_id", "direction", name="uq_step_field_refs"), + ) + + op.create_table( + "task_field_values", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("task_id", sa.Integer, sa.ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False), + sa.Column("field_id", sa.Integer, sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), nullable=False), + sa.Column("value", sa.Text, nullable=True), + sa.UniqueConstraint("task_id", "field_id", name="uq_task_field_values"), + ) + + +def downgrade() -> None: + op.drop_table("task_field_values") + op.drop_table("step_field_refs") + op.drop_table("workflow_fields") diff --git a/progi/alembic/versions/0013_drop_task_description.py b/progi/alembic/versions/0013_drop_task_description.py new file mode 100644 index 0000000..4526f8c --- /dev/null +++ b/progi/alembic/versions/0013_drop_task_description.py @@ -0,0 +1,25 @@ +"""Drop tasks.description column + +Revision ID: 0013 +Revises: 0012 +Create Date: 2026-06-28 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0013" +down_revision: Union[str, None] = "0012" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("tasks") as batch_op: + batch_op.drop_column("description") + + +def downgrade() -> None: + import sqlalchemy as sa + with op.batch_alter_table("tasks") as batch_op: + batch_op.add_column(sa.Column("description", sa.Text, nullable=True)) diff --git a/progi/alembic/versions/0014_unified_state.py b/progi/alembic/versions/0014_unified_state.py new file mode 100644 index 0000000..f45d953 --- /dev/null +++ b/progi/alembic/versions/0014_unified_state.py @@ -0,0 +1,79 @@ +"""Replace step_field_refs with unified workflow state: add default_value/required +to workflow_fields, create sub_workflow_field_mappings, drop step_field_refs. + +Revision ID: 0014 +Revises: 0013 +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0014" +down_revision: Union[str, None] = "0013" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_fields") as batch_op: + batch_op.add_column(sa.Column("default_value", sa.Text, nullable=True)) + batch_op.add_column( + sa.Column("required", sa.Boolean, nullable=False, server_default="0") + ) + + op.create_table( + "sub_workflow_field_mappings", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "step_id", + sa.Integer, + sa.ForeignKey("steps.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "parent_field_id", + sa.Integer, + sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sub_workflow_field_id", + sa.Integer, + sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), + nullable=False, + ), + sa.UniqueConstraint( + "step_id", "sub_workflow_field_id", name="uq_sub_wf_field_mappings" + ), + ) + + op.drop_table("step_field_refs") + + +def downgrade() -> None: + op.create_table( + "step_field_refs", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "step_id", + sa.Integer, + sa.ForeignKey("steps.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "field_id", + sa.Integer, + sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("direction", sa.String(8), nullable=False), + sa.UniqueConstraint("step_id", "field_id", "direction", name="uq_step_field_refs"), + ) + + op.drop_table("sub_workflow_field_mappings") + + with op.batch_alter_table("workflow_fields") as batch_op: + batch_op.drop_column("required") + batch_op.drop_column("default_value") diff --git a/progi/alembic/versions/0015_drop_requires_approval.py b/progi/alembic/versions/0015_drop_requires_approval.py new file mode 100644 index 0000000..539b8a8 --- /dev/null +++ b/progi/alembic/versions/0015_drop_requires_approval.py @@ -0,0 +1,27 @@ +"""Drop requires_approval column from steps. + +Revision ID: 0015 +Revises: 0014 +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0015" +down_revision: Union[str, None] = "0014" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("steps") as batch_op: + batch_op.drop_column("requires_approval") + + +def downgrade() -> None: + with op.batch_alter_table("steps") as batch_op: + batch_op.add_column( + sa.Column("requires_approval", sa.Boolean, nullable=False, server_default="0") + ) diff --git a/progi/alembic/versions/0016_drop_workflow_field_type.py b/progi/alembic/versions/0016_drop_workflow_field_type.py new file mode 100644 index 0000000..b17f3eb --- /dev/null +++ b/progi/alembic/versions/0016_drop_workflow_field_type.py @@ -0,0 +1,27 @@ +"""Drop type column from workflow_fields. + +Revision ID: 0016 +Revises: 0015 +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0016" +down_revision: Union[str, None] = "0015" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("workflow_fields") as batch_op: + batch_op.drop_column("type") + + +def downgrade() -> None: + with op.batch_alter_table("workflow_fields") as batch_op: + batch_op.add_column( + sa.Column("type", sa.String(16), nullable=False, server_default="text") + ) diff --git a/progi/db.py b/progi/db.py index 0d368a7..39a9981 100644 --- a/progi/db.py +++ b/progi/db.py @@ -35,6 +35,7 @@ from __future__ import annotations from datetime import datetime, timezone +from pathlib import Path from typing import Any import sqlalchemy as sa @@ -43,9 +44,71 @@ from sqlalchemy.exc import IntegrityError from .config import Config -from .models import library_entries, playbooks, step_edges, step_instances, steps, tasks, workflows +from .models import library_entries, playbooks, step_edges, step_instances, steps, sub_workflow_field_mappings, task_field_values, tasks, workflow_fields, workflows from .models import metadata # noqa: F401 — re-exported for test helpers +import logging +import re + +_log = logging.getLogger(__name__) + +_FIELD_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") +_MENTION_RE = re.compile(r"@([a-z][a-z0-9_]*)") + +# Default maximum number of QA retries before a step is marked failed. +# Steps can override this with their own max_qa_retries column value. +_QA_MAX_RETRIES: int = 3 + +_QA_BASE_PROMPT: str = (Path(__file__).parent / "prompts" / "qa_playbook.md").read_text(encoding="utf-8") + +_QA_TEMPLATE: str = (Path(__file__).parent / "prompts" / "templates" / "tmpl_qa_playbook.md").read_text(encoding="utf-8") + +_WORKER_TEMPLATE: str = (Path(__file__).parent / "prompts" / "templates" / "tmpl_worker_playbook.md").read_text(encoding="utf-8") + + +_HUMAN_INVOLVEMENT_INSTRUCTION: str = ( + "\n\n---\n" + "**Human involvement required.** " + "When the instruction says to involve a human, write your output to a file in the current working directory " + "(choose an appropriate filename). " + "Present the file path to the user and ask them to review it. " + "If they request changes, edit the file and present it again." + "User also might manually edit the file." +) + +_HUMAN_INVOLVEMENT_NONE_RE = re.compile( + r"##\s+Human involvement\s*\n+.*?none", + re.IGNORECASE | re.DOTALL, +) +_HUMAN_INVOLVEMENT_SECTION_RE = re.compile( + r"##\s+Human involvement", + re.IGNORECASE, +) + + +def _inject_human_involvement_instruction(playbook: str | None) -> str | None: + """Append the file-writing/approval instruction if the playbook has a + non-empty Human involvement section (i.e. not 'None — proceed autonomously.').""" + if not playbook: + return playbook + if not _HUMAN_INVOLVEMENT_SECTION_RE.search(playbook): + return playbook + if _HUMAN_INVOLVEMENT_NONE_RE.search(playbook): + return playbook + return playbook + _HUMAN_INVOLVEMENT_INSTRUCTION + + +def _normalize_playbook(text: str | None) -> str | None: + """Replace literal \\n escape sequences with real newlines. + + LLMs sometimes emit playbook strings with backslash-n instead of actual + newline characters. Normalise on write so the DB always holds clean text. + """ + if text is None: + return None + return text.replace("\\n", "\n") + + # --------------------------------------------------------------------------- # Engine # --------------------------------------------------------------------------- @@ -191,18 +254,16 @@ def _start_step(conn, workflow_id: int) -> dict[str, Any]: return dict(row) -def _evaluate_condition(condition: dict | None, output: dict | str) -> bool: - """Return True if the edge condition matches the step output. +def _evaluate_condition(condition: dict | None, state: dict | str) -> bool: + """Return True if the edge condition matches the workflow state. A null condition always matches (unconditional / default edge). Supported operators: eq, neq, in, not_in. - Plain-text (str) outputs are treated as {"value": output} for condition matching. """ if condition is None: return True field = condition["field"] - output_dict = {"value": output} if isinstance(output, str) else output - value = output_dict.get(field) + value = state.get(field) if isinstance(state, dict) else None op = condition["operator"] if op == "eq": return value == condition["value"] @@ -216,9 +277,152 @@ def _evaluate_condition(condition: dict | None, output: dict | str) -> bool: -def _resolve_next_step(conn, current_step_id: int, output: dict) -> dict[str, Any] | None: +def _get_workflow_fields_by_name(conn, workflow_id: int) -> dict[str, Any]: + """Return {name: field_row_dict} for all workflow_fields of a workflow.""" + rows = ( + conn.execute( + sa.select(workflow_fields).where(workflow_fields.c.workflow_id == workflow_id) + ) + .mappings() + .all() + ) + return {r["name"]: dict(r) for r in rows} + + +def _initialize_task_state(conn, task_id: int, workflow_id: int) -> None: + """Pre-populate task_field_values for all workflow fields with their defaults.""" + fields = ( + conn.execute( + sa.select(workflow_fields).where(workflow_fields.c.workflow_id == workflow_id) + ) + .mappings() + .all() + ) + for f in fields: + conn.execute( + sa.insert(task_field_values).values( + task_id=task_id, + field_id=f["id"], + value=f["default_value"], + ) + ) + + +def _get_full_task_state(conn, task_id: int, workflow_id: int) -> dict[str, Any]: + """Return {field_name: value} for all fields of the workflow in this task.""" + fields = ( + conn.execute( + sa.select(workflow_fields).where(workflow_fields.c.workflow_id == workflow_id) + ) + .mappings() + .all() + ) + if not fields: + return {} + + field_ids = [f["id"] for f in fields] + value_rows = ( + conn.execute( + sa.select(task_field_values.c.field_id, task_field_values.c.value) + .where( + task_field_values.c.task_id == task_id, + task_field_values.c.field_id.in_(field_ids), + ) + ) + .mappings() + .all() + ) + value_by_field_id = {r["field_id"]: r["value"] for r in value_rows} + return {f["name"]: value_by_field_id.get(f["id"]) for f in fields} + + +def _resolve_cross_workflow_refs( + conn, task_id: int, current_si: dict +) -> dict[str, dict[str, Any]]: + """Resolve cross-workflow state references for the current step instance. + + Returns a dict keyed by reference prefix: + - "parent" → parent workflow's state (if inside a sub-workflow) + - "" → sub-workflow state for a sub-workflow step in the current workflow + """ + result: dict[str, dict[str, Any]] = {} + + # Resolve @parent chain + sub_wf_step_id = current_si.get("sub_workflow_step_id") + if sub_wf_step_id: + parent_step = ( + conn.execute(sa.select(steps).where(steps.c.id == sub_wf_step_id)) + .mappings() + .one_or_none() + ) + if parent_step: + parent_wf_id = parent_step["workflow_id"] + result["parent"] = _get_full_task_state(conn, task_id, parent_wf_id) + + # Resolve @ refs — find sub-workflow steps in the current workflow + current_step = ( + conn.execute(sa.select(steps).where(steps.c.id == current_si["step_id"])) + .mappings() + .one_or_none() + ) + if current_step: + current_wf_id = current_step["workflow_id"] + sub_wf_steps = ( + conn.execute( + sa.select(steps.c.id, steps.c.sub_workflow_id) + .where( + steps.c.workflow_id == current_wf_id, + steps.c.sub_workflow_id.isnot(None), + ) + ) + .mappings() + .all() + ) + for sws in sub_wf_steps: + result[str(sws["id"])] = _get_full_task_state( + conn, task_id, sws["sub_workflow_id"] + ) + + return result + + +def _write_task_state( + conn, + task_id: int, + output: dict, + workflow_fields_by_name: dict[str, Any], +) -> None: + """Upsert output keys that match any workflow field into task_field_values.""" + if not isinstance(output, dict): + return + + for key, value in output.items(): + field = workflow_fields_by_name.get(key) + if field is None: + continue + field_id = field["id"] + result = conn.execute( + sa.update(task_field_values) + .where( + task_field_values.c.task_id == task_id, + task_field_values.c.field_id == field_id, + ) + .values(value=str(value) if value is not None else None) + ) + if result.rowcount == 0: + conn.execute( + sa.insert(task_field_values).values( + task_id=task_id, + field_id=field_id, + value=str(value) if value is not None else None, + ) + ) + + +def _resolve_next_step(conn, current_step_id: int, state: dict) -> dict[str, Any] | None: """Return the next step dict by evaluating outgoing edges, or None if terminal. + Edge conditions are evaluated against the workflow state dict. Edges are evaluated in ascending priority order. The first edge whose condition matches the output is taken. Raises ValueError if edges exist but none match. @@ -237,7 +441,7 @@ def _resolve_next_step(conn, current_step_id: int, output: dict) -> dict[str, An return None # terminal step for edge in edges: - if _evaluate_condition(edge["condition"], output): + if _evaluate_condition(edge["condition"], state): row = ( conn.execute(sa.select(steps).where(steps.c.id == edge["to_step_id"])) .mappings() @@ -245,15 +449,244 @@ def _resolve_next_step(conn, current_step_id: int, output: dict) -> dict[str, An ) return dict(row) - output_desc = list(output.keys()) if isinstance(output, dict) else repr(output[:80]) + state_desc = list(state.keys()) if isinstance(state, dict) else repr(state[:80]) raise ValueError( f"No outgoing edge condition matched for step {current_step_id}. " - f"Output fields: {output_desc}. " - f"Check that the output includes the field referenced by at least one edge condition, " + f"State fields: {state_desc}. " + f"Check that the state includes the field referenced by at least one edge condition, " f"or add an unconditional (null condition) fallback edge." ) +# --------------------------------------------------------------------------- +# Sub-workflow helpers +# --------------------------------------------------------------------------- + + +def _get_sub_workflow_ancestor_ids(conn, workflow_id: int) -> set[int]: + """Return the set of all workflow IDs that (transitively) use workflow_id as a sub-workflow. + + Used for circular reference detection: if workflow A is being added as a + sub-workflow step inside workflow B, we must ensure B is not already an + ancestor of A (which would create a cycle). + """ + ancestors: set[int] = set() + queue = [workflow_id] + while queue: + wid = queue.pop() + # Find all workflows that have a step whose sub_workflow_id == wid + rows = conn.execute( + sa.select(steps.c.workflow_id) + .where(steps.c.sub_workflow_id == wid) + .distinct() + ).scalars().all() + for parent_wf_id in rows: + if parent_wf_id not in ancestors: + ancestors.add(parent_wf_id) + queue.append(parent_wf_id) + return ancestors + + +def _check_no_circular_ref(conn, parent_workflow_id: int, sub_workflow_id: int) -> None: + """Raise ValueError if adding sub_workflow_id as a step in parent_workflow_id would create a cycle.""" + if sub_workflow_id == parent_workflow_id: + raise ValueError( + f"Workflow {parent_workflow_id} cannot reference itself as a sub-workflow." + ) + ancestors = _get_sub_workflow_ancestor_ids(conn, parent_workflow_id) + if sub_workflow_id in ancestors: + raise ValueError( + f"Adding workflow {sub_workflow_id} as a sub-workflow of {parent_workflow_id} " + f"would create a circular reference." + ) + + +def _advance_task( + conn, + task_id: int, + current_step: dict, + current_si: dict, + output: Any, +) -> dict[str, Any]: + """Resolve the next step and activate it, or mark the task done. + + Shared by submit_output and submit_qa_result (passed branch). Returns + the same shape as submit_output's in_progress / done responses, without + the qa_verdict key (callers add that if needed). + """ + # Write output keys matching workflow fields into the task state store + current_step_wf_id = conn.execute( + sa.select(steps.c.workflow_id).where(steps.c.id == current_step["id"]) + ).scalar() + wf_fields_by_name: dict[str, Any] = {} + if current_step_wf_id: + wf_fields_by_name = _get_workflow_fields_by_name(conn, current_step_wf_id) + if isinstance(output, dict): + _write_task_state(conn, task_id, output, wf_fields_by_name) + + # Fetch updated state for edge condition evaluation + state = _get_full_task_state(conn, task_id, current_step_wf_id) if current_step_wf_id else {} + + next_step = _resolve_next_step(conn, current_step["id"], state) + + if next_step is None: + parent_step_id = current_si.get("sub_workflow_step_id") + if parent_step_id: + # Use parent workflow's state for parent edge conditions + parent_step_row = conn.execute( + sa.select(steps.c.workflow_id).where(steps.c.id == parent_step_id) + ).scalar() + parent_state = _get_full_task_state(conn, task_id, parent_step_row) if parent_step_row else {} + next_step = _resolve_next_step(conn, parent_step_id, parent_state) + if next_step is None: + conn.execute( + sa.update(tasks) + .where(tasks.c.id == task_id) + .values(status="done", current_step_id=None, progress_notes=None) + ) + return {"status": "done"} + + next_step_wf_id = next_step["workflow_id"] + next_input_data = {"state": _get_full_task_state(conn, task_id, next_step_wf_id)} + conn.execute(sa.update(tasks).where(tasks.c.id == task_id).values(progress_notes=None)) + + next_sub_wf_id = next_step.get("sub_workflow_id") + if next_sub_wf_id: + sub_wf_row = conn.execute( + sa.select(workflows.c.name, workflows.c.playbook).where( + workflows.c.id == next_sub_wf_id + ) + ).mappings().one() + _expand_sub_workflow(conn, task_id, next_sub_wf_id, next_step["id"], next_input_data) + return { + "status": "in_progress", + "next_step": { + "name": next_step["name"], + "input_data": next_input_data, + "is_sub_workflow": True, + "sub_workflow": { + "workflow_id": next_sub_wf_id, + "workflow_name": sub_wf_row["name"], + "playbook": sub_wf_row["playbook"], + }, + }, + } + + inherited_sub_wf_step_id = current_si.get("sub_workflow_step_id") + conn.execute( + sa.insert(step_instances).values( + task_id=task_id, + step_id=next_step["id"], + status="active", + input_data=next_input_data, + sub_workflow_step_id=inherited_sub_wf_step_id, + ) + ) + conn.execute( + sa.update(tasks).where(tasks.c.id == task_id).values(current_step_id=next_step["id"]) + ) + + pb = conn.execute( + sa.select(playbooks.c.content).where( + playbooks.c.step_id == next_step["id"], playbooks.c.type == "worker" + ) + ).first() + + return { + "status": "in_progress", + "next_step": { + "name": next_step["name"], + "input_data": next_input_data, + "playbook": pb[0] if pb else None, + }, + } + + +def _expand_sub_workflow( + conn, + task_id: int, + sub_workflow_id: int, + sub_workflow_step_id: int, + input_data: dict, +) -> dict: + """Activate the entry step of a sub-workflow within the parent task. + + Initialises the sub-workflow's state (defaults + mapped parent values), + creates a step_instance for the entry step with sub_workflow_step_id set, + and updates the task's current_step_id. Returns the activated step dict. + """ + # Initialize sub-workflow state with defaults + _initialize_task_state(conn, task_id, sub_workflow_id) + + # Apply parent → sub-workflow field mappings + mappings = ( + conn.execute( + sa.select(sub_workflow_field_mappings) + .where(sub_workflow_field_mappings.c.step_id == sub_workflow_step_id) + ) + .mappings() + .all() + ) + for m in mappings: + parent_value = conn.execute( + sa.select(task_field_values.c.value).where( + task_field_values.c.task_id == task_id, + task_field_values.c.field_id == m["parent_field_id"], + ) + ).scalar() + conn.execute( + sa.update(task_field_values) + .where( + task_field_values.c.task_id == task_id, + task_field_values.c.field_id == m["sub_workflow_field_id"], + ) + .values(value=parent_value) + ) + + # Validate required sub-workflow fields have values + required_fields = ( + conn.execute( + sa.select(workflow_fields) + .where( + workflow_fields.c.workflow_id == sub_workflow_id, + workflow_fields.c.required == True, # noqa: E712 + ) + ) + .mappings() + .all() + ) + for rf in required_fields: + val = conn.execute( + sa.select(task_field_values.c.value).where( + task_field_values.c.task_id == task_id, + task_field_values.c.field_id == rf["id"], + ) + ).scalar() + if val is None: + raise ValueError( + f"Required field '{rf['name']}' for sub-workflow is not mapped or has no value." + ) + + entry_step = _start_step(conn, sub_workflow_id) + sub_wf_state = _get_full_task_state(conn, task_id, sub_workflow_id) + input_data = {"state": sub_wf_state} + conn.execute( + sa.insert(step_instances).values( + task_id=task_id, + step_id=entry_step["id"], + status="active", + input_data=input_data, + sub_workflow_step_id=sub_workflow_step_id, + ) + ) + conn.execute( + sa.update(tasks) + .where(tasks.c.id == task_id) + .values(current_step_id=entry_step["id"], status="in_progress") + ) + return entry_step + + # --------------------------------------------------------------------------- # Workflow authoring # --------------------------------------------------------------------------- @@ -263,6 +696,9 @@ def save_workflow( cfg: Config, skeleton_json: dict[str, Any], playbooks_by_step: dict[str, str], + workflow_playbook: str | None = None, + qa_playbooks_by_step: dict[str, str] | None = None, + qa_enabled_steps: list[str] | None = None, ) -> dict[str, Any]: """Persist a workflow, its steps, edges, and playbooks in one transaction. @@ -272,7 +708,7 @@ def save_workflow( "name": str, "description": str, "steps": [ - {"order": int, "name": str} + {"order": int, "name": str, "sub_workflow_id": int | null} ], "edges": [ # optional; auto-generated if absent {"from": str, "to": str, "condition": {...} | null, "priority": int} @@ -283,6 +719,8 @@ def save_workflow( ``order`` values (step[i] → step[i+1]). playbooks_by_step: mapping of step name → playbook markdown string. + workflow_playbook: markdown string for the workflow-level playbook (Purpose/Input/Output). + Required when the workflow may be used as a sub-workflow step. """ engine = get_engine(cfg) with engine.begin() as conn: @@ -290,23 +728,54 @@ def save_workflow( sa.insert(workflows).values( name=skeleton_json["name"], description=skeleton_json.get("description"), + playbook=_normalize_playbook(workflow_playbook), ) ).inserted_primary_key[0] step_rows: list[dict[str, Any]] = [] _steps_list = skeleton_json.get("process") or skeleton_json.get("steps") or [] for step in sorted(_steps_list, key=lambda s: s["order"]): + sub_wf_id = step.get("sub_workflow_id") + if sub_wf_id is not None: + _check_no_circular_ref(conn, wf_id, sub_wf_id) + # Validate the sub-workflow has a playbook + sub_pb = conn.execute( + sa.select(workflows.c.playbook).where(workflows.c.id == sub_wf_id) + ).scalar() + if not sub_pb: + raise ValueError( + f"Workflow {sub_wf_id} has no playbook and cannot be used as a sub-workflow step." + ) step_id = conn.execute( sa.insert(steps).values( workflow_id=wf_id, order=step["order"], name=step["name"], - requires_approval=step.get("requires_approval", False), + sub_workflow_id=sub_wf_id, ) ).inserted_primary_key[0] step_row = conn.execute(sa.select(steps).where(steps.c.id == step_id)).mappings().one() step_rows.append(dict(step_row)) + # Insert workflow fields + field_id_by_name: dict[str, int] = {} + for field_def in skeleton_json.get("fields", []): + fname = field_def["name"] + if not _FIELD_NAME_RE.match(fname): + raise ValueError( + f"Invalid field name '{fname}': must match ^[a-z][a-z0-9_]*$" + ) + fid = conn.execute( + sa.insert(workflow_fields).values( + workflow_id=wf_id, + name=fname, + description=field_def.get("description", ""), + default_value=field_def.get("default_value"), + required=bool(field_def.get("required", False)), + ) + ).inserted_primary_key[0] + field_id_by_name[fname] = fid + # Build name → id lookup for edge resolution step_id_by_name = {s["name"]: s["id"] for s in step_rows} @@ -342,19 +811,47 @@ def save_workflow( ) ) + enabled_set = set(qa_enabled_steps or []) for step_row in step_rows: - playbook_content = playbooks_by_step.get(step_row["name"]) + playbook_content = _normalize_playbook(playbooks_by_step.get(step_row["name"])) if playbook_content: conn.execute( - sa.insert(playbooks).values(step_id=step_row["id"], content=playbook_content) + sa.insert(playbooks).values( + step_id=step_row["id"], type="worker", content=playbook_content + ) + ) + # Always insert a QA playbook row so it's editable in the UI. + # Use the step-specific content when provided; fall back to the empty template. + qa_content = _normalize_playbook((qa_playbooks_by_step or {}).get(step_row["name"])) or _QA_TEMPLATE + conn.execute( + sa.insert(playbooks).values( + step_id=step_row["id"], type="qa", content=qa_content + ) + ) + if step_row["name"] in enabled_set: + conn.execute( + sa.update(steps) + .where(steps.c.id == step_row["id"]) + .values(qa_enabled=True) ) workflow = ( conn.execute(sa.select(workflows).where(workflows.c.id == wf_id)).mappings().one() ) + field_rows = ( + conn.execute( + sa.select(workflow_fields).where(workflow_fields.c.workflow_id == wf_id) + ) + .mappings() + .all() + ) result = dict(workflow) result["steps"] = step_rows + result["fields"] = [ + {"name": f["name"], "description": f["description"]} + for f in field_rows + ] return result @@ -425,6 +922,23 @@ def delete_workflow(cfg: Config, workflow_id: int) -> None: """Delete a workflow and all its dependent data.""" engine = get_engine(cfg) with engine.begin() as conn: + # Block delete if this workflow is used as a sub-workflow step elsewhere. + referencing = ( + conn.execute( + sa.select(workflows.c.name) + .select_from(steps.join(workflows, steps.c.workflow_id == workflows.c.id)) + .where(steps.c.sub_workflow_id == workflow_id) + .distinct() + ) + .scalars() + .all() + ) + if referencing: + names = ", ".join(f'"{n}"' for n in referencing) + raise ValueError( + f"Cannot delete: this workflow is used as a sub-workflow step in {names}." + ) + # tasks.workflow_id has no CASCADE, so delete tasks (and their # step_instances via cascade) before touching the workflow. task_ids = ( @@ -435,6 +949,18 @@ def delete_workflow(cfg: Config, workflow_id: int) -> None: if task_ids: conn.execute(sa.delete(step_instances).where(step_instances.c.task_id.in_(task_ids))) conn.execute(sa.delete(tasks).where(tasks.c.id.in_(task_ids))) + + # step_instances.step_id has no CASCADE in the DB (ALTER TABLE ADD COLUMN + # silently drops FK constraints in SQLite), so manually delete any + # step_instances referencing steps of this workflow — including those + # created by tasks from other workflows that ran this as a sub-workflow. + step_ids = ( + conn.execute(sa.select(steps.c.id).where(steps.c.workflow_id == workflow_id)) + .scalars() + .all() + ) + if step_ids: + conn.execute(sa.delete(step_instances).where(step_instances.c.step_id.in_(step_ids))) result = conn.execute(sa.delete(workflows).where(workflows.c.id == workflow_id)) if result.rowcount == 0: raise ValueError(f"Workflow {workflow_id} not found.") @@ -446,7 +972,7 @@ def add_step_to_workflow( name: str, order: int, playbook: str | None = None, - requires_approval: bool = False, + sub_workflow_id: int | None = None, *, reorder: bool = True, ) -> dict[str, Any]: @@ -472,6 +998,16 @@ def add_step_to_workflow( if wf is None: raise ValueError(f"Workflow {workflow_id} not found.") + if sub_workflow_id is not None: + _check_no_circular_ref(conn, workflow_id, sub_workflow_id) + sub_pb = conn.execute( + sa.select(workflows.c.playbook).where(workflows.c.id == sub_workflow_id) + ).scalar() + if not sub_pb: + raise ValueError( + f"Workflow {sub_workflow_id} has no playbook and cannot be used as a sub-workflow step." + ) + if reorder: conn.execute( sa.update(steps) @@ -484,7 +1020,7 @@ def add_step_to_workflow( workflow_id=workflow_id, order=order, name=name, - requires_approval=requires_approval, + sub_workflow_id=sub_workflow_id, ) ).inserted_primary_key[0] @@ -640,22 +1176,41 @@ def update_workflow(cfg: Config, workflow_id: int, name: str) -> dict[str, Any]: return dict(row) +def update_workflow_playbook(cfg: Config, workflow_id: int, playbook: str) -> dict[str, Any]: + """Set or replace the workflow-level playbook. Returns the updated workflow record.""" + engine = get_engine(cfg) + with engine.begin() as conn: + result = conn.execute( + sa.update(workflows).where(workflows.c.id == workflow_id).values(playbook=_normalize_playbook(playbook)) + ) + if result.rowcount == 0: + raise ValueError(f"Workflow {workflow_id} not found.") + row = conn.execute(sa.select(workflows).where(workflows.c.id == workflow_id)).mappings().one() + return dict(row) + + def update_step( cfg: Config, step_id: int, *, name: str | None = None, order: int | None = None, - requires_approval: bool | None = None, + qa_enabled: bool | None = None, + edges: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - """Update one or more fields of a step; only non-None fields change.""" + """Update one or more fields of a step; only non-None fields change. + + ``edges``: optional list of outgoing-edge patches, each a dict with + ``to_step_id`` (int) and any subset of ``{parallel, condition, priority}`` + to update on the matching edge. Unknown ``to_step_id`` values are ignored. + """ values: dict[str, Any] = {} if name is not None: values["name"] = name if order is not None: values["order"] = order - if requires_approval is not None: - values["requires_approval"] = requires_approval + if qa_enabled is not None: + values["qa_enabled"] = qa_enabled engine = get_engine(cfg) with engine.begin() as conn: @@ -664,21 +1219,53 @@ def update_step( raise ValueError(f"Step {step_id} not found.") if values: conn.execute(sa.update(steps).where(steps.c.id == step_id).values(**values)) + if edges: + for patch in edges: + to_id = patch.get("to_step_id") + if to_id is None: + continue + edge_vals: dict[str, Any] = {} + if "parallel" in patch: + edge_vals["parallel"] = bool(patch["parallel"]) + if "condition" in patch: + edge_vals["condition"] = patch["condition"] + if "priority" in patch: + edge_vals["priority"] = int(patch["priority"]) + if edge_vals: + conn.execute( + sa.update(step_edges) + .where( + step_edges.c.from_step_id == step_id, + step_edges.c.to_step_id == to_id, + ) + .values(**edge_vals) + ) row = conn.execute(sa.select(steps).where(steps.c.id == step_id)).mappings().one() return dict(row) -def update_playbook(cfg: Config, step_id: int, content: str) -> dict[str, Any]: - """Upsert the playbook content for a step (insert if none exists).""" +def update_playbook( + cfg: Config, step_id: int, content: str, type: str = "worker" +) -> dict[str, Any]: + """Upsert a playbook for a step. ``type`` is 'worker' (default) or 'qa'.""" engine = get_engine(cfg) + content = _normalize_playbook(content) or "" with engine.begin() as conn: result = conn.execute( - sa.update(playbooks).where(playbooks.c.step_id == step_id).values(content=content) + sa.update(playbooks) + .where(playbooks.c.step_id == step_id, playbooks.c.type == type) + .values(content=content) ) if result.rowcount == 0: - conn.execute(sa.insert(playbooks).values(step_id=step_id, content=content)) + conn.execute( + sa.insert(playbooks).values(step_id=step_id, type=type, content=content) + ) row = ( - conn.execute(sa.select(playbooks).where(playbooks.c.step_id == step_id)) + conn.execute( + sa.select(playbooks).where( + playbooks.c.step_id == step_id, playbooks.c.type == type + ) + ) .mappings() .one() ) @@ -690,7 +1277,7 @@ def update_playbook(cfg: Config, step_id: int, content: str) -> dict[str, Any]: # --------------------------------------------------------------------------- -def create_task(cfg: Config, name: str, workflow_id: int, description: str = "") -> dict[str, Any]: +def create_task(cfg: Config, name: str, workflow_id: int) -> dict[str, Any]: """Create a task directly under a workflow. Does NOT start the task (status stays 'todo', current_step_id stays NULL). @@ -709,7 +1296,6 @@ def create_task(cfg: Config, name: str, workflow_id: int, description: str = "") sa.insert(tasks).values( workflow_id=workflow_id, name=name, - description=description or None, status="todo", current_step_id=None, ) @@ -748,6 +1334,7 @@ def _activate_step( task_workflow_id: int, step: dict[str, Any], input_data: dict[str, Any], + sub_workflow_step_id: int | None = None, ) -> None: """Create a step_instance for ``step`` and mark it active.""" conn.execute( @@ -756,6 +1343,7 @@ def _activate_step( step_id=step["id"], status="active", input_data=input_data, + sub_workflow_step_id=sub_workflow_step_id, ) ) conn.execute( @@ -768,9 +1356,8 @@ def _activate_step( def start_task(cfg: Config, task_id: int) -> dict[str, Any]: """First pick-up only (status must be 'todo'). - Finds the entry-point step (no incoming edges), creates its step_instance, - resolves input_data from the static input_spec, and sets the task to - 'in_progress'. + Finds the entry-point step (no incoming edges), initialises workflow state, + creates the first step_instance, and sets the task to 'in_progress'. """ engine = get_engine(cfg) with engine.begin() as conn: @@ -783,10 +1370,16 @@ def start_task(cfg: Config, task_id: int) -> dict[str, Any]: f"(task {task_id} is '{task['status']}')." ) - first_step = _start_step(conn, task["workflow_id"]) - input_data = {"value": task["description"] or ""} + wf_id = task["workflow_id"] + _initialize_task_state(conn, task_id, wf_id) + + first_step = _start_step(conn, wf_id) + input_data = {"state": _get_full_task_state(conn, task_id, wf_id)} - _activate_step(conn, task_id, task["workflow_id"], first_step, input_data) + if first_step.get("sub_workflow_id"): + _expand_sub_workflow(conn, task_id, first_step["sub_workflow_id"], first_step["id"], input_data) + else: + _activate_step(conn, task_id, wf_id, first_step, input_data) updated = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one() return dict(updated) @@ -824,10 +1417,56 @@ def list_tasks(cfg: Config, status: str = "", workflow_id: int = 0) -> list[dict return [dict(r) for r in rows] -def start_or_continue_task(cfg: Config, task_id: int) -> dict[str, Any]: +def reopen_task(cfg: Config, task_id: int, step_id: int) -> dict[str, Any]: + """Reopen a done/failed task at a specific step. + + Marks the task as 'in_progress', creates a fresh active step_instance for + ``step_id`` (preserving current workflow state), and returns the updated task. + The step must belong to the task's workflow. + """ + engine = get_engine(cfg) + with engine.begin() as conn: + task = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one_or_none() + if task is None: + raise ValueError(f"Task {task_id} not found.") + if task["status"] not in ("done", "failed"): + raise ValueError( + f"reopen_task can only be called on 'done' or 'failed' tasks " + f"(task {task_id} is '{task['status']}')." + ) + + step = conn.execute(sa.select(steps).where(steps.c.id == step_id)).mappings().one_or_none() + if step is None: + raise ValueError(f"Step {step_id} not found.") + + # Verify the step belongs to this task's workflow (direct or via sub-workflow) + task_wf_id = task["workflow_id"] + step_wf_id = step["workflow_id"] + valid_wf_ids = {task_wf_id} + # Include sub-workflow IDs reachable from this task's workflow + sub_wf_rows = conn.execute( + sa.select(steps.c.sub_workflow_id).where( + steps.c.workflow_id == task_wf_id, + steps.c.sub_workflow_id.isnot(None), + ) + ).scalars().all() + valid_wf_ids.update(sub_wf_rows) + if step_wf_id not in valid_wf_ids: + raise ValueError( + f"Step {step_id} does not belong to workflow {task_wf_id} or any of its sub-workflows." + ) + + input_data = {"state": _get_full_task_state(conn, task_id, step_wf_id)} + _activate_step(conn, task_id, task_wf_id, dict(step), input_data) + updated = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one() + return dict(updated) + + +def start_or_continue_task(cfg: Config, task_id: int, reopen_at_step_id: int = 0) -> dict[str, Any]: """Main work-loop entry point. - - done → returns a done message. + - done/failed + reopen_at_step_id → reopens the task at the given step. + - done → returns a done message (unless reopen_at_step_id is set). - todo → starts the task (todo → in_progress), returns full step context. - in_progress → returns full step context so the agent can resume. @@ -839,11 +1478,15 @@ def start_or_continue_task(cfg: Config, task_id: int) -> dict[str, Any]: task = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one_or_none() if task is None: raise ValueError(f"Task {task_id} not found.") - if task["status"] == "done": - return { - "status": "done", - "message": f"Task '{task['name']}' is already complete.", - } + if task["status"] in ("done", "failed"): + if reopen_at_step_id: + task = reopen_task(cfg, task_id, reopen_at_step_id) + else: + return { + "status": task["status"], + "message": f"Task '{task['name']}' is already {task['status']}. " + "Pass reopen_at_step_id to reopen it at a specific step.", + } if task["status"] == "todo": task = start_task(cfg, task_id) @@ -867,41 +1510,120 @@ def start_or_continue_task(cfg: Config, task_id: int) -> dict[str, Any]: .one_or_none() ) - pb = conn.execute( - sa.select(playbooks.c.content).where(playbooks.c.step_id == current_step["id"]) - ).first() + pb_rows = conn.execute( + sa.select(playbooks.c.type, playbooks.c.content).where( + playbooks.c.step_id == current_step["id"], + playbooks.c.type.in_(["worker", "qa"]), + ) + ).all() + pb = _inject_human_involvement_instruction( + next((r[1] for r in pb_rows if r[0] == "worker"), None) + ) + has_qa = bool(current_step["qa_enabled"]) and any(r[0] == "qa" for r in pb_rows) + + # Fetch workflow state for this step's workflow + step_wf_id = current_step.get("workflow_id") + workflow_state: list[dict[str, Any]] = [] + cross_wf_state: dict[str, dict[str, Any]] = {} + if step_wf_id: + wf_fields_by_name = _get_workflow_fields_by_name(conn, step_wf_id) + if wf_fields_by_name: + state_values = _get_full_task_state(conn, task_id, step_wf_id) + for name, field in wf_fields_by_name.items(): + workflow_state.append({ + "name": name, + "description": field.get("description"), + "value": state_values.get(name), + }) + if si: + cross_wf_state = _resolve_cross_workflow_refs(conn, task_id, dict(si)) + + # Check if current step is a sub-workflow step (has sub_workflow_id but no own playbook) + sub_wf_id = current_step.get("sub_workflow_id") + sub_wf_playbook: str | None = None + sub_wf_name: str | None = None + if sub_wf_id: + sub_wf_row = conn.execute( + sa.select(workflows.c.name, workflows.c.playbook).where(workflows.c.id == sub_wf_id) + ).mappings().one_or_none() + if sub_wf_row: + sub_wf_name = sub_wf_row["name"] + sub_wf_playbook = sub_wf_row["playbook"] + + # Build the state field names hint for the finish_step instruction. + state_field_names = [s["name"] for s in workflow_state] if workflow_state else [] + state_hint = ( + f" Include any of these workflow state fields you modified as keys in the output dict" + f" (keys matching field names auto-update shared state): {state_field_names}." + if state_field_names + else "" + ) + finish_step_instruction = ( + f"When done, call finish_step(task_id={task_id}, output={{...}}).{state_hint}" + ) + + current_step_dict: dict[str, Any] = { + "name": current_step["name"], + "input_data": si["input_data"] if si else None, + "playbook": pb if pb else None, + "has_qa": has_qa, + "instruction": finish_step_instruction, + } + if workflow_state: + current_step_dict["state"] = workflow_state + if cross_wf_state: + current_step_dict["cross_workflow_state"] = cross_wf_state result: dict[str, Any] = { "task": { "id": task["id"], "name": task["name"], "status": task["status"], - "description": task["description"], - }, - "current_step": { - "name": current_step["name"], - "input_data": si["input_data"] if si else None, - "playbook": pb[0] if pb else None, - "requires_approval": bool(current_step["requires_approval"]), }, + "current_step": current_step_dict, } + + if sub_wf_id: + result["sub_workflow"] = { + "workflow_id": sub_wf_id, + "workflow_name": sub_wf_name, + "playbook": sub_wf_playbook, + } + result["instruction"] = ( + f"This step is a sub-workflow step. You must spin up a fresh sub-agent to handle it.\n\n" + f"Sub-workflow: \"{sub_wf_name}\" (workflow_id: {sub_wf_id})\n\n" + f"Sub-workflow playbook:\n{sub_wf_playbook}\n\n" + f"Instructions:\n" + f"1. Launch a new sub-agent (e.g. via the Task tool).\n" + f"2. Pass it: task_id={task_id}, and instruct it to call start_or_continue_task({task_id}) " + f"to begin working through the sub-workflow steps.\n" + f"3. The sub-agent must call finish_step after each sub-workflow step completes.\n" + f"4. When the sub-agent signals it is done (all sub-workflow steps complete), " + f"call finish_step({task_id}, output=) to advance the parent task." + ) + else: + playbook_section = f"\n\nStep playbook:\n{pb}" if pb else "" + state_section = ( + f"\n\nWorkflow state fields to include in output if modified: {state_field_names}" + if state_field_names + else "" + ) + result["instruction"] = ( + f"This step must be handled by a fresh sub-agent.\n\n" + f"Step: \"{current_step['name']}\"{playbook_section}{state_section}\n\n" + f"Instructions:\n" + f"1. Launch a new sub-agent (e.g. via the Task tool).\n" + f"2. Pass it: task_id={task_id}, the step playbook above, and tell it to follow the playbook " + f"to complete the step.\n" + f"3. When done, the sub-agent must call finish_step(task_id={task_id}, output={{...}}).{state_hint}\n" + f"4. Wait for the sub-agent to complete, then report the result to the user." + ) + if task.get("progress_notes"): result["progress_notes"] = task["progress_notes"] return result -def update_progress_notes(cfg: Config, task_id: int, notes: str) -> dict[str, Any]: - """Overwrite progress_notes on the task and return the updated task.""" - engine = get_engine(cfg) - with engine.begin() as conn: - result = conn.execute( - sa.update(tasks).where(tasks.c.id == task_id).values(progress_notes=notes) - ) - if result.rowcount == 0: - raise ValueError(f"Task {task_id} not found.") - row = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one() - return dict(row) - def submit_output( cfg: Config, task_id: int, output: dict[str, Any] | str, task_name: str | None = None @@ -950,61 +1672,206 @@ def submit_output( f"at step '{current_step['name']}'." ) - # Mark current step complete conn.execute( sa.update(step_instances) .where(step_instances.c.id == current_si["id"]) .values(status="complete", output=output, completed_at=_now()) ) - # Optionally rename the task now that the agent has more context if task_name: conn.execute(sa.update(tasks).where(tasks.c.id == task_id).values(name=task_name)) - # Resolve next step via edge conditions - next_step = _resolve_next_step(conn, current_step["id"], output) + if current_step["qa_enabled"]: + qa_pb_row = conn.execute( + sa.select(playbooks.c.content).where( + playbooks.c.step_id == current_step["id"], playbooks.c.type == "qa" + ) + ).first() + else: + qa_pb_row = None - if next_step is None: - # Terminal step — task is done + if qa_pb_row is not None: + qa_playbook_content = qa_pb_row[0] + # Mark pending so QA sub-agent can call finish_qa before the task advances. + conn.execute( + sa.update(step_instances) + .where(step_instances.c.id == current_si["id"]) + .values(qa_status="pending") + ) + combined_qa_playbook = _QA_BASE_PROMPT + if qa_playbook_content.strip(): + combined_qa_playbook += ( + "\n\n---\n\n## Step-specific QA criteria\n\n" + qa_playbook_content + ) + return { + "status": "qa_required", + "step_instance_id": current_si["id"], + "qa_instruction": ( + f"This step has a QA gate. You must spin up a fresh QA sub-agent BEFORE advancing.\n\n" + f"DO NOT call finish_step again. DO NOT call finish_qa yourself — only the QA sub-agent calls it.\n\n" + f"Instructions:\n" + f"1. Launch a new sub-agent (e.g. via the Task tool).\n" + f"2. Pass the sub-agent the following:\n" + f" - task_id: {task_id}\n" + f" - The worker output it must evaluate: {output!r}\n" + f" - The task context (call get_task_context_prompt({task_id}) to fetch it).\n" + f" - These QA instructions (its full prompt):\n\n" + f"{combined_qa_playbook}\n\n" + f"3. The QA sub-agent must call finish_qa(task_id={task_id}, passed=, notes='...') " + f"once it has evaluated the output.\n" + f"4. Wait for the QA sub-agent to complete, then report its verdict to the user." + ), + } + + return _advance_task(conn, task_id, current_step, current_si, output) + + +def submit_qa_result( + cfg: Config, + task_id: int, + passed: bool, + notes: str = "", + give_up: bool = False, +) -> dict[str, Any]: + """Record the QA verdict for the most-recently-completed step instance. + + - passed=True → advance the task to the next step (same logic as the tail of submit_output). + - passed=False, give_up=False → re-activate the step for the worker with QA notes as context. + - passed=False, give_up=True → mark the task as failed. + + Raises ValueError if no step instance is in qa_status='pending' for this task. + """ + engine = get_engine(cfg) + with engine.begin() as conn: + task = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one_or_none() + if task is None: + raise ValueError(f"Task {task_id} not found.") + + # The QA-pending instance is complete (worker finished) but qa_status='pending'. + qa_si = ( + conn.execute( + sa.select(step_instances) + .where( + step_instances.c.task_id == task_id, + step_instances.c.qa_status == "pending", + ) + .order_by(step_instances.c.id.desc()) + ) + .mappings() + .one_or_none() + ) + if qa_si is None: + raise ValueError( + f"No step instance with qa_status='pending' found for task {task_id}. " + "Only call finish_qa when start_or_continue_task has indicated qa_required." + ) + + current_step = ( + conn.execute(sa.select(steps).where(steps.c.id == qa_si["step_id"])) + .mappings() + .one() + ) + + if give_up: + # Mark QA as failed and the task itself as failed. + conn.execute( + sa.update(step_instances) + .where(step_instances.c.id == qa_si["id"]) + .values(qa_status="failed", qa_output={"verdict": "give_up", "notes": notes}) + ) conn.execute( sa.update(tasks) .where(tasks.c.id == task_id) - .values(status="done", current_step_id=None, progress_notes=None) + .values( + status="failed", + current_step_id=None, + progress_notes=f"QA gave up on step '{current_step['name']}': {notes}", + ) ) - return {"status": "done"} + return {"status": "failed", "notes": notes} - # Pass the current step's output as the next step's input - next_input_data = { - "value": output if isinstance(output, str) else output.get("value", output), - } + if passed: + conn.execute( + sa.update(step_instances) + .where(step_instances.c.id == qa_si["id"]) + .values(qa_status="passed", qa_output={"verdict": "passed", "notes": notes}) + ) + result = _advance_task(conn, task_id, current_step, qa_si, qa_si["output"]) + if result["status"] == "in_progress": + result["qa_verdict"] = "passed" + return result - # Activate next step (create instance + update task pointer) - conn.execute( - sa.insert(step_instances).values( - task_id=task_id, - step_id=next_step["id"], - status="active", - input_data=next_input_data, + retry_count = qa_si.get("qa_retry_count", 0) + step_max = current_step.get("max_qa_retries") + max_retries = step_max if step_max is not None else _QA_MAX_RETRIES + + if retry_count >= max_retries: + conn.execute( + sa.update(step_instances) + .where(step_instances.c.id == qa_si["id"]) + .values( + qa_status="failed", + qa_output={"verdict": "failed", "notes": notes, "retries_exhausted": True}, + ) ) - ) + conn.execute( + sa.update(tasks) + .where(tasks.c.id == task_id) + .values( + status="failed", + current_step_id=None, + progress_notes=( + f"QA failed step '{current_step['name']}' after {retry_count} retries: {notes}" + ), + ) + ) + return {"status": "failed", "reason": "retries_exhausted", "notes": notes} + + # Stamp the old instance as failed and re-activate the step with QA notes in input_data, + # so the worker knows what to fix without having to re-read a separate feedback channel. conn.execute( - sa.update(tasks) - .where(tasks.c.id == task_id) - .values(current_step_id=next_step["id"], progress_notes=None) + sa.update(step_instances) + .where(step_instances.c.id == qa_si["id"]) + .values( + qa_status="failed", + qa_output={"verdict": "failed", "notes": notes}, + qa_retry_count=retry_count + 1, + ) ) - pb = conn.execute( - sa.select(playbooks.c.content).where(playbooks.c.step_id == next_step["id"]) + worker_pb = conn.execute( + sa.select(playbooks.c.content).where( + playbooks.c.step_id == current_step["id"], playbooks.c.type == "worker" + ) ).first() - return { - "status": "in_progress", - "next_step": { - "name": next_step["name"], - "input_data": next_input_data, - "playbook": pb[0] if pb else None, - }, - } + new_input = dict(qa_si["input_data"] or {}) + new_input["qa_feedback"] = notes + new_input["qa_retry"] = retry_count + 1 + + conn.execute( + sa.insert(step_instances).values( + task_id=task_id, + step_id=current_step["id"], + status="active", + input_data=new_input, + sub_workflow_step_id=qa_si.get("sub_workflow_step_id"), + qa_retry_count=retry_count + 1, + ) + ) + # task.current_step_id is already pointing to the right step — no change needed. + + return { + "status": "qa_retry", + "retry_number": retry_count + 1, + "retries_remaining": max_retries - (retry_count + 1), + "qa_notes": notes, + "step": { + "name": current_step["name"], + "playbook": worker_pb[0] if worker_pb else None, + "input_data": new_input, + }, + } # --------------------------------------------------------------------------- @@ -1034,7 +1901,12 @@ def get_workflow_with_playbooks(cfg: Config, workflow_id: int) -> dict[str, Any] step_ids = [s["id"] for s in step_rows] pb_rows = ( - conn.execute(sa.select(playbooks).where(playbooks.c.step_id.in_(step_ids))) + conn.execute( + sa.select(playbooks).where( + playbooks.c.step_id.in_(step_ids), + playbooks.c.type.in_(["worker", "qa"]), + ) + ) .mappings() .all() if step_ids @@ -1053,18 +1925,74 @@ def get_workflow_with_playbooks(cfg: Config, workflow_id: int) -> dict[str, Any] else [] ) - playbook_by_step = {pb["step_id"]: pb["content"] for pb in pb_rows} + playbook_by_step: dict[int, str] = {} + qa_playbook_by_step: dict[int, str] = {} + for pb in pb_rows: + if pb["type"] == "qa": + qa_playbook_by_step[pb["step_id"]] = pb["content"] + else: + playbook_by_step[pb["step_id"]] = pb["content"] + + # Fetch sub-workflow names for steps that reference them + sub_wf_ids = [s["sub_workflow_id"] for s in step_rows if s.get("sub_workflow_id")] + sub_wf_names: dict[int, str] = {} + if sub_wf_ids: + with engine.connect() as conn2: + name_rows = conn2.execute( + sa.select(workflows.c.id, workflows.c.name).where(workflows.c.id.in_(sub_wf_ids)) + ).mappings().all() + sub_wf_names = {r["id"]: r["name"] for r in name_rows} + + # Fetch workflow fields + with engine.connect() as conn3: + wf_field_rows = ( + conn3.execute( + sa.select(workflow_fields) + .where(workflow_fields.c.workflow_id == wf["id"]) + .order_by(workflow_fields.c.id) + ) + .mappings() + .all() + ) + + # Build set of known field names for mention extraction + _mention_re = re.compile(r"@([a-z][a-z0-9_]*)") + known_field_names = {f["name"] for f in wf_field_rows} + + def _mentioned_fields(step_id: int) -> list[str]: + """Return sorted list of known field names mentioned in this step's playbooks.""" + found: set[str] = set() + for src in (playbook_by_step.get(step_id), qa_playbook_by_step.get(step_id)): + if src: + found.update(m for m in _mention_re.findall(src) if m in known_field_names) + return sorted(found) return { "id": wf["id"], "name": wf["name"], "description": wf["description"], + "playbook": wf["playbook"], + "fields": [ + { + "id": f["id"], + "name": f["name"], + "description": f["description"], + "default_value": f["default_value"], + "required": bool(f["required"]), + } + for f in wf_field_rows + ], "steps": [ { "id": s["id"], "order": s["order"], "name": s["name"], "playbook": playbook_by_step.get(s["id"]), + "qa_playbook": qa_playbook_by_step.get(s["id"]), + "qa_enabled": bool(s["qa_enabled"]), + "sub_workflow_id": s.get("sub_workflow_id"), + "sub_workflow_name": sub_wf_names.get(s["sub_workflow_id"]) if s.get("sub_workflow_id") else None, + "mentioned_fields": _mentioned_fields(s["id"]), } for s in step_rows ], @@ -1141,7 +2069,15 @@ def get_step_detail(cfg: Config, workflow_id: int, step_id: int) -> dict[str, An raise ValueError(f"Step {step_id} not found in workflow {workflow_id}.") pb = conn.execute( - sa.select(playbooks.c.content).where(playbooks.c.step_id == step_id) + sa.select(playbooks.c.content).where( + playbooks.c.step_id == step_id, playbooks.c.type == "worker" + ) + ).scalar() + + qa_pb = conn.execute( + sa.select(playbooks.c.content).where( + playbooks.c.step_id == step_id, playbooks.c.type == "qa" + ) ).scalar() edge_rows = ( @@ -1188,18 +2124,73 @@ def get_step_detail(cfg: Config, workflow_id: int, step_id: int) -> dict[str, An if e["from_step_id"] == step_id ] - return { + sub_wf_name: str | None = None + sub_wf_playbook: str | None = None + if step["sub_workflow_id"]: + with engine.connect() as conn2: + row = conn2.execute( + sa.select(workflows.c.name, workflows.c.playbook).where( + workflows.c.id == step["sub_workflow_id"] + ) + ).mappings().one_or_none() + if row: + sub_wf_name = row["name"] + sub_wf_playbook = row["playbook"] + + # Fetch all workflow fields for the "available" list + with engine.connect() as conn3: + available_fields = ( + conn3.execute( + sa.select(workflow_fields) + .where(workflow_fields.c.workflow_id == workflow_id) + .order_by(workflow_fields.c.id) + ) + .mappings() + .all() + ) + + # For sub-workflow steps, fetch field mappings and required fields + sub_wf_field_mappings: list[dict] = [] + sub_wf_required_fields: list[dict] = [] + sub_wf_all_fields: list[dict] = [] + if step["sub_workflow_id"]: + mapping_data = get_sub_workflow_field_mappings(cfg, step_id) + sub_wf_field_mappings = mapping_data["mappings"] + sub_wf_required_fields = mapping_data.get("required_fields", []) + sub_wf_all_fields = mapping_data.get("sub_workflow_fields", []) + + result: dict[str, Any] = { "workflow": {"id": wf["id"], "name": wf["name"]}, "step": { "id": step["id"], "order": step["order"], "name": step["name"], "playbook": pb, + "qa_playbook": qa_pb, + "qa_enabled": bool(step["qa_enabled"]), "library_entry_id": step["library_entry_id"], + "sub_workflow_id": step["sub_workflow_id"], + "sub_workflow_name": sub_wf_name, + "sub_workflow_playbook": sub_wf_playbook, }, "prev_steps": prev_steps, "next_steps": next_steps, + "available_fields": [ + { + "id": f["id"], + "name": f["name"], + "description": f["description"], + "default_value": f["default_value"], + "required": bool(f["required"]), + } + for f in available_fields + ], } + if step["sub_workflow_id"]: + result["sub_workflow_field_mappings"] = sub_wf_field_mappings + result["sub_workflow_required_fields"] = sub_wf_required_fields + result["sub_workflow_fields"] = sub_wf_all_fields + return result def get_task_detail(cfg: Config, task_id: int) -> dict[str, Any]: @@ -1211,7 +2202,6 @@ def get_task_detail(cfg: Config, task_id: int) -> dict[str, Any]: sa.select( tasks.c.id, tasks.c.name, - tasks.c.description, tasks.c.status, tasks.c.current_step_id, tasks.c.progress_notes, @@ -1237,24 +2227,40 @@ def get_task_detail(cfg: Config, task_id: int) -> dict[str, Any]: sa.select(steps.c.name).where(steps.c.id == task["current_step_id"]) ).scalar() - # Step-instance history (most recent first via id desc) + # Alias for the sub-workflow parent step name lookup + parent_steps = steps.alias("parent_steps") + parent_wf = workflows.alias("parent_wf") + + # Step-instance history with sub-workflow provenance si_rows = ( conn.execute( sa.select( step_instances.c.id, step_instances.c.step_id, + step_instances.c.sub_workflow_step_id, step_instances.c.status, step_instances.c.input_data, step_instances.c.output, step_instances.c.completed_at, + step_instances.c.qa_status, + step_instances.c.qa_output, + step_instances.c.qa_retry_count, sa.func.coalesce(steps.c.name, "Adhoc step").label("step_name"), + # Name of the parent step that triggered sub-workflow expansion + parent_steps.c.name.label("sub_workflow_step_name"), + # ID and name of the sub-workflow workflow itself + parent_wf.c.id.label("sub_workflow_id"), + parent_wf.c.name.label("sub_workflow_name"), ) .select_from( - step_instances.outerjoin(steps, steps.c.id == step_instances.c.step_id) + step_instances + .outerjoin(steps, steps.c.id == step_instances.c.step_id) + .outerjoin(parent_steps, parent_steps.c.id == step_instances.c.sub_workflow_step_id) + .outerjoin(parent_wf, parent_wf.c.id == parent_steps.c.sub_workflow_id) ) .where(step_instances.c.task_id == task_id) .order_by( - # Active instance floats to top, then newest first + # Active instance floats to top, then chronological by id sa.case((step_instances.c.status == "active", 0), else_=1), step_instances.c.id.desc(), ) @@ -1267,7 +2273,6 @@ def get_task_detail(cfg: Config, task_id: int) -> dict[str, Any]: "task": { "id": task["id"], "name": task["name"], - "description": task["description"], "status": task["status"], "progress_notes": task["progress_notes"], "created_at": task["created_at"].isoformat() if task["created_at"] else None, @@ -1281,10 +2286,17 @@ def get_task_detail(cfg: Config, task_id: int) -> dict[str, Any]: "id": si["id"], "step_id": si["step_id"], "step_name": si["step_name"], + "sub_workflow_step_id": si["sub_workflow_step_id"], + "sub_workflow_step_name": si["sub_workflow_step_name"], + "sub_workflow_id": si["sub_workflow_id"], + "sub_workflow_name": si["sub_workflow_name"], "status": si["status"], "input_data": si["input_data"], "output": si["output"], "completed_at": si["completed_at"].isoformat() if si["completed_at"] else None, + "qa_status": si["qa_status"], + "qa_output": si["qa_output"], + "qa_retry_count": si["qa_retry_count"], } for si in si_rows ], @@ -1549,7 +2561,9 @@ def create_library_entry_from_step( try: with engine.begin() as conn: pb_content = conn.execute( - sa.select(playbooks.c.content).where(playbooks.c.step_id == step_id) + sa.select(playbooks.c.content).where( + playbooks.c.step_id == step_id, playbooks.c.type == "worker" + ) ).scalar() if pb_content is None: pb_content = "" @@ -1613,3 +2627,337 @@ def get_library_entries_summary(cfg: Config) -> list[dict[str, Any]]: .all() ) return [dict(r) for r in rows] + + +# --------------------------------------------------------------------------- +# Workflow state store — public API +# --------------------------------------------------------------------------- + + +def list_workflow_fields(cfg: Config, workflow_id: int) -> list[dict[str, Any]]: + """Return all fields defined for a workflow.""" + engine = get_engine(cfg) + with engine.connect() as conn: + rows = ( + conn.execute( + sa.select(workflow_fields) + .where(workflow_fields.c.workflow_id == workflow_id) + .order_by(workflow_fields.c.id) + ) + .mappings() + .all() + ) + return [dict(r) for r in rows] + + +def create_workflow_field( + cfg: Config, + workflow_id: int, + name: str, + description: str, + default_value: str | None = None, + required: bool = False, +) -> dict[str, Any]: + """Create a new named field for a workflow. ``name`` must match ^[a-z][a-z0-9_]*$.""" + if not _FIELD_NAME_RE.match(name): + raise ValueError(f"Invalid field name '{name}': must match ^[a-z][a-z0-9_]*$") + engine = get_engine(cfg) + try: + with engine.begin() as conn: + fid = conn.execute( + sa.insert(workflow_fields).values( + workflow_id=workflow_id, + name=name, + description=description, + default_value=default_value, + required=required, + ) + ).inserted_primary_key[0] + row = ( + conn.execute(sa.select(workflow_fields).where(workflow_fields.c.id == fid)) + .mappings() + .one() + ) + except IntegrityError: + raise ValueError(f"A field named '{name}' already exists in workflow {workflow_id}.") + return dict(row) + + +def update_workflow_field( + cfg: Config, + field_id: int, + *, + name: str | None = None, + description: str | None = None, + default_value: str | None = ..., # type: ignore[assignment] # sentinel: ... = not provided + required: bool | None = None, +) -> dict[str, Any]: + """Update one or more attributes of a workflow field.""" + values: dict[str, Any] = {} + if name is not None: + if not _FIELD_NAME_RE.match(name): + raise ValueError(f"Invalid field name '{name}': must match ^[a-z][a-z0-9_]*$") + values["name"] = name + if description is not None: + values["description"] = description + if default_value is not ...: + values["default_value"] = default_value + if required is not None: + values["required"] = required + + engine = get_engine(cfg) + try: + with engine.begin() as conn: + if values: + result = conn.execute( + sa.update(workflow_fields).where(workflow_fields.c.id == field_id).values(**values) + ) + if result.rowcount == 0: + raise ValueError(f"Workflow field {field_id} not found.") + row = ( + conn.execute(sa.select(workflow_fields).where(workflow_fields.c.id == field_id)) + .mappings() + .one_or_none() + ) + if row is None: + raise ValueError(f"Workflow field {field_id} not found.") + except IntegrityError: + raise ValueError("A field with that name already exists in this workflow.") + return dict(row) + + +def save_workflow_field( + cfg: Config, + workflow_id: int, + name: str, + description: str, + *, + field_id: int | None = None, + default_value: str | None = ..., # type: ignore[assignment] # sentinel: ... = not provided + required: bool | None = None, +) -> dict[str, Any]: + """Create or update a workflow field. + + If ``field_id`` is given, updates that field (only non-sentinel args change). + Otherwise creates a new field under ``workflow_id``. + """ + if field_id is not None: + return update_workflow_field( + cfg, + field_id, + name=name, + description=description, + default_value=default_value, + required=required, + ) + return create_workflow_field( + cfg, + workflow_id, + name, + description, + default_value=None if default_value is ... else default_value, + required=required if required is not None else False, + ) + + +def delete_workflow_field(cfg: Config, field_id: int) -> None: + """Delete a workflow field. Cascade removes task_field_values and sub_workflow_field_mappings.""" + engine = get_engine(cfg) + with engine.begin() as conn: + conn.execute(sa.delete(workflow_fields).where(workflow_fields.c.id == field_id)) + + +def unregistered_mentions( + cfg: Config, workflow_id: int, playbook_content: str +) -> list[str]: + """Return @variable names mentioned in playbook_content that are not yet workflow fields. + + Only names matching ^[a-z][a-z0-9_]*$ are considered. + """ + mentioned = set(_MENTION_RE.findall(playbook_content)) + if not mentioned: + return [] + engine = get_engine(cfg) + with engine.connect() as conn: + existing = { + r[0] + for r in conn.execute( + sa.select(workflow_fields.c.name).where( + workflow_fields.c.workflow_id == workflow_id + ) + ).fetchall() + } + return sorted(mentioned - existing) + + +def set_sub_workflow_field_mappings( + cfg: Config, + step_id: int, + mappings: list[dict[str, str]], +) -> dict[str, Any]: + """Replace parent→sub-workflow field mappings for a sub-workflow step. + + Each mapping: {"parent_field": "", "sub_workflow_field": ""}. + Validates that the step is a sub-workflow step, and that all field names exist. + """ + engine = get_engine(cfg) + with engine.begin() as conn: + step_row = conn.execute(sa.select(steps).where(steps.c.id == step_id)).mappings().one_or_none() + if step_row is None: + raise ValueError(f"Step {step_id} not found.") + sub_wf_id = step_row.get("sub_workflow_id") + if not sub_wf_id: + raise ValueError(f"Step {step_id} is not a sub-workflow step.") + + parent_wf_id = step_row["workflow_id"] + parent_fields = _get_workflow_fields_by_name(conn, parent_wf_id) + sub_wf_fields = _get_workflow_fields_by_name(conn, sub_wf_id) + + # Delete existing mappings + conn.execute( + sa.delete(sub_workflow_field_mappings) + .where(sub_workflow_field_mappings.c.step_id == step_id) + ) + + for m in mappings: + pf_name = m["parent_field"] + sf_name = m["sub_workflow_field"] + if pf_name not in parent_fields: + raise ValueError(f"Unknown parent field '{pf_name}' in workflow {parent_wf_id}.") + if sf_name not in sub_wf_fields: + raise ValueError(f"Unknown sub-workflow field '{sf_name}' in workflow {sub_wf_id}.") + conn.execute( + sa.insert(sub_workflow_field_mappings).values( + step_id=step_id, + parent_field_id=parent_fields[pf_name]["id"], + sub_workflow_field_id=sub_wf_fields[sf_name]["id"], + ) + ) + + return {"step_id": step_id, "mappings": mappings} + + +def get_sub_workflow_field_mappings(cfg: Config, step_id: int) -> dict[str, Any]: + """Return the field mappings and required fields for a sub-workflow step.""" + engine = get_engine(cfg) + with engine.connect() as conn: + step_row = conn.execute(sa.select(steps).where(steps.c.id == step_id)).mappings().one_or_none() + if step_row is None: + raise ValueError(f"Step {step_id} not found.") + sub_wf_id = step_row.get("sub_workflow_id") + if not sub_wf_id: + return {"step_id": step_id, "mappings": [], "required_fields": []} + + parent_wf_id = step_row["workflow_id"] + + # Get current mappings with field names + pf_alias = workflow_fields.alias("pf") + sf_alias = workflow_fields.alias("sf") + mapping_rows = ( + conn.execute( + sa.select( + pf_alias.c.name.label("parent_field"), + sf_alias.c.name.label("sub_workflow_field"), + ) + .select_from( + sub_workflow_field_mappings + .join(pf_alias, pf_alias.c.id == sub_workflow_field_mappings.c.parent_field_id) + .join(sf_alias, sf_alias.c.id == sub_workflow_field_mappings.c.sub_workflow_field_id) + ) + .where(sub_workflow_field_mappings.c.step_id == step_id) + ) + .mappings() + .all() + ) + + # Get sub-workflow required fields + required_rows = ( + conn.execute( + sa.select(workflow_fields.c.name, workflow_fields.c.description) + .where( + workflow_fields.c.workflow_id == sub_wf_id, + workflow_fields.c.required == True, # noqa: E712 + ) + ) + .mappings() + .all() + ) + + # Get all sub-workflow fields + sub_wf_fields = ( + conn.execute( + sa.select( + workflow_fields.c.name, + workflow_fields.c.description, + workflow_fields.c.required, + ) + .where(workflow_fields.c.workflow_id == sub_wf_id) + .order_by(workflow_fields.c.required.desc()) + ) + .mappings() + .all() + ) + + # Get parent workflow fields for mapping dropdowns + parent_fields = ( + conn.execute( + sa.select( + workflow_fields.c.name, + workflow_fields.c.description, + ) + .where(workflow_fields.c.workflow_id == parent_wf_id) + ) + .mappings() + .all() + ) + + return { + "step_id": step_id, + "mappings": [dict(r) for r in mapping_rows], + "required_fields": [dict(r) for r in required_rows], + "sub_workflow_fields": [dict(r) for r in sub_wf_fields], + "parent_fields": [dict(r) for r in parent_fields], + } + + +def get_task_state(cfg: Config, task_id: int) -> dict[str, dict[str, Any]]: + """Return {field_name: {value, description}} for the task's current state.""" + engine = get_engine(cfg) + with engine.connect() as conn: + task_row = conn.execute(sa.select(tasks).where(tasks.c.id == task_id)).mappings().one_or_none() + if task_row is None: + raise ValueError(f"Task {task_id} not found.") + wf_id = task_row["workflow_id"] + + field_rows = ( + conn.execute( + sa.select(workflow_fields).where(workflow_fields.c.workflow_id == wf_id) + ) + .mappings() + .all() + ) + if not field_rows: + return {} + + field_ids = [f["id"] for f in field_rows] + value_rows = ( + conn.execute( + sa.select(task_field_values.c.field_id, task_field_values.c.value) + .where( + task_field_values.c.task_id == task_id, + task_field_values.c.field_id.in_(field_ids), + ) + ) + .mappings() + .all() + ) + value_by_field_id = {r["field_id"]: r["value"] for r in value_rows} + + return { + f["name"]: { + "value": value_by_field_id.get(f["id"]), + "description": f["description"], + "default_value": f["default_value"], + } + for f in field_rows + } diff --git a/progi/mcp_server.py b/progi/mcp_server.py index 04912da..28f2813 100644 --- a/progi/mcp_server.py +++ b/progi/mcp_server.py @@ -5,7 +5,7 @@ here. Two tool families: - **Work loop**: create_task, list_tasks, - start_or_continue_task, update_progress_notes, finish_step. + start_or_continue_task, finish_step. - **Workflow authoring**: get_process_skeleton_prompt, get_playbook_authoring_prompt, save_workflow, list_workflows. @@ -40,6 +40,8 @@ def _monitoring_url(path: str = "") -> str: _PROMPTS_DIR = Path(__file__).parent / "prompts" _WORKFLOW_SKELETON_MD = _PROMPTS_DIR / "workflow_skeleton.md" +_WORKFLOW_PLAYBOOK_MD = _PROMPTS_DIR / "workflow_playbook.md" +_WORKER_PLAYBOOK_MD = _PROMPTS_DIR / "worker_playbook.md" # --------------------------------------------------------------------------- @@ -48,7 +50,7 @@ def _monitoring_url(path: str = "") -> str: @mcp.tool(title="Create Task") -def create_task(name: str, workflow_id: int, description: str = "") -> dict: +def create_task(name: str, workflow_id: int) -> dict: """Create a new task under the given workflow. Creates the task (status 'todo') and returns the task plus a preview of its @@ -57,7 +59,7 @@ def create_task(name: str, workflow_id: int, description: str = "") -> dict: Always show the user the monitoring_url from the response. """ - result = db.create_task(_cfg, name, workflow_id, description) + result = db.create_task(_cfg, name, workflow_id) result["monitoring_url"] = _monitoring_url(f"/tasks/{result['id']}") return result @@ -77,20 +79,27 @@ def list_tasks(status: str = "", workflow_id: int = 0) -> dict: @mcp.tool(title="Start or Continue Task") -def start_or_continue_task(task_id: int) -> dict: +def start_or_continue_task(task_id: int, reopen_at_step_id: int = 0) -> dict: """Main work-loop entry point. - - done → returns a done message. + - done/failed + reopen_at_step_id → reopens the task at the given step and returns step context. + - done/failed (no reopen_at_step_id) → returns a status message with instructions on how to reopen. - todo → starts the task (todo → in_progress) and returns step context. - in_progress → returns step context so the agent can resume. + To reopen a completed or failed task at a specific step, pass the step_id of + the step you want to restart from as reopen_at_step_id. The task's workflow + state is preserved so the step receives the same accumulated outputs. Use + get_task_state to find available step IDs. + Context includes task info, the current step name, input_data, the playbook - markdown, and progress_notes (if any). Follow the playbook's Output section - for what to produce and how to report it back. + markdown, progress_notes (if any), and an instruction field. Always follow + the instruction field — every step requires spinning up a fresh sub-agent + to do the work. Always show the user the monitoring_url from the response. """ - result = db.start_or_continue_task(_cfg, task_id) + result = db.start_or_continue_task(_cfg, task_id, reopen_at_step_id) result["monitoring_url"] = _monitoring_url(f"/tasks/{task_id}") return result @@ -129,32 +138,26 @@ def add_adhoc_step_result(task_id: int, output: dict) -> dict: return db.add_adhoc_step_result(_cfg, task_id, output) -@mcp.tool(title="Update Progress Notes") -def update_progress_notes(task_id: int, notes: str) -> dict: - """Overwrite a task's progress_notes. - - Only call this when the user explicitly asks to save or update progress notes. - Notes are cleared automatically when a step completes. - """ - return db.update_progress_notes(_cfg, task_id, notes) - @mcp.tool(title="Finish Step") def finish_step(task_id: int, output: dict | str, task_name: str = "") -> dict: """Mark the current step complete, store its output, and advance. - Either returns the next step's info (name + playbook, so the agent can - continue immediately) or {'status': 'done'} if it was the last step. + Output keys that match workflow field names automatically update the + workflow's shared state. Include all state variables you modified in the + output dict (e.g. {"draft": "draft.md", "review_needed": "True"}). + + Returns one of: + - {'status': 'in_progress', 'next_step': {...}} — next step info and playbook. + - {'status': 'done'} — last step reached, task complete. + - {'status': 'qa_required', 'qa_instruction': '...'} — this step has a QA + gate; follow the qa_instruction to spin up a QA sub-agent BEFORE advancing. + DO NOT call finish_qa yourself — only the QA sub-agent may call it. Pass task_name to rename the task when you now have enough context to give it a meaningful name (e.g. after the first step reveals what the task is actually about). Leave it empty to keep the current name. - IMPORTANT — approval gate: if start_or_continue_task returned - current_step.requires_approval = true for this step, you MUST present the - output to the user and ask for explicit approval BEFORE calling this tool. - Only call finish_step once the user has confirmed they are happy with the - output. If they request changes, make them first, then ask again. """ if isinstance(output, str): if output.strip(): @@ -167,28 +170,39 @@ def finish_step(task_id: int, output: dict | str, task_name: str = "") -> dict: return db.submit_output(_cfg, task_id, output, task_name or None) +@mcp.tool(title="Finish QA") +def finish_qa(task_id: int, passed: bool, notes: str = "", give_up: bool = False) -> dict: + """Record a QA verdict for the most-recently-completed step. + + *** THIS TOOL IS FOR QA SUB-AGENTS ONLY. *** + Worker agents must NEVER call this tool. Only call it when you are operating + as a QA reviewer launched by a worker agent after finish_step returned + status='qa_required'. + + Arguments: + passed — True if the worker output meets quality criteria; False to send + it back to the worker for revision. + notes — Required when passed=False: specific, actionable feedback for + the worker explaining exactly what to fix. Optional when + passed=True (can include brief observations). + give_up — Set True only when the output is fundamentally broken and + retrying would be pointless. This marks the task as 'failed'. + + Returns: + - {'status': 'in_progress', ...} — QA passed, task advanced to next step. + - {'status': 'done'} — QA passed on the last step, task complete. + - {'status': 'qa_retry', 'retry_number': N, ...} — QA failed; worker will + be re-activated with your notes. Report the verdict to the user. + - {'status': 'failed', ...} — give_up=True or retries exhausted. + """ + return db.submit_qa_result(_cfg, task_id, passed, notes, give_up) + + # --------------------------------------------------------------------------- # Workflow authoring tools # --------------------------------------------------------------------------- -def _library_block() -> str: - """Return a markdown section listing all library entries, or empty string if none.""" - entries = db.get_library_entries_summary(_cfg) - if not entries: - return "" - lines = [ - "\n\n## Step Library\n\n" - "These reusable step playbooks exist in the library. " - "When a step in the workflow closely matches one, mention it to the user " - "so they can decide whether to base the playbook on the library entry:\n" - ] - for e in entries: - desc = e["description"] or "(no description)" - lines.append(f"- **{e['name']}**: {desc}") - return "\n".join(lines) - - @mcp.tool(title="Get Process Skeleton Prompt", output_schema=None) def get_process_skeleton_prompt() -> str: """Return the authoring prompt for designing a new workflow. @@ -199,24 +213,54 @@ def get_process_skeleton_prompt() -> str: - Pass 2: once the user approves, generate all step playbooks silently (no further tool calls needed) and call save_workflow with everything. """ - return _WORKFLOW_SKELETON_MD.read_text(encoding="utf-8") + _library_block() + return _WORKFLOW_SKELETON_MD.read_text(encoding="utf-8") @mcp.tool(title="Save Workflow") -def save_workflow(skeleton: dict, playbooks_by_step: dict) -> dict: +def save_workflow( + skeleton: dict, + playbooks_by_step: dict, + workflow_playbook: str = "", + qa_playbooks_by_step: dict | None = None, + qa_enabled_steps: list | None = None, +) -> dict: """Persist a new workflow, its steps, and playbooks. Intended call sequence: 1. get_process_skeleton_prompt → work with user to produce and approve skeleton JSON. - 2. Generate all step playbooks silently using the Pass 2 instructions in that prompt. - 3. Call save_workflow(skeleton, playbooks_by_step) with all playbooks collected. - - skeleton: the workflow skeleton dict (name, description, steps[]). - playbooks_by_step: mapping of step name → playbook markdown string. + 2. Generate all step playbooks, QA playbooks, and the workflow playbook silently + using the Pass 2 instructions in that prompt. + 3. Call save_workflow with everything in a single call. + + skeleton: the workflow skeleton dict (name, description, steps[], optional fields[] and edges[]). + Fields example: [{"name": "draft_path", "description": "...", + "default_value": null, "required": false}] + Steps may include "sub_workflow_id": to embed another workflow as a step. + playbooks_by_step: mapping of step name → worker playbook markdown string. + Omit entries for sub-workflow steps (they have no playbook). + workflow_playbook: the workflow-level playbook markdown (Purpose / Input / Output sections). + Required for workflows that may be used as sub-workflow steps. + qa_playbooks_by_step: mapping of step name → QA playbook markdown string. + Always provide this for every non-sub-workflow step — QA playbooks + are always generated and stored. Whether they are active is controlled + separately by qa_enabled_steps. + qa_enabled_steps: list of step names whose QA gate should be active at runtime. + Steps not in this list get their QA playbook stored but disabled. + Omit (or pass null) to disable QA for all steps by default. + + IMPORTANT: call this tool exactly once with all playbooks ready — do not call it + multiple times to insert playbooks one by one. After saving, always show the user the monitoring_url from the response. """ - result = db.save_workflow(_cfg, skeleton, playbooks_by_step) + result = db.save_workflow( + _cfg, + skeleton, + playbooks_by_step, + workflow_playbook or None, + qa_playbooks_by_step, + qa_enabled_steps, + ) workflow_id = result.pop("id", None) for step in result.get("steps", []): step.pop("id", None) @@ -236,15 +280,23 @@ def list_workflows() -> dict: @mcp.tool(title="Get Workflow") -def get_workflow(workflow_id: int) -> dict: - """Return a workflow's full detail: steps (with order, playbook) and edges. +def get_workflow(workflow_id: int, include_fields: bool = False) -> dict: + """Return a workflow's full detail: steps (with order, playbook), edges, and optionally fields. Use this as a read step before calling add_step, edit_step, or delete_step so you have the current step IDs, order values, and playbook content. + + Set include_fields=True when you only need the field schema (e.g. before + calling save_workflow_field or delete_workflow_field) and want to skip + the full step/edge data — the response will contain only {"fields": [...]}. + Do not surface the raw result to the user — the monitoring UI is the right place for humans to inspect workflows. """ - return db.get_workflow_with_playbooks(_cfg, workflow_id) + result = db.get_workflow_with_playbooks(_cfg, workflow_id) + if include_fields: + return {"fields": result["fields"]} + return result @mcp.tool(title="Add Step to Workflow") @@ -253,8 +305,8 @@ def add_step( name: str, order: int, playbook: str = "", - requires_approval: bool = False, reorder: bool = True, + sub_workflow_id: int = 0, ) -> dict: """Insert a new step into an existing workflow. @@ -272,6 +324,10 @@ def add_step( 4. The function rewires edges so the new step is connected to its immediate predecessor and successor by order. + sub_workflow_id: if non-zero, this step embeds another workflow. The step name + defaults to the referenced workflow's name. The referenced workflow + must have a playbook defined. Mutually exclusive with ``playbook``. + Returns the newly created step dict. """ return db.add_step_to_workflow( @@ -280,17 +336,39 @@ def add_step( name=name, order=order, playbook=playbook or None, - requires_approval=requires_approval, + sub_workflow_id=sub_workflow_id or None, reorder=reorder, ) +@mcp.tool(title="Edit Workflow Playbook") +def edit_workflow_playbook(workflow_id: int, playbook: str = "") -> dict: + """Set or replace the workflow-level playbook (Purpose / Input / Output). + + The workflow playbook is required for a workflow to be usable as a + sub-workflow step inside another workflow. + + 1. Call get_workflow(workflow_id) to read the current state. + 2. If the user seems unsure about the structure or content, omit `playbook` + (or pass an empty string) — this returns the authoring guide so you can + help them draft it before saving. + 3. Once the content is ready, show the user a preview and get confirmation + if changing an existing playbook. + 4. Call edit_workflow_playbook again with the final markdown content. + + Returns the authoring guide (when playbook is empty) or the updated workflow record. + """ + if not playbook: + return {"authoring_guide": _WORKFLOW_PLAYBOOK_MD.read_text()} + return db.update_workflow_playbook(_cfg, workflow_id, playbook) + + @mcp.tool(title="Edit Step") def edit_step( step_id: int, name: str | None = None, playbook: str | None = None, - requires_approval: bool | None = None, + edges: list | None = None, ) -> dict: """Update any combination of fields on an existing step. @@ -298,25 +376,51 @@ def edit_step( pass ``playbook`` as a markdown string — it is upserted so it works whether the step has a playbook already or not. + edges: optional list of outgoing-edge patches. Each entry is a dict with + ``to_step_id`` (int, required) and any of ``parallel`` (bool), + ``condition`` (dict|null), ``priority`` (int). Only the keys present + in each patch are updated; unknown ``to_step_id`` values are ignored. + Use get_workflow() first to find the correct to_step_id values. + Intended usage: - 1. Call get_workflow(workflow_id) to find the step ID and current values. - 2. Before calling this tool, show the user a plain-language summary of what + 1. Call get_workflow(workflow_id) to find the step ID, current values, and + outgoing edge to_step_ids. + 2. If updating the playbook and the user seems unsure about structure or + content, omit ``playbook`` on the first call — the response will include + an authoring guide. Read the existing playbook from get_workflow() first + if changing an existing one. + 3. Before calling this tool, show the user a plain-language summary of what will change — e.g. "I'll rename **Research** to **Background Research** and update its playbook." — and wait for their confirmation. - 3. Call edit_step with only the fields that need to change. + 4. Call edit_step with only the fields that need to change. - Returns a dict with the updated step and its current playbook. + Returns a dict with the updated step and its current playbook, or an + authoring_guide when called without a playbook. """ + if name is None and playbook is None and edges is None: + return {"authoring_guide": _WORKER_PLAYBOOK_MD.read_text()} updated_step = db.update_step( _cfg, step_id, name=name, - requires_approval=requires_approval, + edges=edges, ) updated_playbook = None if playbook is not None: updated_playbook = db.update_playbook(_cfg, step_id, playbook) - return {"step": updated_step, "playbook": updated_playbook} + workflow_id = updated_step["workflow_id"] + new_vars = db.unregistered_mentions(_cfg, workflow_id, playbook) + else: + new_vars = [] + result: dict = {"step": updated_step, "playbook": updated_playbook} + if new_vars: + result["new_field_variables"] = new_vars + result["instructions"] = ( + "The playbook references new @variables that are not yet workflow fields: " + f"{new_vars}. Call save_workflow_field for each one with a meaningful " + "description before finishing." + ) + return result @mcp.tool(title="Delete Step") @@ -340,6 +444,78 @@ def delete_step(step_id: int) -> dict: return {"deleted_step_id": step_id, "ok": True} +# --------------------------------------------------------------------------- +# Workflow state store tools +# --------------------------------------------------------------------------- + + +_UNSET = "__unset__" + + +@mcp.tool(title="Save Workflow Field") +def save_workflow_field( + workflow_id: int, + name: str, + description: str, + field_id: int | None = None, + default_value: str | None = _UNSET, # type: ignore[assignment] + required: bool | None = None, +) -> dict: + """Create or update a field in a workflow's state schema. + + To create: omit field_id (or pass null). workflow_id and name are required. + To update: pass field_id. Only non-null arguments are changed. + + name: slug matching ^[a-z][a-z0-9_]*$ + description: natural-language description for the agent + default_value: initial value when a task starts. Pass null to clear it; + omit entirely to leave it unchanged on update. + required: if True, this field must be provided when the workflow is used as a sub-workflow + + Use get_workflow(workflow_id, include_fields=True) to find field IDs before updating. + """ + dv = ... if default_value == _UNSET else default_value # type: ignore[assignment] + return db.save_workflow_field( + _cfg, + workflow_id, + name, + description, + field_id=field_id, + default_value=dv, + required=required, + ) + + +@mcp.tool(title="Delete Workflow Field") +def delete_workflow_field(field_id: int) -> dict: + """Delete a workflow field. Cascades to task_field_values and sub_workflow_field_mappings.""" + db.delete_workflow_field(_cfg, field_id) + return {"deleted_field_id": field_id, "ok": True} + + +@mcp.tool(title="Set Sub-Workflow Field Mappings") +def set_sub_workflow_field_mappings(step_id: int, mappings: list) -> dict: + """Map parent workflow state variables to a sub-workflow's required fields. + + step_id: the sub-workflow step's ID + mappings: list of {"parent_field": "", "sub_workflow_field": ""} + + When the task reaches this step, mapped parent values are copied into the + sub-workflow's state before its first step runs. + """ + return db.set_sub_workflow_field_mappings(_cfg, step_id, mappings) + + +@mcp.tool(title="Get Task State") +def get_task_state(task_id: int) -> dict: + """Return the current state store for a task. + + Returns {field_name: {value, description}} for all workflow fields. + A null value means the field has not been written yet. + """ + return db.get_task_state(_cfg, task_id) + + def run(cfg: Config | None = None, transport: str = "stdio", **transport_kwargs) -> None: """Run the MCP server. Blocks until the client disconnects (stdio) or killed (sse/http).""" global _cfg diff --git a/progi/models.py b/progi/models.py index adcdd33..262544b 100644 --- a/progi/models.py +++ b/progi/models.py @@ -20,6 +20,7 @@ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), sa.Column("name", sa.String(255), nullable=False), sa.Column("description", sa.Text), + sa.Column("playbook", sa.Text, nullable=True), sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), nullable=False), ) @@ -36,15 +37,25 @@ # "order" is a SQL keyword; SQLAlchemy quotes it automatically. sa.Column("order", sa.Integer, nullable=False), sa.Column("name", sa.String(255), nullable=False), - # When True, the agent must present the step output to the user and get - # explicit approval before calling finish_step. - sa.Column("requires_approval", sa.Boolean, nullable=False, server_default="0"), sa.Column( "library_entry_id", sa.Integer, sa.ForeignKey("library_entries.id", ondelete="SET NULL"), nullable=True, ), + # When set, this step runs an entire sub-workflow instead of a playbook. + # ON DELETE RESTRICT prevents deleting a workflow that is used as a sub-workflow step. + sa.Column( + "sub_workflow_id", + sa.Integer, + sa.ForeignKey("workflows.id", ondelete="RESTRICT"), + nullable=True, + ), + # NULL = use the global default (QA_MAX_RETRIES in config / db layer). + # Set to 0 to disable QA retries entirely for this step (give_up on first failure). + sa.Column("max_qa_retries", sa.Integer, nullable=True), + # When False the QA playbook row exists (for editing) but the gate is skipped at runtime. + sa.Column("qa_enabled", sa.Boolean, nullable=False, server_default="0"), ) step_edges = sa.Table( @@ -86,9 +97,11 @@ sa.Integer, sa.ForeignKey("steps.id", ondelete="CASCADE"), nullable=False, - unique=True, ), + # "worker" = the main agent playbook; "qa" = the QA reviewer playbook. + sa.Column("type", sa.String(16), nullable=False, server_default="worker"), sa.Column("content", sa.Text, nullable=False), + sa.UniqueConstraint("step_id", "type", name="uq_playbooks_step_type"), ) library_entries = sa.Table( @@ -107,7 +120,6 @@ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), sa.Column("workflow_id", sa.Integer, sa.ForeignKey("workflows.id"), nullable=False), sa.Column("name", sa.String(255), nullable=False), - sa.Column("description", sa.Text), sa.Column("status", sa.String(32), nullable=False, server_default="todo"), # NULL when todo or done; set to the active step's id while in_progress sa.Column("current_step_id", sa.Integer, sa.ForeignKey("steps.id"), nullable=True), @@ -115,6 +127,70 @@ sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), nullable=False), ) +workflow_fields = sa.Table( + "workflow_fields", + metadata, + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "workflow_id", + sa.Integer, + sa.ForeignKey("workflows.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(128), nullable=False), # slug: ^[a-z][a-z0-9_]*$ + sa.Column("description", sa.Text, nullable=False), + sa.Column("default_value", sa.Text, nullable=True), + sa.Column("required", sa.Boolean, nullable=False, server_default="0"), + sa.UniqueConstraint("workflow_id", "name", name="uq_workflow_fields_wf_name"), +) + +sub_workflow_field_mappings = sa.Table( + "sub_workflow_field_mappings", + metadata, + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "step_id", + sa.Integer, + sa.ForeignKey("steps.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "parent_field_id", + sa.Integer, + sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sub_workflow_field_id", + sa.Integer, + sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), + nullable=False, + ), + sa.UniqueConstraint( + "step_id", "sub_workflow_field_id", name="uq_sub_wf_field_mappings" + ), +) + +task_field_values = sa.Table( + "task_field_values", + metadata, + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column( + "task_id", + sa.Integer, + sa.ForeignKey("tasks.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "field_id", + sa.Integer, + sa.ForeignKey("workflow_fields.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("value", sa.Text, nullable=True), # text content or file path; NULL = not yet written + sa.UniqueConstraint("task_id", "field_id", name="uq_task_field_values"), +) + step_instances = sa.Table( "step_instances", metadata, @@ -135,4 +211,18 @@ sa.Column("input_data", sa.JSON), sa.Column("output", sa.JSON), sa.Column("completed_at", sa.DateTime), + # When set, this instance was created as part of a sub-workflow expansion. + # Points to the step in the parent workflow that triggered the expansion. + sa.Column( + "sub_workflow_step_id", + sa.Integer, + sa.ForeignKey("steps.id"), + nullable=True, + ), + # QA gate tracking. null qa_status = no QA configured for this step. + # "pending" → QA sub-agent running; "passed" → advance; "failed" → worker retried. + sa.Column("qa_status", sa.String(16), nullable=True), + sa.Column("qa_output", sa.JSON, nullable=True), + # How many times this worker attempt has been sent back by QA. + sa.Column("qa_retry_count", sa.Integer, nullable=False, server_default="0"), ) diff --git a/progi/prompts/qa_playbook.md b/progi/prompts/qa_playbook.md new file mode 100644 index 0000000..5b1aaea --- /dev/null +++ b/progi/prompts/qa_playbook.md @@ -0,0 +1,57 @@ +# QA Reviewer — Base Instructions + +You are a **QA sub-agent**. Your job is to evaluate the output produced by a +worker agent for a single workflow step, then call `finish_qa` with your +verdict. You are **not** the worker — do not continue the task yourself. + +## Your inputs + +You will receive: +- **Worker output** — the output the worker agent produced for the step. +- **Task context** — completed steps so far (from `get_task_context_prompt`). +- **Step-specific QA playbook** — additional criteria defined for this step + (appended below these base instructions). If none is provided, use only these + base instructions. + +## Decision framework + +### Pass — call `finish_qa(task_id, passed=True, notes="...")` + +Pass when the output: +- Directly addresses what the step asked for. +- Is complete enough for the next step to proceed without ambiguity. +- Contains no factual errors, broken references, or missing required fields. + +A brief, actionable note is still helpful even on a pass (provide one sentence reasoning why it passed) + +### Send back to worker — call `finish_qa(task_id, passed=False, notes="...")` + +Send back when the output has a fixable problem: it is incomplete, incorrect, +ambiguous, or missing a required element that the worker *could and should* have +produced. Your `notes` must be **specific and actionable** — tell the worker +exactly what is wrong and what to do differently. Vague notes like "improve +quality" are not acceptable. + +### Give up — call `finish_qa(task_id, passed=False, give_up=True, notes="...")` + +Give up when: +- The output is fundamentally broken and cannot be salvaged by the worker + (e.g. the task is impossible given the inputs, a required external resource + is unavailable, or the step definition itself is contradictory). +- You have already reviewed a retry and the same fundamental problem persists + despite specific feedback. + +Giving up marks the task as `failed`. Only do this when continuing would be +pointless. + +## Rules + +- **Call `finish_qa` exactly once.** Do not call any other work-loop tools + (`finish_step`, `start_or_continue_task`, etc.). +- **Do not do the work yourself.** If the output is missing something, send it + back — don't produce the missing piece and pass it through. +- **Be proportional.** Minor style issues or small omissions that don't block + the next step are not grounds for rejection. Reject only when the defect + matters. +- **Your notes are read by the worker agent**, not a human. Write clearly and + technically — reference specific fields, values, or sections that need fixing. diff --git a/progi/prompts/templates/tmpl_qa_playbook.md b/progi/prompts/templates/tmpl_qa_playbook.md new file mode 100644 index 0000000..01a71ac --- /dev/null +++ b/progi/prompts/templates/tmpl_qa_playbook.md @@ -0,0 +1,13 @@ +## What to evaluate + + + +## Pass criteria + +- +- + +## Reject if + +- +- \ No newline at end of file diff --git a/progi/prompts/templates/tmpl_worker_playbook.md b/progi/prompts/templates/tmpl_worker_playbook.md new file mode 100644 index 0000000..ac8f723 --- /dev/null +++ b/progi/prompts/templates/tmpl_worker_playbook.md @@ -0,0 +1,7 @@ +## Instructions +1. +1. +1. + +## Human involvement +Every point where a human must act or approve something, and what specifically they need to review. If none, state "None — proceed autonomously." diff --git a/progi/prompts/templates/tmpl_workflow_playbook.md b/progi/prompts/templates/tmpl_workflow_playbook.md new file mode 100644 index 0000000..4232352 --- /dev/null +++ b/progi/prompts/templates/tmpl_workflow_playbook.md @@ -0,0 +1,2 @@ +## Purpose +One or two sentences describing what this workflow accomplishes. diff --git a/progi/prompts/worker_playbook.md b/progi/prompts/worker_playbook.md new file mode 100644 index 0000000..4aaf394 --- /dev/null +++ b/progi/prompts/worker_playbook.md @@ -0,0 +1,77 @@ +# Worker Playbook + +A worker playbook is a short document that instructs the worker agent on what to +do for a single workflow step. It is delivered to the agent via +`start_or_continue_task` when the step becomes active. + +## Structure + +Every worker playbook must contain exactly these two `##` sections, in this +order. No `#` (h1) heading. Subsections (`###`, `####`, etc.) are allowed within +each section as needed. + +### `## Instructions` + +A numbered list of concrete actions the agent takes to produce the deliverable. +Use the repeating-`1.` format so items can be reordered freely: + +```md +1. +1. +1. +``` + +- Describe what inputs are available: the step receives the full workflow state — + all field values are accessible via `@field_name` syntax. Reference specific + fields the step needs (e.g. "Read the topic from `@topic`"). +- Describe the concrete work to do and the deliverable to produce. +- For steps with conditional outgoing edges, name the state field(s) those edges + reference (e.g. "`review_needed`: "True"/"False"") so the agent knows to + include them in its output. +- Name any workflow fields the step writes so the agent knows to include them in + its output dict. +- **Do not restate instructions that are already implied by earlier steps in the + same list.** If a step says "store X as `@field`", do not add a trailing line + like "Output X to workflow state" — storing *is* outputting to state. + +### `## Human involvement` + +Explicitly state every point where a human must act or approve something, and +what specifically they need to review. If no human involvement is needed, state +that clearly (e.g. "None — proceed autonomously."). + +> **Automatic file-writing protocol:** When this section is non-empty (i.e. +> human review is needed), the system automatically appends an instruction +> telling the agent to write its output to a file in the current working +> directory and present it to the user before completing the step. You do +> **not** need to include this instruction in the playbook — just describe +> *what* needs reviewing. + +## Style + +- **Instructions should be MECE** — Mutually Exclusive, Collectively Exhaustive. + Each instruction covers a distinct piece of the work (no overlap), and + together they cover everything the agent needs to do (nothing missing). +- **Decompose from first principles.** Break the step's goal down to its + fundamental, irreducible sub-tasks. Solve each piece, then compose the + solutions back into the full answer — verifying that the interfaces between + pieces line up. +- Address the agent in the second person. +- Keep playbooks short. One or two sentences per instruction is usually enough — + say what the agent needs to know to do the job, nothing more. Expand only when + a step has genuine complexity that would otherwise be ambiguous. +- When referring to workflow field names, format them as `@field_name` + (e.g. `@draft_path`, `@review_needed`). + +## Example + +```markdown +## Instructions +1. Read the topic from `@topic` and search for three to five authoritative + sources covering it. +1. Summarise key facts, statistics, and perspectives in a structured outline. + Write the outline to `@research_notes`. + +## Human involvement +None — proceed autonomously. +``` diff --git a/progi/prompts/workflow_playbook.md b/progi/prompts/workflow_playbook.md new file mode 100644 index 0000000..8dfc06a --- /dev/null +++ b/progi/prompts/workflow_playbook.md @@ -0,0 +1,44 @@ +# Workflow Playbook + +Progi has three kinds of playbook, each serving a different audience and purpose: + +- **Workflow playbook** *(this document)* — one per workflow; describes the + workflow as a whole for documentation and sub-workflow context. Authored via + `edit_workflow_playbook`. +- **Worker playbook** — one per step; instructs the worker agent on what to do. + Stored per step and delivered via `start_or_continue_task`. +- **QA playbook** — one per step (optional); instructs the QA sub-agent on how + to evaluate the worker's output before the task advances. + +--- + +A workflow playbook is a short document that describes what a workflow does, +what it needs to start, and what it produces when done. It is **authored by the +user** (via the MCP tool `edit_workflow_playbook`) and is stored alongside the +workflow definition. + +The workflow playbook serves two roles: + +1. **Documentation** — visible on the workflow detail page so humans understand + the workflow's purpose at a glance. +2. **Sub-workflow context** — when this workflow is embedded as a step inside + another workflow, the executing sub-agent reads this playbook to orient itself + before working through the steps. + +## Structure + +Every workflow playbook must contain exactly one `##` section. No `#` (h1) heading. +Subsections (`###`, `####`) are allowed within the section. + +### `## Purpose` +One or two sentences describing what this workflow accomplishes. Be concrete — "Produces +a publication-ready blog post from a topic idea" is better than "Handles blog +post creation". Do not list or summarize the steps. + +## Example + +```markdown +## Purpose +Produces a publication-ready blog post from a topic idea, covering research, +drafting, editing, and publishing. +``` diff --git a/progi/prompts/workflow_skeleton.md b/progi/prompts/workflow_skeleton.md index 5ecb1da..af42378 100644 --- a/progi/prompts/workflow_skeleton.md +++ b/progi/prompts/workflow_skeleton.md @@ -17,49 +17,72 @@ that connect them. skeleton. Confirm the step list and branching logic with the user before finalizing — playbooks are written against this structure, so it must be right first. -3. When a step has conditional outgoing edges, the step's playbook will need to +3. **Ask the user which steps (if any) require human review**, and specifically + what output needs to be reviewed at each such step. Capture this per-step so + it can be written into the `## Human involvement` section of each worker + playbook in Pass 2. +4. When a step has conditional outgoing edges, the step's playbook will need to produce an output dict containing the field(s) used by those conditions. Keep this in mind when naming steps and edges. -4. Produce the skeleton as a single JSON object (see schema below). +5. Produce the skeleton as a single JSON object (see schema below). ## Output schema -Return exactly one JSON object of this shape: +Produce the skeleton as a single JSON object. Full example: ```json { "name": "Blog Post", - "description": "Workflow for researching, writing, editing, and publishing a blog post.", + "description": "Research, draft, edit, and publish.", + "fields": [ + {"name": "draft_path", "description": "Path to draft .md file"}, + {"name": "topic", "description": "Blog post topic", "default_value": null, "required": false} + ], "steps": [ {"order": 1, "name": "Research"}, - {"order": 2, "name": "Outline"}, - {"order": 3, "name": "Draft"} + {"order": 2, "name": "Draft"}, + {"order": 3, "name": "Edit"}, + {"order": 4, "name": "Publish"} ], + "edges": [ - {"from": "Research", "to": "Outline", "condition": null, "priority": 0}, - {"from": "Outline", "to": "Draft", "condition": null, "priority": 0} + {"from": "Research", "to": "Draft", "condition": null, "priority": 0}, + {"from": "Draft", "to": "Edit", "condition": null, "priority": 0}, + {"from": "Edit", "to": "Publish", "condition": null, "priority": 0} ] } ``` -For a branching workflow, the edges express the routing logic: +### Workflow fields (shared state) -```json -{ - "name": "Content Review", - "description": "Write and review before publishing, with an optional fast-track.", - "steps": [ - {"order": 1, "name": "Draft"}, - {"order": 2, "name": "Edit"}, - {"order": 3, "name": "Publish"} - ], - "edges": [ - {"from": "Draft", "to": "Edit", "condition": {"field": "review_needed", "operator": "eq", "value": true}, "priority": 0}, - {"from": "Draft", "to": "Publish", "condition": {"field": "review_needed", "operator": "eq", "value": false}, "priority": 1}, - {"from": "Edit", "to": "Publish", "condition": null, "priority": 0} - ] -} -``` +Every workflow defines **named fields** — shared state that persists across the +task's lifetime. All fields are accessible to all steps via `@field_name` syntax +in playbook text. Any output keys matching a field name automatically update the +workflow state when a step completes. + +- `name`: slug matching `^[a-z][a-z0-9_]*$` +- `description`: natural-language hint for the agent +- `default_value`: optional default value for the field (populated when a task starts) +- `required`: boolean; meaningful when the workflow is used as a sub-workflow — + required fields must be mapped from the parent workflow's state + +Steps do not declare reads or writes. The full workflow state is available to +every step, and any step can update any field by including it as a key in its +output. + +Only define fields that cross step boundaries. + +> **Tip — collecting user input at the start:** +> If a workflow needs user-provided input at the very start (e.g. a topic, a URL, +> a file path), model it as a dedicated first step whose purpose is to collect +> that input from the user and write it to state — so all subsequent steps can +> reference it via `@field_name`. + +### Sub-workflow steps + +**Only add a sub-workflow step when the user explicitly asks for one and names the workflow (by name or id).** Do not infer or suggest sub-workflow embedding on your own. + +When the user does ask: set `"sub_workflow_id"` to the referenced workflow's integer id on the step, and omit that step from `playbooks_by_step`. The referenced workflow must have a workflow playbook defined (`playbook` non-null in `list_workflows()`); if it does not, ask the user to add one via `edit_workflow_playbook` before proceeding. ### Field rules @@ -67,16 +90,17 @@ For a branching workflow, the edges express the routing logic: determine execution order (edges do). Use sequential 1-based integers. - Keep steps coarse enough to be meaningful deliverables (3–6 steps is typical), not micro-tasks. -- **Data flow**: the first step automatically receives `input_data.value` = the - task's description/topic (whatever the user provided when creating the task). - Every subsequent step automatically receives `input_data.value` = the previous - step's output value. The playbook for each step describes how to use it. +- **Data flow**: every step receives the full workflow state (all fields and + their current values) at activation. Steps update state by including matching + field names as keys in their output. - `edges` — list of transitions. Each edge has: - `from` — name of the source step - `to` — name of the destination step - `condition` — `null` for an unconditional edge, or - `{"field": "", "operator": "", "value": }` for a - conditional edge. Operators: `eq`, `neq`, `in`, `not_in`. + `{"field": "", "operator": "", "value": }` for a + conditional edge. The `field` references a workflow state field name. + Operators: `eq`, `neq`, `in`, `not_in`. Note: state values are stored as + strings, so edge condition values should also be strings (e.g. `"True"` not `true`). - `priority` — integer; when a step has multiple outgoing edges, they are evaluated in ascending priority order and the first matching one is taken. Use 0, 1, 2 … Keep at least one unconditional (`null`) edge as a fallback @@ -111,46 +135,112 @@ For a branching workflow, the edges express the routing logic: ## After the user approves the skeleton -Once the user approves the skeleton JSON, generate all step playbooks silently -(no user interaction needed for this — the Pass 2 instructions are below). -Then call `save_workflow` with the skeleton and the completed playbooks map. +Once the user approves the skeleton JSON, generate the workflow playbook, all +worker playbooks, and **all QA playbooks** silently in one pass (no further user +interaction). Then call `save_workflow` **exactly once** with everything: +skeleton, playbooks_by_step, workflow_playbook, and qa_playbooks_by_step (every +non-sub-workflow step). Do not pass `qa_enabled_steps` — QA gates are disabled +by default and the user must explicitly request enablement separately. **Do not output the skeleton JSON to the user.** The JSON is an internal artifact for tool calls only. Acknowledge approval briefly, generate playbooks -silently, then call `save_workflow`. +silently, then call `save_workflow` once. --- # Pass 2: Playbook Authoring -You are authoring the **playbook** for each step of the workflow you just -designed. A playbook is one self-contained markdown document that the AI -**agent** (the assistant inside the user's harness — Claude Code, Cursor, etc.) -will follow to perform that step at runtime. +You are authoring playbooks for the workflow you just designed. There are three +kinds of playbook: + +**1. Workflow playbook** — one per workflow; describes the workflow as a whole. +Pass this as the `workflow_playbook` argument to `save_workflow`. Required +structure (one `##` section, no `#` h1): + +```md +## Purpose +One or two sentences describing what this workflow accomplishes. Be concrete — "Produces +a publication-ready blog post from a topic idea" is better than "Handles blog +post creation". Do not list or summarize the steps. +``` -For each step in the skeleton, write a playbook. Collect all playbooks into the -`playbooks_by_step` map (step name → markdown string) and pass them to -`save_workflow`. +**2. Worker playbook** — one per regular step; instructs the worker agent on +what to do. Sub-workflow steps have no worker playbook (omit them from +`playbooks_by_step`). Collect all worker playbooks into the `playbooks_by_step` +map (step name → markdown string) and pass them to `save_workflow`. -## Playbook structure +**3. QA playbook** — optional, one per step where you want a quality gate. +Collect into `qa_playbooks_by_step` (step name → markdown string). -Every playbook must contain exactly these four `##` sections, in this order. No `#` (h1) heading. Subsections (`###`, `####`, etc.) are allowed within each section as needed. +Call `save_workflow(skeleton, playbooks_by_step, workflow_playbook, qa_playbooks_by_step)` +once all playbooks are ready. Omit `qa_playbooks_by_step` (or pass `null`) if no +steps need a QA gate. -### `## Input` -Describe what the step starts from. -- For the **first step**: `input_data.value` contains the task description/topic the user provided at task creation. Say what to do with it (e.g. ask the user for clarification if needed). -- For **all subsequent steps**: `input_data.value` contains the previous step's output (typically a file path or URL). Say where to find it and how to use it. +`qa_playbooks_by_step` controls both content **and** activation: steps included in the +map get a real QA playbook and have QA **enabled** at runtime. Steps omitted from the +map get a blank template inserted automatically and have QA **disabled** — the template +is editable in the UI later but the gate won't fire during task execution. + +## Worker playbook structure + +Every worker playbook must contain exactly these two `##` sections, in this order. No `#` (h1) heading. Subsections (`###`, `####`, etc.) are allowed within each section as needed. ### `## Instructions` -The concrete actions the agent takes to produce the deliverable. Be specific and actionable. +A numbered list of concrete actions the agent takes to produce the deliverable. Use the repeating-`1.` format, so items can be reordered freely: + +```md +1. +1. +1. +``` + +- Describe what inputs are available: the step receives the full workflow state — all field values are accessible via `@field_name` syntax. Reference specific fields the step needs (e.g. "Read the topic from `@topic`"). +- Describe the concrete work to do and the deliverable to produce. +- For steps with conditional outgoing edges, name the state field(s) those edges reference (e.g. "`review_needed`: "True"/"False"") so the agent knows to include them in its output. +- Name any workflow fields the step writes so the agent knows to include them in its output dict. +- **Do not restate instructions that are already implied by earlier steps in the same list.** If a step says "store X as `@field`", do not add a trailing line like "Output X to workflow state" — storing *is* outputting to state. Each instruction must carry distinct meaning. ### `## Human involvement` -Explicitly state every point where a human must act or approve something, and what they need to do. If no human involvement is needed, state that clearly (e.g. "None — proceed autonomously."). +Explicitly state every point where a human must act or approve something, and what specifically they need to review. If no human involvement is needed, state that clearly (e.g. "None — proceed autonomously."). -### `## Output` -Exactly what deliverable the step produces, what format, and how the agent reports it back via `finish_step`. For steps with conditional outgoing edges, explicitly list the output dict fields that edge conditions reference (e.g. "`review_needed`: true/false"). +> **Automatic file-writing protocol:** When this section is non-empty (i.e. human review is needed), the system automatically appends an instruction telling the agent to write its output to a file in the current working directory and present it to the user before completing the step. You do **not** need to include this instruction in the playbook — just describe *what* needs reviewing. ## Style +- **Instructions should be MECE** — Mutually Exclusive, Collectively Exhaustive. Each instruction covers a distinct piece of the work (no overlap), and together they cover everything the agent needs to do (nothing missing). +- **Decompose from first principles.** Break the step's goal down to its fundamental, irreducible sub-tasks. Solve each piece, then compose the solutions back into the full answer — verifying that the interfaces between pieces line up. - Address the agent in the second person. -- Be specific and actionable; no fluff. +- Keep playbooks short. One or two sentences per instruction is usually enough — say what the agent needs to know to do the job, nothing more. Expand only when a step has genuine complexity that would otherwise be ambiguous. - Keep each playbook to a single markdown document — no separate files. +- When referring to workflow field names in any playbook text, format them as `@field_name` (e.g. `@draft_path`, `@review_needed`). This applies to both worker and QA playbooks. + +--- + +## QA playbooks + +Write a QA playbook for **every non-sub-workflow step** and include all of them +in `qa_playbooks_by_step`. Do not ask the user — just write them all silently. + +Which steps have QA **enabled** at runtime is already known from the Pass 1 +question: use that answer for `qa_enabled_steps`. Steps not named there get their +playbook stored but the gate disabled. + +**QA playbook structure:** + +```md +## What to evaluate + + + +## Pass criteria + +- +- + +## Reject if + +- +- +``` + +Keep QA criteria concrete and checkable. Focus only on what is specific to this +step — what the output should contain, what counts as acceptable, what doesn't. diff --git a/progi/seed.py b/progi/seed.py index 0db0501..55f4e98 100644 --- a/progi/seed.py +++ b/progi/seed.py @@ -13,6 +13,12 @@ BLOG_POST_SKELETON: dict = { "name": "Blog Post", "description": "Workflow for researching, writing, editing, and publishing a blog post.", + "fields": [ + {"name": "research_notes", "description": "Path to research notes markdown file"}, + {"name": "outline", "description": "Path to approved outline markdown file"}, + {"name": "draft", "description": "Path to first draft markdown file"}, + {"name": "edited_post", "description": "Path to final edited post markdown file"}, + ], "process": [ {"order": 1, "name": "Research"}, {"order": 2, "name": "Outline"}, @@ -23,104 +29,67 @@ } PLAYBOOKS: dict[str, str] = { - "Research": """## Input + "Research": """## Instructions -`input_data.value` — the topic and any initial notes the user provided when creating the task. +Ask the user: "What is the exact topic and target audience? Do you have any reference links or key points you want included?" Wait for their reply. -## Instructions +Gather information from the provided links plus your own knowledge. Identify 5-8 key points or angles most relevant to the target audience. Assess source credibility and note any conflicting information. -1. Ask the user: "What is the exact topic and target audience? Do you have any reference links or key points you want included?" -2. Wait for the user's reply. -3. Gather information from the provided links plus your own knowledge. Identify 5-8 key points or angles most relevant to the target audience. -4. Assess source credibility; note any conflicting information. -5. Save research notes to `research.md`. The file must list the main talking points with short explanations, cite sources with URLs where applicable, and note any open questions or gaps. +Save research notes to `research.md` (list of main talking points with short explanations, sources with URLs, and open questions). Call `finish_step` with `{"research_notes": "research.md"}`. ## Human involvement -None required beyond the initial clarification in step 1. +One upfront clarification question. No further approval needed.""", -## Output + "Outline": """## Instructions -`research.md` in the working directory. Report back that it is ready once saved.""", - "Outline": """## Input +`@research_notes` contains the path to the research notes. Read that file fully. -`input_data.value` — the file path to the research notes from the previous step (typically `research.md`). +Ask the user: "What is the desired post length (short ~500 w, medium ~1000 w, long ~2000 w)? Any sections that must or must not be included?" Wait for their reply. -## Instructions +Group related points into 3-6 sections, order them for logical flow, and write a one-sentence summary of each section. Save to `outline.md` and present it to the user. -1. Ask the user: "What is the desired post length (short ~500 w, medium ~1000 w, long ~2000 w)? Any sections that must or must not be included?" Wait for their reply. -2. Read `research.md` fully. -3. Group related points into 3-6 sections. -4. Order sections for logical flow (context → problem → solution → conclusion, or similar). -5. Write a one-sentence summary of each section. -6. Save the outline to `outline.md` and present it to the user. +Call `finish_step` with `{"outline": "outline.md"}` once the user approves. ## Human involvement -The user must approve the outline structure before the step is complete. Ask: "Does this structure look right, or would you like to move/remove/add any sections?" Iterate until the user approves. +One upfront length/structure question. User must approve the outline before finishing — iterate until they do.""", -## Output + "Draft": """## Instructions -`outline.md` — the approved outline. Report that it is ready once the user approves.""", - "Draft": """## Input +`@outline` contains the path to the approved outline. Read that file fully. -`input_data.value` — the file path to the approved outline from the previous step (typically `outline.md`). +Ask the user: "What tone should the post have (casual, technical, formal)? Any specific phrasing or terminology to use or avoid?" Wait for their reply. -## Instructions +Follow the outline's section structure exactly. Write full prose for each section — include a working title and short intro paragraph. Aim for the length implied by the outline scope. Write the full draft autonomously with no mid-draft check-ins. Save to `draft.md`. -1. Ask the user in one message: "What tone should the post have (casual, technical, formal)? Any specific phrasing or terminology to use or avoid?" Wait for their reply. -2. Follow the outline's section structure exactly. -3. Write full prose for each section — vary sentence length, avoid filler phrases. -4. Include a working title and a short intro paragraph. -5. Aim for the length implied by the Outline step's approved scope. -6. Write the full draft autonomously with no mid-draft check-ins. -7. Save the draft to `draft.md`. +Call `finish_step` with `{"draft": "draft.md"}`. ## Human involvement -One upfront question (tone/terminology) before writing. No approval needed at this stage — review happens in the Edit step. - -## Output +One upfront tone question. No approval needed at this stage — review happens in Edit.""", -`draft.md` — the first draft. Report that it is ready.""", - "Edit": """## Input + "Edit": """## Instructions -`input_data.value` — the file path to the first draft from the previous step (typically `draft.md`). +`@draft` contains the path to the first draft. Read it end-to-end before making any changes. -## Instructions +Fix grammar, punctuation, and spelling. Improve sentence flow: split run-ons, vary structure, remove redundancy. Verify the opening paragraph hooks the reader and the conclusion is clear. Ensure headings are consistent and match the outline. Edit autonomously. -1. Read the draft end-to-end before making any changes. -2. Fix grammar, punctuation, and spelling errors. -3. Improve sentence flow: split run-ons, vary structure, remove redundancy. -4. Verify the opening paragraph hooks the reader and the conclusion is clear. -5. Ensure headings are consistent and match the outline. -6. Edit autonomously with no mid-edit check-ins. -7. Present the revised post to the user. +Present the revised post to the user. Call `finish_step` with `{"edited_post": "edited_post.md"}` once they approve. ## Human involvement -The user must approve the edited post before the step is complete. Ask: "Here is the edited draft. Does it read well, or are there any sections you would like adjusted?" Iterate until the user approves. - -## Output - -`edited_post.md` — the final approved post. Report that it is ready once the user approves.""", - "Publish": """## Input +User must approve the edited post — iterate until they do.""", -`input_data.value` — the file path to the final edited post from the previous step (typically `edited_post.md`). + "Publish": """## Instructions -## Instructions +`@edited_post` contains the path to the final post. Ask the user: "Where should this post be published (CMS name / URL, or shall I walk you through it)?" Follow their publishing workflow — copy/paste content, set metadata (title, tags, publish date) as directed. Confirm with the user once live. -1. Ask the user: "Where should this post be published (CMS name / URL, or shall I walk you through it)?" -2. Follow the user's publishing workflow — copy/paste content, set metadata (title, tags, publish date) as directed. -3. Confirm with the user once the post is live. +Call `finish_step` with `{"value": ""}`. ## Human involvement -The user must provide the publishing destination and confirm the post is live. Ask them to confirm the public URL once published. - -## Output - -The public URL of the published post. Report it back — this is the step's deliverable.""", +User must provide the publishing destination and confirm the post is live.""", } @@ -130,6 +99,12 @@ "Write a draft, then either fast-track to publish (no review needed) " "or deep-edit before publishing." ), + "fields": [ + {"name": "draft", "description": "Path to the draft markdown file"}, + {"name": "edited", "description": "Path to the edited markdown file"}, + {"name": "published_url", "description": "Public URL of the published post"}, + {"name": "review_needed", "description": "Whether the draft needs editorial review"}, + ], "process": [ {"order": 1, "name": "Draft"}, {"order": 2, "name": "Quick Publish"}, @@ -142,13 +117,13 @@ { "from": "Draft", "to": "Quick Publish", - "condition": {"field": "review_needed", "operator": "eq", "value": False}, + "condition": {"field": "review_needed", "operator": "eq", "value": "False"}, "priority": 0, }, { "from": "Draft", "to": "Deep Edit", - "condition": {"field": "review_needed", "operator": "eq", "value": True}, + "condition": {"field": "review_needed", "operator": "eq", "value": "True"}, "priority": 1, }, { @@ -162,77 +137,45 @@ } CONTENT_REVIEW_PLAYBOOKS: dict[str, str] = { - "Draft": """## Input - -`input_data.value` — the topic and any initial notes the user provided when creating the task. + "Draft": """## Instructions -## Instructions +Write a draft on the topic in `task.description`. Save it to `draft.md`. -1. Write a draft on the given topic. -2. At the end, decide whether the content needs editorial review before publishing. -3. Save the draft to `draft.md`. +At the end, decide whether the content needs editorial review before publishing. Call `finish_step` with `{"draft": "draft.md", "review_needed": true}` or `{"draft": "draft.md", "review_needed": false}`. ## Human involvement -None required unless the topic is ambiguous — ask for clarification upfront if needed. +None required unless the topic is ambiguous — ask for clarification upfront if needed.""", -## Output + "Quick Publish": """## Instructions -Submit a dict with: -- `value`: path to the draft file (e.g. `draft.md`) -- `review_needed`: `true` if editorial review is required, `false` to fast-track -""", - "Quick Publish": """## Input +`@draft` contains the draft file path. Publish it directly without editorial review. -`input_data.value` — the draft file path. - -## Instructions - -Publish the draft directly without editorial review. +Ask the user for the publishing destination if not already known. Confirm the post is live before finishing. Call `finish_step` with `{"published_url": ""}`. ## Human involvement -Ask the user for the publishing destination if not already known. Confirm the post is live before finishing. - -## Output - -Submit `{"value": ""}`. -""", - "Deep Edit": """## Input +Ask the user for the publishing destination. Confirm the post is live.""", -`input_data.value` — the draft file path. + "Deep Edit": """## Instructions -## Instructions +`@draft` contains the draft file path. Read it end-to-end. Fix grammar, punctuation, and spelling. Improve sentence flow and remove redundancy. Save the polished version to `edited.md`. -1. Read the draft end-to-end. -2. Fix grammar, punctuation, and spelling errors. -3. Improve sentence flow and remove redundancy. -4. Save the polished version to `edited.md`. +Call `finish_step` with `{"edited": "edited.md"}`. ## Human involvement -None required — edit autonomously. +None — edit autonomously.""", -## Output + "Publish": """## Instructions -Submit `{"value": "edited.md"}`. -""", - "Publish": """## Input +`@edited` contains the edited file path. Publish it to the target destination. -`input_data.value` — the edited file path. - -## Instructions - -Publish the edited document to the target destination. +Ask the user for the publishing destination if not already known. Confirm the post is live before finishing. Call `finish_step` with `{"published_url": ""}`. ## Human involvement -Ask the user for the publishing destination if not already known. Confirm the post is live before finishing. - -## Output - -Submit `{"value": ""}`. -""", +Ask the user for the publishing destination. Confirm the post is live.""", } @@ -245,13 +188,7 @@ def seed(cfg: Config | None = None) -> bool: if BLOG_POST_SKELETON["name"] not in existing: workflow = db.save_workflow(cfg, BLOG_POST_SKELETON, PLAYBOOKS) - db.create_task( - cfg, - "Introduction to ScyllaDB for Developers", - workflow["id"], - "A beginner-friendly blog post explaining what ScyllaDB is, how it " - "compares to Cassandra, and when to use it.", - ) + db.create_task(cfg, "Introduction to ScyllaDB for Developers", workflow["id"]) created_any = True if CONTENT_REVIEW_SKELETON["name"] not in existing: diff --git a/progi/web/routers/workflows.py b/progi/web/routers/workflows.py index 3d42442..1eda647 100644 --- a/progi/web/routers/workflows.py +++ b/progi/web/routers/workflows.py @@ -3,6 +3,7 @@ import json from fastapi import APIRouter, Body, HTTPException, Request +from sqlalchemy.exc import IntegrityError from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.templating import Jinja2Templates @@ -117,6 +118,25 @@ def delete_workflow(workflow_id: int, request: Request): cfg = request.app.state.cfg try: db.delete_workflow(cfg, workflow_id) + except ValueError as exc: + msg = str(exc) + status = 409 if msg.startswith("Cannot delete") else 404 + raise HTTPException(status_code=status, detail=msg) + except IntegrityError: + raise HTTPException(status_code=409, detail="Cannot delete: this workflow is referenced by another workflow's steps.") + return Response(status_code=204) + + +@router.patch("/workflows/{workflow_id}/playbook", status_code=204) +def update_workflow_playbook( + workflow_id: int, + request: Request, + payload: dict = Body(...), +): + cfg = request.app.state.cfg + playbook = payload.get("playbook", "") + try: + db.update_workflow_playbook(cfg, workflow_id, playbook) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) return Response(status_code=204) @@ -132,12 +152,110 @@ def update_step( cfg = request.app.state.cfg try: if "playbook" in payload: - db.update_playbook(cfg, step_id, payload.pop("playbook")) + db.update_playbook(cfg, step_id, payload.pop("playbook"), type="worker") + _sentinel = object() + qa_playbook = payload.pop("qa_playbook", _sentinel) + if qa_playbook is _sentinel: + qa_playbook = payload.pop("qaPlaybook", _sentinel) + if qa_playbook is not _sentinel: + db.update_playbook(cfg, step_id, qa_playbook, type="qa") step_fields = { - k: v for k, v in payload.items() if k in ("name",) + k: v for k, v in payload.items() if k in ("name", "qa_enabled") } if step_fields: db.update_step(cfg, step_id, **step_fields) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) return Response(status_code=204) + + +# --------------------------------------------------------------------------- +# Workflow state store routes +# --------------------------------------------------------------------------- + + +@router.get("/workflows/{workflow_id}/fields", response_class=JSONResponse) +def list_workflow_fields(workflow_id: int, request: Request): + cfg = request.app.state.cfg + return {"fields": db.list_workflow_fields(cfg, workflow_id)} + + +@router.post("/workflows/{workflow_id}/fields", response_class=JSONResponse, status_code=201) +def create_workflow_field(workflow_id: int, request: Request, payload: dict = Body(...)): + cfg = request.app.state.cfg + try: + field = db.create_workflow_field( + cfg, + workflow_id, + name=payload.get("name", ""), + description=payload.get("description", ""), + default_value=payload.get("default_value"), + required=bool(payload.get("required", False)), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return field + + +@router.patch("/workflows/{workflow_id}/fields/{field_id}", response_class=JSONResponse) +def update_workflow_field( + workflow_id: int, # noqa: ARG001 + field_id: int, + request: Request, + payload: dict = Body(...), +): + cfg = request.app.state.cfg + kwargs: dict = {} + if "name" in payload: + kwargs["name"] = payload["name"] + if "description" in payload: + kwargs["description"] = payload["description"] + if "default_value" in payload: + kwargs["default_value"] = payload["default_value"] + if "required" in payload: + kwargs["required"] = payload["required"] + try: + field = db.update_workflow_field(cfg, field_id, **kwargs) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return field + + +@router.delete("/workflows/{workflow_id}/fields/{field_id}", status_code=204) +def delete_workflow_field( + workflow_id: int, # noqa: ARG001 + field_id: int, + request: Request, +): + cfg = request.app.state.cfg + db.delete_workflow_field(cfg, field_id) + return Response(status_code=204) + + +@router.patch("/workflows/{workflow_id}/steps/{step_id}/field-mappings", response_class=JSONResponse) +def set_field_mappings( + workflow_id: int, # noqa: ARG001 + step_id: int, + request: Request, + payload: dict = Body(...), +): + cfg = request.app.state.cfg + try: + result = db.set_sub_workflow_field_mappings( + cfg, + step_id, + mappings=payload.get("mappings", []), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return result + + +@router.get("/tasks/{task_id}/state", response_class=JSONResponse) +def get_task_state(task_id: int, request: Request): + cfg = request.app.state.cfg + try: + state = db.get_task_state(cfg, task_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + return state diff --git a/progi/web/static/app.js b/progi/web/static/app.js index 2ce8b5d..85f25ef 100644 --- a/progi/web/static/app.js +++ b/progi/web/static/app.js @@ -50,6 +50,34 @@ function taskDetail() { this.stepInstances = data.stepInstances; }, + get items() { + const reversed = [...this.stepInstances].reverse(); + const result = []; + const seenSubWf = new Set(); + for (const si of reversed) { + if (!si.sub_workflow_step_id) { + result.push({ type: 'step', si }); + } else { + const key = si.sub_workflow_step_id; + if (!seenSubWf.has(key)) { + seenSubWf.add(key); + const subs = this.stepInstances.filter(s => s.sub_workflow_step_id === key); + let status = 'complete'; + if (subs.some(s => s.status === 'active')) status = 'active'; + else if (!subs.every(s => s.status === 'complete')) status = 'pending'; + result.push({ + type: 'subwf', + key, + name: si.sub_workflow_name || 'Sub-workflow', + steps: subs, + status, + }); + } + } + } + return result.reverse(); + }, + async deleteTask(taskId) { if (!confirm('Delete this task permanently? This cannot be undone.')) return; const resp = await fetch(`/tasks/${taskId}`, { method: 'DELETE' }); @@ -67,6 +95,12 @@ function workflowEditor() { activeId: null, activeWorkflow: null, modalOpen: false, + playbookModalOpen: false, + fieldsModalOpen: false, + workflowFields: [], + fieldEdits: {}, + newField: { name: '', description: '', default_value: '', required: false, error: '' }, + showNewFieldForm: false, openMenuId: null, renamingId: null, renameValue: '', @@ -121,10 +155,32 @@ function workflowEditor() { history.replaceState(null, '', `/workflows/${this.activeId}`); }, + closePlaybookModal() { + this.playbookModalOpen = false; + this.playbookEdit = { editing: false, draft: '' }; + }, + + playbookEdit: { editing: false, draft: '' }, + + startPlaybookEdit() { + this.playbookEdit.draft = this.activeWorkflow.playbook || ''; + this.playbookEdit.editing = true; + }, + + cancelPlaybookEdit() { + this.playbookEdit.editing = false; + }, + + async savePlaybookEdit() { + const ok = await this.saveWorkflowPlaybook(this.activeId, this.playbookEdit.draft); + if (ok) this.playbookEdit.editing = false; + }, + async selectWorkflow(id) { if (this.activeId === id) return; this.activeId = id; this.modalOpen = false; + this.playbookModalOpen = false; history.pushState(null, '', `/workflows/${id}`); const resp = await fetch(`/workflows/${id}/graph`); @@ -182,6 +238,18 @@ function workflowEditor() { this.cancelRename(); }, + async saveWorkflowPlaybook(id, playbook) { + const resp = await fetch(`/workflows/${id}/playbook`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playbook }), + }); + if (resp.ok && this.activeWorkflow && this.activeWorkflow.id === id) { + this.activeWorkflow = { ...this.activeWorkflow, playbook }; + } + return resp.ok; + }, + async copyWorkflow(id) { const resp = await fetch(`/workflows/${id}/export`); if (!resp.ok) return; @@ -201,6 +269,9 @@ function workflowEditor() { document.getElementById('mermaid-container').innerHTML = ''; history.replaceState(null, '', '/workflows'); } + } else { + const err = await resp.json().catch(() => ({})); + alert(err.detail || 'Failed to delete workflow.'); } this.openMenuId = null; }, @@ -220,7 +291,7 @@ function workflowEditor() { const scale = Math.min( (container.clientWidth - padding) / diagramW, (container.clientHeight - padding) / diagramH, - 1 + 2 ); this.zoom = scale; this.panX = (container.clientWidth - diagramW * scale) / 2; @@ -295,6 +366,98 @@ function workflowEditor() { if (svg) svg.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`; }, + async openPlaybookModal() { + const resp = await fetch(`/workflows/${this.activeId}/fields`); + if (resp.ok) { + const data = await resp.json(); + this.workflowFields = data.fields || []; + this.fieldEdits = {}; + this.newField = { name: '', type: 'text', description: '', default_value: '', required: false, error: '' }; + this.showNewFieldForm = false; + } + this.playbookModalOpen = true; + }, + + async openFieldsModal() { + const resp = await fetch(`/workflows/${this.activeId}/fields`); + if (resp.ok) { + const data = await resp.json(); + this.workflowFields = data.fields || []; + this.fieldEdits = {}; + this.newField = { name: '', type: 'text', description: '', default_value: '', required: false, error: '' }; + } + this.fieldsModalOpen = true; + }, + + startFieldEdit(field) { + this.fieldEdits = { + ...this.fieldEdits, + [field.id]: { editing: true, name: field.name, description: field.description, default_value: field.default_value || '', required: !!field.required }, + }; + }, + + cancelFieldEdit(fieldId) { + const edits = { ...this.fieldEdits }; + delete edits[fieldId]; + this.fieldEdits = edits; + }, + + async saveFieldEdit(fieldId) { + const edit = this.fieldEdits[fieldId]; + if (!edit) return; + const resp = await fetch(`/workflows/${this.activeId}/fields/${fieldId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: edit.name, description: edit.description, default_value: edit.default_value, required: edit.required }), + }); + if (resp.ok) { + const updated = await resp.json(); + this.workflowFields = this.workflowFields.map(f => f.id === fieldId ? updated : f); + // Update activeWorkflow.fields so step_detail data stays consistent + if (this.activeWorkflow) { + this.activeWorkflow = { + ...this.activeWorkflow, + fields: this.workflowFields, + }; + } + this.cancelFieldEdit(fieldId); + } + }, + + async saveNewField() { + const name = this.newField.name.trim(); + if (!name) return; + this.newField.error = ''; + const resp = await fetch(`/workflows/${this.activeId}/fields`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: this.newField.description.trim(), default_value: this.newField.default_value.trim() || null, required: this.newField.required }), + }); + if (resp.ok) { + const field = await resp.json(); + this.workflowFields = [...this.workflowFields, field]; + if (this.activeWorkflow) { + this.activeWorkflow = { ...this.activeWorkflow, fields: this.workflowFields }; + } + this.newField = { name: '', type: 'text', description: '', default_value: '', required: false, error: '' }; + this.showNewFieldForm = false; + } else { + const err = await resp.json().catch(() => ({})); + this.newField.error = err.detail || 'Failed to add field.'; + } + }, + + async deleteField(fieldId) { + if (!confirm('Delete this field? Any step declarations and task values for this field will also be removed.')) return; + const resp = await fetch(`/workflows/${this.activeId}/fields/${fieldId}`, { method: 'DELETE' }); + if (resp.ok) { + this.workflowFields = this.workflowFields.filter(f => f.id !== fieldId); + if (this.activeWorkflow) { + this.activeWorkflow = { ...this.activeWorkflow, fields: this.workflowFields }; + } + } + }, + async _renderMermaid(steps, edges) { const container = document.getElementById('mermaid-container'); container.innerHTML = ''; @@ -311,13 +474,32 @@ function workflowEditor() { // Build Mermaid flowchart definition (top-to-bottom) let def = 'flowchart TB\n'; + // Style sub-workflow nodes with a distinct accent colour + def += ' classDef subwf fill:#1a3a4a,stroke:#00a3ff,stroke-width:2px,color:#e0f4ff\n'; + // Add nodes — sanitize names for Mermaid IDs + // Sub-workflow steps use [[label]] (subroutine shape) to distinguish them visually + const subwfNodeIds = []; steps.forEach(s => { const nodeId = `step_${s.id}`; const label = escapeHtml(s.name); - def += ` ${nodeId}["${label}"]\n`; + + const fieldLine = s.mentioned_fields && s.mentioned_fields.length > 0 + ? `
${s.mentioned_fields.map(f => `@${f}`).join('
')}` + : ''; + + if (s.sub_workflow_id) { + def += ` ${nodeId}[["⤵ ${label}${fieldLine}"]]\n`; + subwfNodeIds.push(nodeId); + } else { + def += ` ${nodeId}["${label}${fieldLine}"]\n`; + } }); + if (subwfNodeIds.length > 0) { + def += ` class ${subwfNodeIds.join(',')} subwf\n`; + } + // 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 @@ -386,7 +568,7 @@ function workflowEditor() { const scale = Math.min( (containerW - padding) / diagramW, (containerH - padding) / diagramH, - 1 // never scale up beyond natural size + 2 // cap upscaling at 2× ); // Center the scaled diagram @@ -405,6 +587,38 @@ function workflowEditor() { el.addEventListener('click', () => { this.openStep(this.activeId, s.id); }); + + if (s.qa_enabled) { + // Find the node's bounding shape to place the badge in its corner + const shape = el.querySelector('rect,polygon,circle,ellipse'); + if (shape) { + const bbox = shape.getBBox(); + const PAD = 4; + const ns = 'http://www.w3.org/2000/svg'; + + const bg = document.createElementNS(ns, 'rect'); + bg.setAttribute('x', bbox.x + bbox.width - 20 - PAD); + bg.setAttribute('y', bbox.y + PAD); + bg.setAttribute('width', 20); + bg.setAttribute('height', 14); + bg.setAttribute('rx', 3); + bg.setAttribute('fill', '#16a34a'); + + const txt = document.createElementNS(ns, 'text'); + txt.setAttribute('x', bbox.x + bbox.width - 10 - PAD); + txt.setAttribute('y', bbox.y + PAD + 10); + txt.setAttribute('text-anchor', 'middle'); + txt.setAttribute('fill', '#ffffff'); + txt.setAttribute('font-size', '8'); + txt.setAttribute('font-weight', 'bold'); + txt.setAttribute('font-family', 'sans-serif'); + txt.setAttribute('pointer-events', 'none'); + txt.textContent = 'QA'; + + el.appendChild(bg); + el.appendChild(txt); + } + } }); }); }, @@ -417,16 +631,127 @@ function stepDetail() { stepId: null, stepName: '', playbook: '', + qaPlaybook: '', + qaEnabled: false, prevSteps: [], nextSteps: [], libraryEntryId: null, - editing: { playbook: false }, - drafts: { playbook: '' }, + subWorkflowId: null, + subWorkflowName: null, + subWorkflowPlaybook: '', + availableFields: [], + subWorkflowFieldMappings: [], + subWorkflowRequiredFields: [], + subWorkflowFields: [], + editingFieldMappings: false, + fieldMappingDraft: {}, + fieldMappingError: '', + editing: { playbook: false, qaPlaybook: false }, + drafts: { playbook: '', qaPlaybook: '' }, errors: {}, + mention: { + active: false, + field: null, + query: '', + index: 0, + atPos: -1, + }, init() { const data = JSON.parse(document.getElementById('step-detail-data').textContent); Object.assign(this, data); + // Initialise the field mapping draft + this.fieldMappingDraft = {}; + for (const m of this.subWorkflowFieldMappings || []) { + this.fieldMappingDraft[m.sub_workflow_field] = m.parent_field; + } + }, + + mentionMatches() { + const q = this.mention.query.toLowerCase(); + return (this.availableFields || []).filter(f => f.name.toLowerCase().startsWith(q)); + }, + + onTextareaInput(e, field) { + const ta = e.target; + const pos = ta.selectionStart; + const text = ta.value; + let i = pos - 1; + while (i >= 0 && /\w/.test(text[i])) i--; + if (i >= 0 && text[i] === '@') { + const query = text.slice(i + 1, pos); + this.mention = { active: true, field, query, index: 0, atPos: i }; + } else { + this.mention.active = false; + } + }, + + onTextareaKeydown(e, field) { + if (!this.mention.active) return; + const matches = this.mentionMatches(); + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.mention.index = (this.mention.index + 1) % Math.max(matches.length, 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.mention.index = (this.mention.index - 1 + Math.max(matches.length, 1)) % Math.max(matches.length, 1); + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (matches.length > 0) { + e.preventDefault(); + this.insertMention(field, matches[this.mention.index].name); + } + } else if (e.key === 'Escape') { + e.stopPropagation(); + this.mention.active = false; + } + }, + + insertMention(field, name) { + const ta = document.querySelector(`textarea[data-mention-field="${field}"]`); + if (!ta) return; + const pos = ta.selectionStart; + const text = ta.value; + const before = text.slice(0, this.mention.atPos + 1); // keep the '@' + const after = text.slice(pos); + ta.value = before + name + after; + // Move caret after inserted name + const newPos = this.mention.atPos + 1 + name.length; + ta.setSelectionRange(newPos, newPos); + this.drafts[field] = ta.value; + this.mention.active = false; + ta.focus(); + }, + + getMappedParentField(subFieldName) { + const m = (this.subWorkflowFieldMappings || []).find(m => m.sub_workflow_field === subFieldName); + return m ? '@' + m.parent_field : null; + }, + + getFieldMappingDraft(subFieldName) { + return this.fieldMappingDraft[subFieldName] || ''; + }, + + updateFieldMappingDraft(subFieldName, parentFieldName) { + this.fieldMappingDraft = { ...this.fieldMappingDraft, [subFieldName]: parentFieldName }; + }, + + async saveFieldMappings() { + this.fieldMappingError = ''; + const mappings = Object.entries(this.fieldMappingDraft) + .filter(([, v]) => v) + .map(([sub_workflow_field, parent_field]) => ({ parent_field, sub_workflow_field })); + const resp = await fetch(`/workflows/${this.workflowId}/steps/${this.stepId}/field-mappings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mappings }), + }); + if (resp.ok) { + this.subWorkflowFieldMappings = mappings; + this.editingFieldMappings = false; + } else { + const err = await resp.json().catch(() => ({})); + this.fieldMappingError = err.detail || 'Failed to save field mappings.'; + } }, startEdit(field, value) { @@ -450,11 +775,21 @@ function stepDetail() { }); if (resp.ok) { - if (field === 'playbook') this.playbook = body.playbook; + this[field] = this.drafts[field]; this.editing[field] = false; } }, + async toggleQaEnabled() { + const next = !this.qaEnabled; + const resp = await fetch(`/workflows/${this.workflowId}/steps/${this.stepId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ qa_enabled: next }), + }); + if (resp.ok) this.qaEnabled = next; + }, + async navigateStep(targetStepId) { const container = document.getElementById('step-detail'); const resp = await fetch(`/workflows/${this.workflowId}/steps/${targetStepId}`, { @@ -467,7 +802,10 @@ function stepDetail() { }, renderMarkdown(content) { - return marked.parse(content || ''); + const html = marked.parse(content || ''); + return html.replace(/@(\w+)/g, (_, name) => { + return `@${name}`; + }); }, }; } diff --git a/progi/web/static/style.css b/progi/web/static/style.css index 391e9af..a901741 100644 --- a/progi/web/static/style.css +++ b/progi/web/static/style.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:"Inter","Geist",ui-sans-serif,system-ui,sans-serif;--font-mono:"Geist Mono",ui-monospace,"Cascadia Code",monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-900:oklch(39.3% .095 152.535);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.875rem;--text-xs--line-height:calc(1/.75);--text-sm:1rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--tracking-widest:.1em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in:cubic-bezier(.4,0,1,1);--ease-out:cubic-bezier(0,0,.2,1);--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface-0:#000;--color-surface-1:#0a0a0a;--color-surface-2:#111;--color-surface-3:#1a1a1a;--color-border:#ffffff14;--color-border-hover:#ffffff24;--color-text-primary:#fafafa;--color-text-secondary:#a1a1aa;--color-text-muted:#71717a;--color-text-faint:#52525b;--color-text-ghost:#3f3f46;--color-accent:#00a3ff;--color-accent-dim:#00a3ff26}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.top-1\.5{top:calc(var(--spacing)*1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing)*2)}.right-1{right:calc(var(--spacing)*1)}.right-1\.5{right:calc(var(--spacing)*1.5)}.right-2{right:calc(var(--spacing)*2)}.left-full{left:100%}.z-10{z-index:10}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-1\.5{margin-top:calc(var(--spacing)*1.5)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1\.5{margin-bottom:calc(var(--spacing)*1.5)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.table{display:table}.size-1\.5{width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5)}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-2\.5{width:calc(var(--spacing)*2.5);height:calc(var(--spacing)*2.5)}.size-3{width:calc(var(--spacing)*3);height:calc(var(--spacing)*3)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-5{width:calc(var(--spacing)*5);height:calc(var(--spacing)*5)}.size-6{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6)}.size-7{width:calc(var(--spacing)*7);height:calc(var(--spacing)*7)}.h-6{height:calc(var(--spacing)*6)}.h-12{height:calc(var(--spacing)*12)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-24{min-height:calc(var(--spacing)*24)}.min-h-\[calc\(100vh-2\.75rem\)\]{min-height:calc(100vh - 2.75rem)}.min-h-screen{min-height:100vh}.w-6{width:calc(var(--spacing)*6)}.w-12{width:calc(var(--spacing)*12)}.w-56{width:calc(var(--spacing)*56)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[120px\]{min-width:120px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.-translate-y-1{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-180{rotate:180deg}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.appearance-none{appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-white\/8>:not(:last-child)){border-color:#ffffff14}@supports (color:color-mix(in lab, red, red)){:where(.divide-white\/8>:not(:last-child)){border-color:color-mix(in oklab,var(--color-white)8%,transparent)}}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-accent{border-color:var(--color-accent);border-color:var(--color-accent)}.border-accent\/40{border-color:#00a3ff66}@supports (color:color-mix(in lab, red, red)){.border-accent\/40{border-color:color-mix(in oklab,var(--color-accent)40%,transparent)}}.border-red-900\/50{border-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.border-red-900\/50{border-color:color-mix(in oklab,var(--color-red-900)50%,transparent)}}.border-subtle{border-color:var(--color-border)}.bg-accent{background-color:var(--color-accent);background-color:var(--color-accent)}.bg-accent-dim{background-color:var(--color-accent-dim);background-color:var(--color-accent-dim)}.bg-accent\/20{background-color:#00a3ff33}@supports (color:color-mix(in lab, red, red)){.bg-accent\/20{background-color:color-mix(in oklab,var(--color-accent)20%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/40{background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/40{background-color:color-mix(in oklab,var(--color-green-900)40%,transparent)}}.bg-surface-0{background-color:var(--color-surface-0);background-color:var(--color-surface-0)}.bg-surface-0\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-surface-0\/80{background-color:color-mix(in oklab,var(--color-surface-0)80%,transparent)}}.bg-surface-1{background-color:var(--color-surface-1);background-color:var(--color-surface-1)}.bg-surface-2{background-color:var(--color-surface-2);background-color:var(--color-surface-2)}.bg-surface-3{background-color:var(--color-surface-3);background-color:var(--color-surface-3)}.p-0\.5{padding:calc(var(--spacing)*.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-5{padding-block:calc(var(--spacing)*5)}.py-6{padding-block:calc(var(--spacing)*6)}.py-12{padding-block:calc(var(--spacing)*12)}.py-16{padding-block:calc(var(--spacing)*16)}.py-24{padding-block:calc(var(--spacing)*24)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-5{padding-top:calc(var(--spacing)*5)}.pr-0{padding-right:calc(var(--spacing)*0)}.pr-7{padding-right:calc(var(--spacing)*7)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pl-1{padding-left:calc(var(--spacing)*1)}.pl-2\.5{padding-left:calc(var(--spacing)*2.5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-faint{color:var(--color-text-faint)}.text-ghost{color:var(--color-text-ghost)}.text-green-400{color:var(--color-green-400)}.text-muted{color:var(--color-text-muted)}.text-primary{color:var(--color-text-primary)}.text-red-400{color:var(--color-red-400)}.text-secondary{color:var(--color-text-secondary)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:bg-accent:is(:where(.group):hover *){background-color:var(--color-accent);background-color:var(--color-accent)}.group-hover\:text-faint:is(:where(.group):hover *){color:var(--color-text-faint)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-ghost::placeholder{color:var(--color-text-ghost)}@media (hover:hover){.hover\:border-subtle-hover:hover{border-color:var(--color-border-hover)}.hover\:bg-accent\/30:hover{background-color:#00a3ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/30:hover{background-color:color-mix(in oklab,var(--color-accent)30%,transparent)}}.hover\:bg-red-950\/40:hover{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-950\/40:hover{background-color:color-mix(in oklab,var(--color-red-950)40%,transparent)}}.hover\:bg-surface-1:hover{background-color:var(--color-surface-1);background-color:var(--color-surface-1)}.hover\:bg-surface-2:hover{background-color:var(--color-surface-2);background-color:var(--color-surface-2)}.hover\:bg-surface-3:hover{background-color:var(--color-surface-3);background-color:var(--color-surface-3)}.hover\:text-muted:hover{color:var(--color-text-muted)}.hover\:text-primary:hover{color:var(--color-text-primary)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:text-secondary:hover{color:var(--color-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-accent:focus{border-color:var(--color-accent);border-color:var(--color-accent)}.focus\:border-subtle-hover:focus{border-color:var(--color-border-hover)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}[x-cloak]{display:none!important}.prose h1,.prose h2,.prose h3,.prose h4,.prose h5,.prose h6{color:var(--color-text-primary);margin-top:1.25em;margin-bottom:.5em;font-weight:600;line-height:1.25}.prose h1{font-size:1.25em}.prose h2{font-size:1.1em}.prose h3{font-size:1em}.prose p{color:var(--color-text-secondary);margin-top:.5em;margin-bottom:.5em}.prose ul,.prose ol{color:var(--color-text-secondary);margin-top:.5em;margin-bottom:.5em;padding-left:1.5em}.prose ul{list-style-type:disc}.prose ol{list-style-type:decimal}.prose li{margin-top:.25em;margin-bottom:.25em}.prose strong{color:var(--color-text-primary);font-weight:600}.prose em{font-style:italic}.prose code{font-family:var(--font-mono);background-color:var(--color-surface-3);border-radius:.25em;padding:.1em .3em;font-size:.85em}.prose pre{background-color:var(--color-surface-3);border-radius:.5em;margin-top:.75em;margin-bottom:.75em;padding:.75em 1em;overflow-x:auto}.prose pre code{background:0 0;padding:0}.prose blockquote{border-left:3px solid var(--color-border);color:var(--color-text-muted);margin:.75em 0;padding-left:1em;font-style:italic}.prose a{color:var(--color-accent);text-decoration:underline}.prose hr{border-color:var(--color-border);margin:1em 0}.prose :first-child{margin-top:0}.prose :last-child{margin-bottom:0}.prose-notes h1,.prose-notes h2,.prose-notes h3,.prose-notes h4,.prose-notes h5,.prose-notes h6{color:var(--color-text-primary);margin-top:1em;margin-bottom:.25em;font-weight:600;line-height:1.25}.prose-notes h1,.prose-notes h2{font-size:1em}.prose-notes h3,.prose-notes h4{font-size:.9em}.prose-notes p{color:var(--color-text-secondary);margin-top:.25em;margin-bottom:.25em}.prose-notes ul,.prose-notes ol{color:var(--color-text-secondary);margin-top:.25em;margin-bottom:.25em;padding-left:1.25em}.prose-notes ul{list-style-type:disc}.prose-notes ol{list-style-type:decimal}.prose-notes li{margin-top:.15em;margin-bottom:.15em}.prose-notes strong{color:var(--color-text-primary);font-weight:600}.prose-notes em{font-style:italic}.prose-notes code{font-family:var(--font-mono);background-color:var(--color-surface-3);border-radius:.25em;padding:.1em .3em;font-size:.85em}.prose-notes :first-child{margin-top:0}.prose-notes :last-child{margin-bottom:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:"Inter","Geist",ui-sans-serif,system-ui,sans-serif;--font-mono:"Geist Mono",ui-monospace,"Cascadia Code",monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-900:oklch(41.4% .112 45.904);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-900:oklch(39.3% .095 152.535);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-xl:36rem;--container-2xl:42rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.875rem;--text-xs--line-height:calc(1/.75);--text-sm:1rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--tracking-widest:.1em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in:cubic-bezier(.4,0,1,1);--ease-out:cubic-bezier(0,0,.2,1);--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-surface-0:#000;--color-surface-1:#0a0a0a;--color-surface-2:#111;--color-surface-3:#1a1a1a;--color-border:#ffffff14;--color-border-hover:#ffffff24;--color-text-primary:#fafafa;--color-text-secondary:#a1a1aa;--color-text-muted:#71717a;--color-text-faint:#52525b;--color-text-ghost:#3f3f46;--color-accent:#00a3ff;--color-accent-dim:#00a3ff26}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.top-1\.5{top:calc(var(--spacing)*1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing)*2)}.right-1{right:calc(var(--spacing)*1)}.right-1\.5{right:calc(var(--spacing)*1.5)}.right-2{right:calc(var(--spacing)*2)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-full{left:100%}.z-10{z-index:10}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-1\.5{margin-top:calc(var(--spacing)*1.5)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-1\.5{margin-bottom:calc(var(--spacing)*1.5)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.table{display:table}.size-1\.5{width:calc(var(--spacing)*1.5);height:calc(var(--spacing)*1.5)}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-2\.5{width:calc(var(--spacing)*2.5);height:calc(var(--spacing)*2.5)}.size-3{width:calc(var(--spacing)*3);height:calc(var(--spacing)*3)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-5{width:calc(var(--spacing)*5);height:calc(var(--spacing)*5)}.size-6{width:calc(var(--spacing)*6);height:calc(var(--spacing)*6)}.size-7{width:calc(var(--spacing)*7);height:calc(var(--spacing)*7)}.h-6{height:calc(var(--spacing)*6)}.h-12{height:calc(var(--spacing)*12)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[80vh\]{max-height:80vh}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-24{min-height:calc(var(--spacing)*24)}.min-h-\[calc\(100vh-2\.75rem\)\]{min-height:calc(100vh - 2.75rem)}.min-h-screen{min-height:100vh}.w-6{width:calc(var(--spacing)*6)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-24{width:calc(var(--spacing)*24)}.w-48{width:calc(var(--spacing)*48)}.w-56{width:calc(var(--spacing)*56)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-none{max-width:none}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[120px\]{min-width:120px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-y-0{--tw-translate-y:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-180{rotate:180deg}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.appearance-none{appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}.gap-px{gap:1px}.gap-x-4{column-gap:calc(var(--spacing)*4)}.gap-y-2{row-gap:calc(var(--spacing)*2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-white\/8>:not(:last-child)){border-color:#ffffff14}@supports (color:color-mix(in lab, red, red)){:where(.divide-white\/8>:not(:last-child)){border-color:color-mix(in oklab,var(--color-white)8%,transparent)}}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-accent{border-color:var(--color-accent);border-color:var(--color-accent)}.border-accent\/30{border-color:#00a3ff4d}@supports (color:color-mix(in lab, red, red)){.border-accent\/30{border-color:color-mix(in oklab,var(--color-accent)30%,transparent)}}.border-accent\/40{border-color:#00a3ff66}@supports (color:color-mix(in lab, red, red)){.border-accent\/40{border-color:color-mix(in oklab,var(--color-accent)40%,transparent)}}.border-green-600\/40{border-color:#00a54466}@supports (color:color-mix(in lab, red, red)){.border-green-600\/40{border-color:color-mix(in oklab,var(--color-green-600)40%,transparent)}}.border-green-600\/50{border-color:#00a54480}@supports (color:color-mix(in lab, red, red)){.border-green-600\/50{border-color:color-mix(in oklab,var(--color-green-600)50%,transparent)}}.border-red-600\/50{border-color:#e4001480}@supports (color:color-mix(in lab, red, red)){.border-red-600\/50{border-color:color-mix(in oklab,var(--color-red-600)50%,transparent)}}.border-red-900\/50{border-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.border-red-900\/50{border-color:color-mix(in oklab,var(--color-red-900)50%,transparent)}}.border-subtle{border-color:var(--color-border)}.bg-accent{background-color:var(--color-accent);background-color:var(--color-accent)}.bg-accent-dim{background-color:var(--color-accent-dim);background-color:var(--color-accent-dim)}.bg-accent-dim\/20{background-color:#00a3ff08}@supports (color:color-mix(in lab, red, red)){.bg-accent-dim\/20{background-color:color-mix(in oklab,var(--color-accent-dim)20%,transparent)}}.bg-accent\/15{background-color:#00a3ff26}@supports (color:color-mix(in lab, red, red)){.bg-accent\/15{background-color:color-mix(in oklab,var(--color-accent)15%,transparent)}}.bg-accent\/20{background-color:#00a3ff33}@supports (color:color-mix(in lab, red, red)){.bg-accent\/20{background-color:color-mix(in oklab,var(--color-accent)20%,transparent)}}.bg-amber-900\/40{background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.bg-amber-900\/40{background-color:color-mix(in oklab,var(--color-amber-900)40%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-900\/40{background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.bg-green-900\/40{background-color:color-mix(in oklab,var(--color-green-900)40%,transparent)}}.bg-red-900\/40{background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.bg-red-900\/40{background-color:color-mix(in oklab,var(--color-red-900)40%,transparent)}}.bg-surface-0{background-color:var(--color-surface-0);background-color:var(--color-surface-0)}.bg-surface-0\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-surface-0\/80{background-color:color-mix(in oklab,var(--color-surface-0)80%,transparent)}}.bg-surface-1{background-color:var(--color-surface-1);background-color:var(--color-surface-1)}.bg-surface-2{background-color:var(--color-surface-2);background-color:var(--color-surface-2)}.bg-surface-3{background-color:var(--color-surface-3);background-color:var(--color-surface-3)}.p-0\.5{padding:calc(var(--spacing)*.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-5{padding-block:calc(var(--spacing)*5)}.py-6{padding-block:calc(var(--spacing)*6)}.py-12{padding-block:calc(var(--spacing)*12)}.py-16{padding-block:calc(var(--spacing)*16)}.py-24{padding-block:calc(var(--spacing)*24)}.pt-0\.5{padding-top:calc(var(--spacing)*.5)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-5{padding-top:calc(var(--spacing)*5)}.pr-0{padding-right:calc(var(--spacing)*0)}.pr-7{padding-right:calc(var(--spacing)*7)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pl-1{padding-left:calc(var(--spacing)*1)}.pl-2\.5{padding-left:calc(var(--spacing)*2.5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-amber-400{color:var(--color-amber-400)}.text-faint{color:var(--color-text-faint)}.text-ghost{color:var(--color-text-ghost)}.text-green-400{color:var(--color-green-400)}.text-muted{color:var(--color-text-muted)}.text-primary{color:var(--color-text-primary)}.text-red-400{color:var(--color-red-400)}.text-secondary{color:var(--color-text-secondary)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-accent{accent-color:var(--color-accent)}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:bg-accent:is(:where(.group):hover *){background-color:var(--color-accent);background-color:var(--color-accent)}.group-hover\:text-faint:is(:where(.group):hover *){color:var(--color-text-faint)}.group-hover\:text-primary:is(:where(.group):hover *){color:var(--color-text-primary)}.group-hover\:opacity-100:is(:where(.group):hover *),.group-hover\/chip\:opacity-100:is(:where(.group\/chip):hover *){opacity:1}}.placeholder\:text-ghost::placeholder{color:var(--color-text-ghost)}@media (hover:hover){.hover\:border-green-500:hover{border-color:var(--color-green-500)}.hover\:border-red-500:hover{border-color:var(--color-red-500)}.hover\:border-subtle-hover:hover{border-color:var(--color-border-hover)}.hover\:bg-accent\/10:hover{background-color:#00a3ff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/10:hover{background-color:color-mix(in oklab,var(--color-accent)10%,transparent)}}.hover\:bg-accent\/25:hover{background-color:#00a3ff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/25:hover{background-color:color-mix(in oklab,var(--color-accent)25%,transparent)}}.hover\:bg-accent\/30:hover{background-color:#00a3ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/30:hover{background-color:color-mix(in oklab,var(--color-accent)30%,transparent)}}.hover\:bg-green-900\/20:hover{background-color:#0d542b33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-green-900\/20:hover{background-color:color-mix(in oklab,var(--color-green-900)20%,transparent)}}.hover\:bg-red-900\/20:hover{background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}.hover\:bg-red-950\/40:hover{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-950\/40:hover{background-color:color-mix(in oklab,var(--color-red-950)40%,transparent)}}.hover\:bg-surface-1:hover{background-color:var(--color-surface-1);background-color:var(--color-surface-1)}.hover\:bg-surface-2:hover{background-color:var(--color-surface-2);background-color:var(--color-surface-2)}.hover\:bg-surface-3:hover{background-color:var(--color-surface-3);background-color:var(--color-surface-3)}.hover\:text-muted:hover{color:var(--color-text-muted)}.hover\:text-primary:hover{color:var(--color-text-primary)}.hover\:text-red-300:hover{color:var(--color-red-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-secondary:hover{color:var(--color-text-secondary)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-accent:focus{border-color:var(--color-accent);border-color:var(--color-accent)}.focus\:border-subtle-hover:focus{border-color:var(--color-border-hover)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}}[x-cloak]{display:none!important}.prose h1,.prose h2,.prose h3,.prose h4,.prose h5,.prose h6{color:var(--color-text-primary);margin-top:1.25em;margin-bottom:.5em;font-weight:600;line-height:1.25}.prose h1{font-size:1.25em}.prose h2{font-size:1.1em}.prose h3{font-size:1em}.prose p{color:var(--color-text-secondary);margin-top:.5em;margin-bottom:.5em}.prose ul,.prose ol{color:var(--color-text-secondary);margin-top:.5em;margin-bottom:.5em;padding-left:1.5em}.prose ul{list-style-type:disc}.prose ol{list-style-type:decimal}.prose li{margin-top:.25em;margin-bottom:.25em}.prose strong{color:var(--color-text-primary);font-weight:600}.prose em{font-style:italic}.prose code{font-family:var(--font-mono);background-color:var(--color-surface-3);border-radius:.25em;padding:.1em .3em;font-size:.85em}.prose pre{background-color:var(--color-surface-3);border-radius:.5em;margin-top:.75em;margin-bottom:.75em;padding:.75em 1em;overflow-x:auto}.prose pre code{background:0 0;padding:0}.prose blockquote{border-left:3px solid var(--color-border);color:var(--color-text-muted);margin:.75em 0;padding-left:1em;font-style:italic}.prose a{color:var(--color-accent);text-decoration:underline}.prose hr{border-color:var(--color-border);margin:1em 0}.prose :first-child{margin-top:0}.prose :last-child{margin-bottom:0}.prose-notes h1,.prose-notes h2,.prose-notes h3,.prose-notes h4,.prose-notes h5,.prose-notes h6{color:var(--color-text-primary);margin-top:1em;margin-bottom:.25em;font-weight:600;line-height:1.25}.prose-notes h1,.prose-notes h2{font-size:1em}.prose-notes h3,.prose-notes h4{font-size:.9em}.prose-notes p{color:var(--color-text-secondary);margin-top:.25em;margin-bottom:.25em}.prose-notes ul,.prose-notes ol{color:var(--color-text-secondary);margin-top:.25em;margin-bottom:.25em;padding-left:1.25em}.prose-notes ul{list-style-type:disc}.prose-notes ol{list-style-type:decimal}.prose-notes li{margin-top:.15em;margin-bottom:.15em}.prose-notes strong{color:var(--color-text-primary);font-weight:600}.prose-notes em{font-style:italic}.prose-notes code{font-family:var(--font-mono);background-color:var(--color-surface-3);border-radius:.25em;padding:.1em .3em;font-size:.85em}.prose-notes :first-child{margin-top:0}.prose-notes :last-child{margin-bottom:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false} \ No newline at end of file diff --git a/progi/web/templates/pages/workflows.html b/progi/web/templates/pages/workflows.html index dabb04a..d235090 100644 --- a/progi/web/templates/pages/workflows.html +++ b/progi/web/templates/pages/workflows.html @@ -130,6 +130,15 @@ @@ -179,6 +188,213 @@

+ {# ── Workflow playbook modal ───────────────────────────────────────────────── #} + + {# ── Step detail modal ─────────────────────────────────────────────────────── #}