@daniellins/power-claude 0.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @daniellins/power-claude might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/cli.js +124 -0
- package/installer/__tests__/doctor-ready-oracles.test.cjs +140 -0
- package/installer/__tests__/merge-settings.test.cjs +330 -0
- package/installer/adopt.js +161 -0
- package/installer/copy.js +203 -0
- package/installer/doctor.js +266 -0
- package/installer/install.js +155 -0
- package/installer/manifest.js +102 -0
- package/installer/motor-scripts.js +50 -0
- package/installer/motor-scripts.json +39 -0
- package/installer/uninstall.js +99 -0
- package/installer/update.js +376 -0
- package/installer/util.js +113 -0
- package/installer/wizard.js +64 -0
- package/package.json +26 -0
- package/scripts/__fixtures__/anti-path-demo/agents/ap-writer.md +13 -0
- package/scripts/__fixtures__/anti-path-demo/templates/write-tmpl.md +7 -0
- package/scripts/__fixtures__/anti-path-demo/workflows/wf-ap.smoke.json +10 -0
- package/scripts/__fixtures__/anti-path-demo/workflows/wf-ap.yaml +25 -0
- package/scripts/__fixtures__/assess-tier/retrofit-leve/pod/agents/content-chief.md +1 -0
- package/scripts/__fixtures__/assess-tier/retrofit-leve/pod/workflows/wf-criacao-redteam.yaml +3 -0
- package/scripts/__fixtures__/assess-tier/retrofit-leve/pod-spec.json +13 -0
- package/scripts/__fixtures__/fidelity/fake-green-average-in-band.yaml +21 -0
- package/scripts/__fixtures__/fidelity/fake-green-average.yaml +18 -0
- package/scripts/__fixtures__/fidelity/fake-green-denominator.yaml +19 -0
- package/scripts/__fixtures__/fidelity/fake-green-missing-evidence.yaml +18 -0
- package/scripts/__fixtures__/fidelity/fake-green-threshold.yaml +18 -0
- package/scripts/__fixtures__/fidelity/fake-green-weight.yaml +18 -0
- package/scripts/__fixtures__/fidelity/golden.yaml +31 -0
- package/scripts/__fixtures__/fidelity/trindade-incoherent.yaml +15 -0
- package/scripts/__fixtures__/forge-canonical/pod-spec-example.json +37 -0
- package/scripts/__fixtures__/migrate-squad/authored/agents/offer-engineer.md +17 -0
- package/scripts/__fixtures__/migrate-squad/authored/knowledge/no-source.md +7 -0
- package/scripts/__fixtures__/migrate-squad/authored/knowledge/value-stack-method.md +8 -0
- package/scripts/__fixtures__/migrate-squad/authored/specs/wf-sample.smoke.json +24 -0
- package/scripts/__fixtures__/migrate-squad/authored/specs/wf-sample.spec.json +45 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/HEADLINE.md +3 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/README.md +4 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/agents/hormozi-offer.md +14 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/checklists/dead-redundant.md +4 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/checklists/method-guide.md +6 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/checklists/veto-screen.md +4 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/config/config.yaml +2 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/data/case-library.md +3 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/scripts/validate-mvn.py +16 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/sops/sop-sample.md +3 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/squad-io.yaml +14 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/tasks/gather-context.md +7 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/templates/output-tmpl.md +5 -0
- package/scripts/__fixtures__/migrate-squad/legacy-squad/workflows/wf-sample.yaml +24 -0
- package/scripts/__fixtures__/pipeline/pipelines/pipeline-checkpoint.yaml +21 -0
- package/scripts/__fixtures__/pipeline/pipelines/pipeline-demo.yaml +22 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-a/agents/maker.md +13 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-a/memory/learnings.consolidated.md +8 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-a/memory/learnings.jsonl +83 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-a/templates/make-tmpl.md +8 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-a/workflows/wf-a.yaml +16 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-b/agents/user.md +13 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-b/memory/learnings.consolidated.md +8 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-b/memory/learnings.jsonl +14 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-b/templates/use-tmpl.md +6 -0
- package/scripts/__fixtures__/pipeline/pods/pipe-b/workflows/wf-b.yaml +16 -0
- package/scripts/__fixtures__/pod-run/agents/forge-chief.md +13 -0
- package/scripts/__fixtures__/pod-run/agents/pod-critic.md +13 -0
- package/scripts/__fixtures__/pod-run/templates/build-tmpl.md +8 -0
- package/scripts/__fixtures__/pod-run/templates/gate-tmpl.md +6 -0
- package/scripts/__fixtures__/pod-run/workflows/wf-min.yaml +43 -0
- package/scripts/__fixtures__/pod-run/workflows/wf-skip-modes.yaml +50 -0
- package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-all-skippable.yaml +30 -0
- package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-criteria-escalar-de-bloco.yaml +40 -0
- package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-skip-mode-desconhecido.yaml +30 -0
- package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-unknown-key.yaml +29 -0
- package/scripts/__fixtures__/pod-workflow/invalid-cycle.yaml +26 -0
- package/scripts/__fixtures__/pod-workflow/invalid-fix-loop-backedge.yaml +29 -0
- package/scripts/__fixtures__/pod-workflow/invalid-fix-loop-forward.yaml +29 -0
- package/scripts/__fixtures__/pod-workflow/invalid-handoff-open.yaml +27 -0
- package/scripts/__fixtures__/pod-workflow/invalid-knowledge-shape.yaml +18 -0
- package/scripts/__fixtures__/pod-workflow/invalid-missing-agent.yaml +15 -0
- package/scripts/__fixtures__/pod-workflow/invalid-missing-outputs.yaml +15 -0
- package/scripts/__fixtures__/pod-workflow/invalid-missing-template.yaml +15 -0
- package/scripts/__fixtures__/pod-workflow/invalid-missing-veto.yaml +13 -0
- package/scripts/__fixtures__/pod-workflow/invalid-template-not-on-disk.yaml +16 -0
- package/scripts/__fixtures__/pod-workflow/invalid-veto-prose.yaml +17 -0
- package/scripts/__fixtures__/pod-workflow/knowledge/a.md +4 -0
- package/scripts/__fixtures__/pod-workflow/knowledge/b.md +4 -0
- package/scripts/__fixtures__/pod-workflow/knowledge/c.md +4 -0
- package/scripts/__fixtures__/pod-workflow/templates/step-tmpl.md +7 -0
- package/scripts/__fixtures__/pod-workflow/valid-checkpoint-skip-modes.yaml +29 -0
- package/scripts/__fixtures__/pod-workflow/valid-fix-loop.yaml +30 -0
- package/scripts/__fixtures__/pod-workflow/valid-knowledge.yaml +30 -0
- package/scripts/__fixtures__/pod-workflow/valid-veto-always-true.yaml +20 -0
- package/scripts/__fixtures__/pod-workflow/valid.yaml +27 -0
- package/scripts/__fixtures__/smoke-canary/agents/canary-reporter.md +13 -0
- package/scripts/__fixtures__/smoke-canary/agents/canary-researcher.md +13 -0
- package/scripts/__fixtures__/smoke-canary/agents/canary-synth.md +13 -0
- package/scripts/__fixtures__/smoke-canary/memory/learnings.consolidated.md +10 -0
- package/scripts/__fixtures__/smoke-canary/memory/learnings.jsonl +448 -0
- package/scripts/__fixtures__/smoke-canary/templates/report-tmpl.md +9 -0
- package/scripts/__fixtures__/smoke-canary/templates/research-tmpl.md +7 -0
- package/scripts/__fixtures__/smoke-canary/templates/synth-tmpl.md +9 -0
- package/scripts/__fixtures__/smoke-canary/workflows/wf-canary.smoke.json +11 -0
- package/scripts/__fixtures__/smoke-canary/workflows/wf-canary.yaml +53 -0
- package/scripts/__fixtures__/source-audit/fake-green-bronze-unsignaled.yaml +10 -0
- package/scripts/__fixtures__/source-audit/fake-green-counts.yaml +9 -0
- package/scripts/__fixtures__/source-audit/fake-green-missing-evidence.yaml +9 -0
- package/scripts/__fixtures__/source-audit/fake-green-unclassified.yaml +10 -0
- package/scripts/__fixtures__/source-audit/fake-green-verdict.yaml +10 -0
- package/scripts/__fixtures__/source-audit/golden-blocked.yaml +10 -0
- package/scripts/__fixtures__/source-audit/golden.yaml +11 -0
- package/scripts/__fixtures__/sweep-forms/agents/forms-count.md +13 -0
- package/scripts/__fixtures__/sweep-forms/agents/forms-field.md +13 -0
- package/scripts/__fixtures__/sweep-forms/agents/forms-fileref.md +13 -0
- package/scripts/__fixtures__/sweep-forms/agents/forms-json.md +13 -0
- package/scripts/__fixtures__/sweep-forms/agents/forms-section.md +13 -0
- package/scripts/__fixtures__/sweep-forms/specs/wf-forms.spec.json +5 -0
- package/scripts/__fixtures__/sweep-forms/templates/t_count.md +5 -0
- package/scripts/__fixtures__/sweep-forms/templates/t_field.md +3 -0
- package/scripts/__fixtures__/sweep-forms/templates/t_fileref.md +3 -0
- package/scripts/__fixtures__/sweep-forms/templates/t_json.md +0 -0
- package/scripts/__fixtures__/sweep-forms/templates/t_section.md +3 -0
- package/scripts/__fixtures__/sweep-forms/workflows/wf-forms.smoke.json +13 -0
- package/scripts/__fixtures__/sweep-forms/workflows/wf-forms.yaml +67 -0
- package/scripts/__fixtures__/validate-story-acs-corpus/corpus-1-noop-state-json.fixture.md +18 -0
- package/scripts/__fixtures__/validate-story-acs-corpus/corpus-2-crash-buildworkflowyaml.fixture.md +26 -0
- package/scripts/__fixtures__/validate-story-acs-corpus/corpus-3-nasce-verde-skip-modes.fixture.md +29 -0
- package/scripts/__fixtures__/validate-story-acs-corpus/corpus-4-guarda-sem-irmao.fixture.md +17 -0
- package/scripts/__fixtures__/validate-story-acs-corpus/corpus-5a-diff-stat-sem-guarda.fixture.md +17 -0
- package/scripts/__fixtures__/validate-story-acs-corpus/corpus-5b-test-f-sobre-diretorio.fixture.md +20 -0
- package/scripts/__fixtures__/veto-checks/copy-clean.md +4 -0
- package/scripts/__fixtures__/veto-checks/copy-dashes.md +4 -0
- package/scripts/__fixtures__/veto-checks/n8n-bad.md +18 -0
- package/scripts/__fixtures__/veto-checks/n8n-ok.md +22 -0
- package/scripts/__fixtures__/veto-checks/page-bad.html +7 -0
- package/scripts/__fixtures__/veto-checks/phash/copy.png +0 -0
- package/scripts/__fixtures__/veto-checks/phash/distinct.png +0 -0
- package/scripts/__fixtures__/veto-checks/phash/distinct2.png +0 -0
- package/scripts/__fixtures__/veto-checks/phash/make-fixtures.cjs +203 -0
- package/scripts/__fixtures__/veto-checks/phash/ref.png +0 -0
- package/scripts/__fixtures__/veto-checks/slide-ok.html +15 -0
- package/scripts/__fixtures__/veto-checks/slide-overflow.html +14 -0
- package/scripts/__fixtures__/veto-checks/sweep-pod/agents/vc-checker.md +13 -0
- package/scripts/__fixtures__/veto-checks/sweep-pod/templates/check-tmpl.md +6 -0
- package/scripts/__fixtures__/veto-checks/sweep-pod/workflows/wf-vc.smoke.json +10 -0
- package/scripts/__fixtures__/veto-checks/sweep-pod/workflows/wf-vc.yaml +19 -0
- package/scripts/__fixtures__/voz-do-dono/README.md +23 -0
- package/scripts/__fixtures__/voz-do-dono/dono-escalar.yaml +9 -0
- package/scripts/__fixtures__/voz-do-dono/dono-guia.yaml +10 -0
- package/scripts/__fixtures__/voz-do-dono/peca-3a-pessoa.md +6 -0
- package/scripts/__fixtures__/voz-do-dono/peca-com-lexico-proibido.md +6 -0
- package/scripts/__fixtures__/voz-do-dono/peca-limpa-1a-pessoa.md +6 -0
- package/scripts/__tests__/derive-ready-transitions.test.cjs +269 -0
- package/scripts/__tests__/enforce-cycle-authority.test.cjs +869 -0
- package/scripts/__tests__/enforce-git-push-authority.test.cjs +701 -0
- package/scripts/__tests__/enforce-ready-oracles.test.cjs +745 -0
- package/scripts/__tests__/motor-scripts.test.cjs +339 -0
- package/scripts/__tests__/pc-run-pod-counter.test.cjs +58 -0
- package/scripts/__tests__/pc-run-pod.test.cjs +578 -0
- package/scripts/__tests__/validate-pod-workflow.test.cjs +90 -0
- package/scripts/__tests__/validate-story-acs.test.cjs +607 -0
- package/scripts/anti-path.test.cjs +163 -0
- package/scripts/arena.cjs +514 -0
- package/scripts/arena.test.cjs +213 -0
- package/scripts/brief-leitura-obrigatoria.test.cjs +364 -0
- package/scripts/consolidate-knowledge-runner.test.cjs +249 -0
- package/scripts/default-funcional.test.cjs +153 -0
- package/scripts/derive-ready-transitions.cjs +325 -0
- package/scripts/forge-additive.test.cjs +158 -0
- package/scripts/forge-assess-tier.cjs +205 -0
- package/scripts/forge-assess-tier.test.cjs +114 -0
- package/scripts/forge-canonical.test.cjs +624 -0
- package/scripts/forge-generate.cjs +1303 -0
- package/scripts/forge-harden-learning.cjs +921 -0
- package/scripts/forge-harden-learning.test.cjs +242 -0
- package/scripts/forge-ingest-memory.cjs +136 -0
- package/scripts/forge-upgrade-pod.cjs +368 -0
- package/scripts/forge.test.cjs +609 -0
- package/scripts/harden-knowledge.test.cjs +612 -0
- package/scripts/harness-g5-lite.cjs +243 -0
- package/scripts/harness-g5-lite.test.cjs +134 -0
- package/scripts/hn-carousel-contrato-entrada.test.cjs +96 -0
- package/scripts/migrate-squad-orchestration.test.cjs +146 -0
- package/scripts/migration-manifest-sf3.2.test.cjs +382 -0
- package/scripts/migration-manifest-sf3.3.test.cjs +170 -0
- package/scripts/migration-manifest-sf5.5.test.cjs +189 -0
- package/scripts/migration-manifest.cjs +1146 -0
- package/scripts/migration-manifest.test.cjs +186 -0
- package/scripts/npm-scope-invocation.test.cjs +194 -0
- package/scripts/owner-layer.cjs +442 -0
- package/scripts/owner-layer.test.cjs +358 -0
- package/scripts/pc-run-pipeline.cjs +289 -0
- package/scripts/pc-run-pipeline.test.cjs +256 -0
- package/scripts/pc-run-pod-countgte.test.cjs +66 -0
- package/scripts/pc-run-pod.cjs +1355 -0
- package/scripts/phash-gate.test.cjs +433 -0
- package/scripts/pod-export-compile.cjs +487 -0
- package/scripts/pod-export-compile.test.cjs +312 -0
- package/scripts/pod-export-executor.cjs +678 -0
- package/scripts/pod-export-executor.test.cjs +1341 -0
- package/scripts/pod-export-hardening.test.cjs +531 -0
- package/scripts/pod-export-postprocess.cjs +427 -0
- package/scripts/pod-export-postprocess.test.cjs +558 -0
- package/scripts/pod-export-queue-race.test.cjs +865 -0
- package/scripts/pod-export-release.cjs +473 -0
- package/scripts/pod-export-release.test.cjs +267 -0
- package/scripts/pod-export-service.cjs +1297 -0
- package/scripts/pod-export-service.test.cjs +489 -0
- package/scripts/pod-export-tools.cjs +470 -0
- package/scripts/pod-export-ui.cjs +805 -0
- package/scripts/pod-export-ui.test.cjs +301 -0
- package/scripts/pod-memory-budget.test.cjs +309 -0
- package/scripts/pod-memory-note.cjs +357 -0
- package/scripts/pod-memory-note.test.cjs +452 -0
- package/scripts/pod-memory.cjs +1252 -0
- package/scripts/pod-memory.test.cjs +951 -0
- package/scripts/render-png.test.cjs +307 -0
- package/scripts/smoke-isolation.test.cjs +100 -0
- package/scripts/smoke-pod-workflow.cjs +755 -0
- package/scripts/smoke-sweep.test.cjs +170 -0
- package/scripts/validate-adopt.js +235 -0
- package/scripts/validate-branding.js +84 -0
- package/scripts/validate-budgets.js +140 -0
- package/scripts/validate-fidelity-output.cjs +267 -0
- package/scripts/validate-fidelity-output.test.cjs +101 -0
- package/scripts/validate-install.js +230 -0
- package/scripts/validate-no-clone.cjs +220 -0
- package/scripts/validate-no-clone.test.cjs +77 -0
- package/scripts/validate-pipeline.cjs +145 -0
- package/scripts/validate-pod-workflow.cjs +878 -0
- package/scripts/validate-source-audit.cjs +170 -0
- package/scripts/validate-source-audit.test.cjs +90 -0
- package/scripts/validate-story-acs.cjs +862 -0
- package/scripts/validate-update-scope-motor.js +421 -0
- package/scripts/veto-checks/html-valid.cjs +92 -0
- package/scripts/veto-checks/lexical-check.cjs +62 -0
- package/scripts/veto-checks/phash-gate.cjs +589 -0
- package/scripts/veto-checks/render-gate.cjs +165 -0
- package/scripts/veto-checks/render-png.cjs +358 -0
- package/scripts/veto-checks.test.cjs +219 -0
- package/scripts/watcher-audit.cjs +178 -0
- package/template/.claude/CLAUDE.md +103 -0
- package/template/.claude/agents/pc-analyst.md +100 -0
- package/template/.claude/agents/pc-architect.md +32 -0
- package/template/.claude/agents/pc-dev.md +27 -0
- package/template/.claude/agents/pc-devops.md +45 -0
- package/template/.claude/agents/pc-planner.md +32 -0
- package/template/.claude/agents/pc-po.md +25 -0
- package/template/.claude/agents/pc-qa.md +25 -0
- package/template/.claude/commands/power/academy.md +9 -0
- package/template/.claude/commands/power/automation.md +9 -0
- package/template/.claude/commands/power/content-carousel.md +267 -0
- package/template/.claude/commands/power/content.md +9 -0
- package/template/.claude/commands/power/dev.md +9 -0
- package/template/.claude/commands/power/forge.md +224 -0
- package/template/.claude/commands/power/hormozi.md +221 -0
- package/template/.claude/commands/power/modo.md +57 -0
- package/template/.claude/hooks/README.md +114 -0
- package/template/.claude/hooks/context-budget.cjs +64 -0
- package/template/.claude/hooks/enforce-cycle-authority.cjs +457 -0
- package/template/.claude/hooks/enforce-git-push-authority.cjs +925 -0
- package/template/.claude/hooks/enforce-ready-oracles.cjs +502 -0
- package/template/.claude/rules/authority.md +76 -0
- package/template/.claude/rules/curadoria-de-conhecimento.md +30 -0
- package/template/.claude/rules/model-matrix.md +34 -0
- package/template/.claude/rules/orchestration.md +133 -0
- package/template/.claude/settings.json +50 -0
- package/template/.claude/skills/pc-apply-qa-fixes/SKILL.md +108 -0
- package/template/.claude/skills/pc-apply-qa-fixes/references/fix-loop.md +40 -0
- package/template/.claude/skills/pc-apply-qa-fixes/references/fix-request-template.md +70 -0
- package/template/.claude/skills/pc-arena/SKILL.md +96 -0
- package/template/.claude/skills/pc-close-story/SKILL.md +105 -0
- package/template/.claude/skills/pc-commit/SKILL.md +108 -0
- package/template/.claude/skills/pc-develop-story/SKILL.md +116 -0
- package/template/.claude/skills/pc-full-cycle/SKILL.md +150 -0
- package/template/.claude/skills/pc-pipeline/SKILL.md +63 -0
- package/template/.claude/skills/pc-pipeline/references/gate-templates.md +46 -0
- package/template/.claude/skills/pc-review-story/SKILL.md +129 -0
- package/template/.claude/skills/pc-review-story/references/false-positive-detection.md +50 -0
- package/template/.claude/skills/pc-review-story/references/nfr-assess.md +47 -0
- package/template/.claude/skills/pc-review-story/references/risk-profile.md +75 -0
- package/template/.claude/skills/pc-review-story/references/security-checklist.md +69 -0
- package/template/.claude/skills/pc-review-story/references/test-design.md +37 -0
- package/template/.claude/skills/pc-review-story/references/trace-requirements.md +46 -0
- package/template/.claude/skills/pc-run-pod/SKILL.md +123 -0
- package/template/.claude/skills/pc-validate-story/SKILL.md +114 -0
- package/template/.claude/skills/pc-wave/SKILL.md +133 -0
- package/template/.env.example +10 -0
- package/template/knowledge/analyst/brainstorming.md +40 -0
- package/template/knowledge/analyst/brief-skeleton.md +32 -0
- package/template/knowledge/analyst/competitor-skeleton.md +39 -0
- package/template/knowledge/analyst/elicitation-menu.md +49 -0
- package/template/knowledge/analyst/market-research-skeleton.md +36 -0
- package/template/knowledge/analyst/research-prompt-structure.md +48 -0
- package/template/knowledge/analyst/roi-skeleton.md +51 -0
- package/template/knowledge/voice-dna/README.md +99 -0
- package/template/knowledge/voice-dna/voice-dna.example.yaml +157 -0
- package/template/pods/README.md +64 -0
- package/template/pods/academy/README.md +37 -0
- package/template/pods/academy/agents/academy-chief.md +27 -0
- package/template/pods/academy/examples/palestra.example.md +142 -0
- package/template/pods/academy/pod-spec.json +44 -0
- package/template/pods/academy/templates/materiais-tmpl.md +17 -0
- package/template/pods/academy/templates/revisao-didatica-tmpl.md +20 -0
- package/template/pods/academy/workflows/wf-materiais-revisao.yaml +30 -0
- package/template/pods/authority-content/README.md +43 -0
- package/template/pods/authority-content/agents/content-chief.md +28 -0
- package/template/pods/authority-content/pod-spec.json +45 -0
- package/template/pods/authority-content/templates/criacao-na-voz-tmpl.md +17 -0
- package/template/pods/authority-content/templates/red-team-factual-tmpl.md +23 -0
- package/template/pods/authority-content/workflows/wf-criacao-redteam.yaml +30 -0
- package/template/pods/automation/README.md +37 -0
- package/template/pods/automation/agents/langgraph-architect.md +30 -0
- package/template/pods/automation/agents/n8n-architect.md +30 -0
- package/template/pods/automation/examples/README.md +46 -0
- package/template/pods/automation/examples/n8n-content-radar.example.json +380 -0
- package/template/pods/automation/knowledge/langgraph-patterns.md +126 -0
- package/template/pods/automation/knowledge/n8n-patterns.md +131 -0
- package/template/pods/automation/memory/README.md +16 -0
- package/template/pods/automation/memory/learnings.consolidated.md +8 -0
- package/template/pods/automation/memory/learnings.jsonl +0 -0
- package/template/pods/automation/pod-spec.json +109 -0
- package/template/pods/automation/specs/wf-langgraph-build.spec.json +107 -0
- package/template/pods/automation/squad-io.yaml +28 -0
- package/template/pods/automation/templates/lg-build-tmpl.md +17 -0
- package/template/pods/automation/templates/lg-design-tmpl.md +23 -0
- package/template/pods/automation/templates/lg-discover-tmpl.md +20 -0
- package/template/pods/automation/templates/lg-handoff-tmpl.md +20 -0
- package/template/pods/automation/templates/lg-test-tmpl.md +20 -0
- package/template/pods/automation/templates/n8n-build-tmpl.md +14 -0
- package/template/pods/automation/templates/n8n-design-tmpl.md +23 -0
- package/template/pods/automation/templates/n8n-discover-tmpl.md +20 -0
- package/template/pods/automation/templates/n8n-handoff-tmpl.md +20 -0
- package/template/pods/automation/templates/n8n-test-tmpl.md +20 -0
- package/template/pods/automation/workflows/wf-langgraph-build.smoke.json +11 -0
- package/template/pods/automation/workflows/wf-langgraph-build.yaml +64 -0
- package/template/pods/automation/workflows/wf-n8n-build.smoke.json +11 -0
- package/template/pods/automation/workflows/wf-n8n-build.yaml +65 -0
- package/template/pods/content-carousel/README.md +91 -0
- package/template/pods/content-carousel/agents/carousel-builder.md +28 -0
- package/template/pods/content-carousel/agents/carousel-visual.md +24 -0
- package/template/pods/content-carousel/agents/hn-curator.md +74 -0
- package/template/pods/content-carousel/config/heuristic-watcher-rubric.yaml +59 -0
- package/template/pods/content-carousel/knowledge/aquisicao-ancora.md +443 -0
- package/template/pods/content-carousel/knowledge/cta-brief.md +25 -0
- package/template/pods/content-carousel/knowledge/dono.example.yaml +45 -0
- package/template/pods/content-carousel/knowledge/identidade-visual-mascote.md +62 -0
- package/template/pods/content-carousel/knowledge/image-generation.md +62 -0
- package/template/pods/content-carousel/knowledge/selecao-de-ancora.md +60 -0
- package/template/pods/content-carousel/knowledge/slides-html.md +56 -0
- package/template/pods/content-carousel/memory/README.md +18 -0
- package/template/pods/content-carousel/memory/learnings.consolidated.md +6 -0
- package/template/pods/content-carousel/memory/learnings.jsonl +0 -0
- package/template/pods/content-carousel/pod-spec.json +100 -0
- package/template/pods/content-carousel/squad-io.yaml +10 -0
- package/template/pods/content-carousel/templates/assembly-tmpl.md +23 -0
- package/template/pods/content-carousel/templates/digest-tmpl.md +29 -0
- package/template/pods/content-carousel/templates/visuals-tmpl.md +20 -0
- package/template/pods/content-carousel/workflows/wf-content-carousel.smoke.json +23 -0
- package/template/pods/content-carousel/workflows/wf-content-carousel.yaml +80 -0
- package/template/pods/dev-ia/README.md +45 -0
- package/template/pods/dev-ia/agents/db-architect.md +33 -0
- package/template/pods/dev-ia/agents/ux-architect.md +29 -0
- package/template/pods/dev-ia/knowledge/db/migration-protocol.md +95 -0
- package/template/pods/dev-ia/knowledge/db/rls-patterns.md +101 -0
- package/template/pods/dev-ia/knowledge/db/schema-review-checklist.md +76 -0
- package/template/pods/dev-ia/memory/README.md +16 -0
- package/template/pods/dev-ia/memory/learnings.consolidated.md +8 -0
- package/template/pods/dev-ia/memory/learnings.jsonl +0 -0
- package/template/pods/dev-ia/pod-spec.json +34 -0
- package/template/pods/dev-ia/templates/schema-review-tmpl.md +23 -0
- package/template/pods/dev-ia/templates/ux-flow-review-tmpl.md +23 -0
- package/template/pods/dev-ia/tools/db-ops/backup-manager.sh +321 -0
- package/template/pods/dev-ia/tools/db-ops/best-practices-auditor.sh +534 -0
- package/template/pods/dev-ia/tools/db-ops/bootstrap-runner.sh +255 -0
- package/template/pods/dev-ia/tools/db-ops/data-loader.sh +616 -0
- package/template/pods/dev-ia/tools/db-ops/detect-database.sh +113 -0
- package/template/pods/dev-ia/tools/db-ops/explain-analyzer.sh +285 -0
- package/template/pods/dev-ia/tools/db-ops/health-checker.sh +323 -0
- package/template/pods/dev-ia/tools/db-ops/migration-runner.sh +308 -0
- package/template/pods/dev-ia/tools/db-ops/postgresql-schema-loader.sh +334 -0
- package/template/pods/dev-ia/tools/db-ops/query-runner.sh +171 -0
- package/template/pods/dev-ia/tools/db-ops/rls-policy-installer.sh +480 -0
- package/template/pods/dev-ia/tools/db-ops/rls-test-suite.sh +172 -0
- package/template/pods/dev-ia/tools/db-ops/schema-context-loader.sh +176 -0
- package/template/pods/hormozi-method/README.md +52 -0
- package/template/pods/hormozi-method/agents/acquisition-strategist.md +33 -0
- package/template/pods/hormozi-method/agents/conversion-copywriter.md +34 -0
- package/template/pods/hormozi-method/agents/monetization-architect.md +35 -0
- package/template/pods/hormozi-method/agents/offer-engineer.md +37 -0
- package/template/pods/hormozi-method/knowledge/core-four-leads.md +51 -0
- package/template/pods/hormozi-method/knowledge/grand-slam-offer.md +66 -0
- package/template/pods/hormozi-method/knowledge/growth-decision.md +38 -0
- package/template/pods/hormozi-method/knowledge/hooks-and-ads.md +56 -0
- package/template/pods/hormozi-method/knowledge/launch-email.md +31 -0
- package/template/pods/hormozi-method/knowledge/money-model.md +46 -0
- package/template/pods/hormozi-method/knowledge/opportunity-screening.md +44 -0
- package/template/pods/hormozi-method/knowledge/pages.md +25 -0
- package/template/pods/hormozi-method/knowledge/pos-lancamento-leads.md +56 -0
- package/template/pods/hormozi-method/knowledge/pricing.md +36 -0
- package/template/pods/hormozi-method/knowledge/retention-ltv.md +37 -0
- package/template/pods/hormozi-method/knowledge/scale-roadmap.md +35 -0
- package/template/pods/hormozi-method/knowledge/swipefiles-curados.md +44 -0
- package/template/pods/hormozi-method/knowledge/value-equation-diagnostic.md +77 -0
- package/template/pods/hormozi-method/memory/README.md +16 -0
- package/template/pods/hormozi-method/memory/learnings.consolidated.md +6 -0
- package/template/pods/hormozi-method/memory/learnings.jsonl +0 -0
- package/template/pods/hormozi-method/pod-spec.json +50 -0
- package/template/pods/hormozi-method/specs/wf-full-launch-sequence.spec.json +149 -0
- package/template/pods/hormozi-method/specs/wf-lead-magnet-pipeline.spec.json +125 -0
- package/template/pods/hormozi-method/specs/wf-money-model-design.spec.json +117 -0
- package/template/pods/hormozi-method/specs/wf-paid-ads-campaign.spec.json +132 -0
- package/template/pods/hormozi-method/specs/wf-scaling-diagnostic.spec.json +121 -0
- package/template/pods/hormozi-method/squad-io.yaml +43 -0
- package/template/pods/hormozi-method/templates/conversion-copywriter-tmpl.md +20 -0
- package/template/pods/hormozi-method/templates/launch-execucao-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/launch-fechamento-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/launch-pos-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/launch-preparacao-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/lm-copy-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/lm-distribuicao-tmpl.md +18 -0
- package/template/pods/hormozi-method/templates/lm-estrategia-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/mm-arquitetura-tmpl.md +18 -0
- package/template/pods/hormozi-method/templates/mm-copy-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/mm-diagnostico-tmpl.md +14 -0
- package/template/pods/hormozi-method/templates/monetization-architect-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/offer-engineer-tmpl.md +20 -0
- package/template/pods/hormozi-method/templates/paid-criativos-tmpl.md +19 -0
- package/template/pods/hormozi-method/templates/paid-fundacao-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/paid-teste-escala-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/scaling-diagnostico-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/scaling-priorizacao-tmpl.md +17 -0
- package/template/pods/hormozi-method/templates/scaling-roadmap-tmpl.md +17 -0
- package/template/pods/hormozi-method/workflows/wf-full-launch-sequence.smoke.json +12 -0
- package/template/pods/hormozi-method/workflows/wf-full-launch-sequence.yaml +56 -0
- package/template/pods/hormozi-method/workflows/wf-grand-slam-offer.smoke.json +11 -0
- package/template/pods/hormozi-method/workflows/wf-grand-slam-offer.yaml +45 -0
- package/template/pods/hormozi-method/workflows/wf-lead-magnet-pipeline.smoke.json +9 -0
- package/template/pods/hormozi-method/workflows/wf-lead-magnet-pipeline.yaml +45 -0
- package/template/pods/hormozi-method/workflows/wf-money-model-design.smoke.json +11 -0
- package/template/pods/hormozi-method/workflows/wf-money-model-design.yaml +45 -0
- package/template/pods/hormozi-method/workflows/wf-paid-ads-campaign.smoke.json +9 -0
- package/template/pods/hormozi-method/workflows/wf-paid-ads-campaign.yaml +46 -0
- package/template/pods/hormozi-method/workflows/wf-scaling-diagnostic.smoke.json +11 -0
- package/template/pods/hormozi-method/workflows/wf-scaling-diagnostic.yaml +44 -0
- package/template/pods/squad-forge/README.md +53 -0
- package/template/pods/squad-forge/agents/forge-chief.md +31 -0
- package/template/pods/squad-forge/agents/mind-extractor.md +29 -0
- package/template/pods/squad-forge/agents/pod-critic.md +26 -0
- package/template/pods/squad-forge/config/heuristic-watcher-rubric.yaml +74 -0
- package/template/pods/squad-forge/knowledge/fidelity-rubric.yaml +176 -0
- package/template/pods/squad-forge/knowledge/g6-memory-check.md +17 -0
- package/template/pods/squad-forge/knowledge/memory-ingest.md +16 -0
- package/template/pods/squad-forge/knowledge/source-audit-rubric.yaml +60 -0
- package/template/pods/squad-forge/memory/README.md +16 -0
- package/template/pods/squad-forge/memory/learnings.consolidated.md +8 -0
- package/template/pods/squad-forge/memory/learnings.jsonl +0 -0
- package/template/pods/squad-forge/pod-spec.json +166 -0
- package/template/pods/squad-forge/squad-io.yaml +13 -0
- package/template/pods/squad-forge/tasks/pipeline.md +158 -0
- package/template/pods/squad-forge/templates/discovery-tmpl.md +40 -0
- package/template/pods/squad-forge/templates/generation-tmpl.md +18 -0
- package/template/pods/squad-forge/templates/red-team-tmpl.md +17 -0
- package/template/pods/squad-forge/templates/register-tmpl.md +17 -0
- package/template/pods/squad-forge/templates/workflow-tmpl.yaml +101 -0
- package/template/pods/squad-forge/workflows/wf-forge.smoke.json +12 -0
- package/template/pods/squad-forge/workflows/wf-forge.yaml +60 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* pod-export-queue-race.test.cjs — Story SF10.16 (F5): concorrência CROSS-PROCESSO da fila do
|
|
5
|
+
* serviço-irmão (fecha `NOTE-2`; ADR-018 Adendo F5-b, DF-SF10.16-1..DF-SF10.16-4).
|
|
6
|
+
*
|
|
7
|
+
* HERMÉTICO (sem VPS, sem rede externa, sem LLM) e PORTÁTIL (macOS + Linux/CI) — nenhuma dependência
|
|
8
|
+
* de `flock(1)`, que não existe no macOS. A concorrência é REAL: `child_process` de verdade
|
|
9
|
+
* (`node <script>.cjs`), importando os módulos de PRODUÇÃO, disputando o MESMO diretório de fila,
|
|
10
|
+
* sincronizados por uma barreira de relógio (busy-spin até `START_AT`) — nunca stubs de `fs`.
|
|
11
|
+
*
|
|
12
|
+
* Os quatro efeitos MEDIDOS no `*assess` (cada um com um teste que força a corrida real):
|
|
13
|
+
* E1 pickup disputado em `pending/` — o perdedor lançava `ENOENT` e o `serve` inteiro caía por UM
|
|
14
|
+
* item (`main().catch` → exit 1 → `StartLimitBurst` → unit em `failed` PERMANENTE).
|
|
15
|
+
* E2 `reject` × `reject` em `awaiting/` — o perdedor lançava DENTRO do handler async do node:http
|
|
16
|
+
* → *unhandled rejection* → o processo da UI MORRIA (superfície de aprovação inteira fora).
|
|
17
|
+
* E3 `approve` × `approve` no mesmo `runId` — sem reivindicação, DOIS `spawn` do executor sobre o
|
|
18
|
+
* MESMO `runDir`: dois escritores no `state.json` e **gasto de LLM em dobro**.
|
|
19
|
+
* E4 publicação NÃO-atômica em `pending/` — o nome ficava visível ao `readdir` antes do conteúdo;
|
|
20
|
+
* o payload era despachado VAZIO e o ledger gravava `started_by: null`: o gate fail-loud de
|
|
21
|
+
* identidade de DF3-3 era contornado EM SILÊNCIO (o mais grave, porque é mudo).
|
|
22
|
+
* Mais: lock de instância (`O_EXCL`) com recusa acionável, tomada de lock órfão após `SIGKILL`,
|
|
23
|
+
* liberação em `SIGTERM`, e relato de órfãos de `processing/` SEM requeue automático.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const http = require('node:http');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
31
|
+
|
|
32
|
+
const svc = require('./pod-export-service.cjs');
|
|
33
|
+
const ui = require('./pod-export-ui.cjs');
|
|
34
|
+
|
|
35
|
+
const SVC_PATH = path.join(__dirname, 'pod-export-service.cjs');
|
|
36
|
+
const UI_PATH = path.join(__dirname, 'pod-export-ui.cjs');
|
|
37
|
+
|
|
38
|
+
let failures = 0;
|
|
39
|
+
function assert(ok, msg) { if (ok) console.log(`[OK ] ${msg}`); else { console.error(`[FALHA] ${msg}`); failures++; } }
|
|
40
|
+
function tmpDir(label) { return fs.mkdtempSync(path.join(os.tmpdir(), `sf10.16-${label}-`)); }
|
|
41
|
+
function messageOf(fn) { try { fn(); return ''; } catch (e) { return e.message; } }
|
|
42
|
+
const IS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Infra de processos REAIS: escreve o script do filho, dispara, coleta código de saída e saídas.
|
|
46
|
+
// A barreira é um instante de relógio compartilhado (`START_AT`): cada filho ocupa a CPU até o
|
|
47
|
+
// instante combinado e só então age — a sincronização mais apertada possível sem instrumentar o
|
|
48
|
+
// código de produção (que é justamente o que não se pode fazer num teste de corrida honesto).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const BARRIER = `
|
|
52
|
+
function barrier() { const t = Number(process.env.START_AT || 0); while (Date.now() < t) { /* spin */ } }
|
|
53
|
+
function waitFile(p, ms) { const d = Date.now() + ms; while (!fs.existsSync(p)) { if (Date.now() > d) { console.error('timeout esperando ' + p); process.exit(9); } } }
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
function writeChild(dir, name, body) {
|
|
57
|
+
const p = path.join(dir, name);
|
|
58
|
+
fs.writeFileSync(p, `'use strict';\nconst fs = require('node:fs');\nconst path = require('node:path');\n${BARRIER}\n${body}\n`);
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function runChild(script, env, { detached = false } = {}) {
|
|
63
|
+
const child = spawn(process.execPath, [script], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
64
|
+
let out = ''; let err = '';
|
|
65
|
+
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
66
|
+
child.stderr.on('data', (d) => { err += d.toString(); });
|
|
67
|
+
const done = new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal, out: () => out, err: () => err })));
|
|
68
|
+
return detached ? { child, done, out: () => out, err: () => err } : done;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Instante da barreira: folga suficiente para todos os filhos estarem carregados e girando. */
|
|
72
|
+
function startAt(ms = 700) { return String(Date.now() + ms); }
|
|
73
|
+
|
|
74
|
+
function waitFor(predicate, timeoutMs, label) {
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const tick = () => {
|
|
78
|
+
let v = false;
|
|
79
|
+
try { v = predicate(); } catch { v = false; }
|
|
80
|
+
if (v) return resolve(true);
|
|
81
|
+
if (Date.now() > deadline) return reject(new Error(`timeout: ${label}`));
|
|
82
|
+
return setTimeout(tick, 15);
|
|
83
|
+
};
|
|
84
|
+
tick();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
89
|
+
const markersIn = (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json') && !f.endsWith('.result.json')) : []);
|
|
90
|
+
|
|
91
|
+
// ===========================================================================
|
|
92
|
+
// E1 — pickup disputado em pending/: 4 PROCESSOS REAIS sobre o MESMO payload.
|
|
93
|
+
// Sem o fix: o `renameSync` de `processOne` lança `ENOENT` nos 3 perdedores → *unhandled rejection*
|
|
94
|
+
// → exit 1 (em produção: `main().catch` derruba o worker inteiro por UM item).
|
|
95
|
+
// Com o fix: 1 reivindica, 3 devolvem `{claimed:false}` e SEGUEM TRABALHANDO (cada filho processa em
|
|
96
|
+
// seguida um item próprio) — provar "não lança" não bastaria; o requisito é continuar servindo.
|
|
97
|
+
// ===========================================================================
|
|
98
|
+
async function e1PickupDisputado() {
|
|
99
|
+
console.log('\n--- E1: pickup de pending/ disputado por 4 processos REAIS (1 vence, 3 seguem servindo) ---');
|
|
100
|
+
const root = tmpDir('e1');
|
|
101
|
+
const queueDir = path.join(root, 'queue');
|
|
102
|
+
const spawnLog = path.join(root, 'spawnlog');
|
|
103
|
+
fs.mkdirSync(spawnLog, { recursive: true });
|
|
104
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
105
|
+
const contested = path.join(dirs.pending, 'contested.json');
|
|
106
|
+
fs.writeFileSync(contested, JSON.stringify({ pod: 'demo', started_by: 'alice', n: 0 }));
|
|
107
|
+
|
|
108
|
+
const N = 4;
|
|
109
|
+
const script = writeChild(root, 'e1-worker.cjs', `
|
|
110
|
+
const svc = require(process.env.SVC);
|
|
111
|
+
const cfg = { queueDir: process.env.QUEUE, bundleDir: null, runsRoot: process.env.RUNS, pollMs: 1 };
|
|
112
|
+
const deps = {
|
|
113
|
+
log: () => {},
|
|
114
|
+
// Cada invocação do executor deixa um rastro em disco: é assim que se conta, entre processos,
|
|
115
|
+
// quantas vezes o executor REALMENTE foi disparado (o que em produção custa LLM).
|
|
116
|
+
runExecutor: async (c, processingPath, runId) => {
|
|
117
|
+
fs.writeFileSync(path.join(process.env.SPAWNLOG, runId + '.' + process.env.IDX), processingPath);
|
|
118
|
+
return { code: 0, result: { complete: true } };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
(async () => {
|
|
122
|
+
barrier();
|
|
123
|
+
// SEM try/catch: espelha produção (o loop \`serve\` não protege \`processOne\`; um throw aqui vira
|
|
124
|
+
// unhandled rejection e mata o processo, exatamente como o worker real morria).
|
|
125
|
+
const contested = await svc.processOne(cfg, process.env.CONTESTED, deps);
|
|
126
|
+
const own = await svc.processOne(cfg, process.env.OWN, deps);
|
|
127
|
+
fs.writeFileSync(process.env.OUT, JSON.stringify({ contested, own }));
|
|
128
|
+
})();
|
|
129
|
+
`);
|
|
130
|
+
|
|
131
|
+
const START_AT = startAt();
|
|
132
|
+
const results = await Promise.all(Array.from({ length: N }, (_, i) => {
|
|
133
|
+
const own = path.join(dirs.pending, `own-${i}.json`);
|
|
134
|
+
fs.writeFileSync(own, JSON.stringify({ pod: 'demo', started_by: 'alice', n: i + 1 }));
|
|
135
|
+
return runChild(script, {
|
|
136
|
+
SVC: SVC_PATH, QUEUE: queueDir, RUNS: path.join(root, 'runs'), SPAWNLOG: spawnLog,
|
|
137
|
+
CONTESTED: contested, OWN: own, OUT: path.join(root, `out-${i}.json`), IDX: String(i), START_AT,
|
|
138
|
+
});
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
const alive = results.filter((r) => r.code === 0).length;
|
|
142
|
+
assert(alive === N, `os ${N} processos SOBREVIVERAM à disputa (exit 0 em todos) — perder a corrida não derruba o worker`);
|
|
143
|
+
if (alive !== N) console.error(results.map((r) => r.err()).join('\n'));
|
|
144
|
+
const outs = Array.from({ length: N }, (_, i) => path.join(root, `out-${i}.json`)).filter(fs.existsSync).map(readJson);
|
|
145
|
+
const winners = outs.filter((o) => o.contested && o.contested.claimed === true);
|
|
146
|
+
const losers = outs.filter((o) => o.contested && o.contested.claimed === false);
|
|
147
|
+
assert(winners.length === 1, `EXATAMENTE 1 processo reivindicou o payload disputado (${winners.length})`);
|
|
148
|
+
assert(losers.length === N - 1 && losers.every((o) => o.contested.reason === 'claim-lost'),
|
|
149
|
+
`os outros ${N - 1} receberam \`claimed:false\` (ENOENT sancionado = "outro ator ganhou"), sem exceção`);
|
|
150
|
+
assert(outs.length === N && outs.every((o) => o.own && o.own.claimed === true && o.own.kind === 'done'),
|
|
151
|
+
'todo processo que PERDEU a disputa continuou trabalhando em seguida (processou o item próprio até done/)');
|
|
152
|
+
assert(markersIn(dirs.done).length === N + 1, `o item disputado foi processado UMA vez e nenhum sumiu (${N + 1} marcadores em done/)`);
|
|
153
|
+
assert(fs.readdirSync(spawnLog).length === N + 1, `o executor foi disparado EXATAMENTE ${N + 1}x (1 pelo item disputado + ${N} próprios) — zero duplicação`);
|
|
154
|
+
assert(svc.listPending(queueDir).length === 0 && markersIn(dirs.processing).length === 0, 'pending/ e processing/ ficaram vazios (nenhum item perdido no caminho)');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ===========================================================================
|
|
158
|
+
// E1b — o LOOP `serve` segue servindo depois de perder um item para OUTRO PROCESSO REAL.
|
|
159
|
+
// O ladrão (escritor de trigger/operador — mutador sancionado por DF2-5) rouba o 2º item do snapshot
|
|
160
|
+
// enquanto o worker executa o 1º. O worker precisa: pular o item, COMPLETAR os ciclos e sair 0.
|
|
161
|
+
// ===========================================================================
|
|
162
|
+
async function e1bServeSegueServindo() {
|
|
163
|
+
console.log('\n--- E1b: o loop `serve` perde um item para outro processo REAL e SEGUE servindo ---');
|
|
164
|
+
const root = tmpDir('e1b');
|
|
165
|
+
const queueDir = path.join(root, 'queue');
|
|
166
|
+
const stolenDir = path.join(root, 'stolen');
|
|
167
|
+
fs.mkdirSync(stolenDir, { recursive: true });
|
|
168
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
169
|
+
fs.writeFileSync(path.join(dirs.pending, 'a-first.json'), JSON.stringify({ pod: 'demo', started_by: 'alice' }));
|
|
170
|
+
const target = path.join(dirs.pending, 'b-stolen.json');
|
|
171
|
+
fs.writeFileSync(target, JSON.stringify({ pod: 'demo', started_by: 'bob' }));
|
|
172
|
+
|
|
173
|
+
const stealNow = path.join(root, 'steal-now');
|
|
174
|
+
const stolen = path.join(root, 'stolen.flag');
|
|
175
|
+
|
|
176
|
+
const worker = writeChild(root, 'e1b-worker.cjs', `
|
|
177
|
+
const svc = require(process.env.SVC);
|
|
178
|
+
const cfg = { queueDir: process.env.QUEUE, bundleDir: null, runsRoot: process.env.RUNS, pollMs: 1 };
|
|
179
|
+
svc.serve(cfg, {
|
|
180
|
+
log: () => {}, warn: () => {},
|
|
181
|
+
env: { ANTHROPIC_API_KEY: 'x', OPENROUTER_API_KEY: 'y' },
|
|
182
|
+
probeChrome: () => '/usr/bin/chromium',
|
|
183
|
+
sleep: async () => {}, stopAfter: 2,
|
|
184
|
+
runExecutor: async (c, processingPath) => {
|
|
185
|
+
if (processingPath.includes('a-first')) {
|
|
186
|
+
// O item 'b-stolen' JÁ está no snapshot deste ciclo. Enquanto processamos o 1º, um processo
|
|
187
|
+
// REAL rouba o 2º e confirma — a corrida acontece de fato, não é simulada.
|
|
188
|
+
fs.writeFileSync(process.env.STEAL_NOW, '1');
|
|
189
|
+
waitFile(process.env.STOLEN, 20000);
|
|
190
|
+
}
|
|
191
|
+
return { code: 0, result: { complete: true } };
|
|
192
|
+
},
|
|
193
|
+
}).then((res) => { fs.writeFileSync(process.env.OUT, JSON.stringify({ cycles: res.cycles, processed: res.processed.length })); });
|
|
194
|
+
`);
|
|
195
|
+
const thief = writeChild(root, 'e1b-thief.cjs', `
|
|
196
|
+
waitFile(process.env.STEAL_NOW, 20000);
|
|
197
|
+
fs.renameSync(process.env.TARGET, path.join(process.env.STOLEN_DIR, 'b.json')); // reivindicação REAL de outro processo
|
|
198
|
+
fs.writeFileSync(process.env.STOLEN, '1');
|
|
199
|
+
`);
|
|
200
|
+
|
|
201
|
+
const env = { SVC: SVC_PATH, QUEUE: queueDir, RUNS: path.join(root, 'runs'), STEAL_NOW: stealNow, STOLEN: stolen, OUT: path.join(root, 'out.json'), TARGET: target, STOLEN_DIR: stolenDir };
|
|
202
|
+
const [w, t] = await Promise.all([runChild(worker, env), runChild(thief, env)]);
|
|
203
|
+
assert(w.code === 0, 'o worker SOBREVIVEU (exit 0) à perda do item para outro processo');
|
|
204
|
+
if (w.code !== 0) console.error(w.err());
|
|
205
|
+
assert(t.code === 0, 'o ladrão (processo real) concluiu o roubo por rename');
|
|
206
|
+
const out = fs.existsSync(path.join(root, 'out.json')) ? readJson(path.join(root, 'out.json')) : null;
|
|
207
|
+
assert(out && out.cycles === 2, 'o loop COMPLETOU os 2 ciclos após a perda — segue servindo, não apenas "não lança"');
|
|
208
|
+
assert(out && out.processed === 1, 'exatamente 1 item processado (o roubado não foi processado duas vezes nem sumiu)');
|
|
209
|
+
assert(fs.existsSync(path.join(stolenDir, 'b.json')) && !fs.existsSync(target), 'o item roubado ficou com o ladrão (a fila não o reprocessou)');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ===========================================================================
|
|
213
|
+
// E2 — `reject` × `reject`: 4 PROCESSOS REAIS sobre o MESMO checkpoint.
|
|
214
|
+
// Sem o fix (`existsSync` → `renameSync`, TOCTOU), o perdedor lança `ENOENT`; na UI real isso ocorre
|
|
215
|
+
// dentro de um handler `async` do node:http → *unhandled rejection* → o processo MORRE.
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
async function e2RejectDisputado() {
|
|
218
|
+
console.log('\n--- E2: reject × reject por 4 processos REAIS (1 rejeita, 3 recebem `gone`, ninguém morre) ---');
|
|
219
|
+
const root = tmpDir('e2');
|
|
220
|
+
const queueDir = path.join(root, 'queue');
|
|
221
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
222
|
+
const runId = 'pex-hn-carousel-e2';
|
|
223
|
+
fs.writeFileSync(path.join(dirs.awaiting, `${runId}.json`), JSON.stringify({ pod: 'hn-carousel', started_by: 'bob' }));
|
|
224
|
+
fs.writeFileSync(path.join(dirs.awaiting, `${runId}.result.json`), JSON.stringify({ runId, kind: 'awaiting', awaiting: 'montagem' }));
|
|
225
|
+
|
|
226
|
+
const N = 4;
|
|
227
|
+
const script = writeChild(root, 'e2-rejecter.cjs', `
|
|
228
|
+
const uimod = require(process.env.UI);
|
|
229
|
+
const cfg = { queueDir: process.env.QUEUE, runsRoot: process.env.RUNS };
|
|
230
|
+
barrier();
|
|
231
|
+
// SEM try/catch: no servidor real este código roda dentro de um handler async — um throw aqui é
|
|
232
|
+
// *unhandled rejection* e mata o processo (foi o que o *assess* mediu).
|
|
233
|
+
const r = uimod.rejectRun(cfg, process.env.RUNID, { reason: 'motivo ' + process.env.IDX, rejectedBy: 'daniel' });
|
|
234
|
+
fs.writeFileSync(process.env.OUT, JSON.stringify(r));
|
|
235
|
+
`);
|
|
236
|
+
const START_AT = startAt();
|
|
237
|
+
const results = await Promise.all(Array.from({ length: N }, (_, i) => runChild(script, {
|
|
238
|
+
UI: UI_PATH, QUEUE: queueDir, RUNS: path.join(root, 'runs'), RUNID: runId, OUT: path.join(root, `out-${i}.json`), IDX: String(i), START_AT,
|
|
239
|
+
})));
|
|
240
|
+
assert(results.every((r) => r.code === 0), `os ${N} processos SOBREVIVERAM ao reject disputado (exit 0) — a superfície de aprovação não cai`);
|
|
241
|
+
if (!results.every((r) => r.code === 0)) console.error(results.map((r) => r.err()).join('\n'));
|
|
242
|
+
const outs = Array.from({ length: N }, (_, i) => path.join(root, `out-${i}.json`)).filter(fs.existsSync).map(readJson);
|
|
243
|
+
assert(outs.filter((o) => o.ok === true).length === 1, 'EXATAMENTE 1 reject teve efeito (o item é reivindicado por rename antes de qualquer escrita)');
|
|
244
|
+
assert(outs.filter((o) => o.ok === false && o.gone === true).length === N - 1, `os outros ${N - 1} receberam \`gone\` (o estado que a UI já sabe renderizar: "já tratado")`);
|
|
245
|
+
assert(markersIn(dirs.blocked).length === 1 && markersIn(dirs.awaiting).length === 0, 'o checkpoint terminou em blocked/ UMA vez e saiu de awaiting/');
|
|
246
|
+
const res = readJson(path.join(dirs.blocked, `${runId}.result.json`));
|
|
247
|
+
assert(res.rejected === true && /motivo \d/.test(res.reason), 'o motivo registrado é o do vencedor (um único result.json, sem escrita concorrente)');
|
|
248
|
+
assert(!fs.existsSync(path.join(dirs.awaiting, `${runId}.result.json`)), 'o companion de awaiting/ foi removido pelo dono do item (nenhum checkpoint fantasma)');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
// E2b — a UI REAL (servidor node:http em processo PRÓPRIO) responde `flash=gone` a um reject cuja
|
|
253
|
+
// disputa foi perdida para OUTRO PROCESSO, e CONTINUA VIVA e atendendo.
|
|
254
|
+
// (Premissa medida no *assess*, reverificada aqui: uma exceção em handler async do node:http mata o
|
|
255
|
+
// processo — é por isso que absorver `ENOENT` por errno, e só ele, é o que mantém a UI de pé.)
|
|
256
|
+
// ===========================================================================
|
|
257
|
+
function uiFixture(root) {
|
|
258
|
+
const queueDir = path.join(root, 'queue');
|
|
259
|
+
const runsRoot = path.join(root, 'runs');
|
|
260
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
261
|
+
const runId = 'pex-hn-carousel-e2b';
|
|
262
|
+
fs.writeFileSync(path.join(dirs.awaiting, `${runId}.json`), JSON.stringify({ pod: 'hn-carousel', started_by: 'bob' }));
|
|
263
|
+
fs.writeFileSync(path.join(dirs.awaiting, `${runId}.result.json`), JSON.stringify({ runId, kind: 'awaiting', awaiting: 'montagem', started_by: 'bob' }));
|
|
264
|
+
const runDir = path.join(runsRoot, 'hn-carousel', 'wf', runId);
|
|
265
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
266
|
+
fs.writeFileSync(path.join(runDir, 'state.json'), JSON.stringify({ run_id: runId, steps: {} }));
|
|
267
|
+
const identitiesFile = path.join(root, 'identities.json');
|
|
268
|
+
fs.writeFileSync(identitiesFile, JSON.stringify({ identities: [{ id: 'daniel', label: 'Daniel', is_approver: true }, { id: 'alice', label: 'Alice' }] }));
|
|
269
|
+
return { queueDir, runsRoot, runId, identitiesFile, dirs };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function httpReq(port, { method = 'GET', pathUrl = '/', cookie, body } = {}) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const headers = { Origin: `http://127.0.0.1:${port}` };
|
|
275
|
+
if (cookie) headers.Cookie = cookie;
|
|
276
|
+
if (body != null) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; headers['Content-Length'] = Buffer.byteLength(body); }
|
|
277
|
+
const req = http.request({ host: '127.0.0.1', port, method, path: pathUrl, headers }, (res) => {
|
|
278
|
+
let data = ''; res.on('data', (c) => { data += c; });
|
|
279
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
|
|
280
|
+
});
|
|
281
|
+
req.on('error', reject);
|
|
282
|
+
if (body != null) req.write(body);
|
|
283
|
+
req.end();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const cookieOf = (res) => { const sc = res.headers['set-cookie']; const m = sc && String(sc[0]).match(/pex_ui=([^;]*)/); return m ? `pex_ui=${m[1]}` : null; };
|
|
287
|
+
|
|
288
|
+
async function e2bUiRealNaoMorre() {
|
|
289
|
+
console.log('\n--- E2b: UI REAL (processo próprio) perde a disputa → `flash=gone` e SEGUE ATENDENDO ---');
|
|
290
|
+
const root = tmpDir('e2b');
|
|
291
|
+
const fx = uiFixture(root);
|
|
292
|
+
const SESSION_KEY = 'k'.repeat(48);
|
|
293
|
+
const TEAM_SECRET = 'senha-da-equipe';
|
|
294
|
+
const portFile = path.join(root, 'port');
|
|
295
|
+
|
|
296
|
+
const server = writeChild(root, 'e2b-ui.cjs', `
|
|
297
|
+
const uimod = require(process.env.UI);
|
|
298
|
+
const svcmod = require(process.env.SVC);
|
|
299
|
+
const cfg = uimod.loadUiConfig({
|
|
300
|
+
PODEXPORT_QUEUE_DIR: process.env.QUEUE, PODEXPORT_RUNS_DIR: process.env.RUNS,
|
|
301
|
+
PODEXPORT_IDENTITIES: process.env.IDS, PODEXPORT_UI_TEAM_HASH: process.env.TEAM_HASH,
|
|
302
|
+
PODEXPORT_UI_SESSION_KEY: process.env.SESSION_KEY, PODEXPORT_APPROVER_SECRET_HASH: process.env.APPROVER_HASH,
|
|
303
|
+
PODEXPORT_UI_INSECURE: '1',
|
|
304
|
+
});
|
|
305
|
+
process.env.PODEXPORT_IDENTITIES = process.env.IDS;
|
|
306
|
+
const s = uimod.createServer(cfg, {});
|
|
307
|
+
s.listen(0, '127.0.0.1', () => { fs.writeFileSync(process.env.PORT_FILE, String(s.address().port)); });
|
|
308
|
+
`);
|
|
309
|
+
const teamHash = svc.hashApproverSecret(TEAM_SECRET);
|
|
310
|
+
const approverHash = svc.hashApproverSecret('segredo-do-aprovador');
|
|
311
|
+
const handle = runChild(server, {
|
|
312
|
+
UI: UI_PATH, SVC: SVC_PATH, QUEUE: fx.queueDir, RUNS: fx.runsRoot, IDS: fx.identitiesFile,
|
|
313
|
+
TEAM_HASH: teamHash, SESSION_KEY, APPROVER_HASH: approverHash, PORT_FILE: portFile,
|
|
314
|
+
}, { detached: true });
|
|
315
|
+
try {
|
|
316
|
+
await waitFor(() => fs.existsSync(portFile) && fs.readFileSync(portFile, 'utf8').length > 0, 10000, 'UI subir');
|
|
317
|
+
const port = Number(fs.readFileSync(portFile, 'utf8'));
|
|
318
|
+
|
|
319
|
+
let res = await httpReq(port, { method: 'POST', pathUrl: '/login', body: new URLSearchParams({ password: TEAM_SECRET }).toString() });
|
|
320
|
+
const cookie = cookieOf(res);
|
|
321
|
+
const sid = ui.verifySession(cookie.replace('pex_ui=', ''), SESSION_KEY).sid;
|
|
322
|
+
res = await httpReq(port, { method: 'POST', pathUrl: '/operator', cookie, body: new URLSearchParams({ operator: 'daniel', _csrf: ui.csrfToken(sid, SESSION_KEY) }).toString() });
|
|
323
|
+
const cookieOp = cookieOf(res) || cookie;
|
|
324
|
+
const csrf = ui.csrfToken(sid, SESSION_KEY);
|
|
325
|
+
|
|
326
|
+
// OUTRO PROCESSO REAL (o serviço, aprovando) reivindica o marcador antes do reject chegar.
|
|
327
|
+
const claimer = writeChild(root, 'e2b-claimer.cjs', `
|
|
328
|
+
const svcmod = require(process.env.SVC);
|
|
329
|
+
const r = svcmod.claimApproveMarker({ queueDir: process.env.QUEUE }, process.env.RUNID, {});
|
|
330
|
+
fs.writeFileSync(process.env.OUT, JSON.stringify(r));
|
|
331
|
+
`);
|
|
332
|
+
const c = await runChild(claimer, { SVC: SVC_PATH, QUEUE: fx.queueDir, RUNID: fx.runId, OUT: path.join(root, 'claim.json') });
|
|
333
|
+
assert(c.code === 0 && readJson(path.join(root, 'claim.json')).claimed === true, 'outro processo REAL reivindicou o checkpoint (awaiting/ → processing/)');
|
|
334
|
+
|
|
335
|
+
res = await httpReq(port, { method: 'POST', pathUrl: '/reject', cookie: cookieOp, body: new URLSearchParams({ run: fx.runId, reason: 'chegou tarde', _csrf: csrf }).toString() });
|
|
336
|
+
assert(res.status === 303 && /flash=gone/.test(res.headers.location || ''), 'reject que PERDEU a disputa → 303 `flash=gone` (a UI já sabe renderizar esse estado)');
|
|
337
|
+
|
|
338
|
+
const health = await httpReq(port, { pathUrl: '/health' });
|
|
339
|
+
assert(health.status === 200 && health.body === 'ok', 'a UI CONTINUA VIVA e atendendo depois da disputa perdida (antes: unhandled rejection → processo morto)');
|
|
340
|
+
const list = await httpReq(port, { pathUrl: '/?flash=gone', cookie: cookieOp });
|
|
341
|
+
assert(list.status === 200 && /já foi tratado por outra pessoa/.test(list.body), 'a lista renderiza a mensagem honesta de item já tratado');
|
|
342
|
+
assert(markersIn(fx.dirs.blocked).length === 0, 'o reject perdedor NÃO produziu efeito colateral em blocked/ (não escreveu motivo sobre item alheio)');
|
|
343
|
+
} finally {
|
|
344
|
+
handle.child.kill('SIGKILL');
|
|
345
|
+
await handle.done;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Premissa de DF-SF10.16-1 reverificada NESTE runtime: exceção em handler async do node:http mata o
|
|
349
|
+
// processo. É o que tornava LETAL o `ENOENT` do reject — e o que a sanção por errno evita.
|
|
350
|
+
const boom = writeChild(root, 'e2b-unhandled.cjs', `
|
|
351
|
+
const http = require('node:http');
|
|
352
|
+
const s = http.createServer(async () => { throw new Error('ENOENT simulado em handler async'); });
|
|
353
|
+
s.listen(0, '127.0.0.1', () => {
|
|
354
|
+
http.get({ host: '127.0.0.1', port: s.address().port, path: '/' }, () => {}).on('error', () => {});
|
|
355
|
+
});
|
|
356
|
+
setTimeout(() => process.exit(0), 5000);
|
|
357
|
+
`);
|
|
358
|
+
const r = await runChild(boom, {});
|
|
359
|
+
assert(r.code === 1, 'premissa (DF-SF10.16-1): um throw em handler async do node:http DERRUBA o processo (exit 1) — daí a letalidade do ENOENT não sancionado');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ===========================================================================
|
|
363
|
+
// E3 — `approve` × `approve` no mesmo runId: 3 PROCESSOS REAIS. O gasto de LLM é o dano.
|
|
364
|
+
// Sem o fix: 3 spawns do executor sobre o MESMO runDir. Com o fix: EXATAMENTE 1.
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
async function e3ApproveDisputado() {
|
|
367
|
+
console.log('\n--- E3: approve × approve por 3 processos REAIS → EXATAMENTE 1 spawn do executor ---');
|
|
368
|
+
const root = tmpDir('e3');
|
|
369
|
+
const queueDir = path.join(root, 'queue');
|
|
370
|
+
const spawnLog = path.join(root, 'spawnlog');
|
|
371
|
+
fs.mkdirSync(spawnLog, { recursive: true });
|
|
372
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
373
|
+
const runId = 'pex-hn-carousel-e3';
|
|
374
|
+
fs.writeFileSync(path.join(dirs.awaiting, `${runId}.json`), JSON.stringify({ pod: 'hn-carousel', started_by: 'bob' }));
|
|
375
|
+
const secretHash = svc.hashApproverSecret('correct horse');
|
|
376
|
+
|
|
377
|
+
const N = 3;
|
|
378
|
+
const script = writeChild(root, 'e3-approver.cjs', `
|
|
379
|
+
const { EventEmitter } = require('node:events');
|
|
380
|
+
const svcmod = require(process.env.SVC);
|
|
381
|
+
const cfg = { queueDir: process.env.QUEUE, bundleDir: '/opt/bundle', runsRoot: process.env.RUNS, pollMs: 1 };
|
|
382
|
+
const idModel = { identities: [{ id: 'daniel', is_approver: true }], approver: { id: 'daniel', is_approver: true }, byId: { daniel: { id: 'daniel', is_approver: true } } };
|
|
383
|
+
// spawn INJETADO que deixa RASTRO EM DISCO: é o contador cross-processo de invocações do executor
|
|
384
|
+
// (cada uma seria, em produção, uma execução de LLM paga).
|
|
385
|
+
const fakeSpawn = (bin, args) => {
|
|
386
|
+
fs.writeFileSync(path.join(process.env.SPAWNLOG, 'spawn-' + process.env.IDX), args.join(' '));
|
|
387
|
+
const ch = new EventEmitter(); ch.stdout = new EventEmitter();
|
|
388
|
+
setTimeout(() => { ch.stdout.emit('data', Buffer.from('RESULTADO: {"approved":true}\\n')); ch.emit('exit', 0); }, 120);
|
|
389
|
+
return ch;
|
|
390
|
+
};
|
|
391
|
+
(async () => {
|
|
392
|
+
barrier();
|
|
393
|
+
const r = await svcmod.approveRun(cfg, process.env.RUNID, {
|
|
394
|
+
spawn: fakeSpawn, log: () => {},
|
|
395
|
+
identityModel: idModel, asId: 'daniel', approverSecret: 'correct horse', approverSecretHash: process.env.SECRET_HASH,
|
|
396
|
+
findStateByRunId: () => null, // run ainda em progresso → nada a reconciliar (marcador volta a awaiting/)
|
|
397
|
+
});
|
|
398
|
+
fs.writeFileSync(process.env.OUT, JSON.stringify(r));
|
|
399
|
+
})();
|
|
400
|
+
`);
|
|
401
|
+
const START_AT = startAt();
|
|
402
|
+
const results = await Promise.all(Array.from({ length: N }, (_, i) => runChild(script, {
|
|
403
|
+
SVC: SVC_PATH, QUEUE: queueDir, RUNS: path.join(root, 'runs'), RUNID: runId, SPAWNLOG: spawnLog,
|
|
404
|
+
SECRET_HASH: secretHash, OUT: path.join(root, `out-${i}.json`), IDX: String(i), START_AT,
|
|
405
|
+
})));
|
|
406
|
+
assert(results.every((r) => r.code === 0), `os ${N} processos de approve terminaram sem crash`);
|
|
407
|
+
const spawns = fs.readdirSync(spawnLog);
|
|
408
|
+
assert(spawns.length === 1, `EXATAMENTE 1 spawn do executor entre ${N} approves concorrentes (${spawns.length}) — duplo gasto de LLM fechado`);
|
|
409
|
+
const outs = Array.from({ length: N }, (_, i) => path.join(root, `out-${i}.json`)).filter(fs.existsSync).map(readJson);
|
|
410
|
+
assert(outs.filter((o) => o.code === 0 && !o.denied).length === 1, 'exatamente 1 approve teve efeito');
|
|
411
|
+
const gone = outs.filter((o) => o.denied === true && o.gone === true);
|
|
412
|
+
assert(gone.length === N - 1 && gone.every((o) => /reivindicado/.test(o.reason)), `os outros ${N - 1} foram recusados como disputa perdida (\`gone\`), não como erro de segredo`);
|
|
413
|
+
assert(markersIn(dirs.awaiting).length === 1 && markersIn(dirs.processing).length === 0,
|
|
414
|
+
'sem reconciliação, o marcador VOLTOU a awaiting/ (checkpoint segue retomável; nada preso em processing/)');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ===========================================================================
|
|
418
|
+
// E3b — §4a do gate: a reivindicação NÃO regride o gate de autorização de DF3-3 (SF10.4).
|
|
419
|
+
// Ela vem estritamente DEPOIS da decisão de auth e é CONDICIONAL ao marcador existir.
|
|
420
|
+
// ===========================================================================
|
|
421
|
+
async function e3bGateDeAutorizacaoIntacto() {
|
|
422
|
+
console.log('\n--- E3b: a reivindicação não autoriza o que o segredo recusaria, nem recusa o que ele autorizaria ---');
|
|
423
|
+
const root = tmpDir('e3b');
|
|
424
|
+
const queueDir = path.join(root, 'queue');
|
|
425
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
426
|
+
const runId = 'pex-gate-1';
|
|
427
|
+
fs.writeFileSync(path.join(dirs.awaiting, `${runId}.json`), JSON.stringify({ pod: 'hn' }));
|
|
428
|
+
const cfg = { queueDir, bundleDir: '/opt/bundle', runsRoot: path.join(root, 'runs'), pollMs: 1 };
|
|
429
|
+
const idModel = { identities: [{ id: 'daniel', is_approver: true }, { id: 'alice' }], approver: { id: 'daniel', is_approver: true }, byId: { daniel: { id: 'daniel', is_approver: true }, alice: { id: 'alice' } } };
|
|
430
|
+
const secretHash = svc.hashApproverSecret('correct horse');
|
|
431
|
+
let spawns = 0;
|
|
432
|
+
const { EventEmitter } = require('node:events');
|
|
433
|
+
const fakeSpawn = () => {
|
|
434
|
+
spawns++;
|
|
435
|
+
const ch = new EventEmitter(); ch.stdout = new EventEmitter();
|
|
436
|
+
setImmediate(() => { ch.stdout.emit('data', Buffer.from('RESULTADO: {"approved":true}\n')); ch.emit('exit', 0); });
|
|
437
|
+
return ch;
|
|
438
|
+
};
|
|
439
|
+
const base = { spawn: fakeSpawn, log: () => {}, identityModel: idModel, asId: 'daniel', approverSecret: 'correct horse', approverSecretHash: secretHash, findStateByRunId: () => null };
|
|
440
|
+
|
|
441
|
+
const wrongSecret = await svc.approveRun(cfg, runId, { ...base, approverSecret: 'chute' });
|
|
442
|
+
assert(wrongSecret.denied && /segredo do aprovador inválido/.test(wrongSecret.reason) && spawns === 0, 'segredo ERRADO → recusa do gate DF3-3 (sem spawn)');
|
|
443
|
+
assert(markersIn(dirs.awaiting).length === 1 && markersIn(dirs.processing).length === 0, 'recusa por segredo NÃO reivindica o marcador (ele fica intocado em awaiting/)');
|
|
444
|
+
|
|
445
|
+
const notApprover = await svc.approveRun(cfg, runId, { ...base, asId: 'alice' });
|
|
446
|
+
assert(notApprover.denied && /aprovadora designada/.test(notApprover.reason) && spawns === 0, 'identidade NÃO-aprovadora → recusa do gate DF3-3 (sem spawn)');
|
|
447
|
+
assert(markersIn(dirs.awaiting).length === 1, 'recusa por identidade NÃO consome o marcador');
|
|
448
|
+
|
|
449
|
+
const badId = await svc.approveRun(cfg, '../etc/passwd', base);
|
|
450
|
+
assert(badId.denied && badId.code === 3 && spawns === 0 && markersIn(dirs.awaiting).length === 1, 'runId inválido (SF10.9/AC5) continua recusado ANTES de qualquer efeito de fila');
|
|
451
|
+
|
|
452
|
+
const ok = await svc.approveRun(cfg, runId, base);
|
|
453
|
+
assert(ok.code === 0 && !ok.denied && spawns === 1, 'autorização VÁLIDA → aprova normalmente (a reivindicação não recusa quem o segredo autoriza)');
|
|
454
|
+
assert(markersIn(dirs.awaiting).length === 1 && markersIn(dirs.processing).length === 0, 'após o approve sem reconciliação, o marcador volta a awaiting/ (estado observável idêntico ao pré-SF10.16)');
|
|
455
|
+
|
|
456
|
+
// Approve FORA-DA-FILA (run que nunca teve marcador): comportamento INALTERADO — a reivindicação é
|
|
457
|
+
// condicional à existência do marcador, nunca um novo motivo de recusa.
|
|
458
|
+
const outOfQueue = await svc.approveRun(cfg, 'pex-sem-marcador', base);
|
|
459
|
+
assert(outOfQueue.code === 0 && !outOfQueue.denied && spawns === 2, 'approve de run SEM marcador na fila segue funcionando (reivindicação condicional, não é novo gate)');
|
|
460
|
+
const claim = svc.claimApproveMarker(cfg, 'pex-sem-marcador');
|
|
461
|
+
assert(claim.claimed === false && claim.absent === true, 'claimApproveMarker distingue AUSENTE (fora-da-fila) de OCUPADO (disputa perdida)');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ===========================================================================
|
|
465
|
+
// E4 — publicação atômica: um payload NUNCA é observado meio-publicado em pending/.
|
|
466
|
+
// Publicador e observador são PROCESSOS REAIS e simultâneos; os payloads são grandes de propósito
|
|
467
|
+
// (a janela de escrita de `writeFileSync` direto no destino era exatamente o defeito).
|
|
468
|
+
// ===========================================================================
|
|
469
|
+
async function e4PublicacaoAtomica() {
|
|
470
|
+
console.log('\n--- E4: publicação em pending/ é atômica (observador REAL nunca vê payload parcial) ---');
|
|
471
|
+
const root = tmpDir('e4');
|
|
472
|
+
const queueDir = path.join(root, 'queue');
|
|
473
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
474
|
+
const idsFile = path.join(root, 'identities.json');
|
|
475
|
+
fs.writeFileSync(idsFile, JSON.stringify({ identities: [{ id: 'daniel', is_approver: true }, { id: 'alice' }] }));
|
|
476
|
+
// payload gordo (~2MB): alarga a escrita e torna a janela da publicação não-atômica observável.
|
|
477
|
+
const srcDir = path.join(root, 'src');
|
|
478
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
479
|
+
const K = 5;
|
|
480
|
+
for (let i = 0; i < K; i++) {
|
|
481
|
+
fs.writeFileSync(path.join(srcDir, `p${i}.json`), JSON.stringify({ pod: 'demo', started_by: 'alice', filler: 'x'.repeat(2 * 1024 * 1024) }));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const publisher = writeChild(root, 'e4-publisher.cjs', `
|
|
485
|
+
const svcmod = require(process.env.SVC);
|
|
486
|
+
const cfg = { queueDir: process.env.QUEUE, identitiesFile: process.env.IDS };
|
|
487
|
+
barrier();
|
|
488
|
+
for (let i = 0; i < Number(process.env.K); i++) svcmod.enqueue(cfg, path.join(process.env.SRC, 'p' + i + '.json'));
|
|
489
|
+
fs.writeFileSync(process.env.PUB_DONE, '1');
|
|
490
|
+
`);
|
|
491
|
+
const observer = writeChild(root, 'e4-observer.cjs', `
|
|
492
|
+
const pend = path.join(process.env.QUEUE, 'pending');
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
const first = [];
|
|
495
|
+
barrier();
|
|
496
|
+
const deadline = Date.now() + 20000;
|
|
497
|
+
let polls = 0;
|
|
498
|
+
// Espia como o SERVIÇO espia: \`readdir\` em laço e, no INSTANTE em que um nome aparece (o instante
|
|
499
|
+
// em que \`processOne\` o reivindicaria), afere o item. A publicação não-atômica torna o nome visível
|
|
500
|
+
// no \`open(O_CREAT|O_TRUNC)\`, ANTES do conteúdo: o \`stat\` de primeira visão flagra 0 byte — e era
|
|
501
|
+
// esse payload vazio que o executor recebia, com \`started_by: null\` no ledger.
|
|
502
|
+
while (Date.now() < deadline) {
|
|
503
|
+
polls++;
|
|
504
|
+
let files = [];
|
|
505
|
+
try { files = fs.readdirSync(pend).filter((f) => f.endsWith('.json')); } catch { files = []; }
|
|
506
|
+
for (const f of files) {
|
|
507
|
+
if (seen.has(f)) continue;
|
|
508
|
+
seen.add(f);
|
|
509
|
+
let size = -1;
|
|
510
|
+
try { size = fs.statSync(path.join(pend, f)).size; } catch { size = -1; }
|
|
511
|
+
let raw = ''; let parsed = false; let startedBy = null;
|
|
512
|
+
try { raw = fs.readFileSync(path.join(pend, f), 'utf8'); } catch { raw = ''; }
|
|
513
|
+
try { const o = JSON.parse(raw); parsed = true; startedBy = o.started_by || null; } catch { parsed = false; }
|
|
514
|
+
first.push({ f, size, parsed, startedBy });
|
|
515
|
+
}
|
|
516
|
+
if (fs.existsSync(process.env.PUB_DONE) && seen.size >= Number(process.env.K)) break;
|
|
517
|
+
}
|
|
518
|
+
const partial = first.filter((x) => x.size <= 0 || !x.parsed || x.startedBy === null);
|
|
519
|
+
fs.writeFileSync(process.env.OUT, JSON.stringify({ polls, seen: seen.size, partialCount: partial.length, partial: partial.slice(0, 5) }));
|
|
520
|
+
`);
|
|
521
|
+
const START_AT = startAt();
|
|
522
|
+
const env = { SVC: SVC_PATH, QUEUE: queueDir, IDS: idsFile, SRC: srcDir, K: String(K), PUB_DONE: path.join(root, 'pub-done'), START_AT };
|
|
523
|
+
const [p, o] = await Promise.all([
|
|
524
|
+
runChild(publisher, env),
|
|
525
|
+
runChild(observer, { ...env, OUT: path.join(root, 'obs.json') }),
|
|
526
|
+
]);
|
|
527
|
+
assert(p.code === 0, 'o publicador (processo real) enfileirou os payloads sem erro');
|
|
528
|
+
if (p.code !== 0) console.error(p.err());
|
|
529
|
+
assert(o.code === 0, 'o observador (processo real) rodou até o fim');
|
|
530
|
+
const obs = readJson(path.join(root, 'obs.json'));
|
|
531
|
+
assert(obs.seen === K, `o observador viu os ${K} payloads publicados (${obs.seen})`);
|
|
532
|
+
assert(obs.polls > 50, `o observador amostrou a fila em laço apertado durante as publicações (${obs.polls} varreduras) — a janela, se existisse, seria vista`);
|
|
533
|
+
assert(obs.partialCount === 0, `NENHUMA observação parcial em ${K} publicações concorrentes (${obs.partialCount}) — o item existe COMPLETO ou não existe`);
|
|
534
|
+
if (obs.partialCount > 0) console.error(JSON.stringify(obs.partial, null, 2));
|
|
535
|
+
assert(svc.listPending(queueDir).length === K, 'pending/ contém exatamente os payloads publicados');
|
|
536
|
+
assert(fs.existsSync(path.join(queueDir, svc.TMP_SUBDIR)) && fs.readdirSync(path.join(queueDir, svc.TMP_SUBDIR)).length === 0,
|
|
537
|
+
'`.tmp/` existe (criado por ensureQueue) e NÃO acumula lixo após as publicações');
|
|
538
|
+
assert(!svc.listPending(queueDir).some((p2) => p2.includes(svc.TMP_SUBDIR)), 'listPending continua lendo SÓ pending/ (a área temporária está fora do caminho de dados)');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ===========================================================================
|
|
542
|
+
// E4b — o gate `started_by` de DF3-3 deixa de ser evadível: um payload meio-publicado (o estado
|
|
543
|
+
// EXATO medido no *assess*: nome visível, conteúdo vazio) NUNCA é despachado ao executor.
|
|
544
|
+
// ===========================================================================
|
|
545
|
+
async function e4bPayloadParcialNaoEDespachado() {
|
|
546
|
+
console.log('\n--- E4b: payload meio-publicado NUNCA é despachado (gate started_by de DF3-3 não é evadível) ---');
|
|
547
|
+
const root = tmpDir('e4b');
|
|
548
|
+
const queueDir = path.join(root, 'queue');
|
|
549
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
550
|
+
const cfg = { queueDir, bundleDir: null, runsRoot: path.join(root, 'runs'), pollMs: 1 };
|
|
551
|
+
// Estado exato do defeito: `open(O_CREAT|O_TRUNC)` tornou o nome visível; o conteúdo ainda não foi
|
|
552
|
+
// escrito. `listPending` JÁ o enxerga.
|
|
553
|
+
const partial = path.join(dirs.pending, 'meio-publicado.json');
|
|
554
|
+
fs.closeSync(fs.openSync(partial, 'w'));
|
|
555
|
+
assert(svc.listPending(queueDir).length === 1, 'um arquivo recém-criado e VAZIO já é visível ao readdir do serviço (a janela existe)');
|
|
556
|
+
|
|
557
|
+
let dispatched = 0;
|
|
558
|
+
const r = await svc.processOne(cfg, partial, { log: () => {}, runExecutor: async () => { dispatched++; return { code: 0, result: { complete: true } }; } });
|
|
559
|
+
assert(dispatched === 0, 'o executor NÃO foi disparado com payload vazio (nenhum gasto de LLM sobre lixo)');
|
|
560
|
+
assert(r.kind === 'failed' && r.rejectedPayload === true, 'o item foi roteado a failed/ com o motivo explícito (fail-loud, não silêncio)');
|
|
561
|
+
const res = readJson(r.resultPath);
|
|
562
|
+
assert(/ILEG[IÍ]VEL|incompleto/i.test(res.error) && res.started_by === null && res.ok === false, 'o result.json registra a causa; nenhum run anônimo foi "concluído"');
|
|
563
|
+
assert(!fs.existsSync(svc.usageLogPath(cfg)), 'o ledger de uso NÃO recebeu registro anônimo (antes: `started_by: null` gravado em silêncio)');
|
|
564
|
+
assert(markersIn(dirs.failed).length === 1 && markersIn(dirs.pending).length === 0, 'o item saiu de pending/ e ficou visível em failed/ para triagem');
|
|
565
|
+
|
|
566
|
+
// Não-regressão: payload legado SEM started_by (drop direto sancionado por DF2-5) continua sendo
|
|
567
|
+
// processado, registrando started_by null — a tolerância que já existia NÃO foi endurecida.
|
|
568
|
+
const legacy = path.join(dirs.pending, 'legado.json');
|
|
569
|
+
fs.writeFileSync(legacy, JSON.stringify({ pod: 'demo' }));
|
|
570
|
+
const r2 = await svc.processOne(cfg, legacy, { log: () => {}, runExecutor: async () => ({ code: 0, result: { complete: true } }) });
|
|
571
|
+
assert(r2.kind === 'done' && r2.claimed === true, 'não-regressão: payload JSON válido sem started_by continua sendo processado (tolerância de DF2-5 preservada)');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ===========================================================================
|
|
575
|
+
// AC4 — lock de instância (`O_EXCL`): recusa acionável, tomada de órfão após SIGKILL, liberação em
|
|
576
|
+
// SIGTERM. Restrição dominante: NENHUM caminho pode imobilizar o serviço permanentemente.
|
|
577
|
+
// ===========================================================================
|
|
578
|
+
function serveChildScript(root) {
|
|
579
|
+
return writeChild(root, 'serve-forever.cjs', `
|
|
580
|
+
const svcmod = require(process.env.SVC);
|
|
581
|
+
const cfg = { queueDir: process.env.QUEUE, bundleDir: null, runsRoot: process.env.RUNS, pollMs: 5 };
|
|
582
|
+
svcmod.serve(cfg, {
|
|
583
|
+
log: (m) => console.log(m), warn: (m) => console.error(m),
|
|
584
|
+
env: { ANTHROPIC_API_KEY: 'x', OPENROUTER_API_KEY: 'y' },
|
|
585
|
+
probeChrome: () => '/usr/bin/chromium',
|
|
586
|
+
stopAfter: Number(process.env.STOP_AFTER || 0) || undefined,
|
|
587
|
+
runExecutor: async () => ({ code: 0, result: { complete: true } }),
|
|
588
|
+
}).then(() => process.exit(0)).catch((e) => { console.error(e.message); process.exit(1); });
|
|
589
|
+
`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function ac4LockDeInstancia() {
|
|
593
|
+
console.log('\n--- AC4: lock O_EXCL de instância única do `serve` (processos REAIS: recusa, SIGTERM, órfão de SIGKILL) ---');
|
|
594
|
+
const root = tmpDir('lock');
|
|
595
|
+
const queueDir = path.join(root, 'queue');
|
|
596
|
+
svc.ensureQueue(queueDir);
|
|
597
|
+
const lockPath = svc.serviceLockPath(queueDir);
|
|
598
|
+
const script = serveChildScript(root);
|
|
599
|
+
const env = { SVC: SVC_PATH, QUEUE: queueDir, RUNS: path.join(root, 'runs') };
|
|
600
|
+
|
|
601
|
+
// (1) 1º serve toma o lock.
|
|
602
|
+
const a = runChild(script, env, { detached: true });
|
|
603
|
+
await waitFor(() => fs.existsSync(lockPath), 10000, 'lock criado pelo 1º serve');
|
|
604
|
+
const held = readJson(lockPath);
|
|
605
|
+
assert(held.pid === a.child.pid && typeof held.startedAt === 'string' && held.host === os.hostname(),
|
|
606
|
+
'o 1º `serve` gravou {pid, host, startedAt} em <queue>/.service.lock');
|
|
607
|
+
|
|
608
|
+
// (2) 2º serve na MESMA fila → RECUSA fail-loud com mensagem acionável.
|
|
609
|
+
const b = await runChild(script, env);
|
|
610
|
+
assert(b.code === 1, 'um SEGUNDO `serve` na mesma fila é RECUSADO (exit != 0) — a invariante deixou de ser pressuposta');
|
|
611
|
+
const msg = b.err() + b.out();
|
|
612
|
+
assert(/FAIL-LOUD/.test(msg) && new RegExp(`pid ${a.child.pid}`).test(msg) && /systemctl stop|remova/.test(msg) && msg.includes(lockPath),
|
|
613
|
+
'a recusa é ACIONÁVEL: cita pid, desde quando, o arquivo e como limpar');
|
|
614
|
+
|
|
615
|
+
// (3) SIGTERM libera o lock (sem isso, todo `systemctl stop` deixaria órfão).
|
|
616
|
+
a.child.kill('SIGTERM');
|
|
617
|
+
await a.done;
|
|
618
|
+
await waitFor(() => !fs.existsSync(lockPath), 10000, 'lock liberado no SIGTERM');
|
|
619
|
+
assert(!fs.existsSync(lockPath), 'SIGTERM (o sinal do `systemctl stop`) LIBERA o lock');
|
|
620
|
+
|
|
621
|
+
// (4) e o serviço sobe de novo normalmente depois disso (nada ficou imobilizado).
|
|
622
|
+
const c = await runChild(script, { ...env, STOP_AFTER: '1' });
|
|
623
|
+
assert(c.code === 0, 'após a liberação, um novo `serve` sobe normalmente');
|
|
624
|
+
assert(!fs.existsSync(lockPath), 'saída limpa também libera o lock');
|
|
625
|
+
|
|
626
|
+
// (5) SIGKILL deixa lock órfão → o serviço seguinte TOMA POSSE com aviso alto (autocura).
|
|
627
|
+
const d = runChild(script, env, { detached: true });
|
|
628
|
+
await waitFor(() => fs.existsSync(lockPath), 10000, 'lock do 3º serve');
|
|
629
|
+
const orphanPid = readJson(lockPath).pid;
|
|
630
|
+
d.child.kill('SIGKILL');
|
|
631
|
+
await d.done;
|
|
632
|
+
assert(fs.existsSync(lockPath), 'SIGKILL deixa o lock para trás (é o caso que a tomada de posse cobre)');
|
|
633
|
+
const e = await runChild(script, { ...env, STOP_AFTER: '1' });
|
|
634
|
+
const emsg = e.err() + e.out();
|
|
635
|
+
assert(e.code === 0, 'após SIGKILL do dono, o serviço SOBE SOZINHO (o lock nunca causa indisponibilidade permanente)');
|
|
636
|
+
assert(/AVISO ALTO/.test(emsg) && /[óo]rf[ãa]o/i.test(emsg) && emsg.includes(String(orphanPid)),
|
|
637
|
+
'a tomada de posse é ANUNCIADA alto (órfão + pid morto), nunca silenciosa');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ===========================================================================
|
|
641
|
+
// LOCK-1 (achado do gate `pc-qa`) — o lock TEM QUE SERIALIZAR ARRANQUES CONCORRENTES.
|
|
642
|
+
// Defeito medido pelo QA: `openSync(lockPath,'wx')` criava o arquivo VAZIO e só depois o preenchia;
|
|
643
|
+
// nessa janela o 2º arranque lia vazio, concluía "órfão/corrompido" e REMOVIA o lock VIVO do
|
|
644
|
+
// vencedor — 2 donos em 20/20 (N=2, órfão) e 4-5 donos em 15/15 (N=6, fresco). É o MESMO defeito que
|
|
645
|
+
// esta story fecha em `pending/` (nome visível antes do conteúdo), não aplicado ao próprio lock.
|
|
646
|
+
// Aqui os DOIS cenários do QA rodam com processos REAIS, no N e nas iterações que ele exigiu.
|
|
647
|
+
// ===========================================================================
|
|
648
|
+
async function lock1ArranquesConcorrentes() {
|
|
649
|
+
console.log('\n--- LOCK-1: arranques REAIS concorrentes → EXATAMENTE 1 dono (cenários do gate) ---');
|
|
650
|
+
const root = tmpDir('lock1');
|
|
651
|
+
const script = writeChild(root, 'lock1-acquire.cjs', `
|
|
652
|
+
const svc = require(process.env.SVC);
|
|
653
|
+
const cfg = { queueDir: process.env.QUEUE };
|
|
654
|
+
barrier();
|
|
655
|
+
// Sem try/catch: adquirir OU recusar fail-loud (a recusa é uma exceção, e sair != 0 é o esperado
|
|
656
|
+
// para quem perde). Quem adquire grava um arquivo PRÓPRIO — a contagem de donos é feita em disco.
|
|
657
|
+
let handle = null;
|
|
658
|
+
try {
|
|
659
|
+
handle = svc.acquireServiceLock(cfg, { log: () => {}, warn: () => {} });
|
|
660
|
+
} catch (e) {
|
|
661
|
+
fs.writeFileSync(path.join(process.env.OUTDIR, 'recusa-' + process.env.IDX), String(e.message).slice(0, 400));
|
|
662
|
+
process.exit(3);
|
|
663
|
+
}
|
|
664
|
+
fs.writeFileSync(path.join(process.env.OUTDIR, 'dono-' + process.env.IDX), JSON.stringify(handle));
|
|
665
|
+
// O vencedor PERMANECE VIVO até todos os concorrentes terem arbitrado (SF10.19, correção de
|
|
666
|
+
// premissa do harness — nenhuma asserção mudou). Sem isto, o vencedor saía em milissegundos e um
|
|
667
|
+
// perdedor lento encontrava o lock com pid MORTO: o produto então toma posse como ÓRFÃO, que é o
|
|
668
|
+
// comportamento ESPECIFICADO (e asserido pelo cenário 1 logo abaixo) — e a contagem via 2 donos
|
|
669
|
+
// sob carga, sem nenhum defeito real. Um serviço de verdade não morre 5 ms depois de subir; a
|
|
670
|
+
// disputa que LOCK-1 mede é entre arranques VIVOS. Espera ADAPTATIVA (custo ~0 quando rápido).
|
|
671
|
+
const deadline = Date.now() + 5000;
|
|
672
|
+
const esperados = Number(process.env.NPEERS || 0);
|
|
673
|
+
while (fs.readdirSync(process.env.OUTDIR).length < esperados && Date.now() < deadline) { /* spin */ }
|
|
674
|
+
process.exit(0);
|
|
675
|
+
`);
|
|
676
|
+
|
|
677
|
+
// --- Cenário 1 do QA: lock ÓRFÃO (pid morto) + N=2, 20 iterações ---
|
|
678
|
+
const dead = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
|
|
679
|
+
let donos1 = []; let recusas1 = [];
|
|
680
|
+
for (let it = 0; it < 20; it++) {
|
|
681
|
+
const queueDir = path.join(root, `orfao-${it}`);
|
|
682
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
683
|
+
const outDir = path.join(queueDir, 'out'); fs.mkdirSync(outDir, { recursive: true });
|
|
684
|
+
// órfão COMPLETO (como o mecanismo passa a publicar): pid comprovadamente morto.
|
|
685
|
+
fs.writeFileSync(svc.serviceLockPath(queueDir), JSON.stringify({ pid: dead.pid, host: os.hostname(), startedAt: '2026-08-01T00:00:00Z' }));
|
|
686
|
+
const START_AT = startAt(220);
|
|
687
|
+
await Promise.all([0, 1].map((i) => runChild(script, { SVC: SVC_PATH, QUEUE: queueDir, OUTDIR: outDir, IDX: String(i), START_AT, NPEERS: '2' })));
|
|
688
|
+
donos1.push(fs.readdirSync(outDir).filter((f) => f.startsWith('dono-')).length);
|
|
689
|
+
recusas1.push(fs.readdirSync(outDir).filter((f) => f.startsWith('recusa-')).length);
|
|
690
|
+
void dirs;
|
|
691
|
+
}
|
|
692
|
+
assert(donos1.every((d) => d === 1), `ÓRFÃO, N=2, 20 iterações → EXATAMENTE 1 dono em todas (distribuição: ${JSON.stringify(donos1)})`);
|
|
693
|
+
assert(recusas1.every((r) => r === 1), 'o perdedor RECUSA fail-loud (nunca dois donos, nunca dois silêncios)');
|
|
694
|
+
|
|
695
|
+
// --- Cenário 2 do QA: arranque FRESCO (sem lock) + N=6, 15 iterações ---
|
|
696
|
+
const N = 6;
|
|
697
|
+
let donos2 = [];
|
|
698
|
+
for (let it = 0; it < 15; it++) {
|
|
699
|
+
const queueDir = path.join(root, `fresco-${it}`);
|
|
700
|
+
svc.ensureQueue(queueDir);
|
|
701
|
+
const outDir = path.join(queueDir, 'out'); fs.mkdirSync(outDir, { recursive: true });
|
|
702
|
+
const START_AT = startAt(260);
|
|
703
|
+
await Promise.all(Array.from({ length: N }, (_, i) => runChild(script, { SVC: SVC_PATH, QUEUE: queueDir, OUTDIR: outDir, IDX: String(i), START_AT, NPEERS: String(N) })));
|
|
704
|
+
donos2.push(fs.readdirSync(outDir).filter((f) => f.startsWith('dono-')).length);
|
|
705
|
+
}
|
|
706
|
+
assert(donos2.every((d) => d === 1), `FRESCO, N=${N}, 15 iterações → EXATAMENTE 1 dono em todas (distribuição: ${JSON.stringify(donos2)})`);
|
|
707
|
+
|
|
708
|
+
// A causa-raiz, em teste direto: o arquivo de lock NUNCA existe vazio/incompleto.
|
|
709
|
+
const q = path.join(root, 'atomicidade');
|
|
710
|
+
const d = svc.ensureQueue(q);
|
|
711
|
+
const h = svc.acquireServiceLock({ queueDir: q }, { log: () => {}, warn: () => {} });
|
|
712
|
+
const conteudo = fs.readFileSync(svc.serviceLockPath(q), 'utf8');
|
|
713
|
+
assert(conteudo.length > 0 && JSON.parse(conteudo).pid === process.pid,
|
|
714
|
+
'o lock é publicado COMPLETO (tmp + link), nunca criado vazio para preencher depois');
|
|
715
|
+
assert(fs.readdirSync(d.tmp).length === 0, 'a publicação do lock não deixa lixo em .tmp/');
|
|
716
|
+
svc.releaseServiceLock(h, { warn: () => {} });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function ac4ArbitragemUnitaria() {
|
|
720
|
+
console.log('\n--- AC4: arbitragem do lock (ilegível, host estrangeiro, EPERM=vivo, ESRCH=morto) ---');
|
|
721
|
+
const queueDir = tmpDir('arb');
|
|
722
|
+
svc.ensureQueue(queueDir);
|
|
723
|
+
const cfg = { queueDir };
|
|
724
|
+
const lockPath = svc.serviceLockPath(queueDir);
|
|
725
|
+
const quiet = { log: () => {}, warn: () => {} };
|
|
726
|
+
|
|
727
|
+
// Liveness: EPERM (pid 1, sem permissão de sinalizar) conta como VIVO; pid morto → ESRCH → falso.
|
|
728
|
+
const dead = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
|
|
729
|
+
assert(svc.pidAlive(process.pid) === true, 'pidAlive: o próprio processo está VIVO');
|
|
730
|
+
assert(svc.pidAlive(dead.pid) === false, 'pidAlive: pid comprovadamente morto (ESRCH) → morto');
|
|
731
|
+
assert(svc.pidAlive(1) === true, 'pidAlive: pid 1 (EPERM para não-root) conta como VIVO — EPERM NUNCA é lido como morto');
|
|
732
|
+
|
|
733
|
+
// (a) lock ilegível → assume órfão, avisa alto e TOMA POSSE (jamais imobiliza).
|
|
734
|
+
fs.writeFileSync(lockPath, 'lixo{corrompido');
|
|
735
|
+
const warns = [];
|
|
736
|
+
const h1 = svc.acquireServiceLock(cfg, { log: () => {}, warn: (m) => warns.push(m) });
|
|
737
|
+
assert(h1 && readJson(lockPath).pid === process.pid, 'lock ILEGÍVEL → tomada de posse (um lock corrompido não pode travar o serviço)');
|
|
738
|
+
assert(warns.some((w) => /AVISO ALTO/.test(w) && /ileg[ií]vel|corrompido/i.test(w)), 'a tomada de posse de lock ilegível é avisada ALTO');
|
|
739
|
+
assert(svc.releaseServiceLock(h1, quiet) === true && !fs.existsSync(lockPath), 'releaseServiceLock remove o lock do próprio dono');
|
|
740
|
+
|
|
741
|
+
// (b) host diferente → RECUSA fail-loud (a fila é local por contrato).
|
|
742
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, host: 'outro-host', startedAt: 'x' }));
|
|
743
|
+
const m = messageOf(() => svc.acquireServiceLock(cfg, quiet));
|
|
744
|
+
assert(/FAIL-LOUD/.test(m) && /outro-host/.test(m), 'lock de OUTRO HOST → recusa fail-loud citando o host');
|
|
745
|
+
|
|
746
|
+
// (c) pid VIVO no mesmo host → RECUSA acionável.
|
|
747
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: '2026-08-01T00:00:00Z' }));
|
|
748
|
+
const m2 = messageOf(() => svc.acquireServiceLock(cfg, quiet));
|
|
749
|
+
assert(/FAIL-LOUD/.test(m2) && new RegExp(`pid ${process.pid}`).test(m2) && /systemctl stop/.test(m2), 'pid VIVO → recusa com pid, desde quando e como limpar');
|
|
750
|
+
|
|
751
|
+
// (d) pid MORTO no mesmo host → toma posse com aviso.
|
|
752
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: dead.pid, host: os.hostname(), startedAt: '2026-08-01T00:00:00Z' }));
|
|
753
|
+
const warns2 = [];
|
|
754
|
+
const h2 = svc.acquireServiceLock(cfg, { log: () => {}, warn: (m3) => warns2.push(m3) });
|
|
755
|
+
assert(h2 && readJson(lockPath).pid === process.pid && warns2.some((w) => /AVISO ALTO/.test(w)), 'pid MORTO (ESRCH) → tomada de posse anunciada');
|
|
756
|
+
svc.releaseServiceLock(h2, quiet);
|
|
757
|
+
|
|
758
|
+
// (e) release NÃO remove lock de outro dono (nunca desarma o sucessor).
|
|
759
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: 999999, host: os.hostname(), startedAt: 'x' }));
|
|
760
|
+
assert(svc.releaseServiceLock({ path: lockPath, pid: process.pid }, quiet) === false && fs.existsSync(lockPath),
|
|
761
|
+
'release NÃO remove um lock que pertence a outro pid (não desarma o sucessor)');
|
|
762
|
+
fs.unlinkSync(lockPath);
|
|
763
|
+
|
|
764
|
+
// (f) a UI NÃO toma lock (ela não reivindica pending/; duas UIs já colidem na porta).
|
|
765
|
+
const uiFns = Object.keys(ui);
|
|
766
|
+
assert(!uiFns.some((k) => /lock/i.test(k)), 'a UI não expõe nenhum mecanismo de lock (DF-SF10.16-3: só o `serve` assere instância única)');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ===========================================================================
|
|
770
|
+
// AC5 — órfãos de processing/ são RELATADOS no arranque; requeue automático é PROIBIDO.
|
|
771
|
+
// ===========================================================================
|
|
772
|
+
async function ac5OrfaosRelatadosSemRequeue() {
|
|
773
|
+
console.log('\n--- AC5: órfãos de processing/ relatados no arranque, SEM requeue automático ---');
|
|
774
|
+
const root = tmpDir('orfaos');
|
|
775
|
+
const queueDir = path.join(root, 'queue');
|
|
776
|
+
const dirs = svc.ensureQueue(queueDir);
|
|
777
|
+
fs.writeFileSync(path.join(dirs.processing, 'pex-orfao-1.json'), JSON.stringify({ pod: 'hn' }));
|
|
778
|
+
fs.writeFileSync(path.join(dirs.processing, 'pex-orfao-2.json'), JSON.stringify({ pod: 'hn' }));
|
|
779
|
+
fs.writeFileSync(path.join(dirs.processing, 'pex-orfao-1.result.json'), JSON.stringify({ runId: 'pex-orfao-1' }));
|
|
780
|
+
|
|
781
|
+
const warns = [];
|
|
782
|
+
const orphans = svc.reportProcessingOrphans({ queueDir }, { warn: (m) => warns.push(m) });
|
|
783
|
+
assert(orphans.length === 2 && orphans.every((o) => typeof o.ageMs === 'number'), 'os 2 marcadores órfãos são listados com idade (o `.result.json` companion não conta como item)');
|
|
784
|
+
assert(warns.some((w) => /AVISO ALTO/.test(w) && /REQUEUE AUTOM[ÁA]TICO N[ÃA]O/.test(w)), 'o relato é ALTO e diz explicitamente que não houve requeue');
|
|
785
|
+
assert(markersIn(dirs.processing).length === 2 && svc.listPending(queueDir).length === 0,
|
|
786
|
+
'PROIBIDO requeue automático: os itens continuam em processing/ e pending/ segue vazio (o run pode ter gasto LLM e escrito runDir parcial)');
|
|
787
|
+
|
|
788
|
+
// No arranque REAL do serviço (processo próprio), o mesmo relato aparece no log e nada se move.
|
|
789
|
+
const script = serveChildScript(root);
|
|
790
|
+
const r = await runChild(script, { SVC: SVC_PATH, QUEUE: queueDir, RUNS: path.join(root, 'runs'), STOP_AFTER: '1' });
|
|
791
|
+
assert(r.code === 0, 'o serviço SOBE normalmente com órfãos em processing/ (relato não bloqueia o arranque)');
|
|
792
|
+
assert(/AVISO ALTO/.test(r.err()) && /processing\//.test(r.err()), 'o arranque real LOGA os órfãos (antes: silêncio absoluto — nem reprocessava nem avisava)');
|
|
793
|
+
assert(markersIn(dirs.processing).length === 2, 'após o arranque real, os órfãos continuam onde estavam (triagem é ação de operador)');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ===========================================================================
|
|
797
|
+
// §4b do gate — `ENOENT` é o ÚNICO errno absorvido: nada de catch-all disfarçado.
|
|
798
|
+
// ===========================================================================
|
|
799
|
+
function ac2SancaoPorErrno() {
|
|
800
|
+
console.log('\n--- AC2/§4b: só ENOENT é absorvido no rename de reivindicação; todo o resto falha ALTO ---');
|
|
801
|
+
const root = tmpDir('errno');
|
|
802
|
+
const src = path.join(root, 'src.json');
|
|
803
|
+
fs.writeFileSync(src, '{}');
|
|
804
|
+
|
|
805
|
+
assert(svc.claimByRename(path.join(root, 'nao-existe.json'), path.join(root, 'dest.json')) === false,
|
|
806
|
+
'ENOENT (item já reivindicado por outro ator) → false, sem lançar — o caminho normal da disputa');
|
|
807
|
+
assert(svc.claimByRename(src, path.join(root, 'dest.json')) === true && fs.existsSync(path.join(root, 'dest.json')),
|
|
808
|
+
'rename bem-sucedido → true (a reivindicação É a autorização de agir)');
|
|
809
|
+
|
|
810
|
+
// ENOTDIR: destino cujo "diretório" pai é um ARQUIVO. Determinístico e independente de uid/SO.
|
|
811
|
+
const file = path.join(root, 'sou-um-arquivo');
|
|
812
|
+
fs.writeFileSync(file, 'x');
|
|
813
|
+
const src2 = path.join(root, 'src2.json'); fs.writeFileSync(src2, '{}');
|
|
814
|
+
let err = null;
|
|
815
|
+
try { svc.claimByRename(src2, path.join(file, 'dest.json')); } catch (e) { err = e; }
|
|
816
|
+
assert(err && err.code === 'ENOTDIR', `errno ENOTDIR RELANÇADO (${err && err.code}) — nunca engolido junto com o ENOENT`);
|
|
817
|
+
assert(fs.existsSync(src2), 'o item permanece intocado quando o rename falha por errno não sancionado');
|
|
818
|
+
|
|
819
|
+
// EACCES: diretório de destino sem permissão de escrita (root ignora modos — pulado nesse caso).
|
|
820
|
+
if (!IS_ROOT) {
|
|
821
|
+
const locked = path.join(root, 'sem-permissao');
|
|
822
|
+
fs.mkdirSync(locked);
|
|
823
|
+
fs.chmodSync(locked, 0o500);
|
|
824
|
+
let err2 = null;
|
|
825
|
+
try { svc.claimByRename(src2, path.join(locked, 'dest.json')); } catch (e) { err2 = e; }
|
|
826
|
+
assert(err2 && (err2.code === 'EACCES' || err2.code === 'EPERM'), `errno ${err2 && err2.code} (permissão) RELANÇADO — fail-loud preservado`);
|
|
827
|
+
fs.chmodSync(locked, 0o700);
|
|
828
|
+
} else {
|
|
829
|
+
console.log('[INFO ] EACCES pulado: rodando como root (modos de permissão não se aplicam); ENOTDIR já cobre o relançamento.');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// AC1 — nenhum `existsSync`+ação sobreviveu nos TRÊS pontos de mutação (o padrão TOCTOU que a
|
|
833
|
+
// story existe para eliminar). Guarda de regressão sobre o código de produção.
|
|
834
|
+
const svcSrc = fs.readFileSync(SVC_PATH, 'utf8');
|
|
835
|
+
const uiSrc = fs.readFileSync(UI_PATH, 'utf8');
|
|
836
|
+
assert(!/if \(fs\.existsSync\(p\)\) processed\.push/.test(svcSrc), 'o loop `serve` não reconfere `existsSync` antes do pickup (a única autorização é o rename)');
|
|
837
|
+
const rejectBody = uiSrc.slice(uiSrc.indexOf('function rejectRun'), uiSrc.indexOf('// HTTP helpers'));
|
|
838
|
+
assert(/svc\.claimByRename\(src,/.test(rejectBody) && !/existsSync\(src\)/.test(rejectBody), '`rejectRun` reivindica por rename e não consulta `existsSync` do marcador');
|
|
839
|
+
const approveBody = svcSrc.slice(svcSrc.indexOf('function approveRun'), svcSrc.indexOf('// Loop de serviço'));
|
|
840
|
+
assert(approveBody.indexOf('authorizeApproval') < approveBody.indexOf('claimApproveMarker')
|
|
841
|
+
&& approveBody.indexOf('claimApproveMarker') < approveBody.indexOf('spawnFn('),
|
|
842
|
+
'em `approveRun` a ordem é AUTORIZAR → reivindicar → spawn (a reivindicação nunca precede a decisão de auth)');
|
|
843
|
+
// §4b — nenhum catch cego (`catch {}` / `catch (e) {}` vazio) no caminho da fila.
|
|
844
|
+
assert(!/catch\s*(\([^)]*\))?\s*\{\s*\}/.test(svcSrc) && !/catch\s*(\([^)]*\))?\s*\{\s*\}/.test(uiSrc),
|
|
845
|
+
'nenhum `catch {}` cego (sem corpo) sobrevive no serviço nem na UI');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
(async () => {
|
|
850
|
+
await e1PickupDisputado();
|
|
851
|
+
await e1bServeSegueServindo();
|
|
852
|
+
await e2RejectDisputado();
|
|
853
|
+
await e2bUiRealNaoMorre();
|
|
854
|
+
await e3ApproveDisputado();
|
|
855
|
+
await e3bGateDeAutorizacaoIntacto();
|
|
856
|
+
await e4PublicacaoAtomica();
|
|
857
|
+
await e4bPayloadParcialNaoEDespachado();
|
|
858
|
+
await ac4LockDeInstancia();
|
|
859
|
+
await lock1ArranquesConcorrentes();
|
|
860
|
+
ac4ArbitragemUnitaria();
|
|
861
|
+
await ac5OrfaosRelatadosSemRequeue();
|
|
862
|
+
ac2SancaoPorErrno();
|
|
863
|
+
console.log(`\n${failures === 0 ? '[SUCESSO]' : '[FALHOU]'} pod-export fila cross-processo (SF10.16): ${failures} falha(s).`);
|
|
864
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
865
|
+
})().catch((e) => { console.error('[ERRO NO TESTE]', e); process.exit(1); });
|