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,508 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Browser spectator E2E: happy committee, moderated replies and hard failures."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
RAIZ = os.path.dirname(AQUI)
|
|
16
|
+
sys.path.insert(0, AQUI)
|
|
17
|
+
from testlib import afirma, detiene_hubs_de_ciudad, resumen # noqa: E402
|
|
18
|
+
|
|
19
|
+
CLIENT = os.path.join(RAIZ, 'plugin', 'channel', 'client.js')
|
|
20
|
+
CLAUDE_ACTIVITY = os.path.join(RAIZ, 'plugin', 'hooks', 'activity.sh')
|
|
21
|
+
CHANNEL_DIR = os.path.join(RAIZ, 'plugin', 'channel')
|
|
22
|
+
WATCH_JS = r"""
|
|
23
|
+
const WebSocket = require('ws');
|
|
24
|
+
const readline = require('readline');
|
|
25
|
+
const ws = new WebSocket(process.argv[1], { origin: process.argv[2] });
|
|
26
|
+
ws.on('open', () => process.stdout.write(JSON.stringify({type:'test.open'}) + '\n'));
|
|
27
|
+
ws.on('message', raw => process.stdout.write(String(raw) + '\n'));
|
|
28
|
+
ws.on('unexpected-response', (_request, response) => {
|
|
29
|
+
process.stdout.write(JSON.stringify({type:'test.rejected', status:response.statusCode}) + '\n');
|
|
30
|
+
process.exit(3);
|
|
31
|
+
});
|
|
32
|
+
ws.on('error', error => process.stderr.write(error.message + '\n'));
|
|
33
|
+
readline.createInterface({input:process.stdin}).on('line', line => {
|
|
34
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(line);
|
|
35
|
+
});
|
|
36
|
+
process.on('SIGTERM', () => { try { ws.close(); } catch {} process.exit(0); });
|
|
37
|
+
"""
|
|
38
|
+
REJECT_JS = r"""
|
|
39
|
+
const WebSocket = require('ws');
|
|
40
|
+
const ws = new WebSocket(process.argv[1], { origin: process.argv[2] });
|
|
41
|
+
let done = false;
|
|
42
|
+
const finish = code => {
|
|
43
|
+
if (done) return;
|
|
44
|
+
done = true;
|
|
45
|
+
try { ws.close(); } catch {}
|
|
46
|
+
process.exit(code);
|
|
47
|
+
};
|
|
48
|
+
ws.on('open', () => finish(2));
|
|
49
|
+
ws.on('unexpected-response', (_request, response) => finish(response.statusCode === 403 ? 0 : 3));
|
|
50
|
+
ws.on('error', () => {});
|
|
51
|
+
setTimeout(() => finish(4), 4000);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Watcher:
|
|
56
|
+
def __init__(self, url):
|
|
57
|
+
self.messages = []
|
|
58
|
+
self.lines = queue.Queue()
|
|
59
|
+
self.errors = []
|
|
60
|
+
self.process = subprocess.Popen(
|
|
61
|
+
['node', '--input-type=commonjs', '-e', WATCH_JS, url,
|
|
62
|
+
'http://127.0.0.1:43210'],
|
|
63
|
+
cwd=CHANNEL_DIR, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
64
|
+
stderr=subprocess.PIPE, text=True, bufsize=1)
|
|
65
|
+
threading.Thread(target=self._read, daemon=True).start()
|
|
66
|
+
threading.Thread(target=self._read_errors, daemon=True).start()
|
|
67
|
+
|
|
68
|
+
def _read(self):
|
|
69
|
+
for line in self.process.stdout:
|
|
70
|
+
try:
|
|
71
|
+
value = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
continue
|
|
74
|
+
self.messages.append(value)
|
|
75
|
+
self.lines.put(value)
|
|
76
|
+
|
|
77
|
+
def _read_errors(self):
|
|
78
|
+
for line in self.process.stderr:
|
|
79
|
+
self.errors.append(line.rstrip())
|
|
80
|
+
|
|
81
|
+
def wait(self, condition, seconds=8):
|
|
82
|
+
limit = time.monotonic() + seconds
|
|
83
|
+
for message in self.messages:
|
|
84
|
+
if condition(message):
|
|
85
|
+
return message
|
|
86
|
+
while time.monotonic() < limit:
|
|
87
|
+
try:
|
|
88
|
+
message = self.lines.get(timeout=max(.01, min(.2, limit - time.monotonic())))
|
|
89
|
+
except queue.Empty:
|
|
90
|
+
continue
|
|
91
|
+
if condition(message):
|
|
92
|
+
return message
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
def send(self, value):
|
|
96
|
+
self.process.stdin.write(json.dumps(value) + '\n')
|
|
97
|
+
self.process.stdin.flush()
|
|
98
|
+
|
|
99
|
+
def close(self):
|
|
100
|
+
if self.process.poll() is None:
|
|
101
|
+
self.process.terminate()
|
|
102
|
+
try:
|
|
103
|
+
self.process.wait(timeout=2)
|
|
104
|
+
except subprocess.TimeoutExpired:
|
|
105
|
+
self.process.kill()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def command(env, actor, verb, thread='', payload=None):
|
|
109
|
+
args = ['node', CLIENT, 'committee', verb]
|
|
110
|
+
if thread:
|
|
111
|
+
args.append(thread)
|
|
112
|
+
body = None
|
|
113
|
+
if payload is not None:
|
|
114
|
+
args += ['--input', '-']
|
|
115
|
+
body = json.dumps(payload)
|
|
116
|
+
return subprocess.run(
|
|
117
|
+
args, input=body, capture_output=True, text=True,
|
|
118
|
+
env=dict(env, CITY_BUS_ACTOR=actor), timeout=12)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def publish_activity(env, actor, payload, thread=''):
|
|
122
|
+
args = ['node', CLIENT, 'activity', 'publish']
|
|
123
|
+
if thread:
|
|
124
|
+
args.append(thread)
|
|
125
|
+
args += ['--input', '-']
|
|
126
|
+
return subprocess.run(
|
|
127
|
+
args, input=json.dumps(payload), capture_output=True, text=True,
|
|
128
|
+
env=dict(env, CITY_BUS_ACTOR=actor), timeout=12)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def claude_hook(env, actor, event, payload):
|
|
132
|
+
return subprocess.run(
|
|
133
|
+
[CLAUDE_ACTIVITY, event], input=json.dumps(payload), capture_output=True,
|
|
134
|
+
text=True, env=dict(env, CITY_BUS_ACTOR=actor,
|
|
135
|
+
CLAUDE_PLUGIN_ROOT=os.path.join(RAIZ, 'plugin')),
|
|
136
|
+
timeout=12)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def value(result):
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(result.stdout)
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def spectator_url(endpoint, token=None):
|
|
147
|
+
parsed = urllib.parse.urlparse(endpoint['url'])
|
|
148
|
+
query = urllib.parse.urlencode({
|
|
149
|
+
'mode': 'spectator',
|
|
150
|
+
'token': token if token is not None else endpoint['spectatorToken'],
|
|
151
|
+
})
|
|
152
|
+
return urllib.parse.urlunparse(parsed._replace(query=query))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def rejected(url, origin='http://127.0.0.1:43210'):
|
|
156
|
+
return subprocess.run(
|
|
157
|
+
['node', '--input-type=commonjs', '-e', REJECT_JS, url, origin],
|
|
158
|
+
cwd=CHANNEL_DIR, capture_output=True, text=True, timeout=6).returncode == 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def events_from(message):
|
|
162
|
+
return message.get('events', []) if message.get('type') == 'activity.state' else []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ordered(actual, expected):
|
|
166
|
+
cursor = 0
|
|
167
|
+
for item in actual:
|
|
168
|
+
if cursor < len(expected) and item == expected[cursor]:
|
|
169
|
+
cursor += 1
|
|
170
|
+
return cursor == len(expected)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main():
|
|
174
|
+
print('\n live browser committee feed over the real local WebSocket hub')
|
|
175
|
+
base = tempfile.mkdtemp(prefix='agents-city-live-feed-')
|
|
176
|
+
app = os.path.join(base, 'app')
|
|
177
|
+
city = os.path.join(app, 'alice', 'home')
|
|
178
|
+
os.makedirs(city)
|
|
179
|
+
open(os.path.join(city, 'city.yml'), 'w', encoding='utf-8').write(
|
|
180
|
+
'id: city_live_feed\nname: Home\nslug: home\nowner: alice\n')
|
|
181
|
+
open(os.path.join(city, 'roads.json'), 'w', encoding='utf-8').write(
|
|
182
|
+
'{"version": 1, "roads": []}\n')
|
|
183
|
+
open(os.path.join(city, 'alice.md'), 'w', encoding='utf-8').write(
|
|
184
|
+
'---\nuser: alice\nagent: alice/ceo\nrepos: [api, web, ops]\n'
|
|
185
|
+
'role.api: data-engineer\nrole.web: seo\nrole.ops: quality\n'
|
|
186
|
+
'runs.api: claude\nruns.web: codex\nruns.ops: opencode\n---\n')
|
|
187
|
+
env = dict(os.environ, AGENTS_CITY_HOME=app, AGENTS_CITY_DATA=city,
|
|
188
|
+
AGENTS_CITY_USER='alice')
|
|
189
|
+
for key in ('CITY_BUS_URL', 'CITY_BUS_TOKEN', 'CITY_DIR'):
|
|
190
|
+
env.pop(key, None)
|
|
191
|
+
|
|
192
|
+
watcher = reconnect = None
|
|
193
|
+
try:
|
|
194
|
+
opened = command(env, 'seat', 'open', payload={
|
|
195
|
+
'question': 'Do we release the migration?',
|
|
196
|
+
'desiredOutcome': 'A verified go or no-go',
|
|
197
|
+
'definitionOfDone': ['rollback reproduced', 'decision verified'],
|
|
198
|
+
'participants': ['api', 'web'],
|
|
199
|
+
'authority': 'decide',
|
|
200
|
+
'maxRebuttals': 2,
|
|
201
|
+
})
|
|
202
|
+
state = value(opened)
|
|
203
|
+
thread = state.get('id', '')
|
|
204
|
+
endpoint_path = os.path.join(
|
|
205
|
+
app, '.runtime', 'bus', 'city-live-feed', 'endpoint.json')
|
|
206
|
+
endpoint = json.load(open(endpoint_path, encoding='utf-8'))
|
|
207
|
+
afirma('· a committee starts the real local hub with a rotated spectator token',
|
|
208
|
+
opened.returncode == 0 and bool(thread) and bool(endpoint.get('spectatorToken')),
|
|
209
|
+
opened.stderr.strip())
|
|
210
|
+
|
|
211
|
+
watcher = Watcher(spectator_url(endpoint))
|
|
212
|
+
snapshot = watcher.wait(lambda message: message.get('type') == 'activity.state')
|
|
213
|
+
afirma('· the browser receives its initial state over WebSocket',
|
|
214
|
+
any(event.get('kind') == 'committee.opened'
|
|
215
|
+
for event in events_from(snapshot)),
|
|
216
|
+
'\n'.join(watcher.errors[-3:]))
|
|
217
|
+
|
|
218
|
+
# Normal runtime conversation is first-class activity too; the old
|
|
219
|
+
# implementation only mirrored committee.* events and left City live
|
|
220
|
+
# blank while the seat visibly answered in its terminal.
|
|
221
|
+
source_id = 'test-normal-seat-prompt-1'
|
|
222
|
+
normal = publish_activity(env, 'seat', {
|
|
223
|
+
'sourceId': source_id,
|
|
224
|
+
'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
|
|
225
|
+
'title': 'seat asked', 'summary': 'What is this city for?',
|
|
226
|
+
# These are deliberately ignored: identity is the socket credential.
|
|
227
|
+
'actor': 'web', 'role': 'member',
|
|
228
|
+
}, 'runtime-thread-1')
|
|
229
|
+
visible_prompt = watcher.wait(
|
|
230
|
+
lambda message: message.get('type') == 'activity.event'
|
|
231
|
+
and message.get('event', {}).get('sourceId') == source_id)
|
|
232
|
+
prompt_event = visible_prompt.get('event', {})
|
|
233
|
+
afirma('· happy: a normal seat prompt appears immediately over WebSocket',
|
|
234
|
+
normal.returncode == 0
|
|
235
|
+
and prompt_event.get('summary') == 'What is this city for?'
|
|
236
|
+
and prompt_event.get('actor') == 'seat'
|
|
237
|
+
and prompt_event.get('role') == 'chair',
|
|
238
|
+
normal.stderr or json.dumps(visible_prompt))
|
|
239
|
+
|
|
240
|
+
# Provider reconnects and repeated hooks can report the same completed
|
|
241
|
+
# item. A stable source id makes that replay idempotent.
|
|
242
|
+
repeated = publish_activity(env, 'seat', {
|
|
243
|
+
'sourceId': source_id,
|
|
244
|
+
'kind': 'conversation.user', 'phase': 'asked', 'tone': 'question',
|
|
245
|
+
'title': 'seat asked again', 'summary': 'DUPLICATE MUST NOT APPEAR',
|
|
246
|
+
}, 'runtime-thread-1')
|
|
247
|
+
activity_file = os.path.join(
|
|
248
|
+
app, '.runtime', 'bus', 'city-live-feed', 'activity.jsonl')
|
|
249
|
+
persisted = [json.loads(line) for line in open(activity_file, encoding='utf-8')
|
|
250
|
+
if line.strip()]
|
|
251
|
+
matching = [event for event in persisted if event.get('sourceId') == source_id]
|
|
252
|
+
afirma('· happy: replaying one provider item does not duplicate the feed',
|
|
253
|
+
repeated.returncode == 0 and len(matching) == 1
|
|
254
|
+
and 'DUPLICATE MUST NOT APPEAR' not in json.dumps(matching),
|
|
255
|
+
repeated.stderr or json.dumps(matching))
|
|
256
|
+
|
|
257
|
+
# Claude supplies visible prompt/final-answer fields through documented
|
|
258
|
+
# hooks. Both travel through the exact same authenticated bus.
|
|
259
|
+
transcript = os.path.join(base, 'claude-transcript.jsonl')
|
|
260
|
+
open(transcript, 'w', encoding='utf-8').write('{"visible":true}\n')
|
|
261
|
+
hook_prompt = claude_hook(env, 'api', 'UserPromptSubmit', {
|
|
262
|
+
'session_id': 'claude-visible-session', 'transcript_path': transcript,
|
|
263
|
+
'prompt': 'Inspect the migration rollback.',
|
|
264
|
+
})
|
|
265
|
+
hook_answer = claude_hook(env, 'api', 'Stop', {
|
|
266
|
+
'session_id': 'claude-visible-session', 'transcript_path': transcript,
|
|
267
|
+
'last_assistant_message': 'Rollback fixture passes cleanly.',
|
|
268
|
+
})
|
|
269
|
+
claude_visible = watcher.wait(
|
|
270
|
+
lambda message: message.get('type') == 'activity.event'
|
|
271
|
+
and message.get('event', {}).get('kind') == 'conversation.agent'
|
|
272
|
+
and message.get('event', {}).get('actor') == 'api')
|
|
273
|
+
afirma('· happy: Claude visible prompts and answers use the WebSocket feed',
|
|
274
|
+
hook_prompt.returncode == 0 and hook_answer.returncode == 0
|
|
275
|
+
and 'Rollback fixture passes cleanly.' in json.dumps(claude_visible),
|
|
276
|
+
hook_prompt.stderr + hook_answer.stderr + json.dumps(claude_visible))
|
|
277
|
+
|
|
278
|
+
# A Channel assignment is transport, not dialogue. The prompt wrapper
|
|
279
|
+
# must stay out of the spectator feed, while Claude's visible answer is
|
|
280
|
+
# grouped into the semantic committee thread left by the channel.
|
|
281
|
+
marker_dir = os.path.join(
|
|
282
|
+
app, '.runtime', 'bus', 'city-live-feed', 'claude-threads')
|
|
283
|
+
os.makedirs(marker_dir, exist_ok=True)
|
|
284
|
+
marker_path = os.path.join(marker_dir, 'api.json')
|
|
285
|
+
open(marker_path, 'w', encoding='utf-8').write(json.dumps({
|
|
286
|
+
'thread': thread, 'envelopeId': 'assignment-1',
|
|
287
|
+
'kind': 'committee.assignment',
|
|
288
|
+
}))
|
|
289
|
+
internal_prompt = (
|
|
290
|
+
'<channel source="plugin:city:city-bus" '
|
|
291
|
+
'protocol="agents-city-bus/2">SECRET_TRANSPORT_WRAPPER</channel>')
|
|
292
|
+
internal = claude_hook(env, 'api', 'UserPromptSubmit', {
|
|
293
|
+
'session_id': 'claude-committee-session', 'transcript_path': transcript,
|
|
294
|
+
'prompt': internal_prompt,
|
|
295
|
+
})
|
|
296
|
+
internal_answer = claude_hook(env, 'api', 'Stop', {
|
|
297
|
+
'session_id': 'claude-committee-session', 'transcript_path': transcript,
|
|
298
|
+
'last_assistant_message': 'API recommends a reversible migration.',
|
|
299
|
+
})
|
|
300
|
+
grouped_answer = watcher.wait(
|
|
301
|
+
lambda message: message.get('type') == 'activity.event'
|
|
302
|
+
and message.get('event', {}).get('summary')
|
|
303
|
+
== 'API recommends a reversible migration.')
|
|
304
|
+
afirma('· happy: a Claude Channel answer joins the committee conversation',
|
|
305
|
+
internal.returncode == 0 and internal_answer.returncode == 0
|
|
306
|
+
and grouped_answer.get('event', {}).get('thread') == thread,
|
|
307
|
+
internal.stderr + internal_answer.stderr + json.dumps(grouped_answer))
|
|
308
|
+
afirma('· non-happy: raw Channel transport is neither shown nor persisted',
|
|
309
|
+
'SECRET_TRANSPORT_WRAPPER' not in json.dumps(watcher.messages)
|
|
310
|
+
and 'SECRET_TRANSPORT_WRAPPER'
|
|
311
|
+
not in open(activity_file, encoding='utf-8').read()
|
|
312
|
+
and not os.path.exists(marker_path),
|
|
313
|
+
open(activity_file, encoding='utf-8').read())
|
|
314
|
+
|
|
315
|
+
private = 'PRIVATE_REASONING_MUST_NEVER_REACH_CITY_LIVE'
|
|
316
|
+
rejected_private = publish_activity(env, 'seat', {
|
|
317
|
+
'sourceId': 'private-reasoning-1',
|
|
318
|
+
'kind': 'conversation.reasoning', 'title': 'hidden thought',
|
|
319
|
+
'summary': private,
|
|
320
|
+
}, 'runtime-thread-1')
|
|
321
|
+
afirma('· non-happy: private reasoning is rejected, not logged as activity',
|
|
322
|
+
rejected_private.returncode != 0
|
|
323
|
+
and 'private model reasoning' in rejected_private.stderr
|
|
324
|
+
and private not in open(activity_file, encoding='utf-8').read(),
|
|
325
|
+
rejected_private.stderr)
|
|
326
|
+
|
|
327
|
+
invalid = publish_activity(env, 'seat', {
|
|
328
|
+
'kind': 'committee.decision.recorded', 'title': 'forged decision',
|
|
329
|
+
'summary': 'A runtime tried to forge committee state.',
|
|
330
|
+
}, 'runtime-thread-1')
|
|
331
|
+
afirma('· non-happy: runtime activity cannot forge committee protocol events',
|
|
332
|
+
invalid.returncode != 0
|
|
333
|
+
and 'must describe conversation, runtime or work' in invalid.stderr,
|
|
334
|
+
invalid.stderr)
|
|
335
|
+
|
|
336
|
+
secret = 'SECRET_FIRST_POSITION_VISIBLE_ONLY_AFTER_BARRIER'
|
|
337
|
+
command(env, 'api', 'respond', thread, {
|
|
338
|
+
'stance': 'conditional', 'recommendation': secret,
|
|
339
|
+
'evidence': ['api migration replay 482'],
|
|
340
|
+
})
|
|
341
|
+
first = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
342
|
+
and message.get('event', {}).get('kind')
|
|
343
|
+
== 'committee.position.submitted')
|
|
344
|
+
afirma('· the live feed shows progress but does not leak an early position',
|
|
345
|
+
bool(first) and secret not in json.dumps(watcher.messages),
|
|
346
|
+
json.dumps(first))
|
|
347
|
+
|
|
348
|
+
command(env, 'web', 'respond', thread, {
|
|
349
|
+
'stance': 'support', 'recommendation': 'ship with a cache-safe rollback banner',
|
|
350
|
+
'evidence': ['web end-to-end rollback passed'],
|
|
351
|
+
})
|
|
352
|
+
barrier = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
353
|
+
and message.get('event', {}).get('kind')
|
|
354
|
+
== 'committee.positions.revealed')
|
|
355
|
+
watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
356
|
+
and message.get('event', {}).get('kind')
|
|
357
|
+
== 'committee.position.revealed'
|
|
358
|
+
and message.get('event', {}).get('actor') == 'web')
|
|
359
|
+
revealed = [message.get('event', {}) for message in watcher.messages
|
|
360
|
+
if message.get('type') == 'activity.event'
|
|
361
|
+
and message.get('event', {}).get('kind')
|
|
362
|
+
== 'committee.position.revealed']
|
|
363
|
+
afirma('· after the barrier each specialist speaks as a visible participant',
|
|
364
|
+
len(revealed) == 2
|
|
365
|
+
and secret in json.dumps(revealed)
|
|
366
|
+
and 'cache-safe rollback banner' in json.dumps(revealed)
|
|
367
|
+
and secret not in json.dumps(barrier),
|
|
368
|
+
json.dumps(revealed))
|
|
369
|
+
|
|
370
|
+
command(env, 'seat', 'synthesize', thread, {
|
|
371
|
+
'summary': 'Both paths support a reversible release',
|
|
372
|
+
'agreements': ['rollback first'], 'conflicts': ['banner cache'],
|
|
373
|
+
'unknowns': [],
|
|
374
|
+
})
|
|
375
|
+
requested = value(command(env, 'api', 'floor-request', thread, {
|
|
376
|
+
'basis': 'new_evidence', 'reason': 'the clean replay just completed',
|
|
377
|
+
'evidence': ['immutable run 482 passed'],
|
|
378
|
+
}))
|
|
379
|
+
request_id = requested.get('myFloorRequests', [{}])[-1].get('id', '')
|
|
380
|
+
command(env, 'seat', 'floor-grant', thread, {'requestId': request_id})
|
|
381
|
+
command(env, 'api', 'reply', thread, {
|
|
382
|
+
'claim': 'the migration replay is now green',
|
|
383
|
+
'evidence': ['immutable run 482'],
|
|
384
|
+
'consequence': 'the migration gate is satisfied',
|
|
385
|
+
})
|
|
386
|
+
spoke = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
387
|
+
and message.get('event', {}).get('kind')
|
|
388
|
+
== 'committee.floor.spoke')
|
|
389
|
+
afirma('· a requested and granted intervention appears live with its evidence',
|
|
390
|
+
'migration replay is now green' in json.dumps(spoke)
|
|
391
|
+
and 'immutable run 482' in json.dumps(spoke), json.dumps(spoke))
|
|
392
|
+
|
|
393
|
+
web_outbox = os.path.join(
|
|
394
|
+
app, '.runtime', 'bus', 'city-live-feed', 'outbox', 'web')
|
|
395
|
+
queued = []
|
|
396
|
+
for name in os.listdir(web_outbox):
|
|
397
|
+
if name.endswith('.json'):
|
|
398
|
+
queued.append(json.load(open(os.path.join(web_outbox, name), encoding='utf-8')))
|
|
399
|
+
heard = next((item for item in queued if item.get('kind') == 'committee.reply.heard'), {})
|
|
400
|
+
afirma('· every other member hears the chaired intervention and may seek a reply',
|
|
401
|
+
heard.get('payload', {}).get('reply', {}).get('actor') == 'api'
|
|
402
|
+
and 'request the floor' in heard.get('payload', {}).get('note', '').lower(),
|
|
403
|
+
json.dumps(heard))
|
|
404
|
+
|
|
405
|
+
web_request = value(command(env, 'web', 'floor-request', thread, {
|
|
406
|
+
'basis': 'contradiction', 'reason': 'the CDN still serves the old banner',
|
|
407
|
+
'evidence': ['edge trace cache-age=310'],
|
|
408
|
+
}))
|
|
409
|
+
web_request_id = web_request.get('myFloorRequests', [{}])[-1].get('id', '')
|
|
410
|
+
blocked = command(env, 'seat', 'decide', thread, {
|
|
411
|
+
'outcome': 'ship', 'rationale': 'replay passed', 'owner': 'alice',
|
|
412
|
+
'executor': 'api', 'verifier': 'ops',
|
|
413
|
+
'verificationQuestion': 'does rollback pass?',
|
|
414
|
+
'selectedEvidence': ['run 482'],
|
|
415
|
+
'decisiveContributors': ['api', 'web'], 'reopenIf': ['rollback fails'],
|
|
416
|
+
})
|
|
417
|
+
rejected_event = watcher.wait(
|
|
418
|
+
lambda message: message.get('type') == 'activity.event'
|
|
419
|
+
and message.get('event', {}).get('kind') == 'committee.command.rejected')
|
|
420
|
+
afirma('· an illegal early decision is rejected and visibly explained',
|
|
421
|
+
blocked.returncode != 0 and 'pending floor request' in blocked.stderr
|
|
422
|
+
and 'pending floor request' in json.dumps(rejected_event),
|
|
423
|
+
blocked.stderr.strip())
|
|
424
|
+
command(env, 'seat', 'floor-deny', thread, {
|
|
425
|
+
'requestId': web_request_id,
|
|
426
|
+
'reason': 'the verifier will reproduce that exact cache trace',
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
command(env, 'seat', 'decide', thread, {
|
|
430
|
+
'outcome': 'ship after clean rollback',
|
|
431
|
+
'rationale': 'migration evidence is green and cache risk is assigned',
|
|
432
|
+
'owner': 'alice', 'executor': 'api', 'verifier': 'ops',
|
|
433
|
+
'verificationQuestion': 'do clean rollback and cache checks pass?',
|
|
434
|
+
'selectedEvidence': ['run 482', 'web rollback e2e'],
|
|
435
|
+
'decisiveContributors': ['api', 'web'],
|
|
436
|
+
'dissent': ['web retains a cache concern'], 'reopenIf': ['cache check fails'],
|
|
437
|
+
})
|
|
438
|
+
command(env, 'ops', 'verify', thread, {
|
|
439
|
+
'result': 'pass', 'evidence': ['clean replay 483'],
|
|
440
|
+
'checks': ['rollback', 'CDN cache'], 'residualRisks': [],
|
|
441
|
+
})
|
|
442
|
+
command(env, 'seat', 'close', thread, {
|
|
443
|
+
'summary': 'Release authorised after independent verification',
|
|
444
|
+
'learnings': ['cache belongs in the rollback fixture'], 'followups': [],
|
|
445
|
+
})
|
|
446
|
+
closed = watcher.wait(lambda message: message.get('type') == 'activity.event'
|
|
447
|
+
and message.get('event', {}).get('kind') == 'committee.closed')
|
|
448
|
+
afirma('· the happy path reaches a verified, visible closure',
|
|
449
|
+
'Release authorised' in json.dumps(closed), json.dumps(closed))
|
|
450
|
+
|
|
451
|
+
watcher.send({
|
|
452
|
+
'type': 'command', 'requestId': 'browser-write',
|
|
453
|
+
'command': 'committee.cancel', 'thread': thread,
|
|
454
|
+
'payload': {'reason': 'browser tried to take control'},
|
|
455
|
+
})
|
|
456
|
+
read_only = watcher.wait(lambda message: message.get('type') == 'result'
|
|
457
|
+
and message.get('requestId') == 'browser-write')
|
|
458
|
+
afirma('· the browser WebSocket is a mirror and cannot mutate the committee',
|
|
459
|
+
read_only.get('ok') is False
|
|
460
|
+
and 'read-only' in read_only.get('error', ''), json.dumps(read_only))
|
|
461
|
+
|
|
462
|
+
afirma('· a guessed spectator token is refused at the WebSocket handshake',
|
|
463
|
+
rejected(spectator_url(endpoint, 'guessed-token')))
|
|
464
|
+
afirma('· even a valid token is refused from a non-local browser origin',
|
|
465
|
+
rejected(spectator_url(endpoint), 'https://evil.example'))
|
|
466
|
+
|
|
467
|
+
reconnect = Watcher(spectator_url(endpoint))
|
|
468
|
+
restored = reconnect.wait(lambda message: message.get('type') == 'activity.state')
|
|
469
|
+
restored_events = events_from(restored)
|
|
470
|
+
kinds = [event.get('kind') for event in restored_events
|
|
471
|
+
if event.get('thread') == thread]
|
|
472
|
+
expected = [
|
|
473
|
+
'committee.opened', 'committee.position.submitted',
|
|
474
|
+
'committee.position.submitted', 'committee.positions.revealed',
|
|
475
|
+
'committee.position.revealed', 'committee.position.revealed',
|
|
476
|
+
'committee.synthesis.published', 'committee.floor.requested',
|
|
477
|
+
'committee.floor.granted', 'committee.floor.spoke',
|
|
478
|
+
'committee.floor.requested', 'committee.command.rejected',
|
|
479
|
+
'committee.floor.denied', 'committee.decision.recorded',
|
|
480
|
+
'committee.verification.pass', 'committee.closed',
|
|
481
|
+
]
|
|
482
|
+
afirma('· reconnect restores the ordered conversation without polling',
|
|
483
|
+
ordered(kinds, expected), str(kinds))
|
|
484
|
+
seqs = [event.get('seq') for event in restored_events]
|
|
485
|
+
afirma('· persisted feed sequence is monotonic and duplicate-free',
|
|
486
|
+
seqs == sorted(set(seqs)), str(seqs))
|
|
487
|
+
afirma('· the same semantic feed is durable for browser reloads',
|
|
488
|
+
os.path.isfile(activity_file)
|
|
489
|
+
and 'committee.floor.spoke' in open(activity_file, encoding='utf-8').read())
|
|
490
|
+
diagnostic_file = os.path.join(
|
|
491
|
+
app, '.runtime', 'bus', 'city-live-feed', 'diagnostics.jsonl')
|
|
492
|
+
diagnostic_text = open(diagnostic_file, encoding='utf-8').read()
|
|
493
|
+
afirma('· non-happy failures leave durable diagnostics without credentials',
|
|
494
|
+
'command.rejected' in diagnostic_text
|
|
495
|
+
and endpoint['spectatorToken'] not in diagnostic_text,
|
|
496
|
+
diagnostic_text[-1000:])
|
|
497
|
+
finally:
|
|
498
|
+
if watcher:
|
|
499
|
+
watcher.close()
|
|
500
|
+
if reconnect:
|
|
501
|
+
reconnect.close()
|
|
502
|
+
detiene_hubs_de_ciudad(city)
|
|
503
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
504
|
+
return resumen('live-feed')
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == '__main__':
|
|
508
|
+
sys.exit(main())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Road pairing: a code once, a bounded backlog, an approval that grants little.
|
|
3
|
+
|
|
4
|
+
Runs under a throwaway data dir. The unhappy paths are the product: a repeat
|
|
5
|
+
request does not re-reveal the code, a flood cannot exceed the pending cap, an
|
|
6
|
+
expired code is refused, a wrong code approves nothing, and pending views never
|
|
7
|
+
leak the code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
AQUI = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
RAIZ = os.path.dirname(AQUI)
|
|
17
|
+
sys.path.insert(0, os.path.join(RAIZ, "plugin", "scripts"))
|
|
18
|
+
sys.path.insert(0, AQUI)
|
|
19
|
+
|
|
20
|
+
import pairing # noqa: E402
|
|
21
|
+
from testlib import afirma, comprueba, resumen # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def flujo_feliz():
|
|
25
|
+
base = tempfile.mkdtemp(prefix="agents-city-pair-")
|
|
26
|
+
try:
|
|
27
|
+
r = pairing.solicita(base, "alice/home")
|
|
28
|
+
afirma("first contact reveals a code", r["created"] and "code" in r)
|
|
29
|
+
comprueba("the code uses the unambiguous alphabet and length",
|
|
30
|
+
len(r["code"]), pairing.CODIGO_LARGO)
|
|
31
|
+
afirma("no ambiguous characters in the code",
|
|
32
|
+
not (set(r["code"]) & set("0O1I")))
|
|
33
|
+
again = pairing.solicita(base, "alice/home")
|
|
34
|
+
afirma("re-messaging does NOT reveal a second code",
|
|
35
|
+
not again["created"] and "code" not in again)
|
|
36
|
+
comprueba("but returns the same opaque request id", again["id"], r["id"])
|
|
37
|
+
|
|
38
|
+
afirma("before approval the sender is not granted",
|
|
39
|
+
not pairing.concedido(base, "alice/home"))
|
|
40
|
+
quien = pairing.aprueba(base, r["code"])
|
|
41
|
+
comprueba("approving the code grants exactly that sender", quien, "alice/home")
|
|
42
|
+
afirma("now the sender is granted", pairing.concedido(base, "alice/home"))
|
|
43
|
+
afirma("and the pending request is gone",
|
|
44
|
+
all(p["address"] != "alice/home" for p in pairing.pendientes(base)))
|
|
45
|
+
finally:
|
|
46
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def limites_y_errores():
|
|
50
|
+
base = tempfile.mkdtemp(prefix="agents-city-pair-")
|
|
51
|
+
try:
|
|
52
|
+
for i in range(pairing.PENDIENTE_MAX):
|
|
53
|
+
afirma(f"pending {i} accepted", pairing.solicita(base, f"c{i}/home")["created"])
|
|
54
|
+
lleno = pairing.solicita(base, "overflow/home")
|
|
55
|
+
afirma("past the cap, no new pending is minted", lleno.get("full"))
|
|
56
|
+
comprueba("the pending list is capped", len(pairing.pendientes(base)),
|
|
57
|
+
pairing.PENDIENTE_MAX)
|
|
58
|
+
|
|
59
|
+
afirma("a wrong code approves nothing", pairing.aprueba(base, "ZZZZZZZZ") is None)
|
|
60
|
+
# Malformed input returns None (no-op), never crashes the handler.
|
|
61
|
+
afirma("a None code approves nothing (no crash)", pairing.aprueba(base, None) is None)
|
|
62
|
+
afirma("a non-ASCII code approves nothing (no crash)",
|
|
63
|
+
pairing.aprueba(base, "café1234") is None)
|
|
64
|
+
afirma("a non-ASCII opaque id approves nothing (no crash)",
|
|
65
|
+
pairing.aprueba_por_id(base, "pr_café") is None)
|
|
66
|
+
|
|
67
|
+
# Expiry: reach past the TTL by rewriting the stored timestamp.
|
|
68
|
+
objeto = pairing._lee(base)
|
|
69
|
+
for p in objeto["pending"].values():
|
|
70
|
+
p["ts"] = p["ts"] - pairing.PENDIENTE_TTL_MS - 1
|
|
71
|
+
pairing._escribe(base, objeto)
|
|
72
|
+
codigo_viejo = objeto["pending"]["c0/home"]["code"]
|
|
73
|
+
afirma("an expired code is refused", pairing.aprueba(base, codigo_viejo) is None)
|
|
74
|
+
comprueba("expiry sweeps the pending list empty", len(pairing.pendientes(base)), 0)
|
|
75
|
+
finally:
|
|
76
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def no_filtra_ni_amplia():
|
|
80
|
+
base = tempfile.mkdtemp(prefix="agents-city-pair-")
|
|
81
|
+
try:
|
|
82
|
+
r = pairing.solicita(base, "bob/research")
|
|
83
|
+
vista = pairing.pendientes(base)
|
|
84
|
+
afirma("the pending view carries the opaque id, never the code",
|
|
85
|
+
vista and vista[0]["id"] == r["id"]
|
|
86
|
+
and all(r["code"] not in str(v) for v in vista))
|
|
87
|
+
afirma("approving by opaque id works without ever handling the code",
|
|
88
|
+
pairing.aprueba_por_id(base, r["id"]) == "bob/research")
|
|
89
|
+
afirma("revoke withdraws the grant", pairing.revoca(base, "bob/research"))
|
|
90
|
+
afirma("and after revoke the sender is not granted",
|
|
91
|
+
not pairing.concedido(base, "bob/research"))
|
|
92
|
+
finally:
|
|
93
|
+
shutil.rmtree(base, ignore_errors=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
flujo_feliz()
|
|
97
|
+
limites_y_errores()
|
|
98
|
+
no_filtra_ni_amplia()
|
|
99
|
+
sys.exit(resumen("pairing"))
|