agents-city 0.3.0-beta.21
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/.claude-plugin/marketplace.json +16 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.es.md +2021 -0
- package/README.md +1999 -0
- package/benchmarks/committee/README.md +39 -0
- package/benchmarks/committee/metrics.py +51 -0
- package/benchmarks/committee/run.py +64 -0
- package/benchmarks/committee/traces/01-single.json +8 -0
- package/benchmarks/committee/traces/02-mesh.json +16 -0
- package/benchmarks/committee/traces/03-chair.json +15 -0
- package/benchmarks/latency/README.md +35 -0
- package/benchmarks/latency/fake-claude-cli.mjs +119 -0
- package/benchmarks/latency/fake-native-server.mjs +340 -0
- package/benchmarks/latency/fake-tui.py +91 -0
- package/benchmarks/latency/live.py +380 -0
- package/benchmarks/stress/README.md +20 -0
- package/benchmarks/stress/run.py +487 -0
- package/bin/agents +4 -0
- package/bin/agents-city.js +116 -0
- package/bin/benchmark +17 -0
- package/bin/bus +6 -0
- package/bin/cities +3 -0
- package/bin/city +86 -0
- package/bin/committee +6 -0
- package/bin/demo +200 -0
- package/bin/exit +5 -0
- package/bin/hall +73 -0
- package/bin/hall.html +426 -0
- package/bin/logs +3 -0
- package/bin/report.py +14 -0
- package/bin/reset +3 -0
- package/bin/road +3 -0
- package/bin/seat +10 -0
- package/bin/serve.py +1428 -0
- package/bin/setup.py +753 -0
- package/bin/skills +3 -0
- package/bin/test +131 -0
- package/bin/test-adapter.py +183 -0
- package/bin/test-admision.py +99 -0
- package/bin/test-avatar.py +58 -0
- package/bin/test-benchmark.py +40 -0
- package/bin/test-broker.py +334 -0
- package/bin/test-cage.py +206 -0
- package/bin/test-card.py +402 -0
- package/bin/test-channel.py +379 -0
- package/bin/test-cities.py +453 -0
- package/bin/test-claude-runtime.py +252 -0
- package/bin/test-committee.py +340 -0
- package/bin/test-contracts.py +544 -0
- package/bin/test-crecimiento.py +164 -0
- package/bin/test-demo.py +308 -0
- package/bin/test-doctor.py +106 -0
- package/bin/test-domains.py +151 -0
- package/bin/test-evidencia.py +50 -0
- package/bin/test-exit.py +128 -0
- package/bin/test-hall-protocol.py +93 -0
- package/bin/test-launch.py +169 -0
- package/bin/test-live-feed.py +508 -0
- package/bin/test-pairing.py +99 -0
- package/bin/test-parcels.py +283 -0
- package/bin/test-runtime-failures.py +252 -0
- package/bin/test-runtime-ui.py +488 -0
- package/bin/test-runtime.py +268 -0
- package/bin/test-rutas.py +80 -0
- package/bin/test-seat.py +1386 -0
- package/bin/test-security.py +150 -0
- package/bin/test-serve.py +1057 -0
- package/bin/test-stress.py +64 -0
- package/bin/test-widgets.py +143 -0
- package/bin/test-workspace.py +188 -0
- package/bin/testlib.py +95 -0
- package/bin/tokens.py +14 -0
- package/bus/scripts/deploy.sh +60 -0
- package/bus/scripts/mint-token.sh +68 -0
- package/bus/scripts/setup-dev.sh +79 -0
- package/bus/scripts/test-channel.ts +8 -0
- package/bus/scripts/test-hub.ts +266 -0
- package/bus/scripts/test-local.sh +40 -0
- package/bus/scripts/test-queue.ts +132 -0
- package/bus/worker/package-lock.json +1971 -0
- package/bus/worker/package.json +17 -0
- package/bus/worker/src/index.ts +485 -0
- package/bus/worker/tsconfig.json +13 -0
- package/bus/worker/wrangler.toml +32 -0
- package/city/oven/README.md +59 -0
- package/city/oven/collect.py +43 -0
- package/city/oven/oven.html +4 -0
- package/city/scripts/history.py +120 -0
- package/city/scripts/seed.py +442 -0
- package/city/web/assets/modelos/License.txt +28 -0
- package/city/web/assets/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/assets/modelos/building-a.glb +0 -0
- package/city/web/assets/modelos/building-b.glb +0 -0
- package/city/web/assets/modelos/building-c.glb +0 -0
- package/city/web/assets/modelos/building-d.glb +0 -0
- package/city/web/assets/modelos/building-e.glb +0 -0
- package/city/web/assets/modelos/building-f.glb +0 -0
- package/city/web/assets/modelos/building-g.glb +0 -0
- package/city/web/assets/modelos/building-h.glb +0 -0
- package/city/web/assets/sprites/building-a.png +0 -0
- package/city/web/assets/sprites/building-b.png +0 -0
- package/city/web/assets/sprites/building-c.png +0 -0
- package/city/web/assets/sprites/building-d.png +0 -0
- package/city/web/assets/sprites/building-e.png +0 -0
- package/city/web/assets/sprites/building-f.png +0 -0
- package/city/web/assets/sprites/building-g.png +0 -0
- package/city/web/assets/sprites/building-h.png +0 -0
- package/city/web/assets/sprites/building-i.png +0 -0
- package/city/web/assets/sprites/building-j.png +0 -0
- package/city/web/assets/sprites/building-k.png +0 -0
- package/city/web/assets/sprites/building-l.png +0 -0
- package/city/web/assets/sprites/building-m.png +0 -0
- package/city/web/assets/sprites/building-n.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-a.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-b.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-c.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-d.png +0 -0
- package/city/web/assets/sprites/building-skyscraper-e.png +0 -0
- package/city/web/assets/sprites/catalogo.json +47 -0
- package/city/web/assets/sprites/detail-awning-wide.png +0 -0
- package/city/web/assets/sprites/detail-awning.png +0 -0
- package/city/web/assets/sprites/detail-overhang-wide.png +0 -0
- package/city/web/assets/sprites/detail-overhang.png +0 -0
- package/city/web/assets/sprites/detail-parasol-a.png +0 -0
- package/city/web/assets/sprites/detail-parasol-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-b.png +0 -0
- package/city/web/assets/sprites/low-detail-building-c.png +0 -0
- package/city/web/assets/sprites/low-detail-building-d.png +0 -0
- package/city/web/assets/sprites/low-detail-building-e.png +0 -0
- package/city/web/assets/sprites/low-detail-building-f.png +0 -0
- package/city/web/assets/sprites/low-detail-building-g.png +0 -0
- package/city/web/assets/sprites/low-detail-building-h.png +0 -0
- package/city/web/assets/sprites/low-detail-building-i.png +0 -0
- package/city/web/assets/sprites/low-detail-building-j.png +0 -0
- package/city/web/assets/sprites/low-detail-building-k.png +0 -0
- package/city/web/assets/sprites/low-detail-building-l.png +0 -0
- package/city/web/assets/sprites/low-detail-building-m.png +0 -0
- package/city/web/assets/sprites/low-detail-building-n.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/assets/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/assets/sprites/medidas.json +248 -0
- package/city/web/dist/city.js +939 -0
- package/city/web/dist/index.html +294 -0
- package/city/web/dist/modelos/License.txt +28 -0
- package/city/web/dist/modelos/PROCEDENCIA.txt +3 -0
- package/city/web/dist/modelos/building-a.glb +0 -0
- package/city/web/dist/modelos/building-b.glb +0 -0
- package/city/web/dist/modelos/building-c.glb +0 -0
- package/city/web/dist/modelos/building-d.glb +0 -0
- package/city/web/dist/modelos/building-e.glb +0 -0
- package/city/web/dist/modelos/building-f.glb +0 -0
- package/city/web/dist/modelos/building-g.glb +0 -0
- package/city/web/dist/modelos/building-h.glb +0 -0
- package/city/web/dist/sprites/building-a.png +0 -0
- package/city/web/dist/sprites/building-b.png +0 -0
- package/city/web/dist/sprites/building-c.png +0 -0
- package/city/web/dist/sprites/building-d.png +0 -0
- package/city/web/dist/sprites/building-e.png +0 -0
- package/city/web/dist/sprites/building-f.png +0 -0
- package/city/web/dist/sprites/building-g.png +0 -0
- package/city/web/dist/sprites/building-h.png +0 -0
- package/city/web/dist/sprites/building-i.png +0 -0
- package/city/web/dist/sprites/building-j.png +0 -0
- package/city/web/dist/sprites/building-k.png +0 -0
- package/city/web/dist/sprites/building-l.png +0 -0
- package/city/web/dist/sprites/building-m.png +0 -0
- package/city/web/dist/sprites/building-n.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-a.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-b.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-c.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-d.png +0 -0
- package/city/web/dist/sprites/building-skyscraper-e.png +0 -0
- package/city/web/dist/sprites/catalogo.json +47 -0
- package/city/web/dist/sprites/detail-awning-wide.png +0 -0
- package/city/web/dist/sprites/detail-awning.png +0 -0
- package/city/web/dist/sprites/detail-overhang-wide.png +0 -0
- package/city/web/dist/sprites/detail-overhang.png +0 -0
- package/city/web/dist/sprites/detail-parasol-a.png +0 -0
- package/city/web/dist/sprites/detail-parasol-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-b.png +0 -0
- package/city/web/dist/sprites/low-detail-building-c.png +0 -0
- package/city/web/dist/sprites/low-detail-building-d.png +0 -0
- package/city/web/dist/sprites/low-detail-building-e.png +0 -0
- package/city/web/dist/sprites/low-detail-building-f.png +0 -0
- package/city/web/dist/sprites/low-detail-building-g.png +0 -0
- package/city/web/dist/sprites/low-detail-building-h.png +0 -0
- package/city/web/dist/sprites/low-detail-building-i.png +0 -0
- package/city/web/dist/sprites/low-detail-building-j.png +0 -0
- package/city/web/dist/sprites/low-detail-building-k.png +0 -0
- package/city/web/dist/sprites/low-detail-building-l.png +0 -0
- package/city/web/dist/sprites/low-detail-building-m.png +0 -0
- package/city/web/dist/sprites/low-detail-building-n.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-a.png +0 -0
- package/city/web/dist/sprites/low-detail-building-wide-b.png +0 -0
- package/city/web/dist/sprites/medidas.json +248 -0
- package/city/web/dist-hall/hall.js +264 -0
- package/city/web/index.html +294 -0
- package/city/web/package.json +18 -0
- package/city/web/sella.py +21 -0
- package/city/web/src/activity-actors.ts +47 -0
- package/city/web/src/activity.ts +172 -0
- package/city/web/src/ayuntamiento.ts +580 -0
- package/city/web/src/draw.ts +640 -0
- package/city/web/src/game-speech.ts +79 -0
- package/city/web/src/hall.ts +1867 -0
- package/city/web/src/main.ts +2225 -0
- package/city/web/src/oven.ts +124 -0
- package/city/web/src/people.ts +244 -0
- package/city/web/src/presencia.ts +193 -0
- package/city/web/src/puertas.ts +180 -0
- package/city/web/src/repo-roles.ts +59 -0
- package/city/worker/package.json +16 -0
- package/city/worker/schema.sql +117 -0
- package/city/worker/src/index.ts +702 -0
- package/city/worker/src/square.ts +218 -0
- package/city/worker/wrangler.toml +58 -0
- package/demo/ada.md +50 -0
- package/demo/bruno.md +62 -0
- package/demo/camila.md +56 -0
- package/demo/city/ada.md +40 -0
- package/demo/city/city.yml +8 -0
- package/demo/city/parcels.yml +15 -0
- package/demo/city/roads.json +4 -0
- package/demo/city/units.yml +10 -0
- package/demo/clinica/city.yml +8 -0
- package/demo/clinica/parcels.yml +15 -0
- package/demo/clinica/roads.json +4 -0
- package/demo/clinica/units.yml +9 -0
- package/demo/clinica/vera.md +46 -0
- package/demo/dante.md +54 -0
- package/demo/despacho/city.yml +8 -0
- package/demo/despacho/marta.md +46 -0
- package/demo/despacho/parcels.yml +15 -0
- package/demo/despacho/roads.json +4 -0
- package/demo/despacho/units.yml +9 -0
- package/demo/elsa.md +50 -0
- package/demo/farid.md +52 -0
- package/demo/greta.md +50 -0
- package/demo/hugo.md +49 -0
- package/demo/iris.md +49 -0
- package/demo/jonas.md +51 -0
- package/demo/kira.md +51 -0
- package/demo/luca.md +49 -0
- package/demo/parcels.yml +92 -0
- package/demo/seed.py +20 -0
- package/demo/show.py +161 -0
- package/demo/stories.py +824 -0
- package/demo/units.yml +33 -0
- package/docs/agents-first.md +99 -0
- package/docs/glossary.md +55 -0
- package/docs/map-live-layers.md +73 -0
- package/docs/security.md +231 -0
- package/docs/self-host.md +151 -0
- package/docs/testing.md +205 -0
- package/package.json +73 -0
- package/plugin/.claude-plugin/plugin.json +62 -0
- package/plugin/.mcp.json +15 -0
- package/plugin/channel/activity-cli.ts +16 -0
- package/plugin/channel/adapter-prompts.ts +113 -0
- package/plugin/channel/adapter.js +4292 -0
- package/plugin/channel/adapter.ts +133 -0
- package/plugin/channel/bus.js +20188 -0
- package/plugin/channel/bus.ts +131 -0
- package/plugin/channel/city-config.ts +125 -0
- package/plugin/channel/claude-channel.ts +58 -0
- package/plugin/channel/cli-args.ts +75 -0
- package/plugin/channel/client.js +4200 -0
- package/plugin/channel/client.ts +38 -0
- package/plugin/channel/committee/activity.ts +256 -0
- package/plugin/channel/committee/collection.ts +154 -0
- package/plugin/channel/committee/decision.ts +175 -0
- package/plugin/channel/committee/floor.ts +135 -0
- package/plugin/channel/committee/guards.ts +30 -0
- package/plugin/channel/committee/history.ts +38 -0
- package/plugin/channel/committee/render.ts +102 -0
- package/plugin/channel/committee/service.ts +72 -0
- package/plugin/channel/committee/storage.ts +92 -0
- package/plugin/channel/committee/types.ts +163 -0
- package/plugin/channel/committee/view.ts +63 -0
- package/plugin/channel/committee-cli.ts +109 -0
- package/plugin/channel/delivery-metrics.ts +61 -0
- package/plugin/channel/delivery-queue.ts +118 -0
- package/plugin/channel/hub/activity-controller.ts +72 -0
- package/plugin/channel/hub/activity-feed.ts +169 -0
- package/plugin/channel/hub/committee-controller.ts +60 -0
- package/plugin/channel/hub/connections.ts +48 -0
- package/plugin/channel/hub/diagnostics.ts +67 -0
- package/plugin/channel/hub/envelope-validity.ts +109 -0
- package/plugin/channel/hub/envelopes.ts +66 -0
- package/plugin/channel/hub/lifecycle.ts +91 -0
- package/plugin/channel/hub/local-roads.ts +89 -0
- package/plugin/channel/hub/remote-roads.ts +165 -0
- package/plugin/channel/hub/road-controller.ts +99 -0
- package/plugin/channel/hub-client.ts +149 -0
- package/plugin/channel/local-hub.js +5995 -0
- package/plugin/channel/local-hub.ts +349 -0
- package/plugin/channel/map-reporter.ts +48 -0
- package/plugin/channel/package-lock.json +1731 -0
- package/plugin/channel/package.json +19 -0
- package/plugin/channel/protocol.ts +98 -0
- package/plugin/channel/road-cli.ts +18 -0
- package/plugin/channel/run.sh +15 -0
- package/plugin/channel/runtime/claude.ts +458 -0
- package/plugin/channel/runtime/codex-config.ts +77 -0
- package/plugin/channel/runtime/codex.ts +669 -0
- package/plugin/channel/runtime/command.ts +62 -0
- package/plugin/channel/runtime/factory.ts +15 -0
- package/plugin/channel/runtime/json-rpc.ts +154 -0
- package/plugin/channel/runtime/kimi.ts +277 -0
- package/plugin/channel/runtime/opencode.ts +248 -0
- package/plugin/channel/runtime/process.ts +84 -0
- package/plugin/channel/runtime/types.ts +45 -0
- package/plugin/channel/runtime-files.ts +73 -0
- package/plugin/channel/runtime-gateway.js +6396 -0
- package/plugin/channel/runtime-gateway.ts +358 -0
- package/plugin/channel/runtime-metrics.ts +52 -0
- package/plugin/channel/runtime-subscription.ts +180 -0
- package/plugin/channel/terminal-delivery.ts +131 -0
- package/plugin/channel/untrusted.ts +60 -0
- package/plugin/commands/committee.md +31 -0
- package/plugin/commands/exit.md +32 -0
- package/plugin/commands/goals.md +20 -0
- package/plugin/commands/join.md +18 -0
- package/plugin/commands/notice.md +20 -0
- package/plugin/commands/propose.md +19 -0
- package/plugin/commands/round.md +17 -0
- package/plugin/commands/session.md +22 -0
- package/plugin/commands/settings.md +25 -0
- package/plugin/commands/setup.md +21 -0
- package/plugin/commands/team.md +23 -0
- package/plugin/domains/custom.md +27 -0
- package/plugin/domains/finance.md +31 -0
- package/plugin/domains/healthcare.md +34 -0
- package/plugin/domains/legal.md +31 -0
- package/plugin/domains/marketing.md +33 -0
- package/plugin/domains/operations.md +31 -0
- package/plugin/domains/research.md +32 -0
- package/plugin/domains/sales.md +30 -0
- package/plugin/domains/software.md +37 -0
- package/plugin/hooks/activity.sh +6 -0
- package/plugin/hooks/digging.sh +68 -0
- package/plugin/hooks/growth.sh +42 -0
- package/plugin/hooks/hooks.json +106 -0
- package/plugin/hooks/notice-on-pr.sh +35 -0
- package/plugin/hooks/notice-on-stop.sh +80 -0
- package/plugin/hooks/notice-pending.sh +58 -0
- package/plugin/hooks/solo-en-ciudad.sh +23 -0
- package/plugin/hooks/tokens.sh +47 -0
- package/plugin/roles/examples/account-executive.md +25 -0
- package/plugin/roles/examples/ai-manager.md +34 -0
- package/plugin/roles/examples/associate.md +24 -0
- package/plugin/roles/examples/brand-lead.md +20 -0
- package/plugin/roles/examples/cfo.md +20 -0
- package/plugin/roles/examples/city-lead.md +25 -0
- package/plugin/roles/examples/clinical-director.md +26 -0
- package/plugin/roles/examples/clinical-ops.md +25 -0
- package/plugin/roles/examples/clinician.md +25 -0
- package/plugin/roles/examples/compliance.md +27 -0
- package/plugin/roles/examples/content.md +24 -0
- package/plugin/roles/examples/controller.md +27 -0
- package/plugin/roles/examples/cpto.md +41 -0
- package/plugin/roles/examples/customer-success.md +25 -0
- package/plugin/roles/examples/data-engineer.md +37 -0
- package/plugin/roles/examples/data.md +60 -0
- package/plugin/roles/examples/dev.md +37 -0
- package/plugin/roles/examples/devops.md +39 -0
- package/plugin/roles/examples/enablement.md +24 -0
- package/plugin/roles/examples/ethics.md +24 -0
- package/plugin/roles/examples/fin-analytics.md +27 -0
- package/plugin/roles/examples/health-compliance.md +25 -0
- package/plugin/roles/examples/health-data.md +26 -0
- package/plugin/roles/examples/knowledge.md +25 -0
- package/plugin/roles/examples/lifecycle.md +26 -0
- package/plugin/roles/examples/llm-engineer.md +34 -0
- package/plugin/roles/examples/managing-partner.md +20 -0
- package/plugin/roles/examples/methods.md +23 -0
- package/plugin/roles/examples/operations-lead.md +23 -0
- package/plugin/roles/examples/ops.md +24 -0
- package/plugin/roles/examples/patient-safety.md +26 -0
- package/plugin/roles/examples/performance.md +27 -0
- package/plugin/roles/examples/po.md +36 -0
- package/plugin/roles/examples/process-owner.md +23 -0
- package/plugin/roles/examples/product-design.md +39 -0
- package/plugin/roles/examples/program-manager.md +24 -0
- package/plugin/roles/examples/quality.md +25 -0
- package/plugin/roles/examples/research-director.md +24 -0
- package/plugin/roles/examples/research-ops.md +25 -0
- package/plugin/roles/examples/researcher.md +25 -0
- package/plugin/roles/examples/revenue-lead.md +25 -0
- package/plugin/roles/examples/revops.md +26 -0
- package/plugin/roles/examples/seo.md +27 -0
- package/plugin/roles/examples/specialist.md +24 -0
- package/plugin/scripts/admision.py +165 -0
- package/plugin/scripts/apaga.py +232 -0
- package/plugin/scripts/avatar.py +211 -0
- package/plugin/scripts/broker.py +531 -0
- package/plugin/scripts/cage.py +252 -0
- package/plugin/scripts/capabilities.py +197 -0
- package/plugin/scripts/card.py +312 -0
- package/plugin/scripts/cities.py +520 -0
- package/plugin/scripts/city-env.sh +90 -0
- package/plugin/scripts/city-runtime.sh +87 -0
- package/plugin/scripts/city-session.sh +526 -0
- package/plugin/scripts/city_env.py +67 -0
- package/plugin/scripts/crecimiento.py +140 -0
- package/plugin/scripts/deliberations.py +48 -0
- package/plugin/scripts/doctor.py +172 -0
- package/plugin/scripts/domains.py +201 -0
- package/plugin/scripts/evidencia.py +64 -0
- package/plugin/scripts/find-repos.sh +111 -0
- package/plugin/scripts/gh.py +159 -0
- package/plugin/scripts/hall_protocol.py +147 -0
- package/plugin/scripts/hook_activity.py +139 -0
- package/plugin/scripts/launch.py +94 -0
- package/plugin/scripts/logs.py +98 -0
- package/plugin/scripts/pairing.py +185 -0
- package/plugin/scripts/parcels.py +132 -0
- package/plugin/scripts/read-card.py +51 -0
- package/plugin/scripts/report.py +201 -0
- package/plugin/scripts/reset.py +180 -0
- package/plugin/scripts/roads.py +230 -0
- package/plugin/scripts/roles.py +274 -0
- package/plugin/scripts/runtime_log.py +69 -0
- package/plugin/scripts/runtime_processes.py +132 -0
- package/plugin/scripts/rutas.py +83 -0
- package/plugin/scripts/seat.py +1349 -0
- package/plugin/scripts/tokens.py +194 -0
- package/plugin/scripts/trust-repos.py +59 -0
- package/plugin/scripts/ui.py +280 -0
- package/plugin/scripts/units.py +80 -0
- package/plugin/scripts/workspace.py +364 -0
- package/plugin/skills/city/SKILL.md +191 -0
- package/templates/blank.md +39 -0
- package/templates/finance.md +44 -0
- package/templates/legal.md +45 -0
- package/templates/marketing.md +48 -0
- package/templates/product.md +45 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents City — Worker.
|
|
3
|
+
*
|
|
4
|
+
* GET / the front end (assets)
|
|
5
|
+
* GET /api/city what is built: parcels + houses (D1)
|
|
6
|
+
* GET /api/state the now: lights and notices (Durable Object)
|
|
7
|
+
* GET /ws live spectator (WebSocket, receive only)
|
|
8
|
+
* POST /ingest event intake from the bus (X-City-Secret header)
|
|
9
|
+
* cron GitHub -> D1, in batches
|
|
10
|
+
*
|
|
11
|
+
* The split: the DO holds the now, D1 holds the past. Never both the same thing.
|
|
12
|
+
*/
|
|
13
|
+
import { Square } from './square';
|
|
14
|
+
export { Square };
|
|
15
|
+
|
|
16
|
+
interface Env {
|
|
17
|
+
ASSETS: Fetcher;
|
|
18
|
+
TOKENS: KVNamespace; // the bus's own KV: identity is never set by the client
|
|
19
|
+
SQUARE: DurableObjectNamespace;
|
|
20
|
+
DB: D1Database;
|
|
21
|
+
GITHUB_ORG: string;
|
|
22
|
+
CITY_ID?: string; // local Hall discovery: never frame another city's map
|
|
23
|
+
CITY_SECRET?: string; // wrangler secret put CITY_SECRET
|
|
24
|
+
GITHUB_TOKEN?: string; // wrangler secret put GITHUB_TOKEN
|
|
25
|
+
REQUIRE_ACCESS?: string; // "1" in production: no Access header, nothing served
|
|
26
|
+
PASSPHRASE?: string; // stopgap door, until the Access app exists
|
|
27
|
+
PASS_SIGNING_KEY?: string; // key the pass cookie is signed with
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const square = (env: Env) => env.SQUARE.get(env.SQUARE.idFromName('city'));
|
|
31
|
+
|
|
32
|
+
function formulario(error = ''): string {
|
|
33
|
+
return `<!doctype html><meta charset="utf-8"><title>Agents City</title>
|
|
34
|
+
<body style="background:#131a24;color:#e8e2d4;font:16px/1.6 Georgia,serif;display:grid;place-items:center;height:100vh;margin:0">
|
|
35
|
+
<form method="post" action="/entrar" style="display:grid;gap:12px;width:min(320px,80vw)">
|
|
36
|
+
<div style="font:600 22px/1.1 system-ui;letter-spacing:.01em">Agents City</div>
|
|
37
|
+
${error ? `<div style="color:#e2604f;font-size:14px">${error}</div>` : ''}
|
|
38
|
+
<input name="clave" type="password" autofocus placeholder="passphrase"
|
|
39
|
+
style="padding:10px 12px;background:#1b2431;border:1px solid #38495f;color:#e8e2d4;border-radius:3px;font:14px ui-monospace,monospace">
|
|
40
|
+
<button style="padding:10px;background:#f5c451;border:0;border-radius:3px;font:600 14px system-ui;cursor:pointer">Enter</button>
|
|
41
|
+
<div style="font-size:12px;color:#6b7889">A stopgap door, until Cloudflare Access is in front of this.</div>
|
|
42
|
+
</form></body>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compare without leaking through timing. A `!==` returns on the first differing
|
|
47
|
+
* byte, and that is enough to guess a secret byte by byte by measuring the
|
|
48
|
+
* response. Here the secret opens the door to writing what the whole team sees.
|
|
49
|
+
*/
|
|
50
|
+
function igualSeguro(a: string, b: string): boolean {
|
|
51
|
+
if (a.length !== b.length) return false;
|
|
52
|
+
let d = 0;
|
|
53
|
+
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
54
|
+
return d === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function sha256(s: string): Promise<string> {
|
|
58
|
+
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
|
59
|
+
return [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join('');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The public face only opens behind an identity proxy.
|
|
64
|
+
*
|
|
65
|
+
* Access filters at the edge, but a workers.dev URL walks straight past it: if
|
|
66
|
+
* the Worker checks nothing, the map of your whole codebase sits on the internet
|
|
67
|
+
* for anyone who finds the subdomain. With REQUIRE_ACCESS=1, nothing is served
|
|
68
|
+
* without the header the proxy sets.
|
|
69
|
+
*
|
|
70
|
+
* This does not replace Access. It is the second door, for the path Access does
|
|
71
|
+
* not cover.
|
|
72
|
+
*/
|
|
73
|
+
async function pasaLaPuerta(req: Request, env: Env): Promise<boolean> {
|
|
74
|
+
if (env.REQUIRE_ACCESS !== '1') return true;
|
|
75
|
+
if (req.headers.get('Cf-Access-Jwt-Assertion')) return true;
|
|
76
|
+
return paseValido(req, env);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ── Puerta provisional ─────────────────────────────────────────────────────
|
|
80
|
+
* For before the identity proxy exists. It is weaker and worth knowing why: a
|
|
81
|
+
* shared passphrase says somebody knows it, never WHO came in. What it does do
|
|
82
|
+
* right: the passphrase does not travel on every request — it is swapped for a
|
|
83
|
+
* signed, HttpOnly, twelve-hour cookie — the comparison is constant-time, and
|
|
84
|
+
* failed attempts go through the throttle.
|
|
85
|
+
*
|
|
86
|
+
* It disappears on its own: with no PASSPHRASE secret, this door does not exist.
|
|
87
|
+
* Once
|
|
88
|
+
* Access is in place: `wrangler secret delete PASSPHRASE` and it is gone.
|
|
89
|
+
*/
|
|
90
|
+
async function firma(valor: string, env: Env): Promise<string> {
|
|
91
|
+
const clave = await crypto.subtle.importKey(
|
|
92
|
+
'raw',
|
|
93
|
+
new TextEncoder().encode(env.PASS_SIGNING_KEY ?? 'unsigned'),
|
|
94
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
95
|
+
false,
|
|
96
|
+
['sign'],
|
|
97
|
+
);
|
|
98
|
+
const f = await crypto.subtle.sign('HMAC', clave, new TextEncoder().encode(valor));
|
|
99
|
+
return [...new Uint8Array(f)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function paseValido(req: Request, env: Env): Promise<boolean> {
|
|
103
|
+
if (!env.PASSPHRASE || !env.PASS_SIGNING_KEY) return false;
|
|
104
|
+
const galleta = (req.headers.get('Cookie') ?? '')
|
|
105
|
+
.split(';')
|
|
106
|
+
.map((c) => c.trim())
|
|
107
|
+
.find((c) => c.startsWith('pase='));
|
|
108
|
+
if (!galleta) return false;
|
|
109
|
+
const [caduca, mac] = galleta.slice(5).split('.');
|
|
110
|
+
if (!caduca || !mac) return false;
|
|
111
|
+
if (Number(caduca) < Date.now()) return false;
|
|
112
|
+
return igualSeguro(mac, await firma(caduca, env));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Who is reporting: the owner of the token, never what the client claims. Same
|
|
117
|
+
* rule as the bus — identity is looked up, not accepted.
|
|
118
|
+
*/
|
|
119
|
+
async function quienEs(req: Request, env: Env): Promise<string | null> {
|
|
120
|
+
const auth = req.headers.get('Authorization') ?? '';
|
|
121
|
+
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
|
122
|
+
if (!token) return null;
|
|
123
|
+
const raw = await env.TOKENS.get(`tok:${await sha256(token)}`);
|
|
124
|
+
if (!raw) return null;
|
|
125
|
+
try {
|
|
126
|
+
const { user } = JSON.parse(raw) as { user?: string };
|
|
127
|
+
return user || null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How often one seat may write. A valid token in a loop — a hook that lost its
|
|
135
|
+
* throttle, a script in a `while true` — would otherwise hold a D1 write open
|
|
136
|
+
* for every iteration. The same per-sender counter the square already keeps, so
|
|
137
|
+
* there is one place where "too often" is defined.
|
|
138
|
+
*/
|
|
139
|
+
async function pasaElRitmo(env: Env, quien: string): Promise<boolean> {
|
|
140
|
+
const r = await square(env).fetch(
|
|
141
|
+
new Request('https://square/ingest', {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: { 'content-type': 'application/json' },
|
|
144
|
+
body: JSON.stringify({ t: 'attempt', usuario: `report:${quien}`, user: `report:${quien}` }),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
return r.ok;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default {
|
|
151
|
+
async fetch(req: Request, env: Env): Promise<Response> {
|
|
152
|
+
const url = new URL(req.url);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The endpoints that report INTO the city carry their own authentication —
|
|
156
|
+
* a bus token, or the shared secret — so they do not go through the identity
|
|
157
|
+
* door. Everything else, the web and its read APIs, does.
|
|
158
|
+
*
|
|
159
|
+
* All four of them, and not the two this used to list: with Access in front,
|
|
160
|
+
* a reporter running on somebody's laptop gets the login page instead of the
|
|
161
|
+
* API, and the map silently stops learning about growth and spend. A door
|
|
162
|
+
* that blocks the sensors is not a security measure, it is an outage.
|
|
163
|
+
*/
|
|
164
|
+
const ENTRADAS = new Set(['/ingest', '/worker', '/api/growth', '/api/tokens']);
|
|
165
|
+
const esEntrada = ENTRADAS.has(url.pathname) && req.method === 'POST';
|
|
166
|
+
|
|
167
|
+
// The stopgap door: the passphrase is swapped for a signed cookie.
|
|
168
|
+
if (url.pathname === '/entrar') {
|
|
169
|
+
if (!env.PASSPHRASE || !env.PASS_SIGNING_KEY) {
|
|
170
|
+
return new Response('this door is not open', { status: 404 });
|
|
171
|
+
}
|
|
172
|
+
const dada =
|
|
173
|
+
url.searchParams.get('clave') ??
|
|
174
|
+
(req.method === 'POST' ? (await req.formData()).get('clave')?.toString() : null);
|
|
175
|
+
if (!dada)
|
|
176
|
+
return new Response(formulario(), {
|
|
177
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Per-IP throttle: without it, a shared passphrase is brute-forced.
|
|
181
|
+
const ip = req.headers.get('CF-Connecting-IP') ?? 'no-ip';
|
|
182
|
+
const fr = await square(env).fetch(
|
|
183
|
+
new Request('https://square/ingest', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'content-type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ t: 'attempt', usuario: `pase:${ip}` }),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
if (!fr.ok) return new Response('too many attempts, wait a minute', { status: 429 });
|
|
190
|
+
|
|
191
|
+
if (!igualSeguro(dada, env.PASSPHRASE)) {
|
|
192
|
+
return new Response(formulario('That is not the passphrase'), {
|
|
193
|
+
status: 401,
|
|
194
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const caduca = String(Date.now() + 12 * 3600_000);
|
|
198
|
+
const pase = `${caduca}.${await firma(caduca, env)}`;
|
|
199
|
+
return new Response(null, {
|
|
200
|
+
status: 302,
|
|
201
|
+
headers: {
|
|
202
|
+
location: '/',
|
|
203
|
+
'set-cookie': `pase=${pase}; Path=/; Max-Age=43200; HttpOnly; Secure; SameSite=Lax`,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!esEntrada && !(await pasaLaPuerta(req, env))) {
|
|
209
|
+
const cerrada = 'This view is for your team only.';
|
|
210
|
+
const con = env.PASSPHRASE ? ` Have the passphrase? <a href="/entrar">Sign in</a>.` : '';
|
|
211
|
+
return new Response(
|
|
212
|
+
`<!doctype html><meta charset="utf-8">
|
|
213
|
+
<body style="background:#131a24;color:#e8e2d4;font:16px/1.6 Georgia,serif;padding:14vh 8vw">
|
|
214
|
+
<p>${cerrada}${con}</p></body>`,
|
|
215
|
+
{ status: 403, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (url.pathname === '/ws' || url.pathname === '/api/state') {
|
|
220
|
+
const destino = url.pathname === '/ws' ? '/ws' : '/state';
|
|
221
|
+
return square(env).fetch(new Request('https://plaza' + destino, req));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (url.pathname === '/ingest') {
|
|
225
|
+
if (req.method !== 'POST') return new Response('no', { status: 405 });
|
|
226
|
+
// The bus authenticates with a shared secret. Without it nothing gets in:
|
|
227
|
+
// this endpoint writes the state everybody looks at.
|
|
228
|
+
const dado = req.headers.get('X-City-Secreto') ?? '';
|
|
229
|
+
if (!env.CITY_SECRET || !igualSeguro(dado, env.CITY_SECRET)) {
|
|
230
|
+
return new Response('not authorised', { status: 401 });
|
|
231
|
+
}
|
|
232
|
+
const ev = await req.json<any>();
|
|
233
|
+
// The DO's response is propagated: if it throttles, the sender has to find
|
|
234
|
+
// out. This used to be discarded, which made the throttle useless.
|
|
235
|
+
const r = await square(env).fetch(
|
|
236
|
+
new Request('https://square/ingest', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
body: JSON.stringify(ev),
|
|
239
|
+
headers: { 'content-type': 'application/json' },
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
if (!r.ok) return new Response(await r.text(), { status: r.status });
|
|
243
|
+
if (ev.t === 'notice') {
|
|
244
|
+
await env.DB.prepare(
|
|
245
|
+
'INSERT INTO evento (ts,tipo,origen,destino,etiqueta,texto) VALUES (?,?,?,?,?,?)',
|
|
246
|
+
)
|
|
247
|
+
.bind(new Date().toISOString(), 'carta', ev.from, ev.to, ev.tag ?? null, ev.text ?? null)
|
|
248
|
+
.run();
|
|
249
|
+
}
|
|
250
|
+
return Response.json({ ok: true });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// A worker announces itself with ITS OWN bus token, not a shared secret. So
|
|
254
|
+
// the user comes from the token and nobody can dig in somebody else's name —
|
|
255
|
+
// exactly the property the bus already gave us.
|
|
256
|
+
if (url.pathname === '/worker' && req.method === 'POST') {
|
|
257
|
+
const user = await quienEs(req, env);
|
|
258
|
+
if (!user) return new Response('invalid or missing token', { status: 401 });
|
|
259
|
+
|
|
260
|
+
const body = await req.json<any>();
|
|
261
|
+
// The window name is written by a hook on somebody else's machine: it gets
|
|
262
|
+
// trimmed and stripped here, at the door, rather than trusting the front
|
|
263
|
+
// end to escape it. Both, not either.
|
|
264
|
+
const limpio = (v: unknown, n: number) =>
|
|
265
|
+
String(v ?? '')
|
|
266
|
+
.replace(/[<>"'`\\]/g, '')
|
|
267
|
+
.slice(0, n);
|
|
268
|
+
const r = await square(env).fetch(
|
|
269
|
+
new Request('https://square/ingest', {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'content-type': 'application/json' },
|
|
272
|
+
body: JSON.stringify({
|
|
273
|
+
t: 'worker',
|
|
274
|
+
user,
|
|
275
|
+
window: limpio(body.ventana, 40) || 'unnamed',
|
|
276
|
+
parcel: limpio(body.parcela, 200),
|
|
277
|
+
stopped: !!body.parada,
|
|
278
|
+
}),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
if (!r.ok) return new Response(await r.text(), { status: r.status });
|
|
282
|
+
return Response.json({ ok: true, user });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The replay. Returns two things, not one:
|
|
287
|
+
* base — the floors each parcel ALREADY had before the start date, so the
|
|
288
|
+
* film does not open on an empty city.
|
|
289
|
+
* dias — what landed on each day inside the range.
|
|
290
|
+
*
|
|
291
|
+
* That way you can ask for "the last 90 days" without dragging five years of
|
|
292
|
+
* history, and the heights are still the real ones. Two aggregate queries,
|
|
293
|
+
* none per parcel and none per day.
|
|
294
|
+
*/
|
|
295
|
+
if (url.pathname === '/api/historia') {
|
|
296
|
+
const hoy = new Date().toISOString().slice(0, 10);
|
|
297
|
+
const dia = (v: string | null, pordefecto: string) =>
|
|
298
|
+
/^\d{4}-\d{2}-\d{2}$/.test(v ?? '') ? (v as string) : pordefecto;
|
|
299
|
+
const hasta = dia(url.searchParams.get('hasta'), hoy);
|
|
300
|
+
const desde = dia(
|
|
301
|
+
url.searchParams.get('desde'),
|
|
302
|
+
new Date(Date.parse(hasta) - 89 * 864e5).toISOString().slice(0, 10),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const [base, dias, limites, avisos] = await env.DB.batch([
|
|
306
|
+
env.DB.prepare(
|
|
307
|
+
'SELECT parcela_id, SUM(n) n FROM hito WHERE dia < ? GROUP BY parcela_id',
|
|
308
|
+
).bind(desde),
|
|
309
|
+
env.DB.prepare(
|
|
310
|
+
'SELECT dia, parcela_id, n FROM hito WHERE dia >= ? AND dia <= ? ORDER BY dia',
|
|
311
|
+
).bind(desde, hasta),
|
|
312
|
+
env.DB.prepare('SELECT MIN(dia) primero, MAX(dia) ultimo FROM hito'),
|
|
313
|
+
// The notices of the range, by day: the replay flies them between houses.
|
|
314
|
+
// Without them the film only grows buildings, and nobody watches that
|
|
315
|
+
// twice.
|
|
316
|
+
env.DB.prepare(
|
|
317
|
+
`SELECT substr(ts,1,10) dia, origen, destino, etiqueta, texto
|
|
318
|
+
FROM evento WHERE tipo='notice' AND substr(ts,1,10) >= ?
|
|
319
|
+
AND substr(ts,1,10) <= ? ORDER BY ts LIMIT 4000`,
|
|
320
|
+
).bind(desde, hasta),
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
const porDia = new Map<string, [string, number][]>();
|
|
324
|
+
for (const h of dias.results as any[]) {
|
|
325
|
+
const l = porDia.get(h.dia);
|
|
326
|
+
l ? l.push([h.parcela_id, h.n]) : porDia.set(h.dia, [[h.parcela_id, h.n]]);
|
|
327
|
+
}
|
|
328
|
+
return Response.json(
|
|
329
|
+
{
|
|
330
|
+
desde,
|
|
331
|
+
hasta,
|
|
332
|
+
limites: (limites.results as any[])[0] ?? {},
|
|
333
|
+
base: (base.results as any[]).map((b) => [b.parcela_id, b.n]),
|
|
334
|
+
dias: [...porDia.entries()],
|
|
335
|
+
avisos: (avisos.results as any[]).map((a) => [
|
|
336
|
+
a.dia,
|
|
337
|
+
a.origen,
|
|
338
|
+
a.destino,
|
|
339
|
+
a.etiqueta,
|
|
340
|
+
a.texto,
|
|
341
|
+
]),
|
|
342
|
+
},
|
|
343
|
+
{ headers: { 'cache-control': 'public, max-age=600' } },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Growth reported from outside, for cities that are not made of code.
|
|
349
|
+
*
|
|
350
|
+
* The cron can count merged pull requests on its own, but it cannot run a
|
|
351
|
+
* command inside somebody's folder — and in a marketing, legal or finance
|
|
352
|
+
* city, growth is whatever a command in that folder returns. So the number
|
|
353
|
+
* comes from a reporter running where the folders are, authenticated with the
|
|
354
|
+
* same bus token as everything else: the user is the token's, never the
|
|
355
|
+
* client's claim.
|
|
356
|
+
*
|
|
357
|
+
* The day's history row is the DELTA, computed here. The reporter sends a
|
|
358
|
+
* total, which is the only thing it can know reliably, and the city works out
|
|
359
|
+
* what changed today — so the replay works the same for every kind of city.
|
|
360
|
+
*/
|
|
361
|
+
if (url.pathname === '/api/growth' && req.method === 'POST') {
|
|
362
|
+
const quien = await quienEs(req, env);
|
|
363
|
+
if (!quien) return new Response('invalid or missing token', { status: 401 });
|
|
364
|
+
if (!(await pasaElRitmo(env, quien)))
|
|
365
|
+
return new Response('too many reports', { status: 429 });
|
|
366
|
+
|
|
367
|
+
const body = await req.json<{
|
|
368
|
+
parcels?: {
|
|
369
|
+
id: string;
|
|
370
|
+
floors: number;
|
|
371
|
+
scaffolds?: number;
|
|
372
|
+
stale?: number;
|
|
373
|
+
cracked?: number;
|
|
374
|
+
activity?: number;
|
|
375
|
+
}[];
|
|
376
|
+
}>();
|
|
377
|
+
const lista = (body.parcels ?? []).slice(0, 500);
|
|
378
|
+
if (!lista.length) return Response.json({ ok: true, updated: 0 });
|
|
379
|
+
|
|
380
|
+
const hoy = new Date().toISOString().slice(0, 10);
|
|
381
|
+
const ahora = new Date().toISOString();
|
|
382
|
+
const previos = new Map<string, number>();
|
|
383
|
+
const { results } = await env.DB.prepare(
|
|
384
|
+
`SELECT parcela_id, pisos FROM casa WHERE parcela_id IN (${lista.map(() => '?').join(',')})`,
|
|
385
|
+
)
|
|
386
|
+
.bind(...lista.map((p) => p.id))
|
|
387
|
+
.all<{ parcela_id: string; pisos: number }>();
|
|
388
|
+
for (const r of results) previos.set(r.parcela_id, r.pisos);
|
|
389
|
+
|
|
390
|
+
const casa = env.DB.prepare(`
|
|
391
|
+
INSERT INTO casa (parcela_id,pisos,ladrillos,andamios,andamio_viejo,grieta,actividad30,actualizado)
|
|
392
|
+
VALUES (?,?,0,?,?,?,?,?)
|
|
393
|
+
ON CONFLICT(parcela_id) DO UPDATE SET pisos=excluded.pisos, andamios=excluded.andamios,
|
|
394
|
+
andamio_viejo=excluded.andamio_viejo, grieta=excluded.grieta,
|
|
395
|
+
actividad30=excluded.actividad30, actualizado=excluded.actualizado`);
|
|
396
|
+
const hito = env.DB.prepare(`
|
|
397
|
+
INSERT INTO hito (parcela_id,dia,n) VALUES (?,?,?)
|
|
398
|
+
ON CONFLICT(parcela_id,dia) DO UPDATE SET n = hito.n + excluded.n`);
|
|
399
|
+
|
|
400
|
+
const lotes = [];
|
|
401
|
+
for (const p of lista) {
|
|
402
|
+
const antes = previos.get(p.id) ?? 0;
|
|
403
|
+
const delta = Math.max(0, (p.floors | 0) - antes);
|
|
404
|
+
lotes.push(
|
|
405
|
+
casa.bind(
|
|
406
|
+
p.id,
|
|
407
|
+
p.floors | 0,
|
|
408
|
+
p.scaffolds ?? 0,
|
|
409
|
+
p.stale ?? 0,
|
|
410
|
+
p.cracked ?? 0,
|
|
411
|
+
p.activity ?? 0,
|
|
412
|
+
ahora,
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
if (delta > 0) lotes.push(hito.bind(p.id, hoy, delta));
|
|
416
|
+
}
|
|
417
|
+
await env.DB.batch(lotes);
|
|
418
|
+
return Response.json({ ok: true, updated: lista.length });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Token spend, reported by each person's machine.
|
|
423
|
+
*
|
|
424
|
+
* The numbers come from the agent's own transcripts, which is the only place
|
|
425
|
+
* they exist — and they are counts, never content. Authenticated with the bus
|
|
426
|
+
* token like everything else, so the user is the token's.
|
|
427
|
+
*
|
|
428
|
+
* Reports are absolute per (day, model), not increments, so re-running the
|
|
429
|
+
* reporter is safe and a machine that was off for a week catches up on its
|
|
430
|
+
* own without double counting.
|
|
431
|
+
*/
|
|
432
|
+
if (url.pathname === '/api/tokens' && req.method === 'POST') {
|
|
433
|
+
const user = await quienEs(req, env);
|
|
434
|
+
if (!user) return new Response('invalid or missing token', { status: 401 });
|
|
435
|
+
if (!(await pasaElRitmo(env, user))) return new Response('too many reports', { status: 429 });
|
|
436
|
+
|
|
437
|
+
const body = await req.json<{
|
|
438
|
+
days?: {
|
|
439
|
+
day: string;
|
|
440
|
+
model?: string;
|
|
441
|
+
input?: number;
|
|
442
|
+
output?: number;
|
|
443
|
+
cache_read?: number;
|
|
444
|
+
cache_write?: number;
|
|
445
|
+
}[];
|
|
446
|
+
}>();
|
|
447
|
+
const filas = (body.days ?? [])
|
|
448
|
+
.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.day))
|
|
449
|
+
.slice(0, 800);
|
|
450
|
+
if (!filas.length) return Response.json({ ok: true, updated: 0 });
|
|
451
|
+
// Counts come from somebody else's machine, so they get clamped here and
|
|
452
|
+
// not trusted: a string, a NaN or an absurd number would otherwise land in
|
|
453
|
+
// the table and poison a total that everybody reads.
|
|
454
|
+
const n = (v: unknown) => {
|
|
455
|
+
const x = Math.trunc(Number(v));
|
|
456
|
+
return Number.isFinite(x) && x > 0 ? Math.min(x, 1e12) : 0;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const stmt = env.DB.prepare(`
|
|
460
|
+
INSERT INTO gasto (dia,usuario,modelo,entrada,salida,cache_r,cache_w)
|
|
461
|
+
VALUES (?,?,?,?,?,?,?)
|
|
462
|
+
ON CONFLICT(dia,usuario,modelo) DO UPDATE SET entrada=excluded.entrada,
|
|
463
|
+
salida=excluded.salida, cache_r=excluded.cache_r, cache_w=excluded.cache_w`);
|
|
464
|
+
await env.DB.batch(
|
|
465
|
+
filas.map((d) =>
|
|
466
|
+
stmt.bind(
|
|
467
|
+
d.day,
|
|
468
|
+
user,
|
|
469
|
+
String(d.model ?? '').slice(0, 40),
|
|
470
|
+
n(d.input),
|
|
471
|
+
n(d.output),
|
|
472
|
+
n(d.cache_read),
|
|
473
|
+
n(d.cache_write),
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
return Response.json({ ok: true, updated: filas.length, user });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (url.pathname === '/api/tokens' && req.method === 'GET') {
|
|
481
|
+
// Global totals, nothing per person. The HUD polls this instead of
|
|
482
|
+
// re-downloading every parcel just to refresh two numbers.
|
|
483
|
+
const hoy = new Date().toISOString().slice(0, 10);
|
|
484
|
+
const hace30 = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10);
|
|
485
|
+
const [g1, g30] = await env.DB.batch([
|
|
486
|
+
env.DB.prepare(
|
|
487
|
+
`SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache,
|
|
488
|
+
COUNT(DISTINCT usuario) gente FROM gasto WHERE dia = ?`,
|
|
489
|
+
).bind(hoy),
|
|
490
|
+
env.DB.prepare(
|
|
491
|
+
`SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache
|
|
492
|
+
FROM gasto WHERE dia >= ?`,
|
|
493
|
+
).bind(hace30),
|
|
494
|
+
]);
|
|
495
|
+
return Response.json(
|
|
496
|
+
{ hoy: (g1.results as any[])[0] ?? {}, mes: (g30.results as any[])[0] ?? {} },
|
|
497
|
+
{ headers: { 'cache-control': 'public, max-age=60' } },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (url.pathname === '/api/identity') {
|
|
502
|
+
return Response.json({ cityId: env.CITY_ID || '' });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (url.pathname === '/api/city') {
|
|
506
|
+
// The per-repo count rides along as a window function, so the front end can
|
|
507
|
+
// say when a figure belongs to the whole repo rather than to this parcel —
|
|
508
|
+
// without asking for anything else. One query, one pass: the correlated
|
|
509
|
+
// subquery this used to be re-scanned `parcela` once per row.
|
|
510
|
+
const { results } = await env.DB.prepare(
|
|
511
|
+
`
|
|
512
|
+
SELECT p.id, p.repo, p.ruta, p.unidad, p.nombre, p.dueno,
|
|
513
|
+
COUNT(*) OVER (PARTITION BY p.repo) hermanas,
|
|
514
|
+
COALESCE(c.pisos,0) pisos, COALESCE(c.ladrillos,0) ladrillos,
|
|
515
|
+
COALESCE(c.andamios,0) andamios, COALESCE(c.andamio_viejo,0) andamio_viejo,
|
|
516
|
+
COALESCE(c.grieta,0) grieta, COALESCE(c.actividad30,0) actividad30
|
|
517
|
+
FROM parcela p LEFT JOIN casa c ON c.parcela_id = p.id
|
|
518
|
+
ORDER BY p.unidad, p.repo, p.ruta`,
|
|
519
|
+
).all();
|
|
520
|
+
// Units come from the database, not from the code: every organisation has
|
|
521
|
+
// its own, and changing them should not need a deploy.
|
|
522
|
+
const hoy = new Date().toISOString().slice(0, 10);
|
|
523
|
+
const hace30 = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10);
|
|
524
|
+
const [personas, unidades, gastoHoy, gasto30, metas] = await env.DB.batch([
|
|
525
|
+
env.DB.prepare('SELECT usuario, nombre, rol, oficio, agente FROM persona'),
|
|
526
|
+
env.DB.prepare('SELECT id, nombre, color, orden, nota, cols FROM unidad ORDER BY orden'),
|
|
527
|
+
// Global totals only. There is no endpoint that ranks people by spend,
|
|
528
|
+
// and adding one would break the thing this map is for.
|
|
529
|
+
env.DB.prepare(
|
|
530
|
+
`SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache,
|
|
531
|
+
COUNT(DISTINCT usuario) gente FROM gasto WHERE dia = ?`,
|
|
532
|
+
).bind(hoy),
|
|
533
|
+
env.DB.prepare(
|
|
534
|
+
`SELECT SUM(entrada+salida) tokens, SUM(cache_r) cache
|
|
535
|
+
FROM gasto WHERE dia >= ?`,
|
|
536
|
+
).bind(hace30),
|
|
537
|
+
// The goals ride along: twelve rows for a twelve-person city, and they
|
|
538
|
+
// are the one thing that does not change whatever the city is made of.
|
|
539
|
+
env.DB.prepare(`SELECT usuario, n, titulo, como, medida, partida, meta, cuando, estado
|
|
540
|
+
FROM objetivo ORDER BY usuario, n`),
|
|
541
|
+
]);
|
|
542
|
+
return Response.json(
|
|
543
|
+
{
|
|
544
|
+
parcelas: results,
|
|
545
|
+
personas: personas.results,
|
|
546
|
+
unidades: unidades.results,
|
|
547
|
+
objetivos: metas.results,
|
|
548
|
+
gasto: {
|
|
549
|
+
hoy: (gastoHoy.results as any[])[0] ?? {},
|
|
550
|
+
mes: (gasto30.results as any[])[0] ?? {},
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
headers: { 'cache-control': 'public, max-age=60' },
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The page itself is never cached; the bundle it points at is, and its URL
|
|
560
|
+
// carries the bundle's fingerprint. Otherwise a rebuilt map is invisible
|
|
561
|
+
// until somebody thinks to hard-reload, which is a thing nobody thinks to do.
|
|
562
|
+
const r = await env.ASSETS.fetch(req);
|
|
563
|
+
if (url.pathname === '/' || url.pathname.endsWith('.html')) {
|
|
564
|
+
const h = new Headers(r.headers);
|
|
565
|
+
h.set('cache-control', 'no-cache');
|
|
566
|
+
return new Response(r.body, { status: r.status, headers: h });
|
|
567
|
+
}
|
|
568
|
+
return r;
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* What actually got built.
|
|
573
|
+
*
|
|
574
|
+
* This used to fetch one page of PRs and one of commits and count what came
|
|
575
|
+
* back: with `per_page=100`, any active repo capped at 100 — which is the same
|
|
576
|
+
* as not measuring at all.
|
|
577
|
+
*
|
|
578
|
+
* Now it uses the search API, which returns an exact `total_count` in a single
|
|
579
|
+
* call per metric. That comes with a tighter limit — 30 queries a minute — so
|
|
580
|
+
* the pass runs in batches: each firing takes a few repos and advances a
|
|
581
|
+
* cursor. A whole organisation refreshes in a couple of hours, which for "how
|
|
582
|
+
* many floors does this house have" is plenty.
|
|
583
|
+
*/
|
|
584
|
+
async scheduled(_ev: ScheduledController, env: Env) {
|
|
585
|
+
// Two things this needs, and it says which one is missing rather than
|
|
586
|
+
// returning quietly: a cron that does nothing without explaining itself is a
|
|
587
|
+
// map that stops growing for a reason nobody can find. The placeholder counts
|
|
588
|
+
// as missing — "your-org" is what wrangler.toml ships with.
|
|
589
|
+
if (!env.GITHUB_TOKEN) {
|
|
590
|
+
console.log(
|
|
591
|
+
'city: no GITHUB_TOKEN, skipping the pass. ' +
|
|
592
|
+
'Set it with: npx wrangler@4 secret put GITHUB_TOKEN',
|
|
593
|
+
);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (!env.GITHUB_ORG || env.GITHUB_ORG === 'your-org') {
|
|
597
|
+
console.log(
|
|
598
|
+
'city: GITHUB_ORG is not set, skipping the pass. ' +
|
|
599
|
+
'Put your organisation in city/worker/wrangler.toml under [vars].',
|
|
600
|
+
);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const REPOS_POR_TANDA = 6; // four queries each: 24 < the 30 per minute cap
|
|
605
|
+
const ahora = new Date().toISOString();
|
|
606
|
+
|
|
607
|
+
// One read for everything: repos and their parcels. This used to fetch the
|
|
608
|
+
// repo list and then, inside the loop, each repo's parcels.
|
|
609
|
+
const { results: todas } = await env.DB.prepare(
|
|
610
|
+
'SELECT id, repo FROM parcela ORDER BY repo',
|
|
611
|
+
).all<{ id: string; repo: string }>();
|
|
612
|
+
if (!todas.length) return;
|
|
613
|
+
const porRepo = new Map<string, string[]>();
|
|
614
|
+
for (const p of todas) {
|
|
615
|
+
const l = porRepo.get(p.repo);
|
|
616
|
+
l ? l.push(p.id) : porRepo.set(p.repo, [p.id]);
|
|
617
|
+
}
|
|
618
|
+
const repos = [...porRepo.keys()].map((repo) => ({ repo }));
|
|
619
|
+
|
|
620
|
+
const fila = await env.DB.prepare("SELECT valor FROM meta WHERE clave='cursor'").first<{
|
|
621
|
+
valor: string;
|
|
622
|
+
}>();
|
|
623
|
+
const cursor = Number(fila?.valor ?? 0) % repos.length;
|
|
624
|
+
const tanda = Array.from(
|
|
625
|
+
{ length: Math.min(REPOS_POR_TANDA, repos.length) },
|
|
626
|
+
(_, i) => repos[(cursor + i) % repos.length].repo,
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const hace = (dias: number) => new Date(Date.now() - dias * 864e5).toISOString().slice(0, 10);
|
|
630
|
+
|
|
631
|
+
/** total_count of a search. One call, one exact number. */
|
|
632
|
+
const cuantos = async (tipo: 'issues' | 'commits', q: string): Promise<number> => {
|
|
633
|
+
const r = await fetch(
|
|
634
|
+
`https://api.github.com/search/${tipo}?q=${encodeURIComponent(q)}&per_page=1`,
|
|
635
|
+
{
|
|
636
|
+
headers: {
|
|
637
|
+
authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
|
638
|
+
accept: 'application/vnd.github+json',
|
|
639
|
+
'user-agent': 'agents-city',
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
);
|
|
643
|
+
if (!r.ok) throw new Error(`search ${r.status}`);
|
|
644
|
+
const j = await r.json<{ total_count: number }>();
|
|
645
|
+
return j.total_count ?? 0;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
for (const repo of tanda) {
|
|
649
|
+
const en = `repo:${env.GITHUB_ORG}/${repo}`;
|
|
650
|
+
try {
|
|
651
|
+
const [mergeados, abiertos, viejos, commits] = await Promise.all([
|
|
652
|
+
cuantos('issues', `${en} is:pr is:merged`),
|
|
653
|
+
cuantos('issues', `${en} is:pr is:open`),
|
|
654
|
+
cuantos('issues', `${en} is:pr is:open created:<${hace(14)}`),
|
|
655
|
+
cuantos('commits', `${en} committer-date:>${hace(30)}`),
|
|
656
|
+
]);
|
|
657
|
+
// One prepared statement and a batch, not a write per parcel. A repo
|
|
658
|
+
// split into eight parcels meant eight round trips to D1 for that repo
|
|
659
|
+
// alone — hundreds per pass across an organisation.
|
|
660
|
+
const suyas = porRepo.get(repo) ?? [];
|
|
661
|
+
if (suyas.length) {
|
|
662
|
+
const stmt = env.DB.prepare(`
|
|
663
|
+
INSERT INTO casa (parcela_id,pisos,ladrillos,andamios,andamio_viejo,grieta,actividad30,actualizado)
|
|
664
|
+
VALUES (?,?,?,?,?,?,?,?)
|
|
665
|
+
ON CONFLICT(parcela_id) DO UPDATE SET
|
|
666
|
+
pisos=excluded.pisos, ladrillos=excluded.ladrillos, andamios=excluded.andamios,
|
|
667
|
+
andamio_viejo=excluded.andamio_viejo, actividad30=excluded.actividad30,
|
|
668
|
+
actualizado=excluded.actualizado`);
|
|
669
|
+
await env.DB.batch(
|
|
670
|
+
suyas.map((id) =>
|
|
671
|
+
stmt.bind(
|
|
672
|
+
id,
|
|
673
|
+
mergeados,
|
|
674
|
+
Math.max(0, commits - mergeados),
|
|
675
|
+
abiertos,
|
|
676
|
+
viejos,
|
|
677
|
+
0,
|
|
678
|
+
commits,
|
|
679
|
+
ahora,
|
|
680
|
+
),
|
|
681
|
+
),
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
} catch (e) {
|
|
685
|
+
// One failing repo cannot take down the batch or stall the cursor: a repo
|
|
686
|
+
// moved to another organisation would freeze the map forever.
|
|
687
|
+
console.log(`city: ${repo} failed`, e);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// The cursor and the pruning in a single trip. Without pruning, `evento`
|
|
692
|
+
// grows forever: 90 days of history is more than anybody looks at.
|
|
693
|
+
await env.DB.batch([
|
|
694
|
+
env.DB.prepare(
|
|
695
|
+
"INSERT INTO meta (clave,valor) VALUES ('cursor',?) ON CONFLICT(clave) DO UPDATE SET valor=excluded.valor",
|
|
696
|
+
).bind(String((cursor + tanda.length) % repos.length)),
|
|
697
|
+
env.DB.prepare('DELETE FROM evento WHERE ts < ?').bind(
|
|
698
|
+
new Date(Date.now() - 90 * 864e5).toISOString(),
|
|
699
|
+
),
|
|
700
|
+
]);
|
|
701
|
+
},
|
|
702
|
+
};
|