@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,1297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* pod-export-service.cjs — SERVIÇO-IRMÃO de longa duração do épico pod-export (F2, Story SF10.3).
|
|
5
|
+
*
|
|
6
|
+
* Processo systemd PRÓPRIO na VPS (fora da instância Next.js do `second-brain` — isola o blast
|
|
7
|
+
* radius, ADR-018 premissa travada linha 24). NÃO reimplementa nada de SF10.2: hospeda o executor
|
|
8
|
+
* (`pod-export-executor.cjs`) por spawn e reusa o motor (`pc-run-pod.cjs`) por import (frontier
|
|
9
|
+
* command_check). Sem superfície de rede: o trigger é um CONTRATO DE DIRETÓRIO (DF2-5).
|
|
10
|
+
*
|
|
11
|
+
* Topologia (a) / DF2-1: a VPS deploya o framework NA VERSÃO pinada do lock; o motor resolve os
|
|
12
|
+
* blessed do PRÓPRIO checkout (`VETO_CHECKS_ROOT` ancorado em `__dirname`, INTOCADO). A fronteira
|
|
13
|
+
* `command_check` sai de graça — é o MESMO code-path de produção, não uma reprodução manual.
|
|
14
|
+
*
|
|
15
|
+
* Fila (DF2-5, contrato de diretório):
|
|
16
|
+
* <queue>/pending/<id>.json → payload de input dropado por um escritor de trigger (CLI/F3)
|
|
17
|
+
* <queue>/processing/<runId>.json → o serviço moveu para cá e disparou o executor
|
|
18
|
+
* <queue>/done/<runId>.json → PIPELINE COMPLETO (todas as fases passaram) — sucesso real.
|
|
19
|
+
* <queue>/awaiting/<runId>.json → o run PAUSOU num checkpoint humano (result.awaiting) e saiu
|
|
20
|
+
* exit 0. NÃO concluiu: é RETOMÁVEL. Sai de awaiting/ quando o
|
|
21
|
+
* operador aprova (`approve <runId>` reconcilia o marcador para
|
|
22
|
+
* o estado REAL do run). Achado 5 do smoke real VPS: rotear a
|
|
23
|
+
* done/ era errado (o run vai continuar após o approve).
|
|
24
|
+
* <queue>/blocked/<runId>.json → o executor esgotou o veto numa fase (result.blocked) e saiu
|
|
25
|
+
* exit 0 (bloqueio é resultado NORMAL, não crash). NÃO é sucesso:
|
|
26
|
+
* roteado para cá, NUNCA para done/ (achado 4 do smoke real —
|
|
27
|
+
* ver runbook §"roteamento por RESULTADO"). Triagem: qualidade,
|
|
28
|
+
* não infra.
|
|
29
|
+
* <queue>/failed/<runId>.json → executor CRASHOU (exit != 0) — falha de infra/erro real.
|
|
30
|
+
* <queue>/.tmp/ → área de publicação atômica do `enqueue` (tmp + rename; DF-SF10.16-2.3).
|
|
31
|
+
* <queue>/.service.lock → lock O_EXCL de instância única do loop `serve` (DF-SF10.16-3.1).
|
|
32
|
+
* approve = subcomando do PRÓPRIO serviço (`node scripts/pod-export-service.cjs approve <runId>`):
|
|
33
|
+
* lê PODEXPORT_RUNS_DIR/BUNDLE_DIR da config e invoca o executor com --runs-root correto
|
|
34
|
+
* (achado 6 — o approve DIRETO do executor exige a flag ou dá EACCES no dir default).
|
|
35
|
+
*
|
|
36
|
+
* Roteamento por RESULTADO (achados 4+5 do smoke real): o veredito NÃO é só o exit code. O executor
|
|
37
|
+
* imprime `RESULTADO: {...}` (`{complete:true}` | `{blocked:"<fase>"}` | `{awaiting:"<fase>"}`) e
|
|
38
|
+
* sai exit 0 nos três — bloqueio de veto e checkpoint são resultados normais, não crash. O serviço
|
|
39
|
+
* CAPTURA essa saída (parseExecutorResult) e roteia (classifyOutcome, precedência crash > awaiting/
|
|
40
|
+
* blocked > done): crash(exit!=0)→failed/, awaiting→awaiting/, blocked→blocked/, resto→done/.
|
|
41
|
+
* Overloadar done/ com runs bloqueados/pausados era um verde-falso semântico.
|
|
42
|
+
*
|
|
43
|
+
* Preflight de ARRANQUE (fail-loud, ADR-015 anti-autoatestado — o serviço RECUSA subir se a
|
|
44
|
+
* fronteira não estiver íntegra, nunca sobe "verde falso"):
|
|
45
|
+
* 1. chaves da ORG do env da VPS (getAnthropicApiKey/OpenRouter) — ausência FALHA ALTO (AC2).
|
|
46
|
+
* 2. Chrome headless presente (probeChrome do render-gate; fail-loud, ADR-011 D3, herdado) (AC4).
|
|
47
|
+
* 3. coerência framework_version motor == lock do bundle (assertFrameworkCoherence de SF10.2,
|
|
48
|
+
* FIADA no arranque do serviço — não só no 1º run) (AC3/DF2-1).
|
|
49
|
+
* 4. self-check da fronteira command_check: prova em-deployment que blessed é aceito e
|
|
50
|
+
* não-blessed é RECUSADO no MESMO code-path que os runs usarão (defesa em profundidade) (AC3).
|
|
51
|
+
* 5. manifesto blessed (installer/motor-scripts.js) DISPONÍVEL e não-vazio — sem ele o Guard B do
|
|
52
|
+
* motor degrada MUDO e um .cjs plantado sob scripts/veto-checks/ passaria pelo nome
|
|
53
|
+
* (NOTE-1 do gate de SF10.3; Story SF10.9/AC1, aferido dentro de assertCommandCheckFrontier).
|
|
54
|
+
*
|
|
55
|
+
* CONFIG por ENV (a instância VPS-específica vive no `second-brain`; aqui é genérico/parametrizável):
|
|
56
|
+
* PODEXPORT_BUNDLE_DIR (obrigatório p/ start) — dir do bundle compilado por F1.
|
|
57
|
+
* PODEXPORT_QUEUE_DIR (obrigatório p/ start) — raiz da fila (pending/processing/done/failed).
|
|
58
|
+
* PODEXPORT_RUNS_DIR (default <repo>/.power-claude/pod-export-runs) — runs root do executor.
|
|
59
|
+
* PODEXPORT_LOCK (opcional) — override do export.lock.json.
|
|
60
|
+
* PODEXPORT_POLL_MS (default 3000) — intervalo de poll da fila.
|
|
61
|
+
* ANTHROPIC_API_KEY / OPENROUTER_API_KEY — chaves da ORG (nunca por request, nunca logadas).
|
|
62
|
+
* CHROME_BIN (opcional) — honrado estritamente por render-gate.
|
|
63
|
+
*
|
|
64
|
+
* Node >= 18, zero dep externa. Rede/relógio/executor INJETÁVEIS → suíte de CI hermética.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
const fs = require('node:fs');
|
|
68
|
+
const os = require('node:os');
|
|
69
|
+
const path = require('node:path');
|
|
70
|
+
const crypto = require('node:crypto');
|
|
71
|
+
const { spawn } = require('node:child_process');
|
|
72
|
+
|
|
73
|
+
const motor = require('./pc-run-pod.cjs'); // INTOCADO — frontier por import (DF2-1).
|
|
74
|
+
const render = require('./veto-checks/render-gate.cjs'); // probeChrome (fail-loud, ADR-011 D3).
|
|
75
|
+
const executor = require('./pod-export-executor.cjs'); // assertFrameworkCoherence (AC16 de SF10.2).
|
|
76
|
+
|
|
77
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
78
|
+
const EXECUTOR_PATH = path.join(__dirname, 'pod-export-executor.cjs');
|
|
79
|
+
const MOTOR_SCRIPTS_JS = path.join(REPO_ROOT, 'installer', 'motor-scripts.js');
|
|
80
|
+
const DEFAULT_POLL_MS = 3000;
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Identificador de run (SF10.9/AC5; estreitado por SF10.15/AC1) — FONTE ÚNICA da regra, no BACKEND.
|
|
84
|
+
//
|
|
85
|
+
// A UI (SF10.5) já validava `runId` em rejectRun/runDirFor com esta mesma regra, mas o BACKEND
|
|
86
|
+
// (`approveRun`) não validava nada: o valor chegava CRU do form HTTP (`form.run`) até
|
|
87
|
+
// `buildApproveArgs`/`spawn`. A regra passa a viver AQUI (o gate real, DF3-3: a UI nunca é a única
|
|
88
|
+
// barreira) e a UI a reusa — um único predicado, sem duas cópias que podem divergir.
|
|
89
|
+
//
|
|
90
|
+
// SF10.15 (FIND-1 do gate de SF10.9, `docs/qa/gates/sf10.9.yaml`): o alfabeto aceitava `-` em
|
|
91
|
+
// QUALQUER posição — inclusive a PRIMEIRA. Um runId em forma de FLAG (`--runs-root`, `--input`,
|
|
92
|
+
// `--lock`, `--kill-during`, `--help`, `-rf`) passava o predicado e chegava ao `spawn` como argv,
|
|
93
|
+
// onde o `parseArgs` do executor (`pod-export-executor.cjs` L342-353, casamento por string EXATA)
|
|
94
|
+
// o lia como FLAG e deslocava o parsing (reproduzido pela rota real `POST /approve`). O 1º
|
|
95
|
+
// caractere passa a EXIGIR `[A-Za-z0-9._]`; o `-` segue aceito nas demais posições (todo runId
|
|
96
|
+
// gerado por `genRunId` é `pex-<slug>-<stamp>` — nunca começa por `-`). O fix vive no PRODUTOR do
|
|
97
|
+
// argv; `parseArgs` (o consumidor) NÃO é tocado.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const RUNID_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* True se `id` é um runId aceitável: alfabeto `[A-Za-z0-9._-]`, PRIMEIRO caractere nunca `-`
|
|
104
|
+
* (nada em forma de flag chega ao argv do executor), sem separador de path, sem `..`, sem NUL,
|
|
105
|
+
* tamanho sane (1-255).
|
|
106
|
+
*/
|
|
107
|
+
function safeRunId(id) {
|
|
108
|
+
return typeof id === 'string' && id.length > 0 && id.length < 256 && RUNID_RE.test(id) && !id.includes('..');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// (c) Chaves da ORG do env da VPS — padrão getAnthropicApiKey (fail-loud, nunca por request).
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Padrão `getAnthropicApiKey` da plataforma (achado concreto, second-brain/app): lê SÓ de
|
|
117
|
+
* `process.env` (env da VPS/systemd), lança fail-loud se ausente. NUNCA hardcoded, NUNCA por
|
|
118
|
+
* usuário/client/request. O valor NÃO é logado nem devolvido — só um booleano de presença.
|
|
119
|
+
*/
|
|
120
|
+
function getApiKey(name, envObj = process.env) {
|
|
121
|
+
const v = envObj[name];
|
|
122
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[FAIL-LOUD] ${name} ausente no env do serviço (VPS). Chaves da ORG são lidas do env da ` +
|
|
125
|
+
`VPS (padrão getAnthropicApiKey) — nunca hardcoded, nunca por request. Defina no unit systemd ` +
|
|
126
|
+
`(EnvironmentFile) e reinicie. O serviço RECUSA subir sem ela.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return v;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getAnthropicApiKey(envObj = process.env) { return getApiKey('ANTHROPIC_API_KEY', envObj); }
|
|
133
|
+
function getOpenRouterApiKey(envObj = process.env) { return getApiKey('OPENROUTER_API_KEY', envObj); }
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// F3 — MODELO DE IDENTIDADE (Story SF10.4; ADR-018 Adendo F3, DF3-1..DF3-5).
|
|
137
|
+
//
|
|
138
|
+
// DOIS níveis de confiança, SEPARADOS por design (DF3-3):
|
|
139
|
+
// - ATRIBUIÇÃO (`started_by`): SELEÇÃO honesta, barreira FRACA. Validada contra a lista de
|
|
140
|
+
// identidades conhecidas (não é campo livre — rastreabilidade real numa equipe cooperante),
|
|
141
|
+
// persistida e consultável. NUNCA prova de identidade — o código não finge que seleção é auth.
|
|
142
|
+
// - AUTORIZAÇÃO (`approve`): SEGREDO do aprovador, verificado no BACKEND (scrypt + timingSafeEqual
|
|
143
|
+
// vs hash em env). Selecionar "Daniel" NÃO aprova; aprovar exige provar posse do segredo.
|
|
144
|
+
//
|
|
145
|
+
// Split genérico/instância (DF3-5): o MECANISMO (schema, loader fail-loud, enforcement) vive aqui,
|
|
146
|
+
// versionado. A INSTÂNCIA (nomes reais dos 5-20, quem é `is_approver`, o HASH do segredo) é dado de
|
|
147
|
+
// operador na VPS — arquivo apontado por `PODEXPORT_IDENTITIES` (chmod 600), FORA do commit. O CLARO
|
|
148
|
+
// do segredo NUNCA é residente/logado: só o HASH mora no env; o claro é fornecido por-ação e
|
|
149
|
+
// verificado transientemente. Zero-dep (só `node:crypto`), coerente com a doutrina do framework.
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const SCRYPT_KEYLEN = 64;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Carrega e VALIDA a config de identidades (fail-loud, DF3-5). Fonte: arquivo apontado por
|
|
156
|
+
* `opts.file` (ou `PODEXPORT_IDENTITIES` no env). Invariantes impostos NA CARGA (config inválida
|
|
157
|
+
* → recusa, nunca "verde falso"):
|
|
158
|
+
* - env/arquivo ausente ou ilegível → recusa;
|
|
159
|
+
* - JSON inválido, `identities` não-array, lista vazia → recusa;
|
|
160
|
+
* - `id` ausente/vazio/não-string, ou duplicado → recusa;
|
|
161
|
+
* - EXATAMENTE 1 `is_approver: true` (zero ou >1 → recusa) — o aprovador designado único é invariante.
|
|
162
|
+
* Retorna `{ identities, approver, byId }`. Nunca lê/expõe segredo aqui (segredo é do `approve`).
|
|
163
|
+
*/
|
|
164
|
+
function loadIdentities(opts = {}) {
|
|
165
|
+
const env = opts.env || process.env;
|
|
166
|
+
const file = opts.file || env.PODEXPORT_IDENTITIES;
|
|
167
|
+
if (!file || String(file).trim() === '') {
|
|
168
|
+
throw new Error(
|
|
169
|
+
'[FAIL-LOUD] PODEXPORT_IDENTITIES ausente no env — a lista de identidades conhecidas é dado de ' +
|
|
170
|
+
'operador na VPS (arquivo JSON, chmod 600, fora do commit). Sem ela não há started_by válido nem ' +
|
|
171
|
+
'aprovador designado. Defina no unit systemd (EnvironmentFile). Ver runbook §identidade.',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
let raw;
|
|
175
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
176
|
+
catch (e) { throw new Error(`[FAIL-LOUD] config de identidades ilegível (${file}): ${e.code || e.message}. O serviço RECUSA operar sem uma lista de identidades válida.`); }
|
|
177
|
+
let parsed;
|
|
178
|
+
try { parsed = JSON.parse(raw); }
|
|
179
|
+
catch { throw new Error(`[FAIL-LOUD] config de identidades não é JSON válido (${file}).`); }
|
|
180
|
+
const list = parsed && Array.isArray(parsed.identities) ? parsed.identities : null;
|
|
181
|
+
if (!list || list.length === 0) {
|
|
182
|
+
throw new Error(`[FAIL-LOUD] config de identidades vazia ou sem array "identities" (${file}) — nunca operar sem operadores conhecidos.`);
|
|
183
|
+
}
|
|
184
|
+
const byId = Object.create(null);
|
|
185
|
+
let approver = null;
|
|
186
|
+
for (const it of list) {
|
|
187
|
+
if (!it || typeof it.id !== 'string' || it.id.trim() === '') {
|
|
188
|
+
throw new Error(`[FAIL-LOUD] identidade sem "id" (string não-vazia) na config (${file}).`);
|
|
189
|
+
}
|
|
190
|
+
if (Object.prototype.hasOwnProperty.call(byId, it.id)) {
|
|
191
|
+
throw new Error(`[FAIL-LOUD] "id" duplicado na config de identidades: "${it.id}" (${file}).`);
|
|
192
|
+
}
|
|
193
|
+
byId[it.id] = it;
|
|
194
|
+
if (it.is_approver === true) {
|
|
195
|
+
if (approver) {
|
|
196
|
+
throw new Error(`[FAIL-LOUD] mais de uma identidade com is_approver:true na config (${file}) — o aprovador designado deve ser ÚNICO (premissa travada "só Daniel aprova").`);
|
|
197
|
+
}
|
|
198
|
+
approver = it;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!approver) {
|
|
202
|
+
throw new Error(`[FAIL-LOUD] nenhuma identidade com is_approver:true na config (${file}) — exatamente 1 aprovador designado é invariante.`);
|
|
203
|
+
}
|
|
204
|
+
return { identities: list, approver, byId };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** True se `id` é uma identidade conhecida (existe na config). Seleção honesta, não autenticação. */
|
|
208
|
+
function isKnownIdentity(identityModel, id) {
|
|
209
|
+
return typeof id === 'string' && Object.prototype.hasOwnProperty.call(identityModel.byId, id);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Deriva o hash do segredo do aprovador via `scrypt` (KDF nativo, zero-dep). Formato armazenado:
|
|
214
|
+
* `scrypt$<saltHex>$<keyHex>`. Usado pelo subcomando `hash-secret` (o operador gera o hash a partir
|
|
215
|
+
* de PODEXPORT_APPROVER_SECRET e o coloca em PODEXPORT_APPROVER_SECRET_HASH). O CLARO nunca é
|
|
216
|
+
* devolvido nem logado — só o hash (que é inútil ao atacante isolado).
|
|
217
|
+
*/
|
|
218
|
+
function hashApproverSecret(secret, saltHex) {
|
|
219
|
+
const salt = saltHex ? Buffer.from(saltHex, 'hex') : crypto.randomBytes(16);
|
|
220
|
+
const key = crypto.scryptSync(String(secret), salt, SCRYPT_KEYLEN);
|
|
221
|
+
return `scrypt$${salt.toString('hex')}$${key.toString('hex')}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Verifica o segredo fornecido contra o hash armazenado, em tempo CONSTANTE (crypto.timingSafeEqual
|
|
226
|
+
* sobre buffers de mesmo comprimento — nunca compara strings com `==`, que vaza timing). Qualquer
|
|
227
|
+
* entrada malformada (segredo vazio, hash sem o formato scrypt$salt$key) → false, sem lançar. Não
|
|
228
|
+
* loga nada. Retorna só booleano.
|
|
229
|
+
*/
|
|
230
|
+
function verifyApproverSecret(provided, stored) {
|
|
231
|
+
if (typeof provided !== 'string' || provided === '' || typeof stored !== 'string') return false;
|
|
232
|
+
const parts = stored.split('$');
|
|
233
|
+
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
|
234
|
+
let salt; let expected;
|
|
235
|
+
try { salt = Buffer.from(parts[1], 'hex'); expected = Buffer.from(parts[2], 'hex'); }
|
|
236
|
+
catch { return false; }
|
|
237
|
+
if (expected.length === 0) return false;
|
|
238
|
+
let derived;
|
|
239
|
+
try { derived = crypto.scryptSync(provided, salt, expected.length); }
|
|
240
|
+
catch { return false; }
|
|
241
|
+
if (derived.length !== expected.length) return false;
|
|
242
|
+
return crypto.timingSafeEqual(derived, expected);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* O GATE crítico de SF10.4 (DF3-3): SELEÇÃO ≠ AUTORIZAÇÃO. Decide se um `approve` é autorizado.
|
|
247
|
+
* Aceita SOMENTE quando (1) `asId` é a identidade designada aprovadora (`is_approver:true` na config
|
|
248
|
+
* — por flag, nunca por igualdade de string solta) E (2) o invocador prova posse do segredo do
|
|
249
|
+
* aprovador (`scrypt`+`timingSafeEqual` vs o hash em env). Recusa fail-loud e com RAZÃO (nunca o
|
|
250
|
+
* segredo) para: aprovador não configurado, `asId` ausente/vazio, identidade desconhecida,
|
|
251
|
+
* identidade não-aprovadora, segredo ausente, segredo inválido. Função PURA e hermética (sem env,
|
|
252
|
+
* sem spawn) — o alvo do teste adversarial do QA. Chamar o backend direto com identidade/segredo
|
|
253
|
+
* errado passa por AQUI e é RECUSADO.
|
|
254
|
+
*/
|
|
255
|
+
function authorizeApproval({ identityModel, asId, providedSecret, secretHash } = {}) {
|
|
256
|
+
if (typeof secretHash !== 'string' || secretHash.trim() === '') {
|
|
257
|
+
return { ok: false, reason: 'aprovador não configurado (PODEXPORT_APPROVER_SECRET_HASH ausente no env)' };
|
|
258
|
+
}
|
|
259
|
+
if (typeof asId !== 'string' || asId.trim() === '') {
|
|
260
|
+
return { ok: false, reason: 'identidade de aprovação ausente — approve exige --as <id>' };
|
|
261
|
+
}
|
|
262
|
+
const ident = identityModel && identityModel.byId ? identityModel.byId[asId] : null;
|
|
263
|
+
if (!ident) {
|
|
264
|
+
return { ok: false, reason: `identidade desconhecida: "${asId}" (fora da lista de identidades)` };
|
|
265
|
+
}
|
|
266
|
+
if (ident.is_approver !== true) {
|
|
267
|
+
return { ok: false, reason: `identidade "${asId}" NÃO é a aprovadora designada — selecionar não autoriza` };
|
|
268
|
+
}
|
|
269
|
+
if (typeof providedSecret !== 'string' || providedSecret === '') {
|
|
270
|
+
return { ok: false, reason: 'segredo do aprovador ausente (PODEXPORT_APPROVER_SECRET) — seleção não basta' };
|
|
271
|
+
}
|
|
272
|
+
if (!verifyApproverSecret(providedSecret, secretHash)) {
|
|
273
|
+
return { ok: false, reason: 'segredo do aprovador inválido' };
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, approver: asId };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Fronteira command_check (AC3) — self-check em-deployment via motor.evalVeto (MESMO code-path).
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Deriva o conjunto BLESSED de veto-checks da MESMA fonte que o motor carrega em runtime
|
|
284
|
+
* (`installer/motor-scripts.js` → `motorRels()`, filtrado por `scripts/veto-checks/` — ADR-007).
|
|
285
|
+
* O motor (`pc-run-pod.cjs`, INTOCADO) faz essa carga em `try/catch` silencioso: manifesto ausente
|
|
286
|
+
* ou ilegível o deixa com um `Set` VAZIO e o Guard B (`blessed.size > 0 && !blessed.has(...)`) passa
|
|
287
|
+
* a ser PULADO — estado degradado em que um `.cjs` PLANTADO fisicamente sob `scripts/veto-checks/`
|
|
288
|
+
* satisfaz a checagem de nome. Aqui a mesma carga é feita SEM catch, para que o preflight possa
|
|
289
|
+
* AFERIR a disponibilidade do manifesto e recusar o arranque (fail-loud) nesse estado.
|
|
290
|
+
* `deps.loadMotorScripts`/`deps.motorScriptsPath` injetáveis (CI hermética).
|
|
291
|
+
*/
|
|
292
|
+
function blessedVetoRels(deps = {}) {
|
|
293
|
+
const load = deps.loadMotorScripts || (() => require(deps.motorScriptsPath || MOTOR_SCRIPTS_JS));
|
|
294
|
+
const mod = load();
|
|
295
|
+
const rels = mod && typeof mod.motorRels === 'function' ? mod.motorRels() : [];
|
|
296
|
+
const set = new Set();
|
|
297
|
+
for (const rel of rels) if (String(rel).startsWith('scripts/veto-checks/')) set.add(String(rel));
|
|
298
|
+
return set;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Prova, no arranque, que a fronteira de ADR-011 está íntegra NESTE deployment (topologia (a)):
|
|
303
|
+
* - o manifesto blessed (ADR-007) está DISPONÍVEL e NÃO-VAZIO — sem ele o Guard B do motor
|
|
304
|
+
* degrada MUDO (NOTE-1 do gate de SF10.3; SF10.9/AC1);
|
|
305
|
+
* - um nome de script NÃO-blessed é RECUSADO (sem execução) pela resolução do motor;
|
|
306
|
+
* - um script BLESSED (html-valid.cjs) sobre HTML válido é ACEITO.
|
|
307
|
+
* Reusa `motor.evalVeto` — o code-path REAL de produção, ancorado em `VETO_CHECKS_ROOT`
|
|
308
|
+
* (`__dirname/veto-checks` do checkout deployado). Fail-loud se qualquer invariante quebrar:
|
|
309
|
+
* um serviço que sobe com a fronteira furada é exatamente o "verde falso" que ADR-015 proíbe.
|
|
310
|
+
* `deps.evalVeto` injetável para CI; default é o motor real.
|
|
311
|
+
*/
|
|
312
|
+
function assertCommandCheckFrontier(deps = {}) {
|
|
313
|
+
const evalVeto = deps.evalVeto || motor.evalVeto;
|
|
314
|
+
const roots = { runDir: os.tmpdir(), podRoot: REPO_ROOT };
|
|
315
|
+
|
|
316
|
+
// 0. Manifesto blessed disponível e NÃO-VAZIO (AC1). O motor pula o Guard B quando o Set está
|
|
317
|
+
// vazio (`blessed.size > 0 && ...`): manifesto ausente/ilegível é um estado DEGRADADO silencioso
|
|
318
|
+
// em que um `.cjs` plantado sob scripts/veto-checks/ passaria pela checagem de nome. O serviço
|
|
319
|
+
// RECUSA subir assim — fail-loud no arranque, nunca "verde falso" (ADR-015).
|
|
320
|
+
const blessedFn = deps.blessedVetoRels || blessedVetoRels;
|
|
321
|
+
let blessed;
|
|
322
|
+
try { blessed = blessedFn(deps); } catch (e) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`[FAIL-LOUD] manifesto de scripts blessed INDISPONÍVEL (installer/motor-scripts.js: ${e.code || e.message}). ` +
|
|
325
|
+
'Sem ele o Guard B do command_check degrada MUDO (um .cjs plantado sob scripts/veto-checks/ passaria ' +
|
|
326
|
+
'na checagem de nome). Deploy incompleto — serviço RECUSADO.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (!blessed || typeof blessed.size !== 'number' || blessed.size === 0) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
'[FAIL-LOUD] conjunto BLESSED de veto-checks VAZIO (installer/motor-scripts.js sem entradas ' +
|
|
332
|
+
'scripts/veto-checks/). Nesse estado o Guard B do command_check é PULADO e um script plantado ' +
|
|
333
|
+
'no diretório seria aceito pelo nome. Deploy corrompido — serviço RECUSADO.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 1. Não-blessed → RECUSADO (nome que não existe no manifesto/veto-checks).
|
|
338
|
+
const rejected = evalVeto(
|
|
339
|
+
{ type: 'command_check', command: '__frontier_probe_should_be_rejected__.cjs' },
|
|
340
|
+
'', path.join(os.tmpdir(), 'x'), roots,
|
|
341
|
+
);
|
|
342
|
+
if (rejected.ok) {
|
|
343
|
+
throw new Error('[FAIL-LOUD] fronteira command_check FURADA: script não-blessed foi ACEITO. Deploy corrompido — serviço RECUSADO.');
|
|
344
|
+
}
|
|
345
|
+
// 2. Traversal → RECUSADO.
|
|
346
|
+
const traversal = evalVeto(
|
|
347
|
+
{ type: 'command_check', command: '../smoke-pod-workflow.cjs' },
|
|
348
|
+
'', path.join(os.tmpdir(), 'x'), roots,
|
|
349
|
+
);
|
|
350
|
+
if (traversal.ok) {
|
|
351
|
+
throw new Error('[FAIL-LOUD] fronteira command_check FURADA: traversal para fora de veto-checks/ foi ACEITO. Serviço RECUSADO.');
|
|
352
|
+
}
|
|
353
|
+
// 3. Blessed sobre HTML válido → ACEITO (prova que a resolução do checkout deployado funciona).
|
|
354
|
+
const validHtml = deps.validHtmlPath || path.join(REPO_ROOT, 'scripts', '__fixtures__', 'veto-checks', 'slide-ok.html');
|
|
355
|
+
let accepted = { ok: true };
|
|
356
|
+
if (fs.existsSync(validHtml)) {
|
|
357
|
+
accepted = evalVeto({ type: 'command_check', command: 'html-valid.cjs' }, '', validHtml, roots);
|
|
358
|
+
if (!accepted.ok) {
|
|
359
|
+
throw new Error(`[FAIL-LOUD] fronteira command_check quebrada: blessed html-valid.cjs RECUSOU HTML válido (${accepted.reason}). Deploy incompleto — serviço RECUSADO.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
blessedCount: blessed.size,
|
|
364
|
+
rejectedNonBlessed: !rejected.ok,
|
|
365
|
+
rejectedTraversal: !traversal.ok,
|
|
366
|
+
acceptedBlessed: accepted.ok,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Preflight de arranque (fail-loud). Reúne 1-4 acima.
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Executa o preflight completo. `deps` injetável (CI hermética):
|
|
376
|
+
* deps.env → objeto de env (default process.env)
|
|
377
|
+
* deps.probeChrome → () => string|null (default render.probeChrome)
|
|
378
|
+
* deps.evalVeto → frontier (default motor.evalVeto)
|
|
379
|
+
* deps.assertFrameworkCoherence → (bundle, lockPath) => {...} (default do executor)
|
|
380
|
+
* Retorna um resumo (booleanos/versões), NUNCA os valores das chaves. Lança na 1ª falha.
|
|
381
|
+
*/
|
|
382
|
+
function preflight(cfg, deps = {}) {
|
|
383
|
+
const env = deps.env || process.env;
|
|
384
|
+
const summary = { keys: {}, chrome: null, coherence: null, frontier: null };
|
|
385
|
+
|
|
386
|
+
// (1) chaves da ORG (AC2) — presença, nunca o valor.
|
|
387
|
+
getAnthropicApiKey(env);
|
|
388
|
+
getOpenRouterApiKey(env);
|
|
389
|
+
summary.keys = { anthropic: true, openrouter: true };
|
|
390
|
+
|
|
391
|
+
// (2) Chrome headless presente (AC4/e) — fail-loud herdado de ADR-011 D3, aferido no ARRANQUE.
|
|
392
|
+
const probe = deps.probeChrome || render.probeChrome;
|
|
393
|
+
const chromeBin = probe();
|
|
394
|
+
if (!chromeBin) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
'[FAIL-LOUD] Chrome headless AUSENTE na VPS — o render-gate (ADR-011) não pode rodar. Defina ' +
|
|
397
|
+
'CHROME_BIN ou instale google-chrome/chromium (ver runbook: provisionamento de Chrome). ' +
|
|
398
|
+
'O serviço RECUSA subir sem ele (nunca skip-silencioso — seria verde falso).',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
summary.chrome = path.basename(String(chromeBin));
|
|
402
|
+
|
|
403
|
+
// (3) coerência framework_version motor == lock do bundle (AC3/DF2-1) — FIADA no arranque.
|
|
404
|
+
if (cfg && cfg.bundleDir) {
|
|
405
|
+
const { bundle } = executor.loadBundle(cfg.bundleDir);
|
|
406
|
+
const lockPath = executor.resolveLockPath(bundle, cfg.lockOverride);
|
|
407
|
+
const assertCoherence = deps.assertFrameworkCoherence || executor.assertFrameworkCoherence;
|
|
408
|
+
summary.coherence = assertCoherence(bundle, lockPath); // lança fail-loud em MISMATCH.
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// (4) self-check da fronteira command_check (AC3) — MESMO code-path de produção.
|
|
412
|
+
summary.frontier = assertCommandCheckFrontier(deps);
|
|
413
|
+
|
|
414
|
+
return summary;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Contrato de diretório (DF2-5). Nomes/dirs da fila.
|
|
419
|
+
//
|
|
420
|
+
// CONCORRÊNCIA CROSS-PROCESSO (Story SF10.16; ADR-018 Adendo F5-b, DF-SF10.16-1..DF-SF10.16-4) — a fila tem TRÊS
|
|
421
|
+
// processos mutadores desde F3, com o MESMO usuário de sistema e o MESMO `ReadWritePaths`:
|
|
422
|
+
// (1) este serviço (`serve`: pending/ → processing/ → {done,awaiting,blocked,failed}/);
|
|
423
|
+
// (2) `pod-export-ui.service` (unit PRÓPRIO: `rejectRun` e `approveRun`);
|
|
424
|
+
// (3) o escritor de trigger (`enqueue`, processo separado por contrato).
|
|
425
|
+
// A premissa de "instância única" com que o contrato foi escrito em F2 CADUCOU. A invariante real é
|
|
426
|
+
// **cada item é reivindicado e mutado por exatamente UM ator** — e ela é mecanizada por um primitivo
|
|
427
|
+
// único: `rename(2)`. Quem quer atuar sobre um item MOVE o item primeiro; o rename que sucede É a
|
|
428
|
+
// autorização de agir (`existsSync`+ação está PROIBIDO como padrão de fronteira: é TOCTOU). Perder a
|
|
429
|
+
// disputa é `ENOENT` — o ÚNICO errno absorvido, e só no rename de reivindicação (`claimByRename`).
|
|
430
|
+
// Qualquer outro errno continua fail-loud (try/catch cego trocaria crash barulhento por degradação
|
|
431
|
+
// muda — o anti-padrão que ADR-015/NOTE-1 existem para barrar).
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
const QUEUE_SUBDIRS = ['pending', 'processing', 'done', 'awaiting', 'blocked', 'failed'];
|
|
435
|
+
/** Área de publicação atômica (DF-SF10.16-2.3): tmp NO MESMO filesystem que `pending/`, fora do caminho de
|
|
436
|
+
* dados (`listPending` só lê `pending/` e só `*.json`). Nome com ponto = oculto ao operador. */
|
|
437
|
+
const TMP_SUBDIR = '.tmp';
|
|
438
|
+
/** Lock de instância do loop `serve` (DF-SF10.16-3.1) — dentro do `<queue>`, ao lado do recurso protegido. */
|
|
439
|
+
const SERVICE_LOCK_FILE = '.service.lock';
|
|
440
|
+
|
|
441
|
+
function ensureQueue(queueDir) {
|
|
442
|
+
const dirs = {};
|
|
443
|
+
for (const sub of QUEUE_SUBDIRS) {
|
|
444
|
+
const d = path.join(queueDir, sub);
|
|
445
|
+
fs.mkdirSync(d, { recursive: true });
|
|
446
|
+
dirs[sub] = d;
|
|
447
|
+
}
|
|
448
|
+
const tmp = path.join(queueDir, TMP_SUBDIR);
|
|
449
|
+
fs.mkdirSync(tmp, { recursive: true });
|
|
450
|
+
dirs.tmp = tmp;
|
|
451
|
+
return dirs;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* PRIMITIVO ÚNICO de reivindicação da fila (DF-SF10.16-2.1/6.2). Move `src` → `dest` com `rename(2)`
|
|
456
|
+
* (atômico por POSIX: exatamente um ator entre N concorrentes vence).
|
|
457
|
+
* - sucesso → `true` = REIVINDIQUEI, posso agir sobre o item.
|
|
458
|
+
* - `ENOENT` → `false` = outro ator ganhou (ou o item nunca existiu). Caminho NORMAL: o chamador
|
|
459
|
+
* pula o item e SEGUE (o serviço nunca encerra o loop; a UI responde "já tratado").
|
|
460
|
+
* - qualquer OUTRO errno (`EACCES`, `EIO`, `ENOSPC`, `EXDEV`, `ENOTDIR`...) → RELANÇA, fail-loud.
|
|
461
|
+
* Nunca use `try/catch` cego em volta desta chamada: a sanção é POR CÓDIGO DE ERRO, por desenho.
|
|
462
|
+
*/
|
|
463
|
+
function claimByRename(src, dest) {
|
|
464
|
+
try {
|
|
465
|
+
fs.renameSync(src, dest);
|
|
466
|
+
return true;
|
|
467
|
+
} catch (e) {
|
|
468
|
+
if (e && e.code === 'ENOENT') return false; // perdi a disputa — sinal sancionado, não erro.
|
|
469
|
+
throw e; // todo o resto continua fail-loud.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Lista os payloads pendentes em ordem estável (FIFO por nome). Só `*.json`. */
|
|
474
|
+
function listPending(queueDir) {
|
|
475
|
+
const pend = path.join(queueDir, 'pending');
|
|
476
|
+
if (!fs.existsSync(pend)) return [];
|
|
477
|
+
return fs.readdirSync(pend).filter((f) => f.endsWith('.json')).sort()
|
|
478
|
+
.map((f) => path.join(pend, f));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function genRunId(baseName) {
|
|
482
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
483
|
+
const slug = path.basename(baseName, '.json').replace(/[^A-Za-z0-9_-]/g, '') || 'run';
|
|
484
|
+
return `pex-${slug}-${stamp}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Dispatch do executor (SF10.2) por SPAWN. Injetável para CI (deps.runExecutor).
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Extrai o RESULTADO do executor da sua saída. O executor imprime, na última linha, uma linha
|
|
493
|
+
* `RESULTADO: {json}` com o desfecho do drive (`{complete:true}` | `{blocked:"<fase>"}` |
|
|
494
|
+
* `{awaiting:"<fase>"}` | `{halted:true}`). Devolve o objeto parseado, ou null se ausente/ilegível.
|
|
495
|
+
* Varre de baixo para cima (o último RESULTADO é o vigente). Testável sem VPS (parsing puro).
|
|
496
|
+
*/
|
|
497
|
+
function parseExecutorResult(stdout) {
|
|
498
|
+
const lines = String(stdout || '').split(/\r?\n/);
|
|
499
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
500
|
+
const m = lines[i].match(/^\s*RESULTADO:\s*(\{.*\})\s*$/);
|
|
501
|
+
if (m) { try { return JSON.parse(m[1]); } catch { return null; } }
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Classifica o desfecho de um run em kind da fila a partir de (exit code + RESULTADO capturado).
|
|
508
|
+
* Precedência: crash > awaiting/blocked > done (achados 4 e 5 do smoke real VPS):
|
|
509
|
+
* crash (code != 0) → 'failed' (erro/infra — o executor caiu).
|
|
510
|
+
* result.awaiting (exit 0) → 'awaiting' (PAUSOU no checkpoint humano — NÃO concluiu; retomável
|
|
511
|
+
* via approve. Rotear a done/ era errado: o run vai continuar).
|
|
512
|
+
* result.blocked (exit 0) → 'blocked' (veto esgotado numa fase — resultado normal, NÃO sucesso).
|
|
513
|
+
* caso contrário (exit 0) → 'done' (pipeline completo — dispatch concluído com sucesso).
|
|
514
|
+
* `awaiting` e `blocked` são mutuamente exclusivos no drive; a ordem entre eles não altera o veredito.
|
|
515
|
+
*/
|
|
516
|
+
function classifyOutcome(result) {
|
|
517
|
+
const code = result && typeof result.code === 'number' ? result.code : 1;
|
|
518
|
+
const outcome = result && result.result;
|
|
519
|
+
if (code !== 0) return { kind: 'failed', code, blocked: null, awaiting: null };
|
|
520
|
+
if (outcome && outcome.awaiting) return { kind: 'awaiting', code, blocked: null, awaiting: outcome.awaiting };
|
|
521
|
+
if (outcome && outcome.blocked) return { kind: 'blocked', code, blocked: outcome.blocked, awaiting: null };
|
|
522
|
+
return { kind: 'done', code, blocked: null, awaiting: null };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Dispara o executor de SF10.2 como processo filho: `node pod-export-executor.cjs <bundle> run
|
|
527
|
+
* <runId> --input <payload> --runs-root <runs>`. As chaves da ORG são passadas por ENV do filho
|
|
528
|
+
* (nunca por CLI/arquivo por request). CAPTURA stdout (para ler o RESULTADO — achado 4) e ainda o
|
|
529
|
+
* ecoa ao journald (tee); stderr continua herdado. Resolve { code, result } no exit. deps.spawn
|
|
530
|
+
* injetável.
|
|
531
|
+
*/
|
|
532
|
+
function spawnExecutor(cfg, payloadPath, runId, deps = {}) {
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
const spawnFn = deps.spawn || spawn;
|
|
535
|
+
const args = [EXECUTOR_PATH, cfg.bundleDir, 'run', runId, '--input', payloadPath, '--runs-root', cfg.runsRoot];
|
|
536
|
+
if (cfg.lockOverride) args.push('--lock', cfg.lockOverride);
|
|
537
|
+
const child = spawnFn(process.execPath, args, {
|
|
538
|
+
env: {
|
|
539
|
+
...(deps.env || process.env),
|
|
540
|
+
ANTHROPIC_API_KEY: getAnthropicApiKey(deps.env || process.env),
|
|
541
|
+
OPENROUTER_API_KEY: getOpenRouterApiKey(deps.env || process.env),
|
|
542
|
+
},
|
|
543
|
+
stdio: ['ignore', 'pipe', 'inherit'], // stdout PIPE p/ capturar o RESULTADO; stderr → journald.
|
|
544
|
+
});
|
|
545
|
+
let out = '';
|
|
546
|
+
if (child.stdout) child.stdout.on('data', (d) => { const s = d.toString(); out += s; process.stdout.write(s); });
|
|
547
|
+
child.on('exit', (code) => resolve({ code: code == null ? 1 : code, result: parseExecutorResult(out) }));
|
|
548
|
+
child.on('error', () => resolve({ code: 1, result: null }));
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Processa UM payload pendente: move pending → processing, dispara o executor, e roteia para
|
|
554
|
+
* done/ | blocked/ | failed/ conforme (exit code + RESULTADO capturado) — ver classifyOutcome
|
|
555
|
+
* (achado 4 do smoke real: bloqueio de veto sai exit 0 e NÃO pode cair em done/). Escreve um
|
|
556
|
+
* `<runId>.result.json` com o veredito (nunca o relato do subagente decide). `deps.runExecutor`
|
|
557
|
+
* injetável (CI): deve resolver `{ code, result }` (result = RESULTADO parseado, ou null).
|
|
558
|
+
*/
|
|
559
|
+
async function processOne(cfg, pendingPath, deps = {}) {
|
|
560
|
+
const dirs = ensureQueue(cfg.queueDir);
|
|
561
|
+
const runId = genRunId(pendingPath);
|
|
562
|
+
const processingPath = path.join(dirs.processing, `${runId}.json`);
|
|
563
|
+
const log = deps.log || ((m) => console.log(m));
|
|
564
|
+
// REIVINDICAÇÃO (DF-SF10.16-2.1): o rename É a autorização de processar. Se outro ator (2º loop `serve`,
|
|
565
|
+
// um `enqueue` que sobrescreveu, um operador) ganhou a disputa, `ENOENT` significa "não é meu" —
|
|
566
|
+
// pula o item e SEGUE servindo. Antes de SF10.16 isto lançava e derrubava o worker inteiro por UM
|
|
567
|
+
// item (com `Restart=on-failure`+`StartLimitBurst`, 5 colisões em 60s = unit em `failed` permanente).
|
|
568
|
+
if (!claimByRename(pendingPath, processingPath)) {
|
|
569
|
+
log(`[fila] item já reivindicado por outro ator: ${path.basename(pendingPath)} — pulando (o serviço SEGUE servindo)`);
|
|
570
|
+
return { claimed: false, skipped: true, pendingPath, reason: 'claim-lost' };
|
|
571
|
+
}
|
|
572
|
+
log(`[fila] pickup ${path.basename(pendingPath)} → processing/${runId}.json`);
|
|
573
|
+
|
|
574
|
+
// started_by/pod do payload movido (AC3: persistência/rastreabilidade). Tolerante a payload
|
|
575
|
+
// legado sem started_by (drop direto no dir — DF2-5): registra null, não fail-loud aqui (a
|
|
576
|
+
// validação fail-loud é do enqueue, o boundary de ingestão sancionado).
|
|
577
|
+
//
|
|
578
|
+
// SF10.16/AC3 — payload ILEGÍVEL (não é JSON) NÃO é despachado. Antes, um payload observado
|
|
579
|
+
// meio-publicado (`enqueue` gravava direto no destino: o nome ficava visível ao `readdir` ANTES do
|
|
580
|
+
// conteúdo) era reivindicado, entregue VAZIO ao executor e registrado com `started_by: null` — o
|
|
581
|
+
// gate fail-loud de identidade de DF3-3 ("nunca run anônimo") era contornado EM SILÊNCIO. A
|
|
582
|
+
// publicação agora é atômica (`enqueue` via `.tmp/`+rename), e este guard fecha a via residual
|
|
583
|
+
// (drop manual de operador apanhado no meio de um `cp`): vai para `failed/` sem gastar LLM.
|
|
584
|
+
let meta = null;
|
|
585
|
+
let parseError = null;
|
|
586
|
+
try { meta = JSON.parse(fs.readFileSync(processingPath, 'utf8')); } catch (e) { parseError = e; }
|
|
587
|
+
if (!meta || typeof meta !== 'object') {
|
|
588
|
+
const reason = `payload ILEGÍVEL ou incompleto (${parseError ? parseError.message : 'não é um objeto JSON'}) — publicação parcial ou arquivo corrompido`;
|
|
589
|
+
const failedPath = path.join(dirs.failed, `${runId}.json`);
|
|
590
|
+
fs.renameSync(processingPath, failedPath);
|
|
591
|
+
fs.writeFileSync(path.join(dirs.failed, `${runId}.result.json`), JSON.stringify({
|
|
592
|
+
runId, kind: 'failed', exitCode: 2, ok: false, started_by: null, blocked: null, awaiting: null,
|
|
593
|
+
outcome: null, at: new Date().toISOString(), error: reason,
|
|
594
|
+
}, null, 2));
|
|
595
|
+
log(`[fila][FAIL-LOUD] ${runId}: ${reason}. NÃO despachado ao executor (nenhum run anônimo, nenhum gasto de LLM). Publique com \`enqueue\` (atômico) ou copie para <queue>/.tmp/ e só então \`mv\` para pending/.`);
|
|
596
|
+
return { claimed: true, runId, ok: false, kind: 'failed', exitCode: 2, blocked: null, awaiting: null, finalPath: failedPath, resultPath: path.join(dirs.failed, `${runId}.result.json`), rejectedPayload: true };
|
|
597
|
+
}
|
|
598
|
+
const startedBy = typeof meta.started_by === 'string' ? meta.started_by : null;
|
|
599
|
+
|
|
600
|
+
const runExecutor = deps.runExecutor || ((c, p, id, d) => spawnExecutor(c, p, id, d));
|
|
601
|
+
let result;
|
|
602
|
+
try {
|
|
603
|
+
result = await runExecutor(cfg, processingPath, runId, deps);
|
|
604
|
+
} catch (e) {
|
|
605
|
+
result = { code: 1, error: e.message, result: null };
|
|
606
|
+
}
|
|
607
|
+
const { kind, code, blocked, awaiting } = classifyOutcome(result);
|
|
608
|
+
const ok = kind === 'done';
|
|
609
|
+
const finalDir = dirs[kind];
|
|
610
|
+
const finalPath = path.join(finalDir, `${runId}.json`);
|
|
611
|
+
fs.renameSync(processingPath, finalPath);
|
|
612
|
+
const resultPath = path.join(finalDir, `${runId}.result.json`);
|
|
613
|
+
fs.writeFileSync(resultPath, JSON.stringify({
|
|
614
|
+
runId, kind, exitCode: code, ok, started_by: startedBy, blocked: blocked || null, awaiting: awaiting || null,
|
|
615
|
+
outcome: (result && result.result) || null, at: new Date().toISOString(), error: result && result.error,
|
|
616
|
+
}, null, 2));
|
|
617
|
+
// Ledger de uso por started_by (DF3-4) — 1 registro por run finalizado (freio, não billing).
|
|
618
|
+
const usage = extractUsage(result);
|
|
619
|
+
appendUsage(cfg, { started_by: startedBy, runId, pod: meta.pod || null, kind, ...usage, at: new Date().toISOString() });
|
|
620
|
+
const tag = blocked ? `, blocked@${blocked}` : (awaiting ? `, awaiting@${awaiting}` : '');
|
|
621
|
+
log(`[fila] ${kind.toUpperCase()} ${runId} (exit ${code}${tag})`);
|
|
622
|
+
return { claimed: true, runId, ok, kind, exitCode: code, blocked: blocked || null, awaiting: awaiting || null, finalPath, resultPath };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// approve via o SERVIÇO (achado 6). O approve DIRETO do executor NÃO honra PODEXPORT_RUNS_DIR (usa
|
|
627
|
+
// o default .power-claude/pod-export-runs, relativo ao framework — EACCES para o usuário do serviço)
|
|
628
|
+
// e EXIGE a flag --runs-root. Aqui o operador aprova com a MESMA config do serviço (consistente com
|
|
629
|
+
// enqueue/status): lê runsRoot/bundleDir da config e passa --runs-root correto ao executor.
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/** Monta o argv do executor para `approve <runId>` COM --runs-root da config (achado 6). Puro/testável. */
|
|
633
|
+
function buildApproveArgs(cfg, runId) {
|
|
634
|
+
const args = [EXECUTOR_PATH, cfg.bundleDir, 'approve', runId, '--runs-root', cfg.runsRoot];
|
|
635
|
+
if (cfg.lockOverride) args.push('--lock', cfg.lockOverride);
|
|
636
|
+
return args;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Deriva o kind REAL de um run a partir do seu state.json em disco (motor, read-only) — usado para
|
|
641
|
+
* reconciliar o marcador da fila após um approve (NÃO adivinha; lê o estado verdadeiro):
|
|
642
|
+
* qualquer step failed → 'failed'
|
|
643
|
+
* qualquer step awaiting_approval → 'awaiting' (outro checkpoint — continua retomável)
|
|
644
|
+
* todos os steps done → 'done'
|
|
645
|
+
* ainda em progresso (running/pending) → null (nada a reconciliar por approve).
|
|
646
|
+
* `deps.findStateByRunId`/`deps.readState` injetáveis (CI hermética; default = motor de produção).
|
|
647
|
+
*/
|
|
648
|
+
function deriveRunKind(cfg, runId, deps = {}) {
|
|
649
|
+
const findState = deps.findStateByRunId || motor.findStateByRunId;
|
|
650
|
+
const readState = deps.readState || motor.readState;
|
|
651
|
+
const statePath = findState(cfg.runsRoot, runId);
|
|
652
|
+
if (!statePath) return null;
|
|
653
|
+
const statuses = Object.values(readState(statePath).steps).map((s) => s.status);
|
|
654
|
+
if (statuses.some((s) => s === 'failed')) return 'failed';
|
|
655
|
+
if (statuses.some((s) => s === 'awaiting_approval')) return 'awaiting';
|
|
656
|
+
if (statuses.length > 0 && statuses.every((s) => s === 'done')) return 'done';
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* REIVINDICAÇÃO do marcador para um `approve` (SF10.16/AC1, DF-SF10.16-2.1). Chamada ESTRITAMENTE DEPOIS da
|
|
662
|
+
* decisão de autorização de DF3-3 (segredo/`is_approver`) e ANTES do `spawn` do executor: ela não
|
|
663
|
+
* autoriza nada — apenas SERIALIZA quem já foi autorizado. Sem ela, dois `approve` concorrentes do
|
|
664
|
+
* mesmo `runId` davam DOIS `spawn` sobre o MESMO `runDir` (dois escritores no mesmo `state.json` e
|
|
665
|
+
* gasto de LLM DUPLICADO — o vetor mais caro e menos visível dos três).
|
|
666
|
+
*
|
|
667
|
+
* Mecânica: `awaiting/<runId>.json` → `processing/<runId>.json` por `claimByRename`.
|
|
668
|
+
* - `{ claimed: true }` → sou o dono; sigo para o spawn.
|
|
669
|
+
* - `{ claimed: false, busy: true }` → o marcador está em `processing/`: OUTRO ator (approve
|
|
670
|
+
* concorrente ou o próprio worker) o detém → RECUSA.
|
|
671
|
+
* - `{ claimed: false, absent: true }` → o run não tem marcador na fila (approve fora-da-fila,
|
|
672
|
+
* ex.: run disparado direto pelo executor) → comportamento
|
|
673
|
+
* INALTERADO: segue e aprova. A reivindicação é CONDICIONAL
|
|
674
|
+
* à existência do marcador, NUNCA um novo motivo de recusa.
|
|
675
|
+
*/
|
|
676
|
+
function claimApproveMarker(cfg, runId, deps = {}) {
|
|
677
|
+
if (!cfg.queueDir) return { claimed: false, absent: true };
|
|
678
|
+
const dirs = ensureQueue(cfg.queueDir);
|
|
679
|
+
const src = path.join(dirs.awaiting, `${runId}.json`);
|
|
680
|
+
const dest = path.join(dirs.processing, `${runId}.json`);
|
|
681
|
+
if (claimByRename(src, dest)) return { claimed: true, src, dest };
|
|
682
|
+
// Não estava em awaiting/. Se está em processing/, outro ator o reivindicou AGORA (ou o worker o
|
|
683
|
+
// está processando): recusar é o comportamento correto. Caso contrário, não há marcador na fila.
|
|
684
|
+
if (fs.existsSync(dest)) return { claimed: false, busy: true, dest };
|
|
685
|
+
return { claimed: false, absent: true };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Devolve um marcador reivindicado por `claimApproveMarker` a `awaiting/` (approve sem efeito de
|
|
689
|
+
* fila: executor falhou ou nada a reconciliar). Mantém o estado observável idêntico ao pré-SF10.16. */
|
|
690
|
+
function releaseApproveMarker(cfg, runId, claim, deps = {}) {
|
|
691
|
+
if (!claim || !claim.claimed) return false;
|
|
692
|
+
const dirs = ensureQueue(cfg.queueDir);
|
|
693
|
+
const back = claimByRename(claim.dest, path.join(dirs.awaiting, `${runId}.json`));
|
|
694
|
+
if (!back) {
|
|
695
|
+
(deps.log || ((m) => console.log(m)))(`[approve][AVISO] marcador de ${runId} não estava mais em processing/ ao devolver — outro ator já o moveu (o run em disco é a fonte da verdade).`);
|
|
696
|
+
}
|
|
697
|
+
return back;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Move o marcador da fila `<runId>.json` (tipicamente em awaiting/ após um checkpoint) para o
|
|
702
|
+
* subdir do `kind` derivado, e reescreve o `<runId>.result.json`. Idempotente. Sem queueDir ou sem
|
|
703
|
+
* kind → no-op (approve de um run que não veio da fila). Testável só com fs.
|
|
704
|
+
*/
|
|
705
|
+
function reconcileMarker(cfg, runId, kind, deps = {}) {
|
|
706
|
+
if (!cfg.queueDir || !kind) return null;
|
|
707
|
+
const dirs = ensureQueue(cfg.queueDir);
|
|
708
|
+
const dest = path.join(dirs[kind], `${runId}.json`);
|
|
709
|
+
// SF10.16/AC1: reivindicar em vez de `existsSync`+ação. O approve chega aqui já DONO do marcador
|
|
710
|
+
// (reivindicado em `claimApproveMarker`, tipicamente em `processing/`), mas a varredura continua
|
|
711
|
+
// tolerante: o rename que sucede é a autorização; `ENOENT` = outro ator já moveu (segue para o
|
|
712
|
+
// próximo candidato). Qualquer outro errno permanece fail-loud.
|
|
713
|
+
let moved = false;
|
|
714
|
+
if (fs.existsSync(dest)) {
|
|
715
|
+
moved = true; // já está no destino (idempotência) — nada a mover.
|
|
716
|
+
} else {
|
|
717
|
+
for (const sub of ['processing', 'awaiting', 'done', 'blocked', 'failed']) {
|
|
718
|
+
if (sub === kind) continue;
|
|
719
|
+
if (claimByRename(path.join(dirs[sub], `${runId}.json`), dest)) { moved = true; break; }
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (!moved) return null;
|
|
723
|
+
const resultPath = path.join(dirs[kind], `${runId}.result.json`);
|
|
724
|
+
fs.writeFileSync(resultPath, JSON.stringify({ runId, kind, ok: kind === 'done', reconciledBy: 'approve', at: new Date().toISOString() }, null, 2));
|
|
725
|
+
(deps.log || ((m) => console.log(m)))(`[approve] ${runId} reconciliado → ${kind}/`);
|
|
726
|
+
return { runId, kind, dest, resultPath };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Aprova um checkpoint via o serviço. GATE crítico de SF10.4 (DF3-3): ANTES de qualquer efeito, o
|
|
731
|
+
* `approve` só é aceito quando a identidade `--as` é a aprovadora designada (`is_approver:true`) E o
|
|
732
|
+
* invocador prova o segredo do aprovador (scrypt+timingSafeEqual vs PODEXPORT_APPROVER_SECRET_HASH).
|
|
733
|
+
* Este é o MESMO code-path da CLI e da futura UI (SF10.5): chamar o backend direto com
|
|
734
|
+
* identidade/segredo errado passa por aqui e é RECUSADO (sem spawn, sem reconciliação) — recusa
|
|
735
|
+
* fail-loud e LOGADA com a razão (NUNCA o segredo). Só após autorizar: invoca o executor `approve`
|
|
736
|
+
* com --runs-root da config (achado 6), captura o RESULTADO, e reconcilia o marcador da fila para o
|
|
737
|
+
* estado REAL do run (achado 5). Identidade/segredo vêm de deps (CI) ou do env (CLI):
|
|
738
|
+
* `deps.asId`/PODEXPORT_APPROVER_AS, `deps.approverSecret`/PODEXPORT_APPROVER_SECRET,
|
|
739
|
+
* `deps.approverSecretHash`/PODEXPORT_APPROVER_SECRET_HASH. Resolve { code, result } ou
|
|
740
|
+
* { code, denied:true, reason } na recusa.
|
|
741
|
+
*/
|
|
742
|
+
function approveRun(cfg, runId, deps = {}) {
|
|
743
|
+
return new Promise((resolve) => {
|
|
744
|
+
const env = deps.env || process.env;
|
|
745
|
+
const log = deps.log || ((m) => console.log(m));
|
|
746
|
+
// (0a) SF10.9/AC5 — `runId` é entrada NÃO-CONFIÁVEL (vem cru do form HTTP da UI, `form.run`).
|
|
747
|
+
// Validação PRIMEIRO, antes de QUALQUER efeito: antes de ler a config de identidades, antes do
|
|
748
|
+
// gate de autorização e — sobretudo — antes de `buildApproveArgs`/`spawn`. Mesma regra já usada
|
|
749
|
+
// em rejectRun/runDirFor (safeRunId, fonte única acima). Nunca ecoa o valor recebido no log.
|
|
750
|
+
if (!safeRunId(runId)) {
|
|
751
|
+
const reason = 'runId inválido (formato não aceito: exige [A-Za-z0-9._-] com 1º caractere ≠ "-", sem "..", 1-255 chars)';
|
|
752
|
+
log(`[approve][RECUSADO] runId inválido: ${reason} — recusado antes de qualquer efeito (sem spawn).`);
|
|
753
|
+
return resolve({ code: 3, denied: true, reason, result: null });
|
|
754
|
+
}
|
|
755
|
+
// (0b) GATE F3 — seleção ≠ autorização. Recusa ANTES de qualquer spawn/efeito.
|
|
756
|
+
let identityModel;
|
|
757
|
+
try { identityModel = deps.identityModel || loadIdentities({ env, file: cfg.identitiesFile }); }
|
|
758
|
+
catch (e) { log(`[approve][RECUSADO] ${runId}: ${e.message}`); return resolve({ code: 3, denied: true, reason: e.message, result: null }); }
|
|
759
|
+
const auth = authorizeApproval({
|
|
760
|
+
identityModel,
|
|
761
|
+
asId: deps.asId != null ? deps.asId : env.PODEXPORT_APPROVER_AS,
|
|
762
|
+
providedSecret: deps.approverSecret != null ? deps.approverSecret : env.PODEXPORT_APPROVER_SECRET,
|
|
763
|
+
secretHash: deps.approverSecretHash != null ? deps.approverSecretHash : env.PODEXPORT_APPROVER_SECRET_HASH,
|
|
764
|
+
});
|
|
765
|
+
if (!auth.ok) {
|
|
766
|
+
log(`[approve][RECUSADO] ${runId}: ${auth.reason}`); // razão nunca contém o segredo.
|
|
767
|
+
return resolve({ code: 3, denied: true, reason: auth.reason, result: null });
|
|
768
|
+
}
|
|
769
|
+
log(`[approve][AUTORIZADO] ${runId} por ${auth.approver} (segredo verificado, timing-safe)`);
|
|
770
|
+
// (0c) SF10.16/AC1 — REIVINDICAÇÃO, estritamente DEPOIS da decisão de autorização acima e ANTES
|
|
771
|
+
// do spawn. Não altera QUEM pode aprovar (o gate de DF3-3 já decidiu): serializa quem já foi
|
|
772
|
+
// autorizado, fechando o DUPLO SPAWN (dois executores no mesmo runDir = gasto de LLM em dobro).
|
|
773
|
+
const claim = claimApproveMarker(cfg, runId, deps);
|
|
774
|
+
if (claim.busy) {
|
|
775
|
+
const reason = 'checkpoint já reivindicado por outra aprovação em andamento (o marcador saiu de awaiting/)';
|
|
776
|
+
log(`[approve][RECUSADO] ${runId}: ${reason} — nenhum spawn (evita executor duplicado no mesmo runDir).`);
|
|
777
|
+
return resolve({ code: 3, denied: true, gone: true, reason, result: null });
|
|
778
|
+
}
|
|
779
|
+
const spawnFn = deps.spawn || spawn;
|
|
780
|
+
const child = spawnFn(process.execPath, buildApproveArgs(cfg, runId), {
|
|
781
|
+
env: deps.env || process.env,
|
|
782
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
783
|
+
});
|
|
784
|
+
let out = '';
|
|
785
|
+
if (child.stdout) child.stdout.on('data', (d) => { const s = d.toString(); out += s; process.stdout.write(s); });
|
|
786
|
+
child.on('exit', (code) => {
|
|
787
|
+
const result = { code: code == null ? 1 : code, result: parseExecutorResult(out) };
|
|
788
|
+
let reconciled = null;
|
|
789
|
+
if (result.code === 0) {
|
|
790
|
+
try { const kind = deriveRunKind(cfg, runId, deps); if (kind) reconciled = reconcileMarker(cfg, runId, kind, deps); }
|
|
791
|
+
catch (e) { (deps.log || ((m) => console.log(m)))(`[approve] aviso: reconciliação do marcador falhou (${e.message}) — o run em disco é a fonte da verdade.`); }
|
|
792
|
+
}
|
|
793
|
+
// Sem reconciliação (executor falhou, ou o run ainda não mudou de estado): DEVOLVE o marcador a
|
|
794
|
+
// awaiting/ — o checkpoint continua visível/retomável, exatamente como antes de SF10.16.
|
|
795
|
+
if (!reconciled) releaseApproveMarker(cfg, runId, claim, deps);
|
|
796
|
+
resolve(result);
|
|
797
|
+
});
|
|
798
|
+
child.on('error', () => { releaseApproveMarker(cfg, runId, claim, deps); resolve({ code: 1, result: null }); });
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
// Loop de serviço (systemd de longa duração). Poll da fila; sem superfície de rede.
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
807
|
+
|
|
808
|
+
/** Path do lock de instância do `serve`, DENTRO do `<queue>` (já em `ReadWritePaths`, já com o
|
|
809
|
+
* ownership do serviço) — nunca em `/run/...`: zero mudança de unit/provision (DF-SF10.16-3.1). */
|
|
810
|
+
function serviceLockPath(queueDir) { return path.join(queueDir, SERVICE_LOCK_FILE); }
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Liveness de um pid (DF-SF10.16-3.1). `ESRCH` = MORTO (única condição que autoriza tomar posse do lock).
|
|
814
|
+
* `EPERM` = processo VIVO sob outro dono — NUNCA interpretar como morto. Qualquer outro erro é
|
|
815
|
+
* tratado conservadoramente como VIVO (recusar arranque é reversível; roubar o lock de um worker
|
|
816
|
+
* vivo produziria dois workers na mesma fila, o estado que este lock existe para impedir).
|
|
817
|
+
*/
|
|
818
|
+
function pidAlive(pid) {
|
|
819
|
+
try { process.kill(pid, 0); return true; }
|
|
820
|
+
catch (e) { return !(e && e.code === 'ESRCH'); }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* PUBLICAÇÃO do lock: COMPLETO ou inexistente, e EXCLUSIVA (SF10.16/LOCK-1).
|
|
825
|
+
*
|
|
826
|
+
* Grava o conteúdo inteiro em `<queue>/.tmp/` e publica com **`link(2)`** — o irmão EXCLUSIVO do
|
|
827
|
+
* `rename(2)`: falha com `EEXIST` se o destino já existe, em vez de substituí-lo em silêncio.
|
|
828
|
+
* `rename` seria ERRADO aqui (roubaria o lock de um dono vivo); `link` dá as duas propriedades de que
|
|
829
|
+
* o lock precisa ao mesmo tempo: **atomicidade de conteúdo** e **exclusão mútua**.
|
|
830
|
+
*
|
|
831
|
+
* Era exatamente aqui que estava o defeito LOCK-1: `openSync(lockPath, 'wx')` cria o arquivo VAZIO e
|
|
832
|
+
* só depois o preenche. Nessa janela, um segundo arranque lia um arquivo vazio, concluía
|
|
833
|
+
* "corrompido/órfão" e REMOVIA o lock VIVO do vencedor — dois donos simultâneos, de forma
|
|
834
|
+
* determinística. É o mesmo defeito que esta story fecha em `pending/` (nome visível antes do
|
|
835
|
+
* conteúdo), que faltava aplicar ao próprio lock. Retorna `true` se publicou, `false` em `EEXIST`.
|
|
836
|
+
*/
|
|
837
|
+
function publishServiceLock(dirs, lockPath, payload) {
|
|
838
|
+
const tmp = path.join(dirs.tmp, `service.lock.${payload.pid}.${crypto.randomBytes(6).toString('hex')}`);
|
|
839
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2)); // completo ANTES de existir sob o nome oficial.
|
|
840
|
+
let published = false;
|
|
841
|
+
try {
|
|
842
|
+
fs.linkSync(tmp, lockPath); // EEXIST se já há dono — nunca sobrescreve.
|
|
843
|
+
published = true;
|
|
844
|
+
} catch (e) {
|
|
845
|
+
if (!e || e.code !== 'EEXIST') throw e; // EACCES/EIO/EXDEV/... → fail-loud.
|
|
846
|
+
}
|
|
847
|
+
try { fs.unlinkSync(tmp); } catch (e) { if (!e || e.code !== 'ENOENT') throw e; }
|
|
848
|
+
return published;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Lê um arquivo de lock e CLASSIFICA, com sanção por errno (nunca catch-all — era o corolário de
|
|
853
|
+
* LOCK-1: um `catch { prev = null }` engolia `EACCES` e o tratava como "corrompido", o que levava a
|
|
854
|
+
* REMOVER um lock que talvez estivesse vivo):
|
|
855
|
+
* `gone` → `ENOENT`: sumiu entre a tentativa e a leitura (o chamador reavalia).
|
|
856
|
+
* `corrupt` → conteúdo ilegível/sem `pid` (veredito de CONTEÚDO, não erro de E/S).
|
|
857
|
+
* `ok` → `{pid, host, startedAt}` válido.
|
|
858
|
+
* Qualquer OUTRO errno de leitura é RELANÇADO (fail-loud).
|
|
859
|
+
*/
|
|
860
|
+
function readServiceLock(lockPath) {
|
|
861
|
+
let raw;
|
|
862
|
+
try { raw = fs.readFileSync(lockPath, 'utf8'); }
|
|
863
|
+
catch (e) {
|
|
864
|
+
if (e && e.code === 'ENOENT') return { state: 'gone' };
|
|
865
|
+
throw e; // EACCES/EIO/... — nunca confundir "não consigo ler" com "está corrompido".
|
|
866
|
+
}
|
|
867
|
+
let parsed = null;
|
|
868
|
+
try { parsed = JSON.parse(raw); } catch { parsed = null; } // veredito de conteúdo, não de E/S.
|
|
869
|
+
if (!parsed || typeof parsed.pid !== 'number') return { state: 'corrupt', raw };
|
|
870
|
+
return { state: 'ok', lock: parsed, raw };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* ASSERÇÃO da invariante residual de instância única do loop `serve` (SF10.16/AC4, DF-SF10.16-3.1).
|
|
875
|
+
* Publica `<queue>/.service.lock` COMPLETO e em EXCLUSÃO MÚTUA (`publishServiceLock`: tmp + `link`).
|
|
876
|
+
* Em `EEXIST`, ARBITRA sobre um conteúdo que — por construção — nunca está "meio escrito":
|
|
877
|
+
* - conteúdo ilegível/corrompido → assume ÓRFÃO, AVISA ALTO e toma posse (um lock corrompido jamais
|
|
878
|
+
* pode imobilizar o serviço);
|
|
879
|
+
* - `host` diferente → RECUSA fail-loud (a fila é local por contrato);
|
|
880
|
+
* - pid MORTO (`ESRCH`) → AVISA ALTO e toma posse (cobre `SIGKILL`/`OOM`);
|
|
881
|
+
* - pid VIVO (inclusive `EPERM`) → RECUSA fail-loud com mensagem ACIONÁVEL (pid, desde quando, como limpar).
|
|
882
|
+
*
|
|
883
|
+
* A TOMADA DE POSSE é REIVINDICADA, não presumida: o órfão é movido para `.tmp/` com `claimByRename`
|
|
884
|
+
* (exatamente um ator consegue mover um dado arquivo; os demais recebem `ENOENT` e reavaliam) e o que
|
|
885
|
+
* foi movido é CONFERIDO contra o que se julgou. Se o arquivo capturado não for o órfão julgado — ou
|
|
886
|
+
* seja, se alguém publicou um lock novo entre o julgamento e a captura — ele é DEVOLVIDO por `link`
|
|
887
|
+
* (que nunca sobrescreve) e a arbitragem recomeça. Assim nenhum caminho remove um lock VIVO.
|
|
888
|
+
* Restrição DOMINANTE do desenho: **nenhum caminho pode deixar o serviço permanentemente incapaz de
|
|
889
|
+
* subir** (órfão real, lock ilegível e lock sumido continuam todos se resolvendo sozinhos).
|
|
890
|
+
*/
|
|
891
|
+
function acquireServiceLock(cfg, deps = {}) {
|
|
892
|
+
const log = deps.log || ((m) => console.log(m));
|
|
893
|
+
const warn = deps.warn || ((m) => console.error(m));
|
|
894
|
+
const isAlive = deps.pidAlive || pidAlive;
|
|
895
|
+
const host = deps.hostname || os.hostname();
|
|
896
|
+
const pid = deps.pid || process.pid;
|
|
897
|
+
const dirs = ensureQueue(cfg.queueDir);
|
|
898
|
+
const lockPath = serviceLockPath(cfg.queueDir);
|
|
899
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
900
|
+
if (publishServiceLock(dirs, lockPath, { pid, host, startedAt: new Date().toISOString() })) {
|
|
901
|
+
log(`[lock] instância única assegurada: ${lockPath} (pid ${pid}, host ${host})`);
|
|
902
|
+
return { path: lockPath, pid, host };
|
|
903
|
+
}
|
|
904
|
+
// Há um dono publicado. Arbitra sobre conteúdo COMPLETO (a publicação é atômica).
|
|
905
|
+
const cur = readServiceLock(lockPath);
|
|
906
|
+
if (cur.state === 'gone') continue; // liberado entre o link e a leitura — tenta publicar de novo.
|
|
907
|
+
if (cur.state === 'ok') {
|
|
908
|
+
const prev = cur.lock;
|
|
909
|
+
if (prev.host && prev.host !== host) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`[FAIL-LOUD] a fila ${cfg.queueDir} está travada por um serviço em OUTRO host ("${prev.host}", pid ${prev.pid}, desde ${prev.startedAt || '?'}) — ` +
|
|
912
|
+
'a fila é LOCAL por contrato (DF2-5): dois hosts sobre o mesmo diretório é erro de operação. Corrija o mount/deploy. ' +
|
|
913
|
+
`Se tiver CERTEZA de que aquele serviço não existe mais, remova ${lockPath} e suba de novo.`,
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
if (isAlive(prev.pid)) {
|
|
917
|
+
throw new Error(
|
|
918
|
+
`[FAIL-LOUD] já existe um pod-export-service servindo esta fila (pid ${prev.pid} em ${prev.host || '?'}, desde ${prev.startedAt || '?'}). ` +
|
|
919
|
+
'Um SEGUNDO loop `serve` na mesma fila não é a topologia suportada (SF10.16/DF-SF10.16-3). Como resolver: ' +
|
|
920
|
+
`\`systemctl status pod-export\` e \`systemctl stop pod-export\` antes de subir outro; ou, se o pid ${prev.pid} comprovadamente não existe ` +
|
|
921
|
+
`(\`ps -p ${prev.pid}\`), remova ${lockPath} e suba de novo.`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
warn(`[lock][AVISO ALTO] lock ÓRFÃO em ${lockPath} (pid ${prev.pid} MORTO, desde ${prev.startedAt || '?'}) — provável SIGKILL/queda abrupta. Tomando posse.`);
|
|
925
|
+
} else {
|
|
926
|
+
warn(`[lock][AVISO ALTO] ${lockPath} ilegível/corrompido — assumindo lock ÓRFÃO e tomando posse (um lock ilegível nunca pode imobilizar o serviço).`);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Tomada de posse REIVINDICADA: move o órfão para um nome privado. Só UM ator consegue.
|
|
930
|
+
const stash = path.join(dirs.tmp, `service.lock.orfao.${pid}.${crypto.randomBytes(6).toString('hex')}`);
|
|
931
|
+
if (!claimByRename(lockPath, stash)) continue; // outro ator já o moveu — reavalia do zero.
|
|
932
|
+
const taken = readServiceLock(stash);
|
|
933
|
+
const mesmoOrfao = (cur.state === 'corrupt' && taken.state === 'corrupt' && taken.raw === cur.raw)
|
|
934
|
+
|| (cur.state === 'ok' && taken.state === 'ok'
|
|
935
|
+
&& taken.lock.pid === cur.lock.pid && taken.lock.startedAt === cur.lock.startedAt);
|
|
936
|
+
if (!mesmoOrfao) {
|
|
937
|
+
// Capturei um lock PUBLICADO DEPOIS do meu julgamento (dono possivelmente VIVO): devolvo por
|
|
938
|
+
// `link` — que nunca sobrescreve — e recomeço a arbitragem sobre ele.
|
|
939
|
+
let devolvido = false;
|
|
940
|
+
try { fs.linkSync(stash, lockPath); devolvido = true; }
|
|
941
|
+
catch (e) { if (!e || e.code !== 'EEXIST') throw e; }
|
|
942
|
+
try { fs.unlinkSync(stash); } catch (e) { if (!e || e.code !== 'ENOENT') throw e; }
|
|
943
|
+
if (!devolvido) {
|
|
944
|
+
throw new Error(
|
|
945
|
+
`[FAIL-LOUD] corrida de arranque anômala em ${lockPath}: o lock foi trocado por outro ator duas vezes durante a arbitragem. ` +
|
|
946
|
+
'NENHUM lock foi tomado por este processo (recusa é o desfecho seguro: nunca dois serviços na mesma fila). ' +
|
|
947
|
+
'Verifique quantos `systemctl start`/processos concorrentes existem sobre esta fila e suba um só.',
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
try { fs.unlinkSync(stash); } catch (e) { if (!e || e.code !== 'ENOENT') throw e; }
|
|
953
|
+
// `lockPath` está livre e o órfão foi meu: a próxima volta publica (e o `link` reserializa).
|
|
954
|
+
}
|
|
955
|
+
throw new Error(
|
|
956
|
+
`[FAIL-LOUD] não foi possível adquirir ${lockPath} após 8 tentativas — disputa persistente de arranque. ` +
|
|
957
|
+
'Verifique se há mais de um `systemctl start` concorrente sobre a mesma fila.',
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Libera o lock de instância. Só remove o lock que É NOSSO (compara o pid gravado) — nunca o de um
|
|
962
|
+
* sucessor que já tomou posse. Best-effort e LOGADO: falhar aqui deixa um órfão, que o arranque
|
|
963
|
+
* seguinte cura sozinho; lançar durante `SIGTERM` seria pior (DF-SF10.16-3: nada imobiliza o serviço). */
|
|
964
|
+
function releaseServiceLock(handle, deps = {}) {
|
|
965
|
+
if (!handle || !handle.path) return false;
|
|
966
|
+
const warn = deps.warn || ((m) => console.error(m));
|
|
967
|
+
// Mesma sanção por errno da aquisição (LOCK-1): `ENOENT` = nada a liberar; conteúdo de OUTRO dono =
|
|
968
|
+
// não é nosso, não removemos; e um erro de E/S REAL (EACCES/EIO) não pode ser confundido com
|
|
969
|
+
// "corrompido" — sem conseguir provar a posse, NÃO removemos (o órfão resultante se cura no
|
|
970
|
+
// arranque seguinte; remover o lock de outro seria pior). Nunca lança: roda em SIGTERM/finally.
|
|
971
|
+
let cur;
|
|
972
|
+
try { cur = readServiceLock(handle.path); }
|
|
973
|
+
catch (e) {
|
|
974
|
+
warn(`[lock][AVISO ALTO] não foi possível LER ${handle.path} para liberar (${e.code || e.message}) — posse não comprovada, o lock NÃO será removido. O próximo arranque arbitra.`);
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
if (cur.state === 'gone') return false;
|
|
978
|
+
if (cur.state === 'ok' && cur.lock.pid !== handle.pid) return false; // já é de outro dono.
|
|
979
|
+
try { fs.unlinkSync(handle.path); return true; }
|
|
980
|
+
catch (e) {
|
|
981
|
+
if (e && e.code === 'ENOENT') return false;
|
|
982
|
+
warn(`[lock][AVISO ALTO] falha ao liberar ${handle.path} (${e.code || e.message}) — o próximo arranque tomará posse do órfão automaticamente.`);
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* RELATO de órfãos em `processing/` no arranque (SF10.16/AC5, DF-SF10.16-3.2): marcadores deixados por um
|
|
989
|
+
* run interrompido (`systemctl restart`/crash no meio). São LISTADOS e LOGADOS ALTO com a idade.
|
|
990
|
+
* **REQUEUE AUTOMÁTICO É PROIBIDO**: o run órfão pode ter consumido LLM e escrito um `runDir`
|
|
991
|
+
* parcial — reenfileirar por conta própria gasta dinheiro de novo e pode produzir estado
|
|
992
|
+
* inconsistente. Reenfileirar é AÇÃO DE OPERADOR, documentada no runbook. Esta função NUNCA move
|
|
993
|
+
* nada; só informa (a resposta honesta a "e um restart no meio de um run?": o run é perdido, e agora
|
|
994
|
+
* se SABE disso — antes o marcador ficava lá para sempre, sem dono e sem alarme).
|
|
995
|
+
*/
|
|
996
|
+
function reportProcessingOrphans(cfg, deps = {}) {
|
|
997
|
+
const warn = deps.warn || ((m) => console.error(m));
|
|
998
|
+
const dir = path.join(cfg.queueDir, 'processing');
|
|
999
|
+
if (!fs.existsSync(dir)) return [];
|
|
1000
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
1001
|
+
const orphans = [];
|
|
1002
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
1003
|
+
if (!f.endsWith('.json') || f.endsWith('.result.json')) continue;
|
|
1004
|
+
const p = path.join(dir, f);
|
|
1005
|
+
let mtimeMs = null;
|
|
1006
|
+
try { mtimeMs = fs.statSync(p).mtimeMs; } catch (e) { if (!e || e.code !== 'ENOENT') throw e; continue; }
|
|
1007
|
+
orphans.push({ name: f, path: p, ageMs: Math.max(0, now - mtimeMs), runId: f.slice(0, -'.json'.length) });
|
|
1008
|
+
}
|
|
1009
|
+
if (orphans.length > 0) {
|
|
1010
|
+
warn(`[fila][AVISO ALTO] ${orphans.length} marcador(es) ÓRFÃO(S) em processing/ — runs interrompidos (restart/crash no meio). REQUEUE AUTOMÁTICO NÃO É FEITO (o run pode ter gasto LLM e escrito runDir parcial): triagem é ação de operador — ver runbook §"Órfão em processing/".`);
|
|
1011
|
+
for (const o of orphans) {
|
|
1012
|
+
warn(`[fila][AVISO ALTO] ${o.name} (parado há ${Math.round(o.ageMs / 1000)}s) — inspecione com \`status ${o.runId}\` antes de decidir.`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return orphans;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Loop principal: preflight (fail-loud), lock de instância única, relato de órfãos, e então poll de
|
|
1020
|
+
* pending/. `deps.stopAfter` (nº de ciclos) e `deps.sleep` tornam o loop TESTÁVEL sem rodar para
|
|
1021
|
+
* sempre. Sem stopAfter roda indefinido.
|
|
1022
|
+
*
|
|
1023
|
+
* SF10.16: o loop NUNCA encerra por perder uma disputa de item — `processOne` devolve
|
|
1024
|
+
* `{claimed:false}` e o serviço SEGUE servindo (antes, o `ENOENT` do rename subia até `main().catch`
|
|
1025
|
+
* e derrubava o worker inteiro por UM item). O `existsSync` pré-pickup foi REMOVIDO: era TOCTOU puro
|
|
1026
|
+
* (a única autorização válida é o rename que sucede).
|
|
1027
|
+
*/
|
|
1028
|
+
async function serve(cfg, deps = {}) {
|
|
1029
|
+
const log = deps.log || ((m) => console.log(m));
|
|
1030
|
+
const summary = preflight(cfg, deps);
|
|
1031
|
+
log(`[preflight OK] chrome=${summary.chrome} framework=${summary.coherence ? summary.coherence.engineVersion : 'n/a'} frontier=${JSON.stringify(summary.frontier)}`);
|
|
1032
|
+
ensureQueue(cfg.queueDir);
|
|
1033
|
+
// Lock APÓS o preflight e ANTES do primeiro poll (DF-SF10.16-3.1).
|
|
1034
|
+
const lock = acquireServiceLock(cfg, deps);
|
|
1035
|
+
reportProcessingOrphans(cfg, deps);
|
|
1036
|
+
// Liberação em saída limpa E em SIGTERM/SIGINT: sem estes handlers, todo `systemctl stop` (que
|
|
1037
|
+
// envia SIGTERM) deixaria um lock órfão e a tomada de posse viraria ROTINA em vez de rede de
|
|
1038
|
+
// segurança. SIGKILL continua coberto pela tomada de posse no arranque seguinte.
|
|
1039
|
+
const onExit = () => { releaseServiceLock(lock, deps); };
|
|
1040
|
+
const onSignal = (sig) => { log(`[lock] ${sig} recebido — liberando ${lock.path} e encerrando.`); releaseServiceLock(lock, deps); process.exit(sig === 'SIGINT' ? 130 : 143); };
|
|
1041
|
+
const onTerm = () => onSignal('SIGTERM');
|
|
1042
|
+
const onInt = () => onSignal('SIGINT');
|
|
1043
|
+
if (deps.installSignalHandlers !== false) {
|
|
1044
|
+
process.on('exit', onExit); process.on('SIGTERM', onTerm); process.on('SIGINT', onInt);
|
|
1045
|
+
}
|
|
1046
|
+
const doSleep = deps.sleep || sleep;
|
|
1047
|
+
let cycles = 0;
|
|
1048
|
+
const processed = [];
|
|
1049
|
+
try {
|
|
1050
|
+
while (true) {
|
|
1051
|
+
const pending = listPending(cfg.queueDir);
|
|
1052
|
+
for (const p of pending) {
|
|
1053
|
+
const r = await processOne(cfg, p, deps); // a reivindicação (rename) É a autorização.
|
|
1054
|
+
if (r && r.claimed) processed.push(r);
|
|
1055
|
+
}
|
|
1056
|
+
cycles++;
|
|
1057
|
+
if (deps.stopAfter && cycles >= deps.stopAfter) break;
|
|
1058
|
+
await doSleep(cfg.pollMs || DEFAULT_POLL_MS);
|
|
1059
|
+
}
|
|
1060
|
+
} finally {
|
|
1061
|
+
if (deps.installSignalHandlers !== false) {
|
|
1062
|
+
process.removeListener('exit', onExit); process.removeListener('SIGTERM', onTerm); process.removeListener('SIGINT', onInt);
|
|
1063
|
+
}
|
|
1064
|
+
releaseServiceLock(lock, deps);
|
|
1065
|
+
}
|
|
1066
|
+
return { cycles, processed };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
// CLI: start | approve | enqueue | preflight | status.
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
|
|
1073
|
+
function loadConfig(env = process.env, overrides = {}) {
|
|
1074
|
+
const bundleDir = overrides.bundleDir || env.PODEXPORT_BUNDLE_DIR;
|
|
1075
|
+
const queueDir = overrides.queueDir || env.PODEXPORT_QUEUE_DIR;
|
|
1076
|
+
return {
|
|
1077
|
+
bundleDir: bundleDir ? path.resolve(bundleDir) : null,
|
|
1078
|
+
queueDir: queueDir ? path.resolve(queueDir) : null,
|
|
1079
|
+
runsRoot: path.resolve(overrides.runsRoot || env.PODEXPORT_RUNS_DIR || path.join(REPO_ROOT, '.power-claude', 'pod-export-runs')),
|
|
1080
|
+
lockOverride: overrides.lock || env.PODEXPORT_LOCK || null,
|
|
1081
|
+
pollMs: Number(overrides.pollMs || env.PODEXPORT_POLL_MS || DEFAULT_POLL_MS),
|
|
1082
|
+
identitiesFile: overrides.identitiesFile || env.PODEXPORT_IDENTITIES || null,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* enqueue: escritor de trigger (CLI/F3). Valida `started_by` (AC1/AC2, DF3-3) e escreve o payload em
|
|
1088
|
+
* <queue>/pending/. `started_by` é OBRIGATÓRIO e deve ser uma identidade CONHECIDA (config
|
|
1089
|
+
* PODEXPORT_IDENTITIES) — ausente ou fora da lista → recusa fail-loud (nunca run anônimo na v1).
|
|
1090
|
+
* `deps.startedBy` é o açúcar `--started-by` (injeta o campo se o payload não o traz; a fonte-de-
|
|
1091
|
+
* verdade continua sendo o campo do payload). `deps.identityModel` injetável (CI hermética).
|
|
1092
|
+
*/
|
|
1093
|
+
function enqueue(cfg, srcPayload, deps = {}) {
|
|
1094
|
+
const dirs = ensureQueue(cfg.queueDir);
|
|
1095
|
+
const src = path.resolve(srcPayload);
|
|
1096
|
+
if (!fs.existsSync(src)) throw new Error(`payload não encontrado: ${srcPayload}`);
|
|
1097
|
+
const payload = JSON.parse(fs.readFileSync(src, 'utf8')); // valida que é JSON antes de enfileirar.
|
|
1098
|
+
if (deps.startedBy != null) payload.started_by = deps.startedBy; // açúcar --started-by.
|
|
1099
|
+
const identityModel = deps.identityModel || loadIdentities({ env: deps.env || process.env, file: cfg.identitiesFile });
|
|
1100
|
+
const startedBy = payload.started_by;
|
|
1101
|
+
if (typeof startedBy !== 'string' || startedBy.trim() === '') {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
'[FAIL-LOUD] payload sem "started_by" — todo run registra quem o iniciou (seleção honesta, ' +
|
|
1104
|
+
'DF3-3). Nunca run anônimo na v1. Adicione "started_by":"<id>" ao payload ou use --started-by <id>.',
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
if (!isKnownIdentity(identityModel, startedBy)) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`[FAIL-LOUD] started_by "${startedBy}" fora da lista de identidades conhecidas (PODEXPORT_IDENTITIES) — ` +
|
|
1110
|
+
'não é campo livre; é rastreabilidade real. Adicione a identidade à config ou corrija o payload.',
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
const name = `${Date.now()}-${path.basename(src)}`;
|
|
1114
|
+
const dest = path.join(dirs.pending, name);
|
|
1115
|
+
// PUBLICAÇÃO ATÔMICA (SF10.16/AC3, DF-SF10.16-2.3): grava em `<queue>/.tmp/` (MESMO filesystem, requisito
|
|
1116
|
+
// do rename) e só então renomeia para `pending/`. O item passa a existir COMPLETO ou a não existir.
|
|
1117
|
+
// Antes, `writeFileSync` direto no destino tornava o nome visível ao `readdir` do serviço ANTES do
|
|
1118
|
+
// conteúdo: um payload meio-publicado era reivindicado, entregue VAZIO ao executor e registrado com
|
|
1119
|
+
// `started_by: null` — o gate fail-loud de identidade de DF3-3 era contornado EM SILÊNCIO.
|
|
1120
|
+
const tmp = path.join(dirs.tmp, `${name}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1121
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2)); // grava o payload (pode ter started_by injetado).
|
|
1122
|
+
try {
|
|
1123
|
+
fs.renameSync(tmp, dest); // torna VISÍVEL, já completo.
|
|
1124
|
+
} catch (e) {
|
|
1125
|
+
try { fs.unlinkSync(tmp); } catch (e2) { if (!e2 || e2.code !== 'ENOENT') throw e2; } // não deixa lixo em .tmp/.
|
|
1126
|
+
throw e; // publicação falhou → fail-loud (nada de "enfileirado" que não existe).
|
|
1127
|
+
}
|
|
1128
|
+
return dest;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
// Ledger de USO por `started_by` (DF3-4) — freio mínimo, NÃO billing. Log append-only JSONL, 1
|
|
1133
|
+
// registro por run finalizado (escrito pelo serviço em processOne). Campos: {started_by, runId, pod,
|
|
1134
|
+
// tokens_in, tokens_out, imagens, at}. Os tokens vêm do `usage` que o executor expõe no RESULTADO
|
|
1135
|
+
// (forward-compat: quando ausente, registra 0 — o freio fica vazio, honesto). `usage` AGREGA por
|
|
1136
|
+
// started_by: dá para VER quem gasta, NUNCA para CAPAR (linha 127 do ADR verbatim).
|
|
1137
|
+
// ---------------------------------------------------------------------------
|
|
1138
|
+
|
|
1139
|
+
function usageLogPath(cfg) { return path.join(cfg.queueDir, 'usage.log.jsonl'); }
|
|
1140
|
+
|
|
1141
|
+
/** Extrai o usage de um RESULTADO do executor (tolerante a nomes: input_tokens/tokens_in etc). */
|
|
1142
|
+
function extractUsage(result) {
|
|
1143
|
+
const u = (result && result.result && result.result.usage) || {};
|
|
1144
|
+
return {
|
|
1145
|
+
tokens_in: Number(u.input_tokens || u.tokens_in || 0) || 0,
|
|
1146
|
+
tokens_out: Number(u.output_tokens || u.tokens_out || 0) || 0,
|
|
1147
|
+
imagens: Number(u.images || u.imagens || 0) || 0,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Anexa 1 registro ao ledger append-only (JSONL). No-op sem queueDir. Idempotência não garantida (é log). */
|
|
1152
|
+
function appendUsage(cfg, record) {
|
|
1153
|
+
if (!cfg.queueDir) return null;
|
|
1154
|
+
ensureQueue(cfg.queueDir);
|
|
1155
|
+
const line = JSON.stringify({
|
|
1156
|
+
started_by: record.started_by || null,
|
|
1157
|
+
runId: record.runId,
|
|
1158
|
+
pod: record.pod || null,
|
|
1159
|
+
kind: record.kind || null,
|
|
1160
|
+
tokens_in: record.tokens_in || 0,
|
|
1161
|
+
tokens_out: record.tokens_out || 0,
|
|
1162
|
+
imagens: record.imagens || 0,
|
|
1163
|
+
at: record.at || new Date().toISOString(),
|
|
1164
|
+
});
|
|
1165
|
+
fs.appendFileSync(usageLogPath(cfg), `${line}\n`);
|
|
1166
|
+
return usageLogPath(cfg);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Agrega o ledger por `started_by`: {started_by, runs, tokens_in, tokens_out, imagens}. Linhas ilegíveis são puladas. */
|
|
1170
|
+
function aggregateUsage(cfg) {
|
|
1171
|
+
const p = usageLogPath(cfg);
|
|
1172
|
+
const agg = Object.create(null);
|
|
1173
|
+
if (!cfg.queueDir || !fs.existsSync(p)) return agg;
|
|
1174
|
+
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
1175
|
+
if (!line.trim()) continue;
|
|
1176
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
1177
|
+
const k = r.started_by || '(desconhecido)';
|
|
1178
|
+
if (!agg[k]) agg[k] = { started_by: k, runs: 0, tokens_in: 0, tokens_out: 0, imagens: 0 };
|
|
1179
|
+
agg[k].runs += 1;
|
|
1180
|
+
agg[k].tokens_in += Number(r.tokens_in || 0) || 0;
|
|
1181
|
+
agg[k].tokens_out += Number(r.tokens_out || 0) || 0;
|
|
1182
|
+
agg[k].imagens += Number(r.imagens || 0) || 0;
|
|
1183
|
+
}
|
|
1184
|
+
return agg;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function main() {
|
|
1188
|
+
const argv = process.argv.slice(2);
|
|
1189
|
+
const cmd = argv[0];
|
|
1190
|
+
const cfg = loadConfig();
|
|
1191
|
+
|
|
1192
|
+
if (cmd === 'start') {
|
|
1193
|
+
if (!cfg.bundleDir || !cfg.queueDir) { console.error('start exige PODEXPORT_BUNDLE_DIR e PODEXPORT_QUEUE_DIR no env.'); process.exit(2); }
|
|
1194
|
+
console.log(`[pod-export-service] start — bundle=${cfg.bundleDir} queue=${cfg.queueDir} runs=${cfg.runsRoot}`);
|
|
1195
|
+
await serve(cfg);
|
|
1196
|
+
} else if (cmd === 'preflight') {
|
|
1197
|
+
if (!cfg.bundleDir) { console.error('preflight exige ao menos PODEXPORT_BUNDLE_DIR (coerência de versão) no env.'); process.exit(2); }
|
|
1198
|
+
try {
|
|
1199
|
+
const s = preflight(cfg);
|
|
1200
|
+
console.log('[preflight OK]', JSON.stringify(s));
|
|
1201
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
1202
|
+
} else if (cmd === 'enqueue') {
|
|
1203
|
+
if (!cfg.queueDir) { console.error('enqueue exige PODEXPORT_QUEUE_DIR no env.'); process.exit(2); }
|
|
1204
|
+
const sbIdx = argv.indexOf('--started-by');
|
|
1205
|
+
const startedBy = sbIdx !== -1 ? argv[sbIdx + 1] : undefined; // açúcar; a fonte-de-verdade é o campo do payload.
|
|
1206
|
+
try {
|
|
1207
|
+
const dest = enqueue(cfg, argv[1], { startedBy });
|
|
1208
|
+
console.log(`[enqueue] ${dest}`);
|
|
1209
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
1210
|
+
} else if (cmd === 'approve') {
|
|
1211
|
+
// approve via o PRÓPRIO serviço (achado 6) + GATE F3 (DF3-3): exige --as <id> (aprovador
|
|
1212
|
+
// designado) E o segredo do aprovador (PODEXPORT_APPROVER_SECRET no env desta invocação,
|
|
1213
|
+
// verificado vs PODEXPORT_APPROVER_SECRET_HASH). Seleção não autoriza; o backend recusa
|
|
1214
|
+
// identidade/segredo errado. --runs-root vem da config; reconcilia o marcador de awaiting/.
|
|
1215
|
+
const runId = argv[1];
|
|
1216
|
+
const asIdx = argv.indexOf('--as');
|
|
1217
|
+
const asId = asIdx !== -1 ? argv[asIdx + 1] : undefined;
|
|
1218
|
+
if (!cfg.bundleDir || !runId) { console.error('approve exige PODEXPORT_BUNDLE_DIR e <runId> --as <id> (segredo via PODEXPORT_APPROVER_SECRET; runsRoot de PODEXPORT_RUNS_DIR).'); process.exit(2); }
|
|
1219
|
+
const r = await approveRun(cfg, runId, { asId });
|
|
1220
|
+
if (r.denied) { console.error(`[approve][RECUSADO] ${r.reason}`); process.exit(r.code || 3); }
|
|
1221
|
+
process.exit(r.code || 0);
|
|
1222
|
+
} else if (cmd === 'status') {
|
|
1223
|
+
const runId = argv[1];
|
|
1224
|
+
if (!cfg.bundleDir || !runId) { console.error('status exige PODEXPORT_BUNDLE_DIR e <runId>.'); process.exit(2); }
|
|
1225
|
+
const child = spawn(process.execPath, [EXECUTOR_PATH, cfg.bundleDir, 'status', runId, '--runs-root', cfg.runsRoot], { stdio: 'inherit' });
|
|
1226
|
+
child.on('exit', (code) => process.exit(code || 0));
|
|
1227
|
+
} else if (cmd === 'usage') {
|
|
1228
|
+
// Ledger de uso agregado por started_by (DF3-4) — freio, VER quem gasta; NUNCA capar orçamento.
|
|
1229
|
+
if (!cfg.queueDir) { console.error('usage exige PODEXPORT_QUEUE_DIR no env.'); process.exit(2); }
|
|
1230
|
+
console.log(JSON.stringify(aggregateUsage(cfg), null, 2));
|
|
1231
|
+
} else if (cmd === 'identities') {
|
|
1232
|
+
// Valida/inspeciona a config de identidades (fail-loud). Nunca imprime segredo (não há segredo aqui).
|
|
1233
|
+
try {
|
|
1234
|
+
const m = loadIdentities({ file: cfg.identitiesFile });
|
|
1235
|
+
console.log(JSON.stringify({ count: m.identities.length, approver: m.approver.id, ids: m.identities.map((i) => i.id) }, null, 2));
|
|
1236
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
1237
|
+
} else if (cmd === 'hash-secret') {
|
|
1238
|
+
// Gera o hash a colocar em PODEXPORT_APPROVER_SECRET_HASH a partir de PODEXPORT_APPROVER_SECRET.
|
|
1239
|
+
// Imprime SÓ o hash (inútil ao atacante isolado); o CLARO nunca é logado.
|
|
1240
|
+
const s = process.env.PODEXPORT_APPROVER_SECRET;
|
|
1241
|
+
if (!s || String(s).trim() === '') { console.error('defina PODEXPORT_APPROVER_SECRET no env para gerar o hash (o claro nunca é logado).'); process.exit(2); }
|
|
1242
|
+
console.log(hashApproverSecret(s));
|
|
1243
|
+
} else {
|
|
1244
|
+
console.error('Uso: node scripts/pod-export-service.cjs <start|preflight|enqueue <payload.json> [--started-by <id>]|approve <runId> --as <id>|status <runId>|usage|identities|hash-secret>');
|
|
1245
|
+
console.error('Config por ENV: PODEXPORT_BUNDLE_DIR, PODEXPORT_QUEUE_DIR, PODEXPORT_RUNS_DIR, PODEXPORT_LOCK, PODEXPORT_POLL_MS, PODEXPORT_IDENTITIES, PODEXPORT_APPROVER_SECRET(_HASH), ANTHROPIC_API_KEY, OPENROUTER_API_KEY, CHROME_BIN.');
|
|
1246
|
+
process.exit(2);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (require.main === module) {
|
|
1251
|
+
main().catch((e) => { console.error('[ERRO]', e.message); process.exit(1); });
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
module.exports = {
|
|
1255
|
+
getApiKey,
|
|
1256
|
+
getAnthropicApiKey,
|
|
1257
|
+
getOpenRouterApiKey,
|
|
1258
|
+
safeRunId,
|
|
1259
|
+
blessedVetoRels,
|
|
1260
|
+
assertCommandCheckFrontier,
|
|
1261
|
+
preflight,
|
|
1262
|
+
ensureQueue,
|
|
1263
|
+
claimByRename,
|
|
1264
|
+
listPending,
|
|
1265
|
+
genRunId,
|
|
1266
|
+
parseExecutorResult,
|
|
1267
|
+
classifyOutcome,
|
|
1268
|
+
spawnExecutor,
|
|
1269
|
+
processOne,
|
|
1270
|
+
buildApproveArgs,
|
|
1271
|
+
deriveRunKind,
|
|
1272
|
+
claimApproveMarker,
|
|
1273
|
+
releaseApproveMarker,
|
|
1274
|
+
reconcileMarker,
|
|
1275
|
+
approveRun,
|
|
1276
|
+
serviceLockPath,
|
|
1277
|
+
pidAlive,
|
|
1278
|
+
acquireServiceLock,
|
|
1279
|
+
releaseServiceLock,
|
|
1280
|
+
reportProcessingOrphans,
|
|
1281
|
+
serve,
|
|
1282
|
+
loadConfig,
|
|
1283
|
+
enqueue,
|
|
1284
|
+
loadIdentities,
|
|
1285
|
+
isKnownIdentity,
|
|
1286
|
+
hashApproverSecret,
|
|
1287
|
+
verifyApproverSecret,
|
|
1288
|
+
authorizeApproval,
|
|
1289
|
+
usageLogPath,
|
|
1290
|
+
extractUsage,
|
|
1291
|
+
appendUsage,
|
|
1292
|
+
aggregateUsage,
|
|
1293
|
+
QUEUE_SUBDIRS,
|
|
1294
|
+
TMP_SUBDIR,
|
|
1295
|
+
SERVICE_LOCK_FILE,
|
|
1296
|
+
EXECUTOR_PATH,
|
|
1297
|
+
};
|