@galaxyproject/orbit 0.0.0 → 0.9.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/CHANGELOG.md +123 -0
- package/LICENSE +21 -0
- package/README.md +760 -5
- package/bin/legacy-mcp-config.js +363 -0
- package/bin/orbit.js +810 -0
- package/bin/pi-extension-path.d.ts +4 -0
- package/bin/pi-extension-path.js +44 -0
- package/bin/provider-auth.d.ts +22 -0
- package/bin/provider-auth.js +77 -0
- package/bin/thinking-pref.js +38 -0
- package/bin/update-check.d.ts +15 -0
- package/bin/update-check.js +137 -0
- package/extensions/cli-update/index.ts +32 -0
- package/extensions/orbit/activity-hooks.ts +141 -0
- package/extensions/orbit/activity.ts +116 -0
- package/extensions/orbit/agent-dir.ts +11 -0
- package/extensions/orbit/auto-resume.ts +352 -0
- package/extensions/orbit/config.ts +3 -0
- package/extensions/orbit/confusables.ts +1 -0
- package/extensions/orbit/context.ts +957 -0
- package/extensions/orbit/dashboard-commands.ts +191 -0
- package/extensions/orbit/dashboard-store.ts +473 -0
- package/extensions/orbit/dashboard-tools.ts +1185 -0
- package/extensions/orbit/empty-completion.ts +99 -0
- package/extensions/orbit/evidence-gate.ts +1276 -0
- package/extensions/orbit/evidence-override-command.ts +294 -0
- package/extensions/orbit/exec-guard/bash-risk.ts +1408 -0
- package/extensions/orbit/exec-guard/gate.ts +181 -0
- package/extensions/orbit/exec-guard/guardian-config.ts +54 -0
- package/extensions/orbit/exec-guard/index.ts +1 -0
- package/extensions/orbit/exec-guard/model-tier.ts +36 -0
- package/extensions/orbit/exec-guard/path-jail.ts +54 -0
- package/extensions/orbit/exec-guard/policy.ts +342 -0
- package/extensions/orbit/exec-guard/runtime-state.ts +15 -0
- package/extensions/orbit/exec-guard/sensitive-read.ts +525 -0
- package/extensions/orbit/exec-guard/types.ts +42 -0
- package/extensions/orbit/execution-commands.ts +134 -0
- package/extensions/orbit/feedback-command.ts +81 -0
- package/extensions/orbit/feedback.ts +96 -0
- package/extensions/orbit/galaxy-api.test.ts +122 -0
- package/extensions/orbit/galaxy-api.ts +606 -0
- package/extensions/orbit/galaxy-artifact-link-guidance.ts +5 -0
- package/extensions/orbit/galaxy-destructive-gate.ts +113 -0
- package/extensions/orbit/galaxy-enrich.ts +1108 -0
- package/extensions/orbit/galaxy-failure.ts +101 -0
- package/extensions/orbit/galaxy-fixture.ts +126 -0
- package/extensions/orbit/galaxy-history-guard.ts +233 -0
- package/extensions/orbit/galaxy-job-block.ts +347 -0
- package/extensions/orbit/galaxy-job-ops.ts +288 -0
- package/extensions/orbit/galaxy-live-source.ts +929 -0
- package/extensions/orbit/galaxy-markdown-adapter.ts +248 -0
- package/extensions/orbit/galaxy-op-policy.ts +332 -0
- package/extensions/orbit/galaxy-ops-tools.ts +326 -0
- package/extensions/orbit/galaxy-output-store.ts +80 -0
- package/extensions/orbit/galaxy-output.ts +346 -0
- package/extensions/orbit/galaxy-page-binding.ts +222 -0
- package/extensions/orbit/galaxy-page-markdown-guidance.ts +15 -0
- package/extensions/orbit/galaxy-page-ops.ts +159 -0
- package/extensions/orbit/galaxy-page-sync.test.ts +417 -0
- package/extensions/orbit/galaxy-page-sync.ts +318 -0
- package/extensions/orbit/galaxy-pages-api.ts +203 -0
- package/extensions/orbit/galaxy-pages-sync.ts +409 -0
- package/extensions/orbit/galaxy-poll-guard.ts +189 -0
- package/extensions/orbit/galaxy-poller.ts +706 -0
- package/extensions/orbit/galaxy-provenance.ts +623 -0
- package/extensions/orbit/galaxy-reconcile.ts +956 -0
- package/extensions/orbit/galaxy-recovery.ts +176 -0
- package/extensions/orbit/galaxy-repeat-guard.ts +55 -0
- package/extensions/orbit/galaxy-submission-capture.ts +707 -0
- package/extensions/orbit/galaxy-submission.ts +502 -0
- package/extensions/orbit/galaxy-tool-source.ts +78 -0
- package/extensions/orbit/galaxy-tool-wrap.ts +234 -0
- package/extensions/orbit/galaxy-udt-block.ts +127 -0
- package/extensions/orbit/galaxy-upload-tus.ts +334 -0
- package/extensions/orbit/galaxy-upload.ts +300 -0
- package/extensions/orbit/galaxy-version-capture.ts +65 -0
- package/extensions/orbit/git.ts +173 -0
- package/extensions/orbit/harness-block-fields.ts +597 -0
- package/extensions/orbit/index.ts +593 -0
- package/extensions/orbit/init-gate.ts +284 -0
- package/extensions/orbit/instructions-command.ts +136 -0
- package/extensions/orbit/invocation-failure-hint.ts +57 -0
- package/extensions/orbit/iwc-plan-check.ts +94 -0
- package/extensions/orbit/lesson-command.ts +303 -0
- package/extensions/orbit/lesson-hint.ts +245 -0
- package/extensions/orbit/lesson-nudge.ts +106 -0
- package/extensions/orbit/lessons/compose.ts +177 -0
- package/extensions/orbit/lessons/counters.ts +120 -0
- package/extensions/orbit/lessons/enabled.ts +116 -0
- package/extensions/orbit/lessons/matcher.ts +346 -0
- package/extensions/orbit/lessons/paths.ts +286 -0
- package/extensions/orbit/lessons/pi-event-contract.ts +105 -0
- package/extensions/orbit/lessons/propose-replay.ts +103 -0
- package/extensions/orbit/lessons/propose-tool.ts +222 -0
- package/extensions/orbit/lessons/propose.ts +502 -0
- package/extensions/orbit/lessons/replay.ts +89 -0
- package/extensions/orbit/lessons/reproduction-index.ts +83 -0
- package/extensions/orbit/lessons/search-tool.ts +99 -0
- package/extensions/orbit/lessons/search.ts +136 -0
- package/extensions/orbit/lessons/store.ts +415 -0
- package/extensions/orbit/lessons/text.ts +36 -0
- package/extensions/orbit/lessons/types.ts +69 -0
- package/extensions/orbit/lessons/user-dir.ts +24 -0
- package/extensions/orbit/lessons/user-lesson.ts +88 -0
- package/extensions/orbit/lessons/wrapper.ts +61 -0
- package/extensions/orbit/lessons-command.ts +147 -0
- package/extensions/orbit/local-exec.ts +39 -0
- package/extensions/orbit/mcp-output.ts +179 -0
- package/extensions/orbit/mcp-servers.test.ts +24 -0
- package/extensions/orbit/mcp-servers.ts +25 -0
- package/extensions/orbit/model-script.ts +351 -0
- package/extensions/orbit/notebook-anchors.ts +342 -0
- package/extensions/orbit/notebook-writer.ts +863 -0
- package/extensions/orbit/observation-allowlists.ts +1109 -0
- package/extensions/orbit/observation-replay.ts +148 -0
- package/extensions/orbit/observation-triggers.ts +563 -0
- package/extensions/orbit/observation-ui.ts +175 -0
- package/extensions/orbit/observations-command.ts +421 -0
- package/extensions/orbit/observations-config.ts +200 -0
- package/extensions/orbit/observations.ts +1381 -0
- package/extensions/orbit/profiles.ts +270 -0
- package/extensions/orbit/progress-updates.ts +95 -0
- package/extensions/orbit/proposal-commands.ts +882 -0
- package/extensions/orbit/provenance-fs-file.ts +59 -0
- package/extensions/orbit/provenance-fs.ts +32 -0
- package/extensions/orbit/provenance-memory-fs.ts +59 -0
- package/extensions/orbit/record-store-file.ts +177 -0
- package/extensions/orbit/record-store-page.ts +77 -0
- package/extensions/orbit/record-store-seam.ts +47 -0
- package/extensions/orbit/record-store.ts +149 -0
- package/extensions/orbit/registry-carrier.ts +107 -0
- package/extensions/orbit/registry-eligibility.ts +95 -0
- package/extensions/orbit/registry-evaluation.ts +403 -0
- package/extensions/orbit/registry-evaluator.ts +519 -0
- package/extensions/orbit/registry-import.ts +91 -0
- package/extensions/orbit/registry-lock.ts +221 -0
- package/extensions/orbit/registry-memory-fs.ts +42 -0
- package/extensions/orbit/registry-page-carrier.ts +103 -0
- package/extensions/orbit/registry-proposal.ts +1348 -0
- package/extensions/orbit/registry-runtime.ts +234 -0
- package/extensions/orbit/registry-schema.ts +735 -0
- package/extensions/orbit/registry-submitter.ts +59 -0
- package/extensions/orbit/registry-templates.ts +346 -0
- package/extensions/orbit/registry.ts +476 -0
- package/extensions/orbit/sandbox/index.ts +120 -0
- package/extensions/orbit/sandbox/sandbox-bash.ts +93 -0
- package/extensions/orbit/sandbox/sandbox-config.ts +87 -0
- package/extensions/orbit/sandbox/sandbox-init-message.ts +28 -0
- package/extensions/orbit/secret-redaction.ts +247 -0
- package/extensions/orbit/session-index/cwd.ts +18 -0
- package/extensions/orbit/session-index/db.ts +56 -0
- package/extensions/orbit/session-index/indexer.ts +154 -0
- package/extensions/orbit/session-index/is-enabled.ts +17 -0
- package/extensions/orbit/session-index/parse.ts +254 -0
- package/extensions/orbit/session-index/query.ts +209 -0
- package/extensions/orbit/session-index/schema.ts +72 -0
- package/extensions/orbit/session-index/tools.ts +152 -0
- package/extensions/orbit/session-lifecycle.ts +321 -0
- package/extensions/orbit/skill-triggers.ts +159 -0
- package/extensions/orbit/skills-command.ts +48 -0
- package/extensions/orbit/skills-discovery.ts +435 -0
- package/extensions/orbit/skills.ts +84 -0
- package/extensions/orbit/sra-import-gate.ts +77 -0
- package/extensions/orbit/state.ts +265 -0
- package/extensions/orbit/submission-replay.ts +147 -0
- package/extensions/orbit/sync-command.ts +157 -0
- package/extensions/orbit/teams/critic-parser.ts +93 -0
- package/extensions/orbit/teams/dispatcher.ts +210 -0
- package/extensions/orbit/teams/is-enabled.ts +22 -0
- package/extensions/orbit/teams/tool.ts +187 -0
- package/extensions/orbit/teams/types.ts +57 -0
- package/extensions/orbit/teams/validate.ts +42 -0
- package/extensions/orbit/tester-id-command.ts +133 -0
- package/extensions/orbit/tools-sync.ts +279 -0
- package/extensions/orbit/tools.ts +1300 -0
- package/extensions/orbit/types.ts +41 -0
- package/extensions/orbit/ui-bridge.ts +55 -0
- package/extensions/orbit/ulid.ts +60 -0
- package/extensions/orbit/user-instructions.ts +388 -0
- package/extensions/orbit/vendor/skills/_catalog.json +68 -0
- package/extensions/orbit/vendor/skills/_manifest.json +1106 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/SKILL.md +112 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_provenance.json +218 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_required_tools.json +33 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/add.json +50 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-extract.json +51 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-next-step.json +42 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/draft-validate.json +140 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/cli/list.json +32 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/notes/galaxy-tool-job-failure-reference.md +137 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/schemas/galaxy-tool-summary.schema.json +175 -0
- package/extensions/orbit/vendor/skills/advance-galaxy-draft-step/references/schemas/galaxy-workflow-draft.schema.json +2727 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/SKILL.md +82 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_provenance.json +133 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_required_tools.json +16 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/cli/draft-validate.json +140 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/notes/galaxy-workflow-draft-format.md +135 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/apply-galaxy-workflow-changeset/references/schemas/galaxy-workflow-draft.schema.json +2727 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/SKILL.md +84 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/_provenance.json +166 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/_verify.json +29 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/galaxy-workflow-testability-design.md +136 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/iwc-test-data-conventions.md +308 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/galaxy-workflow-test-plan.schema.json +552 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/summary-galaxy-workflow.schema.json +687 -0
- package/extensions/orbit/vendor/skills/changeset-to-galaxy-test-plan/references/schemas/tests-format.schema.json +7328 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/SKILL.md +127 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_provenance.json +198 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_required_tools.json +16 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/cli/convert.json +123 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/galaxy-data-flow-draft-contract.md +129 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/iwc-test-data-conventions.md +308 -0
- package/extensions/orbit/vendor/skills/compare-against-iwc-exemplar/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/SKILL.md +73 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/_provenance.json +160 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-collection-semantics.md +43 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-collection-semantics.yml +1250 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-tool-job-failure-reference.md +137 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/nextflow-operators-to-galaxy-collection-recipes.md +98 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/debug-galaxy-workflow-output/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/SKILL.md +174 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/_provenance.json +130 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/_required_tools.json +18 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-revisions.json +48 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-search.json +95 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/cli/tool-versions.json +39 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/notes/component-tool-shed-search.md +230 -0
- package/extensions/orbit/vendor/skills/discover-shed-tool/references/schemas/galaxy-tool-discovery.schema.json +231 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/SKILL.md +93 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_provenance.json +201 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_required_tools.json +28 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/cli/planemo.md +44 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/cli/validate-tests.json +40 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/galaxy-workflow-testability-design.md +136 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/iwc-shortcuts-anti-patterns.md +357 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/iwc-test-data-conventions.md +308 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/schemas/galaxy-workflow-test-plan.schema.json +552 -0
- package/extensions/orbit/vendor/skills/implement-galaxy-workflow-test/references/schemas/tests-format.schema.json +7328 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/SKILL.md +81 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/_provenance.json +93 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/references/notes/open-requirements-ledger.md +173 -0
- package/extensions/orbit/vendor/skills/interview-to-galaxy-workflow-changeset/references/schemas/summary-galaxy-workflow.schema.json +687 -0
- package/extensions/orbit/vendor/skills/pipeline-update-interview-to-galaxy/SKILL.md +81 -0
- package/extensions/orbit/vendor/skills/pipeline-update-interview-to-galaxy/_assembly.json +23 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/SKILL.md +79 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/_provenance.json +125 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/_required_tools.json +28 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/cli/planemo.md +44 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/cli/validate-tests.json +40 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/planemo-asserts-idioms.md +246 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/notes/planemo-workflow-test-architecture.md +151 -0
- package/extensions/orbit/vendor/skills/run-workflow-test/references/schemas/tests-format.schema.json +7328 -0
- package/extensions/orbit/vendor/skills/skills/LICENSE +21 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/README.md +39 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/SKILL.md +892 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/api-patterns.md +927 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/apply-rules.md +1482 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/test-patterns.md +996 -0
- package/extensions/orbit/vendor/skills/skills/collection-manipulation/references/tools.md +609 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-data-libraries/SKILL.md +170 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/README.md +162 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/SKILL.md +99 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/README.md +119 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/tool-checking.md +419 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/examples/workflow-testing.md +617 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/galaxy-integration.md +614 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/SKILL.md +144 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/average_col3.ipynb +68 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/extract_sample_metadata.ipynb +190 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/variant_annotation.ipynb +816 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/jupyterlite/examples/vcp_variant_map.ipynb +443 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/scripts/README.md +165 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-integration/scripts/galaxy_tool_checker.py +521 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/SKILL.md +128 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/gotchas.md +135 -0
- package/extensions/orbit/vendor/skills/skills/galaxy-mcp-reference/history-access.md +132 -0
- package/extensions/orbit/vendor/skills/skills/reproduciblify/SKILL.md +146 -0
- package/extensions/orbit/vendor/skills/skills/reproduciblify/references/directives.yml +496 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/SKILL.md +245 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/01-minimal-echo.yml +27 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/02-single-data-in-out.yml +27 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/03-multiple-data-js.yml +28 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/04-select-integer-boolean.yml +41 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/05-resource-requirements.yml +25 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/06-biocontainer-tool.yml +38 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/07-inline-python-script.yml +56 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/examples/08-configfile-script.yml +74 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/references/common-mistakes.md +76 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/references/schema-reference.md +155 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/references/templating.md +196 -0
- package/extensions/orbit/vendor/skills/skills/udt-authoring/scripts/validate.py +109 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/README.md +71 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/SKILL.md +190 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/examples/histology-staining.md +145 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/examples/tissue-microarray-analysis.md +176 -0
- package/extensions/orbit/vendor/skills/skills/workflow-reports/references/directives.md +415 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/SKILL.md +98 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_provenance.json +93 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_required_tools.json +28 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/_verify.json +17 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/convert.json +123 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/foundry.md +25 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/cli/validate.json +142 -0
- package/extensions/orbit/vendor/skills/summarize-galaxy-workflow/references/schemas/summary-galaxy-workflow.schema.json +687 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/SKILL.md +74 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_provenance.json +71 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_required_tools.json +16 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/_verify.json +4 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/cli/validate.json +142 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/notes/galaxy-workflow-invocation-failure-reference.md +138 -0
- package/extensions/orbit/vendor/skills/validate-galaxy-workflow/references/notes/galaxy-workflow-testability-design.md +136 -0
- package/extensions/orbit/vendor-skills.ts +283 -0
- package/extensions/orbit/workspace-state-dir.ts +46 -0
- package/extensions/orbit-handoff/index.ts +73 -0
- package/extensions/orbit-handoff/orbit-launcher.ts +142 -0
- package/extensions/whats-new/index.ts +98 -0
- package/lessons/LICENSE +17 -0
- package/lessons/README.md +117 -0
- package/lessons/SCHEMA.md +187 -0
- package/lessons/build-snapshot.mjs +311 -0
- package/lessons/data/downloaded-file-is-not-what-its-extension-says.md +61 -0
- package/lessons/galaxy-api/403-history-is-a-hard-stop.md +57 -0
- package/lessons/galaxy-api/collection-into-single-dataset-input.md +61 -0
- package/lessons/galaxy-api/connectedvalue-in-command-line.md +62 -0
- package/lessons/galaxy-api/hid-is-not-an-id.md +59 -0
- package/lessons/galaxy-api/invoke-workflow-inputs-not-params.md +60 -0
- package/lessons/galaxy-api/repeat-param-pipe-keys.md +63 -0
- package/lessons/galaxy-api/run-tool-returns-on-submit.md +61 -0
- package/lessons/galaxy-tools/reference-index-not-on-server.md +62 -0
- package/lessons/log.md +42 -0
- package/lessons/reproduction/input-population-mismatch.md +59 -0
- package/lessons/reproduction/methods-text-vs-executed-parameters.md +62 -0
- package/lessons/snapshot.json +1004 -0
- package/lessons/stats/de-contrast-direction-and-sample-labels.md +63 -0
- package/lessons/stats/na-coerced-to-zero-in-filters.md +59 -0
- package/lessons/validate.mjs +1219 -0
- package/package.json +103 -5
- package/shared/agent-prompt.d.ts +8 -0
- package/shared/agent-prompt.js +15 -0
- package/shared/approval-prompt.d.ts +4 -0
- package/shared/approval-prompt.js +61 -0
- package/shared/brain-env.d.ts +14 -0
- package/shared/brain-env.js +108 -0
- package/shared/brain-exit.d.ts +20 -0
- package/shared/brain-exit.js +149 -0
- package/shared/capability-profile.d.ts +16 -0
- package/shared/capability-profile.js +75 -0
- package/shared/config-source.d.ts +13 -0
- package/shared/config-source.js +17 -0
- package/shared/confusables.d.ts +3 -0
- package/shared/confusables.js +93 -0
- package/shared/custom-provider.d.ts +45 -0
- package/shared/custom-provider.js +169 -0
- package/shared/dashboard-contract.d.ts +123 -0
- package/shared/dashboard-contract.js +560 -0
- package/shared/dashboard-layout-store.d.ts +44 -0
- package/shared/dashboard-layout-store.js +281 -0
- package/shared/feedback-contract.d.ts +61 -0
- package/shared/feedback-contract.js +146 -0
- package/shared/galaxy-artifact-links.d.ts +30 -0
- package/shared/galaxy-artifact-links.js +166 -0
- package/shared/galaxy-credentials.d.ts +26 -0
- package/shared/galaxy-credentials.js +71 -0
- package/shared/galaxy-destructive.d.ts +21 -0
- package/shared/galaxy-destructive.js +269 -0
- package/shared/galaxy-guidance.d.ts +2 -0
- package/shared/galaxy-guidance.js +53 -0
- package/shared/galaxy-live-contract.d.ts +92 -0
- package/shared/galaxy-live-contract.js +180 -0
- package/shared/galaxy-page-cells.d.ts +2 -0
- package/shared/galaxy-page-cells.js +22 -0
- package/shared/galaxy-tool-family.d.ts +5 -0
- package/shared/galaxy-tool-family.js +27 -0
- package/shared/galaxy-url.d.ts +1 -0
- package/shared/galaxy-url.js +48 -0
- package/shared/gtn.d.ts +31 -0
- package/shared/gtn.js +253 -0
- package/shared/handled-prompt.d.ts +3 -0
- package/shared/handled-prompt.js +26 -0
- package/shared/known-provider.d.ts +11 -0
- package/shared/known-provider.js +89 -0
- package/shared/lesson-hint-marker.d.ts +7 -0
- package/shared/lesson-hint-marker.js +32 -0
- package/shared/lesson-rules.d.ts +150 -0
- package/shared/lesson-rules.js +203 -0
- package/shared/malformed-object-ids.d.ts +1 -0
- package/shared/malformed-object-ids.js +37 -0
- package/shared/markdown-sections.d.ts +10 -0
- package/shared/markdown-sections.js +118 -0
- package/shared/notebook-fences.d.ts +12 -0
- package/shared/notebook-fences.js +53 -0
- package/shared/observation-contract.d.ts +91 -0
- package/shared/observation-contract.js +544 -0
- package/shared/orbit-config.d.ts +226 -0
- package/shared/orbit-config.js +189 -0
- package/shared/orbit-env.d.ts +13 -0
- package/shared/orbit-env.js +124 -0
- package/shared/orbit-shell-contract.d.ts +74 -0
- package/shared/orbit-shell-contract.js +30 -0
- package/shared/page-content-hash.d.ts +1 -0
- package/shared/page-content-hash.js +13 -0
- package/shared/pi-default-model.d.ts +7 -0
- package/shared/pi-default-model.js +41 -0
- package/shared/prompt-blocks.d.ts +87 -0
- package/shared/prompt-blocks.js +898 -0
- package/shared/provider-auth-caps.d.ts +22 -0
- package/shared/provider-auth-caps.js +57 -0
- package/shared/random.d.ts +2 -0
- package/shared/random.js +15 -0
- package/shared/record-excerpt.d.ts +4 -0
- package/shared/record-excerpt.js +16 -0
- package/shared/redirect-guard.d.ts +36 -0
- package/shared/redirect-guard.js +235 -0
- package/shared/skills-pin.d.ts +8 -0
- package/shared/skills-pin.js +9 -0
- package/shared/skills-router.d.ts +17 -0
- package/shared/skills-router.js +63 -0
- package/shared/sra-import.d.ts +34 -0
- package/shared/sra-import.js +206 -0
- package/shared/state-dir.d.ts +21 -0
- package/shared/state-dir.js +240 -0
- package/shared/team-dispatch-contract.d.ts +28 -0
- package/shared/team-dispatch-contract.js +1 -0
- package/shared/truncate-middle.d.ts +11 -0
- package/shared/truncate-middle.js +68 -0
- package/shared/version-compare.d.ts +10 -0
- package/shared/version-compare.js +74 -0
- package/shared/whats-new.d.ts +31 -0
- package/shared/whats-new.js +83 -0
- package/shared/wsl.d.ts +10 -0
- package/shared/wsl.js +24 -0
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building and shipping an observation (community knowledge loop, contract C1).
|
|
3
|
+
*
|
|
4
|
+
* Everything that decides WHAT is in the payload lives in this half and is
|
|
5
|
+
* pure given its inputs, so the privacy properties are testable without a
|
|
6
|
+
* session: stage comes from an ordered tool-name table, tool ids and datatypes
|
|
7
|
+
* come from a shape allowlist over the triggering call's arguments (never a
|
|
8
|
+
* deep walk of user data), the server is an exact match against the C7
|
|
9
|
+
* allowlist or "private", and the signature goes through the shared
|
|
10
|
+
* normalizer. The description is validated and dropped on any failure rather
|
|
11
|
+
* than trimmed -- a description that trips a leak pattern is not fixable from
|
|
12
|
+
* here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
OBSERVATIONS_ROUTE,
|
|
17
|
+
OBSERVATIONS_ENDPOINT_URL,
|
|
18
|
+
OBSERVATION_KEY_HEADER,
|
|
19
|
+
OBSERVATION_MAX_BYTES,
|
|
20
|
+
OBSERVATION_SCHEMA_VERSION,
|
|
21
|
+
PRIVATE_SERVER,
|
|
22
|
+
PUBLIC_GALAXY_SERVERS,
|
|
23
|
+
RETRACT_TOKEN_HEADER,
|
|
24
|
+
DATATYPES_MAX,
|
|
25
|
+
DATATYPE_MAX,
|
|
26
|
+
TOOLS_MAX,
|
|
27
|
+
TOOL_ID_MAX,
|
|
28
|
+
UNKNOWN_SIGNATURE,
|
|
29
|
+
VERSION_MAX,
|
|
30
|
+
capObservation,
|
|
31
|
+
normalizeSignature,
|
|
32
|
+
signatureStageLeaks,
|
|
33
|
+
observationByteLength,
|
|
34
|
+
scanObservationForLeaks,
|
|
35
|
+
textLeaks,
|
|
36
|
+
validateObservation,
|
|
37
|
+
} from "../../shared/observation-contract.js";
|
|
38
|
+
import type {
|
|
39
|
+
Observation,
|
|
40
|
+
ObservationApp,
|
|
41
|
+
ObservationKind,
|
|
42
|
+
ObservationPlatform,
|
|
43
|
+
ObservationStage,
|
|
44
|
+
ObservationTrigger,
|
|
45
|
+
} from "../../shared/observation-contract.js";
|
|
46
|
+
import {
|
|
47
|
+
appendFileSync,
|
|
48
|
+
chmodSync,
|
|
49
|
+
closeSync,
|
|
50
|
+
existsSync,
|
|
51
|
+
linkSync,
|
|
52
|
+
mkdirSync,
|
|
53
|
+
openSync,
|
|
54
|
+
readFileSync,
|
|
55
|
+
renameSync,
|
|
56
|
+
rmSync,
|
|
57
|
+
statSync,
|
|
58
|
+
writeFileSync,
|
|
59
|
+
writeSync,
|
|
60
|
+
} from "node:fs";
|
|
61
|
+
import { createHash } from "node:crypto";
|
|
62
|
+
import type { ObservationsMode } from "./observations-config.js";
|
|
63
|
+
import { join } from "node:path";
|
|
64
|
+
import { release } from "node:os";
|
|
65
|
+
import { getConfigDir } from "./config.js";
|
|
66
|
+
import { readOrbitVersion } from "./feedback.js";
|
|
67
|
+
import { loadProfiles } from "./profiles.js";
|
|
68
|
+
import { galaxyCredentials } from "./galaxy-api.js";
|
|
69
|
+
import { isWsl } from "../../shared/wsl.js";
|
|
70
|
+
import type { GalaxyToolFamily } from "../../shared/galaxy-tool-family.js";
|
|
71
|
+
import {
|
|
72
|
+
GALAXY_BUILTIN_TOOL_IDS,
|
|
73
|
+
GALAXY_DATATYPES,
|
|
74
|
+
GALAXY_MCP_TOOLS,
|
|
75
|
+
} from "./observation-allowlists.js";
|
|
76
|
+
import { envNames, isDesktopShell, readEnv } from "../../shared/orbit-env.js";
|
|
77
|
+
|
|
78
|
+
// -----------------------------------------------------------------------------
|
|
79
|
+
// Stage
|
|
80
|
+
// -----------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
// Ordered; first match wins. The question each row answers is "what was the
|
|
83
|
+
// session trying to do when this call failed", not "what does the tool read",
|
|
84
|
+
// which is why a failed galaxy_run_tool is tool-parameterization (the
|
|
85
|
+
// submission was refused) rather than job-execution.
|
|
86
|
+
const STAGE_RULES: ReadonlyArray<readonly [RegExp, ObservationStage]> = Object.freeze([
|
|
87
|
+
Object.freeze([/^galaxy_(?:upload|download|import)/, "data-acquisition"] as const),
|
|
88
|
+
Object.freeze([/metadata|datatype|dbkey|genome_build/, "metadata-reconciliation"] as const),
|
|
89
|
+
Object.freeze([
|
|
90
|
+
/^galaxy_(?:run_tool|run_user_tool|invoke_workflow|create_user_tool|update_user_tool|get_tool|search_tool|recommend_iwc|search_iwc|get_iwc|get_workflow_input_template)/,
|
|
91
|
+
"tool-parameterization",
|
|
92
|
+
] as const),
|
|
93
|
+
Object.freeze([/^galaxy_(?:invocation|job|get_job|cancel)/, "job-execution"] as const),
|
|
94
|
+
Object.freeze([
|
|
95
|
+
/^galaxy_(?:get_dataset|get_datasets|get_history|get_histories|list_|create_history|get_collection)/,
|
|
96
|
+
"result-interpretation",
|
|
97
|
+
] as const),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
export function stageForTool(mcpTool: string | undefined): ObservationStage {
|
|
101
|
+
if (!mcpTool) return "unknown";
|
|
102
|
+
for (const [re, stage] of STAGE_RULES) if (re.test(mcpTool)) return stage;
|
|
103
|
+
return "unknown";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -----------------------------------------------------------------------------
|
|
107
|
+
// Tool ids and datatypes
|
|
108
|
+
// -----------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
// Every structured field is admitted against a list, not a shape: a shape lets
|
|
111
|
+
// any identifier-shaped word through, and a patient code or a person's name is
|
|
112
|
+
// as datatype- or tool-id-shaped as `fastqsanger` or `Filter1`. The lists are
|
|
113
|
+
// generated from pinned Galaxy and galaxy-ops releases (see
|
|
114
|
+
// observation-allowlists.ts); a name missing from one is dropped, which costs
|
|
115
|
+
// signal, never privacy.
|
|
116
|
+
//
|
|
117
|
+
// A tool id is either a bare id that ships inside Galaxy, or a path on one of
|
|
118
|
+
// the PUBLIC toolsheds (host/repos/owner/repo/tool[/version]). The toolshed's
|
|
119
|
+
// owner, repo and tool names are public but can't be listed here, so those
|
|
120
|
+
// three keep a shape check; the version slot gets a version shape, and an id
|
|
121
|
+
// whose version fails it keeps its id and loses the version.
|
|
122
|
+
const TOOLSHED_TOOL_ID_RE =
|
|
123
|
+
/^((?:toolshed\.g2\.bx\.psu\.edu|testtoolshed\.g2\.bx\.psu\.edu)\/repos\/\w[\w.+-]*\/\w[\w.+-]*\/\w[\w.+-]*)(?:\/([^/]+))?$/;
|
|
124
|
+
// 2.2.1+galaxy1, 0.7.17, 1.1.4.post2, 3.0rc1: a release number, optionally
|
|
125
|
+
// with galaxy/rc/dev/post-style suffixes. No free-form words.
|
|
126
|
+
const TOOLSHED_VERSION_SHAPE =
|
|
127
|
+
/^\d+(?:\.\d+){0,5}(?:[+~_.-]?(?:galaxy|alpha|beta|rc|dev|post|a|b)\d*(?:\.\d+)*){0,2}$/;
|
|
128
|
+
|
|
129
|
+
function isAdmissibleToolVersion(v: string): boolean {
|
|
130
|
+
// The leak table runs over every string that is sent, so a version it would
|
|
131
|
+
// trip on (0.7.17.4 reads as an IPv4 address) is dropped here rather than
|
|
132
|
+
// taking the whole observation down with it.
|
|
133
|
+
return TOOLSHED_VERSION_SHAPE.test(v) && textLeaks(v).length === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The admitted form of a model-authored tool id, or null. Bare ids must be
|
|
138
|
+
* Galaxy's own; toolshed ids keep their version only when it is
|
|
139
|
+
* version-shaped.
|
|
140
|
+
*/
|
|
141
|
+
export function admitToolId(raw: string): string | null {
|
|
142
|
+
const v = String(raw ?? "").trim();
|
|
143
|
+
if (!v || v.length > TOOL_ID_MAX) return null;
|
|
144
|
+
if (GALAXY_BUILTIN_TOOL_IDS.has(v)) return v;
|
|
145
|
+
const m = v.match(TOOLSHED_TOOL_ID_RE);
|
|
146
|
+
if (!m) return null;
|
|
147
|
+
return m[2] !== undefined && isAdmissibleToolVersion(m[2]) ? `${m[1]}/${m[2]}` : m[1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isAdmissibleToolId(v: string): boolean {
|
|
151
|
+
return admitToolId(v) === v;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Lower-case spelling -> Galaxy's own spelling, so `BAM` from a model still
|
|
155
|
+
// lands on `bam` and an odd-cased real one (if any) keeps its case.
|
|
156
|
+
const DATATYPE_BY_LOWER = new Map([...GALAXY_DATATYPES].map((d) => [d.toLowerCase(), d]));
|
|
157
|
+
|
|
158
|
+
export function admitDatatype(raw: string): string | null {
|
|
159
|
+
const v = String(raw ?? "").trim();
|
|
160
|
+
if (DATATYPE_SENTINELS.has(v) || v.length > DATATYPE_MAX) return null;
|
|
161
|
+
if (GALAXY_DATATYPES.has(v)) return v;
|
|
162
|
+
return DATATYPE_BY_LOWER.get(v.toLowerCase()) ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isAdmissibleDatatype(v: string): boolean {
|
|
166
|
+
return !DATATYPE_SENTINELS.has(v) && GALAXY_DATATYPES.has(v);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* galaxy-ops' real operation names, in wire spelling (`galaxy_run_tool`). Takes a
|
|
171
|
+
* name already through observationToolName; a bare name is refused here.
|
|
172
|
+
*/
|
|
173
|
+
export function isAdmissibleMcpTool(v: string | undefined): v is string {
|
|
174
|
+
return typeof v === "string" && GALAXY_MCP_TOOLS.has(v);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Orbit's bridging job ops, which galaxy-ops does not name yet, so the generated list cannot hold
|
|
179
|
+
* them. The builder admits them only from an in-process call, which every Galaxy call is now.
|
|
180
|
+
*/
|
|
181
|
+
const BRIDGING_GALAXY_TOOLS: ReadonlySet<string> = new Set([
|
|
182
|
+
"galaxy_list_jobs",
|
|
183
|
+
"galaxy_get_job_logs",
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The name the collector knows a Galaxy tool call by, or undefined when the
|
|
188
|
+
* call isn't one. The Galaxy tools reach the model by their bare names
|
|
189
|
+
* (`run_tool`); the wire contract and the allowlist predate that and say
|
|
190
|
+
* `galaxy_<tool>`, and the deployed intake validates that spelling, so the
|
|
191
|
+
* rename stops here rather than reaching the payload. Orbit's own `galaxy_*`
|
|
192
|
+
* tools pass through as-is.
|
|
193
|
+
*/
|
|
194
|
+
export function observationToolName(
|
|
195
|
+
toolName: string | undefined,
|
|
196
|
+
family: GalaxyToolFamily,
|
|
197
|
+
): string | undefined {
|
|
198
|
+
const bare = family.toolName(toolName);
|
|
199
|
+
if (bare !== undefined) {
|
|
200
|
+
// The family's names come from a tool source, so hold them to the plain snake_case the
|
|
201
|
+
// allowlist and the intake expect before they reach a payload.
|
|
202
|
+
return GALAXY_TOOL_SHAPE.test(bare) ? `galaxy_${bare}` : undefined;
|
|
203
|
+
}
|
|
204
|
+
return toolName?.startsWith("galaxy_") ? toolName : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const GALAXY_TOOL_SHAPE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
|
|
208
|
+
|
|
209
|
+
const TOOL_ID_KEYS = ["tool_id", "tool_ids"] as const;
|
|
210
|
+
const DATATYPE_KEYS = ["file_type", "ext", "extension", "datatype"] as const;
|
|
211
|
+
// Galaxy's "work it out for me" sentinel is not a datatype signal.
|
|
212
|
+
const DATATYPE_SENTINELS = new Set(["auto", ""]);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Toolshed ids are `<host>/repos/<owner>/<repo>/<tool>/<version>`. Splitting
|
|
216
|
+
* the version off is what lets triage cluster a failure across versions.
|
|
217
|
+
*/
|
|
218
|
+
export function splitToolId(raw: string): { id: string; version?: string } {
|
|
219
|
+
const t = String(raw ?? "").trim();
|
|
220
|
+
if (!t) return { id: "" };
|
|
221
|
+
const m = t.match(/^(.*\/repos\/[^/]+\/[^/]+\/[^/]+)\/(.+)$/);
|
|
222
|
+
if (m) return { id: m[1], version: m[2] };
|
|
223
|
+
return { id: t };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectStrings(
|
|
227
|
+
input: Record<string, unknown> | undefined,
|
|
228
|
+
keys: readonly string[],
|
|
229
|
+
): string[] {
|
|
230
|
+
if (!input || typeof input !== "object") return [];
|
|
231
|
+
const out: string[] = [];
|
|
232
|
+
for (const key of keys) {
|
|
233
|
+
const value = (input as Record<string, unknown>)[key];
|
|
234
|
+
if (typeof value === "string") out.push(value);
|
|
235
|
+
else if (Array.isArray(value)) for (const v of value) if (typeof v === "string") out.push(v);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function dedupeCap(values: string[], max: number): string[] {
|
|
241
|
+
return [...new Set(values)].slice(0, max);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function admitted(values: string[], admit: (v: string) => string | null): string[] {
|
|
245
|
+
return values.map(admit).filter((v): v is string => v !== null);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function extractToolIds(input: Record<string, unknown> | undefined): string[] {
|
|
249
|
+
return dedupeCap(admitted(collectStrings(input, TOOL_ID_KEYS), admitToolId), TOOLS_MAX);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function extractDatatypes(input: Record<string, unknown> | undefined): string[] {
|
|
253
|
+
return dedupeCap(admitted(collectStrings(input, DATATYPE_KEYS), admitDatatype), DATATYPES_MAX);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -----------------------------------------------------------------------------
|
|
257
|
+
// Server and version
|
|
258
|
+
// -----------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Exact hostname match against the C7 allowlist, else "private". Never a
|
|
262
|
+
* suffix match: `usegalaxy.org.evil.example` and `test.usegalaxy.org` are both
|
|
263
|
+
* private, and so is every institutional mirror and every localhost install.
|
|
264
|
+
*/
|
|
265
|
+
export function resolveObservationServer(url: string | undefined): string {
|
|
266
|
+
const raw = String(url ?? "").trim();
|
|
267
|
+
if (!raw) return PRIVATE_SERVER;
|
|
268
|
+
let host: string;
|
|
269
|
+
try {
|
|
270
|
+
host = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`).hostname.toLowerCase();
|
|
271
|
+
} catch {
|
|
272
|
+
return PRIVATE_SERVER;
|
|
273
|
+
}
|
|
274
|
+
return PUBLIC_GALAXY_SERVERS.includes(host) ? host : PRIVATE_SERVER;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The server this session is actually pointed at. GALAXY_URL is the live
|
|
279
|
+
* answer -- /connect and switchProfile publish the active profile into it --
|
|
280
|
+
* but a session whose active profile carries an encrypted-only key can have a
|
|
281
|
+
* configured profile and no env URL, and reading that as "private" would
|
|
282
|
+
* mislabel a public server. The profile's URL is not a secret.
|
|
283
|
+
*/
|
|
284
|
+
export function currentGalaxyUrl(env?: NodeJS.ProcessEnv): string | undefined {
|
|
285
|
+
const { url } = galaxyCredentials(env);
|
|
286
|
+
if (url) return url;
|
|
287
|
+
const { active, profiles } = loadProfiles();
|
|
288
|
+
return active ? profiles[active]?.url : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The Galaxy version on an observation comes from galaxy-ops (galaxy-version-capture.ts),
|
|
292
|
+
// shape-checked hard (a release number, optionally devN/rcN/postN, nothing free-form that
|
|
293
|
+
// could carry a site name), so a hostile or absent value just leaves the field off. It is
|
|
294
|
+
// remembered against the server it came from, so switching servers mid-session can't label
|
|
295
|
+
// one server's failure with another's version.
|
|
296
|
+
const GALAXY_VERSION_SHAPE = /^\d{2}\.\d{1,2}(?:\.\d{1,3})?(?:\.?(?:dev|rc|post)\d{1,3})?$/;
|
|
297
|
+
let galaxyVersion: { version: string; url: string | undefined } | undefined;
|
|
298
|
+
|
|
299
|
+
/** A version already assembled from /api/version, kept only if it has the release shape. */
|
|
300
|
+
export function recordGalaxyVersion(version: string | undefined): void {
|
|
301
|
+
if (version && GALAXY_VERSION_SHAPE.test(version)) {
|
|
302
|
+
galaxyVersion = { version, url: currentGalaxyUrl() };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function getGalaxyVersion(): string | undefined {
|
|
307
|
+
if (!galaxyVersion || galaxyVersion.url !== currentGalaxyUrl()) return undefined;
|
|
308
|
+
return galaxyVersion.version;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function resetGalaxyVersion(): void {
|
|
312
|
+
galaxyVersion = undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// -----------------------------------------------------------------------------
|
|
316
|
+
// Build
|
|
317
|
+
// -----------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
export interface ObservationFacts {
|
|
320
|
+
kind: ObservationKind;
|
|
321
|
+
trigger: ObservationTrigger;
|
|
322
|
+
/** Overrides the tool-name table when the trigger knows better. */
|
|
323
|
+
stage?: ObservationStage;
|
|
324
|
+
mcpTool?: string;
|
|
325
|
+
/** The call ran through in-process Galaxy tools, so Orbit's bridging ops are admitted too. */
|
|
326
|
+
inProcess?: true;
|
|
327
|
+
toolIds: string[];
|
|
328
|
+
datatypes: string[];
|
|
329
|
+
/** Raw error/outcome text; normalized here, never stored raw. */
|
|
330
|
+
rawSignature: string;
|
|
331
|
+
description?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface ObservationEnvelope {
|
|
335
|
+
id: string;
|
|
336
|
+
clientTs: string;
|
|
337
|
+
app: ObservationApp;
|
|
338
|
+
version: string;
|
|
339
|
+
platform: ObservationPlatform;
|
|
340
|
+
wsl?: boolean;
|
|
341
|
+
installToken: string;
|
|
342
|
+
server: string;
|
|
343
|
+
galaxyVersion?: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const PLATFORMS = new Set<ObservationPlatform>(["darwin", "linux", "win32"]);
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* What a payload may carry. `full` is the `ask` shape: the normalized signature
|
|
350
|
+
* and the description, which a human reads in the confirm before anything
|
|
351
|
+
* goes. `structured` is the `auto` shape and carries no free text at all --
|
|
352
|
+
* with nobody reading it first, no pattern table is trusted to have caught
|
|
353
|
+
* every name or data value an error message can quote.
|
|
354
|
+
*/
|
|
355
|
+
export type ObservationShape = "full" | "structured";
|
|
356
|
+
|
|
357
|
+
export function shapeForMode(mode: "ask" | "auto"): ObservationShape {
|
|
358
|
+
return mode === "auto" ? "structured" : "full";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function buildObservation(
|
|
362
|
+
facts: ObservationFacts,
|
|
363
|
+
env: ObservationEnvelope,
|
|
364
|
+
shape: ObservationShape,
|
|
365
|
+
): Observation {
|
|
366
|
+
const freeText = shape === "full";
|
|
367
|
+
const mcpTool =
|
|
368
|
+
isAdmissibleMcpTool(facts.mcpTool) ||
|
|
369
|
+
(facts.inProcess === true &&
|
|
370
|
+
facts.mcpTool !== undefined &&
|
|
371
|
+
BRIDGING_GALAXY_TOOLS.has(facts.mcpTool))
|
|
372
|
+
? facts.mcpTool
|
|
373
|
+
: undefined;
|
|
374
|
+
const candidate = {
|
|
375
|
+
schemaVersion: OBSERVATION_SCHEMA_VERSION,
|
|
376
|
+
id: env.id,
|
|
377
|
+
clientTs: env.clientTs,
|
|
378
|
+
client: {
|
|
379
|
+
app: env.app,
|
|
380
|
+
version: env.version,
|
|
381
|
+
platform: env.platform,
|
|
382
|
+
...(env.wsl === true ? { wsl: true } : {}),
|
|
383
|
+
},
|
|
384
|
+
installToken: env.installToken,
|
|
385
|
+
kind: facts.kind,
|
|
386
|
+
stage: facts.stage ?? stageForTool(mcpTool),
|
|
387
|
+
trigger: facts.trigger,
|
|
388
|
+
// Re-admitted here as well as in the extractors: facts can arrive from a
|
|
389
|
+
// caller that never went through them.
|
|
390
|
+
tools: admitted(facts.toolIds, admitToolId).map(splitToolId),
|
|
391
|
+
...(mcpTool ? { mcpTool } : {}),
|
|
392
|
+
datatypes: admitted(facts.datatypes, admitDatatype),
|
|
393
|
+
signature: freeText ? normalizeSignature(facts.rawSignature) : UNKNOWN_SIGNATURE,
|
|
394
|
+
galaxy: {
|
|
395
|
+
server: env.server,
|
|
396
|
+
...(env.galaxyVersion ? { version: env.galaxyVersion } : {}),
|
|
397
|
+
},
|
|
398
|
+
description: freeText ? (facts.description ?? "") : "",
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const capped = capObservation(candidate);
|
|
402
|
+
// A description that can't pass is dropped whole. Trimming it would be
|
|
403
|
+
// guessing at which half was the leak, and an empty description still leaves
|
|
404
|
+
// a usable structured observation. Scanned uncapped as well as capped: the
|
|
405
|
+
// cap can cut a hostname down to something no rule recognises.
|
|
406
|
+
if (capped.description) {
|
|
407
|
+
const probe = validateObservation({ ...capped, description: "" });
|
|
408
|
+
const full = validateObservation(capped);
|
|
409
|
+
const leaky =
|
|
410
|
+
textLeaks(capped.description).length > 0 || textLeaks(candidate.description).length > 0;
|
|
411
|
+
if ((!full.ok || leaky) && probe.ok) return { ...capped, description: "" };
|
|
412
|
+
}
|
|
413
|
+
return capped;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface ObservationProblems {
|
|
417
|
+
/** Wire-validator errors, `field:reason`. */
|
|
418
|
+
errors: string[];
|
|
419
|
+
/** Leak-table hits, `field:pattern`. Names only, never a value. */
|
|
420
|
+
leaks: string[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The builder's admission rules, re-applied to a finished observation. The
|
|
425
|
+
* builder only ever produces admissible fields, so on its output this is
|
|
426
|
+
* empty; it is here for the payloads that did not come straight from the
|
|
427
|
+
* builder -- an outbox row read back from disk is local, editable, and
|
|
428
|
+
* re-sent without a fresh confirm.
|
|
429
|
+
*/
|
|
430
|
+
function admissionProblems(obs: Observation): string[] {
|
|
431
|
+
const out: string[] = [];
|
|
432
|
+
const tools: unknown[] = Array.isArray(obs.tools) ? obs.tools : [];
|
|
433
|
+
tools.forEach((t, i) => {
|
|
434
|
+
const ref = (t ?? {}) as { id?: unknown; version?: unknown };
|
|
435
|
+
const joined =
|
|
436
|
+
typeof ref.id !== "string"
|
|
437
|
+
? undefined
|
|
438
|
+
: ref.version === undefined
|
|
439
|
+
? ref.id
|
|
440
|
+
: typeof ref.version === "string"
|
|
441
|
+
? `${ref.id}/${ref.version}`
|
|
442
|
+
: undefined;
|
|
443
|
+
if (joined === undefined || !isAdmissibleToolId(joined)) out.push(`tools[${i}]:not-allowed`);
|
|
444
|
+
});
|
|
445
|
+
// A stored row no longer says how its call ran; every Galaxy call runs in-process, so a
|
|
446
|
+
// bridging name here is one the builder admitted.
|
|
447
|
+
if (
|
|
448
|
+
obs.mcpTool !== undefined &&
|
|
449
|
+
!isAdmissibleMcpTool(obs.mcpTool) &&
|
|
450
|
+
!BRIDGING_GALAXY_TOOLS.has(obs.mcpTool)
|
|
451
|
+
) {
|
|
452
|
+
out.push("mcpTool:not-allowed");
|
|
453
|
+
}
|
|
454
|
+
const datatypes: unknown[] = Array.isArray(obs.datatypes) ? obs.datatypes : [];
|
|
455
|
+
datatypes.forEach((d, i) => {
|
|
456
|
+
if (typeof d !== "string" || !isAdmissibleDatatype(d)) out.push(`datatypes[${i}]:not-allowed`);
|
|
457
|
+
});
|
|
458
|
+
const version = obs.galaxy?.version;
|
|
459
|
+
if (
|
|
460
|
+
version !== undefined &&
|
|
461
|
+
!(typeof version === "string" && GALAXY_VERSION_SHAPE.test(version))
|
|
462
|
+
) {
|
|
463
|
+
out.push("galaxy.version:not-allowed");
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Everything that stops a built observation from going as built. `rawSignature`
|
|
470
|
+
* is the text the signature was built from, passed only for the `full` shape:
|
|
471
|
+
* the staged scan (signatureStageLeaks) runs over it, reported as
|
|
472
|
+
* `signature.staged:<pattern>`, because the normalizer and the cap can each
|
|
473
|
+
* erase a shape the final scan would have caught. buildCheckedObservation
|
|
474
|
+
* turns any signature problem into a withheld signature rather than a refusal.
|
|
475
|
+
*/
|
|
476
|
+
export function observationProblems(
|
|
477
|
+
obs: Observation,
|
|
478
|
+
raw: { rawSignature?: string } = {},
|
|
479
|
+
): ObservationProblems {
|
|
480
|
+
const validity = validateObservation(obs);
|
|
481
|
+
const errors = [...(validity.ok ? [] : validity.errors), ...admissionProblems(obs)];
|
|
482
|
+
const leaks = scanObservationForLeaks(obs);
|
|
483
|
+
if (raw.rawSignature !== undefined) {
|
|
484
|
+
for (const name of signatureStageLeaks(raw.rawSignature)) {
|
|
485
|
+
leaks.push(`signature.staged:${name}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { errors, leaks };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export interface CheckedObservation extends ObservationProblems {
|
|
492
|
+
obs: Observation;
|
|
493
|
+
/**
|
|
494
|
+
* Pattern names that made the signature unsendable, when it was withheld
|
|
495
|
+
* and the rest kept. Empty when nothing was withheld.
|
|
496
|
+
*/
|
|
497
|
+
withheld: string[];
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const isSignatureProblem = (p: string): boolean => /^signature[.:]/.test(p);
|
|
501
|
+
const patternOf = (p: string): string => p.slice(p.lastIndexOf(":") + 1);
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Build and check in one step. In the `full` (ask) shape a signature that
|
|
505
|
+
* trips the staged scan, or the validator, is WITHHELD rather than refusing
|
|
506
|
+
* the observation: it becomes `unknown` and everything else -- the structured
|
|
507
|
+
* fields and a description that passed on its own -- is checked again and
|
|
508
|
+
* kept, so the user still decides on what is left. Anything else that fails
|
|
509
|
+
* still fails.
|
|
510
|
+
*/
|
|
511
|
+
export function buildCheckedObservation(
|
|
512
|
+
facts: ObservationFacts,
|
|
513
|
+
env: ObservationEnvelope,
|
|
514
|
+
shape: ObservationShape,
|
|
515
|
+
): CheckedObservation {
|
|
516
|
+
const obs = buildObservation(facts, env, shape);
|
|
517
|
+
if (shape !== "full") return { obs, ...observationProblems(obs), withheld: [] };
|
|
518
|
+
const first = observationProblems(obs, { rawSignature: facts.rawSignature });
|
|
519
|
+
const signatureProblems = [...first.errors, ...first.leaks].filter(isSignatureProblem);
|
|
520
|
+
if (signatureProblems.length === 0) return { obs, ...first, withheld: [] };
|
|
521
|
+
const kept = { ...obs, signature: UNKNOWN_SIGNATURE };
|
|
522
|
+
return {
|
|
523
|
+
obs: kept,
|
|
524
|
+
...observationProblems(kept),
|
|
525
|
+
withheld: [...new Set(signatureProblems.map(patternOf))],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const WITHHELD_PHRASES: Record<string, string> = {
|
|
530
|
+
url: "a URL",
|
|
531
|
+
"scheme-url": "a URL",
|
|
532
|
+
email: "an address",
|
|
533
|
+
"user-at-host": "an address",
|
|
534
|
+
hostname: "a host name",
|
|
535
|
+
"host-port": "a host name",
|
|
536
|
+
ipv4: "a network address",
|
|
537
|
+
ipv6: "a network address",
|
|
538
|
+
"home-path": "a path",
|
|
539
|
+
"windows-path": "a path",
|
|
540
|
+
"tilde-path": "a path",
|
|
541
|
+
"tilde-user": "a path",
|
|
542
|
+
"path-separator": "a path",
|
|
543
|
+
"long-hex": "an id",
|
|
544
|
+
uuid: "an id",
|
|
545
|
+
"galaxy-id-phrase": "an id",
|
|
546
|
+
"id-phrase": "an id",
|
|
547
|
+
"non-ascii": "non-ASCII text",
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
/** The one-line reason the confirm shows when the error text was withheld. */
|
|
551
|
+
export function withheldReason(names: string[]): string {
|
|
552
|
+
const phrases = [...new Set(names.map((n) => WITHHELD_PHRASES[n] ?? "something identifying"))];
|
|
553
|
+
const list =
|
|
554
|
+
phrases.length <= 1
|
|
555
|
+
? (phrases[0] ?? "something identifying")
|
|
556
|
+
: `${phrases.slice(0, -1).join(", ")} and ${phrases[phrases.length - 1]}`;
|
|
557
|
+
return `error text withheld: it contained ${list}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The impure half: who and where this install is. No secrets, no hostname. */
|
|
561
|
+
export function collectObservationEnvelope(installToken: string): ObservationEnvelope {
|
|
562
|
+
const platform = process.platform as ObservationPlatform;
|
|
563
|
+
return {
|
|
564
|
+
id: crypto.randomUUID(),
|
|
565
|
+
clientTs: new Date().toISOString(),
|
|
566
|
+
app: isDesktopShell() ? "orbit" : "loom-cli",
|
|
567
|
+
version: readOrbitVersion() ?? "0.0.0",
|
|
568
|
+
platform: PLATFORMS.has(platform) ? platform : "linux",
|
|
569
|
+
...(isWsl({ platform: process.platform, env: process.env, release: release() })
|
|
570
|
+
? { wsl: true }
|
|
571
|
+
: {}),
|
|
572
|
+
installToken,
|
|
573
|
+
server: resolveObservationServer(currentGalaxyUrl()),
|
|
574
|
+
...(getGalaxyVersion() ? { galaxyVersion: getGalaxyVersion() } : {}),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// -----------------------------------------------------------------------------
|
|
579
|
+
// Transport
|
|
580
|
+
// -----------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Whether `value` may replace the intake base URL: plain http only to this
|
|
584
|
+
* machine (`http://localhost[:port]`, `http://127.0.0.1[:port]`), otherwise
|
|
585
|
+
* https. Every request carries the shared feedback key and the payload, so an
|
|
586
|
+
* override pointing anywhere else would hand both to whoever is listening --
|
|
587
|
+
* and an env var is easy to inherit without noticing.
|
|
588
|
+
*/
|
|
589
|
+
export function isAllowedObservationsOverride(value: string): boolean {
|
|
590
|
+
let url: URL;
|
|
591
|
+
try {
|
|
592
|
+
url = new URL(value);
|
|
593
|
+
} catch {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
if (url.username || url.password) return false;
|
|
597
|
+
if (url.protocol === "https:") return url.hostname !== "";
|
|
598
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let warnedOverride = false;
|
|
602
|
+
|
|
603
|
+
// ORBIT_OBSERVATIONS_URL points this at `wrangler dev` for local work, the same
|
|
604
|
+
// way LOOM_FEEDBACK_URL does for /feedback -- but only in the forms
|
|
605
|
+
// isAllowedObservationsOverride accepts. Anything else is ignored with a
|
|
606
|
+
// warning that names the variable and never its value, which may embed a
|
|
607
|
+
// token.
|
|
608
|
+
function endpointBase(): string {
|
|
609
|
+
const override = readEnv("OBSERVATIONS_URL")?.trim();
|
|
610
|
+
if (!override) return OBSERVATIONS_ENDPOINT_URL;
|
|
611
|
+
if (isAllowedObservationsOverride(override)) {
|
|
612
|
+
// What was checked is what gets fetched: the parsed form, not the raw text.
|
|
613
|
+
const url = new URL(override);
|
|
614
|
+
return url.origin + url.pathname.replace(/\/+$/, "");
|
|
615
|
+
}
|
|
616
|
+
if (!warnedOverride) {
|
|
617
|
+
warnedOverride = true;
|
|
618
|
+
const name =
|
|
619
|
+
envNames("OBSERVATIONS_URL").find((n) => process.env[n] !== undefined) ??
|
|
620
|
+
"ORBIT_OBSERVATIONS_URL";
|
|
621
|
+
console.warn(
|
|
622
|
+
`${name} is ignored: it must be http://localhost, http://127.0.0.1 or an https:// URL.`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return OBSERVATIONS_ENDPOINT_URL;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const TIMEOUT_MS = 10_000;
|
|
629
|
+
|
|
630
|
+
export const OUTBOX_FILE = "observations-outbox.jsonl";
|
|
631
|
+
export const SENT_LOG_FILE = "observations-sent.jsonl";
|
|
632
|
+
export const TOKEN_STORE_FILE = "observations-tokens.json";
|
|
633
|
+
|
|
634
|
+
export function observationsFilePath(name: string): string {
|
|
635
|
+
return join(getConfigDir(), name);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export interface SubmitObservationResult {
|
|
639
|
+
ok: boolean;
|
|
640
|
+
status?: number;
|
|
641
|
+
id?: string;
|
|
642
|
+
retractToken?: string;
|
|
643
|
+
error?: string;
|
|
644
|
+
/** Field names only -- the route never echoes a rejected value. */
|
|
645
|
+
errors?: string[];
|
|
646
|
+
/**
|
|
647
|
+
* Whether the outbox should keep this for later. A transport failure, a 429
|
|
648
|
+
* and a 503 (the route exists but isn't configured yet) are all "try again";
|
|
649
|
+
* a 400 or a 401 would fail identically forever, so queuing them would just
|
|
650
|
+
* grow a file nobody can drain.
|
|
651
|
+
*/
|
|
652
|
+
queueable: boolean;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The intake route answers 409 when the id already exists: a resend after a
|
|
656
|
+
// lost 202, which can never get past the primary key. The caller records that
|
|
657
|
+
// as sent without a token. A 500 is still given one bounded retry in case it was
|
|
658
|
+
// a blip, and then counts as permanent -- queuing it would loop the outbox on a
|
|
659
|
+
// row that never will be accepted.
|
|
660
|
+
const ATTEMPTS_ON_500 = 2;
|
|
661
|
+
|
|
662
|
+
function queueableStatus(status: number): boolean {
|
|
663
|
+
return status === 429 || (status > 500 && status < 600);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function submitObservation(obs: Observation): Promise<SubmitObservationResult> {
|
|
667
|
+
if (observationByteLength(obs) > OBSERVATION_MAX_BYTES) {
|
|
668
|
+
return { ok: false, error: "observation exceeds the intake size cap", queueable: false };
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
672
|
+
const key = readEnv("FEEDBACK_KEY");
|
|
673
|
+
if (key) headers[OBSERVATION_KEY_HEADER] = key;
|
|
674
|
+
let res: Response | undefined;
|
|
675
|
+
for (let attempt = 1; attempt <= ATTEMPTS_ON_500; attempt++) {
|
|
676
|
+
res = await fetch(endpointBase() + OBSERVATIONS_ROUTE, {
|
|
677
|
+
method: "POST",
|
|
678
|
+
headers,
|
|
679
|
+
body: observationRequestBody(obs),
|
|
680
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
681
|
+
// A redirect would replay the body and the shared key to wherever it
|
|
682
|
+
// points. The route never redirects, so one is a refusal, not a hop.
|
|
683
|
+
redirect: "manual",
|
|
684
|
+
});
|
|
685
|
+
if (res.status !== 500) break;
|
|
686
|
+
if (attempt < ATTEMPTS_ON_500) await res.body?.cancel().catch(() => {});
|
|
687
|
+
}
|
|
688
|
+
if (!res) throw new Error("no response");
|
|
689
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
690
|
+
ok?: boolean;
|
|
691
|
+
id?: string;
|
|
692
|
+
retractToken?: string;
|
|
693
|
+
error?: string;
|
|
694
|
+
errors?: string[];
|
|
695
|
+
};
|
|
696
|
+
return {
|
|
697
|
+
ok: res.ok,
|
|
698
|
+
status: res.status,
|
|
699
|
+
id: data.id,
|
|
700
|
+
retractToken: data.retractToken,
|
|
701
|
+
error: data.error,
|
|
702
|
+
errors: data.errors,
|
|
703
|
+
queueable: res.ok ? false : queueableStatus(res.status),
|
|
704
|
+
};
|
|
705
|
+
} catch (err) {
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
error: err instanceof Error ? err.message : String(err),
|
|
709
|
+
queueable: true,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export interface RetractResult {
|
|
715
|
+
ok: boolean;
|
|
716
|
+
status?: number;
|
|
717
|
+
/** A 404 means the row is not there, which is the outcome the user wanted. */
|
|
718
|
+
alreadyGone: boolean;
|
|
719
|
+
error?: string;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export async function retractObservation(id: string, retractToken: string): Promise<RetractResult> {
|
|
723
|
+
try {
|
|
724
|
+
const headers: Record<string, string> = { [RETRACT_TOKEN_HEADER]: retractToken };
|
|
725
|
+
const key = readEnv("FEEDBACK_KEY");
|
|
726
|
+
if (key) headers[OBSERVATION_KEY_HEADER] = key;
|
|
727
|
+
const res = await fetch(`${endpointBase()}${OBSERVATIONS_ROUTE}/${encodeURIComponent(id)}`, {
|
|
728
|
+
method: "DELETE",
|
|
729
|
+
headers,
|
|
730
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
731
|
+
redirect: "manual",
|
|
732
|
+
});
|
|
733
|
+
await res.json().catch(() => ({}));
|
|
734
|
+
// Idempotent by contract: a repeat retract is a 404, and reporting that as
|
|
735
|
+
// a failure would push the user into retrying something already done.
|
|
736
|
+
if (res.status === 404) return { ok: true, status: 404, alreadyGone: true };
|
|
737
|
+
return { ok: res.ok, status: res.status, alreadyGone: false };
|
|
738
|
+
} catch (err) {
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
alreadyGone: false,
|
|
742
|
+
error: err instanceof Error ? err.message : String(err),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// -----------------------------------------------------------------------------
|
|
748
|
+
// Local logs
|
|
749
|
+
// -----------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
function appendLine(name: string, value: unknown, mode?: number): string | null {
|
|
752
|
+
try {
|
|
753
|
+
const dir = getConfigDir();
|
|
754
|
+
mkdirSync(dir, { recursive: true });
|
|
755
|
+
const file = join(dir, name);
|
|
756
|
+
appendFileSync(file, JSON.stringify(value) + "\n", { encoding: "utf-8", mode: mode ?? 0o644 });
|
|
757
|
+
if (mode !== undefined) {
|
|
758
|
+
try {
|
|
759
|
+
chmodSync(file, mode);
|
|
760
|
+
} catch {
|
|
761
|
+
/* perm-tightening is best-effort */
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return file;
|
|
765
|
+
} catch {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Two processes can share one ~/.loom -- Orbit and a CLI session, or two CLI
|
|
771
|
+
// sessions -- so the outbox is guarded by lock files, not just by the fact that
|
|
772
|
+
// one process's sync code can't interleave with itself.
|
|
773
|
+
//
|
|
774
|
+
// The FILE lock is short and synchronous: it covers one append, one read, or
|
|
775
|
+
// one rewrite. The DRAIN lock is long: it is held for a whole drain, across the
|
|
776
|
+
// POSTs, so two drains can never both read a row and both send it, and a
|
|
777
|
+
// cancel can never remove a row that a drain is sending right now.
|
|
778
|
+
const FILE_LOCK_WAIT_MS = 2000;
|
|
779
|
+
const FILE_LOCK_STALE_MS = 30_000;
|
|
780
|
+
// Longer than the slowest possible drain: OUTBOX_DRAIN_MAX rows, two attempts
|
|
781
|
+
// each, each bounded by TIMEOUT_MS.
|
|
782
|
+
const DRAIN_LOCK_STALE_MS = 15 * 60_000;
|
|
783
|
+
|
|
784
|
+
function sleepSync(ms: number): void {
|
|
785
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// The lock is written in full under a private name and then hard-linked into
|
|
789
|
+
// place, so it never exists empty: a creator that wrote the pid after an
|
|
790
|
+
// exclusive open left a moment where a reader saw "" and judged it stale.
|
|
791
|
+
function tryCreateLock(lockPath: string): boolean {
|
|
792
|
+
const tmp = `${lockPath}.${process.pid}.new`;
|
|
793
|
+
try {
|
|
794
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
795
|
+
try {
|
|
796
|
+
writeSync(fd, String(process.pid));
|
|
797
|
+
} finally {
|
|
798
|
+
closeSync(fd);
|
|
799
|
+
}
|
|
800
|
+
linkSync(tmp, lockPath);
|
|
801
|
+
return true;
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
804
|
+
throw err;
|
|
805
|
+
} finally {
|
|
806
|
+
rmSync(tmp, { force: true });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Remove a lock judged stale, but only if it is still the one that was judged.
|
|
812
|
+
* It is renamed aside first (only one process can win that), and if what was
|
|
813
|
+
* renamed turns out to be someone's fresh lock it is linked back.
|
|
814
|
+
*/
|
|
815
|
+
function removeStaleLock(lockPath: string, judged: string): void {
|
|
816
|
+
const claim = `${lockPath}.${process.pid}.claim`;
|
|
817
|
+
try {
|
|
818
|
+
renameSync(lockPath, claim);
|
|
819
|
+
} catch {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
let now = "";
|
|
823
|
+
try {
|
|
824
|
+
now = readFileSync(claim, "utf-8");
|
|
825
|
+
} catch {
|
|
826
|
+
/* treat as changed */
|
|
827
|
+
}
|
|
828
|
+
if (now !== judged) {
|
|
829
|
+
try {
|
|
830
|
+
linkSync(claim, lockPath);
|
|
831
|
+
} catch {
|
|
832
|
+
/* a new lock already exists; leave it */
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
rmSync(claim, { force: true });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function lockAgeMs(lockPath: string): number {
|
|
839
|
+
try {
|
|
840
|
+
return Date.now() - statSync(lockPath).mtimeMs;
|
|
841
|
+
} catch {
|
|
842
|
+
return 0;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Run `fn` holding `lockPath`, or return undefined if it can't be had in time. */
|
|
847
|
+
function withFileLock<T>(lockPath: string, fn: () => T): { value: T } | undefined {
|
|
848
|
+
const deadline = Date.now() + FILE_LOCK_WAIT_MS;
|
|
849
|
+
try {
|
|
850
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
851
|
+
while (!tryCreateLock(lockPath)) {
|
|
852
|
+
// A holder that died mid-section leaves its lock behind. The sections
|
|
853
|
+
// are milliseconds long, so one this old is not anyone's.
|
|
854
|
+
if (lockAgeMs(lockPath) > FILE_LOCK_STALE_MS) {
|
|
855
|
+
let judged = "";
|
|
856
|
+
try {
|
|
857
|
+
judged = readFileSync(lockPath, "utf-8");
|
|
858
|
+
} catch {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
removeStaleLock(lockPath, judged);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (Date.now() > deadline) return undefined;
|
|
865
|
+
sleepSync(20);
|
|
866
|
+
}
|
|
867
|
+
} catch {
|
|
868
|
+
return undefined;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
return { value: fn() };
|
|
872
|
+
} finally {
|
|
873
|
+
rmSync(lockPath, { force: true });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Write via a per-process temp file and rename, so no reader sees half a file. */
|
|
878
|
+
function writeFileAtomic(file: string, contents: string): void {
|
|
879
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
880
|
+
try {
|
|
881
|
+
writeFileSync(tmp, contents, { mode: 0o600 });
|
|
882
|
+
renameSync(tmp, file);
|
|
883
|
+
} catch (err) {
|
|
884
|
+
rmSync(tmp, { force: true });
|
|
885
|
+
throw err;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function outboxPath(): string {
|
|
890
|
+
return observationsFilePath(OUTBOX_FILE);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function outboxLockPath(): string {
|
|
894
|
+
return `${outboxPath()}.lock`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function readOutboxLines(): string[] {
|
|
898
|
+
const file = outboxPath();
|
|
899
|
+
if (!existsSync(file)) return [];
|
|
900
|
+
return readFileSync(file, "utf-8")
|
|
901
|
+
.split("\n")
|
|
902
|
+
.filter((l) => l.trim());
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function outboxRowId(line: string): string | undefined {
|
|
906
|
+
try {
|
|
907
|
+
const row = JSON.parse(line) as { observation?: { id?: unknown } } | null;
|
|
908
|
+
const id = row?.observation?.id;
|
|
909
|
+
return typeof id === "string" ? id : undefined;
|
|
910
|
+
} catch {
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Rewrite the outbox without the rows `drop` picks out. Caller holds the file lock. */
|
|
916
|
+
function rewriteOutboxWithout(drop: (line: string) => boolean): number {
|
|
917
|
+
const remaining = readOutboxLines().filter((l) => !drop(l));
|
|
918
|
+
writeFileAtomic(outboxPath(), remaining.map((l) => l + "\n").join(""));
|
|
919
|
+
return remaining.length;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let drainingHere = false;
|
|
923
|
+
|
|
924
|
+
function pidAlive(pid: number): boolean {
|
|
925
|
+
try {
|
|
926
|
+
process.kill(pid, 0);
|
|
927
|
+
return true;
|
|
928
|
+
} catch (err) {
|
|
929
|
+
// EPERM: it exists, it just isn't ours to signal.
|
|
930
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function acquireDrainLock(): boolean {
|
|
935
|
+
if (drainingHere) return false;
|
|
936
|
+
const lockPath = `${outboxPath()}.drain`;
|
|
937
|
+
try {
|
|
938
|
+
mkdirSync(getConfigDir(), { recursive: true });
|
|
939
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
940
|
+
if (tryCreateLock(lockPath)) {
|
|
941
|
+
drainingHere = true;
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
let content: string | undefined;
|
|
945
|
+
try {
|
|
946
|
+
content = readFileSync(lockPath, "utf-8");
|
|
947
|
+
} catch {
|
|
948
|
+
continue; // vanished between the two calls; try again
|
|
949
|
+
}
|
|
950
|
+
const holder = Number.parseInt(content, 10);
|
|
951
|
+
const age = lockAgeMs(lockPath);
|
|
952
|
+
// An unreadable pid is only abandoned once it is clearly not mid-write.
|
|
953
|
+
const stale =
|
|
954
|
+
Number.isInteger(holder) && holder > 0
|
|
955
|
+
? !pidAlive(holder) || age > DRAIN_LOCK_STALE_MS
|
|
956
|
+
: age > FILE_LOCK_STALE_MS;
|
|
957
|
+
if (!stale) return false;
|
|
958
|
+
removeStaleLock(lockPath, content);
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function releaseDrainLock(): void {
|
|
967
|
+
drainingHere = false;
|
|
968
|
+
rmSync(`${outboxPath()}.drain`, { force: true });
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* How a queued observation was agreed to. `ask` is an explicit yes to exactly
|
|
973
|
+
* these bytes; `auto` is the standing consent that only holds while `auto` is
|
|
974
|
+
* in effect.
|
|
975
|
+
*/
|
|
976
|
+
export type ObservationConsentMode = "ask" | "auto";
|
|
977
|
+
|
|
978
|
+
export interface ObservationConsent {
|
|
979
|
+
mode: ObservationConsentMode;
|
|
980
|
+
/**
|
|
981
|
+
* SHA-256 over the consent mode and the exact request body that was
|
|
982
|
+
* consented to (consentDigest), so neither can be edited on its own.
|
|
983
|
+
*/
|
|
984
|
+
sha256: string;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* One outbox line. The consent record sits beside the observation, never in
|
|
989
|
+
* it, so the Worker only ever sees the observation.
|
|
990
|
+
*/
|
|
991
|
+
interface OutboxRow {
|
|
992
|
+
consent: ObservationConsent;
|
|
993
|
+
observation: Observation;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** The bytes submitObservation POSTs for `obs`. */
|
|
997
|
+
export function observationRequestBody(obs: Observation): string {
|
|
998
|
+
return JSON.stringify(obs);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Taken at the moment of consent -- the ask confirm, or the auto build -- and
|
|
1003
|
+
* checked again right before a queued row goes. The outbox is a local file
|
|
1004
|
+
* anyone with the account can edit, and a pattern revalidation only proves an
|
|
1005
|
+
* edit still looks harmless, not that it is what the user agreed to.
|
|
1006
|
+
*/
|
|
1007
|
+
export function consentFor(obs: Observation, mode: ObservationConsentMode): ObservationConsent {
|
|
1008
|
+
return { mode, sha256: consentDigest(mode, observationRequestBody(obs)) };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// The mode is inside the digest: otherwise flipping "auto" to "ask" in the
|
|
1012
|
+
// file would let an auto row drain in ask without touching the body.
|
|
1013
|
+
export function consentDigest(mode: ObservationConsentMode, body: string): string {
|
|
1014
|
+
return createHash("sha256").update(`${mode}\n${body}`, "utf-8").digest("hex");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Durability backstop: a POST that could succeed later is kept for the next
|
|
1019
|
+
* drain, with the consent it was given. Returns the file path, or null when it
|
|
1020
|
+
* could not be written -- the caller has to say so, because the user was about
|
|
1021
|
+
* to be told it was saved.
|
|
1022
|
+
*/
|
|
1023
|
+
export function appendToObservationOutbox(
|
|
1024
|
+
obs: Observation,
|
|
1025
|
+
consent: ObservationConsent,
|
|
1026
|
+
): string | null {
|
|
1027
|
+
const row: OutboxRow = { consent, observation: obs };
|
|
1028
|
+
try {
|
|
1029
|
+
const done = withFileLock(outboxLockPath(), () => {
|
|
1030
|
+
const file = outboxPath();
|
|
1031
|
+
// 0600: this file carries the install token, which is the thing that
|
|
1032
|
+
// ties rows together. Nothing else in it is sensitive, but that is enough.
|
|
1033
|
+
appendFileSync(file, JSON.stringify(row) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
1034
|
+
try {
|
|
1035
|
+
chmodSync(file, 0o600);
|
|
1036
|
+
} catch {
|
|
1037
|
+
/* perm-tightening is best-effort */
|
|
1038
|
+
}
|
|
1039
|
+
return file;
|
|
1040
|
+
});
|
|
1041
|
+
return done?.value ?? null;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export type OutboxRemoval = "removed" | "absent" | "busy" | "failed";
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Take a queued observation out before it is ever sent. Refused while a drain
|
|
1051
|
+
* is running, since that drain may already be sending this very row.
|
|
1052
|
+
*/
|
|
1053
|
+
export function removeFromObservationOutbox(id: string): OutboxRemoval {
|
|
1054
|
+
if (!acquireDrainLock()) return "busy";
|
|
1055
|
+
try {
|
|
1056
|
+
const done = withFileLock(outboxLockPath(), () => {
|
|
1057
|
+
if (!readOutboxLines().some((l) => outboxRowId(l) === id)) return "absent" as const;
|
|
1058
|
+
rewriteOutboxWithout((l) => outboxRowId(l) === id);
|
|
1059
|
+
return "removed" as const;
|
|
1060
|
+
});
|
|
1061
|
+
return done?.value ?? "failed";
|
|
1062
|
+
} catch {
|
|
1063
|
+
return "failed";
|
|
1064
|
+
} finally {
|
|
1065
|
+
releaseDrainLock();
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export const OUTBOX_DRAIN_MAX = 10;
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Why a queued row was dropped instead of sent. Reasons only: a dropped row's
|
|
1073
|
+
* content is exactly what may have been tampered with, so it is never logged.
|
|
1074
|
+
*/
|
|
1075
|
+
export type OutboxDropReason =
|
|
1076
|
+
/** Not JSON, or not a consent-carrying row. */
|
|
1077
|
+
| "unreadable"
|
|
1078
|
+
/** The observation no longer hashes to what was consented to. */
|
|
1079
|
+
| "changed-since-consent"
|
|
1080
|
+
/** Queued under auto, and auto is no longer in effect. */
|
|
1081
|
+
| "consent-lapsed"
|
|
1082
|
+
/** Fails the builder's own admission rules. */
|
|
1083
|
+
| "refused-locally"
|
|
1084
|
+
/** The route refused it for good. */
|
|
1085
|
+
| "refused-by-service";
|
|
1086
|
+
|
|
1087
|
+
export interface OutboxDrainCounts {
|
|
1088
|
+
sent: number;
|
|
1089
|
+
kept: number;
|
|
1090
|
+
dropped: number;
|
|
1091
|
+
/** Per-reason breakdown of `dropped`, present when anything was dropped. */
|
|
1092
|
+
dropReasons?: Partial<Record<OutboxDropReason, number>>;
|
|
1093
|
+
/** Present when a row went but its retract token could not be saved. */
|
|
1094
|
+
unretractable?: number;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Whether consent given under `consented` still covers sending now, under
|
|
1099
|
+
* `current`. An explicit yes holds in ask and auto; auto's standing consent
|
|
1100
|
+
* holds only while auto is in effect; off sends nothing.
|
|
1101
|
+
*/
|
|
1102
|
+
export function consentStillHolds(
|
|
1103
|
+
consented: ObservationConsentMode,
|
|
1104
|
+
current: ObservationsMode,
|
|
1105
|
+
): boolean {
|
|
1106
|
+
if (current === "off") return false;
|
|
1107
|
+
return consented === "ask" || current === "auto";
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function parseOutboxRow(line: string): OutboxRow | undefined {
|
|
1111
|
+
let row: unknown;
|
|
1112
|
+
try {
|
|
1113
|
+
row = JSON.parse(line);
|
|
1114
|
+
} catch {
|
|
1115
|
+
return undefined;
|
|
1116
|
+
}
|
|
1117
|
+
if (!row || typeof row !== "object") return undefined;
|
|
1118
|
+
const { consent, observation } = row as Partial<OutboxRow>;
|
|
1119
|
+
if (!consent || typeof consent !== "object") return undefined;
|
|
1120
|
+
if (consent.mode !== "ask" && consent.mode !== "auto") return undefined;
|
|
1121
|
+
if (typeof consent.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(consent.sha256)) {
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
if (!observation || typeof observation !== "object" || Array.isArray(observation)) {
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
return { consent, observation };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Retry what the outbox holds, oldest first, at most OUTBOX_DRAIN_MAX per call.
|
|
1132
|
+
* A row goes only if it still hashes to the bytes and mode that were consented
|
|
1133
|
+
* to, passes the same rules the builder applies (a second line behind the hash
|
|
1134
|
+
* -- anyone who can edit the file can recompute a digest), and its consent
|
|
1135
|
+
* still holds under the current mode (consentStillHolds).
|
|
1136
|
+
* Anything that fails, or that the route now refuses for good, is dropped
|
|
1137
|
+
* rather than kept to fail forever. Only queueable failures stay.
|
|
1138
|
+
*
|
|
1139
|
+
* The rewrite removes rows by id, never by position, so a row appended or
|
|
1140
|
+
* removed by someone else while the POSTs were in flight is left as it is. If
|
|
1141
|
+
* another drain is already running (here or in another process sharing this
|
|
1142
|
+
* state dir), this one does nothing.
|
|
1143
|
+
*/
|
|
1144
|
+
export async function drainObservationOutbox(
|
|
1145
|
+
submit: (obs: Observation) => Promise<SubmitObservationResult>,
|
|
1146
|
+
/**
|
|
1147
|
+
* Asked again before every row, not once per drain: a drain can run for
|
|
1148
|
+
* minutes on a slow network, and the mode may change meanwhile.
|
|
1149
|
+
*/
|
|
1150
|
+
currentMode: () => ObservationsMode,
|
|
1151
|
+
): Promise<OutboxDrainCounts> {
|
|
1152
|
+
const counts: OutboxDrainCounts = { sent: 0, kept: 0, dropped: 0 };
|
|
1153
|
+
const drop = (reason: OutboxDropReason): void => {
|
|
1154
|
+
counts.dropped += 1;
|
|
1155
|
+
counts.dropReasons = {
|
|
1156
|
+
...counts.dropReasons,
|
|
1157
|
+
[reason]: (counts.dropReasons?.[reason] ?? 0) + 1,
|
|
1158
|
+
};
|
|
1159
|
+
};
|
|
1160
|
+
if (!existsSync(outboxPath())) return counts;
|
|
1161
|
+
if (!acquireDrainLock()) return counts;
|
|
1162
|
+
try {
|
|
1163
|
+
const lines = withFileLock(outboxLockPath(), readOutboxLines)?.value ?? [];
|
|
1164
|
+
if (lines.length === 0) return counts;
|
|
1165
|
+
|
|
1166
|
+
// Settled rows: sent, or dropped for good. Matched by id; a line too
|
|
1167
|
+
// broken to carry an id is matched by its exact text.
|
|
1168
|
+
const doneIds = new Set<string>();
|
|
1169
|
+
const doneLines = new Set<string>();
|
|
1170
|
+
const settle = (line: string, id: string | undefined): void => {
|
|
1171
|
+
if (id) doneIds.add(id);
|
|
1172
|
+
else doneLines.add(line);
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
let unreachable = false;
|
|
1176
|
+
for (const [i, line] of lines.entries()) {
|
|
1177
|
+
// Past the per-drain cap, once the route has proved unreachable this
|
|
1178
|
+
// round, or once collection has been turned off, the rest just wait: on
|
|
1179
|
+
// a black-holed network each try costs the full timeout, and this runs
|
|
1180
|
+
// before the turn's own prompts appear.
|
|
1181
|
+
if (i >= OUTBOX_DRAIN_MAX || unreachable) break;
|
|
1182
|
+
const mode = currentMode();
|
|
1183
|
+
if (mode === "off") break;
|
|
1184
|
+
const row = parseOutboxRow(line);
|
|
1185
|
+
if (!row) {
|
|
1186
|
+
settle(line, undefined);
|
|
1187
|
+
drop("unreadable");
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
const obs = row.observation;
|
|
1191
|
+
// Re-serialized, because those are the bytes that would go. A row that
|
|
1192
|
+
// fails here is settled by its exact line, never by its id: the id is
|
|
1193
|
+
// part of what may have been edited.
|
|
1194
|
+
if (consentDigest(row.consent.mode, observationRequestBody(obs)) !== row.consent.sha256) {
|
|
1195
|
+
settle(line, undefined);
|
|
1196
|
+
drop("changed-since-consent");
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
const id = outboxRowId(line);
|
|
1200
|
+
if (id && doneIds.has(id)) continue;
|
|
1201
|
+
// Before anything reads a field: a digest can be recomputed, so a
|
|
1202
|
+
// matching one says nothing about the row's shape.
|
|
1203
|
+
const problems = observationProblems(obs);
|
|
1204
|
+
if (problems.errors.length > 0 || problems.leaks.length > 0) {
|
|
1205
|
+
settle(line, id);
|
|
1206
|
+
drop("refused-locally");
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
if (!consentStillHolds(row.consent.mode, mode)) {
|
|
1210
|
+
// Dropped, not kept: the user stepped back from auto, so this was
|
|
1211
|
+
// never seen and is not going to be.
|
|
1212
|
+
if (id) appendSentLog(sentLogEntryFor(obs, "cancelled"));
|
|
1213
|
+
settle(line, id);
|
|
1214
|
+
drop("consent-lapsed");
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const res = await submit(obs);
|
|
1218
|
+
if (res.ok) {
|
|
1219
|
+
if (res.retractToken && !saveRetractToken(obs.id, res.retractToken)) {
|
|
1220
|
+
counts.unretractable = (counts.unretractable ?? 0) + 1;
|
|
1221
|
+
}
|
|
1222
|
+
appendSentLog(sentLogEntryFor(obs, "sent"));
|
|
1223
|
+
settle(line, id);
|
|
1224
|
+
counts.sent += 1;
|
|
1225
|
+
} else if (res.status === 409) {
|
|
1226
|
+
// Already stored from an earlier attempt whose 202 never arrived: the
|
|
1227
|
+
// row is there, the token is gone.
|
|
1228
|
+
appendSentLog(sentLogEntryFor(obs, "sent"));
|
|
1229
|
+
settle(line, id);
|
|
1230
|
+
counts.sent += 1;
|
|
1231
|
+
counts.unretractable = (counts.unretractable ?? 0) + 1;
|
|
1232
|
+
} else if (res.queueable) {
|
|
1233
|
+
if (res.status === undefined) unreachable = true;
|
|
1234
|
+
} else {
|
|
1235
|
+
settle(line, id);
|
|
1236
|
+
drop("refused-by-service");
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const rewritten = withFileLock(outboxLockPath(), () =>
|
|
1241
|
+
rewriteOutboxWithout((l) => doneLines.has(l) || doneIds.has(outboxRowId(l) ?? "")),
|
|
1242
|
+
);
|
|
1243
|
+
// Couldn't take the lock to rewrite: the settled rows stay in the file and
|
|
1244
|
+
// a sent one may go again next time. Leaving them is the safe failure; a
|
|
1245
|
+
// rewrite without the lock could drop a row someone just appended.
|
|
1246
|
+
counts.kept = rewritten?.value ?? readOutboxLines().length;
|
|
1247
|
+
return counts;
|
|
1248
|
+
} finally {
|
|
1249
|
+
releaseDrainLock();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
export interface ObservationSentEntry {
|
|
1254
|
+
at: string;
|
|
1255
|
+
id: string;
|
|
1256
|
+
status: "sent" | "queued" | "retracted" | "cancelled";
|
|
1257
|
+
kind: string;
|
|
1258
|
+
stage: string;
|
|
1259
|
+
trigger: string;
|
|
1260
|
+
signature: string;
|
|
1261
|
+
tools: string[];
|
|
1262
|
+
mcpTool?: string;
|
|
1263
|
+
datatypes: string[];
|
|
1264
|
+
server: string;
|
|
1265
|
+
description: string;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* The readable "what have I sent" row. Deliberately carries neither the
|
|
1270
|
+
* install token nor the retract token, so `/observations sent` can show the
|
|
1271
|
+
* log without handing either one over.
|
|
1272
|
+
*/
|
|
1273
|
+
export function sentLogEntryFor(
|
|
1274
|
+
obs: Observation,
|
|
1275
|
+
status: ObservationSentEntry["status"],
|
|
1276
|
+
): ObservationSentEntry {
|
|
1277
|
+
return {
|
|
1278
|
+
at: new Date().toISOString(),
|
|
1279
|
+
id: obs.id,
|
|
1280
|
+
status,
|
|
1281
|
+
kind: obs.kind,
|
|
1282
|
+
stage: obs.stage,
|
|
1283
|
+
trigger: obs.trigger,
|
|
1284
|
+
signature: obs.signature,
|
|
1285
|
+
tools: obs.tools.map((t) => t.id),
|
|
1286
|
+
...(obs.mcpTool ? { mcpTool: obs.mcpTool } : {}),
|
|
1287
|
+
datatypes: obs.datatypes,
|
|
1288
|
+
server: obs.galaxy.server,
|
|
1289
|
+
description: obs.description,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
export function appendSentLog(entry: ObservationSentEntry): string | null {
|
|
1294
|
+
// 0600 like the outbox. The rows hold no token, but what an install has
|
|
1295
|
+
// reported is the user's business, not every local process's.
|
|
1296
|
+
return appendLine(SENT_LOG_FILE, entry, 0o600);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
export function readSentLog(): ObservationSentEntry[] {
|
|
1300
|
+
const file = observationsFilePath(SENT_LOG_FILE);
|
|
1301
|
+
if (!existsSync(file)) return [];
|
|
1302
|
+
const out: ObservationSentEntry[] = [];
|
|
1303
|
+
try {
|
|
1304
|
+
for (const line of readFileSync(file, "utf-8").split("\n")) {
|
|
1305
|
+
if (!line.trim()) continue;
|
|
1306
|
+
try {
|
|
1307
|
+
out.push(JSON.parse(line) as ObservationSentEntry);
|
|
1308
|
+
} catch {
|
|
1309
|
+
// Skip, same as the activity log's own hydrate.
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
} catch {
|
|
1313
|
+
return out;
|
|
1314
|
+
}
|
|
1315
|
+
return out;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// -----------------------------------------------------------------------------
|
|
1319
|
+
// Retract tokens
|
|
1320
|
+
// -----------------------------------------------------------------------------
|
|
1321
|
+
|
|
1322
|
+
function readTokenStore(): Record<string, string> {
|
|
1323
|
+
const file = observationsFilePath(TOKEN_STORE_FILE);
|
|
1324
|
+
if (!existsSync(file)) return {};
|
|
1325
|
+
try {
|
|
1326
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
1327
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1328
|
+
} catch {
|
|
1329
|
+
// A corrupt store costs the ability to retract older rows, which is better
|
|
1330
|
+
// than throwing out of a command the user is standing in front of.
|
|
1331
|
+
return {};
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function tokenStorePath(): string {
|
|
1336
|
+
return observationsFilePath(TOKEN_STORE_FILE);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Read-modify-write under a lock, written via temp + rename: a crash mid-write
|
|
1341
|
+
* would otherwise leave a truncated store, which reads back as empty and loses
|
|
1342
|
+
* every retract token at once. False when the change could not be saved.
|
|
1343
|
+
*/
|
|
1344
|
+
function updateTokenStore(change: (store: Record<string, string>) => void): boolean {
|
|
1345
|
+
try {
|
|
1346
|
+
const done = withFileLock(`${tokenStorePath()}.lock`, () => {
|
|
1347
|
+
const store = readTokenStore();
|
|
1348
|
+
change(store);
|
|
1349
|
+
writeFileAtomic(tokenStorePath(), JSON.stringify(store, null, 2) + "\n");
|
|
1350
|
+
return true;
|
|
1351
|
+
});
|
|
1352
|
+
return done?.value === true;
|
|
1353
|
+
} catch {
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* The Worker returns a retract token once, so it has to be kept to make
|
|
1360
|
+
* `/observations retract` possible. Kept out of the sent log and listed in the
|
|
1361
|
+
* exec-guard's credential stores so the agent can never read it. False when it
|
|
1362
|
+
* could not be written, which the caller tells the user about: without it the
|
|
1363
|
+
* row can't be retracted.
|
|
1364
|
+
*/
|
|
1365
|
+
export function saveRetractToken(id: string, token: string): boolean {
|
|
1366
|
+
return updateTokenStore((store) => {
|
|
1367
|
+
store[id] = token;
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
export function readRetractToken(id: string): string | undefined {
|
|
1372
|
+
const token = readTokenStore()[id];
|
|
1373
|
+
return typeof token === "string" && token.length > 0 ? token : undefined;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
export function forgetRetractToken(id: string): boolean {
|
|
1377
|
+
if (!(id in readTokenStore())) return true;
|
|
1378
|
+
return updateTokenStore((store) => {
|
|
1379
|
+
delete store[id];
|
|
1380
|
+
});
|
|
1381
|
+
}
|