@bongos/core 1.18.3 → 1.18.19
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 +1576 -286
- package/.claude/skills/builder-box/SKILL.md +6 -0
- package/.claude/skills/builder-sequence/SKILL.md +97 -0
- package/.claude/skills/builder-ship/SKILL.md +8 -2
- package/.claude/skills/builder-start/SKILL.md +11 -0
- package/.claude/skills/builder-sync/SKILL.md +41 -0
- package/.claude/skills/fix-task/SKILL.md +64 -0
- package/.claude/skills/goal-create/SKILL.md +158 -0
- package/.claude/skills/goal-review/SKILL.md +3 -1
- package/.claude/skills/idea-triage/SKILL.md +14 -8
- package/.claude/skills/merge-mode/SKILL.md +2 -2
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +45 -18
- package/.env.local.example +4 -3
- package/README.md +4 -2
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +2 -2
- package/clients/bongos-client/bongos-client.global.js +82 -18
- package/clients/bongos-client/index.cjs +82 -18
- package/clients/bongos-client/index.d.ts +131 -29
- package/clients/bongos-client/index.mjs +82 -18
- package/config/branding.neutral.json +6 -1
- package/config/governance.neutral.json +21 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
- package/docs/adr/0090-metic-task-authoring.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
- package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
- package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
- package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
- package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
- package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
- package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
- package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
- package/docs/adr/0152-metic-task-abandonment.md +67 -0
- package/docs/adr/0153-sequential-chain-runner.md +51 -0
- package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
- package/docs/adr/README.md +10 -0
- package/docs/api/openapi.json +843 -17
- package/docs/api-reference.md +23 -10
- package/docs/architecture.md +4 -2
- package/docs/branding-contract.md +3 -1
- package/docs/canonical-permissions.md +21 -8
- package/docs/file-map.md +8 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
- package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
- package/docs/onboarding/diagrams/assertions.json +4 -7
- package/docs/onboarding/drachmae-and-karma.md +3 -3
- package/docs/onboarding/primer.md +17 -16
- package/docs/onboarding/slash-commands.md +124 -0
- package/docs/recipes/builder-box-lifecycle.md +23 -0
- package/docs/recipes/devbox-app-release.md +14 -4
- package/docs/recipes/instance-first-run.md +16 -0
- package/docs/recipes/local-dev.md +81 -0
- package/docs/recipes/ops-gotchas.md +19 -1
- package/docs/recipes/overnight-builder.md +1 -1
- package/docs/recipes/parallel-goal-waves.md +174 -0
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/standalone-live-docs.md +48 -0
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
- package/migrations/003_pms.sql +6 -7
- package/migrations/006_pms_v2.sql +3 -31
- package/migrations/core_193_task_needs_rebase.sql +28 -0
- package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
- package/migrations/core_195_idea_triage_provenance.sql +57 -0
- package/migrations/core_196_session_reward_topup.sql +69 -0
- package/modules/autonomy/pollers/routine-timers.js +9 -8
- package/modules/builder-settings/wandering-prefs.js +61 -18
- package/modules/dev-box/app/README.md +73 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
- package/modules/dev-box/app/electron-builder.yml +95 -0
- package/modules/dev-box/app/package-lock.json +5291 -0
- package/modules/dev-box/app/package.json +30 -0
- package/modules/dev-box/app/renderer/app.js +319 -0
- package/modules/dev-box/app/renderer/index.html +45 -0
- package/modules/dev-box/app/renderer/styles.css +246 -0
- package/modules/dev-box/app/renderer/theme.generated.css +16 -0
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
- package/modules/dev-box/app/scripts/gen-brand.js +138 -0
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
- package/modules/dev-box/app/scripts/gen-icons.js +286 -0
- package/modules/dev-box/app/scripts/vendor-client.js +23 -0
- package/modules/dev-box/app/src/box-controller.js +287 -0
- package/modules/dev-box/app/src/brand.generated.json +13 -0
- package/modules/dev-box/app/src/brand.js +16 -0
- package/modules/dev-box/app/src/config.js +19 -0
- package/modules/dev-box/app/src/gds-client.js +90 -0
- package/modules/dev-box/app/src/main.js +610 -0
- package/modules/dev-box/app/src/pairing.js +57 -0
- package/modules/dev-box/app/src/preload.js +29 -0
- package/modules/dev-box/app/src/session-store.js +58 -0
- package/modules/dev-box/app/src/ssh-setup.js +569 -0
- package/modules/dev-box/app/src/task-picker.js +70 -0
- package/modules/dev-box/app/src/tray.js +99 -0
- package/modules/dev-box/app/src/updater.js +55 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
- package/modules/dev-box/app/test/unit.mjs +526 -0
- package/modules/dev-box/box-access.js +121 -4
- package/modules/dev-box/boxes.js +20 -0
- package/modules/dev-box/module.json +1 -1
- package/modules/dev-box/routes/box.js +87 -3
- package/modules/economy/credits.js +116 -25
- package/modules/economy/reward.js +8 -0
- package/modules/economy/streak.js +2 -1
- package/modules/governance/CLAUDE.md +15 -0
- package/modules/governance/catalog.js +419 -0
- package/modules/governance/config.js +174 -0
- package/modules/governance/db.js +125 -0
- package/modules/governance/migrations/governance_001_tables.sql +81 -0
- package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
- package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
- package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
- package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
- package/modules/governance/module.json +10 -0
- package/modules/governance/protected-surfaces.js +88 -0
- package/modules/governance/protected-surfaces.json +80 -0
- package/modules/governance/rank-roles.js +83 -0
- package/modules/governance/resolver.js +221 -0
- package/modules/governance/routes/governance.js +89 -0
- package/modules/governance/routes/role-sync.js +49 -0
- package/modules/governance/session-scopes.js +127 -0
- package/modules/governance/session-scopes.json +38 -0
- package/modules/hall-ui/public/atlas.css +117 -0
- package/modules/hall-ui/public/atlas.html +3 -114
- package/modules/hall-ui/public/brand-apply.js +34 -6
- package/modules/hall-ui/public/builders.js +187 -219
- package/modules/hall-ui/public/diagrams.html +2 -0
- package/modules/hall-ui/public/dom-utils.js +27 -0
- package/modules/hall-ui/public/drachmae.html +2 -0
- package/modules/hall-ui/public/drachmae.js +54 -1
- package/modules/hall-ui/public/gate.css +98 -0
- package/modules/hall-ui/public/gate.html +3 -95
- package/modules/hall-ui/public/goal-inbox.js +4 -1
- package/modules/hall-ui/public/goals-page.js +626 -114
- package/modules/hall-ui/public/goals.css +79 -1
- package/modules/hall-ui/public/goals.html +38 -11
- package/modules/hall-ui/public/hall-widgets.js +62 -52
- package/modules/hall-ui/public/harbor.css +123 -0
- package/modules/hall-ui/public/harbor.html +3 -120
- package/modules/hall-ui/public/index.html +11 -4
- package/modules/hall-ui/public/instances-panel.js +4 -1
- package/modules/hall-ui/public/modules.css +63 -0
- package/modules/hall-ui/public/modules.html +3 -60
- package/modules/hall-ui/public/new-project-wizard.js +7 -3
- package/modules/hall-ui/public/not-ready.html +2 -0
- package/modules/hall-ui/public/pair.css +73 -0
- package/modules/hall-ui/public/pair.html +3 -70
- package/modules/hall-ui/public/palette.js +302 -0
- package/modules/hall-ui/public/primer.css +13 -0
- package/modules/hall-ui/public/primer.html +2 -0
- package/modules/hall-ui/public/profile.html +2 -0
- package/modules/hall-ui/public/ranks.html +2 -0
- package/modules/hall-ui/public/roadmap.css +179 -0
- package/modules/hall-ui/public/roadmap.html +9 -172
- package/modules/hall-ui/public/roadmap.js +329 -316
- package/modules/hall-ui/public/sessions.css +16 -0
- package/modules/hall-ui/public/sessions.html +3 -13
- package/modules/hall-ui/public/settings.html +2 -0
- package/modules/hall-ui/public/settings.js +4 -4
- package/modules/hall-ui/public/shell.js +143 -18
- package/modules/hall-ui/public/style.css +231 -39
- package/modules/hall-ui/public/task-detail.js +347 -26
- package/modules/hall-ui/public/task.html +6 -1
- package/modules/hall-ui/public/watch.html +2 -0
- package/modules/hall-ui/public/work.html +8 -6
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/ideas/inbox.js +61 -11
- package/modules/ideas/routes/inbox.js +23 -0
- package/modules/lifecycle/claim-eligibility.js +72 -0
- package/modules/lifecycle/db.js +519 -139
- package/modules/lifecycle/done-when.js +18 -3
- package/modules/lifecycle/github-push.js +19 -2
- package/modules/lifecycle/goal-advisory.js +114 -0
- package/modules/lifecycle/goal-authz.js +62 -0
- package/modules/lifecycle/lifecycle.js +9 -0
- package/modules/lifecycle/publish-reconciler.js +109 -6
- package/modules/lifecycle/routes/claims.js +68 -23
- package/modules/lifecycle/routes/done-when.js +110 -15
- package/modules/lifecycle/routes/goals.js +8 -38
- package/modules/lifecycle/routes/tasks.js +38 -6
- package/modules/lifecycle/ship-card.js +6 -0
- package/modules/onboarding/onboarding-state.js +1 -1
- package/modules/platform-identity/CLAUDE.md +15 -0
- package/modules/platform-identity/hub-device.js +147 -0
- package/modules/platform-identity/hub-keys.js +151 -0
- package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
- package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
- package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
- package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
- package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
- package/modules/platform-identity/module.json +18 -0
- package/modules/platform-identity/platform-identity.js +531 -0
- package/modules/platform-identity/pollers/code-sweep.js +43 -0
- package/modules/platform-identity/routes/my-projects.js +140 -0
- package/modules/platform-identity/routes/projects.js +107 -0
- package/modules/platform-identity/routes/scouting.js +35 -0
- package/modules/platform-identity/routes/sso.js +402 -0
- package/modules/platform-identity/tests/platform-identity.mjs +388 -0
- package/modules/platform-identity/ui/.gitkeep +0 -0
- package/modules/security/routes/reports.js +10 -0
- package/modules/sessions/db.js +166 -21
- package/modules/sessions/module.json +1 -1
- package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
- package/modules/sessions/routes/sessions.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/anim/idle/frame_00.webp +0 -0
- package/public-landing/assets/anim/idle/frame_01.webp +0 -0
- package/public-landing/assets/anim/idle/frame_02.webp +0 -0
- package/public-landing/assets/anim/idle/frame_03.webp +0 -0
- package/public-landing/assets/anim/idle/frame_04.webp +0 -0
- package/public-landing/assets/anim/idle/frame_05.webp +0 -0
- package/public-landing/assets/anim/idle/frame_06.webp +0 -0
- package/public-landing/assets/anim/idle/frame_07.webp +0 -0
- package/public-landing/assets/anim/idle/frame_08.webp +0 -0
- package/public-landing/assets/anim/idle/frame_09.webp +0 -0
- package/public-landing/assets/anim/idle/frame_10.webp +0 -0
- package/public-landing/assets/anim/idle/frame_11.webp +0 -0
- package/public-landing/assets/anim/idle/frame_12.webp +0 -0
- package/public-landing/assets/anim/idle/frame_13.webp +0 -0
- package/public-landing/assets/anim/idle/frame_14.webp +0 -0
- package/public-landing/assets/anim/idle/frame_15.webp +0 -0
- package/public-landing/assets/anim/idle/frame_16.webp +0 -0
- package/public-landing/assets/anim/idle/frame_17.webp +0 -0
- package/public-landing/assets/anim/idle/frame_18.webp +0 -0
- package/public-landing/assets/anim/idle/frame_19.webp +0 -0
- package/public-landing/assets/anim/idle/frame_20.webp +0 -0
- package/public-landing/assets/anim/idle/frame_21.webp +0 -0
- package/public-landing/assets/anim/idle/frame_22.webp +0 -0
- package/public-landing/assets/anim/idle/frame_23.webp +0 -0
- package/public-landing/assets/anim/idle/frame_24.webp +0 -0
- package/public-landing/assets/anim/idle/frame_25.webp +0 -0
- package/public-landing/assets/anim/idle/frame_26.webp +0 -0
- package/public-landing/assets/anim/idle/frame_27.webp +0 -0
- package/public-landing/assets/anim/idle/frame_28.webp +0 -0
- package/public-landing/assets/anim/idle/frame_29.webp +0 -0
- package/public-landing/assets/anim/idle/frame_30.webp +0 -0
- package/public-landing/assets/anim/idle/frame_31.webp +0 -0
- package/public-landing/assets/anim/idle/frame_32.webp +0 -0
- package/public-landing/assets/anim/idle/frame_33.webp +0 -0
- package/public-landing/assets/anim/idle/frame_34.webp +0 -0
- package/public-landing/assets/anim/idle/frame_35.webp +0 -0
- package/public-landing/assets/anim/idle/frame_36.webp +0 -0
- package/public-landing/assets/anim/idle/frame_37.webp +0 -0
- package/public-landing/assets/anim/idle/frame_38.webp +0 -0
- package/public-landing/assets/anim/idle/frame_39.webp +0 -0
- package/public-landing/assets/anim/idle/frame_40.webp +0 -0
- package/public-landing/assets/anim/idle/frame_41.webp +0 -0
- package/public-landing/assets/anim/idle/frame_42.webp +0 -0
- package/public-landing/assets/anim/idle/frame_43.webp +0 -0
- package/public-landing/create.html +51 -58
- package/public-landing/gate/README.md +137 -0
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +587 -0
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +799 -0
- package/public-landing/gate/space.js +1826 -0
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
- package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
- package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
- package/public-landing/world/assets/plates/scene-establishing.png +0 -0
- package/scripts/gds/abandon.js +6 -4
- package/scripts/gds/api.js +10 -5
- package/scripts/gds/api.ps1 +26 -0
- package/scripts/gds/api.sh +6 -0
- package/scripts/gds/audit-ships.js +7 -1
- package/scripts/gds/backfill-sessions.js +78 -18
- package/scripts/gds/backup.js +23 -1
- package/scripts/gds/box-connect-lib.js +124 -31
- package/scripts/gds/box-sync.js +66 -0
- package/scripts/gds/box.js +225 -53
- package/scripts/gds/cf-tunnel.js +145 -6
- package/scripts/gds/claim.js +34 -3
- package/scripts/gds/cli-lib.js +161 -27
- package/scripts/gds/code.js +6 -2
- package/scripts/gds/deploy-divergence.js +209 -0
- package/scripts/gds/dev-lib.js +309 -0
- package/scripts/gds/dev.js +322 -0
- package/scripts/gds/doctor.js +90 -61
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/fetch-sound-prefs.js +2 -2
- package/scripts/gds/fitness.js +228 -12
- package/scripts/gds/gds-db-backup.service +16 -1
- package/scripts/gds/gds-db-offsite.service +9 -1
- package/scripts/gds/gen-api-client.js +1 -1
- package/scripts/gds/gen-api-docs.js +36 -14
- package/scripts/gds/gen-diagrams.js +45 -28
- package/scripts/gds/gen-file-map.js +2 -1
- package/scripts/gds/gen-freshness.js +32 -0
- package/scripts/gds/gen-repo-map.js +3 -2
- package/scripts/gds/gen-session-index.js +14 -5
- package/scripts/gds/hooks/pre-push.js +2 -2
- package/scripts/gds/init.js +60 -3
- package/scripts/gds/install-git-hooks.js +1 -1
- package/scripts/gds/key-card.js +15 -6
- package/scripts/gds/key-status.js +1 -1
- package/scripts/gds/linkify-refs.js +35 -9
- package/scripts/gds/login.js +16 -1
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +123 -12
- package/scripts/gds/publish-manifest.js +7 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +4 -2
- package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
- package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
- package/scripts/gds/sequence.js +425 -0
- package/scripts/gds/session-cost.js +10 -1
- package/scripts/gds/setup.js +31 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +319 -29
- package/scripts/gds/smoke-gds.js +139 -8
- package/scripts/gds/start.js +171 -22
- package/scripts/gds/status.js +1 -1
- package/scripts/gds/triage.js +10 -9
- package/scripts/gds/upgrade.js +124 -2
- package/scripts/migrate.sh +99 -14
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +447 -102
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +39 -0
- package/src/bongos/module-scope-map.js +27 -1
- package/src/bongos/permission-path-check.js +136 -102
- package/src/bongos/repo-info.js +47 -11
- package/src/bongos/route-rank-check.js +429 -46
- package/src/bongos/routes/_helpers.js +12 -1
- package/src/bongos/routes/auth.js +219 -2
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +32 -0
- package/src/instance-config.js +30 -0
- package/src/module-api.js +124 -1
- package/src/platform-server.js +86 -9
- package/tests/abandon_task.mjs +59 -3
- package/tests/api_cli_flags.mjs +53 -0
- package/tests/auth_page_gate.mjs +13 -0
- package/tests/backfill_sessions.mjs +127 -1
- package/tests/backup_cli.mjs +54 -0
- package/tests/bongos_box_connect.mjs +77 -0
- package/tests/box_access.mjs +130 -1
- package/tests/box_credential.mjs +125 -0
- package/tests/box_host_keys.mjs +24 -0
- package/tests/box_infra_resolve.mjs +149 -0
- package/tests/box_scope_session.mjs +6 -2
- package/tests/box_sync.mjs +55 -0
- package/tests/box_task_scope.mjs +99 -0
- package/tests/branding.mjs +12 -0
- package/tests/cf_ruleset_preflight.mjs +142 -0
- package/tests/cf_tunnel.mjs +37 -0
- package/tests/claim_gate_rebase.mjs +98 -0
- package/tests/clawback_confirmed_backlog.mjs +103 -0
- package/tests/cli_lib_formatters.mjs +20 -0
- package/tests/confirm_attribution.mjs +30 -46
- package/tests/credit_grant.mjs +198 -10
- package/tests/criterion_authoring_gate.mjs +272 -0
- package/tests/criterion_progress.mjs +33 -1
- package/tests/dev_lib.mjs +253 -0
- package/tests/devbox_artifact_gate.mjs +75 -0
- package/tests/devbox_dmg_backdrop.mjs +137 -0
- package/tests/devbox_icons.mjs +304 -0
- package/tests/devbox_installer_brand.mjs +232 -0
- package/tests/devbox_key_register.mjs +234 -0
- package/tests/docs_openapi_route.mjs +110 -0
- package/tests/doctor_hooks.mjs +141 -0
- package/tests/extract_json.mjs +140 -0
- package/tests/fitness.mjs +89 -0
- package/tests/gen_freshness.mjs +96 -0
- package/tests/goal_advisory.mjs +251 -0
- package/tests/goal_criterion_confirm_ui.mjs +269 -0
- package/tests/goal_routes.mjs +59 -4
- package/tests/goal_tier.mjs +12 -3
- package/tests/governance_catalog.mjs +129 -0
- package/tests/governance_claim_eligibility.mjs +262 -0
- package/tests/governance_config.mjs +187 -0
- package/tests/governance_ownership_scope.mjs +410 -0
- package/tests/governance_page_gates.mjs +324 -0
- package/tests/governance_parity.mjs +336 -0
- package/tests/governance_principals.mjs +232 -0
- package/tests/governance_protected_surfaces.mjs +208 -0
- package/tests/governance_require_permission.mjs +223 -0
- package/tests/governance_resolver.mjs +84 -0
- package/tests/governance_role_sync.mjs +231 -0
- package/tests/governance_seed.mjs +127 -0
- package/tests/governance_session_scope.mjs +434 -0
- package/tests/governance_vanilla_boot.mjs +203 -0
- package/tests/grade_attribution.mjs +7 -7
- package/tests/hall_audit.mjs +223 -0
- package/tests/hall_nav.mjs +191 -0
- package/tests/hall_palette.mjs +206 -0
- package/tests/hall_signout.mjs +124 -0
- package/tests/hook_user_agents.mjs +10 -0
- package/tests/idea_triage.mjs +162 -0
- package/tests/idp_device_exchange.mjs +93 -0
- package/tests/init.mjs +51 -0
- package/tests/instance_config.mjs +65 -0
- package/tests/instance_mismatch_guard.mjs +23 -6
- package/tests/instance_release_config.mjs +104 -0
- package/tests/linkify_refs.mjs +15 -2
- package/tests/migrate_root_resolution.mjs +124 -1
- package/tests/module_contributions.mjs +55 -0
- package/tests/module_loader.mjs +10 -5
- package/tests/module_scope_active_claims.mjs +94 -0
- package/tests/needs_rebase.mjs +92 -0
- package/tests/one_active_claim_per_session.mjs +76 -0
- package/tests/override_attribution.mjs +10 -8
- package/tests/override_request_ownership.mjs +8 -4
- package/tests/parse_cookie_decode.mjs +42 -0
- package/tests/pay_on_land.mjs +129 -0
- package/tests/platform_boot.mjs +100 -1
- package/tests/post_login_landing.mjs +42 -0
- package/tests/preflight_checks.mjs +72 -0
- package/tests/provision.mjs +132 -7
- package/tests/publish_branch_route.mjs +53 -0
- package/tests/publish_reconciler.mjs +187 -2
- package/tests/repo_info_binding.mjs +248 -0
- package/tests/reward_gate.mjs +11 -2
- package/tests/reward_mode_cost_plus_only.mjs +116 -0
- package/tests/reward_mode_surfacing.mjs +168 -0
- package/tests/route_permission_check.mjs +301 -0
- package/tests/route_rank_check.mjs +254 -12
- package/tests/route_validation.mjs +30 -0
- package/tests/sequence.mjs +267 -0
- package/tests/session_digest.mjs +44 -0
- package/tests/session_index.mjs +18 -6
- package/tests/session_records.mjs +39 -1
- package/tests/session_reward_on_land.mjs +381 -0
- package/tests/session_reward_reconcile.mjs +229 -0
- package/tests/ship_api_client_pathspec.mjs +100 -0
- package/tests/ship_base_flag.mjs +93 -0
- package/tests/ship_ci_deploy.mjs +80 -3
- package/tests/ship_upstream_deletion.mjs +57 -0
- package/tests/smoke_target.mjs +233 -0
- package/tests/start_env_guard.mjs +87 -0
- package/tests/start_rebase_warning.mjs +103 -0
- package/tests/status_mirror_sync.mjs +122 -0
- package/tests/stop_refcheck.mjs +19 -1
- package/tests/task_goal_link.mjs +15 -6
- package/tests/upgrade.mjs +139 -0
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
package/tests/session_digest.mjs
CHANGED
|
@@ -69,5 +69,49 @@ test('parseHookInput: partial payload (only session_id) leaves the rest for fall
|
|
|
69
69
|
assert.equal(r.cwd, '/c'); // no cwd in payload → keeps the fallback
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
// --- snippetOf must never emit a NUL (task 1002507) -------------------------
|
|
73
|
+
// Postgres rejects a literal NUL inside jsonb, and `detail` is stored as jsonb.
|
|
74
|
+
// One NUL in one snippet therefore made POST /sessions answer 500 and discarded
|
|
75
|
+
// the ENTIRE digest -- so the session recorded nothing and, on a cost-plus-only
|
|
76
|
+
// instance, earned nothing. UTF-16LE tool output is the ordinary trigger.
|
|
77
|
+
//
|
|
78
|
+
// NUL is built with String.fromCharCode(0) so this FILE stays plain ASCII: a
|
|
79
|
+
// literal NUL in the source would make git and grep treat it as binary.
|
|
80
|
+
const ship = require('../scripts/gds/ship.js');
|
|
81
|
+
const NUL = String.fromCharCode(0);
|
|
82
|
+
const hasNul = (v) => typeof v === 'string' && v.indexOf(NUL) !== -1;
|
|
83
|
+
|
|
84
|
+
test('snippetOf strips NUL from UTF-16LE-looking tool output', () => {
|
|
85
|
+
// `wsl --list` read as UTF-8: every other byte is a NUL.
|
|
86
|
+
const wsl = 'NAME'.split('').join(NUL) + NUL + ' STATE' + NUL;
|
|
87
|
+
const out = ship.snippetOf(wsl, []);
|
|
88
|
+
assert.ok(out, 'a NUL-laden string must still yield a snippet');
|
|
89
|
+
assert.equal(hasNul(out), false, 'snippet must carry no NUL');
|
|
90
|
+
assert.ok(out.includes('NAME'), 'the readable text must survive');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('snippetOf leaves ordinary text unchanged apart from flattening', () => {
|
|
94
|
+
assert.equal(ship.snippetOf('hello world\n\nagain', []), 'hello world again');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a NUL-only string yields no snippet rather than an empty-ish one', () => {
|
|
98
|
+
assert.equal(ship.snippetOf(NUL + NUL + NUL, []), undefined);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('buildSessionDigest yields a digest whose detail carries no NUL', () => {
|
|
102
|
+
// The end-to-end guarantee the route depends on: whatever the transcript
|
|
103
|
+
// contained, the serialised detail must be storable as jsonb.
|
|
104
|
+
const entries = [
|
|
105
|
+
{ type: 'assistant', timestamp: '2026-07-26T21:18:16.356Z',
|
|
106
|
+
message: { model: 'claude-opus-5', usage: { input_tokens: 5, output_tokens: 7 },
|
|
107
|
+
content: [{ type: 'text', text: 'ran' + NUL + ' wsl --list' }] } },
|
|
108
|
+
{ type: 'user', timestamp: '2026-07-26T21:19:00.000Z',
|
|
109
|
+
message: { content: [{ type: 'tool_result', content: 'N' + NUL + 'A' + NUL + 'M' + NUL + 'E' }] } },
|
|
110
|
+
];
|
|
111
|
+
const digest = ship.buildSessionDigest(entries, {});
|
|
112
|
+
assert.equal(hasNul(JSON.stringify(digest.detail)), false, 'no NUL may reach the detail jsonb');
|
|
113
|
+
assert.ok(digest.totalTokens > 0, 'token accounting is unaffected by the strip');
|
|
114
|
+
});
|
|
115
|
+
|
|
72
116
|
console.log(`\nSessionEnd session-digest (#620) tests: ${passed} passed, ${failed} failed`);
|
|
73
117
|
process.exit(failed > 0 ? 1 : 0);
|
package/tests/session_index.mjs
CHANGED
|
@@ -39,7 +39,12 @@ test('--json yields parsed entries, newest first, with date + file + title', ()
|
|
|
39
39
|
const r = run(['--json']);
|
|
40
40
|
assert.equal(r.code, 0);
|
|
41
41
|
const entries = JSON.parse(r.stdout);
|
|
42
|
-
|
|
42
|
+
// Parses EVERY dated session-log file — corpus-agnostic (the neutral core carries no
|
|
43
|
+
// session logs; an instance carries many). Was a magic `> 40` that failed on a clean
|
|
44
|
+
// neutral-core checkout (task 1002234).
|
|
45
|
+
const logsDir = path.join(ROOT, 'docs', 'session-logs');
|
|
46
|
+
const onDiskCount = existsSync(logsDir) ? readdirSync(logsDir).filter((n) => gen.SESSION_FILE_RE.test(n)).length : 0;
|
|
47
|
+
assert.equal(entries.length, onDiskCount, 'parses every dated session-log file on disk');
|
|
43
48
|
for (const e of entries) {
|
|
44
49
|
assert.match(e.date, /^\d{4}-\d{2}-\d{2}$/, 'each entry has an ISO date');
|
|
45
50
|
assert.match(e.file, /\.md$/, 'each entry references a markdown file');
|
|
@@ -55,12 +60,14 @@ test('EVERY index link resolves to a real file — the dangling-link bug is stru
|
|
|
55
60
|
// docs/session-log-index.md is gitignored (ADR 0110) — assert on the generated index string.
|
|
56
61
|
const idx = gen.renderIndex(gen.collectEntries());
|
|
57
62
|
const links = [...idx.matchAll(/\]\(session-logs\/([^)]+\.md)\)/g)].map((m) => m[1]);
|
|
58
|
-
assert.ok(links.length > 40, 'index lists the corpus');
|
|
59
63
|
for (const f of links) {
|
|
60
64
|
assert.ok(existsSync(path.join(ROOT, 'docs', 'session-logs', f)), `index links a missing file: ${f}`);
|
|
61
65
|
}
|
|
62
|
-
//
|
|
63
|
-
|
|
66
|
+
// Covers every dated file on disk (no session silently dropped) and no more — the
|
|
67
|
+
// real invariant, corpus-agnostic (0 on the neutral core; was a magic `> 40` that
|
|
68
|
+
// failed on a clean checkout — task 1002234).
|
|
69
|
+
const logsDir = path.join(ROOT, 'docs', 'session-logs');
|
|
70
|
+
const onDisk = existsSync(logsDir) ? readdirSync(logsDir).filter((n) => gen.SESSION_FILE_RE.test(n)) : [];
|
|
64
71
|
assert.equal(links.length, onDisk.length, 'index entry count matches dated files on disk');
|
|
65
72
|
});
|
|
66
73
|
|
|
@@ -70,7 +77,12 @@ test('CLAUDE.md carries the bounded generated snippet between markers, links res
|
|
|
70
77
|
assert.ok(md.includes(gen.END), 'CLAUDE.md has the snippet end marker');
|
|
71
78
|
const between = md.slice(md.indexOf(gen.BEGIN), md.indexOf(gen.END));
|
|
72
79
|
const links = [...between.matchAll(/\]\(docs\/session-logs\/([^)]+\.md)\)/g)].map((m) => m[1]);
|
|
73
|
-
|
|
80
|
+
// Bounded to at most SNIPPET_COUNT recent sessions: exactly that when the corpus is
|
|
81
|
+
// large enough, fewer (the whole corpus) when smaller — 0 on the neutral core, which
|
|
82
|
+
// carries no session logs (was a hard `=== SNIPPET_COUNT` that failed there — task 1002234).
|
|
83
|
+
const corpusCount = gen.collectEntries().length;
|
|
84
|
+
assert.equal(links.length, Math.min(corpusCount, gen.SNIPPET_COUNT),
|
|
85
|
+
`snippet is bounded to ${gen.SNIPPET_COUNT} recent sessions (or the whole corpus if smaller)`);
|
|
74
86
|
for (const f of links) {
|
|
75
87
|
assert.ok(existsSync(path.join(ROOT, 'docs', 'session-logs', f)), `snippet links a missing file: ${f}`);
|
|
76
88
|
}
|
|
@@ -93,7 +105,7 @@ test('generated markdown is linkify-clean: no bare #NNN, no broken nested links'
|
|
|
93
105
|
test('cleanTitle de-linkifies embedded refs, de-hashes bare #NNN, keeps genuine PR refs', () => {
|
|
94
106
|
// embedded markdown link → its text, then bare #NNN → plain number (no nested link, linkify-clean)
|
|
95
107
|
assert.equal(
|
|
96
|
-
gen.cleanTitle('# 2026-06-06 — Box-lane verify ([#720](https://
|
|
108
|
+
gen.cleanTitle('# 2026-06-06 — Box-lane verify ([#720](https://example.com/builders#/task/720))'),
|
|
97
109
|
'2026-06-06 — Box-lane verify (720)'
|
|
98
110
|
);
|
|
99
111
|
// "task #727" → "task 727"
|
|
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
|
|
22
22
|
const require = createRequire(import.meta.url);
|
|
23
|
-
const { scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath } = require('../scripts/gds/ship.js');
|
|
23
|
+
const { scrubSecrets, snippetOf, buildSessionDigest, resolveTranscriptPath, encodeProjectPath } = require('../scripts/gds/ship.js');
|
|
24
24
|
|
|
25
25
|
// Secret-shaped fakes built at runtime — see the NOTE above.
|
|
26
26
|
const FAKE = {
|
|
@@ -135,6 +135,44 @@ test('resolveTranscriptPath falls back to REPO_ROOT, else null', () => {
|
|
|
135
135
|
assert.equal(resolveTranscriptPath(sid, ['/a', '/b'], { base, existsFn: () => false }), null);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
// ---------- encodeProjectPath (task 1002471: win32 separators) ----------
|
|
139
|
+
//
|
|
140
|
+
// Claude Code encodes the project dir by replacing every NON-ALPHANUMERIC char
|
|
141
|
+
// with '-' (the rule already documented + implemented in
|
|
142
|
+
// scripts/gds/onboarding-config.js). ship.js replaced only '/' and '.', so on
|
|
143
|
+
// Windows — where the separator is '\', the drive carries ':', and paths often
|
|
144
|
+
// contain spaces — it produced a path that cannot exist, the transcript was
|
|
145
|
+
// never found, no session_record was written, and the cost-plus reward could
|
|
146
|
+
// never book (ADR 0146 makes that the only paid stream). Every Windows ship
|
|
147
|
+
// paid 0. The win32 expectations below are the REAL directory names observed
|
|
148
|
+
// under ~/.claude/projects on a win32 box.
|
|
149
|
+
|
|
150
|
+
test('encodeProjectPath: win32 drive + backslashes + spaces', () => {
|
|
151
|
+
assert.equal(
|
|
152
|
+
encodeProjectPath('C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos'),
|
|
153
|
+
'C--Users-rocke-OneDrive-Desktop-Cloud-Bongos-cloud-bongos'
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('encodeProjectPath: win32 nested worktree path', () => {
|
|
158
|
+
assert.equal(
|
|
159
|
+
encodeProjectPath('C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos\\.claude\\worktrees\\task-1002467'),
|
|
160
|
+
'C--Users-rocke-OneDrive-Desktop-Cloud-Bongos-cloud-bongos--claude-worktrees-task-1002467'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('encodeProjectPath: POSIX behaviour unchanged (regression guard)', () => {
|
|
165
|
+
// These are the exact shapes the pre-existing tests above and in
|
|
166
|
+
// tests/memory_pull.mjs pin — they must not move.
|
|
167
|
+
assert.equal(encodeProjectPath('/proj'), '-proj');
|
|
168
|
+
assert.equal(encodeProjectPath('/proj/.claude/worktrees/wt1'), '-proj--claude-worktrees-wt1');
|
|
169
|
+
assert.equal(encodeProjectPath('/Users/x/Desktop/Claude-OTB'), '-Users-x-Desktop-Claude-OTB');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('encodeProjectPath: POSIX path containing a space', () => {
|
|
173
|
+
assert.equal(encodeProjectPath('/Users/x/My Project'), '-Users-x-My-Project');
|
|
174
|
+
});
|
|
175
|
+
|
|
138
176
|
// ---------- buildSessionDigest ----------
|
|
139
177
|
|
|
140
178
|
// A small fixture in the real Claude Code transcript shape.
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// tests/session_reward_on_land.mjs
|
|
2
|
+
//
|
|
3
|
+
// Regression guard for ADR 0120 "pay on land" — the SESSION-TOKEN reward half
|
|
4
|
+
// (the per-task credit half is pinned by tests/pay_on_land.mjs +
|
|
5
|
+
// tests/confirm_attribution.mjs). The cost-plus session token reward
|
|
6
|
+
// (reason='session.token_reward', task 1005/ADR 0054) must book only when the
|
|
7
|
+
// code LANDS (a task the session shipped reaches status='shipped'), never at
|
|
8
|
+
// upload time — so a session that strands at 'confirmed' collects nothing until
|
|
9
|
+
// its code is on `main`.
|
|
10
|
+
//
|
|
11
|
+
// The design is a DUAL TRIGGER, both calling the SAME idempotent
|
|
12
|
+
// awardSessionTokenReward (exactly-once via per-session_id idempotency):
|
|
13
|
+
// 1. UPLOAD side — db.upsertSessionRecordWithReward awards inside the upload txn
|
|
14
|
+
// ONLY IF a task the session shipped is already status='shipped' (the
|
|
15
|
+
// sync/laptop order: land, then upload). Otherwise it DEFERS.
|
|
16
|
+
// 2. LAND side — db.awardDeferredSessionRewardForTask(taskId), driven by
|
|
17
|
+
// routes/sessions.js's task.shipped listener, books the reward for every
|
|
18
|
+
// session that shipped that task (the ci/async order: upload, then land).
|
|
19
|
+
//
|
|
20
|
+
// The DB-free layer drives both db functions with a SQL-aware fake client:
|
|
21
|
+
// upsertSessionRecordWithReward / awardDeferredSessionRewardForTask call
|
|
22
|
+
// api.withTx(fn), so we patch api.withTx to hand fn our fake client, and register
|
|
23
|
+
// the REAL economy `reward` port so the real awardSessionTokenReward SQL runs
|
|
24
|
+
// against the fake (the confirm_attribution.mjs pattern). A guarded real-DB
|
|
25
|
+
// block then proves the actual uniq_credit_session_token_reward_basis partial
|
|
26
|
+
// index enforces exactly-once end-to-end, and that a costlier later ship tops up.
|
|
27
|
+
//
|
|
28
|
+
// Run: node tests/session_reward_on_land.mjs
|
|
29
|
+
|
|
30
|
+
import { strict as assert } from 'node:assert';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
33
|
+
|
|
34
|
+
process.env.NODE_ENV = 'test';
|
|
35
|
+
|
|
36
|
+
// The watermark idempotency SELECT (task 1002474) — replaced the old `SELECT 1 …`
|
|
37
|
+
// existence probe, so the fakes must answer with { paid, legacy }.
|
|
38
|
+
const WATERMARK_RE = /COALESCE\(SUM\(delta\)/;
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
// The session db slice resolves the economy `reward` kernel port; register it
|
|
42
|
+
// (real provider) so awardSessionTokenReward's real SQL runs against our fake.
|
|
43
|
+
const seams = require('../src/module-seams.js');
|
|
44
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
45
|
+
const api = require('../src/module-api.js');
|
|
46
|
+
const db = require('../modules/sessions/db.js');
|
|
47
|
+
|
|
48
|
+
const { test, summary } = makeRunner();
|
|
49
|
+
|
|
50
|
+
// Opus 110k input tokens → $0.55 true cost → $0.55 × 1.20 = 1 drachma (the same
|
|
51
|
+
// priceable fixture credit_grant.mjs uses). A non-zero payout is what lets the
|
|
52
|
+
// idempotency SELECT even run (awardSessionTokenReward short-circuits at $0).
|
|
53
|
+
const MODEL_USAGE = { 'claude-opus-4-8': { input: 110_000 } };
|
|
54
|
+
|
|
55
|
+
const norm = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
|
|
56
|
+
const find = (queries, re) => queries.find((q) => re.test(norm(q)));
|
|
57
|
+
const all = (queries, re) => queries.filter((q) => re.test(norm(q)));
|
|
58
|
+
|
|
59
|
+
// Drive a db function that opens api.withTx(fn) against a fake client whose
|
|
60
|
+
// query() answers are dictated by `handler(normalizedSql)`. Returns the db
|
|
61
|
+
// function's result + the recorded { sql, params } call log. Restores withTx.
|
|
62
|
+
async function withFakeTx(handler, run) {
|
|
63
|
+
const fake = makeSqlAwareClient(handler);
|
|
64
|
+
const prev = api.withTx;
|
|
65
|
+
api.withTx = (fn) => fn(fake._client);
|
|
66
|
+
try {
|
|
67
|
+
const result = await run();
|
|
68
|
+
return { result, queries: fake.queries };
|
|
69
|
+
} finally {
|
|
70
|
+
api.withTx = prev;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A session_records row as upsertSessionRecordTx's INSERT ... RETURNING yields it.
|
|
75
|
+
function upsertReturns(sessionId, taskIds) {
|
|
76
|
+
return {
|
|
77
|
+
rows: [{
|
|
78
|
+
id: 101, session_id: sessionId, builder_id: 7, task_ids: taskIds,
|
|
79
|
+
drachmae_earned: 0, total_tokens: 5000, duration_seconds: 60,
|
|
80
|
+
uploaded_at: '2026-07-15T00:00:00Z',
|
|
81
|
+
}],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =========================================================================
|
|
86
|
+
// (1) UPLOAD side — DEFER when no shipped task (ci/async order)
|
|
87
|
+
// =========================================================================
|
|
88
|
+
|
|
89
|
+
await test('upsert: session task NOT shipped → NO reward INSERT (deferred), tokenReward.awarded === false', async () => {
|
|
90
|
+
const { result, queries } = await withFakeTx(
|
|
91
|
+
(s) => {
|
|
92
|
+
if (/^INSERT INTO session_records/.test(s)) return upsertReturns('sess-defer', [660]);
|
|
93
|
+
if (/SELECT 1 FROM tasks WHERE id = ANY/.test(s)) return { rows: [] }; // not landed yet
|
|
94
|
+
return null;
|
|
95
|
+
},
|
|
96
|
+
() => db.upsertSessionRecordWithReward({
|
|
97
|
+
sessionId: 'sess-defer', builderId: 7, taskIds: [660],
|
|
98
|
+
totalTokens: 5000, modelUsage: MODEL_USAGE,
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// The land gate ran...
|
|
103
|
+
assert.ok(find(queries, /SELECT 1 FROM tasks WHERE id = ANY.*status = 'shipped'/),
|
|
104
|
+
'the pay-on-land gate (tasks status=shipped) must be consulted');
|
|
105
|
+
// ...found nothing shipped, so NO reward was booked.
|
|
106
|
+
assert.equal(all(queries, /INSERT INTO credit_log/).length, 0,
|
|
107
|
+
'a session with no landed task must NOT book a token reward at upload');
|
|
108
|
+
assert.equal(result.tokenReward.awarded, false, 'awarded must be false when deferred');
|
|
109
|
+
assert.equal(result.tokenReward.deferred, true, 'the deferred flag must be set');
|
|
110
|
+
// The digest itself is still recorded (the upsert ran + returned the row).
|
|
111
|
+
assert.ok(find(queries, /^INSERT INTO session_records/), 'the digest upsert still runs');
|
|
112
|
+
assert.equal(result.id, 101, 'the record is returned even when the reward defers');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('upsert: session with NO task_ids → deferred, gate not even queried', async () => {
|
|
116
|
+
const { result, queries } = await withFakeTx(
|
|
117
|
+
(s) => {
|
|
118
|
+
if (/^INSERT INTO session_records/.test(s)) return upsertReturns('sess-none', []);
|
|
119
|
+
return null;
|
|
120
|
+
},
|
|
121
|
+
() => db.upsertSessionRecordWithReward({
|
|
122
|
+
sessionId: 'sess-none', builderId: 7, taskIds: [],
|
|
123
|
+
totalTokens: 5000, modelUsage: MODEL_USAGE,
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
assert.equal(all(queries, /INSERT INTO credit_log/).length, 0, 'no tasks → no reward');
|
|
127
|
+
assert.ok(!find(queries, /FROM tasks WHERE id = ANY/), 'no task_ids → skip the gate query entirely');
|
|
128
|
+
assert.equal(result.tokenReward.awarded, false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// =========================================================================
|
|
132
|
+
// (2) UPLOAD side — AWARD when a task is already shipped (sync/laptop order)
|
|
133
|
+
// =========================================================================
|
|
134
|
+
|
|
135
|
+
await test('upsert: a task already shipped → awardSessionTokenReward runs (a session.token_reward INSERT)', async () => {
|
|
136
|
+
const { result, queries } = await withFakeTx(
|
|
137
|
+
(s) => {
|
|
138
|
+
if (/^INSERT INTO session_records/.test(s)) return upsertReturns('sess-land', [660]);
|
|
139
|
+
if (/SELECT 1 FROM tasks WHERE id = ANY/.test(s)) return { rows: [{ '?column?': 1 }] }; // landed
|
|
140
|
+
if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] }; // nothing paid yet
|
|
141
|
+
if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
|
|
142
|
+
return null;
|
|
143
|
+
},
|
|
144
|
+
() => db.upsertSessionRecordWithReward({
|
|
145
|
+
sessionId: 'sess-land', builderId: 7, taskIds: [660],
|
|
146
|
+
totalTokens: 5000, modelUsage: MODEL_USAGE,
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const insert = find(queries, /^INSERT INTO credit_log/);
|
|
151
|
+
assert.ok(insert, 'a credit_log INSERT must run once a task has landed');
|
|
152
|
+
assert.match(norm(insert), /session\.token_reward/, 'the reward reason must be session.token_reward');
|
|
153
|
+
// params: [builderId, drachmae, description, sessionId]
|
|
154
|
+
assert.equal(insert.params[0], 7, 'reward attributed to the session owner');
|
|
155
|
+
assert.equal(insert.params[1], 1, '$0.55 × 1.20 → 1 drachma');
|
|
156
|
+
assert.equal(insert.params[3], 'sess-land', 'idempotency key is the session_id');
|
|
157
|
+
assert.equal(result.tokenReward.awarded, true);
|
|
158
|
+
assert.equal(result.tokenReward.drachmae, 1);
|
|
159
|
+
// observability mirror onto session_records.token_reward_drachmae
|
|
160
|
+
assert.ok(find(queries, /^UPDATE session_records SET token_reward_drachmae/), 'mirror UPDATE runs on a fresh award');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// =========================================================================
|
|
164
|
+
// (3) LAND side — awardDeferredSessionRewardForTask books the deferred reward
|
|
165
|
+
// =========================================================================
|
|
166
|
+
|
|
167
|
+
await test('awardDeferredSessionRewardForTask: session with the task + priceable usage → awards (INSERT for the session builder)', async () => {
|
|
168
|
+
const { result, queries } = await withFakeTx(
|
|
169
|
+
(s) => {
|
|
170
|
+
if (/^SELECT session_id, builder_id, model_usage, total_tokens FROM session_records/.test(s)) {
|
|
171
|
+
return { rows: [{ session_id: 'sess-ci', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
|
|
172
|
+
}
|
|
173
|
+
if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] }; // nothing paid yet
|
|
174
|
+
if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 2 }] };
|
|
175
|
+
return null;
|
|
176
|
+
},
|
|
177
|
+
() => db.awardDeferredSessionRewardForTask(660)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// The lookup is by task_ids array containment (the shipped task's id).
|
|
181
|
+
assert.ok(find(queries, /FROM session_records WHERE task_ids @> ARRAY/),
|
|
182
|
+
'sessions are found by task_ids containment of the shipped task');
|
|
183
|
+
const insert = find(queries, /^INSERT INTO credit_log/);
|
|
184
|
+
assert.ok(insert, 'a session.token_reward INSERT must run for the deferred session');
|
|
185
|
+
assert.match(norm(insert), /session\.token_reward/);
|
|
186
|
+
assert.equal(insert.params[0], 7, "attributed to the session record's builder_id");
|
|
187
|
+
assert.equal(insert.params[3], 'sess-ci', 'keyed on the session_id');
|
|
188
|
+
assert.equal(result.sessions, 1, 'one session scanned');
|
|
189
|
+
assert.equal(result.awarded, 1, 'one reward freshly booked');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await test('awardDeferredSessionRewardForTask: prior reward exists (idempotency SELECT returns a row) → NO second INSERT', async () => {
|
|
193
|
+
const { result, queries } = await withFakeTx(
|
|
194
|
+
(s) => {
|
|
195
|
+
if (/^SELECT session_id, builder_id, model_usage, total_tokens FROM session_records/.test(s)) {
|
|
196
|
+
return { rows: [{ session_id: 'sess-ci', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
|
|
197
|
+
}
|
|
198
|
+
if (WATERMARK_RE.test(s)) return { rows: [{ paid: 999_999, legacy: 0 }] }; // ALREADY PAID IN FULL
|
|
199
|
+
return null;
|
|
200
|
+
},
|
|
201
|
+
() => db.awardDeferredSessionRewardForTask(660)
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
assert.ok(find(queries, WATERMARK_RE),
|
|
205
|
+
'the per-session watermark guard must be consulted');
|
|
206
|
+
assert.equal(all(queries, /INSERT INTO credit_log/).length, 0, 're-firing on a rewarded session is a no-op');
|
|
207
|
+
assert.ok(!find(queries, /^UPDATE session_records SET token_reward_drachmae/), 'no mirror UPDATE when nothing was booked');
|
|
208
|
+
assert.equal(result.sessions, 1);
|
|
209
|
+
assert.equal(result.awarded, 0, 'nothing freshly booked');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await test('awardDeferredSessionRewardForTask: no session shipped this task → clean no-op', async () => {
|
|
213
|
+
const { result, queries } = await withFakeTx(
|
|
214
|
+
(s) => {
|
|
215
|
+
if (/^SELECT session_id, builder_id, model_usage, total_tokens FROM session_records/.test(s)) return { rows: [] };
|
|
216
|
+
return null;
|
|
217
|
+
},
|
|
218
|
+
() => db.awardDeferredSessionRewardForTask(424242)
|
|
219
|
+
);
|
|
220
|
+
assert.equal(all(queries, /INSERT INTO credit_log/).length, 0);
|
|
221
|
+
assert.equal(result.sessions, 0);
|
|
222
|
+
assert.equal(result.awarded, 0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await test('awardDeferredSessionRewardForTask: non-numeric id → immediate no-op (no DB touch)', async () => {
|
|
226
|
+
// No withTx patch — a bad id must return before opening a transaction.
|
|
227
|
+
const out = await db.awardDeferredSessionRewardForTask('not-a-number');
|
|
228
|
+
assert.deepEqual(out, { sessions: 0, awarded: 0 });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// =========================================================================
|
|
232
|
+
// (4) REAL-DB — the actual uniq_credit_session_token_reward partial index
|
|
233
|
+
// enforces exactly-once across the two triggers. Guarded on DATABASE_URL
|
|
234
|
+
// (env.sh sets it); skipped cleanly otherwise.
|
|
235
|
+
// =========================================================================
|
|
236
|
+
|
|
237
|
+
if (process.env.DATABASE_URL) {
|
|
238
|
+
const pool = api.pool;
|
|
239
|
+
const suffix = `srol_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
|
|
240
|
+
const ctx = { builderId: null, versionId: `ver_${suffix}`, taskId: null };
|
|
241
|
+
let setupOk = false;
|
|
242
|
+
try {
|
|
243
|
+
const b = await pool.query(
|
|
244
|
+
`INSERT INTO builders (github_id, github_login, display_name) VALUES ($1,$2,$3) RETURNING id`,
|
|
245
|
+
[`gh_${suffix}`, `login_${suffix}`, `Test ${suffix}`]
|
|
246
|
+
);
|
|
247
|
+
ctx.builderId = Number(b.rows[0].id);
|
|
248
|
+
await pool.query(
|
|
249
|
+
`INSERT INTO versions (id, name, track, status) VALUES ($1,$2,'internal','planning')`,
|
|
250
|
+
[ctx.versionId, `V ${suffix}`]
|
|
251
|
+
);
|
|
252
|
+
const tk = await pool.query(
|
|
253
|
+
`INSERT INTO tasks (version_id, title, status) VALUES ($1,$2,'shipped') RETURNING id`,
|
|
254
|
+
[ctx.versionId, `shipped task ${suffix}`]
|
|
255
|
+
);
|
|
256
|
+
ctx.taskId = Number(tk.rows[0].id);
|
|
257
|
+
setupOk = true;
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.log(` SKIP real-DB block (setup failed: ${e.message})`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (setupOk) {
|
|
263
|
+
const rewardRows = (sessionId) => pool.query(
|
|
264
|
+
`SELECT delta FROM credit_log WHERE session_id = $1 AND reason = 'session.token_reward'`,
|
|
265
|
+
[sessionId]
|
|
266
|
+
);
|
|
267
|
+
try {
|
|
268
|
+
await test('real-DB: awardDeferredSessionRewardForTask books once + is idempotent (partial index)', async () => {
|
|
269
|
+
const sid = `sess-deferred-${suffix}`;
|
|
270
|
+
await pool.query(
|
|
271
|
+
`INSERT INTO session_records (session_id, builder_id, task_ids, total_tokens, model_usage)
|
|
272
|
+
VALUES ($1, $2, ARRAY[$3]::bigint[], $4, $5::jsonb)`,
|
|
273
|
+
[sid, ctx.builderId, ctx.taskId, 5000, JSON.stringify(MODEL_USAGE)]
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const first = await db.awardDeferredSessionRewardForTask(ctx.taskId);
|
|
277
|
+
assert.equal(first.awarded, 1, 'first land books the reward');
|
|
278
|
+
let rows = (await rewardRows(sid)).rows;
|
|
279
|
+
assert.equal(rows.length, 1, 'exactly one session.token_reward row');
|
|
280
|
+
assert.ok(rows[0].delta > 0, 'a positive drachmae payout landed');
|
|
281
|
+
|
|
282
|
+
const mirror = await pool.query(
|
|
283
|
+
`SELECT token_reward_drachmae FROM session_records WHERE session_id = $1`, [sid]
|
|
284
|
+
);
|
|
285
|
+
assert.ok(Number(mirror.rows[0].token_reward_drachmae) > 0, 'the drachmae is mirrored onto the row');
|
|
286
|
+
|
|
287
|
+
const second = await db.awardDeferredSessionRewardForTask(ctx.taskId);
|
|
288
|
+
assert.equal(second.awarded, 0, 're-firing the land event is a no-op');
|
|
289
|
+
rows = (await rewardRows(sid)).rows;
|
|
290
|
+
assert.equal(rows.length, 1, 'the partial-unique index held — still exactly one row, no double payout');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// task 1002474 — the whole point of the watermark: a session that keeps
|
|
294
|
+
// spending after its first ship must be paid the difference, and the real
|
|
295
|
+
// (session_id, reward_basis) index must permit exactly that second row while
|
|
296
|
+
// still refusing a duplicate at the same basis.
|
|
297
|
+
await test('real-DB: a costlier later ship TOPS UP, and the top-up is itself idempotent', async () => {
|
|
298
|
+
const sid = `sess-topup-${suffix}`;
|
|
299
|
+
await pool.query(
|
|
300
|
+
`INSERT INTO session_records (session_id, builder_id, task_ids, total_tokens, model_usage)
|
|
301
|
+
VALUES ($1, $2, ARRAY[$3]::bigint[], $4, $5::jsonb)`,
|
|
302
|
+
[sid, ctx.builderId, ctx.taskId, 5000, JSON.stringify(MODEL_USAGE)]
|
|
303
|
+
);
|
|
304
|
+
await db.awardDeferredSessionRewardForTask(ctx.taskId);
|
|
305
|
+
const afterFirst = (await rewardRows(sid)).rows;
|
|
306
|
+
assert.equal(afterFirst.length, 1, 'the first ship books one row');
|
|
307
|
+
const firstPaid = Number(afterFirst[0].delta);
|
|
308
|
+
|
|
309
|
+
// The session keeps working and gets much more expensive, then ships again.
|
|
310
|
+
const bigger = {};
|
|
311
|
+
for (const [model, usage] of Object.entries(MODEL_USAGE)) {
|
|
312
|
+
bigger[model] = Object.fromEntries(
|
|
313
|
+
Object.entries(usage).map(([k, v]) => [k, Number(v) * 40])
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
await pool.query(
|
|
317
|
+
`UPDATE session_records SET model_usage = $2::jsonb WHERE session_id = $1`,
|
|
318
|
+
[sid, JSON.stringify(bigger)]
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const topup = await db.awardDeferredSessionRewardForTask(ctx.taskId);
|
|
322
|
+
assert.equal(topup.awarded, 1, 'the grown session books a second row');
|
|
323
|
+
const afterTopup = (await rewardRows(sid)).rows;
|
|
324
|
+
assert.equal(afterTopup.length, 2, 'the (session_id, reward_basis) index PERMITS the increment');
|
|
325
|
+
const total = afterTopup.reduce((n, r) => n + Number(r.delta), 0);
|
|
326
|
+
assert.ok(total > firstPaid, 'the builder is now paid more than the first ship alone');
|
|
327
|
+
assert.ok(
|
|
328
|
+
afterTopup.every((r) => Number(r.delta) > 0),
|
|
329
|
+
'every row is a positive credit — a top-up never claws back'
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Re-firing at the SAME (unchanged) cost must add nothing.
|
|
333
|
+
const again = await db.awardDeferredSessionRewardForTask(ctx.taskId);
|
|
334
|
+
assert.equal(again.awarded, 0, 'no further payment when nothing changed');
|
|
335
|
+
assert.equal(
|
|
336
|
+
(await rewardRows(sid)).rows.length, 2,
|
|
337
|
+
'the index still refuses a duplicate at the same basis — exactly-once per level'
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await test('real-DB: upsertSessionRecordWithReward DEFERS when the task is not shipped', async () => {
|
|
342
|
+
const sid = `sess-upload-defer-${suffix}`;
|
|
343
|
+
// A task id that is not status=shipped (nonexistent id → gate finds nothing).
|
|
344
|
+
const out = await db.upsertSessionRecordWithReward({
|
|
345
|
+
sessionId: sid, builderId: ctx.builderId, taskIds: [999_000_000 + Math.floor(Math.random() * 1000)],
|
|
346
|
+
totalTokens: 5000, modelUsage: MODEL_USAGE,
|
|
347
|
+
});
|
|
348
|
+
assert.equal(out.tokenReward.awarded, false, 'no landed task → deferred');
|
|
349
|
+
assert.equal(out.tokenReward.deferred, true);
|
|
350
|
+
assert.equal((await rewardRows(sid)).rows.length, 0, 'no reward row booked at upload');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await test('real-DB: upsertSessionRecordWithReward AWARDS when a task is already shipped', async () => {
|
|
354
|
+
const sid = `sess-upload-award-${suffix}`;
|
|
355
|
+
const out = await db.upsertSessionRecordWithReward({
|
|
356
|
+
sessionId: sid, builderId: ctx.builderId, taskIds: [ctx.taskId],
|
|
357
|
+
totalTokens: 5000, modelUsage: MODEL_USAGE,
|
|
358
|
+
});
|
|
359
|
+
assert.equal(out.tokenReward.awarded, true, 'a shipped task in the session → award at upload');
|
|
360
|
+
assert.ok(out.tokenReward.drachmae > 0);
|
|
361
|
+
assert.equal((await rewardRows(sid)).rows.length, 1, 'exactly one reward row');
|
|
362
|
+
});
|
|
363
|
+
} finally {
|
|
364
|
+
// FK-safe teardown (credit_log + session_records → builder; tasks → version).
|
|
365
|
+
try {
|
|
366
|
+
await pool.query(`DELETE FROM credit_log WHERE builder_id = $1`, [ctx.builderId]);
|
|
367
|
+
await pool.query(`DELETE FROM session_records WHERE builder_id = $1`, [ctx.builderId]);
|
|
368
|
+
await pool.query(`DELETE FROM tasks WHERE version_id = $1`, [ctx.versionId]);
|
|
369
|
+
await pool.query(`DELETE FROM versions WHERE id = $1`, [ctx.versionId]);
|
|
370
|
+
await pool.query(`DELETE FROM builders WHERE id = $1`, [ctx.builderId]);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
console.log(` WARN real-DB teardown left rows (${e.message})`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
console.log(' SKIP real-DB block (no DATABASE_URL)');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// -------------------------------------------------------------------------
|
|
381
|
+
summary();
|