Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b0e5e8d9 | ||
|
|
1f2d17932f | ||
|
|
cd009b766b | ||
|
|
7deedeb737 | ||
|
|
387562318c | ||
|
|
0d4a477661 | ||
|
|
9bdc72b909 | ||
|
|
a061b048c7 | ||
|
|
c5af34eb82 | ||
|
|
dbba6d26c0 | ||
|
|
d23e1126fd | ||
|
|
2e0e820285 | ||
|
|
59d6212836 | ||
|
|
b46e28e730 | ||
|
|
94b32fcf5a | ||
|
|
0430150f33 | ||
|
|
3c6bc9c92a | ||
|
|
4c8ecaebfe | ||
|
|
5f8dcec1c9 | ||
|
|
021af33835 | ||
|
|
689488c2e3 | ||
|
|
b9a5ac383f | ||
|
|
e55e59661b | ||
|
|
f6b60c159e | ||
|
|
0e3dce34b7 | ||
|
|
34829c48a9 | ||
|
|
a7e4c5bd48 | ||
|
|
f3eaddb132 | ||
|
|
00d2fa13be | ||
|
|
d9cf19f195 | ||
|
|
5d44059623 | ||
|
|
35b8c4bfcf | ||
|
|
e651ccac16 | ||
|
|
4e0bd1d09b | ||
|
|
be92f4ad5f | ||
|
|
0352c8ed9c | ||
|
|
3bb246549c | ||
|
|
6737150a9a | ||
|
|
4f699c714b | ||
|
|
513de4b1e2 | ||
|
|
e198d9cd47 | ||
|
|
f4e5c7c6f3 | ||
|
|
0077dfd590 | ||
|
|
81cd2e1455 | ||
|
|
f24786e5a7 | ||
|
|
63cae5c2d2 | ||
|
|
8346fb65db | ||
|
|
21e1bb6577 | ||
|
|
be2bc30a7c | ||
|
|
3d74a312f2 | ||
|
|
423af9abad | ||
|
|
75cc42fd1c | ||
|
|
362cbed84f | ||
|
|
5b77463ad0 | ||
|
|
edbbfe58e4 | ||
|
|
a80b683f61 | ||
|
|
2031ce3a79 | ||
|
|
42e8ce1b60 | ||
|
|
0550159a9a | ||
|
|
2f52c34f9c | ||
|
|
d41d4a5b69 | ||
|
|
7084a2fbbb | ||
|
|
2546b0c9d0 | ||
|
|
e5d2898fd9 | ||
|
|
10caeb61b4 | ||
|
|
a6aaae65f2 | ||
|
|
c314f3941b | ||
|
|
da0ae02281 | ||
|
|
83890e7958 | ||
|
|
15dae13e49 | ||
|
|
7807ff5601 | ||
|
|
d25df44f21 | ||
|
|
37a05c025f | ||
|
|
775493ebf2 | ||
|
|
3dc761d736 | ||
|
|
604b135add | ||
|
|
2335dcc4ca | ||
|
|
55075bcbbe | ||
|
|
ec595bc567 | ||
|
|
f86fb162f8 | ||
|
|
3bf9cc994c | ||
|
|
3d1f91ecd8 | ||
|
|
da762aee5b | ||
|
|
032c33d55a | ||
|
|
607edfdbcd | ||
|
|
2c0445a308 | ||
|
|
b6c423e358 | ||
|
|
07607bf57a | ||
|
|
50a7448678 | ||
|
|
870ec49f3d | ||
|
|
b7a90b2774 | ||
|
|
493fb3d4e7 | ||
|
|
09954907cf | ||
|
|
08df02b535 | ||
|
|
138d96a1b8 | ||
|
|
cf2092380b | ||
|
|
d8175e0a37 | ||
|
|
97fed1fee2 | ||
|
|
3d892d9633 | ||
|
|
8c0e928789 | ||
|
|
0b52de0236 | ||
|
|
3fbbbbebf5 | ||
|
|
a17d63c10d | ||
|
|
4c41abbb29 | ||
|
|
3fc42b1f1f | ||
|
|
5a65e739a9 | ||
|
|
d4820277af | ||
|
|
d40c8560bb | ||
|
|
7d737f2a53 | ||
|
|
35e07a65ae | ||
|
|
8d3d20eb79 | ||
|
|
0cda941c4c | ||
|
|
385a3a30bd | ||
|
|
16d0f000ea | ||
|
|
f52b5b2366 | ||
|
|
3dd4823e3e | ||
|
|
c8aae3366c | ||
|
|
997630fcb5 | ||
|
|
5d1c6fff5d | ||
|
|
84995c9ac1 | ||
|
|
4a269d65d4 | ||
|
|
768a0c6bf8 | ||
|
|
06417f4243 | ||
|
|
7e41959b01 | ||
|
|
c7d3d11579 | ||
|
|
99bd0d71ac | ||
|
|
3c95187c8e | ||
|
|
32da053f28 | ||
|
|
06ff53615b | ||
|
|
3c50a167c7 | ||
|
|
b62ad51e7c | ||
|
|
cbaae9e800 | ||
|
|
39288fe709 | ||
|
|
f5a426c923 | ||
|
|
0bdf531004 | ||
|
|
59c9eb52eb | ||
|
|
dee04c5c89 | ||
|
|
7cd8035964 | ||
|
|
4ef1b36e45 | ||
|
|
7bbfe0f5a7 | ||
|
|
426f918286 | ||
|
|
322248b98c | ||
|
|
27e52c0ea2 | ||
|
|
5b3f257a78 | ||
|
|
9ff7367f47 | ||
|
|
b2c86b60f5 | ||
|
|
ca6ab46c0a | ||
|
|
9e56b6768f | ||
|
|
df125ac864 | ||
|
|
8f024d9227 | ||
|
|
f4d5827263 | ||
|
|
8511a46d38 | ||
|
|
014080d3e8 | ||
|
|
5f0857d8f8 | ||
|
|
bd5aec5e12 | ||
|
|
7f58503cf6 | ||
|
|
baac10392f | ||
|
|
881c464e08 | ||
|
|
b03f317986 | ||
|
|
0ad40998e1 | ||
|
|
a452df5282 | ||
|
|
959138ac28 | ||
|
|
329a741409 | ||
|
|
a8485d11af | ||
|
|
abfbd61d1d | ||
|
|
cb67e2bbec | ||
|
|
44ad7190cc | ||
|
|
bc11f1111e | ||
|
|
c75b442523 | ||
|
|
6bf39c2ebd | ||
|
|
2351a1e5bb | ||
|
|
6461b9f7eb | ||
|
|
9f6ae73148 | ||
|
|
43bfcd9463 | ||
|
|
71c53613e4 | ||
|
|
d5cccfa026 | ||
|
|
fb5b277927 | ||
|
|
c90e39c018 | ||
|
|
a5d6da77ae | ||
|
|
217f13a582 | ||
|
|
73bd9d78f8 | ||
|
|
5dc7232cda | ||
|
|
9119ebbf3c | ||
|
|
966882d9b7 | ||
|
|
56c8f3770a | ||
|
|
897a9e1280 | ||
|
|
c953754197 | ||
|
|
490acbb1c5 | ||
|
|
da5be4b051 | ||
|
|
678bd10d0d | ||
|
|
a338fc440b | ||
|
|
6fdfc0d3a6 | ||
|
|
1043182671 | ||
|
|
68a28f2eda | ||
|
|
a8af187b99 |
@@ -0,0 +1,30 @@
|
||||
# Build the image from source only — never copy host-built artifacts or deps.
|
||||
#
|
||||
# The web stage runs `npm ci` + `npm run build` itself and the go stage copies
|
||||
# go.mod/cmd/internal explicitly (see Dockerfile), so the host must NOT ship its
|
||||
# own node_modules/dist into the context: at `COPY . .` a host node_modules
|
||||
# clobbers the image's fresh `npm ci` tree and poisons BuildKit's node_modules
|
||||
# cache-mount ("cannot replace directory with file"), which breaks every build.
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
build
|
||||
out
|
||||
.next
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@@ -109,6 +109,22 @@ VITE_WS_RELAY_URL=
|
||||
# WORLDPOP_API_KEY=
|
||||
|
||||
|
||||
# ------ Telemetry (Hanzo Cloud — @hanzo/event) ------
|
||||
|
||||
# Telemetry (pageviews, product events, errors) posts to api.hanzo.ai/v1/event,
|
||||
# fanned server-side into web analytics, product analytics, and error tracking.
|
||||
# Signed-in visitors are attributed by their IAM bearer automatically. To also
|
||||
# accept LOGGED-OUT / anonymous traffic on the fail-closed door, ship a write-only
|
||||
# publishable ingest key (pk_live_…). It is safe to bundle (cannot read, only
|
||||
# ingest). Mint one per org via POST /v1/ingest/keys. Absent → anonymous events
|
||||
# are best-effort (dropped by the door unless it allows unauthenticated ingest).
|
||||
VITE_HANZO_INGEST_KEY=
|
||||
|
||||
# Google Tag Manager container id (marketing tags — GA / ad pixels). Orthogonal
|
||||
# to the telemetry above; no-op until provisioned from KMS.
|
||||
# VITE_GTM_ID=
|
||||
|
||||
|
||||
# ------ Site Configuration ------
|
||||
|
||||
# Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app)
|
||||
|
||||
@@ -27,3 +27,13 @@ data/world-model.json
|
||||
data/world-model.json.tmp
|
||||
data/world-model-history.json.gz
|
||||
data/world-model-history.json.gz.tmp
|
||||
|
||||
# embedded datastore lake + settings (SQLite, WAL mode) — regenerated on boot,
|
||||
# lands here only when WORLD_DATA_DIR points at the repo (local dev)
|
||||
world.db
|
||||
world.db-wal
|
||||
world.db-shm
|
||||
data/world.db
|
||||
data/world.db-wal
|
||||
data/world.db-shm
|
||||
/world
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Hanzo World — agent guide
|
||||
|
||||
Vite + TypeScript SPA (`world-monitor`). Real-time global-intelligence dashboard
|
||||
served at `world.hanzo.ai`, shipped by `hanzo.yml` CI/CD onto the `world`
|
||||
operator Service CR. Same-origin data plane under `/v1/world/*`.
|
||||
|
||||
## Browser control — prefer the Hanzo MCP extension over Playwright
|
||||
|
||||
When the **Hanzo browser MCP** (the `mcp__hanzo__browser` / `mcp__claude-in-chrome__*`
|
||||
tools, backed by the local extension in `~/work/hanzo/extension`) is available,
|
||||
use it by default to drive a real browser for interactive UI work — inspecting
|
||||
the globe, tweaking layers, checking live feeds, capturing screenshots against a
|
||||
running dev server. It talks to a real Chrome/Firefox with the real WebGL/deck.gl
|
||||
context, so what you see is what a user sees.
|
||||
|
||||
Fall back to **Playwright only when** the MCP extension is not connected, or for
|
||||
the deterministic **e2e suite** (`e2e/*.spec.ts`) — those run headless in CI with
|
||||
mocked `/v1/world/cloud/*` feeds and must stay reproducible offline. E2e is not
|
||||
interactive editing; keep the two lanes separate.
|
||||
|
||||
Order of preference for "look at / poke the UI": Hanzo MCP browser → Playwright.
|
||||
|
||||
## The map / globe
|
||||
|
||||
- Basemaps: `dark` · `dot` · `satellite` · `terrain`. **`dot` is the default**
|
||||
for every variant (`DEFAULT_BASEMAP_STYLE` in `src/config/variant.ts`) — the
|
||||
Kaspersky-style cybermap: land drawn only as a glowing dot-lattice over a black
|
||||
ocean sphere, no country fills/borders/imagery.
|
||||
- The lattice is one pure value — `getLandDots()` in `src/services/land-dots.ts` —
|
||||
consumed by BOTH the 2D mercator map (`DeckGLMap`) and the 3D globe
|
||||
(`GlobeNative`). One source, two projections. Don't fork it.
|
||||
- Cloud view layers (default-on, `?variant=cloud`): live request-origin dots +
|
||||
animated traffic arcs (`AnimatedArcLayer`, a travelling pulse advanced on RAF),
|
||||
validator chain-nodes, BYO-GPU rings, datacenter clusters. Feeds are best-effort
|
||||
and degrade to honest empty states — never fabricate volume.
|
||||
- `satellite`/`terrain` need `VITE_MAPBOX_TOKEN` (from KMS `hanzo/deploy/`, never
|
||||
in git); `dark`/`dot` are keyless CartoDB.
|
||||
|
||||
## Release
|
||||
|
||||
Bump `package.json` PATCH (x.y.z → x.y.z+1, never a lazy major), tag `v<version>`,
|
||||
`workflow_dispatch` the `cicd` workflow. The image tag is the version WITHOUT the
|
||||
`v`. CI's "Deploy" step can false-negative while the operator finishes rolling —
|
||||
verify the live version, not just the CI square. Test/doc-only changes need no
|
||||
release (the image is byte-identical).
|
||||
@@ -2,6 +2,90 @@
|
||||
|
||||
All notable changes to World Monitor are documented here.
|
||||
|
||||
## [2.4.43]
|
||||
|
||||
### Added
|
||||
|
||||
- **Finance-terminal alt-asset feeds are live** (`?variant=finance`). The "Art & Collectibles — Auctions" and "Luxury Real Estate" panels now render REAL data instead of "live feed pending":
|
||||
- `GET /v1/world/auctions` — Christie's public auction results (realized **sale totals**: title, inferred category, price + currency, sale date, location, image, link). Sotheby's gates realized prices behind a login (its sale-results API returns `401 Not signed in`), so the honest public major house is the source.
|
||||
- `GET /v1/world/luxury-realestate` — LuxuryEstate.com luxury listings (title, location, price + currency, type, image, link). JamesEdition sits behind a Cloudflare JS challenge that blocks datacenter egress, so it cannot be fetched from the pod.
|
||||
- Both are scraped server-side (`internal/world/handlers_altassets.go`) at most **once an hour** and served from cache to every caller (respectful). On a source failure the last-good copy is served; with no cache yet, an honest empty `{items:[]}` (the panel shows "live feed pending") — **never fabricated data**. The payload attributes its source per feed.
|
||||
|
||||
## [2.4.19]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Moving/resizing a panel no longer shifts the others** (the "when I shift the 3D map it shifts all other components" report). The dashboard now DEFAULTS to the free layout: every panel owns its own {x,y,w,h}, so a drag or resize leaves every sibling exactly where it was — no more grid reflow. The switch is invisible: the current grid arrangement is frozen as the starting geometry (`grid-config.applyDefaultLayout`), and Grid stays one dropdown-click away for anyone who wants snap-to-grid back.
|
||||
- **Panels resize granularly** — pixel-exact width AND height from any edge or corner, instead of jumping between coarse column/row spans.
|
||||
- **Panels can be made much narrower/smaller** ("constrained on min width"): the free-mode floors drop to 96×40px (map 140px), and the grid widget-size slider now reaches a 120px column track (was 140).
|
||||
|
||||
### Changed
|
||||
|
||||
- A panel shown from the Panels menu / "+ Add widget" while in free mode seeds a tidy default slot below the placed panels (never a full-width block under its absolute siblings). Hidden panels no longer persist 0×0 geometry.
|
||||
|
||||
## [2.4.13]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Globe overlays now sit ON the sphere with correct occlusion** (the "map spazzes" report). On the native deck.gl GlobeView, data layers (shared with the 2D map) carried no depth parameters, so the far hemisphere showed through and count badges floated above the limb. GlobeNative now seats every data layer against the depth-writing ocean sphere (test, don't write — no inter-marker z-fight), and the far-side billboard cull (`occludeFarSide`) faces the globe's OWN live camera instead of the parked (frozen) mapbox center — extended to `TextLayer` so a back-side "36" count badge disappears instead of hovering over the planet.
|
||||
- **Terrain striping on the globe**: the draped ESRI imagery tiles each wrote depth, so coplanar neighbours z-fought at the tile seams (horizontal stripes / partial render). Tiles now depth-TEST only; the ocean sphere owns the depth buffer.
|
||||
- **Live request-geo dots kept polling on the 3D globe**: the `/v1/world/cloud/*` poll was gated on `renderPaused`, which the native GlobeView sets on activation — so the realtime dots froze the moment you entered the default 3D view. It now gates on tab visibility, so the dots stay live (and fade) on the globe.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Request-origin → serving-region arcs on by default** in the Cloud view: the `/cloud/traffic` arcs derive from the same real native request-geo points as the dots (origin country → nearest Hanzo region) and degrade to empty — never demo.
|
||||
|
||||
## [2.4.6]
|
||||
|
||||
### Added
|
||||
|
||||
- **Enso Flywheel panel (AI variant)**: the router self-improvement loop made visible. New `/v1/world/enso-training` folds the routing-decision ledger tail + reward tail (`export-routing-ledger` / `export-routing-rewards`, super-admin) into ledger growth, engine-vs-heuristic mix, a routing-confidence histogram, and task/model distributions, alongside the latest enso-bench eval scores (an embedded `results/summary.json` snapshot, overridable live via `ENSO_BENCH_URL`). The response is event-typed so future retrain/deploy milestones slot into the same timeline. Eval scores render even signed-out; `state` (live/partial/demo) says which live sources resolved.
|
||||
|
||||
## [2.4.5]
|
||||
|
||||
### Added
|
||||
|
||||
- **AI Compute panel (AI variant)**: live Hanzo inference-plane telemetry over SSE — tokens/s, requests/s, 24h spend, top models by real spend, and the serving fleet (GPUs, machines online, models served). New `/v1/world/ai-pulse` pushes typed `usage`/`fleet`/`status` frames (EventSource) and answers a plain GET with one JSON snapshot as the poll fallback. Honest "connecting"/"unavailable" states — never a zero dressed up as live; the service bearer stays server-side.
|
||||
|
||||
## [2.4.4]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Cloud Pulse is real when a service token is wired**: `/v1/world/cloud-pulse` now folds MEASURED platform-wide 24h request/token volume from the ClickHouse-backed usage ledger (`get-cloud-usages ?org=all`, super-admin) on top of the live model/node/GPU/region counts — dropping both `demo:true` and `volumeModeled:true`. Top models come from real ledger spend. Without a token, or with a non-admin token, it stays honestly demo/modeled — platform numbers are never faked silently. The service bearer stays server-side (never sent to the browser).
|
||||
|
||||
## [2.4.2]
|
||||
|
||||
### Added
|
||||
|
||||
- **Streaming analyst**: answers flow in live over SSE — reasoning shows as dim thinking text, tool calls appear as chips the moment they run, the reply types itself in; the final render and command dispatch are unchanged (done event = the old JSON contract)
|
||||
- **Model menu**: the composer pill opens a grouped popover (Auto / Zen / GPT / Llama / Claude / Agents) with per-family marks and an active check
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Model identity**: the Zen ring appears only on zen* models — gpt-oss/llama/claude get their own marks on the pill, menu rows, avatars, and the thinking row
|
||||
- **Dev server**: `npm run dev` proxies /v1 to production by default (VITE_DEV_API_PROXY overrides)
|
||||
|
||||
## [2.4.1]
|
||||
|
||||
### Added
|
||||
|
||||
- **Western Pacific cyclones**: cross-agency tropical-cyclone attribution (GDACS + HKO warnings via new `/v1/world/hko-warnings` proxy) with per-agency wind observations, canonical dedup, and map popup detail rows
|
||||
- **China macro snapshot**: `/v1/world/china-macro` — CPI/CLI (OECD), policy rate, USD/CNY (FRED), HKMA context, NBS release calendar + PBoC LPR dates, surfaced with staleness-honest indicator tiles
|
||||
- **Model roster**: `Best (auto)` leads the analyst model picker — the gateway routing alias that always resolves
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI default model**: `zen5` → `best`; a pinned family id goes dark when the inference plane's claim catalog shifts, the routing alias never does
|
||||
- **Server cache is now stale-while-revalidate**: `cachedJSON`/`passthrough` serve stale instantly and refresh in the background (single-flight); GDELT/theater-posture no longer stall requests ~10s on TTL expiry
|
||||
- **GDELT cache warmers**: hot keys (analyst grounding, protests layer) refreshed every ~4min so no user ever eats a cold miss
|
||||
- **Sparkline payloads**: close arrays rounded to 7 significant digits (float32-widening noise stripped, ~40% smaller; scalars untouched)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **News first paint**: panels no longer gate their first DOM write on a 65MB ML sentiment model download — headlines paint immediately, sentiment refines in place
|
||||
- **Analyst grounding snapshot**: context fetches bounded at 2.5s so a cold endpoint can't hold the chat send hostage
|
||||
- **AI errors are honest**: upstream error codes (`insufficient_balance`, `spend_cap_exceeded`, …) surface in the chat instead of a bare `status 402`
|
||||
|
||||
## [2.4.0] - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -37,13 +37,30 @@ ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
|
||||
RUN npm run build
|
||||
|
||||
# ---- go stage: build the static server binary (CGO-free) -----------------
|
||||
FROM golang:1.23-alpine AS gobuild
|
||||
# go 1.26: go.mod requires >= 1.26.4 (github.com/hanzoai/sqlite drop-in). The
|
||||
# binary stays CGO-free — with CGO_ENABLED=0, hanzoai/sqlite selects its vendored
|
||||
# pure-Go engine (zero modernc.org/* in the module graph). That engine gates FTS5
|
||||
# behind the `sqlite_fts5` build tag, which the store's items_fts virtual table
|
||||
# needs — so the build below MUST carry `-tags sqlite_fts5` or Open degrades.
|
||||
FROM golang:1.26-alpine AS gobuild
|
||||
WORKDIR /src
|
||||
# stdlib-only module: go.mod has no requires, so there is no go.sum to copy.
|
||||
COPY go.mod ./
|
||||
# git: go resolves the PRIVATE indirect dep github.com/hanzoai/csqlite 'direct'
|
||||
# (not via the module proxy), which needs the git binary + an https credential.
|
||||
# alpine ships neither, so add git and mount the gh_token BuildKit secret that
|
||||
# hanzoai/ci passes (--secret id=gh_token); the mount is a no-op for public builds.
|
||||
RUN apk add --no-cache git
|
||||
ENV GOPRIVATE=github.com/hanzoai,github.com/luxfi,github.com/zooai
|
||||
# Deps: hanzo-kv client (go-redis) + embedded SQLite (modernc). Download once for
|
||||
# a cached layer before the source is copied.
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=secret,id=gh_token \
|
||||
if [ -s /run/secrets/gh_token ]; then \
|
||||
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
|
||||
fi; \
|
||||
go mod download
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/world ./cmd/world
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -tags sqlite_fts5 -trimpath -ldflags="-s -w" -o /out/world ./cmd/world
|
||||
|
||||
# ---- final stage: minimal image running the Go binary --------------------
|
||||
FROM alpine:3.20
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Elie Habib
|
||||
Copyright (c) 2026 Hanzo AI
|
||||
Copyright (c) 2026 Hanzo AI, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
World Monitor — Hanzo fork
|
||||
Copyright (c) 2026 Hanzo AI. Licensed under the MIT License (see LICENSE).
|
||||
Hanzo World — Hanzo fork
|
||||
Copyright (c) 2026 Hanzo AI, Inc. Licensed under the MIT License (see LICENSE).
|
||||
|
||||
Forked from koala73/worldmonitor (https://github.com/koala73/worldmonitor) at
|
||||
v2.4.0 (commit 572f3808) — the last release under the MIT License. This fork
|
||||
tracks only the MIT-era code and develops forward independently.
|
||||
|
||||
Original work © 2024-2026 Elie Habib, used under the MIT License.
|
||||
|
||||
DEVIATIONS
|
||||
Changes made by Hanzo AI, Inc. relative to the upstream v2.4.0 baseline:
|
||||
|
||||
- Rebranded the product to "Hanzo World": index.html <title>/meta/OpenGraph,
|
||||
the vite.config full variant (siteName "Hanzo World"), and canonical host
|
||||
world.hanzo.ai.
|
||||
- Added a single Go binary (cmd/world, internal/world; go.mod module
|
||||
github.com/hanzoai/world) that serves the Vite SPA with SPA fallback AND
|
||||
ports each upstream Vercel edge function to same-origin /v1/world/* (and
|
||||
legacy /api/*) in Go. Upstream shipped a Vite SPA plus Vercel edge functions.
|
||||
- Added Hanzo deploy variants beyond upstream full/tech/finance:
|
||||
src/config/variants/saas.ts (Hanzo Cloud platform metrics/usage), ai.ts, and
|
||||
crypto.ts, with matching vite.config entries.
|
||||
- Added Hanzo CI/CD manifest hanzo.yml: image ghcr.io/hanzoai/world built via
|
||||
hanzoai/ci, rolled onto the "world" operator Service CR on DOKS
|
||||
do-sfo3-hanzo-k8s, with secrets fetched from KMS (kms.hanzo.ai).
|
||||
- Reworked Dockerfile to build the Go binary serving the SPA plus the API
|
||||
endpoints (replacing the static-only image), built on Hanzo hardware
|
||||
(platform.hanzo.ai / on-cluster BuildKit) rather than GitHub builders.
|
||||
- Rebranded package.json (name @hanzo/world, added description) and the
|
||||
primary Tauri desktop app (productName and window title "Hanzo World").
|
||||
- Added Copyright (c) 2026 Hanzo AI, Inc. to LICENSE alongside the original.
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="world" width="880"></p>
|
||||
|
||||
# World Monitor
|
||||
# Hanzo World
|
||||
|
||||
**Real-time global intelligence dashboard** — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface.
|
||||
|
||||
[](https://github.com/koala73/worldmonitor/stargazers)
|
||||
[](https://github.com/koala73/worldmonitor/network/members)
|
||||
> Hanzo World is Hanzo AI, Inc.'s fork of [World Monitor](https://github.com/koala73/worldmonitor) (MIT) by Elie Habib. See [NOTICE](./NOTICE) for provenance and deviations.
|
||||
|
||||
[](https://github.com/hanzoai/world/stargazers)
|
||||
[](https://github.com/hanzoai/world/network/members)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://github.com/koala73/worldmonitor/commits/main)
|
||||
[](https://github.com/koala73/worldmonitor/releases/latest)
|
||||
[](https://github.com/hanzoai/world/commits/main)
|
||||
[](https://github.com/hanzoai/world/releases/latest)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://worldmonitor.app"><img src="https://img.shields.io/badge/Web_App-worldmonitor.app-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>
|
||||
<a href="https://world.hanzo.ai"><img src="https://img.shields.io/badge/Web_App-world.hanzo.ai-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>
|
||||
<a href="https://tech.worldmonitor.app"><img src="https://img.shields.io/badge/Tech_Variant-tech.worldmonitor.app-0891b2?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Tech Variant"></a>
|
||||
<a href="https://finance.worldmonitor.app"><img src="https://img.shields.io/badge/Finance_Variant-finance.worldmonitor.app-059669?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Finance Variant"></a>
|
||||
</p>
|
||||
@@ -25,14 +27,14 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="./docs/DOCUMENTATION.md"><strong>Full Documentation</strong></a> ·
|
||||
<a href="https://github.com/koala73/worldmonitor/releases/latest"><strong>All Releases</strong></a>
|
||||
<a href="https://github.com/hanzoai/world/releases/latest"><strong>All Releases</strong></a>
|
||||
</p>
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Why World Monitor?
|
||||
## Why Hanzo World?
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
@@ -1092,7 +1094,7 @@ See [full roadmap](./docs/DOCUMENTATION.md#roadmap).
|
||||
|
||||
## Support the Project
|
||||
|
||||
If you find World Monitor useful:
|
||||
If you find Hanzo World useful:
|
||||
|
||||
- **Star this repo** to help others discover it
|
||||
- **Share** with colleagues interested in OSINT
|
||||
@@ -1107,9 +1109,9 @@ MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
## Author
|
||||
## Authors
|
||||
|
||||
**Elie Habib** — [GitHub](https://github.com/koala73)
|
||||
**Hanzo World** is maintained by **Hanzo AI, Inc.** — fork of the original **World Monitor** by **Elie Habib** ([GitHub](https://github.com/koala73)), used under the MIT License. See [NOTICE](./NOTICE).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Low-end laptop profiler for world.hanzo.ai
|
||||
// Usage: node profile.mjs <url> <label> [cpuThrottle=6]
|
||||
// Measures under Chrome DevTools CPU throttling via CDP.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = process.argv[2] || 'https://world.hanzo.ai/';
|
||||
const label = process.argv[3] || 'run';
|
||||
const throttle = Number(process.argv[4] || 6);
|
||||
|
||||
const browser = await chromium.launch({ args: ['--enable-precise-memory-info'] });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Track network transfer sizes by type
|
||||
const transfer = { js: 0, css: 0, other: 0, jsCount: 0, eagerJs: 0 };
|
||||
const seen = new Set();
|
||||
page.on('response', async (resp) => {
|
||||
try {
|
||||
const u = resp.url();
|
||||
if (seen.has(u)) return; seen.add(u);
|
||||
const hdr = resp.headers();
|
||||
const enc = hdr['content-encoding'] || '';
|
||||
const len = Number(hdr['content-length'] || 0);
|
||||
const ct = hdr['content-type'] || '';
|
||||
let bytes = len;
|
||||
if (!bytes) { try { bytes = (await resp.body()).length; } catch { bytes = 0; } }
|
||||
if (/javascript/.test(ct) || u.endsWith('.js')) { transfer.js += bytes; transfer.jsCount++; }
|
||||
else if (/css/.test(ct) || u.endsWith('.css')) { transfer.css += bytes; }
|
||||
else transfer.other += bytes;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const client = await context.newCDPSession(page);
|
||||
await client.send('Emulation.setCPUThrottlingRate', { rate: throttle });
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.goto(url, { waitUntil: 'commit', timeout: 120000 });
|
||||
|
||||
// Inject longtask + paint observers ASAP
|
||||
await page.addInitScript(() => {
|
||||
window.__lt = { total: 0, count: 0, max: 0 };
|
||||
try {
|
||||
new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__lt.total += e.duration; window.__lt.count++; window.__lt.max = Math.max(window.__lt.max, e.duration); } }).observe({ entryTypes: ['longtask'] });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Wait for the app shell / map container to exist and network to settle a bit
|
||||
let ttiApprox = null;
|
||||
try {
|
||||
await page.waitForSelector('#app .header, #app .main-content, #mapContainer', { timeout: 60000 });
|
||||
ttiApprox = Date.now() - t0;
|
||||
} catch {}
|
||||
|
||||
// Let it run to steady state under throttle
|
||||
await page.waitForTimeout(9000);
|
||||
|
||||
// Paint + nav timings
|
||||
const timings = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0] || {};
|
||||
const fcp = (performance.getEntriesByType('paint').find(p => p.name === 'first-contentful-paint') || {}).startTime || null;
|
||||
return {
|
||||
domContentLoaded: nav.domContentLoadedEventEnd || null,
|
||||
loadEvent: nav.loadEventEnd || null,
|
||||
fcp,
|
||||
longtasks: window.__lt || null,
|
||||
scripts: performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script' || /\.js(\?|$)/.test(r.name)).length,
|
||||
};
|
||||
});
|
||||
|
||||
// FPS sample over 4s (rAF frame deltas) — the idle/spin steady-state frame cost
|
||||
const fps = await page.evaluate(() => new Promise((resolve) => {
|
||||
const deltas = []; let last = performance.now(); let frames = 0; const start = last;
|
||||
function tick(now) { deltas.push(now - last); last = now; frames++; if (now - start < 4000) requestAnimationFrame(tick); else {
|
||||
deltas.sort((a,b)=>a-b);
|
||||
const avg = deltas.reduce((a,b)=>a+b,0)/deltas.length;
|
||||
const p95 = deltas[Math.floor(deltas.length*0.95)] || avg;
|
||||
resolve({ fps: +(1000/avg).toFixed(1), avgFrameMs: +avg.toFixed(1), p95FrameMs: +p95.toFixed(1), frames });
|
||||
} }
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
|
||||
const out = {
|
||||
label, url, cpuThrottle: throttle,
|
||||
transferKB: { js: Math.round(transfer.js/1024), css: Math.round(transfer.css/1024), jsFiles: transfer.jsCount },
|
||||
fcpMs: timings.fcp ? Math.round(timings.fcp) : null,
|
||||
domContentLoadedMs: timings.domContentLoaded ? Math.round(timings.domContentLoaded) : null,
|
||||
loadEventMs: timings.loadEvent ? Math.round(timings.loadEvent) : null,
|
||||
shellVisibleMs: ttiApprox,
|
||||
longTasks: timings.longtasks,
|
||||
steadyState: fps,
|
||||
};
|
||||
console.log('PROFILE_JSON ' + JSON.stringify(out));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"metaTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"audience": {
|
||||
"type": "string"
|
||||
},
|
||||
"pubDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modifiedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"authorUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"authorBio": {
|
||||
"type": "string"
|
||||
},
|
||||
"heroImage": {
|
||||
"type": "string"
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"description",
|
||||
"metaTitle",
|
||||
"keywords",
|
||||
"audience",
|
||||
"pubDate"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
@@ -0,0 +1,163 @@
|
||||
declare module 'astro:content' {
|
||||
export interface RenderResult {
|
||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
remarkPluginFrontmatter: Record<string, any>;
|
||||
}
|
||||
interface Render {
|
||||
'.md': Promise<RenderResult>;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
html: string;
|
||||
metadata?: {
|
||||
imagePaths: Array<string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||
|
||||
export type CollectionKey = keyof DataEntryMap;
|
||||
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
|
||||
|
||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||
|
||||
export type ReferenceDataEntry<
|
||||
C extends CollectionKey,
|
||||
E extends keyof DataEntryMap[C] = string,
|
||||
> = {
|
||||
collection: C;
|
||||
id: E;
|
||||
};
|
||||
|
||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||
collection: C;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||
): Promise<E[]>;
|
||||
export function getCollection<C extends keyof DataEntryMap>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter?: LiveLoaderCollectionFilterType<C>,
|
||||
): Promise<
|
||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||
>;
|
||||
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
entry: ReferenceDataEntry<C, E>,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
id: E,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? string extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]> | undefined
|
||||
: Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter: string | LiveLoaderEntryFilterType<C>,
|
||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||
|
||||
/** Resolve an array of entry references from the same collection */
|
||||
export function getEntries<C extends keyof DataEntryMap>(
|
||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function render<C extends keyof DataEntryMap>(
|
||||
entry: DataEntryMap[C][string],
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function reference<
|
||||
C extends
|
||||
| keyof DataEntryMap
|
||||
// Allow generic `string` to avoid excessive type errors in the config
|
||||
// if `dev` is not running to update as you edit.
|
||||
// Invalid collection names will be caught at build time.
|
||||
| (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodPipe<
|
||||
import('astro/zod').ZodString,
|
||||
import('astro/zod').ZodTransform<
|
||||
C extends keyof DataEntryMap
|
||||
? {
|
||||
collection: C;
|
||||
id: string;
|
||||
}
|
||||
: never,
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
|
||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
|
||||
type InferLoaderSchema<
|
||||
C extends keyof DataEntryMap,
|
||||
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
|
||||
> = L extends { schema: import('astro/zod').ZodSchema }
|
||||
? import('astro/zod').infer<L['schema']>
|
||||
: any;
|
||||
|
||||
type DataEntryMap = {
|
||||
"blog": Record<string, {
|
||||
id: string;
|
||||
body?: string;
|
||||
collection: "blog";
|
||||
data: InferEntrySchema<"blog">;
|
||||
rendered?: RenderedContent;
|
||||
filePath?: string;
|
||||
}>;
|
||||
|
||||
};
|
||||
|
||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||
infer TData,
|
||||
infer TEntryFilter,
|
||||
infer TCollectionFilter,
|
||||
infer TError
|
||||
>
|
||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||
|
||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||
: import('astro/zod').infer<
|
||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||
>;
|
||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||
LiveContentConfig['collections'][C]['loader']
|
||||
>;
|
||||
|
||||
export type ContentConfig = typeof import("../src/content.config.js");
|
||||
export type LiveContentConfig = never;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/world/internal/world"
|
||||
)
|
||||
|
||||
// TestDataRoutesBeatTheSPACatchAll pins the one thing wiring the two halves
|
||||
// together can get wrong: the SPA is mounted on "/*" AFTER the data plane, and
|
||||
// zip resolves by pattern specificity rather than registration order. A /v1 path
|
||||
// must reach its handler, an unknown /v1/world path must get the JSON 404, and
|
||||
// everything else must still fall through to the shell.
|
||||
func TestDataRoutesBeatTheSPACatchAll(t *testing.T) {
|
||||
root := writeTree(t)
|
||||
srv := world.NewServer()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
app := srv.NewApp()
|
||||
app.All("/*", zip.AdaptNetHTTP(gzipStatic(newSPAHandler(root))))
|
||||
ts := httptest.NewServer(world.Handler(app))
|
||||
defer ts.Close()
|
||||
|
||||
do := func(method, path string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, ts.URL+path, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
t.Run("data route wins over the SPA", func(t *testing.T) {
|
||||
resp, body := do(http.MethodGet, "/v1/world/health")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, `"status":"ok"`) {
|
||||
t.Fatalf("not the health handler: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare /v1/feedback is not shadowed by the SPA", func(t *testing.T) {
|
||||
resp, body := do(http.MethodOptions, "/v1/feedback")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "POST, OPTIONS" {
|
||||
t.Fatalf("Allow-Methods = %q, want %q", got, "POST, OPTIONS")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown data path is the JSON 404, never the shell", func(t *testing.T) {
|
||||
resp, body := do(http.MethodGet, "/v1/world/nope")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if strings.Contains(body, "<!doctype") {
|
||||
t.Fatalf("SPA shell leaked from a data path: %s", body)
|
||||
}
|
||||
if want := `{"error":"Not found: /v1/world/nope"}`; strings.TrimSpace(body) != want {
|
||||
t.Fatalf("body = %s, want %s", body, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("client-routed path still gets the shell", func(t *testing.T) {
|
||||
resp, body := do(http.MethodGet, "/country/US")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "<!doctype") {
|
||||
t.Fatalf("want the SPA shell, got: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static asset still resolves", func(t *testing.T) {
|
||||
resp, body := do(http.MethodGet, "/assets/main-abc123.js")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(body, "export const answer = 42;") {
|
||||
t.Fatal("asset body did not come from disk")
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("Cache-Control = %q, want immutable", resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing asset is a 404, not the shell", func(t *testing.T) {
|
||||
resp, body := do(http.MethodGet, "/assets/gone.js")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if strings.Contains(body, "<!doctype") {
|
||||
t.Fatal("SPA shell leaked from an asset miss")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// gzip on the fly for the static SPA. The hanzoai/static image this binary
|
||||
// replaced compressed responses for us; serving the Vite bundle raw (main.js
|
||||
// 1.5MB, map.js 2.7MB) is the whole reason a cold load felt slow. Restoring
|
||||
// gzip here cuts the JS/CSS wire size ~75%. Paired with immutable cache headers
|
||||
// on hashed assets (see setCacheHeaders), each client pays the transfer once.
|
||||
//
|
||||
// Scope: wraps ONLY the SPA handler, never /v1/world/* — those include SSE /
|
||||
// streaming endpoints that must not be buffered through a gzip.Writer.
|
||||
|
||||
var gzipPool = sync.Pool{
|
||||
New: func() any {
|
||||
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
|
||||
return w
|
||||
},
|
||||
}
|
||||
|
||||
// gzipStatic compresses compressible responses when the client accepts gzip.
|
||||
// Range requests pass through untouched (compressing a byte-range is invalid).
|
||||
func gzipStatic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "" || !acceptsGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
gw := &gzipResponseWriter{ResponseWriter: w}
|
||||
defer gw.close()
|
||||
next.ServeHTTP(gw, r)
|
||||
})
|
||||
}
|
||||
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(strings.SplitN(enc, ";", 2)[0]), "gzip") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compressible reports whether a content type shrinks under gzip. Already-
|
||||
// compressed media (png/jpg/woff2/…) is skipped so we never waste CPU or grow
|
||||
// the payload.
|
||||
func compressible(ct string) bool {
|
||||
ct = strings.ToLower(ct)
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "text/"),
|
||||
strings.Contains(ct, "javascript"),
|
||||
strings.Contains(ct, "json"),
|
||||
strings.Contains(ct, "svg"),
|
||||
strings.Contains(ct, "xml"),
|
||||
strings.Contains(ct, "wasm"),
|
||||
strings.Contains(ct, "manifest"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
decided bool
|
||||
compress bool
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) WriteHeader(status int) {
|
||||
g.decide(status)
|
||||
g.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
if !g.decided {
|
||||
g.decide(http.StatusOK) // FileServer may Write without an explicit WriteHeader
|
||||
}
|
||||
if g.compress {
|
||||
return g.gz.Write(b)
|
||||
}
|
||||
return g.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// decide inspects the headers the wrapped handler set (Content-Type is already
|
||||
// populated by http.ServeContent at this point) and commits to compress-or-not
|
||||
// exactly once.
|
||||
func (g *gzipResponseWriter) decide(status int) {
|
||||
if g.decided {
|
||||
return
|
||||
}
|
||||
g.decided = true
|
||||
h := g.Header()
|
||||
if status == http.StatusOK && h.Get("Content-Encoding") == "" && compressible(h.Get("Content-Type")) {
|
||||
g.compress = true
|
||||
h.Del("Content-Length") // length changes after compression
|
||||
h.Set("Content-Encoding", "gzip")
|
||||
h.Add("Vary", "Accept-Encoding")
|
||||
g.gz = gzipPool.Get().(*gzip.Writer)
|
||||
g.gz.Reset(g.ResponseWriter)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) close() {
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Close()
|
||||
gzipPool.Put(g.gz)
|
||||
g.gz = nil
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
"github.com/hanzoai/world/internal/world"
|
||||
)
|
||||
|
||||
@@ -46,24 +48,22 @@ func main() {
|
||||
world.LoadKMSSecrets(rootCtx)
|
||||
|
||||
srv := world.NewServer()
|
||||
srv.StartModel(rootCtx) // continuously-folded world-state engine
|
||||
mux := http.NewServeMux()
|
||||
srv.Mount(mux) // /v1/world/* routes
|
||||
defer srv.Close() // release hanzo-kv + embedded datastore handles
|
||||
srv.StartModel(rootCtx) // continuously-folded world-state engine
|
||||
srv.StartDatastore(rootCtx) // shared feed warmer + lake write-behind/prune
|
||||
srv.StartFund(rootCtx) // autonomous PAPER-only multi-asset fund brain
|
||||
srv.StartAltAssets(rootCtx) // hourly Christie's auctions + LuxuryEstate warmer
|
||||
|
||||
// Static SPA + fallback handles everything not matched by an /api route.
|
||||
mux.Handle("/", newSPAHandler(*root))
|
||||
app := srv.NewApp() // /v1/world/* + /v1/feedback
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: logRequests(mux),
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
// Static SPA + fallback handles everything not matched by a /v1 route.
|
||||
// gzipStatic wraps ONLY this handler — /v1/world/* keeps its streaming
|
||||
// endpoints unbuffered.
|
||||
app.All("/*", zip.AdaptNetHTTP(gzipStatic(newSPAHandler(*root))))
|
||||
|
||||
go func() {
|
||||
log.Printf("world: serving SPA from %q and /v1/world/* on %s", *root, *addr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
if err := app.Listen(listenAddr(*addr)); err != nil {
|
||||
log.Fatalf("world: server error: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -72,7 +72,17 @@ func main() {
|
||||
log.Printf("world: shutting down")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = httpSrv.Shutdown(ctx)
|
||||
_ = app.ShutdownWithContext(ctx)
|
||||
}
|
||||
|
||||
// listenAddr names the transport for addr. zip picks the wire from the scheme
|
||||
// and a bare address means ZAP, so the plain host:port the CR sets in PORT /
|
||||
// WORLD_ADDR is spelled out as HTTP — the SPA is served to browsers.
|
||||
func listenAddr(addr string) string {
|
||||
if strings.Contains(addr, "://") {
|
||||
return addr
|
||||
}
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
// spaHandler serves files from root and falls back to index.html for any GET
|
||||
@@ -123,6 +133,7 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := os.Stat(full)
|
||||
switch {
|
||||
case err == nil && !info.IsDir():
|
||||
setCacheHeaders(w, rel)
|
||||
h.fileSrv.ServeHTTP(w, r) // real file
|
||||
case errors.Is(err, fs.ErrNotExist) && !hasExt(rel):
|
||||
h.serveIndex(w, r) // client-routed path → SPA shell
|
||||
@@ -147,6 +158,17 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// setCacheHeaders makes Vite's content-hashed bundles cacheable forever while
|
||||
// keeping every unhashed file (favicons, manifest, service worker) revalidated
|
||||
// so SW updates and icon swaps are never stuck behind a stale cache.
|
||||
func setCacheHeaders(w http.ResponseWriter, rel string) {
|
||||
if strings.HasPrefix(rel, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
|
||||
// hasExt reports whether the last path segment has a file extension, used to
|
||||
// distinguish an asset miss (foo.js → 404) from a client route (/country/US →
|
||||
// SPA shell).
|
||||
@@ -155,18 +177,6 @@ func hasExt(rel string) bool {
|
||||
return strings.Contains(base, ".")
|
||||
}
|
||||
|
||||
func logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/world/") {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("api %s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeTree lays out a minimal Vite-style dist for the SPA handler tests.
|
||||
func writeTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A hashed JS bundle large enough that gzip clearly wins.
|
||||
js := strings.Repeat("export const answer = 42;\n", 4000)
|
||||
files := map[string]string{
|
||||
"index.html": "<!doctype html><title>world</title>",
|
||||
"assets/main-abc123.js": js,
|
||||
"assets/main-abc123.css": strings.Repeat(".panel{display:flex}\n", 2000),
|
||||
"favicon.ico": "\x00\x00binary",
|
||||
"manifest.webmanifest": `{"name":"world"}`,
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestStaticGzipAndCache(t *testing.T) {
|
||||
root := writeTree(t)
|
||||
srv := httptest.NewServer(gzipStatic(newSPAHandler(root)))
|
||||
defer srv.Close()
|
||||
|
||||
get := func(path, ae string) *http.Response {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
|
||||
req.Header.Set("Accept-Encoding", ae)
|
||||
resp, err := http.DefaultTransport.RoundTrip(req) // no transparent gunzip
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
rawLen := func(root, name string) int {
|
||||
b, _ := os.ReadFile(filepath.Join(root, name))
|
||||
return len(b)
|
||||
}
|
||||
|
||||
t.Run("hashed js is gzipped, immutable, and smaller on the wire", func(t *testing.T) {
|
||||
resp := get("/assets/main-abc123.js", "gzip")
|
||||
defer resp.Body.Close()
|
||||
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding = %q, want gzip", got)
|
||||
}
|
||||
if got := resp.Header.Get("Cache-Control"); !strings.Contains(got, "immutable") {
|
||||
t.Fatalf("Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary = %q, want Accept-Encoding", resp.Header.Get("Vary"))
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
raw := rawLen(root, "assets/main-abc123.js")
|
||||
if len(body) >= raw {
|
||||
t.Fatalf("gzip wire size %d not smaller than raw %d", len(body), raw)
|
||||
}
|
||||
// And it must actually decode back to the original bytes.
|
||||
zr, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip.NewReader: %v", err)
|
||||
}
|
||||
dec, _ := io.ReadAll(zr)
|
||||
if len(dec) != raw {
|
||||
t.Fatalf("decoded %d bytes, want %d", len(dec), raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no gzip when client does not accept it", func(t *testing.T) {
|
||||
resp := get("/assets/main-abc123.js", "identity")
|
||||
defer resp.Body.Close()
|
||||
if resp.Header.Get("Content-Encoding") != "" {
|
||||
t.Fatalf("unexpected Content-Encoding %q", resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unhashed files revalidate, never immutable", func(t *testing.T) {
|
||||
for _, p := range []string{"/favicon.ico", "/manifest.webmanifest"} {
|
||||
resp := get(p, "gzip")
|
||||
resp.Body.Close()
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Fatalf("%s Cache-Control = %q, want no-cache", p, cc)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already-compressed media is not re-gzipped", func(t *testing.T) {
|
||||
resp := get("/favicon.ico", "gzip")
|
||||
defer resp.Body.Close()
|
||||
if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
t.Fatal("favicon.ico should not be gzipped")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("index shell served with no-cache and gzip", func(t *testing.T) {
|
||||
resp := get("/", "gzip")
|
||||
defer resp.Body.Close()
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Fatalf("index Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
if resp.Header.Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("index should be gzipped, got %q", resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -73,12 +73,12 @@ test.describe('AI analyst control surface', () => {
|
||||
await stubWorldApi(page);
|
||||
await appReady(page);
|
||||
|
||||
await page.click('.ai-dock-fab');
|
||||
const prompt = page.locator('.ai-dock-body .ai-analyst-signedout');
|
||||
await page.click('.hzc-fab');
|
||||
const prompt = page.locator('.hzc-body .hzc-signedout');
|
||||
await expect(prompt).toBeVisible();
|
||||
await expect(prompt.locator('.ai-analyst-signin')).toHaveText(/sign in/i);
|
||||
await expect(prompt.locator('.hzc-signin')).toHaveText(/sign in/i);
|
||||
// No chat composer for anonymous users.
|
||||
await expect(page.locator('.ai-dock-body .ai-analyst-input')).toHaveCount(0);
|
||||
await expect(page.locator('.hzc-body .hzc-input')).toHaveCount(0);
|
||||
|
||||
await page.screenshot({ path: `${SCREENS}/analyst-signedout.png` });
|
||||
});
|
||||
@@ -93,23 +93,28 @@ test.describe('AI analyst control surface', () => {
|
||||
expect(beforeIdx).toBeGreaterThan(1); // markets starts well down the grid
|
||||
await page.screenshot({ path: `${SCREENS}/round-trip-before.png` });
|
||||
|
||||
// Open the analyst dock and confirm the model dropdown populated.
|
||||
await page.click('.ai-dock-fab');
|
||||
const composer = page.locator('.ai-dock-body .ai-analyst-input');
|
||||
// Open the analyst dock and confirm the model picker populated.
|
||||
await page.click('.hzc-fab');
|
||||
const composer = page.locator('.hzc-body .hzc-input');
|
||||
await expect(composer).toBeVisible();
|
||||
const modelSelect = page.locator('.ai-dock-body .ai-analyst-model');
|
||||
await expect(modelSelect).toBeVisible();
|
||||
await expect(modelSelect.locator('option')).toHaveCount(3);
|
||||
await expect(modelSelect).toHaveValue('zen5');
|
||||
// The model picker is now a pill that opens a popover listbox (portaled to
|
||||
// <body>), not a native <select>. Its label reflects the default (zen5)…
|
||||
const modelBtn = page.locator('.hzc-body .hzc-model');
|
||||
await expect(modelBtn).toBeVisible();
|
||||
await expect(modelBtn.locator('.hzc-model-name')).toHaveText('Zen 5');
|
||||
// …and opening it lists all three stubbed models.
|
||||
await modelBtn.click();
|
||||
await expect(page.locator('.hzc-model-menu .hzc-model-opt')).toHaveCount(3);
|
||||
await modelBtn.click(); // close the popover
|
||||
|
||||
// Send a command-style message; the stub returns the move+theme commands.
|
||||
await composer.fill('Move markets to the top and go light');
|
||||
await page.click('.ai-dock-body .ai-analyst-send');
|
||||
await page.click('.hzc-body .hzc-send');
|
||||
|
||||
// The prose reply renders…
|
||||
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('moved Markets');
|
||||
await expect(page.locator('.hzc-body .hzc-row.assistant')).toContainText('moved Markets');
|
||||
// …and the per-command action log shows a ✓ for the executed move.
|
||||
const log = page.locator('.ai-dock-body .ai-analyst-actionlog .ai-analyst-action.ok');
|
||||
const log = page.locator('.hzc-body .hzc-actionlog .hzc-action.ok');
|
||||
await expect(log.first()).toBeVisible();
|
||||
await expect(log.first()).toContainText(/moved markets/i);
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Full app control from the chat widget.
|
||||
*
|
||||
* The analyst's control surface is the app-command registry (services/app-commands.ts)
|
||||
* driven through the AppHost port. Until now it could show/hide/move panels and
|
||||
* touch the map, but it could NOT change the layout mode, add a topic to monitor,
|
||||
* or drive the Watch Queue — so "reconfigure the layout and all widgets from the
|
||||
* chat" was not actually true.
|
||||
*
|
||||
* Each test stubs the analyst to return ONE command and asserts the app really
|
||||
* changed — the command is dispatched through the same path a real model reply
|
||||
* takes. No mocked host, no unit-test stand-in for the DOM.
|
||||
*/
|
||||
|
||||
const SCREENS = 'e2e/screens';
|
||||
|
||||
async function stubAnalyst(page: Page, actions: unknown[], reply = 'Done.'): Promise<void> {
|
||||
await page.route('**/v1/world/**', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes('/v1/world/analyst')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ reply, actions, model: 'zen5', tokens: 0, traces: [] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/v1/world/models')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ models: [{ id: 'zen5', name: 'Zen 5' }], default: 'zen5' }),
|
||||
});
|
||||
}
|
||||
// Monitors: signed-in sync endpoints. Keep them empty + accepting.
|
||||
if (url.includes('/v1/world/monitors')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ monitors: [], matches: [], ok: true }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
|
||||
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
|
||||
});
|
||||
}
|
||||
|
||||
async function appReady(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.panel', { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** Send any message; the stub decides which command comes back. */
|
||||
// Two AnalystChats exist (the in-grid panel and the dock) — they share the SAME
|
||||
// code path, so drive the dock's explicitly rather than matching both.
|
||||
async function ask(page: Page, text: string): Promise<void> {
|
||||
await page.click('.hzc-fab');
|
||||
const dock = page.locator('.hzc-panel');
|
||||
await expect(dock).toBeVisible();
|
||||
const composer = dock.locator('.hzc-input');
|
||||
await expect(composer).toBeVisible();
|
||||
await composer.fill(text);
|
||||
await dock.locator('.hzc-send').click();
|
||||
}
|
||||
|
||||
test.describe('analyst full app control', () => {
|
||||
test('set_layout_mode switches the app into immersive', async ({ page }) => {
|
||||
await signIn(page);
|
||||
await stubAnalyst(page, [{ type: 'set_layout_mode', mode: 'immersive' }]);
|
||||
await appReady(page);
|
||||
|
||||
await expect(page.locator('body')).not.toHaveClass(/immersive/);
|
||||
await ask(page, 'go immersive');
|
||||
|
||||
// The app really entered immersive (body class is the mode's source of truth)…
|
||||
await expect(page.locator('body')).toHaveClass(/immersive/);
|
||||
// …and the dock select agrees — the AI and the UI cannot disagree about mode.
|
||||
await expect(page.locator('#dockModeSelect')).toHaveValue('immersive');
|
||||
await page.screenshot({ path: `${SCREENS}/analyst-immersive.png` });
|
||||
});
|
||||
|
||||
test('add_monitor makes the analyst able to watch a new topic', async ({ page }) => {
|
||||
await signIn(page);
|
||||
await stubAnalyst(page, [{ type: 'add_monitor', keywords: 'nvidia, gpu' }]);
|
||||
await appReady(page);
|
||||
|
||||
await ask(page, 'watch for nvidia and gpu');
|
||||
|
||||
// The monitor list really gained the topic (the panel renders it).
|
||||
const monitors = page.locator('#monitorsList');
|
||||
await expect(monitors).toContainText(/nvidia/i);
|
||||
await expect(monitors).toContainText(/gpu/i);
|
||||
|
||||
// And it persisted through the ONE monitor path (localStorage mirror).
|
||||
const stored = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('worldmonitor-monitors') || '[]'),
|
||||
);
|
||||
expect(JSON.stringify(stored)).toContain('nvidia');
|
||||
});
|
||||
|
||||
test('queue_next advances the Watch Queue', async ({ page }) => {
|
||||
await signIn(page);
|
||||
await stubAnalyst(page, [{ type: 'queue_next' }]);
|
||||
await appReady(page);
|
||||
|
||||
// Seed two items straight into the queue's own store, then let the analyst advance it.
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'hanzo-world-watch-queue',
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{ id: 'a', kind: 'video', title: 'First talk', source: 'X', ref: 'aaaaaaaaaaa', addedAt: 1, status: 'queued' },
|
||||
{ id: 'b', kind: 'video', title: 'Second talk', source: 'X', ref: 'bbbbbbbbbbb', addedAt: 2, status: 'queued' },
|
||||
],
|
||||
currentId: 'a',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForSelector('.panel', { timeout: 30_000 });
|
||||
|
||||
await ask(page, 'next video');
|
||||
|
||||
// 'a' is finished and 'b' is now current — tracked consumption, driven by the AI.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => {
|
||||
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
|
||||
return q.currentId;
|
||||
}),
|
||||
)
|
||||
.toBe('b');
|
||||
const statusOfA = await page.evaluate(() => {
|
||||
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
|
||||
return (q.items || []).find((i: { id: string }) => i.id === 'a')?.status;
|
||||
});
|
||||
expect(statusOfA).toBe('watched');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// SuperAdmin fleet view — the admin-only Cloud console panels on world.hanzo.ai:
|
||||
// Clusters & Nodes (DOKS nodes per cluster) and GPU Queue (gpu-jobs depth + what
|
||||
// each worker serves). These prove the CLIENT gate mirrors the server one: the
|
||||
// panels mount + render live data ONLY for an admin-org owner, and a non-admin
|
||||
// (org-admin of their own tenant) is DENIED — the panels never mount.
|
||||
//
|
||||
// The server independently fail-closes 403 (handlers_cloud_admin_infra_test.go);
|
||||
// here we drive the real app with the IAM userinfo + the /v1/world/cloud/* reads
|
||||
// mocked to their real shapes, so the render + gating are exercised end-to-end.
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const CLUSTERS = {
|
||||
available: true,
|
||||
updatedAt: now,
|
||||
note: 'Live DOKS + BYO clusters from visor.',
|
||||
totals: { clusters: 2, nodes: 5, nodesReady: 4, gpus: 2 },
|
||||
clusters: [
|
||||
{
|
||||
id: 'c-hanzo', name: 'hanzo-k8s', region: 'sfo3', status: 'running', kind: 'managed',
|
||||
nodes: 3, nodesReady: 2, gpus: 2,
|
||||
pools: [{ name: 'pool-gpu', size: 'gpu-l40', count: 2, autoScale: true, minNodes: 1, maxNodes: 4 }],
|
||||
nodeList: [
|
||||
{ id: 'n1', name: 'hanzo-k8s-1', status: 'active', type: 's-8vcpu-16gb', region: 'sfo3', gpu: '' },
|
||||
{ id: 'n2', name: 'hanzo-k8s-2', status: 'active', type: 'gpu-l40', region: 'sfo3', gpu: 'L40S' },
|
||||
{ id: 'n3', name: 'hanzo-k8s-3', status: 'provisioning', type: 'gpu-l40', region: 'sfo3', gpu: 'L40S' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'c-adnexus', name: 'adnexus-k8s', region: 'sfo3', status: 'running', kind: 'managed',
|
||||
nodes: 2, nodesReady: 2, gpus: 0, pools: [],
|
||||
nodeList: [
|
||||
{ id: 'a1', name: 'adnexus-k8s-1', status: 'active', type: 's-4vcpu-8gb', region: 'sfo3', gpu: '' },
|
||||
{ id: 'a2', name: 'adnexus-k8s-2', status: 'active', type: 's-4vcpu-8gb', region: 'sfo3', gpu: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const QUEUE = {
|
||||
available: true,
|
||||
updatedAt: now,
|
||||
note: 'Live GPU job queue (gpu-jobs).',
|
||||
namespace: 'gpu-jobs',
|
||||
depth: { pending: 1, running: 2, done: 1, failed: 1, canceled: 0 },
|
||||
workers: { online: 2, total: 3 },
|
||||
services: [
|
||||
{ service: 'studio', pending: 1, running: 1 },
|
||||
{ service: 'engine', pending: 0, running: 1 },
|
||||
],
|
||||
running: [
|
||||
{ id: 'job-1', type: 'studio.render', service: 'studio', status: 'running', worker: 'evo', model: 'flux.1', attempt: 1, startedAt: now, closedAt: '' },
|
||||
{ id: 'job-2', type: 'engine.serve', service: 'engine', status: 'running', worker: 'spark', model: 'qwen3-32b', attempt: 1, startedAt: now, closedAt: '' },
|
||||
],
|
||||
pending: [
|
||||
{ id: 'job-3', type: 'studio.render', service: 'studio', status: 'pending', worker: '', model: 'flux.1', attempt: 0, startedAt: '', closedAt: '' },
|
||||
],
|
||||
recent: [
|
||||
{ id: 'job-4', type: 'echo', service: 'echo', status: 'done', worker: '', model: '', attempt: 1, startedAt: '', closedAt: now },
|
||||
{ id: 'job-5', type: 'studio.render', service: 'studio', status: 'failed', worker: '', model: '', attempt: 2, startedAt: '', closedAt: now },
|
||||
],
|
||||
};
|
||||
|
||||
// Seed a live (non-expired) IAM session so getToken()/isAuthenticated() resolve
|
||||
// without a redirect, then let the userinfo route decide the owner (admin vs not).
|
||||
async function seedSession(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('hanzo_iam_access_token', 'e2e-token');
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000));
|
||||
});
|
||||
}
|
||||
|
||||
async function mockUserinfo(page: import('@playwright/test').Page, owner: string): Promise<void> {
|
||||
await page.route('**/v1/iam/oauth/userinfo', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ sub: 'z', owner, email: 'z@hanzo.ai', name: 'Z' }) }),
|
||||
);
|
||||
}
|
||||
|
||||
async function mockFleetReads(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.route('**/v1/world/cloud/clusters', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CLUSTERS) }));
|
||||
await page.route('**/v1/world/cloud/queue', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(QUEUE) }));
|
||||
}
|
||||
|
||||
test.describe('SuperAdmin fleet view', () => {
|
||||
test('admin sees Clusters & Nodes + GPU Queue with live data', async ({ page }) => {
|
||||
await seedSession(page);
|
||||
await mockUserinfo(page, 'admin');
|
||||
await mockFleetReads(page);
|
||||
|
||||
await page.goto('/?variant=cloud');
|
||||
|
||||
// Clusters panel mounts only for an admin owner, and renders the real DOKS
|
||||
// clusters grouped by cluster with their node status.
|
||||
const clusters = page.locator('#panelsGrid [data-panel="cloud-clusters"]');
|
||||
await expect(clusters).toBeVisible({ timeout: 30000 });
|
||||
await expect(clusters).toContainText('hanzo-k8s');
|
||||
await expect(clusters).toContainText('adnexus-k8s');
|
||||
await expect(clusters).toContainText('hanzo-k8s-2'); // a node row
|
||||
await expect(clusters).toContainText('L40S'); // GPU node spec
|
||||
await expect(clusters.locator('.cloud-cluster-group')).toHaveCount(2);
|
||||
|
||||
// Queue panel: depth + what's running, from which service, on which worker.
|
||||
const queue = page.locator('#panelsGrid [data-panel="cloud-queue"]');
|
||||
await expect(queue).toBeVisible();
|
||||
await expect(queue).toContainText('gpu-jobs');
|
||||
await expect(queue).toContainText('studio.render');
|
||||
await expect(queue).toContainText('engine.serve');
|
||||
await expect(queue).toContainText('spark'); // claiming worker
|
||||
await expect(queue).toContainText('qwen3-32b'); // the model that worker serves
|
||||
await expect(queue.locator('.cloud-queue-job').first()).toBeVisible();
|
||||
|
||||
await clusters.scrollIntoViewIfNeeded();
|
||||
await clusters.screenshot({ path: 'e2e-shots/superadmin-clusters.png' });
|
||||
await queue.scrollIntoViewIfNeeded();
|
||||
await queue.screenshot({ path: 'e2e-shots/superadmin-queue.png' });
|
||||
});
|
||||
|
||||
test('non-admin (org-admin only) is DENIED — panels never mount', async ({ page }) => {
|
||||
await seedSession(page);
|
||||
await mockUserinfo(page, 'acme'); // a real tenant, NOT the reserved admin org
|
||||
await mockFleetReads(page); // even with data mocked, the gate must hold
|
||||
|
||||
await page.goto('/?variant=cloud');
|
||||
|
||||
// The always-mounted cloud overview confirms the grid built for this signed-in
|
||||
// non-admin, so the absence of the admin panels below is a real deny, not a race.
|
||||
await expect(page.locator('#panelsGrid [data-panel="cloud-overview"]')).toBeVisible({ timeout: 30000 });
|
||||
await page.waitForTimeout(1500); // let any async admin-mount attempt resolve
|
||||
|
||||
await expect(page.locator('#panelsGrid [data-panel="cloud-clusters"]')).toHaveCount(0);
|
||||
await expect(page.locator('#panelsGrid [data-panel="cloud-queue"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { clickMapControl } from './helpers/map-controls';
|
||||
|
||||
// Cloud mode: on world.hanzo.ai (and local dev — a hanzo brand host) the H logo is a
|
||||
// toggle that reveals the [Hanzo | World | AI | Crypto | Finance | Tech] switcher,
|
||||
// and the flagship `hanzo` view ships the live-traffic globe layer. This spec drives
|
||||
// the real header + map on the main app, plus the TrafficGlobePanel in isolation.
|
||||
|
||||
const TRAFFIC = {
|
||||
updatedAt: '2026-07-16T12:00:00Z',
|
||||
live: true,
|
||||
window: { minutes: 60, since: '2026-07-16T11:00:00Z', until: '2026-07-16T12:00:00Z' },
|
||||
points: [
|
||||
{ country: 'US', region: 'CA', lat: 36.12, lon: -119.68, count: 42, byService: { chat: 40, media: 2 } },
|
||||
{ country: 'GB', lat: 55.38, lon: -3.44, count: 12, byService: { models: 12 } },
|
||||
{ country: 'DE', lat: 51.17, lon: 10.45, count: 7, byService: { embeddings: 7 } },
|
||||
],
|
||||
totals: { rps_1m: 0.7, rpm_60m: 0.35, top_countries: [{ country: 'US', count: 42 }, { country: 'GB', count: 12 }, { country: 'DE', count: 7 }] },
|
||||
};
|
||||
|
||||
test.describe('Cloud mode', () => {
|
||||
test('H logo reveals the switcher; cloud view ships the live-traffic globe layer', async ({ page }) => {
|
||||
// Feed the globe layer real points so its poll resolves (avoids a 404 empty state).
|
||||
await page.route('**/v1/world/cloud/traffic-globe', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(TRAFFIC) }),
|
||||
);
|
||||
|
||||
await page.goto('/?variant=cloud');
|
||||
|
||||
// The H logo renders as the Hanzo-mode toggle on this (hanzo brand) host.
|
||||
const hLogo = page.locator('[data-hanzo-toggle]');
|
||||
await expect(hLogo).toBeVisible();
|
||||
|
||||
// Flagship default: the switcher starts collapsed (revealed by the H, not shown
|
||||
// by default), so the clean globe view leads.
|
||||
const switcher = page.locator('.variant-switcher');
|
||||
await expect(switcher).toBeHidden();
|
||||
|
||||
// Click the H → Cloud mode reveals the switcher.
|
||||
await hLogo.click();
|
||||
await expect(page.locator('.header.hanzo-mode')).toHaveCount(1);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(hLogo).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Exactly the [Cloud | World | AI | Crypto | Finance | Tech] tabs, Cloud first.
|
||||
await expect(switcher.locator('.variant-option')).toHaveCount(6);
|
||||
await expect(switcher.locator('.variant-option').first()).toHaveAttribute('data-variant', 'cloud');
|
||||
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(1);
|
||||
|
||||
// Click the H again → collapses.
|
||||
await hLogo.click();
|
||||
await expect(switcher).toBeHidden();
|
||||
|
||||
// The globe's native traffic layer is wired into the layer-toggle system and ON
|
||||
// by default in the hanzo view — i.e. the globe layer is toggleable.
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
const trafficToggle = page.locator('.layer-toggle[data-layer="traffic"]');
|
||||
await expect(trafficToggle).toHaveCount(1);
|
||||
await expect(trafficToggle.locator('input[type="checkbox"]')).toBeChecked();
|
||||
});
|
||||
|
||||
test('TrafficGlobePanel renders live throughput + top origins, and an honest empty state', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
// Live payload → throughput tiles + ranked origin countries.
|
||||
const live = await page.evaluate(async (payload) => {
|
||||
const orig = window.fetch;
|
||||
window.fetch = (async () => ({ ok: true, status: 200, json: async () => payload })) as typeof window.fetch;
|
||||
const { TrafficGlobePanel } = await import('/src/components/TrafficGlobePanel.ts');
|
||||
const panel = new TrafficGlobePanel();
|
||||
const root = panel.getElement();
|
||||
document.body.appendChild(root);
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && root.querySelectorAll('.traffic-row').length === 0) {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
const text = root.textContent ?? '';
|
||||
const rows = root.querySelectorAll('.traffic-row').length;
|
||||
panel.destroy(); root.remove(); window.fetch = orig;
|
||||
return { text, rows };
|
||||
}, TRAFFIC);
|
||||
expect(live.rows).toBe(3);
|
||||
expect(live.text).toContain('US');
|
||||
expect(live.text).toContain('requests / sec');
|
||||
expect(live.text.toLowerCase()).toContain('active regions');
|
||||
|
||||
// Empty payload → honest zero state, never fabricated numbers.
|
||||
const empty = await page.evaluate(async () => {
|
||||
const orig = window.fetch;
|
||||
const EMPTY = { updatedAt: '', live: false, window: { minutes: 60, since: '', until: '' }, points: [], totals: { rps_1m: 0, rpm_60m: 0, top_countries: [] } };
|
||||
window.fetch = (async () => ({ ok: true, status: 200, json: async () => EMPTY })) as typeof window.fetch;
|
||||
const { TrafficGlobePanel } = await import('/src/components/TrafficGlobePanel.ts');
|
||||
const panel = new TrafficGlobePanel();
|
||||
const root = panel.getElement();
|
||||
document.body.appendChild(root);
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && !(root.textContent ?? '').includes('No live traffic')) {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
const text = root.textContent ?? '';
|
||||
panel.destroy(); root.remove(); window.fetch = orig;
|
||||
return text;
|
||||
});
|
||||
expect(empty).toContain('No live traffic yet');
|
||||
});
|
||||
|
||||
test('3D globe settles (no idle-spin flicker) + Cloud-accurate legend, no cables assertion', async ({ page }) => {
|
||||
await page.route('**/v1/world/cloud/traffic-globe', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(TRAFFIC) }),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||||
|
||||
await page.goto('/?variant=cloud');
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
|
||||
// Legend must MATCH what the globe plots — the Cloud data classes, not the
|
||||
// geopolitical default (which would mislabel a traffic dot as "high alert").
|
||||
const legend = page.locator('.deckgl-legend');
|
||||
await expect(legend).toBeVisible();
|
||||
const legendText = (await legend.innerText()).toLowerCase();
|
||||
for (const cls of ['request origin', 'validator node', 'gpu fleet', 'cloud region', 'datacenter']) {
|
||||
expect(legendText).toContain(cls);
|
||||
}
|
||||
for (const geo of ['high alert', 'nuclear', 'elevated']) {
|
||||
expect(legendText).not.toContain(geo);
|
||||
}
|
||||
|
||||
// Switch to the 3D globe (GlobeNative is exposed on window in dev/e2e).
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 20000 })
|
||||
.toBe(true);
|
||||
|
||||
// Idle auto-rotate is OFF, so the globe SETTLES: the camera longitude does not
|
||||
// drift and the deck render loop idles (a few data-sync renders, not ~40/s). A
|
||||
// perpetually-spinning globe re-rasterizes thin vector lines every frame — that is
|
||||
// the shimmer; a settled globe does not.
|
||||
const settled = await page.evaluate(async () => {
|
||||
const g = (window as unknown as {
|
||||
__globeNative: { getCenter: () => { lon: number }; getDeck: () => { setProps: (p: unknown) => void } };
|
||||
}).__globeNative;
|
||||
const c0 = g.getCenter();
|
||||
let renders = 0;
|
||||
g.getDeck().setProps({ onAfterRender: () => { renders++; } });
|
||||
await new Promise((r) => setTimeout(r, 1300));
|
||||
return { lonDelta: Math.abs(g.getCenter().lon - c0.lon), renders };
|
||||
});
|
||||
expect(settled.lonDelta).toBeLessThan(0.001); // no idle auto-rotate drift
|
||||
expect(settled.renders).toBeLessThan(12); // render loop idles (vs ~40/s spinning)
|
||||
|
||||
// The broken cables PathLayer must not assert (cables off in Cloud + path guarded).
|
||||
expect(errors.filter((e) => /cables-layer|assertion failed/i.test(e))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,13 @@ test.describe('right-click context menus', () => {
|
||||
// emits) inside its own panel appended to the grid. A synthetic panel is used
|
||||
// so a live panel's periodic re-render can't wipe the item mid-test, and so it
|
||||
// is not under the map canvas — the menu code path is identical either way.
|
||||
// The app defaults to FREE layout, where a raw injected panel (no coordinates)
|
||||
// stacks at 0,0 under the full-width map and the map eats the right-click; pin
|
||||
// grid mode so the injected panel flows to the grid end as a hit-testable child.
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'),
|
||||
);
|
||||
await page.waitForTimeout(80);
|
||||
await page.evaluate(() => {
|
||||
const grid = document.querySelector('#panelsGrid')!;
|
||||
const panel = document.createElement('div');
|
||||
@@ -151,23 +158,24 @@ test.describe('analyst data-tool traces', () => {
|
||||
});
|
||||
await appReady(page);
|
||||
|
||||
await page.click('.ai-dock-fab');
|
||||
const composer = page.locator('.ai-dock-body .ai-analyst-input');
|
||||
await page.click('.hzc-fab');
|
||||
const composer = page.locator('.hzc-body .hzc-input');
|
||||
await expect(composer).toBeVisible();
|
||||
await composer.fill('What is the state of global instability?');
|
||||
await page.click('.ai-dock-body .ai-analyst-send');
|
||||
await page.click('.hzc-body .hzc-send');
|
||||
|
||||
// The collapsed tool trace renders before the reply that cites it.
|
||||
const trace = page.locator('.ai-dock-body .ai-analyst-tool .ai-analyst-tool-summary');
|
||||
// The tool trace renders before the reply that cites it; its summary carries
|
||||
// the tool call (the redesign shows a database glyph, not the 🔧 emoji).
|
||||
const trace = page.locator('.hzc-body .hzc-tool .hzc-tool-summary');
|
||||
await expect(trace).toBeVisible();
|
||||
await expect(trace).toContainText('🔧 world_brief(');
|
||||
await expect(trace).toContainText('world_brief(');
|
||||
|
||||
// Expanding it reveals the raw result body.
|
||||
await trace.click();
|
||||
await expect(page.locator('.ai-dock-body .ai-analyst-tool-result')).toContainText('instability');
|
||||
// The detail renders open, so the raw result body — a compact table of the
|
||||
// tool's JSON — is visible inline and carries the returned instability field.
|
||||
await expect(page.locator('.hzc-body .hzc-tool .hzc-table')).toContainText('instability');
|
||||
|
||||
// …and the grounded prose reply is shown.
|
||||
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('instability is steady');
|
||||
await expect(page.locator('.hzc-body .hzc-row.assistant')).toContainText('instability is steady');
|
||||
|
||||
await page.screenshot({ path: `${SCREENS}/analyst-tool-trace.png` });
|
||||
});
|
||||
|
||||
@@ -105,6 +105,15 @@ async function appReady(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function openCountry(page: Page, code = 'FR', name = 'France'): Promise<void> {
|
||||
// __app is exposed before init() finishes, but countryBriefPage is constructed
|
||||
// later in init() (after loadAllData) — long after #panelsGrid paints. Wait for
|
||||
// the hook so the open isn't raced to a silent early-return; a real map click
|
||||
// only ever fires post-boot anyway.
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { __app?: { countryBriefPage?: unknown } }).__app?.countryBriefPage),
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.evaluate(
|
||||
([c, n]) => (window as unknown as { __app: { openCountryBriefByCode(c: string, n: string): Promise<void> } }).__app.openCountryBriefByCode(c, n),
|
||||
[code, name],
|
||||
@@ -132,8 +141,14 @@ test.describe('Country application view', () => {
|
||||
// …docked analyst chat on the right (signed-in → composer, model picker present).
|
||||
const sidebar = page.locator('.cb-analyst-sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(sidebar.locator('.ai-analyst-input')).toBeVisible();
|
||||
await expect(sidebar.locator('.ai-analyst-model option')).toHaveCount(2);
|
||||
await expect(sidebar.locator('.hzc-input')).toBeVisible();
|
||||
// Model picker is a pill opening a popover listbox (portaled to <body>); wait
|
||||
// for the roster to paint, then open it to confirm the two stubbed models.
|
||||
const modelBtn = sidebar.locator('.hzc-model');
|
||||
await expect(modelBtn.locator('.hzc-model-name')).toHaveText('Zen 5');
|
||||
await modelBtn.click();
|
||||
await expect(page.locator('.hzc-model-menu .hzc-model-opt')).toHaveCount(2);
|
||||
await modelBtn.click(); // close the popover
|
||||
// Sidebar is on the right of the intel content.
|
||||
const mainBox = await page.locator('.cb-main').boundingBox();
|
||||
const sideBox = await sidebar.boundingBox();
|
||||
@@ -185,7 +200,7 @@ test.describe('Country application view', () => {
|
||||
// Open the bottom sheet.
|
||||
await page.click('.cb-analyst-fab');
|
||||
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
|
||||
await expect(page.locator('.cb-analyst-sidebar .ai-analyst-input')).toBeVisible();
|
||||
await expect(page.locator('.cb-analyst-sidebar .hzc-input')).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENS}/country-view-mobile-open.png` });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
// The signed-in dashboard-sync contract, verified end-to-end against the REAL app:
|
||||
// 1. on boot, this identity's server dashboard is written into localStorage
|
||||
// BEFORE the app reads it (server precedence, cross-device),
|
||||
// 2. a change to a dashboard key is synced to the server (debounced PUT of the
|
||||
// full snapshot),
|
||||
// 3. signed out, nothing ever touches the server (localStorage only).
|
||||
// IAM is faked (a non-expired token + a stubbed userinfo) and /v1/world/dashboard
|
||||
// is stubbed, so no real backend or identity is needed.
|
||||
|
||||
const DASH = '**/v1/world/dashboard';
|
||||
const HIST = '**/v1/world/history';
|
||||
const LN = '[data-panel="live-news"]';
|
||||
|
||||
async function fakeAuth(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('hanzo_iam_access_token', 'faketoken');
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
|
||||
localStorage.setItem('hanzo_iam_owner', 'acme');
|
||||
});
|
||||
// Keep the fake session valid: a userinfo 401 would let the app drop the token,
|
||||
// and the sync (correctly) stops when signed out.
|
||||
await page.route('**/v1/iam/oauth/userinfo', (r) =>
|
||||
r.fulfill({ json: { sub: 'u1', owner: 'acme', email: 'u@acme.test' } }),
|
||||
);
|
||||
// Default empty history so boot's history hydrate never hits the real backend;
|
||||
// tests that assert on history override this with a later page.route (last wins).
|
||||
await page.route(HIST, (r) => r.fulfill({ json: { config: {} } }));
|
||||
}
|
||||
|
||||
test.describe('dashboard sync (real app)', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('boot hydrates localStorage from the server (server precedence)', async ({ page }) => {
|
||||
await fakeAuth(page);
|
||||
await page.route(DASH, async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { config: { 'hanzo-world-map-mode': '3d' } } });
|
||||
} else {
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
// The server value was applied to localStorage at boot, before the app read it.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
|
||||
.toBe('3d');
|
||||
});
|
||||
|
||||
test('a dashboard change is synced to the server (debounced PUT of the snapshot)', async ({ page }) => {
|
||||
await fakeAuth(page);
|
||||
const puts: Array<Record<string, string>> = [];
|
||||
await page.route(DASH, async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({ json: { config: {} } }); // empty server → first run
|
||||
} else {
|
||||
puts.push(route.request().postDataJSON() as Record<string, string>);
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await page.waitForTimeout(2000); // let the boot writes flush their debounced PUT first
|
||||
// Change an observed dashboard key; a debounced PUT carries a snapshot of ALL
|
||||
// dashboard keys, including our change.
|
||||
await page.evaluate(() => localStorage.setItem('worldmonitor-layers', '{"quakes":true}'));
|
||||
await expect
|
||||
.poll(() => puts.some((p) => p && p['worldmonitor-layers'] === '{"quakes":true}'), { timeout: 8000 })
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('signed out: never touches the server (localStorage only)', async ({ page }) => {
|
||||
let hit = false;
|
||||
await page.route(DASH, async (route) => {
|
||||
hit = true;
|
||||
await route.fulfill({ json: { config: {} } });
|
||||
});
|
||||
await page.goto('/'); // no fakeAuth → anonymous
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await page.evaluate(() => localStorage.setItem('worldmonitor-layers', '{"a":1}'));
|
||||
await page.waitForTimeout(1200);
|
||||
expect(hit).toBe(false);
|
||||
});
|
||||
|
||||
test('history keys sync to /v1/world/history, not /dashboard', async ({ page }) => {
|
||||
await fakeAuth(page);
|
||||
const dashPuts: string[] = [];
|
||||
const histPuts: Array<Record<string, string>> = [];
|
||||
await page.route(DASH, async (route) => {
|
||||
if (route.request().method() === 'GET') await route.fulfill({ json: { config: {} } });
|
||||
else {
|
||||
dashPuts.push(route.request().postData() || '');
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
});
|
||||
await page.route(HIST, async (route) => {
|
||||
if (route.request().method() === 'GET') await route.fulfill({ json: { config: {} } });
|
||||
else {
|
||||
histPuts.push(route.request().postDataJSON() as Record<string, string>);
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await page.waitForTimeout(2000); // flush boot writes first
|
||||
|
||||
// A real user action (a recent search) is a HISTORY key → PUT to /history.
|
||||
await page.evaluate(() => localStorage.setItem('worldmonitor_recent_searches', '["nvidia"]'));
|
||||
await expect
|
||||
.poll(() => histPuts.some((p) => p && p['worldmonitor_recent_searches'] === '["nvidia"]'), { timeout: 8000 })
|
||||
.toBe(true);
|
||||
// …and it was NOT mixed into a dashboard PUT (clean namespace separation).
|
||||
expect(dashPuts.some((b) => b.includes('worldmonitor_recent_searches'))).toBe(false);
|
||||
});
|
||||
|
||||
// Org-shared default precedence: the org default is hydrated as the BASE, then the
|
||||
// user's own doc overlays it. One broad route serves both scopes, branching on URL
|
||||
// (the `/shared` GET is the org default; the bare GET is the per-user doc).
|
||||
async function routeDashboardScopes(
|
||||
page: Page,
|
||||
shared: Record<string, string>,
|
||||
user: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await page.route('**/v1/world/dashboard**', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
return;
|
||||
}
|
||||
const isShared = route.request().url().includes('/dashboard/shared');
|
||||
await route.fulfill({ json: { config: isShared ? shared : user } });
|
||||
});
|
||||
}
|
||||
|
||||
test('org default hydrates as the base when the user has no doc yet', async ({ page }) => {
|
||||
await fakeAuth(page);
|
||||
// Org published a default; this member has never saved their own → they get it.
|
||||
await routeDashboardScopes(page, { 'hanzo-world-map-mode': '3d' }, {});
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
|
||||
.toBe('3d');
|
||||
});
|
||||
|
||||
test('the per-user doc overrides the org default (user wins)', async ({ page }) => {
|
||||
await fakeAuth(page);
|
||||
// Org default says 3d, the user's own doc says 2d → the user's choice wins.
|
||||
await routeDashboardScopes(page, { 'hanzo-world-map-mode': '3d' }, { 'hanzo-world-map-mode': '2d' });
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
|
||||
.toBe('2d');
|
||||
});
|
||||
|
||||
test('boot hydrates history from /v1/world/history (server precedence)', async ({ page }) => {
|
||||
await fakeAuth(page);
|
||||
await page.route(DASH, (r) => r.fulfill({ json: { config: {} } }));
|
||||
await page.route(HIST, (r) => r.fulfill({ json: { config: { 'hanzo-world-watch-queue': '{"items":[7]}' } } }));
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-watch-queue')), { timeout: 8000 })
|
||||
.toBe('{"items":[7]}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Enso Live Training must reflect REAL models (the served catalog), never the opaque
|
||||
// upstream "arm-N" routing mix that read as random/meaningless amounts. Both feeds are
|
||||
// stubbed with real-shaped payloads; the router-stats stub deliberately includes an
|
||||
// arm-N by_model map to prove it is NOT rendered anymore.
|
||||
|
||||
const ENSO = '.panel[data-panel="enso-training"]';
|
||||
|
||||
const ROUTER_STATS = {
|
||||
scope: 'platform',
|
||||
window: { since: '2026-07-17T00:00:00Z', until: '2026-07-18T00:00:00Z', events: 12345 },
|
||||
cost: { saved_pct: 0.23, cumulative_saved_index: 98765, baseline_model: 'x', priced_events: 100 },
|
||||
quality: { reward_rate: 0.5, rewarded_events: 10, engine_share: 0.62, avg_confidence: 0.8, shadow_agreement: 0.91 },
|
||||
by_task: {},
|
||||
by_model: { 'arm-1': 100, 'arm-2': 50, 'arm-3': 25 }, // opaque arms — must NOT render
|
||||
throughput: { per_hour: [1, 2, 3, 4, 5, 6], total_window: 21 },
|
||||
retrain: {
|
||||
version: 'v9', trained_time: '2026-07-17T12:00:00Z', events: 100, gate_passed: true,
|
||||
published: true, gate_kind: 'reward', gate_metric: 'rate', gate_value: 0.5, gate_base: 0.4, note: '',
|
||||
},
|
||||
};
|
||||
|
||||
const CLOUD_MODELS = {
|
||||
updatedAt: '2026-07-18T00:00:00Z',
|
||||
totalModels: 42,
|
||||
zenModels: 8,
|
||||
cloudRegions: 5,
|
||||
cloudPlans: 3,
|
||||
families: ['Zen', 'Fable'],
|
||||
models: [
|
||||
{ id: 'zen5', name: 'Zen 5', provider: 'hanzo', tier: 'flagship', context: 200000, inPrice: 3, outPrice: 15 },
|
||||
{ id: 'zen5-flash', name: 'Zen 5 Flash', provider: 'hanzo', tier: 'fast', context: 128000, inPrice: 0.5, outPrice: 1.5 },
|
||||
{ id: 'fable5', name: 'Fable 5', provider: 'hanzo', tier: 'premium', context: 400000, inPrice: 5, outPrice: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
test.describe('Enso Live Training — real models only', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('renders the real served-model catalog, never opaque arm-N amounts', async ({ page }) => {
|
||||
await page.route('**/v1/world/cloud/router-stats*', (r) => r.fulfill({ json: ROUTER_STATS }));
|
||||
await page.route('**/v1/world/cloud/models', (r) => r.fulfill({ json: CLOUD_MODELS }));
|
||||
|
||||
// Enso Live Training is a Cloud-flagship panel (added only in the cloud
|
||||
// variant); ?variant=cloud wins over the build-time VITE_VARIANT at runtime.
|
||||
await page.goto('/?variant=cloud');
|
||||
await page.waitForSelector(ENSO, { timeout: 45000 });
|
||||
const panel = page.locator(ENSO);
|
||||
|
||||
// Real model names from the catalog appear.
|
||||
await expect(panel).toContainText('Zen 5 Flash', { timeout: 15000 });
|
||||
await expect(panel).toContainText('Fable 5');
|
||||
await expect(panel).toContainText('models served');
|
||||
// Real aggregates still render (unchanged, real telemetry).
|
||||
await expect(panel).toContainText('cost saved');
|
||||
// The opaque per-arm amounts are GONE — no meaningless "Enso arm N" rows.
|
||||
await expect(panel).not.toContainText('Enso arm');
|
||||
await expect(panel).not.toContainText('arm-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Mounts the real EnsoTrainingPanel against stubbed router-stats + served-model
|
||||
// catalog payloads and asserts the rendered DOM. The redesign REPLACED the opaque
|
||||
// "Enso arm N" routing mix with the REAL served-model catalog (real names, tiers,
|
||||
// pricing) — this asserts that new surface: real model rows (never opaque arms,
|
||||
// never a third-party vendor name), the cost-saved headline, the retrain gate
|
||||
// verdict, and the honest "—" shadow state. Mirrors the app-level assertions in
|
||||
// e2e/enso-real-models.spec.ts at the runtime-harness (unit) level.
|
||||
test.describe('Enso Live Training panel', () => {
|
||||
test('renders the real served-model catalog, cost-saved headline and retrain gate — no opaque arms, no vendor names', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const ROUTER_STATS = {
|
||||
scope: 'platform',
|
||||
window: { since: '2026-07-15T00:00:00Z', until: '2026-07-16T00:00:00Z', events: 48210 },
|
||||
cost: { saved_pct: 21.5, cumulative_saved_index: 1372, baseline_model: 'arm-1', priced_events: 41000 },
|
||||
quality: { reward_rate: 0.31, rewarded_events: 1200, engine_share: 0.62, avg_confidence: 0.74, shadow_agreement: null },
|
||||
by_task: { chat: { events: 30000, models: { 'arm-1': 18000, 'arm-2': 12000 } } },
|
||||
by_model: { 'arm-1': 30000, 'arm-2': 14000, 'arm-3': 4210 }, // opaque arms — must NOT render
|
||||
throughput: { per_hour: Array.from({ length: 24 }, (_, i) => 1500 + i * 40), total_window: 48210 },
|
||||
retrain: {
|
||||
version: 'router-2026.07.16', trained_time: '2026-07-16T00:00:00Z', events: 48210,
|
||||
gate_passed: true, published: true, gate_kind: 'holdout', gate_metric: 'reward',
|
||||
gate_value: 0.312, gate_base: 0.298, note: 'shipped',
|
||||
},
|
||||
};
|
||||
// The REAL served catalog the router trains across — Hanzo's own models only.
|
||||
const CLOUD_MODELS = {
|
||||
updatedAt: '2026-07-16T00:00:00Z',
|
||||
totalModels: 42,
|
||||
zenModels: 8,
|
||||
cloudRegions: 5,
|
||||
cloudPlans: 3,
|
||||
families: ['Zen', 'Fable'],
|
||||
models: [
|
||||
{ id: 'zen5', name: 'Zen 5', provider: 'hanzo', tier: 'flagship', context: 200000, inPrice: 3, outPrice: 15 },
|
||||
{ id: 'zen5-flash', name: 'Zen 5 Flash', provider: 'hanzo', tier: 'fast', context: 128000, inPrice: 0.5, outPrice: 1.5 },
|
||||
{ id: 'fable5', name: 'Fable 5', provider: 'hanzo', tier: 'premium', context: 400000, inPrice: 5, outPrice: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
// Stub the same-origin proxies the panel calls, by endpoint.
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
const body = url.includes('/cloud/models') ? CLOUD_MODELS : ROUTER_STATS;
|
||||
return { ok: true, status: 200, json: async () => body } as Response;
|
||||
}) as typeof window.fetch;
|
||||
|
||||
const { EnsoTrainingPanel } = await import('/src/components/EnsoTrainingPanel.ts');
|
||||
const panel = new EnsoTrainingPanel();
|
||||
const root = panel.getElement();
|
||||
document.body.appendChild(root);
|
||||
|
||||
// Wait for the async fetch → render to land (real model rows appear).
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && root.querySelectorAll('.cloud-model-row').length === 0) {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
|
||||
const modelNames = Array.from(root.querySelectorAll('.cloud-model-name')).map(
|
||||
(n) => (n.textContent ?? '').trim(),
|
||||
);
|
||||
const text = root.textContent ?? '';
|
||||
|
||||
panel.destroy();
|
||||
root.remove();
|
||||
window.fetch = origFetch;
|
||||
|
||||
return { modelNames, text };
|
||||
});
|
||||
|
||||
// The three REAL served models render by name — never opaque "arm-N".
|
||||
expect(result.modelNames.length).toBe(3);
|
||||
expect(result.modelNames[0]).toContain('Zen 5');
|
||||
expect(result.modelNames[1]).toContain('Zen 5 Flash');
|
||||
expect(result.modelNames[2]).toContain('Fable 5');
|
||||
expect(result.text).toContain('models served');
|
||||
// The opaque per-arm mix is GONE.
|
||||
expect(result.text).not.toContain('Enso arm');
|
||||
expect(result.text).not.toContain('arm-1');
|
||||
// No third-party vendor identity leaks through the served catalog.
|
||||
for (const vendor of ['claude', 'anthropic', 'gpt', 'openai', 'deepseek', 'qwen', 'gemini', 'llama']) {
|
||||
expect(result.text.toLowerCase()).not.toContain(vendor);
|
||||
}
|
||||
|
||||
// Headline cost-saved + retrain gate verdict + honest empty shadow state.
|
||||
expect(result.text).toContain('21.5%');
|
||||
expect(result.text).toContain('passed');
|
||||
expect(result.text).toContain('Shadow-vs-served agreement: —');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ type GlobeHarness = {
|
||||
setCamera: (lon: number, lat: number, zoom: number) => void;
|
||||
stopSpin: () => void;
|
||||
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
|
||||
setBasemapStyle: (style: 'dark' | 'satellite' | 'terrain') => void;
|
||||
getBasemapStyle: () => string;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
@@ -111,4 +113,24 @@ test.describe('native deck.gl GlobeView', () => {
|
||||
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('basemap style switches dark/satellite/terrain, stays single-context, data still picks', async ({
|
||||
page,
|
||||
}) => {
|
||||
await ready(page, '?globe=native');
|
||||
|
||||
const setStyle = (s: 'dark' | 'satellite' | 'terrain') =>
|
||||
page.evaluate((style) => (window as HarnessWindow).__globeHarness!.setBasemapStyle(style), s);
|
||||
const getStyle = () =>
|
||||
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getBasemapStyle());
|
||||
|
||||
for (const style of ['satellite', 'terrain', 'dark'] as const) {
|
||||
await setStyle(style);
|
||||
expect(await getStyle()).toBe(style);
|
||||
// Draping imagery must not spawn a second WebGL context...
|
||||
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
|
||||
// ...and the data layers keep rendering ON TOP (still pickable on the sphere).
|
||||
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 15000 }).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { clickMapControl } from './helpers/map-controls';
|
||||
|
||||
// P0-1: deck.gl overlays must actually RENDER on the mapbox-gl v3 globe.
|
||||
//
|
||||
@@ -110,7 +111,7 @@ test.describe('P0-1 deck overlay renders on the globe', () => {
|
||||
|
||||
// → 3D globe. Deck's active viewport must be a GlobeViewport (proves deck is
|
||||
// reprojecting onto the sphere) and the dot must still pick on the globe.
|
||||
await page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]').click();
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
|
||||
await expect.poll(() => projection(page), { timeout: 30000 }).toBe('globe');
|
||||
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
|
||||
await expect.poll(() => viewportType(page), { timeout: 20000 }).toMatch(/Globe/i);
|
||||
@@ -128,10 +129,10 @@ test.describe('P0-1 deck overlay renders on the globe', () => {
|
||||
await testInfo.attach('globe-3d-dots', { body: shot, contentType: 'image/png' });
|
||||
|
||||
// Round-trip 2D → 3D again; picks must survive each transition.
|
||||
await page.locator('.deckgl-projection-toggle .proj-btn[data-mode="2d"]').click();
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="2d"]');
|
||||
await expect.poll(() => projection(page), { timeout: 20000 }).toBe('mercator');
|
||||
await pollPickFound(page);
|
||||
await page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]').click();
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
|
||||
await expect.poll(() => projection(page), { timeout: 20000 }).toBe('globe');
|
||||
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
|
||||
await pollPickFound(page);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Globe render-fix acceptance suite (world.hanzo.ai Hanzo-Cloud view).
|
||||
//
|
||||
// Guards the three "spazz" bugs the CTO reported and the 2D/3D parity + live-analytics
|
||||
// contract:
|
||||
// 1. Overlays must sit ON the globe surface with correct occlusion — a back-hemisphere
|
||||
// dot/badge is HIDDEN, a front one is drawn (no floating layers above the sphere).
|
||||
// 2. Terrain drapes on the globe in a single WebGL context (no second canvas, no
|
||||
// striping regression — depth-write disabled on the coplanar imagery tiles).
|
||||
// 3. Every cloud data layer mounts in BOTH 2D (mercator) and 3D (globe) — parity.
|
||||
// Plus: the REAL live request-geo dots appear and update.
|
||||
//
|
||||
// Data is mocked at the same-origin /v1/world/cloud/* contracts so the run is
|
||||
// deterministic and offline (production serves the real, live payloads).
|
||||
|
||||
type DeckLayer = { id: string; props: Record<string, unknown> };
|
||||
type Rgba = [number, number, number, number];
|
||||
|
||||
const cloudMap = {
|
||||
// Two request-origin points on OPPOSITE hemispheres so occlusion is testable:
|
||||
// FRONT (lon 0) faces a camera centred at lon 0; BACK (lon 180) is behind the globe.
|
||||
trafficGlobe: {
|
||||
updatedAt: '2026-07-18T12:00:00Z',
|
||||
live: true,
|
||||
window: { minutes: 60, since: '2026-07-18T11:00:00Z', until: '2026-07-18T12:00:00Z' },
|
||||
points: [
|
||||
{ country: 'FR', lat: 20, lon: 0, count: 90, byService: { models: 90 } },
|
||||
{ country: 'US', lat: 37.09, lon: -95.71, count: 42, byService: { models: 42 } },
|
||||
{ country: 'JP', lat: 20, lon: 180, count: 30, byService: { models: 30 } },
|
||||
],
|
||||
totals: { rps_1m: 1.6, rpm_60m: 96, top_countries: [{ country: 'FR', count: 90 }, { country: 'US', count: 42 }, { country: 'JP', count: 30 }] },
|
||||
},
|
||||
traffic: {
|
||||
updatedAt: '2026-07-18T12:00:00Z', demo: false,
|
||||
arcs: [
|
||||
{ fromLat: 20, fromLon: 0, toLat: 51.5, toLon: -0.12, weight: 1, label: 'FR → lon' },
|
||||
{ fromLat: 37.09, fromLon: -95.71, toLat: 40.71, toLon: -74.0, weight: 0.6, label: 'US → nyc' },
|
||||
],
|
||||
},
|
||||
chainNodes: {
|
||||
updatedAt: '2026-07-18T12:00:00Z', positionsModeled: true,
|
||||
networks: [{ id: 'lux', name: 'Lux Network', chainId: 96369, blockHeight: 1096461, peers: 3, live: true,
|
||||
nodes: [{ lat: 40.71, lon: -74.0, city: 'New York', kind: 'validator' }, { lat: 37.77, lon: -122.42, city: 'San Francisco', kind: 'validator' }] }],
|
||||
},
|
||||
byoGpu: { updatedAt: '2026-07-18T12:00:00Z', demo: false, gpus: [] },
|
||||
};
|
||||
|
||||
// The flagship Cloud variant now OPENS on the native 3D globe (its immersive default),
|
||||
// which parks the mapbox 2D map — `.deckgl-map-wrapper` mounts hidden. These specs
|
||||
// exercise the 2D→3D toggle path (2D layer parity, then go3D), so they pin the start
|
||||
// to 2D with `?mode=2d` (resolveInitialMapMode: the URL wins over the variant default).
|
||||
// The globe-as-default render is proven separately (direct visual + the live prod shot).
|
||||
const CLOUD_2D = '/?variant=cloud&mode=2d';
|
||||
|
||||
async function mockCloud(page: import('@playwright/test').Page, traffic = cloudMap.trafficGlobe): Promise<void> {
|
||||
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
await page.route('**/v1/world/cloud/traffic-globe', (r) => r.fulfill(json(traffic)));
|
||||
await page.route('**/v1/world/cloud/traffic', (r) => r.fulfill(json(cloudMap.traffic)));
|
||||
await page.route('**/v1/world/cloud/chain-nodes', (r) => r.fulfill(json(cloudMap.chainNodes)));
|
||||
await page.route('**/v1/world/cloud/byo-gpu', (r) => r.fulfill(json(cloudMap.byoGpu)));
|
||||
}
|
||||
|
||||
const layerIds = (page: import('@playwright/test').Page): Promise<string[]> =>
|
||||
page.evaluate(() => {
|
||||
const m = (window as unknown as { __deckMap?: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
|
||||
return (m?.asGlobeSource().buildLayers() ?? []).flat(Infinity).filter(Boolean).map((l: DeckLayer) => l.id);
|
||||
});
|
||||
|
||||
const go3D = async (page: import('@playwright/test').Page): Promise<void> => {
|
||||
// Enter the 3D globe through the projection toggle's REAL click handler
|
||||
// (proj-btn → setProjectionMode('3d') → activateNativeGlobe). The flagship Cloud
|
||||
// view floats its control dock over the full-viewport globe canvas, so under
|
||||
// headless swiftshader a synthesized mouse click's hit-test lands on the canvas,
|
||||
// not the collapsed-dropdown button — dispatch the click on the element itself.
|
||||
// This spec verifies the GLOBE render (occlusion/terrain/parity/live dots); the
|
||||
// dock's pointer reachability is a separate controls concern, out of scope here.
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('.deckgl-projection-toggle .proj-btn[data-mode="3d"]') as HTMLElement | null;
|
||||
btn?.click();
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 25000 })
|
||||
.toBe(true);
|
||||
await page.waitForTimeout(1200); // let the first data-sync pull + push layers
|
||||
};
|
||||
|
||||
test.describe('Globe render fixes — occlusion, terrain, 2D/3D parity, live dots', () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
test.use({ reducedMotion: 'reduce' });
|
||||
|
||||
// The cloud data layers that must exist on the Hanzo-Cloud globe once feeds resolve.
|
||||
const CLOUD_LAYERS = ['traffic', 'trafficArcs', 'chainNodes', 'datacenter-clusters-layer'];
|
||||
|
||||
test('3D: cloud layers mount, live dots render on the sphere, no WebGL errors', async ({ page }, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i, /the layer 'background'/i, /status of 40[13]/i];
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await mockCloud(page);
|
||||
await page.goto(CLOUD_2D);
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
await go3D(page);
|
||||
|
||||
// Single WebGL context for the globe (deck GlobeView, not a 2nd overlay canvas).
|
||||
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-canvas').length)).toBe(1);
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getViewportType: () => string | null } }).__globeNative.getViewportType())).toMatch(/Globe/i);
|
||||
|
||||
// Every cloud data layer is mounted.
|
||||
const ids = await layerIds(page);
|
||||
for (const id of CLOUD_LAYERS) expect(ids).toContain(id);
|
||||
|
||||
// The live request-geo dots are present with the real (mocked) point count.
|
||||
const trafficCount = await page.evaluate(() => {
|
||||
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
|
||||
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
|
||||
return (t?.props?.data as unknown[])?.length ?? 0;
|
||||
});
|
||||
expect(trafficCount).toBe(cloudMap.trafficGlobe.points.length);
|
||||
|
||||
const shot = await page.locator('.globe-native-wrapper').screenshot();
|
||||
await testInfo.attach('3d-globe-cloud-layers', { body: shot, contentType: 'image/png' });
|
||||
expect(errors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
|
||||
});
|
||||
|
||||
test('occlusion: a back-hemisphere dot is culled to transparent, a front dot is opaque', async ({ page }) => {
|
||||
await mockCloud(page);
|
||||
await page.goto(CLOUD_2D);
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
await go3D(page);
|
||||
|
||||
// Face the camera at lon 0 (the FRONT point). Read the REAL traffic layer's fill
|
||||
// accessor for each point: front → opaque (alpha>0), back (lon 180) → alpha 0.
|
||||
const alphas = await page.evaluate(() => {
|
||||
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
|
||||
m.setOcclusionCenter(0, 20);
|
||||
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic') as DeckLayer;
|
||||
const data = t.props.data as Array<{ lon: number }>;
|
||||
const getFill = t.props.getFillColor as (d: unknown) => Rgba;
|
||||
const front = getFill(data.find((d) => d.lon === 0));
|
||||
const back = getFill(data.find((d) => d.lon === 180));
|
||||
return { frontAlpha: front[3], backAlpha: back[3] };
|
||||
});
|
||||
expect(alphas.frontAlpha).toBeGreaterThan(0);
|
||||
expect(alphas.backAlpha).toBe(0);
|
||||
});
|
||||
|
||||
test('occlusion: a back-hemisphere count badge is culled (no floating badge over the globe)', async ({ page }) => {
|
||||
await mockCloud(page);
|
||||
await page.goto(CLOUD_2D);
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
await go3D(page);
|
||||
|
||||
// datacenter count badges are a TextLayer; a back-side badge's glyph AND pill must
|
||||
// both go transparent. Probe both hemispheres against a fixed camera centre.
|
||||
const badge = await page.evaluate(() => {
|
||||
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
|
||||
m.setOcclusionCenter(0, 20);
|
||||
const b = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'datacenter-clusters-badge') as DeckLayer | undefined;
|
||||
if (!b) return { skip: true };
|
||||
const getColor = b.props.getColor as (d: unknown) => Rgba;
|
||||
const getBg = b.props.getBackgroundColor as (d: unknown) => Rgba;
|
||||
const front = { lon: 0, lat: 20, count: 5 };
|
||||
const back = { lon: 180, lat: 20, count: 5 };
|
||||
return { skip: false, frontText: getColor(front)[3], backText: getColor(back)[3], backBg: getBg(back)[3] };
|
||||
});
|
||||
if (badge.skip) test.skip(true, 'no datacenter badge in this build');
|
||||
expect(badge.frontText).toBeGreaterThan(0);
|
||||
expect(badge.backText).toBe(0);
|
||||
expect(badge.backBg).toBe(0);
|
||||
});
|
||||
|
||||
test('2D/3D parity: every cloud layer that mounts in 3D also mounts in 2D', async ({ page }, testInfo) => {
|
||||
await mockCloud(page);
|
||||
await page.goto(CLOUD_2D);
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
await page.waitForTimeout(1500); // 2D feeds settle
|
||||
|
||||
const ids2d = await layerIds(page);
|
||||
for (const id of CLOUD_LAYERS) expect(ids2d, `2D missing ${id}`).toContain(id);
|
||||
await testInfo.attach('2d-map-cloud-layers', { body: await page.locator('.deckgl-map-wrapper').screenshot(), contentType: 'image/png' });
|
||||
|
||||
await go3D(page);
|
||||
const ids3d = await layerIds(page);
|
||||
for (const id of CLOUD_LAYERS) expect(ids3d, `3D missing ${id}`).toContain(id);
|
||||
await testInfo.attach('3d-globe-cloud-layers-parity', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
|
||||
});
|
||||
|
||||
test('terrain drapes on the globe in a single context (no second canvas)', async ({ page }, testInfo) => {
|
||||
await mockCloud(page);
|
||||
await page.goto(CLOUD_2D);
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
await go3D(page);
|
||||
|
||||
await page.evaluate(() => (window as unknown as { __globeNative: { setBasemapStyle: (s: string) => void } }).__globeNative.setBasemapStyle('terrain'));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getDeck: () => { props: { layers: DeckLayer[] } } } }).__globeNative.getDeck().props.layers.flat(Infinity).some((l: DeckLayer) => l?.id?.startsWith('globe-imagery-terrain'))), { timeout: 20000 })
|
||||
.toBe(true);
|
||||
// Still exactly one canvas — imagery drapes as deck tiles, not a 2nd WebGL context.
|
||||
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-wrapper canvas').length)).toBe(1);
|
||||
await page.waitForTimeout(2500);
|
||||
await testInfo.attach('3d-terrain-globe', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
|
||||
});
|
||||
|
||||
test('live dots update when the feed changes', async ({ page }) => {
|
||||
await mockCloud(page, { ...cloudMap.trafficGlobe, points: cloudMap.trafficGlobe.points.slice(0, 1) });
|
||||
await page.goto(CLOUD_2D);
|
||||
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
|
||||
await go3D(page);
|
||||
|
||||
const count = () => page.evaluate(() => {
|
||||
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
|
||||
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
|
||||
return (t?.props?.data as unknown[])?.length ?? 0;
|
||||
});
|
||||
await expect.poll(count, { timeout: 15000 }).toBe(1);
|
||||
|
||||
// Swap the feed to the full 3-point payload; the DeckGLMap poll picks it up.
|
||||
await mockCloud(page, cloudMap.trafficGlobe);
|
||||
await expect.poll(count, { timeout: 20000 }).toBe(cloudMap.trafficGlobe.points.length);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { clickMapControl } from './helpers/map-controls';
|
||||
|
||||
type LayerSnapshot = { id: string; dataCount: number };
|
||||
|
||||
@@ -160,7 +161,7 @@ test.describe('3D globe', () => {
|
||||
}
|
||||
|
||||
// Click the real 3D pill in the map header.
|
||||
await page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]').click();
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
|
||||
await expect.poll(() => projectionType(page), { timeout: 30000 }).toBe('globe');
|
||||
await expect(page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]')).toHaveClass(/active/);
|
||||
|
||||
@@ -186,7 +187,7 @@ test.describe('3D globe', () => {
|
||||
}
|
||||
|
||||
// Flip back to the flat map.
|
||||
await page.locator('.deckgl-projection-toggle .proj-btn[data-mode="2d"]').click();
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="2d"]');
|
||||
await expect.poll(() => projectionType(page), { timeout: 20000 }).toBe('mercator');
|
||||
|
||||
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
// The map's projection / basemap / time-range controls collapsed into dropdowns:
|
||||
// each option button (.proj-btn / .style-btn / .time-btn) lives in a popover that a
|
||||
// trigger opens. Tests that click an option must open its dropdown first. This helper
|
||||
// opens the button's parent .deckgl-dd (if it's in one) and clicks the option — a
|
||||
// no-op open when the button isn't inside a dropdown, so it's safe everywhere.
|
||||
export async function clickMapControl(page: Page, buttonSelector: string): Promise<void> {
|
||||
const btn: Locator = page.locator(buttonSelector).first();
|
||||
const dd = btn.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " deckgl-dd ")][1]');
|
||||
if (await dd.count()) {
|
||||
const trigger = dd.locator('.dd-trigger').first();
|
||||
if (!(await dd.evaluate((el) => el.classList.contains('open')).catch(() => false))) {
|
||||
await trigger.click();
|
||||
}
|
||||
}
|
||||
await btn.click();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { clickMapControl } from './helpers/map-controls';
|
||||
|
||||
// Capture the CTO-facing proof screenshots for the layout/style batch. These drive
|
||||
// the REAL app (not a harness) so they show the shipped chrome + layout. They are
|
||||
@@ -33,7 +34,7 @@ test.describe('layout/style batch — deliverable screenshots', () => {
|
||||
await boot(page);
|
||||
|
||||
// Globe on.
|
||||
await page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]').click().catch(() => {});
|
||||
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]').catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Enter immersive.
|
||||
@@ -59,19 +60,22 @@ test.describe('layout/style batch — deliverable screenshots', () => {
|
||||
test('basemap style switcher (dark / satellite / terrain)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await boot(page);
|
||||
// The basemap control collapsed into a dropdown: assert its trigger, then open it.
|
||||
await expect(page.locator('.deckgl-dd[data-dd="basemap"] .dd-trigger')).toBeVisible();
|
||||
await page.locator('.deckgl-dd[data-dd="basemap"] .dd-trigger').click();
|
||||
await expect(page.locator('.deckgl-style-switcher')).toBeVisible();
|
||||
await shot(page, 'style-switcher-dark.png');
|
||||
|
||||
// Satellite + terrain need a Mapbox token (VITE_MAPBOX_TOKEN); the buttons are
|
||||
// disabled without one. When a token is configured, actually switch and let the
|
||||
// relief render before capturing.
|
||||
// relief render before capturing. clickMapControl reopens the dropdown each time
|
||||
// (picking an option closes it).
|
||||
const sat = page.locator('.deckgl-style-switcher .style-btn[data-style="satellite"]');
|
||||
if (!(await sat.isDisabled())) {
|
||||
await sat.click();
|
||||
await clickMapControl(page, '.deckgl-style-switcher .style-btn[data-style="satellite"]');
|
||||
await page.waitForTimeout(4000);
|
||||
await shot(page, 'style-switcher-satellite.png');
|
||||
const terrain = page.locator('.deckgl-style-switcher .style-btn[data-style="terrain"]');
|
||||
await terrain.click();
|
||||
await clickMapControl(page, '.deckgl-style-switcher .style-btn[data-style="terrain"]');
|
||||
await page.waitForTimeout(4000);
|
||||
await shot(page, 'style-switcher-terrain.png');
|
||||
}
|
||||
|
||||
@@ -10,11 +10,22 @@ test.describe('keyword spike modal/badge flow', () => {
|
||||
const trending = await import('/src/services/trending-keywords.ts');
|
||||
const correlation = await import('/src/services/correlation.ts');
|
||||
|
||||
// The badge/modal render through i18n (t()); the app bootstrap initializes it
|
||||
// before any component mounts. This isolated harness must do the same, else
|
||||
// t() returns undefined and getSignalContext().actionableInsight.split() throws.
|
||||
const { initI18n } = await import('/src/services/i18n.ts');
|
||||
await initI18n();
|
||||
|
||||
const previousConfig = trending.getTrendingConfig();
|
||||
const headerRight = document.createElement('div');
|
||||
headerRight.className = 'header-right';
|
||||
document.body.appendChild(headerRight);
|
||||
|
||||
// The findings badge is opt-in (default OFF): its constructor only mounts and
|
||||
// renders when the user has enabled it. Simulate an opted-in session so the
|
||||
// badge mounts into .header-right and its count/dropdown render.
|
||||
localStorage.setItem('worldmonitor-intel-findings', 'shown');
|
||||
|
||||
const modal = new SignalModal();
|
||||
const badge = new IntelligenceGapBadge();
|
||||
badge.setOnSignalClick((signal) => modal.showSignal(signal));
|
||||
@@ -27,13 +38,18 @@ test.describe('keyword spike modal/badge flow', () => {
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
// Keep the trending proper noun ("Iran") mid-sentence and capitalized: the
|
||||
// significance filter (isLikelyProperNoun) only counts capitalization for
|
||||
// tokens that appear past position 0, so a sentence-leading term reads as an
|
||||
// ordinary word and gets suppressed. Mid-sentence, it registers as a genuine
|
||||
// entity spiking across sources — no ML worker required.
|
||||
const headlines = [
|
||||
{ source: 'Reuters', title: 'Iran sanctions pressure rises amid talks', link: 'https://example.com/reuters/1' },
|
||||
{ source: 'AP', title: 'Iran sanctions debate intensifies in Washington', link: 'https://example.com/ap/1' },
|
||||
{ source: 'BBC', title: 'Iran sanctions trigger fresh market concerns', link: 'https://example.com/bbc/1' },
|
||||
{ source: 'Reuters', title: 'Iran sanctions package draws regional response', link: 'https://example.com/reuters/2' },
|
||||
{ source: 'AP', title: 'Iran sanctions proposal gains momentum', link: 'https://example.com/ap/2' },
|
||||
{ source: 'BBC', title: 'Iran sanctions timeline shortens after warnings', link: 'https://example.com/bbc/2' },
|
||||
{ source: 'Reuters', title: 'Talks on Iran sanctions stall in Washington', link: 'https://example.com/reuters/1' },
|
||||
{ source: 'AP', title: 'New Iran sanctions debate intensifies among allies', link: 'https://example.com/ap/1' },
|
||||
{ source: 'BBC', title: 'Markets watch Iran sanctions with fresh concern', link: 'https://example.com/bbc/1' },
|
||||
{ source: 'Reuters', title: 'Regional powers weigh Iran sanctions package', link: 'https://example.com/reuters/2' },
|
||||
{ source: 'AP', title: 'Momentum grows for Iran sanctions proposal', link: 'https://example.com/ap/2' },
|
||||
{ source: 'BBC', title: 'Analysts see Iran sanctions timeline shortening', link: 'https://example.com/bbc/2' },
|
||||
].map(item => ({
|
||||
...item,
|
||||
pubDate: now,
|
||||
@@ -101,6 +117,7 @@ test.describe('keyword spike modal/badge flow', () => {
|
||||
if (store?.previousConfig) {
|
||||
trending.updateTrendingConfig(store.previousConfig);
|
||||
}
|
||||
localStorage.removeItem('worldmonitor-intel-findings');
|
||||
delete (window as unknown as Record<string, unknown>).__keywordSpikeTest;
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -4,6 +4,9 @@ import { expect, test, type Page } from '@playwright/test';
|
||||
// FLIP reflow, snapping resize) through the deterministic harness.
|
||||
|
||||
const HARNESS = '/tests/panel-drag-harness.html';
|
||||
const LAYOUT_HARNESS = '/tests/layout-harness.html';
|
||||
// Screenshots land in a repo-relative artifacts dir (Playwright creates it).
|
||||
const SHOTS = 'e2e/layout-shots';
|
||||
|
||||
interface PanelDragHarness {
|
||||
ready: boolean;
|
||||
@@ -12,9 +15,31 @@ interface PanelDragHarness {
|
||||
order: () => string[];
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutHarness {
|
||||
ready: boolean;
|
||||
mode: () => 'grid' | 'free';
|
||||
setMode: (m: 'grid' | 'free') => void;
|
||||
toggle: () => 'grid' | 'free';
|
||||
cell: () => number;
|
||||
setCell: (px: number) => void;
|
||||
rect: (id: string) => Rect | null;
|
||||
colStep: () => { step: number; cols: number; padL: number };
|
||||
order: () => string[];
|
||||
overlayVisible: () => boolean;
|
||||
gridColumnOf: (id: string) => string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__panelDragHarness?: PanelDragHarness;
|
||||
__layoutHarness?: LayoutHarness;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +128,554 @@ test.describe('panel drag + resize', () => {
|
||||
expect(span).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Layout engine: grid ⇄ free, corner resize, cell-size, overlay ──────────
|
||||
// Drives the real Panel grips + grid-config against the true main.css, at
|
||||
// 1440x900 (see playwright.layout.config.ts).
|
||||
|
||||
const lh = async (page: Page): Promise<void> => {
|
||||
await page.goto(LAYOUT_HARNESS);
|
||||
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
|
||||
await page.waitForTimeout(60); // let the queued registerPanel microtasks settle
|
||||
};
|
||||
|
||||
const rect = (page: Page, id: string): Promise<Rect> =>
|
||||
page.evaluate((pid) => window.__layoutHarness!.rect(pid)!, id);
|
||||
|
||||
const headerBox = async (page: Page, id: string) =>
|
||||
(await page.locator(`[data-panel="${id}"] .panel-header`).first().boundingBox())!;
|
||||
|
||||
test.describe('layout engine', () => {
|
||||
// Gate viewport: 1440x900 (independent of the base config's default size).
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('grid mode: a dropped panel lands on a cell boundary', async ({ page }) => {
|
||||
await lh(page);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('grid');
|
||||
|
||||
const src = await headerBox(page, 'charlie');
|
||||
const dst = (await page.locator('[data-panel="echo"]').boundingBox())!;
|
||||
|
||||
await page.mouse.move(src.x + 30, src.y + src.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(src.x + 60, src.y + src.height / 2, { steps: 4 });
|
||||
await page.mouse.move(dst.x + dst.width * 0.8, dst.y + dst.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Final resting position is snapped to a grid cell (multiple of the column step).
|
||||
const { step, padL } = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
const r = await rect(page, 'charlie');
|
||||
const k = Math.round((r.left - padL) / step);
|
||||
expect(Math.abs(r.left - padL - k * step)).toBeLessThan(4);
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/grid-snap.png` });
|
||||
});
|
||||
|
||||
test('grid mode: bottom-right corner resizes width + height (snapped)', async ({ page }) => {
|
||||
await lh(page);
|
||||
const corner = (await page
|
||||
.locator('[data-panel="delta"] .panel-corner-resize-handle.se')
|
||||
.boundingBox())!;
|
||||
const { step } = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
// Pull out ~1.5 columns wide and ~250px tall.
|
||||
await page.mouse.move(
|
||||
corner.x + corner.width / 2 + step * 1.5,
|
||||
corner.y + corner.height / 2 + 250,
|
||||
{ steps: 16 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
// Height grew (fine 16px row grid → a data-span well above the ~100px min) and
|
||||
// width snapped to multiple columns. Resize lands on the fine grid, not the
|
||||
// coarse span-N tier classes, so the panel carries `resized` + a data-span.
|
||||
await expect(page.locator('[data-panel="delta"]')).toHaveClass(/resized/);
|
||||
const span = await page.evaluate(() =>
|
||||
parseInt(document.querySelector<HTMLElement>('[data-panel="delta"]')!.dataset.span ?? '0', 10),
|
||||
);
|
||||
expect(span).toBeGreaterThan(5); // taller than its start via the fine row grid
|
||||
const gc = await page.evaluate(() => window.__layoutHarness!.gridColumnOf('delta'));
|
||||
expect(gc).toMatch(/span [2-9]/);
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/resized-from-corner.png` });
|
||||
});
|
||||
|
||||
test('grid mode: overlay appears only while dragging', async ({ page }) => {
|
||||
await lh(page);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.overlayVisible())).toBe(false);
|
||||
|
||||
const src = await headerBox(page, 'bravo');
|
||||
await page.mouse.move(src.x + 30, src.y + src.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(src.x + 120, src.y + 40, { steps: 8 });
|
||||
|
||||
// Mid-drag: the faint track overlay is shown.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
|
||||
.toBe(true);
|
||||
await page.screenshot({ path: `${SHOTS}/grid-overlay.png` });
|
||||
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('grid mode: changing cell size re-snaps the grid', async ({ page }) => {
|
||||
await lh(page);
|
||||
const before = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
|
||||
await page.evaluate(() => window.__layoutHarness!.setCell(240));
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const after = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
// Wider cells ⇒ fewer, wider columns: the panels re-snap to a new track grid.
|
||||
expect(after.step).toBeGreaterThan(before.step + 20);
|
||||
expect(after.cols).toBeLessThanOrEqual(before.cols);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.cell())).toBe(240);
|
||||
});
|
||||
|
||||
test('free mode: pixel drag + corner resize persist across reload', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
|
||||
// Hold Alt to bypass grid snapping — this test asserts that exact arbitrary-pixel
|
||||
// geometry survives a reload (snapping has its own tests below).
|
||||
await page.keyboard.down('Alt');
|
||||
// Drag alpha by an arbitrary (non-cell) pixel delta.
|
||||
const start = await rect(page, 'alpha');
|
||||
const hdr = await headerBox(page, 'alpha');
|
||||
const DX = 223;
|
||||
const DY = -137;
|
||||
await page.mouse.move(hdr.x + 30, hdr.y + hdr.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(hdr.x + 40, hdr.y + hdr.height / 2, { steps: 3 });
|
||||
await page.mouse.move(hdr.x + 30 + DX, hdr.y + hdr.height / 2 + DY, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
|
||||
const moved = await rect(page, 'alpha');
|
||||
expect(Math.abs(moved.left - (start.left + DX))).toBeLessThan(6);
|
||||
expect(Math.abs(moved.top - (start.top + DY))).toBeLessThan(6);
|
||||
// Arbitrary pixel position — not snapped to a cell.
|
||||
await page.screenshot({ path: `${SHOTS}/free-form.png` });
|
||||
|
||||
// Resize from the corner to an arbitrary size.
|
||||
const corner = (await page
|
||||
.locator('[data-panel="alpha"] .panel-corner-resize-handle.se')
|
||||
.boundingBox())!;
|
||||
const WD = 118;
|
||||
const HD = 94;
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
corner.x + corner.width / 2 + WD,
|
||||
corner.y + corner.height / 2 + HD,
|
||||
{ steps: 16 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const resized = await rect(page, 'alpha');
|
||||
expect(Math.abs(resized.width - (moved.width + WD))).toBeLessThan(6);
|
||||
expect(Math.abs(resized.height - (moved.height + HD))).toBeLessThan(6);
|
||||
|
||||
await page.keyboard.up('Alt');
|
||||
// Reload: the mode + exact geometry are restored.
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
|
||||
await page.waitForTimeout(80);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
const restored = await rect(page, 'alpha');
|
||||
expect(Math.abs(restored.left - resized.left)).toBeLessThan(3);
|
||||
expect(Math.abs(restored.top - resized.top)).toBeLessThan(3);
|
||||
expect(Math.abs(restored.width - resized.width)).toBeLessThan(3);
|
||||
expect(Math.abs(restored.height - resized.height)).toBeLessThan(3);
|
||||
});
|
||||
|
||||
test('free mode: all four corners resize and pin the opposite corner', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
|
||||
// Alt bypasses snapping so the shrink is exactly the drag delta; this test is
|
||||
// about the anchor-pinning invariant, which holds with or without snapping.
|
||||
await page.keyboard.down('Alt');
|
||||
const D = 60; // drag each corner inward (toward the panel centre) — always in-bounds
|
||||
// corner → inward drag sign + which opposite corner must stay pinned.
|
||||
const cases = [
|
||||
{ panel: 'alpha', corner: 'nw', sx: 1, sy: 1, fix: 'br' },
|
||||
{ panel: 'bravo', corner: 'ne', sx: -1, sy: 1, fix: 'bl' },
|
||||
{ panel: 'charlie', corner: 'sw', sx: 1, sy: -1, fix: 'tr' },
|
||||
{ panel: 'delta', corner: 'se', sx: -1, sy: -1, fix: 'tl' },
|
||||
] as const;
|
||||
|
||||
for (const c of cases) {
|
||||
const b = await rect(page, c.panel);
|
||||
const h = (await page
|
||||
.locator(`[data-panel="${c.panel}"] .panel-corner-resize-handle.${c.corner}`)
|
||||
.boundingBox())!;
|
||||
const px = h.x + h.width / 2;
|
||||
const py = h.y + h.height / 2;
|
||||
await page.mouse.move(px, py);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(px + c.sx * D, py + c.sy * D, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
const a = await rect(page, c.panel);
|
||||
|
||||
// The grabbed corner pulled inward on both axes → the panel shrank.
|
||||
expect(a.width).toBeLessThan(b.width - 20);
|
||||
expect(a.height).toBeLessThan(b.height - 20);
|
||||
|
||||
// The OPPOSITE corner never moved — proof the resize is anchor-aware
|
||||
// (nw/ne/sw shift left/top to hold the far edge, not just grow w/h).
|
||||
const bR = b.left + b.width;
|
||||
const bB = b.top + b.height;
|
||||
const aR = a.left + a.width;
|
||||
const aB = a.top + a.height;
|
||||
if (c.fix === 'br') {
|
||||
expect(Math.abs(aR - bR)).toBeLessThan(4);
|
||||
expect(Math.abs(aB - bB)).toBeLessThan(4);
|
||||
} else if (c.fix === 'bl') {
|
||||
expect(Math.abs(a.left - b.left)).toBeLessThan(4);
|
||||
expect(Math.abs(aB - bB)).toBeLessThan(4);
|
||||
} else if (c.fix === 'tr') {
|
||||
expect(Math.abs(aR - bR)).toBeLessThan(4);
|
||||
expect(Math.abs(a.top - b.top)).toBeLessThan(4);
|
||||
} else {
|
||||
expect(Math.abs(a.left - b.left)).toBeLessThan(4);
|
||||
expect(Math.abs(a.top - b.top)).toBeLessThan(4);
|
||||
}
|
||||
}
|
||||
await page.keyboard.up('Alt');
|
||||
await page.screenshot({ path: `${SHOTS}/free-all-corners.png` });
|
||||
});
|
||||
|
||||
test('free mode: moving one panel never shifts its siblings (no reflow)', async ({ page }) => {
|
||||
// The owner's #1 complaint: dragging the map/one panel "shifts all other
|
||||
// components". In free mode each panel is independent — this proves a big drag
|
||||
// of one leaves every sibling byte-for-byte where it was.
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
|
||||
const siblings = ['bravo', 'charlie', 'delta', 'echo'] as const;
|
||||
const before: Record<string, Rect> = {};
|
||||
for (const id of siblings) before[id] = await rect(page, id);
|
||||
|
||||
const hdr = await headerBox(page, 'alpha');
|
||||
await page.mouse.move(hdr.x + 30, hdr.y + hdr.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(hdr.x + 40, hdr.y + hdr.height / 2, { steps: 3 });
|
||||
await page.mouse.move(hdr.x + 300, hdr.y + hdr.height / 2 + 200, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
|
||||
for (const id of siblings) {
|
||||
const a = await rect(page, id);
|
||||
const b = before[id]!;
|
||||
expect(Math.abs(a.left - b.left)).toBeLessThan(2);
|
||||
expect(Math.abs(a.top - b.top)).toBeLessThan(2);
|
||||
expect(Math.abs(a.width - b.width)).toBeLessThan(2);
|
||||
expect(Math.abs(a.height - b.height)).toBeLessThan(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('free mode: a panel resizes narrower than the old 160px min width', async ({ page }) => {
|
||||
// The owner's #3 complaint: panels are "constrained on min width". Free mode's
|
||||
// floor is now ~96px, so a panel can be pulled well under the old 160px track.
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
|
||||
const before = await rect(page, 'bravo');
|
||||
const corner = (await page
|
||||
.locator('[data-panel="bravo"] .panel-corner-resize-handle.se')
|
||||
.boundingBox())!;
|
||||
// Pull the SE corner far left → collapse width toward the low floor. Hold Alt for
|
||||
// fine (un-snapped) sizing — snapping would otherwise land on a whole cell (≥160).
|
||||
await page.keyboard.down('Alt');
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(corner.x - before.width, corner.y + corner.height / 2, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Alt');
|
||||
|
||||
const after = await rect(page, 'bravo');
|
||||
expect(after.width).toBeLessThan(150); // narrower than the old 160px floor
|
||||
expect(after.width).toBeGreaterThanOrEqual(90); // …but not past the new ~96px floor
|
||||
});
|
||||
|
||||
test('free mode: the map participates with a 240px floor', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
const pos = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('[data-panel="map"]')!).position,
|
||||
);
|
||||
expect(pos).toBe('absolute');
|
||||
const r = await rect(page, 'map');
|
||||
expect(r.width).toBeGreaterThanOrEqual(240);
|
||||
expect(r.height).toBeGreaterThanOrEqual(240);
|
||||
});
|
||||
|
||||
test('free mode: dragging snaps to logical grid lines (panels align to shared tracks)', async ({ page }) => {
|
||||
// The owner's feedback: "the snap is not logical." Two panels dragged to targets
|
||||
// less than half a cell apart must land on the SAME grid line — proof placement is
|
||||
// quantised to the logical grid, not arbitrary px.
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
const cell = await page.evaluate(() => window.__layoutHarness!.cell());
|
||||
const colPitch = cell + 4; // + gap
|
||||
const rowPitch = 16 + 4; // ROW_UNIT + gap
|
||||
|
||||
const dragTo = async (id: string, x: number, y: number) => {
|
||||
const hdr = await headerBox(page, id);
|
||||
await page.mouse.move(hdr.x + 20, hdr.y + hdr.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(hdr.x + 30, hdr.y + hdr.height / 2, { steps: 3 });
|
||||
await page.mouse.move(x, y, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
};
|
||||
|
||||
const gridBox = (await page.locator('#panelsGrid').boundingBox())!;
|
||||
const tx = gridBox.x + colPitch * 2 + 20;
|
||||
const ty = gridBox.y + rowPitch * 8 + 30;
|
||||
await dragTo('alpha', tx, ty);
|
||||
await dragTo('bravo', tx + 30, ty + 8); // < half a cell/row from alpha's target
|
||||
|
||||
const a = await rect(page, 'alpha');
|
||||
const b = await rect(page, 'bravo');
|
||||
// Both snapped to the SAME grid line instead of sitting 30/8px apart.
|
||||
expect(Math.abs(a.left - b.left)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(a.top - b.top)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('free mode: resizing snaps width to whole cells and pins the opposite edge', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
const cell = await page.evaluate(() => window.__layoutHarness!.cell());
|
||||
const GAP = 4;
|
||||
|
||||
const before = await rect(page, 'alpha');
|
||||
const corner = (await page
|
||||
.locator('[data-panel="alpha"] .panel-corner-resize-handle.se')
|
||||
.boundingBox())!;
|
||||
// Pull the SE corner out by a non-cell amount; width must land on a whole-cell size.
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(corner.x + corner.width / 2 + 210, corner.y + corner.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
|
||||
const after = await rect(page, 'alpha');
|
||||
// width == N*cell + (N-1)*gap for some integer N ≥ 1.
|
||||
const n = Math.round((after.width + GAP) / (cell + GAP));
|
||||
expect(n).toBeGreaterThanOrEqual(1);
|
||||
expect(Math.abs(after.width - (n * cell + (n - 1) * GAP))).toBeLessThanOrEqual(2);
|
||||
// The SE resize pins the top-left corner — it must not have moved.
|
||||
expect(Math.abs(after.left - before.left)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(after.top - before.top)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('resize handles show no visible glyphs (owner request) but still resize', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
// The corner/edge resize glyphs are hidden (display:none on the ::after marks) —
|
||||
// no visible "< >" chevrons — while the handles stay live.
|
||||
const displays = await page.evaluate(() => {
|
||||
const d = (sel: string) => {
|
||||
const el = document.querySelector(sel);
|
||||
return el ? getComputedStyle(el, '::after').display : 'missing';
|
||||
};
|
||||
return {
|
||||
corner: d('[data-panel="alpha"] .panel-corner-resize-handle.se'),
|
||||
col: d('[data-panel="alpha"] .panel-col-resize-handle'),
|
||||
row: d('[data-panel="alpha"] .panel-resize-handle'),
|
||||
};
|
||||
});
|
||||
expect(displays.corner).toBe('none');
|
||||
expect(displays.col).toBe('none');
|
||||
expect(displays.row).toBe('none');
|
||||
// Functionality intact: the (now glyph-less) SE corner still resizes.
|
||||
const before = await rect(page, 'alpha');
|
||||
const corner = (await page.locator('[data-panel="alpha"] .panel-corner-resize-handle.se').boundingBox())!;
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(corner.x + 180, corner.y + 120, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
expect((await rect(page, 'alpha')).width).toBeGreaterThan(before.width + 40);
|
||||
});
|
||||
|
||||
test('cell size can go down to the finer 80px floor', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setCell(80));
|
||||
await page.waitForTimeout(30);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.cell())).toBe(80);
|
||||
// Clamped: a request below the floor snaps back up to 80, never lower.
|
||||
await page.evaluate(() => window.__layoutHarness!.setCell(40));
|
||||
await page.waitForTimeout(30);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.cell())).toBe(80);
|
||||
});
|
||||
|
||||
test('toggle flips grid ⇄ free and back', async ({ page }) => {
|
||||
await lh(page);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('free');
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('grid');
|
||||
// Back in grid mode the free inline geometry is stripped.
|
||||
const pos = await page.evaluate(
|
||||
() => document.querySelector<HTMLElement>('[data-panel="alpha"]')!.style.position,
|
||||
);
|
||||
expect(pos).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Live News (video) resizes freely; the video fills the panel at any size ──
|
||||
// Real app: proves the CTO requirement — Live News can grow to 2-3 cols / full
|
||||
// width (grid) or any pixel size (free), and the 16:9 video (`.live-news-player`,
|
||||
// width-driven) scales to fill, never capped small. NOTE: in the offline e2e
|
||||
// runtime the YouTube embed eventually errors and replaces `.live-news-player`
|
||||
// with a message, so the video-fill invariant is asserted where it's reliably
|
||||
// present (default size) and the resize is proved via the player's containing
|
||||
// block (`.panel-content`, always present), which the player fills 1:1.
|
||||
|
||||
const rectW = (page: Page, sel: string): Promise<number> =>
|
||||
page.evaluate((s) => {
|
||||
const el = document.querySelector<HTMLElement>(s);
|
||||
return el ? Math.round(el.getBoundingClientRect().width) : -1;
|
||||
}, sel);
|
||||
|
||||
const LN = '[data-panel="live-news"]';
|
||||
const LN_CONTENT = `${LN} .panel-content`;
|
||||
const LN_PLAYER = `${LN} .live-news-player`;
|
||||
|
||||
test.describe('live news video resize (real app)', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('grid: default → 3 cols → full-width; the video fills at every width', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await page.waitForTimeout(500);
|
||||
// The app defaults to free layout now; this test asserts GRID column-span
|
||||
// semantics, so pin grid explicitly (setLayoutMode marks it an explicit choice
|
||||
// so the deferred default-to-free won't re-flip it).
|
||||
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'));
|
||||
await page.waitForTimeout(80);
|
||||
|
||||
const grid = (await page.locator('#panelsGrid').boundingBox())!;
|
||||
const ln = page.locator(LN);
|
||||
const colHandle = ln.locator('.panel-col-resize-handle');
|
||||
|
||||
// The fill setup is active: the panel-content is a full-bleed flex column
|
||||
// (padding 0) so the width-driven 16:9 `.live-news-player` fills it edge-to-edge
|
||||
// at any width. (Keyed on #live-news — dead until the id fix in LiveNewsPanel.)
|
||||
const setup = await page.evaluate((sel) => {
|
||||
const c = document.querySelector<HTMLElement>(sel);
|
||||
if (!c) return null;
|
||||
const s = getComputedStyle(c);
|
||||
return { display: s.display, dir: s.flexDirection, padLeft: s.paddingLeft };
|
||||
}, LN_CONTENT);
|
||||
expect(setup).toEqual({ display: 'flex', dir: 'column', padLeft: '0px' });
|
||||
|
||||
// The video, while present (offline embed errors after a beat), fills the
|
||||
// container 1:1 at the default width.
|
||||
const startContent = await rectW(page, LN_CONTENT);
|
||||
const startVideo = await rectW(page, LN_PLAYER);
|
||||
if (startVideo > 0) expect(Math.abs(startVideo - startContent)).toBeLessThan(4);
|
||||
|
||||
// Drag the right edge out to ~3 columns.
|
||||
const h1 = (await colHandle.boundingBox())!;
|
||||
await page.mouse.move(h1.x + h1.width / 2, h1.y + h1.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grid.x + grid.width * 0.42, h1.y + h1.height / 2, { steps: 14 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const midContent = await rectW(page, LN_CONTENT);
|
||||
expect(midContent).toBeGreaterThan(startContent + 40); // genuinely wider
|
||||
|
||||
// Drag the right edge all the way out → full width. No column cap.
|
||||
const h2 = (await colHandle.boundingBox())!;
|
||||
await page.mouse.move(h2.x + h2.width / 2, h2.y + h2.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grid.x + grid.width + 200, h2.y + h2.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const fullContent = await rectW(page, LN_CONTENT);
|
||||
expect(fullContent).toBeGreaterThan(midContent);
|
||||
expect(fullContent).toBeGreaterThan(grid.width * 0.9); // ~full grid width
|
||||
// The video (when present) fills the now-full-width container 1:1.
|
||||
const fullVideo = await rectW(page, LN_PLAYER);
|
||||
if (fullVideo > 0) expect(Math.abs(fullVideo - fullContent)).toBeLessThan(6);
|
||||
|
||||
// Make it tall too (drag the bottom edge down) so the 16:9 video is large.
|
||||
const bottom = ln.locator('.panel-resize-handle');
|
||||
const b = (await bottom.boundingBox())!;
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + 520, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(() => document.querySelector('[data-panel="live-news"]')?.scrollIntoView({ block: 'start' }));
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: 'e2e/layout-shots/live-news-fullwidth.png' });
|
||||
|
||||
// Container is now full-width AND tall → a large video area.
|
||||
expect(await rectW(page, LN_CONTENT)).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
test('free: Live News resizes to an arbitrary pixel size; video fills', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN_PLAYER, { timeout: 45000 });
|
||||
await page.waitForTimeout(600);
|
||||
const startContent = await rectW(page, LN_CONTENT);
|
||||
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('free'));
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const corner = page.locator(`${LN} .panel-corner-resize-handle.se`);
|
||||
const c = (await corner.boundingBox())!;
|
||||
await page.mouse.move(c.x + c.width / 2, c.y + c.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(c.x + 240, c.y + 260, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const content = await rectW(page, LN_CONTENT);
|
||||
expect(content).toBeGreaterThan(startContent + 120); // grew to an arbitrary pixel width
|
||||
const video = await rectW(page, LN_PLAYER);
|
||||
if (video > 0) expect(Math.abs(video - content)).toBeLessThan(6); // fills at that size
|
||||
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'));
|
||||
});
|
||||
});
|
||||
|
||||
// Text size / UI scale (accessibility) — real app.
|
||||
test.describe('text size control (real app)', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
test('the dock text-size slider scales panel content and persists across reload', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-panel="live-news"]', { timeout: 45000 });
|
||||
// Drive the dock "Text size" slider to 1.4.
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById('dockFontSize') as HTMLInputElement;
|
||||
el.value = '1.4';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ui-scale').trim()))
|
||||
.toBe('1.4');
|
||||
expect(await page.evaluate(() => localStorage.getItem('hanzo-world-ui-scale'))).toBe('1.4');
|
||||
const zoom = await page.evaluate(() => {
|
||||
const c = document.querySelector('.panel-content');
|
||||
return c ? getComputedStyle(c).zoom : '';
|
||||
});
|
||||
expect(parseFloat(zoom)).toBeCloseTo(1.4, 1);
|
||||
// Persists across reload (applyStoredUiScale runs at boot).
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-panel="live-news"]', { timeout: 45000 });
|
||||
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--ui-scale').trim())).toBe('1.4');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,17 +93,22 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
|
||||
calls.push(url);
|
||||
|
||||
if (url.includes('127.0.0.1:46123/api/fred-data')) {
|
||||
// The runtime patch only intercepts /v1/world/* paths. It first hits the
|
||||
// local sidecar (127.0.0.1:46123); on failure it retries the same path
|
||||
// against the remote API base — same-origin under VITE_VARIANT=full, so the
|
||||
// fallback arrives as a relative /v1/world/* URL, distinct from the absolute
|
||||
// local attempt.
|
||||
if (url.includes('127.0.0.1:46123/v1/world/fred-data')) {
|
||||
return responseJson({ error: 'missing local api key' }, 500);
|
||||
}
|
||||
if (url.includes('worldmonitor.app/api/fred-data')) {
|
||||
if (url.startsWith('/v1/world/fred-data')) {
|
||||
return responseJson({ observations: [{ value: '321.5' }] }, 200);
|
||||
}
|
||||
|
||||
if (url.includes('127.0.0.1:46123/api/stablecoin-markets')) {
|
||||
if (url.includes('127.0.0.1:46123/v1/world/stablecoin-markets')) {
|
||||
throw new Error('ECONNREFUSED');
|
||||
}
|
||||
if (url.includes('worldmonitor.app/api/stablecoin-markets')) {
|
||||
if (url.startsWith('/v1/world/stablecoin-markets')) {
|
||||
return responseJson({ stablecoins: [{ symbol: 'USDT' }] }, 200);
|
||||
}
|
||||
|
||||
@@ -117,10 +122,10 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
try {
|
||||
runtime.installRuntimeFetchPatch();
|
||||
|
||||
const fredResponse = await window.fetch('/api/fred-data?series_id=CPIAUCSL');
|
||||
const fredResponse = await window.fetch('/v1/world/fred-data?series_id=CPIAUCSL');
|
||||
const fredBody = await fredResponse.json() as { observations?: Array<{ value: string }> };
|
||||
|
||||
const stableResponse = await window.fetch('/api/stablecoin-markets');
|
||||
const stableResponse = await window.fetch('/v1/world/stablecoin-markets');
|
||||
const stableBody = await stableResponse.json() as { stablecoins?: Array<{ symbol: string }> };
|
||||
|
||||
return {
|
||||
@@ -146,10 +151,10 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
expect(result.stableStatus).toBe(200);
|
||||
expect(result.stableSymbol).toBe('USDT');
|
||||
|
||||
expect(result.calls.some((url) => url.includes('127.0.0.1:46123/api/fred-data'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/fred-data'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.includes('127.0.0.1:46123/api/stablecoin-markets'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.includes('worldmonitor.app/api/stablecoin-markets'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.includes('127.0.0.1:46123/v1/world/fred-data'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.startsWith('/v1/world/fred-data'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.includes('127.0.0.1:46123/v1/world/stablecoin-markets'))).toBe(true);
|
||||
expect(result.calls.some((url) => url.startsWith('/v1/world/stablecoin-markets'))).toBe(true);
|
||||
});
|
||||
|
||||
test('chunk preload reload guard is one-shot until app boot clears it', async ({ page }) => {
|
||||
@@ -225,64 +230,12 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
expect(result.storedValue).toBe('1');
|
||||
});
|
||||
|
||||
test('update badge picks architecture-correct desktop download url', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const { App } = await import('/src/App.ts');
|
||||
const globalWindow = window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: (command: string) => Promise<unknown> } };
|
||||
};
|
||||
const previousTauri = globalWindow.__TAURI__;
|
||||
const releaseUrl = 'https://github.com/koala73/worldmonitor/releases/latest';
|
||||
|
||||
const appProto = App.prototype as unknown as {
|
||||
resolveUpdateDownloadUrl: (releaseUrl: string) => Promise<string>;
|
||||
mapDesktopDownloadPlatform: (os: string, arch: string) => string | null;
|
||||
};
|
||||
const fakeApp = {
|
||||
mapDesktopDownloadPlatform: appProto.mapDesktopDownloadPlatform,
|
||||
};
|
||||
|
||||
try {
|
||||
globalWindow.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async (command: string) => {
|
||||
if (command !== 'get_desktop_runtime_info') throw new Error(`Unexpected command: ${command}`);
|
||||
return { os: 'macos', arch: 'aarch64' };
|
||||
},
|
||||
},
|
||||
};
|
||||
const macArm = await appProto.resolveUpdateDownloadUrl.call(fakeApp, releaseUrl);
|
||||
|
||||
globalWindow.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async () => ({ os: 'windows', arch: 'amd64' }),
|
||||
},
|
||||
};
|
||||
const windowsX64 = await appProto.resolveUpdateDownloadUrl.call(fakeApp, releaseUrl);
|
||||
|
||||
globalWindow.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async () => ({ os: 'linux', arch: 'x86_64' }),
|
||||
},
|
||||
};
|
||||
const linuxFallback = await appProto.resolveUpdateDownloadUrl.call(fakeApp, releaseUrl);
|
||||
|
||||
return { macArm, windowsX64, linuxFallback };
|
||||
} finally {
|
||||
if (previousTauri === undefined) {
|
||||
delete globalWindow.__TAURI__;
|
||||
} else {
|
||||
globalWindow.__TAURI__ = previousTauri;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.macArm).toBe('https://worldmonitor.app/api/download?platform=macos-arm64');
|
||||
expect(result.windowsX64).toBe('https://worldmonitor.app/api/download?platform=windows-exe');
|
||||
expect(result.linuxFallback).toBe('https://github.com/koala73/worldmonitor/releases/latest');
|
||||
});
|
||||
// The upstream worldmonitor.app update-badge + arch-aware desktop-download
|
||||
// machinery (App.resolveUpdateDownloadUrl / mapDesktopDownloadPlatform, driven by
|
||||
// the get_desktop_runtime_info Tauri command) was removed for Hanzo —
|
||||
// App.checkForUpdate is now a deliberate no-op and downloads are served from
|
||||
// /v1/world/download?platform=* via DownloadBanner. No behavior remains to assert
|
||||
// here, so the obsolete arch-resolution test was removed.
|
||||
|
||||
test('loadMarkets keeps Yahoo-backed data when Finnhub is skipped', async ({ page }) => {
|
||||
await page.goto('/tests/runtime-harness.html');
|
||||
@@ -324,8 +277,6 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
const marketConfigErrors: string[] = [];
|
||||
const heatmapRenders: number[] = [];
|
||||
const heatmapConfigErrors: string[] = [];
|
||||
const commoditiesRenders: number[] = [];
|
||||
const commoditiesConfigErrors: string[] = [];
|
||||
const cryptoRenders: number[] = [];
|
||||
const apiStatuses: Array<{ name: string; status: string }> = [];
|
||||
|
||||
@@ -334,7 +285,7 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
calls.push(url);
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.pathname === '/api/finnhub') {
|
||||
if (parsed.pathname === '/v1/world/finnhub') {
|
||||
return responseJson({
|
||||
quotes: [],
|
||||
skipped: true,
|
||||
@@ -342,12 +293,14 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === '/api/yahoo-finance') {
|
||||
const symbol = parsed.searchParams.get('symbol') ?? 'UNKNOWN';
|
||||
return responseJson(yahooChart(symbol));
|
||||
if (parsed.pathname === '/v1/world/yahoo-batch') {
|
||||
const symbols = (parsed.searchParams.get('symbols') ?? '').split(',').filter(Boolean);
|
||||
return responseJson({
|
||||
results: symbols.map((symbol) => ({ symbol, chart: yahooChart(symbol) })),
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === '/api/coingecko') {
|
||||
if (parsed.pathname === '/v1/world/coingecko') {
|
||||
return responseJson([
|
||||
{ id: 'bitcoin', current_price: 50000, price_change_percentage_24h: 1.2, sparkline_in_7d: { price: [1, 2, 3] } },
|
||||
{ id: 'ethereum', current_price: 3000, price_change_percentage_24h: -0.5, sparkline_in_7d: { price: [1, 2, 3] } },
|
||||
@@ -369,10 +322,6 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
renderHeatmap: (data: Array<unknown>) => heatmapRenders.push(data.length),
|
||||
showConfigError: (message: string) => heatmapConfigErrors.push(message),
|
||||
},
|
||||
commodities: {
|
||||
renderCommodities: (data: Array<unknown>) => commoditiesRenders.push(data.length),
|
||||
showConfigError: (message: string) => commoditiesConfigErrors.push(message),
|
||||
},
|
||||
crypto: {
|
||||
renderCrypto: (data: Array<unknown>) => cryptoRenders.push(data.length),
|
||||
},
|
||||
@@ -388,25 +337,14 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
await (App.prototype as unknown as { loadMarkets: (thisArg: unknown) => Promise<void> })
|
||||
.loadMarkets.call(fakeApp);
|
||||
|
||||
const commoditySymbols = ['^VIX', 'GC=F', 'CL=F', 'NG=F', 'SI=F', 'HG=F'];
|
||||
const commodityYahooCalls = commoditySymbols.map((symbol) =>
|
||||
calls.some((url) => {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname === '/api/yahoo-finance' && parsed.searchParams.get('symbol') === symbol;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
marketRenders,
|
||||
marketConfigErrors,
|
||||
heatmapRenders,
|
||||
heatmapConfigErrors,
|
||||
commoditiesRenders,
|
||||
commoditiesConfigErrors,
|
||||
cryptoRenders,
|
||||
apiStatuses,
|
||||
latestMarketsCount: fakeApp.latestMarkets.length,
|
||||
commodityYahooCalls,
|
||||
};
|
||||
} finally {
|
||||
window.fetch = originalFetch;
|
||||
@@ -420,10 +358,6 @@ test.describe('desktop runtime routing guardrails', () => {
|
||||
expect(result.heatmapRenders.length).toBe(0);
|
||||
expect(result.heatmapConfigErrors).toEqual(['FINNHUB_API_KEY not configured — add in Settings']);
|
||||
|
||||
expect(result.commoditiesRenders.some((count) => count > 0)).toBe(true);
|
||||
expect(result.commoditiesConfigErrors.length).toBe(0);
|
||||
expect(result.commodityYahooCalls.every(Boolean)).toBe(true);
|
||||
|
||||
expect(result.cryptoRenders.some((count) => count > 0)).toBe(true);
|
||||
expect(result.apiStatuses.some((entry) => entry.name === 'Finnhub' && entry.status === 'error')).toBe(true);
|
||||
expect(result.apiStatuses.some((entry) => entry.name === 'CoinGecko' && entry.status === 'ok')).toBe(true);
|
||||
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 416 KiB |
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// News Wall — every live news channel at once, hover a tile for AUDIO FOCUS.
|
||||
//
|
||||
// The panel is opt-in (enabled:false, like Live Webcams), and its grid + N YouTube
|
||||
// players build LAZILY the first time it scrolls into view. This test un-hides it and
|
||||
// brings it into view to trigger that build, then asserts: one tile per channel, and
|
||||
// that hovering a tile gives EXACTLY that tile audio focus (the .stations-audio-on ring
|
||||
// + 🔊 badge) while muting the rest — the "hover to enable voice" behavior.
|
||||
//
|
||||
// The YouTube players themselves need real GL + network; the audio-FOCUS logic (class +
|
||||
// badge toggling, and the mute()/unMute() calls it drives) runs regardless of whether a
|
||||
// player finished loading, so the focus behavior is what we assert. We block the YT
|
||||
// embed so the test stays hermetic and fast — the tiles + their hover handlers are built
|
||||
// before (and independent of) the players.
|
||||
|
||||
test.describe('News Wall — all stations at once, hover for audio', () => {
|
||||
test('one tile per channel; hover moves single audio focus; leaving mutes all', async ({ page }) => {
|
||||
await page.route('**/*youtube*/**', (r) => r.abort());
|
||||
|
||||
await page.goto('/?variant=full');
|
||||
|
||||
// Un-hide the opt-in panel and scroll it into view → its IntersectionObserver fires
|
||||
// → the wall builds. (Toggling it on via the Panels menu is a separate trivial path.)
|
||||
await page.locator('.panel[data-panel="stations-wall"]').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.panel[data-panel="stations-wall"]') as HTMLElement | null;
|
||||
el?.classList.remove('hidden');
|
||||
el?.scrollIntoView();
|
||||
});
|
||||
|
||||
// One tile per channel (full variant ships ≥ 4 news channels).
|
||||
const tiles = page.locator('.stations-tile');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 30000 });
|
||||
expect(await tiles.count()).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Nothing focused initially → no tile is unmuted.
|
||||
await expect(page.locator('.stations-tile.stations-audio-on')).toHaveCount(0);
|
||||
|
||||
// Hover the first tile → exactly it gets audio focus + the 🔊 badge.
|
||||
await tiles.nth(0).hover();
|
||||
await expect(page.locator('.stations-tile.stations-audio-on')).toHaveCount(1);
|
||||
await expect(tiles.nth(0)).toHaveClass(/stations-audio-on/);
|
||||
await expect(tiles.nth(0).locator('.stations-audio')).toHaveText('🔊');
|
||||
|
||||
// Hover a second tile → focus MOVES (still exactly one); the first is muted again.
|
||||
await tiles.nth(1).hover();
|
||||
await expect(page.locator('.stations-tile.stations-audio-on')).toHaveCount(1);
|
||||
await expect(tiles.nth(1)).toHaveClass(/stations-audio-on/);
|
||||
await expect(tiles.nth(0)).not.toHaveClass(/stations-audio-on/);
|
||||
|
||||
// Leaving the wall entirely mutes everything.
|
||||
await page.locator('.stations-grid').dispatchEvent('mouseleave');
|
||||
await expect(page.locator('.stations-tile.stations-audio-on')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
// Markets Bubble — the whole market universe as one D3 circle-pack.
|
||||
//
|
||||
// The panel is fed by the shared market-universe service (Yahoo passthrough +
|
||||
// CoinGecko), joined to the universe metadata. We mock both endpoints with small
|
||||
// deterministic fixtures, un-hide the opt-in panel and scroll it into view to trip
|
||||
// its IntersectionObserver → lazy build (same path stations-wall uses; the full
|
||||
// variant has no finance TradingView terminal to occlude the hover). Then we assert:
|
||||
// bubbles render across the classes (a class ring/label per class, many leaf
|
||||
// circles), hovering a leaf shows the tooltip with a signed %, and the panel survives
|
||||
// a live re-poll (under VITE_E2E the poll runs on a short cadence and the service
|
||||
// cache TTL is tiny, so a real second round-trip happens within the test).
|
||||
|
||||
// Deterministic percent moves — a mix of up/down plus a big mover, so leaves span the
|
||||
// green→red diverging scale.
|
||||
const PCTS = [3.4, -2.6, 0.3, -4.8, 1.9, -0.7, 2.2, -1.4];
|
||||
|
||||
function yahooBatchBody(url: string): unknown {
|
||||
const symbols = (new URL(url).searchParams.get('symbols') ?? '').split(',').filter(Boolean);
|
||||
const results = symbols.map((symbol, i) => {
|
||||
const pct = PCTS[i % PCTS.length];
|
||||
const price = 100 + i;
|
||||
const previousClose = price / (1 + pct / 100);
|
||||
return {
|
||||
symbol,
|
||||
chart: {
|
||||
chart: {
|
||||
result: [
|
||||
{
|
||||
meta: { regularMarketPrice: price, previousClose },
|
||||
indicators: { quote: [{ close: [previousClose, price] }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return { results };
|
||||
}
|
||||
|
||||
const COINGECKO = [
|
||||
{ id: 'bitcoin', current_price: 62000, price_change_percentage_24h: 2.8, sparkline_in_7d: { price: [61000, 62000] } },
|
||||
{ id: 'ethereum', current_price: 3400, price_change_percentage_24h: -1.9, sparkline_in_7d: { price: [3450, 3400] } },
|
||||
{ id: 'solana', current_price: 145, price_change_percentage_24h: 5.1, sparkline_in_7d: { price: [138, 145] } },
|
||||
];
|
||||
|
||||
test.describe('Markets Bubble — every asset class as one circle-pack', () => {
|
||||
test('renders bubbles across classes, tooltips on hover, and survives a re-poll', async ({ page }) => {
|
||||
let batchHits = 0;
|
||||
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
await page.route('**/v1/world/yahoo-batch**', (r) => {
|
||||
batchHits += 1;
|
||||
return r.fulfill(json(yahooBatchBody(r.request().url())));
|
||||
});
|
||||
await page.route('**/v1/world/coingecko**', (r) => r.fulfill(json(COINGECKO)));
|
||||
|
||||
await page.goto('/?variant=full');
|
||||
|
||||
// Un-hide the opt-in panel and scroll it into view → IntersectionObserver fires →
|
||||
// the bubble builds lazily.
|
||||
const panel = page.locator('.panel[data-panel="trading-bubble"]');
|
||||
await panel.waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.panel[data-panel="trading-bubble"]') as HTMLElement | null;
|
||||
el?.classList.remove('hidden');
|
||||
el?.scrollIntoView();
|
||||
});
|
||||
|
||||
// Leaf circles render — the universe has 34 Yahoo symbols + 3 crypto, so plenty.
|
||||
const leaves = page.locator('.trading-bubble .tb-leaf-circle');
|
||||
await expect(leaves.first()).toBeVisible({ timeout: 30000 });
|
||||
expect(await leaves.count()).toBeGreaterThan(20);
|
||||
|
||||
// One cluster per class present — all five classes are mocked, so ≥ 3.
|
||||
const classLabels = page.locator('.trading-bubble .tb-class-label');
|
||||
expect(await classLabels.count()).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Hover the largest bubble → tooltip appears with a signed %.
|
||||
await leaves.first().hover();
|
||||
const tooltip = page.locator('.trading-bubble-tooltip.visible');
|
||||
await expect(tooltip).toBeVisible({ timeout: 10000 });
|
||||
await expect(tooltip.locator('.tb-tt-chg')).toContainText('%');
|
||||
|
||||
// Survives a live re-poll: the short-cadence e2e poll makes a second upstream
|
||||
// round-trip, and the circles are reused by id (count stays stable, no teardown).
|
||||
const before = await leaves.count();
|
||||
await expect.poll(() => batchHits, { timeout: 12000 }).toBeGreaterThan(1);
|
||||
await expect(leaves.first()).toBeVisible();
|
||||
expect(await leaves.count()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* UI/UX polish e2e — the four changes shipped together, each asserted against
|
||||
* the real DOM/CSS rather than by eye:
|
||||
*
|
||||
* 1. Right-click a headline → "Summarize with AI" is the FIRST item, and it
|
||||
* opens the analyst dock with the story as the question.
|
||||
* 2. The app header carries no underline (border-bottom).
|
||||
* 3. The map's drag pill is gone, but the grip strip is still the drag target.
|
||||
* 4. "Try Hanzo" is an acquisition CTA: visible signed-out, hidden once
|
||||
* identity resolves as signed-in.
|
||||
*/
|
||||
|
||||
const SCREENS = 'e2e/screens';
|
||||
|
||||
async function stubWorldApi(page: Page): Promise<void> {
|
||||
await page.route('**/v1/world/**', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes('/v1/world/analyst')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
reply: 'Stubbed summary.',
|
||||
actions: [],
|
||||
model: 'zen5',
|
||||
tokens: 0,
|
||||
traces: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes('/v1/world/models')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ models: [{ id: 'zen5', name: 'Zen 5' }], default: 'zen5' }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign in the way the analyst e2e does — the analyst only accepts a question
|
||||
* when identity is present, so the summarize path needs a signed-in session. */
|
||||
async function signIn(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
|
||||
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
|
||||
});
|
||||
}
|
||||
|
||||
async function appReady(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.panel', { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** Inject a news-shaped item — the exact data-ctx-* convention NewsPanel emits. */
|
||||
async function addNewsItem(page: Page): Promise<void> {
|
||||
// The app defaults to FREE layout (absolutely-positioned panels); a raw injected
|
||||
// panel has no coordinates and stacks at 0,0 under the full-width map, which then
|
||||
// eats the right-click. Pin grid mode so the injected panel flows to the grid end
|
||||
// and is a normal, hit-testable child (real news items are positioned by the
|
||||
// layout engine — only this raw test injection needs the nudge).
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'),
|
||||
);
|
||||
await page.waitForTimeout(80);
|
||||
await page.evaluate(() => {
|
||||
const grid = document.querySelector('#panelsGrid')!;
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'panel';
|
||||
panel.dataset.panel = 'e2e-ui-panel';
|
||||
panel.innerHTML =
|
||||
'<div class="panel-content"><div class="item" id="e2e-news-item" ' +
|
||||
'data-ctx-url="https://example.com/story" data-ctx-headline="Nvidia unveils new GPU" ' +
|
||||
'style="padding:12px;min-height:24px;">Nvidia unveils new GPU</div></div>';
|
||||
grid.appendChild(panel);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('ui polish', () => {
|
||||
test('headline right-click leads with "Summarize with AI" and opens the analyst', async ({ page }) => {
|
||||
await stubWorldApi(page);
|
||||
await signIn(page); // the analyst answers only for a signed-in identity
|
||||
await appReady(page);
|
||||
await addNewsItem(page);
|
||||
|
||||
await page.locator('#e2e-news-item').scrollIntoViewIfNeeded();
|
||||
await page.locator('#e2e-news-item').click({ button: 'right' });
|
||||
await expect(page.locator('#panelContextMenu')).toBeVisible();
|
||||
|
||||
const labels = await page
|
||||
.locator('#panelContextMenu .panel-context-menu-item')
|
||||
.allTextContents();
|
||||
const trimmed = labels.map((l) => l.trim());
|
||||
// It is the action you want on a headline → it leads the menu.
|
||||
expect(trimmed[0]).toBe('Summarize with AI');
|
||||
// The copy/open actions are still there, below it.
|
||||
expect(trimmed).toEqual(expect.arrayContaining(['Open link', 'Copy link', 'Copy headline']));
|
||||
|
||||
await page.screenshot({ path: `${SCREENS}/ctxmenu-summarize.png` });
|
||||
|
||||
// Clicking it routes into the ONE analyst dock, pre-asked with the story.
|
||||
await page
|
||||
.locator('#panelContextMenu .panel-context-menu-item', { hasText: 'Summarize with AI' })
|
||||
.click();
|
||||
// .hzc is a 0x0 wrapper (children are position:fixed) — the panel is the
|
||||
// surface that actually opens.
|
||||
await expect(page.locator('.hzc-panel')).toBeVisible();
|
||||
// The question carries the headline (the analyst is asked, not just opened).
|
||||
await expect(page.locator('.hzc-row.user').first()).toContainText('Nvidia unveils new GPU');
|
||||
});
|
||||
|
||||
test('app header has no underline', async ({ page }) => {
|
||||
await stubWorldApi(page);
|
||||
await appReady(page);
|
||||
const borderBottom = await page
|
||||
.locator('.header')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).borderBottomWidth);
|
||||
expect(borderBottom).toBe('0px');
|
||||
});
|
||||
|
||||
test('map shows no drag pill, but the grip strip still drags', async ({ page }) => {
|
||||
await stubWorldApi(page);
|
||||
await appReady(page);
|
||||
const grip = page.locator('.map-panel .panel-header.map-drag-grip');
|
||||
await expect(grip).toHaveCount(1);
|
||||
|
||||
// No visible indicator …
|
||||
const pill = await grip.evaluate((el) => getComputedStyle(el, '::after').content);
|
||||
expect(pill === 'none' || pill === 'normal').toBe(true);
|
||||
|
||||
// … but it is still the drag target (that is what makes hiding it safe).
|
||||
await expect(grip).toHaveCSS('cursor', 'grab');
|
||||
});
|
||||
|
||||
test('"Try Hanzo" hides once signed in', async ({ page }) => {
|
||||
await stubWorldApi(page);
|
||||
await appReady(page);
|
||||
|
||||
const cta = page.locator('.try-hanzo');
|
||||
await expect(cta).toBeVisible(); // signed-out: the CTA is the point
|
||||
|
||||
// The one signal identity resolution emits.
|
||||
await page.evaluate(() => {
|
||||
document.dispatchEvent(new CustomEvent('hanzo:auth', { detail: { authed: true } }));
|
||||
});
|
||||
await expect(cta).toBeHidden();
|
||||
|
||||
// And it comes back on sign-out.
|
||||
await page.evaluate(() => {
|
||||
document.dispatchEvent(new CustomEvent('hanzo:auth', { detail: { authed: false } }));
|
||||
});
|
||||
await expect(cta).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// In-place variant switch (the tab-switch freeze fix).
|
||||
//
|
||||
// The header variant tabs [Cloud | AI | Crypto | Finance | Tech | World] used to
|
||||
// switch by setting window.location.href — a full page reload that re-created the
|
||||
// deck.gl WebGL context, respawned the ML workers, re-mounted the video iframes and
|
||||
// cold-refetched every feed. That churn WAS the freeze. The switch is now in place:
|
||||
// recompute the target variant's config and re-apply it live, like the intra-variant
|
||||
// panel toggle — no reload, heavy singletons kept warm.
|
||||
//
|
||||
// The airtight proof of "no reload": inject a sentinel into the JS context AFTER the
|
||||
// app boots. A full navigation destroys the context and wipes the sentinel; an
|
||||
// in-place switch preserves it. We also assert the SAME <canvas> survives (deck.gl
|
||||
// not torn down), the panel set swaps, the active tab + URL re-point, and the switch
|
||||
// is fast.
|
||||
|
||||
const TRAFFIC = {
|
||||
updatedAt: '2026-07-18T12:00:00Z',
|
||||
live: true,
|
||||
window: { minutes: 60, since: '2026-07-18T11:00:00Z', until: '2026-07-18T12:00:00Z' },
|
||||
points: [
|
||||
{ country: 'US', lat: 37.09, lon: -95.71, count: 42, byService: { models: 42 } },
|
||||
{ country: 'GB', lat: 55.38, lon: -3.44, count: 12, byService: { models: 12 } },
|
||||
],
|
||||
totals: { rps_1m: 0.9, rpm_60m: 54, top_countries: [{ country: 'US', count: 42 }, { country: 'GB', count: 12 }] },
|
||||
};
|
||||
|
||||
async function mockCloud(page: import('@playwright/test').Page): Promise<void> {
|
||||
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
await page.route('**/v1/world/cloud/traffic-globe', (r) => r.fulfill(json(TRAFFIC)));
|
||||
await page.route('**/v1/world/cloud/traffic', (r) => r.fulfill(json({ updatedAt: TRAFFIC.updatedAt, demo: false, arcs: [] })));
|
||||
await page.route('**/v1/world/cloud/chain-nodes', (r) => r.fulfill(json({ updatedAt: TRAFFIC.updatedAt, positionsModeled: true, networks: [] })));
|
||||
await page.route('**/v1/world/cloud/byo-gpu', (r) => r.fulfill(json({ updatedAt: TRAFFIC.updatedAt, demo: false, gpus: [] })));
|
||||
}
|
||||
|
||||
test.describe('Variant switch — in place, no reload', () => {
|
||||
test('switching tabs keeps the JS context, the globe canvas, and swaps the panel set', async ({ page }) => {
|
||||
await mockCloud(page);
|
||||
await page.goto('/?variant=cloud');
|
||||
|
||||
// Reveal the switcher (flagship cloud starts collapsed behind the H toggle).
|
||||
await page.locator('[data-hanzo-toggle]').click();
|
||||
const switcher = page.locator('.variant-switcher');
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(1);
|
||||
|
||||
// The globe mounts ONE deck.gl <canvas>. Marking it lets us later prove it is the
|
||||
// SAME element after a switch (WebGL context never torn down). This is a bonus
|
||||
// proof layered on top of the airtight sentinel below: it only fires where a GL
|
||||
// context actually renders. Under headless swiftshader (CI) deck.gl may not create
|
||||
// a canvas at all, and in the immersive cloud view the globe is a fixed background
|
||||
// rather than a panel child — so we mark the canvas IF one is present and gate the
|
||||
// identity assertions on that, instead of blocking the whole test on GL rendering.
|
||||
const globe = page.locator('canvas').first();
|
||||
const canvasMarked = await globe
|
||||
.waitFor({ state: 'attached', timeout: 15000 })
|
||||
.then(() => globe.evaluate((c) => { (c as HTMLCanvasElement & { __globeMark?: string }).__globeMark = 'orig'; }))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const globeSurvived = async (): Promise<boolean> =>
|
||||
!canvasMarked ||
|
||||
(await globe.evaluate((c) => (c as HTMLCanvasElement & { __globeMark?: string }).__globeMark === 'orig').catch(() => false));
|
||||
|
||||
// Sentinel in the live JS context. A reload destroys the context → the sentinel
|
||||
// is gone. Survival across a tab click == no navigation happened.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __noReload?: string }).__noReload = 'sentinel';
|
||||
window.addEventListener('beforeunload', () => {
|
||||
(window as unknown as { __unloaded?: boolean }).__unloaded = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Switch Cloud → AI.
|
||||
await page.locator('.variant-option[data-variant="ai"]').click();
|
||||
await page.waitForFunction(() => new URLSearchParams(location.search).get('variant') === 'ai', undefined, { timeout: 20000 });
|
||||
|
||||
// The switch's SYNCHRONOUS cost — the actual "is it a cold-start freeze?" measure —
|
||||
// is recorded on window.__switchT by setSiteVariant. This is the honest perf gate:
|
||||
// it times the switch code itself, immune to boot/GL thread-contention that inflates
|
||||
// wall-clock under headless software rendering (where the click merely queues behind
|
||||
// the cold globe render). A reload would cold-start everything; the in-place switch
|
||||
// is a few ms.
|
||||
const switchCost = await page.evaluate(() => (window as unknown as { __switchT?: { total?: number } }).__switchT?.total ?? -1);
|
||||
expect(switchCost, `in-place switch synchronous cost was ${switchCost}ms`).toBeGreaterThanOrEqual(0);
|
||||
expect(switchCost, `in-place switch synchronous cost was ${switchCost}ms`).toBeLessThan(500);
|
||||
|
||||
// The active tab + URL re-pointed to AI, with NO reload.
|
||||
await expect(page.locator('.variant-option[data-variant="ai"].active')).toHaveCount(1);
|
||||
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/variant=ai/);
|
||||
|
||||
// The airtight assertions: the JS context survived (sentinel intact, no unload) —
|
||||
// a reload would have wiped both. Plus, where a GL canvas rendered, it is the very
|
||||
// same element (globe/WebGL never torn down).
|
||||
expect(await page.evaluate(() => (window as unknown as { __noReload?: string }).__noReload)).toBe('sentinel');
|
||||
expect(await page.evaluate(() => (window as unknown as { __unloaded?: boolean }).__unloaded)).toBeFalsy();
|
||||
expect(await globeSurvived()).toBe(true);
|
||||
|
||||
// The panel set swapped in place: an AI panel is now visible, the cloud-only
|
||||
// live-traffic panel is hidden (kept alive, just `.hidden`).
|
||||
await expect(page.locator('.panel[data-panel="ai-compute"]:not(.hidden)')).toHaveCount(1);
|
||||
await expect(page.locator('.panel[data-panel="traffic-globe"]:not(.hidden)')).toHaveCount(0);
|
||||
|
||||
// Two more switches to prove it holds across the family, still no reload.
|
||||
await page.locator('.variant-option[data-variant="finance"]').click();
|
||||
await expect(page).toHaveURL(/variant=finance/);
|
||||
await expect(page.locator('.variant-option[data-variant="finance"].active')).toHaveCount(1);
|
||||
// Finance markets panel shows; the AI compute panel is hidden.
|
||||
await expect(page.locator('.panel[data-panel="markets"]:not(.hidden)')).toHaveCount(1);
|
||||
|
||||
await page.locator('.variant-option[data-variant="full"]').click();
|
||||
await expect(page.locator('.variant-option[data-variant="full"].active')).toHaveCount(1);
|
||||
await expect(page).not.toHaveURL(/variant=/); // full is the canonical default — no param
|
||||
|
||||
// Still the same context + same globe canvas after three switches.
|
||||
expect(await page.evaluate(() => (window as unknown as { __noReload?: string }).__noReload)).toBe('sentinel');
|
||||
expect(await globeSurvived()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,14 @@ async function appReady(page: Page): Promise<void> {
|
||||
undefined,
|
||||
{ timeout: 45000 },
|
||||
);
|
||||
// The app now DEFAULTS to free layout (independent, non-reflowing panels). These
|
||||
// specs exercise the GRID reorder/ghost/row-span/reset machinery specifically, so
|
||||
// pin grid mode (still a first-class, dropdown-selectable mode). setLayoutMode
|
||||
// marks the choice explicit, so the deferred default-to-free never re-flips it.
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'),
|
||||
);
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
function gridOrder(page: Page): Promise<(string | undefined)[]> {
|
||||
@@ -29,6 +37,43 @@ function gridOrder(page: Page): Promise<(string | undefined)[]> {
|
||||
);
|
||||
}
|
||||
|
||||
// Reorder by dragging a LATER on-screen content panel UP onto an EARLIER one, and
|
||||
// return the moved panel's key. The grid is far taller than the viewport, so a drop
|
||||
// at the grid's geometric bottom lands in empty space with no panel to reorder
|
||||
// against; the reorder needs a real panel under the pointer. Dragging up (later →
|
||||
// earlier) guarantees the DOM order changes — dragging an earlier panel down onto
|
||||
// its adjacent neighbour would re-drop it exactly where it already sits.
|
||||
async function dragReorderOnScreen(page: Page): Promise<string> {
|
||||
const onscreen = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('#panelsGrid .panel'))
|
||||
.map((p) => {
|
||||
const r = (p as HTMLElement).getBoundingClientRect();
|
||||
return { k: (p as HTMLElement).dataset.panel, y: r.y, cy: r.y + r.height / 2 };
|
||||
})
|
||||
// grabbable header + droppable centre inside the viewport; the map + live-news
|
||||
// are pinned anchors, never reorder subjects.
|
||||
.filter((b) => b.k && b.k !== 'map' && b.k !== 'live-news' && b.y > 60 && b.cy < 680)
|
||||
.map((b) => b.k as string),
|
||||
);
|
||||
expect(onscreen.length).toBeGreaterThan(1);
|
||||
const target = onscreen[0];
|
||||
const movable = onscreen[onscreen.length - 1];
|
||||
|
||||
const sb = (await page.locator(`#panelsGrid .panel[data-panel="${movable}"] .panel-header`).first().boundingBox())!;
|
||||
const tb = (await page.locator(`#panelsGrid .panel[data-panel="${target}"]`).boundingBox())!;
|
||||
await page.mouse.move(sb.x + 40, sb.y + sb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sb.x + 60, sb.y + sb.height / 2 + 20, { steps: 6 }); // cross the 6px press threshold
|
||||
// The reorder decides insert-before vs -after purely by X (panel-drag.ts: a drop
|
||||
// on the target's LEFT half inserts before it). Aim well inside the left quarter,
|
||||
// not the centre, so the drop is unambiguously "before" and doesn't oscillate on
|
||||
// the gap-opening FLIP reflow; after inserting, the pointer rests over the moved
|
||||
// (excluded) source, so no further reorder fires.
|
||||
await page.mouse.move(tb.x + tb.width * 0.25, tb.y + tb.height / 2, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
return movable;
|
||||
}
|
||||
|
||||
test.describe('video panel drag + reset (live app)', () => {
|
||||
test('the live-news video panel is grabbable by its header and its iframe yields during drag', async ({ page }) => {
|
||||
await appReady(page);
|
||||
@@ -62,18 +107,7 @@ test.describe('video panel drag + reset (live app)', () => {
|
||||
await appReady(page);
|
||||
const before = await gridOrder(page);
|
||||
|
||||
// Pick a mid-grid draggable content panel (not live-news, which is pinned).
|
||||
const movable = before.find((k) => k && k !== 'live-news' && k !== 'map')!;
|
||||
const src = page.locator(`#panelsGrid .panel[data-panel="${movable}"]`);
|
||||
const sb = (await src.locator('.panel-header').first().boundingBox())!;
|
||||
const grid = (await page.locator('#panelsGrid').boundingBox())!;
|
||||
|
||||
await page.mouse.move(sb.x + 40, sb.y + sb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sb.x + 60, sb.y + sb.height / 2 + 20, { steps: 6 });
|
||||
// Sweep to the bottom of the grid → drop near the end.
|
||||
await page.mouse.move(grid.x + grid.width * 0.5, grid.y + grid.height - 30, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
const movable = await dragReorderOnScreen(page);
|
||||
|
||||
const after = await gridOrder(page);
|
||||
expect(after).not.toEqual(before);
|
||||
@@ -94,7 +128,12 @@ test.describe('video panel drag + reset (live app)', () => {
|
||||
await page.mouse.move(h.x + h.width / 2, h.y + h.height / 2 + 230, { steps: 14 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(panel).toHaveClass(/span-2/);
|
||||
// Height resize lands on the fine 16px row grid (smooth ~20px steps), so the
|
||||
// panel carries `resized` + a data-span well above the ~100px minSpan — not the
|
||||
// coarse span-2 tier class the old assertion expected.
|
||||
await expect(panel).toHaveClass(/resized/);
|
||||
const span = await panel.evaluate((el) => parseInt((el as HTMLElement).dataset.span ?? '0', 10));
|
||||
expect(span).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test('Reset layout returns the grid to the default order', async ({ page }) => {
|
||||
@@ -102,15 +141,7 @@ test.describe('video panel drag + reset (live app)', () => {
|
||||
const original = await gridOrder(page);
|
||||
|
||||
// Reorder a panel so the layout is dirty.
|
||||
const movable = original.find((k) => k && k !== 'live-news' && k !== 'map')!;
|
||||
const src = page.locator(`#panelsGrid .panel[data-panel="${movable}"]`);
|
||||
const sb = (await src.locator('.panel-header').first().boundingBox())!;
|
||||
const grid = (await page.locator('#panelsGrid').boundingBox())!;
|
||||
await page.mouse.move(sb.x + 40, sb.y + sb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(sb.x + 60, sb.y + sb.height / 2 + 20, { steps: 6 });
|
||||
await page.mouse.move(grid.x + grid.width * 0.5, grid.y + grid.height - 30, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
await dragReorderOnScreen(page);
|
||||
expect(await gridOrder(page)).not.toEqual(original);
|
||||
|
||||
// Open the Panels menu and hit Reset layout (this reloads the app).
|
||||
@@ -121,11 +152,11 @@ test.describe('video panel drag + reset (live app)', () => {
|
||||
page.click('#resetLayoutBtn'),
|
||||
]);
|
||||
|
||||
// After reset+reload the grid rebuilds from DEFAULT_PANELS: live-news first,
|
||||
// and the order matches a fresh session.
|
||||
// After reset+reload the grid rebuilds from DEFAULT_PANELS: the full-width map
|
||||
// is the leading anchor, and the order matches a fresh session.
|
||||
await appReady(page);
|
||||
const reset = await gridOrder(page);
|
||||
expect(reset[0]).toBe('live-news');
|
||||
expect(reset[0]).toBe('map');
|
||||
expect(reset).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
module github.com/hanzoai/world
|
||||
|
||||
go 1.23
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/hanzoai/sqlite v0.3.2
|
||||
github.com/redis/go-redis/v9 v9.20.0
|
||||
github.com/valyala/fasthttp v1.70.0
|
||||
github.com/zap-proto/fiber/v3 v3.2.1
|
||||
github.com/zap-proto/zip v1.10.0
|
||||
golang.org/x/net v0.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanw/esbuild v0.28.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.4 // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hanzoai/csqlite v0.1.0 // indirect
|
||||
github.com/hanzoai/sqlcipher v0.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/luxfi/log v1.4.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zap-proto/go v1.3.0 // indirect
|
||||
github.com/zap-proto/http v0.3.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
|
||||
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
|
||||
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
|
||||
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hanzoai/csqlite v0.1.0 h1:suwC3dh0INlfP/U0Es6cDf6JNQ+2+GVLLATPWCUux6k=
|
||||
github.com/hanzoai/csqlite v0.1.0/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
|
||||
github.com/hanzoai/sqlcipher v0.1.0 h1:V9gKG3ZltN2ZCteDrOnXWfOeEe/YDhhUm9AorQEAuBo=
|
||||
github.com/hanzoai/sqlcipher v0.1.0/go.mod h1:F0soUYM1i4sawOZUpRvVnWoUayPbeGVlGq01VXy9Aqg=
|
||||
github.com/hanzoai/sqlite v0.3.2 h1:B/TRunlIDZECEypmr6rHeNyWf37YZXvPJHBDEFMKn/g=
|
||||
github.com/hanzoai/sqlite v0.3.2/go.mod h1:a3llsefKbu2Iq/0rJ1mlWCaU2t2cXh+aze85x+oW72k=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
|
||||
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zap-proto/fiber/v3 v3.2.1 h1:k45oKyTwySPtGt8sPz2Ao8OUHc7pDEhai8Np2Ym6Jbg=
|
||||
github.com/zap-proto/fiber/v3 v3.2.1/go.mod h1:eDm2z+ufJrkuE4MeX0Mea4oc/p7/HpXiZjVB+BXCKOA=
|
||||
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
|
||||
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
|
||||
github.com/zap-proto/http v0.3.0 h1:l7DvlngiYqmzNY6fzyRYw2ZIAhF35FqwOe6mvAOqpMg=
|
||||
github.com/zap-proto/http v0.3.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
|
||||
github.com/zap-proto/zip v1.10.0 h1:0Swzr+SNr+4VeO8pUw+Umdkh3z/lnD0hJ4N1kObdP60=
|
||||
github.com/zap-proto/zip v1.10.0/go.mod h1:9R3FOq2ItZa7G+9QilsB/punEpVSHtdXiQji0P84LSE=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -13,6 +13,15 @@ images:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/world
|
||||
# Publishable Mapbox token (pk, URL-restricted to world.hanzo.ai) baked into
|
||||
# the Vite SPA at build so the Satellite/Terrain basemaps load (Dark stays
|
||||
# keyless CartoDB). Sourced from KMS (hanzo/deploy/VITE_MAPBOX_TOKEN,
|
||||
# env=prod) by hanzoai/ci — the key name IS the build-arg. Never in git.
|
||||
build_secrets:
|
||||
- VITE_MAPBOX_TOKEN
|
||||
- VITE_SENTRY_DSN
|
||||
- VITE_ANALYTICS_WEBSITE_ID
|
||||
- VITE_GTM_ID
|
||||
|
||||
# Deploy: roll the freshly-built image onto the `world` Service CR. Fires only
|
||||
# on `main` (+ tags); PRs and forks build but never deploy. KMS hands back the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://www.youtube.com https://www.youtube-nocookie.com;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.hanzo.ai https://www.youtube.com https://static.cloudflareinsights.com https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://snap.licdn.com https://static.ads-twitter.com https://s3.tradingview.com https://s.tradingview.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://www.youtube.com https://www.youtube-nocookie.com https://www.tradingview-widget.com https://s.tradingview.com https://www.tradingview.com;" />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
|
||||
@@ -3,6 +3,7 @@ package world
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -26,10 +27,11 @@ func newAIClient() *AIClient {
|
||||
}
|
||||
model := env("HANZO_AI_MODEL")
|
||||
if model == "" {
|
||||
// zen5 = the current flagship general Zen chat model. The bare "zen" alias
|
||||
// is NOT a servable id (api.hanzo.ai/v1/models), so it would 4xx and force
|
||||
// every AI endpoint (analyst included) into its fallback path.
|
||||
model = "zen5"
|
||||
// "best" is the gateway's own routing alias (owned_by hanzo in /v1/models):
|
||||
// it always resolves to a servable model. A pinned family id (zen5) goes
|
||||
// dark whenever the plane's claim catalog shifts — an unclaimed id falls
|
||||
// through to a proxy that rejects every credential, killing all AI here.
|
||||
model = "best"
|
||||
}
|
||||
return &AIClient{
|
||||
base: strings.TrimRight(base, "/"),
|
||||
@@ -112,10 +114,20 @@ type chatRequest struct {
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
// ID is the gateway's response id (OpenAI chat.completion `id`). It is the
|
||||
// stable key a content-free reward signal is attributed to (POST /v1/feedback),
|
||||
// so it is threaded back out of every completion — never the prompt/response.
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
// A reasoning model (gpt-oss / harmony, deepseek-r1) surfaces its working
|
||||
// on a SEPARATE channel; when it emits no final content we fall back to
|
||||
// this so the analyst still recovers an answer, not a blank "empty response".
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
@@ -136,16 +148,21 @@ func (a *AIClient) chat(ctx context.Context, s *Server, bearer, system, user str
|
||||
// prior turns) and returns the trimmed content. It is the single completion path;
|
||||
// chat is the system+user special case. bearer is the caller's IAM token so the
|
||||
// inference meters to their org/project/billing; extra forwards the caller's
|
||||
// org/project selectors so it meters to the org the user is acting in.
|
||||
// org/project selectors so it meters to the org the user is acting in. It drops
|
||||
// the gateway response id — only the analyst's reward-signal path
|
||||
// (runAnalystLoop → chatMessagesModel) needs it.
|
||||
func (a *AIClient) chatMessages(ctx context.Context, s *Server, bearer string, messages []chatMessage, temperature float64, maxTokens int, extra map[string]string) (string, int, error) {
|
||||
return a.chatMessagesModel(ctx, s, bearer, a.model, messages, temperature, maxTokens, extra)
|
||||
out, tokens, _, err := a.chatMessagesModel(ctx, s, bearer, a.model, messages, temperature, maxTokens, extra)
|
||||
return out, tokens, err
|
||||
}
|
||||
|
||||
// chatMessagesModel is chatMessages with an explicit model override — the single
|
||||
// completion path once a caller (the analyst's model dropdown) chooses the model.
|
||||
// An empty model falls back to the client default (a.model). Everything else —
|
||||
// auth forwarding, org/project selectors, degrade semantics — is identical.
|
||||
func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, model string, messages []chatMessage, temperature float64, maxTokens int, extra map[string]string) (string, int, error) {
|
||||
// auth forwarding, org/project selectors, degrade semantics — is identical. It
|
||||
// also returns the gateway response id (chatResponse.ID) so the analyst can key a
|
||||
// content-free reward signal to it; "" when the gateway omits one.
|
||||
func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, model string, messages []chatMessage, temperature float64, maxTokens int, extra map[string]string) (string, int, string, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = a.model
|
||||
}
|
||||
@@ -157,7 +174,7 @@ func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, mod
|
||||
TopP: 0.9,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
return "", 0, "", err
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Authorization": bearer,
|
||||
@@ -170,19 +187,166 @@ func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, mod
|
||||
defer cancel()
|
||||
body, status, err := s.do(cctx, "POST", a.base+"/chat/completions", headers, reqBody)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
logf("world: ai request error: model=%s: %v", model, err)
|
||||
return "", 0, "", err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", status, fmt.Errorf("hanzo ai status %d", status)
|
||||
e := newAIError(status, body)
|
||||
logf("world: ai non-success: status=%d code=%q model=%s", status, e.code, model)
|
||||
return "", status, "", e
|
||||
}
|
||||
var cr chatResponse
|
||||
if err := json.Unmarshal(body, &cr); err != nil {
|
||||
return "", status, err
|
||||
logf("world: ai decode error: status=%d model=%s: %v", status, model, err)
|
||||
return "", status, "", err
|
||||
}
|
||||
if len(cr.Choices) == 0 {
|
||||
return "", status, fmt.Errorf("empty response")
|
||||
// The gateway answers some failures — notably insufficient balance — with a
|
||||
// 2xx AND an error envelope carrying NO choices. Parse that envelope with the
|
||||
// SAME lenient logic aiStatusError uses so an out-of-credits user gets a typed,
|
||||
// actionable error (→ top-up CTA) instead of an opaque "empty response".
|
||||
if e := newAIError(status, body); e.code != "" || e.msg != "" {
|
||||
logf("world: ai 2xx empty-choices error: status=%d code=%q model=%s", status, e.code, model)
|
||||
return "", status, "", e
|
||||
}
|
||||
logf("world: ai 2xx no choices: status=%d model=%s", status, model)
|
||||
return "", status, "", emptyAnswerErr(model)
|
||||
}
|
||||
return strings.TrimSpace(cr.Choices[0].Message.Content), cr.Usage.TotalTokens, nil
|
||||
// Prefer the answer (content) channel; fall back to the reasoning channel a
|
||||
// reasoning model (gpt-oss/harmony, deepseek-r1) uses when it returns no content —
|
||||
// so the analyst recovers its envelope/prose instead of a blank "empty response".
|
||||
msg := cr.Choices[0].Message
|
||||
// Pre-trim so a whitespace-only content channel falls through to reasoning.
|
||||
out := firstNonEmpty(strings.TrimSpace(msg.Content), strings.TrimSpace(msg.ReasoningContent), strings.TrimSpace(msg.Reasoning))
|
||||
if out == "" {
|
||||
// A 2xx with a real but content-free choice (e.g. a reasoning model that hit the
|
||||
// token cap before emitting content). The body IS a normal completion here — do
|
||||
// NOT run it through newAIError (its top-level `id` would be misread as an error
|
||||
// code); surface the model + stop reason honestly instead.
|
||||
logf("world: ai 2xx empty answer: status=%d model=%s finish=%s", status, model, cr.Choices[0].FinishReason)
|
||||
return "", status, cr.ID, emptyAnswerErr(model)
|
||||
}
|
||||
return out, cr.Usage.TotalTokens, cr.ID, nil
|
||||
}
|
||||
|
||||
// emptyAnswerErr is the honest error for a 2xx completion that carried no usable
|
||||
// answer (neither a content nor a reasoning channel). It names the served model so
|
||||
// the chat surfaces "the <model> model returned an empty answer" — actionable (switch
|
||||
// model) — instead of an opaque "empty response". Not a balance error, so it never
|
||||
// trips the top-up CTA.
|
||||
func emptyAnswerErr(model string) error {
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
return fmt.Errorf("the %s model returned an empty answer", model)
|
||||
}
|
||||
return fmt.Errorf("the model returned an empty answer")
|
||||
}
|
||||
|
||||
// aiError is a typed inference error carrying the upstream HTTP status plus the
|
||||
// error {code,message} parsed from the response body, so callers can branch on
|
||||
// the code (isBalanceError) instead of string-matching the rendered message. Its
|
||||
// Error() reproduces the actionable one-liner the SPA renders verbatim in chat:
|
||||
// "hanzo ai status <n>" plus the upstream code (or a short message) when present.
|
||||
type aiError struct {
|
||||
status int
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *aiError) Error() string {
|
||||
base := fmt.Sprintf("hanzo ai status %d", e.status)
|
||||
switch {
|
||||
case e.code != "":
|
||||
return base + ": " + e.code
|
||||
case e.msg != "":
|
||||
return base + ": " + trim80(e.msg)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// parseAIErrorBody leniently extracts an error {code, message} from an inference
|
||||
// or billing response body, across every shape the gateway/billing layers emit.
|
||||
// It is the SINGLE parse shared by the non-2xx path (newAIError) and the 2xx
|
||||
// empty-choices path (chatMessagesModel): the gateway answers some failures —
|
||||
// notably insufficient balance — with HTTP 2xx and an error envelope carrying no
|
||||
// choices, so both must read the same envelope. Empty strings ⇒ no error found.
|
||||
//
|
||||
// {"error":{"code":"insufficient_balance","message":…}} → code
|
||||
// {"error":"unauthorized","message":…} → "unauthorized"
|
||||
// {"id":"Unauthorized","message":…} → "Unauthorized"
|
||||
// {"detail":…} → message
|
||||
func parseAIErrorBody(body []byte) (code, msg string) {
|
||||
var p struct {
|
||||
Error json.RawMessage `json:"error"`
|
||||
Detail string `json:"detail"`
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(body, &p) != nil {
|
||||
return "", ""
|
||||
}
|
||||
msg = strings.TrimSpace(p.Message)
|
||||
if len(p.Error) > 0 {
|
||||
// error is either a nested {code,message} object or a bare string.
|
||||
var eo struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(p.Error, &eo) == nil {
|
||||
code = strings.TrimSpace(eo.Code)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(eo.Message)
|
||||
}
|
||||
} else {
|
||||
var es string
|
||||
if json.Unmarshal(p.Error, &es) == nil {
|
||||
code = strings.TrimSpace(es)
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(p.ID)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(p.Detail)
|
||||
}
|
||||
return code, msg
|
||||
}
|
||||
|
||||
// newAIError builds a typed aiError from a response status + body.
|
||||
func newAIError(status int, body []byte) *aiError {
|
||||
code, msg := parseAIErrorBody(body)
|
||||
return &aiError{status: status, code: code, msg: msg}
|
||||
}
|
||||
|
||||
// aiStatusError turns a non-2xx inference response into an ACTIONABLE error. The
|
||||
// SPA renders it verbatim in chat, so surfacing e.g. "insufficient_balance" turns
|
||||
// a dead chat into a fixable one; a body it can't parse degrades to the bare status.
|
||||
func aiStatusError(status int, body []byte) error {
|
||||
return newAIError(status, body)
|
||||
}
|
||||
|
||||
// balanceErrorFrom reports whether err is the canonical "out of credits" signal
|
||||
// the ONE AI gateway emits — HTTP 402 with code=insufficient_balance
|
||||
// (hanzo/ai routers/filter_balance.go, the single balance-enforcement point).
|
||||
// World is a thin client of that contract: it neither re-derives balance
|
||||
// semantics (no message keyword-matching) nor invents codes the backend never
|
||||
// sends. The analyst renders a top-up CTA for exactly this case.
|
||||
func balanceErrorFrom(err error) bool {
|
||||
var ae *aiError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.status == http.StatusPaymentRequired || ae.code == "insufficient_balance"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// trim80 trims s to at most 80 characters (runes, never splitting UTF-8) so a
|
||||
// long upstream message stays a compact one-liner in the chat error.
|
||||
func trim80(s string) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) > 80 {
|
||||
return strings.TrimSpace(string(r[:80]))
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// dateContext mirrors the original prompts' current-date grounding.
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package world
|
||||
|
||||
// Streaming inference — the SSE side of the ONE completion path.
|
||||
//
|
||||
// chatMessagesModelStream is chatMessagesModel with "stream":true: it reads the
|
||||
// upstream OpenAI-style SSE frames and hands each content delta to onDelta while
|
||||
// accumulating the full output, so callers keep the exact same (content, tokens,
|
||||
// error) contract and simply gain live deltas.
|
||||
//
|
||||
// replyExtractor solves the analyst's envelope problem: the model emits STRICT
|
||||
// JSON ({"reply":"…","actions":[…],"tools":[…]}), so raw deltas are not
|
||||
// user-presentable. The extractor is an incremental scanner fed those raw
|
||||
// deltas; it emits ONLY the unescaped contents of the top-level "reply" string
|
||||
// as it grows. Tool rounds ({"reply":"","tools":…}) therefore stream nothing,
|
||||
// and a model that ignores the envelope and answers in prose streams verbatim
|
||||
// (mirroring parseAnalystTurn's raw-output degrade).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chatMessagesModelStream runs one completion with streaming, calling onDelta
|
||||
// for every content chunk. Returns the full accumulated content, the token count,
|
||||
// and the gateway response id (first non-empty per-chunk id wins) so the analyst
|
||||
// can key a content-free reward signal to it. A nil onDelta degrades to a plain
|
||||
// buffered call semantically identical to chatMessagesModel.
|
||||
func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, bearer, model string, messages []chatMessage, temperature float64, maxTokens int, extra map[string]string, onDelta func(string)) (string, int, string, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = a.model
|
||||
}
|
||||
reqBody, err := json.Marshal(struct {
|
||||
chatRequest
|
||||
Stream bool `json:"stream"`
|
||||
}{
|
||||
chatRequest: chatRequest{Model: model, Messages: messages, Temperature: temperature, MaxTokens: maxTokens, TopP: 0.9},
|
||||
Stream: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodPost, a.base+"/chat/completions", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", bearer)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
for k, v := range extra {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return "", resp.StatusCode, "", aiStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Some gateways answer a stream request with a plain JSON body (model or
|
||||
// route not stream-capable). Sniff the content type and degrade quietly.
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "event-stream") {
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, "", err
|
||||
}
|
||||
var cr chatResponse
|
||||
if err := json.Unmarshal(body, &cr); err != nil {
|
||||
return "", resp.StatusCode, "", err
|
||||
}
|
||||
if len(cr.Choices) == 0 {
|
||||
return "", resp.StatusCode, "", emptyAnswerErr(model)
|
||||
}
|
||||
msg := cr.Choices[0].Message
|
||||
out := firstNonEmpty(strings.TrimSpace(msg.Content), strings.TrimSpace(msg.ReasoningContent), strings.TrimSpace(msg.Reasoning))
|
||||
if out == "" {
|
||||
return "", resp.StatusCode, cr.ID, emptyAnswerErr(model)
|
||||
}
|
||||
if onDelta != nil {
|
||||
onDelta(out)
|
||||
}
|
||||
return out, cr.Usage.TotalTokens, cr.ID, nil
|
||||
}
|
||||
|
||||
var full strings.Builder
|
||||
var reasoning strings.Builder
|
||||
tokens := 0
|
||||
id := ""
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var frame struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &frame) != nil {
|
||||
continue // tolerate keep-alives / vendor frames
|
||||
}
|
||||
if id == "" && frame.ID != "" {
|
||||
id = frame.ID // first non-empty id wins (every chunk repeats it)
|
||||
}
|
||||
if frame.Usage != nil {
|
||||
tokens = frame.Usage.TotalTokens
|
||||
}
|
||||
for _, c := range frame.Choices {
|
||||
// Reasoning deltas (gpt-oss/harmony, deepseek-r1) ride a separate channel.
|
||||
// Forward them too — the replyExtractor routes this pre-envelope prose to the
|
||||
// live "thinking" line — and keep them as the answer fallback for a model that
|
||||
// never emits a content channel. (Not trimmed: preserve inter-chunk spacing.)
|
||||
rc := c.Delta.ReasoningContent
|
||||
if rc == "" {
|
||||
rc = c.Delta.Reasoning
|
||||
}
|
||||
if rc != "" {
|
||||
reasoning.WriteString(rc)
|
||||
if onDelta != nil {
|
||||
onDelta(rc)
|
||||
}
|
||||
}
|
||||
if c.Delta.Content != "" {
|
||||
full.WriteString(c.Delta.Content)
|
||||
if onDelta != nil {
|
||||
onDelta(c.Delta.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
// A mid-stream drop still yields whatever arrived; the caller decides.
|
||||
if full.Len() == 0 && reasoning.Len() == 0 {
|
||||
return "", resp.StatusCode, "", err
|
||||
}
|
||||
}
|
||||
// The content channel is the answer; fall back to the reasoning channel only when
|
||||
// the model emitted no content at all (mirrors the buffered path above).
|
||||
out := strings.TrimSpace(full.String())
|
||||
if out == "" {
|
||||
out = strings.TrimSpace(reasoning.String())
|
||||
}
|
||||
return out, tokens, id, nil
|
||||
}
|
||||
|
||||
// ── incremental "reply" extraction ───────────────────────────────────────────
|
||||
|
||||
// replyExtractor states.
|
||||
const (
|
||||
rxSniff = iota // deciding: JSON envelope or raw prose?
|
||||
rxSeekKey // scanning for "reply"
|
||||
rxSeekColon
|
||||
rxSeekQuote
|
||||
rxInString // inside the reply value — emit unescaped runes
|
||||
rxDone // reply string closed (or prose mode ended)
|
||||
rxProse // no envelope — everything is the reply
|
||||
)
|
||||
|
||||
// replyExtractor incrementally extracts the top-level "reply" string value from
|
||||
// a streaming JSON envelope, emitting decoded text via emit. Reasoning models
|
||||
// (gpt-oss, zen3-omni) write prose BEFORE the envelope — that pre-envelope text
|
||||
// goes to emitThink (nil-safe) so the UI can show it as live thinking, and the
|
||||
// moment a '{' appears the scanner switches to envelope mode. Feed() accepts
|
||||
// arbitrary chunk boundaries (escapes and \uXXXX may split across chunks).
|
||||
type replyExtractor struct {
|
||||
state int
|
||||
emit func(string)
|
||||
emitThink func(string)
|
||||
// key matching
|
||||
keyBuf string
|
||||
// string decoding
|
||||
esc bool // last byte was a backslash
|
||||
uBuf string // pending \uXXXX hex digits ("" when not in a unicode escape)
|
||||
lead int // sniff: bytes of leading whitespace seen
|
||||
}
|
||||
|
||||
func newReplyExtractor(emit func(string)) *replyExtractor {
|
||||
return &replyExtractor{state: rxSniff, emit: emit}
|
||||
}
|
||||
|
||||
// Feed consumes the next raw chunk of model output.
|
||||
func (x *replyExtractor) Feed(chunk string) {
|
||||
i := 0
|
||||
for i < len(chunk) {
|
||||
c := chunk[i]
|
||||
switch x.state {
|
||||
case rxSniff:
|
||||
if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '{' {
|
||||
x.state = rxSeekKey
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// Pre-envelope prose (a reasoning model thinking out loud) — stream
|
||||
// it as thinking until the envelope's '{' shows up.
|
||||
x.state = rxProse
|
||||
case rxProse:
|
||||
j := i
|
||||
for j < len(chunk) && chunk[j] != '{' {
|
||||
j++
|
||||
}
|
||||
if j > i && x.emitThink != nil {
|
||||
x.emitThink(chunk[i:j])
|
||||
}
|
||||
if j >= len(chunk) {
|
||||
return
|
||||
}
|
||||
x.state = rxSeekKey // envelope begins
|
||||
i = j + 1
|
||||
case rxSeekKey:
|
||||
// scan for the exact key "reply" — cheap rolling match on quoted
|
||||
// runs; the envelope contract puts reply first, so this stays
|
||||
// shallow in practice.
|
||||
if c == '"' {
|
||||
x.keyBuf = ""
|
||||
j := i + 1
|
||||
for j < len(chunk) && chunk[j] != '"' {
|
||||
x.keyBuf += string(chunk[j])
|
||||
j++
|
||||
}
|
||||
if j >= len(chunk) {
|
||||
// key name split across chunks — resume in rxKeyPartial
|
||||
x.state = rxKeyPartial
|
||||
return
|
||||
}
|
||||
if x.keyBuf == "reply" {
|
||||
x.state = rxSeekColon
|
||||
}
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
case rxKeyPartial:
|
||||
for i < len(chunk) && chunk[i] != '"' {
|
||||
x.keyBuf += string(chunk[i])
|
||||
i++
|
||||
}
|
||||
if i < len(chunk) { // closing quote
|
||||
if x.keyBuf == "reply" {
|
||||
x.state = rxSeekColon
|
||||
} else {
|
||||
x.state = rxSeekKey
|
||||
}
|
||||
i++
|
||||
}
|
||||
case rxSeekColon:
|
||||
if c == ':' {
|
||||
x.state = rxSeekQuote
|
||||
}
|
||||
i++
|
||||
case rxSeekQuote:
|
||||
if c == '"' {
|
||||
x.state = rxInString
|
||||
} else if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
|
||||
x.state = rxSeekKey // reply wasn't a string (defensive)
|
||||
}
|
||||
i++
|
||||
case rxInString:
|
||||
if x.uBuf != "" || x.esc {
|
||||
i = x.feedEscape(chunk, i)
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
x.esc = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
x.state = rxDone
|
||||
return
|
||||
}
|
||||
// emit the longest plain run in one call
|
||||
j := i
|
||||
for j < len(chunk) && chunk[j] != '\\' && chunk[j] != '"' {
|
||||
j++
|
||||
}
|
||||
x.emit(chunk[i:j])
|
||||
i = j
|
||||
case rxDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rxKeyPartial handles a key name split across chunk boundaries.
|
||||
const rxKeyPartial = 100
|
||||
|
||||
// feedEscape consumes escape-sequence bytes (possibly across chunks).
|
||||
func (x *replyExtractor) feedEscape(chunk string, i int) int {
|
||||
if x.uBuf != "" { // inside \uXXXX ("u" + collected hex)
|
||||
for i < len(chunk) && len(x.uBuf) < 5 {
|
||||
x.uBuf += string(chunk[i])
|
||||
i++
|
||||
}
|
||||
if len(x.uBuf) == 5 {
|
||||
if v, err := strconv.ParseUint(x.uBuf[1:], 16, 32); err == nil {
|
||||
x.emit(string(rune(v)))
|
||||
}
|
||||
x.uBuf = ""
|
||||
}
|
||||
return i
|
||||
}
|
||||
// x.esc: the byte after a backslash
|
||||
c := chunk[i]
|
||||
x.esc = false
|
||||
switch c {
|
||||
case 'n':
|
||||
x.emit("\n")
|
||||
case 't':
|
||||
x.emit("\t")
|
||||
case 'r':
|
||||
x.emit("\r")
|
||||
case 'u':
|
||||
x.uBuf = "u"
|
||||
case '"', '\\', '/':
|
||||
x.emit(string(c))
|
||||
default:
|
||||
x.emit(string(c))
|
||||
}
|
||||
return i + 1
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// feedChunks drives an extractor with the given chunking and returns the
|
||||
// emitted reply text and thinking text.
|
||||
func feedChunks(t *testing.T, chunks []string) (reply, think string) {
|
||||
t.Helper()
|
||||
var r, th strings.Builder
|
||||
x := newReplyExtractor(func(s string) { r.WriteString(s) })
|
||||
x.emitThink = func(s string) { th.WriteString(s) }
|
||||
for _, c := range chunks {
|
||||
x.Feed(c)
|
||||
}
|
||||
return r.String(), th.String()
|
||||
}
|
||||
|
||||
func TestReplyExtractor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
chunks []string
|
||||
want string
|
||||
wantThink string
|
||||
}{
|
||||
{"whole envelope", []string{`{"reply":"Hello world","actions":[]}`}, "Hello world", ""},
|
||||
{"split mid-value", []string{`{"reply":"Hel`, `lo wor`, `ld","actions":[]}`}, "Hello world", ""},
|
||||
{"split mid-key", []string{`{"re`, `ply":"hi"}`}, "hi", ""},
|
||||
{"escapes", []string{`{"reply":"a\nb\t\"q\" c\\d"}`}, "a\nb\t\"q\" c\\d", ""},
|
||||
{"escape split across chunks", []string{`{"reply":"x\`, `ny"}`}, "x\ny", ""},
|
||||
{"unicode escape", []string{`{"reply":"snow ☃!"}`}, "snow ☃!", ""},
|
||||
{"unicode escape split", []string{`{"reply":"a\u26`, `03b"}`}, "a☃b", ""},
|
||||
{"tool round stays silent", []string{`{"reply":"","tools":[{"name":"world_brief","arguments":{"n":5}}]}`}, "", ""},
|
||||
{"prose streams as thinking", []string{"Just a plain ", "prose answer."}, "", "Just a plain prose answer."},
|
||||
{"reasoning preamble then envelope", []string{"We need to answer briefly. ", `{"reply":"The answer.","actions":[]}`}, "The answer.", "We need to answer briefly. "},
|
||||
{"reasoning split around the brace", []string{"thinking…", " done ", `{"rep`, `ly":"yes"}`}, "yes", "thinking… done "},
|
||||
{"leading whitespace then envelope", []string{" \n ", `{"reply":"ok"}`}, "ok", ""},
|
||||
{"other key first (defensive)", []string{`{"model":"x","reply":"later"}`}, "later", ""},
|
||||
{"stops at closing quote", []string{`{"reply":"done","actions":[{"type":"noise \"reply\":\"nope\""}]}`}, "done", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, think := feedChunks(t, tc.chunks)
|
||||
if got != tc.want || think != tc.wantThink {
|
||||
t.Errorf("got (%q, think %q), want (%q, think %q)", got, think, tc.want, tc.wantThink)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Byte-at-a-time is the cruellest chunking — every state transition lands on a
|
||||
// boundary.
|
||||
func TestReplyExtractorByteAtATime(t *testing.T) {
|
||||
in := `{"reply":"line1\nline2 é end","actions":[]}`
|
||||
chunks := make([]string, 0, len(in))
|
||||
for i := 0; i < len(in); i++ {
|
||||
chunks = append(chunks, in[i:i+1])
|
||||
}
|
||||
if got, _ := feedChunks(t, chunks); got != "line1\nline2 é end" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package world
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAIStatusError: the non-2xx inference body is parsed into an actionable
|
||||
// error across every shape the gateway/billing layers emit; an unparseable body
|
||||
// degrades to the bare status.
|
||||
func TestAIStatusError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"nested code", 402, `{"error":{"code":"insufficient_balance","message":"no funds"}}`, "hanzo ai status 402: insufficient_balance"},
|
||||
{"nested spend cap", 402, `{"error":{"code":"spend_cap_exceeded","message":"cap hit"}}`, "hanzo ai status 402: spend_cap_exceeded"},
|
||||
{"error string", 401, `{"error":"unauthorized","message":"bad token"}`, "hanzo ai status 401: unauthorized"},
|
||||
{"id + message", 401, `{"id":"Unauthorized","message":"login required"}`, "hanzo ai status 401: Unauthorized"},
|
||||
{"detail only", 400, `{"detail":"missing field query"}`, "hanzo ai status 400: missing field query"},
|
||||
{"nested message no code", 403, `{"error":{"message":"forbidden here"}}`, "hanzo ai status 403: forbidden here"},
|
||||
{"unparseable html", 500, `<html>502 bad gateway</html>`, "hanzo ai status 500"},
|
||||
{"empty body", 429, ``, "hanzo ai status 429"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := aiStatusError(c.status, []byte(c.body)).Error(); got != c.want {
|
||||
t.Errorf("%s: aiStatusError(%d, %q) = %q, want %q", c.name, c.status, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrim80 caps a long upstream message to 80 runes without splitting UTF-8.
|
||||
func TestTrim80(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 200; i++ {
|
||||
long += "x"
|
||||
}
|
||||
if got := trim80(long); len([]rune(got)) != 80 {
|
||||
t.Fatalf("trim80 long = %d runes, want 80", len([]rune(got)))
|
||||
}
|
||||
if got := trim80(" short "); got != "short" {
|
||||
t.Fatalf("trim80 trimmed = %q, want %q", got, "short")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Boot cache warmer: keeps the hottest GDELT-backed cache keys warm so the
|
||||
// request path serves a fresh hit instead of blocking ~10s on a cold GDELT
|
||||
// fetch when a key's 5m TTL lapses.
|
||||
//
|
||||
// The hot keys are the ones the SPA hits on every load: the analyst grounding
|
||||
// snapshot (gdelt-doc query=world) and the protests panel (gdelt-geo default).
|
||||
// Each pod warms its OWN in-memory cache (unlike the shared hanzo-kv feed cache,
|
||||
// there is no cross-pod copy to reuse), on boot and every ~4min — just under the
|
||||
// TTL, so a key is refreshed before it can expire. GDELT rate-limits callers to
|
||||
// one request per ~5s, so the specs are walked sequentially with gdeltPace gaps.
|
||||
const cacheWarmInterval = 4 * time.Minute
|
||||
|
||||
// warmSpec is one hot cache key. warm re-produces its value under the exact key
|
||||
// the handler reads (via the shared key/produce helpers in handlers_news.go).
|
||||
type warmSpec struct {
|
||||
name string
|
||||
warm func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// warmSpecs is the table of hot keys, each wired to the same produce path and
|
||||
// key strings its handler uses so warmer and handler can never drift.
|
||||
func (s *Server) warmSpecs() []warmSpec {
|
||||
return []warmSpec{
|
||||
{"gdelt-doc query=world", func(ctx context.Context) error {
|
||||
v, err := s.produceGDELTDoc(ctx, "world", "72h", 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.cache.Set(gdeltDocKey("world", 8, "72h"), v, gdeltTTL, gdeltStale)
|
||||
return nil
|
||||
}},
|
||||
{"gdelt-geo query=protest", func(ctx context.Context) error {
|
||||
key := gdeltGeoKey("protest", "geojson", 250, "7d")
|
||||
_, err := s.fetchAndCache(ctx, key, gdeltGeoURL("protest", "geojson", 250, "7d"), nil, gdeltTTL, gdeltStale)
|
||||
return err
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// startCacheWarmer launches the warmer loop until ctx is cancelled: it warms
|
||||
// once shortly after boot then on the jittered interval.
|
||||
func (s *Server) startCacheWarmer(ctx context.Context) {
|
||||
go func() {
|
||||
s.warmCaches(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(jitter(cacheWarmInterval)):
|
||||
s.warmCaches(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// warmCaches (re)produces every hot key sequentially, each independently
|
||||
// bounded, with GDELT pacing between them. A failure is logged and skipped; the
|
||||
// next cycle retries and the stale value keeps serving in the meantime.
|
||||
func (s *Server) warmCaches(ctx context.Context) {
|
||||
specs := s.warmSpecs()
|
||||
for i, spec := range specs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
wctx, cancel := context.WithTimeout(ctx, 24*time.Second)
|
||||
err := spec.warm(wctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logf("world-warm: %s failed: %v", spec.name, err)
|
||||
}
|
||||
if i < len(specs)-1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(gdeltPace):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,15 +24,37 @@ import (
|
||||
// or non-admin owner → 403. The caller's bearer is returned to forward upstream,
|
||||
// where cloud independently re-verifies — defense in depth.
|
||||
|
||||
// isAdminOrg reports whether an owner claim is one of the admin orgs. Kept in
|
||||
// sync with the cloud deploy's globalAdminOrgs (admin,built-in).
|
||||
func isAdminOrg(owner string) bool {
|
||||
switch strings.TrimSpace(owner) {
|
||||
case "admin", "built-in":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
// adminOrgs is the set of IAM owner claims world treats as a SuperAdmin. The base
|
||||
// is EXACTLY cloud's canonical globalAdminOrgs — {admin, built-in} — the SAME
|
||||
// predicate cloud's principal.IsSuperAdmin enforces (owner == the reserved admin
|
||||
// org). It is ONE source of truth; world never widens it in code. A deployment MAY
|
||||
// name its operator org (so the seeded superuser, e.g. z@hanzo.ai / owner "hanzo",
|
||||
// keeps dashboard access) via WORLD_ADMIN_ORGS (comma-separated) in the deploy env
|
||||
// — additive only. The base is never env-dependent, so an unset/empty
|
||||
// WORLD_ADMIN_ORGS resolves to {admin, built-in}, never empty and never "everyone".
|
||||
// The upstream cloud subsystems independently re-verify the bearer, so this gate
|
||||
// only decides which callers world ATTEMPTS the full/admin reads for — never the
|
||||
// final authz.
|
||||
func adminOrgs() map[string]bool {
|
||||
m := map[string]bool{"admin": true, "built-in": true}
|
||||
for _, o := range strings.Split(env("WORLD_ADMIN_ORGS"), ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
m[o] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// isAdminOrg reports whether an owner claim is one of the admin orgs.
|
||||
func isAdminOrg(owner string) bool { return adminOrgs()[strings.TrimSpace(owner)] }
|
||||
|
||||
// isOrgAdmin reports whether an identity may PUBLISH its org's shared documents
|
||||
// (e.g. the org-shared dashboard default). It is admin iff the owner is a
|
||||
// global-admin org (isAdminOrg) OR IAM marks the identity itself an admin owner
|
||||
// (id.Admin). Either way the write is always scoped to the caller's OWN org, so
|
||||
// this never grants cross-org authority. Reads stay open to every member.
|
||||
func (s *Server) isOrgAdmin(id wIdentity) bool {
|
||||
return isAdminOrg(id.Org) || id.Admin
|
||||
}
|
||||
|
||||
// apiHost returns the api.hanzo.ai origin (no /v1 suffix) that the cloud
|
||||
@@ -58,39 +78,36 @@ func iamIssuer() string {
|
||||
return "https://hanzo.id"
|
||||
}
|
||||
|
||||
// introspectOwner resolves the caller's IAM `owner` claim from userinfo, memoized
|
||||
// by token hash for a short TTL so the gate does not hit IAM on every request.
|
||||
func (s *Server) introspectOwner(ctx context.Context, bearer string) (string, error) {
|
||||
sum := sha256.Sum256([]byte(bearer))
|
||||
key := "cloud-owner:" + hex.EncodeToString(sum[:12])
|
||||
if v, ok := s.cache.Get(key); ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
var u struct {
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
if err := s.getJSON(ctx, iamIssuer()+"/v1/iam/oauth/userinfo",
|
||||
map[string]string{"Authorization": bearer}, &u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.cache.Set(key, u.Owner, 60*time.Second, 60*time.Second)
|
||||
return u.Owner, nil
|
||||
}
|
||||
|
||||
// requireAdmin gates an admin-only Cloud endpoint. It returns the caller's bearer
|
||||
// (to forward upstream) and true only for a validated admin-org owner; otherwise
|
||||
// it writes the fail-closed response (401 without a token, 403 otherwise) and
|
||||
// returns false. Every admin handler calls this after preflight.
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
// adminIdentity is the NON-writing admin probe: it returns the caller's bearer and
|
||||
// true iff a valid IAM identity resolves to an admin-org owner. It short-circuits
|
||||
// (no IAM call) when the request carries no bearer, so anonymous requests stay cheap.
|
||||
// Used by endpoints that have a public fallback (cloud-pulse): they upgrade to the
|
||||
// full admin view when the caller is an admin, and otherwise serve the public path.
|
||||
func (s *Server) adminIdentity(r *http.Request) (string, bool) {
|
||||
bearer := userBearer(r)
|
||||
if bearer == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Sign in required")
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
owner, err := s.introspectOwner(ctx, bearer)
|
||||
if err != nil || !isAdminOrg(owner) {
|
||||
id, err := s.introspectIdentity(ctx, bearer)
|
||||
if err != nil || !isAdminOrg(id.Org) {
|
||||
return "", false
|
||||
}
|
||||
return bearer, true
|
||||
}
|
||||
|
||||
// requireAdmin gates an admin-ONLY Cloud endpoint (no public fallback). It returns
|
||||
// the caller's bearer (to forward upstream) and true only for a validated admin-org
|
||||
// owner; otherwise it writes the fail-closed response (401 without a token, 403
|
||||
// otherwise) and returns false. Every admin handler calls this after preflight.
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if userBearer(r) == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Sign in required")
|
||||
return "", false
|
||||
}
|
||||
bearer, ok := s.adminIdentity(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "Admin only — sign in with the admin org")
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service-plane access to the Hanzo Cloud (api.hanzo.ai) — the ONE place that
|
||||
// knows how world authenticates as a platform service and reads the platform-wide
|
||||
// aggregates the public dashboard surfaces (cloud-pulse volume, ai-pulse,
|
||||
// enso-training). Org-scoped drill-down never comes through here: those panels
|
||||
// call api.hanzo.ai directly with the CALLER's IAM bearer (see cloud-pulse.ts).
|
||||
//
|
||||
// One credential, one base. The service token is the repo's established,
|
||||
// KMS-injected bearer (kms.go's fetch list) — it is NEVER sent to the browser.
|
||||
// All aggregate reads share it, so there is exactly one secret for ops to
|
||||
// provision. For the platform-wide reads (ops/usages/cloud ?org=all and the
|
||||
// routing-ledger exports) it must be a super-admin bearer; a non-admin token
|
||||
// 4xxes upstream and every caller degrades to its honest demo/unavailable state.
|
||||
|
||||
// errNoServiceToken is returned by the service-plane readers when no service
|
||||
// token is configured, so callers keep their honest degrade instead of faking.
|
||||
var errNoServiceToken = errors.New("world: no service token configured")
|
||||
|
||||
// serviceToken returns the platform service bearer (KMS-injected). Empty ⇒ every
|
||||
// service-side aggregate read degrades to demo/unavailable. Never exposed to the
|
||||
// browser.
|
||||
func serviceToken() string { return env("HANZO_CLOUD_PULSE_TOKEN") }
|
||||
|
||||
// serviceAuth is the Authorization header map for a service-side call, or nil
|
||||
// when no service token is configured (so callers short-circuit to their demo
|
||||
// path).
|
||||
func serviceAuth() map[string]string {
|
||||
tok := serviceToken()
|
||||
if tok == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"Authorization": "Bearer " + tok}
|
||||
}
|
||||
|
||||
// ── platform usage ledger (ai GET /v1/ai/usages/cloud) ──────────────────────
|
||||
|
||||
// cloudUsageOverview decodes the fields world aggregates from ai's
|
||||
// object.CloudUsageOverview. The upstream is ClickHouse-backed; ?org=all is the
|
||||
// platform-wide view and requires a super-admin bearer.
|
||||
type cloudUsageOverview struct {
|
||||
Range string `json:"range"`
|
||||
Interval string `json:"interval"`
|
||||
Totals cloudUsageTotals `json:"totals"`
|
||||
Series []cloudUsageSeriesPoint `json:"series"`
|
||||
ByModel cloudUsageByModel `json:"byModel"`
|
||||
}
|
||||
|
||||
type cloudUsageTotals struct {
|
||||
Tokens int64 `json:"tokens"`
|
||||
Requests int64 `json:"requests"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
Models int64 `json:"models"`
|
||||
}
|
||||
|
||||
type cloudUsageSeriesPoint struct {
|
||||
T string `json:"t"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
Requests int64 `json:"requests"`
|
||||
}
|
||||
|
||||
type cloudUsageModelSpend struct {
|
||||
Model string `json:"model"`
|
||||
SpendCents int64 `json:"spendCents"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Requests int64 `json:"requests"`
|
||||
Pct float64 `json:"pct"` // share of total spend, 0..100
|
||||
}
|
||||
|
||||
type cloudUsageByModel struct {
|
||||
Items []cloudUsageModelSpend `json:"items"`
|
||||
}
|
||||
|
||||
// fetchCloudUsage reads the platform-wide usage overview (?org=all) for a range
|
||||
// label ("24h" | "7d" | "30d") using the supplied auth header — the KMS service
|
||||
// bearer (public path) or the signed-in admin's OWN bearer (the flagship admin
|
||||
// path). Either way the upstream independently authorizes ?org=all (super-admin),
|
||||
// so a non-admin bearer 4xxes and the caller keeps its honest fallback. Returns
|
||||
// errNoServiceToken when no auth is available at all.
|
||||
func (s *Server) fetchCloudUsage(ctx context.Context, rangeLabel string, hdr map[string]string) (*cloudUsageOverview, error) {
|
||||
if hdr == nil {
|
||||
return nil, errNoServiceToken
|
||||
}
|
||||
var ov cloudUsageOverview
|
||||
url := apiHost() + "/v1/ai/usages/cloud?org=all&range=" + rangeLabel
|
||||
if err := s.getJSON(ctx, url, hdr, &ov); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ov, nil
|
||||
}
|
||||
|
||||
// intervalSeconds resolves the usage-series bucket width to seconds; 0 when
|
||||
// unresolvable so callers fall back to a window average instead of dividing by a
|
||||
// bogus interval. The upstream ops/usages/cloud emits the width as a WORD enum
|
||||
// ("hour"/"day"/"minute"), NOT a Go duration — time.ParseDuration fails on those
|
||||
// ("hour" needs to be "1h"), which silently forced usageRate to the flat 24h mean
|
||||
// (the "AI Compute usage is dead flat" bug). Handle the enum first, then still
|
||||
// accept a real duration string for any caller that sends one.
|
||||
func intervalSeconds(d string) float64 {
|
||||
switch d {
|
||||
case "":
|
||||
return 0
|
||||
case "minute":
|
||||
return 60
|
||||
case "hour":
|
||||
return 3600
|
||||
case "day":
|
||||
return 86400
|
||||
case "week":
|
||||
return 604800
|
||||
}
|
||||
v, err := time.ParseDuration(d)
|
||||
if err != nil || v <= 0 {
|
||||
return 0
|
||||
}
|
||||
return v.Seconds()
|
||||
}
|
||||
|
||||
// usageRate returns the freshest honest per-second rate for a metric: the most
|
||||
// recent complete series bucket over its interval, falling back to the 24h
|
||||
// average when there is no usable interval/series. sel picks the metric from a
|
||||
// bucket. Shared by cloud-pulse and ai-pulse so the rate is derived one way.
|
||||
func usageRate(total24h int64, series []cloudUsageSeriesPoint, interval string, sel func(cloudUsageSeriesPoint) int64) float64 {
|
||||
if n := len(series); n > 0 {
|
||||
if iv := intervalSeconds(interval); iv > 0 {
|
||||
return float64(sel(series[n-1])) / iv
|
||||
}
|
||||
}
|
||||
const windowSecs = 86400.0
|
||||
return float64(total24h) / windowSecs
|
||||
}
|
||||
|
||||
// seriesRequests / seriesTokens are the bucket selectors for usageRate.
|
||||
func seriesRequests(p cloudUsageSeriesPoint) int64 { return p.Requests }
|
||||
func seriesTokens(p cloudUsageSeriesPoint) int64 { return p.Tokens }
|
||||
|
||||
// topModelsFromUsage maps the ledger's ranked byModel spend into the shared
|
||||
// cloudModel shape (share normalized 0..1). nil when the ledger listed none.
|
||||
func topModelsFromUsage(ov *cloudUsageOverview) []cloudModel {
|
||||
if len(ov.ByModel.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]cloudModel, 0, len(ov.ByModel.Items))
|
||||
for _, m := range ov.ByModel.Items {
|
||||
out = append(out, cloudModel{
|
||||
ID: m.Model,
|
||||
Name: m.Model,
|
||||
Requests24h: m.Requests,
|
||||
Tokens24h: m.Tokens,
|
||||
Share: m.Pct / 100,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package world
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIntervalSeconds guards the "AI Compute usage is dead flat" regression: the
|
||||
// upstream usage series reports its bucket width as a WORD enum ("hour"/"day"), not a
|
||||
// Go duration, so time.ParseDuration failed and usageRate silently fell back to the
|
||||
// flat 24h mean. intervalSeconds must resolve the enums (and still accept a real
|
||||
// duration string).
|
||||
func TestIntervalSeconds(t *testing.T) {
|
||||
cases := map[string]float64{
|
||||
"minute": 60,
|
||||
"hour": 3600,
|
||||
"day": 86400,
|
||||
"week": 604800,
|
||||
"1h": 3600, // real Go-duration string still works
|
||||
"30m": 1800,
|
||||
"": 0, // unresolvable → 0 (caller falls back to window average)
|
||||
"nope": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := intervalSeconds(in); got != want {
|
||||
t.Errorf("intervalSeconds(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageRate proves the freshest-bucket rate is used when the interval resolves
|
||||
// (the fix), and the 24h average is used only when it can't.
|
||||
func TestUsageRate(t *testing.T) {
|
||||
series := []cloudUsageSeriesPoint{{Requests: 100}, {Requests: 720}} // last bucket = 720
|
||||
// "hour" now resolves to 3600s → 720/3600 = 0.2 rps (fresh), NOT 999999/86400.
|
||||
if got := usageRate(999999, series, "hour", seriesRequests); got != 720.0/3600.0 {
|
||||
t.Errorf("usageRate(hour) = %v, want %v (fresh bucket, not 24h mean)", got, 720.0/3600.0)
|
||||
}
|
||||
// Unresolvable interval → 24h average.
|
||||
if got := usageRate(86400, series, "", seriesRequests); got != 1.0 {
|
||||
t.Errorf("usageRate(empty interval) = %v, want 1.0 (24h average)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Platform user metrics — REAL signups / active users from Hanzo IAM (Casdoor).
|
||||
//
|
||||
// Source: IAM GET /v1/iam/global-users (every user across every org; global-admin
|
||||
// only). We read it with the signed-in admin's OWN bearer (never a shared key),
|
||||
// fold it into NON-sensitive aggregates (counts + a daily-signup series), and return
|
||||
// ONLY those aggregates — no user record, email, name or other PII ever leaves this
|
||||
// function. Honest-empty (error) when the caller is not a global admin upstream or
|
||||
// IAM is unreachable, so the overview simply omits the user tiles rather than guess.
|
||||
|
||||
type userMetrics struct {
|
||||
Total int `json:"total"`
|
||||
Signups24h int `json:"signups24h"`
|
||||
Signups7d int `json:"signups7d"`
|
||||
ActiveNow int `json:"activeNow"`
|
||||
SignupSeries []int64 `json:"signupSeries"` // new users/day, last 14 days, chronological
|
||||
}
|
||||
|
||||
// iamUser is the minimal, non-PII slice of Casdoor's object.User we read.
|
||||
type iamUser struct {
|
||||
CreatedTime string `json:"createdTime"`
|
||||
IsOnline bool `json:"isOnline"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
|
||||
const userSignupDays = 14
|
||||
|
||||
// fetchUserMetrics reads the platform-wide user list from IAM with auth and folds it
|
||||
// into non-sensitive aggregates. Requires a global-admin bearer upstream; any error
|
||||
// (non-admin, unreachable) leaves the caller to omit the tiles honestly — never a
|
||||
// fabricated count.
|
||||
func (s *Server) fetchUserMetrics(ctx context.Context, auth map[string]string) (*userMetrics, error) {
|
||||
if auth == nil {
|
||||
return nil, errNoServiceToken
|
||||
}
|
||||
var users []iamUser
|
||||
if err := s.getJSON(ctx, iamIssuer()+"/v1/iam/global-users", auth, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
m := &userMetrics{SignupSeries: make([]int64, userSignupDays)}
|
||||
for _, u := range users {
|
||||
if u.IsDeleted {
|
||||
continue
|
||||
}
|
||||
m.Total++
|
||||
if u.IsOnline {
|
||||
m.ActiveNow++
|
||||
}
|
||||
t, ok := parseUserTime(u.CreatedTime)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if age := now.Sub(t); age >= 0 {
|
||||
if age <= 24*time.Hour {
|
||||
m.Signups24h++
|
||||
}
|
||||
if age <= 7*24*time.Hour {
|
||||
m.Signups7d++
|
||||
}
|
||||
if d := int(age.Hours() / 24); d < userSignupDays {
|
||||
m.SignupSeries[userSignupDays-1-d]++ // chronological: oldest bucket first, today last
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// parseUserTime parses Casdoor's createdTime (RFC3339, with a couple of legacy
|
||||
// fallbacks). ok=false when unparseable so a bad row is skipped, never guessed.
|
||||
func parseUserTime(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/world/internal/world/kv"
|
||||
"github.com/hanzoai/world/internal/world/model"
|
||||
"github.com/hanzoai/world/internal/world/store"
|
||||
)
|
||||
|
||||
// datastore.go wires world's three storage concerns onto the Server and owns
|
||||
// their lifecycle, keeping each in its lane:
|
||||
// - hanzo-kv (shared hot cache) → instant feed bodies (FeedCache L2)
|
||||
// - embedded SQLite (lake) → the searchable "one place to query everything"
|
||||
// - embedded SQLite (settings) → signed-in per-identity dashboard sync
|
||||
//
|
||||
// Everything degrades cleanly: no hanzo-kv → per-pod in-mem feed cache; no
|
||||
// SQLite → search/analytics return empty and settings say "not stored". The
|
||||
// service never 5xxes over storage.
|
||||
|
||||
// kvAddr is the hanzo-kv endpoint. Defaults to the in-cluster Service; set empty
|
||||
// (WORLD_KV_DISABLE=1) to force the pure in-mem path (local dev / CI).
|
||||
func kvAddr() string {
|
||||
if env("WORLD_KV_DISABLE") != "" {
|
||||
return ""
|
||||
}
|
||||
if a := env("HANZO_KV_ADDR", "WORLD_KV_ADDR"); a != "" {
|
||||
return a
|
||||
}
|
||||
return "hanzo-kv:6379"
|
||||
}
|
||||
|
||||
// kvPassword is optional — hanzo-kv currently requires none; the hook is here for
|
||||
// a future KMS-provisioned password (HANZO_KV_PASSWORD / world-secrets).
|
||||
func kvPassword() string { return env("HANZO_KV_PASSWORD", "WORLD_KV_PASSWORD") }
|
||||
|
||||
// lakeRetention is the rolling window ingested items are kept for.
|
||||
func lakeRetention() time.Duration {
|
||||
if v := env("WORLD_LAKE_RETENTION"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return store.DefaultRetention
|
||||
}
|
||||
|
||||
// initDatastore opens hanzo-kv + the embedded SQLite datastore and builds the
|
||||
// two-tier feed cache. Called once from NewServer; never fails hard.
|
||||
func (s *Server) initDatastore() {
|
||||
s.kv = kv.Open(kvAddr(), kvPassword())
|
||||
|
||||
db, err := store.Open(modelDataDir(), lakeRetention())
|
||||
if err != nil {
|
||||
logf("world-store: degraded (no durable lake/settings): %v", err)
|
||||
}
|
||||
s.store = db
|
||||
s.feeds = NewFeedCache(s.kv, 0, curatedFeedSeed)
|
||||
|
||||
// The world model dumps its folded observations into the lake so model state
|
||||
// is queryable alongside news — the engine stays decomplected from storage,
|
||||
// it just calls this sink.
|
||||
s.worldModel.SetObservationSink(s.ingestObservations)
|
||||
}
|
||||
|
||||
// StartDatastore starts the background loops: the lake write-behind/prune
|
||||
// consumer and the feed warmer. Call once from main after the server is built.
|
||||
func (s *Server) StartDatastore(ctx context.Context) {
|
||||
if s.kv.Enabled() {
|
||||
pctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
if err := s.kv.Ping(pctx); err != nil {
|
||||
logf("world-kv: %s unreachable, using per-pod in-mem cache: %v", kvAddr(), err)
|
||||
} else {
|
||||
logf("world-kv: connected to %s (shared feed cache)", kvAddr())
|
||||
}
|
||||
cancel()
|
||||
} else {
|
||||
logf("world-kv: disabled, using per-pod in-mem feed cache")
|
||||
}
|
||||
go s.store.Lake.Run(ctx)
|
||||
s.startFeedWarmer(ctx)
|
||||
s.startCacheWarmer(ctx)
|
||||
}
|
||||
|
||||
// Close releases the datastore handles. Safe to call once on shutdown.
|
||||
func (s *Server) Close() {
|
||||
if s.kv != nil {
|
||||
s.kv.Close()
|
||||
}
|
||||
if s.store != nil {
|
||||
_ = s.store.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ingestObservations folds one cycle of world-model observations into the lake
|
||||
// (kind=observation), keyed so each entity+source keeps its latest value. This
|
||||
// is the model half of "dump ALL ingested data into the datastore".
|
||||
func (s *Server) ingestObservations(obs []model.Observation) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, o := range obs {
|
||||
country := ""
|
||||
if o.Kind == model.KindCountry {
|
||||
country = o.ID
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"id": o.ID, "kind": o.Kind, "name": o.Name,
|
||||
"metrics": o.Metrics, "note": o.Note, "src": o.Src,
|
||||
})
|
||||
s.store.Lake.Add(store.Item{
|
||||
ID: "obs:" + o.Kind + ":" + o.ID + ":" + o.Src,
|
||||
Kind: "observation",
|
||||
Source: o.Src,
|
||||
TS: now,
|
||||
Title: o.Name,
|
||||
Text: o.Note,
|
||||
Country: country,
|
||||
Payload: string(payload),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Server-side news enrichment: threat classification + geo inference.
|
||||
//
|
||||
// This is the Go-native home for work the browser used to do on every client,
|
||||
// on every render. The keyword tables and geo hubs are DATA (enrichdata/*.json)
|
||||
// — one source of truth, embedded here and imported by the frontend, so the two
|
||||
// runtimes can never drift. enrich_test.go proves byte-for-byte parity with the
|
||||
// TypeScript implementation over an exhaustive 494-case corpus.
|
||||
//
|
||||
// Two parity traps, both handled deliberately:
|
||||
// - JS object key order IS the match priority (first match wins). Go maps are
|
||||
// unordered, so the tables stay ORDERED SLICES.
|
||||
// - JS Array.sort is stable. Go's sort.Slice is not — geo matches use
|
||||
// sort.SliceStable so equal-confidence hubs keep index order.
|
||||
|
||||
//go:embed enrichdata/threat-keywords.json
|
||||
var threatKeywordsJSON []byte
|
||||
|
||||
//go:embed enrichdata/geo-hubs.json
|
||||
var geoHubsJSON []byte
|
||||
|
||||
// ThreatClassification mirrors the frontend type (services/threat-classifier.ts).
|
||||
type ThreatClassification struct {
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// GeoHub is a strategic location an item can be pinned to.
|
||||
type GeoHub struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Type string `json:"type"`
|
||||
Tier string `json:"tier"`
|
||||
Keywords []string `json:"keywords"`
|
||||
}
|
||||
|
||||
// GeoMatch is one inferred hub for a headline.
|
||||
type GeoMatch struct {
|
||||
HubID string `json:"hubId"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MatchedKeyword string `json:"matchedKeyword"`
|
||||
}
|
||||
|
||||
type keywordEntry struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type threatTier struct {
|
||||
Level string `json:"level"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Variant string `json:"variant"` // "any" | "tech"
|
||||
Keywords []keywordEntry `json:"keywords"`
|
||||
}
|
||||
|
||||
type threatTables struct {
|
||||
Exclusions []string `json:"exclusions"`
|
||||
ShortKeywords []string `json:"shortKeywords"`
|
||||
Tiers []threatTier `json:"tiers"`
|
||||
}
|
||||
|
||||
type enricher struct {
|
||||
tables threatTables
|
||||
short map[string]bool
|
||||
res map[string]*regexp.Regexp // keyword → compiled matcher
|
||||
hubs []GeoHub
|
||||
hubIndex map[string]*GeoHub
|
||||
// hubKeywords preserves insertion order, matching the JS Map iteration the
|
||||
// frontend index relies on.
|
||||
hubKeywords []hubKeyword
|
||||
}
|
||||
|
||||
type hubKeyword struct {
|
||||
keyword string
|
||||
hubIDs []string
|
||||
re *regexp.Regexp // non-nil only for short keywords (word-boundary)
|
||||
}
|
||||
|
||||
var (
|
||||
enrichOnce sync.Once
|
||||
enr *enricher
|
||||
)
|
||||
|
||||
func enrichEngine() *enricher {
|
||||
enrichOnce.Do(func() {
|
||||
e := &enricher{
|
||||
short: map[string]bool{},
|
||||
res: map[string]*regexp.Regexp{},
|
||||
hubIndex: map[string]*GeoHub{},
|
||||
}
|
||||
if err := json.Unmarshal(threatKeywordsJSON, &e.tables); err != nil {
|
||||
log.Printf("world: enrich: threat tables: %v", err)
|
||||
}
|
||||
for _, s := range e.tables.ShortKeywords {
|
||||
e.short[s] = true
|
||||
}
|
||||
// Precompile one matcher per keyword, mirroring getKeywordRegex: short
|
||||
// keywords are word-bounded (so "war" never matches "wardrobe"), the rest
|
||||
// are plain substring matches. Titles are lowercased before matching.
|
||||
for _, tier := range e.tables.Tiers {
|
||||
for _, k := range tier.Keywords {
|
||||
e.res[k.Keyword] = compileKeyword(k.Keyword, e.short[k.Keyword])
|
||||
}
|
||||
}
|
||||
|
||||
var hubData struct {
|
||||
Hubs []GeoHub `json:"hubs"`
|
||||
}
|
||||
if err := json.Unmarshal(geoHubsJSON, &hubData); err != nil {
|
||||
log.Printf("world: enrich: geo hubs: %v", err)
|
||||
}
|
||||
e.hubs = hubData.Hubs
|
||||
seen := map[string]int{} // keyword → index into hubKeywords
|
||||
for i := range e.hubs {
|
||||
h := &e.hubs[i]
|
||||
e.hubIndex[h.ID] = h
|
||||
for _, kw := range h.Keywords {
|
||||
lower := strings.ToLower(kw)
|
||||
if idx, ok := seen[lower]; ok {
|
||||
e.hubKeywords[idx].hubIDs = append(e.hubKeywords[idx].hubIDs, h.ID)
|
||||
continue
|
||||
}
|
||||
seen[lower] = len(e.hubKeywords)
|
||||
var re *regexp.Regexp
|
||||
// The frontend word-bounds geo keywords shorter than 5 chars.
|
||||
if len(lower) < 5 {
|
||||
re = regexp.MustCompile(`\b` + regexp.QuoteMeta(lower) + `\b`)
|
||||
}
|
||||
e.hubKeywords = append(e.hubKeywords, hubKeyword{keyword: lower, hubIDs: []string{h.ID}, re: re})
|
||||
}
|
||||
}
|
||||
enr = e
|
||||
})
|
||||
return enr
|
||||
}
|
||||
|
||||
func compileKeyword(kw string, short bool) *regexp.Regexp {
|
||||
q := regexp.QuoteMeta(kw)
|
||||
if short {
|
||||
return regexp.MustCompile(`\b` + q + `\b`)
|
||||
}
|
||||
return regexp.MustCompile(q)
|
||||
}
|
||||
|
||||
// ClassifyByKeyword is the Go port of services/threat-classifier.ts
|
||||
// classifyByKeyword. Same priority cascade, same confidences, same categories.
|
||||
func ClassifyByKeyword(title, variant string) ThreatClassification {
|
||||
e := enrichEngine()
|
||||
lower := strings.ToLower(title)
|
||||
|
||||
info := ThreatClassification{Level: "info", Category: "general", Confidence: 0.3, Source: "keyword"}
|
||||
for _, ex := range e.tables.Exclusions {
|
||||
if strings.Contains(lower, ex) {
|
||||
return info
|
||||
}
|
||||
}
|
||||
isTech := variant == "tech"
|
||||
for _, tier := range e.tables.Tiers {
|
||||
if tier.Variant == "tech" && !isTech {
|
||||
continue
|
||||
}
|
||||
for _, k := range tier.Keywords {
|
||||
if re := e.res[k.Keyword]; re != nil && re.MatchString(lower) {
|
||||
return ThreatClassification{
|
||||
Level: tier.Level,
|
||||
Category: k.Category,
|
||||
Confidence: tier.Confidence,
|
||||
Source: "keyword",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// InferGeoHubs is the Go port of services/geo-hub-index.ts inferGeoHubsFromTitle:
|
||||
// keyword → hub, confidence by keyword length, boosted for conflict/strategic
|
||||
// type and critical tier, sorted by confidence (stable).
|
||||
func InferGeoHubs(title string) []GeoMatch {
|
||||
e := enrichEngine()
|
||||
lower := strings.ToLower(title)
|
||||
matches := []GeoMatch{}
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, hk := range e.hubKeywords {
|
||||
if len(hk.keyword) < 2 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
if hk.re != nil {
|
||||
found = hk.re.MatchString(lower)
|
||||
} else {
|
||||
found = strings.Contains(lower, hk.keyword)
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
for _, id := range hk.hubIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
hub := e.hubIndex[id]
|
||||
if hub == nil {
|
||||
continue
|
||||
}
|
||||
confidence := 0.5
|
||||
switch n := len(hk.keyword); {
|
||||
case n >= 10:
|
||||
confidence = 0.9
|
||||
case n >= 6:
|
||||
confidence = 0.75
|
||||
case n >= 4:
|
||||
confidence = 0.6
|
||||
}
|
||||
if hub.Type == "conflict" || hub.Type == "strategic" {
|
||||
confidence = min1(confidence + 0.1)
|
||||
}
|
||||
if hub.Tier == "critical" {
|
||||
confidence = min1(confidence + 0.1)
|
||||
}
|
||||
matches = append(matches, GeoMatch{HubID: id, Confidence: confidence, MatchedKeyword: hk.keyword})
|
||||
}
|
||||
}
|
||||
// JS Array.sort is stable — equal confidences keep discovery order.
|
||||
sort.SliceStable(matches, func(i, j int) bool { return matches[i].Confidence > matches[j].Confidence })
|
||||
return matches
|
||||
}
|
||||
|
||||
// GeoHubByID exposes a hub for callers that need its coordinates/name.
|
||||
func GeoHubByID(id string) *GeoHub {
|
||||
return enrichEngine().hubIndex[id]
|
||||
}
|
||||
|
||||
func min1(v float64) float64 {
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Parity: the Go enrichment MUST agree with the TypeScript implementation it
|
||||
// replaces, or moving the work server-side would silently change what every
|
||||
// user sees. testdata/enrich_golden.json is generated by EVALUATING the real
|
||||
// frontend classifier/geo-index (not transcribed) over an exhaustive corpus:
|
||||
// every keyword in every tier, both variants, every geo-hub keyword, every
|
||||
// exclusion, plus adversarial word-boundary cases ("war" must not match
|
||||
// "wardrobe"). Any drift fails here.
|
||||
|
||||
type goldenCase struct {
|
||||
Title string `json:"title"`
|
||||
Full ThreatClassification `json:"full"`
|
||||
Tech ThreatClassification `json:"tech"`
|
||||
Geo []GeoMatch `json:"geo"`
|
||||
}
|
||||
|
||||
func loadGolden(t *testing.T) []goldenCase {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/enrich_golden.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
var g struct {
|
||||
Cases []goldenCase `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &g); err != nil {
|
||||
t.Fatalf("parse golden: %v", err)
|
||||
}
|
||||
if len(g.Cases) < 400 {
|
||||
t.Fatalf("golden corpus too small (%d) — regenerate it", len(g.Cases))
|
||||
}
|
||||
return g.Cases
|
||||
}
|
||||
|
||||
func sameClass(a, b ThreatClassification) bool {
|
||||
return a.Level == b.Level && a.Category == b.Category &&
|
||||
a.Source == b.Source && math.Abs(a.Confidence-b.Confidence) < 1e-9
|
||||
}
|
||||
|
||||
func TestClassifyByKeywordMatchesTypeScript(t *testing.T) {
|
||||
cases := loadGolden(t)
|
||||
bad := 0
|
||||
for _, c := range cases {
|
||||
for _, v := range []struct {
|
||||
variant string
|
||||
want ThreatClassification
|
||||
}{{"full", c.Full}, {"tech", c.Tech}} {
|
||||
got := ClassifyByKeyword(c.Title, v.variant)
|
||||
if !sameClass(got, v.want) {
|
||||
bad++
|
||||
if bad <= 10 {
|
||||
t.Errorf("classify(%q, %s)\n got %+v\n want %+v", c.Title, v.variant, got, v.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("%d/%d classifications diverge from the TypeScript source of truth", bad, len(cases)*2)
|
||||
}
|
||||
t.Logf("parity: %d titles × 2 variants identical to TypeScript", len(cases))
|
||||
}
|
||||
|
||||
func TestInferGeoHubsMatchesTypeScript(t *testing.T) {
|
||||
cases := loadGolden(t)
|
||||
bad, withGeo := 0, 0
|
||||
for _, c := range cases {
|
||||
got := InferGeoHubs(c.Title)
|
||||
if len(c.Geo) > 0 {
|
||||
withGeo++
|
||||
}
|
||||
if len(got) != len(c.Geo) {
|
||||
bad++
|
||||
if bad <= 10 {
|
||||
t.Errorf("geo(%q): got %d matches, want %d", c.Title, len(got), len(c.Geo))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
w := c.Geo[i]
|
||||
if got[i].HubID != w.HubID || got[i].MatchedKeyword != w.MatchedKeyword ||
|
||||
math.Abs(got[i].Confidence-w.Confidence) > 1e-9 {
|
||||
bad++
|
||||
if bad <= 10 {
|
||||
t.Errorf("geo(%q)[%d]\n got %+v\n want %+v", c.Title, i, got[i], w)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("%d/%d geo inferences diverge from the TypeScript source of truth", bad, len(cases))
|
||||
}
|
||||
t.Logf("parity: %d titles identical to TypeScript (%d carry geo matches)", len(cases), withGeo)
|
||||
}
|
||||
|
||||
// The adversarial guarantee, asserted directly: short keywords are word-bounded.
|
||||
func TestShortKeywordsAreWordBounded(t *testing.T) {
|
||||
for _, title := range []string{"The wardrobe warehouse expands", "Something banned entirely"} {
|
||||
if got := ClassifyByKeyword(title, "full"); got.Level != "info" {
|
||||
t.Errorf("%q classified %s — a short keyword matched inside a longer word", title, got.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
{
|
||||
"hubs": [
|
||||
{
|
||||
"id": "washington",
|
||||
"name": "Washington DC",
|
||||
"region": "North America",
|
||||
"country": "USA",
|
||||
"lat": 38.9072,
|
||||
"lon": -77.0369,
|
||||
"type": "capital",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"washington",
|
||||
"white house",
|
||||
"pentagon",
|
||||
"state department",
|
||||
"congress",
|
||||
"capitol hill",
|
||||
"biden",
|
||||
"trump"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "moscow",
|
||||
"name": "Moscow",
|
||||
"region": "Europe",
|
||||
"country": "Russia",
|
||||
"lat": 55.7558,
|
||||
"lon": 37.6173,
|
||||
"type": "capital",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"moscow",
|
||||
"kremlin",
|
||||
"putin",
|
||||
"russia",
|
||||
"russian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "beijing",
|
||||
"name": "Beijing",
|
||||
"region": "Asia",
|
||||
"country": "China",
|
||||
"lat": 39.9042,
|
||||
"lon": 116.4074,
|
||||
"type": "capital",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"beijing",
|
||||
"xi jinping",
|
||||
"china",
|
||||
"chinese",
|
||||
"ccp",
|
||||
"prc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "brussels",
|
||||
"name": "Brussels",
|
||||
"region": "Europe",
|
||||
"country": "Belgium",
|
||||
"lat": 50.8503,
|
||||
"lon": 4.3517,
|
||||
"type": "capital",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"brussels",
|
||||
"eu",
|
||||
"european union",
|
||||
"nato",
|
||||
"european commission"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "london",
|
||||
"name": "London",
|
||||
"region": "Europe",
|
||||
"country": "UK",
|
||||
"lat": 51.5074,
|
||||
"lon": -0.1278,
|
||||
"type": "capital",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"london",
|
||||
"uk",
|
||||
"britain",
|
||||
"british",
|
||||
"downing street",
|
||||
"parliament"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jerusalem",
|
||||
"name": "Jerusalem",
|
||||
"region": "Middle East",
|
||||
"country": "Israel",
|
||||
"lat": 31.7683,
|
||||
"lon": 35.2137,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"jerusalem",
|
||||
"israel",
|
||||
"israeli",
|
||||
"knesset",
|
||||
"netanyahu"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "telaviv",
|
||||
"name": "Tel Aviv",
|
||||
"region": "Middle East",
|
||||
"country": "Israel",
|
||||
"lat": 32.0853,
|
||||
"lon": 34.7818,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"tel aviv",
|
||||
"idf",
|
||||
"mossad"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tehran",
|
||||
"name": "Tehran",
|
||||
"region": "Middle East",
|
||||
"country": "Iran",
|
||||
"lat": 35.6892,
|
||||
"lon": 51.389,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"tehran",
|
||||
"iran",
|
||||
"iranian",
|
||||
"khamenei",
|
||||
"irgc",
|
||||
"ayatollah"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "kyiv",
|
||||
"name": "Kyiv",
|
||||
"region": "Europe",
|
||||
"country": "Ukraine",
|
||||
"lat": 50.4501,
|
||||
"lon": 30.5234,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"kyiv",
|
||||
"kiev",
|
||||
"ukraine",
|
||||
"ukrainian",
|
||||
"zelensky",
|
||||
"zelenskyy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "taipei",
|
||||
"name": "Taipei",
|
||||
"region": "Asia",
|
||||
"country": "Taiwan",
|
||||
"lat": 25.033,
|
||||
"lon": 121.5654,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"taipei",
|
||||
"taiwan",
|
||||
"taiwanese",
|
||||
"tsmc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tokyo",
|
||||
"name": "Tokyo",
|
||||
"region": "Asia",
|
||||
"country": "Japan",
|
||||
"lat": 35.6762,
|
||||
"lon": 139.6503,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"tokyo",
|
||||
"japan",
|
||||
"japanese"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "seoul",
|
||||
"name": "Seoul",
|
||||
"region": "Asia",
|
||||
"country": "South Korea",
|
||||
"lat": 37.5665,
|
||||
"lon": 126.978,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"seoul",
|
||||
"south korea",
|
||||
"korean"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pyongyang",
|
||||
"name": "Pyongyang",
|
||||
"region": "Asia",
|
||||
"country": "North Korea",
|
||||
"lat": 39.0392,
|
||||
"lon": 125.7625,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"pyongyang",
|
||||
"north korea",
|
||||
"dprk",
|
||||
"kim jong un"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "newdelhi",
|
||||
"name": "New Delhi",
|
||||
"region": "Asia",
|
||||
"country": "India",
|
||||
"lat": 28.6139,
|
||||
"lon": 77.209,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"new delhi",
|
||||
"delhi",
|
||||
"india",
|
||||
"indian",
|
||||
"modi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "riyadh",
|
||||
"name": "Riyadh",
|
||||
"region": "Middle East",
|
||||
"country": "Saudi Arabia",
|
||||
"lat": 24.7136,
|
||||
"lon": 46.6753,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"riyadh",
|
||||
"saudi",
|
||||
"saudi arabia",
|
||||
"mbs",
|
||||
"mohammed bin salman"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ankara",
|
||||
"name": "Ankara",
|
||||
"region": "Middle East",
|
||||
"country": "Turkey",
|
||||
"lat": 39.9334,
|
||||
"lon": 32.8597,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"ankara",
|
||||
"turkey",
|
||||
"turkish",
|
||||
"erdogan"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "paris",
|
||||
"name": "Paris",
|
||||
"region": "Europe",
|
||||
"country": "France",
|
||||
"lat": 48.8566,
|
||||
"lon": 2.3522,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"paris",
|
||||
"france",
|
||||
"french",
|
||||
"macron",
|
||||
"elysee"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "berlin",
|
||||
"name": "Berlin",
|
||||
"region": "Europe",
|
||||
"country": "Germany",
|
||||
"lat": 52.52,
|
||||
"lon": 13.405,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"berlin",
|
||||
"germany",
|
||||
"german",
|
||||
"scholz",
|
||||
"bundestag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cairo",
|
||||
"name": "Cairo",
|
||||
"region": "Middle East",
|
||||
"country": "Egypt",
|
||||
"lat": 30.0444,
|
||||
"lon": 31.2357,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"cairo",
|
||||
"egypt",
|
||||
"egyptian",
|
||||
"sisi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "islamabad",
|
||||
"name": "Islamabad",
|
||||
"region": "Asia",
|
||||
"country": "Pakistan",
|
||||
"lat": 33.6844,
|
||||
"lon": 73.0479,
|
||||
"type": "capital",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"islamabad",
|
||||
"pakistan",
|
||||
"pakistani"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gaza",
|
||||
"name": "Gaza",
|
||||
"region": "Middle East",
|
||||
"country": "Palestine",
|
||||
"lat": 31.5,
|
||||
"lon": 34.47,
|
||||
"type": "conflict",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"gaza",
|
||||
"hamas",
|
||||
"palestinian",
|
||||
"rafah",
|
||||
"khan younis",
|
||||
"gaza strip"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "westbank",
|
||||
"name": "West Bank",
|
||||
"region": "Middle East",
|
||||
"country": "Palestine",
|
||||
"lat": 31.9,
|
||||
"lon": 35.2,
|
||||
"type": "conflict",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"west bank",
|
||||
"ramallah",
|
||||
"jenin",
|
||||
"nablus",
|
||||
"hebron"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ukraine-front",
|
||||
"name": "Ukraine Front",
|
||||
"region": "Europe",
|
||||
"country": "Ukraine",
|
||||
"lat": 48.5,
|
||||
"lon": 37.5,
|
||||
"type": "conflict",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"donbas",
|
||||
"donbass",
|
||||
"donetsk",
|
||||
"luhansk",
|
||||
"kharkiv",
|
||||
"bakhmut",
|
||||
"avdiivka",
|
||||
"zaporizhzhia",
|
||||
"kherson",
|
||||
"crimea"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "taiwan-strait",
|
||||
"name": "Taiwan Strait",
|
||||
"region": "Asia",
|
||||
"country": "International",
|
||||
"lat": 24.5,
|
||||
"lon": 119.5,
|
||||
"type": "conflict",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"taiwan strait",
|
||||
"formosa",
|
||||
"pla",
|
||||
"chinese military"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "southchinasea",
|
||||
"name": "South China Sea",
|
||||
"region": "Asia",
|
||||
"country": "International",
|
||||
"lat": 12,
|
||||
"lon": 114,
|
||||
"type": "strategic",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"south china sea",
|
||||
"spratlys",
|
||||
"paracels",
|
||||
"nine-dash line",
|
||||
"scarborough"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "yemen",
|
||||
"name": "Yemen",
|
||||
"region": "Middle East",
|
||||
"country": "Yemen",
|
||||
"lat": 15.5527,
|
||||
"lon": 48.5164,
|
||||
"type": "conflict",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"yemen",
|
||||
"houthi",
|
||||
"houthis",
|
||||
"sanaa",
|
||||
"aden"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "syria",
|
||||
"name": "Syria",
|
||||
"region": "Middle East",
|
||||
"country": "Syria",
|
||||
"lat": 34.8,
|
||||
"lon": 39,
|
||||
"type": "conflict",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"syria",
|
||||
"syrian",
|
||||
"assad",
|
||||
"damascus",
|
||||
"idlib",
|
||||
"aleppo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lebanon",
|
||||
"name": "Lebanon",
|
||||
"region": "Middle East",
|
||||
"country": "Lebanon",
|
||||
"lat": 33.8547,
|
||||
"lon": 35.8623,
|
||||
"type": "conflict",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"lebanon",
|
||||
"lebanese",
|
||||
"hezbollah",
|
||||
"beirut"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sudan",
|
||||
"name": "Sudan",
|
||||
"region": "Africa",
|
||||
"country": "Sudan",
|
||||
"lat": 15.5007,
|
||||
"lon": 32.5599,
|
||||
"type": "conflict",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"sudan",
|
||||
"sudanese",
|
||||
"khartoum",
|
||||
"rsf",
|
||||
"darfur"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sahel",
|
||||
"name": "Sahel",
|
||||
"region": "Africa",
|
||||
"country": "International",
|
||||
"lat": 15,
|
||||
"lon": 0,
|
||||
"type": "conflict",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"sahel",
|
||||
"mali",
|
||||
"niger",
|
||||
"burkina faso",
|
||||
"wagner",
|
||||
"junta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ethiopia",
|
||||
"name": "Ethiopia",
|
||||
"region": "Africa",
|
||||
"country": "Ethiopia",
|
||||
"lat": 9.145,
|
||||
"lon": 40.4897,
|
||||
"type": "conflict",
|
||||
"tier": "notable",
|
||||
"keywords": [
|
||||
"ethiopia",
|
||||
"ethiopian",
|
||||
"tigray",
|
||||
"addis ababa",
|
||||
"abiy ahmed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "myanmar",
|
||||
"name": "Myanmar",
|
||||
"region": "Asia",
|
||||
"country": "Myanmar",
|
||||
"lat": 19.7633,
|
||||
"lon": 96.0785,
|
||||
"type": "conflict",
|
||||
"tier": "notable",
|
||||
"keywords": [
|
||||
"myanmar",
|
||||
"burma",
|
||||
"rohingya",
|
||||
"junta",
|
||||
"naypyidaw"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hormuz",
|
||||
"name": "Strait of Hormuz",
|
||||
"region": "Middle East",
|
||||
"country": "International",
|
||||
"lat": 26.5,
|
||||
"lon": 56.5,
|
||||
"type": "strategic",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"hormuz",
|
||||
"strait of hormuz",
|
||||
"persian gulf",
|
||||
"gulf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "redsea",
|
||||
"name": "Red Sea",
|
||||
"region": "Middle East",
|
||||
"country": "International",
|
||||
"lat": 20,
|
||||
"lon": 38,
|
||||
"type": "strategic",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"red sea",
|
||||
"bab el-mandeb",
|
||||
"bab al-mandab"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "suez",
|
||||
"name": "Suez Canal",
|
||||
"region": "Middle East",
|
||||
"country": "Egypt",
|
||||
"lat": 30.5,
|
||||
"lon": 32.3,
|
||||
"type": "strategic",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"suez",
|
||||
"suez canal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "baltic",
|
||||
"name": "Baltic Sea",
|
||||
"region": "Europe",
|
||||
"country": "International",
|
||||
"lat": 58,
|
||||
"lon": 20,
|
||||
"type": "strategic",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"baltic",
|
||||
"baltic sea",
|
||||
"kaliningrad",
|
||||
"gotland"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "arctic",
|
||||
"name": "Arctic",
|
||||
"region": "Arctic",
|
||||
"country": "International",
|
||||
"lat": 75,
|
||||
"lon": 0,
|
||||
"type": "strategic",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"arctic",
|
||||
"northern sea route",
|
||||
"svalbard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blacksea",
|
||||
"name": "Black Sea",
|
||||
"region": "Europe",
|
||||
"country": "International",
|
||||
"lat": 43,
|
||||
"lon": 35,
|
||||
"type": "strategic",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"black sea",
|
||||
"bosphorus",
|
||||
"sevastopol",
|
||||
"odesa",
|
||||
"odessa"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "un-nyc",
|
||||
"name": "United Nations",
|
||||
"region": "North America",
|
||||
"country": "USA",
|
||||
"lat": 40.7489,
|
||||
"lon": -73.968,
|
||||
"type": "organization",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"united nations",
|
||||
"un",
|
||||
"security council",
|
||||
"general assembly",
|
||||
"unsc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nato-hq",
|
||||
"name": "NATO HQ",
|
||||
"region": "Europe",
|
||||
"country": "Belgium",
|
||||
"lat": 50.8796,
|
||||
"lon": 4.4284,
|
||||
"type": "organization",
|
||||
"tier": "critical",
|
||||
"keywords": [
|
||||
"nato",
|
||||
"north atlantic",
|
||||
"alliance",
|
||||
"stoltenberg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "iaea-vienna",
|
||||
"name": "IAEA",
|
||||
"region": "Europe",
|
||||
"country": "Austria",
|
||||
"lat": 48.2352,
|
||||
"lon": 16.4156,
|
||||
"type": "organization",
|
||||
"tier": "major",
|
||||
"keywords": [
|
||||
"iaea",
|
||||
"atomic energy",
|
||||
"nuclear watchdog",
|
||||
"grossi"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
{
|
||||
"exclusions": [
|
||||
"protein",
|
||||
"couples",
|
||||
"relationship",
|
||||
"dating",
|
||||
"diet",
|
||||
"fitness",
|
||||
"recipe",
|
||||
"cooking",
|
||||
"shopping",
|
||||
"fashion",
|
||||
"celebrity",
|
||||
"movie",
|
||||
"tv show",
|
||||
"sports",
|
||||
"game",
|
||||
"concert",
|
||||
"festival",
|
||||
"wedding",
|
||||
"vacation",
|
||||
"travel tips",
|
||||
"life hack",
|
||||
"self-care",
|
||||
"wellness"
|
||||
],
|
||||
"shortKeywords": [
|
||||
"war",
|
||||
"coup",
|
||||
"ban",
|
||||
"vote",
|
||||
"riot",
|
||||
"riots",
|
||||
"hack",
|
||||
"talks",
|
||||
"ipo",
|
||||
"gdp",
|
||||
"virus",
|
||||
"disease",
|
||||
"flood"
|
||||
],
|
||||
"tiers": [
|
||||
{
|
||||
"level": "critical",
|
||||
"confidence": 0.9,
|
||||
"variant": "any",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "nuclear strike",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "nuclear attack",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "nuclear war",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "invasion",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "declaration of war",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "martial law",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "coup",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "coup attempt",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "genocide",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "ethnic cleansing",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "chemical attack",
|
||||
"category": "terrorism"
|
||||
},
|
||||
{
|
||||
"keyword": "biological attack",
|
||||
"category": "terrorism"
|
||||
},
|
||||
{
|
||||
"keyword": "dirty bomb",
|
||||
"category": "terrorism"
|
||||
},
|
||||
{
|
||||
"keyword": "mass casualty",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "pandemic declared",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "health emergency",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "nato article 5",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "evacuation order",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "meltdown",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "nuclear meltdown",
|
||||
"category": "disaster"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": "high",
|
||||
"confidence": 0.8,
|
||||
"variant": "any",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "war",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "armed conflict",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "airstrike",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "air strike",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "drone strike",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "missile",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "missile launch",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "troops deployed",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "military escalation",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "bombing",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "casualties",
|
||||
"category": "conflict"
|
||||
},
|
||||
{
|
||||
"keyword": "hostage",
|
||||
"category": "terrorism"
|
||||
},
|
||||
{
|
||||
"keyword": "terrorist",
|
||||
"category": "terrorism"
|
||||
},
|
||||
{
|
||||
"keyword": "terror attack",
|
||||
"category": "terrorism"
|
||||
},
|
||||
{
|
||||
"keyword": "assassination",
|
||||
"category": "crime"
|
||||
},
|
||||
{
|
||||
"keyword": "cyber attack",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "ransomware",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "data breach",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "sanctions",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "embargo",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "earthquake",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "tsunami",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "hurricane",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "typhoon",
|
||||
"category": "disaster"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": "high",
|
||||
"confidence": 0.75,
|
||||
"variant": "tech",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "major outage",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "service down",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "global outage",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "zero-day",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "critical vulnerability",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "supply chain attack",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "mass layoff",
|
||||
"category": "economic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": "medium",
|
||||
"confidence": 0.7,
|
||||
"variant": "any",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "protest",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "protests",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "riot",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "riots",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "unrest",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "demonstration",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "strike action",
|
||||
"category": "protest"
|
||||
},
|
||||
{
|
||||
"keyword": "military exercise",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "naval exercise",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "arms deal",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "weapons sale",
|
||||
"category": "military"
|
||||
},
|
||||
{
|
||||
"keyword": "diplomatic crisis",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "ambassador recalled",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "expel diplomats",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "trade war",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "tariff",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "recession",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "inflation",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "market crash",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "flood",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "flooding",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "wildfire",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "volcano",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "eruption",
|
||||
"category": "disaster"
|
||||
},
|
||||
{
|
||||
"keyword": "outbreak",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "epidemic",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "infection spread",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "oil spill",
|
||||
"category": "environmental"
|
||||
},
|
||||
{
|
||||
"keyword": "pipeline explosion",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "blackout",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "power outage",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "internet outage",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "derailment",
|
||||
"category": "infrastructure"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": "medium",
|
||||
"confidence": 0.65,
|
||||
"variant": "tech",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "outage",
|
||||
"category": "infrastructure"
|
||||
},
|
||||
{
|
||||
"keyword": "breach",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "hack",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "vulnerability",
|
||||
"category": "cyber"
|
||||
},
|
||||
{
|
||||
"keyword": "layoff",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "layoffs",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "antitrust",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "monopoly",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "ban",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "shutdown",
|
||||
"category": "infrastructure"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": "low",
|
||||
"confidence": 0.6,
|
||||
"variant": "any",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "election",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "vote",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "referendum",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "summit",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "treaty",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "agreement",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "negotiation",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "talks",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "peacekeeping",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "humanitarian aid",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "ceasefire",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "peace treaty",
|
||||
"category": "diplomatic"
|
||||
},
|
||||
{
|
||||
"keyword": "climate change",
|
||||
"category": "environmental"
|
||||
},
|
||||
{
|
||||
"keyword": "emissions",
|
||||
"category": "environmental"
|
||||
},
|
||||
{
|
||||
"keyword": "pollution",
|
||||
"category": "environmental"
|
||||
},
|
||||
{
|
||||
"keyword": "deforestation",
|
||||
"category": "environmental"
|
||||
},
|
||||
{
|
||||
"keyword": "drought",
|
||||
"category": "environmental"
|
||||
},
|
||||
{
|
||||
"keyword": "vaccine",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "vaccination",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "disease",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "virus",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "public health",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "covid",
|
||||
"category": "health"
|
||||
},
|
||||
{
|
||||
"keyword": "interest rate",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "gdp",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "unemployment",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "regulation",
|
||||
"category": "economic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": "low",
|
||||
"confidence": 0.55,
|
||||
"variant": "tech",
|
||||
"keywords": [
|
||||
{
|
||||
"keyword": "ipo",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "funding",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "acquisition",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "merger",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "launch",
|
||||
"category": "tech"
|
||||
},
|
||||
{
|
||||
"keyword": "release",
|
||||
"category": "tech"
|
||||
},
|
||||
{
|
||||
"keyword": "update",
|
||||
"category": "tech"
|
||||
},
|
||||
{
|
||||
"keyword": "partnership",
|
||||
"category": "economic"
|
||||
},
|
||||
{
|
||||
"keyword": "startup",
|
||||
"category": "tech"
|
||||
},
|
||||
{
|
||||
"keyword": "ai model",
|
||||
"category": "tech"
|
||||
},
|
||||
{
|
||||
"keyword": "open source",
|
||||
"category": "tech"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"measured": {
|
||||
"gpqa_diamond": {
|
||||
"gpt-5.6-terra": {
|
||||
"accuracy_pct": 91.1,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 191,
|
||||
"correct": 174,
|
||||
"errors": 0,
|
||||
"infra_excluded": 7,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 25529,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"deepseek-4-flash": {
|
||||
"accuracy_pct": 76.9,
|
||||
"stderr_pct": 3.1,
|
||||
"n": 182,
|
||||
"correct": 140,
|
||||
"errors": 16,
|
||||
"infra_excluded": 16,
|
||||
"tokens": {
|
||||
"prompt": 56442,
|
||||
"completion": 101277,
|
||||
"reasoning": 0,
|
||||
"calls": 182
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"enso-flash": {
|
||||
"accuracy_pct": 75.8,
|
||||
"stderr_pct": 3.0,
|
||||
"n": 198,
|
||||
"correct": 150,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 361936,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"fable-5": {
|
||||
"accuracy_pct": 81.3,
|
||||
"stderr_pct": 2.8,
|
||||
"n": 198,
|
||||
"correct": 161,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 78121,
|
||||
"completion": 199072,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 3.2204,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
"accuracy_pct": 75.3,
|
||||
"stderr_pct": 3.1,
|
||||
"n": 198,
|
||||
"correct": 149,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 107808,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.2113,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"gpt-5.5": {
|
||||
"accuracy_pct": 90.4,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 198,
|
||||
"correct": 179,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 54916,
|
||||
"completion": 362128,
|
||||
"reasoning": 326158,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 3.6899,
|
||||
"wall_seconds": 2396.2,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"glm-5.2": {
|
||||
"accuracy_pct": null,
|
||||
"stderr_pct": null,
|
||||
"n": 69,
|
||||
"correct": 60,
|
||||
"errors": 0,
|
||||
"infra_excluded": 129,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 26595,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "max",
|
||||
"degraded": true,
|
||||
"blank_rate_pct": 65.2
|
||||
},
|
||||
"enso": {
|
||||
"accuracy_pct": 87.9,
|
||||
"stderr_pct": 2.3,
|
||||
"n": 198,
|
||||
"correct": 174,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 74531,
|
||||
"completion": 152519,
|
||||
"reasoning": 29387,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 10.0211,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"enso-ultra": {
|
||||
"accuracy_pct": 90.4,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 198,
|
||||
"correct": 179,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 217469,
|
||||
"completion": 603501,
|
||||
"reasoning": 325464,
|
||||
"calls": 605
|
||||
},
|
||||
"usd_est": 15.8887,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"gpt-5.6-sol": {
|
||||
"accuracy_pct": 94.8,
|
||||
"stderr_pct": 1.6,
|
||||
"n": 193,
|
||||
"correct": 183,
|
||||
"errors": 0,
|
||||
"infra_excluded": 5,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 34023,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "max"
|
||||
},
|
||||
"kimi-k3": {
|
||||
"accuracy_pct": 92.5,
|
||||
"stderr_pct": 4.2,
|
||||
"n": 40,
|
||||
"correct": 37,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 13917,
|
||||
"completion": 90614,
|
||||
"reasoning": 81847,
|
||||
"calls": 40
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "k3-max"
|
||||
},
|
||||
"glm-5.2@openrouter": {
|
||||
"accuracy_pct": null,
|
||||
"stderr_pct": null,
|
||||
"n": 119,
|
||||
"correct": 95,
|
||||
"errors": 79,
|
||||
"infra_excluded": 79,
|
||||
"tokens": {
|
||||
"prompt": 32982,
|
||||
"completion": 1228859,
|
||||
"reasoning": 983679,
|
||||
"calls": 119
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "glm-true",
|
||||
"degraded": true,
|
||||
"blank_rate_pct": 39.9
|
||||
},
|
||||
"opus-4.8": {
|
||||
"accuracy_pct": 86.9,
|
||||
"stderr_pct": 2.4,
|
||||
"n": 198,
|
||||
"correct": 172,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 78121,
|
||||
"completion": 126069,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 10.627,
|
||||
"wall_seconds": 241.3,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"deepseek-3.2": {
|
||||
"accuracy_pct": 73.7,
|
||||
"stderr_pct": 3.1,
|
||||
"n": 198,
|
||||
"correct": 146,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 367921,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"gpt-5.6-luna": {
|
||||
"accuracy_pct": 89.6,
|
||||
"stderr_pct": 2.3,
|
||||
"n": 183,
|
||||
"correct": 164,
|
||||
"errors": 0,
|
||||
"infra_excluded": 15,
|
||||
"tokens": {
|
||||
"prompt": 60695,
|
||||
"completion": 31104,
|
||||
"reasoning": 0,
|
||||
"calls": 198
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
}
|
||||
},
|
||||
"livecodebench": {
|
||||
"gpt-5.6-sol": {
|
||||
"accuracy_pct": null,
|
||||
"stderr_pct": null,
|
||||
"n": 144,
|
||||
"correct": 130,
|
||||
"errors": 17,
|
||||
"infra_excluded": 31,
|
||||
"tokens": {
|
||||
"prompt": 87763,
|
||||
"completion": 43002,
|
||||
"reasoning": 0,
|
||||
"calls": 158
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2",
|
||||
"degraded": true,
|
||||
"blank_rate_pct": 17.7
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
"accuracy_pct": 51.4,
|
||||
"stderr_pct": 3.8,
|
||||
"n": 175,
|
||||
"correct": 90,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 97404,
|
||||
"completion": 75242,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 0.1777,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"deepseek-3.2": {
|
||||
"accuracy_pct": 56.0,
|
||||
"stderr_pct": 3.8,
|
||||
"n": 175,
|
||||
"correct": 98,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 97404,
|
||||
"completion": 353896,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"fable-5": {
|
||||
"accuracy_pct": 82.9,
|
||||
"stderr_pct": 2.8,
|
||||
"n": 175,
|
||||
"correct": 145,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 125867,
|
||||
"completion": 544855,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 8.5504,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"enso": {
|
||||
"accuracy_pct": 92.0,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 174,
|
||||
"correct": 160,
|
||||
"errors": 1,
|
||||
"infra_excluded": 1,
|
||||
"tokens": {
|
||||
"prompt": 92002,
|
||||
"completion": 512599,
|
||||
"reasoning": 454741,
|
||||
"calls": 174
|
||||
},
|
||||
"usd_est": 5.241,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"opus-4.8": {
|
||||
"accuracy_pct": 72.8,
|
||||
"stderr_pct": 3.4,
|
||||
"n": 173,
|
||||
"correct": 126,
|
||||
"errors": 2,
|
||||
"infra_excluded": 2,
|
||||
"tokens": {
|
||||
"prompt": 124432,
|
||||
"completion": 500884,
|
||||
"reasoning": 0,
|
||||
"calls": 173
|
||||
},
|
||||
"usd_est": 39.4328,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"gpt-5.6-luna": {
|
||||
"accuracy_pct": null,
|
||||
"stderr_pct": null,
|
||||
"n": 156,
|
||||
"correct": 135,
|
||||
"errors": 0,
|
||||
"infra_excluded": 19,
|
||||
"tokens": {
|
||||
"prompt": 97404,
|
||||
"completion": 41596,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2",
|
||||
"degraded": true,
|
||||
"blank_rate_pct": 10.9
|
||||
},
|
||||
"enso-ultra": {
|
||||
"accuracy_pct": 85.1,
|
||||
"stderr_pct": 2.7,
|
||||
"n": 175,
|
||||
"correct": 149,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 975812,
|
||||
"completion": 1329941,
|
||||
"reasoning": 474476,
|
||||
"calls": 693
|
||||
},
|
||||
"usd_est": 71.731,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"gpt-5.5": {
|
||||
"accuracy_pct": 92.0,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 174,
|
||||
"correct": 160,
|
||||
"errors": 1,
|
||||
"infra_excluded": 1,
|
||||
"tokens": {
|
||||
"prompt": 92002,
|
||||
"completion": 504668,
|
||||
"reasoning": 444424,
|
||||
"calls": 174
|
||||
},
|
||||
"usd_est": 5.1617,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"glm-5.2": {
|
||||
"accuracy_pct": null,
|
||||
"stderr_pct": null,
|
||||
"n": 60,
|
||||
"correct": 49,
|
||||
"errors": 0,
|
||||
"infra_excluded": 115,
|
||||
"tokens": {
|
||||
"prompt": 97404,
|
||||
"completion": 14943,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2",
|
||||
"degraded": true,
|
||||
"blank_rate_pct": 65.7
|
||||
},
|
||||
"gpt-5.6-terra": {
|
||||
"accuracy_pct": 88.3,
|
||||
"stderr_pct": 2.5,
|
||||
"n": 162,
|
||||
"correct": 143,
|
||||
"errors": 0,
|
||||
"infra_excluded": 13,
|
||||
"tokens": {
|
||||
"prompt": 97404,
|
||||
"completion": 49002,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"enso-flash": {
|
||||
"accuracy_pct": 49.1,
|
||||
"stderr_pct": 3.8,
|
||||
"n": 175,
|
||||
"correct": 86,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 97404,
|
||||
"completion": 367521,
|
||||
"reasoning": 0,
|
||||
"calls": 175
|
||||
},
|
||||
"usd_est": 0.0,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
}
|
||||
},
|
||||
"hle": {
|
||||
"gpt-5.5": {
|
||||
"accuracy_pct": 35.4,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 500,
|
||||
"correct": 177,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 139619,
|
||||
"completion": 2240161,
|
||||
"reasoning": 2061227,
|
||||
"calls": 500
|
||||
},
|
||||
"usd_est": 22.5761,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
"accuracy_pct": 7.2,
|
||||
"stderr_pct": 1.2,
|
||||
"n": 500,
|
||||
"correct": 36,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 134104,
|
||||
"completion": 383371,
|
||||
"reasoning": 0,
|
||||
"calls": 500
|
||||
},
|
||||
"usd_est": 0.7063,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"opus-4.8": {
|
||||
"accuracy_pct": 27.9,
|
||||
"stderr_pct": 2.0,
|
||||
"n": 498,
|
||||
"correct": 139,
|
||||
"errors": 2,
|
||||
"infra_excluded": 2,
|
||||
"tokens": {
|
||||
"prompt": 204638,
|
||||
"completion": 1265083,
|
||||
"reasoning": 0,
|
||||
"calls": 498
|
||||
},
|
||||
"usd_est": 97.9508,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"enso-ultra": {
|
||||
"accuracy_pct": 34.6,
|
||||
"stderr_pct": 2.1,
|
||||
"n": 500,
|
||||
"correct": 173,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 1457803,
|
||||
"completion": 4095530,
|
||||
"reasoning": 2052639,
|
||||
"calls": 1819
|
||||
},
|
||||
"usd_est": 151.7767,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"enso": {
|
||||
"accuracy_pct": 29.4,
|
||||
"stderr_pct": 2.0,
|
||||
"n": 500,
|
||||
"correct": 147,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 180835,
|
||||
"completion": 1429013,
|
||||
"reasoning": 436163,
|
||||
"calls": 500
|
||||
},
|
||||
"usd_est": 76.5405,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"fable-5": {
|
||||
"accuracy_pct": 38.2,
|
||||
"stderr_pct": 2.2,
|
||||
"n": 500,
|
||||
"correct": 191,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 205132,
|
||||
"completion": 1660596,
|
||||
"reasoning": 0,
|
||||
"calls": 500
|
||||
},
|
||||
"usd_est": 25.5243,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
}
|
||||
},
|
||||
"charxiv": {
|
||||
"opus-4.8": {
|
||||
"accuracy_pct": 78.0,
|
||||
"stderr_pct": 2.9,
|
||||
"n": 200,
|
||||
"correct": 156,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 204206,
|
||||
"completion": 5650,
|
||||
"reasoning": 0,
|
||||
"calls": 200
|
||||
},
|
||||
"usd_est": 3.4868,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"enso-ultra": {
|
||||
"accuracy_pct": 85.5,
|
||||
"stderr_pct": 2.5,
|
||||
"n": 200,
|
||||
"correct": 171,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 631448,
|
||||
"completion": 85689,
|
||||
"reasoning": 54757,
|
||||
"calls": 636
|
||||
},
|
||||
"usd_est": 6.2855,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v2"
|
||||
},
|
||||
"gpt-5.5": {
|
||||
"accuracy_pct": 85.5,
|
||||
"stderr_pct": 2.5,
|
||||
"n": 200,
|
||||
"correct": 171,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 181387,
|
||||
"completion": 50493,
|
||||
"reasoning": 47582,
|
||||
"calls": 200
|
||||
},
|
||||
"usd_est": 0.7317,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"fable-5": {
|
||||
"accuracy_pct": 76.0,
|
||||
"stderr_pct": 3.0,
|
||||
"n": 200,
|
||||
"correct": 152,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 204206,
|
||||
"completion": 13961,
|
||||
"reasoning": 0,
|
||||
"calls": 200
|
||||
},
|
||||
"usd_est": 0.822,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
},
|
||||
"enso": {
|
||||
"accuracy_pct": 78.0,
|
||||
"stderr_pct": 2.9,
|
||||
"n": 200,
|
||||
"correct": 156,
|
||||
"errors": 0,
|
||||
"infra_excluded": 0,
|
||||
"tokens": {
|
||||
"prompt": 204206,
|
||||
"completion": 5424,
|
||||
"reasoning": 0,
|
||||
"calls": 200
|
||||
},
|
||||
"usd_est": 3.4699,
|
||||
"wall_seconds": null,
|
||||
"run_tag": "v1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grading": {
|
||||
"hle": {
|
||||
"judge_arm": "gpt-5.5",
|
||||
"judge_calls": 1,
|
||||
"judge_usd_est": 0.0006,
|
||||
"judge_prompt_tokens": 159,
|
||||
"judge_completion_tokens": 36
|
||||
}
|
||||
},
|
||||
"enso_reported": {},
|
||||
"pending": [
|
||||
"swebench_pro",
|
||||
"terminal_bench"
|
||||
],
|
||||
"ultra_ablation": {
|
||||
"gpqa_diamond": {
|
||||
"k3fix": {
|
||||
"accuracy_pct": 32.8,
|
||||
"n": 198,
|
||||
"usd_est": 0.0,
|
||||
"logic": "k3fix"
|
||||
},
|
||||
"v2": {
|
||||
"accuracy_pct": 90.4,
|
||||
"n": 198,
|
||||
"usd_est": 15.8887,
|
||||
"logic": "verify-then-select"
|
||||
},
|
||||
"v1": {
|
||||
"accuracy_pct": 89.9,
|
||||
"n": 198,
|
||||
"usd_est": 16.4333,
|
||||
"logic": "blind-synthesis"
|
||||
}
|
||||
},
|
||||
"livecodebench": {
|
||||
"v1": {
|
||||
"accuracy_pct": 81.1,
|
||||
"n": 175,
|
||||
"usd_est": 90.682,
|
||||
"logic": "blind-synthesis"
|
||||
},
|
||||
"v2": {
|
||||
"accuracy_pct": 85.1,
|
||||
"n": 175,
|
||||
"usd_est": 71.731,
|
||||
"logic": "verify-then-select"
|
||||
}
|
||||
},
|
||||
"hle": {
|
||||
"v1": {
|
||||
"accuracy_pct": 29.2,
|
||||
"n": 500,
|
||||
"usd_est": 191.4856,
|
||||
"logic": "blind-synthesis"
|
||||
},
|
||||
"v2": {
|
||||
"accuracy_pct": 34.6,
|
||||
"n": 500,
|
||||
"usd_est": 151.7767,
|
||||
"logic": "verify-then-select"
|
||||
}
|
||||
},
|
||||
"charxiv": {
|
||||
"v2": {
|
||||
"accuracy_pct": 85.5,
|
||||
"n": 200,
|
||||
"usd_est": 6.2855,
|
||||
"logic": "verify-then-select"
|
||||
},
|
||||
"v1": {
|
||||
"accuracy_pct": 81.5,
|
||||
"n": 200,
|
||||
"usd_est": 10.1498,
|
||||
"logic": "blind-synthesis"
|
||||
}
|
||||
}
|
||||
},
|
||||
"total_usd_est": 564.25,
|
||||
"agentic": {
|
||||
"swebench_pro": {
|
||||
"bench": "SWE-Bench Pro",
|
||||
"metric": "% resolved (agentic, Mini-SWE-Agent)",
|
||||
"n_items": 25,
|
||||
"systems": {
|
||||
"step-routed": {
|
||||
"n": 18,
|
||||
"resolved": 7,
|
||||
"resolved_rate": 0.3889,
|
||||
"usd": 19.696,
|
||||
"calls": 256
|
||||
},
|
||||
"single-opus": {
|
||||
"n": 18,
|
||||
"resolved": 3,
|
||||
"resolved_rate": 0.1667,
|
||||
"usd": 27.561,
|
||||
"calls": 321
|
||||
}
|
||||
},
|
||||
"note": "Agentic pilot: n=18 per system from the agent harness (agent pass/fail); official cross-scoring done on a small common set (results/agentic). Full 731-instance run pending (amd64-only task images + shared-key serialization)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/world/internal/world/store"
|
||||
)
|
||||
|
||||
// rssFetchHeaders is the single header set used for every allowlisted feed
|
||||
// fetch — by the on-demand fall-through (rss-proxy, feeds-batch) AND the
|
||||
// background warmer. One place, one behavior.
|
||||
var rssFetchHeaders = map[string]string{
|
||||
"User-Agent": browserUA,
|
||||
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
||||
}
|
||||
|
||||
// fetchFeedBody performs one bounded, allowlisted GET of a feed and returns the
|
||||
// body only when it is a usable (non-blank) 2xx. This is the ONLY live-fetch
|
||||
// path for feed bodies, shared by the request fall-through and the warmer. The
|
||||
// caller owns the timeout via ctx.
|
||||
func (s *Server) fetchFeedBody(ctx context.Context, feedURL string) ([]byte, bool) {
|
||||
body, status, err := s.getAllowlisted(ctx, feedURL, allowedRSSDomains, rssFetchHeaders)
|
||||
if err != nil || status < 200 || status >= 300 || isBlankBody(body) {
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
// ingestFeedItems parses a feed body and folds its items into the searchable
|
||||
// data lake (kind=news). Cheap and write-behind (Lake.Add never blocks), so it
|
||||
// runs off both the request fall-through and the warmer without touching latency.
|
||||
func (s *Server) ingestFeedItems(feedURL string, body []byte) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
host := feedHost(feedURL)
|
||||
for _, it := range parseFeedItems(body, feedsBatchMaxItems) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"title": it.Title, "link": it.Link, "pubDate": it.PubDate,
|
||||
"source": host, "tickers": it.Tickers,
|
||||
})
|
||||
s.store.Lake.Add(store.Item{
|
||||
ID: newsItemID(it.Link, it.Title),
|
||||
Kind: "news",
|
||||
Source: host,
|
||||
TS: feedItemTime(it.PubDate),
|
||||
Title: it.Title,
|
||||
Tickers: it.Tickers,
|
||||
Payload: string(payload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newsItemID is the stable dedupe key for a news item: its link when present,
|
||||
// else its title. Re-ingesting the same story upserts instead of duplicating.
|
||||
func newsItemID(link, title string) string {
|
||||
seed := link
|
||||
if seed == "" {
|
||||
seed = title
|
||||
}
|
||||
sum := sha1.Sum([]byte(seed))
|
||||
return "news:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// feedItemTime parses a normalized RFC3339 pubDate back to a time, defaulting to
|
||||
// now when the feed omitted or mangled the date.
|
||||
func feedItemTime(pubDate string) time.Time {
|
||||
if pubDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, pubDate); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// feedHost returns the feed's host for use as the item's source label.
|
||||
func feedHost(feedURL string) string {
|
||||
if u, err := url.Parse(feedURL); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return "feed"
|
||||
}
|
||||
|
||||
// curatedFeedSeed is the bootstrap warm set: the highest-value feeds behind the
|
||||
// crypto, financial-regulation, and crypto-news panels, so a brand-new pod warms
|
||||
// THEM first (before any user request). Every URL is on the RSS allowlist. After
|
||||
// the first real page load the warm set becomes demand-driven and fleet-wide; the
|
||||
// seed only bridges the very first cold boot.
|
||||
var curatedFeedSeed = []string{
|
||||
// crypto / crypto-news
|
||||
"https://www.coindesk.com/arc/outboundfeeds/rss/",
|
||||
"https://cointelegraph.com/rss",
|
||||
// financial regulation
|
||||
"https://www.sec.gov/news/pressreleases.rss",
|
||||
"https://www.federalreserve.gov/feeds/press_all.xml",
|
||||
// markets / financial
|
||||
"https://www.cnbc.com/id/100003114/device/rss/rss.html",
|
||||
"https://www.cnbc.com/id/19854910/device/rss/rss.html",
|
||||
"https://seekingalpha.com/market_currents.xml",
|
||||
"https://www.ft.com/rss/home",
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Background feed warmer: the reason news is INSTANT.
|
||||
//
|
||||
// On boot and every interval (jittered) it fetches every warm feed, write-throughs
|
||||
// the body to the shared cache, and folds the items into the lake. So the
|
||||
// on-demand endpoints (rss-proxy, feeds-batch) ALWAYS serve from the warm cache
|
||||
// and never block a request on an upstream — stale-while-revalidate, with the
|
||||
// revalidation done here in the background instead of on the request path.
|
||||
//
|
||||
// Cross-pod dedupe is implicit: a feed whose SHARED (hanzo-kv) copy is still
|
||||
// fresh is skipped, so N pods don't all hammer the same upstream every cycle;
|
||||
// whichever pod refreshes first, the rest read its result.
|
||||
const (
|
||||
feedWarmInterval = 5 * time.Minute
|
||||
feedWarmParallel = 8
|
||||
feedWarmFetchTimeout = 10 * time.Second
|
||||
// feedWarmFreshWindow: skip refetch when a cached copy is younger than this
|
||||
// (slightly under the interval so each cycle still refreshes its own feeds).
|
||||
feedWarmFreshWindow = 4 * time.Minute
|
||||
)
|
||||
|
||||
// startFeedWarmer launches the warmer loop until ctx is cancelled. It warms once
|
||||
// shortly after boot (so a cold pod fills fast) then on the jittered interval.
|
||||
func (s *Server) startFeedWarmer(ctx context.Context) {
|
||||
go func() {
|
||||
s.warmFeeds(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(jitter(feedWarmInterval)):
|
||||
s.warmFeeds(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// warmFeeds refreshes every warm feed in parallel (bounded), skipping any whose
|
||||
// shared copy is still fresh. Each fetch is independently bounded; one slow
|
||||
// upstream cannot hold up the rest.
|
||||
func (s *Server) warmFeeds(ctx context.Context) {
|
||||
urls := s.feeds.WarmURLs(ctx)
|
||||
if len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, feedWarmParallel)
|
||||
var wg sync.WaitGroup
|
||||
refreshed := 0
|
||||
var mu sync.Mutex
|
||||
for _, u := range urls {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if age, ok := s.feeds.Age(ctx, u); ok && age < feedWarmFreshWindow {
|
||||
continue // a peer (or an earlier cycle) already refreshed it
|
||||
}
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(u string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
fctx, cancel := context.WithTimeout(ctx, feedWarmFetchTimeout)
|
||||
defer cancel()
|
||||
if body, ok := s.fetchFeedBody(fctx, u); ok {
|
||||
s.feeds.Put(u, body)
|
||||
s.ingestFeedItems(u, body)
|
||||
mu.Lock()
|
||||
refreshed++
|
||||
mu.Unlock()
|
||||
}
|
||||
}(u)
|
||||
}
|
||||
wg.Wait()
|
||||
if refreshed > 0 {
|
||||
logf("world-feeds: warmed %d/%d feeds", refreshed, len(urls))
|
||||
}
|
||||
}
|
||||
|
||||
// jitter returns d spread by ±20% so pods don't synchronize their warm cycles.
|
||||
func jitter(d time.Duration) time.Duration {
|
||||
spread := int64(d) / 5 // 20%
|
||||
if spread <= 0 {
|
||||
return d
|
||||
}
|
||||
return d + time.Duration(rand.Int63n(2*spread)-spread)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/world/internal/world/kv"
|
||||
)
|
||||
|
||||
const stubRSS = `<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<item><title>Bitcoin warms the cache</title><link>https://x/1</link><pubDate>Mon, 02 Jan 2006 15:04:05 -0700</pubDate></item>
|
||||
<item><title>SEC regulation update</title><link>https://x/2</link></item>
|
||||
</channel></rss>`
|
||||
|
||||
// stubFeed stands up a counting RSS upstream and allowlists its host for the
|
||||
// duration of the test (the SSRF boundary is a package var).
|
||||
func stubFeed(t *testing.T) (feedURL string, hits *int32) {
|
||||
t.Helper()
|
||||
var n int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&n, 1)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(stubRSS))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host := mustHost(t, srv.URL)
|
||||
allowedRSSDomains[host] = true
|
||||
t.Cleanup(func() { delete(allowedRSSDomains, host) })
|
||||
return srv.URL + "/rss.xml", &n
|
||||
}
|
||||
|
||||
// newTestServer builds a Server with the embedded store in a temp dir and
|
||||
// hanzo-kv disabled (pure per-pod cache) — hermetic, no external services.
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
t.Setenv("WORLD_DATA_DIR", t.TempDir())
|
||||
t.Setenv("WORLD_KV_DISABLE", "1")
|
||||
s := NewServer()
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestWarmerPopulatesCache(t *testing.T) {
|
||||
feedURL, hits := stubFeed(t)
|
||||
s := newTestServer(t)
|
||||
// Seed the warm set with the stub feed (demand-driven registration stand-in).
|
||||
s.feeds = NewFeedCache(kv.Open("", ""), 0, []string{feedURL})
|
||||
|
||||
s.warmFeeds(context.Background())
|
||||
|
||||
if got := atomic.LoadInt32(hits); got != 1 {
|
||||
t.Fatalf("upstream fetched %d times, want 1", got)
|
||||
}
|
||||
body, _, ok := s.feeds.Get(context.Background(), feedURL)
|
||||
if !ok || len(body) == 0 {
|
||||
t.Fatal("warmer did not populate the cache")
|
||||
}
|
||||
if string(body) != stubRSS {
|
||||
t.Fatalf("cached body mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmerSkipsFreshFeeds(t *testing.T) {
|
||||
feedURL, hits := stubFeed(t)
|
||||
s := newTestServer(t)
|
||||
s.feeds = NewFeedCache(kv.Open("", ""), 0, nil)
|
||||
// Pre-warm: a fresh copy already in cache (age < freshWindow).
|
||||
s.feeds.Put(feedURL, []byte(stubRSS))
|
||||
|
||||
s.warmFeeds(context.Background())
|
||||
|
||||
if got := atomic.LoadInt32(hits); got != 0 {
|
||||
t.Fatalf("upstream hit %d times, want 0 (fresh feed must be skipped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarmedFeedServedInstantly is the end-to-end promise: once warmed, the
|
||||
// feeds-batch fall-through serves from cache WITHOUT any upstream fetch.
|
||||
func TestWarmedFeedServedInstantly(t *testing.T) {
|
||||
feedURL, hits := stubFeed(t)
|
||||
s := newTestServer(t)
|
||||
s.feeds = NewFeedCache(kv.Open("", ""), 0, []string{feedURL})
|
||||
s.warmFeeds(context.Background()) // one upstream fetch
|
||||
|
||||
// A subsequent request must be served from the warm cache — no new fetch.
|
||||
start := time.Now()
|
||||
body, ok, fresh := s.feedXML(context.Background(), feedURL)
|
||||
elapsed := time.Since(start)
|
||||
if !ok || fresh {
|
||||
t.Fatalf("feedXML ok=%v fresh=%v, want served-from-cache", ok, fresh)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Fatal("empty body from warm cache")
|
||||
}
|
||||
if got := atomic.LoadInt32(hits); got != 1 {
|
||||
t.Fatalf("upstream hit %d times, want 1 (warm read must not refetch)", got)
|
||||
}
|
||||
if elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("warm read took %s, want <50ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func mustHost(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/world/internal/world/kv"
|
||||
)
|
||||
|
||||
// FeedCache is the warm cache for raw RSS/Atom bodies behind the instant
|
||||
// news/feeds panels. It is a two-tier cache, ONE way to read a feed body:
|
||||
//
|
||||
// L1 — a per-pod in-memory mirror: hot reads never touch the network.
|
||||
// L2 — hanzo-kv (shared, cross-pod, survives pod restart): a warm body written
|
||||
// by any pod's warmer is instantly available to every pod, and a restarted
|
||||
// pod repopulates L1 from L2 instead of cold-starting.
|
||||
//
|
||||
// The warm-URL set (which feeds to keep fresh) is demand-driven: any feed served
|
||||
// once is registered, persisted fleet-wide in hanzo-kv, and kept fresh by the
|
||||
// background warmer. A curated seed guarantees the highest-value panels are warm
|
||||
// even on a brand-new pod before the first request. When hanzo-kv is
|
||||
// unreachable, everything degrades to the in-mem tier — still correct, just
|
||||
// per-pod and cold across restart.
|
||||
type FeedCache struct {
|
||||
mu sync.RWMutex
|
||||
mem map[string]feedRow // L1
|
||||
warm map[string]struct{} // in-mem warm-set fallback when kv is down
|
||||
kv *kv.Client
|
||||
max int
|
||||
}
|
||||
|
||||
type feedRow struct {
|
||||
body []byte
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
// feedKeyPrefix / warmSetKey namespace the shared hanzo-kv keys.
|
||||
feedKeyPrefix = "world:feed:v1:"
|
||||
warmSetKey = "world:feed:warm:v1"
|
||||
// feedKVTTL is the L2 safety horizon. The warmer refreshes every few minutes,
|
||||
// so a live entry is normally far younger; the TTL only bounds abandoned feeds.
|
||||
feedKVTTL = 3 * time.Hour
|
||||
// defaultFeedCacheMax bounds the L1 mirror (news feeds are small; ~500 covers
|
||||
// every frontend variant with room to spare).
|
||||
defaultFeedCacheMax = 1024
|
||||
)
|
||||
|
||||
// NewFeedCache builds the cache over an (optional) hanzo-kv client, seeding the
|
||||
// warm set with the curated bootstrap feeds so a cold pod warms them first.
|
||||
func NewFeedCache(kvc *kv.Client, max int, seed []string) *FeedCache {
|
||||
if max <= 0 {
|
||||
max = defaultFeedCacheMax
|
||||
}
|
||||
c := &FeedCache{
|
||||
mem: make(map[string]feedRow),
|
||||
warm: make(map[string]struct{}),
|
||||
kv: kvc,
|
||||
max: max,
|
||||
}
|
||||
for _, u := range seed {
|
||||
c.warm[u] = struct{}{}
|
||||
}
|
||||
// Publish the seed to the shared warm set too (best-effort), so every pod
|
||||
// converges on the same fleet-wide set.
|
||||
if len(seed) > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
c.kv.SAdd(ctx, warmSetKey, seed...)
|
||||
cancel()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns a cached feed body and its fetch time. L1 first (instant); on miss
|
||||
// it consults the shared L2 and, on a hit, backfills L1 so subsequent reads are
|
||||
// instant. Never touches upstream.
|
||||
func (c *FeedCache) Get(ctx context.Context, url string) ([]byte, time.Time, bool) {
|
||||
c.mu.RLock()
|
||||
row, ok := c.mem[url]
|
||||
c.mu.RUnlock()
|
||||
if ok {
|
||||
return row.body, row.at, true
|
||||
}
|
||||
if raw, ok := c.kv.GetBytes(ctx, feedKeyPrefix+url); ok {
|
||||
if at, body, ok := decodeFeed(raw); ok {
|
||||
c.storeMem(url, body, at)
|
||||
return body, at, true
|
||||
}
|
||||
}
|
||||
return nil, time.Time{}, false
|
||||
}
|
||||
|
||||
// Put write-throughs a freshly fetched body to both tiers and registers the URL
|
||||
// in the warm set (demand-driven). It uses a detached, bounded context for the
|
||||
// shared-tier writes so a write-through is never truncated by the request that
|
||||
// triggered it — durability must outlive the request. The fetch time is now.
|
||||
func (c *FeedCache) Put(url string, body []byte) {
|
||||
at := time.Now()
|
||||
c.storeMem(url, body, at)
|
||||
c.mu.Lock()
|
||||
c.warm[url] = struct{}{}
|
||||
c.mu.Unlock()
|
||||
raw := encodeFeed(at, body)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
c.kv.SetBytes(ctx, feedKeyPrefix+url, raw, feedKVTTL)
|
||||
c.kv.SAdd(ctx, warmSetKey, url)
|
||||
}
|
||||
|
||||
// Age returns how old the cached copy is, if any (for the warmer's
|
||||
// skip-if-fresh, cross-pod dedupe).
|
||||
func (c *FeedCache) Age(ctx context.Context, url string) (time.Duration, bool) {
|
||||
if _, at, ok := c.Get(ctx, url); ok {
|
||||
return time.Since(at), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// WarmURLs is the set of feeds to keep fresh: the fleet-wide set from hanzo-kv
|
||||
// unioned with the in-mem set (seed + demand). Deduped.
|
||||
func (c *FeedCache) WarmURLs(ctx context.Context) []string {
|
||||
set := make(map[string]struct{})
|
||||
for _, u := range c.kv.SMembers(ctx, warmSetKey) {
|
||||
set[u] = struct{}{}
|
||||
}
|
||||
c.mu.RLock()
|
||||
for u := range c.warm {
|
||||
set[u] = struct{}{}
|
||||
}
|
||||
for u := range c.mem {
|
||||
set[u] = struct{}{}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
out := make([]string, 0, len(set))
|
||||
for u := range set {
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Len reports the L1 mirror size (for tests/introspection).
|
||||
func (c *FeedCache) Len() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.mem)
|
||||
}
|
||||
|
||||
// storeMem writes L1, evicting the oldest entry when at capacity.
|
||||
func (c *FeedCache) storeMem(url string, body []byte, at time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, exists := c.mem[url]; !exists && len(c.mem) >= c.max {
|
||||
var oldestKey string
|
||||
var oldest time.Time
|
||||
first := true
|
||||
for k, r := range c.mem {
|
||||
if first || r.at.Before(oldest) {
|
||||
oldestKey, oldest, first = k, r.at, false
|
||||
}
|
||||
}
|
||||
if oldestKey != "" {
|
||||
delete(c.mem, oldestKey)
|
||||
}
|
||||
}
|
||||
c.mem[url] = feedRow{body: body, at: at}
|
||||
}
|
||||
|
||||
// ── L2 encoding: [8-byte unix-nano fetch time][gzip(body)] ───────────────────
|
||||
|
||||
func encodeFeed(at time.Time, body []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
var ts [8]byte
|
||||
binary.BigEndian.PutUint64(ts[:], uint64(at.UnixNano()))
|
||||
buf.Write(ts[:])
|
||||
gz := gzip.NewWriter(&buf)
|
||||
_, _ = gz.Write(body)
|
||||
_ = gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func decodeFeed(raw []byte) (time.Time, []byte, bool) {
|
||||
if len(raw) < 8 {
|
||||
return time.Time{}, nil, false
|
||||
}
|
||||
at := time.Unix(0, int64(binary.BigEndian.Uint64(raw[:8])))
|
||||
gz, err := gzip.NewReader(bytes.NewReader(raw[8:]))
|
||||
if err != nil {
|
||||
return time.Time{}, nil, false
|
||||
}
|
||||
defer func() { _ = gz.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(gz, maxBody))
|
||||
if err != nil {
|
||||
return time.Time{}, nil, false
|
||||
}
|
||||
return at, body, true
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/hanzoai/world/internal/world/kv"
|
||||
)
|
||||
|
||||
func TestFeedEncodeDecodeRoundTrip(t *testing.T) {
|
||||
at := time.Now().Truncate(time.Nanosecond)
|
||||
body := []byte("<rss><item>hello</item></rss>")
|
||||
gotAt, gotBody, ok := decodeFeed(encodeFeed(at, body))
|
||||
if !ok {
|
||||
t.Fatal("decode failed")
|
||||
}
|
||||
if !gotAt.Equal(at) {
|
||||
t.Fatalf("time round-trip = %v, want %v", gotAt, at)
|
||||
}
|
||||
if string(gotBody) != string(body) {
|
||||
t.Fatalf("body round-trip = %q, want %q", gotBody, body)
|
||||
}
|
||||
if _, _, ok := decodeFeed([]byte("short")); ok {
|
||||
t.Fatal("decode of truncated blob should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedCacheSharedAcrossPods proves the L2 (hanzo-kv) tier: a body written by
|
||||
// one pod is served to another pod whose L1 is cold — i.e. warming once benefits
|
||||
// the fleet, and a restarted pod (empty L1) reads a still-warm shared cache.
|
||||
func TestFeedCacheSharedAcrossPods(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
kvA := kv.Open(mr.Addr(), "")
|
||||
kvB := kv.Open(mr.Addr(), "")
|
||||
t.Cleanup(func() { kvA.Close(); kvB.Close() })
|
||||
|
||||
podA := NewFeedCache(kvA, 0, nil)
|
||||
podB := NewFeedCache(kvB, 0, nil) // separate pod: cold L1, shared L2
|
||||
|
||||
const url = "https://feeds.example.com/rss.xml"
|
||||
body := []byte("<rss><channel><item><title>Shared</title></item></channel></rss>")
|
||||
podA.Put(url, body)
|
||||
|
||||
if podB.Len() != 0 {
|
||||
t.Fatalf("podB L1 should start cold, has %d", podB.Len())
|
||||
}
|
||||
got, _, ok := podB.Get(context.Background(), url)
|
||||
if !ok || string(got) != string(body) {
|
||||
t.Fatalf("podB Get via shared L2 = %q ok=%v, want the shared body", got, ok)
|
||||
}
|
||||
if podB.Len() != 1 {
|
||||
t.Fatal("podB should have backfilled L1 from L2")
|
||||
}
|
||||
// The warm-URL set is fleet-wide (shared set), so podB knows to keep it fresh.
|
||||
if !hasURL(podB.WarmURLs(context.Background()), url) {
|
||||
t.Fatal("shared warm set missing the demand-added url")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedCacheDegradesWithoutKV proves the graceful fallback: with hanzo-kv
|
||||
// disabled the cache is per-pod (L1 only) and correct — a "restart" (new cache)
|
||||
// is cold, never a crash.
|
||||
func TestFeedCacheDegradesWithoutKV(t *testing.T) {
|
||||
disabled := kv.Open("", "") // no hanzo-kv
|
||||
c := NewFeedCache(disabled, 0, nil)
|
||||
|
||||
const url = "https://feeds.example.com/x.xml"
|
||||
body := []byte("<rss/>")
|
||||
c.Put(url, body)
|
||||
|
||||
got, _, ok := c.Get(context.Background(), url)
|
||||
if !ok || string(got) != string(body) {
|
||||
t.Fatalf("same-pod L1 Get = %q ok=%v", got, ok)
|
||||
}
|
||||
// A fresh cache (simulated restart) with no shared L2 must miss — proving the
|
||||
// per-pod degrade is honest, not silently wrong.
|
||||
fresh := NewFeedCache(disabled, 0, nil)
|
||||
if _, _, ok := fresh.Get(context.Background(), url); ok {
|
||||
t.Fatal("restart with kv disabled should be cold, got a hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedCacheSeedInWarmSet(t *testing.T) {
|
||||
c := NewFeedCache(kv.Open("", ""), 0, []string{"https://a.example/rss", "https://b.example/rss"})
|
||||
warm := c.WarmURLs(context.Background())
|
||||
if !hasURL(warm, "https://a.example/rss") || !hasURL(warm, "https://b.example/rss") {
|
||||
t.Fatalf("seed missing from warm set: %v", warm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedCacheEvictsOldest(t *testing.T) {
|
||||
c := NewFeedCache(kv.Open("", ""), 2, nil)
|
||||
c.Put("u1", []byte("1"))
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
c.Put("u2", []byte("2"))
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
c.Put("u3", []byte("3")) // over cap → evict u1 (oldest)
|
||||
if c.Len() != 2 {
|
||||
t.Fatalf("L1 size = %d, want 2 (bounded)", c.Len())
|
||||
}
|
||||
if _, _, ok := c.Get(context.Background(), "u1"); ok {
|
||||
t.Fatal("oldest entry u1 was not evicted")
|
||||
}
|
||||
if _, _, ok := c.Get(context.Background(), "u3"); !ok {
|
||||
t.Fatal("newest entry u3 missing")
|
||||
}
|
||||
}
|
||||
|
||||
func hasURL(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||