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,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "city-bus-worker",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "wrangler dev",
|
|
7
|
+
"deploy": "wrangler deploy",
|
|
8
|
+
"tail": "wrangler tail --format pretty",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"test:local": "../scripts/test-local.sh"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@cloudflare/workers-types": "^5.20260826.1",
|
|
14
|
+
"typescript": "^7.0.2",
|
|
15
|
+
"wrangler": "^4.0.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* city-bus — the hub that routes messages between autonomous city seats on
|
|
3
|
+
* different machines.
|
|
4
|
+
*
|
|
5
|
+
* Worker + Durable Object. Each seat opens a WebSocket against the hub and
|
|
6
|
+
* registers under its stable address (`alice/home`, `bob/research`, …).
|
|
7
|
+
*
|
|
8
|
+
* It goes through nobody's vendor account: the identity is a token you mint,
|
|
9
|
+
* stored hashed in KV.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Env {
|
|
13
|
+
HUB: DurableObjectNamespace;
|
|
14
|
+
TOKENS: KVNamespace;
|
|
15
|
+
/** Set to "1" to log the BODY of messages too. By default only metadata is
|
|
16
|
+
* logged: who, to whom, how big. */
|
|
17
|
+
LOG_CONTENT?: string;
|
|
18
|
+
/** Vista del city. Si falta cualquiera de las dos, el hub no le cuenta
|
|
19
|
+
* nada a nadie: la vista es opcional y el bus funciona igual sin ella. */
|
|
20
|
+
CITY_URL?: string;
|
|
21
|
+
CITY_SECRET?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Tells the city view what just happened.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not awaited on the critical path: if the view is down, slow or
|
|
28
|
+
* missing, the bus never notices. Routing between city seats is the job;
|
|
29
|
+
* the map is a spectator and must never get in the way.
|
|
30
|
+
*
|
|
31
|
+
* And only metadata travels — who, to whom, the tag — unless LOG_CONTENT is "1".
|
|
32
|
+
* The body of a message between two cities has no business ending up on a map.
|
|
33
|
+
*/
|
|
34
|
+
function avisaAlCity(env: Env, ev: Record<string, unknown>): void {
|
|
35
|
+
if (!env.CITY_URL || !env.CITY_SECRET) return;
|
|
36
|
+
fetch(`${env.CITY_URL}/ingest`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'content-type': 'application/json', 'X-City-Secreto': env.CITY_SECRET },
|
|
39
|
+
body: JSON.stringify(ev),
|
|
40
|
+
}).catch(() => {
|
|
41
|
+
/* la vista no importa tanto */
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Maximum message size, in characters. */
|
|
46
|
+
const MAX_TEXT = 64_000;
|
|
47
|
+
/** Messages per window, per connection. */
|
|
48
|
+
const RATE_LIMIT = 30;
|
|
49
|
+
const RATE_WINDOW_MS = 60_000;
|
|
50
|
+
/** A city address is `owner/city`, lowercase and filesystem-safe. */
|
|
51
|
+
const AGENT_RE = /^[a-z0-9][a-z0-9_-]{0,63}\/[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
52
|
+
/** How long a message waits for its recipient to show up. */
|
|
53
|
+
const PENDING_TTL_MS = 72 * 60 * 60 * 1000;
|
|
54
|
+
/** Queued messages per recipient. Past the cap, the oldest is dropped. */
|
|
55
|
+
const MAX_PENDING = 200;
|
|
56
|
+
const BUS_PROTOCOL = 'agents-city-bus/2';
|
|
57
|
+
|
|
58
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
59
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
60
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Identidad asociada a un token, tal y como se guarda en KV. */
|
|
64
|
+
interface Identity {
|
|
65
|
+
/** The owner prefix authenticated by the token: `alice`, `bob`, … */
|
|
66
|
+
user: string;
|
|
67
|
+
/** If present, restricts who this token may write to. `["*"]` or absent = anyone. */
|
|
68
|
+
can_send_to?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bad(status: number, msg: string): Response {
|
|
72
|
+
return new Response(msg + '\n', { status, headers: { 'content-type': 'text/plain' } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default {
|
|
76
|
+
async fetch(req: Request, env: Env): Promise<Response> {
|
|
77
|
+
const url = new URL(req.url);
|
|
78
|
+
|
|
79
|
+
if (url.pathname === '/health') {
|
|
80
|
+
return new Response('ok\n', { headers: { 'content-type': 'text/plain' } });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (url.pathname !== '/ws') return bad(404, 'not found');
|
|
84
|
+
if (req.headers.get('Upgrade') !== 'websocket') return bad(426, 'expected websocket upgrade');
|
|
85
|
+
|
|
86
|
+
// The token travels in a header. It is accepted in the query string only as
|
|
87
|
+
// a last resort, because URLs end up in logs.
|
|
88
|
+
const auth = req.headers.get('Authorization');
|
|
89
|
+
const token = auth?.startsWith('Bearer ')
|
|
90
|
+
? auth.slice(7).trim()
|
|
91
|
+
: url.searchParams.get('token');
|
|
92
|
+
if (!token) return bad(401, 'falta el token');
|
|
93
|
+
|
|
94
|
+
const raw = await env.TOKENS.get(`tok:${await sha256Hex(token)}`);
|
|
95
|
+
if (!raw) return bad(403, 'token not recognised');
|
|
96
|
+
|
|
97
|
+
let identity: Identity;
|
|
98
|
+
try {
|
|
99
|
+
identity = JSON.parse(raw);
|
|
100
|
+
} catch {
|
|
101
|
+
return bad(500, 'identidad corrupta en KV');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const agent = (url.searchParams.get('agent') || '').toLowerCase();
|
|
105
|
+
if (!AGENT_RE.test(agent)) {
|
|
106
|
+
return bad(400, 'invalid agent name: expected user/agent, lowercase');
|
|
107
|
+
}
|
|
108
|
+
// The name prefix must match the token's owner: nobody can present
|
|
109
|
+
// themselves as somebody else's agent.
|
|
110
|
+
if (agent.split('/')[0] !== identity.user) {
|
|
111
|
+
return bad(403, `this token belongs to "${identity.user}" and cannot register "${agent}"`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// One transport hub. City isolation stays in each seat's explicit road
|
|
115
|
+
// allowlist; an unconnected destination drops the message at its boundary.
|
|
116
|
+
const id = env.HUB.idFromName('global');
|
|
117
|
+
const stub = env.HUB.get(id);
|
|
118
|
+
|
|
119
|
+
const fwd = new URL(req.url);
|
|
120
|
+
fwd.searchParams.set('agent', agent);
|
|
121
|
+
fwd.searchParams.set('user', identity.user);
|
|
122
|
+
fwd.searchParams.set('can_send_to', (identity.can_send_to || ['*']).join(','));
|
|
123
|
+
fwd.searchParams.delete('token');
|
|
124
|
+
|
|
125
|
+
return stub.fetch(new Request(fwd.toString(), req));
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What we keep alongside each WebSocket (survives hibernation). */
|
|
130
|
+
interface Attachment {
|
|
131
|
+
agent: string;
|
|
132
|
+
user: string;
|
|
133
|
+
canSendTo: string[];
|
|
134
|
+
since: number;
|
|
135
|
+
/** ventana de rate limit */
|
|
136
|
+
winStart: number;
|
|
137
|
+
winCount: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface RoadEnvelope {
|
|
141
|
+
protocol: typeof BUS_PROTOCOL;
|
|
142
|
+
id: string;
|
|
143
|
+
kind: string;
|
|
144
|
+
scope: 'road';
|
|
145
|
+
thread: string | null;
|
|
146
|
+
from: { city: string; actor: 'seat'; role: 'external-seat' };
|
|
147
|
+
to: { city: string; actor: 'seat' };
|
|
148
|
+
createdAt: string;
|
|
149
|
+
payload: Record<string, unknown>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normaliseRoadEnvelope(
|
|
153
|
+
value: unknown,
|
|
154
|
+
from: string,
|
|
155
|
+
to: string,
|
|
156
|
+
now: number,
|
|
157
|
+
): RoadEnvelope | null {
|
|
158
|
+
if (value === undefined || value === null) return null;
|
|
159
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
160
|
+
throw new Error('envelope must be an object');
|
|
161
|
+
const raw = value as Record<string, any>;
|
|
162
|
+
if (raw.protocol !== BUS_PROTOCOL || raw.scope !== 'road') {
|
|
163
|
+
throw new Error('invalid road envelope protocol or scope');
|
|
164
|
+
}
|
|
165
|
+
if (raw.from?.city !== from || raw.from?.actor !== 'seat') {
|
|
166
|
+
throw new Error('envelope sender does not match the authenticated city seat');
|
|
167
|
+
}
|
|
168
|
+
if (raw.to?.city !== to || raw.to?.actor !== 'seat') {
|
|
169
|
+
throw new Error('envelope destination does not match "to"');
|
|
170
|
+
}
|
|
171
|
+
if (!raw.payload || typeof raw.payload !== 'object' || Array.isArray(raw.payload)) {
|
|
172
|
+
throw new Error('envelope payload must be an object');
|
|
173
|
+
}
|
|
174
|
+
const kind = String(raw.kind || '');
|
|
175
|
+
if (!kind || kind.length > 100) throw new Error('invalid envelope kind');
|
|
176
|
+
const payload = raw.payload as Record<string, unknown>;
|
|
177
|
+
const encoded = JSON.stringify(payload);
|
|
178
|
+
if (encoded.length > MAX_TEXT + 4_096) throw new Error('envelope payload is too large');
|
|
179
|
+
return {
|
|
180
|
+
protocol: BUS_PROTOCOL,
|
|
181
|
+
id: /^[a-zA-Z0-9_.-]{1,160}$/.test(String(raw.id || '')) ? String(raw.id) : crypto.randomUUID(),
|
|
182
|
+
kind,
|
|
183
|
+
scope: 'road',
|
|
184
|
+
thread: typeof raw.thread === 'string' ? raw.thread.slice(0, 160) : null,
|
|
185
|
+
from: { city: from, actor: 'seat', role: 'external-seat' },
|
|
186
|
+
to: { city: to, actor: 'seat' },
|
|
187
|
+
createdAt: new Date(now).toISOString(),
|
|
188
|
+
payload,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export class Hub implements DurableObject {
|
|
193
|
+
constructor(
|
|
194
|
+
private state: DurableObjectState,
|
|
195
|
+
private env: Env,
|
|
196
|
+
) {
|
|
197
|
+
// The DO is SQLite-backed: the queue survives hibernation and restarts.
|
|
198
|
+
this.state.blockConcurrencyWhile(async () => {
|
|
199
|
+
this.state.storage.sql.exec(`
|
|
200
|
+
CREATE TABLE IF NOT EXISTS pending (
|
|
201
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
202
|
+
recipient TEXT NOT NULL,
|
|
203
|
+
sender TEXT NOT NULL,
|
|
204
|
+
sender_user TEXT NOT NULL,
|
|
205
|
+
text TEXT NOT NULL,
|
|
206
|
+
envelope TEXT,
|
|
207
|
+
msg_id TEXT NOT NULL,
|
|
208
|
+
ts INTEGER NOT NULL
|
|
209
|
+
);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS pending_recipient ON pending(recipient, id);
|
|
211
|
+
`);
|
|
212
|
+
try {
|
|
213
|
+
this.state.storage.sql.exec('ALTER TABLE pending ADD COLUMN envelope TEXT');
|
|
214
|
+
} catch {
|
|
215
|
+
// Already present on a new database or after the first migration.
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Keep a message until its recipient turns up. */
|
|
221
|
+
private queue(
|
|
222
|
+
recipient: string,
|
|
223
|
+
sender: string,
|
|
224
|
+
senderUser: string,
|
|
225
|
+
text: string,
|
|
226
|
+
envelope: RoadEnvelope | null,
|
|
227
|
+
msg_id: string,
|
|
228
|
+
ts: number,
|
|
229
|
+
) {
|
|
230
|
+
const sql = this.state.storage.sql;
|
|
231
|
+
sql.exec('DELETE FROM pending WHERE ts < ?', ts - PENDING_TTL_MS);
|
|
232
|
+
|
|
233
|
+
const n =
|
|
234
|
+
(sql.exec('SELECT COUNT(*) AS n FROM pending WHERE recipient = ?', recipient).toArray()[0]
|
|
235
|
+
?.n as number) ?? 0;
|
|
236
|
+
if (n >= MAX_PENDING) {
|
|
237
|
+
// The oldest goes first: in a daily round, recent is what matters.
|
|
238
|
+
sql.exec(
|
|
239
|
+
'DELETE FROM pending WHERE id IN (SELECT id FROM pending WHERE recipient = ? ORDER BY id LIMIT ?)',
|
|
240
|
+
recipient,
|
|
241
|
+
n - MAX_PENDING + 1,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
sql.exec(
|
|
245
|
+
'INSERT INTO pending (recipient, sender, sender_user, text, envelope, msg_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
246
|
+
recipient,
|
|
247
|
+
sender,
|
|
248
|
+
senderUser,
|
|
249
|
+
text,
|
|
250
|
+
envelope ? JSON.stringify(envelope) : null,
|
|
251
|
+
msg_id,
|
|
252
|
+
ts,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Deliver and delete whatever was waiting for this agent. */
|
|
257
|
+
private drain(ws: WebSocket, agent: string): number {
|
|
258
|
+
const sql = this.state.storage.sql;
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
sql.exec('DELETE FROM pending WHERE ts < ?', now - PENDING_TTL_MS);
|
|
261
|
+
|
|
262
|
+
const rows = sql
|
|
263
|
+
.exec(
|
|
264
|
+
'SELECT id, sender, sender_user, text, envelope, msg_id, ts FROM pending WHERE recipient = ? ORDER BY id',
|
|
265
|
+
agent,
|
|
266
|
+
)
|
|
267
|
+
.toArray() as any[];
|
|
268
|
+
if (rows.length === 0) return 0;
|
|
269
|
+
|
|
270
|
+
for (const r of rows) {
|
|
271
|
+
let envelope: RoadEnvelope | undefined;
|
|
272
|
+
try {
|
|
273
|
+
envelope = r.envelope ? JSON.parse(r.envelope) : undefined;
|
|
274
|
+
} catch {}
|
|
275
|
+
this.send(ws, {
|
|
276
|
+
type: 'msg',
|
|
277
|
+
from: r.sender,
|
|
278
|
+
from_user: r.sender_user,
|
|
279
|
+
text: r.text,
|
|
280
|
+
msg_id: r.msg_id,
|
|
281
|
+
ts: r.ts,
|
|
282
|
+
...(envelope ? { envelope } : {}),
|
|
283
|
+
// El receptor debe saber que esto no es de ahora mismo.
|
|
284
|
+
delayed_minutes: Math.max(0, Math.round((now - r.ts) / 60000)),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
sql.exec('DELETE FROM pending WHERE recipient = ?', agent);
|
|
288
|
+
console.log(JSON.stringify({ ev: 'drain', agent, n: rows.length }));
|
|
289
|
+
return rows.length;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async fetch(req: Request): Promise<Response> {
|
|
293
|
+
const url = new URL(req.url);
|
|
294
|
+
const agent = url.searchParams.get('agent')!;
|
|
295
|
+
const user = url.searchParams.get('user')!;
|
|
296
|
+
const canSendTo = (url.searchParams.get('can_send_to') || '*').split(',').filter(Boolean);
|
|
297
|
+
|
|
298
|
+
const pair = new WebSocketPair();
|
|
299
|
+
const [client, server] = [pair[0], pair[1]];
|
|
300
|
+
|
|
301
|
+
// Hibernation: the DO can be evicted from memory while the sockets stay
|
|
302
|
+
// alive. Which is why state lives in the attachment, not in class fields.
|
|
303
|
+
this.state.acceptWebSocket(server, [agent, `user:${user}`]);
|
|
304
|
+
avisaAlCity(this.env, { t: 'luz', agente: agent, encendida: true });
|
|
305
|
+
const att: Attachment = {
|
|
306
|
+
agent,
|
|
307
|
+
user,
|
|
308
|
+
canSendTo,
|
|
309
|
+
since: Date.now(),
|
|
310
|
+
winStart: Date.now(),
|
|
311
|
+
winCount: 0,
|
|
312
|
+
};
|
|
313
|
+
server.serializeAttachment(att);
|
|
314
|
+
|
|
315
|
+
// If the same agent was already connected — a reconnect after a network
|
|
316
|
+
// drop — the previous connection is closed so deliveries are not doubled.
|
|
317
|
+
for (const other of this.state.getWebSockets(agent)) {
|
|
318
|
+
if (other !== server) {
|
|
319
|
+
try {
|
|
320
|
+
other.close(4009, 'replaced by a newer connection');
|
|
321
|
+
} catch {}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
this.send(server, { type: 'welcome', agent, roster: this.roster(agent) });
|
|
326
|
+
this.drain(server, agent);
|
|
327
|
+
this.broadcastPresence(agent, 'online', server);
|
|
328
|
+
|
|
329
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {
|
|
333
|
+
const att = ws.deserializeAttachment() as Attachment;
|
|
334
|
+
if (typeof data !== 'string') return this.send(ws, { type: 'error', error: 'text only' });
|
|
335
|
+
|
|
336
|
+
let msg: any;
|
|
337
|
+
try {
|
|
338
|
+
msg = JSON.parse(data);
|
|
339
|
+
} catch {
|
|
340
|
+
return this.send(ws, { type: 'error', error: 'invalid json' });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (msg?.type === 'ping') return this.send(ws, { type: 'pong' });
|
|
344
|
+
if (msg?.type === 'roster')
|
|
345
|
+
return this.send(ws, { type: 'roster', agents: this.roster(att.agent) });
|
|
346
|
+
if (msg?.type !== 'send')
|
|
347
|
+
return this.send(ws, { type: 'error', error: `unknown type: ${msg?.type}` });
|
|
348
|
+
|
|
349
|
+
// Per-connection rate limit.
|
|
350
|
+
const now = Date.now();
|
|
351
|
+
if (now - att.winStart > RATE_WINDOW_MS) {
|
|
352
|
+
att.winStart = now;
|
|
353
|
+
att.winCount = 0;
|
|
354
|
+
}
|
|
355
|
+
att.winCount++;
|
|
356
|
+
ws.serializeAttachment(att);
|
|
357
|
+
if (att.winCount > RATE_LIMIT) {
|
|
358
|
+
return this.send(ws, {
|
|
359
|
+
type: 'error',
|
|
360
|
+
error: `rate limit of ${RATE_LIMIT} messages/min reached`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const to = String(msg.to || '').toLowerCase();
|
|
365
|
+
const request_id = String(msg.request_id || '');
|
|
366
|
+
const answer = (value: Record<string, unknown>) =>
|
|
367
|
+
this.send(ws, { ...value, ...(request_id ? { request_id } : {}) });
|
|
368
|
+
if (!to) return answer({ type: 'error', error: '"to" is missing' });
|
|
369
|
+
let roadEnvelope: RoadEnvelope | null;
|
|
370
|
+
try {
|
|
371
|
+
roadEnvelope = normaliseRoadEnvelope(msg.envelope, att.agent, to, now);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return answer({ type: 'error', error: (error as Error).message });
|
|
374
|
+
}
|
|
375
|
+
const text = roadEnvelope ? String(roadEnvelope.payload.text ?? '') : String(msg.text ?? '');
|
|
376
|
+
if (!text.trim()) return answer({ type: 'error', error: 'empty message' });
|
|
377
|
+
if (text.length > MAX_TEXT) {
|
|
378
|
+
return answer({
|
|
379
|
+
type: 'error',
|
|
380
|
+
error: `message is ${text.length} characters, the maximum is ${MAX_TEXT}`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (to === att.agent) return answer({ type: 'error', error: 'that is you' });
|
|
384
|
+
|
|
385
|
+
const allowed =
|
|
386
|
+
att.canSendTo.includes('*') ||
|
|
387
|
+
att.canSendTo.includes(to) ||
|
|
388
|
+
att.canSendTo.includes(to.split('/')[0]);
|
|
389
|
+
if (!allowed) {
|
|
390
|
+
return answer({ type: 'error', error: `your token cannot write to "${to}"` });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const msg_id = roadEnvelope?.id || crypto.randomUUID();
|
|
394
|
+
const delivery = {
|
|
395
|
+
type: 'msg',
|
|
396
|
+
from: att.agent,
|
|
397
|
+
from_user: att.user,
|
|
398
|
+
text,
|
|
399
|
+
msg_id,
|
|
400
|
+
ts: now,
|
|
401
|
+
...(roadEnvelope ? { envelope: roadEnvelope } : {}),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
let targets: WebSocket[];
|
|
405
|
+
if (to === '*') {
|
|
406
|
+
targets = this.state.getWebSockets().filter((s) => s !== ws);
|
|
407
|
+
} else {
|
|
408
|
+
targets = this.state.getWebSockets(to);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (targets.length === 0) {
|
|
412
|
+
// Broadcasting to "*" only reaches whoever is connected: queueing it for
|
|
413
|
+
// everybody would be an avalanche with no owner.
|
|
414
|
+
if (to === '*') {
|
|
415
|
+
return answer({ type: 'error', error: 'nobody else is connected', orig_msg_id: msg_id });
|
|
416
|
+
}
|
|
417
|
+
this.queue(to, att.agent, att.user, text, roadEnvelope, msg_id, now);
|
|
418
|
+
console.log(JSON.stringify({ ev: 'queue', from: att.agent, to, bytes: text.length, msg_id }));
|
|
419
|
+
return answer({ type: 'queued', msg_id, to });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const t of targets) this.send(t, delivery);
|
|
423
|
+
|
|
424
|
+
// Audit: metadata only by default. See LOG_CONTENT in wrangler.toml.
|
|
425
|
+
console.log(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
ev: 'msg',
|
|
428
|
+
from: att.agent,
|
|
429
|
+
to,
|
|
430
|
+
n: targets.length,
|
|
431
|
+
bytes: text.length,
|
|
432
|
+
msg_id,
|
|
433
|
+
...(this.env.LOG_CONTENT === '1' ? { text } : {}),
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
avisaAlCity(this.env, {
|
|
438
|
+
t: 'carta',
|
|
439
|
+
de: att.agent,
|
|
440
|
+
a: to,
|
|
441
|
+
etiqueta: /^\s*\[(\w+)\]/.exec(text)?.[1] ?? 'aviso',
|
|
442
|
+
texto: this.env.LOG_CONTENT === '1' ? text.slice(0, 160) : '',
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
answer({ type: 'sent', msg_id, to, delivered: targets.length });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async webSocketClose(ws: WebSocket) {
|
|
449
|
+
const att = ws.deserializeAttachment() as Attachment | null;
|
|
450
|
+
if (att) {
|
|
451
|
+
this.broadcastPresence(att.agent, 'offline', ws);
|
|
452
|
+
avisaAlCity(this.env, { t: 'luz', agente: att.agent, encendida: false });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async webSocketError(ws: WebSocket) {
|
|
457
|
+
const att = ws.deserializeAttachment() as Attachment | null;
|
|
458
|
+
if (att) this.broadcastPresence(att.agent, 'offline', ws);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private roster(exclude?: string) {
|
|
462
|
+
const out: { agent: string; user: string; since: number }[] = [];
|
|
463
|
+
const seen = new Set<string>();
|
|
464
|
+
for (const s of this.state.getWebSockets()) {
|
|
465
|
+
const a = s.deserializeAttachment() as Attachment | null;
|
|
466
|
+
if (!a || a.agent === exclude || seen.has(a.agent)) continue;
|
|
467
|
+
seen.add(a.agent);
|
|
468
|
+
out.push({ agent: a.agent, user: a.user, since: a.since });
|
|
469
|
+
}
|
|
470
|
+
return out.sort((x, y) => x.agent.localeCompare(y.agent));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private broadcastPresence(agent: string, status: 'online' | 'offline', except: WebSocket) {
|
|
474
|
+
for (const s of this.state.getWebSockets()) {
|
|
475
|
+
if (s === except) continue;
|
|
476
|
+
this.send(s, { type: 'presence', agent, status });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private send(ws: WebSocket, obj: unknown) {
|
|
481
|
+
try {
|
|
482
|
+
ws.send(JSON.stringify(obj));
|
|
483
|
+
} catch {}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2022",
|
|
4
|
+
"module": "es2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["es2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name = "city-bus"
|
|
2
|
+
main = "src/index.ts"
|
|
3
|
+
compatibility_date = "2026-08-01"
|
|
4
|
+
# Pinned on purpose: if you have more than one Cloudflare account, wrangler picks
|
|
5
|
+
# one for you and the deploy lands somewhere you did not mean.
|
|
6
|
+
account_id = "<YOUR_CLOUDFLARE_ACCOUNT_ID>"
|
|
7
|
+
|
|
8
|
+
# Metadata only in the logs: who, to whom, how many bytes. Set it to "1" to log
|
|
9
|
+
# the text of the messages as well — which means the text of every message
|
|
10
|
+
# between agents ends up in your Cloudflare logs, so decide that deliberately.
|
|
11
|
+
[vars]
|
|
12
|
+
LOG_CONTENT = "0"
|
|
13
|
+
# The city view. Without this line the hub tells nobody anything and the bus works
|
|
14
|
+
# exactly the same: the view is optional by design.
|
|
15
|
+
CITY_URL = "https://city.example.com"
|
|
16
|
+
|
|
17
|
+
[[durable_objects.bindings]]
|
|
18
|
+
name = "HUB"
|
|
19
|
+
class_name = "Hub"
|
|
20
|
+
|
|
21
|
+
[[migrations]]
|
|
22
|
+
tag = "v1"
|
|
23
|
+
new_sqlite_classes = ["Hub"]
|
|
24
|
+
|
|
25
|
+
# Create it with: npx wrangler@4 kv namespace create TOKENS
|
|
26
|
+
# and paste the id it prints here.
|
|
27
|
+
[[kv_namespaces]]
|
|
28
|
+
binding = "TOKENS"
|
|
29
|
+
id = "<YOUR_KV_NAMESPACE_ID>"
|
|
30
|
+
|
|
31
|
+
[observability]
|
|
32
|
+
enabled = true
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# The oven
|
|
2
|
+
|
|
3
|
+
Bakes the 3D models of a city kit into the **isometric sprites** Pixi draws. It
|
|
4
|
+
exists because the good CC0 kits are all 3D, and live 3D reads worse: with a
|
|
5
|
+
hundred and fifty parcels there is always a building hiding another one. This way
|
|
6
|
+
you get a game's art and an isometric map's legibility.
|
|
7
|
+
|
|
8
|
+
The `.glb` files are **not in the repo**: they are 3.6 MB, re-downloadable, and
|
|
9
|
+
there is no reason to version them. What is versioned is the baked PNGs, which is
|
|
10
|
+
what the web serves.
|
|
11
|
+
|
|
12
|
+
## Baking again
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# 1. the kit (CC0, no attribution required)
|
|
16
|
+
curl -sL -o /tmp/kit.zip \
|
|
17
|
+
"https://kenney.nl/media/pages/assets/city-kit-commercial/a742d900eb-1753115042/kenney_city-kit-commercial_2.1.zip"
|
|
18
|
+
unzip -o -j /tmp/kit.zip "Models/GLB format/*.glb" -d city/oven/glb
|
|
19
|
+
|
|
20
|
+
# 2. the oven and the collector
|
|
21
|
+
(cd city/web && npx --yes esbuild src/oven.ts --bundle --format=esm --outfile=../oven/oven.js)
|
|
22
|
+
ls city/oven/glb | sed 's/\.glb$//' | python3 -c "import sys,json,re;ms=[l.strip() for l in sys.stdin if l.strip()];p='city/oven/oven.html';h=open(p).read();open(p,'w').write(re.sub(r'window.__modelos = .*','window.__modelos = '+json.dumps(ms),h))"
|
|
23
|
+
(cd city/oven && python3 -m http.server 8811 &) ; python3 city/oven/collect.py &
|
|
24
|
+
|
|
25
|
+
# 3. open http://127.0.0.1:8811/oven.html in a browser and, once the title says
|
|
26
|
+
# the models are baked, send them to the collector from the console:
|
|
27
|
+
# window.__horneado.reduce((p,s)=>p.then(()=>fetch('http://127.0.0.1:8812/',
|
|
28
|
+
# {method:'POST',body:JSON.stringify(s)})), Promise.resolve())
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
They land in `city/web/assets/sprites/`: one PNG per model and a `medidas.json`
|
|
32
|
+
carrying the height, the width, where the ground sits in the image, and how many
|
|
33
|
+
pixels one unit measures. With those four numbers Pixi places and scales without
|
|
34
|
+
guessing anything.
|
|
35
|
+
|
|
36
|
+
## Decisions that matter
|
|
37
|
+
|
|
38
|
+
**Baked in light grey.** The colour comes from Pixi's `tint`, per district, so
|
|
39
|
+
one model serves every district instead of baking the same building once per
|
|
40
|
+
colour.
|
|
41
|
+
|
|
42
|
+
**One light and one angle for all of them.** Orthographic at 45° of yaw and
|
|
43
|
+
35.26° of pitch — plain old isometric — with the camera looking at the ground and
|
|
44
|
+
not at the centre of each building: that way the y=0 plane always falls on the
|
|
45
|
+
same pixel and the sprites anchor to the tile. With the camera aimed at each
|
|
46
|
+
model's middle, some floated and others sank.
|
|
47
|
+
|
|
48
|
+
**The framing is fixed, not per model.** Fit each one to its own size and they
|
|
49
|
+
come out at different scales on the map, and the city looks like mismatched toys.
|
|
50
|
+
|
|
51
|
+
**The model is chosen by target height, and then stretched a little.** The first
|
|
52
|
+
version picked one of four models by band and left it alone, which meant a house
|
|
53
|
+
showed one or two heights across a whole replay: floors landed and nothing moved.
|
|
54
|
+
Now the height a parcel's capital asks for is computed first, the closest baked
|
|
55
|
+
model is picked for it, and the sprite is stretched the rest of the way — never
|
|
56
|
+
by more than about half, so a building still reads as a building. Ties among
|
|
57
|
+
models of similar height are broken by hashing the parcel's name, so two parcels
|
|
58
|
+
of the same size are not the same silhouette, and the same parcel is always the
|
|
59
|
+
same one.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Collect the sprites the oven sends and write them to disk.
|
|
3
|
+
|
|
4
|
+
It exists because the PNGs come out of the browser in base64, and there is no
|
|
5
|
+
point routing them through anything in between: the browser posts them here and
|
|
6
|
+
here they are saved.
|
|
7
|
+
"""
|
|
8
|
+
import base64, json, os, re
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
|
|
11
|
+
DESTINO = os.path.join(os.path.dirname(__file__), '..', 'web', 'assets', 'sprites')
|
|
12
|
+
os.makedirs(DESTINO, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
class Manos(BaseHTTPRequestHandler):
|
|
15
|
+
def do_OPTIONS(self):
|
|
16
|
+
self.send_response(204); self.cors(); self.end_headers()
|
|
17
|
+
def do_POST(self):
|
|
18
|
+
n = int(self.headers.get('content-length', 0))
|
|
19
|
+
cuerpo = self.rfile.read(n)
|
|
20
|
+
try:
|
|
21
|
+
d = json.loads(cuerpo)
|
|
22
|
+
# The name comes from outside: unsanitised, a "../../something"
|
|
23
|
+
# writes wherever it likes. A local tool, fixed all the same.
|
|
24
|
+
nombre = os.path.basename(str(d['nombre']))
|
|
25
|
+
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', nombre):
|
|
26
|
+
raise ValueError('invalid name')
|
|
27
|
+
with open(os.path.join(DESTINO, nombre + '.png'), 'wb') as f:
|
|
28
|
+
f.write(base64.b64decode(d['png']))
|
|
29
|
+
metas = os.path.join(DESTINO, 'medidas.json')
|
|
30
|
+
todas = json.load(open(metas)) if os.path.exists(metas) else {}
|
|
31
|
+
todas[nombre] = {'alto': d['alto'], 'ancho': d['ancho'],
|
|
32
|
+
'ancla': d.get('ancla'), 'pxUnidad': d.get('pxUnidad')}
|
|
33
|
+
json.dump(todas, open(metas, 'w'), indent=1, sort_keys=True)
|
|
34
|
+
self.send_response(200)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print('fallo:', e); self.send_response(500)
|
|
37
|
+
self.cors(); self.end_headers()
|
|
38
|
+
def cors(self):
|
|
39
|
+
self.send_header('access-control-allow-origin', '*')
|
|
40
|
+
self.send_header('access-control-allow-headers', '*')
|
|
41
|
+
def log_message(self, *a): pass
|
|
42
|
+
|
|
43
|
+
HTTPServer(('127.0.0.1', 8812), Manos).serve_forever()
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><title>horno</title>
|
|
2
|
+
<style>body{margin:0;background:#222}canvas{width:512px;height:512px}</style></head>
|
|
3
|
+
<body><script>window.__modelos = ["building-a", "building-b", "building-c", "building-d", "building-e", "building-f", "building-g", "building-h", "building-i", "building-j", "building-k", "building-l", "building-m", "building-n", "building-skyscraper-a", "building-skyscraper-b", "building-skyscraper-c", "building-skyscraper-d", "building-skyscraper-e", "detail-awning-wide", "detail-awning", "detail-overhang-wide", "detail-overhang", "detail-parasol-a", "detail-parasol-b", "low-detail-building-a", "low-detail-building-b", "low-detail-building-c", "low-detail-building-d", "low-detail-building-e", "low-detail-building-f", "low-detail-building-g", "low-detail-building-h", "low-detail-building-i", "low-detail-building-j", "low-detail-building-k", "low-detail-building-l", "low-detail-building-m", "low-detail-building-n", "low-detail-building-wide-a", "low-detail-building-wide-b"]</script>
|
|
4
|
+
<script type="module" src="/oven.js"></script></body></html>
|