@bongos/core 1.23.21 → 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 +983 -428
- 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 +6 -3
- 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 +94 -0
- package/clients/bongos-client/index.cjs +94 -0
- package/clients/bongos-client/index.d.ts +154 -4
- package/clients/bongos-client/index.mjs +94 -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/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
- package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
- package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
- package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
- package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
- 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 +10 -1
- package/docs/api/openapi.json +8619 -5885
- package/docs/api-reference.md +68 -9
- 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 +4 -0
- package/docs/module-api-changelog.md +7 -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/CLAUDE.md +15 -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/module.json +26 -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 +252 -0
- 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 +108 -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 +50 -18
- package/modules/government/routing.js +39 -5
- package/modules/government/waiting.js +74 -0
- package/modules/grading/grader-subagent.js +30 -11
- 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/city.html +15 -15
- 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-analytics.js +10 -3
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +48 -18
- package/modules/lifecycle/db-shared.js +9 -0
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/db-versions.js +21 -8
- 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/ship-assurance.js +4 -1
- package/modules/lifecycle/ship-card.js +8 -2
- 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/onboarding/howto-moments.js +8 -1
- 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 +8 -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 +286 -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 +4 -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 +129 -12
- 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 +16 -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/src/platform-server.js +27 -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/batched_writes_parity.mjs +340 -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/conductor_main_e2e.mjs +4 -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/per-row-loops-legacy.cjs +245 -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_archive_disposition.mjs +5 -5
- 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 +146 -23
- package/tests/grader.mjs +31 -0
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_city.mjs +8 -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/hook_shell_tools.mjs +8 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/howto_moments.mjs +17 -0
- 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/module_skills_hook.mjs +69 -0
- 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/public_landing_projects.mjs +37 -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/session_start_freshness.mjs +66 -1
- package/tests/shell_hooks.mjs +209 -0
- package/tests/ship_assurance.mjs +28 -0
- package/tests/ship_card.mjs +20 -5
- 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 +58 -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_close_route.mjs +1 -1
- 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,219 @@
|
|
|
1
|
+
// modules/lifecycle/notes.js — notes on a task or goal, grouped by purpose
|
|
2
|
+
// (task 1004745, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// A note is a short piece of information that helps scope a task or goal or helps
|
|
5
|
+
// finish it. A long thread of them reads badly, so they are shown GROUPED BY
|
|
6
|
+
// PURPOSE — what is decided, what is still a question, what to read, what is
|
|
7
|
+
// risky — with the newest few open in each group and the rest folded away.
|
|
8
|
+
//
|
|
9
|
+
// THE PURPOSE IS CHOSEN BY THE AUTHOR, AND GUESSED WHEN THEY DO NOT. Guessing by
|
|
10
|
+
// text likeness would need a model call per note and could regroup a reader's page
|
|
11
|
+
// under them; a stored purpose is stable, cheap and the author's own word. The
|
|
12
|
+
// guess (`suggestKind`) is a few plain rules (a link, a question mark, "decided",
|
|
13
|
+
// "risk") used only when no purpose was given, and is stored with
|
|
14
|
+
// kind_source 'auto' so it can be told from a deliberate choice.
|
|
15
|
+
//
|
|
16
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const api = require('../../src/module-api');
|
|
21
|
+
|
|
22
|
+
const { pool } = api;
|
|
23
|
+
|
|
24
|
+
const MAX_BODY = 4000;
|
|
25
|
+
|
|
26
|
+
// The most notes one task or goal carries, so no one can bury a page in them.
|
|
27
|
+
const MAX_NOTES_PER_SUBJECT = 500;
|
|
28
|
+
|
|
29
|
+
// Display order is the order a person scoping work wants to read: what the work is,
|
|
30
|
+
// what was decided, what is still open, what is risky, how far along it is, what to
|
|
31
|
+
// read, and everything else last.
|
|
32
|
+
const KINDS = Object.freeze([
|
|
33
|
+
{ key: 'scope', label: 'Scope', hint: 'What is in and out' },
|
|
34
|
+
{ key: 'decision', label: 'Decisions', hint: 'What was settled, and why' },
|
|
35
|
+
{ key: 'question', label: 'Open questions', hint: 'What still needs an answer' },
|
|
36
|
+
{ key: 'risk', label: 'Risks', hint: 'What could go wrong' },
|
|
37
|
+
{ key: 'progress', label: 'Progress', hint: 'How far along it is' },
|
|
38
|
+
{ key: 'reference', label: 'Links & reading', hint: 'Where to look' },
|
|
39
|
+
{ key: 'other', label: 'Other', hint: 'Anything else' },
|
|
40
|
+
]);
|
|
41
|
+
const KIND_KEYS = Object.freeze(KINDS.map((k) => k.key));
|
|
42
|
+
|
|
43
|
+
// How many notes a group shows before folding the rest behind "show more".
|
|
44
|
+
const OPEN_PER_GROUP = 3;
|
|
45
|
+
|
|
46
|
+
// A purpose from the words, for a note written without one. Order matters: the
|
|
47
|
+
// first rule that matches wins, and 'other' is the honest answer when none does.
|
|
48
|
+
function suggestKind(body) {
|
|
49
|
+
const t = String(body == null ? '' : body).trim().toLowerCase();
|
|
50
|
+
if (!t) return 'other';
|
|
51
|
+
if (/\b(decided|decision|we will|we'll go with|going with|agreed|chose|chosen)\b/.test(t)) return 'decision';
|
|
52
|
+
if (/\b(risk|danger|could break|might break|breaks|careful|watch out|blocker|blocked)\b/.test(t)) return 'risk';
|
|
53
|
+
if (t.includes('?') || /^(how|what|why|should|can|could|does|do|is|are|who|when|where)\b/.test(t)) return 'question';
|
|
54
|
+
if (/\b(in scope|out of scope|scope|must|should include|needs to|acceptance|done when)\b/.test(t)) return 'scope';
|
|
55
|
+
if (/\b(done|finished|shipped|merged|started|working on|in progress|so far|next step)\b/.test(t)) return 'progress';
|
|
56
|
+
if (/https?:\/\/|\bsee (the )?(docs?|adr|spec|page|link)\b|\bdocs?\b|\badr\b|\bspec\b/.test(t)) return 'reference';
|
|
57
|
+
return 'other';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Which of the two subjects a request names: { taskId } or { goalId }, else null.
|
|
61
|
+
// Exactly one id must be present and a positive integer.
|
|
62
|
+
function subjectOf(src) {
|
|
63
|
+
const num = (v) => (v == null || v === '' ? null : (Number.isInteger(Number(v)) && Number(v) > 0 ? Number(v) : NaN));
|
|
64
|
+
const taskId = num(src.task_id);
|
|
65
|
+
const goalId = num(src.goal_id);
|
|
66
|
+
if (Number.isNaN(taskId) || Number.isNaN(goalId)) return null;
|
|
67
|
+
if ((taskId == null) === (goalId == null)) return null;
|
|
68
|
+
return taskId != null ? { taskId } : { goalId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate an incoming note; `kind` may be absent (then guessed).
|
|
72
|
+
function validateNote(input) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
const body = input && typeof input.body === 'string' ? input.body.trim() : '';
|
|
75
|
+
if (!body) errors.push({ field: 'body', reason: 'required' });
|
|
76
|
+
else if (body.length > MAX_BODY) errors.push({ field: 'body', reason: `at most ${MAX_BODY} characters` });
|
|
77
|
+
const kind = input ? input.kind : undefined;
|
|
78
|
+
if (kind != null && !KIND_KEYS.includes(kind)) errors.push({ field: 'kind', reason: `pick a group: ${KINDS.map((k) => k.label).join(', ')}` });
|
|
79
|
+
if (!subjectOf(input || {})) errors.push({ field: 'subject', reason: 'say whether the note is on a task or on a goal, not both' });
|
|
80
|
+
if (errors.length) return { ok: false, errors };
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
body,
|
|
84
|
+
kind: kind == null ? suggestKind(body) : kind,
|
|
85
|
+
kindSource: kind == null ? 'auto' : 'author',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Rows (newest first, or any order) -> the grouped view the page renders. Empty
|
|
90
|
+
// groups are left out; within a group newest comes first; `open` is what shows
|
|
91
|
+
// at once and `folded` is what waits behind "show more".
|
|
92
|
+
function groupNotes(rows, { openPerGroup = OPEN_PER_GROUP } = {}) {
|
|
93
|
+
const byKind = new Map(KIND_KEYS.map((k) => [k, []]));
|
|
94
|
+
for (const r of rows || []) {
|
|
95
|
+
const k = byKind.has(r.kind) ? r.kind : 'other';
|
|
96
|
+
byKind.get(k).push(r);
|
|
97
|
+
}
|
|
98
|
+
const groups = [];
|
|
99
|
+
for (const meta of KINDS) {
|
|
100
|
+
const list = byKind.get(meta.key).slice().sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
101
|
+
if (!list.length) continue;
|
|
102
|
+
groups.push({
|
|
103
|
+
kind: meta.key,
|
|
104
|
+
label: meta.label,
|
|
105
|
+
hint: meta.hint,
|
|
106
|
+
count: list.length,
|
|
107
|
+
latest_at: list[0].created_at,
|
|
108
|
+
open: list.slice(0, openPerGroup),
|
|
109
|
+
folded: list.slice(openPerGroup),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return groups;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const COLS = 'n.id, n.task_id, n.goal_id, n.author_id, n.body, n.kind, n.kind_source, n.created_at, n.updated_at, '
|
|
116
|
+
+ 'b.github_login AS author_login, b.display_name AS author_name, b.avatar_url AS author_avatar_url';
|
|
117
|
+
|
|
118
|
+
// Every note on one task or goal, newest first. `subject` is { taskId } or { goalId }.
|
|
119
|
+
async function listNotes(subject, deps = {}) {
|
|
120
|
+
const db = deps.pool || pool;
|
|
121
|
+
const col = subject.taskId != null ? 'n.task_id' : 'n.goal_id';
|
|
122
|
+
const id = subject.taskId != null ? subject.taskId : subject.goalId;
|
|
123
|
+
const { rows } = await db.query(
|
|
124
|
+
`SELECT ${COLS} FROM lifecycle_notes n JOIN builders b ON b.id = n.author_id
|
|
125
|
+
WHERE ${col} = $1 ORDER BY n.created_at DESC, n.id DESC LIMIT $2`,
|
|
126
|
+
[id, MAX_NOTES_PER_SUBJECT]
|
|
127
|
+
);
|
|
128
|
+
return rows;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// How many notes a task or goal already carries (for the per-subject cap).
|
|
132
|
+
async function countNotes(subject, deps = {}) {
|
|
133
|
+
const db = deps.pool || pool;
|
|
134
|
+
const col = subject.taskId != null ? 'task_id' : 'goal_id';
|
|
135
|
+
const id = subject.taskId != null ? subject.taskId : subject.goalId;
|
|
136
|
+
const { rows } = await db.query(`SELECT count(*)::int AS n FROM lifecycle_notes WHERE ${col} = $1`, [id]);
|
|
137
|
+
return rows[0].n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function addNote({ taskId = null, goalId = null, authorId, body, kind, kindSource }, deps = {}) {
|
|
141
|
+
const db = deps.pool || pool;
|
|
142
|
+
const { rows } = await db.query(
|
|
143
|
+
`WITH ins AS (
|
|
144
|
+
INSERT INTO lifecycle_notes (task_id, goal_id, author_id, body, kind, kind_source)
|
|
145
|
+
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *
|
|
146
|
+
)
|
|
147
|
+
SELECT ${COLS} FROM ins n JOIN builders b ON b.id = n.author_id`,
|
|
148
|
+
[taskId, goalId, authorId, body, kind, kindSource]
|
|
149
|
+
);
|
|
150
|
+
return rows[0];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Re-file a note under another purpose. Only its author or a Metic+ may (the route
|
|
154
|
+
// decides who; this just writes), and the choice is now the author's.
|
|
155
|
+
async function setKind(id, kind, deps = {}) {
|
|
156
|
+
const db = deps.pool || pool;
|
|
157
|
+
const { rows } = await db.query(
|
|
158
|
+
`UPDATE lifecycle_notes SET kind = $2, kind_source = 'author', updated_at = now()
|
|
159
|
+
WHERE id = $1 RETURNING id, kind, kind_source, author_id`,
|
|
160
|
+
[id, kind]
|
|
161
|
+
);
|
|
162
|
+
return rows[0] || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function getNote(id, deps = {}) {
|
|
166
|
+
const db = deps.pool || pool;
|
|
167
|
+
const { rows } = await db.query('SELECT id, author_id, kind, task_id, goal_id FROM lifecycle_notes WHERE id = $1', [id]);
|
|
168
|
+
return rows[0] || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function deleteNote(id, deps = {}) {
|
|
172
|
+
const db = deps.pool || pool;
|
|
173
|
+
const { rowCount } = await db.query('DELETE FROM lifecycle_notes WHERE id = $1', [id]);
|
|
174
|
+
return rowCount > 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// May this builder see notes on this task or goal? A private goal's notes (and those of
|
|
178
|
+
// its tasks) are for its members and an Archon only, the same wall the goal's own title
|
|
179
|
+
// and prose sit behind (ADR 0112). Anything else is open to every signed-in builder.
|
|
180
|
+
// A task or goal that does not exist answers false too, so the route gives the same
|
|
181
|
+
// 404 for "missing" and "not yours to see" from ONE read.
|
|
182
|
+
async function canSee(builder, subject, deps = {}) {
|
|
183
|
+
const db = deps.pool || pool;
|
|
184
|
+
const { rows } = subject.taskId != null
|
|
185
|
+
? await db.query('SELECT g.id AS goal_id, g.visibility FROM tasks t LEFT JOIN goals g ON g.id = t.goal_id WHERE t.id = $1', [subject.taskId])
|
|
186
|
+
: await db.query('SELECT g.id AS goal_id, g.visibility FROM goals g WHERE g.id = $1', [subject.goalId]);
|
|
187
|
+
const g = rows[0];
|
|
188
|
+
if (!g) return false;
|
|
189
|
+
if (g.visibility !== 'private' || api.rankMeetsThreshold(builder.rank, ['archon'])) return true;
|
|
190
|
+
const m = await db.query('SELECT 1 FROM goal_members WHERE goal_id = $1 AND builder_id = $2', [g.goal_id, builder.id]);
|
|
191
|
+
return m.rows.length > 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Who may re-file or delete a note: its author, or a Metic or above. The rank is the live
|
|
195
|
+
// one on the authenticated request (ADR 0016), compared through the kernel's own ladder.
|
|
196
|
+
function canChange(builder, note) {
|
|
197
|
+
if (String(builder.id) === String(note.author_id)) return true;
|
|
198
|
+
return api.rankMeetsThreshold(builder.rank, ['metic', 'archon']);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
KINDS,
|
|
203
|
+
MAX_NOTES_PER_SUBJECT,
|
|
204
|
+
subjectOf,
|
|
205
|
+
canSee,
|
|
206
|
+
canChange,
|
|
207
|
+
countNotes,
|
|
208
|
+
KIND_KEYS,
|
|
209
|
+
MAX_BODY,
|
|
210
|
+
suggestKind,
|
|
211
|
+
validateNote,
|
|
212
|
+
groupNotes,
|
|
213
|
+
listNotes,
|
|
214
|
+
|
|
215
|
+
addNote,
|
|
216
|
+
setKind,
|
|
217
|
+
getNote,
|
|
218
|
+
deleteNote,
|
|
219
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// modules/lifecycle/page-notice.js — WHO IS UPDATING A PAGE, in one read (task 1004948).
|
|
2
|
+
//
|
|
3
|
+
// An artist's page claim is a marker (task 1004947), so nothing blocks anyone;
|
|
4
|
+
// this is the other half: telling people. `heldPagesForTouches` answers, for the
|
|
5
|
+
// files a task touches, which pages an artist is updating right now, who, and
|
|
6
|
+
// since when. `engineeringActiveOnPage` answers the reverse for an artist: whose
|
|
7
|
+
// engineering claims are open on files that page is made of. Information only:
|
|
8
|
+
// nothing here refuses, gates or writes, and a failed read answers "no notice"
|
|
9
|
+
// rather than throwing, because a heads-up must never break a claim.
|
|
10
|
+
//
|
|
11
|
+
// Every notice is `{ page_id, holder, since, task_id, line }`, the same shape on
|
|
12
|
+
// every surface (claim output, GET /me, the Conductor). Names and ids that reach a
|
|
13
|
+
// session as text are cleaned first: a display name is somebody else's words.
|
|
14
|
+
// Engineering work is reported by holder only, never by title: a task's title can
|
|
15
|
+
// belong to a goal the asking artist may not read (ADR 0112).
|
|
16
|
+
//
|
|
17
|
+
// Reads through the caller's executor (a pool or a transaction client) and the
|
|
18
|
+
// doorway's path matcher, like the rest of the claim ladder.
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const api = require('../../src/module-api');
|
|
23
|
+
const { touchesOverlap } = api.pathMatch;
|
|
24
|
+
const { PAGE_TWEAK_SOURCE, webClaimPredicate } = require('./page-tweak-artist.js');
|
|
25
|
+
|
|
26
|
+
// Open page markers / claims are bounded by concurrent people; the cap is a backstop.
|
|
27
|
+
const ROW_CAP = 500;
|
|
28
|
+
|
|
29
|
+
// 'page-tweak/builders:studio/r2' -> 'builders:studio' ('' when malformed).
|
|
30
|
+
function pageIdOf(sourceRef) {
|
|
31
|
+
const m = /^page-tweak\/([a-z0-9:-]{1,80})\/r[1-9][0-9]*$/.exec(String(sourceRef || ''));
|
|
32
|
+
return m ? m[1] : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A name that is safe to print into a session's context: no control characters,
|
|
36
|
+
// no newlines, at most 60 characters.
|
|
37
|
+
function cleanName(raw, fallback) {
|
|
38
|
+
const s = String(raw == null ? '' : raw).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
39
|
+
return s || fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One line a person or an AI session reads: gentle, never a refusal.
|
|
43
|
+
function noticeLine(n) {
|
|
44
|
+
const since = n.since ? new Date(n.since).toISOString().slice(0, 10) : 'recently';
|
|
45
|
+
return `${n.page_id} is being updated by ${n.holder} (since ${since}) — expect copy changes on it.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Every open page marker, with its holder: ONE read for any number of tasks.
|
|
49
|
+
async function openMarkers(exec) {
|
|
50
|
+
const { rows } = await exec.query(
|
|
51
|
+
`SELECT t.id AS task_id, t.source_ref, t.touches, c.claimed_at,
|
|
52
|
+
COALESCE(b.display_name, b.github_login) AS holder
|
|
53
|
+
FROM claims c
|
|
54
|
+
JOIN tasks t ON t.id = c.task_id
|
|
55
|
+
JOIN builders b ON b.id = c.builder_id
|
|
56
|
+
WHERE c.released_at IS NULL
|
|
57
|
+
AND t.source = '${PAGE_TWEAK_SOURCE}'
|
|
58
|
+
AND ${webClaimPredicate('c')}
|
|
59
|
+
ORDER BY c.claimed_at
|
|
60
|
+
LIMIT ${ROW_CAP}`
|
|
61
|
+
);
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function noticesFor(touches, markers, exceptTaskId) {
|
|
66
|
+
if (!Array.isArray(touches) || touches.length === 0) return [];
|
|
67
|
+
return markers
|
|
68
|
+
.filter((r) => String(r.task_id) !== String(exceptTaskId) && touchesOverlap(touches, r.touches))
|
|
69
|
+
.map((r) => {
|
|
70
|
+
const n = { page_id: pageIdOf(r.source_ref), holder: cleanName(r.holder, 'an artist'), since: r.claimed_at, task_id: String(r.task_id) };
|
|
71
|
+
return { ...n, line: noticeLine(n) };
|
|
72
|
+
})
|
|
73
|
+
.filter((n) => n.page_id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// heldPagesForTouches(touches, exec, { exceptTaskId }) -> notices (empty on no overlap or any failure)
|
|
77
|
+
async function heldPagesForTouches(touches, exec, { exceptTaskId = null } = {}) {
|
|
78
|
+
if (!Array.isArray(touches) || touches.length === 0) return [];
|
|
79
|
+
try {
|
|
80
|
+
return noticesFor(touches, await openMarkers(exec), exceptTaskId);
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// noticesForClaims(claims, exec) -> Map(task_id -> notices): ONE read for all of a
|
|
87
|
+
// builder's claims (GET /me), instead of one per claim. A claim with no touches or
|
|
88
|
+
// no overlap maps to [].
|
|
89
|
+
async function noticesForClaims(claims, exec = api.pool) {
|
|
90
|
+
const out = new Map();
|
|
91
|
+
const list = Array.isArray(claims) ? claims : [];
|
|
92
|
+
for (const c of list) out.set(String(c.task_id), []);
|
|
93
|
+
if (list.length === 0) return out;
|
|
94
|
+
try {
|
|
95
|
+
const markers = await openMarkers(exec);
|
|
96
|
+
for (const c of list) out.set(String(c.task_id), noticesFor(c.touches, markers, c.task_id));
|
|
97
|
+
} catch (_) { /* heads-up only */ }
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// engineeringActiveOnPage(pageTouches, exec) -> [{ task_id, holder }]
|
|
102
|
+
// Open worktree claims on real code work (never a page round, whoever holds it)
|
|
103
|
+
// whose files overlap the page's files. No title: see the header.
|
|
104
|
+
async function engineeringActiveOnPage(pageTouches, exec = api.pool) {
|
|
105
|
+
if (!Array.isArray(pageTouches) || pageTouches.length === 0) return [];
|
|
106
|
+
try {
|
|
107
|
+
const { rows } = await exec.query(
|
|
108
|
+
`SELECT t.id AS task_id, t.touches, COALESCE(b.display_name, b.github_login) AS holder
|
|
109
|
+
FROM claims c
|
|
110
|
+
JOIN tasks t ON t.id = c.task_id
|
|
111
|
+
JOIN builders b ON b.id = c.builder_id
|
|
112
|
+
WHERE c.released_at IS NULL
|
|
113
|
+
AND t.source IS DISTINCT FROM '${PAGE_TWEAK_SOURCE}'
|
|
114
|
+
LIMIT ${ROW_CAP}`
|
|
115
|
+
);
|
|
116
|
+
return rows
|
|
117
|
+
.filter((r) => touchesOverlap(pageTouches, r.touches))
|
|
118
|
+
.map((r) => ({ task_id: String(r.task_id), holder: cleanName(r.holder, 'an engineer') }));
|
|
119
|
+
} catch (_) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { heldPagesForTouches, noticesForClaims, engineeringActiveOnPage };
|
|
@@ -24,6 +24,23 @@ function webClaimPredicate(alias = 'c') {
|
|
|
24
24
|
return `${alias}.worktree_name IS NULL AND ${alias}.creator_session_id IS NULL`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// A PAGE MARKER is an artist's web claim on a page-tweak round: it says "this
|
|
28
|
+
// page is being updated" and nothing more. The file-overlap checks that guard
|
|
29
|
+
// engineering work against engineering work (the claimable feed, claimTask,
|
|
30
|
+
// the batch claim and its dry-run) leave markers out, so an artist writing a
|
|
31
|
+
// page never hides or refuses coding work on the same files. That includes
|
|
32
|
+
// claimTask's module-disjointness gate, which reads the same active-claim rows:
|
|
33
|
+
// a page round carries no module_key, and a marker must never hold a module.
|
|
34
|
+
// Returns a SQL condition that is true for every claim that is NOT a marker.
|
|
35
|
+
// COALESCE keeps it TRUE (counted) when tasks.source is NULL: without it a
|
|
36
|
+
// both-NULL claim on a source-less task would evaluate to NULL and vanish.
|
|
37
|
+
// src/bongos/optimizer.js carries a hand copy (core cannot require a module);
|
|
38
|
+
// tests/page_tweak_marker_sql.mjs pins the two together.
|
|
39
|
+
// Aliases are code literals at every call site; never pass request input.
|
|
40
|
+
function notPageMarkerSql(claimAlias = 'c', taskAlias = 't') {
|
|
41
|
+
return `NOT (COALESCE(${taskAlias}.source, '') = '${PAGE_TWEAK_SOURCE}' AND ${webClaimPredicate(claimAlias)})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
27
44
|
// The newest claim first; the id breaks a same-instant tie the same way in
|
|
28
45
|
// every reader.
|
|
29
46
|
function latestFirst(alias = 'c') {
|
|
@@ -83,4 +100,4 @@ function helperJoinSql(alias = 't') {
|
|
|
83
100
|
) helper ON true`;
|
|
84
101
|
}
|
|
85
102
|
|
|
86
|
-
module.exports = { PAGE_TWEAK_SOURCE, webClaimPredicate, latestFirst, artistIdSql, shippedBySql, helperJoinSql };
|
|
103
|
+
module.exports = { PAGE_TWEAK_SOURCE, webClaimPredicate, notPageMarkerSql, latestFirst, artistIdSql, shippedBySql, helperJoinSql };
|
|
@@ -852,8 +852,6 @@ function loggers(deps = {}) {
|
|
|
852
852
|
return { logFn: deps.log || console.log, errFn: deps.error || console.error };
|
|
853
853
|
}
|
|
854
854
|
|
|
855
|
-
// A single crash handler for the tick's fire-and-forget sweeps — same reason.
|
|
856
|
-
const crashHandler = (name) => (e) => loggers().errFn(`[publish-reconciler] ${name} crashed: ${e && e.message}`);
|
|
857
855
|
|
|
858
856
|
async function runCostSweep(deps = {}) {
|
|
859
857
|
const { logFn, errFn } = loggers(deps);
|
|
@@ -958,19 +956,23 @@ async function runPromotionSweep(deps = {}) {
|
|
|
958
956
|
|
|
959
957
|
// The craft-achievement backfill (task 1004438, WA6.RP08): the one badge a
|
|
960
958
|
// migration cannot backfill, "A Whole Surface", needs the page inventory, which is
|
|
961
|
-
// a file the database never sees.
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
|
|
959
|
+
// a file the database never sees. Runs HOURLY (task 1004965) through the reward
|
|
960
|
+
// port (economy is optional; no port, no sweep), and INCREMENTALLY: the first run
|
|
961
|
+
// of a process scans every craft-paid builder and hands back a watermark (the
|
|
962
|
+
// highest credit_log id it covered); every later run asks only about builders
|
|
963
|
+
// paid since that id, so a caught-up backfill is one indexed read that finds
|
|
964
|
+
// nothing. Pure DB and idempotent, so a restart that rescans unlocks nothing
|
|
965
|
+
// twice; a failure keeps the watermark (the next run retries the same span) and
|
|
966
|
+
// never touches the other sweeps.
|
|
967
|
+
let craftWatermark = 0;
|
|
966
968
|
async function runCraftAchievementBackfill(deps = {}) {
|
|
967
969
|
const { logFn, errFn } = loggers(deps);
|
|
968
|
-
if (craftBackfillDone && !deps.forceCraftBackfill) return { skipped: 'already_ran' };
|
|
969
|
-
craftBackfillDone = true;
|
|
970
970
|
const reward = deps.reward || seams.resolveOptional('reward');
|
|
971
971
|
if (!reward || typeof reward.backfillCraftAchievements !== 'function') return { skipped: 'unsupported' };
|
|
972
972
|
try {
|
|
973
|
-
const out = await reward.backfillCraftAchievements({});
|
|
973
|
+
const out = await reward.backfillCraftAchievements({ sinceId: craftWatermark });
|
|
974
|
+
const next = out ? Number(out.watermark) : NaN;
|
|
975
|
+
if (Number.isFinite(next) && next > craftWatermark) craftWatermark = next;
|
|
974
976
|
if (out && out.unlocked) logFn(`[publish-reconciler] craft achievements: ${out.unlocked} unlocked across ${out.checked} builder(s)`);
|
|
975
977
|
return out;
|
|
976
978
|
} catch (e) {
|
|
@@ -979,6 +981,57 @@ async function runCraftAchievementBackfill(deps = {}) {
|
|
|
979
981
|
}
|
|
980
982
|
}
|
|
981
983
|
|
|
984
|
+
// The sweeps one tick fires. Each is INDEPENDENT on purpose: the cost (task 1366)
|
|
985
|
+
// and criterion (task 1002069) sweeps are pure DB — no push credential — and a
|
|
986
|
+
// failure in either must never touch the confirmed→shipped work.
|
|
987
|
+
//
|
|
988
|
+
// CADENCE PER SWEEP (task 1004965): an entry's third element is its minimum gap
|
|
989
|
+
// between runs; none means every tick (INTERVAL_MS). The publish, cost and
|
|
990
|
+
// criterion sweeps keep the tick — a strand, an unpriced session and a newly
|
|
991
|
+
// satisfiable criterion are all things someone is waiting on. The promotion sweep
|
|
992
|
+
// and the craft backfill run HOURLY: the ship-time triggers already promote and
|
|
993
|
+
// unlock on the event, so these are backlog drains, and a drain an hour late
|
|
994
|
+
// costs nobody anything while running it every 5 minutes cost every instance
|
|
995
|
+
// ~288 scans a day.
|
|
996
|
+
const HOURLY_MS = 60 * 60_000;
|
|
997
|
+
const SWEEPS = [
|
|
998
|
+
['sweep', runSweep],
|
|
999
|
+
['cost sweep', runCostSweep],
|
|
1000
|
+
['criterion sweep', runCriterionSweep],
|
|
1001
|
+
['promotion sweep', runPromotionSweep, HOURLY_MS],
|
|
1002
|
+
['craft achievement backfill', runCraftAchievementBackfill, HOURLY_MS],
|
|
1003
|
+
];
|
|
1004
|
+
|
|
1005
|
+
// buildTick — one interval round, with a per-sweep overlap guard (task 1004924).
|
|
1006
|
+
// A sweep whose previous run has not settled is SKIPPED this tick, so a slow DB
|
|
1007
|
+
// or GitHub call can never stack rounds and hold several pool connections at
|
|
1008
|
+
// once. The flag is per sweep, so one slow sweep never starves the others, and
|
|
1009
|
+
// it clears whether the run resolves or rejects. `sweeps` is the test seam.
|
|
1010
|
+
//
|
|
1011
|
+
// A sweep with a cadence runs on the first tick, then again once its gap has
|
|
1012
|
+
// passed. Half a tick of slack, so timer jitter (a tick landing a few ms early)
|
|
1013
|
+
// never pushes an hourly sweep a whole tick late. `now` is the clock seam.
|
|
1014
|
+
function buildTick(deps = {}, sweeps = SWEEPS, { now = Date.now, tickMs = INTERVAL_MS } = {}) {
|
|
1015
|
+
const inFlight = new Set();
|
|
1016
|
+
const lastRun = new Map();
|
|
1017
|
+
return () => {
|
|
1018
|
+
const t = now();
|
|
1019
|
+
for (const [name, fn, everyMs] of sweeps) {
|
|
1020
|
+
if (everyMs && lastRun.has(name) && t - lastRun.get(name) < everyMs - tickMs / 2) continue; // not due yet
|
|
1021
|
+
if (inFlight.has(name)) {
|
|
1022
|
+
loggers(deps).logFn(`[publish-reconciler] ${name} still running from the last tick — skipped`);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
lastRun.set(name, t);
|
|
1026
|
+
inFlight.add(name);
|
|
1027
|
+
Promise.resolve()
|
|
1028
|
+
.then(() => fn(deps))
|
|
1029
|
+
.catch((e) => loggers(deps).errFn(`[publish-reconciler] ${name} crashed: ${e && e.message}`))
|
|
1030
|
+
.finally(() => inFlight.delete(name));
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
982
1035
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
|
|
983
1036
|
// by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
|
|
984
1037
|
// default whenever a push credential is configured; the route's getPublishStatus
|
|
@@ -991,16 +1044,7 @@ function start(deps = {}) {
|
|
|
991
1044
|
}
|
|
992
1045
|
if (intervalTimer) return; // already started
|
|
993
1046
|
|
|
994
|
-
|
|
995
|
-
// (task 1002069) sweeps are pure DB — no push credential — and a failure in
|
|
996
|
-
// either must never touch the confirmed→shipped work.
|
|
997
|
-
const tick = () => {
|
|
998
|
-
runSweep(deps).catch(crashHandler('sweep'));
|
|
999
|
-
runCostSweep(deps).catch(crashHandler('cost sweep'));
|
|
1000
|
-
runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
|
|
1001
|
-
runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
|
|
1002
|
-
runCraftAchievementBackfill(deps).catch(crashHandler('craft achievement backfill'));
|
|
1003
|
-
};
|
|
1047
|
+
const tick = buildTick(deps);
|
|
1004
1048
|
|
|
1005
1049
|
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
1006
1050
|
if (firstTimer.unref) firstTimer.unref();
|
|
@@ -1039,7 +1083,10 @@ module.exports = {
|
|
|
1039
1083
|
runPromotionSweep,
|
|
1040
1084
|
runCriterionSweep,
|
|
1041
1085
|
runCraftAchievementBackfill,
|
|
1086
|
+
buildTick,
|
|
1042
1087
|
couldBeStaleGenerated,
|
|
1043
1088
|
describeTarget,
|
|
1044
1089
|
INTERVAL_MS,
|
|
1090
|
+
HOURLY_MS,
|
|
1091
|
+
SWEEPS,
|
|
1045
1092
|
};
|
|
@@ -305,7 +305,7 @@ module.exports = function buildClaimsRouter() {
|
|
|
305
305
|
enforceOverlap: enforceTouches === true,
|
|
306
306
|
autoPickup: autoPickup === true,
|
|
307
307
|
});
|
|
308
|
-
const { just_unlocked: justUnlocked, overlap_warning: overlapWarning, sibling_overlap_warning: siblingOverlapWarning, ...claim } = claimResult;
|
|
308
|
+
const { just_unlocked: justUnlocked, overlap_warning: overlapWarning, sibling_overlap_warning: siblingOverlapWarning, page_notice: pageNotice, ...claim } = claimResult;
|
|
309
309
|
const task = await db.getTask(Number(taskId));
|
|
310
310
|
// task 1168: keep the evergreen newcomer-chore shelf full. If this claim
|
|
311
311
|
// took a chore instance, stamp a replacement NOW (before responding) so
|
|
@@ -339,7 +339,7 @@ module.exports = function buildClaimsRouter() {
|
|
|
339
339
|
const p = rw && typeof rw.rewardConfig === 'function' ? rw.rewardConfig() : null;
|
|
340
340
|
if (p && typeof p.mode === 'string') rewardMode = { mode: p.mode, perTaskCredit: p.perTaskCredit !== false };
|
|
341
341
|
} catch (_) { /* display-only: a claim must never fail over this */ }
|
|
342
|
-
res.status(201).json({ claim, task, just_unlocked: justUnlocked ?? [], overlap_warning: overlapWarning ?? null, sibling_overlap_warning: siblingOverlapWarning ?? null, reward: rewardMode });
|
|
342
|
+
res.status(201).json({ claim, task, just_unlocked: justUnlocked ?? [], overlap_warning: overlapWarning ?? null, sibling_overlap_warning: siblingOverlapWarning ?? null, page_notice: pageNotice ?? [], reward: rewardMode });
|
|
343
343
|
} catch (err) {
|
|
344
344
|
if (err && err.code) {
|
|
345
345
|
const status =
|
|
@@ -388,6 +388,9 @@ module.exports = function buildClaimsRouter() {
|
|
|
388
388
|
// block, not an authorization refusal). The refusal object already
|
|
389
389
|
// carries `message` + `hint`, so no extra hint-building step needed.
|
|
390
390
|
err.code === 'RELEVANCE_REVIEW_PENDING' ? 409 :
|
|
391
|
+
// task 1004950 / ADR 0395: the task needs hands on the live server and
|
|
392
|
+
// the claimer holds no server grant — an authorization refusal, like rank.
|
|
393
|
+
err.code === 'NEEDS_SERVER_ACCESS' ? 403 :
|
|
391
394
|
// ADR 0371 D5/D6: a marked auto-pickup claim the project's switch, monthly
|
|
392
395
|
// cap or per-round limit refuses. The round waits for a person.
|
|
393
396
|
err.code === 'AUTO_PICKUP_NOT_A_TWEAK' ? 409 :
|
|
@@ -1333,6 +1333,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
1333
1333
|
discipline: { type: 'string', maxLength: 64 },
|
|
1334
1334
|
value_summary: { type: 'string', maxLength: LIMITS.VALUE_SUMMARY },
|
|
1335
1335
|
parent_task_id: { type: 'integer', min: 1 },
|
|
1336
|
+
needs: {}, // task 1004950 / ADR 0395 — ['owner'|'server']; a need narrows who is shown the task, it grants nothing
|
|
1336
1337
|
// Deliberately NOT read from the client (validateOrRespond ignores unknown
|
|
1337
1338
|
// fields, so any of these a client smuggles in are simply never used):
|
|
1338
1339
|
// • version_id — derived from the goal (a member can't author cross-version);
|
|
@@ -1343,6 +1344,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
1343
1344
|
})) return;
|
|
1344
1345
|
|
|
1345
1346
|
const body = req.body || {};
|
|
1347
|
+
const goalNeeds = api.taskNeeds.normalizeNeeds(body.needs); if (!goalNeeds.ok) return require('./task-route-helpers.js').badNeeds(res, goalNeeds.bad);
|
|
1346
1348
|
const touches = Array.isArray(body.touches) ? [...body.touches] : [];
|
|
1347
1349
|
|
|
1348
1350
|
try {
|
|
@@ -1425,7 +1427,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
1425
1427
|
kind,
|
|
1426
1428
|
discipline,
|
|
1427
1429
|
source: 'goal-authored',
|
|
1428
|
-
goalId: goal.id,
|
|
1430
|
+
goalId: goal.id, needs: goalNeeds.needs,
|
|
1429
1431
|
// task 1003278 — attribution from the authenticated request (never the body).
|
|
1430
1432
|
createdBy: req.builder.id,
|
|
1431
1433
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// modules/lifecycle/routes/notes.js — notes on a task or a goal, read grouped by
|
|
2
|
+
// purpose (task 1004745, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// Four routes: read the notes on one task or goal (already grouped), add one, re-file
|
|
5
|
+
// one under another purpose, and delete one. The grouping, the subject rule, who may
|
|
6
|
+
// see a private goal's notes and who may change a note are all notes.js's; this surface
|
|
7
|
+
// only gathers the request and renders the verdict.
|
|
8
|
+
//
|
|
9
|
+
// A note nobody may see answers 404, exactly as a missing one does, so the answer
|
|
10
|
+
// never tells a non-member that a private goal has notes. Every write here is recorded
|
|
11
|
+
// by the server's audit middleware, so a Metic's delete leaves a trail.
|
|
12
|
+
//
|
|
13
|
+
// Reaches core only through the doorway (ADR 0083).
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const express = require('express');
|
|
18
|
+
const api = require('../../../src/module-api');
|
|
19
|
+
const notes = require('../notes');
|
|
20
|
+
|
|
21
|
+
const { asyncHandler, parseId, validateOrRespond } = api;
|
|
22
|
+
|
|
23
|
+
module.exports = function buildNotesRouter() {
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
|
|
26
|
+
// rank: any-builder — notes are shared working information, readable by anyone
|
|
27
|
+
// signed in; a private goal's notes are narrowed to its members by notes.canSee.
|
|
28
|
+
router.get('/notes', api.requireBuilder, asyncHandler('GET /notes', async (req, res) => {
|
|
29
|
+
const subject = notes.subjectOf(req.query || {});
|
|
30
|
+
if (!subject) return res.fail('invalid_subject', 400, { hint: 'give exactly one of task_id or goal_id' });
|
|
31
|
+
if (!(await notes.canSee(req.builder, subject))) return res.fail('not_found', 404);
|
|
32
|
+
const rows = await notes.listNotes(subject);
|
|
33
|
+
return res.json({ count: rows.length, groups: notes.groupNotes(rows), kinds: notes.KINDS });
|
|
34
|
+
}, { errorCode: 'notes_read_failed' }));
|
|
35
|
+
|
|
36
|
+
// rank: any-builder — adding information is not a privileged act; the author is
|
|
37
|
+
// the caller and is never taken from the body.
|
|
38
|
+
router.post('/notes', api.requireBuilder, asyncHandler('POST /notes', async (req, res) => {
|
|
39
|
+
if (validateOrRespond(req, res, {
|
|
40
|
+
body: { required: true, type: 'string', minLength: 1, maxLength: notes.MAX_BODY },
|
|
41
|
+
kind: { type: 'string', maxLength: 16 },
|
|
42
|
+
task_id: { type: 'integer', min: 1 },
|
|
43
|
+
goal_id: { type: 'integer', min: 1 },
|
|
44
|
+
})) return;
|
|
45
|
+
const body = req.body || {};
|
|
46
|
+
const v = notes.validateNote(body);
|
|
47
|
+
if (!v.ok) return res.fail('invalid_note', 400, { errors: v.errors, kinds: notes.KIND_KEYS });
|
|
48
|
+
const subject = notes.subjectOf(body);
|
|
49
|
+
if (!(await notes.canSee(req.builder, subject))) return res.fail('not_found', 404);
|
|
50
|
+
if ((await notes.countNotes(subject)) >= notes.MAX_NOTES_PER_SUBJECT) {
|
|
51
|
+
return res.fail('note_limit', 409, { limit: notes.MAX_NOTES_PER_SUBJECT });
|
|
52
|
+
}
|
|
53
|
+
const note = await notes.addNote({
|
|
54
|
+
taskId: subject.taskId ?? null,
|
|
55
|
+
goalId: subject.goalId ?? null,
|
|
56
|
+
authorId: req.builder.id,
|
|
57
|
+
body: v.body,
|
|
58
|
+
kind: v.kind,
|
|
59
|
+
kindSource: v.kindSource,
|
|
60
|
+
});
|
|
61
|
+
return res.status(201).json({ ok: true, note });
|
|
62
|
+
}, { errorCode: 'note_create_failed' }));
|
|
63
|
+
|
|
64
|
+
// rank: any-builder, narrowed in the handler — the author re-files their own note;
|
|
65
|
+
// a Metic or Archon may re-file anyone's (the grouping is a shared view).
|
|
66
|
+
router.patch('/notes/:id', api.requireBuilder, asyncHandler('PATCH /notes/:id', async (req, res) => {
|
|
67
|
+
if (validateOrRespond(req, res, { kind: { required: true, type: 'string', maxLength: 16 } })) return;
|
|
68
|
+
const id = parseId(req.params.id);
|
|
69
|
+
const kind = req.body.kind;
|
|
70
|
+
if (id == null) return res.fail('invalid_id', 400);
|
|
71
|
+
if (!notes.KIND_KEYS.includes(kind)) return res.fail('invalid_note', 400, { errors: [{ field: 'kind', reason: 'pick one of the listed groups' }], kinds: notes.KIND_KEYS });
|
|
72
|
+
const existing = await notes.getNote(id);
|
|
73
|
+
if (!existing || !(await notes.canSee(req.builder, notes.subjectOf(existing)))) return res.fail('not_found', 404);
|
|
74
|
+
if (!notes.canChange(req.builder, existing)) return res.fail('not_yours', 403);
|
|
75
|
+
const row = await notes.setKind(id, kind);
|
|
76
|
+
return res.json({ ok: true, note: row });
|
|
77
|
+
}, { errorCode: 'note_update_failed' }));
|
|
78
|
+
|
|
79
|
+
// rank: any-builder, narrowed in the handler exactly as the re-file above.
|
|
80
|
+
router.delete('/notes/:id', api.requireBuilder, asyncHandler('DELETE /notes/:id', async (req, res) => {
|
|
81
|
+
const id = parseId(req.params.id);
|
|
82
|
+
if (id == null) return res.fail('invalid_id', 400);
|
|
83
|
+
const existing = await notes.getNote(id);
|
|
84
|
+
if (!existing || !(await notes.canSee(req.builder, notes.subjectOf(existing)))) return res.fail('not_found', 404);
|
|
85
|
+
if (!notes.canChange(req.builder, existing)) return res.fail('not_yours', 403);
|
|
86
|
+
await notes.deleteNote(id);
|
|
87
|
+
return res.json({ ok: true });
|
|
88
|
+
}, { errorCode: 'note_delete_failed' }));
|
|
89
|
+
|
|
90
|
+
return router;
|
|
91
|
+
};
|