dsh-compound-engineering 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/README.zh.md +161 -0
- package/cordis.patch.yml +6 -0
- package/lib/frontmatter.js +167 -0
- package/lib/index.js +46 -0
- package/lib/provider.js +201 -0
- package/package.json +62 -4
- package/skills/CODING_STANDARDS.md +68 -0
- package/skills/ce-babysit-pr/SKILL.md +59 -0
- package/skills/ce-babysit-pr/references/branch-currency.md +22 -0
- package/skills/ce-babysit-pr/references/envelope.md +44 -0
- package/skills/ce-babysit-pr/references/pipeline.md +15 -0
- package/skills/ce-babysit-pr/references/report.md +20 -0
- package/skills/ce-babysit-pr/references/settle.md +37 -0
- package/skills/ce-babysit-pr/references/setup.md +33 -0
- package/skills/ce-babysit-pr/references/stack-commands.md +39 -0
- package/skills/ce-babysit-pr/references/stack.md +43 -0
- package/skills/ce-babysit-pr/references/tick.md +102 -0
- package/skills/ce-babysit-pr/references/watch-loop.md +222 -0
- package/skills/ce-babysit-pr/scripts/pr-snapshot +3656 -0
- package/skills/ce-bakeoff/SKILL.md +48 -0
- package/skills/ce-bakeoff/references/candidates.md +37 -0
- package/skills/ce-bakeoff/references/judging.md +19 -0
- package/skills/ce-bakeoff/references/output.md +11 -0
- package/skills/ce-bakeoff/references/verification.md +15 -0
- package/skills/ce-brainstorm/SKILL.md +69 -0
- package/skills/ce-brainstorm/references/agents/slack-researcher.md +127 -0
- package/skills/ce-brainstorm/references/approaches.md +57 -0
- package/skills/ce-brainstorm/references/bakeoff.md +9 -0
- package/skills/ce-brainstorm/references/blindspot-pass.md +70 -0
- package/skills/ce-brainstorm/references/brainstorm-sections.md +458 -0
- package/skills/ce-brainstorm/references/dialogue.md +89 -0
- package/skills/ce-brainstorm/references/handoff.md +187 -0
- package/skills/ce-brainstorm/references/html-rendering.md +638 -0
- package/skills/ce-brainstorm/references/interaction-rules.md +33 -0
- package/skills/ce-brainstorm/references/markdown-rendering.md +242 -0
- package/skills/ce-brainstorm/references/model-tiers.md +13 -0
- package/skills/ce-brainstorm/references/output-mode.md +26 -0
- package/skills/ce-brainstorm/references/phase-0.md +104 -0
- package/skills/ce-brainstorm/references/plan-write.md +32 -0
- package/skills/ce-brainstorm/references/product-pressure-test.md +42 -0
- package/skills/ce-brainstorm/references/reasoning-elevation.md +124 -0
- package/skills/ce-brainstorm/references/settled-decisions.md +45 -0
- package/skills/ce-brainstorm/references/synthesis-summary.md +149 -0
- package/skills/ce-brainstorm/references/universal-brainstorming.md +72 -0
- package/skills/ce-brainstorm/references/verdict-routing.md +26 -0
- package/skills/ce-brainstorm/references/visual-probes.md +164 -0
- package/skills/ce-brainstorm/scripts/elevation-dispatch.sh +288 -0
- package/skills/ce-brainstorm/scripts/light-webserver.js +1275 -0
- package/skills/ce-brainstorm/scripts/packs-resolve.py +737 -0
- package/skills/ce-brainstorm/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-code-review/SKILL.md +40 -0
- package/skills/ce-code-review/references/action-class-rubric.md +67 -0
- package/skills/ce-code-review/references/cross-model-recovery.md +38 -0
- package/skills/ce-code-review/references/cross-model-review.md +191 -0
- package/skills/ce-code-review/references/depth-paths.md +32 -0
- package/skills/ce-code-review/references/diff-scope.md +59 -0
- package/skills/ce-code-review/references/dispatch-reviewers.md +135 -0
- package/skills/ce-code-review/references/findings-schema.json +125 -0
- package/skills/ce-code-review/references/finish-input.md +118 -0
- package/skills/ce-code-review/references/finish-review.md +202 -0
- package/skills/ce-code-review/references/intent-and-plan.md +73 -0
- package/skills/ce-code-review/references/modes-and-output.md +156 -0
- package/skills/ce-code-review/references/persona-catalog.md +66 -0
- package/skills/ce-code-review/references/personas/adversarial-reviewer.md +111 -0
- package/skills/ce-code-review/references/personas/agent-native-reviewer.md +173 -0
- package/skills/ce-code-review/references/personas/api-contract-reviewer.md +44 -0
- package/skills/ce-code-review/references/personas/correctness-reviewer.md +48 -0
- package/skills/ce-code-review/references/personas/data-migration-reviewer.md +111 -0
- package/skills/ce-code-review/references/personas/deployment-verification-agent.md +157 -0
- package/skills/ce-code-review/references/personas/julik-frontend-races-reviewer.md +45 -0
- package/skills/ce-code-review/references/personas/learnings-researcher.md +260 -0
- package/skills/ce-code-review/references/personas/maintainability-reviewer.md +85 -0
- package/skills/ce-code-review/references/personas/performance-reviewer.md +45 -0
- package/skills/ce-code-review/references/personas/previous-comments-reviewer.md +59 -0
- package/skills/ce-code-review/references/personas/project-standards-reviewer.md +77 -0
- package/skills/ce-code-review/references/personas/reliability-reviewer.md +49 -0
- package/skills/ce-code-review/references/personas/security-reviewer.md +50 -0
- package/skills/ce-code-review/references/personas/swift-ios-reviewer.md +99 -0
- package/skills/ce-code-review/references/personas/testing-reviewer.md +51 -0
- package/skills/ce-code-review/references/review-output-template.md +172 -0
- package/skills/ce-code-review/references/scope.md +176 -0
- package/skills/ce-code-review/references/select-and-route.md +93 -0
- package/skills/ce-code-review/references/subagent-template.md +206 -0
- package/skills/ce-code-review/references/validator-batch-template.md +67 -0
- package/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +1275 -0
- package/skills/ce-code-review/scripts/findings-mechanics.py +274 -0
- package/skills/ce-code-review/scripts/packs-resolve.py +737 -0
- package/skills/ce-code-review/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-code-review/scripts/review-scope.py +419 -0
- package/skills/ce-code-review/scripts/run-log.py +280 -0
- package/skills/ce-commit/SKILL.md +60 -0
- package/skills/ce-commit-push-pr/SKILL.md +69 -0
- package/skills/ce-commit-push-pr/references/apply-and-handoff.md +79 -0
- package/skills/ce-commit-push-pr/references/branch-creation.md +47 -0
- package/skills/ce-commit-push-pr/references/commit-and-push.md +26 -0
- package/skills/ce-commit-push-pr/references/compose.md +29 -0
- package/skills/ce-commit-push-pr/references/context.md +42 -0
- package/skills/ce-commit-push-pr/references/gh-stack-cli.md +83 -0
- package/skills/ce-commit-push-pr/references/pr-description-writing.md +208 -0
- package/skills/ce-commit-push-pr/references/stack-submit.md +81 -0
- package/skills/ce-compound/SKILL.md +79 -0
- package/skills/ce-compound/assets/resolution-template.md +96 -0
- package/skills/ce-compound/references/agents/best-practices-researcher.md +115 -0
- package/skills/ce-compound/references/agents/data-integrity-guardian.md +68 -0
- package/skills/ce-compound/references/agents/framework-docs-researcher.md +93 -0
- package/skills/ce-compound/references/agents/pattern-recognition-specialist.md +55 -0
- package/skills/ce-compound/references/agents/performance-oracle.md +108 -0
- package/skills/ce-compound/references/agents/security-sentinel.md +91 -0
- package/skills/ce-compound/references/agents/session-historian.md +82 -0
- package/skills/ce-compound/references/assembly.md +127 -0
- package/skills/ce-compound/references/concepts-vocabulary.md +98 -0
- package/skills/ce-compound/references/enhancement.md +42 -0
- package/skills/ce-compound/references/grounding-validation.md +89 -0
- package/skills/ce-compound/references/lightweight.md +56 -0
- package/skills/ce-compound/references/modes.md +13 -0
- package/skills/ce-compound/references/refresh-and-discoverability.md +96 -0
- package/skills/ce-compound/references/report.md +115 -0
- package/skills/ce-compound/references/research.md +144 -0
- package/skills/ce-compound/references/schema.yaml +236 -0
- package/skills/ce-compound/references/session-history.md +73 -0
- package/skills/ce-compound/references/yaml-schema.md +130 -0
- package/skills/ce-compound/scripts/packs-resolve.py +737 -0
- package/skills/ce-compound/scripts/session-history/__pycache__/dsh_session.cpython-314.pyc +0 -0
- package/skills/ce-compound/scripts/session-history/discover-sessions.sh +290 -0
- package/skills/ce-compound/scripts/session-history/discover_dsh.py +123 -0
- package/skills/ce-compound/scripts/session-history/dsh_session.py +225 -0
- package/skills/ce-compound/scripts/session-history/extract-errors.py +178 -0
- package/skills/ce-compound/scripts/session-history/extract-metadata.py +301 -0
- package/skills/ce-compound/scripts/session-history/extract-skeleton.py +346 -0
- package/skills/ce-compound/scripts/validate-doc-claims.py +497 -0
- package/skills/ce-compound/scripts/validate-frontmatter.py +137 -0
- package/skills/ce-compound-refresh/SKILL.md +88 -0
- package/skills/ce-compound-refresh/assets/resolution-template.md +96 -0
- package/skills/ce-compound-refresh/references/classify.md +42 -0
- package/skills/ce-compound-refresh/references/commit.md +7 -0
- package/skills/ce-compound-refresh/references/concepts-vocabulary.md +116 -0
- package/skills/ce-compound-refresh/references/discoverability.md +15 -0
- package/skills/ce-compound-refresh/references/investigate.md +23 -0
- package/skills/ce-compound-refresh/references/modes.md +27 -0
- package/skills/ce-compound-refresh/references/per-action-flows.md +142 -0
- package/skills/ce-compound-refresh/references/report.md +23 -0
- package/skills/ce-compound-refresh/references/schema.yaml +236 -0
- package/skills/ce-compound-refresh/references/scope.md +18 -0
- package/skills/ce-compound-refresh/references/worth-audit.md +35 -0
- package/skills/ce-compound-refresh/references/yaml-schema.md +130 -0
- package/skills/ce-compound-refresh/scripts/validate-doc-claims.py +497 -0
- package/skills/ce-compound-refresh/scripts/validate-frontmatter.py +137 -0
- package/skills/ce-debug/SKILL.md +122 -0
- package/skills/ce-debug/references/anti-patterns.md +91 -0
- package/skills/ce-debug/references/defense-in-depth.md +39 -0
- package/skills/ce-debug/references/fix.md +21 -0
- package/skills/ce-debug/references/investigate.md +114 -0
- package/skills/ce-debug/references/investigation-techniques.md +384 -0
- package/skills/ce-debug/references/pipeline-mode.md +78 -0
- package/skills/ce-debug/references/post-fix-handoff.md +60 -0
- package/skills/ce-debug/references/return-to-caller.md +57 -0
- package/skills/ce-doc-review/SKILL.md +75 -0
- package/skills/ce-doc-review/references/bulk-preview.md +146 -0
- package/skills/ce-doc-review/references/cross-model-review.md +207 -0
- package/skills/ce-doc-review/references/decision-primer.md +47 -0
- package/skills/ce-doc-review/references/dispatch.md +56 -0
- package/skills/ce-doc-review/references/document-intake.md +44 -0
- package/skills/ce-doc-review/references/findings-schema.json +85 -0
- package/skills/ce-doc-review/references/modes.md +20 -0
- package/skills/ce-doc-review/references/open-questions-defer.md +158 -0
- package/skills/ce-doc-review/references/persona-selection.md +41 -0
- package/skills/ce-doc-review/references/personas/adversarial-document-reviewer.md +109 -0
- package/skills/ce-doc-review/references/personas/coherence-reviewer.md +69 -0
- package/skills/ce-doc-review/references/personas/design-lens-reviewer.md +48 -0
- package/skills/ce-doc-review/references/personas/feasibility-reviewer.md +50 -0
- package/skills/ce-doc-review/references/personas/product-lens-reviewer.md +85 -0
- package/skills/ce-doc-review/references/personas/scope-guardian-reviewer.md +83 -0
- package/skills/ce-doc-review/references/personas/security-lens-reviewer.md +41 -0
- package/skills/ce-doc-review/references/personas/whole-doc-reviewer.md +25 -0
- package/skills/ce-doc-review/references/rendering-floor.md +175 -0
- package/skills/ce-doc-review/references/review-output-template.md +123 -0
- package/skills/ce-doc-review/references/subagent-template.md +188 -0
- package/skills/ce-doc-review/references/synthesis-and-presentation.md +350 -0
- package/skills/ce-doc-review/references/walkthrough.md +347 -0
- package/skills/ce-doc-review/scripts/cross-model-doc-review.sh +1270 -0
- package/skills/ce-doc-review/scripts/packs-resolve.py +737 -0
- package/skills/ce-doc-review/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-dogfood/SKILL.md +66 -0
- package/skills/ce-dogfood/agents/openai.yaml +2 -0
- package/skills/ce-dogfood/references/dogfood-report-template.md +97 -0
- package/skills/ce-dogfood/references/phases.md +167 -0
- package/skills/ce-dogfood/references/test-matrix-taxonomy.md +62 -0
- package/skills/ce-dogfood/scripts/packs-resolve.py +737 -0
- package/skills/ce-explain/SKILL.md +81 -0
- package/skills/ce-explain/references/agents/behavior-trace-scout.md +19 -0
- package/skills/ce-explain/references/agents/work-recap-scout.md +23 -0
- package/skills/ce-explain/references/check-in.md +18 -0
- package/skills/ce-explain/references/destinations.md +35 -0
- package/skills/ce-explain/references/explainer-html.md +29 -0
- package/skills/ce-explain/references/explainer-markdown.md +24 -0
- package/skills/ce-explain/references/intake.md +50 -0
- package/skills/ce-explain/references/orchestration.md +46 -0
- package/skills/ce-handoff/SKILL.md +54 -0
- package/skills/ce-handoff/references/create.md +88 -0
- package/skills/ce-handoff/references/resume.md +46 -0
- package/skills/ce-ideate/SKILL.md +75 -0
- package/skills/ce-ideate/references/agents/issue-intelligence-analyst.md +166 -0
- package/skills/ce-ideate/references/agents/learnings-researcher.md +247 -0
- package/skills/ce-ideate/references/agents/slack-researcher.md +127 -0
- package/skills/ce-ideate/references/agents/web-researcher.md +121 -0
- package/skills/ce-ideate/references/decomposition.md +39 -0
- package/skills/ce-ideate/references/divergent-ideation.md +112 -0
- package/skills/ce-ideate/references/grounding.md +138 -0
- package/skills/ce-ideate/references/html-rendering.md +638 -0
- package/skills/ce-ideate/references/ideation-sections.md +191 -0
- package/skills/ce-ideate/references/issue-intelligence.md +44 -0
- package/skills/ce-ideate/references/markdown-rendering.md +242 -0
- package/skills/ce-ideate/references/output-mode.md +34 -0
- package/skills/ce-ideate/references/post-ideation-workflow.md +169 -0
- package/skills/ce-ideate/references/scope-gates.md +148 -0
- package/skills/ce-ideate/references/universal-ideation.md +119 -0
- package/skills/ce-ideate/references/user-research-artifacts.md +28 -0
- package/skills/ce-ideate/references/web-research-cache.md +56 -0
- package/skills/ce-noslop/SKILL.md +45 -0
- package/skills/ce-noslop/references/patterns.md +61 -0
- package/skills/ce-noslop/references/terminology.md +3 -0
- package/skills/ce-optimize/SKILL.md +64 -0
- package/skills/ce-optimize/references/agents/learnings-researcher.md +247 -0
- package/skills/ce-optimize/references/agents/repo-research-analyst.md +258 -0
- package/skills/ce-optimize/references/example-expensive-benchmark-spec.yaml +88 -0
- package/skills/ce-optimize/references/example-hard-spec.yaml +64 -0
- package/skills/ce-optimize/references/example-judge-spec.yaml +83 -0
- package/skills/ce-optimize/references/example-text-target-spec.yaml +80 -0
- package/skills/ce-optimize/references/experiment-log-schema.yaml +563 -0
- package/skills/ce-optimize/references/experiment-prompt-template.md +143 -0
- package/skills/ce-optimize/references/judge-prompt-template.md +117 -0
- package/skills/ce-optimize/references/loop.md +286 -0
- package/skills/ce-optimize/references/measurement.md +168 -0
- package/skills/ce-optimize/references/optimize-spec-schema.yaml +570 -0
- package/skills/ce-optimize/references/persistence.md +93 -0
- package/skills/ce-optimize/references/spec.md +107 -0
- package/skills/ce-optimize/references/text-targets.md +51 -0
- package/skills/ce-optimize/references/usage-guide.md +147 -0
- package/skills/ce-optimize/references/wrap-up.md +54 -0
- package/skills/ce-optimize/scripts/decide.mjs +676 -0
- package/skills/ce-optimize/scripts/experiment-worktree.sh +330 -0
- package/skills/ce-optimize/scripts/measure.sh +140 -0
- package/skills/ce-optimize/scripts/parallel-probe.sh +134 -0
- package/skills/ce-plan/SKILL.md +60 -0
- package/skills/ce-plan/references/agents/agent-native-planning-strategist.md +66 -0
- package/skills/ce-plan/references/agents/architecture-strategist.md +46 -0
- package/skills/ce-plan/references/agents/best-practices-researcher.md +118 -0
- package/skills/ce-plan/references/agents/data-integrity-guardian.md +68 -0
- package/skills/ce-plan/references/agents/framework-docs-researcher.md +97 -0
- package/skills/ce-plan/references/agents/git-history-analyzer.md +40 -0
- package/skills/ce-plan/references/agents/learnings-researcher.md +268 -0
- package/skills/ce-plan/references/agents/pattern-recognition-specialist.md +55 -0
- package/skills/ce-plan/references/agents/performance-oracle.md +108 -0
- package/skills/ce-plan/references/agents/repo-research-analyst.md +262 -0
- package/skills/ce-plan/references/agents/security-sentinel.md +91 -0
- package/skills/ce-plan/references/agents/slack-researcher.md +131 -0
- package/skills/ce-plan/references/agents/spec-flow-analyzer.md +84 -0
- package/skills/ce-plan/references/agents/web-researcher.md +125 -0
- package/skills/ce-plan/references/approach-altitude.md +55 -0
- package/skills/ce-plan/references/bakeoff.md +9 -0
- package/skills/ce-plan/references/deepening-workflow.md +263 -0
- package/skills/ce-plan/references/final-review.md +134 -0
- package/skills/ce-plan/references/html-rendering.md +638 -0
- package/skills/ce-plan/references/intake.md +158 -0
- package/skills/ce-plan/references/markdown-rendering.md +242 -0
- package/skills/ce-plan/references/output-contracts.md +29 -0
- package/skills/ce-plan/references/output-mode.md +45 -0
- package/skills/ce-plan/references/plan-handoff.md +116 -0
- package/skills/ce-plan/references/plan-sections.md +489 -0
- package/skills/ce-plan/references/reasoning-elevation.md +124 -0
- package/skills/ce-plan/references/research.md +211 -0
- package/skills/ce-plan/references/resume.md +61 -0
- package/skills/ce-plan/references/settled-decisions.md +45 -0
- package/skills/ce-plan/references/structure.md +207 -0
- package/skills/ce-plan/references/synthesis-summary.md +260 -0
- package/skills/ce-plan/references/universal-planning.md +170 -0
- package/skills/ce-plan/scripts/elevation-dispatch.sh +288 -0
- package/skills/ce-plan/scripts/packs-resolve.py +737 -0
- package/skills/ce-plan/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-polish/SKILL.md +22 -0
- package/skills/ce-polish/agents/openai.yaml +2 -0
- package/skills/ce-polish/references/dev-server-astro.md +58 -0
- package/skills/ce-polish/references/dev-server-detection.md +40 -0
- package/skills/ce-polish/references/dev-server-next.md +61 -0
- package/skills/ce-polish/references/dev-server-nuxt.md +58 -0
- package/skills/ce-polish/references/dev-server-procfile.md +59 -0
- package/skills/ce-polish/references/dev-server-rails.md +50 -0
- package/skills/ce-polish/references/dev-server-remix.md +58 -0
- package/skills/ce-polish/references/dev-server-sveltekit.md +58 -0
- package/skills/ce-polish/references/dev-server-vite.md +48 -0
- package/skills/ce-polish/references/install-riffrec.md +35 -0
- package/skills/ce-polish/references/launch-json-schema.md +177 -0
- package/skills/ce-polish/references/live-loop.md +98 -0
- package/skills/ce-polish/references/live-remote.md +35 -0
- package/skills/ce-polish/references/live-start.md +93 -0
- package/skills/ce-polish/references/live-stream-contract.md +253 -0
- package/skills/ce-polish/references/run.md +67 -0
- package/skills/ce-polish/scripts/detect-project-type.sh +277 -0
- package/skills/ce-polish/scripts/detect-riffrec.sh +225 -0
- package/skills/ce-polish/scripts/live-endpoint.js +2841 -0
- package/skills/ce-polish/scripts/read-launch-json.sh +117 -0
- package/skills/ce-polish/scripts/resolve-package-manager.sh +95 -0
- package/skills/ce-polish/scripts/resolve-port.sh +307 -0
- package/skills/ce-pov/SKILL.md +64 -0
- package/skills/ce-pov/references/agents/external-evidence-researcher.md +26 -0
- package/skills/ce-pov/references/agents/pov-peer.md +43 -0
- package/skills/ce-pov/references/agents/precedent-activity-scout.md +21 -0
- package/skills/ce-pov/references/agents/project-grounding-scout.md +32 -0
- package/skills/ce-pov/references/boundaries.md +36 -0
- package/skills/ce-pov/references/cross-model-panel.md +596 -0
- package/skills/ce-pov/references/followup.md +9 -0
- package/skills/ce-pov/references/grounding.md +47 -0
- package/skills/ce-pov/references/intake.md +61 -0
- package/skills/ce-pov/references/invocation.md +47 -0
- package/skills/ce-pov/references/method.md +68 -0
- package/skills/ce-pov/references/pov-schema.json +56 -0
- package/skills/ce-pov/references/report.md +7 -0
- package/skills/ce-pov/scripts/cross-model-pov.sh +1110 -0
- package/skills/ce-pov/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-product-pulse/SKILL.md +81 -0
- package/skills/ce-product-pulse/agents/openai.yaml +2 -0
- package/skills/ce-product-pulse/references/config.md +20 -0
- package/skills/ce-product-pulse/references/interview.md +260 -0
- package/skills/ce-product-pulse/references/report-template.md +89 -0
- package/skills/ce-product-pulse/references/run.md +46 -0
- package/skills/ce-product-pulse/references/setup.md +43 -0
- package/skills/ce-promote/SKILL.md +90 -0
- package/skills/ce-promote/agents/openai.yaml +2 -0
- package/skills/ce-promote/references/spiral-cli.md +149 -0
- package/skills/ce-proof/SKILL.md +65 -0
- package/skills/ce-proof/references/api.md +176 -0
- package/skills/ce-proof/references/workflows.md +124 -0
- package/skills/ce-prototype/SKILL.md +59 -0
- package/skills/ce-prototype/assets/annotate.css +206 -0
- package/skills/ce-prototype/assets/annotate.js +837 -0
- package/skills/ce-prototype/references/annotation-loop.md +32 -0
- package/skills/ce-prototype/references/build.md +39 -0
- package/skills/ce-prototype/references/craft-floor.md +43 -0
- package/skills/ce-prototype/references/preview.md +118 -0
- package/skills/ce-prototype/references/scoping.md +38 -0
- package/skills/ce-prototype/references/write-back.md +37 -0
- package/skills/ce-prototype/scripts/light-webserver.js +1275 -0
- package/skills/ce-resolve-pr-feedback/SKILL.md +60 -0
- package/skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md +59 -0
- package/skills/ce-resolve-pr-feedback/references/evaluation-rubric.md +141 -0
- package/skills/ce-resolve-pr-feedback/references/full-mode.md +336 -0
- package/skills/ce-resolve-pr-feedback/references/pipeline-mode.md +23 -0
- package/skills/ce-resolve-pr-feedback/references/resume.md +60 -0
- package/skills/ce-resolve-pr-feedback/references/return-to-caller.md +111 -0
- package/skills/ce-resolve-pr-feedback/references/targeted-mode.md +48 -0
- package/skills/ce-resolve-pr-feedback/scripts/get-pr-comments +176 -0
- package/skills/ce-resolve-pr-feedback/scripts/get-thread-for-comment +84 -0
- package/skills/ce-resolve-pr-feedback/scripts/pending-feedback.py +287 -0
- package/skills/ce-resolve-pr-feedback/scripts/reply-to-pr-thread +57 -0
- package/skills/ce-resolve-pr-feedback/scripts/resolve-pr-thread +23 -0
- package/skills/ce-retune/SKILL.md +42 -0
- package/skills/ce-retune/agents/openai.yaml +2 -0
- package/skills/ce-retune/references/baseline-mining.md +122 -0
- package/skills/ce-retune/references/corpus-audit.md +128 -0
- package/skills/ce-retune/references/cut-passes.md +129 -0
- package/skills/ce-retune/references/halt-taxonomy.md +123 -0
- package/skills/ce-retune/references/noise-floor.md +131 -0
- package/skills/ce-retune/references/workflow-shapes.md +73 -0
- package/skills/ce-riffrec-feedback-analysis/SKILL.md +33 -0
- package/skills/ce-riffrec-feedback-analysis/references/analyzer.md +18 -0
- package/skills/ce-riffrec-feedback-analysis/references/compound-engineering-feedback-format.md +116 -0
- package/skills/ce-riffrec-feedback-analysis/references/extensive-analysis.md +98 -0
- package/skills/ce-riffrec-feedback-analysis/references/install-riffrec.md +27 -0
- package/skills/ce-riffrec-feedback-analysis/references/quick-bug-report.md +38 -0
- package/skills/ce-riffrec-feedback-analysis/scripts/analyze_riffrec_zip.py +1306 -0
- package/skills/ce-setup/SKILL.md +103 -0
- package/skills/ce-setup/agents/openai.yaml +2 -0
- package/skills/ce-setup/assets/compounding-directive.md +11 -0
- package/skills/ce-setup/assets/noslop-directive.md +7 -0
- package/skills/ce-setup/assets/pack-rule-template.md +11 -0
- package/skills/ce-setup/references/acpx-cache-warm.md +14 -0
- package/skills/ce-setup/references/config-template.yaml +210 -0
- package/skills/ce-setup/references/legacy-codex-tool-map.md +15 -0
- package/skills/ce-setup/references/pack-scaffold.md +45 -0
- package/skills/ce-setup/references/repo-fixes.md +121 -0
- package/skills/ce-setup/scripts/check-health +929 -0
- package/skills/ce-setup/scripts/packs-resolve.py +737 -0
- package/skills/ce-simplify-code/SKILL.md +76 -0
- package/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/skills/ce-simplify-code/references/personas/comment-reviewer.md +24 -0
- package/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/skills/ce-simplify-code/references/personas/test-value-reviewer.md +23 -0
- package/skills/ce-simplify-code/references/reviewer-dispatch.md +11 -0
- package/skills/ce-simplify-code/references/standalone-report.md +25 -0
- package/skills/ce-strategy/SKILL.md +61 -0
- package/skills/ce-strategy/references/grounding.md +22 -0
- package/skills/ce-strategy/references/interview.md +179 -0
- package/skills/ce-strategy/references/strategy-template.md +94 -0
- package/skills/ce-strategy/references/update-run.md +33 -0
- package/skills/ce-sweep/SKILL.md +93 -0
- package/skills/ce-sweep/agents/openai.yaml +2 -0
- package/skills/ce-sweep/references/agents/media-analyzer.md +54 -0
- package/skills/ce-sweep/references/interview.md +208 -0
- package/skills/ce-sweep/references/model-tiers.md +13 -0
- package/skills/ce-sweep/references/plan-template.md +62 -0
- package/skills/ce-sweep/references/run.md +126 -0
- package/skills/ce-sweep/references/sources/email.md +57 -0
- package/skills/ce-sweep/references/sources/github-issues.md +59 -0
- package/skills/ce-sweep/references/sources/slack.md +61 -0
- package/skills/ce-sweep/references/state-schema.md +157 -0
- package/skills/ce-sweep/references/subagent-template.md +60 -0
- package/skills/ce-sweep/scripts/analyze_riffrec_zip.py +1306 -0
- package/skills/ce-sweep/scripts/sweep-state.py +793 -0
- package/skills/ce-test-browser/SKILL.md +50 -0
- package/skills/ce-test-browser/references/agent-browser-driver.md +47 -0
- package/skills/ce-test-browser/references/pipeline-orchestration.md +45 -0
- package/skills/ce-test-browser/references/route-and-report.md +129 -0
- package/skills/ce-test-browser/scripts/resolve-port.sh +41 -0
- package/skills/ce-test-xcode/SKILL.md +26 -0
- package/skills/ce-test-xcode/agents/openai.yaml +2 -0
- package/skills/ce-test-xcode/references/setup-and-build.md +32 -0
- package/skills/ce-test-xcode/references/test-and-report.md +64 -0
- package/skills/ce-work/SKILL.md +62 -0
- package/skills/ce-work/references/agents/figma-design-sync.md +27 -0
- package/skills/ce-work/references/agents/implementation-worker.md +17 -0
- package/skills/ce-work/references/cross-model-execution.md +191 -0
- package/skills/ce-work/references/execution-engines.md +64 -0
- package/skills/ce-work/references/execution-strategy.md +73 -0
- package/skills/ce-work/references/implementation-loop.md +107 -0
- package/skills/ce-work/references/implementation-result-schema.json +52 -0
- package/skills/ce-work/references/input-triage.md +50 -0
- package/skills/ce-work/references/non-code-execution.md +23 -0
- package/skills/ce-work/references/return-to-caller.md +33 -0
- package/skills/ce-work/references/review-findings-followup.md +82 -0
- package/skills/ce-work/references/shipping-workflow.md +89 -0
- package/skills/ce-work/references/tracker-defer.md +150 -0
- package/skills/ce-work/references/work-intake.md +51 -0
- package/skills/ce-work/references/workspace-setup.md +27 -0
- package/skills/ce-work/scripts/__pycache__/unit_workspace_jobs.cpython-314.pyc +0 -0
- package/skills/ce-work/scripts/__pycache__/unit_workspace_state.cpython-314.pyc +0 -0
- package/skills/ce-work/scripts/cross-model-work.sh +1385 -0
- package/skills/ce-work/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-work/scripts/unit-workspace.py +215 -0
- package/skills/ce-work/scripts/unit_workspace_ignored.py +93 -0
- package/skills/ce-work/scripts/unit_workspace_integration.py +677 -0
- package/skills/ce-work/scripts/unit_workspace_jobs.py +1216 -0
- package/skills/ce-work/scripts/unit_workspace_lifecycle.py +981 -0
- package/skills/ce-work/scripts/unit_workspace_state.py +1379 -0
- package/skills/ce-work/scripts/unit_workspace_transaction.py +612 -0
- package/skills/ce-worktree/SKILL.md +54 -0
- package/skills/lfg/SKILL.md +59 -0
- package/skills/lfg/references/debug-return.md +25 -0
- package/skills/lfg/references/intake.md +30 -0
- package/skills/lfg/references/next-work-handoff.md +96 -0
- package/skills/lfg/references/plan-brief.md +36 -0
- package/skills/lfg/references/review-followup.md +78 -0
- package/skills/lfg/references/shipping.md +65 -0
- package/skills/lfg/references/stage-routing.md +49 -0
- package/skills/lfg/references/task-visibility.md +11 -0
- package/skills/lfg/references/tracker-defer.md +150 -0
- package/skills/lfg/references/work-return.md +27 -0
- package/skills/wtf/SKILL.md +33 -0
- package/skills/wtf/agents/openai.yaml +2 -0
|
@@ -0,0 +1,2841 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Live endpoint for ce-polish live mode: page -> endpoint event intake over
|
|
3
|
+
// HTTP POST, endpoint -> page SSE, endpoint -> agent blocking wake.
|
|
4
|
+
// Adapted from the ce-prototype helper (light-webserver.js): the run-directory
|
|
5
|
+
// lifecycle (pidfile, state/, idle timeout, --owner-pid, start/status/stop/wait)
|
|
6
|
+
// is kept; file serving, the overlay, /version, SSE grace shutdown, and the
|
|
7
|
+
// pending-document handshake are gone. Owner death and idle timeout stop the
|
|
8
|
+
// process but never end the session: a later `start --root` resumes it.
|
|
9
|
+
// Only /session/end or an explicit `stop` ends a session.
|
|
10
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
|
11
|
+
import { execFileSync, spawn } from "node:child_process"
|
|
12
|
+
import fs from "node:fs"
|
|
13
|
+
import http from "node:http"
|
|
14
|
+
import net from "node:net"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
import readline from "node:readline"
|
|
17
|
+
import { fileURLToPath } from "node:url"
|
|
18
|
+
|
|
19
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
20
|
+
const DEFAULT_HOST = "127.0.0.1"
|
|
21
|
+
const DEFAULT_URL_HOST = "localhost"
|
|
22
|
+
const SCHEMA_VERSION = "live/1"
|
|
23
|
+
const IDLE_TIMEOUT_MS = Number(process.env.CE_LIVE_IDLE_TIMEOUT_MS) || 30 * 60 * 1000
|
|
24
|
+
const LIFECYCLE_CHECK_MS = Number(process.env.CE_LIVE_LIFECYCLE_CHECK_MS) || 60 * 1000
|
|
25
|
+
const WAIT_TIMEOUT_MS = Number(process.env.CE_LIVE_WAIT_TIMEOUT_MS) || 30 * 1000
|
|
26
|
+
const PAGE_LOST_GRACE_MS = Number(process.env.CE_LIVE_PAGE_LOST_GRACE_MS) || 15 * 1000
|
|
27
|
+
const MINT_TIMEOUT_MS = Number(process.env.CE_LIVE_MINT_TIMEOUT_MS) || 10 * 1000
|
|
28
|
+
const BODY_LIMIT = 64 * 1024
|
|
29
|
+
// How long after a stream delivery the response is ended so buffering
|
|
30
|
+
// intermediaries release it; a burst inside this window shares one response.
|
|
31
|
+
const STREAM_FLUSH_MS = Number(process.env.CE_LIVE_STREAM_FLUSH_MS) || 300
|
|
32
|
+
const FRAME_BODY_LIMIT = 2 * 1024 * 1024
|
|
33
|
+
const ARCHIVE_BODY_LIMIT = Number(process.env.CE_LIVE_ARCHIVE_LIMIT_BYTES) || 400 * 1024 * 1024
|
|
34
|
+
const DISK_CAP_BYTES = Number(process.env.CE_LIVE_DISK_CAP_BYTES) || 500 * 1024 * 1024
|
|
35
|
+
// Above the image cap, a ceiling on the whole session log: past it every
|
|
36
|
+
// envelope is refused, not only frames, so a session that keeps sending
|
|
37
|
+
// small events (or a page that keeps posting) cannot grow the log without
|
|
38
|
+
// bound. Normal sessions never reach it; the page treats the refusal as
|
|
39
|
+
// buffering and replays once accepted again.
|
|
40
|
+
const DISK_HARD_CAP_BYTES = Math.max(DISK_CAP_BYTES, Number(process.env.CE_LIVE_DISK_HARD_CAP_BYTES) || DISK_CAP_BYTES + 64 * 1024 * 1024)
|
|
41
|
+
// An archive upload streams into `archive.<ext>.<uuid>.part` and is renamed
|
|
42
|
+
// once complete; a `.part` found at startup is an upload the previous process
|
|
43
|
+
// did not finish.
|
|
44
|
+
const ARCHIVE_PART_SUFFIX = ".part"
|
|
45
|
+
const BRIEF_MAX_CHARS = 3000
|
|
46
|
+
// Envelopes held ahead of a sequence gap before early arrivals are dropped for replay.
|
|
47
|
+
const OUT_OF_ORDER_CAP = 512
|
|
48
|
+
const MINTS_PER_MINUTE = 5
|
|
49
|
+
// Refused requests logged to agent.ndjson per window; the last slot in a
|
|
50
|
+
// window is the one line saying the rest of that window is suppressed.
|
|
51
|
+
const REJECTION_LOG_PER_WINDOW = Number(process.env.CE_LIVE_REJECTION_LOG_PER_MINUTE) || 60
|
|
52
|
+
const REJECTION_LOG_WINDOW_MS = 60 * 1000
|
|
53
|
+
const OPENAI_BASE_URL = (process.env.OPENAI_BASE_URL || "https://api.openai.com").replace(/\/+$/, "")
|
|
54
|
+
const REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || "gpt-realtime"
|
|
55
|
+
const REALTIME_VOICE = process.env.OPENAI_REALTIME_VOICE || "marin"
|
|
56
|
+
const CLIENT_SECRET_TTL_S = 600
|
|
57
|
+
|
|
58
|
+
const PAGE_EVENT_TYPES = new Set([
|
|
59
|
+
"click", "navigation", "network_request", "console_error",
|
|
60
|
+
"transcript", "unit", "unit_update", "unit_withdraw", "annotation",
|
|
61
|
+
"checkpoint", "answer", "frame", "mic", "mode", "stream_state",
|
|
62
|
+
])
|
|
63
|
+
// Page-emitted checkpoint triggers (KTD9). `silence`/`page_change`/`send`
|
|
64
|
+
// wake the agent only when they release something; `final` always wakes.
|
|
65
|
+
const PAGE_CHECKPOINT_KINDS = new Set(["silence", "page_change", "send", "final"])
|
|
66
|
+
const ALWAYS_WAKE_KINDS = new Set(["answer", "mode_change", "final"])
|
|
67
|
+
const EXECUTION_MODES = new Set(["instant", "smart", "collect"])
|
|
68
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
69
|
+
// Agent-postable unit statuses: riffrec's UnitStatus set minus `initial`.
|
|
70
|
+
const AGENT_UNIT_STATUSES = new Set(["triaging", "accepted", "needs_info", "working", "applied", "blocked", "withdrawn"])
|
|
71
|
+
const UNIT_STATUSES = new Set(["initial", ...AGENT_UNIT_STATUSES])
|
|
72
|
+
const AGENT_AWAY_GRACE_MS = 15_000
|
|
73
|
+
|
|
74
|
+
// Payload shapes, ported from riffrec's `isPayloadFor` (src/live/contract.ts)
|
|
75
|
+
// so a malformed event is refused before it is acknowledged.
|
|
76
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
77
|
+
const isString = (v) => typeof v === "string"
|
|
78
|
+
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v)
|
|
79
|
+
const isBoolean = (v) => typeof v === "boolean"
|
|
80
|
+
const optionalString = (v) => v === undefined || isString(v)
|
|
81
|
+
const isStringArray = (v) => Array.isArray(v) && v.every(isString)
|
|
82
|
+
const isRect = (v) => isRecord(v) && isFiniteNumber(v.x) && isFiniteNumber(v.y) && isFiniteNumber(v.width) && isFiniteNumber(v.height)
|
|
83
|
+
const isAnchor = (v) => isRecord(v) && isString(v.route) && isString(v.selector) && (v.component === undefined || v.component === null || isString(v.component)) && isRect(v.rect) && isFiniteNumber(v.t)
|
|
84
|
+
const isAnchorArray = (v) => Array.isArray(v) && v.every(isAnchor)
|
|
85
|
+
const isConfirmation = (v) => isRecord(v) && isBoolean(v.element) && isBoolean(v.change)
|
|
86
|
+
const isSpan = (v) => isRecord(v) && isFiniteNumber(v.t_start) && isFiniteNumber(v.t_end)
|
|
87
|
+
const isEvidence = (v) => isRecord(v) && isStringArray(v.frame_ids) && isStringArray(v.annotation_ids) && isSpan(v.transcript_span)
|
|
88
|
+
&& optionalString(v.audio_clip_id) && (v.telemetry_window === undefined || (isSpan(v.telemetry_window) && Array.isArray(v.telemetry_window.events)))
|
|
89
|
+
const isPointArray = (v) => Array.isArray(v) && v.every((p) => isRecord(p) && isFiniteNumber(p.x) && isFiniteNumber(p.y) && (p.pressure === undefined || isFiniteNumber(p.pressure)))
|
|
90
|
+
|
|
91
|
+
function validPayload(type, payload) {
|
|
92
|
+
if (!isRecord(payload)) return false
|
|
93
|
+
switch (type) {
|
|
94
|
+
case "click":
|
|
95
|
+
return payload.type === type && isFiniteNumber(payload.t) && isRecord(payload.element) && isString(payload.element.selector)
|
|
96
|
+
case "network_request":
|
|
97
|
+
return payload.type === type && isFiniteNumber(payload.t) && isString(payload.url) && isString(payload.method) && isFiniteNumber(payload.status)
|
|
98
|
+
case "console_error":
|
|
99
|
+
return payload.type === type && isFiniteNumber(payload.t) && isString(payload.message)
|
|
100
|
+
case "navigation":
|
|
101
|
+
return payload.type === type && isFiniteNumber(payload.t) && isString(payload.from) && isString(payload.to)
|
|
102
|
+
case "transcript":
|
|
103
|
+
return isString(payload.id) && (payload.role === "riffer" || payload.role === "interviewer") && isString(payload.text)
|
|
104
|
+
&& isFiniteNumber(payload.t_start) && isFiniteNumber(payload.t_end) && isBoolean(payload.final)
|
|
105
|
+
case "unit":
|
|
106
|
+
return isString(payload.id) && isString(payload.statement) && isString(payload.transcript_excerpt) && isAnchorArray(payload.anchors)
|
|
107
|
+
&& isEvidence(payload.evidence) && UNIT_STATUSES.has(payload.status) && (payload.confirmed === undefined || isConfirmation(payload.confirmed))
|
|
108
|
+
case "unit_update":
|
|
109
|
+
return isString(payload.unit_id) && optionalString(payload.statement) && (payload.anchors_add === undefined || isAnchorArray(payload.anchors_add))
|
|
110
|
+
&& (payload.confirmed === undefined || isConfirmation(payload.confirmed))
|
|
111
|
+
case "unit_withdraw":
|
|
112
|
+
return isString(payload.unit_id) && optionalString(payload.reason)
|
|
113
|
+
case "annotation":
|
|
114
|
+
return isString(payload.id) && (payload.kind === "stroke" || payload.kind === "pin") && isPointArray(payload.points) && isRect(payload.bbox)
|
|
115
|
+
&& isAnchor(payload.anchor) && optionalString(payload.text) && optionalString(payload.unit_id) && optionalString(payload.composite_frame_id)
|
|
116
|
+
case "checkpoint":
|
|
117
|
+
return isString(payload.id) && PAGE_CHECKPOINT_KINDS.has(payload.trigger) && EXECUTION_MODES.has(payload.mode)
|
|
118
|
+
case "answer":
|
|
119
|
+
return isString(payload.unit_id) && isString(payload.text)
|
|
120
|
+
case "frame":
|
|
121
|
+
return isString(payload.id) && isFiniteNumber(payload.t) && isString(payload.route)
|
|
122
|
+
&& ["gesture", "periodic", "composite"].includes(payload.kind) && isString(payload.jpeg_base64)
|
|
123
|
+
&& (payload.dropped === undefined || ["quota", "oversize"].includes(payload.dropped))
|
|
124
|
+
case "mic":
|
|
125
|
+
return ["granted", "denied", "muted", "unmuted"].includes(payload.state)
|
|
126
|
+
case "mode":
|
|
127
|
+
return EXECUTION_MODES.has(payload.mode)
|
|
128
|
+
case "stream_state":
|
|
129
|
+
return ["streaming", "buffering", "unloading"].includes(payload.state)
|
|
130
|
+
default:
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const EVIDENCE_PROFILES = {
|
|
135
|
+
anchors_transcript_only: { frames: "none", annotations: false, clips: false, telemetry: false },
|
|
136
|
+
strokes_composite: { frames: "composite", annotations: true, clips: false, telemetry: false },
|
|
137
|
+
everything: { frames: "all", annotations: true, clips: true, telemetry: true },
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The executable copy of the interviewer's tools the mint sends to OpenAI:
|
|
141
|
+
// verbatim riffrec `LIVE_TOOLS` (src/live/tools.ts). The human-readable copy
|
|
142
|
+
// is references/live-stream-contract.md and the pinned dump is
|
|
143
|
+
// tests/fixtures/ce-polish-live/live-tools.json; change all three together.
|
|
144
|
+
// After connecting, the page appends any tool it can answer that the mint
|
|
145
|
+
// lacked (the endpoint's copies win), so a stale copy degrades rather than
|
|
146
|
+
// breaks; a verbatim copy is never patched.
|
|
147
|
+
const INTERVIEWER_TOOLS = [
|
|
148
|
+
{
|
|
149
|
+
"type": "function",
|
|
150
|
+
"name": "record_unit",
|
|
151
|
+
"description": "Record one requested change as a unit on the board. Call when the riffer has asked for exactly one concrete change to the app and you can state it in one sentence; call once per change, so a sentence that asks for three things becomes three calls. Never call for questions, thinking aloud, praise, or utterances shorter than three words without a change verb; ask a clarifying question instead when the target element or the intended change is ambiguous.",
|
|
152
|
+
"parameters": {
|
|
153
|
+
"type": "object",
|
|
154
|
+
"properties": {
|
|
155
|
+
"statement": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"description": "Normalized imperative statement of the change, in the riffer's vocabulary, one sentence, no speculation."
|
|
158
|
+
},
|
|
159
|
+
"anchors": {
|
|
160
|
+
"type": "array",
|
|
161
|
+
"description": "Anchor references for the element(s) the change is about: an anchor id from a [PAGE] note announcing what the riffer clicked, drew on, or pinned (\"this\"/\"here\" means the most recent one), or the riffer's own words for the element (\"the sidebar toggle\", \"that red button\"). Empty only when the riffer named no element and no anchor was announced.",
|
|
162
|
+
"items": {
|
|
163
|
+
"type": "string"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"transcript_excerpt": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "The riffer's own words that carry this change, verbatim, trimmed to the relevant span."
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"required": [
|
|
172
|
+
"statement",
|
|
173
|
+
"anchors",
|
|
174
|
+
"transcript_excerpt"
|
|
175
|
+
],
|
|
176
|
+
"additionalProperties": false
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"type": "function",
|
|
181
|
+
"name": "update_unit",
|
|
182
|
+
"description": "Refine a unit you recorded earlier. Call when the riffer adds detail, corrects wording, or names another element for a change already on the board, or when they answer a clarifying question you asked about it. Never call to change a unit into a different change; withdraw it and record a new one. The call is rejected once the unit has left the initial status; the result tells you so, and you must then record the refinement as a new unit.",
|
|
183
|
+
"parameters": {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": {
|
|
186
|
+
"unit_id": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"description": "Id returned by record_unit."
|
|
189
|
+
},
|
|
190
|
+
"statement": {
|
|
191
|
+
"type": "string",
|
|
192
|
+
"description": "Replacement statement, when the wording changes."
|
|
193
|
+
},
|
|
194
|
+
"anchors_add": {
|
|
195
|
+
"type": "array",
|
|
196
|
+
"description": "Additional anchor references to attach; existing anchors are kept.",
|
|
197
|
+
"items": {
|
|
198
|
+
"type": "string"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
"required": [
|
|
203
|
+
"unit_id"
|
|
204
|
+
],
|
|
205
|
+
"additionalProperties": false
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"type": "function",
|
|
210
|
+
"name": "withdraw_unit",
|
|
211
|
+
"description": "Retract a unit the riffer no longer wants. Call when the riffer says never mind, undo, scrap that, or otherwise takes back a change you recorded. Never call because you are unsure the unit was right; ask instead. Never call for units you did not record in this session.",
|
|
212
|
+
"parameters": {
|
|
213
|
+
"type": "object",
|
|
214
|
+
"properties": {
|
|
215
|
+
"unit_id": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"description": "Id returned by record_unit."
|
|
218
|
+
},
|
|
219
|
+
"reason": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": "The riffer's reason, in their words, when they gave one."
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
"required": [
|
|
225
|
+
"unit_id"
|
|
226
|
+
],
|
|
227
|
+
"additionalProperties": false
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"type": "function",
|
|
232
|
+
"name": "relay_answer",
|
|
233
|
+
"description": "Relay the riffer's answer to a question the coding agent asked about a unit. Call when you voiced a question that arrived from the endpoint for a specific unit and the riffer has answered it. Never call for answers to your own clarifying questions; use update_unit for those. Never invent or summarize an answer the riffer did not give.",
|
|
234
|
+
"parameters": {
|
|
235
|
+
"type": "object",
|
|
236
|
+
"properties": {
|
|
237
|
+
"unit_id": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"description": "Id of the unit the question was attached to."
|
|
240
|
+
},
|
|
241
|
+
"answer_text": {
|
|
242
|
+
"type": "string",
|
|
243
|
+
"description": "The riffer's answer, verbatim or lightly cleaned of filler."
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
"required": [
|
|
247
|
+
"unit_id",
|
|
248
|
+
"answer_text"
|
|
249
|
+
],
|
|
250
|
+
"additionalProperties": false
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"type": "function",
|
|
255
|
+
"name": "look_at_screen",
|
|
256
|
+
"description": "See the riffer's screen right now. The page attaches a screenshot of the current view as an image in the conversation, then returns this call's result with the route and how old the frame is. Call when the riffer refers to how something looks (\"this\", \"here\", \"that color\", \"it looks off\") and the clicked or drawn anchors the page announced do not settle what they mean, when they ask whether you can see their screen, or when they ask you to look. Never call more than once per riffer turn, and never call to browse: only to answer what the riffer just said. If the result says no frame is available, ask the riffer to describe what they see.",
|
|
257
|
+
"parameters": {
|
|
258
|
+
"type": "object",
|
|
259
|
+
"properties": {
|
|
260
|
+
"reason": {
|
|
261
|
+
"type": "string",
|
|
262
|
+
"description": "Why you need to see the screen, in a few words."
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"required": [],
|
|
266
|
+
"additionalProperties": false
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
// The interviewer's instructions: verbatim riffrec
|
|
272
|
+
// `DEFAULT_INTERVIEWER_INSTRUCTIONS` (src/live/realtime/persona.ts), pinned by
|
|
273
|
+
// tests/fixtures/ce-polish-live/interviewer-instructions.txt. The page checks
|
|
274
|
+
// the minted persona for the [SCREEN CONTEXT] marker after connect and appends
|
|
275
|
+
// the section when it is missing; keep it here, and keep it last.
|
|
276
|
+
const SCREEN_CONTEXT_SECTION = [
|
|
277
|
+
"[SCREEN CONTEXT]",
|
|
278
|
+
"The page keeps you informed about the screen, and this section is authoritative about it: it supersedes any earlier statement that you cannot see the page or must not claim to.",
|
|
279
|
+
"Every click the riffer makes arrives as a system note tagged [PAGE] that names the element (its component, visible text, selector, and route) and gives it an anchor id. Drawings and pins arrive the same way. The most recent note is what \"this\", \"here\", and \"that\" refer to: put its anchor id in record_unit's anchors, and never ask which element they mean when a note arrived within the last few seconds.",
|
|
280
|
+
"You can also see the screen. Call look_at_screen when the riffer refers to how something looks, asks whether you can see their screen, or asks you to look; the page attaches a screenshot of the current view and you may then describe or refer to what is in it. The riffrec panel docked at the top right is not part of the app. Never say you cannot see the screen: if no frame is available the tool result says so, and you ask the riffer to describe what they see instead.",
|
|
281
|
+
].join("\n")
|
|
282
|
+
|
|
283
|
+
const INTERVIEWER_PERSONA = [
|
|
284
|
+
"You are the riffrec interviewer: a calm, terse product partner listening to a designer or developer (the riffer) talk through changes they want while they click and draw on their own running app. The page tells you what they click, draw on, and pin, and shows you the screen when you ask for it; the last section says how.",
|
|
285
|
+
"Your job is to turn what the riffer says into units of change on a shared board, one unit per requested change, using the record_unit tool. A sentence that asks for three things becomes three record_unit calls. Never call record_unit for questions, thinking aloud, praise, or utterances shorter than three words without a change verb.",
|
|
286
|
+
"Ask immediately, in one short sentence, when the target element or the intended value is ambiguous: which element, which side, what color, how much. Otherwise stay quiet and let the riffer keep talking. Do not narrate, summarize, or confirm each unit aloud; the board already shows it.",
|
|
287
|
+
"Never invent anchors. Use only the anchor ids the page announced or the element references the riffer named. When the riffer names no element and no anchor was announced, record the unit with an empty anchors list.",
|
|
288
|
+
"When the riffer takes back a change, call withdraw_unit and acknowledge it aloud in a few words. When they refine a change already on the board, call update_unit; if it is rejected because the unit was already picked up, record the refinement as a new unit.",
|
|
289
|
+
"When a note marked [ENDPOINT QUESTION] arrives, read the question to the riffer in your own words at the next pause and, once they answer, call relay_answer with their answer for that unit. Never answer such a question yourself.",
|
|
290
|
+
"When the riffer asks to compound, to capture what was learned, or to remember a decision for next time, record one unit whose statement starts with \"/ce-compound:\" followed by what to capture, with an empty anchors list, and say in a few words that it will be compounded.",
|
|
291
|
+
"Keep every spoken turn under two sentences. Speak the riffer's language.",
|
|
292
|
+
SCREEN_CONTEXT_SECTION,
|
|
293
|
+
].join("\n\n")
|
|
294
|
+
|
|
295
|
+
function usage() {
|
|
296
|
+
return [
|
|
297
|
+
"Usage:",
|
|
298
|
+
" node live-endpoint.js start --root <dir> --app-origin <origin> [--host 127.0.0.1] [--port 0] [--owner-pid <pid>] [--trust-proxy <ip>[,<ip>]] [--foreground]",
|
|
299
|
+
" node live-endpoint.js status --root <dir>",
|
|
300
|
+
" node live-endpoint.js stop --root <dir>",
|
|
301
|
+
" node live-endpoint.js wait --root <dir>",
|
|
302
|
+
" node live-endpoint.js replay --root <dir> --profile <anchors_transcript_only|strokes_composite|everything> --to <endpoint> --token <page token> [--log <log-ended-<stamp>|dir>]",
|
|
303
|
+
].join("\n")
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function parseArgs(argv) {
|
|
307
|
+
const command = argv[2]
|
|
308
|
+
const options = { command, host: DEFAULT_HOST, port: undefined, foreground: false }
|
|
309
|
+
|
|
310
|
+
for (let i = 3; i < argv.length; i++) {
|
|
311
|
+
const arg = argv[i]
|
|
312
|
+
if (arg === "--root") options.root = argv[++i]
|
|
313
|
+
else if (arg === "--host") { options.host = argv[++i]; options.hostExplicit = true }
|
|
314
|
+
else if (arg === "--port") options.port = Number(argv[++i])
|
|
315
|
+
else if (arg === "--foreground") options.foreground = true
|
|
316
|
+
else if (arg === "--owner-pid") options.ownerPid = Number(argv[++i])
|
|
317
|
+
else if (arg === "--app-origin") options.appOrigin = argv[++i]
|
|
318
|
+
else if (arg === "--profile") options.profile = argv[++i]
|
|
319
|
+
else if (arg === "--to") options.to = argv[++i]
|
|
320
|
+
else if (arg === "--token") options.token = argv[++i]
|
|
321
|
+
else if (arg === "--log") options.log = argv[++i]
|
|
322
|
+
else if (arg === "--trust-proxy") options.trustProxy = [...(options.trustProxy ?? []), ...String(argv[++i] ?? "").split(",").map((ip) => ip.trim()).filter(Boolean)]
|
|
323
|
+
else throw new Error(`Unknown argument: ${arg}`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!["start", "serve", "stop", "status", "wait", "replay"].includes(command)) {
|
|
327
|
+
throw new Error(usage())
|
|
328
|
+
}
|
|
329
|
+
if (!options.root) throw new Error("--root is required")
|
|
330
|
+
if (options.port !== undefined && (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535)) {
|
|
331
|
+
throw new Error("--port must be an integer from 0 to 65535")
|
|
332
|
+
}
|
|
333
|
+
if (options.ownerPid !== undefined && (!Number.isInteger(options.ownerPid) || options.ownerPid <= 1)) {
|
|
334
|
+
throw new Error("--owner-pid must be an integer greater than 1")
|
|
335
|
+
}
|
|
336
|
+
options.trustProxy = options.trustProxy ?? []
|
|
337
|
+
if (command === "start" || command === "serve") {
|
|
338
|
+
if (options.trustProxy.some((ip) => !net.isIP(ip))) throw new Error("--trust-proxy takes IP addresses (comma-separated or repeated)")
|
|
339
|
+
if (!options.appOrigin) {
|
|
340
|
+
// The documented recovery is a bare `start --root <dir>`: a session
|
|
341
|
+
// whose agent token is still retained lends its origin.
|
|
342
|
+
const resumable = readJsonOrNull(path.join(path.resolve(options.root), "state", "session.json"))
|
|
343
|
+
// Also lent by an ended session still draining its final batch.
|
|
344
|
+
if (resumable && resumable.agent_token && typeof resumable.app_origin === "string") options.appOrigin = resumable.app_origin
|
|
345
|
+
}
|
|
346
|
+
if (!options.appOrigin) throw new Error("--app-origin is required (the browser-facing origin of the app under polish)")
|
|
347
|
+
options.appOrigin = normalizeOrigin(options.appOrigin)
|
|
348
|
+
if (!options.appOrigin) throw new Error("--app-origin must be an origin such as http://localhost:3000")
|
|
349
|
+
}
|
|
350
|
+
if (command === "replay") {
|
|
351
|
+
if (!options.profile || !EVIDENCE_PROFILES[options.profile]) {
|
|
352
|
+
throw new Error(`--profile must be one of: ${Object.keys(EVIDENCE_PROFILES).join(", ")}`)
|
|
353
|
+
}
|
|
354
|
+
if (!options.to || !normalizeOrigin(options.to)) throw new Error("--to must be the endpoint origin to replay into")
|
|
355
|
+
options.to = normalizeOrigin(options.to)
|
|
356
|
+
if (!options.token) throw new Error("--token is required (the target endpoint's page token)")
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
options.root = path.resolve(options.root)
|
|
360
|
+
options.stateDir = path.join(options.root, "state")
|
|
361
|
+
options.pidFile = path.join(options.stateDir, "server.pid")
|
|
362
|
+
options.sessionFile = path.join(options.stateDir, "session.json")
|
|
363
|
+
options.boardFile = path.join(options.stateDir, "board.json")
|
|
364
|
+
options.briefFile = path.join(options.stateDir, "brief.md")
|
|
365
|
+
options.logFile = path.join(options.stateDir, "server.log")
|
|
366
|
+
options.batchesDir = path.join(options.stateDir, "batches")
|
|
367
|
+
options.logDir = path.join(options.stateDir, "log")
|
|
368
|
+
// `replay --log` addresses an earlier session on this root: a rotated
|
|
369
|
+
// `state/log-ended-<stamp>` by name, or any log directory by path.
|
|
370
|
+
if (options.log !== undefined) {
|
|
371
|
+
if (command !== "replay") throw new Error("--log applies to replay only")
|
|
372
|
+
const named = path.join(options.stateDir, path.basename(options.log))
|
|
373
|
+
const candidate = fs.existsSync(path.join(named, "events.ndjson")) ? named : path.resolve(options.log)
|
|
374
|
+
if (!fs.existsSync(path.join(candidate, "events.ndjson"))) throw new Error(`No session log at ${path.join(candidate, "events.ndjson")}`)
|
|
375
|
+
options.logDir = candidate
|
|
376
|
+
}
|
|
377
|
+
return options
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function normalizeOrigin(value) {
|
|
381
|
+
try {
|
|
382
|
+
const url = new URL(value)
|
|
383
|
+
if (!/^https?:$/.test(url.protocol) || url.pathname !== "/" || url.search || url.hash) return null
|
|
384
|
+
return url.origin
|
|
385
|
+
} catch {
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function ensureDirs(options) {
|
|
391
|
+
fs.mkdirSync(options.stateDir, { recursive: true, mode: 0o700 })
|
|
392
|
+
fs.chmodSync(options.stateDir, 0o700)
|
|
393
|
+
fs.mkdirSync(options.batchesDir, { recursive: true, mode: 0o700 })
|
|
394
|
+
fs.mkdirSync(options.logDir, { recursive: true, mode: 0o700 })
|
|
395
|
+
fs.mkdirSync(path.join(options.logDir, "frames"), { recursive: true, mode: 0o700 })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function writePrivate(filePath, contents) {
|
|
399
|
+
const tmp = `${filePath}.${process.pid}.tmp`
|
|
400
|
+
fs.writeFileSync(tmp, contents, { mode: 0o600 })
|
|
401
|
+
fs.chmodSync(tmp, 0o600)
|
|
402
|
+
fs.renameSync(tmp, filePath)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function writePrivateJson(filePath, value) {
|
|
406
|
+
writePrivate(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function appendPrivate(filePath, line) {
|
|
410
|
+
fs.appendFileSync(filePath, line, { mode: 0o600 })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function jsonOut(value) {
|
|
414
|
+
process.stdout.write(`${JSON.stringify(value)}\n`)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function readJson(filePath) {
|
|
418
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function readJsonOrNull(filePath) {
|
|
422
|
+
try {
|
|
423
|
+
return readJson(filePath)
|
|
424
|
+
} catch {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function readableFile(filePath) {
|
|
430
|
+
try {
|
|
431
|
+
fs.accessSync(filePath, fs.constants.R_OK)
|
|
432
|
+
return fs.statSync(filePath).isFile()
|
|
433
|
+
} catch {
|
|
434
|
+
return false
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// A frame file named by a session log, admitted only as a regular file that
|
|
439
|
+
// really sits under that log's frames/ directory: the log may be a copy from
|
|
440
|
+
// elsewhere, so a `frame_file` of `../x` or a symlink out of the directory
|
|
441
|
+
// must not make replay read and transmit an arbitrary local file.
|
|
442
|
+
function frameFileWithin(logDir, frameFile) {
|
|
443
|
+
if (typeof frameFile !== "string" || !frameFile) return null
|
|
444
|
+
let framesDir
|
|
445
|
+
try {
|
|
446
|
+
framesDir = fs.realpathSync(path.join(logDir, "frames"))
|
|
447
|
+
} catch {
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
const candidate = path.resolve(logDir, frameFile)
|
|
451
|
+
try {
|
|
452
|
+
if (fs.lstatSync(candidate).isSymbolicLink()) return null
|
|
453
|
+
const real = fs.realpathSync(candidate)
|
|
454
|
+
if (path.dirname(real) !== framesDir || !fs.statSync(real).isFile()) return null
|
|
455
|
+
fs.accessSync(real, fs.constants.R_OK)
|
|
456
|
+
return real
|
|
457
|
+
} catch {
|
|
458
|
+
return null
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function processAlive(pid) {
|
|
463
|
+
if (!pid || !Number.isInteger(pid)) return false
|
|
464
|
+
try {
|
|
465
|
+
process.kill(pid, 0)
|
|
466
|
+
return true
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return error?.code === "EPERM"
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function processArgs(pid) {
|
|
473
|
+
try {
|
|
474
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
475
|
+
encoding: "utf8",
|
|
476
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
477
|
+
}).trim()
|
|
478
|
+
} catch {
|
|
479
|
+
return null
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Whether the process behind state/server.pid is this root's endpoint:
|
|
484
|
+
// `owned`, `foreign` (the PID was reused by something else), or `unknown`
|
|
485
|
+
// (the command line could not be inspected). The helper is matched by its
|
|
486
|
+
// file name, not the path it was launched from: a session resumed from a
|
|
487
|
+
// newer plugin checkout, or a foreground helper started as
|
|
488
|
+
// `node live-endpoint.js`, is still the same endpoint for this root.
|
|
489
|
+
function inspectServerProcess(options, pid) {
|
|
490
|
+
// Exact argv when the platform exposes it (Linux /proc): the `--root`
|
|
491
|
+
// argument is compared whole, so a root containing spaces or one that is
|
|
492
|
+
// a prefix of another cannot be confused with it.
|
|
493
|
+
const argv = processArgv(pid)
|
|
494
|
+
if (argv) {
|
|
495
|
+
const isHelper = argv.some((token) => path.basename(token) === path.basename(scriptPath))
|
|
496
|
+
const rootIndex = argv.indexOf("--root")
|
|
497
|
+
const forThisRoot = rootIndex !== -1 && argv[rootIndex + 1] === options.root
|
|
498
|
+
const serving = argv.includes("serve") || argv.includes("start")
|
|
499
|
+
return isHelper && forThisRoot && serving ? "owned" : "foreign"
|
|
500
|
+
}
|
|
501
|
+
const args = processArgs(pid)
|
|
502
|
+
if (args === null) return "unknown"
|
|
503
|
+
const tokens = args.split(/\s+/)
|
|
504
|
+
const isHelper = tokens.some((token) => path.basename(token) === path.basename(scriptPath))
|
|
505
|
+
// Flattened command line (`ps`): argv boundaries are gone, so the root must
|
|
506
|
+
// be followed by the next flag or the end of the line. A root that is this
|
|
507
|
+
// one plus more words (`--root /tmp/root other`) is ambiguous and reads as
|
|
508
|
+
// foreign rather than owned.
|
|
509
|
+
const rootArgument = new RegExp(`(^|\\s)--root\\s+${escapeRegExp(options.root)}(\\s+--|\\s*$)`)
|
|
510
|
+
const forThisRoot = rootArgument.test(args)
|
|
511
|
+
const serving = tokens.includes("serve") || tokens.includes("start")
|
|
512
|
+
return isHelper && forThisRoot && serving ? "owned" : "foreign"
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The process's argv with its boundaries intact, or null where the platform
|
|
516
|
+
// does not expose it (no /proc, or a PID this user may not read).
|
|
517
|
+
function processArgv(pid) {
|
|
518
|
+
try {
|
|
519
|
+
const raw = fs.readFileSync(`/proc/${pid}/cmdline`)
|
|
520
|
+
if (raw.length === 0) return null
|
|
521
|
+
const parts = raw.toString("utf8").split("\0")
|
|
522
|
+
if (parts[parts.length - 1] === "") parts.pop()
|
|
523
|
+
return parts
|
|
524
|
+
} catch {
|
|
525
|
+
return null
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function escapeRegExp(text) {
|
|
530
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function ownsServerProcess(options, pid) {
|
|
534
|
+
return inspectServerProcess(options, pid) === "owned"
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// A live PID whose ownership cannot be verified is never signalled and never
|
|
538
|
+
// displaced: the caller stops with the PID named rather than risk killing an
|
|
539
|
+
// unrelated process or launching a second writer beside the real endpoint.
|
|
540
|
+
function requireInspectableProcess(options, pid) {
|
|
541
|
+
if (processAlive(pid) && inspectServerProcess(options, pid) === "unknown") {
|
|
542
|
+
throw new Error(`Cannot verify that process ${pid} (state/server.pid) is this root's endpoint: process inspection (ps) is unavailable. Stop it yourself if it is, then remove ${options.pidFile}`)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function resolveOwnerPid() {
|
|
547
|
+
const parentPid = process.ppid
|
|
548
|
+
if (!parentPid || parentPid <= 1) return null
|
|
549
|
+
try {
|
|
550
|
+
const grandparent = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(parentPid)], {
|
|
551
|
+
encoding: "utf8",
|
|
552
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
553
|
+
}).trim())
|
|
554
|
+
if (Number.isInteger(grandparent) && grandparent > 1) return grandparent
|
|
555
|
+
} catch {
|
|
556
|
+
// Fall back to the direct parent when grandparent lookup is unavailable.
|
|
557
|
+
}
|
|
558
|
+
return parentPid
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function readPid(options) {
|
|
562
|
+
if (!fs.existsSync(options.pidFile)) return null
|
|
563
|
+
const pid = Number(fs.readFileSync(options.pidFile, "utf8").trim())
|
|
564
|
+
return Number.isInteger(pid) ? pid : null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readSession(options) {
|
|
568
|
+
return readJsonOrNull(options.sessionFile)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function serverRunning(options) {
|
|
572
|
+
const pid = readPid(options)
|
|
573
|
+
return processAlive(pid) && ownsServerProcess(options, pid)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function getRunningInfo(options) {
|
|
577
|
+
if (!serverRunning(options)) return null
|
|
578
|
+
const session = readSession(options)
|
|
579
|
+
if (!session || session.pid !== readPid(options)) return null
|
|
580
|
+
return session
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function publicStartEnvelope(session) {
|
|
584
|
+
return { url: session.url, port: session.port, page_token: session.page_token }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// The address a local client uses to reach the bound interface: a wildcard
|
|
588
|
+
// bind is reached through its loopback, anything else through itself.
|
|
589
|
+
function localAddressFor(host) {
|
|
590
|
+
if (!host || host === "0.0.0.0") return DEFAULT_HOST
|
|
591
|
+
if (host === "::" || host === "[::]") return "[::1]"
|
|
592
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function newToken() {
|
|
596
|
+
return randomBytes(32).toString("base64url")
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function bestEffort(action) {
|
|
600
|
+
try {
|
|
601
|
+
action()
|
|
602
|
+
} catch {
|
|
603
|
+
// Cleanup on a failing disk is advisory.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function tokenMatches(candidate, expected) {
|
|
608
|
+
if (typeof candidate !== "string" || typeof expected !== "string") return false
|
|
609
|
+
const a = Buffer.from(candidate)
|
|
610
|
+
const b = Buffer.from(expected)
|
|
611
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Credentials are accepted from `Authorization: Bearer` only.
|
|
615
|
+
function bearerToken(req) {
|
|
616
|
+
const auth = req.headers.authorization
|
|
617
|
+
if (typeof auth !== "string" || !auth.startsWith("Bearer ")) return null
|
|
618
|
+
const token = auth.slice(7).trim()
|
|
619
|
+
return token || null
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function sendJson(res, status, value, headers = {}) {
|
|
623
|
+
// Kept for the server's finish hook, which logs refusals to agent.ndjson.
|
|
624
|
+
if (status >= 400) res.rejection = value
|
|
625
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...headers })
|
|
626
|
+
res.end(`${JSON.stringify(value)}\n`)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The path of a request for the rejection log: no query string (a caller may
|
|
630
|
+
// put a token there), no fragment, bounded length. Never decoded, so a
|
|
631
|
+
// malformed escape cannot throw here.
|
|
632
|
+
function loggedRoute(req) {
|
|
633
|
+
const raw = typeof req.url === "string" ? req.url : ""
|
|
634
|
+
return raw.split("?")[0].split("#")[0].slice(0, 200) || null
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// What a refusal says about itself, from the body this server wrote: the
|
|
638
|
+
// contract's `reason`, else its `error`, else its `status` word.
|
|
639
|
+
function rejectionReason(value) {
|
|
640
|
+
if (!isRecord(value)) return null
|
|
641
|
+
for (const key of ["reason", "error", "status"]) {
|
|
642
|
+
if (typeof value[key] === "string") return value[key]
|
|
643
|
+
}
|
|
644
|
+
return null
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Reads a body up to `limit` bytes. Past the limit the request is drained
|
|
648
|
+
// (so the 413 the caller writes is delivered) and the promise resolves with
|
|
649
|
+
// `{ tooLarge: true }`; a hard ceiling destroys the socket so an attacker
|
|
650
|
+
// cannot make the server read forever.
|
|
651
|
+
// Reads a request body up to `limit`. Past the limit the body is discarded
|
|
652
|
+
// but read to its end, so the caller's 413 travels on an orderly connection.
|
|
653
|
+
// Past the hard ceiling the caller gets its answer at once (the page needs
|
|
654
|
+
// the 413 to replace the frame with a `dropped` envelope, and a reset would
|
|
655
|
+
// read as an outage) while the rest is discarded as it arrives; only an
|
|
656
|
+
// upload that keeps going far beyond that is cut off.
|
|
657
|
+
function readBody(req, limit) {
|
|
658
|
+
return new Promise((resolve, reject) => {
|
|
659
|
+
const chunks = []
|
|
660
|
+
let size = 0
|
|
661
|
+
let tooLarge = false
|
|
662
|
+
let settled = false
|
|
663
|
+
const settle = (value) => {
|
|
664
|
+
if (settled) return
|
|
665
|
+
settled = true
|
|
666
|
+
resolve(value)
|
|
667
|
+
}
|
|
668
|
+
const hardCeiling = Math.max(limit * 2, limit + 1024 * 1024)
|
|
669
|
+
const abuseCeiling = hardCeiling * 16
|
|
670
|
+
req.on("data", (chunk) => {
|
|
671
|
+
size += chunk.length
|
|
672
|
+
if (tooLarge) {
|
|
673
|
+
if (size > hardCeiling) settle({ tooLarge: true, size })
|
|
674
|
+
if (size > abuseCeiling) req.destroy()
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
if (size > limit) {
|
|
678
|
+
tooLarge = true
|
|
679
|
+
chunks.length = 0
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
chunks.push(chunk)
|
|
683
|
+
})
|
|
684
|
+
req.on("end", () => settle(tooLarge ? { tooLarge: true, size } : { text: Buffer.concat(chunks).toString("utf8"), size }))
|
|
685
|
+
req.on("error", (error) => {
|
|
686
|
+
if (settled) return
|
|
687
|
+
settled = true
|
|
688
|
+
reject(error)
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function parseJsonObject(text) {
|
|
694
|
+
try {
|
|
695
|
+
const value = JSON.parse(text)
|
|
696
|
+
return value && typeof value === "object" ? value : null
|
|
697
|
+
} catch {
|
|
698
|
+
return null
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function isLoopback(address) {
|
|
703
|
+
if (!address) return false
|
|
704
|
+
const plain = address.replace(/^::ffff:/, "")
|
|
705
|
+
return plain === "127.0.0.1" || plain === "::1" || plain.startsWith("127.")
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const SECRET_SHAPES = [
|
|
709
|
+
/\bsk-[A-Za-z0-9_-]{8,}/,
|
|
710
|
+
/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/,
|
|
711
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
712
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
713
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
714
|
+
/\bAIza[0-9A-Za-z_-]{30,}/,
|
|
715
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
716
|
+
/\b[A-Za-z0-9_.-]*(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)\s*[=:]\s*["']?[^\s"']{6,}/i,
|
|
717
|
+
/[?&](token|key|api_key|apikey|secret|password|access_token|auth|sig|signature)=[^&\s]+/i,
|
|
718
|
+
// Any URL scheme with userinfo credentials: postgres://u:p@h, https://u:p@h.
|
|
719
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i,
|
|
720
|
+
]
|
|
721
|
+
|
|
722
|
+
function briefContainsSecret(text) {
|
|
723
|
+
return SECRET_SHAPES.some((shape) => shape.test(text))
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function readBrief(options) {
|
|
727
|
+
try {
|
|
728
|
+
return fs.readFileSync(options.briefFile, "utf8").slice(0, BRIEF_MAX_CHARS)
|
|
729
|
+
} catch {
|
|
730
|
+
return ""
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function directorySize(dir) {
|
|
735
|
+
let total = 0
|
|
736
|
+
let entries
|
|
737
|
+
try {
|
|
738
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
739
|
+
} catch {
|
|
740
|
+
return 0
|
|
741
|
+
}
|
|
742
|
+
for (const entry of entries) {
|
|
743
|
+
const full = path.join(dir, entry.name)
|
|
744
|
+
if (entry.isDirectory()) total += directorySize(full)
|
|
745
|
+
else if (entry.isFile()) {
|
|
746
|
+
try {
|
|
747
|
+
total += fs.statSync(full).size
|
|
748
|
+
} catch {
|
|
749
|
+
// Removed between readdir and stat.
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return total
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Statuses at which the agent is finished with a unit. A released unit at
|
|
757
|
+
// any other status (triaging, accepted, needs_info, working, or one a later
|
|
758
|
+
// build adds) is close-out work the agent still owes: only its own
|
|
759
|
+
// `applied`/`blocked` post retires it. Units never released to the agent
|
|
760
|
+
// (`initial`) are not its work.
|
|
761
|
+
const TERMINAL_UNIT_STATUSES = new Set(["applied", "blocked", "withdrawn"])
|
|
762
|
+
|
|
763
|
+
function unitsPendingCloseOut(board) {
|
|
764
|
+
if (!isRecord(board) || !Array.isArray(board.unit_order) || !isRecord(board.units)) return false
|
|
765
|
+
return board.unit_order.some((id) => {
|
|
766
|
+
const unit = Object.hasOwn(board.units, id) ? board.units[id] : undefined
|
|
767
|
+
return isRecord(unit) && unit.released === true && !TERMINAL_UNIT_STATUSES.has(unit.status)
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// A `.part` in state/log/ is an archive upload the previous process did not
|
|
772
|
+
// finish: the page retries Done with the whole archive, so the partial holds
|
|
773
|
+
// no evidence and must not count against the disk cap that retry is checked
|
|
774
|
+
// against.
|
|
775
|
+
function removeOrphanedArchiveParts(logDir) {
|
|
776
|
+
let entries
|
|
777
|
+
try {
|
|
778
|
+
entries = fs.readdirSync(logDir, { withFileTypes: true })
|
|
779
|
+
} catch {
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
for (const entry of entries) {
|
|
783
|
+
if (entry.isFile() && entry.name.startsWith("archive.") && entry.name.endsWith(ARCHIVE_PART_SUFFIX)) {
|
|
784
|
+
bestEffort(() => fs.rmSync(path.join(logDir, entry.name), { force: true }))
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
// Board: the persisted session state `GET /status` and the `status` CLI read.
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
|
|
793
|
+
function emptyBoard() {
|
|
794
|
+
return {
|
|
795
|
+
schema_version: SCHEMA_VERSION,
|
|
796
|
+
session_id: null,
|
|
797
|
+
ended: false,
|
|
798
|
+
mode: "smart",
|
|
799
|
+
acked_seq: 0,
|
|
800
|
+
units: {},
|
|
801
|
+
unit_order: [],
|
|
802
|
+
annotations: {},
|
|
803
|
+
annotation_order: [],
|
|
804
|
+
answers: [],
|
|
805
|
+
transcript_count: 0,
|
|
806
|
+
frame_count: 0,
|
|
807
|
+
checkpoints: [],
|
|
808
|
+
acked_checkpoint_ids: [],
|
|
809
|
+
released_unit_ids: [],
|
|
810
|
+
released_annotation_ids: [],
|
|
811
|
+
pending_withdrawn: [],
|
|
812
|
+
page: { stream: "never", last_stream_state: null, mic: null, lost_episodes: 0 },
|
|
813
|
+
page_lost_pending: null,
|
|
814
|
+
watch_for_loss: false,
|
|
815
|
+
final_emitted: false,
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function boardSummary(board, batches, logBytes) {
|
|
820
|
+
const byStatus = {}
|
|
821
|
+
for (const id of board.unit_order) {
|
|
822
|
+
const status = board.units[id]?.status ?? "unknown"
|
|
823
|
+
byStatus[status] = (byStatus[status] ?? 0) + 1
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
schema_version: SCHEMA_VERSION,
|
|
827
|
+
session_id: board.session_id,
|
|
828
|
+
ended: board.ended,
|
|
829
|
+
mode: board.mode,
|
|
830
|
+
page: board.page,
|
|
831
|
+
acked_seq: board.acked_seq,
|
|
832
|
+
units: {
|
|
833
|
+
total: board.unit_order.length,
|
|
834
|
+
by_status: byStatus,
|
|
835
|
+
// Enough for a run resumed after an ack to pick a unit up from the
|
|
836
|
+
// board alone: what to change, where (anchors), and the evidence that
|
|
837
|
+
// grounds it, not just the status it was left at.
|
|
838
|
+
list: board.unit_order.map((id) => {
|
|
839
|
+
const unit = board.units[id]
|
|
840
|
+
return {
|
|
841
|
+
id,
|
|
842
|
+
statement: unit.statement,
|
|
843
|
+
status: unit.status,
|
|
844
|
+
confirmed: unit.confirmed ?? null,
|
|
845
|
+
anchors: Array.isArray(unit.anchors) ? unit.anchors : [],
|
|
846
|
+
evidence: isRecord(unit.evidence) ? unit.evidence : null,
|
|
847
|
+
...(typeof unit.question === "string" ? { question: unit.question } : {}),
|
|
848
|
+
...(typeof unit.note === "string" ? { note: unit.note } : {}),
|
|
849
|
+
...(typeof unit.guess === "string" ? { guess: unit.guess } : {}),
|
|
850
|
+
}
|
|
851
|
+
}),
|
|
852
|
+
},
|
|
853
|
+
annotations: board.annotation_order.length,
|
|
854
|
+
answers: board.answers.length,
|
|
855
|
+
transcript_count: board.transcript_count,
|
|
856
|
+
frame_count: board.frame_count,
|
|
857
|
+
checkpoints: board.checkpoints.length,
|
|
858
|
+
batches: {
|
|
859
|
+
unserved: batches.filter((batch) => !batch.served).length,
|
|
860
|
+
unacked: batches.filter((batch) => batch.served).length,
|
|
861
|
+
},
|
|
862
|
+
page_lost_pending: Boolean(board.page_lost_pending),
|
|
863
|
+
log_bytes: logBytes,
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function loadBatches(options) {
|
|
868
|
+
let files
|
|
869
|
+
try {
|
|
870
|
+
files = fs.readdirSync(options.batchesDir).filter((file) => file.endsWith(".json"))
|
|
871
|
+
} catch {
|
|
872
|
+
return []
|
|
873
|
+
}
|
|
874
|
+
return files
|
|
875
|
+
.map((file) => readJsonOrNull(path.join(options.batchesDir, file)))
|
|
876
|
+
.filter((batch) => batch && batch.envelope?.checkpoint_id)
|
|
877
|
+
.sort((a, b) => a.order - b.order)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// `status` CLI and `GET /status` read the board through this one function.
|
|
881
|
+
function readBoardSummary(options) {
|
|
882
|
+
const board = readJsonOrNull(options.boardFile) ?? emptyBoard()
|
|
883
|
+
return boardSummary(board, loadBatches(options), directorySize(options.logDir))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
// CLI commands
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
async function start(options) {
|
|
891
|
+
ensureDirs(options)
|
|
892
|
+
options.ownerPid = options.ownerPid ?? resolveOwnerPid()
|
|
893
|
+
// One start per root at a time, foreground or detached: the running check
|
|
894
|
+
// and the launch happen under the same claim.
|
|
895
|
+
const lockPath = acquireStartLock(options)
|
|
896
|
+
try {
|
|
897
|
+
await startLocked(options)
|
|
898
|
+
} finally {
|
|
899
|
+
fs.rmSync(lockPath, { force: true })
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function acquireStartLock(options) {
|
|
904
|
+
const lockPath = path.join(options.stateDir, "start.lock")
|
|
905
|
+
let lockFd
|
|
906
|
+
try {
|
|
907
|
+
lockFd = fs.openSync(lockPath, "wx", 0o600)
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (error.code !== "EEXIST") throw error
|
|
910
|
+
const holder = Number(fs.readFileSync(lockPath, "utf8")) || null
|
|
911
|
+
if (holder && processAlive(holder)) throw new Error("Another `start` for this root is in progress")
|
|
912
|
+
fs.rmSync(lockPath, { force: true })
|
|
913
|
+
lockFd = fs.openSync(lockPath, "wx", 0o600)
|
|
914
|
+
}
|
|
915
|
+
fs.writeSync(lockFd, `${process.pid}\n`)
|
|
916
|
+
fs.closeSync(lockFd)
|
|
917
|
+
return lockPath
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Releases the lock only once the session file proves the server is up.
|
|
921
|
+
async function startLocked(options) {
|
|
922
|
+
const running = getRunningInfo(options)
|
|
923
|
+
if (running && !running.ended) {
|
|
924
|
+
if (running.app_origin !== options.appOrigin) {
|
|
925
|
+
throw new Error(`An endpoint for this root is already running with --app-origin ${running.app_origin}; stop it first`)
|
|
926
|
+
}
|
|
927
|
+
jsonOut({ ...publicStartEnvelope(running), status: "running" })
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
if (running) await stopServer(options)
|
|
931
|
+
requireInspectableProcess(options, readPid(options))
|
|
932
|
+
fs.rmSync(options.pidFile, { force: true })
|
|
933
|
+
const previous = readSession(options)
|
|
934
|
+
|
|
935
|
+
if (options.foreground) {
|
|
936
|
+
// serve() resolves once listening; the server handle keeps the process up.
|
|
937
|
+
const serving = serve(options)
|
|
938
|
+
serving.catch(() => {})
|
|
939
|
+
const up = await waitForSession(options, process.pid, previous)
|
|
940
|
+
await serving
|
|
941
|
+
if (!up) throw new Error("Endpoint failed to start")
|
|
942
|
+
return null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
await spawnServe(options, previous)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function spawnServe(options, previous) {
|
|
949
|
+
const logFd = fs.openSync(options.logFile, "a", 0o600)
|
|
950
|
+
const child = spawn(process.execPath, [
|
|
951
|
+
scriptPath,
|
|
952
|
+
"serve",
|
|
953
|
+
"--root",
|
|
954
|
+
options.root,
|
|
955
|
+
"--app-origin",
|
|
956
|
+
options.appOrigin,
|
|
957
|
+
...(options.hostExplicit ? ["--host", options.host] : []),
|
|
958
|
+
...(options.port !== undefined ? ["--port", String(options.port)] : []),
|
|
959
|
+
...(options.ownerPid ? ["--owner-pid", String(options.ownerPid)] : []),
|
|
960
|
+
...(options.trustProxy.length > 0 ? ["--trust-proxy", options.trustProxy.join(",")] : []),
|
|
961
|
+
], {
|
|
962
|
+
detached: true,
|
|
963
|
+
stdio: ["ignore", logFd, logFd],
|
|
964
|
+
})
|
|
965
|
+
child.unref()
|
|
966
|
+
fs.closeSync(logFd)
|
|
967
|
+
|
|
968
|
+
const started = await waitForSession(options, child.pid, previous)
|
|
969
|
+
if (!started) {
|
|
970
|
+
throw new Error(`Endpoint failed to start. See ${options.logFile}`)
|
|
971
|
+
}
|
|
972
|
+
// The child decides whether it resumed; the same tokens are the proof.
|
|
973
|
+
const resumed = Boolean(previous?.agent_token) && started.agent_token === previous.agent_token
|
|
974
|
+
jsonOut({ ...publicStartEnvelope(started), status: resumed ? "resumed" : "started" })
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function waitForSession(options, pid, previous) {
|
|
978
|
+
for (let i = 0; i < 100; i++) {
|
|
979
|
+
const session = readSession(options)
|
|
980
|
+
if (session && session.pid === pid && session.pid !== previous?.pid) return session
|
|
981
|
+
if (pid && !processAlive(pid)) return null
|
|
982
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
983
|
+
}
|
|
984
|
+
return null
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// A new session after an ended one keeps the old log by rotating it.
|
|
988
|
+
function rotateLog(options) {
|
|
989
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-")
|
|
990
|
+
const rotated = path.join(options.stateDir, `log-ended-${stamp}`)
|
|
991
|
+
try {
|
|
992
|
+
fs.renameSync(options.logDir, rotated)
|
|
993
|
+
} catch {
|
|
994
|
+
// Nothing to rotate.
|
|
995
|
+
}
|
|
996
|
+
ensureDirs(options)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Work the agent still owes this root: a batch on disk (served or not, never
|
|
1000
|
+
// acknowledged), or a released unit the board holds at a status only the
|
|
1001
|
+
// agent's `applied`/`blocked` post can retire. An ended session that holds
|
|
1002
|
+
// work resumes on `start`, `wait` sends the agent back to `start` for it,
|
|
1003
|
+
// and the page cannot open a new session over it.
|
|
1004
|
+
function sessionHoldsWork(options) {
|
|
1005
|
+
if (loadBatches(options).length > 0) return true
|
|
1006
|
+
return unitsPendingCloseOut(readJsonOrNull(options.boardFile))
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function endedAndDrained(options) {
|
|
1010
|
+
const stopped = readSession(options)
|
|
1011
|
+
if (!stopped?.ended) return false
|
|
1012
|
+
// `stop` retired the tokens: nothing can be posted against this session
|
|
1013
|
+
// any more, so whatever the board still holds is terminal as it stands.
|
|
1014
|
+
if (!stopped.agent_token) return true
|
|
1015
|
+
return !sessionHoldsWork(options)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function exitSessionEnded() {
|
|
1019
|
+
process.exitCode = 1
|
|
1020
|
+
jsonOut({ status: "session-ended" })
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function wait(options) {
|
|
1024
|
+
process.stdout.on("error", (error) => {
|
|
1025
|
+
console.error(error.message)
|
|
1026
|
+
process.exitCode = 2
|
|
1027
|
+
})
|
|
1028
|
+
const info = getRunningInfo(options)
|
|
1029
|
+
if (!info?.port) {
|
|
1030
|
+
// Idle/owner shutdown leaves the session file in place; an ended session
|
|
1031
|
+
// must still report that terminal status rather than "not running".
|
|
1032
|
+
// Ended and drained is terminal; ended with batches on disk still holds
|
|
1033
|
+
// work and needs a resume.
|
|
1034
|
+
if (endedAndDrained(options)) return exitSessionEnded()
|
|
1035
|
+
console.error("Endpoint is not running; run `start --root` to resume the session")
|
|
1036
|
+
process.exit(2)
|
|
1037
|
+
}
|
|
1038
|
+
if (!info.agent_token) {
|
|
1039
|
+
if (info.ended) return exitSessionEnded()
|
|
1040
|
+
console.error("No agent token in state/session.json")
|
|
1041
|
+
process.exit(2)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const url = `http://${localAddressFor(info.host)}:${info.port}/wait`
|
|
1045
|
+
const headers = { Authorization: `Bearer ${info.agent_token}` }
|
|
1046
|
+
while (true) {
|
|
1047
|
+
let response
|
|
1048
|
+
try {
|
|
1049
|
+
response = await fetch(url, { headers })
|
|
1050
|
+
} catch {
|
|
1051
|
+
// The helper may have died mid-poll; a retained final batch still needs
|
|
1052
|
+
// a resume, never a "session-ended" exit.
|
|
1053
|
+
if (endedAndDrained(options)) return exitSessionEnded()
|
|
1054
|
+
console.error("Endpoint is not running; run `start --root` to resume the session")
|
|
1055
|
+
process.exit(2)
|
|
1056
|
+
}
|
|
1057
|
+
if (response.status === 200 || response.status === 410) {
|
|
1058
|
+
const text = await response.text()
|
|
1059
|
+
// Let pending stdout writes drain instead of truncating a piped batch.
|
|
1060
|
+
process.exitCode = response.status === 200 ? 0 : 1
|
|
1061
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
if (response.status === 409) {
|
|
1065
|
+
process.exitCode = 3
|
|
1066
|
+
jsonOut({ status: "wait-taken" })
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
if (response.status === 204) continue
|
|
1070
|
+
console.error(`wait: unexpected HTTP ${response.status}`)
|
|
1071
|
+
process.exit(2)
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function stopServer(options) {
|
|
1076
|
+
const pid = readPid(options)
|
|
1077
|
+
requireInspectableProcess(options, pid)
|
|
1078
|
+
if (processAlive(pid) && ownsServerProcess(options, pid)) {
|
|
1079
|
+
process.kill(pid)
|
|
1080
|
+
for (let i = 0; i < 20; i++) {
|
|
1081
|
+
if (!processAlive(pid)) break
|
|
1082
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1083
|
+
}
|
|
1084
|
+
if (processAlive(pid)) {
|
|
1085
|
+
try {
|
|
1086
|
+
process.kill(pid, "SIGKILL")
|
|
1087
|
+
} catch {
|
|
1088
|
+
// Process may have exited between the liveness check and kill.
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
fs.rmSync(options.pidFile, { force: true })
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// `stop` ends the session: both tokens are invalidated, un-acked batches are
|
|
1096
|
+
// discarded, and state/log/ is kept for replay.
|
|
1097
|
+
async function stop(options) {
|
|
1098
|
+
await stopServer(options)
|
|
1099
|
+
const session = readSession(options)
|
|
1100
|
+
if (session) {
|
|
1101
|
+
writePrivateJson(options.sessionFile, { ...session, page_token: null, agent_token: null, ended: true, pid: null })
|
|
1102
|
+
}
|
|
1103
|
+
const board = readJsonOrNull(options.boardFile)
|
|
1104
|
+
if (board) writePrivateJson(options.boardFile, { ...board, ended: true })
|
|
1105
|
+
fs.rmSync(options.batchesDir, { recursive: true, force: true })
|
|
1106
|
+
jsonOut({ status: "stopped", root: options.root, log_dir: options.logDir })
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function status(options) {
|
|
1110
|
+
const running = getRunningInfo(options)
|
|
1111
|
+
const session = readSession(options)
|
|
1112
|
+
jsonOut({
|
|
1113
|
+
status: running ? "running" : "stopped",
|
|
1114
|
+
root: options.root,
|
|
1115
|
+
...(running ? { url: running.url, port: running.port, app_origin: running.app_origin } : {}),
|
|
1116
|
+
session_ended: Boolean(session?.ended),
|
|
1117
|
+
board: readBoardSummary(options),
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Replay: re-emit state/log/ under an evidence profile to another endpoint.
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
const TELEMETRY_EVENT_TYPES = new Set(["click", "navigation", "network_request", "console_error"])
|
|
1126
|
+
|
|
1127
|
+
// `retainedFrames` is the set of frame ids the profile keeps, so unit
|
|
1128
|
+
// evidence never points at a frame the replay did not send.
|
|
1129
|
+
function applyProfile(envelope, profile, retainedFrames) {
|
|
1130
|
+
const rules = EVIDENCE_PROFILES[profile]
|
|
1131
|
+
const { type, payload } = envelope
|
|
1132
|
+
if (type === "frame") {
|
|
1133
|
+
if (rules.frames === "none") return null
|
|
1134
|
+
if (rules.frames === "composite" && payload?.kind !== "composite") return null
|
|
1135
|
+
return envelope
|
|
1136
|
+
}
|
|
1137
|
+
if (TELEMETRY_EVENT_TYPES.has(type) && !rules.telemetry) return null
|
|
1138
|
+
if (type === "annotation" && !rules.annotations) return null
|
|
1139
|
+
if ((type === "unit" || type === "unit_update") && payload && typeof payload === "object") {
|
|
1140
|
+
const next = { ...payload }
|
|
1141
|
+
if (next.evidence && typeof next.evidence === "object") {
|
|
1142
|
+
const evidence = { ...next.evidence }
|
|
1143
|
+
if (rules.frames === "none") evidence.frame_ids = []
|
|
1144
|
+
else if (Array.isArray(evidence.frame_ids)) evidence.frame_ids = evidence.frame_ids.filter((id) => retainedFrames.has(id))
|
|
1145
|
+
if (!rules.annotations) evidence.annotation_ids = []
|
|
1146
|
+
if (!rules.clips) delete evidence.audio_clip_id
|
|
1147
|
+
if (!rules.telemetry) delete evidence.telemetry_window
|
|
1148
|
+
next.evidence = evidence
|
|
1149
|
+
}
|
|
1150
|
+
return { ...envelope, payload: next }
|
|
1151
|
+
}
|
|
1152
|
+
return envelope
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async function replay(options) {
|
|
1156
|
+
const eventsFile = path.join(options.logDir, "events.ndjson")
|
|
1157
|
+
if (!fs.existsSync(eventsFile)) throw new Error(`No session log at ${eventsFile}`)
|
|
1158
|
+
// No longer than the recorded id: the rewrite must not push an envelope the
|
|
1159
|
+
// source accepted at the 64 KB cap over it at the target.
|
|
1160
|
+
const recorded = readJsonOrNull(options.boardFile)?.session_id ?? ""
|
|
1161
|
+
const sessionId = `r${randomBytes(16).toString("hex")}`.slice(0, Math.max(1, String(recorded).length))
|
|
1162
|
+
const headers = {
|
|
1163
|
+
"Content-Type": "application/json",
|
|
1164
|
+
Authorization: `Bearer ${options.token}`,
|
|
1165
|
+
"X-Riffrec-Session": sessionId,
|
|
1166
|
+
}
|
|
1167
|
+
let seq = 0
|
|
1168
|
+
let sent = 0
|
|
1169
|
+
let skipped = 0
|
|
1170
|
+
let batch = []
|
|
1171
|
+
let batchBytes = 0
|
|
1172
|
+
|
|
1173
|
+
// First pass: which frames the profile keeps, so evidence can be pruned to
|
|
1174
|
+
// them on the second pass. Only ids are held, and only for frames the
|
|
1175
|
+
// second pass will actually emit: one whose image file is missing is
|
|
1176
|
+
// skipped there, so its id must not survive in a unit's evidence either.
|
|
1177
|
+
const retainedFrames = new Set()
|
|
1178
|
+
const frameRule = EVIDENCE_PROFILES[options.profile].frames
|
|
1179
|
+
if (frameRule !== "none") {
|
|
1180
|
+
const scan = readline.createInterface({ input: fs.createReadStream(eventsFile, "utf8"), crlfDelay: Infinity })
|
|
1181
|
+
for await (const line of scan) {
|
|
1182
|
+
const stored = line ? parseJsonObject(line) : null
|
|
1183
|
+
if (stored?.type !== "frame" || typeof stored.payload?.id !== "string") continue
|
|
1184
|
+
if (frameRule === "composite" && stored.payload.kind !== "composite") continue
|
|
1185
|
+
const emittable = !stored.frame_file || frameFileWithin(options.logDir, stored.frame_file) !== null
|
|
1186
|
+
if (emittable) retainedFrames.add(stored.payload.id)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function post(envelopes) {
|
|
1191
|
+
const body = JSON.stringify(envelopes.length === 1 ? envelopes[0] : envelopes)
|
|
1192
|
+
const response = await fetch(`${options.to}/events`, { method: "POST", headers, body })
|
|
1193
|
+
if (!response.ok) {
|
|
1194
|
+
const text = await response.text().catch(() => "")
|
|
1195
|
+
throw new Error(`replay: ${options.to}/events answered ${response.status} ${text.trim()}`)
|
|
1196
|
+
}
|
|
1197
|
+
sent += envelopes.length
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function flush() {
|
|
1201
|
+
if (batch.length === 0) return
|
|
1202
|
+
const pending = batch
|
|
1203
|
+
batch = []
|
|
1204
|
+
batchBytes = 0
|
|
1205
|
+
await post(pending)
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Encoded size of the request body the batch would produce: a lone
|
|
1209
|
+
// envelope is posted bare, so it fits whenever the target accepted it.
|
|
1210
|
+
function bodyBytes(count, payloadBytes) {
|
|
1211
|
+
return count <= 1 ? payloadBytes : payloadBytes + 2 + (count - 1)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Line by line: a log near the 500 MB budget must not be held whole.
|
|
1215
|
+
const lines = readline.createInterface({ input: fs.createReadStream(eventsFile, "utf8"), crlfDelay: Infinity })
|
|
1216
|
+
for await (const line of lines) {
|
|
1217
|
+
if (!line) continue
|
|
1218
|
+
const stored = parseJsonObject(line)
|
|
1219
|
+
if (!stored || typeof stored.type !== "string") continue
|
|
1220
|
+
let envelope = { schema_version: SCHEMA_VERSION, session_id: sessionId, seq: 0, t: stored.t, type: stored.type, payload: stored.payload }
|
|
1221
|
+
if (stored.type === "frame" && !stored.frame_file) {
|
|
1222
|
+
envelope = { ...envelope, payload: { ...stored.payload, jpeg_base64: "" } }
|
|
1223
|
+
} else if (stored.type === "frame") {
|
|
1224
|
+
try {
|
|
1225
|
+
const frameFile = frameFileWithin(options.logDir, stored.frame_file)
|
|
1226
|
+
if (frameFile === null) throw new Error("frame file outside the selected log")
|
|
1227
|
+
const jpeg = fs.readFileSync(frameFile)
|
|
1228
|
+
envelope = { ...envelope, payload: { ...stored.payload, jpeg_base64: jpeg.toString("base64") } }
|
|
1229
|
+
} catch {
|
|
1230
|
+
skipped += 1
|
|
1231
|
+
continue
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
envelope = applyProfile(envelope, options.profile, retainedFrames)
|
|
1235
|
+
if (!envelope) {
|
|
1236
|
+
skipped += 1
|
|
1237
|
+
continue
|
|
1238
|
+
}
|
|
1239
|
+
envelope.seq = ++seq
|
|
1240
|
+
if (envelope.type === "frame") {
|
|
1241
|
+
await flush()
|
|
1242
|
+
await post([envelope])
|
|
1243
|
+
continue
|
|
1244
|
+
}
|
|
1245
|
+
// The target's 64 KB cap is in encoded bytes; measure the same way and
|
|
1246
|
+
// flush before the envelope that would cross it.
|
|
1247
|
+
const encoded = Buffer.byteLength(JSON.stringify(envelope))
|
|
1248
|
+
if (envelope.type !== "frame" && encoded > BODY_LIMIT) {
|
|
1249
|
+
throw new Error(`replay: envelope ${stored.seq ?? "?"} (${envelope.type}) is ${encoded} bytes, over the ${BODY_LIMIT}-byte cap even posted alone`)
|
|
1250
|
+
}
|
|
1251
|
+
if (batch.length > 0 && bodyBytes(batch.length + 1, batchBytes + encoded) > BODY_LIMIT) await flush()
|
|
1252
|
+
batch.push(envelope)
|
|
1253
|
+
batchBytes += encoded
|
|
1254
|
+
if (envelope.type === "checkpoint") await flush()
|
|
1255
|
+
}
|
|
1256
|
+
await flush()
|
|
1257
|
+
jsonOut({ status: "replayed", to: options.to, profile: options.profile, session_id: sessionId, envelopes_sent: sent, envelopes_skipped: skipped })
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
// Server
|
|
1262
|
+
// ---------------------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
async function serve(options) {
|
|
1265
|
+
ensureDirs(options)
|
|
1266
|
+
|
|
1267
|
+
// Resume when a live session file exists for this root; otherwise mint a
|
|
1268
|
+
// fresh pair of credentials and start a new board.
|
|
1269
|
+
const previous = readSession(options)
|
|
1270
|
+
// The end of a session is two writes (board, then session record). A
|
|
1271
|
+
// process that died between them left one saying ended and the other
|
|
1272
|
+
// not; either one saying so is the recorded intent, and both are brought
|
|
1273
|
+
// to it here before anything is served, so a resume cannot answer 410 on
|
|
1274
|
+
// page routes while `status` still reports the session live.
|
|
1275
|
+
const storedBoard = readJsonOrNull(options.boardFile)
|
|
1276
|
+
if (previous && storedBoard?.ended === true && !previous.ended) previous.ended = true
|
|
1277
|
+
// An ended session whose agent token is still retained resumes while the
|
|
1278
|
+
// agent still owes it close-out work: a batch not yet acknowledged, or a
|
|
1279
|
+
// unit the board holds at triaging/accepted/needs_info that the resumed
|
|
1280
|
+
// run must apply or mark blocked. Only a drained, reconciled ended root
|
|
1281
|
+
// starts fresh.
|
|
1282
|
+
const draining = Boolean(previous?.ended && previous.agent_token && sessionHoldsWork(options))
|
|
1283
|
+
const resuming = Boolean(previous && previous.agent_token && ((!previous.ended && previous.page_token) || draining))
|
|
1284
|
+
// A resume keeps the previous bind host unless the caller names a new one;
|
|
1285
|
+
// the documented recovery is a bare `start --root <dir>` again.
|
|
1286
|
+
if (resuming && !options.hostExplicit && typeof previous.host === "string" && previous.host) options.host = previous.host
|
|
1287
|
+
if (resuming && options.trustProxy.length === 0 && Array.isArray(previous.trust_proxy)) options.trustProxy = previous.trust_proxy.filter((ip) => net.isIP(ip))
|
|
1288
|
+
const pageToken = resuming ? previous.page_token : newToken()
|
|
1289
|
+
const agentToken = resuming ? previous.agent_token : newToken()
|
|
1290
|
+
if (!resuming) {
|
|
1291
|
+
fs.rmSync(options.batchesDir, { recursive: true, force: true })
|
|
1292
|
+
fs.rmSync(options.boardFile, { force: true })
|
|
1293
|
+
fs.mkdirSync(options.batchesDir, { recursive: true, mode: 0o700 })
|
|
1294
|
+
if (previous?.ended) rotateLog(options)
|
|
1295
|
+
}
|
|
1296
|
+
const board = resuming ? { ...emptyBoard(), ...(storedBoard ?? {}) } : emptyBoard()
|
|
1297
|
+
if (resuming && previous.ended) board.ended = true
|
|
1298
|
+
// A batch file whose checkpoint the board already records as acknowledged
|
|
1299
|
+
// is the retirement half of an ack that did not finish; it is not served again.
|
|
1300
|
+
const batches = []
|
|
1301
|
+
for (const batch of resuming ? loadBatches(options) : []) {
|
|
1302
|
+
if (board.acked_checkpoint_ids.includes(batch.envelope.checkpoint_id)) {
|
|
1303
|
+
bestEffort(() => fs.rmSync(path.join(options.batchesDir, `${encodeURIComponent(batch.envelope.checkpoint_id)}.json`), { force: true }))
|
|
1304
|
+
} else {
|
|
1305
|
+
batches.push(batch)
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
let batchOrder = batches.reduce((max, batch) => Math.max(max, batch.order), 0)
|
|
1309
|
+
const port = options.port ?? (resuming && Number.isInteger(previous.port) ? previous.port : 0)
|
|
1310
|
+
|
|
1311
|
+
const eventsLog = path.join(options.logDir, "events.ndjson")
|
|
1312
|
+
const agentLog = path.join(options.logDir, "agent.ndjson")
|
|
1313
|
+
const framesDir = path.join(options.logDir, "frames")
|
|
1314
|
+
removeOrphanedArchiveParts(options.logDir)
|
|
1315
|
+
let logBytes = directorySize(options.logDir)
|
|
1316
|
+
let session = null
|
|
1317
|
+
let waiter = null
|
|
1318
|
+
// What the agent is actually doing, for the page: "listening" while a wait is
|
|
1319
|
+
// parked, "working" from the moment a batch is served until the next wait,
|
|
1320
|
+
// "away" when no wait has parked for a while.
|
|
1321
|
+
let agentState = { state: "away", since: Date.now() }
|
|
1322
|
+
let agentAwayTimer = null
|
|
1323
|
+
function setAgentState(state, extra = {}) {
|
|
1324
|
+
if (state !== "away" && agentAwayTimer) {
|
|
1325
|
+
clearTimeout(agentAwayTimer)
|
|
1326
|
+
agentAwayTimer = null
|
|
1327
|
+
}
|
|
1328
|
+
if (agentState.state === state && !extra.checkpoint_id) return
|
|
1329
|
+
agentState = { state, since: Date.now(), ...extra }
|
|
1330
|
+
broadcast("agent", agentState)
|
|
1331
|
+
}
|
|
1332
|
+
function agentMaybeAway() {
|
|
1333
|
+
if (agentAwayTimer || agentState.state === "working") return
|
|
1334
|
+
agentAwayTimer = setTimeout(() => {
|
|
1335
|
+
agentAwayTimer = null
|
|
1336
|
+
if (!waiter) setAgentState("away")
|
|
1337
|
+
}, AGENT_AWAY_GRACE_MS)
|
|
1338
|
+
agentAwayTimer.unref?.()
|
|
1339
|
+
}
|
|
1340
|
+
const streamClients = new Set()
|
|
1341
|
+
const outOfOrder = new Map()
|
|
1342
|
+
let endingInFlight = false
|
|
1343
|
+
// Non-null while an envelope transaction is open; SSE notices and wakes
|
|
1344
|
+
// queue here and are delivered only once the transaction commits.
|
|
1345
|
+
let heldNotices = null
|
|
1346
|
+
// Ids are validated but may still name inherited Object properties.
|
|
1347
|
+
const unitById = (id) => (Object.hasOwn(board.units, id) ? board.units[id] : undefined)
|
|
1348
|
+
const annotationById = (id) => (Object.hasOwn(board.annotations, id) ? board.annotations[id] : undefined)
|
|
1349
|
+
// Bytes of frames waiting in the gap buffer, counted against the disk cap
|
|
1350
|
+
// before they land so a burst of early frames cannot overshoot it.
|
|
1351
|
+
let reservedBytes = 0
|
|
1352
|
+
let pageLostTimer = null
|
|
1353
|
+
let mintInFlight = false
|
|
1354
|
+
const mintTimes = []
|
|
1355
|
+
let lastActivity = Date.now()
|
|
1356
|
+
const touch = () => {
|
|
1357
|
+
lastActivity = Date.now()
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function saveBoard() {
|
|
1361
|
+
writePrivateJson(options.boardFile, board)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function saveSession(patch) {
|
|
1365
|
+
session = { ...session, ...patch }
|
|
1366
|
+
writePrivateJson(options.sessionFile, session)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function logEvent(record) {
|
|
1370
|
+
const line = `${JSON.stringify(record)}\n`
|
|
1371
|
+
appendPrivate(eventsLog, line)
|
|
1372
|
+
logBytes += Buffer.byteLength(line)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function logAgent(record) {
|
|
1376
|
+
const line = `${JSON.stringify({ t: Date.now(), ...record })}\n`
|
|
1377
|
+
appendPrivate(agentLog, line)
|
|
1378
|
+
logBytes += Buffer.byteLength(line)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Events a reconnecting stream cannot rebuild from the board (`applied`
|
|
1382
|
+
// is a moment, not a state) wait here while no stream is attached: after
|
|
1383
|
+
// a flushed response the page is between connections for a second or
|
|
1384
|
+
// more, and a notice posted then would otherwise never reach it. Events
|
|
1385
|
+
// the connect-time replay reconstructs are not kept; the replay is newer.
|
|
1386
|
+
const REPLAYED_ON_CONNECT = new Set(["ack", "unit_status", "ask", "agent"])
|
|
1387
|
+
const PENDING_DELIVERY_CAP = 256
|
|
1388
|
+
const pendingDeliveries = []
|
|
1389
|
+
|
|
1390
|
+
function broadcast(event, payload) {
|
|
1391
|
+
if (heldNotices) {
|
|
1392
|
+
heldNotices.push({ event, payload })
|
|
1393
|
+
return
|
|
1394
|
+
}
|
|
1395
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`
|
|
1396
|
+
if (streamClients.size === 0 && !REPLAYED_ON_CONNECT.has(event) && !board.ended) {
|
|
1397
|
+
pendingDeliveries.push(frame)
|
|
1398
|
+
if (pendingDeliveries.length > PENDING_DELIVERY_CAP) pendingDeliveries.shift()
|
|
1399
|
+
}
|
|
1400
|
+
for (const client of streamClients) {
|
|
1401
|
+
if (!client.writableEnded) client.write(frame)
|
|
1402
|
+
}
|
|
1403
|
+
// An ack rides every POST and the page already has it in that POST's
|
|
1404
|
+
// response; ending the stream for it would keep the page reconnecting.
|
|
1405
|
+
// The agent state changes on every wait; it rides the stream without forcing a reconnect.
|
|
1406
|
+
if (event !== "ack" && event !== "agent") scheduleStreamFlush()
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Delivers what a transaction held once it has committed: wakes to the
|
|
1410
|
+
// parked agent, notices to the stream. A no-op when nothing is held.
|
|
1411
|
+
function releaseHeldNotices() {
|
|
1412
|
+
const notices = heldNotices
|
|
1413
|
+
heldNotices = null
|
|
1414
|
+
if (!notices) return
|
|
1415
|
+
for (const notice of notices) {
|
|
1416
|
+
if (notice.wake) fulfillWaiter()
|
|
1417
|
+
else broadcast(notice.event, notice.payload)
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// A stream response is ended shortly after a delivery. Some TLS-terminating
|
|
1422
|
+
// intermediaries (cloudflared quick tunnels, fronted by a Worker) hold a
|
|
1423
|
+
// streaming body until the response completes, so an open SSE stream never
|
|
1424
|
+
// reaches the page; a completed one does at once. The page reconnects a
|
|
1425
|
+
// second later and handleStream replays every unit's current state, so on
|
|
1426
|
+
// such a path the stream is a sequence of short responses, and on a direct
|
|
1427
|
+
// path the same responses arrive as they are written. The delay lets a
|
|
1428
|
+
// burst (a release, an accepted then applied) ride one response.
|
|
1429
|
+
let streamFlushTimer = null
|
|
1430
|
+
function scheduleStreamFlush() {
|
|
1431
|
+
if (streamClients.size === 0) return
|
|
1432
|
+
if (streamFlushTimer) clearTimeout(streamFlushTimer)
|
|
1433
|
+
streamFlushTimer = setTimeout(() => {
|
|
1434
|
+
streamFlushTimer = null
|
|
1435
|
+
for (const client of streamClients) {
|
|
1436
|
+
if (!client.writableEnded) client.end()
|
|
1437
|
+
}
|
|
1438
|
+
}, STREAM_FLUSH_MS)
|
|
1439
|
+
streamFlushTimer.unref?.()
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// --- batches ---------------------------------------------------------------
|
|
1443
|
+
|
|
1444
|
+
function batchFile(checkpointId) {
|
|
1445
|
+
return path.join(options.batchesDir, `${encodeURIComponent(checkpointId)}.json`)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function persistBatch(batch) {
|
|
1449
|
+
writePrivateJson(batchFile(batch.envelope.checkpoint_id), batch)
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function heldUnits() {
|
|
1453
|
+
return board.unit_order
|
|
1454
|
+
.map((id) => unitById(id))
|
|
1455
|
+
.filter((unit) => unit && !unit.released && unit.status !== "withdrawn")
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function heldAnnotations() {
|
|
1459
|
+
return board.annotation_order
|
|
1460
|
+
.map((id) => annotationById(id))
|
|
1461
|
+
.filter((annotation) => annotation && !annotation.released)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function makeEnvelope(checkpointId, kind, mode, extra) {
|
|
1465
|
+
return {
|
|
1466
|
+
schema_version: SCHEMA_VERSION,
|
|
1467
|
+
checkpoint_id: checkpointId,
|
|
1468
|
+
kind,
|
|
1469
|
+
mode_at_checkpoint: mode,
|
|
1470
|
+
session_status: "live",
|
|
1471
|
+
units: [],
|
|
1472
|
+
annotations: [],
|
|
1473
|
+
answers: [],
|
|
1474
|
+
...extra,
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function publicUnit(unit) {
|
|
1479
|
+
const { released, checkpoint_id, ...rest } = unit
|
|
1480
|
+
return rest
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function publicAnnotation(annotation) {
|
|
1484
|
+
const { released, ...rest } = annotation
|
|
1485
|
+
return rest
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// KTD12: units the agent accepted but has not applied or blocked since.
|
|
1489
|
+
// `final` and `mode_change` carry them so a Collect backlog is applied.
|
|
1490
|
+
function backlogUnits() {
|
|
1491
|
+
return board.unit_order
|
|
1492
|
+
.map((id) => unitById(id))
|
|
1493
|
+
.filter((unit) => unit && unit.released && unit.status === "accepted")
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// A checkpoint releases every held unit and annotation plus the
|
|
1497
|
+
// withdrawals that arrived after an earlier release. `silence`,
|
|
1498
|
+
// `page_change`, and `send` wake the agent only when they release
|
|
1499
|
+
// something; `final` always wakes and also carries the accepted backlog.
|
|
1500
|
+
function releaseCheckpoint(checkpointId, kind, mode) {
|
|
1501
|
+
const units = heldUnits()
|
|
1502
|
+
const annotations = heldAnnotations()
|
|
1503
|
+
const withdrawn = board.pending_withdrawn.map((id) => unitById(id)).filter(Boolean)
|
|
1504
|
+
const backlog = kind === "final" ? backlogUnits() : []
|
|
1505
|
+
const releases = units.length + annotations.length + withdrawn.length + backlog.length > 0
|
|
1506
|
+
if (!releases && !ALWAYS_WAKE_KINDS.has(kind)) return null
|
|
1507
|
+
for (const unit of units) {
|
|
1508
|
+
unit.released = true
|
|
1509
|
+
unit.status = "triaging"
|
|
1510
|
+
unit.checkpoint_id = checkpointId
|
|
1511
|
+
board.released_unit_ids.push(unit.id)
|
|
1512
|
+
}
|
|
1513
|
+
for (const annotation of annotations) {
|
|
1514
|
+
annotation.released = true
|
|
1515
|
+
board.released_annotation_ids.push(annotation.id)
|
|
1516
|
+
}
|
|
1517
|
+
board.pending_withdrawn = []
|
|
1518
|
+
board.mode = mode
|
|
1519
|
+
board.checkpoints.push({ id: checkpointId, kind, mode, t: Date.now() })
|
|
1520
|
+
if (kind === "final") board.final_emitted = true
|
|
1521
|
+
const envelope = makeEnvelope(checkpointId, kind, mode, {
|
|
1522
|
+
units: [
|
|
1523
|
+
...units.map(publicUnit),
|
|
1524
|
+
...backlog.map(publicUnit),
|
|
1525
|
+
...withdrawn.map((unit) => ({ ...publicUnit(unit), status: "withdrawn" })),
|
|
1526
|
+
],
|
|
1527
|
+
annotations: annotations.map(publicAnnotation),
|
|
1528
|
+
})
|
|
1529
|
+
enqueueBatch(envelope)
|
|
1530
|
+
for (const unit of units) broadcast("unit_status", { unit_id: unit.id, status: "triaging" })
|
|
1531
|
+
saveBoard()
|
|
1532
|
+
return envelope
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// KTD12: leaving Collect wakes the agent at once with the accepted backlog.
|
|
1536
|
+
function emitModeChange(mode) {
|
|
1537
|
+
const checkpointId = `ck-mode-${randomUUID()}`
|
|
1538
|
+
board.checkpoints.push({ id: checkpointId, kind: "mode_change", mode, t: Date.now() })
|
|
1539
|
+
enqueueBatch(makeEnvelope(checkpointId, "mode_change", mode, { units: backlogUnits().map(publicUnit) }))
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function enqueueBatch(envelope) {
|
|
1543
|
+
const batch = { order: ++batchOrder, served: false, envelope }
|
|
1544
|
+
batches.push(batch)
|
|
1545
|
+
persistBatch(batch)
|
|
1546
|
+
fulfillWaiter()
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function nextBatch() {
|
|
1550
|
+
return batches.find((batch) => batch.served) ?? batches.find((batch) => !batch.served) ?? null
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function takeWaiter() {
|
|
1554
|
+
const parked = waiter
|
|
1555
|
+
waiter = null
|
|
1556
|
+
clearTimeout(parked.timer)
|
|
1557
|
+
return parked.res
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Serve order: a batch served without an ack first, then the oldest new
|
|
1561
|
+
// batch, then a pending page-lost notice; an ended session with nothing
|
|
1562
|
+
// held answers 410.
|
|
1563
|
+
function fulfillWaiter() {
|
|
1564
|
+
if (heldNotices) {
|
|
1565
|
+
heldNotices.push({ wake: true })
|
|
1566
|
+
return
|
|
1567
|
+
}
|
|
1568
|
+
if (!waiter || waiter.res.writableEnded) return
|
|
1569
|
+
const batch = nextBatch()
|
|
1570
|
+
if (batch) {
|
|
1571
|
+
batch.served = true
|
|
1572
|
+
persistBatch(batch)
|
|
1573
|
+
sendJson(takeWaiter(), 200, batch.envelope)
|
|
1574
|
+
setAgentState("working", { checkpoint_id: batch.envelope.checkpoint_id })
|
|
1575
|
+
return
|
|
1576
|
+
}
|
|
1577
|
+
if (board.page_lost_pending) {
|
|
1578
|
+
// Left by a helper from before page-lost wakes were queued as batches.
|
|
1579
|
+
const envelope = board.page_lost_pending
|
|
1580
|
+
board.page_lost_pending = null
|
|
1581
|
+
saveBoard()
|
|
1582
|
+
enqueueBatch(envelope)
|
|
1583
|
+
return
|
|
1584
|
+
}
|
|
1585
|
+
if (board.ended) {
|
|
1586
|
+
sendJson(takeWaiter(), 410, { status: "session-ended" })
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// --- page-lost detection (KTD8) ----------------------------------------------
|
|
1591
|
+
|
|
1592
|
+
function armPageLost() {
|
|
1593
|
+
if (pageLostTimer || board.ended) return
|
|
1594
|
+
pageLostTimer = setTimeout(() => {
|
|
1595
|
+
pageLostTimer = null
|
|
1596
|
+
if (streamClients.size > 0 || board.ended) return
|
|
1597
|
+
const last = board.checkpoints[board.checkpoints.length - 1]
|
|
1598
|
+
// Queued like any batch: persisted and re-served until acknowledged.
|
|
1599
|
+
// The batch lands before the watch is cleared, so a failure at either
|
|
1600
|
+
// step leaves the durable board still watching: a restart re-arms it
|
|
1601
|
+
// and the loss is reported then, rather than lost with a crashed timer.
|
|
1602
|
+
// The wake is held until the whole transition has committed, so a
|
|
1603
|
+
// parked agent never receives a batch that a failed board save then
|
|
1604
|
+
// takes back from under its acknowledgment.
|
|
1605
|
+
const undo = snapshotState()
|
|
1606
|
+
heldNotices = []
|
|
1607
|
+
try {
|
|
1608
|
+
enqueueBatch(makeEnvelope(`page-lost-${randomUUID()}`, last?.kind ?? "send", board.mode, {
|
|
1609
|
+
session_status: "page_lost",
|
|
1610
|
+
lost_after_checkpoint_id: last?.id ?? null,
|
|
1611
|
+
}))
|
|
1612
|
+
board.page.lost_episodes += 1
|
|
1613
|
+
board.page.stream = "lost"
|
|
1614
|
+
board.watch_for_loss = false
|
|
1615
|
+
saveBoard()
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
heldNotices = null
|
|
1618
|
+
undo()
|
|
1619
|
+
bestEffort(() => logAgent({ kind: "page_lost_persist_failed", error: error.code ?? error.message }))
|
|
1620
|
+
armPageLost()
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1623
|
+
releaseHeldNotices()
|
|
1624
|
+
}, PAGE_LOST_GRACE_MS)
|
|
1625
|
+
pageLostTimer.unref()
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function disarmPageLost() {
|
|
1629
|
+
if (pageLostTimer) {
|
|
1630
|
+
clearTimeout(pageLostTimer)
|
|
1631
|
+
pageLostTimer = null
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// The watch set by an applied notice is one-shot: it ends when the page
|
|
1636
|
+
// reconnects, or once the grace window passes with the stream still up
|
|
1637
|
+
// (the edit did not take the page down). Without that expiry, a stream
|
|
1638
|
+
// that never dropped would carry the watch to the next ordinary disconnect.
|
|
1639
|
+
let watchForLossTimer = null
|
|
1640
|
+
|
|
1641
|
+
function clearWatchForLoss() {
|
|
1642
|
+
if (watchForLossTimer) {
|
|
1643
|
+
clearTimeout(watchForLossTimer)
|
|
1644
|
+
watchForLossTimer = null
|
|
1645
|
+
}
|
|
1646
|
+
if (board.watch_for_loss) board.watch_for_loss = false
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function watchForLoss() {
|
|
1650
|
+
board.watch_for_loss = true
|
|
1651
|
+
saveBoard()
|
|
1652
|
+
if (streamClients.size === 0) {
|
|
1653
|
+
armPageLost()
|
|
1654
|
+
return
|
|
1655
|
+
}
|
|
1656
|
+
if (watchForLossTimer) clearTimeout(watchForLossTimer)
|
|
1657
|
+
watchForLossTimer = setTimeout(() => {
|
|
1658
|
+
watchForLossTimer = null
|
|
1659
|
+
if (streamClients.size === 0 || board.ended) return
|
|
1660
|
+
clearWatchForLoss()
|
|
1661
|
+
bestEffort(() => saveBoard())
|
|
1662
|
+
}, PAGE_LOST_GRACE_MS)
|
|
1663
|
+
watchForLossTimer.unref()
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// --- session end -----------------------------------------------------------
|
|
1667
|
+
|
|
1668
|
+
// /session/end ends the board, not the tokens. The page token stays valid so
|
|
1669
|
+
// the same link can open another session once this one drains (see
|
|
1670
|
+
// openSession); both tokens live until `stop` (or a fresh start on the ended
|
|
1671
|
+
// root), per I4/KTD18: an agent that crashes between the final ack and
|
|
1672
|
+
// `stop` can still resume, `status`, and `stop`.
|
|
1673
|
+
function endSession() {
|
|
1674
|
+
if (board.ended) return
|
|
1675
|
+
// Terminal state is committed before anything observable happens; if the
|
|
1676
|
+
// writes fail the session stays live and /session/end can be retried.
|
|
1677
|
+
const before = { ended: board.ended, stream: board.page.stream, session }
|
|
1678
|
+
board.ended = true
|
|
1679
|
+
board.page.stream = streamClients.size > 0 ? "connected" : board.page.stream
|
|
1680
|
+
try {
|
|
1681
|
+
saveBoard()
|
|
1682
|
+
saveSession({ ended: true })
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
board.ended = before.ended
|
|
1685
|
+
board.page.stream = before.stream
|
|
1686
|
+
session = before.session
|
|
1687
|
+
bestEffort(() => saveBoard())
|
|
1688
|
+
bestEffort(() => writePrivateJson(options.sessionFile, session))
|
|
1689
|
+
throw error
|
|
1690
|
+
}
|
|
1691
|
+
disarmPageLost()
|
|
1692
|
+
// A transition that released a final batch before ending held its wake
|
|
1693
|
+
// and notices until this commit; they go out now, ahead of the end.
|
|
1694
|
+
releaseHeldNotices()
|
|
1695
|
+
broadcast("session_ended", { reason: "session_end", session_id: board.session_id, log_dir: options.logDir })
|
|
1696
|
+
for (const client of streamClients) {
|
|
1697
|
+
if (!client.writableEnded) client.end()
|
|
1698
|
+
}
|
|
1699
|
+
streamClients.clear()
|
|
1700
|
+
fulfillWaiter()
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Nothing of the ended session is still owed to the agent: every batch is
|
|
1704
|
+
// acknowledged, every released unit is terminal, and no /session/end
|
|
1705
|
+
// upload is in flight. Until then a new session id is refused rather than
|
|
1706
|
+
// erasing a board the agent still has to reconcile.
|
|
1707
|
+
function drained() {
|
|
1708
|
+
return batches.length === 0 && !endingInFlight && !unitsPendingCloseOut(board)
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// A new session id on an ended, drained board: what a fresh `start` on an
|
|
1712
|
+
// ended root does, in-process and with the same tokens.
|
|
1713
|
+
function openSession(sessionId) {
|
|
1714
|
+
// The durable writes come first and the irreversible steps last: if the
|
|
1715
|
+
// new board or the live session record cannot be saved, the ended
|
|
1716
|
+
// session is restored in memory and on disk, and its log and batches
|
|
1717
|
+
// directory are untouched, so a refused opener leaves it intact.
|
|
1718
|
+
const before = { board: structuredClone(board), session }
|
|
1719
|
+
for (const key of Object.keys(board)) delete board[key]
|
|
1720
|
+
Object.assign(board, emptyBoard(), { session_id: sessionId })
|
|
1721
|
+
try {
|
|
1722
|
+
saveBoard()
|
|
1723
|
+
saveSession({ ended: false })
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
for (const key of Object.keys(board)) delete board[key]
|
|
1726
|
+
Object.assign(board, before.board)
|
|
1727
|
+
session = before.session
|
|
1728
|
+
bestEffort(() => saveBoard())
|
|
1729
|
+
bestEffort(() => writePrivateJson(options.sessionFile, session))
|
|
1730
|
+
throw error
|
|
1731
|
+
}
|
|
1732
|
+
fs.rmSync(options.batchesDir, { recursive: true, force: true })
|
|
1733
|
+
rotateLog(options)
|
|
1734
|
+
batches.length = 0
|
|
1735
|
+
batchOrder = 0
|
|
1736
|
+
outOfOrder.clear()
|
|
1737
|
+
pendingDeliveries.length = 0
|
|
1738
|
+
reservedBytes = 0
|
|
1739
|
+
logBytes = 0
|
|
1740
|
+
disarmPageLost()
|
|
1741
|
+
clearWatchForLoss()
|
|
1742
|
+
// Presence is per session: the wait that drained the old one is gone.
|
|
1743
|
+
setAgentState("away")
|
|
1744
|
+
bestEffort(() => logAgent({ kind: "session_opened", session_id: sessionId }))
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// --- auth and CORS -----------------------------------------------------------
|
|
1748
|
+
|
|
1749
|
+
function corsHeaders() {
|
|
1750
|
+
return {
|
|
1751
|
+
"Access-Control-Allow-Origin": options.appOrigin,
|
|
1752
|
+
Vary: "Origin",
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// The page credential: the page token and an Origin that is either absent
|
|
1757
|
+
// or exactly --app-origin.
|
|
1758
|
+
function authorizePageToken(req, res) {
|
|
1759
|
+
const origin = req.headers.origin
|
|
1760
|
+
if (typeof origin === "string" && origin !== options.appOrigin) {
|
|
1761
|
+
sendJson(res, 403, { reason: "origin" }, corsHeaders())
|
|
1762
|
+
return null
|
|
1763
|
+
}
|
|
1764
|
+
const token = bearerToken(req)
|
|
1765
|
+
if (!token) {
|
|
1766
|
+
sendJson(res, 401, { error: "unauthorized" }, corsHeaders())
|
|
1767
|
+
return null
|
|
1768
|
+
}
|
|
1769
|
+
if (tokenMatches(token, agentToken)) {
|
|
1770
|
+
sendJson(res, 403, { reason: "wrong_credential" }, corsHeaders())
|
|
1771
|
+
return null
|
|
1772
|
+
}
|
|
1773
|
+
if (!tokenMatches(token, pageToken)) {
|
|
1774
|
+
sendJson(res, 401, { error: "unauthorized" }, corsHeaders())
|
|
1775
|
+
return null
|
|
1776
|
+
}
|
|
1777
|
+
return true
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// Page routes: the page credential plus the session header. On an ended
|
|
1781
|
+
// board the ended id stays 410; any other id opens a fresh board once the
|
|
1782
|
+
// ended session has drained, and is then handled as that session's first
|
|
1783
|
+
// request.
|
|
1784
|
+
function authorizePage(req, res) {
|
|
1785
|
+
if (!authorizePageToken(req, res)) return null
|
|
1786
|
+
const sessionId = req.headers["x-riffrec-session"]
|
|
1787
|
+
const named = typeof sessionId === "string" && sessionId.trim() !== ""
|
|
1788
|
+
if (board.ended) {
|
|
1789
|
+
if (!named || sessionId === board.session_id) {
|
|
1790
|
+
sendJson(res, 410, { status: "session-ended" }, corsHeaders())
|
|
1791
|
+
return null
|
|
1792
|
+
}
|
|
1793
|
+
if (!drained()) {
|
|
1794
|
+
sendJson(res, 409, { error: "previous_session_draining" }, corsHeaders())
|
|
1795
|
+
return null
|
|
1796
|
+
}
|
|
1797
|
+
openSession(sessionId)
|
|
1798
|
+
}
|
|
1799
|
+
if (!named) {
|
|
1800
|
+
sendJson(res, 400, { error: "missing X-Riffrec-Session" }, corsHeaders())
|
|
1801
|
+
return null
|
|
1802
|
+
}
|
|
1803
|
+
// A closed tab ends its session: when the bound page said it was unloading
|
|
1804
|
+
// (or went lost) and no stream is open, a new page takes over instead of
|
|
1805
|
+
// being refused. A reload keeps its session id, so it never lands here.
|
|
1806
|
+
if (board.session_id && board.session_id !== sessionId && pageClosed()) {
|
|
1807
|
+
bestEffort(() => logAgent({ kind: "session_closed_by_page", session_id: board.session_id }))
|
|
1808
|
+
endSession()
|
|
1809
|
+
if (!drained()) {
|
|
1810
|
+
sendJson(res, 409, { error: "previous_session_draining" }, corsHeaders())
|
|
1811
|
+
return null
|
|
1812
|
+
}
|
|
1813
|
+
openSession(sessionId)
|
|
1814
|
+
}
|
|
1815
|
+
if (board.session_id && board.session_id !== sessionId) {
|
|
1816
|
+
sendJson(res, 409, { active_session_id: board.session_id }, corsHeaders())
|
|
1817
|
+
return null
|
|
1818
|
+
}
|
|
1819
|
+
if (!board.session_id) {
|
|
1820
|
+
// The first request binds the board to its session id; a bind that
|
|
1821
|
+
// does not persist is not a bind, so a later legitimate id is not
|
|
1822
|
+
// refused as foreign against an in-memory value the disk never held.
|
|
1823
|
+
board.session_id = sessionId
|
|
1824
|
+
try {
|
|
1825
|
+
saveBoard()
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
board.session_id = null
|
|
1828
|
+
throw error
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return sessionId
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function pageClosed() {
|
|
1835
|
+
if (board.ended || streamClients.size > 0) return false
|
|
1836
|
+
return board.page.last_stream_state === "unloading" || board.page.stream === "lost"
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// Agent routes: the agent token, no Origin header at all, no CORS.
|
|
1840
|
+
function authorizeAgent(req, res) {
|
|
1841
|
+
if (req.headers.origin !== undefined) {
|
|
1842
|
+
sendJson(res, 403, { reason: "browser_origin" })
|
|
1843
|
+
return false
|
|
1844
|
+
}
|
|
1845
|
+
const token = bearerToken(req)
|
|
1846
|
+
if (!token) {
|
|
1847
|
+
sendJson(res, 401, { error: "unauthorized" })
|
|
1848
|
+
return false
|
|
1849
|
+
}
|
|
1850
|
+
if (tokenMatches(token, pageToken)) {
|
|
1851
|
+
sendJson(res, 403, { reason: "wrong_credential" })
|
|
1852
|
+
return false
|
|
1853
|
+
}
|
|
1854
|
+
if (!tokenMatches(token, agentToken)) {
|
|
1855
|
+
sendJson(res, 401, { error: "unauthorized" })
|
|
1856
|
+
return false
|
|
1857
|
+
}
|
|
1858
|
+
if (session.agent_token === null) {
|
|
1859
|
+
sendJson(res, 410, { status: "session-ended" })
|
|
1860
|
+
return false
|
|
1861
|
+
}
|
|
1862
|
+
return true
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// --- page routes -------------------------------------------------------------
|
|
1866
|
+
|
|
1867
|
+
// Rejection names follow riffrec's LiveEnvelopeRejection; only
|
|
1868
|
+
// `unsupported_schema_version` maps to 409, everything else to 400.
|
|
1869
|
+
function validEnvelope(value, sessionId) {
|
|
1870
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "not_object"
|
|
1871
|
+
if (value.schema_version !== SCHEMA_VERSION) return "unsupported_schema_version"
|
|
1872
|
+
if (typeof value.session_id !== "string" || !value.session_id) return "missing_session_id"
|
|
1873
|
+
if (value.session_id !== sessionId) return "session_mismatch"
|
|
1874
|
+
if (value.seq === undefined || value.seq === null) return "missing_seq"
|
|
1875
|
+
if (!Number.isInteger(value.seq) || value.seq < 1) return "invalid_seq"
|
|
1876
|
+
if (typeof value.t !== "number" || !Number.isFinite(value.t)) return "invalid_t"
|
|
1877
|
+
if (typeof value.type !== "string" || !PAGE_EVENT_TYPES.has(value.type)) return "unknown_type"
|
|
1878
|
+
if (!validPayload(value.type, value.payload)) return "invalid_payload"
|
|
1879
|
+
// Ids become object keys and batch file names.
|
|
1880
|
+
for (const field of ["id", "unit_id"]) {
|
|
1881
|
+
const id = value.payload[field]
|
|
1882
|
+
if (id !== undefined && !SAFE_ID.test(String(id))) return "invalid_payload"
|
|
1883
|
+
}
|
|
1884
|
+
return null
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function applyEnvelope(envelope) {
|
|
1888
|
+
const { type, payload } = envelope
|
|
1889
|
+
if (type === "transcript") {
|
|
1890
|
+
board.transcript_count += 1
|
|
1891
|
+
} else if (type === "unit") {
|
|
1892
|
+
if (typeof payload.id !== "string" || !payload.id) return
|
|
1893
|
+
const existing = unitById(payload.id)
|
|
1894
|
+
board.units[payload.id] = {
|
|
1895
|
+
...(existing ?? {}),
|
|
1896
|
+
...payload,
|
|
1897
|
+
status: existing?.released ? existing.status : (payload.status ?? "initial"),
|
|
1898
|
+
released: Boolean(existing?.released),
|
|
1899
|
+
}
|
|
1900
|
+
if (!existing) board.unit_order.push(payload.id)
|
|
1901
|
+
// Instant means continuous pickup: the unit is released the moment it
|
|
1902
|
+
// lands, with whatever is held beside it, and the agent wakes now. The
|
|
1903
|
+
// page sees `triaging` on the stream at once; no checkpoint is waited for.
|
|
1904
|
+
if (board.mode === "instant" && !board.units[payload.id].released) {
|
|
1905
|
+
releaseCheckpoint(`instant-${payload.id}`, "instant", "instant")
|
|
1906
|
+
}
|
|
1907
|
+
} else if (type === "unit_update") {
|
|
1908
|
+
const unit = unitById(payload.unit_id)
|
|
1909
|
+
if (!unit) return
|
|
1910
|
+
// KTD5: a unit is refined only while it is still initial and unreleased;
|
|
1911
|
+
// the riffer's confirmation pass (KTD22) may land at any point.
|
|
1912
|
+
if (payload.confirmed !== undefined) unit.confirmed = payload.confirmed
|
|
1913
|
+
if (unit.released || unit.status !== "initial") return
|
|
1914
|
+
if (typeof payload.statement === "string") unit.statement = payload.statement
|
|
1915
|
+
if (Array.isArray(payload.anchors_add)) unit.anchors = [...(unit.anchors ?? []), ...payload.anchors_add]
|
|
1916
|
+
} else if (type === "unit_withdraw") {
|
|
1917
|
+
const unit = unitById(payload.unit_id)
|
|
1918
|
+
if (!unit || unit.status === "withdrawn") return
|
|
1919
|
+
unit.status = "withdrawn"
|
|
1920
|
+
unit.withdraw_reason = payload.reason ?? null
|
|
1921
|
+
if (unit.released && !board.pending_withdrawn.includes(unit.id)) board.pending_withdrawn.push(unit.id)
|
|
1922
|
+
broadcast("unit_status", { unit_id: unit.id, status: "withdrawn" })
|
|
1923
|
+
} else if (type === "annotation") {
|
|
1924
|
+
if (typeof payload.id !== "string" || !payload.id) return
|
|
1925
|
+
const existing = annotationById(payload.id)
|
|
1926
|
+
board.annotations[payload.id] = { ...(existing ?? {}), ...payload, released: Boolean(existing?.released) }
|
|
1927
|
+
if (!existing) board.annotation_order.push(payload.id)
|
|
1928
|
+
} else if (type === "checkpoint") {
|
|
1929
|
+
const trigger = PAGE_CHECKPOINT_KINDS.has(payload.trigger) ? payload.trigger : "send"
|
|
1930
|
+
const mode = EXECUTION_MODES.has(payload.mode) ? payload.mode : board.mode
|
|
1931
|
+
board.mode = mode
|
|
1932
|
+
// A checkpoint id keys its batch file and its ack route, so a reused
|
|
1933
|
+
// page id gets a suffix rather than overwriting the earlier batch.
|
|
1934
|
+
const wanted = typeof payload.id === "string" && payload.id ? payload.id : `ck-${randomUUID()}`
|
|
1935
|
+
let checkpointId = wanted
|
|
1936
|
+
for (let n = 2; board.checkpoints.some((c) => c.id === checkpointId); n += 1) checkpointId = `${wanted}-${n}`
|
|
1937
|
+
releaseCheckpoint(checkpointId, trigger, mode)
|
|
1938
|
+
} else if (type === "answer") {
|
|
1939
|
+
const answer = { unit_id: payload.unit_id, text: payload.text, t: envelope.t }
|
|
1940
|
+
board.answers.push(answer)
|
|
1941
|
+
const checkpointId = `ck-answer-${randomUUID()}`
|
|
1942
|
+
board.checkpoints.push({ id: checkpointId, kind: "answer", mode: board.mode, t: Date.now() })
|
|
1943
|
+
enqueueBatch(makeEnvelope(checkpointId, "answer", board.mode, { answers: [{ unit_id: answer.unit_id, text: answer.text }] }))
|
|
1944
|
+
} else if (type === "frame") {
|
|
1945
|
+
board.frame_count += 1
|
|
1946
|
+
} else if (type === "mic") {
|
|
1947
|
+
board.page.mic = payload.state ?? null
|
|
1948
|
+
} else if (type === "mode") {
|
|
1949
|
+
if (!EXECUTION_MODES.has(payload.mode)) return
|
|
1950
|
+
const leavingCollect = board.mode === "collect" && payload.mode !== "collect"
|
|
1951
|
+
board.mode = payload.mode
|
|
1952
|
+
if (leavingCollect) emitModeChange(payload.mode)
|
|
1953
|
+
// Switching to Instant flushes what is held right away, since nothing
|
|
1954
|
+
// will wait for a checkpoint from here on. (Leaving Collect woke the
|
|
1955
|
+
// agent with the accepted backlog above; this carries the unreleased.)
|
|
1956
|
+
if (payload.mode === "instant" && (heldUnits().length > 0 || heldAnnotations().length > 0)) {
|
|
1957
|
+
releaseCheckpoint(`ck-instant-${randomUUID()}`, "mode_change", "instant")
|
|
1958
|
+
}
|
|
1959
|
+
} else if (type === "stream_state") {
|
|
1960
|
+
board.page.last_stream_state = payload.state ?? null
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// Everything applyEnvelope may touch: the board and the batch queue.
|
|
1965
|
+
function snapshotState() {
|
|
1966
|
+
const savedBoard = structuredClone(board)
|
|
1967
|
+
const savedBatches = batches.slice()
|
|
1968
|
+
const savedOrder = batchOrder
|
|
1969
|
+
return () => {
|
|
1970
|
+
for (const key of Object.keys(board)) delete board[key]
|
|
1971
|
+
Object.assign(board, savedBoard)
|
|
1972
|
+
for (const batch of batches.slice(savedBatches.length)) bestEffort(() => fs.rmSync(batchFile(batch.envelope.checkpoint_id), { force: true }))
|
|
1973
|
+
batches.length = 0
|
|
1974
|
+
batches.push(...savedBatches)
|
|
1975
|
+
batchOrder = savedOrder
|
|
1976
|
+
// applyEnvelope may have saved the board mid-way (releaseCheckpoint);
|
|
1977
|
+
// the durable copy must match the restored state.
|
|
1978
|
+
bestEffort(() => saveBoard())
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// Where the event log stood before an envelope's transaction, so a failed
|
|
1983
|
+
// commit can take back exactly what that envelope stored: the appended
|
|
1984
|
+
// line, the frame file keyed by its seq, and the bytes counted for both.
|
|
1985
|
+
function markLog() {
|
|
1986
|
+
let eventsSize = 0
|
|
1987
|
+
try {
|
|
1988
|
+
eventsSize = fs.statSync(eventsLog).size
|
|
1989
|
+
} catch {
|
|
1990
|
+
eventsSize = 0
|
|
1991
|
+
}
|
|
1992
|
+
return { eventsSize, logBytes }
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function unwindLog(mark, seq) {
|
|
1996
|
+
bestEffort(() => {
|
|
1997
|
+
if (fs.existsSync(eventsLog) && fs.statSync(eventsLog).size > mark.eventsSize) fs.truncateSync(eventsLog, mark.eventsSize)
|
|
1998
|
+
})
|
|
1999
|
+
bestEffort(() => {
|
|
2000
|
+
for (const name of fs.readdirSync(framesDir)) {
|
|
2001
|
+
if (name.startsWith(`${seq}-`)) fs.rmSync(path.join(framesDir, name), { force: true })
|
|
2002
|
+
}
|
|
2003
|
+
})
|
|
2004
|
+
logBytes = mark.logBytes
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function storeEnvelope(envelope) {
|
|
2008
|
+
if (envelope.type === "frame") {
|
|
2009
|
+
const id = typeof envelope.payload.id === "string" && envelope.payload.id ? envelope.payload.id : randomUUID()
|
|
2010
|
+
// Keyed by seq too: a reused frame id must not overwrite earlier evidence.
|
|
2011
|
+
const fileName = `${envelope.seq}-${encodeURIComponent(id)}.jpg`
|
|
2012
|
+
const frameFile = path.join("frames", fileName)
|
|
2013
|
+
const { jpeg_base64: jpeg, ...rest } = envelope.payload
|
|
2014
|
+
// A `dropped` frame kept its seq but not its bytes: keep the metadata,
|
|
2015
|
+
// write no file, and treat the image as absent.
|
|
2016
|
+
if (rest.dropped) {
|
|
2017
|
+
logEvent({ seq: envelope.seq, t: envelope.t, type: "frame", payload: rest, frame_file: null })
|
|
2018
|
+
return
|
|
2019
|
+
}
|
|
2020
|
+
if (typeof jpeg === "string") {
|
|
2021
|
+
const bytes = Buffer.from(jpeg, "base64")
|
|
2022
|
+
fs.writeFileSync(path.join(framesDir, fileName), bytes, { mode: 0o600 })
|
|
2023
|
+
logBytes += bytes.length
|
|
2024
|
+
}
|
|
2025
|
+
logEvent({ seq: envelope.seq, t: envelope.t, type: "frame", payload: rest, frame_file: frameFile })
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
logEvent({ seq: envelope.seq, t: envelope.t, type: envelope.type, payload: envelope.payload })
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Envelopes are applied strictly in `seq` order. One that arrives ahead
|
|
2032
|
+
// of a gap waits in memory until the gap closes; it is not acknowledged,
|
|
2033
|
+
// so a restart loses nothing the page will not replay. The buffer is
|
|
2034
|
+
// bounded: past the cap an early envelope is dropped unacknowledged and
|
|
2035
|
+
// the page replays it after the gap closes.
|
|
2036
|
+
function admitEnvelope(envelope, bodySize) {
|
|
2037
|
+
const { seq } = envelope
|
|
2038
|
+
if (seq <= board.acked_seq || outOfOrder.has(seq)) return
|
|
2039
|
+
if (seq !== board.acked_seq + 1) {
|
|
2040
|
+
if (outOfOrder.size < OUT_OF_ORDER_CAP) {
|
|
2041
|
+
// Every buffered envelope is stored once the gap closes, so its bytes
|
|
2042
|
+
// are reserved against the caps now: the frame's whole body (posted
|
|
2043
|
+
// alone), or the envelope's own encoding out of a batch.
|
|
2044
|
+
const reserved = envelope.type === "frame" ? bodySize : Buffer.byteLength(JSON.stringify(envelope))
|
|
2045
|
+
outOfOrder.set(seq, { envelope, reserved })
|
|
2046
|
+
reservedBytes += reserved
|
|
2047
|
+
}
|
|
2048
|
+
return
|
|
2049
|
+
}
|
|
2050
|
+
let next = { envelope, reserved: 0 }
|
|
2051
|
+
while (next) {
|
|
2052
|
+
// One transaction per envelope: apply, store, save the board with the
|
|
2053
|
+
// advanced seq. A failure rolls everything back and leaves the seq
|
|
2054
|
+
// unacknowledged, so the page retries it instead of discarding an event
|
|
2055
|
+
// the endpoint lost. Wakes and SSE notices are held until the commit.
|
|
2056
|
+
const undo = snapshotState()
|
|
2057
|
+
const logMark = markLog()
|
|
2058
|
+
heldNotices = []
|
|
2059
|
+
try {
|
|
2060
|
+
applyEnvelope(next.envelope)
|
|
2061
|
+
storeEnvelope(next.envelope)
|
|
2062
|
+
board.acked_seq = next.envelope.seq
|
|
2063
|
+
saveBoard()
|
|
2064
|
+
} catch (error) {
|
|
2065
|
+
heldNotices = null
|
|
2066
|
+
undo()
|
|
2067
|
+
// The log is part of the transaction: a line or frame stored for a
|
|
2068
|
+
// seq that was never acknowledged would be stored again on retry and
|
|
2069
|
+
// replayed twice.
|
|
2070
|
+
unwindLog(logMark, next.envelope.seq)
|
|
2071
|
+
// A buffered envelope that failed leaves the buffer: the page retries
|
|
2072
|
+
// it from the returned acked_seq, and that retry must be admitted
|
|
2073
|
+
// rather than skipped as a duplicate of the entry still held here.
|
|
2074
|
+
if (outOfOrder.get(next.envelope.seq) === next) {
|
|
2075
|
+
outOfOrder.delete(next.envelope.seq)
|
|
2076
|
+
reservedBytes -= next.reserved
|
|
2077
|
+
}
|
|
2078
|
+
throw error
|
|
2079
|
+
}
|
|
2080
|
+
outOfOrder.delete(next.envelope.seq)
|
|
2081
|
+
reservedBytes -= next.reserved
|
|
2082
|
+
releaseHeldNotices()
|
|
2083
|
+
next = outOfOrder.get(board.acked_seq + 1)
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
async function handleEvents(req, res, sessionId) {
|
|
2088
|
+
const body = await readBody(req, FRAME_BODY_LIMIT)
|
|
2089
|
+
// The session may have ended while this body was in flight.
|
|
2090
|
+
if (board.ended || board.session_id !== sessionId) {
|
|
2091
|
+
sendJson(res, 410, { status: "session-ended" }, corsHeaders())
|
|
2092
|
+
return
|
|
2093
|
+
}
|
|
2094
|
+
if (body.tooLarge) {
|
|
2095
|
+
sendJson(res, 413, { max_bytes: BODY_LIMIT, frame_max_bytes: FRAME_BODY_LIMIT }, corsHeaders())
|
|
2096
|
+
return
|
|
2097
|
+
}
|
|
2098
|
+
const parsed = parseJsonObject(body.text)
|
|
2099
|
+
const envelopes = Array.isArray(parsed) ? parsed : parsed ? [parsed] : null
|
|
2100
|
+
if (!envelopes || envelopes.length === 0) {
|
|
2101
|
+
sendJson(res, 400, { error: "body must be an envelope or an array of envelopes" }, corsHeaders())
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
2104
|
+
const loneFrame = envelopes.length === 1 && envelopes[0]?.type === "frame"
|
|
2105
|
+
if (!loneFrame && body.size > BODY_LIMIT) {
|
|
2106
|
+
sendJson(res, 413, { max_bytes: BODY_LIMIT }, corsHeaders())
|
|
2107
|
+
return
|
|
2108
|
+
}
|
|
2109
|
+
for (const envelope of envelopes) {
|
|
2110
|
+
const problem = validEnvelope(envelope, sessionId)
|
|
2111
|
+
if (problem === "unsupported_schema_version") {
|
|
2112
|
+
sendJson(res, 409, { expected_schema_version: SCHEMA_VERSION }, corsHeaders())
|
|
2113
|
+
return
|
|
2114
|
+
}
|
|
2115
|
+
if (problem) {
|
|
2116
|
+
sendJson(res, 400, { reason: problem, seq: Number.isInteger(envelope?.seq) ? envelope.seq : null }, corsHeaders())
|
|
2117
|
+
return
|
|
2118
|
+
}
|
|
2119
|
+
if (envelope.type === "frame" && envelopes.length > 1) {
|
|
2120
|
+
sendJson(res, 400, { reason: "invalid_payload", seq: envelope.seq, detail: "frame envelopes are posted alone" }, corsHeaders())
|
|
2121
|
+
return
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
// The cap guards image bytes. A frame the page already dropped (`dropped`
|
|
2125
|
+
// set, no image kept) stores only its metadata line, and it is how the
|
|
2126
|
+
// page keeps the sequence moving once the cap is reached, so refusing it
|
|
2127
|
+
// would hold every later event behind that seq.
|
|
2128
|
+
const storesImage = loneFrame && !envelopes[0].payload.dropped
|
|
2129
|
+
if (storesImage && logBytes + reservedBytes + body.size > DISK_CAP_BYTES) {
|
|
2130
|
+
sendJson(res, 507, { reason: "disk_cap", stream_state: "buffering", max_bytes: DISK_CAP_BYTES, acked_seq: board.acked_seq }, corsHeaders())
|
|
2131
|
+
return
|
|
2132
|
+
}
|
|
2133
|
+
// The hard ceiling applies to every envelope, dropped frames included.
|
|
2134
|
+
if (logBytes + reservedBytes + body.size > DISK_HARD_CAP_BYTES) {
|
|
2135
|
+
sendJson(res, 507, { reason: "disk_cap", stream_state: "buffering", max_bytes: DISK_HARD_CAP_BYTES, acked_seq: board.acked_seq }, corsHeaders())
|
|
2136
|
+
return
|
|
2137
|
+
}
|
|
2138
|
+
touch()
|
|
2139
|
+
let storageError = null
|
|
2140
|
+
try {
|
|
2141
|
+
for (const envelope of envelopes) admitEnvelope(envelope, body.size)
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
storageError = error
|
|
2144
|
+
}
|
|
2145
|
+
broadcast("ack", { acked_seq: board.acked_seq })
|
|
2146
|
+
if (storageError) {
|
|
2147
|
+
// Whatever was acknowledged before the failure stays acknowledged;
|
|
2148
|
+
// the rest is retriable from acked_seq.
|
|
2149
|
+
sendJson(res, 500, { error: "storage_failed", code: storageError.code ?? null, acked_seq: board.acked_seq }, corsHeaders())
|
|
2150
|
+
return
|
|
2151
|
+
}
|
|
2152
|
+
sendJson(res, 200, { acked_seq: board.acked_seq }, corsHeaders())
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function handleStream(req, res) {
|
|
2156
|
+
// No `Connection` header: it is hop-by-hop, and a TLS-terminating tunnel
|
|
2157
|
+
// (cloudflared) forwards this response over HTTP/2, where that header is
|
|
2158
|
+
// invalid; the edge then held the whole stream and the page never saw a
|
|
2159
|
+
// status. `no-transform` and `X-Accel-Buffering` tell intermediaries not
|
|
2160
|
+
// to buffer, and the flush pushes the headers out with the first bytes.
|
|
2161
|
+
res.writeHead(200, {
|
|
2162
|
+
"Content-Type": "text/event-stream",
|
|
2163
|
+
"Cache-Control": "no-cache, no-transform",
|
|
2164
|
+
"X-Accel-Buffering": "no",
|
|
2165
|
+
...corsHeaders(),
|
|
2166
|
+
})
|
|
2167
|
+
res.flushHeaders()
|
|
2168
|
+
res.write(":ok\n\n")
|
|
2169
|
+
res.write(`event: ack\ndata: ${JSON.stringify({ acked_seq: board.acked_seq })}\n\n`)
|
|
2170
|
+
res.write(`event: agent\ndata: ${JSON.stringify(agentState)}\n\n`)
|
|
2171
|
+
// A page that just reloaded, or reconnected after a flushed response,
|
|
2172
|
+
// reconciles its board from these: every unit the endpoint has moved,
|
|
2173
|
+
// with the note or guess that moved it, and any question still open.
|
|
2174
|
+
let replayed = false
|
|
2175
|
+
for (const id of board.unit_order) {
|
|
2176
|
+
const unit = unitById(id)
|
|
2177
|
+
if (!unit.released && unit.status !== "withdrawn") continue
|
|
2178
|
+
const status = { unit_id: id, status: unit.status }
|
|
2179
|
+
if (typeof unit.note === "string") status.note = unit.note
|
|
2180
|
+
if (typeof unit.guess === "string") status.guess = unit.guess
|
|
2181
|
+
res.write(`event: unit_status\ndata: ${JSON.stringify(status)}\n\n`)
|
|
2182
|
+
replayed = true
|
|
2183
|
+
if (unit.status === "needs_info" && typeof unit.question === "string") {
|
|
2184
|
+
res.write(`event: ask\ndata: ${JSON.stringify({ unit_id: id, question: unit.question })}\n\n`)
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
// Notices that fell in the gap between this connection and the last.
|
|
2188
|
+
for (const frame of pendingDeliveries) res.write(frame)
|
|
2189
|
+
if (pendingDeliveries.length > 0) replayed = true
|
|
2190
|
+
pendingDeliveries.length = 0
|
|
2191
|
+
streamClients.add(res)
|
|
2192
|
+
// Replayed state is a delivery like any other: behind a buffering tunnel
|
|
2193
|
+
// the page sees it only once this response completes.
|
|
2194
|
+
if (replayed) scheduleStreamFlush()
|
|
2195
|
+
disarmPageLost()
|
|
2196
|
+
// The page is back, so the edit it was watched for did not take it down;
|
|
2197
|
+
// a later disconnect (closing the tab, navigating away) is not a loss.
|
|
2198
|
+
clearWatchForLoss()
|
|
2199
|
+
board.page.stream = "connected"
|
|
2200
|
+
saveBoard()
|
|
2201
|
+
touch()
|
|
2202
|
+
req.on("close", () => {
|
|
2203
|
+
// endSession already dropped every client; a late close must not touch
|
|
2204
|
+
// a board that has since reopened for a new session.
|
|
2205
|
+
if (!streamClients.delete(res)) return
|
|
2206
|
+
if (streamClients.size === 0 && !board.ended) {
|
|
2207
|
+
board.page.stream = "disconnected"
|
|
2208
|
+
// A socket close runs outside any request's error handling; a disk
|
|
2209
|
+
// that refuses this bookkeeping write must not take the session down.
|
|
2210
|
+
bestEffort(() => saveBoard())
|
|
2211
|
+
if (board.watch_for_loss) armPageLost()
|
|
2212
|
+
}
|
|
2213
|
+
})
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
async function handleMint(req, res, sessionId) {
|
|
2217
|
+
const body = await readBody(req, BODY_LIMIT)
|
|
2218
|
+
if (body.tooLarge) {
|
|
2219
|
+
sendJson(res, 413, { max_bytes: BODY_LIMIT }, corsHeaders())
|
|
2220
|
+
return
|
|
2221
|
+
}
|
|
2222
|
+
const parsed = parseJsonObject(body.text || "{}")
|
|
2223
|
+
if (!parsed || (parsed.session_id !== undefined && parsed.session_id !== sessionId)) {
|
|
2224
|
+
sendJson(res, 400, { error: "session_id must match X-Riffrec-Session" }, corsHeaders())
|
|
2225
|
+
return
|
|
2226
|
+
}
|
|
2227
|
+
// A loopback peer is either the local browser or a TLS-terminating
|
|
2228
|
+
// tunnel on this host. Any other peer must be a proxy named with
|
|
2229
|
+
// --trust-proxy for its X-Forwarded-Proto to count; the header alone
|
|
2230
|
+
// proves nothing about the transport it arrived on.
|
|
2231
|
+
const peer = req.socket.remoteAddress
|
|
2232
|
+
const forwardedProto = String(req.headers["x-forwarded-proto"] ?? "").split(",")[0].trim().toLowerCase()
|
|
2233
|
+
const trustedProxy = options.trustProxy.includes(String(peer ?? "").replace(/^::ffff:/, ""))
|
|
2234
|
+
if (!isLoopback(peer) && !(trustedProxy && forwardedProto === "https")) {
|
|
2235
|
+
sendJson(res, 403, { reason: "tls_required" }, corsHeaders())
|
|
2236
|
+
return
|
|
2237
|
+
}
|
|
2238
|
+
// A key the riffer pasted into the page wins over the environment's, so a
|
|
2239
|
+
// missing or rejected OPENAI_API_KEY can be fixed from the browser.
|
|
2240
|
+
const pastedKey = String(req.headers["x-riffrec-openai-key"] ?? "").trim()
|
|
2241
|
+
const apiKey = pastedKey || process.env.OPENAI_API_KEY
|
|
2242
|
+
if (!apiKey) {
|
|
2243
|
+
sendJson(res, 503, { reason: "no_key" }, corsHeaders())
|
|
2244
|
+
return
|
|
2245
|
+
}
|
|
2246
|
+
const brief = readBrief(options)
|
|
2247
|
+
if (brief && briefContainsSecret(brief)) {
|
|
2248
|
+
sendJson(res, 503, { reason: "brief_contains_secret" }, corsHeaders())
|
|
2249
|
+
return
|
|
2250
|
+
}
|
|
2251
|
+
const now = Date.now()
|
|
2252
|
+
while (mintTimes.length > 0 && now - mintTimes[0] > 60 * 1000) mintTimes.shift()
|
|
2253
|
+
if (mintInFlight) {
|
|
2254
|
+
sendJson(res, 429, { retry_after: 1 }, corsHeaders())
|
|
2255
|
+
return
|
|
2256
|
+
}
|
|
2257
|
+
if (mintTimes.length >= MINTS_PER_MINUTE) {
|
|
2258
|
+
sendJson(res, 429, { retry_after: Math.ceil((60 * 1000 - (now - mintTimes[0])) / 1000) }, corsHeaders())
|
|
2259
|
+
return
|
|
2260
|
+
}
|
|
2261
|
+
mintTimes.push(now)
|
|
2262
|
+
mintInFlight = true
|
|
2263
|
+
touch()
|
|
2264
|
+
try {
|
|
2265
|
+
const instructions = brief ? `${INTERVIEWER_PERSONA}\n\n[SESSION BRIEF]\n${brief}` : INTERVIEWER_PERSONA
|
|
2266
|
+
const upstreamBody = {
|
|
2267
|
+
expires_after: { anchor: "created_at", seconds: CLIENT_SECRET_TTL_S },
|
|
2268
|
+
session: {
|
|
2269
|
+
type: "realtime",
|
|
2270
|
+
model: REALTIME_MODEL,
|
|
2271
|
+
instructions,
|
|
2272
|
+
tools: INTERVIEWER_TOOLS,
|
|
2273
|
+
tool_choice: "auto",
|
|
2274
|
+
audio: {
|
|
2275
|
+
input: {
|
|
2276
|
+
transcription: { model: "gpt-4o-mini-transcribe" },
|
|
2277
|
+
turn_detection: { type: "semantic_vad", create_response: true, interrupt_response: true },
|
|
2278
|
+
},
|
|
2279
|
+
output: { voice: REALTIME_VOICE },
|
|
2280
|
+
},
|
|
2281
|
+
},
|
|
2282
|
+
}
|
|
2283
|
+
let upstream
|
|
2284
|
+
try {
|
|
2285
|
+
upstream = await fetch(`${OPENAI_BASE_URL}/v1/realtime/client_secrets`, {
|
|
2286
|
+
method: "POST",
|
|
2287
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
2288
|
+
body: JSON.stringify(upstreamBody),
|
|
2289
|
+
signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
|
|
2290
|
+
})
|
|
2291
|
+
} catch {
|
|
2292
|
+
sendJson(res, 502, { reason: "openai_error", upstream_status: null }, corsHeaders())
|
|
2293
|
+
return
|
|
2294
|
+
}
|
|
2295
|
+
if (!upstream.ok) {
|
|
2296
|
+
// The upstream body is discarded: it may echo the request.
|
|
2297
|
+
await upstream.arrayBuffer().catch(() => undefined)
|
|
2298
|
+
sendJson(res, 502, { reason: "openai_error", upstream_status: upstream.status }, corsHeaders())
|
|
2299
|
+
return
|
|
2300
|
+
}
|
|
2301
|
+
const minted = await upstream.json().catch(() => null)
|
|
2302
|
+
const secret = typeof minted?.value === "string" ? minted.value : minted?.client_secret?.value
|
|
2303
|
+
const expiresAt = minted?.expires_at ?? minted?.client_secret?.expires_at ?? null
|
|
2304
|
+
if (!secret) {
|
|
2305
|
+
sendJson(res, 502, { reason: "openai_error", upstream_status: upstream.status }, corsHeaders())
|
|
2306
|
+
return
|
|
2307
|
+
}
|
|
2308
|
+
// The secret is minted; the audit line must not cost the page its mint.
|
|
2309
|
+
bestEffort(() => logAgent({ kind: "mint", session_id: sessionId, expires_at: expiresAt }))
|
|
2310
|
+
// The session may have ended while the upstream call was in flight.
|
|
2311
|
+
if (board.ended || board.session_id !== sessionId) {
|
|
2312
|
+
sendJson(res, 410, { status: "session-ended" }, corsHeaders())
|
|
2313
|
+
return
|
|
2314
|
+
}
|
|
2315
|
+
sendJson(res, 200, { client_secret: secret, expires_at: expiresAt, model: REALTIME_MODEL }, corsHeaders())
|
|
2316
|
+
} finally {
|
|
2317
|
+
mintInFlight = false
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function archiveExtension(contentType) {
|
|
2322
|
+
const type = String(contentType ?? "").split(";")[0].trim().toLowerCase()
|
|
2323
|
+
if (type === "application/zip") return "zip"
|
|
2324
|
+
if (type === "application/json") return "json"
|
|
2325
|
+
return "bin"
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// The page's full-evidence archive is streamed to state/log/ then the
|
|
2329
|
+
// session ends: a final checkpoint releases anything still held and the
|
|
2330
|
+
// stream announces session_ended. The page token stays valid.
|
|
2331
|
+
function handleSessionEnd(req, res) {
|
|
2332
|
+
if (endingInFlight) {
|
|
2333
|
+
sendJson(res, 409, { error: "session_end_in_progress" }, corsHeaders())
|
|
2334
|
+
return
|
|
2335
|
+
}
|
|
2336
|
+
endingInFlight = true
|
|
2337
|
+
// The cap counts what is on disk plus what is still landing: frames held
|
|
2338
|
+
// ahead of a gap, frames that land while this upload runs, and this
|
|
2339
|
+
// archive as it streams in. The archive's bytes are reserved chunk by
|
|
2340
|
+
// chunk and its budget is read again at every chunk, so the archive and
|
|
2341
|
+
// concurrent /events frames both see one shrinking budget rather than the
|
|
2342
|
+
// free space each of them is filling.
|
|
2343
|
+
const archivePath = path.join(options.logDir, `archive.${archiveExtension(req.headers["content-type"])}`)
|
|
2344
|
+
const tmpPath = `${archivePath}.${randomUUID()}${ARCHIVE_PART_SUFFIX}`
|
|
2345
|
+
const out = fs.createWriteStream(tmpPath, { mode: 0o600 })
|
|
2346
|
+
let size = 0
|
|
2347
|
+
let tooLarge = false
|
|
2348
|
+
let archiveReserved = 0
|
|
2349
|
+
const releaseArchiveReservation = () => {
|
|
2350
|
+
reservedBytes -= archiveReserved
|
|
2351
|
+
archiveReserved = 0
|
|
2352
|
+
}
|
|
2353
|
+
// What this archive may still grow to: the body cap, or what the disk
|
|
2354
|
+
// cap leaves after everything stored and everything else reserved.
|
|
2355
|
+
const archiveBudget = () => Math.max(0, Math.min(ARCHIVE_BODY_LIMIT, DISK_CAP_BYTES - logBytes - (reservedBytes - archiveReserved)))
|
|
2356
|
+
let remaining = archiveBudget()
|
|
2357
|
+
req.on("data", (chunk) => {
|
|
2358
|
+
size += chunk.length
|
|
2359
|
+
if (tooLarge) return
|
|
2360
|
+
remaining = archiveBudget()
|
|
2361
|
+
if (size > remaining) {
|
|
2362
|
+
tooLarge = true
|
|
2363
|
+
releaseArchiveReservation()
|
|
2364
|
+
out.destroy()
|
|
2365
|
+
return
|
|
2366
|
+
}
|
|
2367
|
+
reservedBytes += chunk.length
|
|
2368
|
+
archiveReserved += chunk.length
|
|
2369
|
+
touch()
|
|
2370
|
+
// Pause the upload while the disk catches up; a fast sender must not
|
|
2371
|
+
// park the archive in process memory.
|
|
2372
|
+
if (!out.write(chunk)) {
|
|
2373
|
+
req.pause()
|
|
2374
|
+
out.once("drain", () => req.resume())
|
|
2375
|
+
}
|
|
2376
|
+
})
|
|
2377
|
+
let failed = false
|
|
2378
|
+
out.on("error", (error) => {
|
|
2379
|
+
// ENOSPC or a permission error must not take the endpoint down; the
|
|
2380
|
+
// session stays live and resumable and the page may retry.
|
|
2381
|
+
if (failed || tooLarge) return
|
|
2382
|
+
failed = true
|
|
2383
|
+
endingInFlight = false
|
|
2384
|
+
releaseArchiveReservation()
|
|
2385
|
+
req.pause()
|
|
2386
|
+
// The response must not depend on another write to the disk that just failed.
|
|
2387
|
+
sendJson(res, 500, { error: "archive_write_failed", code: error.code ?? null }, corsHeaders())
|
|
2388
|
+
bestEffort(() => fs.rmSync(tmpPath, { force: true }))
|
|
2389
|
+
bestEffort(() => logAgent({ kind: "archive_failed", error: error.code ?? error.message }))
|
|
2390
|
+
})
|
|
2391
|
+
req.on("error", () => {
|
|
2392
|
+
endingInFlight = false
|
|
2393
|
+
releaseArchiveReservation()
|
|
2394
|
+
out.destroy()
|
|
2395
|
+
bestEffort(() => fs.rmSync(tmpPath, { force: true }))
|
|
2396
|
+
})
|
|
2397
|
+
req.on("end", () => {
|
|
2398
|
+
if (failed) return
|
|
2399
|
+
if (tooLarge) {
|
|
2400
|
+
endingInFlight = false
|
|
2401
|
+
bestEffort(() => fs.rmSync(tmpPath, { force: true }))
|
|
2402
|
+
sendJson(res, 413, { max_bytes: remaining }, corsHeaders())
|
|
2403
|
+
return
|
|
2404
|
+
}
|
|
2405
|
+
out.end(() => {
|
|
2406
|
+
if (failed) return
|
|
2407
|
+
try {
|
|
2408
|
+
finalize()
|
|
2409
|
+
} catch (error) {
|
|
2410
|
+
// Any filesystem failure here takes the same recoverable path.
|
|
2411
|
+
out.emit("error", error)
|
|
2412
|
+
}
|
|
2413
|
+
})
|
|
2414
|
+
|
|
2415
|
+
function finalize() {
|
|
2416
|
+
if (size > 0) {
|
|
2417
|
+
fs.renameSync(tmpPath, archivePath)
|
|
2418
|
+
logBytes += size
|
|
2419
|
+
} else {
|
|
2420
|
+
bestEffort(() => fs.rmSync(tmpPath, { force: true }))
|
|
2421
|
+
}
|
|
2422
|
+
// Landed bytes are counted in logBytes now, not as a reservation.
|
|
2423
|
+
releaseArchiveReservation()
|
|
2424
|
+
touch()
|
|
2425
|
+
// The overlay's Done control sends the `final` checkpoint before
|
|
2426
|
+
// /session/end; a page that ended without one still hands the agent
|
|
2427
|
+
// whatever is held or accepted. The fallback release and the end are
|
|
2428
|
+
// one transition: if ending fails, the board, the queued batch, and
|
|
2429
|
+
// the archive all go back to how they were, so the page's retry
|
|
2430
|
+
// starts from the same state and emits one final checkpoint, not two.
|
|
2431
|
+
const undo = snapshotState()
|
|
2432
|
+
heldNotices = []
|
|
2433
|
+
try {
|
|
2434
|
+
// Nothing the page sent stays unreleased past the end: a unit that
|
|
2435
|
+
// landed after the page's own final checkpoint goes out as a last
|
|
2436
|
+
// ordinary batch, so it is the agent's to finish (and holds the
|
|
2437
|
+
// board from draining) rather than an `initial` the next session
|
|
2438
|
+
// would erase.
|
|
2439
|
+
const held = heldUnits().length > 0 || heldAnnotations().length > 0 || board.pending_withdrawn.length > 0
|
|
2440
|
+
if (!board.final_emitted && (held || backlogUnits().length > 0)) {
|
|
2441
|
+
releaseCheckpoint(`ck-final-${randomUUID()}`, "final", board.mode)
|
|
2442
|
+
} else if (held) {
|
|
2443
|
+
releaseCheckpoint(`ck-late-${randomUUID()}`, "send", board.mode)
|
|
2444
|
+
}
|
|
2445
|
+
endSession()
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
heldNotices = null
|
|
2448
|
+
undo()
|
|
2449
|
+
// The session did not end, so the page retries with the whole
|
|
2450
|
+
// archive: the copy just landed is a replacement-in-waiting, not
|
|
2451
|
+
// stored evidence, and must not be charged against that retry.
|
|
2452
|
+
if (size > 0) {
|
|
2453
|
+
bestEffort(() => fs.rmSync(archivePath, { force: true }))
|
|
2454
|
+
logBytes -= size
|
|
2455
|
+
}
|
|
2456
|
+
throw error
|
|
2457
|
+
}
|
|
2458
|
+
// endSession delivered the held wake and notices as it committed; if
|
|
2459
|
+
// the board was already ended it did nothing, and they go out here.
|
|
2460
|
+
releaseHeldNotices()
|
|
2461
|
+
// The end is committed; the audit line must not turn it into a 500.
|
|
2462
|
+
bestEffort(() => logAgent({ kind: "session_end", archive: size > 0 ? path.basename(archivePath) : null, bytes: size }))
|
|
2463
|
+
endingInFlight = false
|
|
2464
|
+
sendJson(res, 200, { status: "session-ended", log_dir: options.logDir, archive_bytes: size }, corsHeaders())
|
|
2465
|
+
}
|
|
2466
|
+
})
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// --- agent routes ------------------------------------------------------------
|
|
2470
|
+
|
|
2471
|
+
function handleWait(req, res) {
|
|
2472
|
+
touch()
|
|
2473
|
+
if (waiter && !waiter.res.writableEnded) {
|
|
2474
|
+
sendJson(res, 409, { status: "wait-taken" })
|
|
2475
|
+
return
|
|
2476
|
+
}
|
|
2477
|
+
const parked = { res, timer: null }
|
|
2478
|
+
parked.timer = setTimeout(() => {
|
|
2479
|
+
if (waiter === parked) waiter = null
|
|
2480
|
+
if (!res.writableEnded) {
|
|
2481
|
+
res.writeHead(204)
|
|
2482
|
+
res.end()
|
|
2483
|
+
}
|
|
2484
|
+
agentMaybeAway()
|
|
2485
|
+
}, WAIT_TIMEOUT_MS)
|
|
2486
|
+
waiter = parked
|
|
2487
|
+
req.on("close", () => {
|
|
2488
|
+
clearTimeout(parked.timer)
|
|
2489
|
+
if (waiter === parked) {
|
|
2490
|
+
waiter = null
|
|
2491
|
+
agentMaybeAway()
|
|
2492
|
+
}
|
|
2493
|
+
})
|
|
2494
|
+
setAgentState("listening")
|
|
2495
|
+
fulfillWaiter()
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
function handleAck(req, res, checkpointId) {
|
|
2499
|
+
const index = batches.findIndex((batch) => batch.envelope.checkpoint_id === checkpointId)
|
|
2500
|
+
if (index === -1) {
|
|
2501
|
+
// Idempotent: a repeated ack of a batch this session already dropped is
|
|
2502
|
+
// still a success, so a crashed agent can safely re-ack on resume.
|
|
2503
|
+
if (board.acked_checkpoint_ids.includes(checkpointId)) {
|
|
2504
|
+
sendJson(res, 200, { ok: true, checkpoint_id: checkpointId, already_acked: true })
|
|
2505
|
+
return
|
|
2506
|
+
}
|
|
2507
|
+
sendJson(res, 404, { error: "unknown checkpoint" })
|
|
2508
|
+
return
|
|
2509
|
+
}
|
|
2510
|
+
// The acknowledgment is durable before the batch file goes: a board save
|
|
2511
|
+
// that fails leaves the batch in place and answers 500, so the agent
|
|
2512
|
+
// retries the ack; a batch file that outlives its recorded ack is dropped
|
|
2513
|
+
// at the next start instead of being served again.
|
|
2514
|
+
board.acked_checkpoint_ids.push(checkpointId)
|
|
2515
|
+
try {
|
|
2516
|
+
saveBoard()
|
|
2517
|
+
} catch (error) {
|
|
2518
|
+
board.acked_checkpoint_ids.pop()
|
|
2519
|
+
sendJson(res, 500, { error: "storage_failed", code: error.code ?? null, checkpoint_id: checkpointId })
|
|
2520
|
+
return
|
|
2521
|
+
}
|
|
2522
|
+
batches.splice(index, 1)
|
|
2523
|
+
bestEffort(() => fs.rmSync(batchFile(checkpointId), { force: true }))
|
|
2524
|
+
bestEffort(() => logAgent({ kind: "ack", checkpoint_id: checkpointId }))
|
|
2525
|
+
touch()
|
|
2526
|
+
sendJson(res, 200, { ok: true, checkpoint_id: checkpointId })
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// An agent's unit transition is visible (to /status, the stream, and a
|
|
2530
|
+
// later board save) only once the board commit holds it. A save that
|
|
2531
|
+
// fails restores the unit's prior fields and answers 500, so the agent
|
|
2532
|
+
// retries against a board that still reads as it did before the post.
|
|
2533
|
+
function commitUnit(res, unit, transition) {
|
|
2534
|
+
const before = { ...unit }
|
|
2535
|
+
transition()
|
|
2536
|
+
try {
|
|
2537
|
+
saveBoard()
|
|
2538
|
+
} catch (error) {
|
|
2539
|
+
for (const key of Object.keys(unit)) delete unit[key]
|
|
2540
|
+
Object.assign(unit, before)
|
|
2541
|
+
sendJson(res, 500, { error: "storage_failed", code: error.code ?? null, unit_id: unit.id })
|
|
2542
|
+
return false
|
|
2543
|
+
}
|
|
2544
|
+
return true
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
async function handleUnitStatus(req, res, unitId) {
|
|
2548
|
+
const body = await readBody(req, BODY_LIMIT)
|
|
2549
|
+
const parsed = body.tooLarge ? null : parseJsonObject(body.text)
|
|
2550
|
+
if (!parsed || typeof parsed.status !== "string" || !AGENT_UNIT_STATUSES.has(parsed.status)) {
|
|
2551
|
+
sendJson(res, 400, { error: `status must be one of ${[...AGENT_UNIT_STATUSES].join(", ")}` })
|
|
2552
|
+
return
|
|
2553
|
+
}
|
|
2554
|
+
const unit = unitById(unitId)
|
|
2555
|
+
if (!unit) {
|
|
2556
|
+
sendJson(res, 404, { error: "unknown unit" })
|
|
2557
|
+
return
|
|
2558
|
+
}
|
|
2559
|
+
// A page withdrawal is terminal; a late agent status must not revive it.
|
|
2560
|
+
if (unit.status === "withdrawn") {
|
|
2561
|
+
sendJson(res, 409, { error: "unit withdrawn", unit_id: unitId, status: "withdrawn" })
|
|
2562
|
+
return
|
|
2563
|
+
}
|
|
2564
|
+
const transition = () => {
|
|
2565
|
+
unit.status = parsed.status
|
|
2566
|
+
if (typeof parsed.note === "string") unit.note = parsed.note
|
|
2567
|
+
if (typeof parsed.guess === "string") unit.guess = parsed.guess
|
|
2568
|
+
}
|
|
2569
|
+
if (!commitUnit(res, unit, transition)) return
|
|
2570
|
+
bestEffort(() => logAgent({ kind: "unit_status", unit_id: unitId, status: parsed.status, note: parsed.note ?? null, guess: parsed.guess ?? null }))
|
|
2571
|
+
const notice = { unit_id: unitId, status: parsed.status, ...(parsed.note !== undefined ? { note: parsed.note } : {}), ...(parsed.guess !== undefined ? { guess: parsed.guess } : {}) }
|
|
2572
|
+
broadcast("unit_status", notice)
|
|
2573
|
+
if (parsed.status === "applied") {
|
|
2574
|
+
broadcast("applied", { checkpoint_id: unit.checkpoint_id ?? null, unit_ids: [unitId] })
|
|
2575
|
+
watchForLoss()
|
|
2576
|
+
}
|
|
2577
|
+
touch()
|
|
2578
|
+
sendJson(res, 200, { ok: true, unit_id: unitId, status: parsed.status })
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
async function handleUnitAsk(req, res, unitId) {
|
|
2582
|
+
const body = await readBody(req, BODY_LIMIT)
|
|
2583
|
+
const parsed = body.tooLarge ? null : parseJsonObject(body.text)
|
|
2584
|
+
if (!parsed || typeof parsed.question !== "string" || !parsed.question.trim()) {
|
|
2585
|
+
sendJson(res, 400, { error: "question is required" })
|
|
2586
|
+
return
|
|
2587
|
+
}
|
|
2588
|
+
const unit = unitById(unitId)
|
|
2589
|
+
if (!unit) {
|
|
2590
|
+
sendJson(res, 404, { error: "unknown unit" })
|
|
2591
|
+
return
|
|
2592
|
+
}
|
|
2593
|
+
if (unit.status === "withdrawn") {
|
|
2594
|
+
sendJson(res, 409, { error: "unit withdrawn", unit_id: unitId, status: "withdrawn" })
|
|
2595
|
+
return
|
|
2596
|
+
}
|
|
2597
|
+
const transition = () => {
|
|
2598
|
+
unit.status = "needs_info"
|
|
2599
|
+
unit.question = parsed.question
|
|
2600
|
+
}
|
|
2601
|
+
if (!commitUnit(res, unit, transition)) return
|
|
2602
|
+
bestEffort(() => logAgent({ kind: "ask", unit_id: unitId, question: parsed.question }))
|
|
2603
|
+
broadcast("unit_status", { unit_id: unitId, status: "needs_info" })
|
|
2604
|
+
broadcast("ask", { unit_id: unitId, question: parsed.question })
|
|
2605
|
+
touch()
|
|
2606
|
+
sendJson(res, 200, { ok: true, unit_id: unitId, status: "needs_info" })
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function handleStatus(req, res) {
|
|
2610
|
+
sendJson(res, 200, boardSummary(board, batches, logBytes))
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// GET /session: the page asks, with its token alone, whether the board is
|
|
2614
|
+
// live or ended and whether a new session id would open a fresh board. It
|
|
2615
|
+
// binds nothing and is not activity, so an abandoned endpoint still idles out.
|
|
2616
|
+
function handleSessionProbe(req, res) {
|
|
2617
|
+
if (!authorizePageToken(req, res)) return
|
|
2618
|
+
sendJson(res, 200, {
|
|
2619
|
+
status: board.ended ? "ended" : "live",
|
|
2620
|
+
session_id: board.session_id,
|
|
2621
|
+
accepts_new_session: board.ended && drained(),
|
|
2622
|
+
}, corsHeaders())
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// --- dispatch ----------------------------------------------------------------
|
|
2626
|
+
|
|
2627
|
+
const PAGE_ROUTES = new Set(["/events", "/stream", "/mint", "/session/end"])
|
|
2628
|
+
|
|
2629
|
+
function preflight(res) {
|
|
2630
|
+
res.writeHead(204, {
|
|
2631
|
+
...corsHeaders(),
|
|
2632
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Riffrec-Session, X-Riffrec-OpenAI-Key",
|
|
2633
|
+
"Access-Control-Allow-Methods": "GET, POST",
|
|
2634
|
+
"Access-Control-Max-Age": "600",
|
|
2635
|
+
})
|
|
2636
|
+
res.end()
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
async function handleRequest(req, res) {
|
|
2640
|
+
let urlPath
|
|
2641
|
+
try {
|
|
2642
|
+
urlPath = decodeURIComponent(req.url.split("?")[0].split("#")[0])
|
|
2643
|
+
} catch {
|
|
2644
|
+
sendJson(res, 400, { error: "bad request" })
|
|
2645
|
+
return
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
if (urlPath === "/session") {
|
|
2649
|
+
if (req.method === "OPTIONS") {
|
|
2650
|
+
preflight(res)
|
|
2651
|
+
return
|
|
2652
|
+
}
|
|
2653
|
+
if (req.method !== "GET") {
|
|
2654
|
+
sendJson(res, 405, { error: "method not allowed" }, { ...corsHeaders(), Allow: "GET, OPTIONS" })
|
|
2655
|
+
return
|
|
2656
|
+
}
|
|
2657
|
+
return handleSessionProbe(req, res)
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
if (PAGE_ROUTES.has(urlPath)) {
|
|
2661
|
+
if (req.method === "OPTIONS") {
|
|
2662
|
+
preflight(res)
|
|
2663
|
+
return
|
|
2664
|
+
}
|
|
2665
|
+
const expected = urlPath === "/stream" ? "GET" : "POST"
|
|
2666
|
+
if (req.method !== expected) {
|
|
2667
|
+
sendJson(res, 405, { error: "method not allowed" }, { ...corsHeaders(), Allow: `${expected}, OPTIONS` })
|
|
2668
|
+
return
|
|
2669
|
+
}
|
|
2670
|
+
const sessionId = authorizePage(req, res)
|
|
2671
|
+
if (!sessionId) return
|
|
2672
|
+
if (urlPath === "/events") return handleEvents(req, res, sessionId)
|
|
2673
|
+
if (urlPath === "/stream") return handleStream(req, res)
|
|
2674
|
+
if (urlPath === "/mint") return handleMint(req, res, sessionId)
|
|
2675
|
+
return handleSessionEnd(req, res)
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
const ack = urlPath.match(/^\/checkpoints\/([^/]+)\/ack$/)
|
|
2679
|
+
const unitStatus = urlPath.match(/^\/units\/([^/]+)\/status$/)
|
|
2680
|
+
const unitAsk = urlPath.match(/^\/units\/([^/]+)\/ask$/)
|
|
2681
|
+
const agentRoute = urlPath === "/wait" || urlPath === "/status" || ack || unitStatus || unitAsk
|
|
2682
|
+
if (agentRoute) {
|
|
2683
|
+
if (!authorizeAgent(req, res)) return
|
|
2684
|
+
if (req.method === "GET" && urlPath === "/wait") return handleWait(req, res)
|
|
2685
|
+
if (req.method === "GET" && urlPath === "/status") return handleStatus(req, res)
|
|
2686
|
+
if (req.method === "POST" && ack) return handleAck(req, res, ack[1])
|
|
2687
|
+
if (req.method === "POST" && unitStatus) return handleUnitStatus(req, res, unitStatus[1])
|
|
2688
|
+
if (req.method === "POST" && unitAsk) return handleUnitAsk(req, res, unitAsk[1])
|
|
2689
|
+
sendJson(res, 405, { error: "method not allowed" })
|
|
2690
|
+
return
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// Nothing is served from the run directory (R40).
|
|
2694
|
+
sendJson(res, 404, { error: "not found" })
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// Every refusal (4xx/5xx) leaves one line in agent.ndjson: method, path,
|
|
2698
|
+
// status, and the reason this server gave. Never the credential, never the
|
|
2699
|
+
// request headers or body, so a session can be debugged from its log.
|
|
2700
|
+
// Refusals need no credential to produce, so on a LAN or tunnel-exposed
|
|
2701
|
+
// endpoint they are the one write an outsider can drive: the log takes a
|
|
2702
|
+
// bounded number per minute, notes once that the rest of the minute is
|
|
2703
|
+
// suppressed, and writes nothing once the session's disk cap is reached.
|
|
2704
|
+
let rejectionWindowStart = 0
|
|
2705
|
+
let rejectionWindowCount = 0
|
|
2706
|
+
function logRejection(req, res) {
|
|
2707
|
+
const now = Date.now()
|
|
2708
|
+
if (now - rejectionWindowStart >= REJECTION_LOG_WINDOW_MS) {
|
|
2709
|
+
rejectionWindowStart = now
|
|
2710
|
+
rejectionWindowCount = 0
|
|
2711
|
+
}
|
|
2712
|
+
if (logBytes >= DISK_CAP_BYTES) return
|
|
2713
|
+
rejectionWindowCount += 1
|
|
2714
|
+
if (rejectionWindowCount > REJECTION_LOG_PER_WINDOW) return
|
|
2715
|
+
if (rejectionWindowCount === REJECTION_LOG_PER_WINDOW) {
|
|
2716
|
+
bestEffort(() => logAgent({ kind: "rejected_suppressed", until: rejectionWindowStart + REJECTION_LOG_WINDOW_MS, per_window: REJECTION_LOG_PER_WINDOW }))
|
|
2717
|
+
return
|
|
2718
|
+
}
|
|
2719
|
+
const value = res.rejection
|
|
2720
|
+
const record = { kind: "rejected", method: req.method ?? null, route: loggedRoute(req), status: res.statusCode, reason: rejectionReason(value) }
|
|
2721
|
+
if (isRecord(value) && Number.isInteger(value.seq)) record.seq = value.seq
|
|
2722
|
+
bestEffort(() => logAgent(record))
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
const server = http.createServer((req, res) => {
|
|
2726
|
+
res.on("finish", () => {
|
|
2727
|
+
if (res.statusCode >= 400) logRejection(req, res)
|
|
2728
|
+
})
|
|
2729
|
+
Promise.resolve(handleRequest(req, res)).catch(() => {
|
|
2730
|
+
if (!res.headersSent) sendJson(res, 500, { error: "internal error" })
|
|
2731
|
+
else if (!res.writableEnded) res.end()
|
|
2732
|
+
})
|
|
2733
|
+
})
|
|
2734
|
+
// The session archive on /session/end can take longer than Node's default
|
|
2735
|
+
// 5-minute request budget on a slow link.
|
|
2736
|
+
server.requestTimeout = 0
|
|
2737
|
+
|
|
2738
|
+
const listen = (onPort) => new Promise((resolve, reject) => {
|
|
2739
|
+
const onError = (error) => reject(error)
|
|
2740
|
+
server.once("error", onError)
|
|
2741
|
+
server.listen(onPort, options.host, () => {
|
|
2742
|
+
server.off("error", onError)
|
|
2743
|
+
resolve()
|
|
2744
|
+
})
|
|
2745
|
+
})
|
|
2746
|
+
try {
|
|
2747
|
+
await listen(port)
|
|
2748
|
+
} catch (error) {
|
|
2749
|
+
// A resumed session prefers its old port; when something else took it,
|
|
2750
|
+
// any free port still resumes the session (the page learns the new
|
|
2751
|
+
// origin from the URL the skill hands over).
|
|
2752
|
+
if (options.port !== undefined || error?.code !== "EADDRINUSE") throw error
|
|
2753
|
+
await listen(0)
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
const address = server.address()
|
|
2757
|
+
const boundPort = typeof address === "object" && address ? address.port : port
|
|
2758
|
+
const urlHost = options.host === DEFAULT_HOST || options.host === "0.0.0.0" || options.host === "::" ? DEFAULT_URL_HOST : localAddressFor(options.host)
|
|
2759
|
+
const url = `http://${urlHost}:${boundPort}`
|
|
2760
|
+
// A resume rewrites only what this process changed: pid, owner_pid, url
|
|
2761
|
+
// (and host/port when the old port was taken). Tokens and everything else
|
|
2762
|
+
// are the previous session's.
|
|
2763
|
+
session = resuming
|
|
2764
|
+
? { ...previous, url, app_origin: options.appOrigin, host: options.host, port: boundPort, trust_proxy: options.trustProxy, pid: process.pid, owner_pid: options.ownerPid ?? null, ended: Boolean(previous.ended) }
|
|
2765
|
+
: {
|
|
2766
|
+
page_token: pageToken,
|
|
2767
|
+
agent_token: agentToken,
|
|
2768
|
+
url,
|
|
2769
|
+
app_origin: options.appOrigin,
|
|
2770
|
+
host: options.host,
|
|
2771
|
+
port: boundPort,
|
|
2772
|
+
trust_proxy: options.trustProxy,
|
|
2773
|
+
pid: process.pid,
|
|
2774
|
+
owner_pid: options.ownerPid ?? null,
|
|
2775
|
+
ended: false,
|
|
2776
|
+
root: options.root,
|
|
2777
|
+
log_dir: options.logDir,
|
|
2778
|
+
started_at: new Date().toISOString(),
|
|
2779
|
+
}
|
|
2780
|
+
if (board.page.stream === "connected") board.page.stream = "disconnected"
|
|
2781
|
+
// A shutdown inside the grace window dropped the timer; the promise of a
|
|
2782
|
+
// page_lost wake survives in the board, so pick it up again.
|
|
2783
|
+
if (board.watch_for_loss && !board.ended) armPageLost()
|
|
2784
|
+
writePrivate(options.pidFile, `${process.pid}\n`)
|
|
2785
|
+
saveBoard()
|
|
2786
|
+
writePrivateJson(options.sessionFile, session)
|
|
2787
|
+
// The agent token never leaves the state file. This line is the start
|
|
2788
|
+
// envelope for `--foreground`; detached, it lands in server.log (0600).
|
|
2789
|
+
jsonOut({ ...publicStartEnvelope(session), status: resuming ? "resumed" : "started" })
|
|
2790
|
+
|
|
2791
|
+
// Owner death, idle timeout, SIGTERM: stop the process, keep the session.
|
|
2792
|
+
// The board, batches, and session file are already on disk, so a later
|
|
2793
|
+
// `start --root` resumes with the same tokens.
|
|
2794
|
+
function shutdown() {
|
|
2795
|
+
disarmPageLost()
|
|
2796
|
+
if (waiter && !waiter.res.writableEnded) {
|
|
2797
|
+
clearTimeout(waiter.timer)
|
|
2798
|
+
waiter.res.writeHead(204)
|
|
2799
|
+
waiter.res.end()
|
|
2800
|
+
waiter = null
|
|
2801
|
+
}
|
|
2802
|
+
for (const client of streamClients) {
|
|
2803
|
+
if (!client.writableEnded) client.end()
|
|
2804
|
+
}
|
|
2805
|
+
streamClients.clear()
|
|
2806
|
+
server.close(() => process.exit(0))
|
|
2807
|
+
server.closeAllConnections()
|
|
2808
|
+
setTimeout(() => process.exit(0), 2000).unref()
|
|
2809
|
+
}
|
|
2810
|
+
process.on("SIGTERM", shutdown)
|
|
2811
|
+
process.on("SIGINT", shutdown)
|
|
2812
|
+
|
|
2813
|
+
// An archive still streaming in is work in progress even with no stream
|
|
2814
|
+
// client attached: the idle timeout waits for it, and its chunks count
|
|
2815
|
+
// as activity so a slow remote link is not cut off mid-upload.
|
|
2816
|
+
const idleTimer = setInterval(() => {
|
|
2817
|
+
if (options.ownerPid && !processAlive(options.ownerPid)) shutdown()
|
|
2818
|
+
else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS && streamClients.size === 0 && !endingInFlight) shutdown()
|
|
2819
|
+
}, LIFECYCLE_CHECK_MS)
|
|
2820
|
+
idleTimer.unref()
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
async function main() {
|
|
2824
|
+
let command
|
|
2825
|
+
try {
|
|
2826
|
+
const options = parseArgs(process.argv)
|
|
2827
|
+
command = options.command
|
|
2828
|
+
if (command === "start") await start(options)
|
|
2829
|
+
else if (command === "serve") await serve(options)
|
|
2830
|
+
else if (command === "stop") await stop(options)
|
|
2831
|
+
else if (command === "status") status(options)
|
|
2832
|
+
else if (command === "wait") await wait(options)
|
|
2833
|
+
else if (command === "replay") await replay(options)
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
console.error(error.message)
|
|
2836
|
+
// Wait reserves exit 1 for session-ended and 3 for wait-taken; any other failure is exit 2.
|
|
2837
|
+
process.exit((command ?? process.argv[2]) === "wait" ? 2 : 1)
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
await main()
|