@bongos/core 1.23.22 → 1.24.1
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/.bongos-core.json +902 -407
- package/.claude/skills/blocker-review/SKILL.md +9 -1
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +1 -1
- package/.claude/skills/builder-redteam/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/.claude/skills/collab-review/SKILL.md +5 -2
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +2 -2
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/owner-review/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +11 -11
- package/.claude/skills/read-session-export/SKILL.md +1 -1
- package/.claude/skills/recall/SKILL.md +1 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/strand-fix/SKILL.md +13 -0
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/AGENTS.md +15 -0
- package/GEMINI.md +5 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +90 -0
- package/clients/bongos-client/index.cjs +90 -0
- package/clients/bongos-client/index.d.ts +149 -4
- package/clients/bongos-client/index.mjs +90 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
- package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
- package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
- package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
- package/docs/adr/README.md +5 -1
- package/docs/api/openapi.json +8555 -5876
- package/docs/api-reference.md +63 -10
- package/docs/architecture.md +37 -1
- package/docs/canonical-permissions.md +1 -1
- package/docs/copy-inventory.md +420 -337
- package/docs/copy-registry.json +1321 -500
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +5 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
- package/docs/onboarding/diagrams/README.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +15 -15
- package/docs/onboarding/primer.md +2 -2
- package/docs/page-inventory.json +55 -6
- package/docs/page-readings.json +1185 -1011
- package/docs/recipes/google-sign-in.md +60 -0
- package/docs/recipes/ops-gotchas.md +1 -1
- package/migrations/core_282_builders_lower_login_idx.sql +33 -0
- package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
- package/migrations/core_283_payout_approvals.sql +183 -0
- package/migrations/core_284_payout_gates.sql +179 -0
- package/migrations/core_285_task_needs.sql +74 -0
- package/modules/ai-studio/billing.js +67 -0
- package/modules/ai-studio/context.js +29 -0
- package/modules/ai-studio/db.js +172 -0
- package/modules/ai-studio/gemini.js +75 -0
- package/modules/ai-studio/generate.js +119 -0
- package/modules/ai-studio/keys.js +73 -0
- package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
- package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
- package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
- package/modules/ai-studio/openrouter.js +90 -0
- package/modules/ai-studio/payments.js +105 -0
- package/modules/ai-studio/pricing.js +92 -0
- package/modules/ai-studio/routes/ai-studio.js +235 -5
- package/modules/ai-studio/studio.js +121 -0
- package/modules/autonomy/fence.js +64 -1
- package/modules/builder-settings/builder-needs.js +27 -1
- package/modules/copy-desk/routes/copy-desk.js +5 -0
- package/modules/economy/achievements.js +22 -10
- package/modules/economy/cost-classify.js +9 -0
- package/modules/economy/cost-write-guard.js +52 -0
- package/modules/economy/cost.js +2 -1
- package/modules/economy/credits.js +18 -15
- package/modules/economy/goal-reward.js +161 -0
- package/modules/economy/reward.js +4 -0
- package/modules/economy/routes/cost.js +33 -7
- package/modules/government/catalog.js +6 -0
- package/modules/government/db.js +34 -0
- package/modules/government/docket.js +36 -0
- package/modules/government/migrations/government_035_payout_approve.sql +29 -0
- package/modules/government/pollers/board-expiry.js +108 -20
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/government/routes/government.js +22 -0
- package/modules/government/routing.js +39 -5
- package/modules/government/waiting.js +74 -0
- package/modules/hall-ui/public/ai-studio-lib.js +268 -0
- package/modules/hall-ui/public/ai-studio.css +193 -0
- package/modules/hall-ui/public/ai-studio.html +98 -0
- package/modules/hall-ui/public/ai-studio.js +556 -0
- package/modules/hall-ui/public/ai-studio.states.json +94 -0
- package/modules/hall-ui/public/blockers-by-person.js +156 -0
- package/modules/hall-ui/public/blockers-lib.js +15 -1
- package/modules/hall-ui/public/blockers.html +8 -0
- package/modules/hall-ui/public/blockers.js +1 -1
- package/modules/hall-ui/public/claim-action.js +3 -0
- package/modules/hall-ui/public/collab.html +12 -0
- package/modules/hall-ui/public/craft-mode.js +4 -1
- package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals-render.js +4 -0
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/gov-living.js +6 -2
- package/modules/hall-ui/public/hall-ask.css +5 -0
- package/modules/hall-ui/public/hall-ask.js +39 -3
- package/modules/hall-ui/public/notes-panel.css +20 -0
- package/modules/hall-ui/public/notes-panel.js +146 -0
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +7 -5
- package/modules/hall-ui/public/settings-ai-keys.js +124 -0
- package/modules/hall-ui/public/settings.html +15 -0
- package/modules/hall-ui/public/street-deck.js +12 -3
- package/modules/hall-ui/public/studio.css +90 -8
- package/modules/hall-ui/public/studio.html +39 -10
- package/modules/hall-ui/public/studio.js +148 -18
- package/modules/hall-ui/public/task.html +2 -0
- package/modules/hall-ui/public/task.js +4 -0
- package/modules/hall-ui/public/tweak-editor.js +12 -3
- package/modules/hall-ui/public/welcome-boot.css +42 -6
- package/modules/hall-ui/public/welcome-boot.js +20 -6
- package/modules/hall-ui/public/work.js +20 -0
- package/modules/hall-ui/records/ai-studio.md +36 -0
- package/modules/hall-ui/records/blockers-people.md +24 -0
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +18 -5
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/done-when.js +23 -1
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/notes.js +219 -0
- package/modules/lifecycle/page-notice.js +124 -0
- package/modules/lifecycle/page-tweak-artist.js +18 -1
- package/modules/lifecycle/publish-reconciler.js +67 -20
- package/modules/lifecycle/routes/claims.js +5 -2
- package/modules/lifecycle/routes/goals.js +3 -1
- package/modules/lifecycle/routes/notes.js +91 -0
- package/modules/lifecycle/routes/project-settings.js +43 -35
- package/modules/lifecycle/routes/task-route-helpers.js +21 -0
- package/modules/lifecycle/routes/task-write-routes.js +21 -3
- package/modules/lifecycle/routes/tasks.js +12 -12
- package/modules/lifecycle/task-needs-gate.js +70 -0
- package/modules/market-watch/CLAUDE.md +15 -0
- package/modules/market-watch/db.js +340 -0
- package/modules/market-watch/key.js +58 -0
- package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
- package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
- package/modules/market-watch/module.json +26 -0
- package/modules/market-watch/pollers/hub-seed.js +54 -0
- package/modules/market-watch/pollers/weekly-run.js +100 -0
- package/modules/market-watch/research.js +350 -0
- package/modules/market-watch/run.js +245 -0
- package/modules/market-watch/seed/market-data.json +38 -0
- package/modules/market-watch/seed/partners-data.json +497 -0
- package/modules/market-watch/seed.js +252 -0
- package/modules/market-watch/store.js +230 -0
- package/modules/memory/routes/memory.js +14 -2
- package/modules/memory/routes/search.js +8 -2
- package/modules/platform-identity/account-logins.js +192 -0
- package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
- package/modules/platform-identity/platform-identity.js +5 -4
- package/modules/platform-identity/routes/my-projects.js +6 -0
- package/modules/platform-identity/routes/profile.js +18 -0
- package/modules/platform-identity/routes/public-profile.js +10 -1
- package/modules/platform-identity/routes/sso.js +26 -1
- package/modules/platform-identity/tests/platform-identity.mjs +2 -1
- package/modules/provisioning/credential-preflight.js +13 -3
- package/modules/provisioning/pollers/app-liveness.js +25 -5
- package/modules/provisioning/routes/provisioning.js +1 -1
- package/modules/provisioning/starter-bundles.js +6 -2
- package/modules/public-landing/public/account.html +10 -2
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
- package/modules/public-landing/public/community.html +1 -1
- package/modules/public-landing/public/connections.html +2 -2
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +2 -2
- package/modules/public-landing/public/projects.html +5 -5
- package/modules/raise/views/nda.html +2 -0
- package/modules/specialities/db.js +32 -1
- package/modules/specialities/routes/specialities.js +10 -6
- package/modules/status-ui/public/index.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +220 -0
- package/scripts/bongos/autobongos-tweak-lane.js +46 -1
- package/scripts/bongos/backfill-subagent-rewards.js +2 -2
- package/scripts/bongos/claim.js +22 -1
- package/scripts/bongos/db-backup-nightly.sh +29 -0
- package/scripts/bongos/deploy-divergence.js +7 -0
- package/scripts/bongos/doc-cli-guard.js +6 -0
- package/scripts/bongos/fitness.js +3 -2
- package/scripts/bongos/gen-diagrams.js +3 -3
- package/scripts/bongos/lib.sh +4 -5
- package/scripts/bongos/migration-namespace.js +13 -1
- package/scripts/bongos/plain-cards.js +4 -0
- package/scripts/bongos/provision-github-setup.js +3 -1
- package/scripts/bongos/provision-units.js +6 -1
- package/scripts/bongos/publish-manifest.js +3 -0
- package/scripts/bongos/release.js +2 -0
- package/scripts/bongos/run-unit-tests.js +268 -82
- package/scripts/bongos/seed-tasks.example.json +56 -0
- package/scripts/bongos/seed-tasks.js +222 -0
- package/scripts/bongos/seed-verify-criteria.js +1 -2
- package/scripts/bongos/ship-finish.js +2 -0
- package/scripts/bongos/ship-resume.js +2 -0
- package/scripts/bongos/strand-lease.js +250 -0
- package/scripts/bongos/strand-watch.js +26 -5
- package/scripts/bongos/version-literal-guard.js +2 -2
- package/scripts/discord/post.js +4 -3
- package/scripts/gds/seed-tasks.js +7 -0
- package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/auth-config.js +29 -1
- package/src/bongos/auth-discord.js +3 -3
- package/src/bongos/auth-github.js +3 -6
- package/src/bongos/auth-google.js +217 -0
- package/src/bongos/device-approval.js +140 -0
- package/src/bongos/llm-pricing.js +17 -1
- package/src/bongos/metrics.js +48 -2
- package/src/bongos/middleware/rate-limit.js +30 -11
- package/src/bongos/module-scope-map.js +12 -1
- package/src/bongos/optimizer.js +15 -1
- package/src/bongos/payout-approvals.js +342 -0
- package/src/bongos/payout-gates.js +364 -0
- package/src/bongos/prelaunch-gate.js +7 -9
- package/src/bongos/prelaunch-switch.js +20 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/auth-google.js +246 -0
- package/src/bongos/routes/auth.js +38 -1
- package/src/bongos/routes/backup.js +33 -9
- package/src/bongos/routes/builders.js +57 -30
- package/src/bongos/routes/github.js +9 -0
- package/src/bongos/routes/me.js +18 -0
- package/src/bongos/routes/my-payouts.js +133 -0
- package/src/bongos/routes/payout-approvals.js +312 -0
- package/src/bongos/routes/payout-gates.js +179 -0
- package/src/bongos/routes.js +15 -1
- package/src/bongos/secret-box.js +18 -3
- package/src/bongos/serve-internal.js +47 -19
- package/src/bongos/sign-in-pages.js +138 -0
- package/src/bongos/task-needs.js +146 -0
- package/src/bongos-downloads.js +18 -1
- package/src/instance-config.js +28 -0
- package/src/module-api.js +14 -1
- package/tests/account_logins.mjs +326 -0
- package/tests/account_logins_db.mjs +161 -0
- package/tests/ai_studio_billing.mjs +253 -0
- package/tests/ai_studio_generate.mjs +282 -0
- package/tests/ai_studio_keys.mjs +159 -0
- package/tests/ai_studio_requests.mjs +319 -0
- package/tests/auth_backchannel_logout_route.mjs +201 -0
- package/tests/auto_satisfy_criteria.mjs +10 -1
- package/tests/autobongos_tweak_lane.mjs +144 -0
- package/tests/autobongos_tweak_pin_identity.mjs +137 -0
- package/tests/backfill_subagent_rewards.mjs +17 -1
- package/tests/backup_trigger_lock.mjs +119 -0
- package/tests/blockers_people.mjs +198 -0
- package/tests/bounded_rate_maps.mjs +184 -0
- package/tests/claimable_blocker_status.mjs +1 -1
- package/tests/claims_batch_routes.mjs +421 -0
- package/tests/config_read_cache.mjs +171 -0
- package/tests/copy_desk_page_editor.mjs +12 -0
- package/tests/cost_write_guard.mjs +200 -0
- package/tests/craft_achievements.mjs +33 -10
- package/tests/criterion_goal_attach.mjs +1 -1
- package/tests/db_backup_nightly.mjs +73 -1
- package/tests/deploy_divergence_line.mjs +7 -0
- package/tests/effective_visibility_predicate.mjs +12 -0
- package/tests/fitness.mjs +19 -0
- package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
- package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
- package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
- package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
- package/tests/github_one_trip.mjs +34 -0
- package/tests/github_repo_visibility.mjs +20 -0
- package/tests/goal_achievement.mjs +30 -1
- package/tests/goal_achievement_reward.mjs +205 -0
- package/tests/goal_sort_staleness.mjs +2 -1
- package/tests/goal_tier.mjs +11 -1
- package/tests/google_fonts_nonblocking.mjs +84 -0
- package/tests/google_sign_in.mjs +401 -0
- package/tests/government_board_expiry.mjs +94 -0
- package/tests/government_protected_surfaces.mjs +1 -1
- package/tests/government_routes.mjs +1 -1
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_studio_home.mjs +61 -2
- package/tests/home_attention_cards.mjs +1 -1
- package/tests/human_signal.mjs +141 -0
- package/tests/install_script_reader.mjs +51 -0
- package/tests/integration_fixture_ids.mjs +82 -0
- package/tests/invite_expiry_db.mjs +13 -1
- package/tests/lib_sh_resolution.mjs +0 -4
- package/tests/market_watch_data.mjs +244 -0
- package/tests/market_watch_data_db.mjs +308 -0
- package/tests/market_watch_module.mjs +114 -0
- package/tests/market_watch_run.mjs +503 -0
- package/tests/metrics.mjs +124 -0
- package/tests/migration_namespace.mjs +14 -0
- package/tests/module_api.mjs +2 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/notes.mjs +182 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/page_tweak_marker_sql.mjs +76 -0
- package/tests/page_tweak_no_block.mjs +135 -0
- package/tests/page_tweak_notice.mjs +103 -0
- package/tests/payout_approvals.mjs +253 -0
- package/tests/payout_approvals_db.mjs +106 -0
- package/tests/payout_gates.mjs +322 -0
- package/tests/payout_gates_db.mjs +113 -0
- package/tests/planning_session_skill.mjs +13 -3
- package/tests/project_settings_page.mjs +29 -5
- package/tests/project_settings_spend_cap_route.mjs +235 -0
- package/tests/promotion_sweep.mjs +29 -1
- package/tests/provision.mjs +3 -2
- package/tests/provisioning_app_status.mjs +18 -0
- package/tests/provisioning_credential_preflight.mjs +53 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
- package/tests/publish_reconciler_overlap.mjs +60 -0
- package/tests/rank_change_broadcast_port.mjs +151 -0
- package/tests/seed_tasks.mjs +176 -0
- package/tests/seed_verify_criteria.mjs +2 -6
- package/tests/site_classified_once.mjs +142 -0
- package/tests/speciality_db_shape.mjs +23 -0
- package/tests/speciality_session_skills.mjs +48 -2
- package/tests/strand_lease.mjs +249 -0
- package/tests/strand_watch.mjs +10 -0
- package/tests/task_criteria_routes.mjs +275 -0
- package/tests/task_criteria_unlink_db.mjs +123 -0
- package/tests/task_needs.mjs +233 -0
- package/tests/task_needs_db.mjs +110 -0
- package/tests/unit_runner_serial.mjs +266 -43
- package/tests/version_literals.mjs +5 -7
- package/tests/welcome_boot.mjs +16 -5
- package/tests/welcome_boot_browser.mjs +36 -4
- package/docs/recipes/bongos-cli-release.md +0 -66
- package/scripts/bongos/backfill-disciplines.js +0 -144
- package/scripts/bongos/backfill-session-earnings.js +0 -123
- package/scripts/bongos/backfill-session-types.js +0 -87
- package/scripts/bongos/build-bongos.js +0 -94
- package/scripts/bongos/cleanup-orphans.js +0 -74
- package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
- package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
- package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
- package/scripts/bongos/smoke-batch-claim.sh +0 -238
- package/scripts/bongos/smoke-batch-release.sh +0 -316
- package/scripts/bongos/smoke-claim-consistency.sh +0 -222
- package/scripts/bongos/smoke-dependencies.sh +0 -143
- package/scripts/gds/backfill-disciplines.js +0 -7
- package/scripts/gds/backfill-session-earnings.js +0 -7
- package/scripts/gds/backfill-session-types.js +0 -7
- package/scripts/gds/cleanup-orphans.js +0 -7
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
- package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
- package/scripts/gds/smoke-batch-claim.sh +0 -8
- package/scripts/gds/smoke-batch-release.sh +0 -8
- package/scripts/gds/smoke-claim-consistency.sh +0 -8
- package/scripts/gds/smoke-dependencies.sh +0 -8
- package/tests/smoke_dependencies_witness.mjs +0 -46
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// tests/market_watch_run.mjs — the weekly Market Watch run with no database and no network
|
|
2
|
+
// (task 1004937, goal 1000137, ADR 0394).
|
|
3
|
+
//
|
|
4
|
+
// The Messages API client is a fake whose `messages.create` records the request it was
|
|
5
|
+
// handed and answers with a scripted response, so every test asserts on the REAL request
|
|
6
|
+
// the run builds (model, web tool caps, the key) and on the run's handling of the reply.
|
|
7
|
+
// The store is an in-memory stand-in with store.js's method names and its two guarantees
|
|
8
|
+
// (an item result is recorded once; a result for another project's item throws
|
|
9
|
+
// cross_project). store.js's own SQL guard is tested at the bottom against a recording
|
|
10
|
+
// pool. Nothing here can make a paid call: no test ever constructs the real SDK.
|
|
11
|
+
//
|
|
12
|
+
// The SQL runs on real Postgres in tests/market_watch_data_db.mjs (INTEGRATION set).
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const seed = require('../modules/market-watch/seed.js');
|
|
20
|
+
const research = require('../modules/market-watch/research.js');
|
|
21
|
+
const run = require('../modules/market-watch/run.js');
|
|
22
|
+
const store = require('../modules/market-watch/store.js');
|
|
23
|
+
const key = require('../modules/market-watch/key.js');
|
|
24
|
+
const weekly = require('../modules/market-watch/pollers/weekly-run.js');
|
|
25
|
+
const { priceWebSearches, WEB_SEARCH_USD_PER_REQUEST } = require('../src/bongos/llm-pricing.js');
|
|
26
|
+
|
|
27
|
+
const P = 'https://watch.example.test';
|
|
28
|
+
const OTHER = 'https://other.example.test';
|
|
29
|
+
const KEY = 'sk-ant-api03-project-own-key-ABCD';
|
|
30
|
+
const NOW = Date.parse('2026-11-20T10:00:00Z');
|
|
31
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
const built = seed.buildSeed({ market: seed.readSeed('market-data.json'), partners: seed.readSeed('partners-data.json') });
|
|
34
|
+
|
|
35
|
+
// --- a fake Messages API ---------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const NEWS_URL = 'https://news.example.com/launch';
|
|
38
|
+
const FETCHED_URL = 'https://blog.example.com/post';
|
|
39
|
+
|
|
40
|
+
function reply(obj, { stop = 'end_turn', usage, extra = [] } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
stop_reason: stop,
|
|
43
|
+
content: [
|
|
44
|
+
{ type: 'server_tool_use', id: 's1', name: 'web_search', input: { query: 'x' } },
|
|
45
|
+
{ type: 'web_search_tool_result', tool_use_id: 's1', content: [{ type: 'web_search_result', url: NEWS_URL, title: 'Launch' }] },
|
|
46
|
+
{ type: 'server_tool_use', id: 'f1', name: 'web_fetch', input: { url: FETCHED_URL } },
|
|
47
|
+
{ type: 'web_fetch_tool_result', tool_use_id: 'f1', content: { type: 'web_fetch_result', url: FETCHED_URL, content: {} } },
|
|
48
|
+
...extra,
|
|
49
|
+
{ type: 'text', text: typeof obj === 'string' ? obj : JSON.stringify(obj) },
|
|
50
|
+
],
|
|
51
|
+
usage: usage || { input_tokens: 20_000, output_tokens: 1_500, server_tool_use: { web_search_requests: 2 } },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// 20k in × $2/M + 1.5k out × $10/M + 2 searches × $0.01
|
|
55
|
+
const REPLY_COST = 0.075;
|
|
56
|
+
|
|
57
|
+
const GOOD = {
|
|
58
|
+
entries: [
|
|
59
|
+
{ type: 'news', takeaway: 'Shipped agent mode to self-hosted customers.', source_url: NEWS_URL, source_label: 'news', confidence: 'high', dimension: 'human_gates' },
|
|
60
|
+
{ type: 'insight', takeaway: 'Pricing moved to usage.', source_url: `${FETCHED_URL}/`, source_label: '', confidence: 'medium', dimension: null },
|
|
61
|
+
{ type: 'news', takeaway: 'An invented scoop.', source_url: 'https://made-up.example.org/x', source_label: 'x', confidence: 'high', dimension: null },
|
|
62
|
+
],
|
|
63
|
+
scores: [
|
|
64
|
+
{ dimension: 'overall', value: 6.24, reason: 'Closer to us after the self-host launch.' },
|
|
65
|
+
{ dimension: 'human_gates', value: 7, reason: 'Adds an approval step.' },
|
|
66
|
+
{ dimension: 'not_a_dimension', value: 5, reason: 'x' },
|
|
67
|
+
{ dimension: 'open_source_ethos', value: 11, reason: 'out of range' },
|
|
68
|
+
],
|
|
69
|
+
candidates: [{ name: 'Newco Agents', url: 'https://newco.example.com', kind: 'competitor', why: 'Named as the rival in the launch post.' }],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function fakeClient(respond = () => reply(GOOD)) {
|
|
73
|
+
const calls = [];
|
|
74
|
+
return {
|
|
75
|
+
calls,
|
|
76
|
+
client: { messages: { create: async (req) => { calls.push(req); return respond(req, calls.length); } } },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- an in-memory store with store.js's contract ----------------------------
|
|
81
|
+
|
|
82
|
+
function memStore({ items, settings, runs = [], runItems = [], spendThrows = false, spendThrowsAfter = Infinity, rules } = {}) {
|
|
83
|
+
const s = {
|
|
84
|
+
items: items || built.items.slice(0, 4).map((it, i) => ({ id: i + 1, project: P, kind: it.kind, name: it.name, url: it.url, description: it.description, why_tracked: it.whyTracked, status: 'active' })),
|
|
85
|
+
settings: settings === undefined ? { enabled: true, monthly_cap_usd: 20, cadence: 'weekly' } : settings,
|
|
86
|
+
runs: runs.map((r) => ({ ...r })),
|
|
87
|
+
runItems: new Map(runItems.map((r) => [`${r.run_id}:${r.item_id}`, { ...r }])),
|
|
88
|
+
entries: [], scores: [], proposals: [], newItems: [],
|
|
89
|
+
spendReads: 0,
|
|
90
|
+
rules: rules === undefined ? { id: 1, ...built.rules, search_queries: built.rules.searchQueries } : rules,
|
|
91
|
+
};
|
|
92
|
+
let nextRun = 100;
|
|
93
|
+
const api = {
|
|
94
|
+
state: s,
|
|
95
|
+
async readSettings(project) { assert.equal(project, P); return s.settings ? { ...s.settings } : { enabled: false, monthly_cap_usd: 20, cadence: 'weekly' }; },
|
|
96
|
+
async lastRun(project) { const r = s.runs.filter((x) => x.project === project).sort((a, b) => b.created_at - a.created_at)[0]; return r || null; },
|
|
97
|
+
async openRun(project) {
|
|
98
|
+
const r = s.runs.find((x) => x.project === project && ['queued', 'running'].includes(x.status));
|
|
99
|
+
return r ? { ...r, leased: r.claimed_until > NOW } : null;
|
|
100
|
+
},
|
|
101
|
+
async insertSkipped(project, o) { const id = nextRun++; s.runs.push({ id, project, status: 'skipped', ...o, created_at: NOW }); return id; },
|
|
102
|
+
async createRun(project, o) {
|
|
103
|
+
if (s.runs.some((x) => x.project === project && ['queued', 'running'].includes(x.status))) return null;
|
|
104
|
+
const id = nextRun++;
|
|
105
|
+
s.runs.push({ id, project, status: 'running', ...o, claimed_until: NOW + o.leaseMs, created_at: NOW });
|
|
106
|
+
return id;
|
|
107
|
+
},
|
|
108
|
+
async claimRun(project, id, leaseMs) {
|
|
109
|
+
const r = s.runs.find((x) => x.id === id && x.project === project);
|
|
110
|
+
if (!r || r.claimed_until > NOW) return false;
|
|
111
|
+
r.claimed_until = NOW + leaseMs; r.status = 'running'; return true;
|
|
112
|
+
},
|
|
113
|
+
async renewLease(project, id) { return s.runs.some((x) => x.id === id && x.project === project && x.status === 'running'); },
|
|
114
|
+
async finishRun(project, id, { status, reason }) { const r = s.runs.find((x) => x.id === id && x.project === project); Object.assign(r, { status, reason }); return true; },
|
|
115
|
+
async startItem(project, runId, itemId) {
|
|
116
|
+
const k = `${runId}:${itemId}`;
|
|
117
|
+
const row = s.runItems.get(k);
|
|
118
|
+
if (row && ['done', 'error'].includes(row.status)) return false;
|
|
119
|
+
s.runItems.set(k, { run_id: runId, item_id: itemId, status: 'running' });
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
async recordItem(project, runId, itemId, r) {
|
|
123
|
+
const item = s.items.find((i) => i.id === itemId);
|
|
124
|
+
const runRow = s.runs.find((x) => x.id === runId);
|
|
125
|
+
if (!item || item.project !== project || !runRow || runRow.project !== project) {
|
|
126
|
+
const e = new Error('cross_project'); e.code = 'cross_project'; throw e;
|
|
127
|
+
}
|
|
128
|
+
const row = s.runItems.get(`${runId}:${itemId}`);
|
|
129
|
+
if (row && ['done', 'error'].includes(row.status)) return { recorded: false, counts: { entries: 0, scores: 0, proposals: 0 } };
|
|
130
|
+
const counts = { entries: 0, scores: 0, proposals: 0 };
|
|
131
|
+
if (r.ok) {
|
|
132
|
+
for (const e of r.entries) { assert.match(e.sourceUrl, /^https?:\/\//, 'never an entry without a source'); s.entries.push({ item_id: itemId, run_id: runId, ...e }); counts.entries += 1; }
|
|
133
|
+
for (const sc of r.scores) { s.scores.push({ item_id: itemId, run_id: runId, ...sc }); counts.scores += 1; }
|
|
134
|
+
for (const c of r.candidates) { s.proposals.push({ project, run_id: runId, kind: 'add_item', origin: 'run', payload: c }); counts.proposals += 1; }
|
|
135
|
+
}
|
|
136
|
+
s.runItems.set(`${runId}:${itemId}`, { run_id: runId, item_id: itemId, status: r.ok ? 'done' : 'error', error: r.ok ? null : r.error, cost_usd: r.costUsd });
|
|
137
|
+
return { recorded: true, counts };
|
|
138
|
+
},
|
|
139
|
+
async remainingItems(project, runId) {
|
|
140
|
+
return s.items.filter((i) => i.status === 'active' && !['done', 'error'].includes((s.runItems.get(`${runId}:${i.id}`) || {}).status));
|
|
141
|
+
},
|
|
142
|
+
async monthSpend() {
|
|
143
|
+
s.spendReads += 1;
|
|
144
|
+
if (spendThrows || s.spendReads > spendThrowsAfter) throw new Error('db down');
|
|
145
|
+
return [...s.runItems.values()].reduce((a, r) => a + (Number(r.cost_usd) || 0), 0);
|
|
146
|
+
},
|
|
147
|
+
async activeRules() { return s.rules; },
|
|
148
|
+
};
|
|
149
|
+
return api;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fakeEconomy() {
|
|
153
|
+
const rows = new Map();
|
|
154
|
+
const calls = [];
|
|
155
|
+
return {
|
|
156
|
+
calls, rows,
|
|
157
|
+
async logCost(row) { calls.push(row); const k = `${row.source}|${row.sourceRef}`; if (!rows.has(k)) rows.set(k, row); },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function deps(over = {}) {
|
|
162
|
+
const fc = over.fake || fakeClient();
|
|
163
|
+
const keysSeen = [];
|
|
164
|
+
return {
|
|
165
|
+
fc,
|
|
166
|
+
keysSeen,
|
|
167
|
+
d: {
|
|
168
|
+
project: P,
|
|
169
|
+
now: () => NOW,
|
|
170
|
+
readKey: async () => KEY,
|
|
171
|
+
createClient: (k) => { keysSeen.push(k); return fc.client; },
|
|
172
|
+
economy: fakeEconomy(),
|
|
173
|
+
warn: () => {},
|
|
174
|
+
...over.d,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- the request -----------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
test('the request: Sonnet 5, web search capped at 3 and fetch at 2, rules cached first, a JSON reply', () => {
|
|
182
|
+
const req = research.buildRequest({ item: { name: 'Acme', kind: 'competitor', url: 'https://acme.test' }, rules: { ...built.rules, search_queries: built.rules.searchQueries }, today: '2026-11-20' });
|
|
183
|
+
assert.equal(req.model, 'claude-sonnet-5');
|
|
184
|
+
const search = req.tools.find((t) => t.name === 'web_search');
|
|
185
|
+
const fetch = req.tools.find((t) => t.name === 'web_fetch');
|
|
186
|
+
assert.deepEqual([search.type, search.max_uses], ['web_search_20260209', 3]);
|
|
187
|
+
assert.deepEqual([fetch.type, fetch.max_uses, fetch.max_content_tokens], ['web_fetch_20260209', 2, 10_000]);
|
|
188
|
+
assert.equal(req.tools.length, 2, 'no other tool: dynamic filtering brings its own code execution');
|
|
189
|
+
assert.ok(req.max_tokens > 0 && req.max_tokens <= 8_000, 'bounded output');
|
|
190
|
+
assert.deepEqual(req.system[0].cache_control, { type: 'ephemeral' });
|
|
191
|
+
assert.match(req.system[0].text, /Every finding needs a source link/, 'the approved rules are the cached prefix');
|
|
192
|
+
assert.doesNotMatch(req.system[0].text, /2026-11-20|Acme/, 'nothing per-item or per-day in the cached prefix');
|
|
193
|
+
assert.match(req.messages[0].content, /Acme/);
|
|
194
|
+
assert.equal(req.output_config.format.type, 'json_schema');
|
|
195
|
+
assert.equal(req.output_config.format.schema, research.REPLY_SCHEMA);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('the run calls the API on the PROJECT key it read, and only that key', async () => {
|
|
199
|
+
const { d, fc, keysSeen } = deps();
|
|
200
|
+
const r = await run.runDue({ ...d, store: memStore() });
|
|
201
|
+
assert.equal(r.outcome, 'done');
|
|
202
|
+
assert.deepEqual(keysSeen, [KEY], 'one client, built on the project key');
|
|
203
|
+
assert.equal(fc.calls.length, 4, 'one call per tracked item');
|
|
204
|
+
assert.ok(fc.calls.every((c) => c.model === 'claude-sonnet-5'));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the key accessor decrypts inside a run only, and a blob it cannot open is no key', async () => {
|
|
208
|
+
const pool = (enc) => ({ query: async (sql, params) => { assert.equal(sql, key.KEY_SQL); assert.equal(params[0], P); return { rows: enc === undefined ? [] : [{ anthropic_key_enc: enc }] }; } });
|
|
209
|
+
assert.equal(await key.readProjectKey(pool('v1.a.b.c'), P, { decrypt: () => KEY }), KEY);
|
|
210
|
+
assert.equal(await key.readProjectKey(pool(undefined), P, { decrypt: () => KEY }), null);
|
|
211
|
+
assert.equal(await key.readProjectKey(pool(null), P, { decrypt: () => KEY }), null);
|
|
212
|
+
assert.equal(await key.readProjectKey(pool('v1.a.b.c'), P, { decrypt: () => { throw new Error('bad tag'); } }), null);
|
|
213
|
+
const sealed = key.sealProjectKey(` ${KEY} `, { encrypt: (s) => `v1.sealed(${s.length})` });
|
|
214
|
+
assert.deepEqual(sealed, { key_enc: `v1.sealed(${KEY.length})`, last4: 'ABCD' });
|
|
215
|
+
assert.equal(key.sealProjectKey('short', { encrypt: () => 'x' }).error, 'key_wrong_length');
|
|
216
|
+
const spaced = key.sealProjectKey('has a space in it here', { encrypt: () => 'x' });
|
|
217
|
+
assert.deepEqual(spaced, { error: 'key_has_spaces' }, 'refused, and the key is never echoed back');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- the reply -------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
test('parsing: only entries sourced from THIS response survive, scores stay on the rubric and in 0..10', () => {
|
|
223
|
+
const seen = research.seenUrls(reply(GOOD).content);
|
|
224
|
+
const p = research.parseReply(JSON.stringify(GOOD), { seen, dimensions: ['human_gates', 'open_source_ethos'] });
|
|
225
|
+
assert.equal(p.ok, true);
|
|
226
|
+
assert.deepEqual(p.entries.map((e) => e.sourceUrl), [NEWS_URL, `${FETCHED_URL}/`], 'the invented link is dropped');
|
|
227
|
+
assert.deepEqual(p.entries.map((e) => e.confidence), [3, 2]);
|
|
228
|
+
assert.equal(p.entries[1].sourceLabel, 'blog.example.com', 'an empty label falls back to the host');
|
|
229
|
+
assert.deepEqual(p.scores.map((s) => [s.dimension, s.value]), [['overall', 6.2], ['human_gates', 7]]);
|
|
230
|
+
assert.deepEqual(p.dropped, { entries: 1, scores: 2, candidates: 0 });
|
|
231
|
+
assert.equal(p.candidates[0].name, 'Newco Agents');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('parsing: a malformed reply is an error, never a partial guess', () => {
|
|
235
|
+
const seen = new Set([NEWS_URL]);
|
|
236
|
+
for (const bad of ['', 'not json', '{"entries": [}', '[]', '{"entries": []}', JSON.stringify({ entries: 'x', scores: [], candidates: [] }), 'null']) {
|
|
237
|
+
assert.deepEqual(research.parseReply(bad, { seen }), { ok: false, error: 'malformed_reply' }, `input: ${bad}`);
|
|
238
|
+
}
|
|
239
|
+
const fenced = '```json\n' + JSON.stringify({ entries: [], scores: [], candidates: [] }) + '\n```';
|
|
240
|
+
assert.equal(research.parseReply(fenced, { seen }).ok, true, 'a fenced object is still the object');
|
|
241
|
+
const noSource = { entries: [{ type: 'news', takeaway: 'x', source_url: '', source_label: '', confidence: 'high', dimension: null }], scores: [], candidates: [] };
|
|
242
|
+
assert.equal(research.parseReply(JSON.stringify(noSource), { seen }).entries.length, 0, 'no source, no entry');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a refusal, a max_tokens or pause_turn stop, a tool error or a thrown call ends the item, costed', async () => {
|
|
246
|
+
const item = { name: 'Acme', kind: 'competitor' };
|
|
247
|
+
const rules = built.rules;
|
|
248
|
+
for (const stop of ['refusal', 'max_tokens', 'pause_turn']) {
|
|
249
|
+
const { client } = fakeClient(() => reply(GOOD, { stop }));
|
|
250
|
+
const r = await research.researchItem({ client, item, rules, today: '2026-11-20' });
|
|
251
|
+
assert.deepEqual([r.ok, r.error, r.costUsd], [false, stop, REPLY_COST]);
|
|
252
|
+
}
|
|
253
|
+
const toolErr = { type: 'web_fetch_tool_result', tool_use_id: 'f2', content: { type: 'web_fetch_tool_result_error', error_code: 'url_not_accessible' } };
|
|
254
|
+
let r = await research.researchItem({ client: fakeClient(() => reply(GOOD, { extra: [toolErr] })).client, item, rules, today: 'x' });
|
|
255
|
+
assert.deepEqual([r.ok, r.error], [false, 'tool_error:url_not_accessible']);
|
|
256
|
+
const capped = { type: 'web_search_tool_result', tool_use_id: 's9', content: { type: 'web_search_tool_result_error', error_code: 'max_uses_exceeded' } };
|
|
257
|
+
r = await research.researchItem({ client: fakeClient(() => reply(GOOD, { extra: [capped] })).client, item, rules, today: 'x' });
|
|
258
|
+
assert.equal(r.ok, true, 'the search cap refusing a 4th search is the cap working, not a failure');
|
|
259
|
+
r = await research.researchItem({ client: fakeClient(() => reply('I could not find anything.')).client, item, rules, today: 'x' });
|
|
260
|
+
assert.deepEqual([r.ok, r.error, r.costUsd], [false, 'malformed_reply', REPLY_COST]);
|
|
261
|
+
r = await research.researchItem({ client: { messages: { create: async () => { const e = new Error('overloaded'); e.status = 529; throw e; } } }, item, rules, today: 'x' });
|
|
262
|
+
assert.deepEqual([r.ok, r.error, r.costUsd], [false, 'api_error 529', 0]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('cost: tokens at the Sonnet 5 rate plus $0.01 a search, and an item ceiling that covers the bounds', () => {
|
|
266
|
+
assert.equal(WEB_SEARCH_USD_PER_REQUEST, 0.01);
|
|
267
|
+
assert.equal(priceWebSearches(3), 0.03);
|
|
268
|
+
assert.equal(priceWebSearches(undefined), 0);
|
|
269
|
+
assert.equal(research.priceResponse(reply(GOOD).usage), REPLY_COST);
|
|
270
|
+
assert.equal(research.priceResponse({ input_tokens: 1000, cache_read_input_tokens: 3000, output_tokens: 0 }), 0.0026);
|
|
271
|
+
const ceiling = research.itemCeilingUsd();
|
|
272
|
+
assert.ok(Math.abs(ceiling - 0.27) < 1e-9, `ceiling ${ceiling}`);
|
|
273
|
+
const worst = research.priceResponse({ input_tokens: 80_000, output_tokens: 8_000, server_tool_use: { web_search_requests: 3 } });
|
|
274
|
+
assert.ok(worst <= ceiling + 1e-9, 'a response at every bound fits in the ceiling');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// --- a run on seed data ----------------------------------------------------
|
|
278
|
+
|
|
279
|
+
test('a run on seed data writes sourced entries, score snapshots and proposals, and mirrors each item cost', async () => {
|
|
280
|
+
const { d } = deps();
|
|
281
|
+
const st = memStore();
|
|
282
|
+
const r = await run.runDue({ ...d, store: st });
|
|
283
|
+
assert.equal(r.outcome, 'done');
|
|
284
|
+
assert.equal(r.done, 4);
|
|
285
|
+
assert.equal(st.state.entries.length, 8, 'two sourced entries per item; the invented one never lands');
|
|
286
|
+
assert.ok(st.state.entries.every((e) => e.sourceUrl === NEWS_URL || e.sourceUrl === `${FETCHED_URL}/`));
|
|
287
|
+
assert.equal(st.state.scores.length, 8);
|
|
288
|
+
assert.ok(st.state.scores.every((s) => s.reason));
|
|
289
|
+
const runId = r.runId;
|
|
290
|
+
assert.equal(st.state.runs.find((x) => x.id === runId).status, 'done');
|
|
291
|
+
// The cost roll-up: one cost_log row per item, in-process, under market-watch:<run id>.
|
|
292
|
+
const rows = [...d.economy.rows.values()];
|
|
293
|
+
assert.equal(rows.length, 4);
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
assert.equal(row.source, `market-watch:${runId}`);
|
|
296
|
+
assert.match(row.sourceRef, /^item-\d+$/);
|
|
297
|
+
assert.equal(row.category, 'api');
|
|
298
|
+
assert.equal(row.amountUsd, REPLY_COST);
|
|
299
|
+
assert.equal(row.builderId, null, 'the project pays, not a builder');
|
|
300
|
+
assert.ok(row.description.includes(P), 'recorded against the project');
|
|
301
|
+
}
|
|
302
|
+
assert.ok(Math.abs(r.costUsd - 4 * REPLY_COST) < 1e-9);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('a new candidate becomes a proposal, never a tracked item', async () => {
|
|
306
|
+
const { d } = deps();
|
|
307
|
+
const st = memStore();
|
|
308
|
+
const before = st.state.items.length;
|
|
309
|
+
await run.runDue({ ...d, store: st });
|
|
310
|
+
assert.equal(st.state.items.length, before, 'no item added');
|
|
311
|
+
assert.ok(st.state.proposals.length >= 1);
|
|
312
|
+
assert.ok(st.state.proposals.every((p) => p.kind === 'add_item' && p.origin === 'run' && p.project === P));
|
|
313
|
+
// and the SQL that writes it targets proposals only, scoped to the project
|
|
314
|
+
assert.match(store.SQL.PROPOSAL_SQL, /INSERT INTO market_watch_proposals/);
|
|
315
|
+
assert.doesNotMatch(store.SQL.PROPOSAL_SQL, /INSERT INTO market_watch_items/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// --- nothing runs, and the row says why ---------------------------------------
|
|
319
|
+
|
|
320
|
+
test('Market Watch off: nothing runs, the run row says off', async () => {
|
|
321
|
+
const { d, fc, keysSeen } = deps();
|
|
322
|
+
const st = memStore({ settings: { enabled: false, monthly_cap_usd: 20, cadence: 'weekly' } });
|
|
323
|
+
const r = await run.runDue({ ...d, store: st });
|
|
324
|
+
assert.deepEqual([r.outcome, r.reason], ['skipped', 'off']);
|
|
325
|
+
assert.equal(st.state.runs.at(-1).status, 'skipped');
|
|
326
|
+
assert.equal(st.state.runs.at(-1).reason, 'off');
|
|
327
|
+
assert.equal(fc.calls.length + keysSeen.length, 0);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('no key: nothing runs, the run row says no_key', async () => {
|
|
331
|
+
const { d, fc, keysSeen } = deps({ d: { readKey: async () => null } });
|
|
332
|
+
const st = memStore();
|
|
333
|
+
const r = await run.runDue({ ...d, store: st });
|
|
334
|
+
assert.deepEqual([r.outcome, r.reason], ['skipped', 'no_key']);
|
|
335
|
+
assert.deepEqual([st.state.runs.at(-1).status, st.state.runs.at(-1).reason], ['skipped', 'no_key']);
|
|
336
|
+
assert.equal(fc.calls.length + keysSeen.length, 0);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// --- the cap ---------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
test('over the cap before the run: skipped and visible, with the cap and the spend recorded', async () => {
|
|
342
|
+
const { d, fc } = deps();
|
|
343
|
+
const st = memStore({ settings: { enabled: true, monthly_cap_usd: 5, cadence: 'weekly' },
|
|
344
|
+
runs: [{ id: 1, project: P, status: 'done', created_at: NOW - 10 * DAY }],
|
|
345
|
+
runItems: [{ run_id: 1, item_id: 1, status: 'done', cost_usd: 4.8 }] });
|
|
346
|
+
const r = await run.runDue({ ...d, store: st });
|
|
347
|
+
assert.deepEqual([r.outcome, r.reason], ['skipped', 'cap_reached']);
|
|
348
|
+
const row = st.state.runs.at(-1);
|
|
349
|
+
assert.deepEqual([row.status, row.reason, row.capUsd, row.spentBeforeUsd], ['skipped', 'cap_reached', 5, 4.8]);
|
|
350
|
+
assert.equal(fc.calls.length, 0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('the cap is checked before EVERY item: a run stops mid-way, partial, and never passes the cap', async () => {
|
|
354
|
+
const { d, fc } = deps();
|
|
355
|
+
// $0.70 cap, $0.27 ceiling, $0.075 an item: items 1..6 fit (0.375 + 0.27 = 0.645), the 7th would not.
|
|
356
|
+
const items = built.items.slice(0, 10).map((it, i) => ({ id: i + 1, project: P, kind: it.kind, name: it.name, url: it.url, status: 'active' }));
|
|
357
|
+
const st = memStore({ items, settings: { enabled: true, monthly_cap_usd: 0.7, cadence: 'weekly' } });
|
|
358
|
+
const r = await run.runDue({ ...d, store: st });
|
|
359
|
+
assert.deepEqual([r.outcome, r.reason, r.done], ['partial', 'cap_reached', 6]);
|
|
360
|
+
assert.equal(fc.calls.length, 6, 'no call after the cap check refused');
|
|
361
|
+
assert.equal(st.state.runs.at(-1).status, 'partial');
|
|
362
|
+
const spent = [...st.state.runItems.values()].reduce((a, x) => a + x.cost_usd, 0);
|
|
363
|
+
assert.ok(spent <= 0.7);
|
|
364
|
+
assert.ok(st.state.spendReads >= 7, 'the spend was re-read before each item');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('a cap of zero, or one that is not a number, allows nothing (fail closed)', async () => {
|
|
368
|
+
for (const cap of [0, -1, NaN, 'lots']) {
|
|
369
|
+
const { d, fc } = deps();
|
|
370
|
+
const st = memStore({ settings: { enabled: true, monthly_cap_usd: cap, cadence: 'weekly' } });
|
|
371
|
+
const r = await run.runDue({ ...d, store: st });
|
|
372
|
+
assert.equal(r.reason, 'cap_reached', `cap ${cap}`);
|
|
373
|
+
assert.equal(fc.calls.length, 0);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('an unreadable spend runs nothing: before the run it is a skipped row, mid-run it stops the run', async () => {
|
|
378
|
+
let { d, fc } = deps();
|
|
379
|
+
let st = memStore({ spendThrows: true });
|
|
380
|
+
let r = await run.runDue({ ...d, store: st });
|
|
381
|
+
assert.deepEqual([r.outcome, r.reason, fc.calls.length], ['skipped', 'spend_unreadable', 0]);
|
|
382
|
+
({ d, fc } = deps());
|
|
383
|
+
st = memStore({ spendThrowsAfter: 3 }); // the precheck read, then items 1 and 2
|
|
384
|
+
r = await run.runDue({ ...d, store: st });
|
|
385
|
+
assert.deepEqual([r.outcome, r.reason, r.done, fc.calls.length], ['partial', 'spend_unreadable', 2, 2]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// --- resume after a cut-off --------------------------------------------------
|
|
389
|
+
|
|
390
|
+
test('a cut-off run resumes only the items it has not finished, and never mirrors a cost twice', async () => {
|
|
391
|
+
const { d, fc } = deps();
|
|
392
|
+
const st = memStore({
|
|
393
|
+
runs: [{ id: 7, project: P, status: 'running', claimed_until: NOW - 60_000, created_at: NOW - 2 * 3600_000 }],
|
|
394
|
+
runItems: [
|
|
395
|
+
{ run_id: 7, item_id: 1, status: 'done', cost_usd: 0.09 },
|
|
396
|
+
{ run_id: 7, item_id: 2, status: 'error', error: 'refusal', cost_usd: 0.02 },
|
|
397
|
+
{ run_id: 7, item_id: 3, status: 'running' }, // the call in flight when the deploy hit
|
|
398
|
+
],
|
|
399
|
+
});
|
|
400
|
+
// The first run's mirrors are already in cost_log.
|
|
401
|
+
await d.economy.logCost({ source: 'market-watch:7', sourceRef: 'item-1', amountUsd: 0.09 });
|
|
402
|
+
d.economy.calls.length = 0;
|
|
403
|
+
const r = await run.runDue({ ...d, store: st });
|
|
404
|
+
assert.equal(r.resumed, true);
|
|
405
|
+
assert.equal(r.runId, 7);
|
|
406
|
+
assert.equal(fc.calls.length, 2, 'items 3 and 4 only');
|
|
407
|
+
assert.deepEqual(d.economy.calls.map((c) => c.sourceRef).sort(), ['item-3', 'item-4']);
|
|
408
|
+
assert.equal(st.state.runs[0].status, 'done');
|
|
409
|
+
// A second write of an already-recorded item is refused by the store, so nothing is mirrored.
|
|
410
|
+
const again = await st.recordItem(P, 7, 3, { ok: true, entries: [], scores: [], candidates: [], costUsd: 0.5 });
|
|
411
|
+
assert.equal(again.recorded, false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('a run still leased by another process is left alone (a lapsed lease is the resume case above)', async () => {
|
|
415
|
+
const { d, fc } = deps();
|
|
416
|
+
const st = memStore({ runs: [{ id: 9, project: P, status: 'running', claimed_until: NOW + 60_000, created_at: NOW - 600_000 }] });
|
|
417
|
+
assert.deepEqual(await run.runDue({ ...d, store: st }), { skipped: 'in_flight', runId: 9 });
|
|
418
|
+
assert.equal(fc.calls.length, 0);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('due by the ledger: a week after the last run (minus 30 minutes), never by the boot', () => {
|
|
422
|
+
assert.equal(run.isDue(null, 'weekly', NOW), true, 'no run yet');
|
|
423
|
+
assert.equal(run.isDue({ created_at: NOW - 3 * DAY }, 'weekly', NOW), false);
|
|
424
|
+
assert.equal(run.isDue({ created_at: NOW - 7 * DAY + 20 * 60_000 }, 'weekly', NOW), true);
|
|
425
|
+
assert.equal(run.isDue({ created_at: NOW - 7 * DAY + 40 * 60_000 }, 'weekly', NOW), false);
|
|
426
|
+
assert.equal(run.isDue({ created_at: NOW - 10 * DAY }, 'biweekly', NOW), false);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('a run that is not due does nothing at all', async () => {
|
|
430
|
+
const { d, fc } = deps();
|
|
431
|
+
const st = memStore({ runs: [{ id: 1, project: P, status: 'done', created_at: NOW - 2 * DAY }] });
|
|
432
|
+
assert.deepEqual(await run.runDue({ ...d, store: st }), { skipped: 'not_due' });
|
|
433
|
+
assert.equal(st.state.runs.length, 1, 'not even a skipped row');
|
|
434
|
+
assert.equal(fc.calls.length, 0);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// --- the cross-project guard -------------------------------------------------
|
|
438
|
+
|
|
439
|
+
test('a run never researches or writes another project\'s item', async () => {
|
|
440
|
+
const { d, fc } = deps();
|
|
441
|
+
const st = memStore();
|
|
442
|
+
st.state.items[1].project = OTHER; // a read that leaked a foreign row
|
|
443
|
+
const r = await run.runDue({ ...d, store: st });
|
|
444
|
+
assert.equal(r.refused, 1);
|
|
445
|
+
assert.equal(fc.calls.length, 3, 'no spend on the foreign item');
|
|
446
|
+
assert.ok(!st.state.entries.some((e) => e.item_id === 2));
|
|
447
|
+
assert.ok(![...d.economy.rows.values()].some((x) => x.sourceRef === 'item-2'));
|
|
448
|
+
// and the store itself refuses it, even if the caller did not
|
|
449
|
+
await assert.rejects(st.recordItem(P, r.runId, 2, { ok: true, entries: [], scores: [], candidates: [], costUsd: 0 }), /cross_project/);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('store.js: the guard is in the SQL, and a foreign item writes nothing', async () => {
|
|
453
|
+
const calls = [];
|
|
454
|
+
const client = {
|
|
455
|
+
async query(sql, params) { calls.push({ sql, params }); return /FROM market_watch_items i\s+JOIN market_watch_runs/.test(sql) ? { rows: [] } : { rows: [], rowCount: 0 }; },
|
|
456
|
+
release() {},
|
|
457
|
+
};
|
|
458
|
+
const pool = { connect: async () => client };
|
|
459
|
+
await assert.rejects(store.recordItem(pool, P, 5, 2, { ok: true, entries: [{ type: 'news', takeaway: 't', sourceUrl: NEWS_URL, sourceLabel: '', confidence: 2, dimension: null }], scores: [], candidates: [], costUsd: 0.1 }), /cross_project/);
|
|
460
|
+
assert.ok(!calls.some((c) => /INSERT/.test(c.sql)), 'nothing inserted');
|
|
461
|
+
assert.ok(calls.some((c) => c.sql === 'ROLLBACK'));
|
|
462
|
+
for (const sql of [store.SQL.GUARD_SQL, store.SQL.ENTRY_SQL, store.SQL.SCORE_SQL, store.SQL.START_ITEM_SQL]) {
|
|
463
|
+
assert.match(sql, /\.project = \$1/, `scoped to the project: ${sql.trim().slice(0, 50)}`);
|
|
464
|
+
}
|
|
465
|
+
assert.match(store.SQL.PROPOSAL_SQL, /SELECT \$1::text, 'add_item', 'run'/);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('store.js: an item result is recorded once; a second write rolls back and reports it', async () => {
|
|
469
|
+
const calls = [];
|
|
470
|
+
const client = {
|
|
471
|
+
async query(sql, params) {
|
|
472
|
+
calls.push(sql);
|
|
473
|
+
if (/JOIN market_watch_runs r ON r.project = i.project/.test(sql)) return { rows: [{ '?column?': 1 }] };
|
|
474
|
+
if (/UPDATE market_watch_run_items/.test(sql)) return { rows: [] }; // already finished
|
|
475
|
+
return { rows: [], rowCount: 1 };
|
|
476
|
+
},
|
|
477
|
+
release() {},
|
|
478
|
+
};
|
|
479
|
+
const r = await store.recordItem({ connect: async () => client }, P, 5, 2, { ok: false, error: 'refusal', costUsd: 0.02 });
|
|
480
|
+
assert.equal(r.recorded, false);
|
|
481
|
+
assert.equal(calls.at(-1), 'ROLLBACK');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// --- the poller --------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
test('the poller tick never throws, and is quiet on a machine with no database', async () => {
|
|
487
|
+
const boom = { openRun: async () => { throw new Error('connect ECONNREFUSED 127.0.0.1:5432'); } };
|
|
488
|
+
assert.deepEqual(await weekly.tick({ project: P, store: boom, readKey: async () => KEY }), { skipped: 'db_unreachable' });
|
|
489
|
+
const bad = { openRun: async () => { throw new Error('syntax error'); } };
|
|
490
|
+
let reported = 0;
|
|
491
|
+
const r = await weekly.tick({ project: P, store: bad, readKey: async () => KEY, reportError: () => { reported += 1; } });
|
|
492
|
+
assert.equal(r.skipped, 'error');
|
|
493
|
+
assert.equal(reported, 1);
|
|
494
|
+
assert.deepEqual(await weekly.tick({ project: null }), { skipped: 'no_project_origin' });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('the migration widens proposals for the run, adds the key columns, and one open run per project', () => {
|
|
498
|
+
const sql = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
|
|
499
|
+
assert.match(sql, /CHECK \(origin IN \('member', 'weekly_ask', 'run'\)\)/);
|
|
500
|
+
assert.match(sql, /anthropic_key_enc LIKE 'v1\.%'/);
|
|
501
|
+
assert.match(sql, /market_watch_runs_one_open\s+ON market_watch_runs \(project\) WHERE status IN \('queued', 'running'\)/);
|
|
502
|
+
assert.match(store.SQL.CREATE_SQL, /ON CONFLICT \(project\) WHERE status IN \('queued', 'running'\) DO NOTHING/);
|
|
503
|
+
});
|
package/tests/metrics.mjs
CHANGED
|
@@ -100,6 +100,96 @@ await test('httpMiddleware records request + duration + auth-failure on res fini
|
|
|
100
100
|
assert.doesNotMatch(out, /route="\/metrics"/, '/metrics scrape is not self-counted');
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// task 1004924 — the label leak. A scanner crawl of random 404 paths used to mint
|
|
104
|
+
// a new counter AND histogram series per path, held forever.
|
|
105
|
+
await test('a crawl of 404 paths collapses to ONE unmatched series, not one per path', async () => {
|
|
106
|
+
metrics.__resetForTests();
|
|
107
|
+
const mw = metrics.httpMiddleware();
|
|
108
|
+
for (let i = 0; i < 300; i++) {
|
|
109
|
+
const req = fakeReq({ path: `/wp-admin/probe-${i}/x.php`, method: 'GET' });
|
|
110
|
+
const res = new EventEmitter(); res.statusCode = 404;
|
|
111
|
+
mw(req, res, () => {}); res.emit('finish');
|
|
112
|
+
}
|
|
113
|
+
const out = await metrics.render(fakePool);
|
|
114
|
+
assert.match(out, /gds_http_requests_total\{method="GET",route="unmatched",status="404"\} 300/);
|
|
115
|
+
assert.doesNotMatch(out, /probe-/, 'no literal scanner path becomes a label');
|
|
116
|
+
assert.equal((out.match(/^gds_http_requests_total\{/gm) || []).length, 1, 'one counter series');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await test('a matched route labels by mount path + pattern, never the literal path', async () => {
|
|
120
|
+
metrics.__resetForTests();
|
|
121
|
+
const mw = metrics.httpMiddleware();
|
|
122
|
+
const hit = (req, status) => {
|
|
123
|
+
const res = new EventEmitter(); res.statusCode = status;
|
|
124
|
+
mw({ method: 'GET', ...req }, res, () => {}); res.emit('finish');
|
|
125
|
+
};
|
|
126
|
+
const routed = { baseUrl: '/api/bongos', route: { path: '/tasks/:id' } };
|
|
127
|
+
hit({ path: '/tasks/1380', ...routed }, 200);
|
|
128
|
+
// a matched route that answers 404 (task not found) keeps its own label
|
|
129
|
+
hit({ path: '/tasks/999999', ...routed }, 404);
|
|
130
|
+
// a mount path carrying a literal id is normalized, not kept verbatim
|
|
131
|
+
hit({ path: '/x', baseUrl: '/api/projects/77', route: { path: '/x' } }, 200);
|
|
132
|
+
// unrouted, non-404 (a static file) keeps the normalized path
|
|
133
|
+
hit({ path: '/assets/atlas-89d47b50abcd' }, 200);
|
|
134
|
+
const out = await metrics.render(fakePool);
|
|
135
|
+
assert.match(out, /route="\/api\/bongos\/tasks\/:id",status="200"\} 1/);
|
|
136
|
+
assert.match(out, /route="\/api\/bongos\/tasks\/:id",status="404"\} 1/);
|
|
137
|
+
assert.match(out, /route="\/api\/projects\/:id\/x",status="200"\} 1/);
|
|
138
|
+
assert.match(out, /route="\/assets\/:hash",status="200"\} 1/);
|
|
139
|
+
// Scoped to route labels: a duration _sum such as 0.0000393999999... matched a
|
|
140
|
+
// bare /999999/ and redded this test at random.
|
|
141
|
+
assert.doesNotMatch(out, /route="[^"]*(1380|999999|projects\/77)/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('through real Express: matched routes label by pattern, unknown paths are unmatched', async () => {
|
|
145
|
+
const express = require('express');
|
|
146
|
+
metrics.__resetForTests();
|
|
147
|
+
const app = express();
|
|
148
|
+
app.use(metrics.httpMiddleware());
|
|
149
|
+
const router = express.Router();
|
|
150
|
+
router.get('/tasks/:id', (req, res) => res.json({ id: req.params.id }));
|
|
151
|
+
app.use('/api/bongos', router);
|
|
152
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
153
|
+
try {
|
|
154
|
+
const port = server.address().port;
|
|
155
|
+
// node:http with no keep-alive agent, not fetch: pooled fetch sockets still
|
|
156
|
+
// closing at process.exit abort the run on Windows (libuv assertion).
|
|
157
|
+
const get = (p) => new Promise((resolve, reject) => {
|
|
158
|
+
require('node:http').get({ host: '127.0.0.1', port, path: p, agent: false }, (res) => { res.resume(); res.on('end', resolve); }).on('error', reject);
|
|
159
|
+
});
|
|
160
|
+
for (const p of ['/api/bongos/tasks/1', '/api/bongos/tasks/abc', '/.env', '/wp-login.php', '/random-slug-zz']) await get(p);
|
|
161
|
+
await new Promise((r) => setTimeout(r, 20)); // let every 'finish' land
|
|
162
|
+
} finally { await new Promise((r) => server.close(r)); }
|
|
163
|
+
const out = await metrics.render(fakePool);
|
|
164
|
+
assert.match(out, /route="\/api\/bongos\/tasks\/:id",status="200"\} 2/);
|
|
165
|
+
assert.match(out, /route="unmatched",status="404"\} 3/);
|
|
166
|
+
assert.doesNotMatch(out, /\.env|wp-login|random-slug/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await test('series cap: past the cap a metric folds new label sets into one overflow series', async () => {
|
|
170
|
+
metrics.__resetForTests();
|
|
171
|
+
// The cap's exact value is not the contract; that it exists and is modest is.
|
|
172
|
+
const N = 12000;
|
|
173
|
+
for (let i = 0; i < N; i++) {
|
|
174
|
+
metrics.incCounter('cap_test_total', 'h', { method: 'GET', route: `/static/f${i}.png`, status: '200' });
|
|
175
|
+
metrics.observeHistogram('cap_test_seconds', 'h', { method: 'GET', route: `/static/f${i}.png` }, 0.01);
|
|
176
|
+
metrics.incCounter('cap_noroute_total', 'h', { builder: `b${i}` });
|
|
177
|
+
}
|
|
178
|
+
const out = await metrics.render(fakePool);
|
|
179
|
+
const counterSeries = (out.match(/^cap_test_total\{/gm) || []).length;
|
|
180
|
+
assert.ok(counterSeries > 1 && counterSeries <= 10001, `bounded: ${counterSeries} series for ${N} label sets`);
|
|
181
|
+
const folded = N - (counterSeries - 1);
|
|
182
|
+
// assert.ok, not match: a failing match would dump a multi-megabyte render
|
|
183
|
+
assert.ok(new RegExp(`cap_test_total\\{method="GET",route="other",status="200"\\} ${folded}\\n`).test(out), 'overflow counter series holds every folded hit');
|
|
184
|
+
assert.ok(new RegExp(`cap_test_seconds_count\\{method="GET",route="other"\\} ${folded}\\n`).test(out), 'overflow histogram series');
|
|
185
|
+
// a metric with no route label folds every label to 'other' — still one series
|
|
186
|
+
assert.equal((out.match(/^cap_noroute_total\{/gm) || []).length, counterSeries);
|
|
187
|
+
assert.ok(/cap_noroute_total\{builder="other"\}/.test(out), 'route-less overflow series');
|
|
188
|
+
// an EXISTING series still counts normally once the cap is reached
|
|
189
|
+
metrics.incCounter('cap_test_total', 'h', { method: 'GET', route: '/static/f0.png', status: '200' });
|
|
190
|
+
assert.ok(/cap_test_total\{method="GET",route="\/static\/f0\.png",status="200"\} 2/.test(await metrics.render(fakePool)), 'existing series still counts');
|
|
191
|
+
});
|
|
192
|
+
|
|
103
193
|
await test('authorizeScrape: local Prometheus yes, public no, bearer token yes', () => {
|
|
104
194
|
// loopback, no proxy header → allowed (the local agent)
|
|
105
195
|
assert.equal(metrics.authorizeScrape(fakeReq({ remote: '127.0.0.1' })), true);
|
|
@@ -144,4 +234,38 @@ await test('render is resilient: a DB failure still yields process metrics + db_
|
|
|
144
234
|
assert.match(out, /gds_db_scrape_ok\{?\}? ?0/);
|
|
145
235
|
});
|
|
146
236
|
|
|
237
|
+
// Task 1004961: a 1 s lag timer kept an idle instance awake forever. Load the
|
|
238
|
+
// module fresh with setInterval spied, and pin that every interval it arms is
|
|
239
|
+
// at least 5 s (the audit's allowed 5-10 s sampling window) and unref'd.
|
|
240
|
+
await test('no sub-5-second interval is armed at load (event-loop lag sampler)', async () => {
|
|
241
|
+
const realSetInterval = globalThis.setInterval;
|
|
242
|
+
const armed = [];
|
|
243
|
+
globalThis.setInterval = (fn, ms, ...rest) => {
|
|
244
|
+
const t = realSetInterval(fn, ms, ...rest);
|
|
245
|
+
const unref = t.unref.bind(t);
|
|
246
|
+
const rec = { ms, unrefd: false };
|
|
247
|
+
t.unref = () => { rec.unrefd = true; return unref(); };
|
|
248
|
+
armed.push({ rec, t });
|
|
249
|
+
return t;
|
|
250
|
+
};
|
|
251
|
+
const key = require.resolve('../src/bongos/metrics.js');
|
|
252
|
+
const cached = require.cache[key];
|
|
253
|
+
try {
|
|
254
|
+
delete require.cache[key];
|
|
255
|
+
const fresh = require('../src/bongos/metrics.js');
|
|
256
|
+
assert.ok(armed.length >= 1, 'the lag sampler arms an interval');
|
|
257
|
+
for (const { rec } of armed) {
|
|
258
|
+
assert.ok(rec.ms >= 5000 && rec.ms <= 10_000, `interval of ${rec.ms} ms is outside 5-10 s`);
|
|
259
|
+
assert.ok(rec.unrefd, 'the sampler interval is unref\'d');
|
|
260
|
+
}
|
|
261
|
+
const out = await fresh.render({ async query() { return { rows: [] }; } });
|
|
262
|
+
assert.match(out, /# TYPE gds_nodejs_eventloop_lag_seconds gauge/);
|
|
263
|
+
assert.match(out, /gds_nodejs_eventloop_lag_seconds\{?\}? ?0/);
|
|
264
|
+
} finally {
|
|
265
|
+
globalThis.setInterval = realSetInterval;
|
|
266
|
+
for (const { t } of armed) clearInterval(t);
|
|
267
|
+
require.cache[key] = cached;
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
147
271
|
summary();
|