Web — parte 3 (desde RN-239)
PROGRAMA 28 — Onda 4, frente E2: virtualização de linha e minimapa na aba Code (RN-239..242)
RN-239 — Virtualização de linha: só a janela visível vira nó de DOM
CodeEditor.tsx renderiza só as linhas dentro da janela visível (mais
OVERSCAN = 20 de margem) como [data-line-row]; o resto vira dois
espaçadores que reservam a altura sem existir como nó. A decisão de NÃO
usar react-window/react-virtual: a altura de linha é FIXA
(ALTURA_LINHA = 21, monoespaçado), o que reduz "qual linha está visível"
a uma divisão inteira sobre scrollTop — o problema que essas libs
resolvem de verdade é altura VARIÁVEL por item, que não é o caso da aba
Code.
- Onde:
apps/web/src/routes/code/CodeEditor.tsx(linhas 38-96, 45, 48) - Teste:
apps/web/src/routes/code/CodeEditor.test.tsx— describe "CodeEditor — virtualização", "caminho feliz: arquivo de 5.000 linhas renderiza uma janela pequena de nós, não o arquivo inteiro" (< 150nós[data-line-row]) e "rolar para o meio do arquivo troca a janela — linha 1 some, linhas do meio aparecem"
RN-240 — Sem medição de altura, degrada para janela padrão generosa, nunca renderiza tudo
Quando containerHeight ainda não foi medido (1º render, ou jsdom sem
ResizeObserver em teste), a janela cai para LINHAS_SEM_MEDICAO = 40 —
generosa o bastante para não truncar os arquivos pequenos que os testes
usam, pequena o bastante para nunca virar "renderiza tudo" num arquivo
grande de verdade.
- Onde:
apps/web/src/routes/code/CodeEditor.tsx(linha 57) - Teste:
apps/web/src/routes/code/CodeEditor.test.tsx— "falha/borda: sem medição de altura (jsdom sem ResizeObserver, clientHeight 0), degrada para uma janela padrão em vez de não renderizar nada"
RN-241 — Minimapa reusa a MESMA tokenização da virtualização — zero segundo passe sobre o arquivo
minimap.ts não tokeniza nada: ele resume a saída que highlight.ts já
produziu (HighlightToken[][] de highlightFile, chamada uma vez só) —
a MESMA que a virtualização usa para colorir as linhas visíveis.
Tokenizar de novo só para o minimapa pagaria o custo de leitura do
arquivo pela segunda vez, e por isso o minimapa só entra depois de a
virtualização estar de pé, nunca antes.
- Onde:
apps/web/src/routes/code/minimap.ts(linhas 1-18, 39) - Teste:
apps/web/src/routes/code/minimap.test.ts
RN-242 — Minimapa é CANVAS, um nó de DOM só, nunca um <div> por linha
O desenho é em <canvas> (desenharMinimapa), não em elemento por
linha: um minimapa com um nó por linha dobraria de novo a contagem que a
virtualização (RN-239) acabou de cortar. Sem contexto de canvas 2D
disponível (jsdom sem o pacote canvas), o overlay clicável continua
funcionando — só o desenho é pulado, nunca a interação.
- Onde:
apps/web/src/routes/code/minimap.ts(linhas 14-16, 91) - Teste:
apps/web/src/routes/code/CodeEditor.test.tsx— describe "CodeEditor — minimapa", "caminho feliz: clicar no minimapa rola o editor e troca a janela renderizada" e "falha: sem contexto de canvas 2D (jsdom sem o pacotecanvas), o overlay continua clicável e nada quebra"
PROGRAMA 28 — Onda 4, frente H4: os colapsos ad-hoc restantes migram para o Disclosure (RN-249..251)
RN-249 — Alvo de clique do marco com detalhe sobe de 20px para o piso de 24px do Disclosure
O marco COM detalhe de AgentTimelineTree tinha min-height: 20px no
cabeçalho clicável — abaixo do WCAG 2.2 AA 2.5.8 (Target Size, mínimo
24px) que um comentário anterior já prometia sem cumprir. A migração
para o Disclosure compartilhado corrige isso de graça: o componente
genérico já nasce em 24px, e não é preciso lembrar de setar a régua em
cada novo consumidor.
- Onde:
apps/web/src/components/AgentTimelineTree.module.css(linhas 135-155,.marcoLinha) - Teste:
apps/web/src/components/AgentTimelineTree.test.tsx - ADR: nenhum — correção pontual de a11y, mesmo padrão do ADR 0036
RN-250 — Faixa de arquivo do diff em ApprovalCard NÃO migra para o Disclosure — a animação própria é a razão
A faixa de arquivo do diff em ApprovalCard gira o chevron por
transform: rotate(90deg) com transição própria
(.chevron.open/ApprovalCard.module.css); o Disclosure genérico
TROCA o ícone (seta direita → seta baixo) sem animação nenhuma. Forçar a
migração apagaria a micro-interação sem ganho — a exclusividade (só um
arquivo aberto por vez) já vem de fora (expandedFile), o mesmo que o
Disclosure controlado faria. O que faltava, e não era peculiaridade —
é o mesmo defeito que o Disclosure existe para fechar —, era
aria-controls apontando para uma região nomeada: corrigido diretamente,
sem trocar de componente.
- Onde:
apps/web/src/components/ApprovalCard.tsx(linhas 511-539) - Teste:
apps/web/src/components/ApprovalCard.test.tsx
RN-251 — Cinco call sites migram para o Disclosure compartilhado; o componente ganha testId como único hook novo
ModelCatalogSection (a referência original que gerou o componente na
FASE 16), AgentTimelineTree (ramo + marco), code/CodeExplorer.tsx
(pasta da árvore) e code/CodeShell.tsx (painel inferior) passam a usar
o Disclosure compartilhado — nenhuma das seis implementações ad-hoc que
sobreviveram à FASE 16 fica de fora, exceto a exceção declarada da
RN-250. testId é o único prop novo que um consumidor precisou (data- testid no cabeçalho, para os testes existentes continuarem
selecionando o botão certo sem reescrever a suíte inteira). A região
controlada existe no DOM mesmo fechada — só hidden e sem os filhos
montados —, porque aria-controls apontando para um id que não resolve
é pior que não ter o atributo (leitor de tela anunciaria controle de
algo inexistente); é essa mesma propriedade que permite colapsar listas
caras (o catálogo do OpenRouter tem 338 modelos) sem montar o que
ninguém está vendo.
- Onde:
apps/web/src/components/ui/Disclosure.tsx(linhas 33-71),apps/web/src/components/ModelCatalogSection.tsx,apps/web/src/components/AgentTimelineTree.tsx,apps/web/src/routes/code/CodeExplorer.tsx,apps/web/src/routes/code/CodeShell.tsx - Teste:
apps/web/src/components/ui/Disclosure.test.tsx,apps/web/src/routes/code/CodeExplorer.test.tsx,apps/web/src/routes/code/CodeShell.test.tsx
PROGRAMA 28 — Onda 5, frente G3: a tela do Chat RAG (RN-252..254, ADR 0082)
RN-252 — A tela do Chat RAG mostra as duas degradações honestas que o backend já declara, nunca as esconde
HybridSearchUseCase já devolve vectorAvailable: false quando o provider
de embedding não respondeu (RN-233) e GetRagCoverageUseCase já nunca
inclui timestamp de indexação (RN-237) — mas um contrato honesto no
backend não garante uma tela honesta se ela decidir não ler os dois
campos. ProjectRagTab mostra um aviso acima dos resultados quando
vectorAvailable é false (com vectorUnavailableReason, quando
existe), e RagCoveragePanel só renderiza contagem REAL (filesIndexed/
filesInRepo, sessionsIndexed/sessionsInProject) — nenhum texto do
tipo "reindexado há Xmin" nasce nesta tela, porque a resposta não carrega
esse dado e inventá-lo mentiria (mesma régua do ADR 0042 para nota de
modelo).
- Onde:
apps/web/src/routes/ProjectRagTab.tsx(linhas 27-31, 176-182),apps/web/src/components/rag/RagCoveragePanel.tsx(linhas 27-34) - Teste:
apps/web/src/routes/ProjectRagTab.test.tsx— "CASO DE FALHA (degradação honesta): vectorAvailable false avisa...";apps/web/src/components/rag/RagCoveragePanel.test.tsx— "CASO DE FALHA (degradação honesta): nunca escreve..." - ADR: 0082
RN-253 — Citação de origem session navega até o EVENTO exato, reusando o mecanismo do Psicólogo
RagCitationCard não inventa um segundo caminho de navegação: origem
{ kind: 'session', sessionId, eventId } chama useNavigate para
/projects/:projectId/sessions/:sessionId com search: { highlightEvent: eventId } — a MESMA rota e o MESMO parâmetro que os chips de evidência
do Psicólogo já usam (HypothesisCard.tsx, Fase 4b) para rolar o fio até
o evento e destacá-lo. Origem { kind: 'file' } mostra caminho e
headingPath como texto, sem link: a aba Código não tem hoje deep-link
por caminho, e construir essa navegação está fora do escopo desta
frente.
- Onde:
apps/web/src/components/rag/RagCitationCard.tsx(linhas 32-38, 49-56) - Teste:
apps/web/src/components/rag/RagCitationCard.test.tsx— "caminho feliz: origem de sessão navega até o evento exato ao clicar" - ADR: 0082
RN-254 — O botão de reindexar é maintainer/owner na TELA, espelhando a régua da rota
POST .../rag/reindex já exige role:maintainer (RN-238) — quem
garante é a api. ProjectRagTab espelha a régua no CLIENTE pelo mesmo
padrão que ProjectSettingsTab/ProjectApprovalsTab já usam para outros
gates de maintainer (useCurrentWorkspaceWithRole, já que não existe
hoje um papel de PROJETO no cliente, só o de workspace que a listagem
devolve): o botão "Reindexar agora" nem aparece para quem não é
owner/maintainer, em vez de aparecer desabilitado — reindexar dispara
N chamadas ao repositório do projeto e ao provider de embedding (mesma
régua "muda o que o produto gasta sem perguntar" do teto de paralelismo,
RN-083), e um botão visível mas sempre recusado só ensinaria a
ignorar o 403.
- Onde:
apps/web/src/routes/ProjectRagTab.tsx(linhas 43-44, 109) - Teste:
apps/web/src/routes/ProjectRagTab.test.tsx— "botão de reindexar só aparece para maintainer/owner..."; "maintainer vê e pode disparar a reindexação" - ADR: 0082
RN-268 — A aba Terminal mostra o estado REAL do container, nunca finge um terminal que não existe
O plano original desta frente era o terminal interativo completo, mas a
investigação confirmou que a FASE 25b continua cortada: nenhum serviço monta
/var/run/docker.sock, e mesmo depois do ADR 0081 (Onda 4) nada transiciona
project_containers em produção. Implementar um terminal que finge executar
comandos — ou que roda no mesmo container do monorepo do Brabo, a dívida que
o ADR 0055 já descreve como política e não isolamento — seria inventar
capacidade, o mesmo erro que os ADRs 0041/0042 já recusam para provider de
LLM e modelo de catálogo.
O que a aba GANHA é honesto: sob o texto explicativo que já existia (FASE
26b), CodeBottomPanel busca GET .../container/lifecycle (RN-267) só
enquanto a aba Terminal está aberta (enabled: aba === 'terminal' — sem
polling em segundo plano, a mesma disciplina de tráfego da RN-107) e mostra
o status com um Badge (provisioning/running/stopped/failed/
removed, em pt-BR), há quanto tempo, e o failureReason quando o estado é
failed. Projeto nunca provisionado — o caso comum hoje — mostra a frase
"ainda não foi provisionado", nunca um badge inventado.
- Onde:
apps/web/src/routes/code/CodeBottomPanel.tsx,apps/web/src/routes/code/CodeBottomPanel.module.css,apps/web/src/lib/api-client.ts(getContainerLifecycle),apps/web/src/lib/api-types.ts(CicloDeVidaDoContainer) - Teste:
apps/web/src/routes/code/CodeBottomPanel.test.tsx(describe "Terminal — o estado REAL do ciclo de vida do container") - Borda: o terminal interativo em si NÃO nasce aqui — é FASE 25b, que segue cortada e depende da parede física do container (o worktree do agente vivendo lá dentro), não desta rota de leitura.
- ADR: 0083
UX Designer — o quinto agente conversacional (RN-285..287, ADR 0087)
RN-287 — uxDesignerActive no roster, nas DUAS fontes (RN-090)
Mesmo critério de infraActive: handoff accepted endereçado a
"ux-designer" na sessão. apps/web/src/lib/agent-status.ts
(rosterFactsFromEvents/rosterFromFacts) ganhou o fato e o empurra
condicionalmente no roster como agente SOLO — sem pushAreaMembers
correspondente, porque ele não é área.
A RN-090 exige as DUAS fontes em sincronia (painel do time via event log; o
card do dashboard via ProjectCardSummary.roster, computado na api) para que
um agente novo não apareça num lugar e falte no outro. O fato entrou nas
duas: apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts
amplia a MESMA consulta de infraActive (um inArray(handoffs.toAgent, ['infra', 'ux-designer']) no lugar do eq único, partido em dois Set por
toAgent) — nenhuma consulta nova, a contagem de doze consultas constantes
que projects-summary.repository.spec.ts prova não cresce.
- Onde:
apps/engine/lib/engine/agents/ux_designer_server.ex,ux_designer_tools.ex,ux_designer_supervisor.ex;apps/engine/lib/engine/harness/agents.ex(identidade),artifact_schemas.ex(tipoprototipo_navegavel);apps/engine/lib/engine/application.ex;apps/engine/lib/engine_web/controllers/agent_command_controller.ex;apps/web/src/lib/agents.ts,agent-status.ts;apps/api/src/application/ports/projects-summary-repository.port.ts,apps/api/src/infrastructure/persistence/drizzle/projects-summary.repository.ts - Teste:
apps/engine/test/engine/agents/ux_designer_server_test.exs,ux_designer_tools_test.exs;apps/web/src/lib/agent-status.test.ts;apps/api/test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts(describe com os doistoAgentna mesma sessão) - ADR: 0087
Onda 1 do ADR 0104 — execution_mode em três valores, workspace verificado pelo runner
RN-428 — Carrossel de promoção de histórias sobrevive à janela de eventos
promocoesPendentes/o carrossel de promoção do PO (RN-148) não depende mais
de scan sobre a janela dos últimos 200 eventos de useSessionEvents. A fonte
de conteúdo/contagem é useBacklog (Story.proposedReady, por sessão,
completa e sem janela) — numa sessão longa, backlog.story_promotion_proposed
sai da janela enquanto a história continua pendente de verdade, e antes disso
o carrossel encolhia (ou sumia) silenciosamente. Mesma classe de bug que a
RN-180 já corrigiu para ContextAside.
Quando a story não está no backlog carregado (query ainda não respondeu), degrada story a story para o scan de janela de sempre. O ancoramento na timeline continua vindo do evento dentro da janela quando ele existir; se o evento que abriu a leva já saiu da janela, o carrossel ancora no TOPO do trecho visível em vez de sumir — nunca esconder um estado real por causa de corte de leitura.
- Onde:
apps/web/src/routes/SessionPage.tsx(useMemotimeline) - Teste:
apps/web/src/routes/SessionPage.carrossel-janela-estourada.test.tsx - ADR: nenhum — correção de bug, mesma classe já corrigida pela RN-180
- Origem: achado por uso real ("o chat falhou em mostrar o carrossel do PO")
RN-431 — O selo da aba Arquitetura conta pendência de validação, nunca "diagrama não gerado"
contagens.arquiteturaPendente (régua de abas) vem de
architecture.pendencies.length (divergência de validação cruzada
história↔módulo), não de c4Diagram.status === 'sem_diagrama'. As outras
três contagens da régua (promocoesPendentes, aprovacoesPendentes,
hipotesesPendentes) significam todas "algo espera SUA decisão" — gerar o
diagrama é trabalho do Arquiteto, não fila de decisão do usuário, e
badigar por isso seria ruído.
- Onde:
apps/web/src/routes/ProjectPage.tsx - Teste:
ProjectPage.test.tsx - ADR: nenhum
- Origem: decisão de design tomada ao extrair a aba Arquitetura da Visão Geral
RN-432 — Preferência de idioma vem no payload de login/refresh, nunca numa chamada extra
Revisada pela RN-891: a coluna aceita também
ruezh-Hans, e o padrão do web passou deenparapt-BR(ADR 0216).
Fundação de i18n da interface (Onda 6a de um programa maior — a extração em
massa das strings do resto do app é etapa separada, em paralelo). Coluna
locale em users ('pt-BR' | 'en', default 'pt-BR' — nunca flipa
silenciosamente quem já tem conta). EmitirSessaoUseCase é o ÚNICO ponto
que emite sessão (login, refresh, login social) — locale embutido no
corpo de /auth/login//auth/refresh a partir dele, sem round-trip
adicional. GET/PATCH /users/me/preferences existe como via REDUNDANTE,
pra AccountPage reafirmar o valor sem esperar o próximo refresh.
en é o idioma DEFAULT do app a partir de agora (react-i18next, nova
dependência — mesma régua de "dependência nova precisa de justificativa"
que mermaid/@xterm/xterm já seguiram); pt-BR continua disponível.
apps/web/src/lib/idioma.ts (mesmo desenho de tema.ts): o SERVIDOR é a
fonte de verdade, localStorage['brabo.locale'] é só cache pra evitar
flash de idioma errado no primeiro paint. Usuário sem conta ainda usa
navigator.language só como sugestão de EXIBIÇÃO, nunca persiste nada
antes de existir conta.
- Onde:
apps/api/src/application/use-cases/auth/emitir-sessao.use-case.ts,apps/api/src/interfaces/http/iam/user-preferences.controller.ts,apps/web/src/lib/idioma.ts,apps/web/src/lib/i18n.ts,apps/web/src/routes/AccountPage.tsx - Teste:
apps/web/src/lib/idioma.test.ts,apps/web/src/routes/AccountPage.test.tsx,apps/api/test/application/use-cases/auth/*.spec.ts,apps/api/test/interfaces/route-surface.spec.ts - ADR: nenhum — extensão aditiva de um choke point já existente, sem mudar o formato de sessão pra ninguém que não usa o campo novo
- Origem: pedido do dono do produto — interface e docs em inglês por padrão, com português preservado
O explorador de pasta do Runner vira três colunas, e a criação de um projeto runner deixa de esperar o passo final (RN-436/436)
Duas mudanças pedidas pelo dono do produto depois de testar a criação de um
projeto real: (1) o navegador de pasta existente
(FolderBrowserModal) era uma lista simples com breadcrumb, quando a
referência visual que ele mandou (um picker estilo GNOME Files/GTK, de três
colunas) já existia como handoff de design pronto pra adaptar; (2) "Procurar
pasta..." não funcionava na TELA DE CRIAÇÃO do projeto — só depois de
criado — porque o ticket do canal do Runner (ADR 0107) é ancorado a um
projectId que, até esta correção, só nascia na confirmação.
RN-436 — FolderBrowserModal vira um explorador de três colunas: atalhos, lista com um clique seleciona/duplo clique entra, e um painel de detalhes
O layout de lista única virou três colunas dentro do Modal size="full":
atalhos (Pasta pessoal, que chama diretorioInicial(); Raiz, /), a lista
central com breadcrumb, e um painel de detalhes à direita. O protocolo
(FsEntrada, em apps/web/src/lib/fs-browser-channel.ts) só tem
{ nome, isDir } — sem tamanho nem data de modificação — e não foi
estendido (mexeria em apps/engine/apps/runner, fora do escopo desta
entrega); o painel de detalhes só mostra o que dá pra derivar client-side:
nome, tipo, e a contagem de itens quando o item exibido é a pasta JÁ ABERTA
(nunca de uma pasta só selecionada, que exigiria uma chamada extra ao
runner).
Os DOIS gestos que antes estavam fundidos num único clique (que já navegava
pra dentro da pasta) foram separados: um clique agora só SELECIONA (destaca
o item e atualiza o painel de detalhes) e um duplo clique ENTRA. O botão
final ("Usar esta pasta", renomeado de "Selecionar esta pasta" — a chave
folderBrowserModal.select de pt-BR/en mudou junto) usa o item
selecionado quando houver um (selected.kind === 'dir' ? join(path, selected.name) : path) ou a pasta aberta no momento quando não houver
nenhum. A lista deixou de filtrar arquivos — a listagem inteira aparece
agora, com arquivos renderizados como <div aria-disabled> em vez de
<button>: visualmente apagados e sem gesto nenhum, porque só pasta
continua navegável/selecionável.
- Onde:
apps/web/src/components/FolderBrowserModal.tsx,FolderBrowserModal.module.css,apps/web/src/locales/{pt-BR,en}/terminal.json - Teste:
apps/web/src/components/FolderBrowserModal.test.tsx— clique único seleciona sem chamarlistarDiretoriode novo; duplo clique navega; arquivo aparece na lista mas não é alcançável porgetByRole('button'); atalhos "Pasta pessoal"/"Raiz"; e "Usar esta pasta" usa o selecionado quando houver, senão a pasta aberta - ADR: nenhum — redesenho de UI sobre um componente que já existia, sem mudança de protocolo nem de fronteira de dado (o ADR 0107 continua descrevendo o mecanismo por trás)
- Origem: pedido do dono do produto, depois de testar a criação de um projeto real e achar o navegador de pasta pouco parecido com um explorador de arquivos de verdade — referência visual enviada por ele (picker de três colunas, estilo GNOME Files/GTK)
RN-437 — No modo runner, "Procurar pasta..." cria o projeto ANTECIPADAMENTE, e a confirmação final reusa por SNAPSHOT de identidade
O ADR 0107 já tinha declarado esta lacuna na própria seção de Consequências:
sem projeto ainda, o ticket do canal (RN-429) não tem a quem se ancorar, e
"Procurar pasta..." caía sempre no estado "disponível depois que o projeto
existir" — mesmo no modo runner, onde a criação NÃO depende de validar
disco (ADR 0104, item 2). Fechado pelo ADR 0108:
NewProjectWizard.tsx (handleProcurarPasta) chama createProject ao
clicar "Procurar pasta..." quando modoDeWorkspace === 'runner' — usando o
caminho já digitado, ou um placeholder lexicalmente válido e claramente
provisório (/workspace-a-confirmar) quando o campo ainda está vazio, nunca
bloqueando o clique. O modo mounted não muda: continua com
projectId={null} até a confirmação, porque ali a validação de disco roda
na criação e criar cedo com caminho vazio devolveria a recusa da RN-422 num
momento em que o usuário nem pensou em caminho ainda.
Um SNAPSHOT de identidade ({ name, externalId, adotando } — nunca
caminhoLocal, cujo refino é o PROPÓSITO de navegar) decide quando reusar:
clicar "Procurar pasta..." de novo com a identidade intacta reabre o modal
com o MESMO projeto, sem criar outro; mudar o nome (ou voltar e trocar o
repositório a adotar) invalida o snapshot, e a próxima navegada cria um
projeto novo. handleConfirm aplica a mesma régua — reusa o projeto
já criado ao navegar quando o snapshot ainda bate, e só chama createProject
quando não há nada pra reusar. montarPayloadDeCriacao, função pura extraída
do que antes vivia só dentro de handleConfirm, é reaproveitada pelos dois
caminhos.
O pré-requisito do botão é dito ANTES do clique (AT-214): neste modo a lista
vem do agente local, então sem um brabo-runner rodando e conectado ao
projeto recém-criado o modal só consegue mostrar a espera e o onboarding. A
tela passou a dizer isso em TEXTO abaixo do campo (aviso-procurar-runner,
ADR 0064 — nunca tooltip), com o comando
brabo-runner --project <id> --dir <pasta> e a alternativa que não depende
de nada: digitar o caminho no campo, que o runner confirma ao conectar. O
mecanismo (a criação antecipada, o transporte pelo agente local) não mudou.
- Onde:
apps/web/src/routes/NewProjectWizard.tsx(handleProcurarPasta,handleConfirm,montarPayloadDeCriacao,snapshotDeIdentidade/mesmaIdentidade) - Teste:
apps/web/src/routes/NewProjectWizard.test.tsx, describenavegação de pasta antecipada no modo Runner— cria o projeto e abre o modal com o id real; usa o placeholder quando o campo está vazio; clicar de novo sem mudar nada não cria outro; mudar o nome invalida o snapshot e cria um projeto diferente;handleConfirmreusa em vez de duplicar. O teste preexistente "'Procurar pasta...' mostra o estado declarado" (modomounted) continua verde, sem alteração — prova que o escopo não vazou - ADR: 0108 — mudança estrutural (QUANDO um projeto passa a existir no fluxo de criação), com o efeito colateral aceito de projeto "não provisionado" órfão quando o wizard é fechado sem terminar (o MESMO estado que qualquer criação interrompida hoje já produz, não uma regressão nova)
- Origem: pedido do dono do produto, depois de testar a criação de um projeto real no modo Runner e não conseguir navegar pastas antes de confirmar — a lacuna que o ADR 0107 já tinha declarado e adiado
RN-438 — fs-browser-channel.ts tinha o MESMO bug de path duplicado que a RN-433 já tinha corrigido no terminal-channel.ts irmão
Achado ao verificar a RN-437 ponta a ponta no Chrome, contra o engine real:
o modal abria com o projectId certo, mas a conexão caía na hora com "A
conexão com o runner caiu — feche e reabra para tentar de novo." em vez de
mostrar RunnerOnboardingPanel. apps/web/src/lib/fs-browser-channel.ts
concatenava '/runner/websocket' a um engineWsUrl que a api já devolve
PRONTO (ws://host:porta/runner, de engineWsUrlPublico()) — o mesmo
Socket do phoenix.js ainda acrescenta /websocket sozinho no
construtor, e o resultado batia no engine como GET /runner/runner/websocket/websocket, que Phoenix.Router.NoRouteError
recusa. É EXATAMENTE o item 3 da RN-433, só que no módulo IRMÃO — a
correção de lá nunca tinha sido replicada aqui, e nenhuma suite pegava
porque este módulo não tinha teste nenhum até agora (o próprio docblock
dizia, incorretamente, que terminal-channel.test.ts cobria "indiretamente").
Fix: mesma linha da RN-433 — engineWsUrl.replace(/^http/, 'ws'), sem
concatenar path nenhum.
Verificado END TO END contra um projeto runner real (owner logado,
teste-navegacao, criado pela RN-437): antes da correção, o modal caía
direto no erro de conexão; depois, mostra RunnerOnboardingPanel com o
comando brabo-runner --project <id-real> --dir <pasta> em menos de 2s —
o resultado esperado sem um runner de verdade conectado.
- Onde:
apps/web/src/lib/fs-browser-channel.ts - Teste:
apps/web/src/lib/fs-browser-channel.test.ts(novo — o módulo não tinha teste próprio nenhum até esta correção). Caminho feliz confirmasocket.url === engineWsUrlsem concatenação; maisdiretorioInicial/listarDiretorioroundtrip, falha ao buscar ticket, efechar()idempotente - ADR: nenhum — restaura o comportamento que o ADR 0103/0107 e a RN-433 já declaravam como intenção, só que num módulo que a correção anterior não alcançou
- Origem: achado testando manualmente a RN-437 (criação antecipada de
projeto
runner) contra o Chrome e o engine real — a primeira vez queFolderBrowserModalfoi exercitado ponta a ponta contra uma conexão de verdade, porque antes desta entrega "Procurar pasta..." nunca alcançava um projeto real antes da confirmação
Pasta local anexada vira o quarto escopo do índice RAG, lido pelo NAVEGADOR (RN-455..457, ADR 0113)
RN-456 — Teto agregado REJEITA (400) o upload inteiro; arquivo individual grande/binário só é PULADO
Diferente de docs/adr (uma varredura em background, sem ninguém
olhando), anexar uma pasta é um gesto ÚNICO com um seletor de pasta na
tela. IndexLocalFolderUseCase recusa (400) o lote inteiro quando a
quantidade de arquivos (RAG_LOCAL_FILE_COUNT_LIMIT, 500) ou os bytes
somados (RAG_LOCAL_TOTAL_BYTES_LIMIT, 8 MiB) estouram — nunca trunca em
silêncio, porque quem clicou "Anexar" pode escolher uma pasta menor. Um
arquivo individual grande demais (RAG_LOCAL_FILE_BYTES_LIMIT, 512 KiB) ou
de extensão não reconhecida (RAG_LOCAL_ALLOWED_EXTENSIONS, allowlist) é
só PULADO (filesSkipped), nunca derruba o lote — a mesma distinção que
IndexProjectDocsUseCase já faz implicitamente ao filtrar só .md.
Caminho com .. ou barra inicial é RECUSADO (400), nunca aceito
silenciosamente, mesma disciplina de nunca confiar em caminho vindo do
cliente (RN-092/095) mesmo quando ele "não deveria" conter isso.
apps/web/src/lib/rag-local-limits.ts espelha os mesmos números no
cliente, só como conveniência de UX (resumo antes de enviar) — quem
garante de verdade é o servidor.
- Onde:
apps/api/src/domain/rag/rag-search-limits.ts(RAG_LOCAL_*),apps/api/src/application/use-cases/rag/index-local-folder.use-case.ts - Teste:
apps/api/test/application/use-cases/rag/index-local-folder.use-case.spec.ts(tetos de quantidade e bytes somados rejeitam; arquivo grande/binário é pulado; caminho com../barra inicial é recusado) - ADR: 0113
- Origem: pedido do dono do produto
RN-460 — A faixa de atividade do turno narra em tempo real, e o fio só recebe a resposta no fim
A tela de Sessão mostra, ACIMA do composer, uma faixa que narra em
linguagem humana o que um agente conversacional (Criativo, PO, Arquiteto,
Dev Lead, UX Designer, Staff) está fazendo DURANTE o turno — referência
visual: a linha de status do Claude Code. O fio só recebe a bolha de
resposta DEPOIS que o turno termina; a regra é do CHAT (canal Phoenix) e
vale só para os seis agentes conversacionais — o chat consultivo sem
agente ativo (SSE, streamChatMessage) continua com a bolha de streaming
de sempre, intocada. (Desde a RN-682 a tela não chama mais esse
SSE: a consultiva sem agente pede um agente antes de enviar.)
Mecanismo (engine): os seis servers já emitem tool.call DURÁVEL no
event log; passam a também fazer broadcast(state, "tool.call", %{tool: name, agent: @agent}) — EFÊMERO, sem args (nunca payload cru, mesma
régua da RN-096/RN-412) — logo depois, pro canal session:<id> entregar
em tempo real. Não há behaviour/macro compartilhado entre os seis
dispatch_tool (quatro formas estruturais distintas confirmadas por
leitura completa): a mudança é seis edições adaptadas à forma local de
cada um.
Mecanismo (web): um reducer PURO
(reduzirAtividadeDoTurno/lib/atividade-do-turno.ts) acumula o texto do
agent.delta como "corrente"; ao chegar um tool.call do canal, arquiva
o corrente não-vazio como linha de NARRAÇÃO, zera, e SEMPRE adiciona uma
linha de FERRAMENTA com fraseDaFerramenta(tool) (dicionário das 19
ferramentas dos seis agentes, lib/narracao-de-ferramentas.ts, sem RN
própria — só existe pra servir esta regra) — duas chamadas consecutivas
sem delta entre elas viram duas linhas separadas, nunca uma. Fim de turno
reseta (finalizarTurnoDoAgente, ÚNICO ponto de reset). turnoViaCanal
é uma flag PRÓPRIA (nunca derivada de statusAgent/streamingAgent, que
passam por janelas legitimamente null no meio de um turno) que decide
se a faixa aparece OU a bolha antiga — nunca as duas.
Regra de apresentação (histórico): agruparNarracoesDoTurno, nova
passada pura que roda DEPOIS de afundarDesfechos (RN-172) no mesmo
useMemo — dentro de um mesmo turno+autor, agent.response
consecutivas viram um Disclosure compacto ("Passos do turno · N"),
deixando só a última intacta e fora dele. turno === 0 (o prólogo
sentinela de turnoDoSeq) fica de FORA do agrupamento, de propósito —
não é um turno de verdade, e fixtures antigas empilham agent.response
sem fronteira de turno pra testar outro mecanismo (RN-138/RN-177).
Função agnóstica a agente (só lê turno/autor), afundarDesfechos em
si não muda.
- Onde:
apps/engine/lib/engine/agents/{po,criativo,arquiteto, dev_lead,ux_designer,staff}_server.ex(broadcast detool.call);apps/web/src/lib/atividade-do-turno.ts(reducer);apps/web/src/components/TurnActivityStrip.tsx(componente);apps/web/src/lib/session-channel.ts:54(onToolCall);apps/web/src/routes/session-fio.tsx:48(agruparNarracoesDoTurno),apps/web/src/routes/SessionPage.tsx:191(turnoViaCanal) - Teste:
apps/web/src/lib/atividade-do-turno.test.ts,apps/web/src/components/TurnActivityStrip.test.tsx,apps/web/src/lib/session-channel.test.ts,apps/web/src/routes/SessionPage.ordenacao-e-avisos.test.tsx(agruparNarracoesDoTurno), suite deapps/engine/test/engine/agents/ *_server_test.exs(broadcast detool.callsem args crus) - Origem: pedido do dono do produto
Painel "precisa de você" (RN-467)
RN-467 — O painel das cinco filas de decisão NUNCA soma e NUNCA executa: separa por fila e encaminha para a decisão
O chip "Precisa de você", no topo do projeto, abre um painel com as CINCO filas de decisão do projeto — aprovações de ação, merges de PR, promoções de história, pendências de arquitetura e hipóteses do Psicólogo. Duas garantias o definem, e nenhuma das duas é estética:
Nunca soma. Cada fila tem cabeçalho e contagem PRÓPRIA, e não existe
total do projeto em lugar nenhum: nem no painel, nem no chip que o abre — o
chip anuncia PRESENÇA (um ponto, temAlgoEsperando, booleano por assinatura)
e nunca quantidade. É a mesma decisão de produto que já mantém os cinco
contadores do trilho separados (ADR 0126): somar apaga QUAL fila está pedindo
atenção, que é justamente a pergunta de quem abre o painel. montarFilas
devolve as cinco na ordem de urgência declarada (ORDEM_DAS_FILAS) e não
exporta função de total — a ausência é o mecanismo, não um esquecimento.
A única dedupe que existe é por IDENTIDADE, nunca entre filas: a MESMA
proposed_action de git_merge chega pelos dois hooks (usePendingActions é
da sessão e não filtra por tipo; useProjectPendingActions é project-wide) e
apareceria duas vezes na mesma lista, sob dois títulos. Fica no grupo mais
específico (prs).
Nunca executa. As duas filas acionáveis no painel (aprovacoes, prs)
renderizam o MESMO ApprovalCard da aba de Aprovações (uma variante só desde
a AT-322, com o detalhe recolhido),
e os botões chamam os mesmos endpoints de decisão — o painel é um ATALHO para
a decisão, nunca um substituto dela. Isso importa em especial para
git_merge: merge em branch protegida é rebaixado a require_approval
INCONDICIONALMENTE (apps/api/src/domain/actions/decide.ts), teto que nem
agent_autonomy nem permissions.json levantam, e nada no painel toca nesse
caminho. onActivateAutoMode é OMITIDO nos dois cards de propósito: ligar
"auto mode" (RN-153) é mudar POLÍTICA do agente, não decidir a ação que está
na frente — quem quer isso decide na aba de Aprovações, onde o papel de
workspace já é checado. As outras três filas não têm card de decisão fora do
contexto delas e por isso LEVAM à aba onde a decisão mora.
A pendência de arquitetura não tem data própria — nenhuma, em campo
nenhum (ArchitecturePendency): ela é visão DERIVADA do cruzamento entre
história e module_map, recalculada a cada leitura e nunca gravada. O painel
EMPRESTA a data da história relacionada e DIZ que emprestou ("história
atualizada há 18 min", dataEmprestada); sem a história no backlog carregado,
mostra "sem data" e ordena o item no FIM da fila. Em nenhum caso um instante
inventado: renderizar "agora" faria a linha mais urgente da tela ser a que
menos se sabe, sem nada denunciando a mentira. Acrescentar coluna e migração
na api resolveria de verdade e é decisão à parte, fora do escopo desta.
- Onde:
apps/web/src/lib/precisa-de-voce.ts:108(ORDEM_DAS_FILAS),:117(FILAS_ACIONAVEIS),:139(montarFilas— a dedupe por identidade e o empréstimo de data),:245(temAlgoEsperando);apps/web/src/components/PainelPrecisaDeVoce.tsx:246-248(role="dialog",aria-modal, rótulo) e:279-284(grupo por fila, com a contagem dela);apps/web/src/routes/ProjectPage.tsx:120(monta as filas a partir dos cinco hooks que os contadores do trilho já usam) - Teste:
apps/web/src/lib/precisa-de-voce.test.ts(as cinco filas separadas com os itens de cada uma; nada somado e nenhuma função de total; ogit_mergeduplicado aparece uma vez, na fila de PRs; ordem por espera mais longa; data emprestada marcada, ausente viranulle vai para o fim),apps/web/src/components/PainelPrecisaDeVoce.test.tsx(cinco cabeçalhos com a contagem de cada fila; o total não aparece em lugar nenhum; frase própria do vazio;ApprovalCarddecide pela sessão da PRÓPRIA ação; "Modo automático" não é oferecido; linha de arquitetura sem data renderiza sem quebrar;Esc, clique-fora, foco earia-expanded) - Origem: revisão de design do dono do produto (item #3 do canvas de melhorias de UI)
Estado de ambiente na tela (RN-468)
RN-468 — Sinal de ambiente diz o que SABE: workspaceVerifiedAt é registro de confirmação, nunca garantia de que o runner está vivo
O produto mostra estado de ambiente em dois lugares, e o recorte de cada um é a regra: a tela de login mostra só o que é verdade SEM identidade, e a Visão geral do projeto mostra o que só é verdade COM ela.
Pré-login, o recorte é imposto pelo escopo do dado, não por escolha
estética. Sobram os dois /health — públicos nos dois serviços de propósito
(@Public() na api; "Sem auth de propósito" no router.ex), os mesmos que
StatusPage já consome sem sessão. Presença de runner é chaveada por
{user_id, project_id} (runner_device_keys) e a lista de modelos é
projects/:projectId/models com papel viewer: antes do login não existe
nenhum dos dois sujeitos, então a tela declara a ausência ("Runner e
modelos locais dependem da sua conta e de um projeto") em vez de omiti-la —
omitir faria a plataforma parecer não ter o que ela tem. Os dois /health
mantêm os TRÊS estados separados (RN-088 aplicada a um sinal de ambiente):
verificando… não é sem resposta, e nenhum dos dois é respondendo. A
sonda tem TETO (6s): uma api que aceita a conexão e nunca responde deixaria a
linha em "verificando…" para sempre, o que é honesto por meio segundo e
mentira por omissão depois de dez.
E o formulário nunca espera pela sonda. O estado é local ao bloco, que é IRMÃO do card — api fora do ar muda um texto e não atrasa nem esconde um pixel do login. Isso importa exatamente no momento em que a api cai, que é quando alguém mais precisa que a tela ao menos ABRA.
Pós-login, a regra é sobre o que o dado NÃO afirma. O sinal de runner sai
de projects.workspace_verified_at — o carimbo que ConfirmProjectWorkspaceUseCase
grava quando um runner conecta e confirma a pasta (RN-423), e o MESMO campo
que o engine usa como portão (TerminalExecutor recusa comando em projeto
runner com o campo nulo). Ele não é batimento, por duas razões
independentes: não há processo sendo observado, e reconectar reportando o
mesmo caminho não regrava o carimbo (decisão explícita do caso de uso), de
modo que nem a data é "a última vez que o runner apareceu". A tela diz "pasta
confirmada em <data>" com a ressalva do que isso não é, e nunca "de pé",
"online" nem bolinha verde — o tom é neutro, e verde ali leria como uma
garantia de liveness que o dado não sustenta. Quem sabe do AGORA é o socket
do terminal, na aba Código (TerminalPanel/RunnerOnboardingPanel), e a
ressalva aponta para lá. Mesma disciplina que a RN-467 usou ao tomar
emprestado o updatedAt da história.
A linha do runner só existe em projeto no modo runner: nos outros dois o
campo é nulo por definição (a conversão de modo o zera, RN-450) e uma linha
"nunca confirmada" ali seria uma ausência inventada.
- Onde:
apps/web/src/components/SinaisDoAmbiente.tsx:72(useSaude— a sonda com teto e o.catchda rejeição de conexão),:133(SinaisDoAmbiente, o bloco pré-login);apps/web/src/components/AmbienteDoProjeto.tsx:92(AmbienteDoProjeto),:102(linha do runner, só no modorunner, com tomneutroe ressalva);apps/web/src/routes/AuthLayout.tsx:46(colunaDeIdentidade— nada focável ali, ou a primeira parada deTabdeixa de ser o campo de e-mail);apps/api/src/application/use-cases/iam/confirm-project-workspace.use-case.ts:93(o carimbo NÃO é regravado quando o caminho não muda — a razão de a data não ser recência) - Teste:
apps/web/src/components/SinaisDoAmbiente.test.tsx(os três estados distintos; rejeição de conexão vira "sem resposta" e não silêncio; resposta não-OK idem; teto da sonda pendurada; nenhum elemento focável; a ausência de runner/modelos é declarada),apps/web/src/components/AmbienteDoProjeto.test.tsx(sem linha de runner fora do modorunner; confirmado exige a ressalva e o bloco não contémde pé|online|conectado agora; nunca confirmado ensina o caminho; contagem de modelos locais no singular, no plural e no zero; chaves de consulta reusadas),apps/web/src/routes/LoginPage.ambiente.test.tsx(o formulário renderiza e submete com a sonda rejeitando E com a sonda pendurada; um<h1>só; a versão continua com uma fonte) - Origem: revisão de design do dono do produto (item #6 do canvas de melhorias de UI)
RN-469 — Salvar uma seção são N chamadas, e a tela nunca afirma o desfecho que não obteve
Nas seções de Configurações cujas linhas são um ajuste ESCALAR por chave
(Paralelismo por área e Teto de gasto por área, as duas por agent_areas),
o botão de salvar é UM, da seção, e não um por linha. Salvar dispara uma
chamada por linha suja — PUT .../areas/:key/max-parallel e
.../budget são endpoints por ÁREA, não existe endpoint transacional para "grave
estes N tetos", e este produto não inventou um. A consequência é que o
desfecho de um clique pode ser parcial, e a regra é sobre o que a tela pode
dizer então.
As chamadas são em SÉRIE, na ordem da tela, e uma falha não interrompe as seguintes. Em série porque o relatório de falha nomeia linhas e precisa sair na ordem em que a pessoa as vê. Sem interromper porque quem clicou pediu as N: abortar na primeira recusa deixaria linhas sem tentativa nenhuma, e a tela não teria como distinguir "a api recusou" de "nem chegou a tentar".
O desfecho é por LINHA, e é ele que a UI mostra. Só o rascunho que a api CONFIRMOU é descartado; o que falhou permanece no campo, com o que a pessoa digitou, e a seção continua marcada como não salva por exatamente essas linhas — clicar Salvar de novo tenta só elas. Os três desfechos são distintos e nenhum se disfarça de outro: todas passaram → sucesso; nenhuma passou → a mensagem que a API deu, nunca uma contagem; algumas passaram → aviso que diz quantas de quantas e nomeia as que ficaram. "Salvo" e "não salvo" seriam as duas mentira no terceiro caso, e é ele que esta RN existe para proteger.
A seção declara QUANTAS linhas estão pendentes antes do clique. Um botão
por seção diz "Salvar" igual com uma linha suja e com cinco; a contagem
("2 alterações não salvas nesta seção") é a contrapartida de ter trocado N
botões por um. Sujo é comparação por valor interpretado, não por texto:
20 e 20.0 são o mesmo teto, e comparar string mandaria uma chamada que a
api trata como no-op. Rascunho inválido conta como sujo e substitui a
contagem pela mensagem que explica o bloqueio — dois números sobre o mesmo
conjunto seriam ruído, e quem tem valor inválido precisa do que trava o botão.
Onde a regra NÃO vale, e por quê. Seção cujo controle é escolha de valor
NOMEADO (Promoção de história, Modelos por agente (com a caixa da área do lead, RN-912),
papel em Membros) salva no onChange, sem botão, e continua assim: a
confirmação existe para campo DIGITADO, onde salvar a cada tecla mandaria 1 a
caminho de 12. O que essa frase mede é de quem é o valor: ela vale para o
controle que grava o PRÓPRIO valor, e não alcança um seletor cujo valor é o
ARGUMENTO de uma ação sobre outras linhas — ver
RN-476, que é a exceção e diz por quê. E Credenciais mantém botão por linha apesar de também ter
drafts por chave: a credencial é write-only (ADR 0050) e nunca volta do
servidor, então não há valor com que comparar para decidir "sujo"; o botão da
linha alterna entre "Salvar" e "Trocar" conforme aquele provider já tenha
chave; e ele divide o card com "Testar" e "Remover", que são irredutivelmente
da linha.
- Onde:
apps/web/src/routes/settings/secao-salvavel.helpers.ts:64(useSecaoSalvavel— o laço em série, o desfecho por linha e os três toasts),:89(sujo por valor interpretado),apps/web/src/routes/settings/secao-salvavel.tsx:66(MarcaDeNaoSalvo— a contagem, e o inválido substituindo-a),apps/web/src/routes/settings/ParallelismSection.tsx:31,apps/web/src/routes/settings/BudgetSection.tsx:37(os dois consumidores),apps/web/src/routes/settings/CredentialsSection.tsx:57(por que esta seção ficou de fora) - Teste:
apps/web/src/routes/settings/secao-salvavel.test.tsx(a contagem de linhas sujas; voltar ao valor do servidor limpa a marca; inválido substitui a contagem e trava o botão; um clique persiste todas as linhas sujas; número enullna mesma leva; falha parcial diz quantas de quantas e nomeia a que ficou; a seção continua marcada pelas que falharam; o segundo clique tenta só elas; uma falha não interrompe as seguintes; nenhuma passando mostra a mensagem da API e não a contagem) - Origem: revisão de design do dono do produto (item #7 do canvas de melhorias de UI — "salvar por seção em vez de por linha")
RN-470 — A origem do modelo distingue binding próprio de herança do Criativo, e nenhum vazio da cascata usa o mesmo símbolo de outro
origin: 'agent' na resposta da api significa duas coisas diferentes, e a
api está certa em não separá-las: o agente tem uma linha de escopo agent em
model_bindings, ou a cascata pousou em workspace e
herdarModeloDeStart trocou o valor pelo modelo do Criativo — quem
lê "de onde veio" precisa ver que veio de um AGENTE, não de um escopo que não
existe no banco. Quem tem de separá-las é a tela, e até aqui ela não
separava: a coluna Origem imprimia o enum cru, e num projeto com 3 linhas de
agent no banco os 12 agentes mostravam a mesma palavra agent.
A origem é uma CADEIA, não uma palavra. workspace › projeto › área › agente com quatro estados por nó, e os quatro existem porque dizem coisas
diferentes: vigente (é daqui que sai o modelo em uso — o único nó em
Badge), definido (este nível tem valor próprio, mas um mais específico
venceu), vazio (nenhum valor aqui) e pulado (tinha valor e a cascata o
descartou por unavailable/sem_tool_calling, RN-043). Colapsar
definido em vazio devolveria o problema do enum cru um nível abaixo.
Na herança, o nó agente fica VAZIO e um nó extra nomeia o Criativo. O
nível que a cascata alcançou (workspace) aparece como definido e não
como vigente: o modelo do workspace não é o que vale, e marcá-lo vigente
afirmaria o modelo errado. O nó do Criativo é o único que não é escopo do
banco, de propósito — ele nomeia o passo pós-cascata. A mesma cadeia vale para
a seção de ÁREA, onde origin: 'agent' só pode ser esse passo: a consulta
de área não põe escopo de agente na cascata.
A derivação é do CLIENTE, e o que ela não consegue provar continua
acionável. Nenhum endpoint novo: a tela já busca os quatro níveis. agent é
herança quando nenhum nível acima do workspace tinha valor UTILIZÁVEL (o que
foi pulado não segura a descida) e o modelo é o mesmo do Criativo resolvido.
Sobra um caso indistinguível: agente com linha própria apontando para o mesmo
modelo do Criativo, sem padrão de área nem de projeto. Por isso "voltar a
herdar" continua aparecendo em toda origem agent — a ação segue disponível
justamente no caso que a cadeia não prova, e nele ela ainda muda o futuro
(sem a linha, o agente passa a acompanhar o Criativo).
A outra metade dessa escolha: o 404 tem desfecho próprio. Manter o botão em
toda origem agent significa oferecê-lo TAMBÉM onde não há linha para apagar —
e ali ClearModelBindingUseCase responde 404, corretamente ("apaguei o que
não existia" e "apaguei" não são a mesma resposta; colapsá-las esconderia um
agentSlug errado). Para quem clicou, porém, esse 404 não é falha: o estado
pedido — o agente herda — já é verdade, e chamá-lo de erro seria a tela
contradizendo o que ela sabe. Então ele não passa por mensagemDaApi como as
demais recusas, por dois motivos: a frase da api é pt-BR cravada no código e o
idioma default do web é en, e este endpoint tem uma causa de 404 (papel
insuficiente é 403 no RolesGuard, scope_id malformado não é 404) — o cliente
SABE o que este 404 significa e pode dizer na língua de quem lê. Qualquer outro
status continua sendo falha de verdade, com a frase da api e tom danger. Nos
dois desfechos a linha é relida: se a api diz que não havia binding, quem
está velha é a tela.
E o desfecho próprio é do ENDPOINT, não da tela. A outra ação da mesma
linha — trocar o modelo pelo ModelPicker da coluna MODELO VIGENTE — recusa
por sete caminhos, e nenhum status identifica um deles sozinho
(SetModelBindingUseCase): 400 para scope_id malformado ou modelId
reprovado no DTO, 403 para papel abaixo de developer, 404 para duas causas
indistinguíveis ("Modelo não encontrado" e "Projeto não encontrado") e 422
para três (RN-040 sem tool calling; RN-043 desativado no
workspace ou sumido do provider). Ali o cliente NÃO pode nomear o 404 —
escolher uma das duas frases seria a tela afirmando o que não sabe —, então
toda recusa segue a gramática normal, com a frase da api e tom danger. As
duas funções são vizinhas e tratam o mesmo status de formas opostas de
propósito: o que autoriza o desfecho próprio é a causa ÚNICA, não a tela.
Na recusa, a coluna MODELO VIGENTE continua exibindo o binding confirmado.
O ModelPicker não guarda a escolha em estado local (selected sai do prop
selectedModelId, que vem da query), então uma recusa não deixa na tela um
modelo que a api se recusou a gravar — e a linha só é relida no sucesso, já que
na recusa nada mudou no banco. É a mesma disciplina desta RN aplicada ao
tempo: a tela não afirma um estado que não existe, nem por traço, nem por enum,
nem por otimismo.
Vazio tem texto próprio, e vazios diferentes têm textos diferentes. Os três
— da tela diziam três coisas com o mesmo símbolo e agora dizem cada uma a
sua: sem modelo em nenhum nível (nenhum nível tem binding para o agente),
sem gasto ainda (o agente não registrou consumo — diferente de US$ 0,00,
que é ter rodado de graça) e sem padrão em nenhum nível (a área e os dois
níveis acima dela estão vazios). Mesma disciplina da
RN-180: a tela não afirma com um traço o que não
consegue nomear.
- Onde:
apps/web/src/routes/settings/cascata.helpers.ts:14(montarCadeia— os quatro estados e o nó do Criativo),:73(herdouDoCriativo— a dedução e seu limite),apps/web/src/routes/settings/cascata.tsx:196(CadeiaDeCascata),apps/web/src/routes/settings/ModelsSection.tsx:154(cadeiaDoAgente),:321(handleModelChange— por que aqui o 404 NÃO tem desfecho próprio, e por que a linha só relê no sucesso),:403(handleClearAgentBinding— os três desfechos, e por que o 404 tem o dele),:359(coluna Origem),:429(não há nível abaixo),:448(sem gasto ainda),apps/web/src/components/ModelPicker.tsx:95(selectedsai do prop — o picker não guarda a escolha, e é por isso que a recusa não deixa valor fantasma na tela),apps/api/src/application/use-cases/llm/set-model-binding.use-case.ts:38(as duas causas de 404 deste endpoint — este arquivo NÃO mudou),apps/api/src/interfaces/http/shared/llm-binding-error.filter.ts:39(400/422 das recusas de curadoria — este arquivo NÃO mudou),apps/web/src/routes/settings/ModelsSection.tsx(a cadeia do agente, que já desenha o nó da área),apps/api/src/application/use-cases/llm/clear-model-binding.use-case.ts:25(o 404 que a api levanta — este arquivo NÃO mudou),apps/api/src/domain/llm/binding-resolver.ts:140(oorigin: 'agent'que a api devolve, e o comentário que explica por que ele está certo — este arquivo NÃO mudou) - Teste:
apps/web/src/routes/settings/cascata.test.tsx(binding próprio termina no nóagente; herança deixaagentevazio e nomeia o Criativo; o workspace nunca aparece vigente na herança; a seção de área idem; a distinção sobrevive aoen; nível descartado vira nó riscado e o badge concorrente sumiu; os três vazios com três textos;montarCadeiaeherdouDoCriativocomo funções puras, incluindo o caso do padrão de área DESCARTADO, que não segura a descida),apps/web/src/routes/settings/voltar-a-herdar.test.tsx(os três desfechos do clique: apagou e relê; 404 não é erro, não repassa a frase pt-BR da api nem emen, e relê porque a tela é que estava velha; outro status vai pormensagemDaApie não relê),apps/web/src/routes/settings/troca-de-modelo.test.tsx(a outra ação da mesma linha: grava e relê; 422 do modelounavailable— o alcançável, já que o picker mostra o indisponível marcado — vai pormensagemDaApie não relê; a coluna MODELO VIGENTE segue no modelo antigo, nunca no recusado; e o 404 NÃO ganha desfecho próprio, que é o contraste com a função irmã) - Origem: revisão de design do dono do produto (item #9 do canvas de melhorias de UI — "cascata de modelo como cadeia visível")
A seção de Membros respeita o papel, e o papel é o EFETIVO do projeto (RN-471)
RN-471 — Papel na seção de Membros: maintainer, e derivado da linha do projeto sobre a do workspace
MembersSection não checava papel nenhum. As TRÊS ações da seção — convidar,
trocar o papel de alguém e remover — apareciam ativas para todo mundo, viewer
incluído, e a api recusava com 403. Duas delas nem chegavam a dizer isso:
handleRoleChange e handleRemove não tinham try/catch e eram chamadas de um
onChange/onClick, então toda recusa virava unhandled promise rejection —
silêncio na tela e ruído no console, a mesma classe de defeito que a
RN-102 fechou na tabela de modelos. Remover um
membro em silêncio é o pior dos três: é ação consequente e sem volta pela tela,
porque repor exige o UUID que a linha removida levava junto.
O mínimo é maintainer, e é do ENDPOINT. Os três caminhos pedem o mesmo
(projects.controller.ts), e GET pede viewer — por isso quem não edita
continua vendo a tabela inteira:
| ação | endpoint | papel |
|---|---|---|
| convidar | POST :projectId/members |
maintainer |
| trocar papel | POST :projectId/members (upsert) |
maintainer |
| remover | DELETE :projectId/members/:userId |
maintainer |
| ver a tabela | GET :projectId/members |
viewer |
Copiar o gate da seção de modelos (developer, e correto lá) ofereceria aqui os
três controles a quem a api recusa. É a SEGUNDA seção seguida cujo mínimo difere
da vizinha: a régua continua sendo o endpoint, nunca a tela ao lado.
O papel é o EFETIVO do PROJETO — e isto FECHA a lacuna que a RN-102 declarou,
não a repete. Lá a tela lê o papel de WORKSPACE enquanto quem autoriza é
ResolveEffectiveRoleUseCase.forProject, e fechar isso exigiria uma consulta de
papel por projeto que o web não tem. É a MESMA lacuna aqui, vista do outro lado:
esta seção JÁ faz essa consulta. listProjectMembers é findMemberRole para
todo mundo de uma vez, e userIdDaSessao() (o sub do access token) diz qual
linha é a de quem olha. A composição é literalmente a do caso de uso —
projectRole ?? workspaceRole —, não uma segunda fonte de papel inventada só
nesta tela. Enquanto a lista não chegou o papel é AUSENTE, não o de workspace:
sem ela não há como saber se existe linha própria, e errar para o lado de
desabilitar se conserta recarregando.
A sobreposição vale nos DOIS sentidos, e QUATRO lugares diziam o contrário.
forProject devolve a linha de project_members quando ela existe, sem comparar
com o workspace. Então definir viewer aqui rebaixa mesmo — até um owner de
workspace, que depois só é restaurado por quem tenha maintainer. O código nunca
fez o contrário, e o próprio resolve-effective-role.use-case.spec.ts fixa isso
("papel de projeto sobrepõe o de workspace"). Onde a promessa falsa estava:
| lugar | o que dizia | situação |
|---|---|---|
descrição de POST :projectId/members |
"the EFFECTIVE role is the higher of this one and what the person already has in the workspace"; "associating someone as viewer here doesn't downgrade a workspace owner" |
não corrigida — é mudança de api |
descrição de AddMemberDto.role |
a mesma frase do "higher of" | não corrigida — é mudança de api |
resumo de GET :projectId/members |
"with their effective role"; "includes whoever inherits access from the workspace" | não corrigida — é mudança de api |
apps/web/src/lib/roles.ts:3 |
"Papel efetivo nunca é rebaixado" | corrigida aqui |
As três da api ficam declaradas e não são tocadas de passagem: as descrições
propagam para api-types.generated.ts. A do web é PIOR que as outras três e por
isso foi corrigida junto — as da api ao menos moram numa descrição de OpenAPI,
enquanto essa estava no módulo que roleAtLeast e ROLE_ORDER exportam, ou
seja, no código que a próxima pessoa desta família de correções vai abrir e
editar, lendo a regra errada antes de qualquer outra coisa. A hierarquia linear
declarada na mesma linha continua válida — é a ordem que roleAtLeast compara, e
não mudou. A tela, por sua vez, DIZ o que vale, em vez de deixar o Select ser
lido como sugestão inofensiva.
Some o CONTROLE, nunca a INFORMAÇÃO (ADR 0064). Quem não tem maintainer
continua lendo o papel de cada membro no próprio Select apagado — é a
informação central da tabela, e trocá-la por texto esconderia o estado junto com
a ação. O motivo é dito UMA vez, em texto, na legenda: title em elemento
disabled não abre no Chromium, e uma linha por membro repetiria um fato sobre
QUEM OLHA em cima de cada pessoa da lista.
A tabela é um RECORTE, e declara
(RN-180). listMembers é um
innerJoin em project_members: quem alcança o projeto só pelo workspace não
aparece em linha nenhuma, embora o resumo do GET prometa "includes whoever
inherits access from the workspace". Esse dado NÃO está ao alcance do cliente —
nenhuma consulta do web lista membros de workspace com papel —, então a legenda
diz que a lista omite, em vez de deixá-la ser lida como "todo mundo que tem
acesso". É a lacuna que sobra, e ela é declarada, não tratada.
Nada disto é fronteira de segurança: quem recusa continua sendo o
RolesGuard.
Convidar mantém a dica fixa, e é decisão. As outras duas ações usam
mensagemDaApi porque o userId delas veio da lista e existe — o que sobra é
403 e rede, e a frase da api é a informação mais útil que há. No convite o
userId é DIGITADO, e o erro alcançável é apontar para um usuário inexistente:
um UUID bem formado passa pelo @IsUUID(), estoura a FK
project_members.user_id → users.id, nenhum dos filtros globais trata isso e o
Nest responde o 500 padrão. mensagemDaApi devolveria "Internal server error",
pior que a dica — o padrao dela só vale para erro que não é ApiError.
Uniformizar a forma pioraria o conteúdo no caminho mais provável da caixa.
- Onde:
apps/api/src/interfaces/http/iam/projects.controller.ts:121,:135,:147(os três papéis — estas linhas NÃO mudaram),apps/api/src/application/use-cases/iam/resolve-effective-role.use-case.ts:14(projectRole ?? workspaceRole, a sobreposição nos dois sentidos),apps/api/src/infrastructure/persistence/drizzle/project.repository.ts:194(listMembers— oinnerJoinque faz a tabela ser recorte),apps/web/src/routes/settings/MembersSection.tsx:62(o papel efetivo e por quemaintainer),:109(por que convidar não usamensagemDaApi),:142e:162(os doistry/catchque faltavam),apps/web/src/lib/roles.ts:49(roleAtLeast),:3(o comentário deROLE_ORDER, que afirmava "papel efetivo nunca é rebaixado" — o quarto lugar da tabela acima, e o único corrigido) - Teste:
apps/web/src/routes/settings/papel-na-secao-de-membros.test.tsx(vieweredevelopernão editam e o clique não chega na api;maintainereownereditam e o POST/DELETE chegam; papel ausente é inerte; o papel da linha sobrevive ao controle apagado; o PAR que prova a sobreposição nos dois sentidos —viewerde workspace com linhamaintainerEDITA,ownerde workspace com linhaviewerNÃO edita; as duas recusas viram toast com a frase da api; a tela não passa a exibir o papel que a api negou; e convidar mantendo a dica fixa contra um 500) - Origem: revisão da #443 — a lacuna que ela declarou, vista da seção que tem os dados para fechá-la
- Adendo (aditivo, o texto acima não mudou): as três descrições de OpenAPI que a tabela marca como "não corrigida" FORAM corrigidas depois, pela RN-472 / ADR 0127, na mesma mudança que pôs os dois tetos de rebaixamento na api. Não reabra essa linha da tabela.
A pasta vem primeiro, e o binário não é bloqueio (RN-473/474)
RN-473 — A configuração do runner pelo navegador começa pela PASTA, e a falha do binário nunca descarta o que já foi configurado
Revisada pela RN-687 (ADR 0203, 01/10): o fluxo que esta RN ordena SAIU —
runner-bootstrap.tse o teste dele foram removidos com o fluxo do navegador. O que sobrevive dela é a régua do passo humano dito ANTES, que o painel aplica ao comando do instalador.
O fluxo da RN-464..466 rodava na ordem chave → registro → binário → pasta (configurarPastaAutomaticamente,
apps/web/src/lib/runner-bootstrap.ts). Os quatro passos não têm o mesmo risco:
o download do binário é o ÚNICO que depende de uma release publicada no GitHub
(RunnerReleasesController, proxy de releases/latest), e com a release
corrente sem asset para plataforma nenhuma ele devolve 502. A exceção subia
antes de showDirectoryPicker, então o seletor de pastas nunca chegava a
abrir — o botão parecia inerte e a pasta ficava inalcançável, com a
configuração inteira perdida por causa do passo mais frágil.
A ordem passa a ser pasta → config → chave → registro → chave privada → binário, e o último passo é best-effort:
| passo | falha derruba o fluxo? | por quê |
|---|---|---|
1. showDirectoryPicker |
sim | sem pasta não há onde gravar nada. Cancelar (AbortError/NotAllowedError) NÃO é falha — volta ao estado inicial, sem alerta |
2. brabo-runner.config.json |
sim | é metade do par que a RN-466 lê |
3. par Ed25519 + registerRunnerDeviceKey + …device-key.jwk.json |
sim | privada em disco sem contraparte no servidor não autentica; por isso o registro vem ANTES da gravação, e as duas são adjacentes |
| 4. binário | não | devolve falhaDoBinario preenchido; a pessoa fica sem o agente local e a instrução final diz isso e aponta a issue em https://github.com/viralabs-dev/brabo-runner/issues (o runner sai só como binário, AT-774) |
O caminho alternativo funciona sem flag nenhuma exatamente porque os passos
2 e 3 já rodaram: o CLI lê os dois arquivos do cwd (RN-466). É um dos TRÊS
caminhos de distribuição que o CLAUDE.md declara, e o único que não depende
nem da release nem de um checkout do monorepo. O baixarKitManual (fora do
Chromium) segue a mesma régua invertendo a ordem dos dois downloads: o kit
sai primeiro, o binário por último.
O passo do terminal é humano, e a tela nunca finge o contrário. Uma página
web não executa binário na máquina de ninguém, e a File System Access API não
preserva o bit de execução — daí o chmod +x continuar no comando. O que o
produto faz é encolher esse passo a UMA linha copiável em um clique; nenhum
texto de UI diz "instalação automática".
E ele é anunciado ANTES do clique, não só depois. A frase que explica o passo existia desde o início, mas era renderizada apenas no estado de SUCESSO — depois de a pessoa escolher a pasta, esperar o registro da chave e o download do binário. Quem clica num botão chamado "Configurar pasta automaticamente" e só então descobre que ainda precisa abrir um terminal foi surpreendido, mesmo sem nenhuma frase ter mentido: anunciar no fim é o mais tarde possível para ainda não ser fingimento. Agora há um aviso no estado inicial, ao lado do botão, e o do fim CONTINUA — as duas dizem coisas diferentes (uma avisa que o passo virá, a outra explica por que ele existe). O rótulo do botão não mudou: ele fala da PASTA, que é de fato configurada automaticamente, e trocá-lo descreveria pior o que ele faz.
O comando final diz em que pasta rodar, quando dá para afirmar qual é. A
instrução dizia "dentro da pasta escolhida, rode: …" sem nunca dizer onde essa
pasta fica — a File System Access API expõe só o basename (dirHandle.name),
nunca o caminho absoluto. O caminho existe do outro lado: é o
projects.workspace_path que a pessoa digitou ao criar o projeto. Quando o
basename dele bate com a pasta escolhida, a instrução ganha um prefixo
cd <caminho> && ; quando NÃO bate, sai sem prefixo — nada obriga a pessoa a
escolher no seletor a mesma pasta que digitou, e um cd para o lugar errado
seria a tela afirmando o que não sabe. O basename é o máximo que se prova
daqui, e o caso raro em que ele coincide para pastas diferentes falha ALTO
(cd não acha o caminho, ou ./brabo-runner não está lá), nunca em silêncio —
que é a régua que decide se uma heurística pode entrar. O kit manual (fallback
fora do Chromium) não recebe o prefixo, e não é esquecimento: lá os
arquivos caem na pasta de downloads, e cd mandaria para onde eles ainda não
estão.
Lacuna declarada: fechar a aba entre o passo 3 e o fim deixa uma chave de
dispositivo órfã no projeto. Ela é inerte — a privada correspondente só
existiu na memória daquela aba, e sem ela a chave não autentica nada — mas hoje
é invisível: RunnerDeviceKeysController tem POST e DELETE, e nenhuma rota
de LISTAGEM, então não há tela onde revogá-la. Refazer o fluxo registra uma
chave nova, que funciona. Uma listagem é trabalho direto sobre o DELETE que já
existe, quando for pedido.
- Onde:
apps/web/src/lib/runner-bootstrap.ts(configurarPastaAutomaticamente,baixarKitManual,COMANDO_VIA_NPM),apps/web/src/components/RunnerOnboardingPanel.tsx(ehCancelamentoDoSeletor, o bloco de desfecho) - Teste:
apps/web/src/lib/runner-bootstrap.test.ts(a ordem dos quatro passos afirmada como lista; binário 502 e falha de GRAVAÇÃO do binário gravando os outros dois arquivos e devolvendo o comando alternativo; cancelamento do seletor sem registrar chave nem baixar nada; o kit saindo mesmo com o binário em 502),apps/web/src/components/RunnerOnboardingPanel.test.tsx(a pasta é anunciada com o motivo da falha ao lado; cancelar não vira alerta) - ADR: 0118 — esta RN revisa a ORDEM do fluxo que ele estabeleceu, não o mecanismo
- Origem: pedido do dono do produto
RN-474 — Depois da instrução, a tela ESPERA o runner: três estados, teto, e o caminho que vale é o que o runner reportou
Configurar a pasta e mostrar o comando deixava a pessoa sem saber se tinha dado
certo. O quarto passo é uma espera que resolve sozinha
(apps/web/src/components/EsperaDoRunner.tsx), montada pelo
RunnerOnboardingPanel assim que a configuração termina — nos dois caminhos
(pasta gravada e kit baixado).
O mecanismo é o que já existia. O sinal é project.workspaceVerifiedAt, o
carimbo que ConfirmProjectWorkspaceUseCase grava quando o runner conecta
(RN-423) e que o engine usa como PORTÃO (terminal_executor.ex
recusa executar em projeto runner com workspace_verified_at nulo) — a
definição do próprio produto de "este projeto tem runner". É o mesmo dado que
AmbienteDoProjeto já lê (RN-468), na mesma chave de cache
['project', id] que a página inteira mantém. A alternativa considerada foi
sondar connectFsBrowserChannel (cujo erro 'Nenhum runner conectado' o
FolderBrowserModal detecta): ela sabe do AGORA, que é mais forte, mas o canal
grava erroDeConexao de forma permanente por instância, então cada sondagem
custaria ticket + socket NOVOS — dezenas ao longo da espera — e ela ainda não
responderia a segunda metade do que a tela deve dizer: QUAL caminho o runner
reportou.
Confirmado é o carimbo MUDAR, nunca "existir". A espera tira uma linha de
base no primeiro GET e compara contra ela. Sem isso, um projeto já confirmado
antes seria anunciado como recém-conectado no instante em que a tela abrisse.
Os três estados não colapsam, e nenhum é eterno (RN-088, RN-468):
| estado | o que a tela diz |
|---|---|
esperando |
"procurando o runner", com o teto DECLARADO ("paramos depois de 3 minutos") |
confirmado |
a data do carimbo E o workspacePath que o runner reportou, dizendo que ele substitui o que foi digitado — a tela não compete com quem roda no host de verdade |
semResposta |
o teto estourou, e a tela declara o que NÃO sabe: reconectar com uma pasta já confirmada não regrava o carimbo, então ausência aqui não é prova de ausência, e quem sabe do agora é a aba Código. Um botão recomeça a busca sem refazer a configuração |
- Onde:
apps/web/src/components/EsperaDoRunner.tsx(INTERVALO_MS,TETO_MS, a linha de base embase),apps/web/src/components/RunnerOnboardingPanel.tsx(quem a monta) - Teste:
apps/web/src/components/EsperaDoRunner.test.tsx(os três estados, um a um, cada um afirmando que os outros dois NÃO estão na tela; o carimbo preexistente que não conta como conexão nova; o teto estourando com temporizador falso e o botão que recomeça) - ADR: 0118
- Origem: pedido do dono do produto
O kid é o vínculo, e a recusa tem nome (RN-475)
RN-475 — A JWK privada gravada pelo navegador carrega o kid do registro; e o CLI distingue "não há chave" de "há chave e ela não serve"
Revisada pela RN-687 (ADR 0203, 01/10): a metade do NAVEGADOR saiu com o fluxo (e o e2e que a provava num Chromium); o
kidcarimbado continua valendo para a chave criada no terminal (device-key finish --id, RN-551), e a metade do CLI — ausente × recusada — continua inteira.
O modo automático do ADR 0118 (RN-464..466) nunca autenticou nenhuma vez desde que nasceu. As duas metades desta RN são o defeito e o que o escondeu.
O kid não é decoração — é o único vínculo entre os dois lados. O id que
POST projects/:projectId/runner-device-keys devolve identifica o registro
runner_device_keys que guarda a chave PÚBLICA. Ele precisa chegar ao disco
DENTRO da JWK privada, no campo kid (RFC 7517), porque é assim que a cadeia
inteira o carrega: lerChaveDeDispositivo só REPASSA jwk.kid (nunca inventa
nem deriva um id),
assinarTicketComChaveDeDispositivo o põe no header protegido do JWT de
ticket, e o PatAuthGuard usa exatamente esse kid para achar a pública e
verificar a assinatura (RN-465).
O navegador descartava o retorno de registerRunnerDeviceKey nos DOIS
caminhos (configurarPastaAutomaticamente e baixarKitManual), e
crypto.subtle.exportKey exporta a JWK CRUA, sem kid nenhum. O produtor
gravava um arquivo que o consumidor recusa sempre — e o defeito era
invisível dos dois lados: cada função estava certa sozinha. A prova está no
banco: as chaves registradas pelo fluxo tinham last_used_at nulo, todas.
A correção é estrutural, não uma linha: o passo 3 da RN-473 (exportar
a pública, registrar, exportar a privada) vira UMA função
(registrarChaveEExportarPrivada), com o id fluindo dentro dela. Descartá-lo
de novo passa a exigir apagar código, não esquecer uma atribuição. O kid vai
só na PRIVADA: a pública não o leva, porque é o registro dela que o produz.
A recusa tem nome. lerChaveDeDispositivo devolve null sem lançar — de
propósito, e isso não muda: a ausência dos arquivos é o caminho NORMAL de quem
roda com --project/--dir/--token. Mas null respondia a duas perguntas
diferentes, e o CLI imprimia o MESMO bloco de uso() — que fala de flags e não
menciona o arquivo — tanto para "você não configurou nada" quanto para "sua
pasta está configurada e a chave não serve". Uma pessoa com
brabo-runner.config.json perfeito era mandada investigar justamente o lado
que estava certo.
| estado | quem responde | saída do CLI |
|---|---|---|
ausente |
caminho normal | o bloco de uso(), inalterado |
json-invalido |
recusa | explicacaoDaChaveRecusada — nomeia o arquivo, o motivo, e as duas saídas (regravar a pasta pelo navegador, ou --token enquanto isso) |
sem-kid |
recusa | idem, nomeando o campo que falta e para que ele serve |
valida |
segue o fluxo | — |
estadoDaChaveDeDispositivo e explicacaoDaChaveRecusada vivem em
device-key.ts porque falam do ARQUIVO; o que FAZER com a recusa (sair, com
qual código, com ou sem o bloco de uso) continua sendo do index.ts, como o
docblock do módulo já dizia. O tipo de explicacaoDaChaveRecusada exclui
ausente e valida: não existe explicação para o caso normal nem para o
sucesso, e um texto vago cobrindo os quatro estados seria o mesmo defeito de
novo.
O que deixou passar: o teste do web afirmava que o arquivo tinha sido
ABERTO (getFileHandle chamado com o nome certo), nunca o que havia DENTRO
dele. Um teste que prova a criação de um arquivo sem provar seu conteúdo deixa
gravar qualquer coisa. O dublê passou a amarrar nome → conteúdo, e as duas
asserções antigas ganharam a metade que faltava.
- Onde:
apps/web/src/lib/runner-bootstrap.ts(registrarChaveEExportarPrivada,exportarJwkPrivada),apps/runner/src/device-key.ts(estadoDaChaveDeDispositivo,explicacaoDaChaveRecusada,EstadoDaChaveLocal),apps/runner/src/index.ts(o ramo de credencial emlerArgumentos) - Teste:
apps/web/src/lib/runner-bootstrap.test.ts(okidgravado É oiddevolvido, provado com um id diferente do padrão; os dois caminhos — pasta e kit manual; a pública SEMkid; a chave útil sobrevivendo ao binário em 502),apps/runner/src/device-key.spec.ts(os quatro estados, um a um; ausente ≠ sem-kid enquantolerChaveDeDispositivocolapsa os dois emnull; as duas frases de recusa diferentes entre si),apps/runner/src/index.spec.ts(o CLI DE VERDADE, em processo separado: a pasta sem chave cai no bloco de uso, a pasta com chave semkidcai na recusa nomeada, e as duas saídas não são iguais — o defeito era elas serem) - ADR: 0118 — esta RN não revisa o mecanismo dele, fecha o contrato que ele deixou implícito entre as duas pontas
- Origem: uso real do fluxo contra o ambiente local pelo dono do produto
Estado de ambiente na tela (RN-468)
RN-476 — Aplicar um modelo a TODOS os agentes: um valor, N chamadas, e um botão apesar do valor ser nomeado
A tabela Modelos por agente tem uma linha por agente e um seletor em cada
uma. Escolher o mesmo modelo para os 17 era percorrer as 17 linhas, e o custo
disso não é o tempo: é que ninguém confere 17 dropdowns e a tela não tem como
dizer se sobrou um para trás. A seção ganha uma barra ACIMA da tabela — um
seletor e um botão — que aplica UM modelo a todos os agentes de uma vez.
A ação grava no nível do AGENTE, nas 17 linhas — não no projeto. As duas
leituras de "um modelo para todos" existem e produzem estados diferentes:
gravar no projeto e apagar os bindings de agente e de área faria os 17
HERDAREM, e é o idioma da própria cascata. Não foi o escolhido, por dois preços
concretos: PUT projects/:id/model-binding e os endpoints de área exigem
maintainer, enquanto o binding de agente exige developer — a mesma pessoa
que pode trocar linha a linha passaria a não poder fazê-lo de uma vez —, e o
binding de projeto é também o default da SESSÃO, que a
RN-040 deixa livre de propósito. A escolha
tem preço declarado: as 17 linhas passam a divergir, com origem agent na
coluna Origem, e voltar a herdar continua sendo linha a linha.
Há botão, apesar de o valor ser NOMEADO. A régua da
RN-469 diz que escolha de valor nomeado salva no onChange, e ela
continua valendo para o seletor de CADA LINHA, que grava o próprio valor. O
seletor da barra não é configuração de nada: ele é o argumento da ação ao lado.
Aplicar no onChange faria um clique exploratório num dropdown reescrever 17
linhas que a pessoa não estava editando, e desfazer isso são 17 cliques. O
botão NOMEIA quantas linhas vai alcançar, porque esse número é a consequência.
O desfecho segue a RN-469 inteira, e é a razão de a barra não inventar nada. Uma chamada por agente, em SÉRIE, na ordem da tela, sem abortar na primeira recusa — abortar deixaria 16 linhas sem tentativa e a tela não distinguiria "recusou" de "nem tentou". Os três desfechos não se disfarçam: todas passaram → sucesso nomeando o modelo; nenhuma passou → a mensagem da api, nunca uma contagem; algumas passaram → aviso com quantas de quantas, nomeando as que ficaram pelo NOME do agente, que é o que a pessoa lê na primeira coluna. Só as linhas que a api CONFIRMOU são relidas: invalidar as 17 apagaria da tela a diferença que o relatório parcial acabou de contar.
O gate é o do ENDPOINT, e o endpoint é o mesmo dos controles de linha.
developer, por PUT projects/:projectId/agent-bindings/:agentSlug — não o
maintainer da seção vizinha de área (RN-102).
O botão fica inerte para quem não alcança, e o motivo é dito UMA vez, em TEXTO,
na legenda da seção, junto com as outras duas ações que ficam inertes: title
em elemento disabled não abre no Chromium (ADR 0064). O filtro "aptos para
agentes" do seletor abre MARCADO, como no picker de linha e pelo mesmo motivo
(RN-040) — com mais força aqui, porque um
modelo chat-only escolhido na barra não produziria um 422, e sim 17, e o
relatório parcial contaria a mesma recusa dezessete vezes.
- Onde:
apps/web/src/routes/settings/aplicar-a-todos.tsx:66(useAplicacaoEmLote— o laço em série e os três desfechos),apps/web/src/routes/settings/ModelsSection.tsx(a barra, o estado local do modelo escolhido e a invalidação só das linhas confirmadas) - Teste:
apps/web/src/routes/settings/aplicar-a-todos.test.tsx(grava o mesmo modelo em todos os agentes, na ordem da tela; nenhuma passando mostra a mensagem da api e NÃO a contagem, e ainda assim tenta todas; falha parcial diz quantas de quantas e nomeia a que ficou; papel abaixo dedeveloperdeixa o botão inerte e o motivo continua em texto) - Origem: pedido do dono do produto ao configurar um ambiente zerado com um provider só — todos os agentes precisavam do mesmo modelo