diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce71ce9..acf1bb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,4 @@ jobs: - run: python -m pip install -r scripts/requirements-validation.txt - run: python scripts/check_bundle.py - run: python scripts/check_dashboard_pty.py + - run: python scripts/check_workspace_pty.py diff --git a/AGENTS.md b/AGENTS.md index f25784f..a8f40cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,3 +68,11 @@ Dashboard: ações `e` (contexto/exclusões), `a` (aplicar pacote), `p` (prompt) `l` (lançar com token) implementadas com prévia e confirmação; `--once` permanece somente leitura. Contratos em `docs/specs/dashboard.md` e ADR 0017. Cena animada, exportação de código pela TUI e ensaios humanos pendentes. + +TUI unificada: `workspace --demo --project --state ` é +protótipo funcional com eventos sintéticos, persistência e exportação v1 identificada +como `memory-bee-demo`; `--once` não grava. ADR 0018 prioriza esta experiência e +exige lançamento real conjunto Claude/Codex com assinaturas. Protocolo Codex +experimental em `src/workspace/codex.rs` não está ligado à CLI/TUI; integração +real, autenticação e isolamento continuam pendentes. Contrato em +`docs/specs/workspace.md`; teste automatizado adicional `python scripts/check_workspace_pty.py`. diff --git a/README.md b/README.md index d3b3a14..6d3fcad 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,22 @@ contexto (`e`), aplicação (`a`), gravação de prompt (`p`) e lançamento (`l` prévia e confirmação explícitas; `--once` imprime um quadro em texto puro sem precisar de terminal real e sem executar ações. Veja o [contrato do dashboard](docs/specs/dashboard.md). +## TUI unificada experimental + +`workspace --demo` demonstra programação na mesma interface Memory Bee: conversa, +menus, permissões, perfis fictícios, histórico persistente e exportação revisável. +**É simulação:** não chama Claude/Codex, não autentica contas e não edita o projeto. + +```sh +cargo run --locked -- workspace --demo --project . --state /tmp/bee-demo-state +cargo run --locked -- workspace --demo --project . --once --no-color +``` + +Contrato e teclas em [workspace](docs/specs/workspace.md). A direção aprovada usa +assinaturas existentes e lançamento real conjunto de Claude e Codex; integração +real e isolamento continuam pendentes ([ADR 0018](docs/decisions/0018-unified-workspace.md)). +O dashboard anterior permanece disponível. + ## Desenvolvimento A toolchain Rust 1.98.1 está fixada em `rust-toolchain.toml`; instale Rust via rustup. diff --git a/ROADMAP.md b/ROADMAP.md index 444c7a4..3331577 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,27 @@ A mesma separação vale para ensaios manuais das entregas seguintes. Veja o [ADR 0006](docs/decisions/0006-context-export-and-deferred-manual-validation.md) e o [roteiro manual](docs/MANUAL_TESTS.md). Dependências de código continuam válidas. +## Ajuste de direção — 2026-09-25 + +O mantenedor aprovou a TUI permanente da Memory Bee, com Claude/Codex executando +por trás, assinaturas existentes e lançamento conjunto. Isso amplia as etapas +05, 06 e 08; a colmeia animada deixa de ser a próxima entrega. Não exige IDE. + +1. Protótipo funcional com os dois agentes simulados, menus, streaming, + permissões, erro, persistência e exportação: implementado como `workspace --demo`. +2. Prova real por agente/assinatura, incluindo dois perfis isolados: pendente; + integração Claude com assinatura ainda não confirmada para este produto. +3. Experiência integrada aos dois harnesses, histórico e exportação: pendente. +4. Troca real entre agentes/contas com revisão e preservação da origem: pendente. +5. Lançamento conjunto após todos os aceites, sem substituir o requisito por + API paga separada ou terminal nativo incorporado: pendente. + +Aceite real: conversa, edição, execução, permitir/negar, interrupção e retomada +com cada harness; isolamento de dois perfis; exportar após falha/limite; recuperar +histórico sem reenvio; teclado/colagem/resize/sem cor e restauração. Demo e testes +sintéticos não comprovam esses critérios. Veja [ADR 0018](docs/decisions/0018-unified-workspace.md) +e [contrato](docs/specs/workspace.md). HTML sinaliza esta revisão; Markdown prevalece. + ## Marcos | Marco | Etapas | Resultado verificável | diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index 4578e21..cdf4b0d 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -5,10 +5,12 @@ ## Situação atual - Existe documentação de produto, workflow e um HTML de planejamento. -- CLI de inspeção, descoberta, seleção e exportação somente contexto implementadas; referência Git opcional implementada; código selecionado v2 implementado; verify/apply explícito implementados; inspeção/exportação Codex por arquivo e descoberta por projeto sob raiz explícita implementadas, experimentais; preparação de retomada Claude/Codex e lançamento confirmado por token implementados, experimentais; dashboard de terminal (ratatui) com lista, detalhe, exportação de contexto/exclusões, aplicação e prompt/lançamento confirmado implementados; cena animada, exportação de código pela TUI e contas pendentes. +- CLI de inspeção, descoberta, seleção e exportação somente contexto implementadas; referência Git opcional implementada; código selecionado v2 implementado; verify/apply explícito implementados; inspeção/exportação Codex por arquivo e descoberta por projeto sob raiz explícita implementadas, experimentais; preparação de retomada Claude/Codex e lançamento confirmado por token implementados, experimentais; dashboard de terminal (ratatui) com lista, detalhe, exportação de contexto/exclusões, aplicação e prompt/lançamento confirmado implementados; workspace unificado disponível apenas com simulação explícita (ADR 0018); cena animada, exportação de código pela TUI e contas reais pendentes. - Nome decidido: **Memory Bee** (`memory-bee`), com mascote abelha e colmeia dos projetos, conforme o [ADR 0015](decisions/0015-memory-bee-identity.md); crate, binário, pacotes e documentos renomeados para `memory-bee` (registros anteriores mantêm o nome da época). Núcleo em Rust, primeiro alvo macOS arm64 e leitor Claude Code escolhidos. Licença pendente. - Cargo, toolchain Rust 1.98.1 e checks canônicos definidos no README; CI em macOS/Linux. -- Remoto: [lippdev/memory-pier](https://github.com/lippdev/memory-pier), público, após autorização do mantenedor. +- Remoto atual: [lippdev/memory-bee](https://github.com/lippdev/memory-bee), público. + Em 2026-09-25 o push confirmou redirecionamento da URL local antiga + `lippdev/memory-pier`; links históricos foram preservados. - Documentação inicial integrada na `main` pelo PR #1. ## Revisão de sequência — orientação do mantenedor @@ -26,16 +28,20 @@ de patches e consulta ao Git ficam para a etapa 04. ## Próxima tarefa de produto -**Etapa 06 — Cena da colmeia e revisão de experiência.** Dois recortes funcionais -implementados: leitura e ações confirmadas (exportar contexto, aplicar pacote, -gravar prompt e lançar no mesmo checkout). Próxima entrega: portar a cena aprovada -para módulo puro testável `src/tui/scene.rs`, respeitando redução de movimento, -sem cor e telas estreitas; revisar navegação e legibilidade com dados sintéticos. -Referência Git/código selecionado na exportação e nova worktree permanecem na CLI; -não declarar paridade completa nem certificação real. Sem contas/uso neste recorte. - -A renomeação no código está feita (seção "Renomeação — memory-bee"); resta o -remoto GitHub, só com confirmação do mantenedor. +**TUI unificada — prova de integração com assinaturas.** A orientação de +2026-09-25 substitui a prioridade anterior da cena animada: interface permanente +Memory Bee, assinaturas existentes e lançamento real somente com Claude e Codex. +Primeiro recorte `workspace --demo` implementado; não declarar programação real. +Próximo: confirmar a integração Claude elegível e fazer prova controlada de ambos +os harnesses (conversa, edição, execução, permissões, interrupção, retomada e dois +perfis isolados). Protocolo Codex experimental existe, sem conexão à TUI nem +validação real. Não copiar credenciais globais, não substituir assinatura por API +paga e não lançar só um provedor. Ver ADR 0018 e contrato `specs/workspace.md`. +Cena, exportação de código pela TUI e worktree na interface continuam pendentes. + +A renomeação no código está feita (seção "Renomeação — memory-bee"). O GitHub +já informa `lippdev/memory-bee`; a URL local antiga redireciona. Não foi feita +renomeação remota nesta retomada. Compatibilidade real de Claude Code e ensaios de leitura/retomada do pacote estão pendentes com o mantenedor em [docs/MANUAL_TESTS.md](MANUAL_TESTS.md), sem bloquear @@ -538,7 +544,7 @@ Ao começar, registrar a entrega ativa e seus critérios. Ao encerrar, atualizar - Publicação e verificações remotas vinculadas ao [PR #18](https://github.com/lippdev/memory-pier/pull/18). -### Proposta em discussão — trabalhar dentro da TUI +### Proposta em discussão — trabalhar dentro da TUI (registro histórico) O mantenedor perguntou sobre manter a interface Memory Bee durante a programação, usando os harnesses Claude/Codex. Possibilidade técnica pesquisada em 2026-09-25: @@ -549,3 +555,38 @@ limites claude.ai, indicando chave de API como caminho padrão. Perfis isolados continuidade entre provedores exigem desenho e validação próprios. Proposta ainda não adotada como arquitetura nem implementada; não substitui silenciosamente o escopo da entrega atual nem a próxima tarefa aprovada. + + +## TUI unificada — primeiro recorte demonstrável + +- Retomada: sessão anterior deixou implementação local sem commit em + `codex/unified-workspace`. A nova sessão preservou e concluiu esse recorte; + o transcript anterior foi consultado somente para recuperar o plano aprovado. +- ADR 0018 registra a mudança de direção: interface própria permanente, + assinaturas existentes e lançamento real conjunto. README, roadmap, aviso + no HTML, AGENTS, contrato e roteiro manual atualizados. +- `workspace --demo`: conversa, eventos incrementais, ferramentas/diff fictícios, + decisões, interrupção/erro, menus, perfis fictícios, sessões persistidas e + exportação com prévia/exclusões/confirmação. Sem `--demo`, recusa antes de escrever. + `--once` só renderiza estado sintético em memória. +- Pacote v1 com fonte `memory-bee-demo`, proveniência de entrada/simulação e + indicação de snapshot parcial. `verify` aceita com avisos; não é sessão nativa. +- Autorrevisão: corrigidas aprovação já resolvida e eventos de turno antigo no + protocolo experimental, symlink pendente no estado, contexto omitido da prévia, + omissão de interrupção no Markdown e controles cortados na entrada estreita. + Sem revisão independente. +- Limites: demo não edita código nem autentica. Protocolo Codex parcial, separado + da TUI; nenhum harness real ou modelo chamado. Perfis não provam isolamento. + Disco síncrono, trava por pasta de estado, recuperação de trava manual após + crash, rascunho/tema não persistidos, projeto único e colmeia estática. +- Validação local macOS: build, fmt e Clippy aprovados; suíte completa de 176 + testes aprovada, seguida de 17 testes do workspace após adicionar um teste de + transporte falso (177 testes no total). Pacotes v1/v2 aprovados no validador + Python; PTYs do dashboard e workspace aprovados, incluindo schema do pacote + demo e restauração do terminal. Links Markdown, JavaScript do roadmap, + numeração 1–49/sintaxe shell do roteiro e `git diff --check` conferidos. + CI remota e integração vinculadas ao [PR #19](https://github.com/lippdev/memory-bee/pull/19); + não houve validação com agentes reais. +- Ensaios humanos 46–49 pendentes; não substituídos por testes automatizados. +- Próximo passo: prova de integração descrita no topo. O plano completo permanece + em andamento; integração Claude com assinatura depende de confirmação externa. diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index 3c9999c..c464ac8 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -748,3 +748,79 @@ item 40 e registrar versões, autenticação, aceitação do prompt e retorno à | 43: exportação por teclado, ramo, exclusões e cancelamento | Pendente | — | | 44: aplicação confirmada e checkout alterado entre prévia/escrita | Pendente | — | | 45: prompt e lançamento, restauração e agentes reais | Pendente | — | + +## 46. Conversa unificada e permissões simuladas (pendente) + +```sh +cargo build --locked +mb_workspace_tmp=$(mktemp -d) +mkdir "$mb_workspace_tmp/project" +target/debug/memory-bee workspace --demo --project "$mb_workspace_tmp/project" --state "$mb_workspace_tmp/state" --no-color +``` + +Enter abre a conversa. Enviar tarefa sintética, abrir F2 durante eventos, Esc +volta com texto digitado preservado. Tab abre permissão: negar, depois repetir +e permitir uma vez. Ctrl+C durante evento/permissão interrompe; aprovação antiga +não pode ser aceita. Enviar `[erro]`: erro aparece sem perder histórico. +Ctrl+Q durante turno exige escolha; Esc cancela. Nenhum arquivo do projeto muda. + +Repetir com `--agent codex` e outra pasta `--state`; em estado existente a sessão +persistida prevalece. Esperado: SIMULAÇÃO visível, nenhum login/modelo/processo +Claude ou Codex; ferramentas/diff são fictícios. Não usar conversas ou tokens reais. + +## 47. Perfis, contexto e persistência da demo (pendente) + +Na sessão do item 46, F2 → `/accounts`: adicionar perfil fictício. Com turno +parado, selecionar outro agente/perfil, revisar passagem com PgDn/Home e digitar +`CONTINUAR`; Esc antes disso preserva origem sem nova sessão. `/sessions` deve +permitir consultar a origem intacta. Perfil não autentica nem modifica conta real. + +Sair e executar o mesmo comando: histórico reaparece sem reenviar mensagens. +Segundo processo com mesma pasta deve recusar. Para simular crash, usar apenas +estado descartável e encerrar seu processo abruptamente: conferir que não resta +processo, inspecionar/remover manualmente `workspace.lock`, reabrir e verificar +interrupção registrada. `workspace.new` remanescente também requer inspeção; +não remover arquivos de outra sessão. Corrupção de JSON e projeto diferente devem +recusar preservando os bytes originais. Rascunho/tema não são restaurados. + +## 48. Exportar a simulação e revisar origem (pendente) + +F2 → `/export`, informar pasta nova absoluta fora do projeto, excluir número de +registro existente (visível entre colchetes), revisar manifesto/Markdown/histórico. +Esc cancela. Repetir e digitar `EXPORTAR`; destino existente deve ser recusado. + +```sh +# Substitua pelo destino usado no formulário. +target/debug/memory-bee verify /caminho/do/pacote-demo +``` + +Esperado: fonte `memory-bee-demo`, aviso de origem desconhecida, marca SIMULAÇÃO, +proveniência `user_input`/`simulated`, omissões das exclusões. Snapshot após erro +ou interrupção é parcial. Nenhum patch ou referência Git inferida. Detector de +segredos bloqueia escrita de suspeitas até exclusão; não promete detectar tudo. + +## 49. Terminal e limites de lançamento (pendente) + +Testar 100×30, 60×20, 40×12 e 30×10, claro/escuro e `NO_COLOR`. Rolar menus longos, +prévia, colar texto multilinha (não deve enviar), Ctrl+J, Unicode e Backspace. +Entrada curta mantém Enter/F2 acessíveis; abaixo do mínimo há aviso. Sair e +conferir terminal restaurado. A colmeia deste recorte é estática. + +```sh +target/debug/memory-bee workspace --demo --project . --agent codex --once --width 40 --height 12 --no-color +# Deve recusar sem criar o estado nem iniciar agente: +target/debug/memory-bee workspace --project . --state /tmp/bee-live-refused +# Automatizado, separado do ensaio humano; requer dependências de validação: +python3 scripts/check_workspace_pty.py +``` + +Aceite real continua pendente: os dois agentes com assinaturas existentes, +edição/execução real, permitir/negar, interrupção/retomada, exportar após falha/limite +e dois perfis sem mistura. Não interpretar a demo como evidência desses aceites. + +| Itens novos | Estado | Evidência manual | +|---|---|---| +| 46: conversa e permissões simuladas | Pendente | — | +| 47: perfis fictícios, contexto, reinício e crash | Pendente | — | +| 48: exportação revisada com proveniência e omissões | Pendente | — | +| 49: terminal, acessibilidade e recusa de modo real | Pendente | — | diff --git a/docs/decisions/0018-unified-workspace.md b/docs/decisions/0018-unified-workspace.md new file mode 100644 index 0000000..5780b20 --- /dev/null +++ b/docs/decisions/0018-unified-workspace.md @@ -0,0 +1,75 @@ +# 0018 — TUI unificada e lançamento conjunto + +Status: direção aprovada pelo mantenedor em 2026-09-25; primeiro recorte implementado. + +## Decisão e mudança de plano + +O mantenedor pediu uma interface permanente da Memory Bee: conversa, ferramentas, +permissões, menus, perfis e exportação na mesma TUI, com Claude/Codex como motores. +Escolheu usar assinaturas existentes e lançar a experiência real somente quando +**os dois agentes** atenderem ao fluxo. Isso substitui a proposta ainda em discussão +no registro anterior e prioriza esta entrega antes da cena animada. Continuamos +uma CLI com TUI, sem exigir IDE, editor, servidor remoto ou API paga separada. + +Manter Rust/ratatui e preservar o dashboard existente. Entregar primeiro +`workspace --demo`, sempre identificado como simulação, com adaptador determinístico +para cada agente. O comando sem `--demo` recusa antes de criar estado. Não integrar +um terminal nativo nem ativar apenas Codex como substituto silencioso do requisito. + +## Separação e armazenamento + +`workspace::{Session, Event}` define identidade e registros normalizados; +`adapter::Adapter` separa envio, eventos, decisão e interrupção da tela. +`store` mantém snapshots privados em pasta explícita, um escritor por trava +`create_new`, limites e recuperação de turno interrompido sem reenvio. Perfis +neste recorte são nomes fictícios, não contas autenticadas. Mudanças de agente ou +perfil criam outra sessão e preservam a origem. Não usar estado nativo de agentes. + +Exportação reutiliza o núcleo v1 e informa `source.agent = memory-bee-demo`, +`user_input` para entradas e `simulated` para eventos da demonstração. É um +pacote de contexto desconhecido pelo receptor, verificável com avisos; nunca +se apresenta como conversa nativa Claude/Codex. Nenhuma referência/cópia de código +é inferida. Não alterar schemas nem interpretar dados sintéticos como autenticação. + +## Integração real e evidências + +O módulo experimental `workspace::codex` é um início de transporte/protocolo, +não um adaptador de produto concluído. Não tem entrada na CLI/TUI. Implementa +JSONL/stdio, handshake, correlação, requests de thread/turno, tradução parcial de +eventos e respostas de aprovação; desconhecidos não recebem permissão. Eventos +de autenticação não são copiados para a conversa. Perfil explícito usa ambiente +reduzido; isso não certifica isolamento de credenciais, keychain ou configuração +de projeto. Persistência nativa, login utilizável e integração à tela ficam pendentes. + +Fontes consultadas em 2026-09-25: + +- [Codex App Server](https://learn.chatgpt.com/docs/app-server): transporte, + conversas, eventos, aprovações por thread/turno e login gerenciado pelo Codex. + Ajuda local de `codex-cli 0.156.1` confirma `app-server --listen stdio://`. +- [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview): motor + programável; oferta de login/limites claude.ai por terceiros depende de aprovação. +- [Autenticação Claude](https://code.claude.com/docs/en/legal-and-compliance): + diferencia hospedar o binário original com login pelo usuário de oferecer login + em aplicação própria. Essa distinção não comprova a viabilidade da nossa TUI. + Apenas versão local `Claude Code 2.1.282` conferida, sem login ou inferência. + +Não há evidência de aprovação/integração Claude com assinatura para este produto. +Nenhuma credencial foi copiada e nenhuma chamada a modelo foi feita. Não escolher +API com cobrança separada como alternativa, pois contradiz a escolha do mantenedor. + +## Entregas seguintes e limites + +1. Prova controlada de cada harness/assinatura: conversa, edição, execução, + permitir/negar, interrupção, retomada, falha e dois perfis isolados. +2. Conectar os dois adaptadores à TUI responsiva; autenticação oficial separada + do histórico, descoberta/retomada nativa e exportação após falha/limite. +3. Validar troca entre agente/perfil com contexto revisado e origem preservada. +4. Lançamento conjunto somente com essas evidências. A demo não satisfaz esse aceite. + +Snapshot é substituído por rename após sync do arquivo, mas não promete durabilidade +contra perda de energia (sem fsync de diretório), proteção de ancestrais contra +escritores hostis ou recuperação automática de trava após encerramento abrupto. +I/O de persistência/exportação é síncrono; a demo não certifica responsividade +sob disco lento. Uma sessão ativa por pasta de estado, não trava global de checkout +para um futuro motor real. Colmeia estática, projeto explícito e tema não persistido. +Autorrevisão e testes sintéticos não substituem ensaio humano ou prova real. diff --git a/docs/roadmap/index.html b/docs/roadmap/index.html index e8cfd38..e794591 100644 --- a/docs/roadmap/index.html +++ b/docs/roadmap/index.html @@ -8,6 +8,7 @@
+

Revisão de 2026-09-25: o plano agora prioriza uma TUI permanente com Claude/Codex e assinaturas existentes, com lançamento real conjunto. O protótipo workspace --demo é simulação. As etapas visuais abaixo ainda não incorporam integralmente essa ampliação; consulte ROADMAP.md e o contrato do workspace.

Plano de produto / 23 set 2026 / versão 01

O trabalho continua.
O agente pode mudar.

Um painel no terminal para encontrar conversas, acompanhar disponibilidade e passar um trabalho para outro agente, outra conta ou outra pessoa.

Nome escolhido: Memory Bee. Este HTML é o plano de implementação, não a dashboard funcional. Núcleo escolhido: Rust; primeiro leitor Claude Code em macOS arm64.

Atualização de execução: referência Git opcional implementada na exportação (--project). Exportação de código selecionado implementada (--include-path, pacote v2); verify e apply explícito implementados para checkout limpo/base exata; dashboard com exportação de contexto, aplicação e retomada confirmada implementadas; cena animada e ensaios manuais pendentes; consulte docs/EXECUTION.md para evidências.

Referência operacional: roadmap canônico em Markdown · estado de execução. Em caso de divergência, consulte o Markdown.

diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md new file mode 100644 index 0000000..f2ba5f6 --- /dev/null +++ b/docs/specs/workspace.md @@ -0,0 +1,89 @@ +# Workspace unificado — demonstração experimental + +Contrato do primeiro recorte do [ADR 0018](../decisions/0018-unified-workspace.md). +Não é programação real. Claude/Codex, ferramentas, mudanças e permissões são +simulados, sem processos de provedores, rede ou edição do projeto. + +```sh +cargo run --locked -- workspace --demo --project . --state /tmp/bee-demo-state +cargo run --locked -- workspace --demo --project . --agent codex --once --width 60 --height 20 --no-color +``` + +O pai de `--state` precisa existir. Pasta nova é criada 0700, arquivos 0600; +pasta existente precisa ser privada. `--once` usa memória, não aceita `--state`, +não grava nem lê sessões existentes. `--width/--height` (1–500) só com `--once`. +`--agent claude|codex` escolhe apenas a primeira sessão de um estado novo. +`--theme dark|light`, `--no-color` e `NO_COLOR` controlam aparência. Flags repetidas, +modo real, projeto ausente e uso interativo sem TTY retornam 64; falha de I/O, 1. +Projeto é normalizado para caminho absoluto e precisa ser diretório existente. + +## Experiência e teclas + +```text +┌─ Memory Bee · SIMULAÇÃO ──────────────────┐ +│ Claude · Pessoal (demo) · sessão #1 │ +│ /projeto │ +│ [1] Você: adicione busca │ +│ [2] Claude: [SIMULAÇÃO] Vou mostrar... │ +│ [5] Alteração simulada: src/example.rs │ +│ [6] Permissão: cargo test (simulado) │ +│ Tab abre opções; nenhuma ação automática │ +│ > mensagem │ +│ Enter envia · F2 menu · Ctrl+Q sai │ +└──────────────────────────────────────────┘ +``` + +- Enter entra na conversa/envia; Ctrl+J acrescenta linha. Colagem não envia. + Editor simples: acrescentar texto e Backspace, sem movimentação de cursor. +- F2 ou `/` com campo vazio abre comandos. Menus ocupam a tela; eventos continuam + chegando sem descartar o rascunho. Esc volta. Comando desconhecido mostra ajuda. + Para executar um comando por texto pode-se colar `/help` e pressionar Enter. +- Tab ou `/permissions` abre decisão, com **Negar** inicialmente selecionado; + setas + Enter escolhem. Ctrl+C interrompe e invalida aprovação pendente. +- Ctrl+Q ou `/quit` sai; turno ativo exige escolher interromper e sair. +- PgUp/PgDn rolam conversa/prévia; Home vai ao início, End acompanha conversa. +- `/agent`, `/accounts` e `/sessions` mudam destino só com turno parado. + Agente/perfil cria conversa vazia ou pede revisão completa e `CONTINUAR`. + Transferência copia apenas textos de usuário/resposta como referências de + contexto; ferramentas e decisões permanecem no histórico de origem. Não é + retomada nativa nem síntese. Não há exclusões na transferência neste recorte. +- `/export` pede pasta nova e números de registros a excluir. Prévia inclui + Markdown, histórico, manifesto e achados. `EXPORTAR` grava o snapshot revisto; + Esc cancela. Suspeita de segredo bloqueia escrita; detecção não é completa. +- `/diff` mostra última alteração fictícia, `/projects` volta à colmeia estática + do projeto explícito, `/settings` muda tema/cor, `/help` explica teclas. + `[erro]` numa mensagem provoca falha sintética com histórico preservado. + +Em 30×10 ou maior, menus/entrada/rodapé continuam acessíveis; abaixo disso, aviso. +Cabeçalho e status podem truncar; projeto completo fica em `/projects`. Não há +animação, então a demo não exige opção de movimento reduzido. Não promete paridade +visual com o protótipo animado nem editor completo de texto. + +## Persistência, falhas e exportação + +`workspace.json` versionado guarda projeto, perfis fictícios, sessão selecionada +e eventos. Limites: 16 MiB, 128 sessões, 32 perfis, 100 mil eventos por sessão e +64 KiB por entrada. Falha de persistência congela envio/eventos; conteúdo já em +memória continua exportável. A sessão carregada como ativa é marcada interrompida, +sem reexecutar/reencaminhar mensagens. Tema e rascunho não são persistidos. + +`workspace.lock` recusa segundo escritor. Após kill/crash, confirmar que não existe +outro processo usando a pasta antes de remover manualmente a trava. `workspace.new` +remanescente também exige inspeção manual; não apagar automaticamente. JSON inválido, +projeto diferente, versão desconhecida e symlink de estado são recusados sem +substituir o arquivo original. Não é armazenamento cifrado nem trava de checkout. + +Pacotes v1 usam fonte `memory-bee-demo`, proveniência explícita e hashes normais. +`verify` aceita com avisos de origem desconhecida; não certifica sessão nativa. +Snapshot de turno ativo, interrompido ou com erro é parcial. Não inclui código, +credenciais, metadados de autenticação nem referência Git implícita. Texto digitado +pelo usuário continua sendo dado real local, mesmo numa simulação: não versionar. + +## Validação e pendências + +`cargo test --locked` cobre estado, teclado, cancelamento, permissões, retomada, +proveniência, exclusões e telas estreitas. `scripts/check_workspace_pty.py` percorre +colagem, decisão negada, exportação/schema/verify, resize, interrupção e restauração +em PTY com projeto sintético. O [roteiro manual](../MANUAL_TESTS.md) mantém ensaios +humanos separados. Adaptador Codex experimental é apenas protocolo não conectado; +Claude real, contas, isolamento real, reinício nativo e lançamento conjunto pendentes. diff --git a/scripts/check_workspace_pty.py b/scripts/check_workspace_pty.py new file mode 100644 index 0000000..9ff92d8 --- /dev/null +++ b/scripts/check_workspace_pty.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Exercise the demo in a POSIX PTY; no agents, credentials or model calls.""" +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import struct +import subprocess +import tempfile +import termios +import time + + +def main(): + repo = Path(__file__).resolve().parent.parent + binary = str(repo / 'target/debug/memory-bee') + with tempfile.TemporaryDirectory(prefix='memory-bee-workspace-pty-') as tmp: + root = Path(tmp) + project = root / 'project' + project.mkdir() + state = root / 'state' + bundle = root / 'bundle' + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 20, 60, 0, 0)) + before = termios.tcgetattr(slave) + proc = subprocess.Popen( + [binary, 'workspace', '--demo', '--project', str(project), '--state', str(state), '--no-color'], + stdin=slave, stdout=slave, stderr=slave, + env=dict(os.environ, TERM='xterm-256color', PATH='/usr/bin:/bin'), + ) + + def drain(seconds=0.15): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if select.select([master], [], [], max(0, deadline-time.monotonic()))[0]: + data = os.read(master, 65536) + if b'\x1b[6n' in data: + os.write(master, b'\x1b[1;1R') + + def send(text): + os.write(master, text.encode()) + drain() + + def paste(text): + send('\x1b[200~' + text + '\x1b[201~') + + def snapshot(): + return json.loads((state / 'workspace.json').read_text()) + + def wait_for(predicate): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if predicate(): + return + drain() + raise AssertionError('Timed out waiting for workspace state') + + try: + drain() + wait_for(lambda: (state / 'workspace.json').exists()) + send('\r') # Home -> conversation. + paste('synthetic task\nsecond line') + assert not snapshot()['sessions'][0]['events'], 'Paste submitted a message' + send('\r') + wait_for(lambda: snapshot()['sessions'][0]['events'][-1]['kind'] == 'approval') + send('\t') + send('\r') # Default deny. + wait_for(lambda: not snapshot()['sessions'][0]['running']) + events = snapshot()['sessions'][0]['events'] + assert any(e['kind'] == 'decision' and not e['allow'] for e in events) + paste('/export') + send('\r') + paste(str(bundle)) + send('\r') + send('\r') # No exclusions; review snapshot. + assert not bundle.exists() + paste('EXPORTAR') + send('\r') + wait_for(lambda: (bundle / 'manifest.json').exists()) + result = subprocess.run([binary, 'verify', str(bundle)], capture_output=True) + assert result.returncode in (0, 2), result.stderr.decode() + import jsonschema + manifest = json.loads((bundle / 'manifest.json').read_text()) + jsonschema.validate(manifest, json.loads((repo / 'schemas/bundle-v1.schema.json').read_text())) + assert manifest['source']['agent'] == 'memory-bee-demo' + send('\x1b') + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 12, 40, 0, 0)) + paste('interrupted task') + send('\r') + send('\x03') + wait_for(lambda: not snapshot()['sessions'][0]['running']) + assert snapshot()['sessions'][0]['events'][-1]['kind'] == 'interrupted' + send('\x11') + proc.wait(timeout=5) + assert proc.returncode == 0 + assert termios.tcgetattr(slave) == before, 'Terminal was not restored' + assert not (state / 'workspace.lock').exists() + assert list(project.iterdir()) == [], 'Demo modified project' + print('PASS: paste, approval denial, confirmed export/schema/verify, resize, interruption, private state and terminal restoration') + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + os.close(slave) + + +if __name__ == '__main__': + main() diff --git a/src/bundle.rs b/src/bundle.rs index c442195..57635da 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -672,6 +672,103 @@ fn render_handoff( text } +/// Export records from the synthetic workspace without claiming a native agent +/// conversation. Reuses v1 hashing, omission reporting and secret checks. +pub fn prepare_workspace_demo( + session: &crate::workspace::Session, + options: &Options, +) -> Result { + use crate::workspace::Event as WorkspaceEvent; + if !session.simulation + || session.native_id.is_some() + || options.project.is_some() + || !options.include_paths.is_empty() + || options.leaf.is_some() + { + return Err("workspace demo export accepts only synthetic context".into()); + } + let events = session + .events + .iter() + .enumerate() + .map(|(index, event)| { + let (role, text, provenance) = match event { + WorkspaceEvent::User { text } => ("user", text.clone(), "user_input"), + WorkspaceEvent::Text { text } => ("assistant", text.clone(), "simulated"), + _ => ( + "system", + serde_json::to_string(event).unwrap_or_default(), + "simulated", + ), + }; + Event::Codex(crate::codex::Event { + sequence: index + 1, + role, + kind: "text", + text, + timestamp: None, + phase: Some("memory-bee-simulation".into()), + source: crate::codex::Source { + line: index + 1, + block: None, + record_type: "memory_bee_demo".into(), + item_type: None, + id: None, + session_id: Some(format!("demo-{}-{}", session.agent.label(), session.id)), + turn_id: None, + }, + provenance, + tool_id: None, + tool_name: None, + tool_namespace: None, + }) + }) + .collect(); + let partial = session.running + || session.events.iter().any(|event| { + matches!( + event, + WorkspaceEvent::Interrupted | WorkspaceEvent::Error { .. } + ) + }); + let mut prepared = prepare_input( + Input { + agent: "memory-bee-demo", + state: if partial { + ReadState::Partial + } else { + ReadState::Read + }, + events, + diagnostics: Vec::new(), + observed_versions: ["synthetic-v1".into()].into(), + selection: None, + }, + options, + )?; + prepared.manifest.warnings.push("SIMULAÇÃO Memory Bee: respostas, ferramentas, permissões e mudanças não vieram de um harness real. Mensagens do usuário têm proveniência user_input; demais eventos são simulated. Sem autenticação, inferência ou edição de código.".into()); + let completeness = if partial { + "Snapshot parcial: contém turno em andamento, interrompido ou com erro; nenhum evento futuro foi incluído." + } else { + "Snapshot dos eventos disponíveis da simulação." + }; + if partial { + prepared.manifest.omissions.push(completeness.into()); + } + prepared.handoff = format!( + "# SIMULAÇÃO — Memory Bee\n\nNenhum harness real foi executado.\n\n{completeness}\n\n{}", + prepared.handoff + ); + // HANDOFF is modified before hashes are published. + let handoff_hash = format!("{:x}", Sha256::digest(prepared.handoff.as_bytes())); + for file in &mut prepared.manifest.files { + if file.path == "HANDOFF.md" { + file.sha256 = handoff_hash.clone(); + } + } + Ok(prepared) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index ac1107c..870ad03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,4 @@ pub mod git; pub mod receive; pub mod resume; pub mod selection; +pub mod workspace; diff --git a/src/main.rs b/src/main.rs index dec74a0..f185297 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use std::{ process::ExitCode, }; -const USAGE: &str = "Usage:\n memory-bee export-codex (--preview | --output ) [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee inspect-codex \n memory-bee inspect [--leaf ]\n memory-bee sessions-codex --root --project \n memory-bee sessions --root --project \n memory-bee export (--preview | --output ) [--leaf ] [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee verify \n memory-bee apply --project (--check | --write)\n memory-bee prepare-resume --target (claude | codex) --project [--worktree ] (--preview | --output [--launch ])\n memory-bee dashboard --project [--claude-root ] [--codex-root ] [--bundle ] [--theme (dark|light)] [--no-color] [--once [--width ] [--height ]]\nOffline; no model calls.\nCLI launch requires --launch and a matching confirmation from --preview.\nDashboard actions require a preview and explicit confirmation.\nExit: 0 success, 2 partial or attention needed, 3 possible secrets, 4 launched agent exited non-zero, 1 I/O or selection error, 64 usage error."; +const USAGE: &str = "Usage:\n memory-bee workspace --demo --project (--state | --once [--width ] [--height ]) [--agent claude|codex] [--theme dark|light] [--no-color]\n memory-bee export-codex (--preview | --output ) [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee inspect-codex \n memory-bee inspect [--leaf ]\n memory-bee sessions-codex --root --project \n memory-bee sessions --root --project \n memory-bee export (--preview | --output ) [--leaf ] [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee verify \n memory-bee apply --project (--check | --write)\n memory-bee prepare-resume --target (claude | codex) --project [--worktree ] (--preview | --output [--launch ])\n memory-bee dashboard --project [--claude-root ] [--codex-root ] [--bundle ] [--theme (dark|light)] [--no-color] [--once [--width ] [--height ]]\nOffline; no model calls.\nCLI launch requires --launch and a matching confirmation from --preview.\nDashboard actions require a preview and explicit confirmation.\nExit: 0 success, 2 partial or attention needed, 3 possible secrets, 4 launched agent exited non-zero, 1 I/O or selection error, 64 usage error."; fn output(value: &impl Serialize) -> Result<(), (u8, String)> { let stdout = io::stdout(); let mut out = stdout.lock(); @@ -113,6 +113,9 @@ fn run() -> Result { if args.first().is_some_and(|arg| arg == "export-codex") { return export(&args[1..], true); } + if args.first().is_some_and(|arg| arg == "workspace") { + return workspace(&args[1..]); + } if args.first().is_some_and(|arg| arg == "dashboard") { return dashboard(&args[1..]); } @@ -516,3 +519,157 @@ fn prepare_resume(args: &[std::ffi::OsString]) -> Result { output(&preparation)?; Ok(if preparation.needs_attention() { 2 } else { 0 }) } + +fn workspace(args: &[std::ffi::OsString]) -> Result { + use memory_bee::workspace::{ + Agent, + store::{State, Store}, + }; + let mut project: Option = None; + let mut state: Option = None; + let mut demo = false; + let mut once = false; + let mut agent = Agent::Claude; + let mut mode = tui::theme::Mode::Dark; + let mut no_color = env::var_os("NO_COLOR").is_some(); + let (mut width, mut height) = (100u16, 30u16); + let mut seen = std::collections::BTreeSet::new(); + let mut i = 0; + while i < args.len() { + let flag = args[i].to_str().ok_or((64, USAGE.into()))?; + if !seen.insert(flag.to_owned()) { + return Err((64, USAGE.into())); + } + match flag { + "--demo" => demo = true, + "--once" => once = true, + "--no-color" => no_color = true, + "--project" | "--state" | "--agent" | "--theme" | "--width" | "--height" => { + i += 1; + let value = args.get(i).ok_or((64, USAGE.into()))?; + match flag { + "--project" => project = Some(value.into()), + "--state" => state = Some(value.into()), + "--agent" => { + agent = value + .to_str() + .and_then(Agent::parse) + .ok_or((64, USAGE.into()))? + } + "--theme" => { + mode = value + .to_str() + .and_then(tui::theme::Mode::parse) + .ok_or((64, USAGE.into()))? + } + _ => { + let n = value + .to_str() + .and_then(|s| s.parse::().ok()) + .filter(|n| *n > 0 && *n <= 500) + .ok_or((64, "Dimensions must be between 1 and 500".into()))?; + if flag == "--width" { + width = n; + } else { + height = n; + } + } + } + } + _ => return Err((64, USAGE.into())), + } + i += 1; + } + if !demo { + memory_bee::workspace::live_gate().map_err(|e| (64, e))?; + } + let project = project + .ok_or((64, USAGE.into()))? + .canonicalize() + .map_err(|e| (1, e.to_string()))?; + if !project.is_dir() { + return Err((64, "Project must be a directory".into())); + } + let project = project + .to_str() + .ok_or((64, "Project must be UTF-8".into()))? + .to_owned(); + if once && state.is_some() || !once && (seen.contains("--width") || seen.contains("--height")) { + return Err((64, USAGE.into())); + } + if once { + let state = State::new(project, agent); + let mut app = tui::workspace::App::new(state, None, mode, no_color); + app.draft = "Adicione busca por nome na lista de sessões. (exemplo sintético)".into(); + app.key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )); + for _ in 0..5 { + app.tick(); + } + let mut terminal = + Terminal::new(TestBackend::new(width, height)).map_err(|e| (1, e.to_string()))?; + terminal + .draw(|f| tui::workspace::draw(f, &app)) + .map_err(|e| (1, e.to_string()))?; + print_plain(terminal.backend().buffer()); + return Ok(0); + } + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(( + 64, + "workspace needs a terminal; use --once for a read-only demo".into(), + )); + } + let state_path = state.ok_or((64, "Interactive demo requires --state ".into()))?; + let (store, state) = Store::open(&state_path, &project, agent).map_err(|e| (1, e))?; + let mut app = tui::workspace::App::new(state, Some(store), mode, no_color); + app.home = true; + run_workspace(app).map_err(|e| (1, e))?; + Ok(0) +} + +fn run_workspace(mut app: tui::workspace::App) -> Result<(), String> { + use crossterm::{ + cursor::Show, + event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind}, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, + }; + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = crossterm::execute!( + io::stdout(), + DisableBracketedPaste, + LeaveAlternateScreen, + Show + ); + } + } + terminal::enable_raw_mode().map_err(|e| e.to_string())?; + let _restore = Restore; + crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste) + .map_err(|e| e.to_string())?; + let mut terminal = + Terminal::new(CrosstermBackend::new(io::stdout())).map_err(|e| e.to_string())?; + let mut last = std::time::Instant::now(); + while !app.quit { + terminal + .draw(|f| tui::workspace::draw(f, &app)) + .map_err(|e| e.to_string())?; + if event::poll(std::time::Duration::from_millis(50)).map_err(|e| e.to_string())? { + match event::read().map_err(|e| e.to_string())? { + Event::Key(key) if key.kind == KeyEventKind::Press => app.key(key), + Event::Paste(text) => app.paste(&text), + _ => {} + } + } + if last.elapsed() >= std::time::Duration::from_millis(250) { + app.tick(); + last = std::time::Instant::now(); + } + } + Ok(()) +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index dd2917c..2adf9ea 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -6,3 +6,5 @@ pub mod ui; #[cfg(test)] mod action_tests; + +pub mod workspace; diff --git a/src/tui/workspace.rs b/src/tui/workspace.rs new file mode 100644 index 0000000..4e2933e --- /dev/null +++ b/src/tui/workspace.rs @@ -0,0 +1,801 @@ +//! Unified conversation UI. Public entry is explicitly demo-only until both +//! subscription providers pass the release gate. No provider calls here. +use super::theme::{Mode, Palette}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use memory_bee::{ + bundle, + workspace::{ + Agent, Event, + adapter::{Adapter, Demo}, + store::{State, Store}, + }, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Paragraph, Wrap}, +}; +use std::path::PathBuf; + +pub const MENU: &[&str] = &[ + "/agent", + "/accounts", + "/sessions", + "/export", + "/projects", + "/settings", + "/permissions", + "/diff", + "/help", + "/quit", +]; +#[derive(Clone)] +pub enum Choice { + Command(String), + Agent(Agent), + Profile(String), + Session(usize), + Switch(Agent, String, bool), + Theme(Mode), + NoColor, + Approve(String, bool), + Exit, +} +pub enum Form { + ExportPath, + ExportLines(PathBuf), + ExportConfirm, + SwitchConfirm(Agent, String), + Profile, +} +pub struct Modal { + pub title: String, + pub body: String, + pub choices: Vec<(String, Choice)>, + pub selected: usize, + pub input: String, + pub form: Option
, + pub scroll: u16, +} +impl Modal { + fn menu(title: &str, choices: Vec<(String, Choice)>) -> Self { + Self { + title: title.into(), + body: String::new(), + choices, + selected: 0, + input: String::new(), + form: None, + scroll: 0, + } + } + fn form(title: &str, body: String, form: Form) -> Self { + Self { + title: title.into(), + body, + choices: Vec::new(), + selected: 0, + input: String::new(), + form: Some(form), + scroll: 0, + } + } + fn info(title: &str, body: String) -> Self { + Self { + body, + ..Self::menu(title, Vec::new()) + } + } +} +pub struct App { + pub state: State, + store: Option, + adapter: Demo, + pub draft: String, + pub modal: Option, + pub notice: String, + pub quit: bool, + pub frozen: bool, + pub scroll: u16, + pub palette: Palette, + mode: Mode, + no_color: bool, + prepared: Option<(bundle::Prepared, PathBuf)>, + pub home: bool, +} +impl App { + pub fn new(state: State, store: Option, mode: Mode, no_color: bool) -> Self { + let adapter = Demo::new(state.current().agent); + Self { + state, + store, + adapter, + draft: String::new(), + modal: None, + notice: "SIMULAÇÃO: sem modelos, autenticação ou edição do projeto".into(), + quit: false, + frozen: false, + scroll: 0, + palette: Palette::new(mode, no_color), + mode, + no_color, + prepared: None, + home: false, + } + } + fn save(&mut self) { + if let Some(store) = &self.store + && let Err(e) = store.save(&self.state) + { + self.adapter.interrupt(); + self.frozen = true; + self.notice = + format!("Falha ao salvar; envio suspenso, histórico em memória exportável: {e}"); + } + } + pub fn tick(&mut self) { + if self.frozen { + return; + } + if let Some(event) = self.adapter.poll() { + self.state.current_mut().record(event); + self.save(); + } + } + pub fn interrupt(&mut self) { + self.adapter.interrupt(); + // Apply the terminal event now so a subsequent menu action cannot race it. + self.tick(); + self.notice = "Turno interrompido; histórico preservado".into(); + } + fn info(&mut self, title: &str, body: String) { + self.modal = Some(Modal::info(title, body)); + } + pub fn command(&mut self, command: &str) { + match command { + "/menu" => self.modal = Some(Modal::menu("Menu Memory Bee", MENU.iter().map(|s| (s.to_string(),Choice::Command(s.to_string()))).collect())), + "/agent" => self.modal = Some(Modal::menu("Agentes simulados", [Agent::Claude,Agent::Codex].into_iter().map(|a| (format!("{} (demo)",a.label()),Choice::Agent(a))).collect())), + "/accounts" => { + let mut choices: Vec<_> = self.state.profiles.iter().map(|p| (p.clone(),Choice::Profile(p.clone()))).collect(); + choices.push(("Adicionar perfil simulado".into(),Choice::Command("/profile-new".into()))); + self.modal = Some(Modal::menu("Perfis DEMO — sem credenciais ou login real", choices)); + } + "/profile-new" => self.modal = Some(Modal::form("Novo perfil simulado", "Nome local, sem autenticação. Enter adiciona; Esc cancela.".into(), Form::Profile)), + "/sessions" => self.modal = Some(Modal::menu("Sessões locais", self.state.sessions.iter().enumerate().map(|(i,s)| (format!("#{} · {} · {} · {} eventos",s.id,s.agent.label(),s.profile,s.events.len()),Choice::Session(i))).collect())), + "/export" => self.modal = Some(Modal::form("Exportar SIMULAÇÃO", "Caminho literal de uma pasta NOVA; o pai deve existir. Apenas registros desta demonstração.".into(), Form::ExportPath)), + "/projects" => { self.home = true; self.modal = None; } + "/settings" => self.modal = Some(Modal::menu("Aparência", vec![("Tema escuro".into(),Choice::Theme(Mode::Dark)),("Tema claro".into(),Choice::Theme(Mode::Light)),("Alternar sem cor".into(),Choice::NoColor)])), + "/permissions" => { + let pending = self.state.current().events.iter().rev().find_map(|e| match e { + Event::Approval{id,command} => Some(Some((id.clone(),command.clone()))), + Event::Decision{..}|Event::Interrupted|Event::Completed|Event::Error{..} => Some(None), _=>None, + }).flatten(); + if let Some((id,command)) = pending { + let mut panel = Modal::menu("Permissão SIMULADA", vec![("Negar".into(),Choice::Approve(id.clone(),false)),("Permitir uma vez".into(),Choice::Approve(id,true))]); + panel.body = format!("{} / {}\n{}\nProjeto: {}",self.state.current().agent.label(),self.state.current().profile,command,self.state.project); + self.modal = Some(panel); + } else { self.info("Permissões", "Nenhuma permissão pendente.".into()); } + } + "/diff" => { + let text = self.state.current().events.iter().rev().find_map(|e| if let Event::Change{path,diff} = e { Some(format!("SIMULAÇÃO — {path}\n{diff}")) } else {None}).unwrap_or("Nenhuma alteração simulada nesta sessão.".into()); + self.info("Alterações",text); + } + "/help" => self.info("Ajuda", "SIMULAÇÃO — não chama agentes nem edita código.\n\nEnter envia; Ctrl+J insere linha; F2 ou / abre menu.\nCtrl+C interrompe; Ctrl+Q ou /quit sai (confirma se ativo).\nPgUp/PgDn rola; Home volta ao início; End acompanha o fim.\nTab abre a permissão pendente. Esc fecha o painel.\n\nComandos: /agent /accounts /sessions /export /projects /settings /permissions /diff /help /quit\n\nEnvie [erro] para simular falha de provedor. Perfis são fictícios. Colar texto nunca o envia automaticamente.\nAssinaturas reais só serão liberadas após validação dos dois agentes.".into()), + "/quit" => if self.state.current().running { self.modal = Some(Modal::menu("Turno ativo — interromper e sair? Esc volta",vec![("Interromper e sair".into(),Choice::Exit)])); } else { self.quit = true; }, + _ => self.info("Comando desconhecido",format!("{command}\nUse /help. Nada foi enviado ao agente.")), + } + } + fn switching(&mut self, agent: Agent, profile: String) { + if self.state.current().running { + self.info( + "Turno ativo", + "Aguarde ou use Ctrl+C antes de trocar agente/conta/sessão.".into(), + ); + return; + } + self.modal = Some(Modal::menu( + "Nova sessão — a origem será preservada", + vec![ + ( + "Conversa vazia".into(), + Choice::Switch(agent, profile.clone(), false), + ), + ( + "Revisar e levar contexto".into(), + Choice::Switch(agent, profile, true), + ), + ], + )); + } + fn choose(&mut self, choice: Choice) -> Result<(), String> { + match choice { + Choice::Command(s) => self.command(&s), + Choice::Agent(a) => self.switching(a, self.state.current().profile.clone()), + Choice::Profile(p) => self.switching(self.state.current().agent, p), + Choice::Session(i) => { + if self.state.current().running { + return Err("Aguarde ou interrompa o turno antes de abrir outra sessão".into()); + } + self.state.selected = i; + self.adapter = Demo::new(self.state.current().agent); + self.draft.clear(); + self.scroll = 0; + self.save(); + } + Choice::Switch(a, p, context) => { + if context { + let prepared = bundle::prepare_workspace_demo( + self.state.current(), + &bundle::Options::default(), + )?; + self.modal = Some(Modal::form( + "Revisar passagem de contexto", + format!( + "Destino: {} / {}\nDigite CONTINUAR para criar sessão com o contexto abaixo. Apenas mensagens de usuário e texto simulado serão copiados; ferramentas e permissões ficam na origem.\n\n{}\n\nHistórico completo:\n{}", + a.label(), + p, + prepared.handoff(), + prepared.history() + ), + Form::SwitchConfirm(a, p), + )); + } else { + self.switch(a, p, false)?; + } + } + Choice::Theme(mode) => { + self.mode = mode; + self.palette = Palette::new(mode, self.no_color); + } + Choice::NoColor => { + self.no_color = !self.no_color; + self.palette = Palette::new(self.mode, self.no_color); + } + Choice::Approve(id, allow) => self.adapter.decide(&id, allow)?, + Choice::Exit => { + self.interrupt(); + self.quit = true; + } + } + Ok(()) + } + fn switch(&mut self, agent: Agent, profile: String, context: bool) -> Result<(), String> { + if self.frozen { + return Err("Estado sem persistência; exporte antes de sair".into()); + } + self.state.fork(agent, profile, context)?; + self.adapter = Demo::new(agent); + self.draft.clear(); + self.scroll = 0; + self.save(); + Ok(()) + } + fn form(&mut self, form: Form, input: String) -> Result<(), String> { + match form { + Form::Profile => { + let name = input.trim(); + if name.is_empty() + || name.chars().count() > 40 + || name.chars().any(char::is_control) + || self.state.profiles.len() >= 32 + { + return Err("Nome de 1–40 caracteres; máximo de 32 perfis".into()); + } + let name = format!("{name} (demo)"); + if self.state.profiles.contains(&name) { + return Err("Perfil já existe".into()); + } + self.state.profiles.push(name); + self.save(); + self.command("/accounts"); + } + Form::ExportPath => { + if input.is_empty() { + return Err("Informe pasta nova".into()); + } + self.modal=Some(Modal::form("Excluir registros da exportação", "Números separados por vírgula; vazio mantém todos. A numeração aparece no histórico da conversa.".into(),Form::ExportLines(input.into()))); + } + Form::ExportLines(path) => { + let mut options = bundle::Options::default(); + if !input.trim().is_empty() { + for n in input.split(',') { + options.exclude_lines.insert( + n.trim() + .parse() + .map_err(|_| "Números de registros inválidos")?, + ); + } + } + let prepared = bundle::prepare_workspace_demo(self.state.current(), &options)?; + let body = format!( + "Destino: {}\nDigite EXPORTAR para gravar este snapshot.\n\n{}\n\nHistórico:\n{}\n\nManifesto:\n{}\n\nAchados:\n{}", + path.display(), + prepared.handoff(), + prepared.history(), + serde_json::to_string_pretty(prepared.manifest()).map_err(|e| e.to_string())?, + serde_json::to_string(prepared.findings()).map_err(|e| e.to_string())? + ); + self.prepared = Some((prepared, path)); + self.modal = Some(Modal::form( + "Prévia de SIMULAÇÃO", + body, + Form::ExportConfirm, + )); + } + Form::ExportConfirm => { + if input != "EXPORTAR" { + return Err("Confirmação diferente de EXPORTAR; nada foi gravado".into()); + } + let (prepared, path) = self.prepared.take().ok_or("Prévia indisponível")?; + prepared.write(&path).map_err(|e| e.to_string())?; + self.info( + "Pacote gravado", + format!( + "SIMULAÇÃO: {}\nPode ser conferido com memory-bee verify.", + path.display() + ), + ); + } + Form::SwitchConfirm(a, p) => { + if input != "CONTINUAR" { + return Err("Confirmação diferente de CONTINUAR; origem preservada".into()); + } + self.switch(a, p, true)?; + } + } + Ok(()) + } + pub fn paste(&mut self, text: &str) { + let target = if let Some(modal) = &mut self.modal { + if modal.form.is_none() { + return; + } + &mut modal.input + } else { + &mut self.draft + }; + for c in text + .chars() + .filter(|c| !c.is_control() || *c == '\n' || *c == '\t') + { + if target.len() + c.len_utf8() > 64 * 1024 { + break; + } + target.push(c); + } + } + pub fn key(&mut self, key: KeyEvent) { + let code = key.code; + if key.modifiers.contains(KeyModifiers::CONTROL) { + match code { + KeyCode::Char('c') => { + self.interrupt(); + return; + } + KeyCode::Char('q') => { + self.command("/quit"); + return; + } + KeyCode::Char('j') => { + if self.modal.is_none() { + self.paste("\n"); + } + return; + } + _ => return, + } + } + if let Some(mut modal) = self.modal.take() { + let result = match code { + KeyCode::Esc => { + self.prepared = None; + return; + } + KeyCode::Enter => { + if let Some(form) = modal.form { + self.form(form, modal.input) + } else if let Some((_, choice)) = modal.choices.get(modal.selected) { + self.choose(choice.clone()) + } else { + Ok(()) + } + } + _ => { + match code { + KeyCode::Up => modal.selected = modal.selected.saturating_sub(1), + KeyCode::Down => { + modal.selected = + (modal.selected + 1).min(modal.choices.len().saturating_sub(1)) + } + KeyCode::PageUp => modal.scroll = modal.scroll.saturating_sub(5), + KeyCode::PageDown => modal.scroll = modal.scroll.saturating_add(5), + KeyCode::Home => modal.scroll = 0, + KeyCode::Backspace => { + modal.input.pop(); + } + KeyCode::Char(c) + if modal.form.is_some() + && !c.is_control() + && modal.input.len() + c.len_utf8() <= 64 * 1024 => + { + modal.input.push(c) + } + _ => {} + } + self.modal = Some(modal); + Ok(()) + } + }; + if let Err(e) = result { + self.prepared = None; + self.info("Ação não concluída", e); + } + return; + } + if self.home { + match code { + KeyCode::Enter | KeyCode::Esc => self.home = false, + KeyCode::F(2) => self.command("/menu"), + _ => {} + } + return; + } + match code { + KeyCode::F(2) => self.command("/menu"), + KeyCode::Tab => self.command("/permissions"), + KeyCode::PageUp => self.scroll = self.scroll.saturating_add(5), + KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(5), + KeyCode::End => self.scroll = 0, + KeyCode::Home => self.scroll = u16::MAX, + KeyCode::Backspace => { + self.draft.pop(); + } + KeyCode::Char('/') if self.draft.is_empty() => self.command("/menu"), + KeyCode::Char(c) if !c.is_control() && self.draft.len() + c.len_utf8() <= 64 * 1024 => { + self.draft.push(c) + } + KeyCode::Enter => { + if self.draft.starts_with('/') { + let command = std::mem::take(&mut self.draft); + self.command(command.trim()); + } else if !self.draft.trim().is_empty() { + if self.frozen { + self.notice = + "Envio suspenso: falha de persistência. Exporte o histórico.".into(); + return; + } + match self.adapter.send(&self.draft) { + Ok(()) => { + let text = std::mem::take(&mut self.draft); + self.state.current_mut().record(Event::User { text }); + self.save(); + self.scroll = 0; + } + Err(e) => self.notice = e, + } + } + } + _ => {} + } + } +} + +fn clean(text: &str) -> String { + text.chars() + .filter(|c| !c.is_control() || *c == '\n' || *c == '\t') + .collect() +} +/// Pre-wrap by terminal cell widths so scroll offsets and long paths agree. +fn wrapped(text: &str, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + let mut lines = Vec::new(); + for raw in clean(text).split('\n') { + let mut line = String::new(); + let mut used = 0; + for c in raw.chars() { + let piece = if c == '\t' { + " ".into() + } else { + c.to_string() + }; + let cells = ratatui::text::Span::raw(piece.clone()).width(); + if used + cells > width && !line.is_empty() { + lines.push(Line::raw(std::mem::take(&mut line))); + used = 0; + } + line.push_str(&piece); + used += cells; + } + lines.push(Line::raw(line)); + } + lines +} +pub fn draw(f: &mut Frame, app: &App) { + let area = f.area(); + let p = app.palette; + f.render_widget( + Block::default().style(Style::default().fg(p.ink).bg(p.bg)), + area, + ); + if area.width < 30 || area.height < 10 { + f.render_widget( + Paragraph::new("SIMULAÇÃO. Terminal mínimo 30x10. Ctrl+Q sai."), + area, + ); + return; + } + if let Some(m) = &app.modal { + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(2), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(area); + f.render_widget( + Paragraph::new(clean(&format!("SIMULAÇÃO · {}", m.title))) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(p.accent)), + rows[0], + ); + let mut text = m.body.clone(); + for (i, (label, _)) in m.choices.iter().enumerate() { + text.push_str(&format!( + "\n{} {}", + if i == m.selected { "›" } else { " " }, + label + )); + } + let lines = wrapped(&text, rows[1].width); + let max = lines.len().saturating_sub(rows[1].height as usize); + // Keep selected menu entries visible, including lists larger than the terminal. + let offset = if m.choices.is_empty() { + usize::from(m.scroll).min(max) + } else { + let prefix = wrapped(&m.body, rows[1].width).len(); + let selected = prefix + + m.choices + .iter() + .take(m.selected) + .map(|(s, _)| wrapped(&format!("› {s}"), rows[1].width).len()) + .sum::(); + selected + .saturating_sub(rows[1].height as usize - 1) + .min(max) + }; + f.render_widget( + Paragraph::new(lines.into_iter().skip(offset).collect::>()), + rows[1], + ); + let input = if m.form.is_some() { + format!("> {}", m.input) + } else { + "↑↓ escolher · Enter confirmar".into() + }; + let lines = wrapped(&input, rows[2].width); + let skip = lines.len().saturating_sub(rows[2].height as usize); + f.render_widget( + Paragraph::new(lines.into_iter().skip(skip).collect::>()), + rows[2], + ); + f.render_widget(Paragraph::new("Esc volta · PgUp/Dn rola · Home"), rows[3]); + return; + } + if app.home { + let text = format!( + "⬢ MEMORY BEE · SIMULAÇÃO\n\n ⬡ ⬡\n ⬡ ⬢ ⬡ abelha: *\n ⬡ ⬡\n\nProjeto explícito:\n{}\n\n{} sessões locais\nColmeia estática — sem animação\n\nEnter abre conversa · F2 menu", + app.state.project, + app.state.sessions.len() + ); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + f.render_widget(Paragraph::new(wrapped(&text, area.width)), rows[0]); + f.render_widget(Paragraph::new("Enter conversa · F2 menu"), rows[1]); + return; + } + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(2), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + let s = app.state.current(); + f.render_widget( + Paragraph::new(format!( + "⬢ memory bee · SIMULAÇÃO\n{} · {} · sessão #{}\n{}", + s.agent.label(), + clean(&s.profile), + s.id, + clean(&s.project) + )) + .style(Style::default().fg(p.accent).add_modifier(Modifier::BOLD)), + rows[0], + ); + let mut text = String::new(); + if s.events.is_empty() { + text.push_str( + "Converse aqui com o agente simulado.\nF2 abre o menu; nenhuma CLI ocupa esta tela.\n", + ); + } + for (i, e) in s.events.iter().enumerate() { + let item = match e { + Event::User { text } => format!("Você: {text}"), + Event::Text { text } => format!("{}: {text}", s.agent.label()), + Event::Tool { name, detail } => format!("Ferramenta: {name} · {detail}"), + Event::Change { path, .. } => format!("Alteração simulada: {path} · /diff"), + Event::Approval { command, .. } => { + format!("Permissão: {command}\nTab abre opções; nenhuma ação automática.") + } + Event::Decision { allow, .. } => { + format!("Permissão {}", if *allow { "aceita" } else { "negada" }) + } + Event::Completed => "Turno simulado concluído".into(), + Event::Interrupted => "Turno interrompido".into(), + Event::Error { message } => format!("Erro: {message}"), + Event::Notice { message } => message.clone(), + }; + text.push_str(&format!("[{}] {item}\n\n", i + 1)); + } + let lines = wrapped(&text, rows[1].width); + let max = lines.len().saturating_sub(rows[1].height as usize); + let offset = max.saturating_sub(usize::from(app.scroll)); + f.render_widget( + Paragraph::new(lines.into_iter().skip(offset).collect::>()), + rows[1], + ); + let status = if app.frozen { + "Falha ao salvar — exporte antes de sair" + } else if s.running { + "◌ Turno ativo · Tab permissão · Ctrl+C para" + } else { + &app.notice + }; + f.render_widget( + Paragraph::new(clean(status)).style(Style::default().fg(p.warn)), + rows[2], + ); + let draft = wrapped(&format!("> {}", app.draft), rows[3].width); + let skip = draft.len().saturating_sub(3); + f.render_widget( + Paragraph::new(draft.into_iter().skip(skip).collect::>()) + .style(Style::default().fg(p.ink)), + rows[3], + ); + f.render_widget( + Paragraph::new("Enter envia · F2 menu · Ctrl+Q sai"), + rows[4], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend}; + fn app() -> App { + App::new( + State::new("/synthetic/project".into(), Agent::Claude), + None, + Mode::Dark, + true, + ) + } + fn key(app: &mut App, code: KeyCode) { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); + } + fn send(app: &mut App, text: &str) { + app.paste(text); + key(app, KeyCode::Enter); + } + fn frame(app: &App, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|f| draw(f, app)).unwrap(); + let b = terminal.backend().buffer(); + (0..height) + .map(|y| (0..width).map(|x| b[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") + } + #[test] + fn menus_preserve_draft_and_stream_while_open_and_paste_never_sends() { + let mut a = app(); + send(&mut a, "task"); + a.paste("draft\n漢字"); + key(&mut a, KeyCode::F(2)); + for _ in 0..5 { + a.tick(); + } + assert_eq!(a.state.current().events.len(), 6); + assert!(a.modal.is_some()); + key(&mut a, KeyCode::Esc); + assert_eq!(a.draft, "draft\n漢字"); + key(&mut a, KeyCode::Tab); + key(&mut a, KeyCode::Enter); // Default is deny. + for _ in 0..3 { + a.tick(); + } + assert!(!a.state.current().running); + assert!( + a.state + .current() + .events + .iter() + .any(|e| matches!(e, Event::Decision { allow: false, .. })) + ); + assert_eq!(a.draft, "draft\n漢字"); + } + #[test] + fn interrupt_invalidates_open_approval_and_active_quit_needs_confirmation() { + let mut a = app(); + send(&mut a, "task"); + for _ in 0..5 { + a.tick(); + } + a.command("/permissions"); + a.interrupt(); + key(&mut a, KeyCode::Down); + key(&mut a, KeyCode::Enter); + assert!( + !a.state + .current() + .events + .iter() + .any(|e| matches!(e, Event::Decision { allow: true, .. })) + ); + key(&mut a, KeyCode::Esc); + send(&mut a, "next"); + a.command("/quit"); + assert!(!a.quit); + key(&mut a, KeyCode::Esc); + assert!(!a.quit); + a.command("/quit"); + key(&mut a, KeyCode::Enter); + assert!(a.quit); + assert!(!a.state.current().running); + } + #[test] + fn context_switch_reviews_full_history_and_preserves_source() { + let mut a = app(); + send(&mut a, "first task"); + a.interrupt(); + a.state.current_mut().record(Event::Text { + text: "middle record".into(), + }); + a.state.current_mut().record(Event::Completed); + let source = a.state.current().clone(); + a.choose(Choice::Switch( + Agent::Codex, + a.state.profiles[1].clone(), + true, + )) + .unwrap(); + assert!(a.modal.as_ref().unwrap().body.contains("middle record")); + a.paste("CONTINUAR"); + key(&mut a, KeyCode::Enter); + assert_eq!(a.state.sessions[0], source); + assert_eq!(a.state.current().agent, Agent::Codex); + a.command("/not-a-command"); + assert!(!a.state.current().running); + } + #[test] + fn narrow_home_and_long_menus_keep_controls_and_selection_visible() { + let mut a = app(); + for (width, height) in [(100, 30), (60, 20), (40, 12), (30, 10)] { + a.home = true; + let text = frame(&a, width, height); + assert!(text.contains("Enter conversa")); + a.home = false; + a.command("/menu"); + for _ in 0..9 { + key(&mut a, KeyCode::Down); + } + let text = frame(&a, width, height); + assert!(text.contains("› /quit")); + assert!(text.contains("Esc volta")); + assert!(text.contains("SIMULAÇÃO")); + key(&mut a, KeyCode::Esc); + } + } +} diff --git a/src/workspace/adapter.rs b/src/workspace/adapter.rs new file mode 100644 index 0000000..40cb542 --- /dev/null +++ b/src/workspace/adapter.rs @@ -0,0 +1,109 @@ +//! Provider-independent commands and a deterministic, model-free simulator. +use super::{Agent, Event}; +use std::collections::VecDeque; + +pub trait Adapter { + fn send(&mut self, message: &str) -> Result<(), String>; + fn poll(&mut self) -> Option; + fn decide(&mut self, id: &str, allow: bool) -> Result<(), String>; + fn interrupt(&mut self); +} + +pub struct Demo { + agent: Agent, + queue: VecDeque, + pending: Option, + turn: u64, + active: bool, +} +impl Demo { + pub fn new(agent: Agent) -> Self { + Self { + agent, + queue: VecDeque::new(), + pending: None, + turn: 0, + active: false, + } + } +} +impl Adapter for Demo { + fn send(&mut self, message: &str) -> Result<(), String> { + if self.active { + return Err("Aguarde ou interrompa o turno atual".into()); + } + self.turn += 1; + self.active = true; + self.queue.push_back(Event::Text { + text: format!("[SIMULAÇÃO {}] Vou mostrar o fluxo ", self.agent.label()), + }); + self.queue.push_back(Event::Text { + text: "de leitura, alteração e permissão. Nenhum arquivo do projeto será alterado." + .into(), + }); + if message.contains("[erro]") { + self.queue.push_back(Event::Error { + message: "Falha sintética do provedor; histórico preservado.".into(), + }); + } else { + self.queue.push_back(Event::Tool { + name: "Ler arquivo (simulado)".into(), + detail: "src/example.rs".into(), + }); + self.queue.push_back(Event::Change { + path: "src/example.rs".into(), + diff: + "--- exemplo sintético\n+++ exemplo sintético\n- todo!()\n+ buscar_por_nome()" + .into(), + }); + self.queue.push_back(Event::Approval { + id: format!("demo-{}", self.turn), + command: "cargo test (simulado; não será executado)".into(), + }); + } + Ok(()) + } + fn poll(&mut self) -> Option { + if self.pending.is_some() { + return None; + } + let event = self.queue.pop_front()?; + if let Event::Approval { id, .. } = &event { + self.pending = Some(id.clone()); + } + if matches!( + event, + Event::Completed | Event::Interrupted | Event::Error { .. } + ) { + self.active = false; + } + Some(event) + } + fn decide(&mut self, id: &str, allow: bool) -> Result<(), String> { + if self.pending.as_deref() != Some(id) { + return Err("Permissão expirada ou desconhecida".into()); + } + self.pending = None; + self.queue.push_back(Event::Decision { + id: id.into(), + allow, + }); + self.queue.push_back(Event::Text { + text: if allow { + "Permissão aceita. Teste sintético concluído." + } else { + "Permissão negada. Nenhum comando foi executado." + } + .into(), + }); + self.queue.push_back(Event::Completed); + Ok(()) + } + fn interrupt(&mut self) { + self.queue.clear(); + self.pending = None; + if self.active { + self.queue.push_back(Event::Interrupted); + } + } +} diff --git a/src/workspace/codex.rs b/src/workspace/codex.rs new file mode 100644 index 0000000..9f98d5a --- /dev/null +++ b/src/workspace/codex.rs @@ -0,0 +1,348 @@ +//! Experimental Codex App Server protocol adapter. Not enabled in the public +//! workspace: release requires both subscription-backed providers to pass QA. +use super::Event; +use serde_json::{Value, json}; +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader, Write}, + path::Path, + process::{Child, ChildStdin, Command, Stdio}, + sync::mpsc::{self, Receiver, TryRecvError}, +}; + +const MAX_FRAME: u64 = 1024 * 1024; + +pub struct Transport { + child: Child, + stdin: ChildStdin, + messages: Receiver>, +} +impl Transport { + /// Caller supplies a private per-profile home. Provider environment variables + /// and the default home are not inherited; platform credential isolation is + /// still unverified. No shell interprets provider commands. + pub fn spawn(executable: &Path, profile_home: &Path, project: &Path) -> Result { + let metadata = std::fs::symlink_metadata(profile_home).map_err(|e| e.to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("Profile requires a real private directory".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err("Profile requires mode 0700".into()); + } + } + let home = profile_home.canonicalize().map_err(|e| e.to_string())?; + if !home.is_dir() { + return Err("Profile home must be a directory".into()); + } + let mut cmd = Command::new(executable); + cmd.args(["app-server", "--listen", "stdio://"]) + .current_dir(project) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", &home) + .env("CODEX_HOME", &home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = cmd.spawn().map_err(|e| e.to_string())?; + let stdin = child.stdin.take().ok_or("No provider stdin")?; + let stdout = child.stdout.take().ok_or("No provider stdout")?; + let (tx, messages) = mpsc::sync_channel(128); + std::thread::spawn(move || { + use std::io::Read; + let mut reader = BufReader::new(stdout); + loop { + let mut bytes = Vec::new(); + let read = reader + .by_ref() + .take(MAX_FRAME + 1) + .read_until(b'\n', &mut bytes); + let frame = match read { + Ok(0) => Err("Provider disconnected; commands will not be replayed".into()), + Ok(_) if bytes.len() as u64 > MAX_FRAME => { + Err("Provider frame exceeds 1 MiB".into()) + } + Ok(_) => { + serde_json::from_slice(&bytes).map_err(|_| "Malformed provider JSON".into()) + } + Err(_) => Err("Provider transport read failed".into()), + }; + let terminal = frame.is_err(); + if tx.send(frame).is_err() || terminal { + break; + } + } + }); + Ok(Self { + child, + stdin, + messages, + }) + } + pub fn send(&mut self, message: &Value) -> Result<(), String> { + let mut bytes = serde_json::to_vec(message).map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_FRAME { + return Err("Request exceeds 1 MiB".into()); + } + bytes.push(b'\n'); + self.stdin + .write_all(&bytes) + .and_then(|()| self.stdin.flush()) + .map_err(|e| e.to_string()) + } + pub fn poll(&mut self) -> Result, String> { + match self.messages.try_recv() { + Ok(result) => result.map(Some), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err("Provider disconnected".into()), + } + } +} +impl Drop for Transport { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[derive(Default)] +pub struct Protocol { + initialized: bool, + next: u64, + pending: BTreeMap, + approvals: BTreeMap, + pub thread: Option, + pub turn: Option, +} +pub struct Incoming { + pub events: Vec, + pub replies: Vec, +} +impl Protocol { + fn request(&mut self, method: &str, params: Value) -> Value { + self.next += 1; + self.pending.insert(self.next, method.into()); + json!({"id":self.next,"method":method,"params":params}) + } + pub fn initialize(&mut self) -> Value { + self.request("initialize", json!({"clientInfo":{"name":"memory_bee","title":"Memory Bee","version":env!("CARGO_PKG_VERSION")}})) + } + pub fn account(&mut self) -> Result { + self.ready()?; + Ok(self.request("account/read", json!({"refreshToken":false}))) + } + pub fn login(&mut self) -> Result { + self.ready()?; + Ok(self.request("account/login/start", json!({"type":"chatgpt"}))) + } + fn ready(&self) -> Result<(), String> { + if self.initialized { + Ok(()) + } else { + Err("Initialize must succeed before requests".into()) + } + } + pub fn start(&mut self, project: &Path, resume: Option<&str>) -> Result { + self.ready()?; + if self.turn.is_some() + || self + .pending + .values() + .any(|m| matches!(m.as_str(), "turn/start" | "thread/start" | "thread/resume")) + { + return Err("Turn or thread request is active".into()); + } + let cwd = project.to_str().ok_or("Project must be UTF-8")?; + Ok(if let Some(id) = resume { + self.request("thread/resume", json!({"threadId":id,"cwd":cwd})) + } else { + self.request( + "thread/start", + json!({"cwd":cwd,"approvalPolicy":"on-request","sandbox":"workspace-write"}), + ) + }) + } + pub fn send(&mut self, text: &str) -> Result { + self.ready()?; + if self.turn.is_some() + || self + .pending + .values() + .any(|m| matches!(m.as_str(), "turn/start" | "thread/start" | "thread/resume")) + { + return Err("Turn is active".into()); + } + let thread = self.thread.clone().ok_or("No thread")?; + Ok(self.request( + "turn/start", + json!({"threadId":thread,"input":[{"type":"text","text":text}]}), + )) + } + pub fn interrupt(&mut self) -> Result { + let thread = self.thread.clone().ok_or("No thread")?; + let turn = self.turn.clone().ok_or("No turn")?; + Ok(self.request("turn/interrupt", json!({"threadId":thread,"turnId":turn}))) + } + pub fn decide(&mut self, key: &str, allow: bool) -> Result { + let id = self + .approvals + .remove(key) + .ok_or("Approval expired or unknown")?; + Ok(json!({"id":id,"result":{"decision":if allow {"accept"} else {"decline"}}})) + } + pub fn receive(&mut self, value: Value) -> Incoming { + let mut out = Incoming { + events: Vec::new(), + replies: Vec::new(), + }; + if let Some(method) = value.get("method").and_then(Value::as_str) { + let params = &value["params"]; + if let Some(id) = value.get("id") { + let key = id.to_string(); + let supported = matches!( + method, + "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" + ); + // Approval context must match the active thread and turn. + if supported + && params["threadId"].as_str() == self.thread.as_deref() + && params["turnId"].as_str() == self.turn.as_deref() + && self.turn.is_some() + && !self.approvals.contains_key(&key) + && params.get("availableDecisions").is_none_or(|choices| { + choices.is_null() + || choices.as_array().is_some_and(|choices| { + choices.contains(&json!("accept")) + && choices.contains(&json!("decline")) + }) + }) + { + self.approvals.insert(key.clone(), id.clone()); + out.events.push(Event::Approval { + id: key, + command: params.to_string(), + }); + } else { + out.replies.push(json!({"id":id,"error":{"code":-32601,"message":"Unsupported or stale request; permission not granted"}})); + out.events.push(Event::Error { + message: format!("Unsupported or stale provider request: {method}"), + }); + } + return out; + } + if params + .get("threadId") + .and_then(Value::as_str) + .is_some_and(|t| Some(t) != self.thread.as_deref()) + { + return out; + } + let scoped = matches!( + method, + "turn/started" + | "turn/completed" + | "item/agentMessage/delta" + | "item/started" + | "item/completed" + | "serverRequest/resolved" + ); + if scoped + && (self.thread.is_none() || params["threadId"].as_str() != self.thread.as_deref()) + { + return out; + } + let event_turn = if method.starts_with("turn/") { + params["turn"]["id"].as_str() + } else { + params["turnId"].as_str() + }; + if scoped + && method != "serverRequest/resolved" + && method != "turn/started" + && (self.turn.is_none() || event_turn != self.turn.as_deref()) + { + return out; + } + match method { + "serverRequest/resolved" => { + self.approvals.remove(¶ms["requestId"].to_string()); + } + "turn/started" => { + if self.pending.values().any(|m| m == "turn/start") + || self.turn.as_deref() == event_turn + { + self.turn = event_turn.map(str::to_owned); + } + } + "item/agentMessage/delta" => { + if let Some(text) = params["delta"].as_str() { + out.events.push(Event::Text { text: text.into() }); + } + } + "item/started" | "item/completed" => { + let item = ¶ms["item"]; + match item["type"].as_str() { + Some("commandExecution") => out.events.push(Event::Tool { + name: method.into(), + detail: item.to_string(), + }), + Some("fileChange") => out.events.push(Event::Change { + path: "provider changes".into(), + diff: item.to_string(), + }), + Some("userMessage" | "agentMessage") => {} + _ => out.events.push(Event::Notice { + message: format!("Provider item not rendered: {}", item["type"]), + }), + } + } + "turn/completed" => { + self.turn = None; + self.approvals.clear(); + out.events.push(match params["turn"]["status"].as_str() { + Some("completed") => Event::Completed, + Some("interrupted") => Event::Interrupted, + _ => Event::Error { + message: "Provider turn failed; inspect native history".into(), + }, + }); + } + "error" => out.events.push(Event::Error { + message: "Provider error; history preserved".into(), + }), + _ => out.events.push(Event::Notice { + message: format!("Provider notification: {method}"), + }), + } + } else if let Some(id) = value["id"].as_u64() { + if let Some(method) = self.pending.remove(&id) { + if value.get("error").is_some() { + out.events.push(Event::Error { + message: format!("Provider rejected {method}; no automatic retry"), + }); + } else if let Some(result) = value.get("result") { + match method.as_str() { + "initialize" => { self.initialized = true; out.replies.push(json!({"method":"initialized","params":{}})); } + "thread/start" | "thread/resume" => self.thread = result["thread"]["id"].as_str().map(str::to_owned), + "turn/start" => self.turn = result["turn"]["id"].as_str().map(str::to_owned), + // Authentication payloads (including URLs) stay out of history. + "account/read" | "account/login/start" => out.events.push(Event::Notice { message: format!("{method} completed; authentication data is not conversation history") }), + _ => {} + } + } else { + out.events.push(Event::Error { + message: "Provider response missing result".into(), + }); + } + } + } else { + out.events.push(Event::Error { + message: "Unrecognized provider envelope".into(), + }); + } + out + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..337c1e4 --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,80 @@ +//! Unified workspace contracts. Demo and native providers are never conflated. +pub mod adapter; +pub mod codex; +pub mod store; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Agent { + Claude, + Codex, +} +impl Agent { + pub fn label(self) -> &'static str { + match self { + Self::Claude => "Claude", + Self::Codex => "Codex", + } + } + pub fn parse(s: &str) -> Option { + match s { + "claude" => Some(Self::Claude), + "codex" => Some(Self::Codex), + _ => None, + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Event { + User { text: String }, + Text { text: String }, + Tool { name: String, detail: String }, + Change { path: String, diff: String }, + Approval { id: String, command: String }, + Decision { id: String, allow: bool }, + Completed, + Interrupted, + Error { message: String }, + Notice { message: String }, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Session { + pub id: u64, + pub agent: Agent, + pub profile: String, + pub project: String, + pub native_id: Option, + pub simulation: bool, + pub running: bool, + pub events: Vec, +} +impl Session { + pub fn new(id: u64, agent: Agent, profile: String, project: String) -> Self { + Self { + id, + agent, + profile, + project, + native_id: None, + simulation: true, + running: false, + events: Vec::new(), + } + } + pub fn record(&mut self, event: Event) { + match event { + Event::User { .. } => self.running = true, + Event::Completed | Event::Interrupted | Event::Error { .. } => self.running = false, + _ => {} + } + self.events.push(event); + } +} + +/// Both providers must be validated with subscriptions before public live mode. +pub fn live_gate() -> Result<(), String> { + Err("Programação real indisponível: Claude e Codex com assinaturas precisam ser validados juntos. Use --demo para a simulação; nenhuma API paga é usada como alternativa.".into()) +} diff --git a/src/workspace/store.rs b/src/workspace/store.rs new file mode 100644 index 0000000..0743b57 --- /dev/null +++ b/src/workspace/store.rs @@ -0,0 +1,199 @@ +//! Explicit private local storage, single-writer lock, atomic snapshots. +use super::{Agent, Event, Session}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +const MAX_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct State { + pub version: u32, + pub project: String, + pub profiles: Vec, + pub sessions: Vec, + pub selected: usize, +} +impl State { + pub fn new(project: String, agent: Agent) -> Self { + Self { + version: 1, + project: project.clone(), + profiles: vec!["Pessoal (demo)".into(), "Trabalho (demo)".into()], + sessions: vec![Session::new(1, agent, "Pessoal (demo)".into(), project)], + selected: 0, + } + } + pub fn current(&self) -> &Session { + &self.sessions[self.selected] + } + pub fn current_mut(&mut self) -> &mut Session { + &mut self.sessions[self.selected] + } + pub fn fork(&mut self, agent: Agent, profile: String, context: bool) -> Result<(), String> { + if !self.profiles.contains(&profile) { + return Err("Perfil desconhecido".into()); + } + if self.current().running { + return Err("Interrompa ou aguarde o turno antes de trocar".into()); + } + if self.sessions.len() >= 128 { + return Err("Limite de 128 sessões; use outra pasta de estado".into()); + } + let id = self + .sessions + .iter() + .map(|s| s.id) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or("Limite de IDs")?; + let mut session = Session::new(id, agent, profile, self.project.clone()); + if context { + // A provenance reference, not a native continuation or a provider message. + let source = self.current(); + session.record(Event::Notice { message: format!("SIMULAÇÃO: contexto selecionado da sessão {} ({} / {}). Histórico de origem preservado; nenhum harness recebeu conteúdo.", source.id, source.agent.label(), source.profile) }); + for event in &source.events { + if let Event::User { text } | Event::Text { text } = event { + session.events.push(Event::Notice { + message: format!("Contexto importado da sessão {}: {text}", source.id), + }); + } + } + } + self.sessions.push(session); + self.selected = self.sessions.len() - 1; + Ok(()) + } + fn validate(&self, project: &str) -> Result<(), String> { + if self.version != 1 + || self.project != project + || self.selected >= self.sessions.len() + || self.sessions.len() > 128 + || self.profiles.is_empty() + || self.profiles.len() > 32 + { + return Err("Estado incompatível, inválido ou pertencente a outro projeto".into()); + } + let mut ids = std::collections::BTreeSet::new(); + if self.sessions.iter().any(|s| { + !s.simulation + || s.native_id.is_some() + || s.project != self.project + || !self.profiles.contains(&s.profile) + || !ids.insert(s.id) + || s.id == u64::MAX + || s.events.len() > 100_000 + }) { + return Err("Sessão inválida no estado de simulação".into()); + } + Ok(()) + } +} + +pub struct Store { + root: PathBuf, +} +fn private_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path).map_err(|e| e.to_string()) +} +impl Store { + pub fn open(root: &Path, project: &str, agent: Agent) -> Result<(Self, State), String> { + if !root.exists() { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(root).map_err(|e| e.to_string())?; + } + let meta = fs::symlink_metadata(root).map_err(|e| e.to_string())?; + if !meta.is_dir() || meta.file_type().is_symlink() { + return Err("Estado exige pasta real, sem symlink".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o077 != 0 { + return Err("Pasta de estado deve ter permissão 0700".into()); + } + } + let root = root.canonicalize().map_err(|e| e.to_string())?; + let mut lock = private_file(&root.join("workspace.lock")).map_err(|_| "Estado em uso ou trava anterior presente. Após confirmar que não existe outro processo, remova workspace.lock manualmente.")?; + let store = Self { root }; + writeln!(lock, "{}", std::process::id()).map_err(|e| e.to_string())?; + let path = store.root.join("workspace.json"); + let metadata = match fs::symlink_metadata(&path) { + Ok(meta) => Some(meta), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.to_string()), + }; + let mut state = if let Some(meta) = metadata { + if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > MAX_BYTES { + return Err("Arquivo de estado inválido ou acima de 16 MiB".into()); + } + let mut bytes = Vec::new(); + fs::File::open(&path) + .map_err(|e| e.to_string())? + .take(MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Estado acima do limite".into()); + } + serde_json::from_slice::(&bytes) + .map_err(|e| format!("Estado inválido; original preservado: {e}"))? + } else { + State::new(project.into(), agent) + }; + state.validate(project)?; + for session in &mut state.sessions { + if session.running { + session.record(Event::Interrupted); + session.record(Event::Notice { + message: "Processo anterior interrompido; nenhum comando foi reenviado.".into(), + }); + } + } + store.save(&state)?; + Ok((store, state)) + } + pub fn save(&self, state: &State) -> Result<(), String> { + state.validate(&state.project)?; + let bytes = serde_json::to_vec(state).map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Estado acima de 16 MiB; exporte o histórico antes de continuar".into()); + } + let temp = self.root.join("workspace.new"); + let mut file = private_file(&temp).map_err( + |_| "Arquivo workspace.new já existe ou está indisponível; original preservado", + )?; + let result = (|| { + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|e| e.to_string())?; + fs::rename(&temp, self.root.join("workspace.json")).map_err(|e| e.to_string())?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result + } +} +impl Drop for Store { + fn drop(&mut self) { + let _ = fs::remove_file(self.root.join("workspace.lock")); + } +} diff --git a/tests/workspace.rs b/tests/workspace.rs new file mode 100644 index 0000000..66ff31d --- /dev/null +++ b/tests/workspace.rs @@ -0,0 +1,406 @@ +use memory_bee::{ + bundle, receive, + workspace::{ + Agent, Event, + adapter::{Adapter, Demo}, + codex::Protocol, + store::{State, Store}, + }, +}; +use serde_json::json; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let p = std::env::temp_dir().join(format!( + "memory-bee-workspace-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&p).unwrap(); + Self(p) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn until_approval(demo: &mut Demo) -> String { + for _ in 0..10 { + if let Some(Event::Approval { id, .. }) = demo.poll() { + return id; + } + } + panic!("missing approval") +} +#[test] +fn both_demo_adapters_require_decision_and_never_execute() { + for agent in [Agent::Claude, Agent::Codex] { + let mut demo = Demo::new(agent); + demo.send("task").unwrap(); + assert!(demo.send("overlap").is_err()); + let id = until_approval(&mut demo); + assert!(demo.poll().is_none()); + assert!(demo.decide("wrong", true).is_err()); + demo.decide(&id, false).unwrap(); + assert!(matches!( + demo.poll(), + Some(Event::Decision { allow: false, .. }) + )); + demo.poll(); + assert_eq!(demo.poll(), Some(Event::Completed)); + demo.send("next").unwrap(); + } +} +#[test] +fn interruption_invalidates_permission_and_error_finishes_turn() { + let mut demo = Demo::new(Agent::Claude); + demo.send("task").unwrap(); + let id = until_approval(&mut demo); + demo.interrupt(); + assert_eq!(demo.poll(), Some(Event::Interrupted)); + assert!(demo.decide(&id, true).is_err()); + assert!(demo.poll().is_none()); + demo.send("[erro]").unwrap(); + demo.poll(); + demo.poll(); + assert!(matches!(demo.poll(), Some(Event::Error { .. }))); + demo.send("retry manually").unwrap(); +} +#[test] +fn store_reopens_history_and_marks_interrupted_without_replay() { + let temp = Temp::new(); + let path = temp.0.join("state"); + let (store, mut state) = Store::open(&path, "/synthetic/project", Agent::Claude).unwrap(); + state.current_mut().record(Event::User { + text: "pending".into(), + }); + store.save(&state).unwrap(); + assert!(Store::open(&path, "/synthetic/project", Agent::Codex).is_err()); + drop(store); + let (_store, state) = Store::open(&path, "/synthetic/project", Agent::Codex).unwrap(); + assert_eq!(state.current().agent, Agent::Claude); + assert!(!state.current().running); + assert!(state.current().events.contains(&Event::Interrupted)); + assert!(matches!(state.current().events[0], Event::User { .. })); +} +#[test] +fn store_refuses_foreign_project_and_preserves_malformed_data() { + let temp = Temp::new(); + let path = temp.0.join("state"); + let (store, _) = Store::open(&path, "/one", Agent::Claude).unwrap(); + drop(store); + assert!(Store::open(&path, "/two", Agent::Codex).is_err()); + assert!(!path.join("workspace.lock").exists()); + fs::write(path.join("workspace.json"), "bad data").unwrap(); + assert!(Store::open(&path, "/one", Agent::Codex).is_err()); + assert_eq!( + fs::read_to_string(path.join("workspace.json")).unwrap(), + "bad data" + ); +} +#[cfg(unix)] +#[test] +fn store_uses_private_permissions_and_refuses_symlinks() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let temp = Temp::new(); + let path = temp.0.join("state"); + let (store, _) = Store::open(&path, "/one", Agent::Claude).unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(path.join("workspace.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + drop(store); + symlink(&path, temp.0.join("alias")).unwrap(); + assert!(Store::open(&temp.0.join("alias"), "/one", Agent::Codex).is_err()); + fs::remove_file(path.join("workspace.json")).unwrap(); + symlink(temp.0.join("target"), path.join("workspace.json")).unwrap(); + fs::write(temp.0.join("target"), "untouched").unwrap(); + assert!(Store::open(&path, "/one", Agent::Codex).is_err()); + assert_eq!( + fs::read_to_string(temp.0.join("target")).unwrap(), + "untouched" + ); +} +#[test] +fn state_fork_keeps_source_identity_and_requires_idle_turn() { + let mut state = State::new("/project".into(), Agent::Claude); + state.current_mut().record(Event::User { + text: "original task".into(), + }); + assert!( + state + .fork(Agent::Codex, state.profiles[1].clone(), true) + .is_err() + ); + state.current_mut().record(Event::Completed); + let original = state.current().clone(); + state + .fork(Agent::Codex, state.profiles[1].clone(), true) + .unwrap(); + assert_eq!(state.sessions[0], original); + assert_eq!(state.current().agent, Agent::Codex); + assert_eq!(state.current().profile, "Trabalho (demo)"); + assert!( + state + .current() + .events + .iter() + .any(|e| matches!(e,Event::Notice{message} if message.contains("original task"))) + ); +} +#[test] +fn simulation_bundle_has_honest_provenance_and_valid_hashes() { + let tmp = Temp::new(); + let mut state = State::new("/project".into(), Agent::Claude); + state.current_mut().record(Event::User { + text: "Task".into(), + }); + state.current_mut().record(Event::Text { + text: "simulated answer".into(), + }); + let prepared = + bundle::prepare_workspace_demo(state.current(), &bundle::Options::default()).unwrap(); + assert!(prepared.is_partial()); + assert!(prepared.handoff().contains("SIMULAÇÃO")); + let manifest = serde_json::to_value(prepared.manifest()).unwrap(); + assert_eq!(manifest["source"]["agent"], "memory-bee-demo"); + assert!(prepared.history().contains("user_input")); + assert!(prepared.history().contains("simulated")); + prepared.write(&tmp.0.join("bundle")).unwrap(); + let v = receive::verify(&tmp.0.join("bundle")).unwrap(); + assert!(v.known_source().is_none()); + assert!(!v.has_changes()); +} +#[test] +fn simulation_export_blocks_secrets_and_allows_exclusion() { + let tmp = Temp::new(); + let mut state = State::new("/project".into(), Agent::Codex); + let token = format!("ghp_{}", "syntheticfixture".repeat(3)); + state.current_mut().record(Event::User { text: token }); + state.current_mut().record(Event::Text { + text: "safe".into(), + }); + let p = bundle::prepare_workspace_demo(state.current(), &bundle::Options::default()).unwrap(); + assert!(p.write(&tmp.0.join("blocked")).is_err()); + assert!(!tmp.0.join("blocked").exists()); + let p = bundle::prepare_workspace_demo( + state.current(), + &bundle::Options { + exclude_lines: [1].into(), + ..Default::default() + }, + ) + .unwrap(); + p.write(&tmp.0.join("safe")).unwrap(); + assert!(receive::verify(&tmp.0.join("safe")).is_ok()); +} +fn ready() -> Protocol { + let mut p = Protocol::default(); + let init = p.initialize(); + let out = p.receive(json!({"id":init["id"],"result":{"userAgent":"synthetic"}})); + assert_eq!(out.replies[0]["method"], "initialized"); + let start = p.start(std::path::Path::new("/project"), None).unwrap(); + p.receive(json!({"id":start["id"],"result":{"thread":{"id":"t"}}})); + let send = p.send("hello").unwrap(); + p.receive(json!({"id":send["id"],"result":{"turn":{"id":"turn"}}})); + p +} +#[test] +fn protocol_requires_handshake_correlates_requests_and_refuses_overlap() { + let mut p = Protocol::default(); + assert!(p.send("before init").is_err()); + assert!(p.start(std::path::Path::new("/project"), None).is_err()); + let mut p = ready(); + assert!(p.send("overlap").is_err()); + assert_eq!(p.interrupt().unwrap()["params"]["turnId"], "turn"); + p.receive(json!({"method":"turn/completed","params":{"threadId":"t","turn":{"id":"turn","status":"interrupted"}}})); + assert!(p.send("manual retry").is_ok()); +} +#[test] +fn protocol_scopes_and_invalidates_approvals_and_never_grants_unknown_requests() { + let mut p = ready(); + let out=p.receive(json!({"id":9,"method":"item/commandExecution/requestApproval","params":{"threadId":"other","turnId":"turn"}})); + assert!(out.replies[0].get("error").is_some()); + assert!(p.decide("9", true).is_err()); + let out=p.receive(json!({"id":10,"method":"item/commandExecution/requestApproval","params":{"threadId":"t","turnId":"turn","command":"echo synthetic"}})); + assert!(matches!(out.events[0], Event::Approval { .. })); + assert_eq!( + p.decide("10", false).unwrap()["result"]["decision"], + "decline" + ); + assert!(p.decide("10", true).is_err()); + let out = p.receive(json!({"id":"unknown","method":"item/tool/requestUserInput","params":{}})); + assert!(out.replies[0].get("error").is_some()); + p.receive(json!({"id":11,"method":"item/fileChange/requestApproval","params":{"threadId":"t","turnId":"turn"}})); + p.receive(json!({"method":"turn/completed","params":{"threadId":"t","turn":{"id":"turn","status":"completed"}}})); + assert!(p.decide("11", true).is_err()); +} +#[test] +fn protocol_authentication_values_never_become_conversation_events() { + let mut p = ready(); + let request = p.account().unwrap(); + let out=p.receive(json!({"id":request["id"],"result":{"account":{"email":"synthetic@example.invalid","token":"private-value"}}})); + let events = serde_json::to_string(&out.events).unwrap(); + assert!(!events.contains("synthetic@example.invalid")); + assert!(!events.contains("private-value")); +} +#[test] +fn cli_gate_precedes_state_creation_and_once_is_read_only() { + let tmp = Temp::new(); + let binary = env!("CARGO_BIN_EXE_memory-bee"); + let out = std::process::Command::new(binary) + .args(["workspace", "--project"]) + .arg(&tmp.0) + .arg("--state") + .arg(tmp.0.join("state")) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(64)); + assert!(!tmp.0.join("state").exists()); + for agent in ["claude", "codex"] { + for (w, h) in [(100, 30), (60, 20), (40, 12), (30, 10)] { + let out = std::process::Command::new(binary) + .args(["workspace", "--demo", "--project"]) + .arg(&tmp.0) + .args([ + "--once", + "--agent", + agent, + "--width", + &w.to_string(), + "--height", + &h.to_string(), + "--no-color", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let text = String::from_utf8(out.stdout).unwrap(); + assert!(text.contains("SIMULAÇÃO")); + assert!(!text.contains('\x1b')); + assert_eq!(text.lines().count(), h); + } + } + assert_eq!(fs::read_dir(&tmp.0).unwrap().count(), 0); +} +#[test] +fn malformed_protocol_does_not_start_or_retry_work() { + let mut p = Protocol::default(); + let r = p.initialize(); + let out = p.receive(json!({"id":r["id"],"error":{"message":"failed"}})); + assert!(matches!(out.events[0], Event::Error { .. })); + assert!(p.account().is_err()); + assert!(p.thread.is_none()); + assert!(p.turn.is_none()); +} + +#[test] +fn stale_turn_events_and_resolved_permissions_cannot_affect_current_work() { + let mut p = ready(); + for event in [ + json!({"method":"turn/completed","params":{"threadId":"t","turn":{"id":"old","status":"completed"}}}), + json!({"method":"item/agentMessage/delta","params":{"threadId":"t","turnId":"old","delta":"stale"}}), + json!({"method":"item/agentMessage/delta","params":{"turnId":"turn","delta":"unscoped"}}), + ] { + assert!(p.receive(event).events.is_empty()); + assert_eq!(p.turn.as_deref(), Some("turn")); + } + p.receive(json!({"id":12,"method":"item/commandExecution/requestApproval","params":{"threadId":"t","turnId":"turn"}})); + p.receive(json!({"method":"serverRequest/resolved","params":{"threadId":"t","requestId":12}})); + assert!(p.decide("12", true).is_err()); + let out = p.receive(json!({"id":13,"method":"item/commandExecution/requestApproval","params":{"threadId":"t","turnId":"turn","availableDecisions":["cancel"]}})); + assert!(out.replies[0].get("error").is_some()); + assert!(p.decide("13", true).is_err()); +} + +#[test] +fn interrupted_export_reports_partial_history_in_markdown_and_manifest() { + let mut state = State::new("/project".into(), Agent::Claude); + state.current_mut().record(Event::User { + text: "task".into(), + }); + state.current_mut().record(Event::Interrupted); + let p = bundle::prepare_workspace_demo(state.current(), &bundle::Options::default()).unwrap(); + assert!(p.is_partial()); + assert!(p.handoff().contains("Snapshot parcial")); + assert!( + serde_json::to_string(p.manifest()) + .unwrap() + .contains("Snapshot parcial") + ); +} + +#[cfg(unix)] +#[test] +fn dangling_state_symlink_is_not_replaced() { + let temp = Temp::new(); + let path = temp.0.join("state"); + let (store, _) = Store::open(&path, "/one", Agent::Claude).unwrap(); + drop(store); + fs::remove_file(path.join("workspace.json")).unwrap(); + std::os::unix::fs::symlink(temp.0.join("absent"), path.join("workspace.json")).unwrap(); + assert!(Store::open(&path, "/one", Agent::Claude).is_err()); + assert!( + fs::symlink_metadata(path.join("workspace.json")) + .unwrap() + .file_type() + .is_symlink() + ); +} + +#[cfg(unix)] +#[test] +fn transport_uses_explicit_profile_and_bounds_invalid_frames() { + use memory_bee::workspace::codex::Transport; + use std::os::unix::fs::PermissionsExt; + let temp = Temp::new(); + let home = temp.0.join("profile"); + fs::create_dir(&home).unwrap(); + fs::set_permissions(&home, fs::Permissions::from_mode(0o700)).unwrap(); + let fake = temp.0.join("fake-provider"); + fs::write(&fake, "#!/bin/sh\n[ \"$1\" = app-server ] || exit 1\n[ \"$2\" = --listen ] || exit 2\n[ \"$3\" = stdio:// ] || exit 3\n[ \"$HOME\" = \"$CODEX_HOME\" ] || exit 4\n[ -z \"$OPENAI_API_KEY$CODEX_API_KEY$CODEX_ACCESS_TOKEN\" ] || exit 5\nprintf 'private\\n' > \"$CODEX_HOME/marker\"\nread -r request\nprintf '{\"id\":1,\"result\":{}}\\n'\nprintf 'not-json\\n'\n").unwrap(); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).unwrap(); + let mut t = Transport::spawn(&fake, &home, &temp.0).unwrap(); + t.send(&json!({"id":1,"method":"initialize","params":{}})) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut received = false; + loop { + assert!(std::time::Instant::now() < deadline, "provider timeout"); + match t.poll() { + Ok(Some(value)) => { + assert_eq!(value["id"], 1); + received = true; + } + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(5)), + Err(error) => { + assert!(error.contains("Malformed")); + break; + } + } + } + assert!(received); + assert_eq!( + fs::read_to_string(home.join("marker")).unwrap(), + "private\n" + ); + assert!(t.send(&json!({"text":"x".repeat(1024 * 1024)})).is_err()); + drop(t); + fs::set_permissions(&home, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(Transport::spawn(&fake, &home, &temp.0).is_err()); +}