From 7c174a4bd4e56ffd3278db925f9fc5ec925b39c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 01:27:24 +0000 Subject: [PATCH] =?UTF-8?q?Fase=207=20Onda=2014:=20Designer=20salva=20hone?= =?UTF-8?q?sto=20=E2=80=94=20schema=20real=20+=20ledger=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit squad.workflow.v1 novo (JSON Schema + tipo Rust + fixture golden, padrão de experiment.v1): SquadWorkflow{nodes, edges} com validate_edges() rejeitando arestas pra nós inexistentes antes de qualquer escrita. POST /api/designer/workflow direto em forge-server (zero dependência nova). Grafo inválido nunca toca o ledger (422, provado por teste). Grafo válido grava via o mesmo LedgerStore::append de toda outra escrita de auditoria — seq real, nunca o 248 fabricado do mock antigo. "Salvar honesto" corrigiu os 2 lados da mentira antiga: seq fabricado no mock E a cópia "orquestrador aplica na próxima forge squad" (nunca foi real — os 5 agentes fixos continuam decidindo, sem reescrita nesta fase). MARK_SAVED ganhou o seq real; banner/toast/botão declaram só "salvo e validado — aplicação real é trabalho futuro". Decisões completas em pendencias.md (seção "Onda 14"). --- crates/forge-schemas/src/lib.rs | 1 + crates/forge-schemas/src/workflow.rs | 127 ++++++++++++++ crates/forge-schemas/tests/schema_fixtures.rs | 27 +++ crates/forge-server/src/lib.rs | 162 ++++++++++++++++++ pendencias.md | 41 +++++ schemas/fixtures/squad-workflow.v1.json | 50 ++++++ schemas/json/squad-workflow.v1.schema.json | 52 ++++++ web/src/api/designer.ts | 21 ++- .../screens/user/Designer/Designer.tsx | 3 +- .../screens/user/Designer/Toolbar.tsx | 14 +- .../screens/user/Designer/reducer.ts | 7 +- .../designer-real-backend.spec.ts | 43 +++++ 12 files changed, 532 insertions(+), 16 deletions(-) create mode 100644 crates/forge-schemas/src/workflow.rs create mode 100644 schemas/fixtures/squad-workflow.v1.json create mode 100644 schemas/json/squad-workflow.v1.schema.json create mode 100644 web/tests/e2e-integration/designer-real-backend.spec.ts diff --git a/crates/forge-schemas/src/lib.rs b/crates/forge-schemas/src/lib.rs index 86ab74e..ea824ef 100644 --- a/crates/forge-schemas/src/lib.rs +++ b/crates/forge-schemas/src/lib.rs @@ -10,5 +10,6 @@ pub mod handoff; pub mod ledger; pub mod telemetry; pub mod verification; +pub mod workflow; pub use canonical::{canonical_json, request_hash, sha256_hex}; diff --git a/crates/forge-schemas/src/workflow.rs b/crates/forge-schemas/src/workflow.rs new file mode 100644 index 0000000..b227fa7 --- /dev/null +++ b/crates/forge-schemas/src/workflow.rs @@ -0,0 +1,127 @@ +//! Grafo do Squad Designer (`squad.workflow.v1`, Fase 7 Onda 14). +//! +//! Salvar valida a forma (schema + integridade de arestas) e grava no +//! ledger — **não aplica** ao orquestrador real: o `UnifiedOrchestrator` +//! continua com os 5 agentes fixos (`forge_squad`), sem reescrita nesta +//! fase. "Salvar honesto": o servidor confirma que o grafo foi validado e +//! persistido, nunca que o squad passou a usá-lo. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowNodeKind { + Card, + Pill, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WorkflowNodeParam { + pub k: String, + pub v: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WorkflowNode { + pub id: String, + pub x: f64, + pub y: f64, + pub kind: WorkflowNodeKind, + pub name: String, + pub role: String, + pub color: String, + pub icon: String, + pub sub: String, + pub params: Vec, + pub removable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WorkflowEdge { + pub from: String, + pub to: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SquadWorkflow { + pub nodes: Vec, + pub edges: Vec, +} + +impl SquadWorkflow { + /// Única checagem semântica além do schema (campos/tipos obrigatórios já + /// cobertos por serde + JSON Schema): toda aresta referencia um nó que + /// existe. Erro aponta o lado (`from`/`to`) e o id que falhou — um 422 + /// claro, não um 500 genérico nem um grafo salvo silenciosamente + /// quebrado. + pub fn validate_edges(&self) -> Result<(), String> { + let ids: std::collections::HashSet<&str> = + self.nodes.iter().map(|n| n.id.as_str()).collect(); + for edge in &self.edges { + if !ids.contains(edge.from.as_str()) { + return Err(format!( + "aresta referencia nó inexistente em 'from': {}", + edge.from + )); + } + if !ids.contains(edge.to.as_str()) { + return Err(format!( + "aresta referencia nó inexistente em 'to': {}", + edge.to + )); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(id: &str) -> WorkflowNode { + WorkflowNode { + id: id.into(), + x: 0.0, + y: 0.0, + kind: WorkflowNodeKind::Card, + name: id.into(), + role: "agente".into(), + color: "var(--rust)".into(), + icon: "◆".into(), + sub: "".into(), + params: vec![], + removable: true, + } + } + + #[test] + fn grafo_com_arestas_validas_passa() { + let wf = SquadWorkflow { + nodes: vec![node("a"), node("b")], + edges: vec![WorkflowEdge { + from: "a".into(), + to: "b".into(), + label: None, + }], + }; + assert!(wf.validate_edges().is_ok()); + } + + #[test] + fn aresta_para_no_inexistente_e_rejeitada_com_erro_claro() { + let wf = SquadWorkflow { + nodes: vec![node("a")], + edges: vec![WorkflowEdge { + from: "a".into(), + to: "fantasma".into(), + label: None, + }], + }; + let err = wf.validate_edges().unwrap_err(); + assert!(err.contains("fantasma"), "erro deveria citar o id: {err}"); + } +} diff --git a/crates/forge-schemas/tests/schema_fixtures.rs b/crates/forge-schemas/tests/schema_fixtures.rs index c203a00..4a332a3 100644 --- a/crates/forge-schemas/tests/schema_fixtures.rs +++ b/crates/forge-schemas/tests/schema_fixtures.rs @@ -22,6 +22,7 @@ use forge_schemas::experiment::ExperimentReport; use forge_schemas::handoff::HandoffEvent; use forge_schemas::ledger::LedgerEntry; use forge_schemas::telemetry::TelemetryEvent; +use forge_schemas::workflow::SquadWorkflow; use jsonschema::validator_for; use serde_json::Value; @@ -128,6 +129,32 @@ fn experiment_fixture_valida_e_desserializa() { ); } +/// A checagem semântica (aresta referencia nó inexistente) não é +/// expressável em JSON Schema puro — fica em `SquadWorkflow::validate_edges` +/// (testada isoladamente em `workflow.rs`). Aqui só a FORMA: campo +/// obrigatório ausente (`removable`) deve reprovar o schema. +#[test] +fn squad_workflow_fixture_valida_e_desserializa() { + let schema = schema("squad-workflow"); + let doc = fixture("squad-workflow"); + let validator = validator_for(&schema).expect("schema compila"); + + assert!( + validator.is_valid(&doc["valid"]), + "fixture válida não bateu o schema: {:?}", + validator.iter_errors(&doc["valid"]).collect::>() + ); + let parsed: SquadWorkflow = + serde_json::from_value(doc["valid"].clone()).expect("desserializa em SquadWorkflow"); + assert_eq!(parsed.nodes.len(), 2); + assert!(parsed.validate_edges().is_ok()); + + assert!( + !validator.is_valid(&doc["invalid_missing_removable"]), + "documento sem 'removable' deveria reprovar o schema" + ); +} + /// Sem tipo Rust/Python — só protege o schema em si (sintaxe/drift), não /// uma paridade de tipo. Ver nota no topo do arquivo e no `$comment` da /// fixture. diff --git a/crates/forge-server/src/lib.rs b/crates/forge-server/src/lib.rs index 2aefdc3..98a6cc8 100644 --- a/crates/forge-server/src/lib.rs +++ b/crates/forge-server/src/lib.rs @@ -24,6 +24,7 @@ use axum::Router; use forge_llm::model_tier::{tier_from_id, ModelTier}; use forge_llm::rate_limit::RateLimiter; use forge_schemas::experiment::{ExperimentReport, VariantStats}; +use forge_schemas::workflow::SquadWorkflow; use forge_store::{LedgerStore, PromptLibrary, Telemetry}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -119,6 +120,7 @@ pub fn router( .route("/api/providers", get(list_providers)) .route("/api/verify/run", post(run_verify_start)) .route("/api/verify/{id}", get(get_verify_status)) + .route("/api/designer/workflow", post(save_workflow)) .fallback_service(serve_dir) .with_state(AppState { telemetry, @@ -656,6 +658,60 @@ async fn verify_ledger(State(state): State) -> Response { } } +#[derive(Serialize)] +struct SaveWorkflowResponse { + seq: u64, + workflow_id: &'static str, +} + +/// `POST /api/designer/workflow` (Fase 7 Onda 14) — valida o grafo do Squad +/// Designer contra `squad.workflow.v1` (schema + integridade de arestas via +/// `SquadWorkflow::validate_edges`) e grava no ledger (mesmo +/// `LedgerStore::append` que toda outra escrita de auditoria já usa — zero +/// mudança de ledger). "Salvar honesto": confirma que o grafo foi validado e +/// persistido, nunca que o orquestrador passou a usá-lo — os 5 agentes +/// fixos do `UnifiedOrchestrator` continuam decidindo, sem reescrita nesta +/// fase (aplicar o grafo real é trabalho futuro). +async fn save_workflow( + State(state): State, + Json(workflow): Json, +) -> Response { + if let Err(e) = workflow.validate_edges() { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(ErrorBody::new("invalid_workflow", e)), + ) + .into_response(); + } + let payload = match serde_json::to_value(&workflow) { + Ok(v) => v, + Err(e) => return db_error(e), + }; + let entry = forge_schemas::ledger::LedgerEntry { + seq: 0, + prev_hash: String::new(), + entry_hash: String::new(), + kind: "designer.workflow_saved".into(), + actor: "web:designer".into(), + payload, + r#override: None, + fake_marker: None, + ts: now_rfc3339(), + }; + let mut ledger = state.ledger.lock().unwrap_or_else(|e| e.into_inner()); + match ledger.append(entry) { + Ok(saved) => ( + StatusCode::CREATED, + Json(SaveWorkflowResponse { + seq: saved.seq, + workflow_id: "squad.workflow.v1", + }), + ) + .into_response(), + Err(e) => db_error(e), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1262,6 +1318,112 @@ mod tests { assert!(json.get("error").is_none()); } + fn workflow_body(edges: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "nodes": [ + { + "id": "task", "x": 0.0, "y": 0.0, "kind": "pill", "name": "tarefa", + "role": "entrada", "color": "c", "icon": "▸", "sub": "", "params": [], + "removable": false + }, + { + "id": "architect", "x": 10.0, "y": 10.0, "kind": "card", "name": "architect", + "role": "arquitetura", "color": "c", "icon": "◆", "sub": "", "params": [], + "removable": true + } + ], + "edges": edges, + }) + } + + /// Fronteira da Onda 14 (Designer, "salvar honesto"): grafo válido grava + /// no MESMO ledger que a rota de leitura já usa — lido direto de volta + /// (não uma segunda fonte de verdade), `seq` real (não fabricado no + /// cliente), `kind`/`actor` corretos. + #[tokio::test] + async fn salvar_workflow_valido_grava_no_ledger_e_e_lido_de_volta() { + let ledger = ledger_vazio(); + let web_dir = fixture_web_dir(); + let app = router( + Telemetry::open_in_memory().unwrap(), + prompt_library_vazia(), + Arc::clone(&ledger), + web_dir.path(), + web_dir.path(), + ); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/designer/workflow") + .header("content-type", "application/json") + .body(Body::from( + workflow_body(serde_json::json!([{"from": "task", "to": "architect"}])) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["seq"], 1); + assert_eq!(json["workflow_id"], "squad.workflow.v1"); + + // Lido direto do MESMO storage por trás da rota — não uma segunda + // cópia inventada na resposta HTTP. + let store = ledger.lock().unwrap(); + let entries = store.recent(10, None).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, "designer.workflow_saved"); + assert_eq!(entries[0].actor, "web:designer"); + assert_eq!(entries[0].payload["nodes"].as_array().unwrap().len(), 2); + } + + /// Grafo malformado (aresta pra nó inexistente) é rejeitado com erro + /// claro (422, citando o id) — não salvo silenciosamente. O ledger + /// continua vazio: a validação acontece ANTES do `append`, não depois. + #[tokio::test] + async fn salvar_workflow_com_aresta_pendente_e_rejeitado_e_nao_grava_nada() { + let ledger = ledger_vazio(); + let web_dir = fixture_web_dir(); + let app = router( + Telemetry::open_in_memory().unwrap(), + prompt_library_vazia(), + Arc::clone(&ledger), + web_dir.path(), + web_dir.path(), + ); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/designer/workflow") + .header("content-type", "application/json") + .body(Body::from( + workflow_body(serde_json::json!([{"from": "task", "to": "fantasma"}])) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"].as_str().unwrap().contains("fantasma")); + + let store = ledger.lock().unwrap(); + assert_eq!(store.recent(10, None).unwrap().len(), 0); + } + /// Fronteira da Onda 7 (A5): `GET /api/models/usage` bate por igualdade /// com agregação MANUAL dos mesmos eventos semeados — inclui a coluna /// `tier` derivada de `tier_from_id` (não fabricada), e não conta um diff --git a/pendencias.md b/pendencias.md index b157407..87b9103 100644 --- a/pendencias.md +++ b/pendencias.md @@ -1095,3 +1095,44 @@ ADR 0019, sem decisão em aberto que precisasse deste arquivo. providers já tem fronteira determinística a nível Rust (`doctor_agrega_as_4_checagens_com_providers_real`, com isolamento de env var) — reafirmar no Playwright seria uma segunda cópia do mesmo teste. + +## Onda 14 — Designer (salvar honesto) + +- **[decisão] `squad.workflow.v1` novo (JSON Schema + tipo Rust + + fixture golden), mesmo padrão de `experiment.v1`** — `SquadWorkflow{nodes, + edges}` (`crates/forge-schemas/src/workflow.rs`), com `WorkflowNodeKind` + espelhando o union `'card'|'pill'` do TS como enum (`#[serde(rename_all = + "snake_case")]`, mesma convenção de `ModelTier`/`ExperimentVerdict`). A + checagem semântica (aresta referencia nó inexistente) NÃO é expressável em + JSON Schema puro — fica em `SquadWorkflow::validate_edges` (Rust puro, + testada isoladamente); a fixture golden só prova forma (campo obrigatório + ausente reprova o schema), não a checagem de aresta. +- **[decisão] `POST /api/designer/workflow` direto em `forge-server`** + (mesma classe de posicionamento de A2/A5/A12 — só precisa de + `forge-store`/`forge-schemas`, já dependências do crate). Valida + `validate_edges()` ANTES de qualquer escrita — grafo malformado nunca + chega a tocar o ledger (provado por teste: ledger continua com 0 entradas + após um 422). Grafo válido grava via o MESMO `LedgerStore::append` que + toda outra escrita de auditoria da plataforma já usa (zero mudança de + ledger) — `kind: "designer.workflow_saved"`, `actor: "web:designer"`, + `payload` é o grafo serializado direto (sem DTO espelho). +- **[decisão] "Salvar honesto" tocou os 2 lados da mentira antiga, não só + o backend**: o mock antigo (`saveWorkflow`) fabricava `seq: 248` fixo E + a cópia da tela prometia "orquestrador aplica na próxima forge squad" — + nunca foi real (`UnifiedOrchestrator` continua com os 5 agentes fixos, + sem reescrita nesta fase). Os dois foram corrigidos juntos: + `MARK_SAVED` (reducer) ganhou o campo `seq` real (antes não carregava + nenhum), e a cópia (banner em `Designer.tsx`, toast em `Toolbar.tsx`, + botão "salvar & aplicar" → "salvar") declara só "salvo e validado — + aplicação real é trabalho futuro". Corrigir só um dos dois teria deixado + a outra metade da mentira de pé. +- **[nota] Grafo padrão (`templates.ts`'s `initialNodes`/`initialEdges`, + 8 nós, várias arestas) já é válido e não-trivial** — a fronteira por + Playwright não precisa arrastar nem conectar nada na tela: clicar + "salvar" no estado inicial já exercita `POST /api/designer/workflow` de + ponta a ponta com um grafo real. Testar o caso de aresta pendente + (422) pela UI não é possível de propósito: `REMOVE_NODE` do próprio + reducer já remove as arestas de um nó ao removê-lo — a UI estruturalmente + não deixa construir um grafo inválido por interação normal. Esse caso + fica só no teste Rust (`salvar_workflow_com_aresta_pendente...`), que + constrói o corpo HTTP direto, sem passar pela UI. diff --git a/schemas/fixtures/squad-workflow.v1.json b/schemas/fixtures/squad-workflow.v1.json new file mode 100644 index 0000000..45dcead --- /dev/null +++ b/schemas/fixtures/squad-workflow.v1.json @@ -0,0 +1,50 @@ +{ + "valid": { + "nodes": [ + { + "id": "task", + "x": 20, + "y": 40, + "kind": "pill", + "name": "tarefa", + "role": "entrada", + "color": "var(--muted)", + "icon": "▸", + "sub": "", + "params": [], + "removable": false + }, + { + "id": "architect", + "x": 200, + "y": 40, + "kind": "card", + "name": "architect", + "role": "arquitetura", + "color": "var(--rust)", + "icon": "◆", + "sub": "decide o desenho", + "params": [{ "k": "autonomia", "v": "nível 2" }], + "removable": true + } + ], + "edges": [{ "from": "task", "to": "architect", "label": "delega" }] + }, + "invalid_missing_removable": { + "nodes": [ + { + "id": "task", + "x": 20, + "y": 40, + "kind": "pill", + "name": "tarefa", + "role": "entrada", + "color": "var(--muted)", + "icon": "▸", + "sub": "", + "params": [] + } + ], + "edges": [] + } +} diff --git a/schemas/json/squad-workflow.v1.schema.json b/schemas/json/squad-workflow.v1.schema.json new file mode 100644 index 0000000..21446e6 --- /dev/null +++ b/schemas/json/squad-workflow.v1.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://forge.buildtovalue.dev/schemas/squad-workflow.v1.schema.json", + "title": "squad-workflow.v1", + "description": "Grafo do Squad Designer (Fase 7 Onda 14). Salvar valida contra este schema e grava no ledger — aplicação real ao orquestrador (que continua com os 5 agentes fixos) é trabalho futuro, não fingida aqui.", + "type": "object", + "required": ["nodes", "edges"], + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "x", "y", "kind", "name", "role", "color", "icon", "sub", "params", "removable"], + "properties": { + "id": { "type": "string" }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "kind": { "type": "string", "enum": ["card", "pill"] }, + "name": { "type": "string" }, + "role": { "type": "string" }, + "color": { "type": "string" }, + "icon": { "type": "string" }, + "sub": { "type": "string" }, + "params": { + "type": "array", + "items": { + "type": "object", + "required": ["k", "v"], + "properties": { + "k": { "type": "string" }, + "v": { "type": "string" } + } + } + }, + "removable": { "type": "boolean" } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { "type": "string" }, + "to": { "type": "string" }, + "label": { "type": "string" } + } + } + } + } +} diff --git a/web/src/api/designer.ts b/web/src/api/designer.ts index 892119e..3306d29 100644 --- a/web/src/api/designer.ts +++ b/web/src/api/designer.ts @@ -1,4 +1,12 @@ -import { simulateLatency } from './client' +/** + * Fase 7 Onda 14 (Designer, "salvar honesto"): `POST /api/designer/workflow` + * valida o grafo contra `squad.workflow.v1` (schema + integridade de + * arestas) e grava no ledger de verdade — `seq` real, não fabricado. + * "Aplica na próxima forge squad" nunca foi real (o orquestrador Python + * continua com os 5 agentes fixos, sem reescrita nesta fase) — a resposta + * e a cópia da tela dizem só "salvo e validado". + */ +import { fetchJson } from './client' import type { DesignerEdge, DesignerNode } from '../types/domain' export interface SaveWorkflowResult { @@ -6,8 +14,11 @@ export interface SaveWorkflowResult { workflowId: string } -/** // TODO: backend Fase N — POST squad.workflow.v1 → schema validado → ledger append → orquestrador aplica na próxima `forge squad`. */ -export async function saveWorkflow(_graph: { nodes: DesignerNode[]; edges: DesignerEdge[] }): Promise { - await simulateLatency(500) - return { seq: 248, workflowId: 'squad.workflow.v1' } +export async function saveWorkflow(graph: { nodes: DesignerNode[]; edges: DesignerEdge[] }): Promise { + const result = await fetchJson<{ seq: number; workflow_id: string }>('/api/designer/workflow', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(graph), + }) + return { seq: result.seq, workflowId: result.workflow_id } } diff --git a/web/src/components/screens/user/Designer/Designer.tsx b/web/src/components/screens/user/Designer/Designer.tsx index 4468063..6c0220d 100644 --- a/web/src/components/screens/user/Designer/Designer.tsx +++ b/web/src/components/screens/user/Designer/Designer.tsx @@ -13,7 +13,8 @@ export function Designer() { {state.wfSaved && (
- ✓ squad.workflow.v1 salvo → schema validado → ledger seq 248 → orquestrador aplica na próxima forge squad + ✓ squad.workflow.v1 salvo → schema validado → ledger seq {state.lastSavedSeq} → aplicação real ao + orquestrador é trabalho futuro (os 5 agentes fixos continuam decidindo)
)}
diff --git a/web/src/components/screens/user/Designer/Toolbar.tsx b/web/src/components/screens/user/Designer/Toolbar.tsx index 882ce11..281c400 100644 --- a/web/src/components/screens/user/Designer/Toolbar.tsx +++ b/web/src/components/screens/user/Designer/Toolbar.tsx @@ -2,6 +2,7 @@ import type { Dispatch } from 'react' import { Button } from '../../../primitives/Button' import { useAsyncAction } from '../../../../hooks/useAsyncAction' import { useToast } from '../../../primitives/Toast' +import { ApiError } from '../../../../api/client' import { saveWorkflow } from '../../../../api/designer' import type { DesignerAction, DesignerState } from './reducer' @@ -12,13 +13,10 @@ export function Toolbar({ state, dispatch }: { state: DesignerState; dispatch: D async function handleSave() { try { const result = await save.run({ nodes: state.nodes, edges: state.edges }) - dispatch({ type: 'MARK_SAVED' }) - toast.push( - 'success', - `${result.workflowId} salvo → schema validado → ledger seq ${result.seq} → orquestrador aplica na próxima forge squad`, - ) - } catch { - toast.push('error', 'falha ao salvar workflow') + dispatch({ type: 'MARK_SAVED', seq: result.seq }) + toast.push('success', `${result.workflowId} salvo → schema validado → ledger seq ${result.seq}`) + } catch (e) { + toast.push('error', e instanceof ApiError ? e.message : 'falha ao salvar workflow') } } @@ -45,7 +43,7 @@ export function Toolbar({ state, dispatch }: { state: DesignerState; dispatch: D onClick={() => void handleSave()} disabled={save.state.status === 'loading'} > - {save.state.status === 'loading' ? 'salvando…' : state.wfSaved ? '✓ salvo' : 'salvar & aplicar'} + {save.state.status === 'loading' ? 'salvando…' : state.wfSaved ? '✓ salvo' : 'salvar'}
diff --git a/web/src/components/screens/user/Designer/reducer.ts b/web/src/components/screens/user/Designer/reducer.ts index 819ff6e..857cf69 100644 --- a/web/src/components/screens/user/Designer/reducer.ts +++ b/web/src/components/screens/user/Designer/reducer.ts @@ -12,6 +12,8 @@ export interface DesignerState { grabDY: number addCount: number wfSaved: boolean + /** `seq` real do ledger devolvido por `POST /api/designer/workflow` — `null` antes do 1º save. */ + lastSavedSeq: number | null } export type DesignerAction = @@ -24,7 +26,7 @@ export type DesignerAction = | { type: 'REMOVE_NODE'; id: string } | { type: 'SELECT_NODE'; id: string | null } | { type: 'RESET' } - | { type: 'MARK_SAVED' } + | { type: 'MARK_SAVED'; seq: number } export function initDesignerState(): DesignerState { return { @@ -38,6 +40,7 @@ export function initDesignerState(): DesignerState { grabDY: 0, addCount: 0, wfSaved: false, + lastSavedSeq: null, } } @@ -121,6 +124,6 @@ export function designerReducer(state: DesignerState, action: DesignerAction): D return initDesignerState() case 'MARK_SAVED': - return { ...state, wfSaved: true } + return { ...state, wfSaved: true, lastSavedSeq: action.seq } } } diff --git a/web/tests/e2e-integration/designer-real-backend.spec.ts b/web/tests/e2e-integration/designer-real-backend.spec.ts new file mode 100644 index 0000000..93b66f9 --- /dev/null +++ b/web/tests/e2e-integration/designer-real-backend.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test' + +/** Fase 7 Onda 14 (Designer, "salvar honesto"): prova a fronteira por + * EXECUÇÃO. O grafo padrão (`initialNodes`/`initialEdges`, 8 nós + várias + * arestas válidas) já é suficiente pra salvar — não precisa arrastar nem + * conectar nada na tela pra exercitar `POST /api/designer/workflow` de + * verdade. `seq` vem do MESMO `LedgerStore::append` que toda outra escrita + * de auditoria da plataforma usa — nunca o `seq 248` fabricado que o mock + * antigo sempre devolvia, e a cópia não promete mais "aplica na próxima + * forge squad" (o orquestrador Python continua com os 5 agentes fixos). + */ +test('salvar o grafo padrão grava no ledger real — seq real, cópia honesta sobre aplicação', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Squad Designer' }).click() + await expect(page.getByRole('heading', { name: 'Squad Designer' })).toBeVisible() + + await page.getByRole('button', { name: 'salvar', exact: true }).click() + + const banner = page.getByText(/squad\.workflow\.v1 salvo/).first() + await expect(banner).toBeVisible({ timeout: 10_000 }) + await expect(banner).toContainText('ledger seq') + await expect(banner).toContainText('trabalho futuro') + // O mock antigo prometia aplicação real e um seq fixo — nenhum dos dois + // pode sobreviver por trás do backend real. + await expect(banner).not.toContainText('aplica na próxima forge squad') + await expect(page.getByText('seq 248')).toHaveCount(0) + + await expect(page.getByRole('button', { name: '✓ salvo' })).toBeVisible() +}) + +test('backend do designer fora do ar mostra erro explícito, não a confirmação fabricada', async ({ page }) => { + await page.route('**/api/designer/workflow', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom","code":"forced_failure"}' }), + ) + await page.goto('/') + await page.getByRole('button', { name: 'Squad Designer' }).click() + await expect(page.getByRole('heading', { name: 'Squad Designer' })).toBeVisible() + + await page.getByRole('button', { name: 'salvar', exact: true }).click() + + await expect(page.getByText('boom')).toBeVisible() + await expect(page.getByText(/squad\.workflow\.v1 salvo/)).toHaveCount(0) +})