diff --git a/PENDING_LOG.md b/PENDING_LOG.md index 392b986..fd03541 100644 --- a/PENDING_LOG.md +++ b/PENDING_LOG.md @@ -1,5 +1,21 @@ # PENDING_LOG +## Triagem atual — evolução arquitetural incremental + +- Em 2026-03-20, a análise comparativa entre SynapseOS, Superset, Mastra e coding-agent foi consolidada como direção de produto e arquitetura para a próxima onda de evolução local. +- A conclusão prática é que o SynapseOS não deve tentar reproduzir o produto Superset nem migrar o runtime central para TypeScript neste momento; o ganho líquido imediato está em absorver boundaries, contratos internos e padrões de extensibilidade de forma incremental sobre o core Python atual. +- Em 2026-03-20, a onda local `F51` → `F53` foi executada sequencialmente: +- `F51-runtime-boundaries-foundation` abriu contratos explícitos de `ToolSpec`/capabilities, `WorkspaceProvider`, `RunContext` e lifecycle hooks no Synapse-Flow. +- `F52-workspace-isolation-foundation` tornou o workspace efetivo da run auditável com `workspace_path` persistido e provider `run-scoped` opcional. +- `F53-observability-runtime-events` enriqueceu a timeline local com `run_context_initialized`, `step_started` e `state_transitioned`, além de refletir `workspace_path` em `runs show` e `RUN_REPORT.md`. +- Com isso, a frente ativa imediata deixa de ser `F51` e passa a ser nenhuma: a próxima decisão de produto volta a ser escolher um novo bucket pequeno sobre baseline já ampliada. +- A fila seguinte recomendada, em ordem, fica restrita por enquanto a quatro buckets pequenos e verificáveis: +- `multi-agent-session-orchestration`: formalizar registry/capabilities e coordenação entre adapters sem abrir UI desktop. +- `local-control-plane-foundation`: expor API local mínima para TUI/integrações futuras, mantendo CLI-first e shell desktop como hipótese posterior. +- `baseline-handoff-sync`: alinhar `PENDING_LOG.md`, `ERROR_LOG.md`, README e artefatos de feature ao estado real pós-`F53`. +- O bucket `desktop-shell` fica explicitamente fora da fila principal neste momento; ele só volta à mesa depois que `runtime boundaries`, `workspace isolation`, `observability` e `control plane` estiverem estabilizados. +- O bucket `TypeScript-first runtime migration` fica explicitamente descartado por ora; qualquer uso futuro de TypeScript deve ficar limitado a shell/UI opcional consumindo um core Python autoritativo. + ## Decisões incorporadas recentemente - Em 2026-03-13, `origin/main` absorveu a merge de `F42-tui-filters` pela PR `#86`, adicionando filtros visuais locais no dashboard TUI para falhas (`f`), atividade (`r`) e restauracao da lista completa (`x`). @@ -174,8 +190,9 @@ - Fixtures de testes aspiracionais marcadas como 🔜 no TDD.md: `tests/fixtures/worker/` (ainda ausente). - Property-based testing com `hypothesis` ainda não implementado (mencionado como evolução futura em TDD.md). -- Fechar a `chore-post-f40-f42-baseline-sync` atualizando `PENDING_LOG.md`, `memory.md`, `ERROR_LOG.md`, `README.md` e `CHANGELOG.md` ao estado pos-`F42`/`F40`. -- Rodar nova `technical-triage` depois da `chore-post-f40-f42-baseline-sync` para escolher a proxima frente fora de `remote_multi_host_auth`. +- Fechar o bucket `baseline-handoff-sync` alinhando `ERROR_LOG.md`, `README.md` e eventuais docs publicas ao baseline local pos-`F53`. +- Rodar nova `technical-triage` depois do `baseline-handoff-sync` para escolher uma unica frente entre `multi-agent-session-orchestration` e `local-control-plane-foundation`. +- Manter `desktop-shell` e `TypeScript-first runtime migration` explicitamente fora da fila principal ate o core Python atual estabilizar boundaries, isolamento e observabilidade. - Manter `remote_multi_host_auth` explicitamente adiado ate existir demanda concreta, recorte proprio e validavel. ## Pontos de atenção futuros diff --git a/features/F51-runtime-boundaries-foundation/REPORT.md b/features/F51-runtime-boundaries-foundation/REPORT.md new file mode 100644 index 0000000..042fdea --- /dev/null +++ b/features/F51-runtime-boundaries-foundation/REPORT.md @@ -0,0 +1,50 @@ +# F51 Report + +## Resumo executivo + +- A `F51-runtime-boundaries-foundation` introduz boundaries internos explícitos para `ToolSpec`, `WorkspaceProvider`, `RunContext` e lifecycle hooks no Synapse-Flow. +- O recorte foi implementado de forma incremental sobre o core Python atual, sem migrar stack, sem abrir desktop shell e sem quebrar a pipeline linear do MVP. +- A feature prepara o terreno para isolamento operacional de workspace, observabilidade mais rica e futuras frentes de multi-agent orchestration. + +## Escopo alterado + +- novo módulo [runtime_contracts.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/runtime_contracts.py) com contratos de `ToolSpec`, `WorkspaceContext`, `RunContext`, `WorkspaceProvider`, `LocalWorkspaceProvider` e `RunLifecycleHooks` +- [contracts.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/contracts.py) passa a exportar `ToolSpec` +- [adapters.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/adapters.py) passa a expor `tool_spec`, `capabilities` e `command_prefix` por adapter +- [pipeline.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/pipeline.py) passa a carregar `run_context` explícito e a aceitar `workspace_provider` +- [persistence.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/persistence.py) passa a propagar `initiated_by` e `workspace_provider` ao runtime persistido +- [runtime/dispatch.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/runtime/dispatch.py) passa a validar e resolver SPEC por `WorkspaceProvider` + +## Validacoes executadas + +- `validate_spec_file(Path("features/F51-runtime-boundaries-foundation/SPEC.md"))` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m pytest tests/unit/test_contracts.py tests/unit/test_cli_adapter.py tests/unit/test_pipeline_engine.py tests/unit/test_runtime_dispatch.py -q` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m ruff check src/synapse_os/contracts.py src/synapse_os/runtime_contracts.py src/synapse_os/adapters.py src/synapse_os/pipeline.py src/synapse_os/persistence.py src/synapse_os/runtime/dispatch.py tests/unit/test_contracts.py tests/unit/test_cli_adapter.py tests/unit/test_pipeline_engine.py tests/unit/test_runtime_dispatch.py` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m ruff format --check src/synapse_os/contracts.py src/synapse_os/runtime_contracts.py src/synapse_os/adapters.py src/synapse_os/pipeline.py src/synapse_os/persistence.py src/synapse_os/runtime/dispatch.py tests/unit/test_contracts.py tests/unit/test_cli_adapter.py tests/unit/test_pipeline_engine.py tests/unit/test_runtime_dispatch.py` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m mypy src/synapse_os/contracts.py src/synapse_os/runtime_contracts.py src/synapse_os/adapters.py src/synapse_os/pipeline.py src/synapse_os/persistence.py src/synapse_os/runtime/dispatch.py` + +## Review de seguranca + +- Parecer final: aprovado. +- Riscos revisados: + - ampliar a superfície de execução do runtime ao introduzir abstrações novas + - enfraquecer o boundary de `workspace_root` no dispatch + - acoplar a feature a uma migração prematura de stack +- Mitigações aplicadas: + - nenhum subprocesso ou transporte novo foi introduzido + - o dispatch continua ancorado em `resolve_path_within_root`, agora encapsulado por provider explícito + - o recorte permaneceu local, CLI-first e Python-first + +## Riscos residuais + +- O fallback do `PipelineEngine` sem `workspace_provider` explícito ainda resolve o workspace de forma local simples via diretório da SPEC. +- A feature introduz boundaries, mas ainda não materializa isolamento operacional por run nem worktree real. + +## Proximos passos + +- Usar `WorkspaceProvider` como base para isolamento operacional de workspace por run. +- Reaproveitar `RunContext` e hooks de lifecycle para enriquecer timeline, status e observabilidade local. + +## Status final da frente + +- `READY_FOR_COMMIT` diff --git a/features/F51-runtime-boundaries-foundation/SPEC.md b/features/F51-runtime-boundaries-foundation/SPEC.md new file mode 100644 index 0000000..e708315 --- /dev/null +++ b/features/F51-runtime-boundaries-foundation/SPEC.md @@ -0,0 +1,46 @@ +--- +id: F51-runtime-boundaries-foundation +type: refactor +summary: "Introduzir boundaries internos para tools, workspace, run context e lifecycle do Synapse-Flow" +inputs: + - Arquitetura atual do SynapseOS + - Padrões arquiteturais extraídos de Superset, Mastra e coding-agent +outputs: + - Contratos internos explícitos para tools, workspace, run context e lifecycle + - Integração do runtime atual com os novos contratos sem regressão funcional + - Base estável para worktrees, multi-agent orchestration e control plane local em fases futuras +acceptance_criteria: + - O core deve expor um contrato explícito para tool/capability sem depender da implementação concreta de adapter + - O core deve expor um contrato explícito para workspace provider sem introduzir multi-workspace por run no MVP + - O core deve expor um run context/lifecycle hook utilizável por pipeline, runtime e persistência + - O comportamento atual do Synapse-Flow deve permanecer compatível com a pipeline linear state-driven existente + - Deve existir cobertura de teste para os novos contratos internos e para a compatibilidade do fluxo atual com esses contratos +non_goals: + - Implementar shell desktop + - Migrar runtime central para TypeScript, Node ou Bun + - Introduzir worktree por tarefa nesta fase + - Introduzir memória semântica ativa ou roteamento automático por memória + - Reestruturar o monorepo para o modelo do Superset +--- + +# Contexto +O SynapseOS já possui um núcleo coerente e auditável para o Synapse-Flow, a engine própria de pipeline do projeto, com pipeline linear state-driven, runtime dual simples, persistência local e adapters CLI. Esse núcleo está visível principalmente em `src/synapse_os/pipeline.py`, `src/synapse_os/state_machine.py`, `src/synapse_os/runtime/` e `src/synapse_os/adapters.py`. + +A análise comparativa com Superset, Mastra e coding-agent mostrou que o maior ganho imediato não está em copiar stack, Electron ou o produto completo deles, mas em absorver boundaries mais fortes para: + +1. tool/capability registry +2. workspace abstraction +3. run context e lifecycle hooks +4. observability e extensibilidade por contrato + +Hoje esses pontos existem de forma parcial ou implícita. Isso dificulta evoluções incrementais como worktree por run, multi-agent orchestration, control plane local e uma UX mais rica sem aumentar acoplamento. + +# Objetivo +Criar a primeira camada de fundação arquitetural inspirada em Superset e Mastra, mas implementada de forma nativa e incremental no SynapseOS. + +O recorte desta feature deve: + +1. introduzir contratos internos mínimos para `ToolSpec`/capabilities, `WorkspaceProvider`, `RunContext` e lifecycle hooks; +2. integrar esses contratos ao Synapse-Flow atual sem quebrar a pipeline linear do MVP; +3. preparar o terreno para features futuras de worktree isolation, observability mais rica, multi-agent orchestration e eventual control plane local; +4. preservar os princípios atuais de CLI-first, spec-first, local-first e auditabilidade. diff --git a/features/F52-workspace-isolation-foundation/REPORT.md b/features/F52-workspace-isolation-foundation/REPORT.md new file mode 100644 index 0000000..a608d0a --- /dev/null +++ b/features/F52-workspace-isolation-foundation/REPORT.md @@ -0,0 +1,49 @@ +# F52 Report + +## Resumo executivo + +- A `F52-workspace-isolation-foundation` introduz a fundação de isolamento operacional de workspace por run sobre os contratos abertos na `F51`. +- O recorte ficou restrito a `workspace_path` auditável e a um provider `run-scoped`, sem abrir `git worktree` obrigatório nem alterar a CLI pública. +- A feature preserva o modo legado de workspace único quando o isolamento adicional não é solicitado. + +## Escopo alterado + +- [runtime_contracts.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/runtime_contracts.py) ganha `RunScopedWorkspaceProvider` +- [persistence.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/persistence.py) passa a persistir `workspace_path` na tabela `runs` +- `RunRepository` passa a aceitar `workspace_path` explícito e a atualizar schema legado com coluna nova +- `PersistedPipelineRunner` passa a resolver workspace por `run_id` quando `run_workspace_root` é fornecido +- o evento `run_started` passa a carregar `workspace` no texto para melhorar rastreabilidade local + +## Validacoes executadas + +- `validate_spec_file(Path("features/F52-workspace-isolation-foundation/SPEC.md"))` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m pytest tests/unit/test_persistence.py tests/integration/test_pipeline_persistence.py tests/unit/test_runtime_dispatch.py -q` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m ruff check src/synapse_os/runtime_contracts.py src/synapse_os/persistence.py tests/unit/test_persistence.py tests/integration/test_pipeline_persistence.py tests/unit/test_runtime_dispatch.py` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m ruff format --check src/synapse_os/runtime_contracts.py src/synapse_os/persistence.py tests/unit/test_persistence.py tests/integration/test_pipeline_persistence.py tests/unit/test_runtime_dispatch.py` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m mypy src/synapse_os/runtime_contracts.py src/synapse_os/persistence.py` + +## Review de seguranca + +- Parecer final: aprovado. +- Riscos revisados: + - materializar diretórios fora da raiz confiável + - quebrar compatibilidade de runs existentes por mudança de schema + - introduzir isolamento mais ambicioso do que o MVP comporta +- Mitigações aplicadas: + - o provider novo só cria diretórios previsíveis sob `run_workspace_root / ` + - a coluna `workspace_path` entra com upgrade compatível para schema legado + - o modo atual continua funcionando com fallback seguro para o diretório da SPEC + +## Riscos residuais + +- O isolamento entregue ainda não cria `git worktree`, cópia de árvore nem múltiplos workspaces por run. +- A materialização do workspace é operacional e auditável, mas ainda não altera a política de artifacts ou diff por run. + +## Proximos passos + +- Reaproveitar `workspace_path` e `RunContext` para enriquecer a timeline de eventos e o diagnóstico da run. +- Decidir depois, em frente própria, se `git worktree` realmente compensa como próximo grau de isolamento. + +## Status final da frente + +- `READY_FOR_COMMIT` diff --git a/features/F52-workspace-isolation-foundation/SPEC.md b/features/F52-workspace-isolation-foundation/SPEC.md new file mode 100644 index 0000000..93edbdd --- /dev/null +++ b/features/F52-workspace-isolation-foundation/SPEC.md @@ -0,0 +1,45 @@ +--- +id: F52-workspace-isolation-foundation +type: feature +summary: "Introduzir isolamento operacional de workspace por run sobre o WorkspaceProvider do Synapse-Flow" +inputs: + - WorkspaceProvider introduzido pela F51 + - Boundary de workspace já endurecido em F24, F38 e F39 +outputs: + - Provider capaz de materializar um workspace operacional por run + - Metadados persistidos para rastrear o workspace resolvido da run + - Compatibilidade com o modo atual de workspace único quando o isolamento não for solicitado +acceptance_criteria: + - O runtime deve conseguir resolver um workspace operacional por run sem quebrar o MVP de um único workspace local por execução + - O caminho efetivo do workspace usado pela run deve ficar auditável para pipeline, persistência e observabilidade local + - O sistema deve continuar rejeitando escapes fora da raiz confiável de workspace + - O modo atual deve permanecer compatível quando nenhum isolamento adicional for solicitado + - Deve existir cobertura de teste para resolução do workspace da run e para persistência do contexto correspondente +non_goals: + - Criar múltiplos workspaces por uma mesma run + - Implementar git worktree obrigatório nesta fase + - Alterar a CLI pública para um modelo desktop-first + - Introduzir scheduling distribuído ou coordenação multi-host +--- + +# Contexto +Com a `F51-runtime-boundaries-foundation`, o SynapseOS passou a expor contratos explícitos para `WorkspaceProvider`, `RunContext` e lifecycle hooks. Isso resolve o primeiro problema estrutural: o core agora tem boundary para runtime, workspace e extensibilidade. + +O próximo gap prático é que o Synapse-Flow, a engine própria de pipeline do SynapseOS, ainda opera sobre um root local único, sem materializar um workspace operacional por run. Hoje já existe endurecimento de boundary em `workspace_root`, persistência e runtime state, especialmente nas frentes `F24`, `F38` e `F39`, mas isso ainda protege a raiz; não isola a execução. + +Esse isolamento operacional é o pré-requisito mais útil para evoluções futuras como: + +1. worktree por tarefa quando isso realmente compensar; +2. artifacts e diff por run com menos ambiguidade; +3. multi-agent orchestration com contexto de filesystem previsível; +4. control plane local com status de workspace explícito. + +# Objetivo +Criar a fundação de isolamento operacional de workspace por run sem abrir ainda a ambição completa de `git worktree per task`. + +O recorte desta feature deve: + +1. permitir que o `WorkspaceProvider` resolva um workspace efetivo por run, ainda dentro de uma raiz confiável local; +2. tornar esse workspace auditável em persistência e observabilidade local; +3. preservar compatibilidade com o modelo atual quando o isolamento adicional não estiver habilitado; +4. preparar o terreno para uma futura frente específica de `git worktree` sem assumir esse custo agora. diff --git a/features/F53-observability-runtime-events/REPORT.md b/features/F53-observability-runtime-events/REPORT.md new file mode 100644 index 0000000..3ea3f53 --- /dev/null +++ b/features/F53-observability-runtime-events/REPORT.md @@ -0,0 +1,50 @@ +# F53 Report + +## Resumo executivo + +- A `F53-observability-runtime-events` enriquece a timeline local do Synapse-Flow com eventos explícitos de contexto, início de step e transição de estado. +- O recorte reaproveita `run_events`, `runs show` e `RUN_REPORT.md`, sem criar tracing distribuído, backend remoto ou streaming novo. +- A feature fecha a primeira onda arquitetural aberta após a análise comparativa: boundaries internos, isolamento operacional de workspace e observabilidade local mais rica. + +## Escopo alterado + +- [pipeline.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/pipeline.py) passa a emitir hooks opcionais para `on_run_context_initialized`, `on_step_started` e `on_state_transition` +- [persistence.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/persistence.py) passa a persistir os novos eventos em `run_events` +- [reporting.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/reporting.py) passa a incluir `workspace_path` no resumo da run +- [rendering.py](/home/g0dsssp33d/work/projects/SynapseOS/src/synapse_os/cli/rendering.py) passa a exibir `workspace path` em `runs show` +- fixtures e testes de pipeline, worker, report e CLI foram atualizados para o novo baseline de timeline + +## Validacoes executadas + +- `validate_spec_file(Path("features/F53-observability-runtime-events/SPEC.md"))` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m pytest tests/integration/test_pipeline_persistence.py tests/unit/test_cli_runs_rendering.py tests/unit/test_report_generator.py tests/integration/test_runs_cli.py -q` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m pytest tests/unit/test_worker_runtime.py tests/integration/test_worker_runtime_flow.py tests/pipeline/test_failure_recovery.py tests/unit/test_pipeline_engine.py tests/integration/test_pipeline_persistence.py tests/unit/test_cli_runs_rendering.py tests/unit/test_report_generator.py tests/integration/test_runs_cli.py -q` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m ruff check src/synapse_os/pipeline.py src/synapse_os/persistence.py src/synapse_os/reporting.py src/synapse_os/cli/rendering.py tests/integration/test_pipeline_persistence.py tests/unit/test_cli_runs_rendering.py tests/unit/test_report_generator.py tests/integration/test_runs_cli.py tests/integration/test_worker_runtime_flow.py` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m ruff format --check src/synapse_os/pipeline.py src/synapse_os/persistence.py src/synapse_os/reporting.py src/synapse_os/cli/rendering.py tests/integration/test_pipeline_persistence.py tests/unit/test_cli_runs_rendering.py tests/unit/test_report_generator.py tests/integration/test_runs_cli.py tests/integration/test_worker_runtime_flow.py` +- `env PYTHONPATH=src ./.venv-codex-runtime/bin/python -m mypy src/synapse_os/pipeline.py src/synapse_os/persistence.py src/synapse_os/reporting.py src/synapse_os/cli/rendering.py` + +## Review de seguranca + +- Parecer final: aprovado. +- Riscos revisados: + - aumentar a superfície de observabilidade com infraestrutura desnecessária + - introduzir eventos ambíguos ou inconsistentes entre pipeline, worker e CLI + - degradar a legibilidade de `runs show` e `RUN_REPORT.md` +- Mitigações aplicadas: + - os novos sinais foram mantidos em `run_events`, sem nova tabela, sem rede e sem telemetria externa + - a emissão fica centrada no lifecycle real do pipeline e reaproveita o observer persistido + - os consumidores locais foram ajustados junto com o baseline de testes para manter legibilidade e consistência + +## Riscos residuais + +- A timeline ficou mais detalhada, então testes e documentação futuros não devem assumir mais a sequência mínima antiga de eventos. +- Ainda não existe agregação de métricas, alertas ativos ou streaming remoto; a observabilidade continua local e orientada a auditoria. + +## Proximos passos + +- Se a fila seguir a ordem definida no backlog atual, o próximo bucket natural é `multi-agent-session-orchestration`. +- Antes disso, o baseline documental e operacional deve refletir que `F51`, `F52` e `F53` já foram executadas localmente. + +## Status final da frente + +- `READY_FOR_COMMIT` diff --git a/features/F53-observability-runtime-events/SPEC.md b/features/F53-observability-runtime-events/SPEC.md new file mode 100644 index 0000000..67ad06e --- /dev/null +++ b/features/F53-observability-runtime-events/SPEC.md @@ -0,0 +1,44 @@ +--- +id: F53-observability-runtime-events +type: feature +summary: "Enriquecer a timeline local do Synapse-Flow com eventos operacionais explícitos de contexto, step e transição" +inputs: + - Runtime events já persistidos em run_events + - RunContext e workspace_path introduzidos em F51 e F52 +outputs: + - Timeline persistida com eventos operacionais mais explícitos + - CLI e RUN_REPORT capazes de refletir melhor o contexto da run + - Cobertura de teste para os novos eventos e seu consumo +acceptance_criteria: + - O sistema deve persistir um evento explícito de contexto operacional da run antes da execução útil dos steps + - O sistema deve persistir quando um step do runtime começa e quando a pipeline avança de estado + - Os novos eventos devem reaproveitar run_events, sem exigir nova infraestrutura de observabilidade + - runs show e RUN_REPORT devem continuar legíveis e incorporar os novos sinais sem regressão da saída atual + - Deve existir cobertura de teste unitária e de integração para a timeline enriquecida +non_goals: + - Introduzir tracing distribuído, OTEL ou backend remoto + - Criar streaming em tempo real fora da observabilidade local já existente + - Alterar a CLI pública para um modelo desktop-first + - Criar sistema genérico de métricas ou alertas ativos +--- + +# Contexto +O SynapseOS já persiste `run_started`, `step_completed`, `run_completed`, `run_failed`, `supervisor_decision` e alguns eventos específicos de segurança ou ownership. Isso é suficiente para auditoria mínima, mas ainda deixa lacunas na explicação operacional da timeline. + +Depois da `F51-runtime-boundaries-foundation` e da `F52-workspace-isolation-foundation`, o sistema já conhece melhor o contexto da run: `initiated_by`, `workspace_path`, `spec_path` e o `RunContext` efetivo. O próximo passo lógico é tornar essa informação visível na timeline local do Synapse-Flow, a engine própria de pipeline do SynapseOS, sem abrir nova stack de observabilidade. + +Hoje, quando um operador inspeciona uma run, ainda faltam sinais explícitos para responder rapidamente: + +1. qual contexto operacional foi inicializado para aquela run; +2. quando um step efetivamente começou; +3. quando a pipeline mudou de estado entre etapas. + +# Objetivo +Enriquecer a timeline persistida da run com eventos operacionais explícitos e consistentes, reaproveitando `run_events`. + +O recorte desta feature deve: + +1. registrar um evento de contexto operacional da run; +2. registrar o início de cada step útil; +3. registrar transições de estado da pipeline; +4. refletir esses sinais na CLI atual e no `RUN_REPORT.md` sem romper a legibilidade existente. diff --git a/src/synapse_os/adapters.py b/src/synapse_os/adapters.py index 782f03c..3185a48 100644 --- a/src/synapse_os/adapters.py +++ b/src/synapse_os/adapters.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from synapse_os.config import AppSettings -from synapse_os.contracts import CLIExecutionResult, CodexExecutionAssessment +from synapse_os.contracts import CLIExecutionResult, CodexExecutionAssessment, ToolSpec from synapse_os.runtime.circuit_breaker import AdapterCircuitBreakerStore from synapse_os.security import sanitize_clean_text @@ -76,6 +76,22 @@ def __init__( def build_command(self, prompt: str) -> list[str]: raise NotImplementedError + @property + def capabilities(self) -> tuple[str, ...]: + return ("cli_execution",) + + @property + def command_prefix(self) -> tuple[str, ...]: + return tuple() + + @property + def tool_spec(self) -> ToolSpec: + return ToolSpec( + name=self.tool_name, + capabilities=self.capabilities, + command_prefix=self.command_prefix, + ) + async def execute(self, prompt: str) -> CLIExecutionResult: command = self.build_command(prompt) self._validate_command(command) @@ -143,6 +159,14 @@ def _validate_command(self, command: list[str]) -> None: class CodexCLIAdapter(BaseCLIAdapter): + @property + def capabilities(self) -> tuple[str, ...]: + return ("cli_execution", "code_generation") + + @property + def command_prefix(self) -> tuple[str, ...]: + return ("./scripts/dev-codex.sh", "--", "exec") + def __init__( self, *, @@ -264,6 +288,14 @@ def _execution_guard(limit: int) -> asyncio.Semaphore: class GeminiCLIAdapter(BaseCLIAdapter): + @property + def capabilities(self) -> tuple[str, ...]: + return ("cli_execution", "planning") + + @property + def command_prefix(self) -> tuple[str, ...]: + return ("gemini", "--prompt") + def __init__( self, *, @@ -279,13 +311,14 @@ def __init__( def build_command(self, prompt: str) -> list[str]: if not prompt.strip(): raise ValueError("prompt must not be empty.") - + return [ sys.executable, "-c", "import os, sys; " "key = os.environ.get('SYNAPSE_OS_GEMINI_API_KEY'); " - "print(f'Gemini response to: {sys.argv[1]}') if key else sys.exit('Error: SYNAPSE_OS_GEMINI_API_KEY not set')", + "print(f'Gemini response to: {sys.argv[1]}') " + "if key else sys.exit('Error: SYNAPSE_OS_GEMINI_API_KEY not set')", prompt, ] diff --git a/src/synapse_os/cli/rendering.py b/src/synapse_os/cli/rendering.py index 0c0138d..9c6dc37 100644 --- a/src/synapse_os/cli/rendering.py +++ b/src/synapse_os/cli/rendering.py @@ -122,6 +122,7 @@ def render_run_detail( summary_table.add_row("Current State", run.current_state) summary_table.add_row("Stop At", run.stop_at) summary_table.add_row("Spec Path", run.spec_path) + summary_table.add_row("Workspace Path", run.workspace_path) summary_table.add_row("Spec Hash", run.spec_hash or "-") summary_table.add_row("Initiated By", run.initiated_by) summary_table.add_row("Locked", "yes" if run.locked else "no") diff --git a/src/synapse_os/contracts.py b/src/synapse_os/contracts.py index 657aad3..d7b6e36 100644 --- a/src/synapse_os/contracts.py +++ b/src/synapse_os/contracts.py @@ -10,6 +10,15 @@ StrictStr, ) +from synapse_os.runtime_contracts import ToolSpec + +__all__ = [ + "RunRequest", + "CLIExecutionResult", + "CodexExecutionAssessment", + "ToolSpec", +] + class RunRequest(BaseModel): prompt: Annotated[str, Field(min_length=1)] diff --git a/src/synapse_os/persistence.py b/src/synapse_os/persistence.py index 909f6d1..577f42d 100644 --- a/src/synapse_os/persistence.py +++ b/src/synapse_os/persistence.py @@ -34,6 +34,12 @@ StepExecutionResult, StepExecutor, ) +from synapse_os.runtime_contracts import ( + LocalWorkspaceProvider, + RunScopedWorkspaceProvider, + WorkspaceContext, + WorkspaceProvider, +) from synapse_os.security import compute_file_sha256, resolve_path_within_root, sanitize_clean_text from synapse_os.state_machine import PipelineState from synapse_os.supervisor import Supervisor, SupervisorDecision @@ -47,6 +53,7 @@ class RunRecord: run_id: str spec_path: str + workspace_path: str spec_hash: str | None initiated_by: str stop_at: str @@ -101,6 +108,7 @@ def __init__(self, database_path: Path) -> None: self.metadata, Column("run_id", String, primary_key=True), Column("spec_path", Text, nullable=False), + Column("workspace_path", Text, nullable=False), Column("spec_hash", String, nullable=True), Column("initiated_by", String, nullable=False, server_default="unknown"), Column("stop_at", String, nullable=False), @@ -143,19 +151,25 @@ def __init__(self, database_path: Path) -> None: def create_run( self, *, + run_id: str | None = None, spec_path: Path, + workspace_path: Path | None = None, initial_state: str, stop_at: str, spec_hash: str | None = None, initiated_by: str = "unknown", ) -> str: - run_id = uuid4().hex + resolved_run_id = uuid4().hex if run_id is None else run_id + resolved_workspace_path = ( + spec_path.resolve().parent if workspace_path is None else workspace_path + ) timestamp = _timestamp() with self.engine.begin() as connection: connection.execute( insert(self.runs).values( - run_id=run_id, + run_id=resolved_run_id, spec_path=str(spec_path), + workspace_path=str(resolved_workspace_path), spec_hash=spec_hash, initiated_by=initiated_by, stop_at=stop_at, @@ -168,7 +182,7 @@ def create_run( completed_at=None, ) ) - return run_id + return resolved_run_id def acquire_lock(self, run_id: str) -> bool: timestamp = _timestamp() @@ -412,6 +426,20 @@ def _upgrade_runs_schema(self) -> None: connection.exec_driver_sql( "UPDATE runs SET initiated_by = 'unknown' WHERE initiated_by IS NULL" ) + if "workspace_path" not in existing_columns: + connection.exec_driver_sql( + "ALTER TABLE runs ADD COLUMN workspace_path TEXT NOT NULL DEFAULT '.'" + ) + legacy_runs = connection.execute( + select(self.runs.c.run_id, self.runs.c.spec_path) + ).mappings() + for row in legacy_runs: + workspace_path = str(Path(cast(str, row["spec_path"])).resolve().parent) + connection.execute( + update(self.runs) + .where(self.runs.c.run_id == cast(str, row["run_id"])) + .values(workspace_path=workspace_path) + ) class ArtifactStore: @@ -530,7 +558,46 @@ def on_run_started(self, context: PipelineContext) -> None: run_id, state=context.current_state, event_type="run_started", - message=f"Run started at {context.current_state}.", + message=( + f"Run started at {context.current_state}. " + f"workspace={context.run_context.workspace.root_path}" + ), + ) + + def on_run_context_initialized(self, context: PipelineContext) -> None: + run_id = self._run_id(context) + self.repository.record_event( + run_id, + state=context.current_state, + event_type="run_context_initialized", + message=( + "Run context initialized for " + f"initiated_by={context.run_context.initiated_by} " + f"workspace={context.run_context.workspace.root_path}." + ), + ) + + def on_step_started(self, step: PipelineStep, context: PipelineContext) -> None: + run_id = self._run_id(context) + self.repository.record_event( + run_id, + state=step.state, + event_type="step_started", + message=f"Step {step.state} started.", + ) + + def on_state_transition( + self, + from_state: str, + to_state: str, + context: PipelineContext, + ) -> None: + run_id = self._run_id(context) + self.repository.record_event( + run_id, + state=from_state, + event_type="state_transitioned", + message=f"{from_state} -> {to_state}", ) def on_step_completed( @@ -689,11 +756,15 @@ def __init__( artifact_store: ArtifactStore, executors: dict[str, StepExecutor | dict[str, StepExecutor]] | None = None, supervisor: Supervisor | None = None, + workspace_provider: WorkspaceProvider | None = None, + run_workspace_root: Path | None = None, ) -> None: self.repository = repository self.artifact_store = artifact_store self.executors = dict(executors or {}) self.supervisor = supervisor + self.workspace_provider = workspace_provider + self.run_workspace_root = run_workspace_root def run( self, @@ -754,16 +825,19 @@ def check_cancellation(self, _: PipelineContext) -> bool: artifact_store=self.artifact_store, ), ) + workspace_provider = self._workspace_provider_for_run(run_record) engine = PipelineEngine( executors=executors, observer=PipelinePersistenceObserver(self.repository, self.artifact_store), supervisor=self.supervisor, cancellation_checker=cancellation_checker, + workspace_provider=workspace_provider, ) return engine.run( Path(run_record.spec_path), stop_at=run_record.stop_at, run_id=run_id, + initiated_by=run_record.initiated_by, ) def _create_pending_run_with_provenance( @@ -775,11 +849,18 @@ def _create_pending_run_with_provenance( spec_hash: str | None = None, ) -> str: resolved_spec_path = spec_path.resolve() + run_id = uuid4().hex + workspace = self._resolve_workspace( + resolved_spec_path, + run_id_hint=run_id, + ) persisted_spec_hash = ( spec_hash if spec_hash is not None else compute_file_sha256(resolved_spec_path) ) run_id = self.repository.create_run( + run_id=run_id, spec_path=resolved_spec_path, + workspace_path=workspace.root_path, initial_state=PipelineState.REQUEST, stop_at=stop_at, spec_hash=persisted_spec_hash, @@ -796,6 +877,37 @@ def _create_pending_run_with_provenance( ) return run_id + def _resolve_workspace( + self, + spec_path: Path, + *, + run_id_hint: str | None, + ) -> WorkspaceContext: + base_provider = self.workspace_provider or LocalWorkspaceProvider(spec_path.parent) + if self.run_workspace_root is None or run_id_hint is None: + return base_provider.resolve(spec_path) + + return RunScopedWorkspaceProvider( + base_provider, + run_workspace_root=self.run_workspace_root, + run_id=run_id_hint, + ).resolve(spec_path) + + def _workspace_provider_for_run(self, run_record: RunRecord) -> WorkspaceProvider: + if self.run_workspace_root is None: + return self.workspace_provider or LocalWorkspaceProvider( + Path(run_record.workspace_path) + ) + + base_provider = self.workspace_provider or LocalWorkspaceProvider( + Path(run_record.spec_path).parent + ) + return RunScopedWorkspaceProvider( + base_provider, + run_workspace_root=self.run_workspace_root, + run_id=run_record.run_id, + ) + def _validate_run_provenance(self, run_record: RunRecord) -> None: if run_record.spec_hash is None: return @@ -868,6 +980,7 @@ def _run_record_from_row(row: RowMapping) -> RunRecord: return RunRecord( run_id=_string_value(row, "run_id"), spec_path=_string_value(row, "spec_path"), + workspace_path=_string_value(row, "workspace_path"), spec_hash=_optional_string_value(row, "spec_hash"), initiated_by=_string_value(row, "initiated_by"), stop_at=_string_value(row, "stop_at"), diff --git a/src/synapse_os/pipeline.py b/src/synapse_os/pipeline.py index a0bbc7b..db72955 100644 --- a/src/synapse_os/pipeline.py +++ b/src/synapse_os/pipeline.py @@ -6,6 +6,12 @@ from pydantic import BaseModel, ConfigDict, Field, StrictStr from synapse_os.config import AppSettings +from synapse_os.runtime_contracts import ( + RunContext, + RunLifecycleHooks, + WorkspaceContext, + WorkspaceProvider, +) from synapse_os.specs import ( SpecDocument, validate_spec_file, @@ -79,6 +85,7 @@ class PipelineContext(BaseModel): spec_path: Path current_state: StrictStr + run_context: RunContext run_id: StrictStr | None = None step_history: list[StrictStr] = Field(default_factory=list) artifacts: dict[str, StrictStr] = Field(default_factory=dict) @@ -98,32 +105,7 @@ class CancellationChecker(Protocol): def check_cancellation(self, context: PipelineContext) -> bool: ... -class PipelineObserver(Protocol): - def on_run_started(self, context: PipelineContext) -> None: ... - - def on_step_completed( - self, - step: PipelineStep, - context: PipelineContext, - result: StepExecutionResult | None, - ) -> None: ... - - def on_run_completed(self, context: PipelineContext) -> None: ... - - def on_run_failed( - self, - context: PipelineContext, - step: PipelineStep | None, - error: Exception, - ) -> None: ... - - def on_supervisor_decision( - self, - step: PipelineStep, - context: PipelineContext, - decision: SupervisorDecision, - error: Exception, - ) -> None: ... +PipelineObserver = RunLifecycleHooks PIPELINE_STEPS: dict[str, PipelineStep] = { @@ -174,12 +156,14 @@ def __init__( observer: PipelineObserver | None = None, supervisor: Supervisor | None = None, cancellation_checker: CancellationChecker | None = None, + workspace_provider: WorkspaceProvider | None = None, ) -> None: self.settings = settings or AppSettings() self.executors = self._normalize_executors(executors or {}) self.state_machine = state_machine or SynapseStateMachine() self.observer = observer self.cancellation_checker = cancellation_checker + self.workspace_provider = workspace_provider if supervisor is None: # Create default supervisor using settings @@ -193,17 +177,36 @@ def run( *, stop_at: str = "TEST_RED", run_id: str | None = None, + initiated_by: str = "system", ) -> PipelineContext: if stop_at not in PIPELINE_STOP_STATES: raise ValueError(f"Unsupported stop_at state: {stop_at}.") self._validate_entry_state() + workspace = self._resolve_workspace(spec_path) context = PipelineContext( - spec_path=spec_path, + spec_path=workspace.spec_path, current_state=self.state_machine.current_state, + run_context=RunContext( + run_id=run_id, + initiated_by=initiated_by, + workspace=workspace, + ), run_id=run_id, ) current_step: PipelineStep | None = None + pending_entry_transition = ( + self.state_machine.current_state + if self.state_machine.current_state + in { + PipelineState.REQUEST, + PipelineState.SPEC_DISCOVERY, + PipelineState.SPEC_NORMALIZATION, + } + else None + ) + + self._notify_optional("on_run_context_initialized", context) if self.observer is not None: self.observer.on_run_started(context) @@ -222,8 +225,20 @@ def run( PipelineState.SPEC_DISCOVERY, PipelineState.SPEC_NORMALIZATION, }: - self.state_machine.advance_to(self._next_state(current_state)) + next_state = self._next_state(current_state) + self.state_machine.advance_to(next_state) context.current_state = self.state_machine.current_state + if ( + context.current_state == PipelineState.SPEC_VALIDATION + and pending_entry_transition is not None + ): + self._notify_optional( + "on_state_transition", + pending_entry_transition, + PipelineState.SPEC_VALIDATION, + context, + ) + pending_entry_transition = None continue if current_state == PipelineState.COMPLETE: @@ -234,6 +249,7 @@ def run( if current_state == PipelineState.SPEC_VALIDATION: current_step = PIPELINE_STEPS[current_state] + self._notify_optional("on_step_started", current_step, context) self._execute_spec_validation(context) if self.observer is not None: self.observer.on_step_completed(current_step, context, None) @@ -241,6 +257,12 @@ def run( if self.observer is not None: self.observer.on_run_completed(context) return context + self._notify_optional( + "on_state_transition", + PipelineState.SPEC_VALIDATION, + PipelineState.PLAN, + context, + ) self.state_machine.advance_to(PipelineState.PLAN) context.current_state = self.state_machine.current_state continue @@ -255,6 +277,7 @@ def run( PipelineState.DOCUMENT, }: current_step = PIPELINE_STEPS[current_state] + self._notify_optional("on_step_started", current_step, context) result = self._run_runtime_step(current_step, context) if result is None: continue @@ -267,7 +290,14 @@ def run( if self.observer is not None: self.observer.on_run_completed(context) return context - self.state_machine.advance_to(self._next_state(current_state)) + next_state = self._next_state(current_state) + self._notify_optional( + "on_state_transition", + current_state, + next_state, + context, + ) + self.state_machine.advance_to(next_state) context.current_state = self.state_machine.current_state continue @@ -356,6 +386,24 @@ def _validate_entry_state(self) -> None: f"Current state '{self.state_machine.current_state}' is not supported by F10." ) + def _resolve_workspace(self, spec_path: Path) -> WorkspaceContext: + if self.workspace_provider is not None: + return self.workspace_provider.resolve(spec_path) + + resolved_spec_path = spec_path.resolve() + return WorkspaceContext( + root_path=resolved_spec_path.parent, + spec_path=resolved_spec_path, + ) + + def _notify_optional(self, method_name: str, *args: object) -> None: + if self.observer is None: + return + callback = getattr(self.observer, method_name, None) + if callback is None: + return + callback(*args) + def _next_state(self, current_state: str) -> str: try: current_index = LINEAR_STATE_FLOW.index(current_state) diff --git a/src/synapse_os/reporting.py b/src/synapse_os/reporting.py index 4b9acea..d2ea167 100644 --- a/src/synapse_os/reporting.py +++ b/src/synapse_os/reporting.py @@ -7,6 +7,7 @@ class _RunRecordProtocol(Protocol): initiated_by: str + workspace_path: str spec_hash: str | None status: str current_state: str @@ -67,6 +68,7 @@ def build(self, run_id: str) -> str: f"- **Status**: {run_record.status}", f"- **Estado final**: {run_record.current_state}", f"- **Initiated By**: {run_record.initiated_by}", + f"- **Workspace Path**: {run_record.workspace_path}", f"- **Spec Hash**: {run_record.spec_hash or '-'}", f"- **SPEC ID**: {spec_id}", f"- **SPEC Summary**: {spec_summary}", diff --git a/src/synapse_os/runtime/dispatch.py b/src/synapse_os/runtime/dispatch.py index 963199a..e745a36 100644 --- a/src/synapse_os/runtime/dispatch.py +++ b/src/synapse_os/runtime/dispatch.py @@ -7,7 +7,8 @@ from synapse_os.persistence import PersistedPipelineRunner, RunRepository from synapse_os.runtime.state import RuntimeState -from synapse_os.security import compute_file_sha256, resolve_path_within_root +from synapse_os.runtime_contracts import LocalWorkspaceProvider, WorkspaceProvider +from synapse_os.security import compute_file_sha256 from synapse_os.specs import validate_spec_file DispatchMode = Literal["sync", "async", "auto"] @@ -41,11 +42,13 @@ def __init__( initiated_by: str = "local_cli", runtime_state_provider: Callable[[], RuntimeState] | None = None, enforce_async_runtime_ownership: bool = False, + workspace_provider: WorkspaceProvider | None = None, ) -> None: self.repository = repository self.runner = runner self.is_runtime_ready = is_runtime_ready self.workspace_root = workspace_root + self.workspace_provider = workspace_provider or LocalWorkspaceProvider(workspace_root) self.initiated_by = initiated_by self.runtime_state_provider = runtime_state_provider self.enforce_async_runtime_ownership = enforce_async_runtime_ownership @@ -89,14 +92,7 @@ def dispatch( def _validate_dispatch_inputs(self, spec_path: Path, *, mode: DispatchMode) -> Path: self._resolve_mode(mode) - try: - resolved_spec_path = resolve_path_within_root(spec_path, root=self.workspace_root) - except ValueError as exc: - raise FileNotFoundError(f"SPEC file not found: {spec_path}") from exc - if not resolved_spec_path.exists(): - raise FileNotFoundError(f"SPEC file not found: {spec_path}") - if not resolved_spec_path.is_file(): - raise FileNotFoundError(f"SPEC file not found: {spec_path}") + resolved_spec_path = self.workspace_provider.resolve(spec_path).spec_path validate_spec_file(resolved_spec_path) return resolved_spec_path diff --git a/src/synapse_os/runtime_contracts.py b/src/synapse_os/runtime_contracts.py new file mode 100644 index 0000000..9bed5be --- /dev/null +++ b/src/synapse_os/runtime_contracts.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +from pydantic import BaseModel, ConfigDict, Field, StrictStr + +from synapse_os.security import resolve_path_within_root + +if TYPE_CHECKING: + from synapse_os.pipeline import PipelineContext, PipelineStep, StepExecutionResult + from synapse_os.supervisor import SupervisorDecision + + +class ToolSpec(BaseModel): + model_config = ConfigDict(strict=True) + + name: StrictStr + capabilities: tuple[StrictStr, ...] = Field(min_length=1) + command_prefix: tuple[StrictStr, ...] = Field(default_factory=tuple) + + +class WorkspaceContext(BaseModel): + model_config = ConfigDict(strict=True) + + root_path: Path + spec_path: Path + + +class RunContext(BaseModel): + model_config = ConfigDict(strict=True) + + run_id: StrictStr | None = None + initiated_by: StrictStr + workspace: WorkspaceContext + + +class WorkspaceProvider(Protocol): + def resolve(self, spec_path: Path) -> WorkspaceContext: ... + + +class RunLifecycleHooks(Protocol): + def on_run_started(self, context: PipelineContext) -> None: ... + + def on_step_completed( + self, + step: PipelineStep, + context: PipelineContext, + result: StepExecutionResult | None, + ) -> None: ... + + def on_run_completed(self, context: PipelineContext) -> None: ... + + def on_run_failed( + self, + context: PipelineContext, + step: PipelineStep | None, + error: Exception, + ) -> None: ... + + def on_supervisor_decision( + self, + step: PipelineStep, + context: PipelineContext, + decision: SupervisorDecision, + error: Exception, + ) -> None: ... + + +class LocalWorkspaceProvider: + def __init__(self, workspace_root: Path) -> None: + self.workspace_root = workspace_root.resolve() + + def resolve(self, spec_path: Path) -> WorkspaceContext: + try: + resolved_spec_path = resolve_path_within_root(spec_path, root=self.workspace_root) + except ValueError as exc: + raise FileNotFoundError(f"SPEC file not found: {spec_path}") from exc + if not resolved_spec_path.exists(): + raise FileNotFoundError(f"SPEC file not found: {spec_path}") + if not resolved_spec_path.is_file(): + raise FileNotFoundError(f"SPEC file not found: {spec_path}") + return WorkspaceContext( + root_path=self.workspace_root, + spec_path=resolved_spec_path, + ) + + +class RunScopedWorkspaceProvider: + def __init__( + self, + base_provider: WorkspaceProvider, + *, + run_workspace_root: Path, + run_id: str, + ) -> None: + self.base_provider = base_provider + self.run_workspace_root = run_workspace_root.resolve() + self.run_id = run_id + + def resolve(self, spec_path: Path) -> WorkspaceContext: + base_workspace = self.base_provider.resolve(spec_path) + workspace_path = self.run_workspace_root / self.run_id + workspace_path.mkdir(parents=True, exist_ok=True) + return WorkspaceContext( + root_path=workspace_path, + spec_path=base_workspace.spec_path, + ) diff --git a/src/synapse_os/state_machine.py b/src/synapse_os/state_machine.py index 66f1bed..d748467 100644 --- a/src/synapse_os/state_machine.py +++ b/src/synapse_os/state_machine.py @@ -40,11 +40,13 @@ class PipelineState(StrEnum): PipelineState.COMPLETE, ) -TERMINAL_STATES: frozenset[PipelineState] = frozenset({ - PipelineState.COMPLETE, - PipelineState.FAILED, - PipelineState.CANCELLED, -}) +TERMINAL_STATES: frozenset[PipelineState] = frozenset( + { + PipelineState.COMPLETE, + PipelineState.FAILED, + PipelineState.CANCELLED, + } +) @dataclass @@ -62,17 +64,15 @@ def advance_to(self, next_state: PipelineState | str) -> None: # Ensure current_state is treated as PipelineState for dict lookup current = PipelineState(self.current_state) allowed_states = self._allowed_transitions.get(current, set()) - + if target not in allowed_states: - raise InvalidStateTransition( - f"Cannot transition from {current} to {target}." - ) + raise InvalidStateTransition(f"Cannot transition from {current} to {target}.") self.current_state = target def fail(self) -> None: self.advance_to(PipelineState.FAILED) - + def cancel(self) -> None: self.advance_to(PipelineState.CANCELLED) diff --git a/tests/fixtures/reports/expected_run_report.md b/tests/fixtures/reports/expected_run_report.md index bb8f2f1..7c99f5e 100644 --- a/tests/fixtures/reports/expected_run_report.md +++ b/tests/fixtures/reports/expected_run_report.md @@ -5,6 +5,7 @@ - **Status**: completed - **Estado final**: DOCUMENT - **Initiated By**: local_cli +- **Workspace Path**: {workspace_path} - **Spec Hash**: abc123 - **SPEC ID**: F06-pipeline-engine-linear - **SPEC Summary**: Implementar a primeira engine linear do Synapse-Flow @@ -20,8 +21,13 @@ ## Eventos relevantes - `security_provenance_recorded` @ `REQUEST`: Provenance recorded for initiated_by=local_cli spec_hash=abc123. -- `run_started` @ `REQUEST`: Run started at REQUEST. +- `run_context_initialized` @ `REQUEST`: Run context initialized for initiated_by=local_cli workspace={workspace_path}. +- `run_started` @ `REQUEST`: Run started at REQUEST. workspace={workspace_path} +- `state_transitioned` @ `REQUEST`: REQUEST -> SPEC_VALIDATION +- `step_started` @ `SPEC_VALIDATION`: Step SPEC_VALIDATION started. - `step_completed` @ `SPEC_VALIDATION`: Step SPEC_VALIDATION completed. +- `state_transitioned` @ `SPEC_VALIDATION`: SPEC_VALIDATION -> PLAN +- `step_started` @ `PLAN`: Step PLAN started. - `step_completed` @ `PLAN`: Step PLAN completed. - `run_completed` @ `DOCUMENT`: Run completed at DOCUMENT. diff --git a/tests/integration/test_gemini_adapter.py b/tests/integration/test_gemini_adapter.py index 470832c..bed7bb3 100644 --- a/tests/integration/test_gemini_adapter.py +++ b/tests/integration/test_gemini_adapter.py @@ -1,39 +1,40 @@ from __future__ import annotations import asyncio -import os from importlib import import_module from pathlib import Path import pytest + def test_gemini_adapter_executes_with_api_key( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: adapters = import_module("synapse_os.adapters") - + monkeypatch.setenv("SYNAPSE_OS_GEMINI_API_KEY", "fake-key") - + adapter = adapters.GeminiCLIAdapter() result = asyncio.run(adapter.execute("Hello World")) - + assert result.success is True assert result.tool_name == "gemini" assert "Gemini response to: Hello World" in result.stdout_clean assert result.return_code == 0 + def test_gemini_adapter_fails_without_api_key( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: adapters = import_module("synapse_os.adapters") - + monkeypatch.delenv("SYNAPSE_OS_GEMINI_API_KEY", raising=False) - + adapter = adapters.GeminiCLIAdapter() result = asyncio.run(adapter.execute("Hello World")) - + assert result.success is False assert result.tool_name == "gemini" assert "Error: SYNAPSE_OS_GEMINI_API_KEY not set" in result.stderr_clean diff --git a/tests/integration/test_pipeline_persistence.py b/tests/integration/test_pipeline_persistence.py index 5abcc46..74ad508 100644 --- a/tests/integration/test_pipeline_persistence.py +++ b/tests/integration/test_pipeline_persistence.py @@ -56,15 +56,19 @@ def test_persisted_pipeline_records_steps_events_and_artifacts_until_plan( tmp_path: Path, ) -> None: persistence = import_module("synapse_os.persistence") + runtime_contracts = import_module("synapse_os.runtime_contracts") spec_path = tmp_path / "SPEC.md" _write_valid_spec(spec_path) repository = persistence.RunRepository(tmp_path / "runs.sqlite3") artifact_store = persistence.ArtifactStore(tmp_path / "artifacts") + run_workspaces_root = tmp_path / "run-workspaces" runner = persistence.PersistedPipelineRunner( repository=repository, artifact_store=artifact_store, executors={"PLAN": _PlanExecutor()}, + workspace_provider=runtime_contracts.LocalWorkspaceProvider(tmp_path), + run_workspace_root=run_workspaces_root, ) context = runner.run(spec_path, stop_at="PLAN") @@ -76,11 +80,19 @@ def test_persisted_pipeline_records_steps_events_and_artifacts_until_plan( assert run_record.status == "completed" assert run_record.current_state == "PLAN" + assert run_record.workspace_path == str(run_workspaces_root / context.run_id) + assert context.run_context.workspace.root_path == run_workspaces_root / context.run_id + assert (run_workspaces_root / context.run_id).is_dir() assert [step.state for step in steps] == ["SPEC_VALIDATION", "PLAN"] assert [event.event_type for event in events] == [ "security_provenance_recorded", + "run_context_initialized", "run_started", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "step_completed", "run_completed", ] @@ -120,7 +132,10 @@ def test_persisted_pipeline_marks_failed_run_when_spec_validation_blocks_plan( assert run_record.failure_message is not None assert [event.event_type for event in events] == [ "security_provenance_recorded", + "run_context_initialized", "run_started", + "state_transitioned", + "step_started", "run_failed", ] assert not (artifact_store.base_path / run_record.run_id / "PLAN").exists() @@ -177,20 +192,35 @@ def execute(self, step, context): # type: ignore[no-untyped-def] assert [event.event_type for event in events] == [ "security_provenance_recorded", + "run_context_initialized", "run_started", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "supervisor_decision", "step_completed", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "step_completed", "run_completed", ] - assert events[4].state == "TEST_RED" - assert events[5].state == "CODE_GREEN" - assert "retry" in events[5].message + assert events[13].state == "CODE_GREEN" + assert events[14].state == "CODE_GREEN" + assert "retry" in events[14].message def test_persisted_pipeline_generates_run_report_until_document(tmp_path: Path) -> None: @@ -296,8 +326,13 @@ def execute(self, step, context): # type: ignore[no-untyped-def] assert all(plan_prefix not in artifact_path for artifact_path in artifact_paths) assert [event.event_type for event in events] == [ "security_provenance_recorded", + "run_context_initialized", "run_started", + "state_transitioned", + "step_started", "step_completed", + "state_transitioned", + "step_started", "security_guardrail_triggered", "run_failed", ] diff --git a/tests/integration/test_runs_cli.py b/tests/integration/test_runs_cli.py index 6aa30b4..aeeca94 100644 --- a/tests/integration/test_runs_cli.py +++ b/tests/integration/test_runs_cli.py @@ -109,6 +109,12 @@ def test_runs_show_reports_run_metadata_steps_events_and_artifacts( duration_ms=45, timed_out=False, ) + repository.record_event( + run_id, + state="PLAN", + event_type="step_started", + message="Step PLAN started.", + ) repository.record_event( run_id, state="PLAN", @@ -127,6 +133,8 @@ def test_runs_show_reports_run_metadata_steps_events_and_artifacts( assert "latest timestamp" in result.stdout.lower() assert "spec path" in result.stdout.lower() assert str(spec_path) in result.stdout + assert "workspace path" in result.stdout.lower() + assert str(tmp_path) in result.stdout assert "completed" in result.stdout.lower() assert "document" in result.stdout.lower() assert "next action" in result.stdout.lower() diff --git a/tests/integration/test_spec_validation_gate.py b/tests/integration/test_spec_validation_gate.py index 3a0f319..1be3839 100644 --- a/tests/integration/test_spec_validation_gate.py +++ b/tests/integration/test_spec_validation_gate.py @@ -1,4 +1,3 @@ - import pytest from synapse_os.persistence import ArtifactStore, PersistedPipelineRunner, RunRepository diff --git a/tests/integration/test_worker_runtime_flow.py b/tests/integration/test_worker_runtime_flow.py index a9cb13a..cee871a 100644 --- a/tests/integration/test_worker_runtime_flow.py +++ b/tests/integration/test_worker_runtime_flow.py @@ -176,7 +176,10 @@ def test_runtime_foreground_worker_consumes_pending_run(tmp_path: Path) -> None: assert run_record.current_state == "SPEC_VALIDATION" assert [step.state for step in steps] == ["SPEC_VALIDATION"] assert [event.event_type for event in events] == [ + "run_context_initialized", "run_started", + "state_transitioned", + "step_started", "step_completed", "run_completed", ] diff --git a/tests/unit/test_cli_adapter.py b/tests/unit/test_cli_adapter.py index d8a2cde..d27bae4 100644 --- a/tests/unit/test_cli_adapter.py +++ b/tests/unit/test_cli_adapter.py @@ -373,6 +373,16 @@ def test_codex_cli_adapter_builds_container_first_exec_command() -> None: ] +def test_codex_cli_adapter_exposes_tool_spec_with_capabilities() -> None: + adapters = _adapters_module() + + tool_spec = adapters.CodexCLIAdapter().tool_spec + + assert tool_spec.name == "codex" + assert tool_spec.capabilities == ("cli_execution", "code_generation") + assert tool_spec.command_prefix == ("./scripts/dev-codex.sh", "--", "exec") + + def test_base_cli_adapter_raises_operational_error_when_launcher_is_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_cli_runs_rendering.py b/tests/unit/test_cli_runs_rendering.py index 818eabc..89718ef 100644 --- a/tests/unit/test_cli_runs_rendering.py +++ b/tests/unit/test_cli_runs_rendering.py @@ -17,6 +17,7 @@ def test_render_run_detail_is_legible_without_tty() -> None: RunRecord( run_id="run-123", spec_path="SPEC.md", + workspace_path="/workspace/runs/run-123", spec_hash="abc123", initiated_by="local_cli", stop_at="DOCUMENT", @@ -47,9 +48,12 @@ def test_render_run_detail_is_legible_without_tty() -> None: RunEventRecord( event_id=1, run_id="run-123", - state="PLAN", - event_type="step_completed", - message="Step PLAN completed.", + state="REQUEST", + event_type="run_context_initialized", + message=( + "Run context initialized for initiated_by=local_cli " + "workspace=/workspace/runs/run-123." + ), created_at="2026-03-12T00:00:11+00:00", ) ], @@ -62,18 +66,20 @@ def test_render_run_detail_is_legible_without_tty() -> None: assert "Diagnostic Summary" in rendered assert "run-123" in rendered assert "Latest Signal" in rendered - assert "step_completed @ PLAN" in rendered + assert "run_context_initialized @ REQUEST" in rendered assert "Latest Timestamp" in rendered assert "Next Action" in rendered assert "Spec Hash" in rendered assert "abc123" in rendered + assert "Workspace Path" in rendered + assert "/workspace/runs/run-123" in rendered assert "Initiated By" in rendered assert "local_cli" in rendered assert "Inspect generated artifacts or report" in rendered assert "PLAN" in rendered assert "run-123/PLAN/raw.txt" in rendered assert "run-123/PLAN/clean.txt" in rendered - assert "step_completed" in rendered + assert "run_context_initialized" in rendered assert "2026-03-12T00:00:11+00:00" in rendered assert "RUN_REPORT.md" in rendered @@ -87,6 +93,7 @@ def test_render_run_detail_completed_at_spec_validation_guides_canonical_happy_p RunRecord( run_id="run-spec-validation", spec_path="SPEC.md", + workspace_path="/workspace/runs/run-spec-validation", spec_hash="abc123", initiated_by="local_cli", stop_at="SPEC_VALIDATION", @@ -119,6 +126,7 @@ def test_render_run_detail_surfaces_artifact_preview_panel() -> None: RunRecord( run_id="run-preview", spec_path="SPEC.md", + workspace_path="/workspace/runs/run-preview", spec_hash="abc123", initiated_by="local_cli", stop_at="DOCUMENT", diff --git a/tests/unit/test_contracts.py b/tests/unit/test_contracts.py index 319cd2c..1ba20c5 100644 --- a/tests/unit/test_contracts.py +++ b/tests/unit/test_contracts.py @@ -160,3 +160,30 @@ def test_cli_execution_result_allows_zero_duration() -> None: ) assert result.duration_ms == 0 + + +def test_tool_spec_requires_non_empty_capabilities() -> None: + contracts_module = import_module("synapse_os.contracts") + + with pytest.raises(ValidationError): + contracts_module.ToolSpec( + name="codex", + capabilities=(), + command_prefix=("./scripts/dev-codex.sh",), + ) + + +def test_tool_spec_serializes_capabilities_and_command_prefix() -> None: + contracts_module = import_module("synapse_os.contracts") + + tool_spec = contracts_module.ToolSpec( + name="codex", + capabilities=("cli_execution", "code_generation"), + command_prefix=("./scripts/dev-codex.sh", "--", "exec"), + ) + + assert tool_spec.model_dump() == { + "name": "codex", + "capabilities": ("cli_execution", "code_generation"), + "command_prefix": ("./scripts/dev-codex.sh", "--", "exec"), + } diff --git a/tests/unit/test_persistence.py b/tests/unit/test_persistence.py index 4d9f46d..d8c4dce 100644 --- a/tests/unit/test_persistence.py +++ b/tests/unit/test_persistence.py @@ -13,6 +13,7 @@ def test_run_repository_persists_run_lifecycle(tmp_path: Path) -> None: repository = persistence.RunRepository(tmp_path / "runs.sqlite3") run_id = repository.create_run( spec_path=tmp_path / "SPEC.md", + workspace_path=tmp_path / "workspaces" / "run-123", initial_state="REQUEST", stop_at="PLAN", spec_hash="abc123", @@ -32,6 +33,7 @@ def test_run_repository_persists_run_lifecycle(tmp_path: Path) -> None: assert run_record.locked is False assert run_record.spec_hash == "abc123" assert run_record.initiated_by == "local_cli" + assert run_record.workspace_path == str(tmp_path / "workspaces" / "run-123") assert run_record.completed_at is not None @@ -41,6 +43,7 @@ def test_run_repository_prevents_double_lock_for_same_run(tmp_path: Path) -> Non repository = persistence.RunRepository(tmp_path / "runs.sqlite3") run_id = repository.create_run( spec_path=tmp_path / "SPEC.md", + workspace_path=tmp_path / "workspaces" / "run-123", initial_state="REQUEST", stop_at="PLAN", spec_hash="hash-1", @@ -97,6 +100,7 @@ def test_run_repository_records_step_execution_metadata(tmp_path: Path) -> None: repository = persistence.RunRepository(tmp_path / "runs.sqlite3") run_id = repository.create_run( spec_path=tmp_path / "SPEC.md", + workspace_path=tmp_path / "workspaces" / "run-123", initial_state="REQUEST", stop_at="DOCUMENT", spec_hash="hash-2", @@ -186,8 +190,10 @@ def test_run_repository_upgrades_legacy_schema_with_provenance_columns(tmp_path: assert "spec_hash" in schema_columns assert "initiated_by" in schema_columns + assert "workspace_path" in schema_columns assert run_record.spec_hash is None assert run_record.initiated_by == "unknown" + assert run_record.workspace_path == str(tmp_path) def test_artifact_store_blocks_unsafe_python_named_artifact(tmp_path: Path) -> None: diff --git a/tests/unit/test_pipeline_engine.py b/tests/unit/test_pipeline_engine.py index 81acf8c..e0c822a 100644 --- a/tests/unit/test_pipeline_engine.py +++ b/tests/unit/test_pipeline_engine.py @@ -148,6 +148,28 @@ def test_pipeline_engine_can_stop_after_spec_validation(tmp_path: Path) -> None: assert plan_executor.calls == [] +def test_pipeline_engine_exposes_run_context_to_executors(tmp_path: Path) -> None: + pipeline = _pipeline_module() + spec_path = tmp_path / "SPEC.md" + _write_valid_spec(spec_path) + plan_executor = _RecordingExecutor(artifact_key="plan_md", artifact_value="plan") + + engine = pipeline.PipelineEngine( + executors={ + "PLAN": plan_executor, + "TEST_RED": _RecordingExecutor(artifact_key="tests_md", artifact_value="red"), + } + ) + + context = engine.run(spec_path, stop_at="PLAN", run_id="run-123", initiated_by="operator-a") + + assert context.run_context.run_id == "run-123" + assert context.run_context.initiated_by == "operator-a" + assert context.run_context.workspace.root_path == tmp_path + assert context.run_context.workspace.spec_path == spec_path.resolve() + assert plan_executor.calls == [("PLAN", "F06-fixture")] + + def test_pipeline_engine_blocks_plan_when_spec_is_invalid(tmp_path: Path) -> None: pipeline = _pipeline_module() spec_path = tmp_path / "SPEC.md" diff --git a/tests/unit/test_report_generator.py b/tests/unit/test_report_generator.py index 524b287..2028536 100644 --- a/tests/unit/test_report_generator.py +++ b/tests/unit/test_report_generator.py @@ -57,11 +57,29 @@ def test_run_report_generator_matches_expected_fixture(tmp_path: Path) -> None: event_type="security_provenance_recorded", message="Provenance recorded for initiated_by=local_cli spec_hash=abc123.", ) + repository.record_event( + run_id, + state="REQUEST", + event_type="run_context_initialized", + message=(f"Run context initialized for initiated_by=local_cli workspace={tmp_path}."), + ) repository.record_event( run_id, state="REQUEST", event_type="run_started", - message="Run started at REQUEST.", + message=f"Run started at REQUEST. workspace={tmp_path}", + ) + repository.record_event( + run_id, + state="REQUEST", + event_type="state_transitioned", + message="REQUEST -> SPEC_VALIDATION", + ) + repository.record_event( + run_id, + state="SPEC_VALIDATION", + event_type="step_started", + message="Step SPEC_VALIDATION started.", ) repository.record_event( run_id, @@ -69,6 +87,18 @@ def test_run_report_generator_matches_expected_fixture(tmp_path: Path) -> None: event_type="step_completed", message="Step SPEC_VALIDATION completed.", ) + repository.record_event( + run_id, + state="SPEC_VALIDATION", + event_type="state_transitioned", + message="SPEC_VALIDATION -> PLAN", + ) + repository.record_event( + run_id, + state="PLAN", + event_type="step_started", + message="Step PLAN started.", + ) repository.record_event( run_id, state="PLAN", @@ -90,4 +120,4 @@ def test_run_report_generator_matches_expected_fixture(tmp_path: Path) -> None: Path(__file__).resolve().parents[1] / "fixtures" / "reports" / "expected_run_report.md" ).read_text(encoding="utf-8") - assert report_content == expected_report.format(run_id=run_id) + assert report_content == expected_report.format(run_id=run_id, workspace_path=tmp_path) diff --git a/tests/unit/test_runtime_dispatch.py b/tests/unit/test_runtime_dispatch.py index b29bcf9..1b2009a 100644 --- a/tests/unit/test_runtime_dispatch.py +++ b/tests/unit/test_runtime_dispatch.py @@ -64,6 +64,62 @@ def test_run_dispatch_service_executes_inline_when_mode_is_sync(tmp_path: Path) assert run_record.spec_hash == hashlib.sha256(spec_path.read_bytes()).hexdigest() +def test_run_dispatch_service_uses_workspace_provider_to_resolve_spec_path(tmp_path: Path) -> None: + persistence = import_module("synapse_os.persistence") + dispatch_module = import_module("synapse_os.runtime.dispatch") + runtime_contracts = import_module("synapse_os.runtime_contracts") + + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + spec_path = workspace_root / "SPEC.md" + _write_valid_spec(spec_path) + repository = persistence.RunRepository(tmp_path / "runs.sqlite3") + artifact_store = persistence.ArtifactStore(tmp_path / "artifacts") + runner = persistence.PersistedPipelineRunner( + repository=repository, + artifact_store=artifact_store, + ) + service = dispatch_module.RunDispatchService( + repository=repository, + runner=runner, + is_runtime_ready=lambda: False, + workspace_root=workspace_root, + workspace_provider=runtime_contracts.LocalWorkspaceProvider(workspace_root), + ) + + result = service.dispatch(spec_path, stop_at="SPEC_VALIDATION", mode="sync") + + run_record = repository.get_run(result.run_id) + assert run_record.spec_path == str(spec_path.resolve()) + + +def test_run_dispatch_service_preserves_default_workspace_when_run_isolation_is_not_enabled( + tmp_path: Path, +) -> None: + persistence = import_module("synapse_os.persistence") + dispatch_module = import_module("synapse_os.runtime.dispatch") + + spec_path = tmp_path / "SPEC.md" + _write_valid_spec(spec_path) + repository = persistence.RunRepository(tmp_path / "runs.sqlite3") + artifact_store = persistence.ArtifactStore(tmp_path / "artifacts") + runner = persistence.PersistedPipelineRunner( + repository=repository, + artifact_store=artifact_store, + ) + service = dispatch_module.RunDispatchService( + repository=repository, + runner=runner, + is_runtime_ready=lambda: False, + workspace_root=tmp_path, + ) + + result = service.dispatch(spec_path, stop_at="SPEC_VALIDATION", mode="sync") + + run_record = repository.get_run(result.run_id) + assert run_record.workspace_path == str(tmp_path) + + def test_run_dispatch_service_auto_queues_when_runtime_is_ready(tmp_path: Path) -> None: persistence = import_module("synapse_os.persistence") dispatch_module = import_module("synapse_os.runtime.dispatch")