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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/testes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Primeiro CI deste repositório.
#
# Até 2026-08-22 não havia nenhum: `.github/` não existia, e os 194 testes só
# rodavam se alguém lembrasse de rodar. Isso passou a doer porque a metade do
# app do Workbench que pausa, retoma e guarda estado mora aqui, e "está verde"
# era afirmação de quem escreveu, nunca fato observável por quem revisa.
#
# O gate é o pytest mais o subconjunto do ruff que pega defeito de verdade
# (E9 sintaxe, F nome indefinido e import morto). O ruff completo roda ao lado,
# sem reprovar, porque o repositório carrega 23 achados de estilo herdados e
# seis deles pedem renomear exceção pública, que é mudança de API e não cabe
# nesta frente. A dívida está declarada em voz alta na saída do job, com issue
# aberta: preferível a um `continue-on-error` mudo ou a um verde por omissão.

name: Testes

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true

# Em venv, não --system: o Python do runner é externally managed e o
# --system falha com exit 2 antes de instalar qualquer coisa.
- name: Instala com os extras de dev
run: |
uv venv
uv pip install -e ".[dev]"

- name: Gate do ruff (sintaxe, nome indefinido, import morto)
run: uv run ruff check . --select E9,F --output-format concise

- name: Dívida de estilo, declarada e não bloqueante
run: |
echo "Achados de estilo herdados (não reprovam este job):"
uv run ruff check . --statistics --exit-zero

# Da raiz do repo: os testes importam `from lib.X`, e o editable depende
# do cwd para resolver o pacote.
- name: Testes
run: uv run pytest -q
174 changes: 165 additions & 9 deletions lib/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import subprocess
import sys
from pathlib import Path
from typing import Any

import click
import yaml
from rich.console import Console
from rich.table import Table

Expand Down Expand Up @@ -867,9 +869,22 @@ def workflow() -> None:


@workflow.command("list")
def workflow_list() -> None:
tracker, _, _, _ = _ctx()
@click.option("--json", "as_json", is_flag=True, help="Uma linha JSON, para consumo por programa.")
@click.option("--apps", "only_apps", is_flag=True, help="Só os que têm bloco `ui` (apps).")
def workflow_list(as_json: bool, only_apps: bool) -> None:
tracker, store, _, _ = _ctx()
rows = tracker.query("SELECT * FROM workflows ORDER BY created_at DESC")
if as_json:
saida = []
for r in rows:
ui = _ui_do_yaml(store.root / r["yaml_path"])
if only_apps and not ui:
continue
saida.append(
{"slug": r["slug"], "name": r["name"], "yaml_path": r["yaml_path"], "ui": ui}
)
click.echo(json.dumps(saida, ensure_ascii=False))
return
if not rows:
click.echo("Nenhum workflow salvo ainda.")
return
Expand All @@ -882,6 +897,42 @@ def workflow_list() -> None:
console.print(table)


def _ui_do_yaml(path: Path) -> dict[str, Any] | None:
"""Devolve o bloco `ui` de um manifesto, ou None se não houver.

Nunca levanta: um arquivo ruim não pode derrubar a lista inteira, pela mesma
razão que ESTADO.md ausente devolve vazio em vez de erro.
"""
try:
data = yaml.safe_load(path.read_text()) or {}
except (OSError, yaml.YAMLError):
return None
ui = data.get("ui")
return ui if isinstance(ui, dict) else None


@workflow.command("show")
@click.argument("workflow_slug")
@click.option("--json", "as_json", is_flag=True)
def workflow_show(workflow_slug: str, as_json: bool) -> None:
"""Despeja o manifesto inteiro. É por aqui que o Workbench lê o app."""
tracker, store, _, _ = _ctx()
wrow = tracker.get_workflow_by_slug(workflow_slug)
if not wrow:
click.echo(f"Workflow `{workflow_slug}` não encontrado.", err=True)
sys.exit(1)
path = store.root / wrow["yaml_path"]
try:
data = yaml.safe_load(path.read_text()) or {}
except (OSError, yaml.YAMLError) as e:
click.echo(f"Manifesto ilegível em {path}: {e}", err=True)
sys.exit(1)
if as_json:
click.echo(json.dumps(data, ensure_ascii=False))
else:
click.echo(path.read_text())


@workflow.command("save")
@click.option("--name", required=True, help="Nome legível: 'Hero Fashion Light'")
@click.option("--from-yaml", "from_yaml", required=True, help="Path do YAML pronto")
Expand All @@ -892,7 +943,15 @@ def workflow_save(name: str, from_yaml: str, description: str) -> None:
if not src.exists():
click.echo(f"Arquivo {src} não existe.", err=True)
sys.exit(1)
slug = slugify(name)
# O slug do arquivo vence o do nome: senão `--name "Eco Bonito"` grava
# `eco-bonito` no banco enquanto o YAML continua dizendo `slug: eco`, e o
# lookup por slug passa a apontar para nada.
try:
declarado = (yaml.safe_load(src.read_text()) or {}).get("slug")
except yaml.YAMLError as e:
click.echo(f"YAML inválido em {src}: {e}", err=True)
sys.exit(1)
slug = str(declarado) if declarado else slugify(name)
target = store.root / "workflows" / f"{slug}.yaml"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(src.read_text())
Expand All @@ -913,13 +972,15 @@ def workflow_save(name: str, from_yaml: str, description: str) -> None:
@click.option("--project", "project_slug", required=True)
@click.option("--inputs", help='JSON: \'{"prompt":"X"}\'')
@click.option("--resume", "resume_run_id", type=int, help="Resume run pausada")
@click.option("--selected", help='Para human_pick: \'{"selected": <asset_id>}\'')
@click.option("--selected", help='Para human_pick: \'{"id": "<id da opção>"}\'')
@click.option("--json", "as_json", is_flag=True, help="Uma linha JSON, para consumo por programa.")
def run_cmd(
workflow_slug: str,
project_slug: str,
inputs: str | None,
resume_run_id: int | None,
selected: str | None,
as_json: bool,
) -> None:
tracker, store, registry, sm = _ctx()
proj = tracker.get_project_by_slug(project_slug)
Expand All @@ -933,32 +994,127 @@ def run_cmd(
spec = WorkflowSpec.from_yaml(store.root / wrow["yaml_path"])
runner = WorkflowRunner(tracker, registry, _providers(registry), store)

def _resumo(run_id: int) -> dict[str, Any]:
row = tracker.query("SELECT * FROM runs WHERE id = ?", (run_id,))[0]
estado = json.loads(row["state"] or "{}")
return {
"run_id": run_id,
"status": row["status"],
"session_id": row["session_id"],
"cost_brl": row["cost_brl"],
"step_outputs": estado.get("step_outputs", {}),
}

try:
if resume_run_id:
user_input = json.loads(selected) if selected else {}
runner.resume(spec, resume_run_id, proj["id"], proj["slug"], None, user_input)
click.echo(f"✓ Run #{resume_run_id} retomada e concluída")
if as_json:
click.echo(json.dumps(_resumo(resume_run_id), ensure_ascii=False))
else:
click.echo(f"✓ Run #{resume_run_id} retomada e concluída")
else:
session_id = sm.ensure_session(proj["id"])
inp = json.loads(inputs) if inputs else {}
run_id = runner.start(
spec, proj["id"], proj["slug"], session_id, inp, wrow["id"]
)
click.echo(f"✓ Run #{run_id} concluída")
if as_json:
click.echo(json.dumps(_resumo(run_id), ensure_ascii=False))
else:
click.echo(f"✓ Run #{run_id} concluída")
except WorkflowPaused as p:
if as_json:
saida = _resumo(p.run_id)
saida.update(
{
"step_id": p.step_id,
"prompt_to_user": p.prompt_to_user,
"options": p.options,
}
)
click.echo(json.dumps(saida, ensure_ascii=False))
return
click.echo(f"\n⏸ Run #{p.run_id} pausada no step '{p.step_id}'")
click.echo(f" {p.prompt_to_user}")
click.echo(f" Opções (asset IDs): {p.options}")
click.echo(f" Opções: {p.options}")
click.echo(
f"\n Para continuar:\n"
f" studiolocal run {workflow_slug} --project {project_slug} "
f"--resume {p.run_id} --selected '{{\"selected\": <id>}}'"
f"--resume {p.run_id} --selected '{{\"id\": \"<id da opção>\"}}'"
)
except WorkflowError as e:
click.echo(f"✗ {e}", err=True)
if as_json:
click.echo(json.dumps({"error": str(e)}, ensure_ascii=False), err=True)
else:
click.echo(f"✗ {e}", err=True)
sys.exit(2)


@main.command("runs")
@click.option("--project", "project_slug", help="Filtra por project (slug).")
@click.option("--paused", is_flag=True, help="Só as pausadas esperando humano.")
@click.option("--json", "as_json", is_flag=True)
def runs_cmd(project_slug: str | None, paused: bool, as_json: bool) -> None:
"""Lista runs. Sem isto, o run_id só existe no stdout de quem disparou."""
tracker, _, _, _ = _ctx()
sql = (
"SELECT r.id, r.status, r.session_id, r.cost_brl, r.started_at, "
"w.slug AS workflow, p.slug AS project, r.state "
"FROM runs r JOIN workflows w ON w.id = r.workflow_id "
"JOIN projects p ON p.id = r.project_id"
)
where, args = [], []
if project_slug:
where.append("p.slug = ?")
args.append(project_slug)
if paused:
where.append("r.status = 'paused'")
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY r.id DESC"
rows = tracker.query(sql, tuple(args))

def _pendente(row) -> str | None:
try:
return (json.loads(row["state"] or "{}")).get("pending_step")
except json.JSONDecodeError:
return None

if as_json:
click.echo(
json.dumps(
[
{
"run_id": r["id"],
"status": r["status"],
"workflow": r["workflow"],
"project": r["project"],
"session_id": r["session_id"],
"cost_brl": r["cost_brl"],
"started_at": r["started_at"],
"pending_step": _pendente(r),
}
for r in rows
],
ensure_ascii=False,
)
)
return
if not rows:
click.echo("Nenhuma run." if not paused else "Nenhuma run pausada.")
return
table = Table(title="Runs")
for col in ("run", "status", "workflow", "project", "sessão", "passo pendente", "início"):
table.add_column(col)
for r in rows:
table.add_row(
str(r["id"]), r["status"], r["workflow"], r["project"],
str(r["session_id"] or "-"), _pendente(r) or "-", r["started_at"],
)
console.print(table)


# --- cleanup --------------------------------------------------------------


Expand Down
Loading
Loading