Quando começamos a construir Sim, notamos que o desenvolvimento de workflows de IA parecia muito com o processo de design que o Figma já havia resolvido. Gerentes de produto precisam esboçar fluxos voltados ao usuário, engenheiros precisam configurar integrações e APIs, e especialistas de domínio precisam validar lógica de negócios—frequentemente tudo ao mesmo tempo. Construtores de workflow tradicionais forçam colaboração serial: uma pessoa edita, salva, exporta e notifica a próxima pessoa. Isso cria fricção desnecessária.
Decidimos que edição multiplayer era a abordagem certa, mesmo que plataformas de workflow como n8n e Make atualmente não ofereçam isso. Este post explica como construímos isso. Cobriremos a fila de operações, resolução de conflitos, como lidamos com blocos/arestas/subfluxos separadamente, desfazer/refazer como um wrapper em torno disso, e por que nosso sistema é muito mais simples do que você esperaria.
Visão Geral da Arquitetura: Cliente-Servidor com WebSockets
Sim usa uma arquitetura cliente-servidor onde clientes de navegador se comunicam com um servidor WebSocket Node.js standalone sobre conexões persistentes. Quando você abre um workflow, seu cliente entra em uma "sala de workflow" no servidor. Todas as operações subsequentes—adicionar blocos, conectar arestas, atualizar configurações—são sincronizadas através desta conexão.
Lado do Servidor: A Fonte da Verdade
O servidor mantém estado autoritativo no PostgreSQL em três tabelas normalizadas:
workflow_blocks: Metadados de blocos, posições, configurações e valores de subblocosworkflow_edges: Conexões entre blocos com handles de origem/destinoworkflow_subflows: Configurações de containers de loop e paralelo com listas de nós filhos
Esta separação é deliberada. Blocos, arestas e subfluxos têm padrões de atualização e características de conflito diferentes. Ao armazená-los separadamente:
- Atualizações direcionadas: Mover um bloco apenas atualiza campos
positionXepositionYpara aquela linha de bloco específica. Não carregamos ou bloqueamos o workflow inteiro. - Otimização de consultas: Operações diferentes atingem tabelas diferentes com índices apropriados. Atualizar conexões de arestas apenas toca
workflow_edges, deixando blocos intocados. - Canais separados: Operações estruturais (adicionar blocos, conectar arestas) passam pelo manipulador de operações principal com lógica de persistência primeiro. Atualizações de valor (editar texto em um subbloco) passam por um canal separado com debounce com coalescência no servidor—reduzindo escritas no banco de dados de centenas para dezenas para uma sessão de digitação típica.
O servidor usa estratégias de broadcast diferentes: atualizações de posição são transmitidas imediatamente para arrastar colaborativo suave (otimista), enquanto operações estruturais (adicionar blocos, conectar arestas) persistem primeiro para garantir consistência (pessimista).
Lado do Cliente: Atualizações Otimistas com Reconciliação
Clientes mantêm cópias locais do estado do workflow em stores Zustand. Quando você arrasta um bloco ou digita em um campo de texto, a UI atualiza imediatamente—isso é renderização otimista. Simultaneamente, o cliente enfileira uma operação em uma store de fila de operações separada para enviar ao servidor.
O cliente não espera confirmação do servidor para renderizar mudanças. Em vez disso, assume sucesso e continua. Se o servidor rejeita uma operação (falha de permissão, conflito, erro de validação), o cliente reconcilia tentando novamente ou revertendo a mudança local.
É por isso que a edição de workflow parece instantânea—você nunca espera uma ida e volta de rede para ver suas mudanças. A desvantagem é complexidade adicional em torno de lidar com reconciliação, tentativas e resolução de conflitos.
A Fila de Operações: Confiabilidade Através de Tentativas
No coração do sistema multiplayer do Sim está a Fila de Operações—uma abstração do lado do cliente que garante que nenhuma operação seja perdida, mesmo sob condições de rede ruins.
Como Funciona
Cada ação do usuário que modifica o estado do workflow gera um objeto de operação:
{ id: 'op-uuid', operation: { operation: 'update', // ou 'add', 'remove', 'move' target: 'block', // ou 'edge', 'subblock', 'variable' payload: { /* dados da mudança */ } }, workflowId: 'workflow-id', userId: 'user-id', status: 'pending' }
Operações são enfileiradas em ordem FIFO. O processador de fila envia uma operação por vez sobre o WebSocket, aguardando confirmação do servidor antes de prosseguir para a próxima. Edições de texto (valores de subblocos, campos de variáveis) são debounced no cliente e coalescidas no servidor—um usuário digitando um prompt de 500 caracteres gera ~10 operações em vez de 500.
Operações falhas tentam novamente com backoff exponencial (mudanças estruturais recebem 3 tentativas, edições de texto recebem 5). Se todas as tentativas falham, o sistema entra em modo offline—a fila é limpa e a UI fica somente leitura até o usuário atualizar manualmente.
Lidando com Operações Dependentes
O verdadeiro poder da fila de operações emerge ao lidar com conflitos entre colaboradores. Considere este cenário:
Usuário A deleta um bloco enquanto Usuário B tem uma atualização de subbloco pendente para esse mesmo bloco em sua fila de operações.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ User A │ │ Server │ │ User B │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ Delete Block X │ │ ├─────────────────────────────────>│ │ │ │ │ │ │ Persist deletion │ │ │ ────────────┐ │ │ │ │ │ │ │<─────────────┘ │ │ │ │ │ │ Broadcast: Block X deleted │ │ ├─────────────────────────────────>│ │ │ │ │ │ Cancel all ops for X │ │ │ (including subblock) │ │ │ ────────┤ │ │ │ │ │ Remove Block X │ │ │ ────────┤ │ │ │
Aqui está o que acontece:
- A operação de deletar do Usuário A chega ao servidor e persiste com sucesso
- O servidor transmite a deleção para todos os clientes, incluindo Usuário B
- O cliente do Usuário B recebe a transmissão e imediatamente cancela todas as operações pendentes para o Bloco X (incluindo a atualização de subbloco)
- Então o cliente do Usuário B remove o Bloco X do estado local
Nenhuma operação é enviada ao servidor para um bloco que não existe mais. O cliente proativamente remove todas as operações relacionadas da fila—tanto operações em nível de bloco quanto operações de subbloco. O Usuário B nunca vê um erro porque a operação obsoleta é silenciosamente descartada antes de ser enviada.
Isso é mais eficiente do que validação no servidor. Ao cancelar operações dependentes localmente ao receber uma transmissão de deleção, evitamos desperdiçar requisições de rede em operações que falhariam de qualquer forma.
Resolução de Conflitos: Timestamps e Determinismo
Alinhado com nosso objetivo de manter as coisas simples, Sim usa uma estratégia último-escritor-ganha com ordenação baseada em timestamp. Cada operação carrega um timestamp gerado pelo cliente. Quando conflitos ocorrem, a operação com o timestamp mais recente tem precedência.
Isso é mais simples do que a abordagem de transformação operacional do Figma, mas suficiente para nosso caso de uso. Construção de workflow tem densidade de conflito menor do que edição de texto—usuários tipicamente trabalham em partes diferentes da tela ou blocos diferentes.
Conflitos de posição são tratados com ordenação de timestamp. Se dois usuários simultaneamente arrastam o mesmo bloco, ambos os clientes renderizam suas posições locais otimisticamente. O servidor persiste ambas as atualizações com base em timestamps, transmitindo cada uma em sequência. Clientes recebem as posições conflitantes e convergem para o timestamp mais recente.
Conflitos de valor (editar o mesmo campo de texto) são mais raros mas usam último-a-chegar-ganha. Atualizações de subblocos são coalescidas no servidor dentro de uma janela de 25ms—qualquer atualização que chegue ao servidor por último dentro dessa janela é persistida, independentemente do timestamp do cliente.
Desfazer/Refazer: Um Wrapper Fino Sobre Sockets
Desfazer/refazer em ambientes multiplayer é notoriamente complexo. Desfazer deve sobrescrever mudanças de outros? O que acontece quando você desfaz algo que alguém mais modificou?
Sim toma uma abordagem pragmática: desfazer/refazer é uma pilha local, por usuário que gera operações inversas enviadas através do mesmo sistema de sockets que edições regulares.
Como Funciona
Cada operação que você executa é registrada em uma pilha de desfazer local com sua inversa:
- Adicionar bloco → Inversa: Remover bloco (com snapshot completo do bloco)
- Remover bloco → Inversa: Adicionar bloco (restaurando do snapshot)
- Mover bloco → Inversa: Mover bloco (com posição original)
- Atualizar subbloco → Inversa: Atualizar subbloco (com valor anterior)
Quando você pressiona Cmd+Z:
- Remove a operação mais recente da sua pilha de desfazer
- Empurra para sua pilha de refazer
- Executa a operação inversa enfileirando-a através da fila de operações
- A operação inversa flui através do sistema de sockets normal: validação, persistência, transmissão
Isso significa que desfazer é apenas outra edição. Se você desfaz adicionar um bloco, Sim envia uma operação "remover bloco" através da fila. Outros usuários veem o bloco desaparecer em tempo real, como se você tivesse deletado manualmente.
Coalescência e Snapshots
Operações consecutivas do mesmo tipo são coalescidas. Se você arrasta um bloco pela tela em 50 pequenos movimentos, apenas as posições inicial e final são registradas—pressionar desfazer move o bloco de volta para onde você começou a arrastar, não através de cada posição intermediária.
Para operações de remoção, fazemos snapshot do estado completo da entidade removida (incluindo todos os valores de subblocos e arestas conectadas) no momento da remoção. Este snapshot viaja com a entrada de desfazer. Quando você desfaz uma deleção, restauramos do snapshot, garantindo reconstrução perfeita mesmo se a estrutura do workflow mudou no interim.
Semântica de Desfazer Multiplayer
Pilhas de desfazer são por usuário. Seu histórico de desfazer não inclui mudanças de outros. Isso corresponde às expectativas do usuário: Cmd+Z desfaz suas ações recentes, não as do seu colaborador.
O sistema poda operações inválidas da sua pilha quando entidades são deletadas por colaboradores. Se o Usuário B tem "adicionar aresta ao Bloco X" em sua pilha de desfazer, mas o Usuário A deleta o Bloco X, essa entrada de desfazer se torna inválida e é automaticamente removida já que o bloco alvo não existe mais.
Um caso interessante: você adiciona um bloco, alguém conecta uma aresta a ele, e então você desfaz sua adição. O bloco desaparece junto com a aresta deles (por causa de restrições de chave estrangeira). Isso está correto—seu bloco não existe mais, então arestas referenciando-o não podem existir também. Ambos os usuários veem o bloco e a aresta desaparecerem.
Durante execução, operações de desfazer são marcadas como em progresso para prevenir gravação circular—desfazer não deve criar uma nova entrada de desfazer para a operação inversa em si.
Conclusão
Construir edição de workflow multiplayer exigiu repensar suposições sobre como construtores de workflow deveriam funcionar. Ao aplicar lições da ferramenta de design colaborativo do Figma ao domínio de workflows de agentes de IA, criamos um sistema que parece rápido, confiável e natural para equipes construindo juntas.
Se você está construindo edição colaborativa para dados estruturados (não apenas texto), considere:
- Se complexidade OT/CRDT é necessária para sua densidade de conflito
- Como separar atualizações de valor de alta frequência de mudanças estruturais
- Quais garantias seus usuários precisam em torno de persistência de dados e edição offline
- Se expor status de operação constrói confiança no sistema
Construção de workflow multiplayer não é mais uma curiosidade técnica—é como equipes deveriam trabalhar juntas para construir agentes de IA. E a infraestrutura para torná-la confiável e rápida é mais acessível do que você pode pensar.
Interessado em como o sistema multiplayer do Sim funciona na prática? Tente construir um workflow com um colaborador em tempo real.

